完成前端部署内容

This commit is contained in:
super
2026-01-15 13:17:41 +08:00
parent 8fa07e4952
commit 183b295e40
34 changed files with 3564 additions and 655 deletions

View File

@@ -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 白名单

2
.env
View File

@@ -4,3 +4,5 @@ VITE_APP_TITLE=楠溪屿后台管理系统
# API基础URL
VITE_API_BASE_URL=/api
# 1Panel API 配置
VITE_1PANEL_API_KEY=KBya4QxxPB3b8eYCSECOfhgWpXE0ZhuI

View File

@@ -707,6 +707,416 @@ export async function decompressFile(path: string, dst: string): Promise<void> {
})
}
// ==================== 网站管理 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<number> {
const response = await panelService.post<PanelResponse<{ id: number }>>('/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<Website & { domains: any[], path: string }> {
const response = await panelService.get<PanelResponse<Website & { domains: any[], path: string }>>(`/websites/${id}`)
return response.data.data
}
/**
* 删除网站
* @param id 网站ID
*/
export async function deleteWebsite(id: number, deleteApp: boolean = false, deleteBackup: boolean = false): Promise<void> {
await panelService.post('/websites/del', {
id,
deleteApp,
deleteBackup,
forceDelete: false
})
}
/**
* 更新网站
* @param id 网站ID
* @param params 更新参数
*/
export async function updateWebsite(id: number, params: Partial<CreateWebsiteParams>): Promise<void> {
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 // 已有证书IDtype=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<void> {
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<any> {
const response = await panelService.get<PanelResponse<any>>(`/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<void> {
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<void> {
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<void> {
await panelService.post('/files/merge', {
path,
filename
})
}
/**
* 删除文件或目录
* @param path 文件或目录路径
*/
export async function deleteFile(path: string): Promise<void> {
await panelService.post('/files/del', {
path,
isDir: false,
forceDelete: false
})
}
/**
* 创建目录
* @param path 目录路径
*/
export async function createDirectory(path: string): Promise<void> {
await panelService.post('/files/mkdir', {
path,
mode: 755
})
}
/**
* 检查文件/目录是否存在
* @param path 路径
*/
export async function checkPathExists(path: string): Promise<boolean> {
try {
const response = await panelService.post<PanelResponse<FileInfo[]>>('/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<Website | null> {
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<DeployResult> {
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

168
src/api/certificate.ts Normal file
View File

@@ -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<CertificateInfo>(`/platform/certificate/${id}`)
return res.data.data
}
/**
* 删除证书
*/
export async function deleteCertificate(id: number) {
await request.delete<void>(`/platform/certificate/${id}`)
}
/**
* 更新证书设置
*/
export async function updateCertificateSettings(id: number, autoRenew?: boolean, description?: string) {
await request.put<void>(`/platform/certificate/${id}/settings`, null, {
params: { autoRenew, description }
})
}
// ==================== 证书申请 API ====================
/**
* 申请证书
* 调用1Panel API申请SSL证书
*/
export async function applyCertificate(data: CertificateApplyRequest): Promise<CertificateApplyResult> {
const res = await request.post<CertificateApplyResult>('/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<AcmeAccount[]> {
const res = await request.get<AcmeAccount[]>(`/platform/certificate/acme-accounts/${serverId}`)
return res.data.data
}
/**
* 获取DNS账户列表
*/
export async function getDnsAccounts(serverId: number): Promise<DnsAccount[]> {
const res = await request.get<DnsAccount[]>(`/platform/certificate/dns-accounts/${serverId}`)
return res.data.data
}
/**
* 获取网站列表
*/
export async function getWebsites(serverId: number): Promise<WebsiteInfo[]> {
const res = await request.get<WebsiteInfo[]>(`/platform/certificate/websites/${serverId}`)
return res.data.data
}

175
src/api/domain.ts Normal file
View File

@@ -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<DomainInfo[]>('/platform/domain/all')
return res.data.data
}
/**
* 获取域名详情
*/
export async function getDomainById(id: number) {
const res = await request.get<DomainInfo>(`/platform/domain/${id}`)
return res.data.data
}
/**
* 创建域名
*/
export async function createDomain(data: Partial<DomainInfo>) {
const res = await request.post<DomainInfo>('/platform/domain', data)
return res.data.data
}
/**
* 更新域名
*/
export async function updateDomain(id: number, data: Partial<DomainInfo>) {
await request.put<void>(`/platform/domain/${id}`, data)
}
/**
* 删除域名
*/
export async function deleteDomain(id: number) {
await request.delete<void>(`/platform/domain/${id}`)
}
// ==================== 域名部署 API ====================
/**
* 部署域名到 1Panel
* 包含创建网站、申请证书、配置HTTPS
*/
export async function deployDomain(data: DomainDeployRequest): Promise<DomainDeployResult> {
const res = await request.post<DomainDeployResult>('/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<DomainInfo>(`/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
}

239
src/api/project.ts Normal file
View File

@@ -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<ProjectInfo[]>('/platform/project/all')
return res.data.data
}
/**
* 获取项目详情
*/
export async function getProjectById(id: number) {
const res = await request.get<ProjectInfo>(`/platform/project/${id}`)
return res.data.data
}
/**
* 创建项目
*/
export async function createProject(data: Partial<ProjectInfo>) {
const res = await request.post<ProjectInfo>('/platform/project', data)
return res.data.data
}
/**
* 更新项目
*/
export async function updateProject(id: number, data: Partial<ProjectInfo>) {
await request.put<void>(`/platform/project/${id}`, data)
}
/**
* 删除项目
*/
export async function deleteProject(id: number) {
await request.delete<void>(`/platform/project/${id}`)
}
// ==================== 部署相关 API ====================
/**
* 部署项目到服务器
*/
export async function deployProject(data: DeployRequest): Promise<DeployResult> {
const res = await request.post<DeployResult>('/platform/project/deploy', data)
return res.data.data
}
/**
* 获取服务器网站列表
*/
export async function getServerWebsites(serverId: number) {
const res = await request.get<string>(`/platform/project/${serverId}/websites`)
return res.data.data
}
/**
* 获取服务器证书列表
*/
export async function getServerCertificates(serverId: number) {
const res = await request.get<string>(`/platform/project/${serverId}/certificates`)
return res.data.data
}
/**
* 获取Acme账户列表
*/
export async function getServerAcmeAccounts(serverId: number): Promise<{ items: AcmeAccount[] }> {
const res = await request.get<string>(`/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<string>(`/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<DeployStatusResult> {
const res = await request.get<DeployStatusResult>(`/platform/project/check-deploy/${projectId}`)
return res.data.data
}
/**
* 检查文件是否存在
*/
export async function checkUploadFiles(projectId: number, paths: string[]): Promise<string[]> {
const res = await request.post<string[]>('/platform/project/upload/check', {
projectId,
paths
})
return res.data.data
}
/**
* 准备上传(清理旧文件)
*/
export async function prepareUploadFile(projectId: number, path: string): Promise<boolean> {
const res = await request.post<boolean>('/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<any> {
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<any>('/platform/project/upload/chunk', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return res.data.data
}

155
src/api/server.ts Normal file
View File

@@ -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<ServerBase[]> {
const res = await request.get<ServerBase[]>('/platform/server/all', { params })
return res.data.data
}
/**
* 获取服务器实时状态从1Panel获取CPU/内存/磁盘使用情况)
*/
export async function getServerStatus(id: number): Promise<ServerInfo> {
const res = await request.get<ServerInfo>(`/platform/server/${id}/status`)
return res.data.data
}
/**
* 刷新服务器状态(同 getServerStatus
*/
export async function refreshServerStatus(id: number): Promise<ServerInfo> {
const res = await request.post<ServerInfo>(`/platform/server/${id}/refresh`)
return res.data.data
}
/**
* 获取服务器详情(基础数据)
*/
export async function getServerDetail(id: number): Promise<ServerBase> {
const res = await request.get<ServerBase>(`/platform/server/${id}`)
return res.data.data
}
/**
* 创建服务器
*/
export async function createServer(data: ServerFormData) {
const res = await request.post<ServerBase>('/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<void>(`/platform/server/${id}`, {
...data,
tags: data.tags ? JSON.stringify(data.tags) : null
})
}
/**
* 删除服务器
*/
export async function deleteServer(id: number) {
await request.delete<void>(`/platform/server/${id}`)
}

147
src/api/system/approval.ts Normal file
View File

@@ -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<ApprovalStats>('/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<PageResult<ApprovalTemplate>>('/system/approval/template/page', { params })
return res.data.data
}
/**
* 获取所有模板列表
*/
export async function getApprovalTemplateList(scenario?: ApprovalScenario) {
const res = await request.get<ApprovalTemplate[]>('/system/approval/template/list', {
params: { scenario }
})
return res.data.data
}
/**
* 获取模板详情
*/
export async function getApprovalTemplateDetail(id: number) {
const res = await request.get<ApprovalTemplate>(`/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<number>('/system/approval/template', data)
return res.data.data
}
/**
* 更新模板
*/
export async function updateApprovalTemplate(id: number, data: TemplateCreateRequest) {
await request.put<void>(`/system/approval/template/${id}`, data)
}
/**
* 删除模板
*/
export async function deleteApprovalTemplate(id: number) {
await request.delete<void>(`/system/approval/template/${id}`)
}
/**
* 切换模板启用状态
*/
export async function toggleApprovalTemplate(id: number) {
await request.post<void>(`/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<PageResult<ApprovalInstance>>('/system/approval/instance/page', { params })
return res.data.data
}
/**
* 获取实例详情
*/
export async function getApprovalInstanceDetail(id: number) {
const res = await request.get<ApprovalInstance>(`/system/approval/instance/${id}`)
return res.data.data
}
/**
* 提交审批
*/
export async function submitApprovalInstance(id: number) {
await request.post<void>(`/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<void>(`/system/approval/instance/${id}/approve`, data)
}
/**
* 撤回审批
*/
export async function withdrawApprovalInstance(id: number) {
await request.post<void>(`/system/approval/instance/${id}/withdraw`)
}
/**
* 取消审批
*/
export async function cancelApprovalInstance(id: number) {
await request.post<void>(`/system/approval/instance/${id}/cancel`)
}

86
src/api/system/dept.ts Normal file
View File

@@ -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<DeptRecord[]>('/system/dept/tree')
return res.data.data
}
/**
* 获取部门列表(扁平结构)
*/
export async function getDeptList() {
const res = await request.get<DeptRecord[]>('/system/dept/list')
return res.data.data
}
/**
* 获取部门详情
*/
export async function getDeptDetail(id: number) {
const res = await request.get<DeptRecord>(`/system/dept/${id}`)
return res.data.data
}
/**
* 创建部门
*/
export async function createDept(data: DeptFormData) {
const res = await request.post<number>('/system/dept', data)
return res.data.data
}
/**
* 更新部门
*/
export async function updateDept(id: number, data: DeptFormData) {
await request.put<void>(`/system/dept/${id}`, data)
}
/**
* 删除部门
*/
export async function deleteDept(id: number) {
await request.delete<void>(`/system/dept/${id}`)
}
/**
* 获取部门用户列表
*/
export async function getDeptUsers(id: number) {
const res = await request.get<number[]>(`/system/dept/${id}/users`)
return res.data.data
}

112
src/api/system/dict.ts Normal file
View File

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

View File

@@ -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<PageResult<RoleRecord>>('/system/role/page', { params })
// 导出类型供外部使用
export type { RoleOption, RoleRecord }
export async function getRolePage(params: RoleQuery) {
const res = await request.get<PageResult<RoleRecord>>('/system/role/page', { params })
return res.data.data
}
export function getRoleList() {
return request.get<RoleRecord[]>('/system/role/list')
export async function getRoleList() {
const res = await request.get<RoleOption[]>('/system/role/list-all')
return res.data.data
}
export function getAllRoles() {
return request.get<RoleOption[]>('/system/role/list-all')
} // Check if backend has list-all or if frontend filtered list.
export function createRole(data: RoleFormData) {
return request.post<void>('/system/role', data)
export async function getAllRoles() {
const res = await request.get<RoleOption[]>('/system/role/list-all')
return res.data.data
}
export function updateRole(data: RoleFormData) {
return request.put<void>(`/system/role/${data.id}`, data)
export async function createRole(data: RoleFormData) {
await request.post<void>('/system/role', data)
}
export function deleteRole(id: number) {
return request.delete<void>(`/system/role/${id}`)
export async function updateRole(data: RoleFormData) {
await request.put<void>(`/system/role/${data.id}`, data)
}
export function getRoleMenuIds(roleId: number) {
return request.get<number[]>(`/system/role/${roleId}/menus`)
export async function deleteRole(id: number) {
await request.delete<void>(`/system/role/${id}`)
}
export function assignRoleMenus(roleId: number, menuIds: number[]) {
return request.post<void>(`/system/role/${roleId}/menus`, { menuIds })
export async function getRoleMenuIds(roleId: number) {
const res = await request.get<number[]>(`/system/role/${roleId}/menus`)
return res.data.data
}
export async function assignRoleMenus(roleId: number, menuIds: number[]) {
await request.post<void>(`/system/role/${roleId}/menus`, menuIds)
}

3
src/components.d.ts vendored
View File

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

View File

@@ -172,10 +172,25 @@
</a-form-item>
<a-form-item label="审批角色" v-if="selectedNode.data.approverType === 'role'">
<a-input
:value="selectedNode.data.approverRole"
placeholder="如:财务主管"
@input="(e: Event) => { updateNodeData('approverRole', (e.target as HTMLInputElement).value); updateApproverDesc() }"
<a-select
:value="selectedNode.data.approverRole"
placeholder="选择角色"
@change="(val: any) => { updateNodeData('approverRole', val); updateApproverDesc() }"
>
<a-select-option v-for="role in roleList" :key="role.code" :value="role.code">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="选择部门" v-if="selectedNode.data.approverType === 'department'">
<a-tree-select
:value="selectedNode.data.deptId"
:tree-data="deptTree"
placeholder="选择部门"
tree-default-expand-all
:field-names="{ children: 'children', label: 'name', value: 'id' }"
@change="(val: any) => { updateNodeData('deptId', val); updateDeptName(val); updateApproverDesc() }"
/>
</a-form-item>
@@ -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<Edge[]>([
const selectedNodeId = ref<string | null>(null)
const approverList = ref<ApproverInfo[]>([])
const deptTree = ref<DeptRecord[]>([])
const roleList = ref<RoleOption[]>([])
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)
}

View File

@@ -20,6 +20,8 @@
ref="uploadCoreRef"
:auto-upload="false"
:flex-mode="true"
:project-id="projectId"
:deploy-path="deployPath"
@files-change="handleFilesChange"
/>
</div>
@@ -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) {

View File

@@ -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<void> {
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<void> {
}
}, 100)
})
// TODO: 真实上传逻辑
}
/**

View File

@@ -19,7 +19,7 @@
@click="handleSelect(icon)"
:title="icon"
>
<component :is="Icons[icon as keyof typeof Icons]" />
<component :is="(Icons as any)[icon]" />
</div>
<div v-if="filteredIcons.length === 0" class="no-data">
未找到图标
@@ -34,8 +34,8 @@
class="icon-input"
>
<template #prefix>
<span v-if="modelValue && Icons[modelValue as keyof typeof Icons]" class="selected-icon">
<component :is="Icons[modelValue as keyof typeof Icons]" />
<span v-if="modelValue && (Icons as any)[modelValue]" class="selected-icon">
<component :is="(Icons as any)[modelValue]" />
</span>
</template>
<template #suffix>

View File

@@ -36,6 +36,7 @@
import { ref, reactive, watch } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import { createDict, updateDict } from '@/api/system/dict'
// 字典类型定义
interface DictRecord {
@@ -112,18 +113,26 @@ async function handleSubmit() {
await formRef.value?.validate()
submitLoading.value = true
// TODO: 接入真实API
// if (isEdit.value) {
// await updateDict(formData)
// } else {
// await createDict(formData)
// }
const data = {
name: formData.name,
code: formData.code,
remark: formData.remark,
status: formData.status
}
if (isEdit.value && formData.id) {
await updateDict(formData.id, data)
} else {
await createDict(data)
}
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('update:visible', false)
emit('success')
} catch (error) {
// 验证失败
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
}
} finally {
submitLoading.value = false
}

View File

@@ -36,10 +36,10 @@
</template>
<template v-if="column.dataIndex === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEditItem(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleEditItem(record as DictItemRecord)">编辑</a-button>
<a-popconfirm
title="确定删除此字典项吗?"
@confirm="handleDeleteItem(record.id)"
@confirm="handleDeleteItem((record as DictItemRecord).id!)"
>
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
@@ -94,6 +94,7 @@ import { ref, reactive, watch } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { FormInstance } from 'ant-design-vue'
import { getDictItems, createDictItem, updateDictItem, deleteDictItem } from '@/api/system/dict'
// 类型定义
interface DictRecord {
@@ -131,7 +132,7 @@ const columns = [
{ title: '默认', dataIndex: 'isDefault', width: 70 },
{ title: '状态', dataIndex: 'status', width: 70 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '操作', dataIndex: 'action', width: 120, fixed: 'right' }
{ title: '操作', dataIndex: 'action', width: 120, fixed: 'right' as const }
]
// 字典项表单
@@ -174,31 +175,11 @@ function handleClose() {
async function loadData(dictId: number) {
loading.value = true
try {
// TODO: 接入真实API
// const res = await getDictItemList(dictId)
// tableData.value = res.data.data
// 模拟数据
if (dictId === 1) {
tableData.value = [
{ id: 1, dictId: 1, label: '男', value: '1', sort: 1, isDefault: 1, status: 1, remark: '' },
{ id: 2, dictId: 1, label: '女', value: '2', sort: 2, isDefault: 0, status: 1, remark: '' },
{ id: 3, dictId: 1, label: '未知', value: '0', sort: 3, isDefault: 0, status: 1, remark: '' }
]
} else if (dictId === 2) {
tableData.value = [
{ id: 4, dictId: 2, label: '正常', value: '1', sort: 1, isDefault: 1, status: 1, remark: '' },
{ id: 5, dictId: 2, label: '禁用', value: '0', sort: 2, isDefault: 0, status: 1, remark: '' }
]
} else if (dictId === 3) {
tableData.value = [
{ id: 6, dictId: 3, label: '待审批', value: 'pending', sort: 1, isDefault: 1, status: 1, remark: '' },
{ id: 7, dictId: 3, label: '已通过', value: 'approved', sort: 2, isDefault: 0, status: 1, remark: '' },
{ id: 8, dictId: 3, label: '已驳回', value: 'rejected', sort: 3, isDefault: 0, status: 1, remark: '' }
]
} else {
tableData.value = []
}
const res = await getDictItems(dictId)
tableData.value = res.data.data || []
} catch (error) {
console.error('加载字典项失败:', error)
tableData.value = []
} finally {
loading.value = false
}
@@ -231,11 +212,16 @@ function handleEditItem(record: DictItemRecord) {
}
async function handleDeleteItem(id: number) {
// TODO: 接入真实API
// await deleteDictItem(id)
message.success('删除成功')
if (props.dictData) {
loadData(props.dictData.id)
try {
await deleteDictItem(id)
message.success('删除成功')
if (props.dictData) {
loadData(props.dictData.id)
}
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
}
}
}
@@ -244,20 +230,31 @@ async function handleSubmitItem() {
await formRef.value?.validate()
submitLoading.value = true
// TODO: 接入真实API
// if (isEditItem.value) {
// await updateDictItem(formData)
// } else {
// await createDictItem(formData)
// }
const data = {
dictId: formData.dictId!,
label: formData.label,
value: formData.value,
sort: formData.sort,
isDefault: formData.isDefault,
status: formData.status,
remark: formData.remark
}
if (isEditItem.value && formData.id) {
await updateDictItem(formData.id, data)
} else {
await createDictItem(data)
}
message.success(isEditItem.value ? '编辑成功' : '新增成功')
itemModalVisible.value = false
if (props.dictData) {
loadData(props.dictData.id)
}
} catch (error) {
// 验证失败
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
}
} finally {
submitLoading.value = false
}

View File

@@ -22,6 +22,17 @@
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="所属部门" name="deptId">
<a-tree-select
v-model:value="formData.deptId"
:tree-data="deptTreeData"
placeholder="请选择部门"
tree-default-expand-all
:field-names="{ children: 'children', label: 'name', value: 'id' }"
allow-clear
@change="handleDeptChange"
/>
</a-form-item>
<a-form-item label="角色" name="role">
<a-select v-model:value="formData.role" placeholder="请选择角色">
<a-select-option v-for="role in roleOptions" :key="role.code" :value="role.code">
@@ -43,11 +54,12 @@
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { ref, reactive, watch, computed, onMounted } from 'vue'
import type { FormInstance } from 'ant-design-vue'
import type { Rule } from 'ant-design-vue/es/form'
import { message } from 'ant-design-vue'
import { createUser, updateUser } from '@/api/system/user'
import { getDeptTree, type DeptRecord } from '@/api/system/dept'
import type { UserFormData } from '@/types/system/user'
interface RoleOption {
@@ -65,6 +77,7 @@ const emit = defineEmits(['update:visible', 'success'])
const loading = ref(false)
const formRef = ref<FormInstance>()
const deptTreeData = ref<DeptRecord[]>([])
const formData = reactive<UserFormData>({
username: '',
@@ -73,6 +86,8 @@ const formData = reactive<UserFormData>({
phone: '',
email: '',
role: undefined,
deptId: undefined,
deptName: undefined,
status: 1,
remark: ''
})
@@ -85,10 +100,44 @@ const formRules: Record<string, Rule[]> = {
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
// 加载部门树
async function loadDeptTree() {
try {
deptTreeData.value = await getDeptTree()
} catch (error) {
console.error('加载部门列表失败:', error)
}
}
// 部门选择变化时,更新部门名称
function handleDeptChange(value: number | undefined) {
if (!value) {
formData.deptName = undefined
return
}
// 递归查找部门名称
const findDeptName = (list: DeptRecord[]): string | undefined => {
for (const dept of list) {
if (dept.id === value) return dept.name
if (dept.children) {
const found = findDeptName(dept.children)
if (found) return found
}
}
return undefined
}
formData.deptName = findDeptName(deptTreeData.value)
}
watch(
() => props.visible,
(val) => {
if (val) {
// 确保部门树已加载
if (deptTreeData.value.length === 0) {
loadDeptTree()
}
if (props.record) {
Object.assign(formData, { ...props.record, password: '' })
} else {
@@ -100,6 +149,8 @@ watch(
phone: '',
email: '',
role: undefined,
deptId: undefined,
deptName: undefined,
status: 1,
remark: ''
})
@@ -110,13 +161,6 @@ watch(
async function handleOk() {
try {
// Skip password validation if editing
if (formData.id) {
// A quick hack for optional password in edit mode if not handled by dynamic rules
// AntDV form validation is powerful.
// Ideally we should use dynamic rules.
}
await formRef.value?.validate()
loading.value = true
@@ -141,4 +185,9 @@ function handleCancel() {
emit('update:visible', false)
formRef.value?.resetFields()
}
onMounted(() => {
loadDeptTree()
})
</script>

View File

@@ -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<ApproverType, string> = {
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 // 部门IDapproverType为department时使用
deptName?: string // 部门名称
approvalMode: ApprovalMode // 审批方式
timeoutHours?: number // 超时时间(小时)
timeoutAction?: 'skip' | 'reject' // 超时操作

View File

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

View File

@@ -13,8 +13,8 @@
placeholder="请选择服务器"
@change="handleServerChange"
>
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.address }})
<a-select-option v-for="server in serverListData" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.ip }})
</a-select-option>
</a-select>
</a-form-item>
@@ -26,6 +26,9 @@
<a-button type="primary" @click="handleApply" :disabled="!selectedServer">
<PlusOutlined /> 申请证书
</a-button>
<a-button @click="handleSync" :loading="syncing" :disabled="!selectedServer" style="margin-left: 12px;">
<SyncOutlined /> 同步证书
</a-button>
</div>
<!-- 证书列表 -->
@@ -137,15 +140,7 @@
</a-select>
</a-form-item>
<a-form-item label="验证方式" name="verifyType" required>
<a-radio-group v-model:value="formData.verifyType">
<a-radio value="dns">DNS账号</a-radio>
<a-radio value="manual">手动解析</a-radio>
<a-radio value="http">HTTP</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item v-if="formData.verifyType === 'dns'" label="DNS账号" name="dnsAccount" required>
<a-form-item label="DNS账号" name="dnsAccount" required>
<a-select v-model:value="formData.dnsAccount" placeholder="请选择 DNS 账号">
<a-select-option v-for="account in dnsAccountList" :key="account.value" :value="account.value">
{{ 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<ServerConfig[]>([
{
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<ServerConfig[]>([])
// 加载服务器列表
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<string>('')
const currentServerConfig = ref<ServerConfig | null>(null)
const selectedServer = ref<number | undefined>(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<TablePaginationConfig>({
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)
}
}

View File

@@ -41,9 +41,14 @@
</a-space>
</a-form-item>
</a-form>
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 添加域名
</a-button>
<div class="action-buttons">
<a-button type="primary" @click="handleAdd">
<PlusOutlined /> 添加域名
</a-button>
<a-button @click="handleSyncFromCert" :loading="syncing" style="margin-left: 12px">
<SyncOutlined /> 从证书同步
</a-button>
</div>
</div>
<!-- 统计卡片 -->
@@ -158,16 +163,16 @@
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button type="link" size="small" @click="handleEdit(record as DomainInfo)">
编辑
</a-button>
<a-button type="link" size="small" @click="handleViewNginx(record)">
<a-button type="link" size="small" @click="handleViewNginx(record as DomainInfo)">
<FileTextOutlined /> Nginx
</a-button>
<a-button type="link" size="small" @click="handleCheckDns(record)">
<a-button type="link" size="small" @click="handleCheckDns(record as DomainInfo)">
<ReloadOutlined /> 检测
</a-button>
<a-popconfirm title="确定删除此域名配置?" @confirm="handleDelete(record)">
<a-popconfirm title="确定删除此域名配置?" @confirm="handleDelete(record as DomainInfo)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
@@ -226,20 +231,30 @@
<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
v-model:value="formData.certificateId"
placeholder="请选择 SSL 证书"
:disabled="filteredCertificates.length === 0"
>
<a-select-option v-for="cert in filteredCertificates" :key="cert.id" :value="cert.id">
{{ cert.primaryDomain }} (过期{{ cert.expireDate }})
</a-select-option>
</a-select>
<div class="form-help">
没有合适的证书
<a-button type="link" size="small" @click="goToCertificates">前往申请</a-button>
<template v-if="!formData.serverId">
<span class="text-warning">请先选择目标服务器</span>
</template>
<template v-else-if="!formData.domain">
请先输入域名以匹配证书
</template>
<template v-else-if="filteredCertificates.length === 0">
<a-tag color="warning">该域名没有匹配的证书</a-tag>
<a-button type="link" size="small" @click="goToCertificates">前往申请</a-button>
</template>
<template v-else>
已找到 {{ filteredCertificates.length }} 个匹配的证书
</template>
</div>
</a-form-item>
@@ -306,7 +321,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import {
@@ -327,9 +342,21 @@ import {
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'
import type { DomainStatus, DnsStatus, SslStatus } from '@/types/platform'
import {
getDomainList,
getAllDomains,
createDomain,
updateDomain,
deleteDomain,
checkDomainDns,
getDomainStats,
syncDomainsFromCertificates,
type DomainInfo
} from '@/api/domain'
import { getServerList } from '@/api/server'
import { getAllProjects } from '@/api/project'
import { getCertificateList, type CertificateInfo } from '@/api/certificate'
const router = useRouter()
const route = useRoute()
@@ -348,22 +375,27 @@ 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 projectList = ref<{ id: number; name: string }[]>([])
const serverList = ref<{ id: number; name: string; ip: string }[]>([])
const certificateList = ref<CertificateInfo[]>([])
const formData = reactive({
const formData = reactive<{
domain: string
projectId?: number
serverId?: number
proxyPass: string
port: number
enableHttps: boolean
certificateId?: number
description: string
}>({
domain: '',
projectId: '',
serverId: '',
projectId: undefined,
serverId: undefined,
proxyPass: '',
port: 80,
enableHttps: false,
forceHttps: false,
certificateId: '',
certificateId: undefined,
description: ''
})
@@ -382,21 +414,7 @@ const columns = [
]
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
return domains.value
})
const stats = computed(() => {
@@ -409,23 +427,103 @@ const stats = computed(() => {
}
})
onMounted(() => {
// 根据输入的域名过滤匹配的证书
const filteredCertificates = computed(() => {
if (!formData.domain || !formData.serverId) return []
const inputDomain = formData.domain.toLowerCase()
return certificateList.value.filter(cert => {
// 匹配主域名
const primaryDomain = cert.primaryDomain?.toLowerCase() || ''
if (primaryDomain === inputDomain) return true
// 匹配通配符证书
if (primaryDomain.startsWith('*.')) {
const wildcardBase = primaryDomain.slice(2)
if (inputDomain.endsWith(wildcardBase)) return true
}
// 匹配其他域名
const otherDomains = cert.otherDomains?.toLowerCase() || ''
if (otherDomains.includes(inputDomain)) return true
return false
})
})
// 加载证书列表
async function loadCertificates(serverId: number) {
try {
const res = await getCertificateList({ page: 1, pageSize: 500, serverId })
certificateList.value = res.records || []
} catch (error) {
console.error('加载证书列表失败:', error)
certificateList.value = []
}
}
// 服务器变化时重新加载证书
watch(() => formData.serverId, async (newServerId) => {
if (newServerId) {
await loadCertificates(Number(newServerId))
} else {
certificateList.value = []
}
})
onMounted(async () => {
if (route.query.serverId) {
filterServer.value = route.query.serverId as string
}
loadDomains()
await Promise.all([loadDomains(), loadProjects(), loadServers()])
})
function loadDomains() {
async function loadDomains() {
loading.value = true
setTimeout(() => {
domains.value = [...mockDomains]
try {
const params = {
page: 1,
pageSize: 1000,
serverId: filterServer.value ? Number(filterServer.value) : undefined,
status: filterStatus.value,
sslStatus: filterSslStatus.value,
keyword: filterKeyword.value
}
const res = await getDomainList(params)
domains.value = res.records.map((d: any) => ({
...d,
id: String(d.id),
projectId: d.projectId ? String(d.projectId) : undefined,
serverId: d.serverId ? String(d.serverId) : undefined
}))
} catch (error) {
message.error('加载域名列表失败')
} finally {
loading.value = false
}, 300)
}
}
function handleSearch() {
// 筛选已通过 computed 实现
async function loadProjects() {
try {
const res = await getAllProjects()
projectList.value = res.map((p: any) => ({ id: p.id, name: p.name }))
} catch (error) {
console.error('加载项目列表失败', error)
}
}
async function loadServers() {
try {
const res = await getServerList()
serverList.value = res.map((s: any) => ({ id: s.id, name: s.name, ip: s.ip }))
} catch (error) {
console.error('加载服务器列表失败', error)
}
}
async function handleSearch() {
await loadDomains()
}
function handleReset() {
@@ -451,20 +549,59 @@ function handleAdd() {
drawerVisible.value = true
}
function handleEdit(record: DomainInfo) {
// 同步状态
const syncing = ref(false)
// 从证书同步域名
async function handleSyncFromCert() {
if (!filterServer.value) {
message.warning('请先在筛选栏选择服务器')
return
}
syncing.value = true
try {
const res = await syncDomainsFromCertificates(Number(filterServer.value))
message.success(res.message)
await loadDomains()
} catch (error) {
message.error('同步失败')
console.error(error)
} finally {
syncing.value = false
}
}
// 跳转到证书申请
function goToCertificates() {
// 如果有选中的服务器带上服务器ID参数
const query = formData.serverId ? { serverId: formData.serverId } : {}
router.push({ path: '/platform/certificates', query })
}
async 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 || ''
})
// 重置表单并赋值,确保类型为 Number
formData.domain = record.domain
formData.projectId = record.projectId ? Number(record.projectId) : undefined
formData.serverId = record.serverId ? Number(record.serverId) : undefined
formData.proxyPass = record.proxyPass || ''
formData.port = record.port || 80
formData.enableHttps = record.enableHttps
formData.certificateId = record.certificateId ? Number(record.certificateId) : undefined
formData.description = record.description || ''
drawerVisible.value = true
// 如果有服务器ID加载对应的证书列表
if (formData.serverId) {
try {
await loadCertificates(formData.serverId)
} catch (e) {
console.error('加载证书失败', e)
}
}
}
async function handleFormSubmit() {
@@ -476,70 +613,56 @@ async function handleFormSubmit() {
formLoading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const project = projectList.value.find(p => p.id === Number(formData.projectId))
const server = serverList.value.find(s => s.id === Number(formData.serverId))
const project = projectList.value.find(p => p.id === formData.projectId)
const server = serverList.value.find(s => s.id === formData.serverId)
const domainData = {
domain: formData.domain,
projectId: formData.projectId ? Number(formData.projectId) : undefined,
projectName: project?.name,
serverId: formData.serverId ? Number(formData.serverId) : undefined,
serverName: server?.name,
serverIp: server?.ip,
proxyPass: formData.proxyPass,
port: formData.port,
enableHttps: formData.enableHttps,
certificateId: formData.certificateId || undefined,
description: formData.description
}
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()
}
}
await updateDomain(Number(currentDomain.value.id), domainData)
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,
await createDomain({
...domainData,
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)
sslStatus: 'none',
deployStatus: 'not_deployed'
})
message.success('域名添加成功')
}
drawerVisible.value = false
} catch (error) {
await loadDomains() // 重新加载列表
} catch (error: any) {
message.error('操作失败')
} finally {
formLoading.value = false
}
}
function handleDelete(record: DomainInfo) {
domains.value = domains.value.filter(d => d.id !== record.id)
message.success('删除成功')
async function handleDelete(record: DomainInfo) {
try {
await deleteDomain(Number(record.id))
message.success('删除成功')
await loadDomains()
} catch (error) {
message.error('删除失败')
}
}
function handleViewNginx(record: DomainInfo) {
@@ -547,25 +670,31 @@ function handleViewNginx(record: DomainInfo) {
nginxDrawerVisible.value = true
}
function handleCheckDns(record: DomainInfo) {
async 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'
try {
const result = await checkDomainDns(Number(record.id))
if (result.resolved) {
const index = domains.value.findIndex(d => d.id === record.id)
if (index !== -1) {
domains.value[index].dnsStatus = 'resolved'
}
message.success('DNS 解析正常')
} else {
message.warning('DNS 未解析')
}
message.success('DNS 解析正常')
}, 2000)
} catch (error) {
message.error('DNS 检测失败')
}
}
function goToCertificates() {
router.push('/platform/certificates')
}
function generateNginxConfig(domain: DomainInfo): string {
let config = `server {\n`
if (domain.enableHttps && domain.forceHttps) {
if (domain.enableHttps) {
// HTTP 重定向
config += ` listen 80;\n`
config += ` server_name ${domain.domain};\n`
config += ` return 301 https://$server_name$request_uri;\n`
@@ -577,11 +706,6 @@ function generateNginxConfig(domain: DomainInfo): string {
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`

View File

@@ -141,7 +141,7 @@
<div class="var-value">
<template v-if="record.isSecret && !showSecrets">
<span class="secret-mask"></span>
<a-button type="link" size="small" @click="toggleShowValue(record)">
<a-button type="link" size="small" @click="toggleShowValue(record as EnvironmentVariable)">
<EyeOutlined />
</a-button>
</template>
@@ -168,10 +168,10 @@
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEditVariable(record)">
<a-button type="link" size="small" @click="handleEditVariable(record as EnvironmentVariable)">
编辑
</a-button>
<a-popconfirm title="确定删除此变量?" @confirm="handleDeleteVariable(record)">
<a-popconfirm title="确定删除此变量?" @confirm="handleDeleteVariable(record as EnvironmentVariable)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>

View File

@@ -173,7 +173,7 @@
</template>
<template v-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">
<a-button type="link" size="small" @click="handleViewDetail(record as LogRecord)">
<EyeOutlined /> 详情
</a-button>
</template>

View File

@@ -13,7 +13,7 @@
<!-- 项目列表 -->
<a-table
:columns="columns"
:data-source="projectStore.projects"
:data-source="projectList"
:loading="loading"
row-key="id"
:pagination="false"
@@ -44,8 +44,8 @@
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '启用' : '禁用' }}
<a-tag :color="record.status === 'active' ? 'green' : 'default'">
{{ record.status === 'active' ? '启用' : '禁用' }}
</a-tag>
</template>
@@ -62,12 +62,37 @@
<a-button type="primary" size="small" ghost @click="handleEnterProject(record as any)">
进入
</a-button>
<a-button type="link" size="small" @click="handleUpload(record as any)">
<a-button
v-if="getDeployStatus((record as any).id).deployed"
type="default"
size="small"
style="color: #52c41a; border-color: #52c41a;"
@click="handleDeploy(record as any)"
>
<CheckCircleOutlined /> 已部署
</a-button>
<a-button
v-else
type="primary"
size="small"
@click="handleDeploy(record as any)"
:disabled="!(record as any).domain"
:loading="getDeployStatus((record as any).id).checking"
>
<CloudUploadOutlined /> 部署
</a-button>
<a-button
type="link"
size="small"
@click="handleUpload(record as any)"
:disabled="!getDeployStatus((record as any).id).deployed"
:title="!getDeployStatus((record as any).id).deployed ? '请先部署项目' : ''"
>
<UploadOutlined /> 上传
</a-button>
<a-button type="link" size="small" @click="handleEdit(record as any)">编辑</a-button>
<a-switch
:checked="record.enabled"
:checked="record.status === 'active'"
size="small"
@change="() => handleToggle(record as any)"
/>
@@ -109,9 +134,9 @@
</a-select>
</a-form-item>
<a-form-item label="项目标识" name="id">
<a-form-item label="项目标识" name="code">
<a-input
v-model:value="formData.id"
v-model:value="formData.code"
placeholder="如codePort英文标识唯一"
:disabled="!!currentProject?.id"
/>
@@ -144,35 +169,6 @@
/>
</a-form-item>
<!-- 访问配置 -->
<a-divider orientation="left">访问配置</a-divider>
<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>
@@ -181,6 +177,7 @@
v-model:value="formData.serverId"
placeholder="请选择部署服务器"
allow-clear
@change="(val: any) => handleServerChange(val)"
>
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.ip }})
@@ -200,6 +197,36 @@
</div>
</a-form-item>
<!-- 访问配置 -->
<a-divider orientation="left">访问配置</a-divider>
<a-form-item label="绑定域名" name="domain">
<a-select
v-model:value="formData.domain"
placeholder="请先选择服务器"
allow-clear
show-search
:filter-option="filterDomain"
@change="onDomainChange"
:disabled="!formData.serverId"
>
<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>
@@ -322,8 +349,91 @@
v-model:visible="uploadVisible"
:project-id="uploadProjectId"
:project-name="uploadProjectName"
:deploy-path="uploadProjectDeployPath"
@uploaded="handleUploadComplete"
/>
<!-- 部署确认弹窗 -->
<a-modal
v-model:open="deployModalVisible"
title="部署到服务器"
:confirm-loading="deploying"
@ok="handleConfirmDeploy"
width="600px"
>
<div class="deploy-confirm-content">
<a-alert
v-if="!deployProject?.domain"
type="warning"
message="该项目未绑定域名,无法部署"
show-icon
style="margin-bottom: 16px;"
/>
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="项目名称">{{ deployProject?.name }}</a-descriptions-item>
<a-descriptions-item label="绑定域名">
<a-tag :color="deployProject?.domain ? 'green' : 'red'">
{{ deployProject?.domain || '未绑定' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="部署路径">{{ deployProject?.deployPath || '-' }}</a-descriptions-item>
<a-descriptions-item label="目标服务器">
{{ getServerName(deployProject?.serverId) || '-' }}
</a-descriptions-item>
</a-descriptions>
<a-alert
type="info"
style="margin-top: 16px;"
>
<template #message>
点击确定后系统将检查并创建 1Panel 网站
如果不绑定域名将无法部署
</template>
</a-alert>
</div>
</a-modal>
<!-- 部署进度抽屉 -->
<a-drawer
v-model:open="deployDrawerVisible"
title="部署进度"
width="500"
placement="right"
:closable="!deploying"
:mask-closable="!deploying"
>
<div class="deploy-progress">
<a-steps direction="vertical" :current="deployCurrentStep" :status="deployStepStatus">
<a-step v-for="(step, index) in deploySteps" :key="index" :title="step.title">
<template #description>
<span :class="['step-desc', step.status]">
{{ step.description }}
</span>
</template>
</a-step>
</a-steps>
<div v-if="deployResult" class="deploy-result">
<a-result
:status="deployResult.success ? 'success' : 'error'"
:title="deployResult.success ? '部署成功' : '部署失败'"
:sub-title="deployResult.message"
>
<template #extra>
<a-button v-if="deployResult.success && deployProject?.domain" type="primary" @click="openDeployedSite">
访问网站
</a-button>
<a-button @click="deployDrawerVisible = false">关闭</a-button>
</template>
</a-result>
</div>
</div>
</a-drawer>
</div>
</template>
@@ -338,35 +448,116 @@ import {
RollbackOutlined,
DeleteOutlined,
UploadOutlined,
FileOutlined
FileOutlined,
CloudUploadOutlined,
CheckCircleOutlined
} from '@ant-design/icons-vue'
import type { PlatformProject, ProjectMenuItem, ProjectVersion } from '@/mock/projects'
import { useProjectStore } from '@/stores'
import ProjectUpload from '@/components/ProjectUpload.vue'
import { mockServers, mockDomains } from '@/mock/platform'
import {
type ProjectInfo,
getProjectList,
createProject,
updateProject,
deleteProject,
getAllProjects,
deployProject as deployProjectApi,
getServerAcmeAccounts,
getServerDnsAccounts,
checkDeployStatus,
type AcmeAccount,
type DnsAccount,
type DeployResult
} from '@/api/project'
import { getServerList } from '@/api/server'
import { getDomainList } from '@/api/domain'
const router = useRouter()
const projectStore = useProjectStore()
// 服务器列表
const serverList = computed(() => mockServers.map(s => ({ id: s.id, name: s.name, ip: s.ip })))
// 项目列表
const projectList = ref<ProjectInfo[]>([])
const loading = ref(false)
// 域名列表
const domainList = computed(() => mockDomains.map(d => ({
id: d.id,
domain: d.domain,
enableHttps: d.enableHttps
})))
// 加载项目列表
async function loadData() {
loading.value = true
try {
// 获取所有项目(不分页或分页取全部)
const res = await getAllProjects()
projectList.value = res || []
// 加载完成后检查所有项目的部署状态
checkAllDeployStatus()
// 可选:同步更新到 Store (需要适配类型)
// projectStore.syncProjects(res)
} catch (error) {
console.error('加载项目列表失败:', error)
message.error('加载项目列表失败')
} finally {
loading.value = false
}
}
// 服务器列表
const serverList = ref<any[]>([])
const domainList = ref<any[]>([])
// 加载服务器列表
async function loadServers() {
try {
const res = await getServerList({})
serverList.value = res || []
} catch (error) {
console.error('加载服务器失败:', error)
}
}
// 服务器变更处理
async function handleServerChange(serverId: number, keepDomain = false) {
if (!keepDomain) {
formData.domain = undefined
formData.baseUrl = ''
formData.deployPath = ''
formData.domainId = undefined
}
if (serverId) {
try {
// 加载该服务器下的域名
const res = await getDomainList({
page: 1,
pageSize: 100,
serverId: Number(serverId)
})
domainList.value = res.records || []
} catch (error) {
console.error('加载域名失败:', error)
domainList.value = []
}
} else {
domainList.value = []
}
}
onMounted(() => {
loadServers()
loadData()
})
// 域名搜索过滤
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://'
@@ -375,21 +566,25 @@ function onDomainChange(domain: string) {
// 自动生成部署路径(根据 1Panel 的目录结构)
formData.deployPath = `/opt/1panel/www/sites/${domain}/index`
// 设置 domainId
formData.domainId = domainInfo?.id
} else {
formData.baseUrl = ''
formData.deployPath = ''
formData.domainId = undefined
}
}
const loading = ref(false)
const formVisible = ref(false)
const formLoading = ref(false)
const formRef = ref()
const currentProject = ref<PlatformProject | null>(null)
const currentProject = ref<any | null>(null)
// 版本管理
const versionDrawerVisible = ref(false)
const versionProject = ref<PlatformProject | null>(null)
const versionProject = ref<any | null>(null)
const newVersionDescription = ref('')
// 文件上传
@@ -398,6 +593,56 @@ const uploadProjectId = ref('')
const uploadProjectName = ref('')
const uploadVersionId = ref<string | null>(null) // 当前上传针对的版本ID
// 部署相关状态
const deployModalVisible = ref(false)
const deployDrawerVisible = ref(false)
const deploying = ref(false)
const deployProject = ref<(PlatformProject & { domain?: string; deployPath?: string; serverId?: string }) | null>(null)
const deployEnableHttps = ref(true)
const deployAcmeAccountId = ref<number | undefined>()
const deployDnsAccountId = ref<number | undefined>()
const acmeAccounts = ref<AcmeAccount[]>([])
const dnsAccounts = ref<DnsAccount[]>([])
const deployResult = ref<DeployResult | null>(null)
const deployCurrentStep = ref(0)
const deployStepStatus = ref<'process' | 'error' | 'finish'>('process')
const deploySteps = ref<{ title: string; description: string; status: string }[]>([])
// 部署状态映射 - 存储每个项目的部署状态
const deployStatusMap = ref<Record<number, { deployed: boolean; websiteId: number | null; checking: boolean }>>({})
// 检查单个项目的部署状态
async function checkProjectDeployStatus(projectId: number) {
if (!projectId) return
deployStatusMap.value[projectId] = { deployed: false, websiteId: null, checking: true }
try {
const result = await checkDeployStatus(projectId)
deployStatusMap.value[projectId] = {
deployed: result.deployed,
websiteId: result.websiteId,
checking: false
}
} catch (error) {
console.error('检查部署状态失败:', error)
deployStatusMap.value[projectId] = { deployed: false, websiteId: null, checking: false }
}
}
// 检查所有项目的部署状态
async function checkAllDeployStatus() {
for (const project of projectList.value) {
if (project.id && project.domain && project.serverId) {
checkProjectDeployStatus(project.id)
}
}
}
// 获取项目部署状态
function getDeployStatus(projectId: number) {
return deployStatusMap.value[projectId] || { deployed: false, websiteId: null, checking: false }
}
const versionList = computed(() => {
if (!versionProject.value) return []
return [...(versionProject.value.versions || [])].reverse()
@@ -428,23 +673,25 @@ const certData = [
]
const formData = reactive({
id: '',
id: '', // 对应后端 ID (Update用)
code: '', // 对应后端 Code (项目标识)
name: '',
shortName: '',
logo: '',
color: '#1890ff',
domain: '',
domainId: undefined as number | undefined,
baseUrl: '',
description: '',
group: 'default',
serverId: '',
serverId: undefined as number | undefined,
deployPath: '',
remark: '',
fileList: [] as any[]
})
const formRules = {
id: [
code: [
{ required: true, message: '请输入项目标识' },
{ pattern: /^[a-zA-Z][a-zA-Z0-9]*$/, message: '只能包含英文字母和数字,且以字母开头' }
],
@@ -582,15 +829,17 @@ function handleAdd() {
currentProject.value = null
Object.assign(formData, {
id: '',
code: '',
name: '',
shortName: '',
logo: '',
color: '#1890ff',
domain: '',
domainId: undefined,
baseUrl: '',
description: '',
group: 'default',
serverId: '',
serverId: undefined,
deployPath: '',
remark: '',
fileList: []
@@ -598,23 +847,31 @@ function handleAdd() {
formVisible.value = true
}
function handleEdit(record: PlatformProject) {
function handleEdit(record: any) {
currentProject.value = record
Object.assign(formData, {
id: record.id,
code: record.code || '',
name: record.name,
shortName: record.shortName,
logo: record.logo,
color: record.color || '#1890ff',
domain: (record as any).domain || '',
baseUrl: record.baseUrl || '',
domain: record.domain || '',
domainId: record.domainId,
baseUrl: record.url || record.baseUrl || '', // 兼容字段
description: record.description || '',
group: (record as any).group || 'default',
serverId: (record as any).serverId || '',
deployPath: (record as any).deployPath || '',
remark: (record as any).remark || '',
group: record.projectGroup || 'default',
serverId: record.serverId,
deployPath: record.deployPath || '',
remark: record.remark || '',
fileList: []
})
// 触发联动逻辑(如果需要加载域名列表),并保留现有域名设置
if (record.serverId) {
handleServerChange(record.serverId, true)
}
formVisible.value = true
}
@@ -627,68 +884,88 @@ async function handleFormSubmit() {
formLoading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const projectData = {
code: formData.code,
name: formData.name,
shortName: formData.shortName,
logo: formData.logo,
color: formData.color,
domain: formData.domain,
baseUrl: formData.baseUrl,
domainId: formData.domainId,
url: formData.baseUrl,
description: formData.description,
group: formData.group,
projectGroup: formData.group,
serverId: formData.serverId,
deployPath: formData.deployPath,
remark: formData.remark
}
if (currentProject.value?.id) {
projectStore.updateProject(currentProject.value.id, projectData)
await updateProject(Number(currentProject.value.id), projectData)
message.success('编辑成功')
} else {
if (projectStore.getProjectById(formData.id)) {
message.error('项目标识已存在')
return
}
projectStore.addProject({
id: formData.id,
await createProject({
...projectData,
enabled: true,
menuCount: 0,
menus: [],
createdAt: new Date().toISOString()
} as any)
type: 'web', // 默认类型
status: 'active'
})
message.success('新建成功')
}
formVisible.value = false
loadData() // 刷新列表
} catch (error) {
message.error('操作失败')
console.error(error)
} finally {
formLoading.value = false
}
}
function handleToggle(record: PlatformProject) {
projectStore.toggleProjectEnabled(record.id)
message.success(record.enabled ? '已禁用' : '已启用')
async function handleToggle(record: any) {
const newStatus = record.status === 'active' ? 'inactive' : 'active'
try {
await updateProject(record.id, { status: newStatus })
message.success(newStatus === 'active' ? '已启用' : '已禁用')
loadData()
} catch (error) {
console.error(error)
message.error('操作失败')
}
}
function handleDelete(record: PlatformProject) {
projectStore.deleteProject(record.id)
message.success('删除成功')
function handleDelete(record: any) {
try {
// 调用删除接口
deleteProject(Number(record.id)).then(() => {
message.success('删除成功')
loadData()
})
} catch(e) {
console.error(e)
message.error('删除失败')
}
}
function goToMenus(record: PlatformProject) {
router.push({ path: '/platform/menus', query: { projectId: record.id } })
}
const uploadProjectDeployPath = ref<string>('')
/**
* 打开上传弹窗
*/
function handleUpload(record: PlatformProject) {
uploadProjectId.value = record.id
function handleUpload(record: ProjectInfo) {
uploadProjectId.value = record.id.toString()
uploadProjectName.value = record.name
// 确保部署路径包含 /index
let deployPath = record.deployPath || ''
if (deployPath && !deployPath.endsWith('/index')) {
deployPath += '/index'
}
uploadProjectDeployPath.value = deployPath
uploadVersionId.value = null // 不关联版本
uploadVisible.value = true
}
@@ -738,6 +1015,166 @@ function handleVersionUploadForVersion(version: ProjectVersion) {
uploadVersionId.value = version.id
uploadVisible.value = true
}
// ==================== 部署相关函数 ====================
/**
* 获取服务器名称
*/
function getServerName(serverId?: string): string {
if (!serverId) return ''
const server = serverList.value.find(s => s.id === serverId)
return server ? `${server.name} (${server.ip})` : ''
}
/**
* 加载 1Panel 账户数据
*/
async function loadPanelAccounts() {
try {
// 从项目绑定的服务器获取账户数据
const serverId = (deployProject.value as any)?.serverId
if (!serverId) {
console.warn('项目未绑定服务器')
return
}
// 通过后端接口获取 Acme 账户列表
try {
const acmeResult = await getServerAcmeAccounts(Number(serverId))
acmeAccounts.value = acmeResult?.items || []
} catch (e) {
console.error('获取 Acme 账户失败:', e)
}
// 通过后端接口获取 DNS 账户列表
try {
const dnsResult = await getServerDnsAccounts(Number(serverId))
dnsAccounts.value = dnsResult?.items || []
} catch (e) {
console.error('获取 DNS 账户失败:', e)
}
// 设置默认值
if (acmeAccounts.value.length > 0) {
deployAcmeAccountId.value = acmeAccounts.value[0].id
}
if (dnsAccounts.value.length > 0) {
deployDnsAccountId.value = dnsAccounts.value[0].id
}
} catch (error) {
console.error('加载 1Panel 账户失败:', error)
}
}
/**
* 打开部署弹窗
*/
async function handleDeploy(record: PlatformProject) {
deployProject.value = record as any
deployEnableHttps.value = true
deployResult.value = null
deploySteps.value = []
deployCurrentStep.value = 0
// 加载账户数据 - 已移除,改用后端自动匹配已绑定证书
// await loadPanelAccounts()
deployModalVisible.value = true
}
/**
* 确认部署
*/
async function handleConfirmDeploy() {
if (!deployProject.value?.domain) {
message.error('项目未绑定域名,无法部署')
return
}
deploying.value = true
deployModalVisible.value = false
deployDrawerVisible.value = true
deployResult.value = null
// 初始化部署步骤
deploySteps.value = [
{ title: '检查/创建网站', description: '准备中...', status: 'wait' },
]
if (deployEnableHttps.value) {
deploySteps.value.push(
{ title: '检查/申请 SSL 证书', description: '等待中...', status: 'wait' },
{ title: '配置 HTTPS', description: '等待中...', status: 'wait' }
)
}
deploySteps.value.push({ title: '完成', description: '', status: 'wait' })
try {
// 更新第一步状态
deployCurrentStep.value = 0
deploySteps.value[0].description = '正在检查网站...'
deploySteps.value[0].status = 'process'
// 调用后端部署 API
const result = await deployProjectApi({
projectId: Number(deployProject.value.id),
enableHttps: deployEnableHttps.value,
acmeAccountId: deployAcmeAccountId.value,
dnsAccountId: deployDnsAccountId.value,
createIfNotExist: true
})
// 更新步骤状态
result.steps.forEach((step, index) => {
if (index < deploySteps.value.length - 1) {
deploySteps.value[index].status = step.status
deploySteps.value[index].description = step.message || (step.status === 'success' ? '完成' : step.status === 'skipped' ? '已跳过' : '失败')
}
})
// 更新最后一步
const lastStepIndex = deploySteps.value.length - 1
if (result.success) {
deployCurrentStep.value = lastStepIndex
deploySteps.value[lastStepIndex].status = 'success'
deploySteps.value[lastStepIndex].description = '部署完成'
deployStepStatus.value = 'finish'
} else {
deployStepStatus.value = 'error'
deploySteps.value[lastStepIndex].status = 'error'
deploySteps.value[lastStepIndex].description = result.message
}
deployResult.value = result
// 部署完成后刷新项目状态
if (deployProject.value?.id) {
checkProjectDeployStatus(Number(deployProject.value.id))
}
} catch (error: any) {
deployStepStatus.value = 'error'
deployResult.value = {
success: false,
message: error.message || '部署过程中发生错误',
steps: []
}
} finally {
deploying.value = false
}
}
/**
* 打开已部署的网站
*/
function openDeployedSite() {
if (deployProject.value?.domain) {
const protocol = deployEnableHttps.value ? 'https' : 'http'
window.open(`${protocol}://${deployProject.value.domain}`, '_blank')
}
}
</script>
<style scoped>
@@ -883,4 +1320,43 @@ function handleVersionUploadForVersion(version: ProjectVersion) {
color: #8c8c8c;
font-size: 12px;
}
/* 部署相关样式 */
.deploy-confirm-content {
padding: 8px 0;
}
.deploy-progress {
padding: 16px 0;
}
.deploy-result {
margin-top: 24px;
border-top: 1px solid #f0f0f0;
padding-top: 24px;
}
.step-desc {
font-size: 12px;
}
.step-desc.success {
color: #52c41a;
}
.step-desc.failed {
color: #ff4d4f;
}
.step-desc.skipped {
color: #8c8c8c;
}
.step-desc.process {
color: #1890ff;
}
.step-desc.wait {
color: #d9d9d9;
}
</style>

View File

@@ -71,7 +71,7 @@
<!-- 服务器列表 -->
<a-table
:columns="columns"
:data-source="filteredServers"
:data-source="servers"
:loading="loading"
row-key="id"
:pagination="{ pageSize: 10, showTotal: (total: number) => `共 ${total} 条` }"
@@ -101,32 +101,37 @@
<template v-if="column.key === 'resources'">
<div class="resource-info">
<div class="resource-item">
<span class="label">CPU:</span>
<div class="resource-row">
<span class="label">CPU</span>
<a-progress
:percent="record.cpu.usage"
:size="60"
:size="[100, 6]"
:stroke-color="getUsageColor(record.cpu.usage)"
:format="(percent: number) => `${percent}%`"
:show-info="false"
/>
<span class="value">{{ record.cpu.usage }}%</span>
</div>
<div class="resource-item">
<span class="label">内存:</span>
<div class="resource-row" :title="`已用: ${record.memory.used}GB / 总计: ${record.memory.total}GB`">
<span class="label">
内存 <span style="font-size: 11px; color: #8c8c8c">({{ record.memory.total }}G)</span>
</span>
<a-progress
:percent="record.memory.usage"
:size="60"
:size="[100, 6]"
:stroke-color="getUsageColor(record.memory.usage)"
:format="(percent: number) => `${percent}%`"
:show-info="false"
/>
<span class="value">{{ record.memory.usage }}%</span>
</div>
<div class="resource-item">
<span class="label">磁盘:</span>
<div class="resource-row">
<span class="label">磁盘</span>
<a-progress
:percent="record.disk.usage"
:size="60"
:size="[100, 6]"
:stroke-color="getUsageColor(record.disk.usage)"
:format="(percent: number) => `${percent}%`"
:show-info="false"
/>
<span class="value">{{ record.disk.usage }}%</span>
</div>
</div>
</template>
@@ -139,21 +144,21 @@
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleView(record)">
<a-button type="link" size="small" @click="handleView(record as unknown as ServerDisplayInfo)">
<EyeOutlined /> 详情
</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">
<a-button type="link" size="small" @click="handleEdit(record as unknown as ServerDisplayInfo)">
编辑
</a-button>
<a-button
v-if="record.panelUrl"
v-if="record.panelPort"
type="link"
size="small"
@click="openPanel(record)"
@click="openPanel(record as unknown as ServerDisplayInfo)"
>
<LinkOutlined /> 面板
</a-button>
<a-popconfirm title="确定删除此服务器?" @confirm="handleDelete(record)">
<a-popconfirm title="确定删除此服务器?" @confirm="handleDelete(record as unknown as ServerDisplayInfo)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
@@ -200,15 +205,7 @@
<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>
@@ -220,6 +217,10 @@
<a-input-number v-model:value="formData.panelPort" :min="1" :max="65535" style="width: 100%" />
</a-form-item>
<a-form-item label="API 密钥" name="panelApiKey">
<a-input-password v-model:value="formData.panelApiKey" placeholder="1Panel API 密钥(用于获取服务器状态)" />
</a-form-item>
<a-divider orientation="left">其他</a-divider>
<a-form-item label="标签" name="tags">
@@ -275,12 +276,7 @@
<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 }}
@@ -366,8 +362,18 @@ import {
FileTextOutlined,
GlobalOutlined
} from '@ant-design/icons-vue'
import type { ServerInfo, ServerStatus, ServerType } from '@/types/platform'
import { mockServers } from '@/mock/platform'
import {
getServerList,
getServerStatus,
createServer,
updateServer,
deleteServer,
refreshServerStatus,
type ServerBase,
type ServerInfo,
type ServerStatus,
type ServerType
} from '@/api/server'
const router = useRouter()
@@ -381,9 +387,34 @@ 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)
// 服务器列表,包含基础信息和实时状态
interface ServerDisplayInfo extends Partial<ServerInfo> {
id: number
name: string
ip: string
internalIp?: string
port?: number
type: ServerType
status: ServerStatus
os?: string
tags: string[]
panelApiKey?: string
panelUrl?: string
panelPort?: number
description?: string
createdAt?: string
updatedAt?: string
// 资源使用情况
cpu: { cores: number; usage: number }
memory: { total: number; used: number; usage: number }
disk: { total: number; used: number; usage: number }
// 状态加载标记
statusLoading?: boolean
}
const servers = ref<ServerDisplayInfo[]>([])
const currentServer = ref<ServerDisplayInfo | null>(null)
const detailServer = ref<ServerDisplayInfo | null>(null)
const formData = reactive({
name: '',
@@ -391,10 +422,9 @@ const formData = reactive({
internalIp: '',
type: 'cloud' as ServerType,
os: '',
sshUser: 'root',
sshPort: 22,
panelUrl: '',
panelPort: 8888,
panelPort: 42588,
panelApiKey: '',
tags: [] as string[],
description: ''
})
@@ -407,31 +437,15 @@ const formRules = {
}
const columns = [
{ title: '服务器', key: 'name', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '类型', key: 'type', width: 100 },
{ title: '服务器', key: 'name', width: 180 },
{ title: '状态', key: 'status', width: 80 },
{ title: '类型', key: 'type', width: 80 },
{ title: '资源使用', key: 'resources', width: 280 },
{ title: '标签', key: 'tags', width: 150 },
{ title: '操作', key: 'action', width: 220, fixed: 'right' as const }
{ title: '操作', key: 'action', width: 200, 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
@@ -447,22 +461,87 @@ onMounted(() => {
loadServers()
})
function loadServers() {
/**
* 加载服务器列表
*/
async function loadServers() {
loading.value = true
setTimeout(() => {
servers.value = [...mockServers]
try {
const data = await getServerList({
status: filterStatus.value,
type: filterType.value,
keyword: filterKeyword.value
})
// 转换为显示格式,并设置默认资源值
servers.value = data.map(server => {
// 解析 tags
let tags: string[] = []
if (server.tags) {
try {
tags = JSON.parse(server.tags)
} catch {
tags = []
}
}
return {
...server,
tags,
cpu: { cores: server.cpuCores || 0, usage: 0 },
memory: { total: server.memoryTotal || 0, used: 0, usage: 0 },
disk: { total: server.diskTotal || 0, used: 0, usage: 0 },
statusLoading: true
}
})
// 异步加载每个服务器的实时状态
for (const server of servers.value) {
loadServerStatus(server.id)
}
} catch (error) {
console.error('加载服务器列表失败:', error)
message.error('加载服务器列表失败')
} finally {
loading.value = false
}, 300)
}
}
/**
* 加载单个服务器的实时状态
*/
async function loadServerStatus(serverId: number) {
try {
const status = await getServerStatus(serverId)
const index = servers.value.findIndex(s => s.id === serverId)
if (index !== -1) {
servers.value[index] = {
...servers.value[index],
status: status.status,
os: status.os,
cpu: status.cpu,
memory: status.memory,
disk: status.disk,
statusLoading: false
}
}
} catch (error) {
console.error(`加载服务器 ${serverId} 状态失败:`, error)
const index = servers.value.findIndex(s => s.id === serverId)
if (index !== -1) {
servers.value[index].status = 'offline'
servers.value[index].statusLoading = false
}
}
}
function handleSearch() {
// 筛选已通过 computed 实现
loadServers()
}
function handleReset() {
filterStatus.value = undefined
filterType.value = undefined
filterKeyword.value = ''
loadServers()
}
function handleAdd() {
@@ -473,8 +552,6 @@ function handleAdd() {
internalIp: '',
type: 'cloud',
os: '',
sshUser: 'root',
sshPort: 22,
panelUrl: '',
panelPort: 8888,
tags: [],
@@ -483,7 +560,7 @@ function handleAdd() {
drawerVisible.value = true
}
function handleEdit(record: ServerInfo) {
function handleEdit(record: ServerDisplayInfo) {
currentServer.value = record
Object.assign(formData, {
name: record.name,
@@ -491,17 +568,16 @@ function handleEdit(record: ServerInfo) {
internalIp: record.internalIp || '',
type: record.type,
os: record.os,
sshUser: record.sshUser || 'root',
sshPort: record.sshPort || 22,
panelUrl: record.panelUrl || '',
panelPort: record.panelPort || 8888,
panelApiKey: record.panelApiKey || '',
tags: [...record.tags],
description: record.description || ''
})
drawerVisible.value = true
}
function handleView(record: ServerInfo) {
function handleView(record: ServerDisplayInfo) {
detailServer.value = record
detailVisible.value = true
}
@@ -515,88 +591,103 @@ async function handleFormSubmit() {
formLoading.value = true
try {
await new Promise(resolve => setTimeout(resolve, 500))
const serverData = {
name: formData.name,
ip: formData.ip,
internalIp: formData.internalIp || undefined,
type: formData.type,
os: formData.os,
panelUrl: formData.panelUrl || undefined,
panelPort: formData.panelPort,
panelApiKey: formData.panelApiKey || undefined,
tags: formData.tags.length > 0 ? formData.tags : undefined,
description: formData.description || undefined
}
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()
}
}
await updateServer(currentServer.value.id, serverData)
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)
await createServer(serverData)
message.success('服务器添加成功')
}
drawerVisible.value = false
} catch (error) {
message.error('操作失败')
// 刷新列表
await loadServers()
} catch (error: any) {
console.error('操作失败:', error)
message.error(error.message || '操作失败')
} 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')
async function handleDelete(record: ServerDisplayInfo) {
try {
await deleteServer(record.id)
message.success('删除成功')
await loadServers()
} catch (error: any) {
console.error('删除失败:', error)
message.error(error.message || '删除失败')
}
}
function handleRefreshStatus(record: ServerInfo) {
message.loading('正在刷新服务器状态...', 1)
setTimeout(() => {
function openPanel(record: ServerDisplayInfo) {
if (record.ip && record.panelPort) {
window.open(`http://${record.ip}:${record.panelPort}`, '_blank')
} else if (record.panelUrl) {
window.open(record.panelUrl, '_blank')
}
}
async function handleRefreshStatus(record: ServerDisplayInfo) {
const hide = message.loading('正在刷新服务器状态...', 0)
try {
const updatedServer = await refreshServerStatus(record.id)
// 更新列表中的服务器数据
const index = servers.value.findIndex(s => s.id === record.id)
if (index !== -1) {
servers.value[index] = {
...servers.value[index],
status: updatedServer.status,
os: updatedServer.os,
cpu: updatedServer.cpu,
memory: updatedServer.memory,
disk: updatedServer.disk,
statusLoading: false
}
}
// 如果是详情页面,也更新
if (detailServer.value?.id === record.id) {
detailServer.value = {
...detailServer.value,
status: updatedServer.status,
os: updatedServer.os,
cpu: updatedServer.cpu,
memory: updatedServer.memory,
disk: updatedServer.disk
}
}
message.success('状态已刷新')
}, 1000)
} catch (error: any) {
console.error('刷新状态失败:', error)
message.error(error.message || '刷新状态失败')
} finally {
hide()
}
}
function goToLogs(record: ServerInfo) {
router.push({ path: '/platform/log-center', query: { serverId: record.id } })
function goToLogs(record: ServerDisplayInfo) {
router.push({ path: '/platform/log-center', query: { serverId: String(record.id) } })
}
function goToDomains(record: ServerInfo) {
router.push({ path: '/platform/domains', query: { serverId: record.id } })
function goToDomains(record: ServerDisplayInfo) {
router.push({ path: '/platform/domains', query: { serverId: String(record.id) } })
}
function getStatusColor(status: ServerStatus): string {
@@ -719,27 +810,41 @@ function getTagColor(tag: string): string {
.resource-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.resource-item {
.resource-row {
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
min-height: 22px;
}
.resource-item .label {
font-size: 12px;
.resource-row .label {
font-size: 11px;
color: #8c8c8c;
width: 36px;
width: 90px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 0;
}
.resource-item :deep(.ant-progress) {
.resource-row .value {
font-size: 11px;
color: #666;
width: 40px;
text-align: right;
flex-shrink: 0;
}
.resource-row :deep(.ant-progress) {
flex: 1;
margin-bottom: 0;
}
.resource-item :deep(.ant-progress-text) {
font-size: 11px;
.resource-row :deep(.ant-progress-inner) {
background-color: #f0f0f0;
}
.drawer-footer {

View File

@@ -221,11 +221,13 @@ import {
import type { ApprovalTemplate, ApprovalScenario, ApprovalNode } from '@/types'
import { ApprovalScenarioMap } from '@/types'
import {
mockGetApprovalStats,
mockGetApprovalTemplateList,
mockToggleApprovalTemplate,
mockDeleteApprovalTemplate
} from '@/mock'
getApprovalStats,
getApprovalTemplateList,
toggleApprovalTemplate,
deleteApprovalTemplate,
createApprovalTemplate,
updateApprovalTemplate
} from '@/api/system/approval'
import { formatDateTime } from '@/utils/common'
import FlowEditor from '@/components/FlowEditor/index.vue'
@@ -290,7 +292,7 @@ function getScenarioColor(scenario: ApprovalScenario): string {
async function loadStats() {
try {
const data = await mockGetApprovalStats()
const data = await getApprovalStats()
Object.assign(stats, data)
} catch (error) {
console.error(error)
@@ -300,10 +302,8 @@ async function loadStats() {
async function loadTemplates() {
loading.value = true
try {
const res = await mockGetApprovalTemplateList({
scenario: filterScenario.value
})
templateList.value = res.list
const list = await getApprovalTemplateList(filterScenario.value)
templateList.value = list
} catch (error) {
console.error(error)
message.error('加载数据失败')
@@ -346,12 +346,27 @@ async function handleSubmit() {
formData.nodes = flowEditorRef.value.toApprovalNodes()
}
// 这里应该调用创建/更新API
const requestData = {
name: formData.name,
description: formData.description,
scenario: formData.scenario!,
enabled: formData.enabled,
nodes: formData.nodes
}
if (editingTemplate.value) {
await updateApprovalTemplate(editingTemplate.value.id, requestData)
} else {
await createApprovalTemplate(requestData)
}
message.success(editingTemplate.value ? '更新成功' : '创建成功')
drawerVisible.value = false
loadTemplates()
} catch {
// 验证失败
loadStats()
} catch (error) {
console.error(error)
message.error('保存失败')
} finally {
submitting.value = false
}
@@ -359,7 +374,7 @@ async function handleSubmit() {
async function handleToggle(id: number) {
try {
await mockToggleApprovalTemplate(id)
await toggleApprovalTemplate(id)
message.success('状态已更新')
loadTemplates()
loadStats()
@@ -371,7 +386,7 @@ async function handleToggle(id: number) {
async function handleDelete(id: number) {
try {
await mockDeleteApprovalTemplate(id)
await deleteApprovalTemplate(id)
message.success('删除成功')
loadTemplates()
loadStats()

View File

@@ -203,7 +203,7 @@ import {
} from '@ant-design/icons-vue'
import type { ApprovalInstance, ApprovalScenario, ApprovalInstanceStatus } from '@/types'
import { ApprovalScenarioMap, ApprovalInstanceStatusMap, ApprovalInstanceStatusBadgeMap } from '@/types'
import { mockGetApprovalInstanceList } from '@/mock'
import { getApprovalInstancePage } from '@/api/system/approval'
import { formatDateTime } from '@/utils/common'
const loading = ref(false)
@@ -274,16 +274,14 @@ function getActionText(action: string): string {
async function loadData() {
loading.value = true
try {
const res = await mockGetApprovalInstanceList({
const res = await getApprovalInstancePage({
page: pagination.current,
pageSize: pagination.pageSize,
keyword: searchKeyword.value || undefined,
scenario: filterScenario.value,
status: filterStatus.value,
startDate: dateRange.value?.[0]?.format('YYYY-MM-DD'),
endDate: dateRange.value?.[1]?.format('YYYY-MM-DD')
status: filterStatus.value
})
instanceList.value = res.list
instanceList.value = res.records
pagination.total = res.total
} catch (error) {
console.error(error)

View File

@@ -0,0 +1,290 @@
<template>
<div class="dept-page">
<a-page-header title="部门管理" sub-title="管理组织架构和部门信息">
<template #extra>
<a-button type="primary" @click="showCreateModal()">
<PlusOutlined /> 新增部门
</a-button>
</template>
</a-page-header>
<a-card class="main-card" :loading="loading">
<a-table
:columns="columns"
:data-source="deptTree"
:pagination="false"
row-key="id"
:default-expand-all-rows="true"
:indent-size="24"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="dept-name">
<ApartmentOutlined class="dept-icon" />
<span>{{ record.name }}</span>
</div>
</template>
<template v-else-if="column.key === 'leader'">
<span v-if="record.leaderName">{{ record.leaderName }}</span>
<span v-else class="empty-text">-</span>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '正常' : '停用' }}
</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showCreateModal(record.id)">
<PlusOutlined /> 新增子部门
</a-button>
<a-button type="link" size="small" @click="showEditModal(record as DeptRecord)">
<EditOutlined /> 编辑
</a-button>
<a-popconfirm
title="确定删除此部门?"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:title="editingDept ? '编辑部门' : '新增部门'"
:confirm-loading="submitting"
@ok="handleSubmit"
@cancel="modalVisible = false"
>
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="上级部门" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:tree-data="parentTreeData"
placeholder="请选择上级部门"
tree-default-expand-all
:field-names="{ children: 'children', label: 'name', value: 'id' }"
allow-clear
/>
</a-form-item>
<a-form-item label="部门名称" name="name" :rules="[{ required: true, message: '请输入部门名称' }]">
<a-input v-model:value="formData.name" placeholder="请输入部门名称" />
</a-form-item>
<a-form-item label="部门编码" name="code">
<a-input v-model:value="formData.code" placeholder="请输入部门编码" />
</a-form-item>
<a-form-item label="负责人" name="leaderName">
<a-input v-model:value="formData.leaderName" placeholder="请输入负责人姓名" />
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item label="显示排序" name="sort">
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">正常</a-radio>
<a-radio :value="0">停用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formData.remark" :rows="3" placeholder="请输入备注" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ApartmentOutlined
} from '@ant-design/icons-vue'
import { getDeptTree, createDept, updateDept, deleteDept, type DeptRecord, type DeptFormData } from '@/api/system/dept'
const loading = ref(false)
const submitting = ref(false)
const deptTree = ref<DeptRecord[]>([])
const modalVisible = ref(false)
const editingDept = ref<DeptRecord | null>(null)
const formRef = ref<FormInstance>()
const formData = reactive<DeptFormData>({
parentId: 0,
name: '',
code: '',
leaderName: '',
phone: '',
email: '',
sort: 0,
status: 1,
remark: ''
})
const columns = [
{ title: '部门名称', key: 'name', width: 280 },
{ title: '部门编码', dataIndex: 'code', width: 120 },
{ title: '负责人', key: 'leader', width: 100 },
{ title: '联系电话', dataIndex: 'phone', width: 120 },
{ title: '状态', key: 'status', width: 80 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{ title: '操作', key: 'action', width: 240 }
]
// 构建上级部门下拉数据(添加顶级选项)
const parentTreeData = computed(() => {
return [
{ id: 0, name: '无(顶级部门)', children: deptTree.value }
]
})
async function loadData() {
loading.value = true
try {
deptTree.value = await getDeptTree()
} catch (error) {
console.error(error)
message.error('加载数据失败')
} finally {
loading.value = false
}
}
function resetForm() {
Object.assign(formData, {
parentId: 0,
name: '',
code: '',
leaderName: '',
phone: '',
email: '',
sort: 0,
status: 1,
remark: ''
})
}
function showCreateModal(parentId?: number) {
editingDept.value = null
resetForm()
if (parentId) {
formData.parentId = parentId
}
modalVisible.value = true
}
function showEditModal(dept: DeptRecord) {
editingDept.value = dept
Object.assign(formData, {
parentId: dept.parentId,
name: dept.name,
code: dept.code || '',
leaderName: dept.leaderName || '',
phone: dept.phone || '',
email: dept.email || '',
sort: dept.sort,
status: dept.status,
remark: dept.remark || ''
})
modalVisible.value = true
}
async function handleSubmit() {
try {
await formRef.value?.validate()
submitting.value = true
if (editingDept.value) {
await updateDept(editingDept.value.id, formData)
message.success('更新成功')
} else {
await createDept(formData)
message.success('创建成功')
}
modalVisible.value = false
loadData()
} catch (error) {
console.error(error)
} finally {
submitting.value = false
}
}
async function handleDelete(id: number) {
try {
await deleteDept(id)
message.success('删除成功')
loadData()
} catch (error) {
console.error(error)
message.error('删除失败')
}
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.dept-page {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
overflow-y: auto;
padding-right: 8px;
}
.main-card {
border-radius: 12px;
}
.dept-name {
display: flex;
align-items: center;
gap: 8px;
}
.dept-icon {
color: #1890ff;
font-size: 16px;
}
.empty-text {
color: #bfbfbf;
}
</style>

View File

@@ -49,8 +49,8 @@
</template>
<template v-if="column.dataIndex === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleViewItems(record)">字典项</a-button>
<a-button type="link" size="small" @click="handleEdit(record as DictRecord)">编辑</a-button>
<a-button type="link" size="small" @click="handleViewItems(record as DictRecord)">字典项</a-button>
<a-popconfirm
title="确定删除此字典吗?"
@confirm="handleDelete(record.id)"
@@ -84,6 +84,7 @@ import { message } from 'ant-design-vue'
import { PlusOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import DictFormModal from '@/components/system/dict/DictFormModal.vue'
import DictItemDrawer from '@/components/system/dict/DictItemDrawer.vue'
import { getDictPage, deleteDict } from '@/api/system/dict'
// 类型定义
interface DictRecord {
@@ -119,7 +120,7 @@ const columns = [
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '状态', dataIndex: 'status', width: 80 },
{ title: '创建时间', dataIndex: 'createdAt', width: 170 },
{ title: '操作', dataIndex: 'action', width: 160, fixed: 'right' }
{ title: '操作', dataIndex: 'action', width: 160, fixed: 'right' as const }
]
// 字典弹窗
@@ -134,18 +135,17 @@ const currentDict = ref<DictRecord | null>(null)
async function loadData() {
loading.value = true
try {
// TODO: 接入真实API
// const res = await getDictList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
// tableData.value = res.data.data.records
// pagination.total = res.data.data.total
// 模拟数据
tableData.value = [
{ id: 1, name: '性别', code: 'gender', remark: '用户性别', status: 1, createdAt: '2026-01-08 10:00:00' },
{ id: 2, name: '状态', code: 'status', remark: '通用状态', status: 1, createdAt: '2026-01-08 10:00:00' },
{ id: 3, name: '审批状态', code: 'approval_status', remark: '审批流程状态', status: 1, createdAt: '2026-01-08 10:00:00' }
]
pagination.total = 3
const res = await getDictPage({
page: pagination.current,
pageSize: pagination.pageSize,
name: searchForm.name || undefined,
code: searchForm.code || undefined
})
tableData.value = res.data.data.records || []
pagination.total = res.data.data.total || 0
} catch (error) {
console.error('加载字典数据失败:', error)
tableData.value = []
} finally {
loading.value = false
}
@@ -184,10 +184,15 @@ function handleViewItems(record: DictRecord) {
}
async function handleDelete(id: number) {
// TODO: 接入真实API
// await deleteDict(id)
message.success('删除成功')
loadData()
try {
await deleteDict(id)
message.success('删除成功')
loadData()
} catch (error: any) {
if (error?.response?.data?.message) {
message.error(error.response.data.message)
}
}
}
onMounted(() => {

View File

@@ -119,7 +119,7 @@ async function loadData() {
loading.value = true
try {
const res = await getRolePage({ pageSize: 100 })
tableData.value = res.data.data.records || []
tableData.value = res.records || []
} catch (error) {
console.error('加载数据失败:', error)
} finally {
@@ -161,7 +161,7 @@ async function handleAssignMenus(record: RoleRecord) {
// 加载角色已有的菜单
const roleMenuRes = await getRoleMenuIds(record.id)
checkedMenuIds.value = roleMenuRes.data.data || []
checkedMenuIds.value = roleMenuRes || []
} catch (error) {
console.error('加载数据失败:', error)
} finally {

View File

@@ -6,6 +6,17 @@
<a-form-item label="关键词">
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="部门">
<a-tree-select
v-model:value="searchForm.deptId"
:tree-data="deptTreeData"
placeholder="请选择部门"
tree-default-expand-all
:field-names="{ children: 'children', label: 'name', value: 'id' }"
allow-clear
style="width: 180px"
/>
</a-form-item>
<a-form-item label="角色">
<a-select v-model:value="searchForm.role" placeholder="请选择角色" allow-clear style="width: 150px">
<a-select-option v-for="role in roleOptions" :key="role.code" :value="role.code">
@@ -68,8 +79,8 @@
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record)">重置密码</a-button>
<a-button type="link" size="small" @click="handleEdit(record as UserRecord)">编辑</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record as UserRecord)">重置密码</a-button>
<a-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
@@ -106,6 +117,7 @@ import {
import { getUserList, deleteUser, updateUserStatus } from '@/api/system/user'
import { getRoleList } from '@/api/system/role'
import { getDeptTree, type DeptRecord } from '@/api/system/dept'
import type { UserRecord, UserFormData } from '@/types/system/user'
import type { RoleRecord } from '@/types/system/role'
@@ -116,10 +128,12 @@ import ResetPasswordModal from '@/components/system/user/ResetPasswordModal.vue'
const loading = ref(false)
const tableData = ref<UserRecord[]>([])
const roleOptions = ref<{ code: string; name: string }[]>([]) // Role List for display/select
const deptTreeData = ref<DeptRecord[]>([])
// 搜索表单
const searchForm = reactive({
keyword: '',
deptId: undefined as number | undefined,
role: undefined as string | undefined,
status: undefined as number | undefined
})
@@ -139,11 +153,11 @@ const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '昵称', dataIndex: 'nickname', key: 'nickname' },
{ title: '手机号', dataIndex: 'phone', key: 'phone' },
{ title: '邮箱', dataIndex: 'email', key: 'email' },
{ title: '部门', dataIndex: 'deptName', key: 'deptName', width: 120 },
{ title: '角色', dataIndex: 'role', key: 'role', width: 120 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
{ title: '操作', key: 'action', width: 200, fixed: 'right' }
{ title: '操作', key: 'action', width: 200, fixed: 'right' as const }
]
// Modal State
@@ -190,16 +204,22 @@ async function loadData() {
// 加载角色列表
async function loadRoles() {
try {
// 这里如果后端没有 list-all我们可以用 list 且不传参(默认第一页),或者传大 pageSize
// 假设后端支持 list 并返回 records
const res = await getRoleList()
// Mapping RoleRecord to Option { code, name }
roleOptions.value = (res.data.data || []).map((r: RoleRecord) => ({ code: r.code, name: r.name }))
const roles = await getRoleList()
roleOptions.value = (roles || []).map((r: any) => ({ code: r.code, name: r.name }))
} catch (error) {
console.error('加载角色列表失败:', error)
}
}
// 加载部门树
async function loadDeptTree() {
try {
deptTreeData.value = await getDeptTree()
} catch (error) {
console.error('加载部门列表失败:', error)
}
}
function handleSearch() {
pagination.current = 1
loadData()
@@ -207,6 +227,7 @@ function handleSearch() {
function handleReset() {
searchForm.keyword = ''
searchForm.deptId = undefined
searchForm.role = undefined
searchForm.status = undefined
handleSearch()
@@ -258,6 +279,7 @@ function handleResetPassword(record: UserRecord) {
onMounted(() => {
loadRoles()
loadDeptTree()
loadData()
})
</script>

View File

@@ -63,7 +63,7 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
proxy.on('proxyReq', (_proxyReq, req) => {
console.log(`[Backend API Proxy] ${req.method} ${req.url}`)
})
}
@@ -74,7 +74,7 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server1/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
proxy.on('proxyReq', (_proxyReq, req) => {
console.log(`[1Panel Proxy - 主服务器] ${req.method} ${req.url}`)
})
}
@@ -85,7 +85,7 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server2/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
proxy.on('proxyReq', (_proxyReq, req) => {
console.log(`[1Panel Proxy - 测试服务器] ${req.method} ${req.url}`)
})
}
@@ -96,7 +96,7 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/1panel-api\/server3/, '/api/v2'),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
proxy.on('proxyReq', (_proxyReq, req) => {
console.log(`[1Panel Proxy - 备份服务器] ${req.method} ${req.url}`)
})
}