#feature: add project&cronjob management
This commit is contained in:
260
app/Http/Controllers/Admin/ProjectController.php
Normal file
260
app/Http/Controllers/Admin/ProjectController.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Project;
|
||||
use App\Services\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProjectService $projectService)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有项目
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$projects = $this->projectService->getAllProjects();
|
||||
$projectsPath = $this->projectService->getProjectsPath();
|
||||
|
||||
// 为每个项目添加状态信息
|
||||
$projectsWithStatus = $projects->map(function (Project $project) use ($projectsPath) {
|
||||
$data = $project->toArray();
|
||||
$data['path_valid'] = $project->isPathValid($projectsPath);
|
||||
$data['full_path'] = $project->getFullPath($projectsPath);
|
||||
return $data;
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'projects' => $projectsWithStatus,
|
||||
'projects_path' => $projectsPath,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个项目
|
||||
*/
|
||||
public function show(string $slug): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->getBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$status = $this->projectService->getProjectStatus($project);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'project' => $project,
|
||||
'status' => $status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slug' => ['required', 'string', 'max:100', 'unique:projects,slug', 'regex:/^[a-z0-9\-_]+$/'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'directory' => ['nullable', 'string', 'max:255'],
|
||||
'absolute_path' => ['nullable', 'string', 'max:500'],
|
||||
'jira_project_code' => ['nullable', 'string', 'max:50'],
|
||||
'git_monitor_enabled' => ['nullable', 'boolean'],
|
||||
'auto_create_release_branch' => ['nullable', 'boolean'],
|
||||
'is_important' => ['nullable', 'boolean'],
|
||||
'log_app_names' => ['nullable', 'array'],
|
||||
'log_app_names.*' => ['string', 'max:100'],
|
||||
'log_env' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
|
||||
$project = $this->projectService->create($data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'project' => $project,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
public function update(Request $request, string $slug): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->getBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'slug' => [
|
||||
'sometimes',
|
||||
'string',
|
||||
'max:100',
|
||||
'regex:/^[a-z0-9\-_]+$/',
|
||||
Rule::unique('projects', 'slug')->ignore($project->id),
|
||||
],
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'directory' => ['nullable', 'string', 'max:255'],
|
||||
'absolute_path' => ['nullable', 'string', 'max:500'],
|
||||
'jira_project_code' => ['nullable', 'string', 'max:50'],
|
||||
'git_monitor_enabled' => ['nullable', 'boolean'],
|
||||
'auto_create_release_branch' => ['nullable', 'boolean'],
|
||||
'is_important' => ['nullable', 'boolean'],
|
||||
'log_app_names' => ['nullable', 'array'],
|
||||
'log_app_names.*' => ['string', 'max:100'],
|
||||
'log_env' => ['nullable', 'string', 'max:50'],
|
||||
]);
|
||||
|
||||
$project = $this->projectService->update($project, $data);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'project' => $project,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
public function destroy(string $slug): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->getBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$this->projectService->delete($project);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目状态
|
||||
*/
|
||||
public function status(string $slug): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->getBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$status = $this->projectService->getProjectStatus($project);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'status' => $status,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 Git 信息
|
||||
*/
|
||||
public function sync(string $slug): JsonResponse
|
||||
{
|
||||
$project = $this->projectService->getBySlug($slug);
|
||||
|
||||
if (!$project) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '项目不存在',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$project = $this->projectService->syncGitInfo($project);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'project' => $project,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现新项目
|
||||
*/
|
||||
public function discover(): JsonResponse
|
||||
{
|
||||
$discovered = $this->projectService->discoverProjects();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'discovered' => $discovered,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加发现的项目
|
||||
*/
|
||||
public function addDiscovered(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'slugs' => ['required', 'array', 'min:1'],
|
||||
'slugs.*' => ['string', 'max:100'],
|
||||
]);
|
||||
|
||||
$added = $this->projectService->addDiscoveredProjects($data['slugs']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'added' => $added,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键同步所有项目的 Git 信息
|
||||
*/
|
||||
public function syncAll(): JsonResponse
|
||||
{
|
||||
$results = $this->projectService->syncAllGitInfo();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'synced' => $results['synced'],
|
||||
'failed' => $results['failed'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/Admin/ScheduledTaskController.php
Normal file
55
app/Http/Controllers/Admin/ScheduledTaskController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ScheduledTaskService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ScheduledTaskController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ScheduledTaskService $taskService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取所有定时任务列表
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'tasks' => $this->taskService->getAllTasks(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换定时任务启用状态
|
||||
*/
|
||||
public function toggle(Request $request, string $name): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'enabled' => 'required|boolean',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->taskService->setTaskEnabled($name, $validated['enabled']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'enabled' => $validated['enabled'],
|
||||
],
|
||||
'message' => $validated['enabled'] ? '任务已启用' : '任务已禁用',
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
], 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
89
app/Models/Project.php
Normal file
89
app/Models/Project.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class Project extends BaseModel
|
||||
{
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'name',
|
||||
'directory',
|
||||
'absolute_path',
|
||||
'jira_project_code',
|
||||
'git_monitor_enabled',
|
||||
'auto_create_release_branch',
|
||||
'is_important',
|
||||
'git_last_checked_commit',
|
||||
'git_current_version',
|
||||
'git_release_branch',
|
||||
'git_version_cached_at',
|
||||
'log_app_names',
|
||||
'log_env',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'git_monitor_enabled' => 'boolean',
|
||||
'auto_create_release_branch' => 'boolean',
|
||||
'is_important' => 'boolean',
|
||||
'log_app_names' => 'array',
|
||||
'git_version_cached_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取项目的完整路径
|
||||
*/
|
||||
public function getFullPath(string $projectsPath): string
|
||||
{
|
||||
if (!empty($this->absolute_path)) {
|
||||
return rtrim($this->absolute_path, '/');
|
||||
}
|
||||
|
||||
$directory = $this->directory ?? $this->slug;
|
||||
return rtrim($projectsPath, '/') . '/' . ltrim($directory, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查项目路径是否有效
|
||||
*/
|
||||
public function isPathValid(string $projectsPath): bool
|
||||
{
|
||||
$path = $this->getFullPath($projectsPath);
|
||||
return is_dir($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是有效的 Git 仓库
|
||||
*/
|
||||
public function isGitRepository(string $projectsPath): bool
|
||||
{
|
||||
$path = $this->getFullPath($projectsPath);
|
||||
return is_dir($path . DIRECTORY_SEPARATOR . '.git');
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 slug 查找项目
|
||||
*/
|
||||
public static function findBySlug(string $slug): ?self
|
||||
{
|
||||
return static::query()->where('slug', $slug)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用 Git 监控的项目
|
||||
*/
|
||||
public static function getGitMonitorEnabled(): \Illuminate\Database\Eloquent\Collection
|
||||
{
|
||||
return static::query()->where('git_monitor_enabled', true)->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 app 名称查找项目
|
||||
*/
|
||||
public static function findByAppName(string $appName): ?self
|
||||
{
|
||||
return static::query()
|
||||
->whereJsonContains('log_app_names', $appName)
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
@@ -22,6 +23,24 @@ class CodeContextService
|
||||
*/
|
||||
public function getRepoPath(string $appName): ?string
|
||||
{
|
||||
// 优先从 Project 模型查找
|
||||
$project = Project::findByAppName($appName);
|
||||
|
||||
if ($project) {
|
||||
$env = $project->log_env ?? 'production';
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($project->slug, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
return $repoPath;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误,继续尝试旧配置
|
||||
}
|
||||
}
|
||||
|
||||
// 回退到旧的配置方式(兼容迁移前的情况)
|
||||
$appEnvMap = $this->configService->get('log_analysis.app_env_map', []);
|
||||
|
||||
if (!isset($appEnvMap[$appName])) {
|
||||
@@ -29,15 +48,15 @@ class CodeContextService
|
||||
}
|
||||
|
||||
$mapping = $appEnvMap[$appName];
|
||||
$project = $mapping['project'] ?? null;
|
||||
$projectSlug = $mapping['project'] ?? null;
|
||||
$env = $mapping['env'] ?? null;
|
||||
|
||||
if (!$project || !$env) {
|
||||
if (!$projectSlug || !$env) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$envContent = $this->envService->getEnvContent($project, $env);
|
||||
$envContent = $this->envService->getEnvContent($projectSlug, $env);
|
||||
$repoPath = $this->parseEnvValue($envContent, 'LOG_ANALYSIS_CODE_REPO_PATH');
|
||||
|
||||
if ($repoPath && is_dir($repoPath)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -10,9 +11,8 @@ use Symfony\Component\Process\Process;
|
||||
|
||||
class GitMonitorService
|
||||
{
|
||||
private const RELEASE_CACHE_KEY = 'git_monitor.current_versions';
|
||||
private const LAST_CHECKED_KEY = 'git_monitor.last_checked_commits';
|
||||
private const DEVELOP_BRANCH = 'develop';
|
||||
private const RELEASE_CACHE_KEY = 'git-monitor.release_cache';
|
||||
|
||||
/**
|
||||
* 项目配置(只包含允许巡检的项目)
|
||||
@@ -30,12 +30,6 @@ class GitMonitorService
|
||||
) {
|
||||
$this->projectsPath = $this->resolveProjectsPath();
|
||||
$this->projects = $this->resolveProjects();
|
||||
|
||||
$enabled = config('git-monitor.enabled_projects', []);
|
||||
if (!empty($enabled)) {
|
||||
$this->projects = array_intersect_key($this->projects, array_flip($enabled));
|
||||
}
|
||||
|
||||
$this->commitScanLimit = (int) config('git-monitor.commit_scan_limit', 30);
|
||||
$this->gitTimeout = (int) config('git-monitor.git_timeout', 180);
|
||||
}
|
||||
@@ -46,10 +40,11 @@ class GitMonitorService
|
||||
return [];
|
||||
}
|
||||
|
||||
// 检查是否需要刷新缓存
|
||||
if (!$force) {
|
||||
$cached = $this->configService->get(self::RELEASE_CACHE_KEY);
|
||||
if (!empty($cached) && !$this->shouldRefreshCache($cached)) {
|
||||
return $cached;
|
||||
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
|
||||
if ($anyProject && !$this->shouldRefreshCache($anyProject->git_version_cached_at)) {
|
||||
return $this->buildCachePayload();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,12 +67,29 @@ class GitMonitorService
|
||||
// 根据当前版本号获取下一个版本
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion);
|
||||
if ($version) {
|
||||
$branch = 'release/' . $version['version'];
|
||||
$payload['repositories'][$repoKey] = [
|
||||
'version' => $version['version'],
|
||||
'description' => $version['description'] ?? null,
|
||||
'release_date' => $version['release_date'],
|
||||
'branch' => 'release/' . $version['version'],
|
||||
'branch' => $branch,
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
|
||||
// 更新 Project 模型
|
||||
$project = Project::findBySlug($repoKey);
|
||||
if ($project) {
|
||||
$project->update([
|
||||
'git_current_version' => $currentVersion,
|
||||
'git_release_branch' => $branch,
|
||||
'git_version_cached_at' => now(),
|
||||
]);
|
||||
|
||||
// 如果启用了自动创建 release 分支,检查并创建
|
||||
if ($project->auto_create_release_branch) {
|
||||
$this->ensureReleaseBranchExists($repoKey, $repoConfig, $branch, $version['description']);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to fetch release version from Jira', [
|
||||
@@ -87,24 +99,44 @@ class GitMonitorService
|
||||
}
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
self::RELEASE_CACHE_KEY,
|
||||
$payload,
|
||||
'Cached release versions fetched from Jira'
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function ensureReleaseCache(): array
|
||||
{
|
||||
$cached = $this->configService->get(self::RELEASE_CACHE_KEY);
|
||||
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
|
||||
|
||||
if (empty($cached) || $this->shouldRefreshCache($cached)) {
|
||||
if (!$anyProject || $this->shouldRefreshCache($anyProject->git_version_cached_at)) {
|
||||
return $this->refreshReleaseCache(true);
|
||||
}
|
||||
|
||||
return $cached;
|
||||
return $this->buildCachePayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Project 模型构建缓存数据
|
||||
*/
|
||||
private function buildCachePayload(): array
|
||||
{
|
||||
$projects = Project::query()
|
||||
->where('git_monitor_enabled', true)
|
||||
->whereNotNull('git_release_branch')
|
||||
->get();
|
||||
|
||||
$payload = [
|
||||
'cached_at' => $projects->first()?->git_version_cached_at?->toDateTimeString() ?? now()->toDateTimeString(),
|
||||
'repositories' => [],
|
||||
];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$payload['repositories'][$project->slug] = [
|
||||
'version' => $project->git_current_version,
|
||||
'branch' => $project->git_release_branch,
|
||||
'current_version' => $project->git_current_version,
|
||||
];
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function checkRepositories(bool $refreshCacheIfNeeded = true): array
|
||||
@@ -151,15 +183,15 @@ class GitMonitorService
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function shouldRefreshCache(array $cached): bool
|
||||
private function shouldRefreshCache(Carbon|\DateTimeInterface|string|null $cachedAt): bool
|
||||
{
|
||||
$cachedAt = Arr::get($cached, 'cached_at');
|
||||
if (empty($cachedAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($cachedAt)->lt(Carbon::now()->startOfDay());
|
||||
$cachedTime = $cachedAt instanceof Carbon ? $cachedAt : Carbon::parse($cachedAt);
|
||||
return $cachedTime->lt(Carbon::now()->startOfDay());
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
@@ -177,7 +209,9 @@ class GitMonitorService
|
||||
$remoteBranch = 'origin/' . $branch;
|
||||
$head = $this->runGit($path, ['git', 'rev-parse', $remoteBranch]);
|
||||
|
||||
$lastChecked = $this->configService->getNested(self::LAST_CHECKED_KEY, $repoKey);
|
||||
// 从 Project 模型获取 lastChecked
|
||||
$project = Project::findBySlug($repoKey);
|
||||
$lastChecked = $project?->git_last_checked_commit;
|
||||
$commits = $this->collectCommits($path, $remoteBranch, $lastChecked);
|
||||
|
||||
$issues = [
|
||||
@@ -378,14 +412,10 @@ class GitMonitorService
|
||||
|
||||
private function updateLastChecked(string $repoKey, string $sha): void
|
||||
{
|
||||
$current = $this->configService->get(self::LAST_CHECKED_KEY, []);
|
||||
$current[$repoKey] = $sha;
|
||||
|
||||
$this->configService->set(
|
||||
self::LAST_CHECKED_KEY,
|
||||
$current,
|
||||
'Last scanned release commits'
|
||||
);
|
||||
$project = Project::findBySlug($repoKey);
|
||||
if ($project) {
|
||||
$project->update(['git_last_checked_commit' => $sha]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getCommitMetadata(string $repoPath, string $commit): array
|
||||
@@ -478,24 +508,39 @@ class GitMonitorService
|
||||
*/
|
||||
private function resolveProjects(): array
|
||||
{
|
||||
$projects = $this->configService->get('workspace.repositories');
|
||||
// 优先从 Project 模型获取启用 Git 监控的项目
|
||||
$projectModels = Project::query()
|
||||
->where('git_monitor_enabled', true)
|
||||
->get();
|
||||
|
||||
if ($projects === null) {
|
||||
$fallback = $this->buildFallbackProjects(config('git-monitor.enabled_projects', []));
|
||||
if (!empty($fallback)) {
|
||||
Log::warning('configs 表未设置 workspace.repositories,已使用 git-monitor.enabled_projects。');
|
||||
if ($projectModels->isNotEmpty()) {
|
||||
$projects = [];
|
||||
foreach ($projectModels as $project) {
|
||||
$projects[$project->slug] = [
|
||||
'jira_project' => $project->jira_project_code,
|
||||
'directory' => $project->directory ?? $project->slug,
|
||||
'path' => $project->absolute_path,
|
||||
'display' => $project->name,
|
||||
];
|
||||
}
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
if (!is_array($projects)) {
|
||||
Log::warning('configs 表 workspace.repositories 配置格式不正确,已降级使用 git-monitor.enabled_projects。');
|
||||
return $this->buildFallbackProjects(config('git-monitor.enabled_projects', []));
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
// 回退到旧的配置方式(兼容迁移前的情况)
|
||||
$configProjects = $this->configService->get('workspace.repositories');
|
||||
|
||||
if ($configProjects !== null && is_array($configProjects)) {
|
||||
$enabled = config('git-monitor.enabled_projects', []);
|
||||
if (!empty($enabled)) {
|
||||
return array_intersect_key($configProjects, array_flip($enabled));
|
||||
}
|
||||
return $configProjects;
|
||||
}
|
||||
|
||||
// 最后回退到环境变量配置
|
||||
return $this->buildFallbackProjects(config('git-monitor.enabled_projects', []));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
@@ -554,4 +599,100 @@ class GitMonitorService
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查远程是否存在指定分支
|
||||
*/
|
||||
private function remoteBranchExists(string $path, string $branch): bool
|
||||
{
|
||||
try {
|
||||
$this->runGit($path, ['git', 'fetch', 'origin']);
|
||||
$this->runGit($path, ['git', 'ls-remote', '--exit-code', '--heads', 'origin', $branch]);
|
||||
return true;
|
||||
} catch (ProcessFailedException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 release 分支存在,如果不存在则从 master 创建并推送
|
||||
*/
|
||||
private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void
|
||||
{
|
||||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||||
|
||||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||||
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查远程分支是否已存在
|
||||
if ($this->remoteBranchExists($path, $branch)) {
|
||||
Log::info('Release branch already exists on remote', ['repository' => $repoKey, 'branch' => $branch]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 从分支名中提取版本号(release/2.63.0.0 -> 2.63.0.0)
|
||||
$version = str_replace('release/', '', $branch);
|
||||
|
||||
try {
|
||||
// 从 origin/master 创建新分支
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||||
|
||||
// 创建本地分支(基于 origin/master)
|
||||
try {
|
||||
// 先尝试删除可能存在的本地分支
|
||||
$this->runGit($path, ['git', 'branch', '-D', $branch]);
|
||||
} catch (ProcessFailedException) {
|
||||
// 忽略,分支可能不存在
|
||||
}
|
||||
|
||||
$this->runGit($path, ['git', 'checkout', '-b', $branch, 'origin/master']);
|
||||
|
||||
// 修改 version.txt 文件
|
||||
$versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt';
|
||||
if (!file_put_contents($versionFile, $version)) {
|
||||
throw new \RuntimeException("Failed to write version.txt");
|
||||
}
|
||||
|
||||
// 添加并提交更改
|
||||
$this->runGit($path, ['git', 'add', 'version.txt']);
|
||||
|
||||
// 构建提交信息:分支名 + 空格 + Jira 描述
|
||||
$commitMessage = $branch . ($description ? ' ' . $description : '');
|
||||
$this->runGit($path, ['git', 'commit', '-m', $commitMessage]);
|
||||
|
||||
// 推送到远程
|
||||
$this->runGit($path, ['git', 'push', '-u', 'origin', $branch]);
|
||||
|
||||
Log::info('Created and pushed release branch', [
|
||||
'repository' => $repoKey,
|
||||
'branch' => $branch,
|
||||
'version' => $version,
|
||||
'message' => $commitMessage,
|
||||
]);
|
||||
|
||||
// 发送钉钉通知
|
||||
$this->dingTalkService->sendText(sprintf(
|
||||
"【Release 分支创建】\n项目: %s\n分支: %s\n版本: %s\n描述: %s",
|
||||
$repoConfig['display'] ?? $repoKey,
|
||||
$branch,
|
||||
$version,
|
||||
$description ?? '无'
|
||||
));
|
||||
} catch (ProcessFailedException $e) {
|
||||
Log::error('Failed to create release branch', [
|
||||
'repository' => $repoKey,
|
||||
'branch' => $branch,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} finally {
|
||||
// 切回 develop 分支,避免影响后续操作
|
||||
try {
|
||||
$this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]);
|
||||
} catch (ProcessFailedException) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,6 +798,7 @@ class JiraService
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
@@ -826,6 +827,7 @@ class JiraService
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
@@ -844,6 +846,7 @@ class JiraService
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'description' => $candidate->description ?? null,
|
||||
'release_date' => !empty($candidate->releaseDate)
|
||||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||||
: null,
|
||||
|
||||
302
app/Services/ProjectService.php
Normal file
302
app/Services/ProjectService.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class ProjectService
|
||||
{
|
||||
private string $projectsPath;
|
||||
|
||||
public function __construct(private readonly ConfigService $configService)
|
||||
{
|
||||
$this->projectsPath = $this->resolveProjectsPath();
|
||||
}
|
||||
|
||||
private function resolveProjectsPath(): string
|
||||
{
|
||||
$path = $this->configService->get('workspace.projects_path');
|
||||
|
||||
if (empty($path)) {
|
||||
throw new RuntimeException('configs 表未设置 workspace.projects_path。');
|
||||
}
|
||||
|
||||
return rtrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目根目录路径
|
||||
*/
|
||||
public function getProjectsPath(): string
|
||||
{
|
||||
return $this->projectsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有项目
|
||||
*/
|
||||
public function getAllProjects(): Collection
|
||||
{
|
||||
return Project::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 slug 获取项目
|
||||
*/
|
||||
public function getBySlug(string $slug): ?Project
|
||||
{
|
||||
return Project::findBySlug($slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
public function create(array $data): Project
|
||||
{
|
||||
return Project::query()->create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
$project->update($data);
|
||||
return $project->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
public function delete(Project $project): bool
|
||||
{
|
||||
return $project->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目状态
|
||||
*/
|
||||
public function getProjectStatus(Project $project): array
|
||||
{
|
||||
$fullPath = $project->getFullPath($this->projectsPath);
|
||||
$pathValid = $project->isPathValid($this->projectsPath);
|
||||
$isGitRepo = $pathValid && $project->isGitRepository($this->projectsPath);
|
||||
|
||||
$status = [
|
||||
'path_valid' => $pathValid,
|
||||
'full_path' => $fullPath,
|
||||
'is_git_repo' => $isGitRepo,
|
||||
'current_branch' => null,
|
||||
'has_uncommitted_changes' => null,
|
||||
];
|
||||
|
||||
if ($isGitRepo) {
|
||||
try {
|
||||
$status['current_branch'] = $this->getCurrentBranch($fullPath);
|
||||
$status['has_uncommitted_changes'] = $this->hasUncommittedChanges($fullPath);
|
||||
} catch (\Exception $e) {
|
||||
// 忽略 Git 命令错误
|
||||
}
|
||||
}
|
||||
|
||||
return $status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现新项目(扫描 projects_path 目录)
|
||||
*/
|
||||
public function discoverProjects(): array
|
||||
{
|
||||
if (!File::exists($this->projectsPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$existingSlugs = Project::query()->pluck('slug')->toArray();
|
||||
$discovered = [];
|
||||
|
||||
$directories = File::directories($this->projectsPath);
|
||||
|
||||
foreach ($directories as $directory) {
|
||||
$dirName = basename($directory);
|
||||
|
||||
// 跳过已存在的项目
|
||||
if (in_array($dirName, $existingSlugs, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过隐藏目录
|
||||
if (str_starts_with($dirName, '.')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isGitRepo = is_dir($directory . DIRECTORY_SEPARATOR . '.git');
|
||||
|
||||
$discovered[] = [
|
||||
'slug' => $dirName,
|
||||
'name' => ucfirst(str_replace(['-', '_'], ' ', $dirName)),
|
||||
'directory' => $dirName,
|
||||
'path' => $directory,
|
||||
'is_git_repo' => $isGitRepo,
|
||||
];
|
||||
}
|
||||
|
||||
return $discovered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加发现的项目
|
||||
*/
|
||||
public function addDiscoveredProjects(array $slugs): array
|
||||
{
|
||||
$discovered = $this->discoverProjects();
|
||||
$added = [];
|
||||
|
||||
foreach ($discovered as $project) {
|
||||
if (in_array($project['slug'], $slugs, true)) {
|
||||
$created = Project::query()->create([
|
||||
'slug' => $project['slug'],
|
||||
'name' => $project['name'],
|
||||
'directory' => $project['directory'],
|
||||
]);
|
||||
$added[] = $created;
|
||||
}
|
||||
}
|
||||
|
||||
return $added;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步项目的 Git 信息
|
||||
*/
|
||||
public function syncGitInfo(Project $project): Project
|
||||
{
|
||||
$fullPath = $project->getFullPath($this->projectsPath);
|
||||
|
||||
if (!$project->isGitRepository($this->projectsPath)) {
|
||||
return $project;
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取当前版本(从 version.txt)
|
||||
$version = $this->getMasterVersion($fullPath);
|
||||
if ($version !== null) {
|
||||
$project->git_current_version = $version;
|
||||
}
|
||||
|
||||
// 获取当前分支
|
||||
$branch = $this->getCurrentBranch($fullPath);
|
||||
if ($branch !== null) {
|
||||
$project->git_release_branch = $branch;
|
||||
}
|
||||
|
||||
$project->git_version_cached_at = now();
|
||||
$project->save();
|
||||
} catch (\Exception $e) {
|
||||
// 忽略错误
|
||||
}
|
||||
|
||||
return $project->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 master 分支的版本号
|
||||
*/
|
||||
private function getMasterVersion(string $path): ?string
|
||||
{
|
||||
try {
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||||
$version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']);
|
||||
return trim($version) ?: null;
|
||||
} catch (ProcessFailedException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前分支
|
||||
*/
|
||||
private function getCurrentBranch(string $path): ?string
|
||||
{
|
||||
try {
|
||||
$branch = $this->runGit($path, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']);
|
||||
return trim($branch) ?: null;
|
||||
} catch (ProcessFailedException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有未提交的更改
|
||||
*/
|
||||
private function hasUncommittedChanges(string $path): bool
|
||||
{
|
||||
try {
|
||||
$output = $this->runGit($path, ['git', 'status', '--porcelain']);
|
||||
return !empty(trim($output));
|
||||
} catch (ProcessFailedException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行 Git 命令
|
||||
*/
|
||||
private function runGit(string $cwd, array $command): string
|
||||
{
|
||||
$process = new Process($command, $cwd);
|
||||
$process->setTimeout(60);
|
||||
$process->mustRun();
|
||||
|
||||
return $process->getOutput();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用 Git 监控的项目
|
||||
*/
|
||||
public function getGitMonitorEnabledProjects(): Collection
|
||||
{
|
||||
return Project::getGitMonitorEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 app 名称查找项目
|
||||
*/
|
||||
public function findByAppName(string $appName): ?Project
|
||||
{
|
||||
return Project::findByAppName($appName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一键同步所有项目的 Git 信息
|
||||
*/
|
||||
public function syncAllGitInfo(): array
|
||||
{
|
||||
$projects = $this->getAllProjects();
|
||||
$synced = [];
|
||||
$failed = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
try {
|
||||
if ($project->isGitRepository($this->projectsPath)) {
|
||||
$this->syncGitInfo($project);
|
||||
$synced[] = $project->slug;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$failed[] = [
|
||||
'slug' => $project->slug,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'synced' => $synced,
|
||||
'failed' => $failed,
|
||||
];
|
||||
}
|
||||
}
|
||||
133
app/Services/ScheduledTaskService.php
Normal file
133
app/Services/ScheduledTaskService.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
class ScheduledTaskService
|
||||
{
|
||||
private const CONFIG_KEY = 'scheduled_tasks';
|
||||
|
||||
private static ?ConfigService $configServiceInstance = null;
|
||||
|
||||
public function __construct(
|
||||
private ConfigService $configService,
|
||||
private Schedule $schedule
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 静态方法:检查任务是否启用(供 console.php 中的 when() 回调使用)
|
||||
*/
|
||||
public static function isEnabled(string $name): bool
|
||||
{
|
||||
try {
|
||||
self::$configServiceInstance ??= app(ConfigService::class);
|
||||
$enabled = self::$configServiceInstance->get(self::CONFIG_KEY, []);
|
||||
return $enabled[$name] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有任务(从 Schedule 实例动态获取)
|
||||
*/
|
||||
public function getAllTasks(): array
|
||||
{
|
||||
$this->ensureTasksLoaded();
|
||||
|
||||
$enabledTasks = $this->configService->get(self::CONFIG_KEY, []);
|
||||
$tasks = [];
|
||||
|
||||
foreach ($this->schedule->events() as $event) {
|
||||
$name = $this->getEventName($event);
|
||||
$tasks[] = [
|
||||
'name' => $name,
|
||||
'command' => $this->getEventCommand($event),
|
||||
'description' => $event->description ?: $name,
|
||||
'frequency' => $this->getFrequencyLabel($event->expression),
|
||||
'cron' => $event->expression,
|
||||
'enabled' => $enabledTasks[$name] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
return $tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置任务启用状态
|
||||
*/
|
||||
public function setTaskEnabled(string $name, bool $enabled): void
|
||||
{
|
||||
$this->ensureTasksLoaded();
|
||||
|
||||
// 验证任务是否存在
|
||||
$exists = false;
|
||||
foreach ($this->schedule->events() as $event) {
|
||||
if ($this->getEventName($event) === $name) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exists) {
|
||||
throw new \InvalidArgumentException("未知任务: {$name}");
|
||||
}
|
||||
|
||||
$tasks = $this->configService->get(self::CONFIG_KEY, []);
|
||||
$tasks[$name] = $enabled;
|
||||
|
||||
$this->configService->set(self::CONFIG_KEY, $tasks, '定时任务启用状态');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保 console.php 中的任务已加载
|
||||
*/
|
||||
private function ensureTasksLoaded(): void
|
||||
{
|
||||
if (count($this->schedule->events()) === 0) {
|
||||
require_once base_path('routes/console.php');
|
||||
}
|
||||
}
|
||||
|
||||
private function getEventName($event): string
|
||||
{
|
||||
if (property_exists($event, 'mutexName') && $event->mutexName) {
|
||||
return $event->mutexName;
|
||||
}
|
||||
return $this->getEventCommand($event);
|
||||
}
|
||||
|
||||
private function getEventCommand($event): string
|
||||
{
|
||||
if (property_exists($event, 'command') && $event->command) {
|
||||
$command = $event->command;
|
||||
if (str_contains($command, 'artisan')) {
|
||||
$command = preg_replace('/^.*artisan\s+/', '', $command);
|
||||
}
|
||||
return trim(str_replace("'", '', $command));
|
||||
}
|
||||
return 'closure';
|
||||
}
|
||||
|
||||
private function getFrequencyLabel(string $expression): string
|
||||
{
|
||||
$map = [
|
||||
'* * * * *' => '每分钟',
|
||||
'*/5 * * * *' => '每 5 分钟',
|
||||
'*/10 * * * *' => '每 10 分钟',
|
||||
'*/15 * * * *' => '每 15 分钟',
|
||||
'*/30 * * * *' => '每 30 分钟',
|
||||
'0 * * * *' => '每小时',
|
||||
'0 */2 * * *' => '每 2 小时',
|
||||
'0 */4 * * *' => '每 4 小时',
|
||||
'0 */6 * * *' => '每 6 小时',
|
||||
'0 */12 * * *' => '每 12 小时',
|
||||
'0 0 * * *' => '每天凌晨 0:00',
|
||||
'0 2 * * *' => '每天凌晨 2:00',
|
||||
'0 0 * * 0' => '每周日凌晨',
|
||||
'0 0 1 * *' => '每月 1 日凌晨',
|
||||
];
|
||||
return $map[$expression] ?? $expression;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('projects', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug', 100)->unique()->comment('项目唯一标识,如 portal-be');
|
||||
$table->string('name', 255)->comment('显示名称');
|
||||
$table->string('directory', 255)->nullable()->comment('相对于 projects_path 的目录名');
|
||||
$table->string('absolute_path', 500)->nullable()->comment('绝对路径覆盖(可选)');
|
||||
$table->string('jira_project_code', 50)->nullable()->comment('JIRA 项目代码');
|
||||
|
||||
// Git 监控相关
|
||||
$table->boolean('git_monitor_enabled')->default(false)->comment('是否启用 Git 监控');
|
||||
$table->string('git_last_checked_commit', 64)->nullable()->comment('最后检查的 commit SHA');
|
||||
$table->string('git_current_version', 50)->nullable()->comment('当前版本号');
|
||||
$table->string('git_release_branch', 255)->nullable()->comment('Release 分支名');
|
||||
$table->timestamp('git_version_cached_at')->nullable()->comment('版本缓存时间');
|
||||
|
||||
// 日志分析相关
|
||||
$table->json('log_app_names')->nullable()->comment('关联的 App 名称列表');
|
||||
$table->string('log_env', 50)->default('production')->comment('日志分析环境');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index('jira_project_code', 'idx_jira_project_code');
|
||||
$table->index('git_monitor_enabled', 'idx_git_monitor_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('projects');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Config;
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('projects') || !Schema::hasTable('configs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 读取现有配置
|
||||
$repositories = Config::query()->where('key', 'workspace.repositories')->first()?->value ?? [];
|
||||
$lastChecked = Config::query()->where('key', 'git_monitor.last_checked_commits')->first()?->value ?? [];
|
||||
$currentVersions = Config::query()->where('key', 'git_monitor.current_versions')->first()?->value ?? [];
|
||||
$appEnvMap = Config::query()->where('key', 'log_analysis.app_env_map')->first()?->value ?? [];
|
||||
$enabledProjects = config('git-monitor.enabled_projects', []);
|
||||
|
||||
// 2. 为每个仓库创建 Project 记录
|
||||
foreach ($repositories as $slug => $repo) {
|
||||
if (!is_string($slug) || $slug === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
if (Project::query()->where('slug', $slug)->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Project::query()->create([
|
||||
'slug' => $slug,
|
||||
'name' => $repo['display'] ?? ucfirst(str_replace('-', ' ', $slug)),
|
||||
'directory' => $repo['directory'] ?? $slug,
|
||||
'absolute_path' => $repo['path'] ?? null,
|
||||
'jira_project_code' => $repo['jira_project'] ?? null,
|
||||
'git_monitor_enabled' => in_array($slug, $enabledProjects, true),
|
||||
'git_last_checked_commit' => $lastChecked[$slug] ?? null,
|
||||
'git_current_version' => $currentVersions['repositories'][$slug]['current_version'] ?? null,
|
||||
'git_release_branch' => $currentVersions['repositories'][$slug]['branch'] ?? null,
|
||||
'git_version_cached_at' => isset($currentVersions['cached_at'])
|
||||
? Carbon::parse($currentVersions['cached_at'])
|
||||
: null,
|
||||
'log_app_names' => $this->findAppNamesForProject($appEnvMap, $slug),
|
||||
'log_env' => $this->findEnvForProject($appEnvMap, $slug),
|
||||
]);
|
||||
}
|
||||
|
||||
// 3. 删除旧配置
|
||||
Config::query()->whereIn('key', [
|
||||
'workspace.repositories',
|
||||
'git_monitor.current_versions',
|
||||
'git_monitor.last_checked_commits',
|
||||
'log_analysis.app_env_map',
|
||||
])->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// 不支持回滚,因为数据结构已经改变
|
||||
// 如需回滚,请手动恢复数据
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 appEnvMap 中找出映射到指定项目的所有 app 名称
|
||||
*/
|
||||
private function findAppNamesForProject(array $appEnvMap, string $projectSlug): ?array
|
||||
{
|
||||
$appNames = [];
|
||||
|
||||
foreach ($appEnvMap as $appName => $mapping) {
|
||||
if (isset($mapping['project']) && $mapping['project'] === $projectSlug) {
|
||||
$appNames[] = $appName;
|
||||
}
|
||||
}
|
||||
|
||||
return empty($appNames) ? null : $appNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 appEnvMap 中找出指定项目的环境
|
||||
*/
|
||||
private function findEnvForProject(array $appEnvMap, string $projectSlug): string
|
||||
{
|
||||
foreach ($appEnvMap as $mapping) {
|
||||
if (isset($mapping['project']) && $mapping['project'] === $projectSlug) {
|
||||
return $mapping['env'] ?? 'production';
|
||||
}
|
||||
}
|
||||
|
||||
return 'production';
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->boolean('is_important')->default(false)->after('git_monitor_enabled')->comment('是否为重要项目');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('is_important');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->boolean('auto_create_release_branch')->default(false)->after('git_monitor_enabled');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table) {
|
||||
$table->dropColumn('auto_create_release_branch');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -60,6 +60,12 @@
|
||||
|
||||
<!-- IP 用户映射页面 -->
|
||||
<ip-user-mappings v-else-if="currentPage === 'ip-mappings'" />
|
||||
|
||||
<!-- 项目配置管理页面 -->
|
||||
<project-management v-else-if="currentPage === 'projects'" />
|
||||
|
||||
<!-- 定时任务管理页面 -->
|
||||
<scheduled-tasks v-else-if="currentPage === 'scheduled-tasks'" />
|
||||
</admin-layout>
|
||||
</template>
|
||||
|
||||
@@ -76,6 +82,8 @@ import LogAnalysis from '../log-analysis/LogAnalysis.vue';
|
||||
import SystemSettings from './SystemSettings.vue';
|
||||
import OperationLogs from './OperationLogs.vue';
|
||||
import IpUserMappings from './IpUserMappings.vue';
|
||||
import ProjectManagement from './ProjectManagement.vue';
|
||||
import ScheduledTasks from './ScheduledTasks.vue';
|
||||
|
||||
export default {
|
||||
name: 'AdminDashboard',
|
||||
@@ -91,7 +99,9 @@ export default {
|
||||
LogAnalysis,
|
||||
SystemSettings,
|
||||
OperationLogs,
|
||||
IpUserMappings
|
||||
IpUserMappings,
|
||||
ProjectManagement,
|
||||
ScheduledTasks
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -121,7 +131,7 @@ export default {
|
||||
}
|
||||
},
|
||||
handleMenuChange(menu) {
|
||||
if (menu === 'ip-mappings' && !this.isAdmin) {
|
||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
||||
this.redirectToDefault();
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +150,9 @@ export default {
|
||||
'log-analysis': 'SLS 日志分析',
|
||||
'settings': '系统设置',
|
||||
'logs': '操作日志',
|
||||
'ip-mappings': 'IP 用户映射'
|
||||
'ip-mappings': 'IP 用户映射',
|
||||
'projects': '项目配置管理',
|
||||
'scheduled-tasks': '定时任务管理'
|
||||
};
|
||||
|
||||
this.pageTitle = titles[menu] || '环境配置管理';
|
||||
@@ -172,9 +184,13 @@ export default {
|
||||
page = 'logs';
|
||||
} else if (path === '/ip-mappings') {
|
||||
page = 'ip-mappings';
|
||||
} else if (path === '/projects') {
|
||||
page = 'projects';
|
||||
} else if (path === '/scheduled-tasks') {
|
||||
page = 'scheduled-tasks';
|
||||
}
|
||||
|
||||
if (page === 'ip-mappings' && !this.isAdmin) {
|
||||
if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) {
|
||||
this.redirectToDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,58 @@
|
||||
<!-- 导航菜单 -->
|
||||
<nav class="mt-6 px-3 flex flex-col flex-1">
|
||||
<div class="space-y-1 flex-1">
|
||||
<!-- 项目配置 - 置顶 -->
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('projects')"
|
||||
v-if="isAdmin"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'projects'
|
||||
? '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 === 'projects' ? '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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
||||
</svg>
|
||||
项目配置
|
||||
</a>
|
||||
|
||||
<!-- 定时任务管理 -->
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('scheduled-tasks')"
|
||||
v-if="isAdmin"
|
||||
:class="[
|
||||
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||
activeMenu === 'scheduled-tasks'
|
||||
? '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 === 'scheduled-tasks' ? '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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
定时任务
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
@click.prevent="setActiveMenu('env')"
|
||||
@@ -287,6 +339,7 @@
|
||||
</svg>
|
||||
IP 用户映射
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
@@ -345,7 +398,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
setActiveMenu(menu) {
|
||||
if (menu === 'ip-mappings' && !this.isAdmin) {
|
||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,9 +435,13 @@ export default {
|
||||
menu = 'logs';
|
||||
} else if (path === '/ip-mappings') {
|
||||
menu = 'ip-mappings';
|
||||
} else if (path === '/projects') {
|
||||
menu = 'projects';
|
||||
} else if (path === '/scheduled-tasks') {
|
||||
menu = 'scheduled-tasks';
|
||||
}
|
||||
|
||||
if (menu === 'ip-mappings' && !this.isAdmin) {
|
||||
if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) {
|
||||
menu = 'env';
|
||||
}
|
||||
|
||||
|
||||
653
resources/js/components/admin/ProjectManagement.vue
Normal file
653
resources/js/components/admin/ProjectManagement.vue
Normal file
@@ -0,0 +1,653 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<div class="flex items-center gap-2">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-800">项目配置管理</h3>
|
||||
<p class="text-sm text-gray-500">统一管理项目路径、Git监控、日志分析等配置</p>
|
||||
</div>
|
||||
<!-- 帮助气泡 -->
|
||||
<div class="relative" @mouseenter="showHelp = true" @mouseleave="showHelp = false">
|
||||
<button class="p-1 text-gray-400 hover:text-blue-500 rounded-full hover:bg-blue-50">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM8.94 6.94a.75.75 0 11-1.061-1.061 3 3 0 112.871 5.026v.345a.75.75 0 01-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 108.94 6.94zM10 15a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<div v-show="showHelp" class="absolute left-0 top-8 z-50 w-80 bg-white rounded-lg shadow-lg border border-gray-200 p-4">
|
||||
<h4 class="font-medium text-gray-800 mb-2">功能说明</h4>
|
||||
<ul class="text-sm text-gray-600 space-y-1.5">
|
||||
<li><strong class="text-gray-700">一键同步</strong>:同步重要项目的 Git 版本信息</li>
|
||||
<li><strong class="text-gray-700">发现项目</strong>:扫描项目根目录,自动发现新项目</li>
|
||||
<li><strong class="text-gray-700">有效/无效</strong>:项目目录是否存在于磁盘上</li>
|
||||
<li><strong class="text-gray-700">星标</strong>:标记重要项目,优先显示并参与一键同步</li>
|
||||
<li><strong class="text-gray-700">Git 监控</strong>:启用后监控项目的 Git 提交变化</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="syncAllProjects"
|
||||
:disabled="syncingAll"
|
||||
class="px-3 py-2 bg-green-600 text-white text-sm font-medium rounded hover:bg-green-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': syncingAll}">
|
||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ syncingAll ? '同步中...' : '一键同步' }}
|
||||
</button>
|
||||
<button
|
||||
@click="discoverProjects"
|
||||
:disabled="discovering"
|
||||
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M9 6a.75.75 0 01.75.75v1.5h1.5a.75.75 0 010 1.5h-1.5v1.5a.75.75 0 01-1.5 0v-1.5h-1.5a.75.75 0 010-1.5h1.5v-1.5A.75.75 0 019 6z" />
|
||||
<path fill-rule="evenodd" d="M2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9zm7-5.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
发现项目
|
||||
</button>
|
||||
<button
|
||||
@click="openCreateModal"
|
||||
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 flex items-center gap-1"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
|
||||
</svg>
|
||||
添加项目
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="message" class="text-sm text-green-600 bg-green-50 px-4 py-3 rounded border border-green-100">{{ message }}</div>
|
||||
<div v-if="error" class="text-sm text-red-600 bg-red-50 px-4 py-3 rounded border border-red-100">{{ error }}</div>
|
||||
|
||||
<!-- Projects Path -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="text-sm font-medium text-gray-600 whitespace-nowrap">项目根目录:</label>
|
||||
<span class="font-mono text-sm text-gray-800 bg-gray-50 px-3 py-1.5 rounded border">{{ projectsPath || '未配置' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Git 监控项目 -->
|
||||
<div v-if="gitMonitorProjects.length > 0" class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
|
||||
Git 监控项目 ({{ gitMonitorProjects.length }})
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<project-card
|
||||
v-for="project in gitMonitorProjects"
|
||||
:key="project.slug"
|
||||
:project="project"
|
||||
@sync="syncProject"
|
||||
@edit="openEditModal"
|
||||
@delete="deleteProject"
|
||||
@toggle-field="toggleField"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重要项目 -->
|
||||
<div v-if="importantProjects.length > 0" class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
|
||||
重要项目 ({{ importantProjects.length }})
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<project-card
|
||||
v-for="project in importantProjects"
|
||||
:key="project.slug"
|
||||
:project="project"
|
||||
@sync="syncProject"
|
||||
@edit="openEditModal"
|
||||
@delete="deleteProject"
|
||||
@toggle-field="toggleField"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他项目 -->
|
||||
<div v-if="otherProjects.length > 0" class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
|
||||
其他项目 ({{ otherProjects.length }})
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<project-card
|
||||
v-for="project in otherProjects"
|
||||
:key="project.slug"
|
||||
:project="project"
|
||||
@sync="syncProject"
|
||||
@edit="openEditModal"
|
||||
@delete="deleteProject"
|
||||
@toggle-field="toggleField"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && projects.length === 0" class="text-center py-12 text-gray-400 bg-white rounded-lg border border-gray-200">
|
||||
暂无项目配置,点击"发现项目"或"添加项目"开始
|
||||
</div>
|
||||
|
||||
<!-- Edit/Create Modal -->
|
||||
<div v-if="showModal" class="fixed inset-0 backdrop-blur-sm bg-white/30 flex items-center justify-center z-50" @click.self="closeModal">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h4 class="text-lg font-semibold text-gray-800">{{ editingProject ? '编辑项目' : '添加项目' }}</h4>
|
||||
<button @click="closeModal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Slug (唯一标识)</label>
|
||||
<input v-model="form.slug" type="text" :disabled="!!editingProject" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100" placeholder="如: portal-be" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">显示名称</label>
|
||||
<input v-model="form.name" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: Portal Backend" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">目录名 (相对路径)</label>
|
||||
<input v-model="form.directory" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-be" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">绝对路径 (可选)</label>
|
||||
<input v-model="form.absolute_path" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="覆盖默认路径" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">JIRA 项目代码</label>
|
||||
<input v-model="form.jira_project_code" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: PORTAL" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.git_monitor_enabled" type="checkbox" id="git_monitor" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
|
||||
<label for="git_monitor" class="text-sm text-gray-700">启用 Git 监控</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.auto_create_release_branch" type="checkbox" id="auto_create_release_branch" class="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<label for="auto_create_release_branch" class="text-sm text-gray-700">自动创建 Release 分支</label>
|
||||
<span class="text-xs text-gray-400" title="当 Jira 中有新版本且远程没有对应分支时,自动从 master 创建并推送">(?)</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input v-model="form.is_important" type="checkbox" id="is_important" class="rounded border-gray-300 text-yellow-500 focus:ring-yellow-500" />
|
||||
<label for="is_important" class="text-sm text-gray-700">标记为重要项目</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">日志 App 名称 (逗号分隔)</label>
|
||||
<input v-model="form.log_app_names_text" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: portal-api, portal-worker" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">日志环境</label>
|
||||
<input v-model="form.log_env" type="text" class="w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500" placeholder="如: production" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-2">
|
||||
<button @click="closeModal" class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200">取消</button>
|
||||
<button @click="saveProject" :disabled="saving" class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Discover Modal -->
|
||||
<div v-if="showDiscoverModal" class="fixed inset-0 backdrop-blur-sm bg-white/30 flex items-center justify-center z-50" @click.self="showDiscoverModal = false">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
|
||||
<h4 class="text-lg font-semibold text-gray-800">发现的项目</h4>
|
||||
<label v-if="discoveredProjects.length > 0" class="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||
<input type="checkbox" :checked="isAllSelected" @change="toggleSelectAll" class="rounded border-gray-300 text-blue-600" />
|
||||
全选
|
||||
</label>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div v-if="discoveredProjects.length === 0" class="text-center text-gray-400 py-8">
|
||||
未发现新项目
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto max-h-80">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-10"></th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Slug</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">名称</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Git</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="project in discoveredProjects" :key="project.slug" class="hover:bg-gray-50 cursor-pointer" @click="toggleProjectSelection(project.slug)">
|
||||
<td class="px-3 py-2">
|
||||
<input type="checkbox" :checked="selectedDiscovered.includes(project.slug)" @click.stop @change="toggleProjectSelection(project.slug)" class="rounded border-gray-300 text-blue-600" />
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono text-sm text-gray-900">{{ project.slug }}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-600">{{ project.name }}</td>
|
||||
<td class="px-3 py-2">
|
||||
<span v-if="project.is_git_repo" class="text-green-600 text-xs">Git</span>
|
||||
<span v-else class="text-gray-400 text-xs">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-2">
|
||||
<button @click="showDiscoverModal = false" class="px-4 py-2 text-sm text-gray-700 bg-gray-100 rounded hover:bg-gray-200">取消</button>
|
||||
<button @click="addDiscoveredProjects" :disabled="selectedDiscovered.length === 0 || addingDiscovered" class="px-4 py-2 text-sm text-white bg-blue-600 rounded hover:bg-blue-700 disabled:opacity-50">
|
||||
添加选中 ({{ selectedDiscovered.length }})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// ProjectCard 子组件
|
||||
const ProjectCard = {
|
||||
name: 'ProjectCard',
|
||||
props: {
|
||||
project: { type: Object, required: true }
|
||||
},
|
||||
emits: ['sync', 'edit', 'delete', 'toggle-field'],
|
||||
template: `
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div class="p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono font-semibold text-gray-800">{{ project.slug }}</span>
|
||||
<span
|
||||
:class="project.path_valid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||||
class="text-xs px-1.5 py-0.5 rounded"
|
||||
:title="project.path_valid ? '项目目录存在' : '项目目录不存在或已被移动'"
|
||||
>
|
||||
{{ project.path_valid ? '有效' : '无效' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-0.5">{{ project.name }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- 同步 -->
|
||||
<button @click="$emit('sync', project)" :disabled="project._syncing" class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="同步Git信息">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': project._syncing}">
|
||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 编辑 -->
|
||||
<button @click="$emit('edit', project)" class="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded" title="编辑">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- 删除 -->
|
||||
<button @click="$emit('delete', project)" :disabled="project._deleting" class="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded" title="删除">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="project.jira_project_code" class="flex items-center gap-2">
|
||||
<span class="text-gray-500">JIRA:</span>
|
||||
<span class="font-mono bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded text-xs">{{ project.jira_project_code }}</span>
|
||||
</div>
|
||||
<div v-if="project.git_current_version" class="flex items-center gap-2">
|
||||
<span class="text-gray-500">版本:</span>
|
||||
<span class="font-mono text-gray-700">{{ project.git_current_version }}</span>
|
||||
</div>
|
||||
<div v-if="project.log_app_names?.length" class="flex items-center gap-2">
|
||||
<span class="text-gray-500">App:</span>
|
||||
<span class="text-gray-700">{{ project.log_app_names.join(', ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle Switches -->
|
||||
<div class="mt-3 pt-3 border-t border-gray-100 flex flex-wrap gap-3">
|
||||
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'git_monitor_enabled')">
|
||||
<span class="relative inline-block">
|
||||
<input type="checkbox" :checked="project.git_monitor_enabled" class="sr-only peer" />
|
||||
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-blue-500 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
|
||||
</span>
|
||||
<span class="text-xs" :class="project.git_monitor_enabled ? 'text-blue-600' : 'text-gray-500'">Git监控</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'auto_create_release_branch')">
|
||||
<span class="relative inline-block">
|
||||
<input type="checkbox" :checked="project.auto_create_release_branch" class="sr-only peer" />
|
||||
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-purple-500 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
|
||||
</span>
|
||||
<span class="text-xs" :class="project.auto_create_release_branch ? 'text-purple-600' : 'text-gray-500'">自动创建分支</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-1.5 cursor-pointer" @click.prevent="$emit('toggle-field', project, 'is_important')">
|
||||
<span class="relative inline-block">
|
||||
<input type="checkbox" :checked="project.is_important" class="sr-only peer" />
|
||||
<span class="block w-8 h-4 bg-gray-200 rounded-full peer peer-checked:bg-yellow-500 transition-colors"></span>
|
||||
<span class="absolute left-0.5 top-0.5 w-3 h-3 bg-white rounded-full transition-transform peer-checked:translate-x-4"></span>
|
||||
</span>
|
||||
<span class="text-xs" :class="project.is_important ? 'text-yellow-600' : 'text-gray-500'">重要</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
export default {
|
||||
name: 'ProjectManagement',
|
||||
components: {
|
||||
ProjectCard
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
discovering: false,
|
||||
addingDiscovered: false,
|
||||
syncingAll: false,
|
||||
showHelp: false,
|
||||
projects: [],
|
||||
projectsPath: '',
|
||||
message: '',
|
||||
error: '',
|
||||
showModal: false,
|
||||
showDiscoverModal: false,
|
||||
editingProject: null,
|
||||
form: this.getEmptyForm(),
|
||||
discoveredProjects: [],
|
||||
selectedDiscovered: []
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadProjects();
|
||||
},
|
||||
computed: {
|
||||
isAllSelected() {
|
||||
return this.discoveredProjects.length > 0 && this.selectedDiscovered.length === this.discoveredProjects.length;
|
||||
},
|
||||
// Git 监控项目(最高优先级)
|
||||
gitMonitorProjects() {
|
||||
return this.projects.filter(p => p.git_monitor_enabled);
|
||||
},
|
||||
// 重要项目(不包含已开启 Git 监控的)
|
||||
importantProjects() {
|
||||
return this.projects.filter(p => p.is_important && !p.git_monitor_enabled);
|
||||
},
|
||||
// 其他项目
|
||||
otherProjects() {
|
||||
return this.projects.filter(p => !p.git_monitor_enabled && !p.is_important);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected) {
|
||||
this.selectedDiscovered = [];
|
||||
} else {
|
||||
this.selectedDiscovered = this.discoveredProjects.map(p => p.slug);
|
||||
}
|
||||
},
|
||||
toggleProjectSelection(slug) {
|
||||
const idx = this.selectedDiscovered.indexOf(slug);
|
||||
if (idx === -1) {
|
||||
this.selectedDiscovered.push(slug);
|
||||
} else {
|
||||
this.selectedDiscovered.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
getEmptyForm() {
|
||||
return {
|
||||
slug: '',
|
||||
name: '',
|
||||
directory: '',
|
||||
absolute_path: '',
|
||||
jira_project_code: '',
|
||||
git_monitor_enabled: false,
|
||||
auto_create_release_branch: false,
|
||||
is_important: false,
|
||||
log_app_names_text: '',
|
||||
log_env: 'production'
|
||||
};
|
||||
},
|
||||
async loadProjects() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/projects');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.projects = (data.data.projects || []).map(p => ({ ...p, _syncing: false, _deleting: false }));
|
||||
this.projectsPath = data.data.projects_path || '';
|
||||
} else {
|
||||
this.error = data.message || '加载失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
openCreateModal() {
|
||||
this.editingProject = null;
|
||||
this.form = this.getEmptyForm();
|
||||
this.showModal = true;
|
||||
},
|
||||
openEditModal(project) {
|
||||
this.editingProject = project;
|
||||
this.form = {
|
||||
slug: project.slug,
|
||||
name: project.name,
|
||||
directory: project.directory || '',
|
||||
absolute_path: project.absolute_path || '',
|
||||
jira_project_code: project.jira_project_code || '',
|
||||
git_monitor_enabled: project.git_monitor_enabled || false,
|
||||
auto_create_release_branch: project.auto_create_release_branch || false,
|
||||
is_important: project.is_important || false,
|
||||
log_app_names_text: (project.log_app_names || []).join(', '),
|
||||
log_env: project.log_env || 'production'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.editingProject = null;
|
||||
},
|
||||
async saveProject() {
|
||||
if (!this.form.slug || !this.form.name) {
|
||||
this.error = 'Slug 和名称不能为空';
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
|
||||
const payload = {
|
||||
slug: this.form.slug,
|
||||
name: this.form.name,
|
||||
directory: this.form.directory || null,
|
||||
absolute_path: this.form.absolute_path || null,
|
||||
jira_project_code: this.form.jira_project_code || null,
|
||||
git_monitor_enabled: this.form.git_monitor_enabled,
|
||||
auto_create_release_branch: this.form.auto_create_release_branch,
|
||||
is_important: this.form.is_important,
|
||||
log_app_names: this.form.log_app_names_text ? this.form.log_app_names_text.split(',').map(s => s.trim()).filter(Boolean) : null,
|
||||
log_env: this.form.log_env || null
|
||||
};
|
||||
|
||||
try {
|
||||
const url = this.editingProject ? `/api/admin/projects/${this.editingProject.slug}` : '/api/admin/projects';
|
||||
const method = this.editingProject ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.message = this.editingProject ? '项目已更新' : '项目已创建';
|
||||
this.closeModal();
|
||||
await this.loadProjects();
|
||||
} else {
|
||||
this.error = data.message || '保存失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
async deleteProject(project) {
|
||||
if (!confirm(`确认删除项目 ${project.slug} 吗?`)) return;
|
||||
project._deleting = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/projects/${project.slug}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.message = '项目已删除';
|
||||
this.projects = this.projects.filter(p => p.slug !== project.slug);
|
||||
} else {
|
||||
this.error = data.message || '删除失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
project._deleting = false;
|
||||
}
|
||||
},
|
||||
async syncProject(project) {
|
||||
project._syncing = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/projects/${project.slug}/sync`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
Object.assign(project, data.data.project);
|
||||
this.message = `${project.slug} 同步完成`;
|
||||
} else {
|
||||
this.error = data.message || '同步失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
project._syncing = false;
|
||||
}
|
||||
},
|
||||
async syncAllProjects() {
|
||||
this.syncingAll = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
// 只同步重要项目(Git 监控项目 + 标记为重要的项目)
|
||||
const importantProjects = this.projects.filter(p => p.git_monitor_enabled || p.is_important);
|
||||
|
||||
if (importantProjects.length === 0) {
|
||||
this.message = '没有需要同步的重要项目,请先标记重要项目或启用 Git 监控';
|
||||
this.syncingAll = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const project of importantProjects) {
|
||||
if (!project.path_valid) continue;
|
||||
project._syncing = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/projects/${project.slug}/sync`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
Object.assign(project, data.data.project);
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
}
|
||||
} catch (e) {
|
||||
failCount++;
|
||||
} finally {
|
||||
project._syncing = false;
|
||||
}
|
||||
}
|
||||
this.message = `同步完成:成功 ${successCount} 个${failCount > 0 ? `,失败 ${failCount} 个` : ''}`;
|
||||
} finally {
|
||||
this.syncingAll = false;
|
||||
}
|
||||
},
|
||||
async toggleField(project, field) {
|
||||
this.error = '';
|
||||
try {
|
||||
const newValue = !project[field];
|
||||
const res = await fetch(`/api/admin/projects/${project.slug}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ [field]: newValue })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
project[field] = newValue;
|
||||
} else {
|
||||
this.error = data.message || '操作失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
}
|
||||
},
|
||||
async discoverProjects() {
|
||||
this.discovering = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/projects/discover');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.discoveredProjects = data.data.discovered || [];
|
||||
this.selectedDiscovered = [];
|
||||
this.showDiscoverModal = true;
|
||||
} else {
|
||||
this.error = data.message || '发现失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.discovering = false;
|
||||
}
|
||||
},
|
||||
async addDiscoveredProjects() {
|
||||
if (this.selectedDiscovered.length === 0) return;
|
||||
this.addingDiscovered = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/projects/add-discovered', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slugs: this.selectedDiscovered })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.message = `已添加 ${data.data.added?.length || 0} 个项目`;
|
||||
this.showDiscoverModal = false;
|
||||
await this.loadProjects();
|
||||
} else {
|
||||
this.error = data.message || '添加失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.addingDiscovered = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
141
resources/js/components/admin/ScheduledTasks.vue
Normal file
141
resources/js/components/admin/ScheduledTasks.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm border border-gray-200">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-800">定时任务管理</h3>
|
||||
<p class="text-sm text-gray-500">查看和控制系统定时任务的启用状态</p>
|
||||
</div>
|
||||
<button
|
||||
@click="loadTasks"
|
||||
:disabled="loading"
|
||||
class="px-3 py-2 bg-gray-100 text-gray-700 text-sm font-medium rounded hover:bg-gray-200 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': loading}">
|
||||
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
<div v-if="message" class="text-sm text-green-600 bg-green-50 px-4 py-3 rounded border border-green-100">{{ message }}</div>
|
||||
<div v-if="error" class="text-sm text-red-600 bg-red-50 px-4 py-3 rounded border border-red-100">{{ error }}</div>
|
||||
|
||||
<!-- Tasks List -->
|
||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">任务名称</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">命令</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">执行频率</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="task in tasks" :key="task.name" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4">
|
||||
<div class="font-medium text-gray-900">{{ task.description }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-xs bg-gray-100 px-2 py-1 rounded text-gray-700">{{ task.command }}</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-900">{{ task.frequency }}</div>
|
||||
<div class="text-xs text-gray-400 font-mono">{{ task.cron }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
:class="task.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'"
|
||||
class="px-2 py-1 text-xs font-medium rounded-full"
|
||||
>
|
||||
{{ task.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
@click="toggleTask(task)"
|
||||
:disabled="task._toggling"
|
||||
:class="task.enabled ? 'text-red-600 hover:text-red-800' : 'text-green-600 hover:text-green-800'"
|
||||
class="text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{{ task._toggling ? '处理中...' : (task.enabled ? '禁用' : '启用') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tasks.length === 0 && !loading">
|
||||
<td colspan="5" class="px-6 py-8 text-center text-gray-400">
|
||||
暂无定时任务
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="text-center py-8 text-gray-400">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ScheduledTasks',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
tasks: [],
|
||||
message: '',
|
||||
error: ''
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadTasks();
|
||||
},
|
||||
methods: {
|
||||
async loadTasks() {
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
try {
|
||||
const res = await fetch('/api/admin/scheduled-tasks');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.tasks = (data.data.tasks || []).map(t => ({ ...t, _toggling: false }));
|
||||
} else {
|
||||
this.error = data.message || '加载失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async toggleTask(task) {
|
||||
task._toggling = true;
|
||||
this.error = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const res = await fetch(`/api/admin/scheduled-tasks/${task.name}/toggle`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !task.enabled })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
task.enabled = data.data.enabled;
|
||||
this.message = `任务 ${task.name} 已${task.enabled ? '启用' : '禁用'}`;
|
||||
} else {
|
||||
this.error = data.message || '操作失败';
|
||||
}
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
task._toggling = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -11,6 +11,8 @@ use App\Http\Controllers\Admin\AdminMetaController;
|
||||
use App\Http\Controllers\Admin\ConfigController;
|
||||
use App\Http\Controllers\Admin\IpUserMappingController;
|
||||
use App\Http\Controllers\Admin\OperationLogController;
|
||||
use App\Http\Controllers\Admin\ProjectController;
|
||||
use App\Http\Controllers\Admin\ScheduledTaskController;
|
||||
|
||||
// 环境管理API路由
|
||||
Route::prefix('env')->group(function () {
|
||||
@@ -76,6 +78,22 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () {
|
||||
Route::post('/ip-user-mappings', [IpUserMappingController::class, 'store']);
|
||||
Route::put('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'update']);
|
||||
Route::delete('/ip-user-mappings/{mapping}', [IpUserMappingController::class, 'destroy']);
|
||||
|
||||
// 项目管理
|
||||
Route::get('/projects', [ProjectController::class, 'index']);
|
||||
Route::get('/projects/discover', [ProjectController::class, 'discover']);
|
||||
Route::post('/projects/add-discovered', [ProjectController::class, 'addDiscovered']);
|
||||
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
|
||||
Route::post('/projects', [ProjectController::class, 'store']);
|
||||
Route::put('/projects/{slug}', [ProjectController::class, 'update']);
|
||||
Route::delete('/projects/{slug}', [ProjectController::class, 'destroy']);
|
||||
Route::post('/projects/{slug}/sync', [ProjectController::class, 'sync']);
|
||||
Route::post('/projects/sync-all', [ProjectController::class, 'syncAll']);
|
||||
Route::get('/projects/{slug}/status', [ProjectController::class, 'status']);
|
||||
|
||||
// 定时任务管理
|
||||
Route::get('/scheduled-tasks', [ScheduledTaskController::class, 'index']);
|
||||
Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']);
|
||||
});
|
||||
|
||||
// 日志分析 API 路由
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ConfigService;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -16,7 +15,8 @@ Artisan::command('inspire', function () {
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 定时任务配置
|
||||
| 所有定时任务统一在此文件管理
|
||||
| 所有任务可在管理后台的"定时任务"页面控制启用/禁用
|
||||
| 启用状态存储在数据库 configs 表 (key: scheduled_tasks)
|
||||
|
|
||||
*/
|
||||
|
||||
@@ -25,50 +25,34 @@ Schedule::command('git-monitor:check')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->name('git-monitor-check');
|
||||
->name('git-monitor-check')
|
||||
->description('Git 监控 - 检查 release 分支变化')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-check'));
|
||||
|
||||
// Git Monitor - 每天凌晨 2 点刷新 release 缓存
|
||||
Schedule::command('git-monitor:cache')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->name('git-monitor-cache');
|
||||
->name('git-monitor-cache')
|
||||
->description('Git 监控 - 刷新 release 缓存')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('git-monitor-cache'));
|
||||
|
||||
// SLS 日志分析定时任务 - 每天凌晨 2 点执行
|
||||
// 分析过去 24 小时的 ERROR 和 WARNING 日志并推送到钉钉
|
||||
// 可通过数据库配置 log_analysis.settings.daily_schedule_enabled 控制是否启用
|
||||
// SLS 日志分析 - 每天凌晨 2 点执行
|
||||
Schedule::command('log-analysis:run --from="-24h" --to="now" --query="ERROR or WARNING" --push')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->when(function () {
|
||||
try {
|
||||
$settings = app(ConfigService::class)->get('log_analysis.settings', []);
|
||||
return $settings['daily_schedule_enabled'] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->name('daily-log-analysis')
|
||||
->onFailure(function () {
|
||||
Log::error('每日日志分析定时任务执行失败');
|
||||
});
|
||||
->description('SLS 日志分析 - 每日分析过去 24 小时日志')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('daily-log-analysis'))
|
||||
->onFailure(fn() => Log::error('每日日志分析定时任务执行失败'));
|
||||
|
||||
// SLS 日志分析定时任务 - 每 4 小时执行一次
|
||||
// 分析过去 6 小时的 ERROR 和 WARNING 日志并推送到钉钉
|
||||
// 可通过数据库配置 log_analysis.settings.schedule_enabled 控制是否启用
|
||||
// SLS 日志分析 - 每 4 小时执行一次
|
||||
Schedule::command('log-analysis:run --from="-6h" --to="now" --query="ERROR or WARNING" --push')
|
||||
->everyFourHours()
|
||||
->withoutOverlapping()
|
||||
->runInBackground()
|
||||
->when(function () {
|
||||
try {
|
||||
$settings = app(ConfigService::class)->get('log_analysis.settings', []);
|
||||
return $settings['schedule_enabled'] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->name('frequent-log-analysis')
|
||||
->onFailure(function () {
|
||||
Log::error('SLS 日志分析定时任务执行失败');
|
||||
});
|
||||
->description('SLS 日志分析 - 定期分析过去 6 小时日志')
|
||||
->when(fn() => \App\Services\ScheduledTaskService::isEnabled('frequent-log-analysis'))
|
||||
->onFailure(fn() => Log::error('SLS 日志分析定时任务执行失败'));
|
||||
|
||||
@@ -18,3 +18,5 @@ Route::get('/log-analysis', [AdminController::class, 'index'])->name('admin.log-
|
||||
Route::get('/settings', [AdminController::class, 'index'])->name('admin.settings');
|
||||
Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs');
|
||||
Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip');
|
||||
Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip');
|
||||
Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip');
|
||||
|
||||
Reference in New Issue
Block a user