完善页面内容开发

This commit is contained in:
super
2026-01-06 16:12:34 +08:00
parent 82dcc17968
commit fef12b01e2
13 changed files with 4470 additions and 224 deletions

View File

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

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

View File

@@ -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
View 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)
}

View File

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

View File

@@ -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
}
},
// 系统设置
{

View File

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

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

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

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

View File

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

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