完善页面内容开发
This commit is contained in:
@@ -447,5 +447,266 @@ export async function updateSSLCertificate(params: UpdateSSLParams): Promise<voi
|
||||
await panelService.post('/websites/ssl/update', params)
|
||||
}
|
||||
|
||||
// ==================== 服务器监控 API ====================
|
||||
|
||||
/**
|
||||
* 服务器基础信息
|
||||
*/
|
||||
export interface ServerBaseInfo {
|
||||
hostname: string
|
||||
os: string
|
||||
platform: string
|
||||
platformFamily: string
|
||||
platformVersion: string
|
||||
kernelArch: string
|
||||
kernelVersion: string
|
||||
virtualizationSystem: string
|
||||
}
|
||||
|
||||
/**
|
||||
* CPU 信息
|
||||
*/
|
||||
export interface CPUInfo {
|
||||
cpuModelName: string
|
||||
cpuCores: number
|
||||
cpuLogicalCores: number
|
||||
cpuPercent: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 内存信息
|
||||
*/
|
||||
export interface MemoryInfo {
|
||||
total: number
|
||||
available: number
|
||||
used: number
|
||||
usedPercent: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 磁盘信息
|
||||
*/
|
||||
export interface DiskInfo {
|
||||
path: string
|
||||
device: string
|
||||
fstype: string
|
||||
total: number
|
||||
free: number
|
||||
used: number
|
||||
usedPercent: number
|
||||
inodesTotal: number
|
||||
inodesUsed: number
|
||||
inodesFree: number
|
||||
inodesUsedPercent: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统资源使用状态
|
||||
*/
|
||||
export interface SystemResourceInfo {
|
||||
cpu: number
|
||||
cpuTotal: number
|
||||
memory: number
|
||||
memoryTotal: number
|
||||
memoryUsed: number
|
||||
load1: number
|
||||
load5: number
|
||||
load15: number
|
||||
uptime: number
|
||||
timeSinceUptime: string
|
||||
procs: number
|
||||
disk: DiskInfo[]
|
||||
diskTotal: number
|
||||
diskUsed: number
|
||||
net: {
|
||||
name: string
|
||||
bytesRecv: number
|
||||
bytesSent: number
|
||||
}[]
|
||||
shotTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务器基础信息
|
||||
*/
|
||||
export async function getServerBaseInfo(): Promise<ServerBaseInfo> {
|
||||
const response = await panelService.get<PanelResponse<ServerBaseInfo>>('/hosts/info')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统资源使用状态
|
||||
*/
|
||||
export async function getSystemResourceInfo(): Promise<SystemResourceInfo> {
|
||||
const response = await panelService.get<PanelResponse<SystemResourceInfo>>('/dashboard/current')
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统实时状态
|
||||
*/
|
||||
export async function getRealtimeStatus(): Promise<SystemResourceInfo> {
|
||||
const response = await panelService.post<PanelResponse<SystemResourceInfo>>('/dashboard/current', {
|
||||
ioOption: 'all',
|
||||
netOption: 'all'
|
||||
})
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
// ==================== Nginx 配置 API ====================
|
||||
|
||||
/**
|
||||
* Nginx 配置信息
|
||||
*/
|
||||
export interface NginxConfig {
|
||||
id: number
|
||||
websiteId: number
|
||||
operate: string
|
||||
params?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 网站配置详情
|
||||
*/
|
||||
export interface WebsiteConfig {
|
||||
config: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取网站 Nginx 配置
|
||||
* @param websiteId 网站ID
|
||||
*/
|
||||
export async function getWebsiteNginxConfig(websiteId: number): Promise<string> {
|
||||
const response = await panelService.get<PanelResponse<WebsiteConfig>>(`/websites/${websiteId}/config/nginx`)
|
||||
return response.data.data.config
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新网站 Nginx 配置
|
||||
* @param websiteId 网站ID
|
||||
* @param config 配置内容
|
||||
*/
|
||||
export async function updateWebsiteNginxConfig(websiteId: number, config: string): Promise<void> {
|
||||
await panelService.post(`/websites/${websiteId}/config/nginx`, { config })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重载 Nginx
|
||||
*/
|
||||
export async function reloadNginx(): Promise<void> {
|
||||
await panelService.post('/websites/nginx/update', { operate: 'reload' })
|
||||
}
|
||||
|
||||
// ==================== 应用管理 API ====================
|
||||
|
||||
/**
|
||||
* 已安装应用信息
|
||||
*/
|
||||
export interface InstalledApp {
|
||||
id: number
|
||||
name: string
|
||||
appKey: string
|
||||
version: string
|
||||
status: string
|
||||
message: string
|
||||
httpPort: number
|
||||
httpsPort: number
|
||||
path: string
|
||||
canUpdate: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已安装应用列表
|
||||
*/
|
||||
export async function getInstalledApps(): Promise<InstalledApp[]> {
|
||||
const response = await panelService.post<PanelResponse<{ items: InstalledApp[], total: number }>>('/apps/installed/search', {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
all: true
|
||||
})
|
||||
return response.data.data.items || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作应用
|
||||
* @param installId 安装ID
|
||||
* @param operate 操作类型:start, stop, restart
|
||||
*/
|
||||
export async function operateApp(installId: number, operate: 'start' | 'stop' | 'restart'): Promise<void> {
|
||||
await panelService.post('/apps/installed/operate', {
|
||||
installId,
|
||||
operate
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 文件管理 API ====================
|
||||
|
||||
/**
|
||||
* 文件信息
|
||||
*/
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
size: number
|
||||
mode: string
|
||||
modeNum: string
|
||||
isDir: boolean
|
||||
isSymlink: boolean
|
||||
linkPath: string
|
||||
path: string
|
||||
extension: string
|
||||
user: string
|
||||
group: string
|
||||
uid: string
|
||||
gid: string
|
||||
modTime: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目录文件列表
|
||||
* @param path 目录路径
|
||||
*/
|
||||
export async function getFileList(path: string): Promise<FileInfo[]> {
|
||||
const response = await panelService.post<PanelResponse<FileInfo[]>>('/files/search', {
|
||||
path,
|
||||
expand: true,
|
||||
page: 1,
|
||||
pageSize: 100
|
||||
})
|
||||
return response.data.data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param path 目标路径
|
||||
* @param file 文件
|
||||
*/
|
||||
export async function uploadFile(path: string, file: File): Promise<void> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('path', path)
|
||||
|
||||
await panelService.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解压文件
|
||||
* @param path 文件路径
|
||||
* @param dst 解压目标路径
|
||||
*/
|
||||
export async function decompressFile(path: string, dst: string): Promise<void> {
|
||||
await panelService.post('/files/decompress', {
|
||||
path,
|
||||
dst,
|
||||
type: path.endsWith('.tar.gz') ? 'tar.gz' : 'zip'
|
||||
})
|
||||
}
|
||||
|
||||
// 导出 panelService 实例供其他地方使用
|
||||
export default panelService
|
||||
|
||||
|
||||
2
src/components.d.ts
vendored
2
src/components.d.ts
vendored
@@ -20,6 +20,8 @@ declare module 'vue' {
|
||||
ACard: typeof import('ant-design-vue/es')['Card']
|
||||
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
|
||||
ACol: typeof import('ant-design-vue/es')['Col']
|
||||
ACollapse: typeof import('ant-design-vue/es')['Collapse']
|
||||
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
|
||||
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
|
||||
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
|
||||
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
|
||||
|
||||
@@ -133,14 +133,22 @@ export const platformModule: ModuleConfig = {
|
||||
children: [
|
||||
{ key: 'platform-projects', label: '项目管理', path: '/platform/projects' },
|
||||
{ key: 'platform-menus', label: '菜单管理', path: '/platform/menus' },
|
||||
{ key: 'platform-certificates', label: '证书管理', path: '/platform/certificates' }
|
||||
{ key: 'platform-servers', label: '服务器管理', path: '/platform/servers' },
|
||||
{ key: 'platform-domains', label: '域名管理', path: '/platform/domains' },
|
||||
{ key: 'platform-certificates', label: '证书管理', path: '/platform/certificates' },
|
||||
{ key: 'platform-environments', label: '环境配置', path: '/platform/environments' },
|
||||
{ key: 'platform-log-center', label: '日志中心', path: '/platform/log-center' }
|
||||
]
|
||||
}
|
||||
],
|
||||
routes: [
|
||||
{ path: 'platform/projects', name: 'PlatformProjects', component: '@/views/platform/projects/index.vue', title: '项目管理' },
|
||||
{ path: 'platform/menus', name: 'PlatformMenus', component: '@/views/platform/menus/index.vue', title: '菜单管理' },
|
||||
{ path: 'platform/certificates', name: 'PlatformCertificates', component: '@/views/platform/certificates/index.vue', title: '证书管理' }
|
||||
{ path: 'platform/servers', name: 'PlatformServers', component: '@/views/platform/servers/index.vue', title: '服务器管理' },
|
||||
{ path: 'platform/domains', name: 'PlatformDomains', component: '@/views/platform/domains/index.vue', title: '域名管理' },
|
||||
{ path: 'platform/certificates', name: 'PlatformCertificates', component: '@/views/platform/certificates/index.vue', title: '证书管理' },
|
||||
{ path: 'platform/environments', name: 'PlatformEnvironments', component: '@/views/platform/environments/index.vue', title: '环境配置' },
|
||||
{ path: 'platform/log-center', name: 'PlatformLogCenter', component: '@/views/platform/log-center/index.vue', title: '日志中心' }
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
566
src/mock/platform.ts
Normal file
566
src/mock/platform.ts
Normal file
@@ -0,0 +1,566 @@
|
||||
/**
|
||||
* 平台管理模块 Mock 数据
|
||||
*/
|
||||
|
||||
import type {
|
||||
ServerInfo,
|
||||
DeployRecord,
|
||||
DeployConfig,
|
||||
LogRecord,
|
||||
DomainInfo,
|
||||
EnvironmentInfo
|
||||
} from '@/types/platform'
|
||||
|
||||
// ==================== 服务器管理 Mock 数据 ====================
|
||||
|
||||
export const mockServers: ServerInfo[] = [
|
||||
{
|
||||
id: 'server-001',
|
||||
name: '生产服务器-主',
|
||||
ip: '47.108.xxx.xxx',
|
||||
internalIp: '172.16.0.1',
|
||||
port: 22,
|
||||
type: 'cloud',
|
||||
status: 'online',
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
cpu: { cores: 8, usage: 35 },
|
||||
memory: { total: 16, used: 8.5, usage: 53 },
|
||||
disk: { total: 200, used: 85, usage: 42 },
|
||||
tags: ['生产', '主节点'],
|
||||
sshUser: 'root',
|
||||
sshPort: 22,
|
||||
description: '主生产服务器,部署核心业务',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z',
|
||||
panelUrl: 'https://panel.example.com',
|
||||
panelPort: 8888
|
||||
},
|
||||
{
|
||||
id: 'server-002',
|
||||
name: '生产服务器-从',
|
||||
ip: '47.108.xxx.xxx',
|
||||
internalIp: '172.16.0.2',
|
||||
port: 22,
|
||||
type: 'cloud',
|
||||
status: 'online',
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
cpu: { cores: 4, usage: 22 },
|
||||
memory: { total: 8, used: 3.2, usage: 40 },
|
||||
disk: { total: 100, used: 35, usage: 35 },
|
||||
tags: ['生产', '从节点'],
|
||||
sshUser: 'root',
|
||||
sshPort: 22,
|
||||
description: '从生产服务器,负载均衡',
|
||||
createdAt: '2024-01-15T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'server-003',
|
||||
name: '测试服务器',
|
||||
ip: '192.168.1.100',
|
||||
port: 22,
|
||||
type: 'virtual',
|
||||
status: 'online',
|
||||
os: 'CentOS 7.9',
|
||||
cpu: { cores: 4, usage: 15 },
|
||||
memory: { total: 8, used: 2.1, usage: 26 },
|
||||
disk: { total: 100, used: 25, usage: 25 },
|
||||
tags: ['测试'],
|
||||
sshUser: 'root',
|
||||
sshPort: 22,
|
||||
description: '测试环境服务器',
|
||||
createdAt: '2024-03-01T00:00:00Z',
|
||||
updatedAt: '2024-12-28T15:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'server-004',
|
||||
name: '开发服务器',
|
||||
ip: '192.168.1.101',
|
||||
port: 22,
|
||||
type: 'virtual',
|
||||
status: 'warning',
|
||||
os: 'Ubuntu 20.04 LTS',
|
||||
cpu: { cores: 2, usage: 85 },
|
||||
memory: { total: 4, used: 3.5, usage: 87 },
|
||||
disk: { total: 50, used: 42, usage: 84 },
|
||||
tags: ['开发'],
|
||||
sshUser: 'dev',
|
||||
sshPort: 22,
|
||||
description: '开发环境服务器,资源告警',
|
||||
createdAt: '2024-06-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T09:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'server-005',
|
||||
name: '备份服务器',
|
||||
ip: '172.16.0.100',
|
||||
port: 22,
|
||||
type: 'physical',
|
||||
status: 'offline',
|
||||
os: 'Debian 11',
|
||||
cpu: { cores: 4, usage: 0 },
|
||||
memory: { total: 16, used: 0, usage: 0 },
|
||||
disk: { total: 2000, used: 850, usage: 42 },
|
||||
tags: ['备份', '离线'],
|
||||
sshUser: 'backup',
|
||||
sshPort: 2222,
|
||||
description: '数据备份服务器,当前离线维护中',
|
||||
createdAt: '2024-02-01T00:00:00Z',
|
||||
updatedAt: '2024-12-20T00:00:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 部署管理 Mock 数据 ====================
|
||||
|
||||
export const mockDeployRecords: DeployRecord[] = [
|
||||
{
|
||||
id: 'deploy-001',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
version: '1.2.0',
|
||||
previousVersion: '1.1.5',
|
||||
status: 'success',
|
||||
type: 'manual',
|
||||
operator: '管理员',
|
||||
startTime: '2024-12-29T10:00:00Z',
|
||||
endTime: '2024-12-29T10:05:32Z',
|
||||
duration: 332,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 15728640, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
config: {
|
||||
preScript: 'pm2 stop codeport',
|
||||
postScript: 'pm2 start codeport',
|
||||
backup: true,
|
||||
backupPath: '/opt/backups/codeport'
|
||||
},
|
||||
createdAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'deploy-002',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-002',
|
||||
serverName: '生产服务器-从',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
version: '1.2.0',
|
||||
previousVersion: '1.1.5',
|
||||
status: 'success',
|
||||
type: 'manual',
|
||||
operator: '管理员',
|
||||
startTime: '2024-12-29T10:06:00Z',
|
||||
endTime: '2024-12-29T10:10:15Z',
|
||||
duration: 255,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 15728640, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
config: {
|
||||
backup: true
|
||||
},
|
||||
createdAt: '2024-12-29T10:06:00Z'
|
||||
},
|
||||
{
|
||||
id: 'deploy-003',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-003',
|
||||
serverName: '测试服务器',
|
||||
serverIp: '192.168.1.100',
|
||||
version: '1.2.1-beta',
|
||||
previousVersion: '1.2.0',
|
||||
status: 'deploying',
|
||||
type: 'auto',
|
||||
operator: 'CI/CD',
|
||||
startTime: '2024-12-29T12:00:00Z',
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 15890432, path: '/opt/apps/codeport' }
|
||||
],
|
||||
logs: [
|
||||
'开始部署...',
|
||||
'正在上传文件...',
|
||||
'文件上传完成',
|
||||
'正在解压文件...',
|
||||
'解压完成',
|
||||
'正在执行前置脚本...'
|
||||
],
|
||||
createdAt: '2024-12-29T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'deploy-004',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
version: '1.1.4',
|
||||
previousVersion: '1.1.3',
|
||||
status: 'failed',
|
||||
type: 'manual',
|
||||
operator: '张三',
|
||||
startTime: '2024-12-28T15:00:00Z',
|
||||
endTime: '2024-12-28T15:02:45Z',
|
||||
duration: 165,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 14523648, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
errorMessage: '前置脚本执行失败:pm2 进程不存在',
|
||||
createdAt: '2024-12-28T15:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'deploy-005',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
version: '1.1.3',
|
||||
previousVersion: '1.1.5',
|
||||
status: 'rollback',
|
||||
type: 'manual',
|
||||
operator: '管理员',
|
||||
startTime: '2024-12-27T18:00:00Z',
|
||||
endTime: '2024-12-27T18:03:00Z',
|
||||
duration: 180,
|
||||
files: [],
|
||||
createdAt: '2024-12-27T18:00:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
export const mockDeployConfigs: DeployConfig[] = [
|
||||
{
|
||||
id: 'config-001',
|
||||
projectId: 'codePort',
|
||||
serverId: 'server-001',
|
||||
deployPath: '/opt/1panel/www/sites/codeport/index',
|
||||
preScript: 'pm2 stop codeport || true',
|
||||
postScript: 'pm2 start codeport',
|
||||
backup: true,
|
||||
backupPath: '/opt/backups/codeport',
|
||||
backupCount: 5,
|
||||
autoRestart: true,
|
||||
healthCheck: {
|
||||
enabled: true,
|
||||
url: 'https://codeport.example.com/api/health',
|
||||
timeout: 30,
|
||||
retries: 3
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 日志中心 Mock 数据 ====================
|
||||
|
||||
export const mockLogs: LogRecord[] = [
|
||||
{
|
||||
id: 'log-001',
|
||||
type: 'operation',
|
||||
level: 'info',
|
||||
module: '项目管理',
|
||||
action: '创建项目',
|
||||
content: '创建了新项目:CodePort 码头',
|
||||
operator: '管理员',
|
||||
operatorIp: '192.168.1.1',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
createdAt: '2024-12-29T12:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'log-002',
|
||||
type: 'deploy',
|
||||
level: 'info',
|
||||
module: '部署管理',
|
||||
action: '部署成功',
|
||||
content: '项目 CodePort 码头 v1.2.0 部署成功',
|
||||
operator: '管理员',
|
||||
operatorIp: '192.168.1.1',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
duration: 332000,
|
||||
createdAt: '2024-12-29T10:05:32Z'
|
||||
},
|
||||
{
|
||||
id: 'log-003',
|
||||
type: 'error',
|
||||
level: 'error',
|
||||
module: '部署管理',
|
||||
action: '部署失败',
|
||||
content: '项目 CodePort 码头 v1.1.4 部署失败:前置脚本执行失败',
|
||||
operator: '张三',
|
||||
operatorIp: '192.168.1.2',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
createdAt: '2024-12-28T15:02:45Z',
|
||||
metadata: {
|
||||
errorCode: 'SCRIPT_FAILED',
|
||||
errorDetail: 'pm2 进程不存在'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'log-004',
|
||||
type: 'access',
|
||||
level: 'info',
|
||||
module: '用户认证',
|
||||
action: '用户登录',
|
||||
content: '用户 管理员 登录成功',
|
||||
operator: '管理员',
|
||||
operatorIp: '192.168.1.1',
|
||||
createdAt: '2024-12-29T08:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'log-005',
|
||||
type: 'system',
|
||||
level: 'warn',
|
||||
module: '服务器监控',
|
||||
action: '资源告警',
|
||||
content: '服务器 开发服务器 CPU 使用率超过 80%',
|
||||
serverId: 'server-004',
|
||||
serverName: '开发服务器',
|
||||
createdAt: '2024-12-29T09:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'log-006',
|
||||
type: 'operation',
|
||||
level: 'info',
|
||||
module: '证书管理',
|
||||
action: '申请证书',
|
||||
content: '为域名 codeport.example.com 申请了 SSL 证书',
|
||||
operator: '管理员',
|
||||
operatorIp: '192.168.1.1',
|
||||
createdAt: '2024-12-28T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'log-007',
|
||||
type: 'system',
|
||||
level: 'error',
|
||||
module: '服务器监控',
|
||||
action: '服务器离线',
|
||||
content: '服务器 备份服务器 已离线',
|
||||
serverId: 'server-005',
|
||||
serverName: '备份服务器',
|
||||
createdAt: '2024-12-20T00:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'log-008',
|
||||
type: 'deploy',
|
||||
level: 'info',
|
||||
module: '部署管理',
|
||||
action: '版本回退',
|
||||
content: '项目 CodePort 码头 从 v1.1.5 回退到 v1.1.3',
|
||||
operator: '管理员',
|
||||
operatorIp: '192.168.1.1',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
createdAt: '2024-12-27T18:03:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 域名管理 Mock 数据 ====================
|
||||
|
||||
export const mockDomains: DomainInfo[] = [
|
||||
{
|
||||
id: 'domain-001',
|
||||
domain: 'codeport.nanxiislet.com',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
status: 'active',
|
||||
dnsStatus: 'resolved',
|
||||
dnsRecords: [
|
||||
{ type: 'A', value: '47.108.xxx.xxx' }
|
||||
],
|
||||
sslStatus: 'valid',
|
||||
sslExpireDate: '2025-03-18',
|
||||
certificateId: 'cert-001',
|
||||
certificateName: 'codeport.nanxiislet.com',
|
||||
nginxConfigPath: '/opt/1panel/www/sites/codeport/nginx.conf',
|
||||
proxyPass: 'http://127.0.0.1:3000',
|
||||
port: 443,
|
||||
enableHttps: true,
|
||||
forceHttps: true,
|
||||
description: 'CodePort 主站域名',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'domain-002',
|
||||
domain: 'api.codeport.nanxiislet.com',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
status: 'active',
|
||||
dnsStatus: 'resolved',
|
||||
dnsRecords: [
|
||||
{ type: 'A', value: '47.108.xxx.xxx' }
|
||||
],
|
||||
sslStatus: 'valid',
|
||||
sslExpireDate: '2025-03-18',
|
||||
certificateId: 'cert-002',
|
||||
proxyPass: 'http://127.0.0.1:3001',
|
||||
port: 443,
|
||||
enableHttps: true,
|
||||
forceHttps: true,
|
||||
description: 'CodePort API 域名',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'domain-003',
|
||||
domain: 'admin.nanxiislet.com',
|
||||
projectId: undefined,
|
||||
projectName: undefined,
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
serverIp: '47.108.xxx.xxx',
|
||||
status: 'active',
|
||||
dnsStatus: 'resolved',
|
||||
sslStatus: 'expiring',
|
||||
sslExpireDate: '2025-01-15',
|
||||
certificateId: 'cert-003',
|
||||
proxyPass: 'http://127.0.0.1:8080',
|
||||
port: 443,
|
||||
enableHttps: true,
|
||||
forceHttps: true,
|
||||
description: '管理后台',
|
||||
createdAt: '2024-03-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'domain-004',
|
||||
domain: 'test.nanxiislet.com',
|
||||
serverId: 'server-003',
|
||||
serverName: '测试服务器',
|
||||
serverIp: '192.168.1.100',
|
||||
status: 'pending',
|
||||
dnsStatus: 'unresolved',
|
||||
sslStatus: 'none',
|
||||
port: 80,
|
||||
enableHttps: false,
|
||||
forceHttps: false,
|
||||
description: '测试域名',
|
||||
createdAt: '2024-12-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'domain-005',
|
||||
domain: 'old.nanxiislet.com',
|
||||
status: 'expired',
|
||||
dnsStatus: 'unresolved',
|
||||
sslStatus: 'expired',
|
||||
sslExpireDate: '2024-06-01',
|
||||
enableHttps: true,
|
||||
forceHttps: false,
|
||||
description: '已废弃的旧域名',
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
updatedAt: '2024-06-01T00:00:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 环境配置 Mock 数据 ====================
|
||||
|
||||
export const mockEnvironments: EnvironmentInfo[] = [
|
||||
{
|
||||
id: 'env-001',
|
||||
name: '开发环境',
|
||||
type: 'development',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-004',
|
||||
serverName: '开发服务器',
|
||||
description: '本地开发使用的环境配置',
|
||||
variables: [
|
||||
{ id: 'var-001', key: 'NODE_ENV', value: 'development', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-002', key: 'API_BASE_URL', value: 'http://localhost:3001', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-003', key: 'DATABASE_URL', value: 'mysql://dev:****@localhost:3306/codeport_dev', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-004', key: 'REDIS_URL', value: 'redis://localhost:6379', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-005', key: 'JWT_SECRET', value: '********', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'env-002',
|
||||
name: '测试环境',
|
||||
type: 'testing',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-003',
|
||||
serverName: '测试服务器',
|
||||
description: '测试使用的环境配置',
|
||||
variables: [
|
||||
{ id: 'var-006', key: 'NODE_ENV', value: 'testing', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-007', key: 'API_BASE_URL', value: 'http://test.nanxiislet.com/api', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-008', key: 'DATABASE_URL', value: 'mysql://test:****@192.168.1.100:3306/codeport_test', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-009', key: 'REDIS_URL', value: 'redis://192.168.1.100:6379', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-010', key: 'JWT_SECRET', value: '********', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
},
|
||||
{
|
||||
id: 'env-003',
|
||||
name: '生产环境',
|
||||
type: 'production',
|
||||
projectId: 'codePort',
|
||||
projectName: 'CodePort 码头',
|
||||
serverId: 'server-001',
|
||||
serverName: '生产服务器-主',
|
||||
description: '生产环境配置',
|
||||
variables: [
|
||||
{ id: 'var-011', key: 'NODE_ENV', value: 'production', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-012', key: 'API_BASE_URL', value: 'https://api.codeport.nanxiislet.com', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-013', key: 'DATABASE_URL', value: 'mysql://prod:****@rds.example.com:3306/codeport_prod', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-014', key: 'REDIS_URL', value: 'redis://redis.example.com:6379', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-015', key: 'JWT_SECRET', value: '********', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-016', key: 'CDN_URL', value: 'https://cdn.nanxiislet.com', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-017', key: 'OSS_BUCKET', value: 'nanxiislet-prod', isSecret: false, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' },
|
||||
{ id: 'var-018', key: 'OSS_ACCESS_KEY', value: '********', isSecret: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }
|
||||
],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-12-29T10:00:00Z'
|
||||
}
|
||||
]
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
export function getServerById(id: string): ServerInfo | undefined {
|
||||
return mockServers.find(s => s.id === id)
|
||||
}
|
||||
|
||||
export function getDeployRecordsByProject(projectId: string): DeployRecord[] {
|
||||
return mockDeployRecords.filter(d => d.projectId === projectId)
|
||||
}
|
||||
|
||||
export function getLogsByFilter(filter: Partial<{
|
||||
type: string
|
||||
level: string
|
||||
projectId: string
|
||||
serverId: string
|
||||
}>): LogRecord[] {
|
||||
return mockLogs.filter(log => {
|
||||
if (filter.type && log.type !== filter.type) return false
|
||||
if (filter.level && log.level !== filter.level) return false
|
||||
if (filter.projectId && log.projectId !== filter.projectId) return false
|
||||
if (filter.serverId && log.serverId !== filter.serverId) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function getDomainsByServer(serverId: string): DomainInfo[] {
|
||||
return mockDomains.filter(d => d.serverId === serverId)
|
||||
}
|
||||
|
||||
export function getEnvironmentsByProject(projectId: string): EnvironmentInfo[] {
|
||||
return mockEnvironments.filter(e => e.projectId === projectId)
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export interface ProjectVersion {
|
||||
description: string // 版本描述
|
||||
createdAt: string // 创建时间
|
||||
createdBy: string // 创建人
|
||||
isCurrent?: boolean // 是否是当前运行版本
|
||||
// 版本关联的文件列表
|
||||
files?: { name: string; size: number; path?: string }[]
|
||||
// 版本快照数据
|
||||
snapshot: {
|
||||
name: string
|
||||
@@ -151,11 +154,52 @@ export const mockProjects: PlatformProject[] = [
|
||||
currentVersion: '1.0.0',
|
||||
versions: [
|
||||
{
|
||||
id: 'v1',
|
||||
id: 'v1.2.0',
|
||||
version: '1.2.0',
|
||||
description: '新增了客服系统和会话管理模块',
|
||||
createdAt: '2024-03-15T10:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: true,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 5242880, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v1.1.0',
|
||||
version: '1.1.0',
|
||||
description: '优化了用户管理和项目列表',
|
||||
createdAt: '2024-02-01T10:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: false,
|
||||
files: [
|
||||
{ name: 'dist.zip', size: 4718592, path: '/opt/1panel/www/sites/codeport/index' }
|
||||
],
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
logo: '码',
|
||||
color: '#1890ff',
|
||||
description: '人才外包平台管理后台',
|
||||
baseUrl: 'http://localhost:5174',
|
||||
menus: codePortMenus
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'v1.0.0',
|
||||
version: '1.0.0',
|
||||
description: '初始版本',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
createdBy: '管理员',
|
||||
isCurrent: false,
|
||||
snapshot: {
|
||||
name: 'CodePort 码头',
|
||||
shortName: 'CodePort',
|
||||
|
||||
@@ -157,6 +157,42 @@ const routes: RouteRecordRaw[] = [
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/servers',
|
||||
name: 'PlatformServers',
|
||||
component: () => import('@/views/platform/servers/index.vue'),
|
||||
meta: {
|
||||
title: '服务器管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/log-center',
|
||||
name: 'PlatformLogCenter',
|
||||
component: () => import('@/views/platform/log-center/index.vue'),
|
||||
meta: {
|
||||
title: '日志中心',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/domains',
|
||||
name: 'PlatformDomains',
|
||||
component: () => import('@/views/platform/domains/index.vue'),
|
||||
meta: {
|
||||
title: '域名管理',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'platform/environments',
|
||||
name: 'PlatformEnvironments',
|
||||
component: () => import('@/views/platform/environments/index.vue'),
|
||||
meta: {
|
||||
title: '环境配置',
|
||||
requiresAuth: true
|
||||
}
|
||||
},
|
||||
|
||||
// 系统设置
|
||||
{
|
||||
|
||||
@@ -263,6 +263,36 @@ export const useProjectStore = defineStore('project', () => {
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 为指定版本添加文件
|
||||
*/
|
||||
function addFilesToVersion(
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
files: { name: string; size: number }[]
|
||||
): boolean {
|
||||
const project = projects.value.find(p => p.id === projectId)
|
||||
if (!project || !project.versions) return false
|
||||
|
||||
const version = project.versions.find(v => v.id === versionId)
|
||||
if (!version) return false
|
||||
|
||||
// 初始化 files 数组
|
||||
if (!version.files) {
|
||||
version.files = []
|
||||
}
|
||||
|
||||
// 添加新文件(避免重复)
|
||||
for (const file of files) {
|
||||
const exists = version.files.some(f => f.name === file.name)
|
||||
if (!exists) {
|
||||
version.files.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
projects,
|
||||
@@ -285,6 +315,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||
rollbackToVersion,
|
||||
getProjectVersions,
|
||||
deleteVersion,
|
||||
addFilesToVersion,
|
||||
// 菜单路由方法
|
||||
getMenuRoutePath,
|
||||
getMenuRouteMap
|
||||
|
||||
223
src/types/platform.ts
Normal file
223
src/types/platform.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 平台管理模块类型定义
|
||||
*/
|
||||
|
||||
// ==================== 服务器管理 ====================
|
||||
|
||||
export type ServerStatus = 'online' | 'offline' | 'warning' | 'maintenance'
|
||||
export type ServerType = 'physical' | 'virtual' | 'cloud'
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string
|
||||
name: string
|
||||
ip: string
|
||||
internalIp?: string
|
||||
port: number
|
||||
type: ServerType
|
||||
status: ServerStatus
|
||||
os: string
|
||||
cpu: {
|
||||
cores: number
|
||||
usage: number // 百分比
|
||||
}
|
||||
memory: {
|
||||
total: number // GB
|
||||
used: number // GB
|
||||
usage: number // 百分比
|
||||
}
|
||||
disk: {
|
||||
total: number // GB
|
||||
used: number // GB
|
||||
usage: number // 百分比
|
||||
}
|
||||
tags: string[]
|
||||
sshUser?: string
|
||||
sshPort?: number
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
// 1Panel 信息
|
||||
panelUrl?: string
|
||||
panelPort?: number
|
||||
}
|
||||
|
||||
// ==================== 部署管理 ====================
|
||||
|
||||
export type DeployStatus = 'pending' | 'deploying' | 'success' | 'failed' | 'rollback'
|
||||
export type DeployType = 'manual' | 'auto' | 'webhook'
|
||||
|
||||
export interface DeployRecord {
|
||||
id: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
serverId: string
|
||||
serverName: string
|
||||
serverIp: string
|
||||
version: string
|
||||
previousVersion?: string
|
||||
status: DeployStatus
|
||||
type: DeployType
|
||||
operator: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
duration?: number // 秒
|
||||
logs?: string[]
|
||||
files: {
|
||||
name: string
|
||||
size: number
|
||||
path?: string
|
||||
}[]
|
||||
config?: {
|
||||
preScript?: string
|
||||
postScript?: string
|
||||
backup: boolean
|
||||
backupPath?: string
|
||||
}
|
||||
errorMessage?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DeployConfig {
|
||||
id: string
|
||||
projectId: string
|
||||
serverId: string
|
||||
deployPath: string
|
||||
preScript?: string
|
||||
postScript?: string
|
||||
backup: boolean
|
||||
backupPath?: string
|
||||
backupCount: number
|
||||
autoRestart: boolean
|
||||
healthCheck?: {
|
||||
enabled: boolean
|
||||
url: string
|
||||
timeout: number
|
||||
retries: number
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 日志中心 ====================
|
||||
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'
|
||||
export type LogType = 'operation' | 'deploy' | 'access' | 'error' | 'system'
|
||||
|
||||
export interface LogRecord {
|
||||
id: string
|
||||
type: LogType
|
||||
level: LogLevel
|
||||
module: string
|
||||
action: string
|
||||
content: string
|
||||
operator?: string
|
||||
operatorIp?: string
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
serverId?: string
|
||||
serverName?: string
|
||||
requestId?: string
|
||||
duration?: number
|
||||
createdAt: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface LogFilter {
|
||||
type?: LogType
|
||||
level?: LogLevel
|
||||
module?: string
|
||||
operator?: string
|
||||
projectId?: string
|
||||
serverId?: string
|
||||
keyword?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
|
||||
// ==================== 域名管理 ====================
|
||||
|
||||
export type DomainStatus = 'active' | 'pending' | 'expired' | 'error'
|
||||
export type DnsStatus = 'resolved' | 'unresolved' | 'checking'
|
||||
export type SslStatus = 'valid' | 'expiring' | 'expired' | 'none'
|
||||
|
||||
export interface DomainInfo {
|
||||
id: string
|
||||
domain: string
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
serverId?: string
|
||||
serverName?: string
|
||||
serverIp?: string
|
||||
status: DomainStatus
|
||||
dnsStatus: DnsStatus
|
||||
dnsRecords?: {
|
||||
type: string // A, CNAME, etc.
|
||||
value: string
|
||||
}[]
|
||||
sslStatus: SslStatus
|
||||
sslExpireDate?: string
|
||||
certificateId?: string
|
||||
certificateName?: string
|
||||
nginxConfigPath?: string
|
||||
proxyPass?: string
|
||||
port?: number
|
||||
enableHttps: boolean
|
||||
forceHttps: boolean
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface NginxConfig {
|
||||
serverName: string
|
||||
listen: number
|
||||
listenSsl?: number
|
||||
root?: string
|
||||
proxyPass?: string
|
||||
sslCertificate?: string
|
||||
sslCertificateKey?: string
|
||||
locations?: {
|
||||
path: string
|
||||
proxyPass?: string
|
||||
root?: string
|
||||
index?: string
|
||||
tryFiles?: string
|
||||
}[]
|
||||
}
|
||||
|
||||
// ==================== 环境配置 ====================
|
||||
|
||||
export type EnvironmentType = 'development' | 'testing' | 'staging' | 'production'
|
||||
|
||||
export interface EnvironmentInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: EnvironmentType
|
||||
projectId?: string
|
||||
projectName?: string
|
||||
serverId?: string
|
||||
serverName?: string
|
||||
description?: string
|
||||
variables: EnvironmentVariable[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EnvironmentVariable {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
isSecret: boolean
|
||||
description?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface EnvironmentCompare {
|
||||
key: string
|
||||
environments: {
|
||||
envId: string
|
||||
envName: string
|
||||
value?: string
|
||||
exists: boolean
|
||||
}[]
|
||||
isDifferent: boolean
|
||||
}
|
||||
857
src/views/platform/domains/index.vue
Normal file
857
src/views/platform/domains/index.vue
Normal file
@@ -0,0 +1,857 @@
|
||||
<template>
|
||||
<div class="platform-domains-page">
|
||||
<a-page-header title="域名管理" sub-title="管理平台域名与 Nginx 配置" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline">
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="filterStatus" placeholder="全部" style="width: 120px" allow-clear>
|
||||
<a-select-option value="active">正常</a-select-option>
|
||||
<a-select-option value="pending">待配置</a-select-option>
|
||||
<a-select-option value="expired">已过期</a-select-option>
|
||||
<a-select-option value="error">异常</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="SSL 状态">
|
||||
<a-select v-model:value="filterSslStatus" placeholder="全部" style="width: 120px" allow-clear>
|
||||
<a-select-option value="valid">正常</a-select-option>
|
||||
<a-select-option value="expiring">即将过期</a-select-option>
|
||||
<a-select-option value="expired">已过期</a-select-option>
|
||||
<a-select-option value="none">未配置</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="服务器">
|
||||
<a-select v-model:value="filterServer" placeholder="全部" style="width: 150px" allow-clear>
|
||||
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
|
||||
{{ server.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model:value="filterKeyword" placeholder="搜索域名" style="width: 180px" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 添加域名
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-active">
|
||||
<GlobalOutlined class="stat-icon" />
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.active }}</div>
|
||||
<div class="stat-label">正常</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-ssl-warning">
|
||||
<SafetyCertificateOutlined class="stat-icon" />
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.sslExpiring }}</div>
|
||||
<div class="stat-label">证书即将过期</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-pending">
|
||||
<ClockCircleOutlined class="stat-icon" />
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
<div class="stat-label">待配置</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-total">
|
||||
<AppstoreOutlined class="stat-icon" />
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总计</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 域名列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredDomains"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:pagination="{ pageSize: 10, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'domain'">
|
||||
<div class="domain-info">
|
||||
<div class="domain-name">
|
||||
<a :href="`${record.enableHttps ? 'https' : 'http'}://${record.domain}`" target="_blank">
|
||||
{{ record.domain }}
|
||||
<LinkOutlined />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="record.projectName" class="domain-project">
|
||||
<TagOutlined /> {{ record.projectName }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-space direction="vertical" :size="4">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
<a-tag :color="getDnsStatusColor(record.dnsStatus)">
|
||||
<template #icon>
|
||||
<CheckCircleOutlined v-if="record.dnsStatus === 'resolved'" />
|
||||
<CloseCircleOutlined v-else-if="record.dnsStatus === 'unresolved'" />
|
||||
<SyncOutlined v-else spin />
|
||||
</template>
|
||||
DNS: {{ getDnsStatusText(record.dnsStatus) }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'ssl'">
|
||||
<div class="ssl-info">
|
||||
<a-tag :color="getSslStatusColor(record.sslStatus)">
|
||||
<template #icon>
|
||||
<SafetyCertificateOutlined />
|
||||
</template>
|
||||
{{ getSslStatusText(record.sslStatus) }}
|
||||
</a-tag>
|
||||
<div v-if="record.sslExpireDate" class="ssl-expire">
|
||||
过期:{{ record.sslExpireDate }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'server'">
|
||||
<div v-if="record.serverName" class="server-info">
|
||||
<div>{{ record.serverName }}</div>
|
||||
<div class="server-ip">{{ record.serverIp }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">未绑定</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'proxy'">
|
||||
<div v-if="record.proxyPass" class="proxy-info">
|
||||
<code>{{ record.proxyPass }}</code>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleViewNginx(record)">
|
||||
<FileTextOutlined /> Nginx
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleCheckDns(record)">
|
||||
<ReloadOutlined /> 检测
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除此域名配置?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加/编辑域名抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="drawerVisible"
|
||||
:title="currentDomain ? '编辑域名' : '添加域名'"
|
||||
width="600"
|
||||
placement="right"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 17 }"
|
||||
>
|
||||
<a-form-item label="域名" name="domain">
|
||||
<a-input v-model:value="formData.domain" placeholder="如:www.example.com" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="关联项目" name="projectId">
|
||||
<a-select v-model:value="formData.projectId" placeholder="选择关联项目(选填)" allow-clear>
|
||||
<a-select-option v-for="project in projectList" :key="project.id" :value="project.id">
|
||||
{{ project.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="目标服务器" name="serverId">
|
||||
<a-select v-model:value="formData.serverId" placeholder="请选择服务器">
|
||||
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
|
||||
{{ server.name }} ({{ server.ip }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">代理配置</a-divider>
|
||||
|
||||
<a-form-item label="反向代理" name="proxyPass">
|
||||
<a-input v-model:value="formData.proxyPass" placeholder="如:http://127.0.0.1:3000" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="监听端口" name="port">
|
||||
<a-input-number v-model:value="formData.port" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">SSL 配置</a-divider>
|
||||
|
||||
<a-form-item label="启用 HTTPS">
|
||||
<a-switch v-model:checked="formData.enableHttps" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="formData.enableHttps" label="强制 HTTPS">
|
||||
<a-switch v-model:checked="formData.forceHttps" />
|
||||
<span class="form-help">开启后将自动跳转 HTTPS</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="formData.enableHttps" label="SSL 证书" name="certificateId">
|
||||
<a-select v-model:value="formData.certificateId" placeholder="请选择 SSL 证书">
|
||||
<a-select-option v-for="cert in certificateList" :key="cert.id" :value="cert.id">
|
||||
{{ cert.domain }} (过期:{{ cert.expireDate }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="form-help">
|
||||
没有合适的证书?
|
||||
<a-button type="link" size="small" @click="goToCertificates">前往申请</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">其他</a-divider>
|
||||
|
||||
<a-form-item label="备注" name="description">
|
||||
<a-textarea v-model:value="formData.description" :rows="3" placeholder="域名用途描述(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="formLoading" @click="handleFormSubmit">
|
||||
{{ currentDomain ? '保存' : '添加' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- Nginx 配置抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="nginxDrawerVisible"
|
||||
title="Nginx 配置"
|
||||
width="700"
|
||||
placement="right"
|
||||
>
|
||||
<template v-if="currentDomain">
|
||||
<a-descriptions :column="2" size="small" bordered style="margin-bottom: 16px">
|
||||
<a-descriptions-item label="域名" :span="2">{{ currentDomain.domain }}</a-descriptions-item>
|
||||
<a-descriptions-item label="配置路径" :span="2">
|
||||
<code>{{ currentDomain.nginxConfigPath || '/etc/nginx/sites-available/' + currentDomain.domain }}</code>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="nginx-config-box">
|
||||
<div class="config-header">
|
||||
<span>nginx.conf</span>
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="copyNginxConfig">
|
||||
<CopyOutlined /> 复制
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="downloadNginxConfig">
|
||||
<DownloadOutlined /> 下载
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<pre class="config-content">{{ generateNginxConfig(currentDomain) }}</pre>
|
||||
</div>
|
||||
|
||||
<a-divider />
|
||||
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handlePushConfig">
|
||||
<CloudUploadOutlined /> 推送到服务器
|
||||
</a-button>
|
||||
<a-button @click="handleReloadNginx">
|
||||
<ReloadOutlined /> 重载 Nginx
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
LinkOutlined,
|
||||
TagOutlined,
|
||||
GlobalOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ClockCircleOutlined,
|
||||
AppstoreOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
SyncOutlined,
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
CopyOutlined,
|
||||
DownloadOutlined,
|
||||
CloudUploadOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { DomainInfo, DomainStatus, DnsStatus, SslStatus } from '@/types/platform'
|
||||
import { mockDomains, mockServers } from '@/mock/platform'
|
||||
import { mockProjects } from '@/mock/projects'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const nginxDrawerVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const filterStatus = ref<DomainStatus | undefined>()
|
||||
const filterSslStatus = ref<SslStatus | undefined>()
|
||||
const filterServer = ref<string | undefined>()
|
||||
const filterKeyword = ref('')
|
||||
|
||||
const domains = ref<DomainInfo[]>([])
|
||||
const currentDomain = ref<DomainInfo | null>(null)
|
||||
|
||||
const projectList = computed(() => mockProjects.map(p => ({ id: p.id, name: p.name })))
|
||||
const serverList = computed(() => mockServers.map(s => ({ id: s.id, name: s.name, ip: s.ip })))
|
||||
const certificateList = ref([
|
||||
{ id: 'cert-001', domain: 'codeport.nanxiislet.com', expireDate: '2025-03-18' },
|
||||
{ id: 'cert-002', domain: '*.nanxiislet.com', expireDate: '2025-03-18' }
|
||||
])
|
||||
|
||||
const formData = reactive({
|
||||
domain: '',
|
||||
projectId: '',
|
||||
serverId: '',
|
||||
proxyPass: '',
|
||||
port: 80,
|
||||
enableHttps: false,
|
||||
forceHttps: false,
|
||||
certificateId: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
domain: [{ required: true, message: '请输入域名' }],
|
||||
serverId: [{ required: true, message: '请选择服务器' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '域名', key: 'domain', width: 250 },
|
||||
{ title: '状态', key: 'status', width: 130 },
|
||||
{ title: 'SSL 证书', key: 'ssl', width: 150 },
|
||||
{ title: '服务器', key: 'server', width: 150 },
|
||||
{ title: '反向代理', key: 'proxy', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
const filteredDomains = computed(() => {
|
||||
let result = domains.value
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(d => d.status === filterStatus.value)
|
||||
}
|
||||
if (filterSslStatus.value) {
|
||||
result = result.filter(d => d.sslStatus === filterSslStatus.value)
|
||||
}
|
||||
if (filterServer.value) {
|
||||
result = result.filter(d => d.serverId === filterServer.value)
|
||||
}
|
||||
if (filterKeyword.value) {
|
||||
const keyword = filterKeyword.value.toLowerCase()
|
||||
result = result.filter(d => d.domain.toLowerCase().includes(keyword))
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = domains.value
|
||||
return {
|
||||
active: all.filter(d => d.status === 'active').length,
|
||||
sslExpiring: all.filter(d => d.sslStatus === 'expiring').length,
|
||||
pending: all.filter(d => d.status === 'pending').length,
|
||||
total: all.length
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.serverId) {
|
||||
filterServer.value = route.query.serverId as string
|
||||
}
|
||||
loadDomains()
|
||||
})
|
||||
|
||||
function loadDomains() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
domains.value = [...mockDomains]
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
// 筛选已通过 computed 实现
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterStatus.value = undefined
|
||||
filterSslStatus.value = undefined
|
||||
filterServer.value = undefined
|
||||
filterKeyword.value = ''
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentDomain.value = null
|
||||
Object.assign(formData, {
|
||||
domain: '',
|
||||
projectId: '',
|
||||
serverId: '',
|
||||
proxyPass: '',
|
||||
port: 80,
|
||||
enableHttps: false,
|
||||
forceHttps: false,
|
||||
certificateId: '',
|
||||
description: ''
|
||||
})
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: DomainInfo) {
|
||||
currentDomain.value = record
|
||||
Object.assign(formData, {
|
||||
domain: record.domain,
|
||||
projectId: record.projectId || '',
|
||||
serverId: record.serverId || '',
|
||||
proxyPass: record.proxyPass || '',
|
||||
port: record.port || 80,
|
||||
enableHttps: record.enableHttps,
|
||||
forceHttps: record.forceHttps,
|
||||
certificateId: record.certificateId || '',
|
||||
description: record.description || ''
|
||||
})
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
async function handleFormSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const project = projectList.value.find(p => p.id === formData.projectId)
|
||||
const server = serverList.value.find(s => s.id === formData.serverId)
|
||||
|
||||
if (currentDomain.value) {
|
||||
// 编辑
|
||||
const index = domains.value.findIndex(d => d.id === currentDomain.value!.id)
|
||||
if (index !== -1) {
|
||||
domains.value[index] = {
|
||||
...domains.value[index],
|
||||
domain: formData.domain,
|
||||
projectId: formData.projectId || undefined,
|
||||
projectName: project?.name,
|
||||
serverId: formData.serverId || undefined,
|
||||
serverName: server?.name,
|
||||
serverIp: server?.ip,
|
||||
proxyPass: formData.proxyPass,
|
||||
port: formData.port,
|
||||
enableHttps: formData.enableHttps,
|
||||
forceHttps: formData.forceHttps,
|
||||
certificateId: formData.certificateId || undefined,
|
||||
description: formData.description,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
message.success('域名配置已更新')
|
||||
} else {
|
||||
// 新增
|
||||
const newDomain: DomainInfo = {
|
||||
id: `domain-${Date.now()}`,
|
||||
domain: formData.domain,
|
||||
projectId: formData.projectId || undefined,
|
||||
projectName: project?.name,
|
||||
serverId: formData.serverId || undefined,
|
||||
serverName: server?.name,
|
||||
serverIp: server?.ip,
|
||||
status: 'pending',
|
||||
dnsStatus: 'checking',
|
||||
sslStatus: formData.enableHttps ? 'none' : 'none',
|
||||
proxyPass: formData.proxyPass,
|
||||
port: formData.port,
|
||||
enableHttps: formData.enableHttps,
|
||||
forceHttps: formData.forceHttps,
|
||||
certificateId: formData.certificateId || undefined,
|
||||
description: formData.description,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
domains.value.unshift(newDomain)
|
||||
message.success('域名添加成功')
|
||||
}
|
||||
|
||||
drawerVisible.value = false
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(record: DomainInfo) {
|
||||
domains.value = domains.value.filter(d => d.id !== record.id)
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
function handleViewNginx(record: DomainInfo) {
|
||||
currentDomain.value = record
|
||||
nginxDrawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleCheckDns(record: DomainInfo) {
|
||||
message.loading(`正在检测 ${record.domain} 的 DNS...`, 2)
|
||||
setTimeout(() => {
|
||||
const index = domains.value.findIndex(d => d.id === record.id)
|
||||
if (index !== -1) {
|
||||
domains.value[index].dnsStatus = 'resolved'
|
||||
}
|
||||
message.success('DNS 解析正常')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function goToCertificates() {
|
||||
router.push('/platform/certificates')
|
||||
}
|
||||
|
||||
function generateNginxConfig(domain: DomainInfo): string {
|
||||
let config = `server {\n`
|
||||
|
||||
if (domain.enableHttps && domain.forceHttps) {
|
||||
config += ` listen 80;\n`
|
||||
config += ` server_name ${domain.domain};\n`
|
||||
config += ` return 301 https://$server_name$request_uri;\n`
|
||||
config += `}\n\n`
|
||||
config += `server {\n`
|
||||
config += ` listen 443 ssl http2;\n`
|
||||
config += ` server_name ${domain.domain};\n\n`
|
||||
config += ` ssl_certificate /etc/nginx/ssl/${domain.domain}.pem;\n`
|
||||
config += ` ssl_certificate_key /etc/nginx/ssl/${domain.domain}.key;\n`
|
||||
config += ` ssl_protocols TLSv1.2 TLSv1.3;\n`
|
||||
config += ` ssl_ciphers HIGH:!aNULL:!MD5;\n\n`
|
||||
} else if (domain.enableHttps) {
|
||||
config += ` listen 443 ssl http2;\n`
|
||||
config += ` server_name ${domain.domain};\n\n`
|
||||
config += ` ssl_certificate /etc/nginx/ssl/${domain.domain}.pem;\n`
|
||||
config += ` ssl_certificate_key /etc/nginx/ssl/${domain.domain}.key;\n\n`
|
||||
} else {
|
||||
config += ` listen ${domain.port || 80};\n`
|
||||
config += ` server_name ${domain.domain};\n\n`
|
||||
}
|
||||
|
||||
if (domain.proxyPass) {
|
||||
config += ` location / {\n`
|
||||
config += ` proxy_pass ${domain.proxyPass};\n`
|
||||
config += ` proxy_set_header Host $host;\n`
|
||||
config += ` proxy_set_header X-Real-IP $remote_addr;\n`
|
||||
config += ` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n`
|
||||
config += ` proxy_set_header X-Forwarded-Proto $scheme;\n`
|
||||
config += ` }\n`
|
||||
} else {
|
||||
config += ` root /var/www/${domain.domain};\n`
|
||||
config += ` index index.html index.htm;\n\n`
|
||||
config += ` location / {\n`
|
||||
config += ` try_files $uri $uri/ /index.html;\n`
|
||||
config += ` }\n`
|
||||
}
|
||||
|
||||
config += `}\n`
|
||||
return config
|
||||
}
|
||||
|
||||
function copyNginxConfig() {
|
||||
if (currentDomain.value) {
|
||||
navigator.clipboard.writeText(generateNginxConfig(currentDomain.value))
|
||||
message.success('配置已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
function downloadNginxConfig() {
|
||||
if (currentDomain.value) {
|
||||
const config = generateNginxConfig(currentDomain.value)
|
||||
const blob = new Blob([config], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${currentDomain.value.domain}.conf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('配置文件已下载')
|
||||
}
|
||||
}
|
||||
|
||||
function handlePushConfig() {
|
||||
message.loading('正在推送配置到服务器...', 2)
|
||||
setTimeout(() => {
|
||||
message.success('配置推送成功')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function handleReloadNginx() {
|
||||
message.loading('正在重载 Nginx...', 2)
|
||||
setTimeout(() => {
|
||||
message.success('Nginx 重载成功')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function getStatusColor(status: DomainStatus): string {
|
||||
const colors: Record<DomainStatus, string> = {
|
||||
active: 'green',
|
||||
pending: 'orange',
|
||||
expired: 'red',
|
||||
error: 'red'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
function getStatusText(status: DomainStatus): string {
|
||||
const texts: Record<DomainStatus, string> = {
|
||||
active: '正常',
|
||||
pending: '待配置',
|
||||
expired: '已过期',
|
||||
error: '异常'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
function getDnsStatusColor(status: DnsStatus): string {
|
||||
const colors: Record<DnsStatus, string> = {
|
||||
resolved: 'green',
|
||||
unresolved: 'red',
|
||||
checking: 'blue'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
function getDnsStatusText(status: DnsStatus): string {
|
||||
const texts: Record<DnsStatus, string> = {
|
||||
resolved: '已解析',
|
||||
unresolved: '未解析',
|
||||
checking: '检测中'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
function getSslStatusColor(status: SslStatus): string {
|
||||
const colors: Record<SslStatus, string> = {
|
||||
valid: 'green',
|
||||
expiring: 'orange',
|
||||
expired: 'red',
|
||||
none: 'default'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
function getSslStatusText(status: SslStatus): string {
|
||||
const texts: Record<SslStatus, string> = {
|
||||
valid: '正常',
|
||||
expiring: '即将过期',
|
||||
expired: '已过期',
|
||||
none: '未配置'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-domains-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 32px;
|
||||
margin-right: 16px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-active {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
|
||||
}
|
||||
|
||||
.stat-ssl-warning {
|
||||
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
|
||||
}
|
||||
|
||||
.stat-pending {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
background: linear-gradient(135deg, #722ed1 0%, #531dab 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.domain-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.domain-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.domain-name a {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.domain-project {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.ssl-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.ssl-expire {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.server-ip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.proxy-info code {
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nginx-config-box {
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-header :deep(.ant-btn-link) {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
902
src/views/platform/environments/index.vue
Normal file
902
src/views/platform/environments/index.vue
Normal file
@@ -0,0 +1,902 @@
|
||||
<template>
|
||||
<div class="platform-environments-page">
|
||||
<a-page-header title="环境配置" sub-title="管理项目不同环境的配置变量" />
|
||||
|
||||
<a-row :gutter="16">
|
||||
<!-- 左侧:环境列表 -->
|
||||
<a-col :span="8">
|
||||
<a-card title="环境列表" :bordered="false">
|
||||
<template #extra>
|
||||
<a-button type="primary" size="small" @click="handleAddEnv">
|
||||
<PlusOutlined /> 新建
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="env-list">
|
||||
<div
|
||||
v-for="env in filteredEnvironments"
|
||||
:key="env.id"
|
||||
:class="['env-item', { active: currentEnv?.id === env.id }]"
|
||||
@click="selectEnv(env)"
|
||||
>
|
||||
<div class="env-header">
|
||||
<div class="env-name">
|
||||
<a-tag :color="getEnvTypeColor(env.type)">{{ getEnvTypeText(env.type) }}</a-tag>
|
||||
{{ env.name }}
|
||||
</div>
|
||||
<a-dropdown :trigger="['click']" @click.stop>
|
||||
<a-button type="text" size="small">
|
||||
<MoreOutlined />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item @click="handleEditEnv(env)">
|
||||
<EditOutlined /> 编辑
|
||||
</a-menu-item>
|
||||
<a-menu-item @click="handleCopyEnv(env)">
|
||||
<CopyOutlined /> 复制
|
||||
</a-menu-item>
|
||||
<a-menu-divider />
|
||||
<a-menu-item danger @click="handleDeleteEnv(env)">
|
||||
<DeleteOutlined /> 删除
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
<div class="env-meta">
|
||||
<span v-if="env.projectName">
|
||||
<TagOutlined /> {{ env.projectName }}
|
||||
</span>
|
||||
<span v-if="env.serverName">
|
||||
<CloudServerOutlined /> {{ env.serverName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="env-stats">
|
||||
<span>{{ env.variables.length }} 个变量</span>
|
||||
<span>{{ env.variables.filter(v => v.isSecret).length }} 个敏感变量</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-empty v-if="filteredEnvironments.length === 0" description="暂无环境配置" />
|
||||
</div>
|
||||
</a-spin>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="env-filter">
|
||||
<a-select
|
||||
v-model:value="filterProject"
|
||||
placeholder="按项目筛选"
|
||||
style="width: 100%"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option v-for="project in projectList" :key="project.id" :value="project.id">
|
||||
{{ project.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
|
||||
<!-- 右侧:环境变量配置 -->
|
||||
<a-col :span="16">
|
||||
<a-card :bordered="false">
|
||||
<template #title>
|
||||
<template v-if="currentEnv">
|
||||
<a-tag :color="getEnvTypeColor(currentEnv.type)">{{ getEnvTypeText(currentEnv.type) }}</a-tag>
|
||||
{{ currentEnv.name }}
|
||||
<span v-if="currentEnv.projectName" class="env-project-badge">
|
||||
- {{ currentEnv.projectName }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
选择环境
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #extra v-if="currentEnv">
|
||||
<a-space>
|
||||
<a-button @click="handleCompare">
|
||||
<SwapOutlined /> 对比
|
||||
</a-button>
|
||||
<a-button @click="handleExport">
|
||||
<DownloadOutlined /> 导出
|
||||
</a-button>
|
||||
<a-button @click="handleImport">
|
||||
<UploadOutlined /> 导入
|
||||
</a-button>
|
||||
<a-button type="primary" @click="handleAddVariable">
|
||||
<PlusOutlined /> 添加变量
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-if="currentEnv">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="var-search">
|
||||
<a-input-search
|
||||
v-model:value="varKeyword"
|
||||
placeholder="搜索变量名或值"
|
||||
style="width: 300px"
|
||||
allow-clear
|
||||
/>
|
||||
<a-checkbox v-model:checked="showSecrets">显示敏感值</a-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 变量列表 -->
|
||||
<a-table
|
||||
:columns="varColumns"
|
||||
:data-source="filteredVariables"
|
||||
row-key="id"
|
||||
:pagination="false"
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'key'">
|
||||
<code class="var-key">{{ record.key }}</code>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'value'">
|
||||
<div class="var-value">
|
||||
<template v-if="record.isSecret && !showSecrets">
|
||||
<span class="secret-mask">••••••••</span>
|
||||
<a-button type="link" size="small" @click="toggleShowValue(record)">
|
||||
<EyeOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<code>{{ record.value }}</code>
|
||||
<a-button type="link" size="small" @click="copyValue(record.value)">
|
||||
<CopyOutlined />
|
||||
</a-button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'secret'">
|
||||
<a-tag v-if="record.isSecret" color="red">
|
||||
<LockOutlined /> 敏感
|
||||
</a-tag>
|
||||
<a-tag v-else color="default">公开</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'description'">
|
||||
<span v-if="record.description">{{ record.description }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEditVariable(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除此变量?" @confirm="handleDeleteVariable(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</template>
|
||||
|
||||
<a-empty v-else description="请选择一个环境查看配置" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 新建/编辑环境抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="envDrawerVisible"
|
||||
:title="editingEnv ? '编辑环境' : '新建环境'"
|
||||
width="500"
|
||||
placement="right"
|
||||
>
|
||||
<a-form
|
||||
ref="envFormRef"
|
||||
:model="envFormData"
|
||||
:rules="envFormRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 17 }"
|
||||
>
|
||||
<a-form-item label="环境名称" name="name">
|
||||
<a-input v-model:value="envFormData.name" placeholder="如:开发环境" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="环境类型" name="type">
|
||||
<a-select v-model:value="envFormData.type" placeholder="请选择">
|
||||
<a-select-option value="development">开发环境</a-select-option>
|
||||
<a-select-option value="testing">测试环境</a-select-option>
|
||||
<a-select-option value="staging">预发布环境</a-select-option>
|
||||
<a-select-option value="production">生产环境</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="关联项目" name="projectId">
|
||||
<a-select v-model:value="envFormData.projectId" placeholder="选择关联项目" allow-clear>
|
||||
<a-select-option v-for="project in projectList" :key="project.id" :value="project.id">
|
||||
{{ project.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="目标服务器" name="serverId">
|
||||
<a-select v-model:value="envFormData.serverId" placeholder="选择服务器(选填)" allow-clear>
|
||||
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
|
||||
{{ server.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea v-model:value="envFormData.description" :rows="3" placeholder="环境描述(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="envDrawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="envFormLoading" @click="handleEnvFormSubmit">
|
||||
{{ editingEnv ? '保存' : '创建' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 变量编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="varModalVisible"
|
||||
:title="editingVar ? '编辑变量' : '添加变量'"
|
||||
@ok="handleVarModalSubmit"
|
||||
:confirm-loading="varModalLoading"
|
||||
>
|
||||
<a-form
|
||||
ref="varFormRef"
|
||||
:model="varFormData"
|
||||
:rules="varFormRules"
|
||||
:label-col="{ span: 5 }"
|
||||
:wrapper-col="{ span: 18 }"
|
||||
>
|
||||
<a-form-item label="变量名" name="key">
|
||||
<a-input
|
||||
v-model:value="varFormData.key"
|
||||
placeholder="如:DATABASE_URL"
|
||||
:disabled="!!editingVar"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="变量值" name="value">
|
||||
<a-input-password
|
||||
v-if="varFormData.isSecret"
|
||||
v-model:value="varFormData.value"
|
||||
placeholder="请输入变量值"
|
||||
/>
|
||||
<a-textarea
|
||||
v-else
|
||||
v-model:value="varFormData.value"
|
||||
:rows="3"
|
||||
placeholder="请输入变量值"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="敏感变量">
|
||||
<a-switch v-model:checked="varFormData.isSecret" />
|
||||
<span class="form-help">敏感变量的值将被加密存储</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-input v-model:value="varFormData.description" placeholder="变量用途描述(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 环境对比抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="compareDrawerVisible"
|
||||
title="环境对比"
|
||||
width="900"
|
||||
placement="right"
|
||||
>
|
||||
<div class="compare-header">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-select
|
||||
v-model:value="compareEnv1"
|
||||
placeholder="选择环境 1"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="env in environments" :key="env.id" :value="env.id">
|
||||
{{ env.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-select
|
||||
v-model:value="compareEnv2"
|
||||
placeholder="选择环境 2"
|
||||
style="width: 100%"
|
||||
>
|
||||
<a-select-option v-for="env in environments" :key="env.id" :value="env.id">
|
||||
{{ env.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
v-if="compareEnv1 && compareEnv2"
|
||||
:columns="compareColumns"
|
||||
:data-source="compareData"
|
||||
row-key="key"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'key'">
|
||||
<code>{{ record.key }}</code>
|
||||
</template>
|
||||
<template v-if="column.key === 'env1Value'">
|
||||
<template v-if="record.env1Value !== undefined">
|
||||
<code>{{ record.env1Value }}</code>
|
||||
</template>
|
||||
<span v-else class="text-muted">未定义</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'env2Value'">
|
||||
<template v-if="record.env2Value !== undefined">
|
||||
<code>{{ record.env2Value }}</code>
|
||||
</template>
|
||||
<span v-else class="text-muted">未定义</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag v-if="record.isDifferent" color="orange">不同</a-tag>
|
||||
<a-tag v-else color="green">相同</a-tag>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-empty v-else description="请选择两个环境进行对比" />
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
TagOutlined,
|
||||
CloudServerOutlined,
|
||||
SwapOutlined,
|
||||
DownloadOutlined,
|
||||
UploadOutlined,
|
||||
EyeOutlined,
|
||||
LockOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { EnvironmentInfo, EnvironmentType, EnvironmentVariable } from '@/types/platform'
|
||||
import { mockEnvironments, mockServers } from '@/mock/platform'
|
||||
import { mockProjects } from '@/mock/projects'
|
||||
|
||||
const loading = ref(false)
|
||||
const envDrawerVisible = ref(false)
|
||||
const varModalVisible = ref(false)
|
||||
const compareDrawerVisible = ref(false)
|
||||
const envFormLoading = ref(false)
|
||||
const varModalLoading = ref(false)
|
||||
|
||||
const envFormRef = ref()
|
||||
const varFormRef = ref()
|
||||
|
||||
const filterProject = ref<string | undefined>()
|
||||
const varKeyword = ref('')
|
||||
const showSecrets = ref(false)
|
||||
|
||||
const environments = ref<EnvironmentInfo[]>([])
|
||||
const currentEnv = ref<EnvironmentInfo | null>(null)
|
||||
const editingEnv = ref<EnvironmentInfo | null>(null)
|
||||
const editingVar = ref<EnvironmentVariable | null>(null)
|
||||
|
||||
const compareEnv1 = ref<string | undefined>()
|
||||
const compareEnv2 = ref<string | undefined>()
|
||||
|
||||
const projectList = computed(() => mockProjects.map(p => ({ id: p.id, name: p.name })))
|
||||
const serverList = computed(() => mockServers.map(s => ({ id: s.id, name: s.name })))
|
||||
|
||||
const envFormData = reactive({
|
||||
name: '',
|
||||
type: 'development' as EnvironmentType,
|
||||
projectId: '',
|
||||
serverId: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const varFormData = reactive({
|
||||
key: '',
|
||||
value: '',
|
||||
isSecret: false,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const envFormRules = {
|
||||
name: [{ required: true, message: '请输入环境名称' }],
|
||||
type: [{ required: true, message: '请选择环境类型' }]
|
||||
}
|
||||
|
||||
const varFormRules = {
|
||||
key: [
|
||||
{ required: true, message: '请输入变量名' },
|
||||
{ pattern: /^[A-Z_][A-Z0-9_]*$/, message: '变量名只能包含大写字母、数字和下划线' }
|
||||
],
|
||||
value: [{ required: true, message: '请输入变量值' }]
|
||||
}
|
||||
|
||||
const varColumns = [
|
||||
{ title: '变量名', key: 'key', width: 200 },
|
||||
{ title: '值', key: 'value', width: 300 },
|
||||
{ title: '类型', key: 'secret', width: 80 },
|
||||
{ title: '描述', key: 'description', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 120 }
|
||||
]
|
||||
|
||||
const compareColumns = computed(() => {
|
||||
const env1 = environments.value.find(e => e.id === compareEnv1.value)
|
||||
const env2 = environments.value.find(e => e.id === compareEnv2.value)
|
||||
return [
|
||||
{ title: '变量名', key: 'key', width: 200 },
|
||||
{ title: env1?.name || '环境 1', key: 'env1Value', width: 280 },
|
||||
{ title: env2?.name || '环境 2', key: 'env2Value', width: 280 },
|
||||
{ title: '状态', key: 'status', width: 80 }
|
||||
]
|
||||
})
|
||||
|
||||
const filteredEnvironments = computed(() => {
|
||||
let result = environments.value
|
||||
if (filterProject.value) {
|
||||
result = result.filter(e => e.projectId === filterProject.value)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const filteredVariables = computed(() => {
|
||||
if (!currentEnv.value) return []
|
||||
let result = currentEnv.value.variables
|
||||
if (varKeyword.value) {
|
||||
const keyword = varKeyword.value.toLowerCase()
|
||||
result = result.filter(v =>
|
||||
v.key.toLowerCase().includes(keyword) ||
|
||||
v.value.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const compareData = computed(() => {
|
||||
if (!compareEnv1.value || !compareEnv2.value) return []
|
||||
|
||||
const env1 = environments.value.find(e => e.id === compareEnv1.value)
|
||||
const env2 = environments.value.find(e => e.id === compareEnv2.value)
|
||||
|
||||
if (!env1 || !env2) return []
|
||||
|
||||
const allKeys = new Set([
|
||||
...env1.variables.map(v => v.key),
|
||||
...env2.variables.map(v => v.key)
|
||||
])
|
||||
|
||||
return Array.from(allKeys).map(key => {
|
||||
const var1 = env1.variables.find(v => v.key === key)
|
||||
const var2 = env2.variables.find(v => v.key === key)
|
||||
return {
|
||||
key,
|
||||
env1Value: var1?.value,
|
||||
env2Value: var2?.value,
|
||||
isDifferent: var1?.value !== var2?.value
|
||||
}
|
||||
}).sort((a, b) => a.key.localeCompare(b.key))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadEnvironments()
|
||||
})
|
||||
|
||||
function loadEnvironments() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
environments.value = [...mockEnvironments]
|
||||
if (environments.value.length > 0) {
|
||||
currentEnv.value = environments.value[0]
|
||||
}
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEnv(env: EnvironmentInfo) {
|
||||
currentEnv.value = env
|
||||
}
|
||||
|
||||
function handleAddEnv() {
|
||||
editingEnv.value = null
|
||||
Object.assign(envFormData, {
|
||||
name: '',
|
||||
type: 'development',
|
||||
projectId: '',
|
||||
serverId: '',
|
||||
description: ''
|
||||
})
|
||||
envDrawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditEnv(env: EnvironmentInfo) {
|
||||
editingEnv.value = env
|
||||
Object.assign(envFormData, {
|
||||
name: env.name,
|
||||
type: env.type,
|
||||
projectId: env.projectId || '',
|
||||
serverId: env.serverId || '',
|
||||
description: env.description || ''
|
||||
})
|
||||
envDrawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleCopyEnv(env: EnvironmentInfo) {
|
||||
Modal.confirm({
|
||||
title: '复制环境',
|
||||
content: `确定要复制环境 "${env.name}" 吗?`,
|
||||
onOk() {
|
||||
const project = projectList.value.find(p => p.id === env.projectId)
|
||||
const server = serverList.value.find(s => s.id === env.serverId)
|
||||
const newEnv: EnvironmentInfo = {
|
||||
...env,
|
||||
id: `env-${Date.now()}`,
|
||||
name: `${env.name} (副本)`,
|
||||
projectName: project?.name,
|
||||
serverName: server?.name,
|
||||
variables: env.variables.map(v => ({ ...v, id: `var-${Date.now()}-${Math.random()}` })),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
environments.value.push(newEnv)
|
||||
message.success('环境复制成功')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleDeleteEnv(env: EnvironmentInfo) {
|
||||
Modal.confirm({
|
||||
title: '删除环境',
|
||||
content: `确定要删除环境 "${env.name}" 吗?此操作不可恢复。`,
|
||||
okType: 'danger',
|
||||
onOk() {
|
||||
environments.value = environments.value.filter(e => e.id !== env.id)
|
||||
if (currentEnv.value?.id === env.id) {
|
||||
currentEnv.value = environments.value[0] || null
|
||||
}
|
||||
message.success('删除成功')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleEnvFormSubmit() {
|
||||
try {
|
||||
await envFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
envFormLoading.value = true
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
const project = projectList.value.find(p => p.id === envFormData.projectId)
|
||||
const server = serverList.value.find(s => s.id === envFormData.serverId)
|
||||
|
||||
if (editingEnv.value) {
|
||||
const index = environments.value.findIndex(e => e.id === editingEnv.value!.id)
|
||||
if (index !== -1) {
|
||||
environments.value[index] = {
|
||||
...environments.value[index],
|
||||
name: envFormData.name,
|
||||
type: envFormData.type,
|
||||
projectId: envFormData.projectId || undefined,
|
||||
projectName: project?.name,
|
||||
serverId: envFormData.serverId || undefined,
|
||||
serverName: server?.name,
|
||||
description: envFormData.description,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
if (currentEnv.value?.id === editingEnv.value.id) {
|
||||
currentEnv.value = environments.value[index]
|
||||
}
|
||||
}
|
||||
message.success('环境更新成功')
|
||||
} else {
|
||||
const newEnv: EnvironmentInfo = {
|
||||
id: `env-${Date.now()}`,
|
||||
name: envFormData.name,
|
||||
type: envFormData.type,
|
||||
projectId: envFormData.projectId || undefined,
|
||||
projectName: project?.name,
|
||||
serverId: envFormData.serverId || undefined,
|
||||
serverName: server?.name,
|
||||
description: envFormData.description,
|
||||
variables: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
environments.value.push(newEnv)
|
||||
currentEnv.value = newEnv
|
||||
message.success('环境创建成功')
|
||||
}
|
||||
|
||||
envDrawerVisible.value = false
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
envFormLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddVariable() {
|
||||
editingVar.value = null
|
||||
Object.assign(varFormData, {
|
||||
key: '',
|
||||
value: '',
|
||||
isSecret: false,
|
||||
description: ''
|
||||
})
|
||||
varModalVisible.value = true
|
||||
}
|
||||
|
||||
function handleEditVariable(variable: EnvironmentVariable) {
|
||||
editingVar.value = variable
|
||||
Object.assign(varFormData, {
|
||||
key: variable.key,
|
||||
value: variable.value,
|
||||
isSecret: variable.isSecret,
|
||||
description: variable.description || ''
|
||||
})
|
||||
varModalVisible.value = true
|
||||
}
|
||||
|
||||
function handleDeleteVariable(variable: EnvironmentVariable) {
|
||||
if (!currentEnv.value) return
|
||||
currentEnv.value.variables = currentEnv.value.variables.filter(v => v.id !== variable.id)
|
||||
message.success('变量已删除')
|
||||
}
|
||||
|
||||
async function handleVarModalSubmit() {
|
||||
try {
|
||||
await varFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentEnv.value) return
|
||||
|
||||
varModalLoading.value = true
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
if (editingVar.value) {
|
||||
const index = currentEnv.value.variables.findIndex(v => v.id === editingVar.value!.id)
|
||||
if (index !== -1) {
|
||||
currentEnv.value.variables[index] = {
|
||||
...currentEnv.value.variables[index],
|
||||
value: varFormData.value,
|
||||
isSecret: varFormData.isSecret,
|
||||
description: varFormData.description,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
message.success('变量更新成功')
|
||||
} else {
|
||||
// 检查是否已存在
|
||||
if (currentEnv.value.variables.some(v => v.key === varFormData.key)) {
|
||||
message.error('变量名已存在')
|
||||
return
|
||||
}
|
||||
const newVar: EnvironmentVariable = {
|
||||
id: `var-${Date.now()}`,
|
||||
key: varFormData.key,
|
||||
value: varFormData.value,
|
||||
isSecret: varFormData.isSecret,
|
||||
description: varFormData.description,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
currentEnv.value.variables.push(newVar)
|
||||
message.success('变量添加成功')
|
||||
}
|
||||
|
||||
varModalVisible.value = false
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
varModalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCompare() {
|
||||
compareEnv1.value = currentEnv.value?.id
|
||||
compareEnv2.value = undefined
|
||||
compareDrawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
if (!currentEnv.value) return
|
||||
|
||||
const envFile = currentEnv.value.variables
|
||||
.map(v => `${v.key}=${v.value}`)
|
||||
.join('\n')
|
||||
|
||||
const blob = new Blob([envFile], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${currentEnv.value.name}.env`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
message.success('环境配置已导出')
|
||||
}
|
||||
|
||||
function handleImport() {
|
||||
message.info('导入功能开发中...')
|
||||
}
|
||||
|
||||
function toggleShowValue(_variable: EnvironmentVariable) {
|
||||
showSecrets.value = true
|
||||
}
|
||||
|
||||
function copyValue(value: string) {
|
||||
navigator.clipboard.writeText(value)
|
||||
message.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
function getEnvTypeColor(type: EnvironmentType): string {
|
||||
const colors: Record<EnvironmentType, string> = {
|
||||
development: 'green',
|
||||
testing: 'blue',
|
||||
staging: 'orange',
|
||||
production: 'red'
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
function getEnvTypeText(type: EnvironmentType): string {
|
||||
const texts: Record<EnvironmentType, string> = {
|
||||
development: '开发',
|
||||
testing: '测试',
|
||||
staging: '预发',
|
||||
production: '生产'
|
||||
}
|
||||
return texts[type]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-environments-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.env-list {
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.env-item {
|
||||
padding: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.env-item:hover {
|
||||
border-color: #1890ff;
|
||||
background: #f6f9ff;
|
||||
}
|
||||
|
||||
.env-item.active {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.env-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.env-name {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.env-meta {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.env-stats {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.env-filter {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.env-project-badge {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.var-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.var-key {
|
||||
font-size: 13px;
|
||||
padding: 2px 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.var-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.var-value code {
|
||||
font-size: 12px;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.secret-mask {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.compare-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
</style>
|
||||
545
src/views/platform/log-center/index.vue
Normal file
545
src/views/platform/log-center/index.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="platform-log-center-page">
|
||||
<a-page-header title="日志中心" sub-title="查看系统操作日志与错误日志" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline">
|
||||
<a-form-item label="日志类型">
|
||||
<a-select v-model:value="filterType" placeholder="全部" style="width: 120px" allow-clear>
|
||||
<a-select-option value="operation">操作日志</a-select-option>
|
||||
<a-select-option value="deploy">部署日志</a-select-option>
|
||||
<a-select-option value="access">访问日志</a-select-option>
|
||||
<a-select-option value="error">错误日志</a-select-option>
|
||||
<a-select-option value="system">系统日志</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="日志级别">
|
||||
<a-select v-model:value="filterLevel" placeholder="全部" style="width: 100px" allow-clear>
|
||||
<a-select-option value="debug">Debug</a-select-option>
|
||||
<a-select-option value="info">Info</a-select-option>
|
||||
<a-select-option value="warn">Warn</a-select-option>
|
||||
<a-select-option value="error">Error</a-select-option>
|
||||
<a-select-option value="fatal">Fatal</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="项目">
|
||||
<a-select v-model:value="filterProject" placeholder="全部" style="width: 150px" allow-clear>
|
||||
<a-select-option v-for="project in projectList" :key="project.id" :value="project.id">
|
||||
{{ project.name }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="时间范围">
|
||||
<a-range-picker v-model:value="filterDateRange" :placeholder="['开始日期', '结束日期']" />
|
||||
</a-form-item>
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model:value="filterKeyword" placeholder="搜索日志内容" style="width: 180px" allow-clear />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
<a-button @click="handleExport">
|
||||
<DownloadOutlined /> 导出
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 日志类型统计 -->
|
||||
<div class="log-stats">
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item', { active: !filterType }]"
|
||||
@click="filterType = undefined"
|
||||
>
|
||||
<span class="stat-count">{{ logRecords.length }}</span>
|
||||
<span class="stat-label">全部</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item stat-operation', { active: filterType === 'operation' }]"
|
||||
@click="filterType = 'operation'"
|
||||
>
|
||||
<span class="stat-count">{{ typeStats.operation }}</span>
|
||||
<span class="stat-label">操作日志</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item stat-deploy', { active: filterType === 'deploy' }]"
|
||||
@click="filterType = 'deploy'"
|
||||
>
|
||||
<span class="stat-count">{{ typeStats.deploy }}</span>
|
||||
<span class="stat-label">部署日志</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item stat-access', { active: filterType === 'access' }]"
|
||||
@click="filterType = 'access'"
|
||||
>
|
||||
<span class="stat-count">{{ typeStats.access }}</span>
|
||||
<span class="stat-label">访问日志</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item stat-error', { active: filterType === 'error' }]"
|
||||
@click="filterType = 'error'"
|
||||
>
|
||||
<span class="stat-count">{{ typeStats.error }}</span>
|
||||
<span class="stat-label">错误日志</span>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="4">
|
||||
<div
|
||||
:class="['stat-item stat-system', { active: filterType === 'system' }]"
|
||||
@click="filterType = 'system'"
|
||||
>
|
||||
<span class="stat-count">{{ typeStats.system }}</span>
|
||||
<span class="stat-label">系统日志</span>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 日志列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredLogs"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:pagination="{
|
||||
pageSize: 15,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true
|
||||
}"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'level'">
|
||||
<a-tag :color="getLevelColor(record.level)">
|
||||
{{ getLevelText(record.level) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeText(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'content'">
|
||||
<div class="log-content-cell">
|
||||
<div class="log-action">
|
||||
<span class="module-name">{{ record.module }}</span>
|
||||
<span class="divider">/</span>
|
||||
<span class="action-name">{{ record.action }}</span>
|
||||
</div>
|
||||
<div class="log-message" :title="record.content">{{ record.content }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'target'">
|
||||
<div v-if="record.projectName || record.serverName" class="target-info">
|
||||
<div v-if="record.projectName">
|
||||
<TagOutlined /> {{ record.projectName }}
|
||||
</div>
|
||||
<div v-if="record.serverName" class="text-muted">
|
||||
<CloudServerOutlined /> {{ record.serverName }}
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'operator'">
|
||||
<div v-if="record.operator" class="operator-info">
|
||||
<div>{{ record.operator }}</div>
|
||||
<div class="text-muted text-small">{{ record.operatorIp || '-' }}</div>
|
||||
</div>
|
||||
<span v-else class="text-muted">系统</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'time'">
|
||||
<div>{{ formatTime(record.createdAt) }}</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 日志详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
title="日志详情"
|
||||
width="600"
|
||||
placement="right"
|
||||
>
|
||||
<template v-if="currentLog">
|
||||
<a-descriptions :column="2" bordered size="small">
|
||||
<a-descriptions-item label="日志ID" :span="2">
|
||||
<code>{{ currentLog.id }}</code>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="日志类型">
|
||||
<a-tag :color="getTypeColor(currentLog.type)">
|
||||
{{ getTypeText(currentLog.type) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="日志级别">
|
||||
<a-tag :color="getLevelColor(currentLog.level)">
|
||||
{{ getLevelText(currentLog.level) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="模块">{{ currentLog.module }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作">{{ currentLog.action }}</a-descriptions-item>
|
||||
<a-descriptions-item label="操作人">{{ currentLog.operator || '系统' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="IP 地址">{{ currentLog.operatorIp || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="关联项目" :span="2">
|
||||
{{ currentLog.projectName || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="关联服务器" :span="2">
|
||||
{{ currentLog.serverName || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="请求 ID" :span="2">
|
||||
<code v-if="currentLog.requestId">{{ currentLog.requestId }}</code>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="耗时">
|
||||
{{ currentLog.duration ? `${currentLog.duration}ms` : '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="记录时间">
|
||||
{{ formatFullTime(currentLog.createdAt) }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">日志内容</a-divider>
|
||||
<div class="log-content-box">
|
||||
{{ currentLog.content }}
|
||||
</div>
|
||||
|
||||
<template v-if="currentLog.metadata && Object.keys(currentLog.metadata).length > 0">
|
||||
<a-divider orientation="left">扩展信息</a-divider>
|
||||
<div class="log-metadata-box">
|
||||
<pre>{{ JSON.stringify(currentLog.metadata, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import {
|
||||
SearchOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
TagOutlined,
|
||||
CloudServerOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { LogRecord, LogType, LogLevel } from '@/types/platform'
|
||||
import { mockLogs } from '@/mock/platform'
|
||||
import { mockProjects } from '@/mock/projects'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
|
||||
const filterType = ref<LogType | undefined>()
|
||||
const filterLevel = ref<LogLevel | undefined>()
|
||||
const filterProject = ref<string | undefined>()
|
||||
const filterKeyword = ref('')
|
||||
const filterDateRange = ref<[Dayjs, Dayjs] | undefined>()
|
||||
|
||||
const logRecords = ref<LogRecord[]>([])
|
||||
const currentLog = ref<LogRecord | null>(null)
|
||||
|
||||
const projectList = computed(() => mockProjects.map(p => ({ id: p.id, name: p.name })))
|
||||
|
||||
const columns = [
|
||||
{ title: '级别', key: 'level', width: 80 },
|
||||
{ title: '类型', key: 'type', width: 100 },
|
||||
{ title: '日志内容', key: 'content', ellipsis: true },
|
||||
{ title: '关联对象', key: 'target', width: 150 },
|
||||
{ title: '操作人', key: 'operator', width: 120 },
|
||||
{ title: '时间', key: 'time', width: 140 },
|
||||
{ title: '操作', key: 'action', width: 80 }
|
||||
]
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
let result = logRecords.value
|
||||
|
||||
if (filterType.value) {
|
||||
result = result.filter(l => l.type === filterType.value)
|
||||
}
|
||||
if (filterLevel.value) {
|
||||
result = result.filter(l => l.level === filterLevel.value)
|
||||
}
|
||||
if (filterProject.value) {
|
||||
result = result.filter(l => l.projectId === filterProject.value)
|
||||
}
|
||||
if (filterKeyword.value) {
|
||||
const keyword = filterKeyword.value.toLowerCase()
|
||||
result = result.filter(l =>
|
||||
l.content.toLowerCase().includes(keyword) ||
|
||||
l.action.toLowerCase().includes(keyword) ||
|
||||
l.module.toLowerCase().includes(keyword)
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const typeStats = computed(() => {
|
||||
return {
|
||||
operation: logRecords.value.filter(l => l.type === 'operation').length,
|
||||
deploy: logRecords.value.filter(l => l.type === 'deploy').length,
|
||||
access: logRecords.value.filter(l => l.type === 'access').length,
|
||||
error: logRecords.value.filter(l => l.type === 'error').length,
|
||||
system: logRecords.value.filter(l => l.type === 'system').length
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 检查 URL 参数
|
||||
if (route.query.serverId) {
|
||||
// 可以根据 serverId 筛选日志
|
||||
}
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
function loadLogs() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
logRecords.value = [...mockLogs].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
// 筛选已通过 computed 实现
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterType.value = undefined
|
||||
filterLevel.value = undefined
|
||||
filterProject.value = undefined
|
||||
filterKeyword.value = ''
|
||||
filterDateRange.value = undefined
|
||||
}
|
||||
|
||||
function handleExport() {
|
||||
message.success('日志导出功能开发中...')
|
||||
}
|
||||
|
||||
function handleViewDetail(record: LogRecord) {
|
||||
currentLog.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
function getLevelColor(level: LogLevel): string {
|
||||
const colors: Record<LogLevel, string> = {
|
||||
debug: 'default',
|
||||
info: 'blue',
|
||||
warn: 'orange',
|
||||
error: 'red',
|
||||
fatal: 'purple'
|
||||
}
|
||||
return colors[level]
|
||||
}
|
||||
|
||||
function getLevelText(level: LogLevel): string {
|
||||
const texts: Record<LogLevel, string> = {
|
||||
debug: 'DEBUG',
|
||||
info: 'INFO',
|
||||
warn: 'WARN',
|
||||
error: 'ERROR',
|
||||
fatal: 'FATAL'
|
||||
}
|
||||
return texts[level]
|
||||
}
|
||||
|
||||
function getTypeColor(type: LogType): string {
|
||||
const colors: Record<LogType, string> = {
|
||||
operation: 'cyan',
|
||||
deploy: 'purple',
|
||||
access: 'green',
|
||||
error: 'red',
|
||||
system: 'blue'
|
||||
}
|
||||
return colors[type]
|
||||
}
|
||||
|
||||
function getTypeText(type: LogType): string {
|
||||
const texts: Record<LogType, string> = {
|
||||
operation: '操作',
|
||||
deploy: '部署',
|
||||
access: '访问',
|
||||
error: '错误',
|
||||
system: '系统'
|
||||
}
|
||||
return texts[type]
|
||||
}
|
||||
|
||||
function formatTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatFullTime(isoString: string): string {
|
||||
const date = new Date(isoString)
|
||||
return date.toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-log-center-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.log-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.stat-item:hover {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.stat-item.active {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.stat-item.stat-operation.active { border-color: #13c2c2; background: #e6fffb; }
|
||||
.stat-item.stat-deploy.active { border-color: #722ed1; background: #f9f0ff; }
|
||||
.stat-item.stat-access.active { border-color: #52c41a; background: #f6ffed; }
|
||||
.stat-item.stat-error.active { border-color: #ff4d4f; background: #fff1f0; }
|
||||
.stat-item.stat-system.active { border-color: #1890ff; background: #e6f7ff; }
|
||||
|
||||
.stat-count {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.log-content-cell {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.log-action {
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.module-name {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: #d9d9d9;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.action-name {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.log-message {
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.target-info {
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.operator-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.text-small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-content-box {
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
line-height: 1.8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.log-metadata-box {
|
||||
padding: 16px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.log-metadata-box pre {
|
||||
margin: 0;
|
||||
color: #d4d4d4;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -136,112 +136,77 @@
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="服务器地址" name="serverAddress" required>
|
||||
<a-input v-model:value="formData.serverAddress" placeholder="如:192.168.1.100" />
|
||||
<a-form-item label="项目描述" name="description">
|
||||
<a-textarea
|
||||
v-model:value="formData.description"
|
||||
:rows="2"
|
||||
placeholder="项目简要描述"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 域名配置 -->
|
||||
<a-divider orientation="left">域名配置</a-divider>
|
||||
|
||||
<a-form-item label="域名" required style="margin-bottom: 0">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="14">
|
||||
<a-form-item name="domain" no-style>
|
||||
<a-input v-model:value="formData.domain" placeholder="如:www.example.com" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="10">
|
||||
<a-form-item name="port" no-style>
|
||||
<a-input-number
|
||||
v-model:value="formData.port"
|
||||
:min="1"
|
||||
:max="65535"
|
||||
placeholder="端口"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="代号" name="alias" required>
|
||||
<a-input v-model:value="formData.alias" placeholder="项目代号" />
|
||||
<div class="form-item-help">对应主目录:/opt/1panel/www/sites/{{ formData.alias || 'xxx' }}/index</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- HTTPS 配置 -->
|
||||
<a-divider orientation="left">安全配置</a-divider>
|
||||
|
||||
<a-form-item label="启用 HTTPS" name="enableHttps">
|
||||
<a-checkbox v-model:checked="formData.enableHttps">
|
||||
开启失败不会影响网站创建
|
||||
</a-checkbox>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="formData.enableHttps">
|
||||
<a-form-item label="证书状态" name="certificate">
|
||||
<!-- 加载中 -->
|
||||
<template v-if="certificateLoading">
|
||||
<a-spin size="small" />
|
||||
<span style="margin-left: 8px; color: #999;">正在检查证书...</span>
|
||||
</template>
|
||||
|
||||
<!-- 找到证书 -->
|
||||
<template v-else-if="matchedCertificate">
|
||||
<a-tag color="success">✓ 已匹配证书</a-tag>
|
||||
<span style="margin-left: 8px;">{{ matchedCertificate.primaryDomain }}</span>
|
||||
|
||||
<!-- 证书信息表格 -->
|
||||
<a-descriptions
|
||||
:column="2"
|
||||
size="small"
|
||||
bordered
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
<a-descriptions-item label="主域名">{{ matchedCertificate.primaryDomain }}</a-descriptions-item>
|
||||
<a-descriptions-item label="其他域名">{{ matchedCertificate.domains || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="签发组织">{{ matchedCertificate.organization || "Let's Encrypt" }}</a-descriptions-item>
|
||||
<a-descriptions-item label="过期时间">{{ matchedCertificate.expireDate }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
|
||||
<!-- 未找到证书 -->
|
||||
<template v-else-if="certificateNotFound">
|
||||
<a-tag color="warning">⚠ 未找到匹配证书</a-tag>
|
||||
<div style="margin-top: 8px;">
|
||||
<span style="color: #ff4d4f;">请先前往证书管理申请证书</span>
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="$router.push('/platform/certificates')"
|
||||
>
|
||||
前往申请 →
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 未输入域名 -->
|
||||
<template v-else>
|
||||
<span style="color: #999;">请先填写域名,系统将自动检查匹配的证书</span>
|
||||
</template>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 项目地址 -->
|
||||
<!-- 访问配置 -->
|
||||
<a-divider orientation="left">访问配置</a-divider>
|
||||
|
||||
<a-form-item label="项目地址" name="baseUrl" required>
|
||||
<a-input v-model:value="formData.baseUrl" placeholder="如:http://localhost:5174" />
|
||||
<div class="form-item-help">用于iframe嵌套的子项目地址</div>
|
||||
<a-form-item label="绑定域名" name="domain">
|
||||
<a-select
|
||||
v-model:value="formData.domain"
|
||||
placeholder="请选择绑定的域名"
|
||||
allow-clear
|
||||
show-search
|
||||
:filter-option="filterDomain"
|
||||
@change="onDomainChange"
|
||||
>
|
||||
<a-select-option v-for="domain in domainList" :key="domain.id" :value="domain.domain">
|
||||
{{ domain.domain }}
|
||||
<a-tag v-if="domain.enableHttps" color="green" size="small" style="margin-left: 8px">HTTPS</a-tag>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="form-item-help">选择已配置的域名,项目地址将自动生成</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 备注 -->
|
||||
<a-form-item label="项目地址">
|
||||
<a-input
|
||||
:value="formData.baseUrl"
|
||||
disabled
|
||||
:placeholder="formData.domain ? '' : '选择域名后自动生成'"
|
||||
/>
|
||||
<div class="form-item-help">根据绑定域名自动生成,用于 iframe 嵌套</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 部署配置 -->
|
||||
<a-divider orientation="left">部署配置</a-divider>
|
||||
|
||||
<a-form-item label="目标服务器" name="serverId">
|
||||
<a-select
|
||||
v-model:value="formData.serverId"
|
||||
placeholder="请选择部署服务器"
|
||||
allow-clear
|
||||
>
|
||||
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
|
||||
{{ server.name }} ({{ server.ip }})
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
<div class="form-item-help">选择前端文件部署的目标服务器</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="部署路径">
|
||||
<a-input
|
||||
:value="formData.deployPath"
|
||||
disabled
|
||||
:placeholder="formData.domain ? '' : '选择域名后自动生成'"
|
||||
/>
|
||||
<div class="form-item-help">
|
||||
对应 1Panel 目录:/opt/1panel/www/sites/{{ formData.domain || '域名' }}/index
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<!-- 其他 -->
|
||||
<a-divider orientation="left">其他</a-divider>
|
||||
|
||||
<a-form-item label="备注" name="remark">
|
||||
<a-textarea
|
||||
v-model:value="formData.remark"
|
||||
:rows="4"
|
||||
:rows="3"
|
||||
placeholder="项目备注信息(选填)"
|
||||
/>
|
||||
</a-form-item>
|
||||
@@ -252,32 +217,27 @@
|
||||
<a-drawer
|
||||
v-model:open="versionDrawerVisible"
|
||||
:title="`版本管理 - ${versionProject?.name || ''}`"
|
||||
width="600"
|
||||
width="680"
|
||||
placement="right"
|
||||
>
|
||||
<div class="version-drawer-content">
|
||||
<!-- 创建新版本 -->
|
||||
<a-card size="small" title="创建新版本" class="create-version-card">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="版本描述">
|
||||
<a-form-item label="版本描述" required>
|
||||
<a-input
|
||||
v-model:value="newVersionDescription"
|
||||
placeholder="请输入版本更新描述"
|
||||
placeholder="请输入版本更新描述(必填)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="handleCreateVersion"
|
||||
:disabled="!newVersionDescription.trim()"
|
||||
>
|
||||
<SaveOutlined /> 保存版本
|
||||
</a-button>
|
||||
<a-button @click="handleVersionUpload">
|
||||
<UploadOutlined /> 上传版本文件
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-button
|
||||
type="primary"
|
||||
@click="handleCreateVersion"
|
||||
:disabled="!newVersionDescription.trim()"
|
||||
>
|
||||
<SaveOutlined /> 创建新版本
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
@@ -303,15 +263,41 @@
|
||||
<div class="version-description">{{ version.description }}</div>
|
||||
<div class="version-meta">
|
||||
<span>创建人:{{ version.createdBy }}</span>
|
||||
<span v-if="version.files && version.files.length > 0" class="version-files-count">
|
||||
| 文件:{{ version.files.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 版本文件列表 -->
|
||||
<div v-if="version.files && version.files.length > 0" class="version-files">
|
||||
<a-collapse size="small" :bordered="false">
|
||||
<a-collapse-panel header="查看文件列表">
|
||||
<div class="file-list">
|
||||
<div v-for="file in version.files" :key="file.name" class="file-item">
|
||||
<FileOutlined />
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
</div>
|
||||
|
||||
<div class="version-actions">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleVersionUploadForVersion(version)"
|
||||
>
|
||||
<UploadOutlined /> 上传文件
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="version.version !== versionProject?.currentVersion"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleRollback(version)"
|
||||
>
|
||||
<RollbackOutlined /> 回退到此版本
|
||||
<RollbackOutlined /> 回退
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
v-if="version.version !== versionProject?.currentVersion"
|
||||
@@ -326,7 +312,7 @@
|
||||
</div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
<a-empty v-else description="暂无版本记录" />
|
||||
<a-empty v-else description="暂无版本记录,请先创建版本" />
|
||||
</a-card>
|
||||
</div>
|
||||
</a-drawer>
|
||||
@@ -342,7 +328,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
@@ -351,16 +337,50 @@ import {
|
||||
SaveOutlined,
|
||||
RollbackOutlined,
|
||||
DeleteOutlined,
|
||||
UploadOutlined
|
||||
UploadOutlined,
|
||||
FileOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { PlatformProject, ProjectMenuItem, ProjectVersion } from '@/mock/projects'
|
||||
import { useProjectStore } from '@/stores'
|
||||
import ProjectUpload from '@/components/ProjectUpload.vue'
|
||||
import { findCertificateByDomain, type SSLCertificate } from '@/api/1panel'
|
||||
import { mockServers, mockDomains } from '@/mock/platform'
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
// 服务器列表
|
||||
const serverList = computed(() => mockServers.map(s => ({ id: s.id, name: s.name, ip: s.ip })))
|
||||
|
||||
// 域名列表
|
||||
const domainList = computed(() => mockDomains.map(d => ({
|
||||
id: d.id,
|
||||
domain: d.domain,
|
||||
enableHttps: d.enableHttps
|
||||
})))
|
||||
|
||||
// 域名搜索过滤
|
||||
function filterDomain(input: string, option: any): boolean {
|
||||
return option.value.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
// 域名变更时自动生成项目地址和部署路径
|
||||
function onDomainChange(domain: string) {
|
||||
if (domain) {
|
||||
// 查找域名是否启用 HTTPS
|
||||
const domainInfo = domainList.value.find(d => d.domain === domain)
|
||||
const protocol = domainInfo?.enableHttps ? 'https://' : 'http://'
|
||||
|
||||
// 自动生成项目地址
|
||||
formData.baseUrl = protocol + domain
|
||||
|
||||
// 自动生成部署路径(根据 1Panel 的目录结构)
|
||||
formData.deployPath = `/opt/1panel/www/sites/${domain}/index`
|
||||
} else {
|
||||
formData.baseUrl = ''
|
||||
formData.deployPath = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const formVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
@@ -376,6 +396,7 @@ const newVersionDescription = ref('')
|
||||
const uploadVisible = ref(false)
|
||||
const uploadProjectId = ref('')
|
||||
const uploadProjectName = ref('')
|
||||
const uploadVersionId = ref<string | null>(null) // 当前上传针对的版本ID
|
||||
|
||||
const versionList = computed(() => {
|
||||
if (!versionProject.value) return []
|
||||
@@ -412,84 +433,16 @@ const formData = reactive({
|
||||
shortName: '',
|
||||
logo: '',
|
||||
color: '#1890ff',
|
||||
serverAddress: '',
|
||||
domain: '',
|
||||
baseUrl: '',
|
||||
description: '',
|
||||
// 新增字段
|
||||
group: 'default',
|
||||
domain: '',
|
||||
port: 80,
|
||||
alias: '',
|
||||
enableHttps: false,
|
||||
acmeAccount: '',
|
||||
certificate: '',
|
||||
serverId: '',
|
||||
deployPath: '',
|
||||
remark: '',
|
||||
fileList: [] as any[]
|
||||
})
|
||||
|
||||
// 证书检查状态
|
||||
const certificateLoading = ref(false)
|
||||
const matchedCertificate = ref<SSLCertificate | null>(null)
|
||||
const certificateNotFound = ref(false)
|
||||
|
||||
// 监听HTTPS开关和域名变化,自动检查证书
|
||||
watch(
|
||||
() => [formData.enableHttps, formData.domain],
|
||||
async ([enableHttps, domain]) => {
|
||||
if (enableHttps && domain) {
|
||||
await checkCertificate(domain as string)
|
||||
} else {
|
||||
// 清空证书相关状态
|
||||
matchedCertificate.value = null
|
||||
certificateNotFound.value = false
|
||||
formData.certificate = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听域名和HTTPS变化,自动生成项目地址
|
||||
watch(
|
||||
() => [formData.domain, formData.enableHttps],
|
||||
([domain, enableHttps]) => {
|
||||
if (domain) {
|
||||
const protocol = enableHttps ? 'https://' : 'http://'
|
||||
formData.baseUrl = protocol + domain
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* 检查域名是否有匹配的证书
|
||||
*/
|
||||
async function checkCertificate(domain: string) {
|
||||
if (!domain) return
|
||||
|
||||
certificateLoading.value = true
|
||||
certificateNotFound.value = false
|
||||
|
||||
try {
|
||||
// 使用 acmeAccountID = 1 调用接口
|
||||
const cert = await findCertificateByDomain(domain, 1)
|
||||
|
||||
if (cert) {
|
||||
matchedCertificate.value = cert
|
||||
formData.certificate = cert.primaryDomain
|
||||
message.success(`已找到匹配证书:${cert.primaryDomain}`)
|
||||
} else {
|
||||
matchedCertificate.value = null
|
||||
certificateNotFound.value = true
|
||||
formData.certificate = ''
|
||||
message.warning('未找到匹配的证书,请先前往证书管理申请证书')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('检查证书失败:', error)
|
||||
certificateNotFound.value = true
|
||||
message.error('检查证书失败')
|
||||
} finally {
|
||||
certificateLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formRules = {
|
||||
id: [
|
||||
{ required: true, message: '请输入项目标识' },
|
||||
@@ -498,15 +451,7 @@ const formRules = {
|
||||
name: [{ required: true, message: '请输入项目名称' }],
|
||||
shortName: [{ required: true, message: '请输入项目简称' }],
|
||||
logo: [{ required: true, message: '请输入Logo文字' }],
|
||||
group: [{ required: true, message: '请选择分组' }],
|
||||
// 服务器地址
|
||||
serverAddress: [{ required: true, message: '请输入服务器地址' }],
|
||||
// 域名配置
|
||||
domain: [{ required: true, message: '请输入域名' }],
|
||||
port: [{ required: true, message: '请输入端口' }],
|
||||
alias: [{ required: true, message: '请输入代号' }],
|
||||
// 访问配置
|
||||
baseUrl: [{ required: true, message: '请输入项目地址' }]
|
||||
group: [{ required: true, message: '请选择分组' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
@@ -641,16 +586,12 @@ function handleAdd() {
|
||||
shortName: '',
|
||||
logo: '',
|
||||
color: '#1890ff',
|
||||
serverAddress: '',
|
||||
domain: '',
|
||||
baseUrl: '',
|
||||
description: '',
|
||||
group: 'default',
|
||||
domain: '',
|
||||
port: 80,
|
||||
alias: '',
|
||||
enableHttps: false,
|
||||
acmeAccount: '',
|
||||
certificate: '',
|
||||
serverId: '',
|
||||
deployPath: '',
|
||||
remark: '',
|
||||
fileList: []
|
||||
})
|
||||
@@ -665,16 +606,12 @@ function handleEdit(record: PlatformProject) {
|
||||
shortName: record.shortName,
|
||||
logo: record.logo,
|
||||
color: record.color || '#1890ff',
|
||||
serverAddress: (record as any).serverAddress || '',
|
||||
domain: (record as any).domain || '',
|
||||
baseUrl: record.baseUrl || '',
|
||||
description: record.description || '',
|
||||
group: (record as any).group || 'default',
|
||||
domain: (record as any).domain || '',
|
||||
port: (record as any).port || 80,
|
||||
alias: (record as any).alias || '',
|
||||
enableHttps: (record as any).enableHttps || false,
|
||||
acmeAccount: (record as any).acmeAccount || '',
|
||||
certificate: (record as any).certificate || '',
|
||||
serverId: (record as any).serverId || '',
|
||||
deployPath: (record as any).deployPath || '',
|
||||
remark: (record as any).remark || '',
|
||||
fileList: []
|
||||
})
|
||||
@@ -697,16 +634,12 @@ async function handleFormSubmit() {
|
||||
shortName: formData.shortName,
|
||||
logo: formData.logo,
|
||||
color: formData.color,
|
||||
serverAddress: formData.serverAddress,
|
||||
domain: formData.domain,
|
||||
baseUrl: formData.baseUrl,
|
||||
description: formData.description,
|
||||
group: formData.group,
|
||||
domain: formData.domain,
|
||||
port: formData.port,
|
||||
alias: formData.alias,
|
||||
enableHttps: formData.enableHttps,
|
||||
acmeAccount: formData.acmeAccount,
|
||||
certificate: formData.certificate,
|
||||
serverId: formData.serverId,
|
||||
deployPath: formData.deployPath,
|
||||
remark: formData.remark
|
||||
}
|
||||
|
||||
@@ -756,23 +689,53 @@ function goToMenus(record: PlatformProject) {
|
||||
function handleUpload(record: PlatformProject) {
|
||||
uploadProjectId.value = record.id
|
||||
uploadProjectName.value = record.name
|
||||
uploadVersionId.value = null // 不关联版本
|
||||
uploadVisible.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传完成回调
|
||||
*/
|
||||
function handleUploadComplete() {
|
||||
message.success('文件上传完成')
|
||||
function handleUploadComplete(files: any[]) {
|
||||
if (uploadVersionId.value && versionProject.value) {
|
||||
// 将文件关联到指定版本
|
||||
const success = projectStore.addFilesToVersion(
|
||||
versionProject.value.id,
|
||||
uploadVersionId.value,
|
||||
files.map(f => ({ name: f.name, size: f.size || 0 }))
|
||||
)
|
||||
if (success) {
|
||||
message.success(`已将 ${files.length} 个文件添加到版本`)
|
||||
// 刷新版本项目数据
|
||||
versionProject.value = projectStore.getProjectById(versionProject.value.id) || null
|
||||
} else {
|
||||
message.error('文件关联失败')
|
||||
}
|
||||
} else {
|
||||
message.success('文件上传完成')
|
||||
}
|
||||
uploadVersionId.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 版本管理中上传文件
|
||||
* 为指定版本上传文件
|
||||
*/
|
||||
function handleVersionUpload() {
|
||||
function handleVersionUploadForVersion(version: ProjectVersion) {
|
||||
if (!versionProject.value) return
|
||||
uploadProjectId.value = versionProject.value.id
|
||||
uploadProjectName.value = versionProject.value.name
|
||||
uploadProjectName.value = `${versionProject.value.name} - v${version.version}`
|
||||
uploadVersionId.value = version.id
|
||||
uploadVisible.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -881,5 +844,43 @@ function handleVersionUpload() {
|
||||
.version-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.version-files-count {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.version-files {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
770
src/views/platform/servers/index.vue
Normal file
770
src/views/platform/servers/index.vue
Normal file
@@ -0,0 +1,770 @@
|
||||
<template>
|
||||
<div class="platform-servers-page">
|
||||
<a-page-header title="服务器管理" sub-title="管理平台部署服务器" />
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<a-form layout="inline">
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="filterStatus" placeholder="全部" style="width: 120px" allow-clear>
|
||||
<a-select-option value="online">在线</a-select-option>
|
||||
<a-select-option value="offline">离线</a-select-option>
|
||||
<a-select-option value="warning">告警</a-select-option>
|
||||
<a-select-option value="maintenance">维护中</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="类型">
|
||||
<a-select v-model:value="filterType" placeholder="全部" style="width: 120px" allow-clear>
|
||||
<a-select-option value="cloud">云服务器</a-select-option>
|
||||
<a-select-option value="physical">物理机</a-select-option>
|
||||
<a-select-option value="virtual">虚拟机</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model:value="filterKeyword" placeholder="名称/IP" style="width: 150px" allow-clear />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<SearchOutlined /> 查询
|
||||
</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined /> 添加服务器
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-online">
|
||||
<div class="stat-value">{{ stats.online }}</div>
|
||||
<div class="stat-label">在线</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-offline">
|
||||
<div class="stat-value">{{ stats.offline }}</div>
|
||||
<div class="stat-label">离线</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-warning">
|
||||
<div class="stat-value">{{ stats.warning }}</div>
|
||||
<div class="stat-label">告警</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="stat-card stat-total">
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
<div class="stat-label">总计</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
|
||||
<!-- 服务器列表 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="filteredServers"
|
||||
:loading="loading"
|
||||
row-key="id"
|
||||
:pagination="{ pageSize: 10, showTotal: (total: number) => `共 ${total} 条` }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="server-info">
|
||||
<div class="server-name">
|
||||
<CloudServerOutlined v-if="record.type === 'cloud'" />
|
||||
<DesktopOutlined v-else-if="record.type === 'physical'" />
|
||||
<ClusterOutlined v-else />
|
||||
<span>{{ record.name }}</span>
|
||||
</div>
|
||||
<div class="server-ip">{{ record.ip }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ getStatusText(record.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'type'">
|
||||
<span>{{ getTypeText(record.type) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'resources'">
|
||||
<div class="resource-info">
|
||||
<div class="resource-item">
|
||||
<span class="label">CPU:</span>
|
||||
<a-progress
|
||||
:percent="record.cpu.usage"
|
||||
:size="60"
|
||||
:stroke-color="getUsageColor(record.cpu.usage)"
|
||||
:format="(percent: number) => `${percent}%`"
|
||||
/>
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="label">内存:</span>
|
||||
<a-progress
|
||||
:percent="record.memory.usage"
|
||||
:size="60"
|
||||
:stroke-color="getUsageColor(record.memory.usage)"
|
||||
:format="(percent: number) => `${percent}%`"
|
||||
/>
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="label">磁盘:</span>
|
||||
<a-progress
|
||||
:percent="record.disk.usage"
|
||||
:size="60"
|
||||
:stroke-color="getUsageColor(record.disk.usage)"
|
||||
:format="(percent: number) => `${percent}%`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'tags'">
|
||||
<a-tag v-for="tag in record.tags" :key="tag" :color="getTagColor(tag)">
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleView(record)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
编辑
|
||||
</a-button>
|
||||
<a-button
|
||||
v-if="record.panelUrl"
|
||||
type="link"
|
||||
size="small"
|
||||
@click="openPanel(record)"
|
||||
>
|
||||
<LinkOutlined /> 面板
|
||||
</a-button>
|
||||
<a-popconfirm title="确定删除此服务器?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 添加/编辑服务器抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="drawerVisible"
|
||||
:title="currentServer ? '编辑服务器' : '添加服务器'"
|
||||
width="600"
|
||||
placement="right"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 17 }"
|
||||
>
|
||||
<a-form-item label="服务器名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="如:生产服务器-主" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="外网 IP" name="ip">
|
||||
<a-input v-model:value="formData.ip" placeholder="如:47.108.xxx.xxx" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="内网 IP" name="internalIp">
|
||||
<a-input v-model:value="formData.internalIp" placeholder="如:172.16.0.1(选填)" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="服务器类型" name="type">
|
||||
<a-select v-model:value="formData.type" placeholder="请选择">
|
||||
<a-select-option value="cloud">云服务器</a-select-option>
|
||||
<a-select-option value="physical">物理机</a-select-option>
|
||||
<a-select-option value="virtual">虚拟机</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="操作系统" name="os">
|
||||
<a-input v-model:value="formData.os" placeholder="如:Ubuntu 22.04 LTS" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">SSH 配置</a-divider>
|
||||
|
||||
<a-form-item label="SSH 用户名" name="sshUser">
|
||||
<a-input v-model:value="formData.sshUser" placeholder="如:root" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="SSH 端口" name="sshPort">
|
||||
<a-input-number v-model:value="formData.sshPort" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">1Panel 配置</a-divider>
|
||||
|
||||
<a-form-item label="面板地址" name="panelUrl">
|
||||
<a-input v-model:value="formData.panelUrl" placeholder="如:https://panel.example.com(选填)" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="面板端口" name="panelPort">
|
||||
<a-input-number v-model:value="formData.panelPort" :min="1" :max="65535" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider orientation="left">其他</a-divider>
|
||||
|
||||
<a-form-item label="标签" name="tags">
|
||||
<a-select v-model:value="formData.tags" mode="tags" placeholder="输入标签后回车">
|
||||
<a-select-option value="生产">生产</a-select-option>
|
||||
<a-select-option value="测试">测试</a-select-option>
|
||||
<a-select-option value="开发">开发</a-select-option>
|
||||
<a-select-option value="备份">备份</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="描述" name="description">
|
||||
<a-textarea v-model:value="formData.description" :rows="3" placeholder="服务器描述(选填)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="drawer-footer">
|
||||
<a-button @click="drawerVisible = false">取消</a-button>
|
||||
<a-button type="primary" :loading="formLoading" @click="handleFormSubmit">
|
||||
{{ currentServer ? '保存' : '添加' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<!-- 服务器详情抽屉 -->
|
||||
<a-drawer
|
||||
v-model:open="detailVisible"
|
||||
title="服务器详情"
|
||||
width="700"
|
||||
placement="right"
|
||||
>
|
||||
<template v-if="detailServer">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="服务器名称" :span="2">
|
||||
{{ detailServer.name }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="外网 IP">
|
||||
{{ detailServer.ip }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="内网 IP">
|
||||
{{ detailServer.internalIp || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(detailServer.status)">
|
||||
{{ getStatusText(detailServer.status) }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="类型">
|
||||
{{ getTypeText(detailServer.type) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="操作系统" :span="2">
|
||||
{{ detailServer.os }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="SSH 用户">
|
||||
{{ detailServer.sshUser || '-' }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="SSH 端口">
|
||||
{{ detailServer.sshPort || 22 }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="标签" :span="2">
|
||||
<a-tag v-for="tag in detailServer.tags" :key="tag" :color="getTagColor(tag)">
|
||||
{{ tag }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">
|
||||
{{ detailServer.description || '-' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider orientation="left">资源使用</a-divider>
|
||||
|
||||
<a-row :gutter="24">
|
||||
<a-col :span="8">
|
||||
<div class="detail-resource-card">
|
||||
<div class="resource-title">CPU</div>
|
||||
<a-progress
|
||||
type="dashboard"
|
||||
:percent="detailServer.cpu.usage"
|
||||
:stroke-color="getUsageColor(detailServer.cpu.usage)"
|
||||
/>
|
||||
<div class="resource-detail">{{ detailServer.cpu.cores }} 核心</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-resource-card">
|
||||
<div class="resource-title">内存</div>
|
||||
<a-progress
|
||||
type="dashboard"
|
||||
:percent="detailServer.memory.usage"
|
||||
:stroke-color="getUsageColor(detailServer.memory.usage)"
|
||||
/>
|
||||
<div class="resource-detail">{{ detailServer.memory.used }}GB / {{ detailServer.memory.total }}GB</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<div class="detail-resource-card">
|
||||
<div class="resource-title">磁盘</div>
|
||||
<a-progress
|
||||
type="dashboard"
|
||||
:percent="detailServer.disk.usage"
|
||||
:stroke-color="getUsageColor(detailServer.disk.usage)"
|
||||
/>
|
||||
<div class="resource-detail">{{ detailServer.disk.used }}GB / {{ detailServer.disk.total }}GB</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-divider orientation="left">快捷操作</a-divider>
|
||||
|
||||
<a-space wrap>
|
||||
<a-button v-if="detailServer.panelUrl" @click="openPanel(detailServer)">
|
||||
<LinkOutlined /> 打开 1Panel
|
||||
</a-button>
|
||||
<a-button @click="handleRefreshStatus(detailServer)">
|
||||
<ReloadOutlined /> 刷新状态
|
||||
</a-button>
|
||||
<a-button @click="goToLogs(detailServer)">
|
||||
<FileTextOutlined /> 查看日志
|
||||
</a-button>
|
||||
<a-button @click="goToDomains(detailServer)">
|
||||
<GlobalOutlined /> 域名配置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
PlusOutlined,
|
||||
SearchOutlined,
|
||||
EyeOutlined,
|
||||
LinkOutlined,
|
||||
CloudServerOutlined,
|
||||
DesktopOutlined,
|
||||
ClusterOutlined,
|
||||
ReloadOutlined,
|
||||
FileTextOutlined,
|
||||
GlobalOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { ServerInfo, ServerStatus, ServerType } from '@/types/platform'
|
||||
import { mockServers } from '@/mock/platform'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const drawerVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const formLoading = ref(false)
|
||||
const formRef = ref()
|
||||
|
||||
const filterStatus = ref<ServerStatus | undefined>()
|
||||
const filterType = ref<ServerType | undefined>()
|
||||
const filterKeyword = ref('')
|
||||
|
||||
const servers = ref<ServerInfo[]>([])
|
||||
const currentServer = ref<ServerInfo | null>(null)
|
||||
const detailServer = ref<ServerInfo | null>(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
ip: '',
|
||||
internalIp: '',
|
||||
type: 'cloud' as ServerType,
|
||||
os: '',
|
||||
sshUser: 'root',
|
||||
sshPort: 22,
|
||||
panelUrl: '',
|
||||
panelPort: 8888,
|
||||
tags: [] as string[],
|
||||
description: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入服务器名称' }],
|
||||
ip: [{ required: true, message: '请输入外网IP' }],
|
||||
type: [{ required: true, message: '请选择服务器类型' }],
|
||||
os: [{ required: true, message: '请输入操作系统' }]
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '服务器', key: 'name', width: 200 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '类型', key: 'type', width: 100 },
|
||||
{ title: '资源使用', key: 'resources', width: 280 },
|
||||
{ title: '标签', key: 'tags', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
|
||||
]
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
let result = servers.value
|
||||
if (filterStatus.value) {
|
||||
result = result.filter(s => s.status === filterStatus.value)
|
||||
}
|
||||
if (filterType.value) {
|
||||
result = result.filter(s => s.type === filterType.value)
|
||||
}
|
||||
if (filterKeyword.value) {
|
||||
const keyword = filterKeyword.value.toLowerCase()
|
||||
result = result.filter(s =>
|
||||
s.name.toLowerCase().includes(keyword) ||
|
||||
s.ip.includes(keyword)
|
||||
)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const all = servers.value
|
||||
return {
|
||||
online: all.filter(s => s.status === 'online').length,
|
||||
offline: all.filter(s => s.status === 'offline').length,
|
||||
warning: all.filter(s => s.status === 'warning').length,
|
||||
total: all.length
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadServers()
|
||||
})
|
||||
|
||||
function loadServers() {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
servers.value = [...mockServers]
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
// 筛选已通过 computed 实现
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
filterStatus.value = undefined
|
||||
filterType.value = undefined
|
||||
filterKeyword.value = ''
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
currentServer.value = null
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
ip: '',
|
||||
internalIp: '',
|
||||
type: 'cloud',
|
||||
os: '',
|
||||
sshUser: 'root',
|
||||
sshPort: 22,
|
||||
panelUrl: '',
|
||||
panelPort: 8888,
|
||||
tags: [],
|
||||
description: ''
|
||||
})
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(record: ServerInfo) {
|
||||
currentServer.value = record
|
||||
Object.assign(formData, {
|
||||
name: record.name,
|
||||
ip: record.ip,
|
||||
internalIp: record.internalIp || '',
|
||||
type: record.type,
|
||||
os: record.os,
|
||||
sshUser: record.sshUser || 'root',
|
||||
sshPort: record.sshPort || 22,
|
||||
panelUrl: record.panelUrl || '',
|
||||
panelPort: record.panelPort || 8888,
|
||||
tags: [...record.tags],
|
||||
description: record.description || ''
|
||||
})
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
function handleView(record: ServerInfo) {
|
||||
detailServer.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
async function handleFormSubmit() {
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
formLoading.value = true
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
|
||||
if (currentServer.value) {
|
||||
// 编辑
|
||||
const index = servers.value.findIndex(s => s.id === currentServer.value!.id)
|
||||
if (index !== -1) {
|
||||
servers.value[index] = {
|
||||
...servers.value[index],
|
||||
name: formData.name,
|
||||
ip: formData.ip,
|
||||
internalIp: formData.internalIp,
|
||||
type: formData.type,
|
||||
os: formData.os,
|
||||
sshUser: formData.sshUser,
|
||||
sshPort: formData.sshPort,
|
||||
panelUrl: formData.panelUrl,
|
||||
panelPort: formData.panelPort,
|
||||
tags: formData.tags,
|
||||
description: formData.description,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
message.success('服务器信息已更新')
|
||||
} else {
|
||||
// 新增
|
||||
const newServer: ServerInfo = {
|
||||
id: `server-${Date.now()}`,
|
||||
name: formData.name,
|
||||
ip: formData.ip,
|
||||
internalIp: formData.internalIp,
|
||||
port: 22,
|
||||
type: formData.type,
|
||||
status: 'offline',
|
||||
os: formData.os,
|
||||
cpu: { cores: 4, usage: 0 },
|
||||
memory: { total: 8, used: 0, usage: 0 },
|
||||
disk: { total: 100, used: 0, usage: 0 },
|
||||
sshUser: formData.sshUser,
|
||||
sshPort: formData.sshPort,
|
||||
panelUrl: formData.panelUrl,
|
||||
panelPort: formData.panelPort,
|
||||
tags: formData.tags,
|
||||
description: formData.description,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
servers.value.unshift(newServer)
|
||||
message.success('服务器添加成功')
|
||||
}
|
||||
|
||||
drawerVisible.value = false
|
||||
} catch (error) {
|
||||
message.error('操作失败')
|
||||
} finally {
|
||||
formLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(record: ServerInfo) {
|
||||
servers.value = servers.value.filter(s => s.id !== record.id)
|
||||
message.success('删除成功')
|
||||
}
|
||||
|
||||
function openPanel(record: ServerInfo) {
|
||||
if (record.panelUrl) {
|
||||
window.open(`${record.panelUrl}:${record.panelPort || 8888}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefreshStatus(record: ServerInfo) {
|
||||
message.loading('正在刷新服务器状态...', 1)
|
||||
setTimeout(() => {
|
||||
message.success('状态已刷新')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function goToLogs(record: ServerInfo) {
|
||||
router.push({ path: '/platform/log-center', query: { serverId: record.id } })
|
||||
}
|
||||
|
||||
function goToDomains(record: ServerInfo) {
|
||||
router.push({ path: '/platform/domains', query: { serverId: record.id } })
|
||||
}
|
||||
|
||||
function getStatusColor(status: ServerStatus): string {
|
||||
const colors: Record<ServerStatus, string> = {
|
||||
online: 'green',
|
||||
offline: 'default',
|
||||
warning: 'orange',
|
||||
maintenance: 'blue'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
|
||||
function getStatusText(status: ServerStatus): string {
|
||||
const texts: Record<ServerStatus, string> = {
|
||||
online: '在线',
|
||||
offline: '离线',
|
||||
warning: '告警',
|
||||
maintenance: '维护中'
|
||||
}
|
||||
return texts[status]
|
||||
}
|
||||
|
||||
function getTypeText(type: ServerType): string {
|
||||
const texts: Record<ServerType, string> = {
|
||||
cloud: '云服务器',
|
||||
physical: '物理机',
|
||||
virtual: '虚拟机'
|
||||
}
|
||||
return texts[type]
|
||||
}
|
||||
|
||||
function getUsageColor(usage: number): string {
|
||||
if (usage >= 80) return '#ff4d4f'
|
||||
if (usage >= 60) return '#faad14'
|
||||
return '#52c41a'
|
||||
}
|
||||
|
||||
function getTagColor(tag: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
'生产': 'red',
|
||||
'测试': 'blue',
|
||||
'开发': 'green',
|
||||
'备份': 'purple',
|
||||
'主节点': 'gold',
|
||||
'从节点': 'cyan',
|
||||
'离线': 'default'
|
||||
}
|
||||
return colors[tag] || 'default'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.platform-servers-page {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat-online {
|
||||
background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%);
|
||||
}
|
||||
|
||||
.stat-offline {
|
||||
background: linear-gradient(135deg, #8c8c8c 0%, #595959 100%);
|
||||
}
|
||||
|
||||
.stat-warning {
|
||||
background: linear-gradient(135deg, #faad14 0%, #d48806 100%);
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
margin-top: 4px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.server-info {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.server-ip {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.resource-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.resource-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.resource-item .label {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
.resource-item :deep(.ant-progress) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.resource-item :deep(.ant-progress-text) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.drawer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-resource-card {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resource-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.resource-detail {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user