#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

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