前端项目关键重构规范

This commit is contained in:
super
2026-01-27 20:29:40 +08:00
parent b40007e74b
commit 9c2f021b64
44 changed files with 5850 additions and 648 deletions

3
.env
View File

@@ -2,4 +2,5 @@
VITE_APP_TITLE=楠溪屿后台管理系统
# API基础URL
VITE_API_BASE_URL=https://api.superwax.cn:4433/api
VITE_API_BASE_URL=https://apis.codeport.online/api
# VITE_API_BASE_URL=https://api.superwax.cn:4433/api

View File

@@ -2,5 +2,6 @@
VITE_APP_TITLE=楠溪屿后台管理系统
# API基础URL - 生产环境
VITE_API_BASE_URL=https://api.superwax.cn:4433/api
VITE_API_BASE_URL=https://apis.codeport.online/api
# VITE_API_BASE_URL=https://api.superwax.cn:4433/api

View File

@@ -1,68 +1,364 @@
import request from '@/utils/request'
import type {
DatabaseType,
DatabaseInfo,
DatabaseItem,
CreateDatabaseRequest,
UpdateDatabaseRequest,
DatabaseConnectionInfo,
DatabaseListParams,
DatabaseListResponse
} from '@/types/database'
/**
* 数据库管理 API
* 通过1Panel管理数据库
*/
import { request } from '@/utils/request'
// 获取数据库信息
export function getDatabaseInfo(type: DatabaseType): Promise<DatabaseInfo> {
return request.get(`/api/database/${type}/info`)
// ==================== 类型定义 ====================
/**
* 应用安装状态
*/
export interface AppInstallStatus {
isExist: boolean
name: string
app: string
version: string
status: string
createdAt: string
lastBackupAt: string
appInstallId: number
containerName: string
installPath: string
httpPort: number
httpsPort: number
}
// 启动数据库
export function startDatabase(type: DatabaseType): Promise<void> {
return request.post(`/api/database/${type}/start`)
/**
* 数据库记录
*/
export interface DatabaseRecord {
id: number
createdAt: string
name: string
from: string
mysqlName: string
format: string
username: string
password: string
permission: string
isDelete: boolean
description: string
showPassword?: boolean // 前端控制是否显示密码
}
// 停止数据库
export function stopDatabase(type: DatabaseType): Promise<void> {
return request.post(`/api/database/${type}/stop`)
/**
* 数据库列表响应
*/
export interface DatabaseListResponse {
total: number
items: DatabaseRecord[]
}
// 重启数据库
export function restartDatabase(type: DatabaseType): Promise<void> {
return request.post(`/api/database/${type}/restart`)
/**
* 创建数据库参数
*/
export interface CreateDatabaseParams {
serverId: number
name: string
username: string
password: string
format?: string // 字符集 format
collation?: string // 排序规则 collation (可选后端接口没明确提到但通常MySQL需要)
permission?: string
permissionIPs?: string
type: string // 数据库类型如mysql
database: string // 数据库名称如mysql
from?: string // 固定 'local'
description?: string
}
// 获取数据库列表
export function getDatabaseList(type: DatabaseType, params?: DatabaseListParams): Promise<DatabaseListResponse> {
return request.get(`/api/database/${type}/list`, { params })
/**
* 删除数据库参数
*/
export interface DeleteDatabaseParams {
serverId: number
id: number
type: string // 数据库类型如mysql
database: string // 数据库名称/应用类型
deleteBackup?: boolean
forceDelete?: boolean
}
// 创建数据库
export function createDatabase(type: DatabaseType, data: CreateDatabaseRequest): Promise<DatabaseItem> {
return request.post(`/api/database/${type}`, data)
/**
* 修改密码参数
*/
export interface ChangePasswordParams {
serverId: number
id: number
value: string // 新密码 (Base64)
type: string // 数据库类型
database: string // 数据库名称
from: string // 固定 'local'
}
// 更新数据库
export function updateDatabase(type: DatabaseType, id: number, data: UpdateDatabaseRequest): Promise<DatabaseItem> {
return request.put(`/api/database/${type}/${id}`, data)
/**
* 更新描述参数
*/
export interface UpdateDescriptionParams {
serverId: number
id: number
description: string
}
// 删除数据库
export function deleteDatabase(type: DatabaseType, id: number): Promise<void> {
return request.delete(`/api/database/${type}/${id}`)
/**
* 操作应用参数
*/
export interface OperateAppParams {
serverId: number
operate: 'start' | 'stop' | 'restart'
installId: number
[key: string]: any
}
// 获取连接信息
export function getDatabaseConnectionInfo(type: DatabaseType, id: number): Promise<DatabaseConnectionInfo> {
return request.get(`/api/database/${type}/${id}/connection`)
// ==================== API 方法 ====================
/**
* 检查应用安装状态MySQL/PostgreSQL/Redis等
*/
export async function checkAppInstalled(
serverId: number,
key: string,
name: string
): Promise<AppInstallStatus> {
const res = await request.post<AppInstallStatus>('/platform/database/app/check', {
serverId,
key,
name
})
return res.data.data
}
// 导出类型
export type {
DatabaseType,
DatabaseInfo,
DatabaseItem,
CreateDatabaseRequest,
UpdateDatabaseRequest,
DatabaseConnectionInfo,
DatabaseListParams,
DatabaseListResponse
/**
* 查询数据库列表
*/
export async function searchDatabases(
serverId: number,
database: string,
page: number = 1,
pageSize: number = 20
): Promise<DatabaseListResponse> {
const res = await request.post<DatabaseListResponse>('/platform/database/search', {
serverId,
database,
page,
pageSize
})
return res.data.data
}
/**
* 创建数据库
*/
export async function createDatabase(params: CreateDatabaseParams): Promise<void> {
await request.post('/platform/database/create', params)
}
/**
* 删除数据库
*/
export async function deleteDatabase(params: DeleteDatabaseParams): Promise<void> {
await request.post('/platform/database/delete', params)
}
/**
* 更新数据库描述
*/
export async function updateDatabaseDescription(params: UpdateDescriptionParams): Promise<void> {
await request.post('/platform/database/description/update', params)
}
/**
* 修改数据库密码
*/
export async function changeDatabasePassword(params: ChangePasswordParams): Promise<void> {
await request.post('/platform/database/password/change', params)
}
/**
* 操作应用(启动/停止/重启)
*/
export async function operateApp(params: OperateAppParams): Promise<void> {
await request.post('/platform/database/app/operate', params)
}
/**
* 获取数据库字符集排序规则选项
*/
export async function getFormatOptions(
serverId: number,
type: string,
database: string,
format?: string
): Promise<string[]> {
const res = await request.post<string[]>('/platform/database/format/options', {
serverId,
type,
database,
format
})
return res.data.data
}
/**
* 应用信息
*/
export interface AppInfo {
id: number
name: string
key: string
shortDescZh: string
shortDescEn: string
description: string
icon: string
type: string
status: string
website: string
github: string
document: string
versions: string[]
installed: boolean
[key: string]: any
}
/**
* 应用版本详情
*/
export interface AppDetail {
id: number
appId: number
version: string
dockerCompose: string
status: string
enable: boolean
params: {
formFields: Array<{
default: any
envKey: string
label: Record<string, string>
labelEn: string
labelZh: string
required: boolean
type: string
random?: boolean
rule?: string
}>
}
[key: string]: any
}
/**
* 安装应用参数
*/
export interface InstallAppParams {
serverId: number
appDetailId: number
name: string
version: string
params: Record<string, any>
dockerCompose: string
taskID: string // 任务ID用于查询安装日志
// 高级设置
advanced?: boolean
containerName?: string
allowPort?: boolean
specifyIP?: string
restartPolicy?: string
cpuQuota?: number
memoryLimit?: number
memoryUnit?: string
pullImage?: boolean
editCompose?: boolean
gpuConfig?: boolean
appID?: string
format?: string
collation?: string
}
/**
* 安装应用返回结果
*/
export interface InstallAppResult {
id: number
name: string
appId: number
appDetailId: number
version: string
status: string
containerName: string
httpPort: number
[key: string]: any
}
/**
* 任务日志响应
*/
export interface TaskLogResponse {
end: boolean
path: string
total: number
taskStatus: string
lines: string[]
totalLines: number
}
/**
* 获取应用信息如Redis
*/
export async function getAppInfo(
serverId: number,
appKey: string
): Promise<AppInfo> {
const res = await request.post<AppInfo>('/platform/database/app/info', {
serverId,
appKey
})
return res.data.data
}
/**
* 获取应用版本详情
*/
export async function getAppDetail(
serverId: number,
appId: number,
version: string
): Promise<AppDetail> {
const res = await request.post<AppDetail>('/platform/database/app/detail', {
serverId,
appId,
version
})
return res.data.data
}
/**
* 安装应用
*/
export async function installApp(params: InstallAppParams): Promise<InstallAppResult> {
const { serverId, ...restParams } = params
const res = await request.post<InstallAppResult>('/platform/database/app/install', {
serverId,
...restParams
})
return res.data.data
}
/**
* 读取任务日志
*/
export async function readTaskLog(
serverId: number,
taskId: string,
page: number = 1,
pageSize: number = 500
): Promise<TaskLogResponse> {
const res = await request.post<TaskLogResponse>('/platform/database/task/log', {
serverId,
taskId,
page,
pageSize
})
return res.data.data
}

View File

@@ -40,6 +40,12 @@ export interface DomainInfo {
deployStatus?: DeployStatus
lastDeployTime?: string
lastDeployMessage?: string
// 运行环境关联
runtimeId?: number
runtimeServerId?: number
runtimeName?: string
runtimeType?: string
runtimeDeployStatus?: DeployStatus
// 时间
createdAt?: string
updatedAt?: string
@@ -135,6 +141,13 @@ export async function deployDomain(data: DomainDeployRequest): Promise<DomainDep
return res.data.data
}
/**
* 删除部署(从 1Panel 删除网站)
*/
export async function undeployDomain(id: number) {
await request.post<void>(`/platform/domain/undeploy/${id}`)
}
/**
* 检查域名 DNS 解析状态
*/
@@ -173,3 +186,48 @@ 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
}
/**
* 部署运行环境到1Panel
*/
export async function deployRuntime(domainId: number) {
const res = await request.post<DomainDeployResult>(`/platform/domain/${domainId}/deploy-runtime`)
return res.data.data
}
// ==================== Nginx配置相关 ====================
/**
* Nginx配置信息
*/
export interface NginxConfigResult {
domain: string
path: string
content: string
name: string
error?: string
}
/**
* 获取域名Nginx配置
*/
export async function getNginxConfig(domainId: number): Promise<NginxConfigResult> {
const res = await request.get<NginxConfigResult>(`/platform/domain/${domainId}/nginx-config`)
return res.data.data
}
/**
* 保存域名Nginx配置
*/
export async function saveNginxConfig(domainId: number, content: string): Promise<boolean> {
const res = await request.put<boolean>(`/platform/domain/${domainId}/nginx-config`, { content })
return res.data.data
}
/**
* 重载Nginx
*/
export async function reloadNginx(domainId: number): Promise<boolean> {
const res = await request.post<boolean>(`/platform/domain/${domainId}/nginx-reload`)
return res.data.data
}

148
src/api/file.ts Normal file
View File

@@ -0,0 +1,148 @@
import { request } from '@/utils/request'
export interface CheckFileResult {
path: string
}
/**
* 批量检查文件是否存在
* @param serverId 服务器ID
* @param paths 文件路径列表
*/
export async function checkFileBatch(serverId: number, paths: string[]) {
const res = await request.post<string[]>('/platform/files/check', {
serverId,
paths
})
return res.data.data
}
/**
* 分片上传文件注意1Panel 不支持真正的分片合并,此方法每次只上传一个分片作为完整文件)
* @deprecated 使用 uploadFile 代替
*/
export async function uploadFileChunk(
serverId: number,
filename: string,
path: string,
chunkIndex: number,
chunkCount: number,
chunk: Blob,
onUploadProgress?: (progressEvent: { loaded: number; total?: number }) => void
) {
const formData = new FormData()
formData.append('serverId', serverId.toString())
formData.append('filename', filename)
formData.append('path', path)
formData.append('chunkIndex', chunkIndex.toString())
formData.append('chunkCount', chunkCount.toString())
formData.append('chunk', chunk)
const res = await request.post('/platform/files/upload/chunk', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 30 * 60 * 1000, // 30分钟超时适合大文件分片
onUploadProgress
})
return res.data
}
/**
* 直接上传完整文件
* @param serverId 服务器ID
* @param path 目标目录路径
* @param file 文件对象
* @param onUploadProgress 上传进度回调
*/
export async function uploadFile(
serverId: number,
path: string,
file: File,
onUploadProgress?: (progressEvent: { loaded: number; total?: number }) => void
) {
const formData = new FormData()
formData.append('serverId', serverId.toString())
formData.append('path', path)
formData.append('file', file)
const res = await request.post('/platform/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 30 * 60 * 1000, // 30分钟超时适合大文件
onUploadProgress
})
return res.data
}
/**
* 分片上传 v2支持真正的分片合并
* @param serverId 服务器ID
* @param path 目标目录路径
* @param filename 文件名
* @param chunkIndex 分片索引从0开始
* @param chunkCount 分片总数
* @param fileSize 文件总大小
* @param uploadId 上传ID
* @param chunk 分片数据
* @param onUploadProgress 上传进度回调
*/
export async function uploadChunkV2(
serverId: number,
path: string,
filename: string,
chunkIndex: number,
chunkCount: number,
fileSize: number,
uploadId: string,
chunk: Blob,
onUploadProgress?: (progressEvent: { loaded: number; total?: number }) => void
) {
const formData = new FormData()
formData.append('serverId', serverId.toString())
formData.append('path', path)
formData.append('filename', filename)
formData.append('chunkIndex', chunkIndex.toString())
formData.append('chunkCount', chunkCount.toString())
formData.append('fileSize', fileSize.toString())
formData.append('uploadId', uploadId)
formData.append('chunk', chunk)
const res = await request.post('/platform/files/upload/chunk/v2', formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
timeout: 10 * 60 * 1000, // 10分钟超时
onUploadProgress
})
return res.data
}
/**
* 合并已上传的分片
* @param serverId 服务器ID
* @param path 目标目录路径
* @param filename 文件名
* @param chunkCount 分片总数
* @param fileSize 文件总大小
* @param uploadId 上传ID
*/
export async function mergeChunks(
serverId: number,
path: string,
filename: string,
chunkCount: number,
fileSize: number,
uploadId: string
) {
const res = await request.post('/platform/files/upload/merge', {
serverId,
path,
filename,
chunkCount,
fileSize,
uploadId
})
return res.data
}

View File

@@ -248,3 +248,40 @@ export async function uploadFileChunk(
return res.data.data
}
// ==================== Nginx配置相关 ====================
/**
* Nginx配置信息
*/
export interface NginxConfigResult {
domain: string
path: string
content: string
name: string
error?: string
}
/**
* 获取项目Nginx配置
*/
export async function getNginxConfig(projectId: number): Promise<NginxConfigResult> {
const res = await request.get<NginxConfigResult>(`/platform/project/${projectId}/nginx-config`)
return res.data.data
}
/**
* 保存项目Nginx配置
*/
export async function saveNginxConfig(projectId: number, content: string): Promise<boolean> {
const res = await request.put<boolean>(`/platform/project/${projectId}/nginx-config`, { content })
return res.data.data
}
/**
* 重载Nginx
*/
export async function reloadNginx(projectId: number): Promise<boolean> {
const res = await request.post<boolean>(`/platform/project/${projectId}/nginx-reload`)
return res.data.data
}

310
src/api/runtime.ts Normal file
View File

@@ -0,0 +1,310 @@
import request from '@/utils/request'
/**
* 运行时类型
*/
export interface RuntimeType {
key: string
label: string
}
/**
* 端口映射
*/
export interface ExposedPort {
hostPort: number
containerPort: number
hostIP: string
}
/**
* 运行时参数
*/
export interface RuntimeParams {
CODE_DIR?: string
CONTAINER_NAME?: string
CONTAINER_PACKAGE_URL?: string
CONTAINER_PORT_0?: string
CUSTOM_SCRIPT?: string
EXEC_SCRIPT?: string
HOST_IP?: string
HOST_IP_0?: string
HOST_PORT_0?: string
NODE_VERSION?: string
PACKAGE_MANAGER?: string
RUN_INSTALL?: string
[key: string]: any
}
/**
* 运行时记录
*/
export interface RuntimeRecord {
id: number
name: string
resource: string
appDetailID: number
appID: number
source: string
status: string
type: string
image: string
params: RuntimeParams
message: string
version: string
createdAt: string
codeDir: string
appParams: any
port: string
path: string
exposedPorts: ExposedPort[]
taskId?: string // 任务ID
environments: any
volumes: any
extraHosts: any
containerStatus: string
container: string
remark: string
}
/**
* 运行时列表响应
*/
export interface RuntimeListResponse {
total: number
items: RuntimeRecord[]
}
/**
* 搜索运行时列表
*/
export async function searchRuntimes(
serverId: number,
type: string,
page: number = 1,
pageSize: number = 20
): Promise<RuntimeListResponse> {
const res = await request.post<any>('/platform/runtime/search', {
serverId,
type,
page,
pageSize
})
return res.data.data as RuntimeListResponse
}
/**
* 搜索正在运行的运行时列表
* @param serverId 服务器ID
* @param type 运行时类型java/node
* @param page 页码
* @param pageSize 每页数量
*/
export async function searchRunningRuntimes(
serverId: number,
type: string,
page: number = 1,
pageSize: number = 100
): Promise<RuntimeListResponse> {
const res = await request.post<any>('/platform/runtime/search', {
serverId,
type,
page,
pageSize,
status: 'Running'
})
return res.data.data as RuntimeListResponse
}
/**
* 同步运行时状态
*/
export async function syncRuntimes(serverId: number): Promise<void> {
await request.post('/platform/runtime/sync', {
serverId
})
}
/**
* 操作运行时(启动/停止/重启)
*/
export async function operateRuntime(params: {
serverId: number
id: number
operate: 'start' | 'stop' | 'restart'
}): Promise<void> {
await request.post('/platform/runtime/operate', params)
}
/**
* 删除运行时
*/
export async function deleteRuntime(params: {
serverId: number
id: number
forceDelete?: boolean
deleteFolder?: boolean
codeDir?: string
}): Promise<void> {
await request.post('/platform/runtime/delete', params)
}
/**
* 运行时应用信息
*/
export interface RuntimeApp {
id: number
name: string
key: string
description: string
status: string
installed: boolean
type: string
versions?: string[]
tags?: string[]
}
/**
* 运行时应用列表响应
*/
export interface RuntimeAppListResponse {
total: number
items: RuntimeApp[]
}
/**
* 搜索运行时应用列表
*/
export async function searchRuntimeApps(
serverId: number,
type: string,
page: number = 1,
pageSize: number = 20
): Promise<RuntimeAppListResponse> {
const res = await request.post<any>('/platform/runtime/apps/search', {
serverId,
type,
page,
pageSize
})
return res.data.data as RuntimeAppListResponse
}
/**
* 获取应用信息(含版本列表)
*/
export async function getRuntimeAppInfo(
serverId: number,
appKey: string
): Promise<RuntimeApp> {
const res = await request.post<any>('/platform/runtime/app/info', {
serverId,
appKey
})
return res.data.data as RuntimeApp
}
/**
* 运行时版本详情
*/
export interface RuntimeVersionDetail {
id: number
appId: number
version: string
dockerCompose: string
status: string
enable: boolean
params: any
}
/**
* 获取运行时版本详情
*/
export async function getRuntimeDetail(
serverId: number,
appId: number,
version: string
): Promise<RuntimeVersionDetail> {
const res = await request.post<any>('/platform/runtime/detail', {
serverId,
appId,
version
})
return res.data.data as RuntimeVersionDetail
}
/**
* 创建运行时参数
*/
export interface CreateRuntimeParams {
serverId: number
appDetailId: number
name: string
type: string
image: string
version: string
source: string
codeDir: string
params: Record<string, any>
exposedPorts?: ExposedPort[]
remark?: string
}
/**
* 创建运行时
*/
export async function createRuntime(params: CreateRuntimeParams): Promise<void> {
await request.post('/platform/runtime/create', params)
}
/**
* 获取容器日志
*/
export async function getContainerLog(
serverId: number,
containerName: string,
composePath?: string
): Promise<{ log: string }> {
// 注意:这里由于后端返回的是 Map {log: "..."},所以类型匹配 { log: string }
const res = await request.post<any>('/platform/runtime/container/log', {
serverId,
containerName,
composePath
})
return res.data.data
}
/**
* 更新运行时参数
*/
export interface UpdateRuntimeParams {
serverId: number
id: number
remark?: string
exposedPorts?: ExposedPort[]
params?: Record<string, any>
codeDir?: string
[key: string]: any
}
/**
* 更新运行时
*/
export async function updateRuntime(params: UpdateRuntimeParams): Promise<void> {
await request.post('/platform/runtime/update', params)
}
export interface NodeScript {
name: string
script: string
}
/**
* 获取Node脚本
*/
export async function getNodeScripts(serverId: number, codeDir: string): Promise<NodeScript[]> {
const res = await request.post<NodeScript[]>('/platform/runtime/node/scripts', { serverId, codeDir })
// @ts-ignore
return res.data.data
}

View File

@@ -1,35 +1,7 @@
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
}
import type { DeptRecord, DeptFormData } from '@/types/system/dept'
export type { DeptRecord, DeptFormData }
/**
* 获取部门树

59
src/components.d.ts vendored
View File

@@ -33,6 +33,7 @@ declare module 'vue' {
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AFormItemRest: typeof import('ant-design-vue/es')['FormItemRest']
AInput: typeof import('ant-design-vue/es')['Input']
AInputGroup: typeof import('ant-design-vue/es')['InputGroup']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
@@ -40,6 +41,9 @@ 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']
@@ -47,9 +51,11 @@ declare module 'vue' {
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APagination: typeof import('ant-design-vue/es')['Pagination']
ApartmentOutlined: typeof import('@ant-design/icons-vue')['ApartmentOutlined']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover']
ApprovalDrawer: typeof import('./components/ApprovalDrawer/index.vue')['default']
AppstoreOutlined: typeof import('@ant-design/icons-vue')['AppstoreOutlined']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
@@ -57,7 +63,10 @@ declare module 'vue' {
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ArrowLeftOutlined: typeof import('@ant-design/icons-vue')['ArrowLeftOutlined']
ArrowUpOutlined: typeof import('@ant-design/icons-vue')['ArrowUpOutlined']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
@@ -76,23 +85,73 @@ declare module 'vue' {
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATree: typeof import('ant-design-vue/es')['Tree']
ATreeSelect: typeof import('ant-design-vue/es')['TreeSelect']
AUpload: typeof import('ant-design-vue/es')['Upload']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
BudgetDetailModal: typeof import('./components/finance/budget/BudgetDetailModal.vue')['default']
BudgetFormModal: typeof import('./components/finance/budget/BudgetFormModal.vue')['default']
CheckCircleOutlined: typeof import('@ant-design/icons-vue')['CheckCircleOutlined']
CheckOutlined: typeof import('@ant-design/icons-vue')['CheckOutlined']
ClockCircleOutlined: typeof import('@ant-design/icons-vue')['ClockCircleOutlined']
CloseCircleFilled: typeof import('@ant-design/icons-vue')['CloseCircleFilled']
CloseCircleOutlined: typeof import('@ant-design/icons-vue')['CloseCircleOutlined']
CloseOutlined: typeof import('@ant-design/icons-vue')['CloseOutlined']
CloudServerOutlined: typeof import('@ant-design/icons-vue')['CloudServerOutlined']
CloudUploadOutlined: typeof import('@ant-design/icons-vue')['CloudUploadOutlined']
ClusterOutlined: typeof import('@ant-design/icons-vue')['ClusterOutlined']
CopyOutlined: typeof import('@ant-design/icons-vue')['CopyOutlined']
DatabaseOutlined: typeof import('@ant-design/icons-vue')['DatabaseOutlined']
DeleteOutlined: typeof import('@ant-design/icons-vue')['DeleteOutlined']
DesktopOutlined: typeof import('@ant-design/icons-vue')['DesktopOutlined']
DictFormModal: typeof import('./components/system/dict/DictFormModal.vue')['default']
DictItemDrawer: typeof import('./components/system/dict/DictItemDrawer.vue')['default']
DislikeOutlined: typeof import('@ant-design/icons-vue')['DislikeOutlined']
DownloadOutlined: typeof import('@ant-design/icons-vue')['DownloadOutlined']
DuplicateFileModal: typeof import('./components/DuplicateFileModal.vue')['default']
DynamicMenu: typeof import('./components/DynamicMenu/index.vue')['default']
EditOutlined: typeof import('@ant-design/icons-vue')['EditOutlined']
EyeInvisibleOutlined: typeof import('@ant-design/icons-vue')['EyeInvisibleOutlined']
EyeOutlined: typeof import('@ant-design/icons-vue')['EyeOutlined']
FileAddOutlined: typeof import('@ant-design/icons-vue')['FileAddOutlined']
FileOutlined: typeof import('@ant-design/icons-vue')['FileOutlined']
FileTextOutlined: typeof import('@ant-design/icons-vue')['FileTextOutlined']
FileUploader: typeof import('./components/FileUploader.vue')['default']
FlowEditor: typeof import('./components/FlowEditor/index.vue')['default']
FolderAddOutlined: typeof import('@ant-design/icons-vue')['FolderAddOutlined']
FolderFilled: typeof import('@ant-design/icons-vue')['FolderFilled']
FolderOpenOutlined: typeof import('@ant-design/icons-vue')['FolderOpenOutlined']
FolderOutlined: typeof import('@ant-design/icons-vue')['FolderOutlined']
FolderSelector: typeof import('./components/FolderSelector.vue')['default']
GlobalOutlined: typeof import('@ant-design/icons-vue')['GlobalOutlined']
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
HistoryOutlined: typeof import('@ant-design/icons-vue')['HistoryOutlined']
IconPicker: typeof import('./components/common/IconPicker.vue')['default']
LikeOutlined: typeof import('@ant-design/icons-vue')['LikeOutlined']
LinkOutlined: typeof import('@ant-design/icons-vue')['LinkOutlined']
LockOutlined: typeof import('@ant-design/icons-vue')['LockOutlined']
MenuFormModal: typeof import('./components/system/menu/MenuFormModal.vue')['default']
MessageOutlined: typeof import('@ant-design/icons-vue')['MessageOutlined']
MoreOutlined: typeof import('@ant-design/icons-vue')['MoreOutlined']
PlusOutlined: typeof import('@ant-design/icons-vue')['PlusOutlined']
ProjectUpload: typeof import('./components/ProjectUpload.vue')['default']
ReloadOutlined: typeof import('@ant-design/icons-vue')['ReloadOutlined']
ResetPasswordModal: typeof import('./components/system/user/ResetPasswordModal.vue')['default']
RightOutlined: typeof import('@ant-design/icons-vue')['RightOutlined']
RoleFormModal: typeof import('./components/system/role/RoleFormModal.vue')['default']
RollbackOutlined: typeof import('@ant-design/icons-vue')['RollbackOutlined']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SafetyCertificateOutlined: typeof import('@ant-design/icons-vue')['SafetyCertificateOutlined']
SaveOutlined: typeof import('@ant-design/icons-vue')['SaveOutlined']
SearchOutlined: typeof import('@ant-design/icons-vue')['SearchOutlined']
SwapOutlined: typeof import('@ant-design/icons-vue')['SwapOutlined']
SyncOutlined: typeof import('@ant-design/icons-vue')['SyncOutlined']
TagOutlined: typeof import('@ant-design/icons-vue')['TagOutlined']
TaskLogViewer: typeof import('./components/TaskLogViewer.vue')['default']
ThunderboltOutlined: typeof import('@ant-design/icons-vue')['ThunderboltOutlined']
UploadCore: typeof import('./components/UploadCore.vue')['default']
UploadOutlined: typeof import('@ant-design/icons-vue')['UploadOutlined']
UserFormModal: typeof import('./components/system/user/UserFormModal.vue')['default']
UserOutlined: typeof import('@ant-design/icons-vue')['UserOutlined']
WarningOutlined: typeof import('@ant-design/icons-vue')['WarningOutlined']
}
}

View File

@@ -0,0 +1,457 @@
<template>
<a-drawer
v-model:open="visible"
title="上传文件"
placement="right"
:width="600"
:maskClosable="false"
@close="handleClose"
>
<div class="uploader-container">
<!-- 顶部操作栏 -->
<div class="action-bar">
<a-space>
<a-upload
:file-list="fileList"
:multiple="true"
:before-upload="beforeUpload"
:show-upload-list="false"
>
<a-button type="primary">
<template #icon><UploadOutlined /></template>
上传文件
</a-button>
</a-upload>
<!-- webkitdirectory 属性用于选择文件夹但在标准 a-upload 中可能需要自定义属性 -->
<a-upload
:file-list="fileList"
:before-upload="beforeUpload"
:show-upload-list="false"
directory
>
<a-button type="primary">
<template #icon><FolderOpenOutlined /></template>
上传文件夹
</a-button>
</a-upload>
<a-button @click="clearFiles" :disabled="fileList.length === 0">清空列表</a-button>
</a-space>
</div>
<!-- 拖拽区域 -->
<div class="upload-area">
<a-upload-dragger
:file-list="fileList"
:multiple="true"
:before-upload="beforeUpload"
:show-upload-list="false"
:customRequest="customRequest"
class="upload-dragger"
>
<div class="dragger-content">
<p class="ant-upload-drag-icon">
<CloudUploadOutlined />
</p>
<p class="ant-upload-text">将需要上传的文件拖拽到此处</p>
</div>
</a-upload-dragger>
</div>
<!-- 文件列表 -->
<div class="file-list-header" v-if="fileList.length > 0">
<span>待上传列表 ({{ fileList.length }})</span>
</div>
<div class="upload-list" v-if="fileList.length > 0">
<a-list :data-source="fileList" size="small">
<template #renderItem="{ item, index }">
<a-list-item>
<template #actions>
<a-button type="text" danger size="small" @click="removeFile(index)">
<DeleteOutlined />
</a-button>
</template>
<a-list-item-meta>
<template #title>
<span :class="{ 'error-text': item.status === 'error', 'success-text': item.status === 'done' }">
{{ item.name }}
</span>
</template>
<template #description>
<a-progress
v-if="item.status === 'uploading'"
:percent="item.percent"
size="small"
/>
<span v-else-if="item.status === 'error'" class="error-text">上传失败</span>
<span v-else-if="item.status === 'done'" class="success-text">上传成功</span>
<span v-else>{{ formatSize(item.size) }}</span>
</template>
<template #avatar>
<FileOutlined />
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</div>
<div v-else class="empty-state">
<InboxOutlined style="font-size: 48px; color: #d9d9d9;" />
<p style="color: #999; margin-top: 10px;">暂无文件</p>
</div>
</div>
<template #footer>
<div class="footer-actions">
<a-button @click="handleClose">取消</a-button>
<a-button
type="primary"
:loading="uploading"
@click="checkAndUpload"
:disabled="fileList.length === 0 || uploading"
>
{{ uploading ? '上传中...' : '确定上传' }} ({{ fileList.length }})
</a-button>
</div>
</template>
<!-- 同名文件提示 -->
<DuplicateFileModal
v-model:visible="duplicateModalVisible"
:duplicate-files="duplicateFiles"
@skip-all="handleSkipDuplicates"
@overwrite-all="handleOverwriteDuplicates"
/>
</a-drawer>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import type { UploadProps, UploadFile } from 'ant-design-vue'
import {
UploadOutlined,
FolderOpenOutlined,
CloudUploadOutlined,
FileOutlined,
DeleteOutlined,
InboxOutlined
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import axios from 'axios'
import DuplicateFileModal from './DuplicateFileModal.vue'
import { checkFileBatch, uploadFile } from '@/api/file'
import type { DuplicateFile } from '@/types'
const props = defineProps<{
serverId: number
path: string
}>()
const visible = ref(false)
const fileList = ref<UploadFile[]>([])
const uploading = ref(false)
const duplicateModalVisible = ref(false)
const duplicateFiles = ref<DuplicateFile[]>([])
// 辅助函数:更新文件进度并强制触发 Vue 响应式更新
const updateFileProgress = (file: UploadFile, percent: number, status?: UploadFile['status']) => {
const index = fileList.value.findIndex(f => f.uid === file.uid)
if (index !== -1) {
const updated = { ...fileList.value[index], percent: Math.floor(percent) }
if (status !== undefined) {
updated.status = status
}
fileList.value[index] = updated
}
}
// 暴露打开方法给父组件
const open = () => {
visible.value = true
fileList.value = []
uploading.value = false
}
// 关闭处理
const handleClose = () => {
if (uploading.value) {
message.warning('正在上传中,请稍后')
return
}
visible.value = false
}
// 拦截自动上传
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// Add to list manually to control state better
fileList.value = [...fileList.value, file]
return false // Prevent auto upload
}
// 也就是阻止默认的XHR请求虽然beforeUpload返回false已经阻止了
// 但UploadDragger有时候行为不一致保留这个空实现更安全
const customRequest = ({ onSuccess }: any) => {
onSuccess()
}
// 移除文件
const removeFile = (index: number) => {
const newFileList = [...fileList.value]
newFileList.splice(index, 1)
fileList.value = newFileList
}
// 清空列表
const clearFiles = () => {
fileList.value = []
}
// 格式化大小
const formatSize = (bytes: number | undefined) => {
if (bytes === undefined) return '0 B'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 检查文件并上传
const checkAndUpload = async () => {
if (fileList.value.length === 0) return
// 1. 构建检查路径
const basePath = props.path.replace(/\/$/, '')
// 过滤掉已经上传成功的
const pendingFiles = fileList.value.filter(f => f.status !== 'done')
if (pendingFiles.length === 0) {
if (fileList.value.some(f => f.status === 'done')) {
message.success('文件已全部上传')
visible.value = false
}
return
}
const pathsToCheck = pendingFiles.map(f => {
// 这里的 UploadFile 可能是 a-upload 生成的,没有 path 属性,使用 name
const relativePath = (f as any).webkitRelativePath || f.name
return `${basePath}/${relativePath}`
})
// 2. 调用检查接口
try {
uploading.value = true
const existingPaths = await checkFileBatch(props.serverId, pathsToCheck)
uploading.value = false
if (existingPaths && existingPaths.length > 0) {
// 发现重复文件
const duplicates: DuplicateFile[] = []
const existingSet = new Set(existingPaths)
for (let i = 0; i < pendingFiles.length; i++) {
const file = pendingFiles[i]
const relativePath = (file as any).webkitRelativePath || file.name
const fullPath = `${basePath}/${relativePath}`
if (existingSet.has(fullPath)) {
duplicates.push({
uid: file.uid,
name: file.name,
path: relativePath,
size: file.size,
file: file.originFileObj as File
})
}
}
if (duplicates.length > 0) {
duplicateFiles.value = duplicates
duplicateModalVisible.value = true
return
}
}
// 没有重复,直接上传
startUpload()
} catch (error) {
console.error(error)
uploading.value = false
// 显示确认框,让用户选择是否跳过检查直接上传
Modal.confirm({
title: '检查文件失败',
content: '无法检查服务器上是否存在同名文件(可能是网络问题)。是否跳过检查直接上传?\n\n注意如果服务器上存在同名文件将会被覆盖。',
okText: '直接上传',
cancelText: '取消',
onOk: () => {
startUpload()
}
})
}
}
// 跳过重复
const handleSkipDuplicates = () => {
// 从 fileList 中移除重复的
const dupeUids = new Set(duplicateFiles.value.map(d => d.uid))
fileList.value = fileList.value.filter(f => !dupeUids.has(f.uid))
duplicateFiles.value = []
if (fileList.value.length > 0) {
startUpload()
} else {
visible.value = false
}
}
// 覆盖重复
const handleOverwriteDuplicates = () => {
// 不做处理,直接上传(因为上传接口支持 overwrite
duplicateFiles.value = []
startUpload()
}
// 开始上传
const startUpload = async () => {
if (fileList.value.length === 0) return
uploading.value = true
for (const file of fileList.value) {
if (file.status === 'done') continue // Skip already uploaded
// 使用辅助函数更新状态
updateFileProgress(file, 0, 'uploading')
try {
// 获取实际的 File 对象,可能在 originFileObj 中或文件本身就是 File
const realFile = (file.originFileObj || file) as File
// 检查文件对象是否有效
if (!realFile || typeof realFile.size !== 'number') {
throw new Error('无效的文件对象')
}
const basePath = props.path.replace(/\/$/, '')
const relativePath = (file as any).webkitRelativePath || file.name
// 1Panel 的 upload 接口 expects "path" to be the DIRECTORY where file is saved.
// 所以我们传 props.path (如果 relativePath 只有文件名)
// 如果 relativePath 包含子目录(如 'folder/file.txt'),我们需要传 props.path + '/folder'
let targetDir = basePath
const lastSlashIndex = relativePath.lastIndexOf('/')
if (lastSlashIndex > 0) {
targetDir = `${basePath}/${relativePath.substring(0, lastSlashIndex)}`
}
// 使用直接上传1Panel 分片上传 API 参数不明确,暂时禁用分片上传)
await uploadFile(
props.serverId,
targetDir,
realFile,
(progressEvent) => {
if (progressEvent.total) {
const percent = (progressEvent.loaded / progressEvent.total) * 100
updateFileProgress(file, percent)
}
}
)
// 上传成功
updateFileProgress(file, 100, 'done')
} catch (error: any) {
console.error('Upload failed', error)
updateFileProgress(file, 0, 'error')
// 显示更详细的错误信息
const errorMsg = error?.response?.data?.message || error?.message || '未知错误'
message.error(`文件 ${file.name} 上传失败: ${errorMsg}`)
}
}
uploading.value = false
// Check if all success
const allSuccess = fileList.value.every(f => f.status === 'done')
if (allSuccess) {
message.success('所有文件上传成功')
setTimeout(() => {
visible.value = false
// Emit event to refresh parent list
emit('upload-success')
}, 1000)
} else {
message.warn('部分文件上传失败,请重试')
}
}
const emit = defineEmits<{
(e: 'upload-success'): void
}>()
defineExpose({
open
})
</script>
<style scoped>
.uploader-container {
display: flex;
flex-direction: column;
height: 100%;
}
.action-bar {
margin-bottom: 16px;
}
.upload-area {
margin-bottom: 24px;
}
.dragger-content {
padding: 30px 0;
}
.file-list-header {
margin-bottom: 8px;
font-weight: bold;
color: #333;
}
.upload-list {
flex: 1;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
padding: 8px;
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px dashed #d9d9d9;
border-radius: 4px;
background-color: #fafafa;
}
.footer-actions {
text-align: right;
}
.error-text {
color: #ff4d4f;
}
.success-text {
color: #52c41a;
}
</style>

View File

@@ -0,0 +1,412 @@
<template>
<div class="folder-selector">
<a-input
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
readonly
@click="!disabled && openModal()"
>
<template #prefix>
<FolderOutlined
class="folder-icon"
:style="{ cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1 }"
@click.stop="!disabled && openModal()"
/>
</template>
</a-input>
<!-- 文件列表弹窗 -->
<a-modal
v-model:open="modalVisible"
title="文件列表"
:width="600"
:maskClosable="false"
@ok="handleConfirm"
@cancel="handleCancel"
>
<div class="folder-browser">
<!-- 当前路径 -->
<div class="path-bar">
<a-input v-model:value="currentPath" readonly>
<template #prefix>
<HomeOutlined />
</template>
</a-input>
</div>
<!-- 操作栏 -->
<div class="action-bar">
<a-button type="link" @click="goBack" :disabled="currentPath === '/'">
返回上一级
</a-button>
<div class="right-actions">
<a-button type="link" @click="handleNewFolder">新建文件夹</a-button>
<a-button type="link" @click="handleNewFile">新建文件</a-button>
</div>
</div>
<!-- 文件/文件夹列表 -->
<div class="file-list">
<a-spin :spinning="loading">
<div
v-for="item in fileList"
:key="item.name"
class="file-item"
:class="{ selected: selectedPath === getFullPath(item.name) }"
@click="handleSelect(item)"
@dblclick="handleDoubleClick(item)"
>
<FolderFilled v-if="item.isDir" class="icon folder" />
<FileOutlined v-else class="icon file" />
<span class="name">{{ item.name }}</span>
<a-popconfirm
title="确定删除吗?"
@confirm="handleDelete(item)"
@click.stop
>
<a-button type="text" danger size="small" class="delete-btn">
<template #icon><DeleteOutlined /></template>
</a-button>
</a-popconfirm>
</div>
<div v-if="!loading && fileList.length === 0" class="empty">
当前目录为空
</div>
</a-spin>
</div>
<!-- 当前选中 -->
<div class="selected-path">
<span>当前选中:</span>
<a-tag v-if="selectedPath" color="blue">{{ selectedPath }}</a-tag>
<span v-else class="no-select">未选择</span>
</div>
</div>
<!-- 新建文件夹对话框 -->
<a-modal
v-model:open="newFolderVisible"
title="新建文件夹"
:width="400"
@ok="confirmNewFolder"
>
<a-input v-model:value="newFolderName" placeholder="请输入文件夹名称" />
</a-modal>
<a-modal
v-model:open="newFileVisible"
title="新建文件"
:width="400"
@ok="confirmNewFile"
>
<a-input v-model:value="newFileName" placeholder="请输入文件名称" />
</a-modal>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import {
FolderOutlined,
FolderFilled,
FileOutlined,
HomeOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
interface FileItem {
name: string
isDir: boolean
size?: number
modTime?: string
}
const props = defineProps<{
modelValue: string
serverId: number
placeholder?: string
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
// 弹窗状态
const modalVisible = ref(false)
const loading = ref(false)
const currentPath = ref('/')
const selectedPath = ref('')
const fileList = ref<FileItem[]>([])
// 新建文件夹
const newFolderVisible = ref(false)
const newFolderName = ref('')
const newFileVisible = ref(false)
const newFileName = ref('')
// 打开弹窗
function openModal() {
modalVisible.value = true
currentPath.value = props.modelValue || '/'
selectedPath.value = props.modelValue || ''
loadFiles()
}
// 加载文件列表
async function loadFiles() {
if (!props.serverId) {
message.warning('请先选择服务器')
return
}
loading.value = true
try {
const res = await request.post<any>('/platform/files/list', {
serverId: props.serverId,
path: currentPath.value
})
if (res.data.code === 200) {
fileList.value = (res.data.data.items || [])
// .filter((item: FileItem) => item.isDir) // 移除过滤,显示所有文件
.sort((a: FileItem, b: FileItem) => {
// 文件夹排在前面
if (a.isDir && !b.isDir) return -1;
if (!a.isDir && b.isDir) return 1;
return a.name.localeCompare(b.name);
})
} else {
message.error(res.data.message || '加载失败')
}
} catch (error: any) {
console.error('加载文件列表失败:', error)
message.error('加载文件列表失败')
} finally {
loading.value = false
}
}
// 返回上一级
function goBack() {
if (currentPath.value === '/') return
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
currentPath.value = '/' + parts.join('/')
if (!currentPath.value) currentPath.value = '/'
loadFiles()
}
// 获取完整路径
function getFullPath(name: string): string {
if (currentPath.value === '/') {
return '/' + name
}
return currentPath.value + '/' + name
}
// 选择项目
function handleSelect(item: FileItem) {
if (item.isDir) {
selectedPath.value = getFullPath(item.name)
}
}
// 双击进入目录
function handleDoubleClick(item: FileItem) {
if (item.isDir) {
currentPath.value = getFullPath(item.name)
loadFiles()
}
}
// 新建文件夹
function handleNewFolder() {
newFolderName.value = ''
newFolderVisible.value = true
}
// 确认新建文件夹
async function confirmNewFolder() {
if (!newFolderName.value.trim()) {
message.warning('请输入文件夹名称')
return
}
try {
const newPath = getFullPath(newFolderName.value)
await request.post('/platform/files/mkdir', {
serverId: props.serverId,
path: newPath
})
message.success('创建成功')
newFolderVisible.value = false
loadFiles()
} catch (error: any) {
// 显示具体错误信息
const msg = error.response?.data?.message || error.message || '创建失败'
message.error(msg)
}
}
// 删除文件/文件夹
async function handleDelete(item: FileItem) {
try {
const path = getFullPath(item.name)
await request.post('/platform/files/delete', {
serverId: props.serverId,
path,
isDir: item.isDir
})
message.success('删除成功')
loadFiles()
} catch (error: any) {
const msg = error.response?.data?.message || error.message || '删除失败'
message.error(msg)
}
}
// 新建文件
function handleNewFile() {
newFileName.value = ''
newFileVisible.value = true
}
// 确认新建文件
async function confirmNewFile() {
if (!newFileName.value.trim()) {
message.warning('请输入文件名称')
return
}
try {
const newPath = getFullPath(newFileName.value)
await request.post('/platform/files/create', {
serverId: props.serverId,
path: newPath
})
message.success('创建成功')
newFileVisible.value = false
loadFiles()
} catch (error: any) {
const msg = error.response?.data?.message || error.message || '创建失败'
message.error(msg)
}
}
// 确认选择
function handleConfirm() {
if (selectedPath.value) {
emit('update:modelValue', selectedPath.value)
}
modalVisible.value = false
}
// 取消
function handleCancel() {
modalVisible.value = false
}
// 监听modelValue变化
watch(() => props.modelValue, (val) => {
if (val && !modalVisible.value) {
selectedPath.value = val
}
})
</script>
<style scoped>
.folder-selector :deep(.ant-input) {
cursor: pointer;
}
.folder-icon {
cursor: pointer;
}
.folder-browser {
min-height: 400px;
}
.path-bar {
margin-bottom: 12px;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.right-actions {
display: flex;
gap: 8px;
}
.file-list {
min-height: 300px;
max-height: 400px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
}
.file-item {
display: flex;
align-items: center;
padding: 10px 16px;
cursor: pointer;
transition: background-color 0.2s;
}
.file-item:hover {
background-color: #f5f5f5;
}
.file-item.selected {
background-color: #e6f7ff;
}
.file-item .icon {
margin-right: 8px;
font-size: 18px;
}
.file-item .icon.folder {
color: #faad14;
}
.file-item .icon.file {
color: #1890ff;
}
.file-item .name {
flex: 1;
}
.empty {
padding: 40px;
text-align: center;
color: #999;
}
.selected-path {
margin-top: 12px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
}
.selected-path .no-select {
color: #999;
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<a-modal
:open="open"
title="日志详情"
:footer="null"
width="800px"
@update:open="val => emit('update:open', val)"
@cancel="handleClose"
>
<div class="log-container">
<div class="log-status">
<span>状态</span>
<a-tag :color="getStatusColor(status)">
{{ getStatusText(status) }}
</a-tag>
<a-spin v-if="isActive(status)" size="small" style="margin-left: 8px" />
<a-button v-if="containerName || composePath" type="link" size="small" @click="fetchLogs">刷新</a-button>
</div>
<div class="log-content" ref="logContentRef">
<pre><code>{{ logs.join('\n') }}</code></pre>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { ref, watch, onUnmounted, nextTick } from 'vue'
import { readTaskLog } from '@/api/database'
import { getContainerLog } from '@/api/runtime'
const props = defineProps<{
open: boolean
serverId: number | undefined
taskId?: string
containerName?: string
composePath?: string
}>()
const emit = defineEmits<{
(e: 'update:open', value: boolean): void
}>()
const logs = ref<string[]>([])
const status = ref('')
const logContentRef = ref<HTMLElement>()
let timer: any = null
function isActive(s: string) {
return s === 'Executing' || s === 'Waiting'
}
function getStatusColor(s: string) {
switch (s) {
case 'Success': return 'green'
case 'Failed': return 'red'
case 'Executing': return 'blue'
case 'Waiting': return 'orange'
case 'Fetched': return 'green'
default: return 'default'
}
}
function getStatusText(s: string) {
switch (s) {
case 'Success': return '成功'
case 'Failed': return '失败'
case 'Executing': return '执行中'
case 'Waiting': return '等待中'
case 'Fetched': return '已获取'
default: return s
}
}
// 1. Remove "data: container |" prefix
function cleanLogLine(line: string) {
if (!line) return ''
let content = line
// Handle "data: " prefix
if (content.startsWith('data:')) {
// Find the pipe separator
const pipeIdx = content.indexOf('|')
if (pipeIdx > -1) {
// "data: name | log..."
content = content.substring(pipeIdx + 1)
} else {
// "data: log..."
content = content.substring(5)
}
}
// 2. Remove ANSI Color Codes
// eslint-disable-next-line no-control-regex
content = content.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '')
return content
}
async function fetchLogs() {
if (!props.serverId) return
if (props.taskId) {
// ... (task log)
try {
const res = await readTaskLog(props.serverId, props.taskId)
logs.value = res.lines || []
status.value = res.taskStatus
// Check if active
if (isActive(res.taskStatus)) {
if (!timer) {
timer = setInterval(fetchLogs, 2000)
}
} else {
stopPolling()
}
} catch (e) {
console.error(e)
stopPolling()
}
} else if (props.containerName || props.composePath) {
status.value = 'Fetching'
try {
const res = await getContainerLog(props.serverId, props.containerName || '', props.composePath)
logs.value = res.log ? res.log.split('\n').map(cleanLogLine) : ['无日志内容']
status.value = 'Fetched'
} catch (e: any) {
logs.value = ['获取日志失败: ' + (e.message || '未知错误')]
status.value = 'Failed'
}
}
// ...
// Auto scroll to bottom
nextTick(() => {
if (logContentRef.value) {
logContentRef.value.scrollTop = logContentRef.value.scrollHeight
}
})
}
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null
}
}
function handleClose() {
stopPolling()
emit('update:open', false)
}
watch(() => props.open, (val) => {
if (val) {
logs.value = []
status.value = ''
fetchLogs()
} else {
stopPolling()
}
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
.log-container .log-status {
display: flex;
align-items: center;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.log-container .log-content {
max-height: 500px;
overflow-y: auto;
background: #1e1e1e;
border-radius: 8px;
padding: 16px;
}
.log-container .log-content pre {
margin: 0;
}
.log-container .log-content code {
color: #d4d4d4;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -418,14 +418,34 @@ function updateMenuState() {
projectStore.switchProject(projectId)
}
const subPath = path.replace(`/app/${projectId}`, '')
// 同步子项目 iframe URL
const subPath = path.replace(`/app/${projectId}`, '') || '/dashboard'
const project = projectStore.getProjectById(projectId)
// 只有当项目已加载且 url 有效时才更新
if (project?.baseUrl) {
const targetUrl = `${project.baseUrl}${subPath}?__embedded=true`
// 避免重复刷新
if (subProjectUrl.value !== targetUrl) {
iframeLoading.value = true
subProjectUrl.value = targetUrl
}
}
const subMenuRouteMap = projectStore.getMenuRouteMap()
for (const [menuKey, menuPath] of Object.entries(subMenuRouteMap)) {
const relativePath = menuPath.replace(`/app/${projectId}`, '')
if (subPath === relativePath || subPath.startsWith(relativePath + '/')) {
selectedKeys.value = [menuKey]
// 展开逻辑略
// 展开父级菜单
const pathParts = menuKey.split('/').filter(Boolean)
if (pathParts.length > 1) {
const parentPath = `/${pathParts[0]}`
const openKey = `sub-${parentPath}`
if (!openKeys.value.includes(openKey)) {
openKeys.value.push(openKey)
}
}
break
}
}

View File

@@ -1,63 +0,0 @@
// 数据库类型
export type DatabaseType = 'mysql' | 'postgresql' | 'redis'
// 数据库状态
export type DatabaseStatus = 'running' | 'stopped' | 'error'
// 数据库信息
export interface DatabaseInfo {
type: DatabaseType
status: DatabaseStatus
version: string
}
// 数据库实例
export interface DatabaseItem {
id: number
name: string
username: string
password: string
description?: string
createdAt: string
updatedAt?: string
}
// 创建数据库请求
export interface CreateDatabaseRequest {
name: string
username: string
password: string
description?: string
}
// 更新数据库请求
export interface UpdateDatabaseRequest {
name?: string
username?: string
password?: string
description?: string
}
// 数据库连接信息
export interface DatabaseConnectionInfo {
host: string
port: number
username: string
password: string
database: string
}
// 分页请求
export interface DatabaseListParams {
keyword?: string
page?: number
pageSize?: number
}
// 分页响应
export interface DatabaseListResponse {
list: DatabaseItem[]
total: number
page: number
pageSize: number
}

View File

@@ -0,0 +1,23 @@
/**
* 证书管理类型定义
*/
export interface Certificate {
id: string
domain: string
otherDomain?: string
cn?: string
issuer: string
status: 'valid' | 'expired' | 'pending'
startDate: string
expireDate: string
autoRenew: boolean
verifyType: 'dns'
dnsAccount?: string
dnsAccountName?: string
dnsAccountType?: string
acmeAccount: string
remark?: string
certContent?: string
keyContent?: string
}

View File

@@ -0,0 +1,9 @@
/**
* 数据库管理类型定义
*/
export interface DatabaseType {
key: string
label: string
defaultPort: number
}

View File

@@ -0,0 +1,11 @@
/**
* 文件管理类型定义
*/
export interface FileItem {
name: string
isDir: boolean
size: number
modTime: string
mode?: string
}

View File

@@ -0,0 +1,17 @@
/**
* 平台菜单管理类型定义
*/
import type { MenuItem } from '@/config'
export interface PlatformProject {
id: string
name: string
shortName: string
logo: string
color?: string
}
export interface MenuNode extends MenuItem {
order?: number
}

View File

@@ -0,0 +1,36 @@
/**
* 服务器管理类型定义
*/
export interface MonitorData {
cpu: number
memory: number
disk: number
networkIn: number
networkOut: number
time: string
}
export type ServerStatus = 'online' | 'offline' | 'warning' | 'maintenance'
export type ServerType = 'cloud' | 'physical' | 'virtual'
export interface ServerInfo {
id: number
name: string
ip: string
internalIp?: string
port?: number
user?: string
status: ServerStatus
type: ServerType
os?: string
cpu?: number
memory?: number
disk?: number
region?: string
expiredAt?: string
remark?: string
// 监控数据
monitor?: MonitorData
tags?: string[]
}

View File

@@ -0,0 +1,14 @@
/**
* 上传文件类型定义
*/
export interface UploadFile {
uid: string
name: string
path: string // 相对路径
size: number
type: string
status: 'pending' | 'uploading' | 'done' | 'error'
percent: number
file: File
}

View File

@@ -0,0 +1,9 @@
/**
* 文件上传类型定义
*/
export interface UploadOptions {
path: string
overwrite: boolean
createDirs: boolean
}

33
src/types/system/dept.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 部门管理类型定义
*/
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
}

12
src/types/system/dict.ts Normal file
View File

@@ -0,0 +1,12 @@
/**
* 字典管理类型定义
*/
export interface DictRecord {
id: number
name: string
code: string
remark?: string
status: number
createdAt?: string
}

View File

@@ -10,7 +10,7 @@ import type { ApiResponse } from '@/types/api/response'
console.log('API Base URL:', import.meta.env.VITE_API_BASE_URL)
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 30000,
timeout: 5 * 60 * 1000, // 5分钟超时适合大文件上传
headers: {
'Content-Type': 'application/json'
}

View File

@@ -262,15 +262,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
ArrowLeftOutlined,
CopyOutlined,
DownloadOutlined,
SyncOutlined
} from '@ant-design/icons-vue'
import type { TablePaginationConfig } from 'ant-design-vue'
import {
getCertificateList,
@@ -287,27 +279,8 @@ import {
type WebsiteInfo
} from '@/api/certificate'
import { getServerList, type ServerBase } from '@/api/server'
import type { Certificate } from '@/types/platform/certificates'
// 证书类型定义
interface Certificate {
id: string
domain: string
otherDomain?: string
cn?: string
issuer: string
status: 'valid' | 'expired' | 'pending'
startDate: string
expireDate: string
autoRenew: boolean
verifyType: 'dns'
dnsAccount?: string
dnsAccountName?: string
dnsAccountType?: string
acmeAccount: string
remark?: string
certContent?: string
keyContent?: string
}
// 表格列配置
const columns = [

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -361,22 +361,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
CopyOutlined,
DeleteOutlined,
TagOutlined,
CloudServerOutlined,
SwapOutlined,
DownloadOutlined,
UploadOutlined,
EyeOutlined,
LockOutlined
} from '@ant-design/icons-vue'
import type { EnvironmentInfo, EnvironmentType, EnvironmentVariable } from '@/types/platform'
import { mockEnvironments, mockServers } from '@/mock/platform'
import { mockProjects } from '@/mock/projects'

View File

@@ -0,0 +1,267 @@
<template>
<div class="file-manager-page">
<a-page-header title="文件管理" sub-title="管理服务器文件">
<template #extra>
<a-select
v-model:value="currentServerId"
placeholder="请选择服务器"
style="width: 200px"
@change="handleServerChange"
>
<a-select-option v-for="server in serverList" :key="server.id" :value="server.id">
{{ server.name }} ({{ server.ip }})
</a-select-option>
</a-select>
</template>
</a-page-header>
<a-card :bordered="false">
<!-- 操作栏 -->
<div class="action-bar">
<div class="left-actions">
<!-- 路径导航 -->
<a-input-group compact style="display: flex; width: 400px">
<a-button @click="goUp" :disabled="currentPath === '/'"><ArrowUpOutlined /></a-button>
<a-input v-model:value="currentPath" @pressEnter="loadFiles" style="flex: 1" />
<a-button type="primary" @click="loadFiles"><ReloadOutlined /></a-button>
</a-input-group>
</div>
<div class="right-actions">
<a-space>
<a-button @click="handleNewFolder"><FolderAddOutlined /> 新建文件夹</a-button>
<a-button @click="handleNewFile"><FileAddOutlined /> 新建文件</a-button>
<FileUploader
v-if="currentServerId"
:server-id="currentServerId"
:path="currentPath"
@success="loadFiles"
>
<a-button type="primary"><CloudUploadOutlined /> 上传文件</a-button>
</FileUploader>
</a-space>
</div>
</div>
<!-- 文件列表 -->
<a-table
:columns="columns"
:data-source="fileList"
:loading="loading"
row-key="name"
:pagination="false"
:scroll="{ y: 600 }"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="file-name" @click="handleItemClick(record as FileItem)" style="cursor: pointer; display: flex; align-items: center;">
<FolderFilled v-if="record.isDir" style="color: #faad14; font-size: 18px; margin-right: 8px;" />
<FileOutlined v-else style="color: #1890ff; font-size: 18px; margin-right: 8px;" />
<span :style="{ fontWeight: record.isDir ? '500' : 'normal' }">{{ record.name }}</span>
</div>
</template>
<template v-if="column.key === 'size'">
<span v-if="!record.isDir">{{ formatSize(record.size) }}</span>
<span v-else>-</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="handleDownload(record as FileItem)" v-if="!record.isDir">下载</a-button>
<a-popconfirm title="确定删除吗?" @confirm="handleDelete(record as FileItem)">
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- 新建文件夹弹窗 -->
<a-modal v-model:open="newFolderVisible" title="新建文件夹" @ok="confirmNewFolder">
<a-input v-model:value="newFolderName" placeholder="请输入文件夹名称" ref="newFolderInput" />
</a-modal>
<!-- 新建文件弹窗 -->
<a-modal v-model:open="newFileVisible" title="新建文件" @ok="confirmNewFile">
<a-input v-model:value="newFileName" placeholder="请输入文件名称" />
</a-modal>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import request from '@/utils/request'
import { getServerList } from '@/api/server'
import FileUploader from '@/components/FileUploader.vue'
import type { FileItem } from '@/types/platform/files'
const loading = ref(false)
const serverList = ref<any[]>([])
const currentServerId = ref<number | undefined>(undefined)
const currentPath = ref('/')
const fileList = ref<FileItem[]>([])
const newFolderVisible = ref(false)
const newFolderName = ref('')
const newFileVisible = ref(false)
const newFileName = ref('')
const columns = [
{ title: '名称', key: 'name', sorter: (a: FileItem, b: FileItem) => a.name.localeCompare(b.name) },
{ title: '大小', key: 'size', width: 120, align: 'right' as const },
{ title: '修改时间', dataIndex: 'modTime', key: 'modTime', width: 200 },
{ title: '权限', dataIndex: 'mode', key: 'mode', width: 100 },
{ title: '操作', key: 'action', width: 150, align: 'center' as const }
]
onMounted(async () => {
await loadServers()
})
async function loadServers() {
try {
const res = await getServerList()
serverList.value = res || []
if (serverList.value.length > 0) {
currentServerId.value = serverList.value[0].id
loadFiles()
}
} catch (error) {
console.error('加载服务器列表失败', error)
}
}
function handleServerChange() {
currentPath.value = '/'
loadFiles()
}
async function loadFiles() {
if (!currentServerId.value) return
loading.value = true
try {
const res = await request.post<any>('/platform/files/list', {
serverId: currentServerId.value,
path: currentPath.value
})
// 兼容不同的后端返回结构
const data = res.data.data
const items = Array.isArray(data) ? data : (data.items || [])
fileList.value = items.sort((a: FileItem, b: FileItem) => {
if (a.isDir && !b.isDir) return -1
if (!a.isDir && b.isDir) return 1
return a.name.localeCompare(b.name)
})
} catch (error) {
console.error('加载文件列表失败', error)
fileList.value = []
} finally {
loading.value = false
}
}
function handleItemClick(item: FileItem) {
if (item.isDir) {
currentPath.value = getFullPath(item.name)
loadFiles()
}
}
function goUp() {
if (currentPath.value === '/') return
const parts = currentPath.value.split('/').filter(Boolean)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
function getFullPath(name: string) {
return currentPath.value === '/' ? `/${name}` : `${currentPath.value}/${name}`
}
function formatSize(bytes: number) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// 新建文件夹
function handleNewFolder() {
newFolderName.value = ''
newFolderVisible.value = true
}
async function confirmNewFolder() {
if (!newFolderName.value) return
try {
await request.post('/platform/files/mkdir', {
serverId: currentServerId.value,
path: getFullPath(newFolderName.value)
})
message.success('创建成功')
newFolderVisible.value = false
loadFiles()
} catch (error: any) {
message.error(error.response?.data?.message || '创建失败')
}
}
// 新建文件
function handleNewFile() {
newFileName.value = ''
newFileVisible.value = true
}
async function confirmNewFile() {
if (!newFileName.value) return
try {
await request.post('/platform/files/create', {
serverId: currentServerId.value,
path: getFullPath(newFileName.value)
})
message.success('创建成功')
newFileVisible.value = false
loadFiles()
} catch (error: any) {
message.error(error.response?.data?.message || '创建失败')
}
}
// 删除
async function handleDelete(item: FileItem) {
try {
await request.post('/platform/files/delete', {
serverId: currentServerId.value,
path: getFullPath(item.name),
isDir: item.isDir
})
message.success('删除成功')
loadFiles()
} catch (error: any) {
message.error(error.response?.data?.message || '删除失败')
}
}
// 下载
function handleDownload(item: FileItem) {
// 简易下载实现,实际上可能需要一个 dedicated download API that handles streams or signed URLs
// Assuming backend has a simple download endpoint or we reuse 1Panel approach
// 这里暂时不做完整实现,或者提示用户
message.info('下载功能开发中')
}
</script>
<style scoped>
.file-manager-page {
padding: 24px;
}
.action-bar {
display: flex;
justify-content: space-between;
margin-bottom: 16px;
}
</style>

View File

@@ -242,17 +242,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import {
SearchOutlined,
DownloadOutlined,
EyeOutlined,
TagOutlined,
CloudServerOutlined
} from '@ant-design/icons-vue'
import type { LogRecord, LogType, LogLevel } from '@/types/platform'
import { mockLogs } from '@/mock/platform'
import { mockProjects } from '@/mock/projects'

View File

@@ -148,26 +148,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { MenuItem } from '@/config'
import type { PlatformProject, MenuNode } from '@/types/platform/menus'
const route = useRoute()
interface PlatformProject {
id: string
name: string
shortName: string
logo: string
color?: string
}
interface MenuNode extends MenuItem {
order?: number
}
const selectedProjectId = ref<string>('')
const selectedMenuKeys = ref<string[]>([])
const isEditing = ref(false)

View File

@@ -56,7 +56,13 @@
show-zero
:number-style="{ backgroundColor: record.color || '#1890ff' }"
>
<a-button type="link" size="small" @click="goToMenus(record as any)">
<a-button
type="link"
size="small"
@click="goToMenus(record as any)"
:disabled="(record as any).systemType === 'portal'"
:title="(record as any).systemType === 'portal' ? '门户类项目无菜单' : ''"
>
查看菜单
</a-button>
</a-badge>
@@ -71,8 +77,7 @@
v-if="getDeployStatus((record as any).id).deployed"
type="default"
size="small"
style="color: #52c41a; border-color: #52c41a;"
@click="handleDeploy(record as any)"
style="color: #52c41a; border-color: #52c41a; cursor: default;"
>
<CheckCircleOutlined /> 已部署
</a-button>
@@ -462,20 +467,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
PlusOutlined,
HistoryOutlined,
SaveOutlined,
RollbackOutlined,
DeleteOutlined,
UploadOutlined,
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'
@@ -491,9 +483,13 @@ import {
getServerAcmeAccounts,
getServerDnsAccounts,
checkDeployStatus,
getNginxConfig,
saveNginxConfig,
reloadNginx,
type AcmeAccount,
type DnsAccount,
type DeployResult
type DeployResult,
type NginxConfigResult
} from '@/api/project'
import { getServerList } from '@/api/server'
import { getDomainList } from '@/api/domain'
@@ -672,30 +668,6 @@ const versionList = computed(() => {
return [...(versionProject.value.versions || [])].reverse()
})
// 证书信息表格列
const certColumns = [
{ title: '主域名', dataIndex: 'domain', key: 'domain' },
{ title: '其他域名', dataIndex: 'otherDomain', key: 'otherDomain' },
{ title: '签发组织', dataIndex: 'issuer', key: 'issuer' },
{ title: '类型', dataIndex: 'type', key: 'type' },
{ title: 'Acme 账号', dataIndex: 'acmeAccount', key: 'acmeAccount' },
{ title: '过期时间', dataIndex: 'expireTime', key: 'expireTime' },
{ title: '备注', dataIndex: 'remark', key: 'remark' }
]
// 证书mock数据
const certData = [
{
domain: 'key.nanxiislet.com',
otherDomain: '',
issuer: "Let's Encrypt",
type: 'DNS账号',
acmeAccount: 'acme@1paneldev.com',
expireTime: '2026-03-18',
remark: ''
}
]
const formData = reactive({
id: '', // 对应后端 ID (Update用)
code: '', // 对应后端 Code (项目标识)
@@ -753,21 +725,6 @@ function formatTime(isoString: string): string {
})
}
/**
* 计算菜单项总数
*/
function countMenuItems(menus: ProjectMenuItem[]): number {
if (!menus) return 0
let count = 0
for (const menu of menus) {
if (menu.children) {
count += menu.children.length
} else {
count += 1
}
}
return count
}
/**
* 打开版本管理抽屉
@@ -839,17 +796,28 @@ function handleDeleteVersion(version: ProjectVersion) {
/**
* 进入项目
*/
function handleEnterProject(record: PlatformProject) {
if (!record.enabled) {
function handleEnterProject(record: any) {
if (record.status !== 'active') {
message.warning('项目已禁用,无法进入')
return
}
if (!record.baseUrl) {
// 1. 集成到框架内的后台:切换项目上下文并跳转
if (record.integrateToFramework) {
projectStore.switchProject(record.id)
message.success(`已切换到项目:${record.name}`)
// 跳转到业务项目路由
router.push(`/app/${record.id}/dashboard`)
return
}
// 2. 未集成:打开新页面
const url = record.url || record.baseUrl
if (!url) {
message.warning('项目未配置访问地址,无法进入')
return
}
projectStore.switchProject(record.id)
router.push(`/app/${record.id}/dashboard`)
window.open(url, '_blank')
}
function handleAdd() {
@@ -1077,45 +1045,6 @@ function getServerName(serverId?: string): string {
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)
}
}
/**
* 打开部署弹窗
@@ -1126,10 +1055,6 @@ async function handleDeploy(record: PlatformProject) {
deployResult.value = null
deploySteps.value = []
deployCurrentStep.value = 0
// 加载账户数据 - 已移除,改用后端自动匹配已绑定证书
// await loadPanelAccounts()
deployModalVisible.value = true
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,16 @@
<a-form layout="inline">
<a-form-item label="状态">
<a-select v-model:value="filterStatus" placeholder="全部" style="width: 120px" allow-clear>
<a-select-option value="online">在线</a-select-option>
<a-select-option value="offline">离线</a-select-option>
<a-select-option value="warning">告警</a-select-option>
<a-select-option value="maintenance">维护中</a-select-option>
<a-select-option v-for="item in serverStatusDict" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="类型">
<a-select v-model:value="filterType" placeholder="全部" style="width: 120px" allow-clear>
<a-select-option value="cloud">云服务器</a-select-option>
<a-select-option value="physical">物理机</a-select-option>
<a-select-option value="virtual">虚拟机</a-select-option>
<a-select-option v-for="item in serverTypeDict" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="关键字">
@@ -195,9 +194,9 @@
<a-form-item label="服务器类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择">
<a-select-option value="cloud">云服务器</a-select-option>
<a-select-option value="physical">物理机</a-select-option>
<a-select-option value="virtual">虚拟机</a-select-option>
<a-select-option v-for="item in serverTypeDict" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
@@ -225,10 +224,9 @@
<a-form-item label="标签" name="tags">
<a-select v-model:value="formData.tags" mode="tags" placeholder="输入标签后回车">
<a-select-option value="生产">生产</a-select-option>
<a-select-option value="测试">测试</a-select-option>
<a-select-option value="开发">开发</a-select-option>
<a-select-option value="备份">备份</a-select-option>
<a-select-option v-for="item in serverTagDict" :key="item.value" :value="item.value">
{{ item.label }}
</a-select-option>
</a-select>
</a-form-item>
@@ -347,21 +345,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
SearchOutlined,
EyeOutlined,
LinkOutlined,
CloudServerOutlined,
DesktopOutlined,
ClusterOutlined,
ReloadOutlined,
FileTextOutlined,
GlobalOutlined
} from '@ant-design/icons-vue'
import {
getServerList,
getServerStatus,
@@ -369,11 +353,10 @@ import {
updateServer,
deleteServer,
refreshServerStatus,
type ServerBase,
type ServerInfo,
type ServerStatus,
type ServerType
} from '@/api/server'
import type { MonitorData, ServerStatus, ServerType, ServerInfo } from '@/types/platform/servers'
import { getDictItemsByCode } from '@/api/system/dict'
const router = useRouter()
@@ -387,8 +370,13 @@ const filterStatus = ref<ServerStatus | undefined>()
const filterType = ref<ServerType | undefined>()
const filterKeyword = ref('')
// Dictionaries
const serverStatusDict = ref<any[]>([])
const serverTypeDict = ref<any[]>([])
const serverTagDict = ref<any[]>([])
// 服务器列表,包含基础信息和实时状态
interface ServerDisplayInfo extends Partial<ServerInfo> {
interface ServerDisplayInfo extends Omit<Partial<ServerInfo>, 'cpu' | 'memory' | 'disk'> {
id: number
name: string
ip: string
@@ -410,6 +398,7 @@ interface ServerDisplayInfo extends Partial<ServerInfo> {
disk: { total: number; used: number; usage: number }
// 状态加载标记
statusLoading?: boolean
monitor?: MonitorData
}
const servers = ref<ServerDisplayInfo[]>([])
@@ -458,9 +447,25 @@ const stats = computed(() => {
})
onMounted(() => {
loadDictionaries()
loadServers()
})
async function loadDictionaries() {
try {
const [statusRes, typeRes, tagRes] = await Promise.all([
getDictItemsByCode('server_status'),
getDictItemsByCode('server_type'),
getDictItemsByCode('server_tag')
])
serverStatusDict.value = statusRes.data.data || []
serverTypeDict.value = typeRes.data.data || []
serverTagDict.value = tagRes.data.data || []
} catch (error) {
console.error('加载字典失败:', error)
}
}
/**
* 加载服务器列表
*/
@@ -690,33 +695,27 @@ function goToDomains(record: ServerDisplayInfo) {
router.push({ path: '/platform/domains', query: { serverId: String(record.id) } })
}
// 字典辅助函数
function getDictColor(dict: any[], value: string, defaultColor: string = 'default'): string {
const item = dict.find(item => item.value === value)
return item?.remark || defaultColor
}
function getDictLabel(dict: any[], value: string, defaultText: string = '-'): string {
const item = dict.find(item => item.value === value)
return item?.label || defaultText
}
function getStatusColor(status: ServerStatus): string {
const colors: Record<ServerStatus, string> = {
online: 'green',
offline: 'default',
warning: 'orange',
maintenance: 'blue'
}
return colors[status]
return getDictColor(serverStatusDict.value, status)
}
function getStatusText(status: ServerStatus): string {
const texts: Record<ServerStatus, string> = {
online: '在线',
offline: '离线',
warning: '告警',
maintenance: '维护中'
}
return texts[status]
return getDictLabel(serverStatusDict.value, status)
}
function getTypeText(type: ServerType): string {
const texts: Record<ServerType, string> = {
cloud: '云服务器',
physical: '物理机',
virtual: '虚拟机'
}
return texts[type]
return getDictLabel(serverTypeDict.value, type)
}
function getUsageColor(usage: number): string {
@@ -726,16 +725,7 @@ function getUsageColor(usage: number): string {
}
function getTagColor(tag: string): string {
const colors: Record<string, string> = {
'生产': 'red',
'测试': 'blue',
'开发': 'green',
'备份': 'purple',
'主节点': 'gold',
'从节点': 'cyan',
'离线': 'default'
}
return colors[tag] || 'default'
return getDictColor(serverTagDict.value, tag)
}
</script>

View File

@@ -105,18 +105,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import {
ArrowLeftOutlined,
CloseOutlined,
UploadOutlined,
FolderOpenOutlined,
CloudUploadOutlined,
FileOutlined,
CheckOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -128,17 +117,7 @@ const folderInputRef = ref<HTMLInputElement | null>(null)
// 拖拽状态
const isDragover = ref(false)
// 文件列表
interface UploadFile {
uid: string
name: string
path: string // 相对路径
size: number
type: string
status: 'pending' | 'uploading' | 'done' | 'error'
percent: number
file: File
}
import type { UploadFile } from '@/types/platform/upload-file'
const fileList = ref<UploadFile[]>([])

View File

@@ -203,21 +203,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { FormInstance } from 'ant-design-vue'
import {
PlusOutlined,
ApartmentOutlined,
CheckCircleOutlined,
FileTextOutlined,
ClockCircleOutlined,
LikeOutlined,
DislikeOutlined,
RightOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import type { ApprovalTemplate, ApprovalScenario, ApprovalNode } from '@/types'
import { ApprovalScenarioMap } from '@/types'
import {

View File

@@ -191,16 +191,8 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import {
SearchOutlined,
ReloadOutlined,
EyeOutlined,
ClockCircleOutlined,
MessageOutlined
} from '@ant-design/icons-vue'
import type { ApprovalInstance, ApprovalScenario, ApprovalInstanceStatus } from '@/types'
import { ApprovalScenarioMap, ApprovalInstanceStatusMap, ApprovalInstanceStatusBadgeMap } from '@/types'
import { getApprovalInstancePage } from '@/api/system/approval'

View File

@@ -123,16 +123,10 @@
</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'
import { getDeptTree, createDept, updateDept, deleteDept } from '@/api/system/dept'
import type { DeptRecord, DeptFormData } from '@/types/system/dept'
const loading = ref(false)
const submitting = ref(false)

View File

@@ -79,22 +79,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
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 {
id: number
name: string
code: string
remark?: string
status: number
createdAt?: string
}
import type { DictRecord } from '@/types/system/dict'
// 搜索表单
const searchForm = reactive({

View File

@@ -72,9 +72,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import type { MenuRecord, MenuFormData } from '@/types/system/menu'
import { getMenuList, deleteMenu } from '@/api/system/menu'
import { getAllProjects, type ProjectInfo } from '@/api/project'

View File

@@ -75,9 +75,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
import { buildMenuTree } from '@/utils/route'

View File

@@ -106,14 +106,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
UserOutlined
} from '@ant-design/icons-vue'
import { getUserList, deleteUser, updateUserStatus } from '@/api/system/user'
import { getRoleList } from '@/api/system/role'

View File

@@ -17,5 +17,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/auto-imports.d.ts", "src/components.d.ts"]
}

View File

@@ -35,7 +35,8 @@ export default defineConfig({
resolvers: [
// Ant Design Vue组件自动导入
AntDesignVueResolver({
importStyle: false
importStyle: false,
resolveIcons: true
}),
// Ant Design Vue图标自动导入
IconsResolver({