#feature: add project&cronjob management

This commit is contained in:
2026-01-16 12:14:43 +08:00
parent bbe68839e3
commit 381d5e6e49
19 changed files with 2157 additions and 84 deletions

View 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'],
],
]);
}
}

View 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
View 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();
}
}

View File

@@ -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)) {

View File

@@ -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) {
// 忽略
}
}
}
}

View File

@@ -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,

View 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,
];
}
}

View 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;
}
}

View File

@@ -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');
}
};

View File

@@ -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';
}
};

View File

@@ -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');
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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;
}

View File

@@ -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';
}

View 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>

View 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>

View File

@@ -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 路由

View File

@@ -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 日志分析定时任务执行失败'));

View File

@@ -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');