From 183b295e40a334027c1eb31a6e75ff776c0a856c Mon Sep 17 00:00:00 2001 From: super Date: Thu, 15 Jan 2026 13:17:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=89=8D=E7=AB=AF=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E5=86=85=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agent/workflows/deploy-workflow.md | 129 ++++ .env | 2 + src/api/1panel.ts | 410 +++++++++++ src/api/certificate.ts | 168 +++++ src/api/domain.ts | 175 +++++ src/api/project.ts | 239 +++++++ src/api/server.ts | 155 +++++ src/api/system/approval.ts | 147 ++++ src/api/system/dept.ts | 86 +++ src/api/system/dict.ts | 112 +++ src/api/system/role.ts | 43 +- src/components.d.ts | 3 - src/components/FlowEditor/index.vue | 77 ++- src/components/ProjectUpload.vue | 13 + src/components/UploadCore.vue | 122 +++- src/components/common/IconPicker.vue | 6 +- src/components/system/dict/DictFormModal.vue | 25 +- src/components/system/dict/DictItemDrawer.vue | 79 ++- src/components/system/user/UserFormModal.vue | 65 +- src/types/approval.ts | 5 +- src/types/system/user.ts | 5 + src/views/platform/certificates/index.vue | 296 ++++---- src/views/platform/domains/index.vue | 380 +++++++---- src/views/platform/environments/index.vue | 6 +- src/views/platform/log-center/index.vue | 2 +- src/views/platform/projects/index.vue | 644 +++++++++++++++--- src/views/platform/servers/index.vue | 385 +++++++---- src/views/system/approval/index.vue | 45 +- src/views/system/approval/instances.vue | 10 +- src/views/system/dept/index.vue | 290 ++++++++ src/views/system/dict/index.vue | 43 +- src/views/system/roles/index.vue | 4 +- src/views/system/users/index.vue | 40 +- vite.config.ts | 8 +- 34 files changed, 3564 insertions(+), 655 deletions(-) create mode 100644 .agent/workflows/deploy-workflow.md create mode 100644 src/api/certificate.ts create mode 100644 src/api/domain.ts create mode 100644 src/api/project.ts create mode 100644 src/api/server.ts create mode 100644 src/api/system/approval.ts create mode 100644 src/api/system/dept.ts create mode 100644 src/api/system/dict.ts create mode 100644 src/views/system/dept/index.vue diff --git a/.agent/workflows/deploy-workflow.md b/.agent/workflows/deploy-workflow.md new file mode 100644 index 0000000..24b6928 --- /dev/null +++ b/.agent/workflows/deploy-workflow.md @@ -0,0 +1,129 @@ +# 项目部署流程设计文档 + +## 概述 + +本文档描述了项目管理模块与 1Panel 服务器的集成方案,采用**前后端分离架构**: + +- **前端**:负责 UI 交互,调用后端 API +- **后端**:存储项目数据到数据库,调用 1Panel API 执行部署 + +## 架构设计 + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 架构示意图 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 前端 │──────│ 后端 API │──────│ 1Panel │ │ +│ │ (Vue.js) │ HTTP │ (Spring) │ HTTP │ 服务器 │ │ +│ └─────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ +│ │ ▼ │ +│ │ ┌──────────┐ │ +│ │ │ MySQL │ │ +│ │ │ 数据库 │ │ +│ │ └──────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## 后端实现 + +### 1. 实体类扩展 (`PlatformProject.java`) + +新增字段: + +- `shortName` - 项目简称 +- `projectGroup` - 项目分组 +- `logo` - Logo 文字 +- `color` - 主题色 +- `domain` - 绑定域名 +- `enableHttps` - 是否启用 HTTPS +- `panelWebsiteId` - 1Panel 网站 ID +- `panelSslId` - 1Panel 证书 ID +- `lastDeployTime` - 最后部署时间 +- `lastDeployStatus` - 最后部署状态 +- `lastDeployMessage` - 最后部署消息 + +### 2. DTO 类 + +- `DeployRequest.java` - 部署请求参数 +- `DeployResult.java` - 部署结果 + +### 3. 服务类 + +- `OnePanelService.java` - 1Panel 服务接口 +- `OnePanelServiceImpl.java` - 1Panel 服务实现 + +### 4. 控制器 (`PlatformProjectController.java`) + +新增接口: +| 接口 | 方法 | 说明 | +|------|------|------| +| `/platform/project/deploy` | POST | 部署项目 | +| `/platform/project/{serverId}/websites` | GET | 获取网站列表 | +| `/platform/project/{serverId}/certificates` | GET | 获取证书列表 | +| `/platform/project/{serverId}/acme-accounts` | GET | 获取 Acme 账户 | +| `/platform/project/{serverId}/dns-accounts` | GET | 获取 DNS 账户 | + +### 5. 数据库迁移 + +执行 `src/main/resources/db/migration_project_deploy.sql` + +## 前端实现 + +### 1. API 封装 (`src/api/project.ts`) + +- `deployProject` - 部署项目 +- `getServerAcmeAccounts` - 获取 Acme 账户 +- `getServerDnsAccounts` - 获取 DNS 账户 + +### 2. 项目管理页面 + +- 部署按钮 +- 部署确认弹窗 +- 部署进度抽屉 + +## 完整部署流程 + +1. 前端:用户点击"部署"按钮 +2. 前端:打开部署确认弹窗,配置 HTTPS 选项 +3. 前端:调用后端 POST /platform/project/deploy +4. 后端:从数据库获取项目和服务器信息 +5. 后端:检查/创建网站 +6. 后端:如启用 HTTPS,检查/申请证书 +7. 后端:配置网站 HTTPS +8. 后端:更新项目状态到数据库 +9. 前端:显示部署结果 + +## 1Panel Token 生成算法 + +```java +// Token = md5('1panel' + API-Key + UnixTimestamp) +String rawToken = "1panel" + apiKey + timestamp; +String token = DigestUtils.md5DigestAsHex(rawToken.getBytes()); +``` + +## 配置说明 + +### 服务器配置 + +在 `platform_server` 表中配置: + +- `panel_port` - 1Panel 端口(默认 42588) +- `panel_api_key` - 1Panel API 密钥 + +### 项目配置 + +创建项目时配置: + +- `domain` - 绑定域名 +- `serverId` - 目标服务器 +- `deployPath` - 部署路径 + +## 安全注意事项 + +1. API 密钥存储在后端数据库,不暴露给前端 +2. 每次请求生成新 Token,防止重放攻击 +3. 建议在 1Panel 中配置 API 白名单 diff --git a/.env b/.env index 3665510..a7c3355 100644 --- a/.env +++ b/.env @@ -4,3 +4,5 @@ VITE_APP_TITLE=楠溪屿后台管理系统 # API基础URL VITE_API_BASE_URL=/api +# 1Panel API 配置 +VITE_1PANEL_API_KEY=KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI diff --git a/src/api/1panel.ts b/src/api/1panel.ts index 233e3e0..ab08904 100644 --- a/src/api/1panel.ts +++ b/src/api/1panel.ts @@ -707,6 +707,416 @@ export async function decompressFile(path: string, dst: string): Promise { }) } +// ==================== 网站管理 API ==================== + +/** + * 创建网站参数 + */ +export interface CreateWebsiteParams { + primaryDomain: string // 主域名 + alias: string // 网站别名(唯一标识) + type: 'static' | 'proxy' // 网站类型:static=静态站点, proxy=反向代理 + remark?: string // 备注 + + // 静态站点配置 + siteDir?: string // 站点目录(静态站点时使用) + + // 反向代理配置 + proxy?: string // 反向代理地址(如 http://127.0.0.1:3000) + proxyType?: string // 代理类型 + + // 其他配置 + otherDomains?: string // 其他域名,逗号分隔 + IPV6?: boolean // 是否启用 IPv6 + + // 运行时配置(静态站点) + runtimeID?: number // 运行时 ID (OpenResty) + runtimeType?: string // 运行时类型 + + // 额外配置 + appType?: string // 应用类型 + webSiteGroupID?: number // 网站分组ID + port?: number // 端口 +} + +/** + * 创建网站 + * @param params 创建参数 + */ +export async function createWebsite(params: CreateWebsiteParams): Promise { + const response = await panelService.post>('/websites', { + primaryDomain: params.primaryDomain, + alias: params.alias, + type: params.type || 'static', + remark: params.remark || '', + siteDir: params.siteDir || '', + proxy: params.proxy || '', + proxyType: params.proxyType || 'http', + otherDomains: params.otherDomains || '', + IPV6: params.IPV6 || false, + runtimeID: params.runtimeID || 0, + runtimeType: params.runtimeType || '', + appType: params.appType || 'new', + webSiteGroupID: params.webSiteGroupID || 0, + port: params.port || 80 + }) + return response.data.data.id +} + +/** + * 获取网站详情 + * @param id 网站ID + */ +export async function getWebsiteDetail(id: number): Promise { + const response = await panelService.get>(`/websites/${id}`) + return response.data.data +} + +/** + * 删除网站 + * @param id 网站ID + */ +export async function deleteWebsite(id: number, deleteApp: boolean = false, deleteBackup: boolean = false): Promise { + await panelService.post('/websites/del', { + id, + deleteApp, + deleteBackup, + forceDelete: false + }) +} + +/** + * 更新网站 + * @param id 网站ID + * @param params 更新参数 + */ +export async function updateWebsite(id: number, params: Partial): Promise { + await panelService.post(`/websites/update`, { + id, + ...params + }) +} + +// ==================== 网站 HTTPS 配置 API ==================== + +/** + * 网站 HTTPS 配置参数 + */ +export interface WebsiteHTTPSConfig { + websiteId: number // 网站ID + enable: boolean // 是否启用 HTTPS + type: string // 证书类型: 'select'=选择已有证书, 'import'=导入证书 + websiteSSLId?: number // 已有证书ID(type=select时使用) + httpConfig: string // HTTP配置: 'HTTPSOnly'=仅HTTPS, 'HTTPAlso'=同时HTTP, 'HTTPToHTTPS'=HTTP跳转HTTPS + SSLProtocol?: string[] // SSL协议版本 + algorithm?: string // 加密算法 +} + +/** + * 为网站配置 HTTPS + * @param config HTTPS配置 + */ +export async function configureWebsiteHTTPS(config: WebsiteHTTPSConfig): Promise { + await panelService.post(`/websites/${config.websiteId}/https`, { + enable: config.enable, + type: config.type, + websiteSSLId: config.websiteSSLId || 0, + httpConfig: config.httpConfig || 'HTTPToHTTPS', + SSLProtocol: config.SSLProtocol || ['TLSv1.2', 'TLSv1.3'], + algorithm: config.algorithm || 'ECDHE-ECDSA-AES128-GCM-SHA256' + }) +} + +/** + * 获取网站 HTTPS 配置 + * @param websiteId 网站ID + */ +export async function getWebsiteHTTPSConfig(websiteId: number): Promise { + const response = await panelService.get>(`/websites/${websiteId}/https`) + return response.data.data +} + +// ==================== 部署相关 API ==================== + +/** + * 上传文件到指定目录 + * @param path 目标路径(目录) + * @param file 文件 + * @param overwrite 是否覆盖 + */ +export async function uploadFileToPath(path: string, file: File, overwrite: boolean = true): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('path', path) + formData.append('overwrite', String(overwrite)) + + await panelService.post('/files/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + timeout: 300000 // 上传超时时间 5 分钟 + }) +} + +/** + * 大文件分块上传 + */ +export interface ChunkUploadParams { + path: string // 目标路径 + filename: string // 文件名 + chunk: Blob // 分块数据 + chunkIndex: number // 当前分块索引 + chunkCount: number // 总分块数 +} + +/** + * 分块上传文件 + * @param params 分块上传参数 + */ +export async function uploadChunk(params: ChunkUploadParams): Promise { + const formData = new FormData() + formData.append('chunk', params.chunk, params.filename) + formData.append('path', params.path) + formData.append('chunkIndex', String(params.chunkIndex)) + formData.append('chunkCount', String(params.chunkCount)) + + await panelService.post('/files/chunkupload', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + }, + timeout: 60000 + }) +} + +/** + * 合并分块文件 + * @param path 目标路径 + * @param filename 文件名 + */ +export async function mergeChunks(path: string, filename: string): Promise { + await panelService.post('/files/merge', { + path, + filename + }) +} + +/** + * 删除文件或目录 + * @param path 文件或目录路径 + */ +export async function deleteFile(path: string): Promise { + await panelService.post('/files/del', { + path, + isDir: false, + forceDelete: false + }) +} + +/** + * 创建目录 + * @param path 目录路径 + */ +export async function createDirectory(path: string): Promise { + await panelService.post('/files/mkdir', { + path, + mode: 755 + }) +} + +/** + * 检查文件/目录是否存在 + * @param path 路径 + */ +export async function checkPathExists(path: string): Promise { + try { + const response = await panelService.post>('/files/search', { + path: path.substring(0, path.lastIndexOf('/')), + expand: true, + page: 1, + pageSize: 100 + }) + const files = response.data.data || [] + const targetName = path.substring(path.lastIndexOf('/') + 1) + return files.some(f => f.name === targetName) + } catch { + return false + } +} + +// ==================== 完整部署流程 API ==================== + +/** + * 项目部署参数 + */ +export interface ProjectDeployParams { + // 项目信息 + projectId: string // 项目ID + projectName: string // 项目名称 + domain: string // 绑定域名 + + // 部署配置 + deployPath: string // 部署路径 + enableHttps: boolean // 是否启用HTTPS + + // SSL 配置(启用HTTPS时需要) + acmeAccountId?: number // Acme账户ID + dnsAccountId?: number // DNS账户ID + + // 可选配置 + otherDomains?: string // 其他域名 + createIfNotExist?: boolean // 如果网站不存在则创建 +} + +/** + * 部署结果 + */ +export interface DeployResult { + success: boolean + websiteId?: number + sslCertificateId?: number + message: string + steps: { + step: string + status: 'success' | 'failed' | 'skipped' + message?: string + }[] +} + +/** + * 检查域名对应的网站是否已存在 + * @param domain 域名 + */ +export async function findWebsiteByDomain(domain: string): Promise { + try { + const websites = await getAllWebsites() + return websites.find(w => w.primaryDomain === domain) || null + } catch { + return null + } +} + +/** + * 执行项目部署 + * 此函数封装了完整的部署流程: + * 1. 检查/创建网站 + * 2. 检查/申请SSL证书 + * 3. 配置HTTPS + * + * @param params 部署参数 + */ +export async function deployProject(params: ProjectDeployParams): Promise { + const result: DeployResult = { + success: false, + message: '', + steps: [] + } + + try { + // Step 1: 检查网站是否存在 + console.log(`[部署] 检查网站是否存在: ${params.domain}`) + let website = await findWebsiteByDomain(params.domain) + + if (!website) { + if (!params.createIfNotExist) { + result.steps.push({ step: '检查网站', status: 'failed', message: '网站不存在' }) + result.message = `域名 ${params.domain} 对应的网站不存在,请先在 1Panel 中创建` + return result + } + + // 创建静态网站 + console.log(`[部署] 创建静态网站: ${params.domain}`) + const websiteId = await createWebsite({ + primaryDomain: params.domain, + alias: params.domain.replace(/\./g, '_'), + type: 'static', + siteDir: params.deployPath, + otherDomains: params.otherDomains || '', + remark: `项目: ${params.projectName}`, + port: 80 + }) + + result.websiteId = websiteId + result.steps.push({ step: '创建网站', status: 'success', message: `网站ID: ${websiteId}` }) + + // 重新获取网站信息 + const websites = await getAllWebsites() + website = websites.find(w => w.id === websiteId) || null + } else { + result.websiteId = website.id + result.steps.push({ step: '检查网站', status: 'success', message: '网站已存在' }) + } + + // Step 2: 如果启用 HTTPS,检查/申请证书 + if (params.enableHttps && website) { + console.log(`[部署] 检查SSL证书: ${params.domain}`) + + // 查找已有证书 + let certificate = await findCertificateByDomain(params.domain, params.acmeAccountId || 1) + + if (!certificate) { + // 需要申请证书 + if (params.acmeAccountId && params.dnsAccountId) { + console.log(`[部署] 申请SSL证书: ${params.domain}`) + await applySSLCertificate({ + primaryDomain: params.domain, + otherDomains: params.otherDomains || '', + provider: 'dnsAccount', + acmeAccountId: params.acmeAccountId, + dnsAccountId: params.dnsAccountId, + autoRenew: true, + keyType: 'P256', + apply: true + }) + + // 等待证书申请完成(最多等待 120 秒) + for (let i = 0; i < 24; i++) { + await new Promise(resolve => setTimeout(resolve, 5000)) + certificate = await findCertificateByDomain(params.domain, params.acmeAccountId) + if (certificate) break + } + + if (certificate) { + result.sslCertificateId = certificate.id + result.steps.push({ step: '申请SSL证书', status: 'success', message: `证书ID: ${certificate.id}` }) + } else { + result.steps.push({ step: '申请SSL证书', status: 'failed', message: '证书申请超时或失败' }) + } + } else { + result.steps.push({ step: '申请SSL证书', status: 'skipped', message: '未配置 Acme/DNS 账户' }) + } + } else { + result.sslCertificateId = certificate.id + result.steps.push({ step: '检查SSL证书', status: 'success', message: '证书已存在' }) + } + + // Step 3: 配置 HTTPS + if (result.sslCertificateId && website) { + console.log(`[部署] 配置HTTPS: ${params.domain}`) + await configureWebsiteHTTPS({ + websiteId: website.id, + enable: true, + type: 'select', + websiteSSLId: result.sslCertificateId, + httpConfig: 'HTTPToHTTPS' + }) + result.steps.push({ step: '配置HTTPS', status: 'success' }) + } + } else { + result.steps.push({ step: '配置HTTPS', status: 'skipped', message: '未启用HTTPS' }) + } + + result.success = true + result.message = '部署流程完成' + return result + + } catch (error: any) { + result.success = false + result.message = error.message || '部署过程中发生错误' + result.steps.push({ step: '异常', status: 'failed', message: error.message }) + return result + } +} + // 导出 panelService 实例供其他地方使用 export default panelService diff --git a/src/api/certificate.ts b/src/api/certificate.ts new file mode 100644 index 0000000..262b823 --- /dev/null +++ b/src/api/certificate.ts @@ -0,0 +1,168 @@ +/** + * 证书管理 API + * 对接后端接口,管理SSL/TLS证书 + */ +import { request } from '@/utils/request' + +// ==================== 类型定义 ==================== + +export type CertificateStatus = 'valid' | 'expired' | 'pending' | 'error' + +export interface CertificateInfo { + id: number + serverId: number + serverName?: string + panelSslId?: number + primaryDomain: string + otherDomains?: string + cn?: string + organization?: string + provider?: string + acmeAccountId?: number + acmeAccountEmail?: string + dnsAccountId?: number + dnsAccountName?: string + dnsAccountType?: string + keyType?: string + status: CertificateStatus + autoRenew?: boolean + startDate?: string + expireDate?: string + certContent?: string + keyContent?: string + description?: string + lastSyncTime?: string + createdAt?: string + updatedAt?: string +} + +export interface CertificateQueryParams { + page: number + pageSize: number + serverId?: number + keyword?: string +} + +export interface CertificateApplyRequest { + serverId: number + primaryDomain: string + otherDomains?: string + acmeAccountId: number + dnsAccountId: number + keyType?: string + autoRenew?: boolean + description?: string +} + +export interface CertificateApplyStep { + step: string + status: 'success' | 'failed' | 'skipped' + message?: string +} + +export interface CertificateApplyResult { + success: boolean + message: string + panelSslId?: number + certificateId?: number + steps: CertificateApplyStep[] +} + +export interface AcmeAccount { + id: number + email: string + type?: string +} + +export interface DnsAccount { + id: number + name: string + type?: string +} + +export interface WebsiteInfo { + id: number + primaryDomain: string + alias?: string +} + +// ==================== 证书管理 API ==================== + +/** + * 获取证书列表(分页) + */ +export async function getCertificateList(params: CertificateQueryParams) { + const res = await request.get<{ records: CertificateInfo[]; total: number }>('/platform/certificate/list', { params }) + return res.data.data +} + +/** + * 获取证书详情(包含证书内容和私钥) + */ +export async function getCertificateDetail(id: number) { + const res = await request.get(`/platform/certificate/${id}`) + return res.data.data +} + +/** + * 删除证书 + */ +export async function deleteCertificate(id: number) { + await request.delete(`/platform/certificate/${id}`) +} + +/** + * 更新证书设置 + */ +export async function updateCertificateSettings(id: number, autoRenew?: boolean, description?: string) { + await request.put(`/platform/certificate/${id}/settings`, null, { + params: { autoRenew, description } + }) +} + +// ==================== 证书申请 API ==================== + +/** + * 申请证书 + * 调用1Panel API申请SSL证书 + */ +export async function applyCertificate(data: CertificateApplyRequest): Promise { + const res = await request.post('/platform/certificate/apply', data) + return res.data.data +} + +// ==================== 证书同步 API ==================== + +/** + * 从1Panel同步证书列表 + */ +export async function syncCertificates(serverId: number) { + const res = await request.post<{ syncCount: number; message: string }>(`/platform/certificate/sync/${serverId}`) + return res.data.data +} + +// ==================== 1Panel 账户查询 API ==================== + +/** + * 获取Acme账户列表 + */ +export async function getAcmeAccounts(serverId: number): Promise { + const res = await request.get(`/platform/certificate/acme-accounts/${serverId}`) + return res.data.data +} + +/** + * 获取DNS账户列表 + */ +export async function getDnsAccounts(serverId: number): Promise { + const res = await request.get(`/platform/certificate/dns-accounts/${serverId}`) + return res.data.data +} + +/** + * 获取网站列表 + */ +export async function getWebsites(serverId: number): Promise { + const res = await request.get(`/platform/certificate/websites/${serverId}`) + return res.data.data +} diff --git a/src/api/domain.ts b/src/api/domain.ts new file mode 100644 index 0000000..1886303 --- /dev/null +++ b/src/api/domain.ts @@ -0,0 +1,175 @@ +/** + * 域名管理 API + * 对接后端接口,管理域名的增删改查及部署 + */ +import { request } from '@/utils/request' + +// ==================== 类型定义 ==================== + +export type DomainStatus = 'active' | 'pending' | 'expired' | 'error' +export type DnsStatus = 'resolved' | 'unresolved' | 'checking' +export type SslStatus = 'valid' | 'expiring' | 'expired' | 'none' +export type DeployStatus = 'not_deployed' | 'deploying' | 'deployed' | 'failed' + +export interface DomainInfo { + id: number + domain: string + projectId?: number + projectName?: string + serverId?: number + serverName?: string + serverIp?: string + status: DomainStatus + dnsStatus: DnsStatus + dnsRecords?: string + sslStatus: SslStatus + sslExpireDate?: string + certificateId?: number + certificateName?: string + nginxConfigPath?: string + proxyPass?: string + port?: number + enableHttps?: boolean + forceHttps?: boolean + description?: string + // 1Panel 相关字段 + panelWebsiteId?: number + panelSslId?: number + sitePath?: string + alias?: string + deployStatus?: DeployStatus + lastDeployTime?: string + lastDeployMessage?: string + // 时间 + createdAt?: string + updatedAt?: string +} + +export interface DomainQueryParams { + page: number + pageSize: number + keyword?: string + status?: DomainStatus + sslStatus?: SslStatus + serverId?: number +} + +export interface DomainDeployRequest { + domainId: number + enableHttps?: boolean + acmeAccountId?: number + dnsAccountId?: number + createIfNotExist?: boolean +} + +export interface DomainDeployStep { + step: string + status: 'success' | 'failed' | 'skipped' + message?: string +} + +export interface DomainDeployResult { + success: boolean + message: string + websiteId?: number + sslCertificateId?: number + steps: DomainDeployStep[] +} + +// ==================== 域名管理 API ==================== + +/** + * 获取域名列表(分页) + */ +export async function getDomainList(params: DomainQueryParams) { + const res = await request.get<{ records: DomainInfo[]; total: number }>('/platform/domain/list', { params }) + return res.data.data +} + +/** + * 获取所有域名 + */ +export async function getAllDomains() { + const res = await request.get('/platform/domain/all') + return res.data.data +} + +/** + * 获取域名详情 + */ +export async function getDomainById(id: number) { + const res = await request.get(`/platform/domain/${id}`) + return res.data.data +} + +/** + * 创建域名 + */ +export async function createDomain(data: Partial) { + const res = await request.post('/platform/domain', data) + return res.data.data +} + +/** + * 更新域名 + */ +export async function updateDomain(id: number, data: Partial) { + await request.put(`/platform/domain/${id}`, data) +} + +/** + * 删除域名 + */ +export async function deleteDomain(id: number) { + await request.delete(`/platform/domain/${id}`) +} + +// ==================== 域名部署 API ==================== + +/** + * 部署域名到 1Panel + * 包含:创建网站、申请证书、配置HTTPS + */ +export async function deployDomain(data: DomainDeployRequest): Promise { + const res = await request.post('/platform/domain/deploy', data) + return res.data.data +} + +/** + * 检查域名 DNS 解析状态 + */ +export async function checkDomainDns(id: number) { + const res = await request.post<{ resolved: boolean; records?: string[] }>(`/platform/domain/${id}/check-dns`) + return res.data.data +} + +/** + * 同步域名信息到 1Panel + * 从 1Panel 获取最新的网站和证书信息并更新本地数据 + */ +export async function syncDomainFromPanel(id: number) { + const res = await request.post(`/platform/domain/${id}/sync`) + return res.data.data +} + +/** + * 获取域名统计信息 + */ +export async function getDomainStats() { + const res = await request.get<{ + total: number + active: number + pending: number + sslExpiring: number + deployed: number + }>('/platform/domain/stats') + return res.data.data +} + +/** + * 从证书同步域名 + */ +export async function syncDomainsFromCertificates(serverId: number) { + const res = await request.post<{ syncCount: number; message: string }>(`/platform/domain/sync-from-certificates/${serverId}`) + return res.data.data +} diff --git a/src/api/project.ts b/src/api/project.ts new file mode 100644 index 0000000..a1a72fd --- /dev/null +++ b/src/api/project.ts @@ -0,0 +1,239 @@ +/** + * 项目管理 API + */ +import { request } from '@/utils/request' + +export interface ProjectInfo { + id: number + name: string + code: string + shortName?: string + type: string + projectGroup?: string + logo?: string + color?: string + domain?: string + url?: string + domainId?: number + serverId?: number + serverName?: string + deployPath?: string + version?: string + status: string + icon?: string + description?: string + remark?: string + sort?: number + enableHttps?: boolean + panelWebsiteId?: number + panelSslId?: number + lastDeployTime?: string + lastDeployStatus?: string + lastDeployMessage?: string + createdTime?: string + updatedTime?: string +} + +export interface DeployRequest { + projectId: number + enableHttps?: boolean + acmeAccountId?: number + dnsAccountId?: number + createIfNotExist?: boolean +} + +export interface DeployStep { + step: string + status: 'success' | 'failed' | 'skipped' + message?: string +} + +export interface DeployResult { + success: boolean + message: string + websiteId?: number + sslCertificateId?: number + steps: DeployStep[] +} + +export interface AcmeAccount { + id: number + email: string + type?: string +} + +export interface DnsAccount { + id: number + name: string + type: string +} + +// ==================== 项目管理 API ==================== + +/** + * 获取项目列表 + */ +export async function getProjectList(params: { page: number; pageSize: number; keyword?: string }) { + const res = await request.get<{ records: ProjectInfo[]; total: number }>('/platform/project/list', { params }) + return res.data.data +} + +/** + * 获取所有项目 + */ +export async function getAllProjects() { + const res = await request.get('/platform/project/all') + return res.data.data +} + +/** + * 获取项目详情 + */ +export async function getProjectById(id: number) { + const res = await request.get(`/platform/project/${id}`) + return res.data.data +} + +/** + * 创建项目 + */ +export async function createProject(data: Partial) { + const res = await request.post('/platform/project', data) + return res.data.data +} + +/** + * 更新项目 + */ +export async function updateProject(id: number, data: Partial) { + await request.put(`/platform/project/${id}`, data) +} + +/** + * 删除项目 + */ +export async function deleteProject(id: number) { + await request.delete(`/platform/project/${id}`) +} + +// ==================== 部署相关 API ==================== + +/** + * 部署项目到服务器 + */ +export async function deployProject(data: DeployRequest): Promise { + const res = await request.post('/platform/project/deploy', data) + return res.data.data +} + +/** + * 获取服务器网站列表 + */ +export async function getServerWebsites(serverId: number) { + const res = await request.get(`/platform/project/${serverId}/websites`) + return res.data.data +} + +/** + * 获取服务器证书列表 + */ +export async function getServerCertificates(serverId: number) { + const res = await request.get(`/platform/project/${serverId}/certificates`) + return res.data.data +} + +/** + * 获取Acme账户列表 + */ +export async function getServerAcmeAccounts(serverId: number): Promise<{ items: AcmeAccount[] }> { + const res = await request.get(`/platform/project/${serverId}/acme-accounts`) + // 后端返回的是 JSON 字符串,需要解析 + const jsonStr = res.data.data + try { + const data = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr + return { items: data?.data?.items || [] } + } catch { + return { items: [] } + } +} + +/** + * 获取DNS账户列表 + */ +export async function getServerDnsAccounts(serverId: number): Promise<{ items: DnsAccount[] }> { + const res = await request.get(`/platform/project/${serverId}/dns-accounts`) + // 后端返回的是 JSON 字符串,需要解析 + const jsonStr = res.data.data + try { + const data = typeof jsonStr === 'string' ? JSON.parse(jsonStr) : jsonStr + return { items: data?.data?.items || [] } + } catch { + return { items: [] } + } +} + +/** + * 检查项目部署状态 + */ +export interface DeployStatusResult { + projectId: number + domain: string + deployed: boolean + websiteId: number | null + message: string +} + +export async function checkDeployStatus(projectId: number): Promise { + const res = await request.get(`/platform/project/check-deploy/${projectId}`) + return res.data.data +} + +/** + * 检查文件是否存在 + */ +export async function checkUploadFiles(projectId: number, paths: string[]): Promise { + const res = await request.post('/platform/project/upload/check', { + projectId, + paths + }) + return res.data.data +} + +/** + * 准备上传(清理旧文件) + */ +export async function prepareUploadFile(projectId: number, path: string): Promise { + const res = await request.post('/platform/project/upload/prepare', { + projectId, + path + }) + return res.data.data +} + +/** + * 分片上传文件 + */ +export async function uploadFileChunk( + projectId: number, + filename: string, + path: string, + chunkIndex: number, + chunkCount: number, + chunk: Blob +): Promise { + const formData = new FormData() + formData.append('projectId', projectId.toString()) + formData.append('filename', filename) + formData.append('path', path) + formData.append('chunkIndex', chunkIndex.toString()) + formData.append('chunkCount', chunkCount.toString()) + formData.append('chunk', chunk) + + const res = await request.post('/platform/project/upload/chunk', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }) + return res.data.data +} + diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000..badd26c --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,155 @@ +/** + * 服务器管理 API + */ +import { request } from '@/utils/request' + +// ==================== 类型定义 ==================== + +export interface CpuInfo { + cores: number + usage: number +} + +export interface MemoryInfo { + total: number + used: number + usage: number +} + +export interface DiskInfo { + total: number + used: number + usage: number +} + +export type ServerStatus = 'online' | 'offline' | 'warning' | 'maintenance' +export type ServerType = 'physical' | 'virtual' | 'cloud' + +/** + * 服务器基础信息(数据库中的数据) + */ +export interface ServerBase { + id: number + name: string + ip: string + internalIp?: string + port: number + type: ServerType + status: ServerStatus + os?: string + cpuCores?: number + memoryTotal?: number + diskTotal?: number + tags?: string // JSON 字符串 + sshUser?: string + sshPort?: number + panelUrl?: string + panelPort?: number + panelApiKey?: string + description?: string + createdAt: string + updatedAt: string +} + +/** + * 服务器信息(包含实时资源使用情况) + */ +export interface ServerInfo { + id: number + name: string + ip: string + internalIp?: string + port: number + type: ServerType + status: ServerStatus + os?: string + tags: string[] + + panelUrl?: string + panelPort?: number + description?: string + createdAt: string + updatedAt: string + // 资源使用情况(从1Panel获取) + cpu: CpuInfo + memory: MemoryInfo + disk: DiskInfo +} + +export interface ServerFormData { + name: string + ip: string + internalIp?: string + port?: number + type: ServerType + os?: string + tags?: string[] + + panelUrl?: string + panelPort?: number + panelApiKey?: string + description?: string +} + +// ==================== API 方法 ==================== + +/** + * 获取所有服务器(基础数据,不含实时状态) + */ +export async function getServerList(params?: { keyword?: string; status?: string; type?: string }): Promise { + const res = await request.get('/platform/server/all', { params }) + return res.data.data +} + +/** + * 获取服务器实时状态(从1Panel获取CPU/内存/磁盘使用情况) + */ +export async function getServerStatus(id: number): Promise { + const res = await request.get(`/platform/server/${id}/status`) + return res.data.data +} + +/** + * 刷新服务器状态(同 getServerStatus) + */ +export async function refreshServerStatus(id: number): Promise { + const res = await request.post(`/platform/server/${id}/refresh`) + return res.data.data +} + +/** + * 获取服务器详情(基础数据) + */ +export async function getServerDetail(id: number): Promise { + const res = await request.get(`/platform/server/${id}`) + return res.data.data +} + +/** + * 创建服务器 + */ +export async function createServer(data: ServerFormData) { + const res = await request.post('/platform/server', { + ...data, + tags: data.tags ? JSON.stringify(data.tags) : null + }) + return res.data.data +} + +/** + * 更新服务器 + */ +export async function updateServer(id: number, data: ServerFormData) { + await request.put(`/platform/server/${id}`, { + ...data, + tags: data.tags ? JSON.stringify(data.tags) : null + }) +} + +/** + * 删除服务器 + */ +export async function deleteServer(id: number) { + await request.delete(`/platform/server/${id}`) +} + diff --git a/src/api/system/approval.ts b/src/api/system/approval.ts new file mode 100644 index 0000000..430b338 --- /dev/null +++ b/src/api/system/approval.ts @@ -0,0 +1,147 @@ +import { request } from '@/utils/request' +import type { PageResult } from '@/types/api/response' +import type { ApprovalTemplate, ApprovalInstance, ApprovalStats, ApprovalScenario, ApprovalInstanceStatus, ApprovalNode } from '@/types' + +// ==================== 统计 ==================== + +/** + * 获取审批统计数据 + */ +export async function getApprovalStats() { + const res = await request.get('/system/approval/stats') + return res.data.data +} + +// ==================== 模板管理 ==================== + +export interface TemplateQueryParams { + page?: number + pageSize?: number + scenario?: ApprovalScenario + enabled?: boolean +} + +/** + * 分页查询模板列表 + */ +export async function getApprovalTemplatePage(params: TemplateQueryParams) { + const res = await request.get>('/system/approval/template/page', { params }) + return res.data.data +} + +/** + * 获取所有模板列表 + */ +export async function getApprovalTemplateList(scenario?: ApprovalScenario) { + const res = await request.get('/system/approval/template/list', { + params: { scenario } + }) + return res.data.data +} + +/** + * 获取模板详情 + */ +export async function getApprovalTemplateDetail(id: number) { + const res = await request.get(`/system/approval/template/${id}`) + return res.data.data +} + +export interface TemplateCreateRequest { + name: string + description?: string + scenario: ApprovalScenario + enabled: boolean + nodes: ApprovalNode[] +} + +/** + * 创建模板 + */ +export async function createApprovalTemplate(data: TemplateCreateRequest) { + const res = await request.post('/system/approval/template', data) + return res.data.data +} + +/** + * 更新模板 + */ +export async function updateApprovalTemplate(id: number, data: TemplateCreateRequest) { + await request.put(`/system/approval/template/${id}`, data) +} + +/** + * 删除模板 + */ +export async function deleteApprovalTemplate(id: number) { + await request.delete(`/system/approval/template/${id}`) +} + +/** + * 切换模板启用状态 + */ +export async function toggleApprovalTemplate(id: number) { + await request.post(`/system/approval/template/${id}/toggle`) +} + +// ==================== 实例管理 ==================== + +export interface InstanceQueryParams { + page?: number + pageSize?: number + scenario?: ApprovalScenario + status?: ApprovalInstanceStatus + keyword?: string +} + +/** + * 分页查询实例列表 + */ +export async function getApprovalInstancePage(params: InstanceQueryParams) { + const res = await request.get>('/system/approval/instance/page', { params }) + return res.data.data +} + +/** + * 获取实例详情 + */ +export async function getApprovalInstanceDetail(id: number) { + const res = await request.get(`/system/approval/instance/${id}`) + return res.data.data +} + +/** + * 提交审批 + */ +export async function submitApprovalInstance(id: number) { + await request.post(`/system/approval/instance/${id}/submit`) +} + +export interface ApproveRequest { + nodeId: number + approverId: number + action: 'approve' | 'reject' + comment?: string +} + +/** + * 审批操作 + */ +export async function approveInstance(id: number, data: ApproveRequest) { + await request.post(`/system/approval/instance/${id}/approve`, data) +} + +/** + * 撤回审批 + */ +export async function withdrawApprovalInstance(id: number) { + await request.post(`/system/approval/instance/${id}/withdraw`) +} + +/** + * 取消审批 + */ +export async function cancelApprovalInstance(id: number) { + await request.post(`/system/approval/instance/${id}/cancel`) +} + diff --git a/src/api/system/dept.ts b/src/api/system/dept.ts new file mode 100644 index 0000000..14922a0 --- /dev/null +++ b/src/api/system/dept.ts @@ -0,0 +1,86 @@ +import { request } from '@/utils/request' + +// 部门类型定义 +export interface DeptRecord { + id: number + parentId: number + name: string + code?: string + leaderId?: number + leaderName?: string + phone?: string + email?: string + sort: number + status: number + remark?: string + createdAt?: string + children?: DeptRecord[] +} + +export interface DeptFormData { + id?: number + parentId: number + name: string + code?: string + leaderId?: number + leaderName?: string + phone?: string + email?: string + sort: number + status: number + remark?: string +} + +/** + * 获取部门树 + */ +export async function getDeptTree() { + const res = await request.get('/system/dept/tree') + return res.data.data +} + +/** + * 获取部门列表(扁平结构) + */ +export async function getDeptList() { + const res = await request.get('/system/dept/list') + return res.data.data +} + +/** + * 获取部门详情 + */ +export async function getDeptDetail(id: number) { + const res = await request.get(`/system/dept/${id}`) + return res.data.data +} + +/** + * 创建部门 + */ +export async function createDept(data: DeptFormData) { + const res = await request.post('/system/dept', data) + return res.data.data +} + +/** + * 更新部门 + */ +export async function updateDept(id: number, data: DeptFormData) { + await request.put(`/system/dept/${id}`, data) +} + +/** + * 删除部门 + */ +export async function deleteDept(id: number) { + await request.delete(`/system/dept/${id}`) +} + +/** + * 获取部门用户列表 + */ +export async function getDeptUsers(id: number) { + const res = await request.get(`/system/dept/${id}/users`) + return res.data.data +} diff --git a/src/api/system/dict.ts b/src/api/system/dict.ts new file mode 100644 index 0000000..369c325 --- /dev/null +++ b/src/api/system/dict.ts @@ -0,0 +1,112 @@ +import request from '@/utils/request' + +// ==================== 字典类型 ==================== + +/** + * 分页查询字典 + */ +export function getDictPage(params: { page: number; pageSize: number; name?: string; code?: string }) { + return request.get('/system/dict/page', { params }) +} + +/** + * 获取所有字典 + */ +export function getDictList() { + return request.get('/system/dict/list') +} + +/** + * 获取字典详情 + */ +export function getDictById(id: number) { + return request.get(`/system/dict/${id}`) +} + +/** + * 根据编码获取字典 + */ +export function getDictByCode(code: string) { + return request.get(`/system/dict/code/${code}`) +} + +/** + * 创建字典 + */ +export function createDict(data: { name: string; code: string; remark?: string; status?: number }) { + return request.post('/system/dict', data) +} + +/** + * 更新字典 + */ +export function updateDict(id: number, data: { name: string; code: string; remark?: string; status?: number }) { + return request.put(`/system/dict/${id}`, data) +} + +/** + * 删除字典 + */ +export function deleteDict(id: number) { + return request.delete(`/system/dict/${id}`) +} + +// ==================== 字典项 ==================== + +/** + * 获取字典项列表 + */ +export function getDictItems(dictId: number) { + return request.get(`/system/dict/${dictId}/items`) +} + +/** + * 根据字典编码获取字典项 + */ +export function getDictItemsByCode(code: string) { + return request.get(`/system/dict/code/${code}/items`) +} + +/** + * 获取字典项详情 + */ +export function getDictItemById(id: number) { + return request.get(`/system/dict/item/${id}`) +} + +/** + * 创建字典项 + */ +export function createDictItem(data: { + dictId: number + label: string + value: string + sort?: number + isDefault?: number + remark?: string + status?: number +}) { + return request.post('/system/dict/item', data) +} + +/** + * 更新字典项 + */ +export function updateDictItem(id: number, data: { + dictId: number + label: string + value: string + sort?: number + isDefault?: number + remark?: string + status?: number +}) { + return request.put(`/system/dict/item/${id}`, data) +} + +/** + * 删除字典项 + */ +export function deleteDictItem(id: number) { + return request.delete(`/system/dict/item/${id}`) +} diff --git a/src/api/system/role.ts b/src/api/system/role.ts index 7c5c6ff..229806b 100644 --- a/src/api/system/role.ts +++ b/src/api/system/role.ts @@ -2,34 +2,41 @@ import { request } from '@/utils/request' import type { RoleRecord, RoleQuery, RoleFormData, RoleOption } from '@/types/system/role' import type { PageResult } from '@/types/api/response' -export function getRolePage(params: RoleQuery) { - return request.get>('/system/role/page', { params }) +// 导出类型供外部使用 +export type { RoleOption, RoleRecord } + +export async function getRolePage(params: RoleQuery) { + const res = await request.get>('/system/role/page', { params }) + return res.data.data } -export function getRoleList() { - return request.get('/system/role/list') +export async function getRoleList() { + const res = await request.get('/system/role/list-all') + return res.data.data } -export function getAllRoles() { - return request.get('/system/role/list-all') -} // Check if backend has list-all or if frontend filtered list. - -export function createRole(data: RoleFormData) { - return request.post('/system/role', data) +export async function getAllRoles() { + const res = await request.get('/system/role/list-all') + return res.data.data } -export function updateRole(data: RoleFormData) { - return request.put(`/system/role/${data.id}`, data) +export async function createRole(data: RoleFormData) { + await request.post('/system/role', data) } -export function deleteRole(id: number) { - return request.delete(`/system/role/${id}`) +export async function updateRole(data: RoleFormData) { + await request.put(`/system/role/${data.id}`, data) } -export function getRoleMenuIds(roleId: number) { - return request.get(`/system/role/${roleId}/menus`) +export async function deleteRole(id: number) { + await request.delete(`/system/role/${id}`) } -export function assignRoleMenus(roleId: number, menuIds: number[]) { - return request.post(`/system/role/${roleId}/menus`, { menuIds }) +export async function getRoleMenuIds(roleId: number) { + const res = await request.get(`/system/role/${roleId}/menus`) + return res.data.data +} + +export async function assignRoleMenus(roleId: number, menuIds: number[]) { + await request.post(`/system/role/${roleId}/menus`, menuIds) } diff --git a/src/components.d.ts b/src/components.d.ts index b113dc6..d407097 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -40,9 +40,6 @@ declare module 'vue' { ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent'] ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader'] ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider'] - AList: typeof import('ant-design-vue/es')['List'] - AListItem: typeof import('ant-design-vue/es')['ListItem'] - AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta'] AMenu: typeof import('ant-design-vue/es')['Menu'] AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] diff --git a/src/components/FlowEditor/index.vue b/src/components/FlowEditor/index.vue index 109dac5..4d25d2f 100644 --- a/src/components/FlowEditor/index.vue +++ b/src/components/FlowEditor/index.vue @@ -172,10 +172,25 @@ - + + {{ role.name }} + + + + + + @@ -250,6 +265,8 @@ import { import type { ApprovalNode, ApproverInfo } from '@/types' import { ApproverTypeMap } from '@/types' import { mockGetApprovers } from '@/mock' +import { getDeptTree, type DeptRecord } from '@/api/system/dept' +import { getRoleList, type RoleOption } from '@/api/system/role' interface Props { modelValue?: ApprovalNode[] @@ -300,6 +317,8 @@ const edges = ref([ const selectedNodeId = ref(null) const approverList = ref([]) +const deptTree = ref([]) +const roleList = ref([]) const nodeTypeList = [ { type: 'approval', label: '审批节点', icon: CheckCircleOutlined }, @@ -342,7 +361,9 @@ function onDrop(event: DragEvent) { approverType: 'specified', approverIds: [], approvalMode: 'or', - timeoutHours: 0 + timeoutHours: 0, + deptId: undefined, + deptName: '' } } @@ -411,13 +432,33 @@ function updateApproverDesc() { }).filter(Boolean) desc = names.join(', ') } else if (node.data.approverType === 'role' && node.data.approverRole) { - desc = `角色: ${node.data.approverRole}` + const role = roleList.value.find(r => r.code === node.data.approverRole) + desc = `角色: ${role?.name || node.data.approverRole}` + } else if (node.data.approverType === 'department' && node.data.deptName) { + desc = `部门: ${node.data.deptName}` } else { desc = ApproverTypeMap[node.data.approverType as keyof typeof ApproverTypeMap] || '' } updateNodeData('approverDesc', desc) } +function updateDeptName(deptId: number) { + const findDept = (list: DeptRecord[]): DeptRecord | undefined => { + for (const dept of list) { + if (dept.id === deptId) return dept + if (dept.children) { + const found = findDept(dept.children) + if (found) return found + } + } + return undefined + } + const dept = findDept(deptTree.value) + if (dept) { + updateNodeData('deptName', dept.name) + } +} + function deleteSelectedNode() { if (!selectedNodeId.value) return const nodeId = selectedNodeId.value @@ -441,6 +482,8 @@ function toApprovalNodes(): any[] { approverType: n.data.approverType, approverIds: n.data.approverIds, approverRole: n.data.approverRole, + deptId: n.data.deptId, + deptName: n.data.deptName, approvalMode: n.data.approvalMode, timeoutHours: n.data.timeoutHours, order: index + 1 @@ -466,7 +509,10 @@ function loadFromApprovalNodes(approvalNodes: ApprovalNode[]) { }).filter(Boolean) desc = names.join(', ') } else if (node.approverType === 'role' && node.approverRole) { - desc = `角色: ${node.approverRole}` + const role = roleList.value.find(r => r.code === node.approverRole) + desc = `角色: ${role?.name || node.approverRole}` + } else if (node.approverType === 'department' && node.deptName) { + desc = `部门: ${node.deptName}` } else { desc = ApproverTypeMap[node.approverType as keyof typeof ApproverTypeMap] || '' } @@ -480,6 +526,8 @@ function loadFromApprovalNodes(approvalNodes: ApprovalNode[]) { approverType: node.approverType, approverIds: node.approverIds || [], approverRole: node.approverRole, + deptId: node.deptId, + deptName: node.deptName || '', approvalMode: node.approvalMode, timeoutHours: node.timeoutHours, approverDesc: desc @@ -522,11 +570,16 @@ watch(() => props.modelValue, (newVal) => { }, { immediate: true }) onMounted(async () => { - approverList.value = await mockGetApprovers() - // 加载数据,此时 approverList 已就绪,由于 watch immediate 可能会先于 onMounted 执行(但 mock 没回), - // 所以这里需要再次检查并重新渲染描述(如果需要)。 - // 更好的方式是:mock 回来后,如果已经有节点,刷新描述。 - // 或者:mock 回来后,再调用一次 loadFromApprovalNodes。 + // 并行加载所有数据 + const [approvers, depts, roles] = await Promise.all([ + mockGetApprovers(), + getDeptTree().catch(() => []), + getRoleList().catch(() => []) + ]) + approverList.value = approvers + deptTree.value = depts + roleList.value = roles + if (props.modelValue?.length) { loadFromApprovalNodes(props.modelValue) } diff --git a/src/components/ProjectUpload.vue b/src/components/ProjectUpload.vue index 422caff..09b4a85 100644 --- a/src/components/ProjectUpload.vue +++ b/src/components/ProjectUpload.vue @@ -20,6 +20,8 @@ ref="uploadCoreRef" :auto-upload="false" :flex-mode="true" + :project-id="projectId" + :deploy-path="deployPath" @files-change="handleFilesChange" /> @@ -57,6 +59,8 @@ ref="uploadCoreRef" :auto-upload="false" :flex-mode="true" + :project-id="projectId" + :deploy-path="deployPath" @files-change="handleFilesChange" /> @@ -87,6 +91,7 @@ const props = withDefaults(defineProps<{ projectId?: string projectName?: string versionId?: string + deployPath?: string title?: string mode?: 'drawer' | 'modal' }>(), { @@ -107,6 +112,10 @@ watch(() => props.visible, (newVal) => { if (!newVal) { uploading.value = false fileCount.value = 0 + // 清空上传列表 + if (uploadCoreRef.value) { + uploadCoreRef.value.reset() + } } }) @@ -152,6 +161,10 @@ async function handleConfirm() { try { const files = await uploadCoreRef.value.startUpload() message.success('上传完成!') + + // 上传成功后清空列表 + uploadCoreRef.value.reset() + emit('uploaded', files) emit('update:visible', false) } catch (error) { diff --git a/src/components/UploadCore.vue b/src/components/UploadCore.vue index f1f10a0..cb2851a 100644 --- a/src/components/UploadCore.vue +++ b/src/components/UploadCore.vue @@ -112,11 +112,14 @@ import { } from '@ant-design/icons-vue' import type { UploadFile, DuplicateFile } from '@/types' import DuplicateFileModal from './DuplicateFileModal.vue' +import { checkUploadFiles, prepareUploadFile, uploadFileChunk } from '@/api/project' const props = withDefaults(defineProps<{ autoUpload?: boolean flexMode?: boolean existingFiles?: string[] // 已存在的文件路径列表 + projectId?: string + deployPath?: string }>(), { autoUpload: false, flexMode: false, @@ -217,19 +220,69 @@ function handleFolderSelect(event: Event) { /** * 添加文件到列表 */ -function addFiles(files: File[]) { +async function addFiles(files: File[]) { const newFiles: UploadFile[] = files.map(file => ({ uid: generateUid(), name: file.name, - path: (file as any).webkitRelativePath || file.name, + path: (file as any).relativePath || (file as any).webkitRelativePath || file.name, size: file.size, type: file.type, status: 'pending', percent: 0, file })) + + // 如果有 projectId 和 deployPath,进行服务端同名检测 + if (props.projectId && props.deployPath) { + const basePath = props.deployPath.replace(/\/$/, '') + const pathsToCheck = newFiles.map(f => { + const filePath = f.path.startsWith('/') ? f.path.substring(1) : f.path + return `${basePath}/${filePath}` + }) + + try { + const existingPaths = await checkUploadFiles(Number(props.projectId), pathsToCheck) + const existingSet = new Set(existingPaths) + + const duplicates: DuplicateFile[] = [] + const nonDuplicates: UploadFile[] = [] + + for (let i = 0; i < newFiles.length; i++) { + const file = newFiles[i] + const fullPath = pathsToCheck[i] + + if (existingSet.has(fullPath)) { + duplicates.push({ + uid: file.uid, + name: file.name, + path: file.path, + size: file.size, + file: file.file + }) + } else { + nonDuplicates.push(file) + } + } + + if (nonDuplicates.length > 0) { + fileList.value.push(...nonDuplicates) + } + + if (duplicates.length > 0) { + duplicateFiles.value = duplicates + pendingFiles.value = newFiles.filter(f => duplicates.some(d => d.uid === f.uid)) + duplicateModalVisible.value = true + } else if (props.autoUpload) { + startUpload() + } + } catch (error) { + console.error(error) + message.error('检查同名文件失败') + } + return + } - // 检测同名文件(与已存在文件和已添加文件对比) + // 原有的本地检测逻辑 (当没有 projectId 时使用) const existingPaths = new Set([ ...props.existingFiles, ...fileList.value.map(f => f.path) @@ -252,12 +305,10 @@ function addFiles(files: File[]) { } } - // 添加非重复文件 if (nonDuplicates.length > 0) { fileList.value.push(...nonDuplicates) } - // 如果有重复文件,显示提示弹窗 if (duplicates.length > 0) { duplicateFiles.value = duplicates pendingFiles.value = newFiles.filter(f => duplicates.some(d => d.uid === f.uid)) @@ -340,7 +391,7 @@ async function handleDrop(event: DragEvent) { } if (files.length > 0) { - addFilesWithPath(files) + addFiles(files) } } @@ -421,7 +472,62 @@ async function uploadFile(file: UploadFile): Promise { file.status = 'uploading' currentUploadFile.value = file - // 模拟上传过程 + // 服务端上传逻辑 + if (props.projectId && props.deployPath) { + try { + const projectIdNum = Number(props.projectId) + const basePath = props.deployPath.replace(/\/$/, '') + const filePath = file.path.startsWith('/') ? file.path.substring(1) : file.path + const fullPath = `${basePath}/${filePath}` + + // 1. 准备上传(清理可能存在的旧文件) + await prepareUploadFile(projectIdNum, fullPath) + + // 2. 计算目标目录(不包含文件名) + // 1Panel 接口需要的是上传到的目录路径 + const dirPath = fullPath.substring(0, fullPath.lastIndexOf('/') + 1) + + // 3. 分片上传 + const chunkSize = 2 * 1024 * 1024 // 2MB 分片 + const totalChunks = Math.ceil(file.size / chunkSize) + + // 如果文件为空,处理为 1 个空分片 + if (totalChunks === 0) { + await uploadFileChunk(projectIdNum, file.name, dirPath, 0, 1, file.file.slice(0, 0)) + file.percent = 100 + } else { + for (let i = 0; i < totalChunks; i++) { + const start = i * chunkSize + const end = Math.min(file.size, start + chunkSize) + const chunkBlob = file.file.slice(start, end) + + await uploadFileChunk( + projectIdNum, + file.name, + dirPath, + i, + totalChunks, + chunkBlob + ) + + const percent = Math.floor(((i + 1) / totalChunks) * 100) + file.percent = percent + } + } + + file.status = 'done' + currentUploadFile.value = null + return + } catch (error) { + console.error(error) + file.status = 'error' + message.error(`上传失败: ${file.name}`) + currentUploadFile.value = null + throw error // 抛出错误以便调用者捕获 + } + } + + // 模拟上传过程 (降级逻辑) return new Promise((resolve) => { let progress = 0 const interval = setInterval(() => { @@ -437,8 +543,6 @@ async function uploadFile(file: UploadFile): Promise { } }, 100) }) - - // TODO: 真实上传逻辑 } /** diff --git a/src/components/common/IconPicker.vue b/src/components/common/IconPicker.vue index 262c228..49d7639 100644 --- a/src/components/common/IconPicker.vue +++ b/src/components/common/IconPicker.vue @@ -19,7 +19,7 @@ @click="handleSelect(icon)" :title="icon" > - +
未找到图标 @@ -34,8 +34,8 @@ class="icon-input" > + diff --git a/src/types/approval.ts b/src/types/approval.ts index fe58a80..5c257c8 100644 --- a/src/types/approval.ts +++ b/src/types/approval.ts @@ -3,12 +3,13 @@ */ // 审批人选择方式 -export type ApproverType = 'specified' | 'role' | 'superior' | 'self_select' +export type ApproverType = 'specified' | 'role' | 'department' | 'superior' | 'self_select' // 审批人类型映射 export const ApproverTypeMap: Record = { specified: '指定人员', role: '按角色', + department: '按部门', superior: '上级领导', self_select: '发起人自选' } @@ -29,6 +30,8 @@ export interface ApprovalNode { approverType: ApproverType // 审批人选择方式 approverIds?: number[] // 指定审批人ID列表(approverType为specified时使用) approverRole?: string // 审批角色(approverType为role时使用) + deptId?: number // 部门ID(approverType为department时使用) + deptName?: string // 部门名称 approvalMode: ApprovalMode // 审批方式 timeoutHours?: number // 超时时间(小时) timeoutAction?: 'skip' | 'reject' // 超时操作 diff --git a/src/types/system/user.ts b/src/types/system/user.ts index bb4beac..128d5e4 100644 --- a/src/types/system/user.ts +++ b/src/types/system/user.ts @@ -10,6 +10,8 @@ export interface UserRecord { email?: string phone?: string role: string + deptId?: number + deptName?: string status: number createdAt?: string lastLoginTime?: string @@ -21,6 +23,7 @@ export interface UserQuery { pageSize?: number keyword?: string role?: string + deptId?: number status?: number } @@ -32,6 +35,8 @@ export interface UserFormData { phone?: string email?: string role?: string + deptId?: number + deptName?: string status: number remark?: string } diff --git a/src/views/platform/certificates/index.vue b/src/views/platform/certificates/index.vue index c750ff5..4bfedba 100644 --- a/src/views/platform/certificates/index.vue +++ b/src/views/platform/certificates/index.vue @@ -13,8 +13,8 @@ placeholder="请选择服务器" @change="handleServerChange" > - - {{ server.name }} ({{ server.address }}) + + {{ server.name }} ({{ server.ip }}) @@ -26,6 +26,9 @@ 申请证书 + + 同步证书 +
@@ -137,15 +140,7 @@ - - - DNS账号 - 手动解析 - HTTP - - - - + {{ account.label }} @@ -273,20 +268,25 @@ import { PlusOutlined, ArrowLeftOutlined, CopyOutlined, - DownloadOutlined + DownloadOutlined, + SyncOutlined } from '@ant-design/icons-vue' import type { TablePaginationConfig } from 'ant-design-vue' import { - getAllWebsites, + getCertificateList, + getCertificateDetail, + deleteCertificate, + applyCertificate, + syncCertificates, getAcmeAccounts, getDnsAccounts, - getSSLCertificates, - setPanelServer, - type Website, + getWebsites, + type CertificateInfo, type AcmeAccount, type DnsAccount, - type SSLCertificate -} from '@/api/1panel' + type WebsiteInfo +} from '@/api/certificate' +import { getServerList, type ServerBase } from '@/api/server' // 证书类型定义 interface Certificate { @@ -299,7 +299,7 @@ interface Certificate { startDate: string expireDate: string autoRenew: boolean - verifyType: 'dns' | 'manual' | 'http' + verifyType: 'dns' dnsAccount?: string dnsAccountName?: string dnsAccountType?: string @@ -429,63 +429,48 @@ MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyf8wwwwwwwwwwww } ] -// 服务器列表 mock 数据 -// 每个服务器对应不同的 1Panel 实例,有各自的地址和 API 密钥 +// 服务器列表(从后端获取) interface ServerConfig { - id: string + id: number name: string - address: string - apiKey: string + ip: string } -const serverList = ref([ - { - id: 'server1', - name: '主服务器', - address: '47.109.57.58:42588', - apiKey: import.meta.env.VITE_1PANEL_API_KEY || '' - }, - { - id: 'server2', - name: '测试服务器', - address: '192.168.1.100:42588', - apiKey: 'test_api_key_123' - }, - { - id: 'server3', - name: '备份服务器', - address: '10.0.0.1:42588', - apiKey: 'backup_api_key_456' +const serverListData = ref([]) + +// 加载服务器列表 +async function loadServerList() { + try { + const servers = await getServerList() + serverListData.value = servers.map((s: ServerBase) => ({ + id: s.id, + name: s.name, + ip: s.ip + })) + // 默认选择第一个服务器 + if (serverListData.value.length > 0 && !selectedServer.value) { + selectedServer.value = serverListData.value[0].id + handleServerChange(serverListData.value[0].id) + } + } catch (error) { + console.error('获取服务器列表失败:', error) } -]) +} // 当前选中的服务器 -const selectedServer = ref('') -const currentServerConfig = ref(null) +const selectedServer = ref(undefined) // 处理服务器切换 -function handleServerChange(serverId: string) { - const server = serverList.value.find(s => s.id === serverId) - if (server) { - currentServerConfig.value = server - - // 切换 1Panel API 服务器配置 - setPanelServer({ - id: server.id, - address: server.address, - apiKey: server.apiKey - }) - - console.log(`[证书管理] 切换到服务器: ${server.name}`) - console.log(`[证书管理] 请求地址: http://${server.address}/api/v2`) - - // 重新加载证书列表 - loadCertificates() - // 重新加载下拉数据 - loadWebsites() - loadAcmeAccounts() - loadDnsAccounts() - } +function handleServerChange(serverId: number) { + selectedServer.value = serverId + console.log(`[证书管理] 切换到服务器ID: ${serverId}`) + + // 重新加载证书列表 + loadCertificates() + // 重新加载下拉数据 + loadWebsites() + loadAcmeAccounts() + loadDnsAccounts() } // 响应式状态 @@ -502,6 +487,9 @@ const pagination = reactive({ showTotal: (total: number) => `共 ${total} 条` }) +// 同步状态 +const syncing = ref(false) + // 申请/编辑抽屉 const applyDrawerVisible = ref(false) const isEditing = ref(false) @@ -516,7 +504,6 @@ const formData = reactive({ fromWebsite: undefined as string | undefined, acmeAccount: undefined as number | undefined, keyAlgorithm: 'EC 256', - verifyType: 'dns' as 'dns' | 'manual' | 'http', dnsAccount: undefined as number | undefined, autoRenew: true }) @@ -526,7 +513,6 @@ const formRules = { domain: [{ required: true, message: '请输入主域名' }], acmeAccount: [{ required: true, message: '请选择 Acme 账户' }], keyAlgorithm: [{ required: true, message: '请选择密钥算法' }], - verifyType: [{ required: true, message: '请选择验证方式' }], dnsAccount: [{ required: true, message: '请选择 DNS 账号' }] } @@ -537,96 +523,90 @@ const dnsAccountList = ref<{ value: number; label: string }[]>([]) // 加载网站列表 async function loadWebsites() { + if (!selectedServer.value) return try { - const websites = await getAllWebsites() - websiteList.value = websites.map((site: Website) => ({ + const websites = await getWebsites(selectedServer.value) + websiteList.value = websites.map((site: WebsiteInfo) => ({ value: site.primaryDomain, label: site.primaryDomain })) } catch (error) { console.error('获取网站列表失败:', error) - // 失败时使用默认数据 - websiteList.value = [ - { value: 'nanxiislet.com', label: 'nanxiislet.com' }, - { value: 'www.nanxiislet.com', label: 'www.nanxiislet.com' } - ] + websiteList.value = [] } } // 加载Acme账户列表 async function loadAcmeAccounts() { + if (!selectedServer.value) return try { - const result = await getAcmeAccounts() - acmeAccountList.value = (result.items || []).map((account: AcmeAccount) => ({ + const accounts = await getAcmeAccounts(selectedServer.value) + acmeAccountList.value = accounts.map((account: AcmeAccount) => ({ value: account.id, label: `${account.email} [${account.type || "Let's Encrypt"}]` })) } catch (error) { console.error('获取Acme账户列表失败:', error) - // 失败时使用默认数据 - acmeAccountList.value = [ - { value: 1, label: "acme@1paneldev.com [Let's Encrypt]" } - ] + acmeAccountList.value = [] } } // 加载DNS账户列表 async function loadDnsAccounts() { + if (!selectedServer.value) return try { - const result = await getDnsAccounts() - dnsAccountList.value = (result.items || []).map((account: DnsAccount) => ({ + const accounts = await getDnsAccounts(selectedServer.value) + dnsAccountList.value = accounts.map((account: DnsAccount) => ({ value: account.id, - label: `${account.name} [${account.type}]` + label: `${account.name} [${account.type || ''}]` })) } catch (error) { console.error('获取DNS账户列表失败:', error) - // 失败时使用默认数据 - dnsAccountList.value = [ - { value: 1, label: '阿里云 [阿里云]' }, - { value: 2, label: 'Cloudflare [Cloudflare]' } - ] + dnsAccountList.value = [] } } // 加载证书列表 async function loadCertificates() { + if (!selectedServer.value) return loading.value = true try { - const result = await getSSLCertificates({ + const result = await getCertificateList({ page: pagination.current || 1, - pageSize: pagination.pageSize || 20 + pageSize: pagination.pageSize || 20, + serverId: selectedServer.value }) // 转换API数据为本地格式 - certificateList.value = (result.items || []).map((cert: SSLCertificate) => { + certificateList.value = (result.records || []).map((cert: CertificateInfo) => { // 根据 acmeAccountId 查找对应的 Acme 账户 email const acmeAccount = acmeAccountList.value.find(a => a.value === cert.acmeAccountId) - const acmeAccountEmail = acmeAccount?.label.split(' [')[0] || '' + const acmeAccountEmail = acmeAccount?.label.split(' [')[0] || cert.acmeAccountEmail || '' // 根据 dnsAccountId 查找对应的 DNS 账户 const dnsAccount = dnsAccountList.value.find(d => d.value === cert.dnsAccountId) - const dnsAccountName = dnsAccount?.label.split(' [')[0] || '' - const dnsAccountType = dnsAccount?.label.match(/\[(.+)\]/)?.[1] || '' + const dnsAccountName = dnsAccount?.label.split(' [')[0] || cert.dnsAccountName || '' + const dnsAccountType = dnsAccount?.label.match(/\[(.+)\]/)?.[1] || cert.dnsAccountType || '' return { id: String(cert.id), domain: cert.primaryDomain, - otherDomain: cert.domains || '', - cn: cert.organization || '', + otherDomain: cert.otherDomains || '', + cn: cert.cn || '', issuer: cert.organization || "Let's Encrypt", - status: getSSLStatus(cert.status, cert.expireDate), + status: getSSLStatus(cert.status, cert.expireDate || ''), startDate: cert.startDate, expireDate: cert.expireDate, autoRenew: cert.autoRenew, - verifyType: getVerifyType(cert.provider), + verifyType: getVerifyType(cert.provider || ''), verifyTypeText: cert.provider || 'DNS账号', dnsAccount: String(cert.dnsAccountId || ''), dnsAccountName: dnsAccountName, dnsAccountType: dnsAccountType, acmeAccount: acmeAccountEmail, remark: cert.description || '', - certContent: '', - keyContent: '' + certContent: cert.certContent || '', + keyContent: cert.keyContent || '' } }) @@ -653,19 +633,13 @@ function getSSLStatus(status: string, expireDate: string): 'valid' | 'expired' | } // 获取验证方式 -function getVerifyType(provider: string): 'dns' | 'manual' | 'http' { - if (provider?.toLowerCase().includes('dns')) return 'dns' - if (provider?.toLowerCase().includes('http')) return 'http' +function getVerifyType(_provider: string): 'dns' { return 'dns' } // 页面加载时选择默认服务器 onMounted(() => { - // 默认选择第一个服务器 - if (serverList.value.length > 0) { - selectedServer.value = serverList.value[0].id - handleServerChange(serverList.value[0].id) - } + loadServerList() }) // 详情抽屉 @@ -701,8 +675,8 @@ function getVerifyTypeTextStatic(type?: string) { return texts[type || ''] || type || '-' } -function getVerifyTypeText(type?: string) { - return getVerifyTypeTextStatic(type) +function getVerifyTypeText(_type?: string) { + return 'DNS账号' } function isExpiringSoon(expireDate: string) { @@ -727,6 +701,27 @@ function handleWebsiteSelect(value: string | undefined) { } } +// 同步证书 +async function handleSync() { + if (!selectedServer.value) { + message.warning('请先选择服务器') + return + } + + syncing.value = true + try { + const result = await syncCertificates(selectedServer.value) + message.success(`${result.message}`) + // 重新加载证书列表 + await loadCertificates() + } catch (error) { + message.error('同步失败') + console.error('同步证书失败:', error) + } finally { + syncing.value = false + } +} + // 申请证书 function handleApply() { isEditing.value = false @@ -737,7 +732,6 @@ function handleApply() { fromWebsite: undefined, acmeAccount: undefined, keyAlgorithm: 'EC 256', - verifyType: 'dns', dnsAccount: undefined, autoRenew: true }) @@ -756,7 +750,6 @@ function handleEdit(record: Certificate) { fromWebsite: undefined, acmeAccount: undefined, // 编辑时需要重新选择 keyAlgorithm: 'EC 256', - verifyType: record.verifyType, dnsAccount: undefined, // 编辑时需要重新选择 autoRenew: record.autoRenew }) @@ -790,8 +783,8 @@ async function handleFormSubmit() { domain: formData.domain, otherDomain: formData.otherDomain, acmeAccount: acmeAccountStr, - verifyType: formData.verifyType, - verifyTypeText: getVerifyTypeTextStatic(formData.verifyType), + verifyType: 'dns', + verifyTypeText: 'DNS账号', dnsAccount: dnsAccountStr, dnsAccountName: dnsAccountStr, dnsAccountType: dnsAccountTypeStr, @@ -800,27 +793,25 @@ async function handleFormSubmit() { } message.success('证书更新成功') } else { - const newCert: Certificate & { verifyTypeText: string } = { - id: String(certificateList.value.length + 1), - domain: formData.domain, - otherDomain: formData.otherDomain, - cn: 'E7', - issuer: "Let's Encrypt", - status: 'pending', - startDate: new Date().toISOString().split('T')[0], - expireDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], - autoRenew: formData.autoRenew, - verifyType: formData.verifyType, - verifyTypeText: getVerifyTypeTextStatic(formData.verifyType), - dnsAccount: dnsAccountStr, - dnsAccountName: dnsAccountStr, - dnsAccountType: dnsAccountTypeStr, - acmeAccount: acmeAccountStr, - certContent: '证书申请中...', - keyContent: '证书申请中...' + if (!selectedServer.value) { + message.warning('请先选择服务器') + return } - certificateList.value.unshift(newCert) + + const applyData = { + serverId: Number(selectedServer.value), + primaryDomain: formData.domain, + otherDomains: formData.otherDomain, + acmeAccountId: Number(formData.acmeAccount), + dnsAccountId: Number(formData.dnsAccount), + keyType: formData.keyAlgorithm, + autoRenew: formData.autoRenew + } + + await applyCertificate(applyData) message.success('证书申请已提交') + applyDrawerVisible.value = false + loadCertificates() } applyDrawerVisible.value = false @@ -832,18 +823,41 @@ async function handleFormSubmit() { } // 查看详情 -function handleViewDetail(record: Certificate) { - currentCertificate.value = record +async function handleViewDetail(record: Certificate) { + currentCertificate.value = { ...record } detailActiveTab.value = 'info' detailDrawerVisible.value = true + + // 异步加载证书详情(包含证书内容和私钥) + try { + const detail = await getCertificateDetail(Number(record.id)) + if (detail) { + // 合并详情数据 + currentCertificate.value = { + ...currentCertificate.value, + certContent: detail.certContent || '', + keyContent: detail.keyContent || '', + cn: detail.cn || currentCertificate.value?.cn || '' + } + } + } catch (error) { + console.error('获取证书详情失败:', error) + } } // 删除证书 -function handleDelete(record: Certificate) { - const index = certificateList.value.findIndex(c => c.id === record.id) - if (index > -1) { - certificateList.value.splice(index, 1) +async function handleDelete(record: Certificate) { + try { + await deleteCertificate(Number(record.id)) + // 从列表中移除 + const index = certificateList.value.findIndex(c => c.id === record.id) + if (index > -1) { + certificateList.value.splice(index, 1) + } message.success('证书已删除') + } catch (error) { + message.error('删除失败') + console.error('删除证书失败:', error) } } diff --git a/src/views/platform/domains/index.vue b/src/views/platform/domains/index.vue index 06a8cdd..0214d5a 100644 --- a/src/views/platform/domains/index.vue +++ b/src/views/platform/domains/index.vue @@ -41,9 +41,14 @@ - - 添加域名 - +
+ + 添加域名 + + + 从证书同步 + +
@@ -158,16 +163,16 @@ diff --git a/src/views/platform/servers/index.vue b/src/views/platform/servers/index.vue index 13b593b..641b9ba 100644 --- a/src/views/platform/servers/index.vue +++ b/src/views/platform/servers/index.vue @@ -71,7 +71,7 @@
-
- CPU: +
+ CPU + {{ record.cpu.usage }}%
-
- 内存: +
+ + 内存 ({{ record.memory.total }}G) + + {{ record.memory.usage }}%
-
- 磁盘: +
+ 磁盘 + {{ record.disk.usage }}%
@@ -139,21 +144,21 @@