mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-10-07 18:30:55 +00:00
refactor: 重构存储与工作流模块,调整目录结构与初始化逻辑
1. 迁移存储模块功能到工作流模块,合并冗余代码 2. 调整环境配置加载路径与初始化脚本目录 3. 更新文档与前端组件代码适配重构 4. 新增工作流相关CRUD、模型与迁移文件 5. 移除过期模块与冗余代码,优化日志配置
This commit is contained in:
@@ -1,248 +0,0 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/monitor/resource";
|
||||
|
||||
export const ResourceAPI = {
|
||||
/**
|
||||
* 获取目录列表
|
||||
* @param query 查询参数
|
||||
*/
|
||||
listResource(query: ResourcePageQuery) {
|
||||
return request<ApiResponse<PageResult<ResourceItem>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
* @param formData 文件数据
|
||||
*/
|
||||
uploadFile(formData: FormData) {
|
||||
return request<ApiResponse<ResourceUploadSchema>>({
|
||||
url: `${API_PATH}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
* @param path 文件路径
|
||||
*/
|
||||
downloadFile(path: string) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/download`,
|
||||
method: "get",
|
||||
params: { path },
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除文件或目录
|
||||
* @param body 文件路径数组
|
||||
*/
|
||||
deleteResource(body: string[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 移动文件或目录
|
||||
* @param body 移动参数
|
||||
*/
|
||||
moveResource(body: ResourceMoveQuery) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/move`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 复制文件或目录
|
||||
* @param body 复制参数
|
||||
*/
|
||||
copyResource(body: ResourceCopyQuery) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/copy`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 重命名文件或目录
|
||||
* @param body 重命名参数
|
||||
*/
|
||||
renameResource(body: ResourceRenameQuery) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/rename`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
* @param body 创建目录参数
|
||||
*/
|
||||
createDirectory(body: ResourceCreateDirQuery) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/mkdir`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出资源列表
|
||||
* @param body 导出条件
|
||||
*/
|
||||
exportResource(body: ResourcePageQuery) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/export`,
|
||||
method: "post",
|
||||
params: body,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default ResourceAPI;
|
||||
|
||||
/**
|
||||
* 资源列表查询参数
|
||||
*/
|
||||
export interface ResourceListQuery {
|
||||
/** 目录路径 */
|
||||
path?: string;
|
||||
/** 包含隐藏文件 */
|
||||
include_hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源目录模型
|
||||
*/
|
||||
export interface ResourceDirectorySchema {
|
||||
/** 目录路径 */
|
||||
path: string;
|
||||
/** 目录名称 */
|
||||
name: string;
|
||||
/** 目录项 */
|
||||
items: ResourceItem[];
|
||||
/** 文件总数 */
|
||||
total_files: number;
|
||||
/** 目录总数 */
|
||||
total_dirs: number;
|
||||
/** 总大小 */
|
||||
total_size: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源搜索查询参数
|
||||
*/
|
||||
export interface ResourceSearchQuery {
|
||||
/** 关键词 */
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源分页查询参数
|
||||
*/
|
||||
export interface ResourcePageQuery extends PageQuery {
|
||||
/** 关键词 */
|
||||
name?: string;
|
||||
/** 目录路径 */
|
||||
path?: string;
|
||||
/** 包含隐藏文件 */
|
||||
include_hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源上传响应模型
|
||||
*/
|
||||
export interface ResourceUploadSchema {
|
||||
/** 文件路径 */
|
||||
file_path?: string;
|
||||
/** 文件名 */
|
||||
file_name?: string;
|
||||
/** 原始文件名 */
|
||||
origin_name?: string;
|
||||
/** 文件URL */
|
||||
file_url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源项信息
|
||||
*/
|
||||
export interface ResourceItem {
|
||||
/** 文件/目录名称 */
|
||||
name: string;
|
||||
/** 文件URL路径 */
|
||||
file_url: string;
|
||||
/** 相对路径 */
|
||||
relative_path: string;
|
||||
/** 是否为文件 */
|
||||
is_file: boolean;
|
||||
/** 是否为目录 */
|
||||
is_dir: boolean;
|
||||
/** 文件大小(字节) */
|
||||
size?: number | null;
|
||||
/** 创建时间 */
|
||||
created_time?: string;
|
||||
/** 修改时间 */
|
||||
modified_time?: string;
|
||||
/** 是否为隐藏文件 */
|
||||
is_hidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源移动参数
|
||||
*/
|
||||
export interface ResourceMoveQuery {
|
||||
/** 源路径 */
|
||||
source_path: string;
|
||||
/** 目标路径 */
|
||||
target_path: string;
|
||||
/** 是否覆盖 */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源复制参数
|
||||
*/
|
||||
export interface ResourceCopyQuery {
|
||||
/** 源路径 */
|
||||
source_path: string;
|
||||
/** 目标路径 */
|
||||
target_path: string;
|
||||
/** 是否覆盖 */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源重命名参数
|
||||
*/
|
||||
export interface ResourceRenameQuery {
|
||||
/** 原路径 */
|
||||
old_path: string;
|
||||
/** 新名称 */
|
||||
new_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录参数
|
||||
*/
|
||||
export interface ResourceCreateDirQuery {
|
||||
/** 父目录路径 */
|
||||
parent_path: string;
|
||||
/** 目录名称 */
|
||||
dir_name: string;
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/storage/file";
|
||||
|
||||
const FileAPI = {
|
||||
listFiles(params?: FileListParams) {
|
||||
return request<ApiResponse<StorageObject[]>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
uploadFile(formData: FormData) {
|
||||
return request<ApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_PATH}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
downloadFile(body: FilePathBody) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/download`,
|
||||
method: "post",
|
||||
data: body,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
deleteFile(body: FilePathBody) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
getFileUrl(params: FileUrlParams) {
|
||||
return request<ApiResponse<string | null>>({
|
||||
url: `${API_PATH}/url`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default FileAPI;
|
||||
|
||||
export interface FileListParams {
|
||||
source_id?: number | null;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export interface FilePathBody {
|
||||
remote_path: string;
|
||||
source_id?: number | null;
|
||||
}
|
||||
|
||||
export interface FileUrlParams extends FilePathBody {
|
||||
expire?: number;
|
||||
}
|
||||
|
||||
export interface StorageObject {
|
||||
name?: string;
|
||||
key?: string;
|
||||
is_dir?: boolean;
|
||||
size?: number;
|
||||
modified_time?: string;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/storage/source";
|
||||
|
||||
const SourceAPI = {
|
||||
pageSource(query?: TablePageQuery) {
|
||||
return request<ApiResponse<PageResult<SourceTable>>>({
|
||||
url: `${API_PATH}/page`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
listSource(query?: Omit<TablePageQuery, "page_no" | "page_size">) {
|
||||
return request<ApiResponse<SourceTable[]>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailSource(id: number) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createSource(body: SourceForm) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateSource(id: number, body: SourceForm) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteSource(ids: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: ids,
|
||||
});
|
||||
},
|
||||
|
||||
testSource(id: number) {
|
||||
return request<ApiResponse<boolean>>({
|
||||
url: `${API_PATH}/test/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
testSourceConfig(body: SourceForm & { source_id?: number }) {
|
||||
return request<ApiResponse<boolean>>({
|
||||
url: `${API_PATH}/test`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default SourceAPI;
|
||||
|
||||
export interface SourceForm extends BaseFormType {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
path_prefix?: string;
|
||||
is_secure?: boolean;
|
||||
implicit_tls?: boolean;
|
||||
is_default?: boolean;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SourceTable extends BaseType {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
path_prefix?: string;
|
||||
is_secure?: boolean;
|
||||
implicit_tls?: boolean;
|
||||
is_default?: boolean;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TablePageQuery extends PageQuery {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
status?: number;
|
||||
}
|
||||
@@ -178,20 +178,29 @@ export class ChatSocket {
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private attempt = 0;
|
||||
private stopped = false;
|
||||
private handlers: { onMessage: (msg: ChatPushMessage) => void; onStatus: (connected: boolean) => void };
|
||||
private handlers: {
|
||||
onMessage: (msg: ChatPushMessage) => void;
|
||||
onStatus: (connected: boolean) => void;
|
||||
};
|
||||
|
||||
constructor(handlers: { onMessage: (msg: ChatPushMessage) => void; onStatus: (connected: boolean) => void }) {
|
||||
constructor(handlers: {
|
||||
onMessage: (msg: ChatPushMessage) => void;
|
||||
onStatus: (connected: boolean) => void;
|
||||
}) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) return;
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING)
|
||||
return;
|
||||
this.stopped = false;
|
||||
try {
|
||||
const url = new URL("/api/v1/system/chat/ws", import.meta.env.VITE_APP_WS_ENDPOINT);
|
||||
const token = Auth.getAccessToken();
|
||||
if (token) url.searchParams.append("token", token);
|
||||
this.ws = new WebSocket(url.toString());
|
||||
// 令牌经 Sec-WebSocket-Protocol 传递,避免出现在 URL 与服务端 access log 中
|
||||
this.ws = token
|
||||
? new WebSocket(url.toString(), ["access_token", `access_token.${token}`])
|
||||
: new WebSocket(url.toString());
|
||||
this.ws.onopen = () => this.handleOpen();
|
||||
this.ws.onmessage = (event) => this.handleMessage(event);
|
||||
this.ws.onclose = (event) => this.handleClose(event);
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
/** 对应后端 `plugin.module_task.workflow.flow` */
|
||||
const API_PATH = "/task/workflow/flow";
|
||||
|
||||
const WorkflowDefinitionAPI = {
|
||||
getWorkflowList(query: WorkflowPageQuery) {
|
||||
return request<ApiResponse<PageResult<WorkflowTable>>>({
|
||||
const FlowAPI = {
|
||||
pageFlow(query?: FlowPageQuery) {
|
||||
return request<ApiResponse<PageResult<FlowTable>>>({
|
||||
url: `${API_PATH}/page`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
listFlow(query?: Omit<FlowPageQuery, "page_no" | "page_size">) {
|
||||
return request<ApiResponse<FlowTable[]>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
getWorkflowDetail(query: number) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/detail/${query}`,
|
||||
detailFlow(id: number) {
|
||||
return request<ApiResponse<FlowTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createWorkflow(body: WorkflowForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
createFlow(body: FlowForm) {
|
||||
return request<ApiResponse<FlowTable>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkflow(id: number, body: WorkflowForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
updateFlow(id: number, body: FlowForm) {
|
||||
return request<ApiResponse<FlowTable>>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteWorkflow(body: number[]) {
|
||||
deleteFlow(ids: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
data: ids,
|
||||
});
|
||||
},
|
||||
|
||||
publishWorkflow(id: number, body: WorkflowPublishForm) {
|
||||
return request<ApiResponse<WorkflowTable>>({
|
||||
url: `${API_PATH}/publish/${id}`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
executeWorkflow(body: WorkflowExecuteForm) {
|
||||
return request<ApiResponse<WorkflowExecuteResult>>({
|
||||
url: `${API_PATH}/execute`,
|
||||
/** 执行传输流程:遍历画布连线生成传输任务,返回任务ID列表;source_paths 为执行时选择的源文件/目录映射 {source_id: 路径} */
|
||||
executeFlow(id: number, body?: { source_paths?: Record<string, string> }) {
|
||||
return request<ApiResponse<number[]>>({
|
||||
url: `${API_PATH}/execute/${id}`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default WorkflowDefinitionAPI;
|
||||
export { WorkflowDefinitionAPI };
|
||||
export default FlowAPI;
|
||||
|
||||
export interface WorkflowPageQuery extends PageQuery, UserByQueryParams {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: number;
|
||||
/** 流程目标节点配置 */
|
||||
export interface FlowTarget {
|
||||
target_id: number;
|
||||
target_path: string;
|
||||
}
|
||||
|
||||
export interface WorkflowTable extends BaseType {
|
||||
/** 流程源节点配置(由画布启用连线实时派生) */
|
||||
export interface FlowSource {
|
||||
source_id: number;
|
||||
source_name?: string | null;
|
||||
}
|
||||
|
||||
/** 表单中的目标行(提交前会清理空项) */
|
||||
export interface FlowTargetInput {
|
||||
target_id?: number | null;
|
||||
target_path?: string;
|
||||
}
|
||||
|
||||
export interface FlowForm extends BaseFormType {
|
||||
name?: string;
|
||||
code?: string;
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
task_type?: "parallel" | "chain";
|
||||
targets?: FlowTargetInput[];
|
||||
/** VueFlow 画布数据 {nodes, edges}(后端按 graph 派生 sources/targets) */
|
||||
graph?: { nodes?: Record<string, any>[]; edges?: Record<string, any>[] } | null;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowForm extends BaseFormType {
|
||||
export interface FlowTable extends BaseType {
|
||||
name?: string;
|
||||
code?: string;
|
||||
nodes?: any[];
|
||||
edges?: any[];
|
||||
sources?: FlowSource[];
|
||||
task_type?: "parallel" | "chain";
|
||||
targets?: FlowTarget[];
|
||||
/** VueFlow 画布数据 {nodes, edges}(列表接口不返回,仅详情返回) */
|
||||
graph?: { nodes?: Record<string, any>[]; edges?: Record<string, any>[] } | null;
|
||||
/** 画布节点/连线统计(列表接口返回) */
|
||||
graph_stats?: { node_count?: number; edge_count?: number } | null;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowPublishForm {
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowExecuteForm {
|
||||
workflow_id: number;
|
||||
variables?: Record<string, any>;
|
||||
business_key?: string;
|
||||
job_id?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowExecuteResult {
|
||||
workflow_id: number;
|
||||
workflow_name: string;
|
||||
status: number;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
variables?: Record<string, any>;
|
||||
node_results?: Record<string, any>;
|
||||
error?: string;
|
||||
export interface FlowPageQuery extends PageQuery {
|
||||
name?: string;
|
||||
task_type?: "parallel" | "chain";
|
||||
status?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/task/workflow/node";
|
||||
|
||||
const NodeAPI = {
|
||||
pageNode(query?: TablePageQuery) {
|
||||
return request<ApiResponse<PageResult<SourceTable>>>({
|
||||
url: `${API_PATH}/page`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
listNode(query?: Omit<TablePageQuery, "page_no" | "page_size">) {
|
||||
return request<ApiResponse<SourceTable[]>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
detailNode(id: number) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createNode(body: SourceForm) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateNode(id: number, body: SourceForm) {
|
||||
return request<ApiResponse<SourceTable>>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteNode(ids: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: ids,
|
||||
});
|
||||
},
|
||||
|
||||
testNode(id: number) {
|
||||
return request<ApiResponse<boolean>>({
|
||||
url: `${API_PATH}/test/${id}`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
testNodeConfig(body: SourceForm & { source_id?: number }) {
|
||||
return request<ApiResponse<boolean>>({
|
||||
url: `${API_PATH}/test`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 支持的存储协议列表(协议枚举 + 默认端口) */
|
||||
getProtocols() {
|
||||
return request<ApiResponse<ProtocolItem[]>>({
|
||||
url: `${API_PATH}/protocols`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 存储 SDK 高级配置字段定义(按协议分组) */
|
||||
getAdvancedFields() {
|
||||
return request<ApiResponse<Record<string, AdvancedFieldDef[]>>>({
|
||||
url: `${API_PATH}/advanced-fields`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default NodeAPI;
|
||||
|
||||
/** 存储协议项 */
|
||||
export interface ProtocolItem {
|
||||
protocol: string;
|
||||
name: string;
|
||||
default_port: number;
|
||||
}
|
||||
|
||||
/** SDK 高级配置字段定义(后端按协议模型自动生成) */
|
||||
export interface AdvancedFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
default: string | number | boolean | null;
|
||||
/** 组件类型:text | number | boolean | select */
|
||||
type: "text" | "number" | "boolean" | "select";
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
export interface SourceForm extends BaseFormType {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
path_prefix?: string;
|
||||
scheme?: string;
|
||||
encrypt_type?: number;
|
||||
connection_mode?: number;
|
||||
encoding?: string;
|
||||
is_secure?: boolean;
|
||||
implicit_tls?: boolean;
|
||||
/** 分片大小(MB,对象存储分片上传,每个端点独立配置) */
|
||||
multipart_part_size?: number;
|
||||
/** 分片上传并发路数 */
|
||||
multipart_concurrency?: number;
|
||||
/** 分片上传内存预算(MB) */
|
||||
multipart_memory_budget?: number;
|
||||
/** SDK 高级配置(JSON,按协议解析;留空使用 SDK 默认值) */
|
||||
advanced_config?: Record<string, string | number | boolean> | null;
|
||||
is_default?: boolean;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SourceTable extends BaseType {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
has_password?: boolean;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
path_prefix?: string;
|
||||
scheme?: string;
|
||||
encrypt_type?: number;
|
||||
connection_mode?: number;
|
||||
encoding?: string;
|
||||
is_secure?: boolean;
|
||||
implicit_tls?: boolean;
|
||||
multipart_part_size?: number;
|
||||
multipart_concurrency?: number;
|
||||
multipart_memory_budget?: number;
|
||||
advanced_config?: Record<string, string | number | boolean> | null;
|
||||
is_default?: boolean;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TablePageQuery extends PageQuery {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
status?: number;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
/** 对应后端 `plugin.module_task.workflow.node_type` */
|
||||
const API_PATH = "/task/workflow/nodes";
|
||||
|
||||
const WorkflowNodeTypeAPI = {
|
||||
getWorkflowNodeTypeOptions() {
|
||||
return request<ApiResponse<WorkflowNodeTypeOption[]>>({
|
||||
url: `${API_PATH}/options`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
getWorkflowNodeTypeSelect() {
|
||||
return request<ApiResponse<WorkflowNodeTypeOption[]>>({
|
||||
url: `${API_PATH}/select`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
getWorkflowNodeTypeList(query: WorkflowNodeTypePageQuery) {
|
||||
return request<ApiResponse<PageResult<WorkflowNodeTypeTable>>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
getWorkflowNodeTypeDetail(id: number) {
|
||||
return request<ApiResponse<WorkflowNodeTypeTable>>({
|
||||
url: `${API_PATH}/detail/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
createWorkflowNodeType(body: WorkflowNodeTypeForm) {
|
||||
return request<ApiResponse<WorkflowNodeTypeTable>>({
|
||||
url: `${API_PATH}/create`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
updateWorkflowNodeType(id: number, body: WorkflowNodeTypeForm) {
|
||||
return request<ApiResponse<WorkflowNodeTypeTable>>({
|
||||
url: `${API_PATH}/update/${id}`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
deleteWorkflowNodeType(body: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default WorkflowNodeTypeAPI;
|
||||
export { WorkflowNodeTypeAPI };
|
||||
|
||||
/** 节点类型选项(对应后端 task_workflow_node_type,内置业务节点 id 为 null) */
|
||||
export interface WorkflowNodeTypeOption {
|
||||
id: number | null;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description?: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowNodeTypePageQuery extends PageQuery, UserByQueryParams {
|
||||
name?: string;
|
||||
code?: string;
|
||||
category?: string;
|
||||
is_active?: boolean;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface WorkflowNodeTypeTable extends BaseType {
|
||||
name?: string;
|
||||
code?: string;
|
||||
category?: string;
|
||||
func?: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
created_by?: CommonType;
|
||||
updated_by?: CommonType;
|
||||
deleted_by?: CommonType;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowNodeTypeForm extends BaseFormType {
|
||||
name: string;
|
||||
code: string;
|
||||
category?: string;
|
||||
func: string;
|
||||
args?: string;
|
||||
kwargs?: string;
|
||||
sort_order?: number;
|
||||
is_active?: boolean;
|
||||
status?: number;
|
||||
description?: string;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { request } from "@utils";
|
||||
|
||||
const API_PATH = "/task/workflow/storage";
|
||||
|
||||
const StorageAPI = {
|
||||
/** 查询文件/目录列表。传 page_no/page_size 时分页返回(PageResult),否则返回全量数组。 */
|
||||
listFiles(params?: FileListParams) {
|
||||
return request<ApiResponse<StorageObject[] | StoragePageResult>>({
|
||||
url: `${API_PATH}/list`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** 查询存储源账号下全部存储桶(对象存储) */
|
||||
listBuckets(params?: { source_id?: number | null }) {
|
||||
return request<ApiResponse<string[]>>({
|
||||
url: `${API_PATH}/buckets`,
|
||||
method: "get",
|
||||
params,
|
||||
});
|
||||
},
|
||||
|
||||
/** 上传文件(可传 onProgress 回调获取 0-100 进度百分比) */
|
||||
uploadFile(formData: FormData, onProgress?: (percent: number) => void) {
|
||||
return request<ApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_PATH}/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
onUploadProgress: onProgress
|
||||
? (e) => {
|
||||
if (e.total) onProgress(Math.min(99, Math.round((e.loaded / e.total) * 100)));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/** 下载目录:后端递归拉取并打包 ZIP 返回 */
|
||||
downloadDir(body: FilePathBody) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/download_dir`,
|
||||
method: "post",
|
||||
data: body,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
downloadFile(body: FilePathBody) {
|
||||
return request<Blob>({
|
||||
url: `${API_PATH}/download`,
|
||||
method: "post",
|
||||
data: body,
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
deleteFile(body: FilePathBody) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/delete`,
|
||||
method: "delete",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 重命名/移动(同存储源内) */
|
||||
renameFile(body: FileRenameBody) {
|
||||
return request<ApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_PATH}/rename`,
|
||||
method: "put",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 新建目录 */
|
||||
mkdir(body: { source_id?: number | null; remote_dir: string; bucket?: string }) {
|
||||
return request<ApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_PATH}/mkdir`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 生成分享链接(对象存储为预签名 URL) */
|
||||
shareFile(body: {
|
||||
source_id?: number | null;
|
||||
remote_path: string;
|
||||
expire?: number;
|
||||
bucket?: string;
|
||||
}) {
|
||||
return request<ApiResponse<string | null>>({
|
||||
url: `${API_PATH}/share`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 复制/移动文件(move=true 为移动,同源移动即重命名) */
|
||||
copyFile(body: FileCopyBody) {
|
||||
return request<ApiResponse<Record<string, unknown>>>({
|
||||
url: `${API_PATH}/copy`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default StorageAPI;
|
||||
|
||||
export interface FileListParams {
|
||||
source_id?: number | null;
|
||||
prefix?: string;
|
||||
bucket?: string;
|
||||
/** 每页数量(传了才走游标分页,不传返回全量) */
|
||||
page_size?: number;
|
||||
/** 下一页游标(上一页返回的 next_cursor) */
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
/** 游标分页结果(与后端 StoragePage 对齐:无总条数,只能顺序翻页) */
|
||||
export interface StoragePageResult {
|
||||
items: StorageObject[];
|
||||
has_next: boolean;
|
||||
next_cursor: string | null;
|
||||
}
|
||||
|
||||
export interface FilePathBody {
|
||||
remote_path: string;
|
||||
source_id?: number | null;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
export interface FileRenameBody {
|
||||
source_id?: number | null;
|
||||
source_path: string;
|
||||
target_path: string;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
export interface FileCopyBody {
|
||||
source_id?: number | null;
|
||||
source_path: string;
|
||||
target_id: number;
|
||||
target_path: string;
|
||||
move?: boolean;
|
||||
bucket?: string;
|
||||
}
|
||||
|
||||
export interface StorageObject {
|
||||
name?: string;
|
||||
key?: string;
|
||||
is_dir?: boolean;
|
||||
size?: number;
|
||||
modified_time?: string;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { request } from "@utils";
|
||||
import { Auth } from "@utils/auth";
|
||||
|
||||
const API_PATH = "/task/workflow/transfer";
|
||||
|
||||
const TransferAPI = {
|
||||
/** 创建传输任务(远端源,JSON) */
|
||||
createTask(body: TransferTaskCreate) {
|
||||
return request<ApiResponse<{ id: number }>>({
|
||||
url: `${API_PATH}/task`,
|
||||
method: "post",
|
||||
data: body,
|
||||
});
|
||||
},
|
||||
|
||||
/** 创建传输任务(本地上传源,multipart:file + name + task_type + targets) */
|
||||
createLocalTask(formData: FormData) {
|
||||
return request<ApiResponse<{ id: number }>>({
|
||||
url: `${API_PATH}/task/upload`,
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
},
|
||||
|
||||
/** 分页查询传输任务 */
|
||||
pageTask(query?: TransferTaskQuery) {
|
||||
return request<ApiResponse<PageResult<TransferTaskItem>>>({
|
||||
url: `${API_PATH}/task/page`,
|
||||
method: "get",
|
||||
params: query,
|
||||
});
|
||||
},
|
||||
|
||||
/** 任务详情(含步骤) */
|
||||
detailTask(id: number) {
|
||||
return request<ApiResponse<TransferTaskItem>>({
|
||||
url: `${API_PATH}/task/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
},
|
||||
|
||||
/** 取消任务 */
|
||||
cancelTask(id: number) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/task/${id}/cancel`,
|
||||
method: "post",
|
||||
});
|
||||
},
|
||||
|
||||
/** 删除任务 */
|
||||
deleteTask(ids: number[]) {
|
||||
return request<ApiResponse>({
|
||||
url: `${API_PATH}/task`,
|
||||
method: "delete",
|
||||
data: ids,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default TransferAPI;
|
||||
|
||||
export type TransferStatus = "pending" | "running" | "success" | "failed" | "canceled";
|
||||
export type TransferTaskType = "parallel" | "chain";
|
||||
export type TransferSourceType = "local" | "remote";
|
||||
|
||||
/** 传输目标配置 */
|
||||
export interface TransferTarget {
|
||||
target_id: number;
|
||||
target_path: string;
|
||||
}
|
||||
|
||||
/** 创建传输任务参数 */
|
||||
export interface TransferTaskCreate {
|
||||
name: string;
|
||||
task_type: TransferTaskType;
|
||||
source_type?: TransferSourceType;
|
||||
source_id?: number | null;
|
||||
source_path?: string | null;
|
||||
targets: TransferTarget[];
|
||||
/** 传输方式:stream 流式 / multipart 分片;不传用存储源默认 */
|
||||
transfer_mode?: "stream" | "multipart" | null;
|
||||
multipart_part_size?: number | null;
|
||||
multipart_concurrency?: number | null;
|
||||
}
|
||||
|
||||
/** 传输步骤 */
|
||||
export interface TransferStepItem {
|
||||
id: number;
|
||||
task_id: number;
|
||||
step_order: number;
|
||||
source_id?: number | null;
|
||||
source_path?: string | null;
|
||||
target_id: number;
|
||||
target_path: string;
|
||||
status: TransferStatus;
|
||||
progress: number;
|
||||
speed: number;
|
||||
total_size: number;
|
||||
transferred_size: number;
|
||||
error_msg?: string | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
}
|
||||
|
||||
/** 传输任务(列表/详情/WS 推送) */
|
||||
export interface TransferTaskItem extends BaseType {
|
||||
name: string;
|
||||
task_type: TransferTaskType;
|
||||
source_type: TransferSourceType;
|
||||
source_id?: number | null;
|
||||
source_path?: string | null;
|
||||
source_name?: string | null;
|
||||
source_size?: number | null;
|
||||
status: TransferStatus;
|
||||
total_size: number;
|
||||
transferred_size: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
error_msg?: string | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
steps?: TransferStepItem[];
|
||||
}
|
||||
|
||||
/** 传输任务分页查询 */
|
||||
export interface TransferTaskQuery extends PageQuery {
|
||||
name?: string;
|
||||
task_type?: TransferTaskType;
|
||||
status?: TransferStatus;
|
||||
}
|
||||
|
||||
/** 服务端 WS 推送消息 */
|
||||
export type TransferPushMessage = { type: "task_update"; data: TransferTaskItem };
|
||||
|
||||
/** 传输任务 WebSocket 客户端(自动重连 + 心跳,参照 ChatSocket) */
|
||||
export class TransferSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private attempt = 0;
|
||||
private stopped = false;
|
||||
private handlers: {
|
||||
onMessage: (msg: TransferPushMessage) => void;
|
||||
onStatus: (connected: boolean) => void;
|
||||
};
|
||||
|
||||
constructor(handlers: {
|
||||
onMessage: (msg: TransferPushMessage) => void;
|
||||
onStatus: (connected: boolean) => void;
|
||||
}) {
|
||||
this.handlers = handlers;
|
||||
}
|
||||
|
||||
connect() {
|
||||
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING)
|
||||
return;
|
||||
this.stopped = false;
|
||||
try {
|
||||
const url = new URL(
|
||||
"/api/v1/task/workflow/transfer/ws",
|
||||
import.meta.env.VITE_APP_WS_ENDPOINT
|
||||
);
|
||||
const token = Auth.getAccessToken();
|
||||
// 令牌经 Sec-WebSocket-Protocol 传递,避免出现在 URL 与服务端 access log 中
|
||||
this.ws = token
|
||||
? new WebSocket(url.toString(), ["access_token", `access_token.${token}`])
|
||||
: new WebSocket(url.toString());
|
||||
this.ws.onopen = () => this.handleOpen();
|
||||
this.ws.onmessage = (event) => this.handleMessage(event);
|
||||
this.ws.onclose = (event) => this.handleClose(event);
|
||||
this.ws.onerror = () => this.ws?.close();
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.stopped = true;
|
||||
this.clearTimers();
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null;
|
||||
this.ws.close(1000, "close");
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleOpen() {
|
||||
this.attempt = 0;
|
||||
this.handlers.onStatus(true);
|
||||
this.heartbeatTimer = setTimeout(() => {
|
||||
try {
|
||||
this.ws?.send("ping");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 30000);
|
||||
this.pingTimer = setInterval(() => {
|
||||
try {
|
||||
this.ws?.send("ping");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent) {
|
||||
if (event.data === "pong") return;
|
||||
try {
|
||||
this.handlers.onMessage(JSON.parse(event.data));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(event?: CloseEvent) {
|
||||
this.ws = null;
|
||||
this.clearTimers();
|
||||
this.handlers.onStatus(false);
|
||||
// 4001 = 令牌无效(登出/失效),不再自动重连
|
||||
if (event?.code === 4001) {
|
||||
this.stopped = true;
|
||||
return;
|
||||
}
|
||||
if (!this.stopped) this.scheduleReconnect();
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.stopped || this.reconnectTimer) return;
|
||||
const delay = Math.min(2000 * Math.pow(1.5, this.attempt), 30000);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.attempt += 1;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
private clearTimers() {
|
||||
if (this.pingTimer) clearInterval(this.pingTimer);
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
|
||||
this.pingTimer = null;
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
|
||||
get connected() {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
}
|
||||
@@ -222,9 +222,11 @@ function handlePaginationEvent(payload: { page: number; limit: number }) {
|
||||
.fa-card :deep(.el-card__header) {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.fa-card :deep(.el-card__body) {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.fa-card :deep(.el-card__footer) {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
:style="{ backgroundColor: item.color || '#f0f0f0' }"
|
||||
>
|
||||
<template v-if="item.image">
|
||||
<img
|
||||
:src="item.image"
|
||||
:alt="item.label"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
<img :src="item.image" :alt="item.label" class="max-h-full max-w-full object-contain" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="fa-carousel__content text-center px-4">
|
||||
|
||||
@@ -2,10 +2,7 @@
|
||||
<div class="page-content box-border px-20! py-3.5 text-center max-md:px-5!">
|
||||
<ElResult :icon="resultIcon" :title="title" :sub-title="message">
|
||||
<template #icon>
|
||||
<FaSvgIcon
|
||||
class="size-14!"
|
||||
:icon="iconCode"
|
||||
/>
|
||||
<FaSvgIcon class="size-14!" :icon="iconCode" />
|
||||
</template>
|
||||
<template #extra>
|
||||
<slot name="buttons"></slot>
|
||||
|
||||
@@ -83,6 +83,6 @@ watch(
|
||||
}
|
||||
|
||||
requestAnimationFrame(step);
|
||||
},
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -13,52 +13,52 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'FaCascader'
|
||||
})
|
||||
name: "FaCascader",
|
||||
});
|
||||
|
||||
interface FaCascaderProps {
|
||||
modelValue?: any
|
||||
options?: any[]
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
filterable?: boolean
|
||||
showAllLevels?: boolean
|
||||
separator?: string
|
||||
checkStrictly?: boolean
|
||||
multiple?: boolean
|
||||
modelValue?: any;
|
||||
options?: any[];
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
filterable?: boolean;
|
||||
showAllLevels?: boolean;
|
||||
separator?: string;
|
||||
checkStrictly?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FaCascaderProps>(), {
|
||||
modelValue: () => [],
|
||||
options: () => [],
|
||||
placeholder: '请选择',
|
||||
placeholder: "请选择",
|
||||
clearable: true,
|
||||
filterable: true,
|
||||
showAllLevels: true,
|
||||
separator: '/',
|
||||
separator: "/",
|
||||
checkStrictly: false,
|
||||
multiple: false
|
||||
})
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: any): void
|
||||
(e: 'change', value: any): void
|
||||
}>()
|
||||
(e: "update:modelValue", value: any): void;
|
||||
(e: "change", value: any): void;
|
||||
}>();
|
||||
|
||||
const cascaderProps = computed(() => ({
|
||||
checkStrictly: props.checkStrictly,
|
||||
multiple: props.multiple,
|
||||
emitPath: false
|
||||
}))
|
||||
emitPath: false,
|
||||
}));
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: any) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
})
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'FaRate',
|
||||
})
|
||||
name: "FaRate",
|
||||
});
|
||||
|
||||
interface Props {
|
||||
modelValue?: number
|
||||
max?: number
|
||||
disabled?: boolean
|
||||
allowHalf?: boolean
|
||||
showText?: boolean
|
||||
showScore?: boolean
|
||||
texts?: string[]
|
||||
colors?: string[]
|
||||
lowThreshold?: number
|
||||
highThreshold?: number
|
||||
modelValue?: number;
|
||||
max?: number;
|
||||
disabled?: boolean;
|
||||
allowHalf?: boolean;
|
||||
showText?: boolean;
|
||||
showScore?: boolean;
|
||||
texts?: string[];
|
||||
colors?: string[];
|
||||
lowThreshold?: number;
|
||||
highThreshold?: number;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -47,18 +47,18 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
colors: () => [],
|
||||
lowThreshold: 2,
|
||||
highThreshold: 4,
|
||||
})
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: number]
|
||||
change: [value: number]
|
||||
}>()
|
||||
"update:modelValue": [value: number];
|
||||
change: [value: number];
|
||||
}>();
|
||||
|
||||
const rateValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: number) => {
|
||||
emit('update:modelValue', val)
|
||||
emit('change', val)
|
||||
emit("update:modelValue", val);
|
||||
emit("change", val);
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -16,44 +16,44 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed } from "vue";
|
||||
|
||||
defineOptions({
|
||||
name: 'FaTimeSelect'
|
||||
})
|
||||
name: "FaTimeSelect",
|
||||
});
|
||||
|
||||
interface FaTimeSelectProps {
|
||||
modelValue?: string
|
||||
start?: string
|
||||
end?: string
|
||||
step?: string
|
||||
minTime?: string
|
||||
maxTime?: string
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
disabled?: boolean
|
||||
modelValue?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
step?: string;
|
||||
minTime?: string;
|
||||
maxTime?: string;
|
||||
placeholder?: string;
|
||||
clearable?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FaTimeSelectProps>(), {
|
||||
modelValue: '',
|
||||
start: '09:00',
|
||||
end: '18:00',
|
||||
step: '00:30',
|
||||
placeholder: '请选择时间',
|
||||
modelValue: "",
|
||||
start: "09:00",
|
||||
end: "18:00",
|
||||
step: "00:30",
|
||||
placeholder: "请选择时间",
|
||||
clearable: true,
|
||||
disabled: false
|
||||
})
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
}>()
|
||||
(e: "update:modelValue", value: string): void;
|
||||
(e: "change", value: string): void;
|
||||
}>();
|
||||
|
||||
const timeValue = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: string) => {
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value)
|
||||
}
|
||||
})
|
||||
emit("update:modelValue", value);
|
||||
emit("change", value);
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<template>
|
||||
<div class="fa-transfer">
|
||||
<ElTransfer
|
||||
v-model="selected"
|
||||
:data="transferData"
|
||||
:titles="titles"
|
||||
:filterable="filterable"
|
||||
:filter-placeholder="filterPlaceholder"
|
||||
:props="transferProps"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: "FaTransfer" });
|
||||
|
||||
interface Props {
|
||||
modelValue?: any[];
|
||||
data?: any[];
|
||||
titles?: [string, string];
|
||||
filterable?: boolean;
|
||||
filterPlaceholder?: string;
|
||||
keyProp?: string;
|
||||
labelProp?: string;
|
||||
disabledProp?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: () => [],
|
||||
data: () => [],
|
||||
titles: () => ["待选择", "已选择"],
|
||||
filterable: true,
|
||||
keyProp: "value",
|
||||
labelProp: "label",
|
||||
disabledProp: "disabled",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: any[]];
|
||||
change: [value: any[], direction: "left" | "right", movedKeys: any[]];
|
||||
"left-check-change": [keys: any[], checkedKeys: any[]];
|
||||
"right-check-change": [keys: any[], checkedKeys: any[]];
|
||||
}>();
|
||||
|
||||
const transferProps = computed(() => ({
|
||||
key: props.keyProp,
|
||||
label: props.labelProp,
|
||||
disabled: props.disabledProp,
|
||||
}));
|
||||
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: any[]) => {
|
||||
emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
const transferData = computed(() => {
|
||||
return props.data.map((item: any) => ({
|
||||
key: item[props.keyProp],
|
||||
label: item[props.labelProp],
|
||||
disabled: item[props.disabledProp],
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
@@ -13,7 +13,10 @@
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
<div v-if="noMore && showNoMore" class="fa-infinite-scroll__empty text-center py-4 text-sm text-gray-400">
|
||||
<div
|
||||
v-if="noMore && showNoMore"
|
||||
class="fa-infinite-scroll__empty text-center py-4 text-sm text-gray-400"
|
||||
>
|
||||
{{ noMoreText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed } from "vue";
|
||||
|
||||
defineOptions({ name: "FaTransfer" });
|
||||
|
||||
|
||||
@@ -610,8 +610,9 @@ const cleanBodyColumnProps = (col: ColumnOption) => {
|
||||
if (isPersonFilterColumn(col)) {
|
||||
columnProps.filters = getColumnFilterOptions(col);
|
||||
columnProps.filterMethod = (value: any, row: any) =>
|
||||
toFilterText(getPathValue(row as Record<string, any>, getPersonNamePath(col.prop as string))) ===
|
||||
String(value);
|
||||
toFilterText(
|
||||
getPathValue(row as Record<string, any>, getPersonNamePath(col.prop as string))
|
||||
) === String(value);
|
||||
}
|
||||
return columnProps;
|
||||
};
|
||||
|
||||
+4
-1
@@ -3,7 +3,10 @@
|
||||
<template #title>
|
||||
<FaMenuRouteIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
|
||||
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div v-if="item.meta.showBadge && !item.meta.showTextBadge" class="fa-badge fa-badge-horizontal" />
|
||||
<div
|
||||
v-if="item.meta.showBadge && !item.meta.showTextBadge"
|
||||
class="fa-badge fa-badge-horizontal"
|
||||
/>
|
||||
<div v-if="item.meta.showBadge && item.meta.showTextBadge" class="fa-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,10 @@
|
||||
>
|
||||
{{ item.formattedTitle }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge && !item.meta.showTextBadge" class="fa-badge fa-badge-mixed" />
|
||||
<div
|
||||
v-if="item.meta.showBadge && !item.meta.showTextBadge"
|
||||
class="fa-badge fa-badge-mixed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
<span class="menu-name">
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge && !item.meta.showTextBadge" class="fa-badge" :style="'right: 10px'" />
|
||||
<div
|
||||
v-if="item.meta.showBadge && !item.meta.showTextBadge"
|
||||
class="fa-badge"
|
||||
:style="'right: 10px'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<FaSidebarSubmenu
|
||||
@@ -47,7 +51,10 @@
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge && !item.meta.showTextBadge" class="fa-badge" />
|
||||
<div v-if="item.meta.showBadge && item.meta.showTextBadge && (level > 0 || menuOpen)" class="fa-text-badge">
|
||||
<div
|
||||
v-if="item.meta.showBadge && item.meta.showTextBadge && (level > 0 || menuOpen)"
|
||||
class="fa-text-badge"
|
||||
>
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
*/
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { useRoute, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import { useRoute, useRouter, type RouteLocationNormalizedLoaded } from "vue-router";
|
||||
import { useSettingsStore, useWorktabStore } from "@stores";
|
||||
|
||||
defineOptions({ name: "FaPageContent" });
|
||||
@@ -83,6 +83,7 @@ function routeLeafCacheKey(r: RouteLocationNormalizedLoaded): string {
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
/** 动态菜单 meta.keepAlive(后端 keep_alive);仅显式 false 时不包 KeepAlive */
|
||||
const wrapPageWithKeepAlive = computed(() => route.meta.keepAlive !== false);
|
||||
|
||||
@@ -93,12 +94,32 @@ const backtopTargetKey = computed(() => (isNarrowViewport.value ? "win" : "main"
|
||||
const { pageTransition, containerWidth, refresh, showWorkTab } = storeToRefs(useSettingsStore());
|
||||
const { keepAliveExclude, opened } = storeToRefs(useWorktabStore());
|
||||
|
||||
/** 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。 */
|
||||
/** 嵌套路由壳组件名:内层叶子缓存依赖壳实例存活,include 需一并纳入(对应 routes.ts 的 NestedRouterParent) */
|
||||
const NESTED_PARENT_COMPONENT_NAME = "NestedRouterParent";
|
||||
|
||||
/** 判断路径是否命中嵌套路由(匹配链上存在 NestedRouterParent 壳) */
|
||||
function isNestedRoutePath(path: string): boolean {
|
||||
try {
|
||||
return router
|
||||
.resolve({ path })
|
||||
.matched.some((m) => m.components?.default?.name === NESTED_PARENT_COMPONENT_NAME);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多标签开启时:仅已打开且允许缓存的标签组件名进入 include;关闭多标签时不传 include,避免 opened 过窄误伤缓存。
|
||||
* 嵌套路由额外把壳组件 NestedRouterParent 纳入 include,否则内层叶子缓存会随壳销毁而失效。
|
||||
*/
|
||||
const keepAliveInclude = computed(() => {
|
||||
if (!showWorkTab.value) return undefined;
|
||||
const names = new Set<string>();
|
||||
for (const t of opened.value) {
|
||||
if (t.name && t.keepAlive !== false) names.add(String(t.name));
|
||||
if (t.keepAlive !== false && isNestedRoutePath(t.path)) {
|
||||
names.add(NESTED_PARENT_COMPONENT_NAME);
|
||||
}
|
||||
}
|
||||
return names.size ? Array.from(names) : undefined;
|
||||
});
|
||||
|
||||
@@ -185,7 +185,10 @@ async function handleDynamicRoutes(
|
||||
} catch (error) {
|
||||
console.error("[路由守卫] 路由初始化失败:", error);
|
||||
// 认证失败(如生产环境部署后旧 token 失效)跳转登录页,不标记为路由初始化失败
|
||||
if (isHttpError(error) && (error.code === ApiStatus.unauthorized || error.code === ApiStatus.forbidden)) {
|
||||
if (
|
||||
isHttpError(error) &&
|
||||
(error.code === ApiStatus.unauthorized || error.code === ApiStatus.forbidden)
|
||||
) {
|
||||
refreshState.dynamicRoutesRegistered = false;
|
||||
return "/login";
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
*/
|
||||
import type { AppRouteRecordRaw } from "@utils";
|
||||
import type { AppRouteRecord, RouteMeta } from "@/types/router";
|
||||
import { defineComponent, h, onMounted, ref } from "vue";
|
||||
import { defineComponent, h, KeepAlive, onMounted, ref, type VNode } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { $t } from "@/locales";
|
||||
import { useWorktabStore } from "@stores";
|
||||
import LayoutComponent from "@/layouts/index.vue";
|
||||
import DashboardWorkplace from "@views/dashboard/workplace/index.vue";
|
||||
import DashboardAnalysis from "@views/dashboard/analysis/index.vue";
|
||||
@@ -150,7 +151,24 @@ export const HOME_ROUTE_NAME = "Home" as const;
|
||||
export const NestedRouterParent = defineComponent({
|
||||
name: "NestedRouterParent",
|
||||
setup() {
|
||||
return () => h(RouterView);
|
||||
const route = useRoute();
|
||||
const worktabStore = useWorktabStore();
|
||||
return () =>
|
||||
h(RouterView, null, {
|
||||
default: ({ Component }: { Component?: VNode }) => {
|
||||
if (!Component) return null;
|
||||
// 内层 KeepAlive(v-slot 模式):按叶子组件名缓存,与工作栏 tab.name 一致,
|
||||
// 关闭标签时由 keepAliveExclude 精确清除;meta.keepAlive === false 时不缓存叶子
|
||||
if (route.meta.keepAlive !== false) {
|
||||
return h(
|
||||
KeepAlive,
|
||||
{ exclude: worktabStore.keepAliveExclude },
|
||||
{ default: () => h(Component) }
|
||||
);
|
||||
}
|
||||
return h(Component);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,119 +15,119 @@ import ChatAPI, { ChatMessageItem, ChatPushMessage, ChatSocket } from "@/api/mod
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
|
||||
export const useChatStore = defineStore(
|
||||
"chatStore",
|
||||
() => {
|
||||
/** 全局未读数 */
|
||||
const unreadTotal = ref(0);
|
||||
/** WebSocket 连接状态 */
|
||||
const wsConnected = ref(false);
|
||||
/** 在线用户缓存 */
|
||||
const onlineUsers = ref<Record<number, boolean>>({});
|
||||
export const useChatStore = defineStore("chatStore", () => {
|
||||
/** 全局未读数 */
|
||||
const unreadTotal = ref(0);
|
||||
/** WebSocket 连接状态 */
|
||||
const wsConnected = ref(false);
|
||||
/** 在线用户缓存 */
|
||||
const onlineUsers = ref<Record<number, boolean>>({});
|
||||
|
||||
let socket: ChatSocket | null = null;
|
||||
/** 当前打开的会话(用于实时消息分发) */
|
||||
const activeConversation = ref<{ type: 1 | 2; id: number } | null>(null);
|
||||
let onNewMessage: ((msg: ChatMessageItem) => void) | null = null;
|
||||
let socket: ChatSocket | null = null;
|
||||
/** 当前打开的会话(用于实时消息分发) */
|
||||
const activeConversation = ref<{ type: 1 | 2; id: number } | null>(null);
|
||||
let onNewMessage: ((msg: ChatMessageItem) => void) | null = null;
|
||||
|
||||
/** 初始化聊天 WebSocket(登录后调用一次) */
|
||||
function initChat() {
|
||||
if (socket) {
|
||||
// 已断开(如令牌失效后)时重新建立连接
|
||||
if (!socket.connected && !wsConnected.value) {
|
||||
socket.connect();
|
||||
}
|
||||
return;
|
||||
/** 初始化聊天 WebSocket(登录后调用一次) */
|
||||
function initChat() {
|
||||
if (socket) {
|
||||
// 已断开(如令牌失效后)时重新建立连接
|
||||
if (!socket.connected && !wsConnected.value) {
|
||||
socket.connect();
|
||||
}
|
||||
socket = new ChatSocket({
|
||||
onMessage: handlePush,
|
||||
onStatus: (connected) => {
|
||||
wsConnected.value = connected;
|
||||
},
|
||||
});
|
||||
socket.connect();
|
||||
return;
|
||||
}
|
||||
|
||||
/** 断开聊天 WebSocket(登出时调用) */
|
||||
function disconnectChat() {
|
||||
socket?.disconnect();
|
||||
socket = null;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
/** 拉取会话并刷新全局未读数 */
|
||||
async function refreshUnread() {
|
||||
try {
|
||||
const response = await ChatAPI.listConversations();
|
||||
const items = Array.isArray(response.data?.data) ? response.data.data : [];
|
||||
unreadTotal.value = items.reduce((sum, item) => sum + (item.unread || 0), 0);
|
||||
} catch {
|
||||
/* 忽略网络错误 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置当前会话(页面进入会话时) */
|
||||
function setActiveConversation(conv: { type: 1 | 2; id: number }, handler: (msg: ChatMessageItem) => void) {
|
||||
activeConversation.value = conv;
|
||||
onNewMessage = handler;
|
||||
}
|
||||
|
||||
/** 清除当前会话 */
|
||||
function clearActiveConversation() {
|
||||
activeConversation.value = null;
|
||||
onNewMessage = null;
|
||||
}
|
||||
|
||||
/** 服务端推送分发 */
|
||||
function handlePush(msg: ChatPushMessage) {
|
||||
if (msg.type === "message") {
|
||||
const m = msg.data;
|
||||
const active = activeConversation.value;
|
||||
const inActive =
|
||||
active !== null && m.conversation_type === active.type && m.receiver_id === active.id;
|
||||
if (inActive) {
|
||||
onNewMessage?.(m);
|
||||
} else {
|
||||
unreadTotal.value += 1;
|
||||
}
|
||||
} else if (msg.type === "presence") {
|
||||
if (onlineUsers.value[msg.user_id] !== msg.online) {
|
||||
onlineUsers.value = { ...onlineUsers.value, [msg.user_id]: msg.online };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标记会话已读并通知服务端 */
|
||||
async function markRead(conv: { type: 1 | 2; id: number }) {
|
||||
await ChatAPI.markRead({ conversation_type: conv.type, receiver_id: conv.id }).catch(() => {
|
||||
/* 忽略 */
|
||||
});
|
||||
await refreshUnread();
|
||||
}
|
||||
|
||||
/** 清空聊天状态(登出) */
|
||||
function clearUserInfo() {
|
||||
disconnectChat();
|
||||
unreadTotal.value = 0;
|
||||
onlineUsers.value = {};
|
||||
activeConversation.value = null;
|
||||
onNewMessage = null;
|
||||
}
|
||||
|
||||
return {
|
||||
unreadTotal,
|
||||
wsConnected,
|
||||
onlineUsers,
|
||||
initChat,
|
||||
disconnectChat,
|
||||
refreshUnread,
|
||||
setActiveConversation,
|
||||
clearActiveConversation,
|
||||
markRead,
|
||||
clearUserInfo,
|
||||
};
|
||||
socket = new ChatSocket({
|
||||
onMessage: handlePush,
|
||||
onStatus: (connected) => {
|
||||
wsConnected.value = connected;
|
||||
},
|
||||
});
|
||||
socket.connect();
|
||||
}
|
||||
);
|
||||
|
||||
/** 断开聊天 WebSocket(登出时调用) */
|
||||
function disconnectChat() {
|
||||
socket?.disconnect();
|
||||
socket = null;
|
||||
wsConnected.value = false;
|
||||
}
|
||||
|
||||
/** 拉取会话并刷新全局未读数 */
|
||||
async function refreshUnread() {
|
||||
try {
|
||||
const response = await ChatAPI.listConversations();
|
||||
const items = Array.isArray(response.data?.data) ? response.data.data : [];
|
||||
unreadTotal.value = items.reduce((sum, item) => sum + (item.unread || 0), 0);
|
||||
} catch {
|
||||
/* 忽略网络错误 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置当前会话(页面进入会话时) */
|
||||
function setActiveConversation(
|
||||
conv: { type: 1 | 2; id: number },
|
||||
handler: (msg: ChatMessageItem) => void
|
||||
) {
|
||||
activeConversation.value = conv;
|
||||
onNewMessage = handler;
|
||||
}
|
||||
|
||||
/** 清除当前会话 */
|
||||
function clearActiveConversation() {
|
||||
activeConversation.value = null;
|
||||
onNewMessage = null;
|
||||
}
|
||||
|
||||
/** 服务端推送分发 */
|
||||
function handlePush(msg: ChatPushMessage) {
|
||||
if (msg.type === "message") {
|
||||
const m = msg.data;
|
||||
const active = activeConversation.value;
|
||||
const inActive =
|
||||
active !== null && m.conversation_type === active.type && m.receiver_id === active.id;
|
||||
if (inActive) {
|
||||
onNewMessage?.(m);
|
||||
} else {
|
||||
unreadTotal.value += 1;
|
||||
}
|
||||
} else if (msg.type === "presence") {
|
||||
if (onlineUsers.value[msg.user_id] !== msg.online) {
|
||||
onlineUsers.value = { ...onlineUsers.value, [msg.user_id]: msg.online };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 标记会话已读并通知服务端 */
|
||||
async function markRead(conv: { type: 1 | 2; id: number }) {
|
||||
await ChatAPI.markRead({ conversation_type: conv.type, receiver_id: conv.id }).catch(() => {
|
||||
/* 忽略 */
|
||||
});
|
||||
await refreshUnread();
|
||||
}
|
||||
|
||||
/** 清空聊天状态(登出) */
|
||||
function clearUserInfo() {
|
||||
disconnectChat();
|
||||
unreadTotal.value = 0;
|
||||
onlineUsers.value = {};
|
||||
activeConversation.value = null;
|
||||
onNewMessage = null;
|
||||
}
|
||||
|
||||
return {
|
||||
unreadTotal,
|
||||
wsConnected,
|
||||
onlineUsers,
|
||||
initChat,
|
||||
disconnectChat,
|
||||
refreshUnread,
|
||||
setActiveConversation,
|
||||
clearActiveConversation,
|
||||
markRead,
|
||||
clearUserInfo,
|
||||
};
|
||||
});
|
||||
|
||||
export function useChatStoreHook() {
|
||||
return useChatStore(store);
|
||||
|
||||
@@ -11,7 +11,6 @@ let downloadLoadingInstance: any;
|
||||
|
||||
interface DownloadUtil {
|
||||
name(name: string, isDelete?: boolean): void;
|
||||
resource(resource: string): void;
|
||||
zip(url: string, name: string): void;
|
||||
saveAs(text: Blob | string, name: string, opts?: any): void;
|
||||
printErrMsg(data: Blob): Promise<void>;
|
||||
@@ -42,26 +41,6 @@ const download: DownloadUtil = {
|
||||
}
|
||||
},
|
||||
|
||||
async resource(resource: string) {
|
||||
const url = baseURL + "/monitor/resource/download?path=" + encodeURIComponent(resource);
|
||||
try {
|
||||
const res = await axios.get<Blob>(url, {
|
||||
responseType: "blob",
|
||||
headers: { Authorization: `Bearer ${Auth.getAccessToken()}` },
|
||||
});
|
||||
const isBlob = blobValidate(res.data);
|
||||
if (isBlob) {
|
||||
const blob = new Blob([res.data]);
|
||||
download.saveAs(blob, decodeURIComponent(res.headers["download-filename"]));
|
||||
} else {
|
||||
await download.printErrMsg(res.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Download] 资源下载失败:", error);
|
||||
ElMessage.error("资源下载失败,请稍后重试");
|
||||
}
|
||||
},
|
||||
|
||||
async zip(url: string, name: string) {
|
||||
const fullUrl = baseURL + url;
|
||||
downloadLoadingInstance = ElLoading.service({
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
<div>
|
||||
<div class="text-base font-semibold text-g-800">{{ currentUser.name }}</div>
|
||||
<div class="text-xs text-g-600">
|
||||
{{ currentUser.dept_name }} · {{ currentUser.description }} · {{ currentUser.last_login }}
|
||||
{{ currentUser.dept_name }} · {{ currentUser.description }} ·
|
||||
{{ currentUser.last_login }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,7 +67,7 @@
|
||||
text: '开始探索',
|
||||
color: 'var(--fa-success)',
|
||||
textColor: '#fff',
|
||||
radius: '6px'
|
||||
radius: '6px',
|
||||
}"
|
||||
@buttonClick="handleBannerClick"
|
||||
/>
|
||||
@@ -82,10 +83,10 @@
|
||||
show: true,
|
||||
text: '立即参与',
|
||||
color: '#FF5A89',
|
||||
textColor: '#fff'
|
||||
textColor: '#fff',
|
||||
}"
|
||||
:imageConfig="{
|
||||
src: icon3
|
||||
src: icon3,
|
||||
}"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
@@ -95,7 +96,7 @@
|
||||
height="100%"
|
||||
boxStyle="!bg-[#70B1FF]"
|
||||
:imageConfig="{
|
||||
src: icon5
|
||||
src: icon5,
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
@@ -104,9 +105,9 @@
|
||||
|
||||
<template #subtitle>
|
||||
<div style="margin-top: 12px">
|
||||
<p style="position: relative; z-index: 10; font-style: italic"
|
||||
>灵活配置,强大扩展,支持自定义插槽内容</p
|
||||
>
|
||||
<p style="position: relative; z-index: 10; font-style: italic">
|
||||
灵活配置,强大扩展,支持自定义插槽内容
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -126,7 +127,6 @@
|
||||
<FaBasicBanner v-bind="PresetBanners.info" height="100%" />
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -135,8 +135,8 @@ import bannerCover from "@imgs/login/lf_icon2.webp";
|
||||
import { useUserStore } from "@stores";
|
||||
import { greetings } from "@utils";
|
||||
import { UserFilled } from "@element-plus/icons-vue";
|
||||
import icon3 from '@imgs/3d/icon3.webp'
|
||||
import icon5 from '@imgs/3d/icon7.webp'
|
||||
import icon3 from "@imgs/3d/icon3.webp";
|
||||
import icon5 from "@imgs/3d/icon7.webp";
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
@@ -144,7 +144,7 @@ const userInfo = computed(() => userStore.basicInfo);
|
||||
|
||||
const handleBannerClick = (): void => {
|
||||
// TODO: 接入真实跳转或路由
|
||||
console.log('banner clicked')
|
||||
console.log("banner clicked");
|
||||
};
|
||||
|
||||
const timefix = greetings();
|
||||
@@ -168,32 +168,32 @@ const bannerSubtitle = `基于 FastAPI + Vue3 + TypeScript 构建的企业级中
|
||||
*/
|
||||
const PresetBanners = {
|
||||
marketing: {
|
||||
title: '限时优惠活动',
|
||||
subtitle: '精选商品 48 小时闪购,最高享受 7 折优惠,数量有限!',
|
||||
titleColor: 'var(--fa-gray-900)',
|
||||
subtitleColor: 'var(--fa-gray-900)',
|
||||
boxStyle: '!bg-success/15',
|
||||
title: "限时优惠活动",
|
||||
subtitle: "精选商品 48 小时闪购,最高享受 7 折优惠,数量有限!",
|
||||
titleColor: "var(--fa-gray-900)",
|
||||
subtitleColor: "var(--fa-gray-900)",
|
||||
boxStyle: "!bg-success/15",
|
||||
meteorConfig: { enabled: true, count: 15 },
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
text: '立即抢购',
|
||||
color: 'var(--fa-success)',
|
||||
textColor: '#fff'
|
||||
}
|
||||
text: "立即抢购",
|
||||
color: "var(--fa-success)",
|
||||
textColor: "#fff",
|
||||
},
|
||||
},
|
||||
info: {
|
||||
title: '服务到期提醒',
|
||||
subtitle: '您的高级服务将在 7 天后到期,请及时续费以继续享受完整功能。',
|
||||
titleColor: 'var(--fa-gray-900)',
|
||||
subtitleColor: 'var(--fa-gray-900)',
|
||||
boxStyle: '!bg-theme/15',
|
||||
title: "服务到期提醒",
|
||||
subtitle: "您的高级服务将在 7 天后到期,请及时续费以继续享受完整功能。",
|
||||
titleColor: "var(--fa-gray-900)",
|
||||
subtitleColor: "var(--fa-gray-900)",
|
||||
boxStyle: "!bg-theme/15",
|
||||
meteorConfig: { enabled: true, count: 15 },
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
text: '立即续费',
|
||||
color: 'var(--fa-secondary)',
|
||||
textColor: '#fff'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
text: "立即续费",
|
||||
color: "var(--fa-secondary)",
|
||||
textColor: "#fff",
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
<template>
|
||||
<ElCarousel height="400px">
|
||||
<ElCarouselItem v-for="card in imageCards" :key="card.id" class="mb-5">
|
||||
<FaImageCard
|
||||
:imageUrl="card.imageUrl"
|
||||
:title="card.title"
|
||||
:category="card.category"
|
||||
:readTime="card.readTime"
|
||||
:views="card.views"
|
||||
:comments="card.comments"
|
||||
:date="card.date"
|
||||
@click="handleImageCardClick(card)"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
<ElCarousel height="400px">
|
||||
<ElCarouselItem v-for="card in imageCards" :key="card.id" class="mb-5">
|
||||
<FaImageCard
|
||||
:imageUrl="card.imageUrl"
|
||||
:title="card.title"
|
||||
:category="card.category"
|
||||
:readTime="card.readTime"
|
||||
:views="card.views"
|
||||
:comments="card.comments"
|
||||
:date="card.date"
|
||||
@click="handleImageCardClick(card)"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FaImageCard from "@/components/cards/fa-image-card/index.vue"
|
||||
import cover1 from '@imgs/cover/img1.webp'
|
||||
import cover2 from '@imgs/cover/img2.webp'
|
||||
import cover3 from '@imgs/cover/img3.webp'
|
||||
import cover4 from '@imgs/cover/img4.webp'
|
||||
import FaImageCard from "@/components/cards/fa-image-card/index.vue";
|
||||
import cover1 from "@imgs/cover/img1.webp";
|
||||
import cover2 from "@imgs/cover/img2.webp";
|
||||
import cover3 from "@imgs/cover/img3.webp";
|
||||
import cover4 from "@imgs/cover/img4.webp";
|
||||
|
||||
defineOptions({ name: 'ImageCards' })
|
||||
defineOptions({ name: "ImageCards" });
|
||||
|
||||
// === 卡片演示数据 ← workplace ===
|
||||
const imageCards = [
|
||||
{
|
||||
id: 1,
|
||||
imageUrl: cover1,
|
||||
title: 'AI技术在医疗领域的创新应用与发展前景',
|
||||
category: '社交',
|
||||
readTime: '2分钟',
|
||||
views: 9125,
|
||||
comments: 3,
|
||||
date: '12月19日 周一'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
imageUrl: cover2,
|
||||
title: '大数据分析助力企业决策的实践案例',
|
||||
category: '技术',
|
||||
readTime: '3分钟',
|
||||
views: 7234,
|
||||
comments: 5,
|
||||
date: '12月20日 周二'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
imageUrl: cover3,
|
||||
title: '区块链技术在供应链管理中的应用',
|
||||
category: '科技',
|
||||
readTime: '4分钟',
|
||||
views: 5678,
|
||||
comments: 8,
|
||||
date: '12月21日 周三'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
imageUrl: cover4,
|
||||
title: '云计算技术发展趋势与未来展望',
|
||||
category: '云技术',
|
||||
readTime: '5分钟',
|
||||
views: 4321,
|
||||
comments: 6,
|
||||
date: '12月22日 周四'
|
||||
}
|
||||
]
|
||||
{
|
||||
id: 1,
|
||||
imageUrl: cover1,
|
||||
title: "AI技术在医疗领域的创新应用与发展前景",
|
||||
category: "社交",
|
||||
readTime: "2分钟",
|
||||
views: 9125,
|
||||
comments: 3,
|
||||
date: "12月19日 周一",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
imageUrl: cover2,
|
||||
title: "大数据分析助力企业决策的实践案例",
|
||||
category: "技术",
|
||||
readTime: "3分钟",
|
||||
views: 7234,
|
||||
comments: 5,
|
||||
date: "12月20日 周二",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
imageUrl: cover3,
|
||||
title: "区块链技术在供应链管理中的应用",
|
||||
category: "科技",
|
||||
readTime: "4分钟",
|
||||
views: 5678,
|
||||
comments: 8,
|
||||
date: "12月21日 周三",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
imageUrl: cover4,
|
||||
title: "云计算技术发展趋势与未来展望",
|
||||
category: "云技术",
|
||||
readTime: "5分钟",
|
||||
views: 4321,
|
||||
comments: 6,
|
||||
date: "12月22日 周四",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 图片卡片类型定义
|
||||
*/
|
||||
interface ImageCard {
|
||||
id: number
|
||||
imageUrl: string
|
||||
title: string
|
||||
category: string
|
||||
readTime: string
|
||||
views: number
|
||||
comments: number
|
||||
date: string
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
category: string;
|
||||
readTime: string;
|
||||
views: number;
|
||||
comments: number;
|
||||
date: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,8 +87,7 @@ interface ImageCard {
|
||||
* @param card 卡片数据
|
||||
*/
|
||||
const handleImageCardClick = (card: ImageCard) => {
|
||||
// TODO: 接入真实跳转
|
||||
console.log('点击卡片:', card)
|
||||
}
|
||||
// TODO: 接入真实跳转
|
||||
console.log("点击卡片:", card);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,87 +1,86 @@
|
||||
<template>
|
||||
<ElCarousel height="400px">
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
title="系统运行正常"
|
||||
description="所有核心服务运行稳定,响应时间在正常范围内。"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
<ElCarousel height="400px">
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
title="系统运行正常"
|
||||
description="所有核心服务运行稳定,响应时间在正常范围内。"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon2"
|
||||
title="重要消息通知"
|
||||
description="您有 3 条待处理的重要消息,请及时查看。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--fa-warning)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon2"
|
||||
title="重要消息通知"
|
||||
description="您有 3 条待处理的重要消息,请及时查看。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--fa-warning)',
|
||||
textColor: '#fff',
|
||||
}"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon3"
|
||||
title="数据分析报告"
|
||||
description="本周业务数据分析报告已完成,请查看关键指标。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '下载报告',
|
||||
color: 'var(--fa-error)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon3"
|
||||
title="数据分析报告"
|
||||
description="本周业务数据分析报告已完成,请查看关键指标。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '下载报告',
|
||||
color: 'var(--fa-error)',
|
||||
textColor: '#fff',
|
||||
}"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon4"
|
||||
title="版本更新提醒"
|
||||
description="FastapiAdmin v3.1.0 已发布,包含优化和新功能。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '立即更新',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff'
|
||||
}"
|
||||
:cancelButton="{
|
||||
show: true,
|
||||
text: '稍后提醒',
|
||||
color: '#eee',
|
||||
textColor: '#333'
|
||||
}"
|
||||
@click="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
<ElCarouselItem>
|
||||
<FaCardBanner
|
||||
:image="icon4"
|
||||
title="版本更新提醒"
|
||||
description="FastapiAdmin v3.1.0 已发布,包含优化和新功能。"
|
||||
:button="{
|
||||
show: true,
|
||||
text: '立即更新',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff',
|
||||
}"
|
||||
:cancelButton="{
|
||||
show: true,
|
||||
text: '稍后提醒',
|
||||
color: '#eee',
|
||||
textColor: '#333',
|
||||
}"
|
||||
@click="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</ElCarouselItem>
|
||||
</ElCarousel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 非关键组件异步导入(延迟加载,提升首屏速度)
|
||||
import FaCardBanner from "@/components/banners/fa-card-banner/index.vue";
|
||||
import icon2 from '@imgs/3d/icon2.webp'
|
||||
import icon3 from '@imgs/3d/icon3.webp'
|
||||
import icon4 from '@imgs/3d/icon4.webp'
|
||||
import icon2 from "@imgs/3d/icon2.webp";
|
||||
import icon3 from "@imgs/3d/icon3.webp";
|
||||
import icon4 from "@imgs/3d/icon4.webp";
|
||||
|
||||
defineOptions({ name: 'ItBanners' })
|
||||
defineOptions({ name: "ItBanners" });
|
||||
|
||||
/**
|
||||
* 处理确认按钮点击事件
|
||||
*/
|
||||
const handleConfirm = () => {
|
||||
// TODO: 接入真实操作
|
||||
console.log('confirm clicked')
|
||||
}
|
||||
// TODO: 接入真实操作
|
||||
console.log("confirm clicked");
|
||||
};
|
||||
|
||||
/**
|
||||
* 处理取消按钮点击事件
|
||||
*/
|
||||
const handleCancel = () => {
|
||||
// TODO: 接入真实操作
|
||||
console.log('cancel clicked')
|
||||
}
|
||||
|
||||
// TODO: 接入真实操作
|
||||
console.log("cancel clicked");
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -12,17 +12,23 @@
|
||||
<div class="header-right">
|
||||
<ElTooltip :content="isFs ? '退出全屏' : '进入全屏'" placement="bottom">
|
||||
<button class="fullscreen-btn" @click="handleToggle">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="fs-icon">
|
||||
<path
|
||||
v-if="isFs"
|
||||
d="M4 14h2v2h2v2H4v-4zm12 0h2v2h2v2h-4v-4zM4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4z"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
d="M4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4zM4 20h4v-2H6v-2H4v4zm14 0h4v-4h-2v2h-2v2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
class="fs-icon"
|
||||
>
|
||||
<path
|
||||
v-if="isFs"
|
||||
d="M4 14h2v2h2v2H4v-4zm12 0h2v2h2v2h-4v-4zM4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4z"
|
||||
/>
|
||||
<path
|
||||
v-else
|
||||
d="M4 4h4v2H6v2H4V4zm14 0h4v4h-2V6h-2V4zM4 20h4v-2H6v-2H4v4zm14 0h4v-4h-2v2h-2v2z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</ElTooltip>
|
||||
<span class="header-time font-mono text-sm">{{ currentTime }}</span>
|
||||
</div>
|
||||
|
||||
@@ -502,10 +502,11 @@ async function onAvatarCropConfirm(dataURL: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGenderValue(v: string | number | undefined): number {
|
||||
if (v === undefined || v === null || v === "") return 1;
|
||||
/** 性别统一为字符串,与后端 CurrentUserUpdateSchema(gender: str) 对齐 */
|
||||
function normalizeGenderValue(v: string | number | undefined): string {
|
||||
if (v === undefined || v === null || v === "") return "1";
|
||||
const n = typeof v === "string" ? Number(v) : v;
|
||||
return Number.isFinite(n) ? n : 1;
|
||||
return Number.isFinite(n) ? String(n) : "1";
|
||||
}
|
||||
|
||||
const initInfoForm = () => {
|
||||
@@ -705,7 +706,15 @@ const handleSave = async () => {
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
const response = await UserAPI.updateCurrentUserInfo({ ...infoFormState });
|
||||
// 只提交后端可编辑字段,避免携带 dept/roles/positions 等冗余信息
|
||||
const response = await UserAPI.updateCurrentUserInfo({
|
||||
name: infoFormState.name,
|
||||
gender: infoFormState.gender,
|
||||
mobile: infoFormState.mobile,
|
||||
email: infoFormState.email,
|
||||
avatar: infoFormState.avatar,
|
||||
description: infoFormState.description,
|
||||
});
|
||||
await userStore.setUserInfo(response.data.data);
|
||||
initInfoForm();
|
||||
ElMessage.success("个人资料已保存");
|
||||
|
||||
@@ -3,150 +3,161 @@
|
||||
<div :style="{ height: containerMinHeight }">
|
||||
<ElSplitter :style="'height: 100%'">
|
||||
<ElSplitterPanel size="360px" :min="260" :max="500">
|
||||
<div
|
||||
class="box-border h-full p-5"
|
||||
>
|
||||
<div class="pb-5 max-md:hidden!">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElAvatar :size="50" :src="selectedPerson?.avatar" />
|
||||
<div>
|
||||
<div class="text-base font-medium">{{ selectedPerson?.name }}</div>
|
||||
<div class="mt-1 text-xs text-g-500">{{ selectedPerson?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ElInput v-model="searchQuery" placeholder="搜索联系人" prefix-icon="Search" clearable />
|
||||
</div>
|
||||
<ElDropdown trigger="click" placement="bottom-start">
|
||||
<span class="mt-5 cursor-pointer">
|
||||
排序方式
|
||||
<ElIcon class="el-icon--right">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem>按时间排序</ElDropdownItem>
|
||||
<ElDropdownItem>按名称排序</ElDropdownItem>
|
||||
<ElDropdownItem>全部标为已读</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<ElScrollbar>
|
||||
<div
|
||||
v-for="item in personList"
|
||||
:key="item.id"
|
||||
class="flex items-center p-3 cursor-pointer rounded-lg transition duration-200 hover:bg-active-color/30 mb-1"
|
||||
:class="{ 'bg-active-color': selectedPerson?.id === item.id }"
|
||||
@click="selectPerson(item)"
|
||||
>
|
||||
<div class="mr-3">
|
||||
<ElBadge is-dot :color="item.online ? 'var(--el-color-success)' : 'var(--el-color-error)'">
|
||||
<ElAvatar :size="40" :src="item.avatar">
|
||||
{{ item.name.charAt(0) }}
|
||||
</ElAvatar>
|
||||
</ElBadge>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||
<span class="text-xs text-g-600">{{ item.lastTime }}</span>
|
||||
<div class="box-border h-full p-5">
|
||||
<div class="pb-5 max-md:hidden!">
|
||||
<div class="flex items-center gap-3">
|
||||
<ElAvatar :size="50" :src="selectedPerson?.avatar" />
|
||||
<div>
|
||||
<div class="text-base font-medium">{{ selectedPerson?.name }}</div>
|
||||
<div class="mt-1 text-xs text-g-500">{{ selectedPerson?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="overflow-hidden text-xs text-g-600 text-ellipsis whitespace-nowrap">
|
||||
{{ item.email }}
|
||||
<div class="mt-3">
|
||||
<ElInput
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索联系人"
|
||||
prefix-icon="Search"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
<ElDropdown trigger="click" placement="bottom-start">
|
||||
<span class="mt-5 cursor-pointer">
|
||||
排序方式
|
||||
<ElIcon class="el-icon--right">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</span>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem>按时间排序</ElDropdownItem>
|
||||
<ElDropdownItem>按名称排序</ElDropdownItem>
|
||||
<ElDropdownItem>全部标为已读</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<ElScrollbar>
|
||||
<div
|
||||
v-for="item in personList"
|
||||
:key="item.id"
|
||||
class="flex items-center p-3 cursor-pointer rounded-lg transition duration-200 hover:bg-active-color/30 mb-1"
|
||||
:class="{ 'bg-active-color': selectedPerson?.id === item.id }"
|
||||
@click="selectPerson(item)"
|
||||
>
|
||||
<div class="mr-3">
|
||||
<ElBadge
|
||||
is-dot
|
||||
:color="item.online ? 'var(--el-color-success)' : 'var(--el-color-error)'"
|
||||
>
|
||||
<ElAvatar :size="40" :src="item.avatar">
|
||||
{{ item.name.charAt(0) }}
|
||||
</ElAvatar>
|
||||
</ElBadge>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-sm font-medium">{{ item.name }}</span>
|
||||
<span class="text-xs text-g-600">{{ item.lastTime }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="overflow-hidden text-xs text-g-600 text-ellipsis whitespace-nowrap">
|
||||
{{ item.email }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</ElSplitterPanel>
|
||||
|
||||
<ElSplitterPanel :min="300">
|
||||
<div class="box-border h-full">
|
||||
<div class="flex items-center justify-between pt-4 px-4 pb-0 mb-5">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="flex items-center gap-1 mt-1.5">
|
||||
<div class="w-2 h-2 rounded-full" :class="isOnline ? 'bg-success' : 'bg-danger'"></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
|
||||
<div class="flex items-center justify-between pt-4 px-4 pb-0 mb-5">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="flex items-center gap-1 mt-1.5">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="isOnline ? 'bg-success' : 'bg-danger'"
|
||||
></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<FaIconButton icon="ri:phone-line" circle class="size-11 text-g-600" />
|
||||
<FaIconButton icon="ri:video-on-line" circle class="size-11 text-g-600" />
|
||||
<FaIconButton icon="ri:more-2-fill" circle class="size-11 text-g-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<FaIconButton icon="ri:phone-line" circle class="size-11 text-g-600" />
|
||||
<FaIconButton icon="ri:video-on-line" circle class="size-11 text-g-600" />
|
||||
<FaIconButton icon="ri:more-2-fill" circle class="size-11 text-g-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col h-[calc(100%-85px)]">
|
||||
<!-- 聊天消息区域 -->
|
||||
<ElScrollbar
|
||||
ref="messageContainer"
|
||||
class="flex-1 py-7.5 px-4 border-t border-(--default-border)"
|
||||
>
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<div
|
||||
:class="[
|
||||
'flex gap-2 items-start w-full mb-7.5',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row justify-start',
|
||||
]"
|
||||
<div class="flex flex-col h-[calc(100%-85px)]">
|
||||
<!-- 聊天消息区域 -->
|
||||
<ElScrollbar
|
||||
ref="messageContainer"
|
||||
class="flex-1 py-7.5 px-4 border-t border-(--default-border)"
|
||||
>
|
||||
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
||||
<div
|
||||
class="flex flex-col max-w-[70%]"
|
||||
:class="message.isMe ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<template v-for="message in messages" :key="message.id">
|
||||
<div
|
||||
class="flex gap-2 mb-1 text-xs"
|
||||
:class="message.isMe ? 'flex-row-reverse' : 'flex-row'"
|
||||
:class="[
|
||||
'flex gap-2 items-start w-full mb-7.5',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row justify-start',
|
||||
]"
|
||||
>
|
||||
<span class="font-medium">{{ message.sender }}</span>
|
||||
<span class="text-g-600">{{ message.time }}</span>
|
||||
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
||||
<div
|
||||
class="flex flex-col max-w-[70%]"
|
||||
:class="message.isMe ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 mb-1 text-xs"
|
||||
:class="message.isMe ? 'flex-row-reverse' : 'flex-row'"
|
||||
>
|
||||
<span class="font-medium">{{ message.sender }}</span>
|
||||
<span class="text-g-600">{{ message.time }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="py-2.5 px-3.5 text-sm leading-[1.4] rounded-md"
|
||||
:class="message.isMe ? 'bg-theme/15!' : 'bg-active-color!'"
|
||||
>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="py-2.5 px-3.5 text-sm leading-[1.4] rounded-md"
|
||||
:class="message.isMe ? 'bg-theme/15!' : 'bg-active-color!'"
|
||||
>
|
||||
{{ message.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElScrollbar>
|
||||
</template>
|
||||
</ElScrollbar>
|
||||
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="p-4 shrink-0">
|
||||
<ElInput
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="flex gap-2 py-2">
|
||||
<ElButton :icon="Paperclip" circle plain />
|
||||
<ElButton :icon="Picture" circle plain />
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="p-4 shrink-0">
|
||||
<ElInput
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="flex gap-2 py-2">
|
||||
<ElButton :icon="Paperclip" circle plain />
|
||||
<ElButton :icon="Picture" circle plain />
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center">
|
||||
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
|
||||
<FaSvgIcon
|
||||
icon="ri:emotion-happy-line"
|
||||
class="mr-5 cursor-pointer text-g-600 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20"
|
||||
>发送</ElButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<div class="flex items-center">
|
||||
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
|
||||
<FaSvgIcon
|
||||
icon="ri:emotion-happy-line"
|
||||
class="mr-5 cursor-pointer text-g-600 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElSplitterPanel>
|
||||
</ElSplitter>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -143,16 +143,6 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
|
||||
"完整性:两 Tab 均需点开核对是否加载成功、清理是否有二次确认。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-resource",
|
||||
title: "文件管理",
|
||||
path: "module_monitor/resource/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar;路径面包屑导航进入子目录。",
|
||||
"工具栏:上传、新建文件夹、下载、删除等(`module_monitor:resource:*` 按钮权限);刷新、列配置。",
|
||||
"主区:文件表格;与后端资源浏览一致(菜单名「文件管理」,component `resource`)。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-server",
|
||||
title: "服务监控",
|
||||
@@ -192,24 +182,44 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
|
||||
},
|
||||
{
|
||||
anchor: "page-workflow",
|
||||
title: "流程编排",
|
||||
title: "传输流程",
|
||||
path: "module_task/workflow/flow/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(可展开)。",
|
||||
"工具栏:新增、批量删除、刷新、列配置。",
|
||||
"行操作:草稿「发布」;已发布「执行」下拉;「编辑」打开 `WorkflowDesignDrawer` 画布;删除。",
|
||||
"权限:`module_task:workflow:flow:create|delete|update|execute` 等(见行内 v-hasPerm)。",
|
||||
"流程定义:源节点 + 目标节点列表(parallel 多目标 / chain 链式),目标路径随行配置。",
|
||||
"权限:`module_task:workflow:flow:create|delete|update` + 行级操作。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-nodetype",
|
||||
title: "节点类型",
|
||||
path: "module_task/workflow/nodes/index.vue",
|
||||
anchor: "page-workflow-node",
|
||||
title: "节点管理",
|
||||
path: "module_task/workflow/node/index.vue",
|
||||
notes: [
|
||||
"检索:FaSearchBar(可展开)。",
|
||||
"工具栏:新增、批量删除、刷新、列配置。",
|
||||
"行操作:编辑(打开表单/脚本配置)、删除等(见 `nodes-operation` 槽)。",
|
||||
"权限:`module_task:workflow:nodes:create|delete|update` + 行级操作。",
|
||||
"节点即传输地址(存储源):FTP/FTPS/SFTP/S3/OBS/OSS/COS/本地目录。",
|
||||
"行操作:打开(进入存储管理)、测试连接、详情、编辑、删除。",
|
||||
"权限:`module_task:workflow:node:create|delete|update` + 行级操作。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-workflow-storage",
|
||||
title: "存储管理",
|
||||
path: "module_task/workflow/storage/index.vue",
|
||||
notes: [
|
||||
"多节点标签浏览各节点内部文件(类似 IDE 标签栏)。",
|
||||
"文件操作:上传、新建目录、下载、复制/移动、重命名、分享、删除。",
|
||||
"可从「节点管理」点击「打开」直接跳入对应节点。",
|
||||
],
|
||||
},
|
||||
{
|
||||
anchor: "page-workflow-transfer",
|
||||
title: "传输任务",
|
||||
path: "module_task/workflow/transfer/index.vue",
|
||||
notes: [
|
||||
"创建传输任务:可选「从流程自动填充」源节点与多目标,或手动指定。",
|
||||
"实时进度:WebSocket 推送任务/步骤状态与速度。",
|
||||
"详情:按步骤展示源 → 目标传输链路与错误信息。",
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -97,9 +97,10 @@ const connectWebSocket = () => {
|
||||
try {
|
||||
const url = new URL("/api/v1/ai/chat/ws", WS_URL);
|
||||
const token = Auth.getAccessToken();
|
||||
if (token) url.searchParams.append("token", token);
|
||||
|
||||
ws = new WebSocket(url.toString());
|
||||
// 令牌经 Sec-WebSocket-Protocol 传递,避免出现在 URL 与服务端 access log 中
|
||||
ws = token
|
||||
? new WebSocket(url.toString(), ["access_token", `access_token.${token}`])
|
||||
: new WebSocket(url.toString());
|
||||
|
||||
ws.onopen = () => {
|
||||
isConnected.value = true;
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
<!-- 资源管理:Art + useTable -->
|
||||
<template>
|
||||
<div class="fa-full-height resource-monitor-page">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="resourceSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
class="resource-monitor-card flex flex-col flex-1 min-h-0 fa-table-card"
|
||||
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
|
||||
>
|
||||
<div
|
||||
class="resource-breadcrumb mb-3 shrink-0 flex flex-wrap items-center gap-2 border-b border-g-200 pb-3"
|
||||
>
|
||||
<ElTooltip content="资源文件管理系统: 点击路径可以快速返回上级目录">
|
||||
<QuestionFilled class="mx-1 h-4 w-4" />
|
||||
</ElTooltip>
|
||||
<span class="text-sm">文件列表(当前路径):</span>
|
||||
<div class="breadcrumb-container min-w-0 flex-1">
|
||||
<ElBreadcrumb separator="/">
|
||||
<ElBreadcrumbItem
|
||||
v-for="(item, index) in breadcrumbList"
|
||||
:key="item.name"
|
||||
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
|
||||
@click="handleBreadcrumbClick(item)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</ElBreadcrumbItem>
|
||||
</ElBreadcrumb>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FaTableHeader
|
||||
class="resource-toolbar shrink-0"
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<ElSpace wrap>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:upload']"
|
||||
type="success"
|
||||
plain
|
||||
:icon="Plus"
|
||||
@click="handleUpload"
|
||||
>
|
||||
上传文件
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:mkdir']"
|
||||
type="primary"
|
||||
plain
|
||||
:icon="FolderAdd"
|
||||
@click="handleCreateDir"
|
||||
>
|
||||
新建文件夹
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:delete']"
|
||||
type="danger"
|
||||
plain
|
||||
:icon="Delete"
|
||||
:disabled="selectedPaths.length === 0"
|
||||
:loading="batchDeleting"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</ElButton>
|
||||
<ElCheckbox
|
||||
v-model="showHiddenFiles"
|
||||
v-hasPerm="['module_monitor:resource:query']"
|
||||
@change="onShowHiddenChange"
|
||||
>
|
||||
显示隐藏文件
|
||||
</ElCheckbox>
|
||||
</ElSpace>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<!-- 面包屑与表头已占用高度:表格高度须在独立 flex 子项内计算,否则分页会被挤出卡片 -->
|
||||
<div class="resource-table-region min-h-0 flex flex-1 flex-col overflow-hidden">
|
||||
<FaTable
|
||||
row-key="file_url"
|
||||
:show-table-header="false"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="uploadDialogVisible"
|
||||
title="上传文件"
|
||||
width="500px"
|
||||
@close="handleUploadClose"
|
||||
>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:multiple="true"
|
||||
:file-list="uploadFileList"
|
||||
drag
|
||||
@change="handleUploadChange"
|
||||
>
|
||||
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
|
||||
<div class="el-upload__text">
|
||||
将文件拖到此处,或
|
||||
<em>点击上传</em>
|
||||
</div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-error">
|
||||
不支持多文件上传,单个文件不超过100MB,多文件上传,取最后一个文件上传
|
||||
</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
<template #footer>
|
||||
<ElButton @click="uploadDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:upload']"
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
@click="handleUploadConfirm"
|
||||
>
|
||||
确定上传
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<FaDialog v-model="createDirDialogVisible" title="新建文件夹" width="400px">
|
||||
<ElForm :model="createDirForm" label-width="80px">
|
||||
<ElFormItem label="文件夹名" required>
|
||||
<ElInput
|
||||
v-model="createDirForm.dir_name"
|
||||
placeholder="请输入文件夹名称"
|
||||
@keyup.enter="handleCreateDirConfirm"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="createDirDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:mkdir']"
|
||||
type="primary"
|
||||
@click="handleCreateDirConfirm"
|
||||
>
|
||||
确定
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<FaDialog v-model="renameDialogVisible" title="重命名" width="400px">
|
||||
<ElForm :model="renameForm" label-width="80px">
|
||||
<ElFormItem label="新名称" required>
|
||||
<ElInput
|
||||
v-model="renameForm.new_name"
|
||||
placeholder="请输入新名称"
|
||||
@keyup.enter="handleRenameConfirm"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="renameDialogVisible = false">取消</ElButton>
|
||||
<ElButton
|
||||
v-hasPerm="['module_monitor:resource:rename']"
|
||||
type="primary"
|
||||
@click="handleRenameConfirm"
|
||||
>
|
||||
确定
|
||||
</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({
|
||||
name: "ResourceMonitor",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import { h, ref, reactive, computed } from "vue";
|
||||
import {
|
||||
ElMessage,
|
||||
ElMessageBox,
|
||||
ElIcon,
|
||||
ElSpace,
|
||||
ElTooltip,
|
||||
type UploadFile,
|
||||
type UploadFiles,
|
||||
type UploadUserFile,
|
||||
} from "element-plus";
|
||||
import {
|
||||
Delete,
|
||||
Document,
|
||||
Folder,
|
||||
FolderAdd,
|
||||
Plus,
|
||||
QuestionFilled,
|
||||
UploadFilled,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ResourceAPI, type ResourceItem } from "@/api/module_monitor/resource";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { TableOperationAction } from "@/utils/table";
|
||||
import { renderTableOperationCell } from "@utils";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import FaDialog from "@/components/modal/fa-dialog/index.vue";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
|
||||
type ResourceSearchForm = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function buildResourceReplaceParams(u: ResourceSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: u.name,
|
||||
};
|
||||
}
|
||||
|
||||
function fetchResourceTableList(params: Record<string, unknown>) {
|
||||
return ResourceAPI.listResource({
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
include_hidden: showHiddenFiles.value,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
const searchForm = ref<ResourceSearchForm>({
|
||||
name: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const resourceSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "关键词",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入文件名或目录名",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedRows = ref<ResourceItem[]>([]);
|
||||
const selectedPaths = computed(() => selectedRows.value.map((r) => r.file_url));
|
||||
|
||||
function onTableSelectionChange(rows: ResourceItem[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
const breadcrumbList = ref([{ name: "资源根目录", path: "/" }]);
|
||||
const showHiddenFiles = ref(false);
|
||||
const currentPath = ref("/");
|
||||
|
||||
const uploadDialogVisible = ref(false);
|
||||
const createDirDialogVisible = ref(false);
|
||||
const renameDialogVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const batchDeleting = ref(false);
|
||||
|
||||
const uploadFileList = ref<UploadUserFile[]>([]);
|
||||
|
||||
const createDirForm = reactive({
|
||||
dir_name: "",
|
||||
});
|
||||
|
||||
const renameForm = reactive({
|
||||
new_name: "",
|
||||
old_path: "",
|
||||
});
|
||||
|
||||
function formatFileSize(size?: number | null) {
|
||||
if (!size || size === null) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let unitIndex = 0;
|
||||
let fileSize = size;
|
||||
|
||||
while (fileSize >= 1024 && unitIndex < units.length - 1) {
|
||||
fileSize /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function updateBreadcrumb() {
|
||||
if (currentPath.value === "/") {
|
||||
breadcrumbList.value = [{ name: "资源根目录", path: "/" }];
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = currentPath.value.split("/").filter((part) => part !== "");
|
||||
|
||||
breadcrumbList.value = [
|
||||
{ name: "资源根目录", path: "/" },
|
||||
...parts.map((part, index) => ({
|
||||
name: part,
|
||||
path: parts.slice(0, index + 1).join("/"),
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
function handleFilePreview(file: ResourceItem) {
|
||||
let previewUrl = file.file_url;
|
||||
|
||||
if (previewUrl && !previewUrl.startsWith("http")) {
|
||||
previewUrl = `${window.location.origin}${previewUrl}`;
|
||||
}
|
||||
|
||||
window.open(previewUrl, "_blank");
|
||||
}
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: fetchResourceTableList,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<ResourceItem>[] => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{
|
||||
prop: "name",
|
||||
label: "名称",
|
||||
minWidth: 200,
|
||||
formatter: (row: ResourceItem) =>
|
||||
h("div", { class: "file-name flex items-center gap-2" }, [
|
||||
h(ElIcon, { class: "file-icon" }, () => h(row.is_dir ? Folder : Document)),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"file-name-clickable cursor-pointer text-theme hover:text-theme/80 hover:underline",
|
||||
onClick: () => handleFileNameClick(row),
|
||||
},
|
||||
row.name
|
||||
),
|
||||
]),
|
||||
},
|
||||
{
|
||||
prop: "size",
|
||||
label: "大小",
|
||||
minWidth: 120,
|
||||
align: "center",
|
||||
formatter: (row: ResourceItem) => (row.is_dir ? "" : h("span", formatFileSize(row.size))),
|
||||
},
|
||||
{
|
||||
prop: "modified_time",
|
||||
label: "修改时间",
|
||||
minWidth: 180,
|
||||
sortable: true,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 180,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: ResourceItem) => formatResourceOperationCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
function buildResourceRowActions(row: ResourceItem): TableOperationAction[] {
|
||||
const actions: TableOperationAction[] = [];
|
||||
if (!row.is_dir) {
|
||||
actions.push({
|
||||
key: "download",
|
||||
label: "下载",
|
||||
artType: "view",
|
||||
icon: "ri:download-line",
|
||||
perm: "module_monitor:resource:download",
|
||||
run: () => void handleDownload(row),
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
key: "rename",
|
||||
label: "重命名",
|
||||
artType: "edit",
|
||||
perm: "module_monitor:resource:rename",
|
||||
run: () => void handleRenameOpen(row),
|
||||
});
|
||||
actions.push({
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
perm: "module_monitor:resource:delete",
|
||||
run: () => void handleDelete(row),
|
||||
});
|
||||
return actions;
|
||||
}
|
||||
|
||||
function formatResourceOperationCell(row: ResourceItem) {
|
||||
return renderTableOperationCell(buildResourceRowActions(row), {
|
||||
wrapperClass: "inline-flex items-center justify-center gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
function handleBreadcrumbClick(item: { path: string }) {
|
||||
currentPath.value = item.path;
|
||||
updateBreadcrumb();
|
||||
void getData({ path: currentPath.value === "/" ? undefined : currentPath.value });
|
||||
}
|
||||
|
||||
function handleFileNameClick(row: ResourceItem) {
|
||||
if (row.is_dir) {
|
||||
if (currentPath.value === "/") {
|
||||
currentPath.value = row.name;
|
||||
} else {
|
||||
currentPath.value = `${currentPath.value}/${row.name}`;
|
||||
}
|
||||
updateBreadcrumb();
|
||||
void getData({ path: currentPath.value });
|
||||
} else {
|
||||
handleFilePreview(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearchBarSearch(params: ResourceSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildResourceReplaceParams(params));
|
||||
getData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = { name: undefined };
|
||||
await resetSearchParams();
|
||||
}
|
||||
|
||||
function onShowHiddenChange() {
|
||||
getData();
|
||||
}
|
||||
|
||||
function handleUpload() {
|
||||
uploadDialogVisible.value = true;
|
||||
uploadFileList.value = [];
|
||||
}
|
||||
|
||||
function handleUploadChange(_file: UploadFile, fileList: UploadFiles) {
|
||||
uploadFileList.value = fileList as UploadUserFile[];
|
||||
}
|
||||
|
||||
async function handleUploadConfirm() {
|
||||
if (uploadFileList.value.length === 0) {
|
||||
ElMessage.warning("请选择要上传的文件");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading.value = true;
|
||||
const formData = new FormData();
|
||||
uploadFileList.value.forEach((file) => {
|
||||
const raw = file.raw;
|
||||
if (raw) formData.append("file", raw);
|
||||
});
|
||||
|
||||
const targetPath = currentPath.value === "/" ? "" : currentPath.value;
|
||||
formData.append("target_path", targetPath);
|
||||
|
||||
await ResourceAPI.uploadFile(formData);
|
||||
uploadDialogVisible.value = false;
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error("Upload error:", error);
|
||||
ElMessage.error("上传文件失败,请稍后重试");
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleUploadClose() {
|
||||
uploadDialogVisible.value = false;
|
||||
uploadFileList.value = [];
|
||||
}
|
||||
|
||||
function handleCreateDir() {
|
||||
createDirForm.dir_name = "";
|
||||
createDirDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleCreateDirConfirm() {
|
||||
if (!createDirForm.dir_name.trim()) {
|
||||
ElMessage.warning("请输入文件夹名称");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parentPath = currentPath.value === "/" ? "" : currentPath.value;
|
||||
await ResourceAPI.createDirectory({
|
||||
parent_path: parentPath,
|
||||
dir_name: createDirForm.dir_name.trim(),
|
||||
});
|
||||
createDirDialogVisible.value = false;
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error("Create directory error:", error);
|
||||
ElMessage.error("创建文件夹失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
function handleRenameOpen(item: ResourceItem) {
|
||||
renameForm.old_path = item.file_url;
|
||||
renameForm.new_name = item.name;
|
||||
renameDialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleRenameConfirm() {
|
||||
if (!renameForm.new_name.trim()) {
|
||||
ElMessage.warning("请输入新名称");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ResourceAPI.renameResource({
|
||||
old_path: renameForm.old_path,
|
||||
new_name: renameForm.new_name.trim(),
|
||||
});
|
||||
renameDialogVisible.value = false;
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error("Rename error:", error);
|
||||
ElMessage.error("重命名失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(item: ResourceItem) {
|
||||
try {
|
||||
const response = await ResourceAPI.downloadFile(item.file_url);
|
||||
const blob = response.data;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = item.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
if (import.meta.env.DEV) console.error("Download error:", error);
|
||||
ElMessage.error("下载文件失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: ResourceItem) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除 ${item.name} 吗?`, "确认删除", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
|
||||
await ResourceAPI.deleteResource([item.file_url]);
|
||||
selectedRows.value = [];
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
if (import.meta.env.DEV) console.error("Delete error:", error);
|
||||
ElMessage.error("删除文件失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning("请选择要删除的文件");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedRows.value.length} 个文件吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
|
||||
batchDeleting.value = true;
|
||||
const paths = selectedRows.value.map((item) => item.file_url);
|
||||
await ResourceAPI.deleteResource(paths);
|
||||
selectedRows.value = [];
|
||||
await refreshData();
|
||||
} catch (error) {
|
||||
if (error !== "cancel") {
|
||||
if (import.meta.env.DEV) console.error("Batch delete error:", error);
|
||||
ElMessage.error("批量删除失败,请稍后重试");
|
||||
}
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.resource-monitor-page :deep(.resource-monitor-card.el-card > .el-card__body) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
:deep(.el-breadcrumb__item) {
|
||||
&.is-link {
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary-light-3);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,353 +0,0 @@
|
||||
<!-- 文件管理:存储源文件浏览/上传/下载/删除 -->
|
||||
<template>
|
||||
<div class="fa-full-height flex flex-col gap-3">
|
||||
<ElCard class="fa-table-card" shadow="never">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<ElSelect
|
||||
v-model="sourceId"
|
||||
:placeholder="'默认存储源'"
|
||||
style="width: 240px"
|
||||
clearable
|
||||
filterable
|
||||
@change="handleSourceChange"
|
||||
>
|
||||
<ElOption v-for="s in sourceOptions" :key="s.id" :label="s.name" :value="s.id!" />
|
||||
</ElSelect>
|
||||
<ElInput v-model="keyword" placeholder="搜索文件名" clearable style="width: 220px">
|
||||
<template #prefix>
|
||||
<ElIcon><Search /></ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElSelect v-model="typeFilter" style="width: 110px">
|
||||
<ElOption label="全部" value="all" />
|
||||
<ElOption label="文件" value="file" />
|
||||
<ElOption label="目录" value="dir" />
|
||||
</ElSelect>
|
||||
<div class="flex-1" />
|
||||
<ElButton type="primary" :icon="UploadFilled" :disabled="uploading" @click="uploadVisible = true">
|
||||
上传文件
|
||||
</ElButton>
|
||||
<ElButton :icon="Refresh" :loading="loading" @click="loadFiles">刷新</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="fa-table-card" shadow="never">
|
||||
<div class="flex flex-wrap items-center gap-1 text-sm">
|
||||
<ElButton text :type="prefix ? '' : 'primary'" @click="goRoot">根目录</ElButton>
|
||||
<template v-for="(seg, idx) in pathSegments" :key="idx">
|
||||
<span class="text-(--el-text-color-placeholder)">/</span>
|
||||
<ElButton
|
||||
text
|
||||
:type="idx === pathSegments.length - 1 ? 'primary' : ''"
|
||||
@click="goPath(idx)"
|
||||
>
|
||||
{{ seg }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="fa-table-card flex-1 min-h-0">
|
||||
<FaTable :loading="loading" :data="filteredData" :columns="columns" />
|
||||
</ElCard>
|
||||
|
||||
<ElDialog
|
||||
v-model="uploadVisible"
|
||||
title="上传文件"
|
||||
width="520px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<ElUpload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
multiple
|
||||
:show-file-list="true"
|
||||
:http-request="handleUploadRequest"
|
||||
>
|
||||
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip">支持多个文件,上传到当前目录({{ prefix || "根目录" }})</div>
|
||||
</template>
|
||||
</ElUpload>
|
||||
<template #footer>
|
||||
<ElButton @click="uploadVisible = false">关闭</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue";
|
||||
import { Document, Folder, Refresh, Search, UploadFilled } from "@element-plus/icons-vue";
|
||||
import { ElMessage, ElMessageBox, type UploadRequestOptions } from "element-plus";
|
||||
import { renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import FileAPI, { type StorageObject } from "@/api/module_storage/file";
|
||||
import SourceAPI, { type SourceTable } from "@/api/module_storage/source";
|
||||
|
||||
defineOptions({
|
||||
name: "StorageFile",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const sourceId = ref<number | undefined>(undefined);
|
||||
const sourceOptions = ref<SourceTable[]>([]);
|
||||
const prefix = ref("");
|
||||
const data = ref<StorageObject[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const keyword = ref("");
|
||||
const typeFilter = ref<"all" | "file" | "dir">("all");
|
||||
|
||||
const uploadVisible = ref(false);
|
||||
const pendingUploads = ref(0);
|
||||
const uploading = computed(() => pendingUploads.value > 0);
|
||||
const uploadRef = ref<{ clearFiles: () => void } | null>(null);
|
||||
|
||||
const pathSegments = computed(() => prefix.value.split("/").filter(Boolean));
|
||||
|
||||
/** 本地过滤:按名称关键字 + 文件/目录类型 */
|
||||
const filteredData = computed(() => {
|
||||
const kw = keyword.value.trim().toLowerCase();
|
||||
return data.value.filter((item) => {
|
||||
if (typeFilter.value === "file" && item.is_dir) return false;
|
||||
if (typeFilter.value === "dir" && !item.is_dir) return false;
|
||||
if (kw && !(item.name ?? "").toLowerCase().includes(kw)) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
const res = await SourceAPI.listSource({ status: 0 });
|
||||
sourceOptions.value = res.data?.data ?? [];
|
||||
} catch {
|
||||
// 无存储源查询权限时忽略
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFiles() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await FileAPI.listFiles({ source_id: sourceId.value, prefix: prefix.value });
|
||||
data.value = res.data?.data ?? [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSourceChange() {
|
||||
prefix.value = "";
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function openDir(item: StorageObject) {
|
||||
prefix.value = item.key ?? "";
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function goRoot() {
|
||||
prefix.value = "";
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function goPath(index: number) {
|
||||
prefix.value = pathSegments.value.slice(0, index + 1).join("/");
|
||||
void loadFiles();
|
||||
}
|
||||
|
||||
function formatSize(size?: number): string {
|
||||
if (size === undefined || size === null) return "-";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function nameCell(row: StorageObject) {
|
||||
return h("span", { class: "inline-flex items-center gap-1.5 min-w-0" }, [
|
||||
h(row.is_dir ? Folder : Document, {
|
||||
style: {
|
||||
color: row.is_dir ? "var(--el-color-warning)" : "var(--el-color-primary)",
|
||||
flexShrink: 0,
|
||||
},
|
||||
}),
|
||||
h("span", { class: "truncate" }, row.name ?? ""),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildActions(row: StorageObject): TableOperationAction[] {
|
||||
if (row.is_dir) {
|
||||
return [
|
||||
{
|
||||
key: "open",
|
||||
label: "打开",
|
||||
artType: "view",
|
||||
icon: "ri:folder-open-line",
|
||||
iconColor: "var(--el-color-warning)",
|
||||
perm: "module_storage:file:query",
|
||||
run: () => openDir(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_storage:file:delete",
|
||||
run: () => void deleteItem(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: "download",
|
||||
label: "下载",
|
||||
artType: "view",
|
||||
icon: "ri:download-2-line",
|
||||
iconColor: "var(--el-color-primary)",
|
||||
perm: "module_storage:file:download",
|
||||
run: () => void downloadItem(row),
|
||||
},
|
||||
{
|
||||
key: "url",
|
||||
label: "获取链接",
|
||||
artType: "view",
|
||||
icon: "ri:link",
|
||||
perm: "module_storage:file:query",
|
||||
run: () => void getFileUrl(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_storage:file:delete",
|
||||
run: () => void deleteItem(row),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatOperationCell(row: StorageObject) {
|
||||
return renderTableOperationCell(buildActions(row), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 file-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
const columns = computed<ColumnOption<StorageObject>[]>(() => [
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "名称", minWidth: 300, showOverflowTooltip: true, formatter: nameCell },
|
||||
{
|
||||
prop: "is_dir",
|
||||
label: "类型",
|
||||
width: 90,
|
||||
formatter: (row: StorageObject) => (row.is_dir ? "目录" : "文件"),
|
||||
},
|
||||
{
|
||||
prop: "size",
|
||||
label: "大小",
|
||||
width: 110,
|
||||
formatter: (row: StorageObject) => formatSize(row.size),
|
||||
},
|
||||
{ prop: "modified_time", label: "修改时间", width: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 200,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: StorageObject) => formatOperationCell(row),
|
||||
},
|
||||
]);
|
||||
|
||||
async function handleUploadRequest(options: UploadRequestOptions) {
|
||||
pendingUploads.value += 1;
|
||||
let batchFailed = false;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", options.file);
|
||||
if (sourceId.value) fd.append("source_id", String(sourceId.value));
|
||||
if (prefix.value) fd.append("remote_path", prefix.value);
|
||||
await FileAPI.uploadFile(fd);
|
||||
void loadFiles();
|
||||
options.onSuccess({});
|
||||
} catch (error) {
|
||||
batchFailed = true;
|
||||
const errObj = error instanceof Error ? error : new Error(String(error));
|
||||
options.onError({
|
||||
...errObj,
|
||||
status: 0,
|
||||
method: "POST",
|
||||
url: "/storage/file/upload",
|
||||
});
|
||||
} finally {
|
||||
pendingUploads.value -= 1;
|
||||
if (pendingUploads.value === 0 && !batchFailed) {
|
||||
uploadRef.value?.clearFiles();
|
||||
uploadVisible.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadItem(item: StorageObject) {
|
||||
try {
|
||||
const response = await FileAPI.downloadFile({ remote_path: item.key ?? "", source_id: sourceId.value });
|
||||
const blob = response.data;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = item.name ?? "download";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// 失败提示由全局拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileUrl(item: StorageObject) {
|
||||
try {
|
||||
const res = await FileAPI.getFileUrl({
|
||||
remote_path: item.key ?? "",
|
||||
source_id: sourceId.value,
|
||||
expire: 3600,
|
||||
});
|
||||
const url = res.data?.data;
|
||||
if (!url) {
|
||||
ElMessage.warning("当前存储源不支持获取访问 URL");
|
||||
return;
|
||||
}
|
||||
await ElMessageBox.alert(url, "文件访问 URL", {
|
||||
confirmButtonText: "复制",
|
||||
showCancelButton: false,
|
||||
});
|
||||
await navigator.clipboard.writeText(url).catch(() => undefined);
|
||||
ElMessage.success("已复制到剪贴板");
|
||||
} catch {
|
||||
// 失败提示由全局拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item: StorageObject) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
item.is_dir ? `确定删除目录「${item.name}」及其全部内容吗?` : `确定删除文件「${item.name}」吗?`,
|
||||
"确认删除",
|
||||
{
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
}
|
||||
);
|
||||
await FileAPI.deleteFile({ remote_path: item.key ?? "", source_id: sourceId.value });
|
||||
void loadFiles();
|
||||
} catch {
|
||||
// 用户取消或失败(失败提示由全局拦截器处理)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSources();
|
||||
void loadFiles();
|
||||
});
|
||||
</script>
|
||||
@@ -1,607 +0,0 @@
|
||||
<!-- 存储源管理:存储源 CRUD + 连接测试 -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="sourceSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:default-expanded="false"
|
||||
include-audit
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<FaTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_storage:source:create']"
|
||||
:perm-delete="['module_storage:source:delete']"
|
||||
:delete-loading="batchDeleting"
|
||||
:create-loading="createLoading"
|
||||
@add="handleAdd"
|
||||
@delete="handleBatchDelete"
|
||||
/>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="720px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions :column="2" :data="detailFormData" :items="sourceDetailItems" max-height="70vh">
|
||||
<template #protocol="{ row }">
|
||||
<span>{{ protocolLabel((row as unknown as SourceTable)?.protocol) }}</span>
|
||||
</template>
|
||||
<template #is_default="{ row }">
|
||||
<FaStatusTag v-if="row?.is_default" type="success" label="默认" />
|
||||
<FaStatusTag v-else type="info" label="否" />
|
||||
</template>
|
||||
<template #is_secure="{ row }">
|
||||
<span>{{ row?.is_secure ? "是" : "否" }}</span>
|
||||
</template>
|
||||
<template #has_password="{ row }">
|
||||
<span>{{ row?.has_password ? "已配置" : "未配置" }}</span>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FaForm
|
||||
:key="sourceFormRenderKey"
|
||||
scrollbar
|
||||
max-height="70vh"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="sourceDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="110"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div v-if="dialogVisible.type === 'detail'" class="fa-dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<ElButton type="primary" @click="handleCloseDialog">关闭</ElButton>
|
||||
</div>
|
||||
<div v-else class="fa-dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<ElButton :loading="testing" style="margin-right: auto" @click="handleTestConfig">测试连接</ElButton>
|
||||
<ElButton type="primary" plain @click="handleCloseDialog">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit">确定</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCrudForm } from "@/hooks/core/useCrudForm";
|
||||
import { resolveStatusColumns, renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import SourceAPI, { type SourceForm, type SourceTable } from "@/api/module_storage/source";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "StorageSource",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const PROTOCOL_OPTIONS = [
|
||||
{ label: "FTP", value: "ftp" },
|
||||
{ label: "FTPS", value: "ftps" },
|
||||
{ label: "SFTP", value: "sftp" },
|
||||
{ label: "S3", value: "s3" },
|
||||
{ label: "OBS(华为云)", value: "obs" },
|
||||
{ label: "OSS(阿里云)", value: "oss" },
|
||||
{ label: "COS(腾讯云)", value: "cos" },
|
||||
{ label: "本地目录", value: "local" },
|
||||
] as const;
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
] as const;
|
||||
|
||||
type SourceSearchForm = {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
status?: number;
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
};
|
||||
|
||||
function buildSourceReplaceParams(p: SourceSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: p.name,
|
||||
protocol: p.protocol,
|
||||
status: p.status,
|
||||
created_id: p.created_id,
|
||||
updated_id: p.updated_id,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
updated_time:
|
||||
Array.isArray(p.updated_time) && p.updated_time.length === 2 ? p.updated_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function protocolLabel(value?: string): string {
|
||||
const opt = PROTOCOL_OPTIONS.find((o) => o.value === value);
|
||||
return opt ? opt.label : (value ?? "-");
|
||||
}
|
||||
|
||||
function buildSourceRowActions(
|
||||
row: SourceTable,
|
||||
ctx: {
|
||||
onTest: (id: number) => void;
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number, name: string) => void;
|
||||
}
|
||||
): TableOperationAction[] {
|
||||
return [
|
||||
{
|
||||
key: "test",
|
||||
label: "测试连接",
|
||||
artType: "view",
|
||||
icon: "ri:link",
|
||||
iconColor: "var(--el-color-primary)",
|
||||
perm: "module_storage:source:query",
|
||||
run: () => ctx.onTest(row.id!),
|
||||
},
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_storage:source:query",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
icon: "ri:edit-2-line",
|
||||
perm: "module_storage:source:update",
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_storage:source:delete",
|
||||
run: () => ctx.onDelete(row.id!, row.name ?? ""),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatSourceOperationCell(row: SourceTable, ctx: Parameters<typeof buildSourceRowActions>[1]) {
|
||||
return renderTableOperationCell(buildSourceRowActions(row, ctx), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 source-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
const searchForm = ref<SourceSearchForm>({
|
||||
name: undefined,
|
||||
protocol: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const sourceSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "存储名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入存储名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "协议",
|
||||
key: "protocol",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择协议",
|
||||
options: PROTOCOL_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: STATUS_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
|
||||
// ─── 表格多选 ───
|
||||
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
|
||||
useTableSelection<SourceTable>();
|
||||
|
||||
const createLoading = ref(false);
|
||||
|
||||
async function deleteSourceRow(id: number, name: string) {
|
||||
try {
|
||||
await confirmDelete(`确定删除「${name}」吗?`);
|
||||
await SourceAPI.deleteSource([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTest(id: number) {
|
||||
await SourceAPI.testSource(id);
|
||||
}
|
||||
|
||||
const testing = ref(false);
|
||||
|
||||
async function handleTestConfig() {
|
||||
try {
|
||||
await dataFormRef.value?.validate?.();
|
||||
} catch {
|
||||
return; // 表单校验不通过时不发起测试
|
||||
}
|
||||
testing.value = true;
|
||||
try {
|
||||
await SourceAPI.testSourceConfig({ ...formData.value, source_id: formData.value.id });
|
||||
} catch {
|
||||
// 失败原因由全局拦截器提示
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 对话框状态 ───
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
|
||||
const detailFormData = ref<SourceTable>({} as SourceTable);
|
||||
|
||||
const sourceDetailItems: import("@/components/display/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "存储源名称", prop: "name" },
|
||||
{ label: "协议", prop: "protocol", slot: "protocol" },
|
||||
{ label: "主机地址", prop: "host" },
|
||||
{ label: "端口", prop: "port" },
|
||||
{ label: "用户名", prop: "username" },
|
||||
{ label: "密码", prop: "has_password", slot: "has_password" },
|
||||
{ label: "Bucket", prop: "bucket" },
|
||||
{ label: "接入点", prop: "endpoint" },
|
||||
{ label: "区域", prop: "region" },
|
||||
{ label: "路径前缀", prop: "path_prefix" },
|
||||
{ label: "TLS(FTPS)", prop: "is_secure", slot: "is_secure" },
|
||||
{ label: "默认存储源", prop: "is_default", slot: "is_default" },
|
||||
{ label: "状态", prop: "status", tag: { map: { 0: { type: "success", text: "启用" }, 1: { type: "danger", text: "停用" } } } },
|
||||
{ label: "备注", prop: "description" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
const initialFormData: SourceForm = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
protocol: "ftp",
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
bucket: undefined,
|
||||
endpoint: undefined,
|
||||
region: undefined,
|
||||
path_prefix: undefined,
|
||||
is_secure: false,
|
||||
implicit_tls: false,
|
||||
is_default: false,
|
||||
status: 0,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const formData = ref<SourceForm>({ ...initialFormData });
|
||||
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: "请输入存储源名称", trigger: "blur" }],
|
||||
host: [{ required: true, message: "请输入主机地址/根目录", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
|
||||
const sourceFormRenderKey = ref(0);
|
||||
|
||||
// ─── CRUD 表单 ───
|
||||
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } = useCrudForm<SourceForm>({
|
||||
formData,
|
||||
initialFormData,
|
||||
dialogVisible,
|
||||
dataFormRef,
|
||||
formRenderKey: sourceFormRenderKey,
|
||||
detailApi: SourceAPI.detailSource,
|
||||
createApi: SourceAPI.createSource,
|
||||
updateApi: SourceAPI.updateSource,
|
||||
titles: { create: "新增存储源", update: "修改存储源", detail: "存储源详情" },
|
||||
detailFormData,
|
||||
onCreateSuccess: async () => {
|
||||
await refreshCreate();
|
||||
},
|
||||
onUpdateSuccess: async () => {
|
||||
await refreshUpdate();
|
||||
},
|
||||
});
|
||||
|
||||
async function handleAdd() {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await handleOpenDialog("create");
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const opCtx = {
|
||||
onTest: handleTest,
|
||||
onDetail: (id: number) => void handleOpenDialog("detail", id),
|
||||
onEdit: (id: number) => void handleOpenDialog("update", id),
|
||||
onDelete: deleteSourceRow,
|
||||
};
|
||||
const sourceDialogFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "存储源名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入存储源名称", maxlength: 64, showWordLimit: true },
|
||||
},
|
||||
{
|
||||
label: "协议",
|
||||
key: "protocol",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: { placeholder: "请选择协议", options: PROTOCOL_OPTIONS },
|
||||
},
|
||||
{
|
||||
label: "主机地址",
|
||||
key: "host",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "IP / 域名 / 根目录", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "端口",
|
||||
key: "port",
|
||||
type: "number",
|
||||
span: 12,
|
||||
props: { controlsPosition: "right", min: 0, max: 65535, placeholder: "留空使用协议默认端口" },
|
||||
},
|
||||
{
|
||||
label: "用户名",
|
||||
key: "username",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "用户名 / AccessKey", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "密码",
|
||||
key: "password",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { type: "password", showPassword: true, placeholder: "密码 / SecretKey(修改时留空则不修改)" },
|
||||
},
|
||||
{
|
||||
label: "Bucket",
|
||||
key: "bucket",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "桶名 / 空间名 / 根目录", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "接入点",
|
||||
key: "endpoint",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "对象存储接入点(可选)", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "区域",
|
||||
key: "region",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "区域(可选)", maxlength: 64 },
|
||||
},
|
||||
{
|
||||
label: "路径前缀",
|
||||
key: "path_prefix",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "统一路径前缀(可选)", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "TLS(FTPS)",
|
||||
key: "is_secure",
|
||||
type: "switch",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "默认存储源",
|
||||
key: "is_default",
|
||||
type: "switch",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "radiogroup",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "备注",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { type: "textarea", rows: 3, maxlength: 255, showWordLimit: true, placeholder: "请输入备注" },
|
||||
},
|
||||
]);
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: SourceAPI.pageSource,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: resolveStatusColumns<SourceTable>(() => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "存储源名称", minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "protocol",
|
||||
label: "协议",
|
||||
width: 110,
|
||||
formatter: (row: SourceTable) => protocolLabel(row.protocol),
|
||||
},
|
||||
{ prop: "host", label: "主机地址", minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: "port", label: "端口", width: 80 },
|
||||
{ prop: "bucket", label: "Bucket", minWidth: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "is_default",
|
||||
label: "默认",
|
||||
width: 80,
|
||||
formatter: (row: SourceTable) => (row.is_default ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 80,
|
||||
status: {
|
||||
0: { type: "success", text: "启用" },
|
||||
1: { type: "danger", text: "停用" },
|
||||
},
|
||||
},
|
||||
{ prop: "description", label: "备注", minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "创建时间",
|
||||
width: 168,
|
||||
sortable: true,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 220,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: SourceTable) => formatSourceOperationCell(row, opCtx),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSearchBarSearch(params: SourceSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildSourceReplaceParams(params));
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
protocol: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
};
|
||||
await resetSearchParams();
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(
|
||||
ids.length,
|
||||
selectedRows.value.map((r) => String(r.name ?? r.id))
|
||||
);
|
||||
batchDeleting.value = true;
|
||||
await SourceAPI.deleteSource(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-220px)] min-h-120 gap-3">
|
||||
<!-- 左:会话列表 -->
|
||||
<div class="flex w-72 shrink-0 flex-col overflow-hidden rounded-xl border border-(--el-border-color-lighter) bg-(--el-bg-color)">
|
||||
<div
|
||||
class="flex w-72 shrink-0 flex-col overflow-hidden rounded-xl border border-(--el-border-color-lighter) bg-(--el-bg-color)"
|
||||
>
|
||||
<div class="flex gap-2 border-b border-(--el-border-color-lighter) p-2.5">
|
||||
<ElInput v-model="searchKeyword" placeholder="搜索会话" clearable size="default">
|
||||
<template #prefix>
|
||||
@@ -12,21 +14,30 @@
|
||||
<ElButton type="primary" :icon="Plus" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem command="private"><ElIcon><ChatLineRound /></ElIcon>发起私聊</ElDropdownItem>
|
||||
<ElDropdownItem command="group"><ElIcon><UserFilled /></ElIcon>创建群聊</ElDropdownItem>
|
||||
<ElDropdownItem command="private"
|
||||
><ElIcon><ChatLineRound /></ElIcon>发起私聊</ElDropdownItem
|
||||
>
|
||||
<ElDropdownItem command="group"
|
||||
><ElIcon><UserFilled /></ElIcon>创建群聊</ElDropdownItem
|
||||
>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<ElScrollbar class="flex-1">
|
||||
<div v-if="filteredConversations.length === 0" class="py-16 text-center text-xs text-(--el-text-color-secondary)">
|
||||
<div
|
||||
v-if="filteredConversations.length === 0"
|
||||
class="py-16 text-center text-xs text-(--el-text-color-secondary)"
|
||||
>
|
||||
暂无会话,点击右上角发起聊天
|
||||
</div>
|
||||
<div
|
||||
v-for="conv in filteredConversations"
|
||||
:key="conv.conversation_type + '-' + conv.id"
|
||||
class="mx-2 mb-1 flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors"
|
||||
:class="isActive(conv) ? 'bg-(--el-color-primary-light-9)' : 'hover:bg-(--el-fill-color-light)'"
|
||||
:class="
|
||||
isActive(conv) ? 'bg-(--el-color-primary-light-9)' : 'hover:bg-(--el-fill-color-light)'
|
||||
"
|
||||
@click="openConversation(conv)"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
@@ -39,12 +50,22 @@
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate text-sm font-medium text-(--el-text-color-primary)">{{ conv.name }}</span>
|
||||
<span class="shrink-0 text-[10px] text-(--el-text-color-secondary)">{{ formatTime(conv.last_time) }}</span>
|
||||
<span class="truncate text-sm font-medium text-(--el-text-color-primary)">{{
|
||||
conv.name
|
||||
}}</span>
|
||||
<span class="shrink-0 text-[10px] text-(--el-text-color-secondary)">{{
|
||||
formatTime(conv.last_time)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex items-center justify-between gap-2">
|
||||
<span class="truncate text-xs text-(--el-text-color-secondary)">{{ conv.last_message || (conv.conversation_type === 2 ? `群聊(${conv.member_count}人)` : "暂无消息") }}</span>
|
||||
<span v-if="conv.unread > 0" class="shrink-0 rounded-full bg-(--el-color-danger) px-1.5 py-0.5 text-[10px] leading-none text-white">
|
||||
<span class="truncate text-xs text-(--el-text-color-secondary)">{{
|
||||
conv.last_message ||
|
||||
(conv.conversation_type === 2 ? `群聊(${conv.member_count}人)` : "暂无消息")
|
||||
}}</span>
|
||||
<span
|
||||
v-if="conv.unread > 0"
|
||||
class="shrink-0 rounded-full bg-(--el-color-danger) px-1.5 py-0.5 text-[10px] leading-none text-white"
|
||||
>
|
||||
{{ conv.unread > 99 ? "99+" : conv.unread }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -54,26 +75,36 @@
|
||||
</div>
|
||||
|
||||
<!-- 右:聊天窗 -->
|
||||
<div class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-(--el-border-color-lighter) bg-(--el-bg-color)">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-(--el-border-color-lighter) bg-(--el-bg-color)"
|
||||
>
|
||||
<template v-if="currentConversation">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between border-b border-(--el-border-color-lighter) px-4 py-3">
|
||||
<div
|
||||
class="flex items-center justify-between border-b border-(--el-border-color-lighter) px-4 py-3"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<ElAvatar :size="36" :src="currentConversation.avatar || defaultAvatar" :icon="UserFilled" />
|
||||
<ElAvatar
|
||||
:size="36"
|
||||
:src="currentConversation.avatar || defaultAvatar"
|
||||
:icon="UserFilled"
|
||||
/>
|
||||
<div class="leading-tight">
|
||||
<div class="text-sm font-medium text-(--el-text-color-primary)">{{ currentConversation.name }}</div>
|
||||
<div class="text-sm font-medium text-(--el-text-color-primary)">
|
||||
{{ currentConversation.name }}
|
||||
</div>
|
||||
<div class="text-xs text-(--el-text-color-secondary)">
|
||||
<template v-if="currentConversation.conversation_type === 1">
|
||||
{{ currentConversation.online ? "在线" : "离线" }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ currentConversation.member_count }} 名成员
|
||||
</template>
|
||||
<template v-else> {{ currentConversation.member_count }} 名成员 </template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ElTag v-if="chatStore.wsConnected" type="success" effect="plain" size="small">实时连接</ElTag>
|
||||
<ElTag v-if="chatStore.wsConnected" type="success" effect="plain" size="small"
|
||||
>实时连接</ElTag
|
||||
>
|
||||
<ElButton
|
||||
v-if="currentConversation.conversation_type === 2"
|
||||
size="small"
|
||||
@@ -90,20 +121,54 @@
|
||||
<!-- 消息区 -->
|
||||
<ElScrollbar ref="messageScrollbarRef" class="flex-1 px-4 py-5">
|
||||
<div class="mb-3 text-center">
|
||||
<ElButton v-if="hasMore" text type="primary" size="small" :loading="loadingHistory" @click="() => loadHistory()">
|
||||
<ElButton
|
||||
v-if="hasMore"
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="loadingHistory"
|
||||
@click="() => loadHistory()"
|
||||
>
|
||||
加载更早的消息
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="messages.length === 0" class="py-20 text-center text-xs text-(--el-text-color-secondary)">
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
class="py-20 text-center text-xs text-(--el-text-color-secondary)"
|
||||
>
|
||||
暂无消息,打个招呼吧
|
||||
</div>
|
||||
<template v-for="msg in messages" :key="msg.id">
|
||||
<div :class="['mb-5 flex w-full items-start gap-2.5', msg.sender_id === myId ? 'flex-row-reverse' : 'flex-row']">
|
||||
<ElAvatar :size="32" :src="msg.sender_avatar || defaultAvatar" :icon="UserFilled" class="shrink-0" />
|
||||
<div :class="['flex max-w-[70%] flex-col', msg.sender_id === myId ? 'items-end' : 'items-start']">
|
||||
<div :class="['mb-1 flex items-center gap-2 text-xs', msg.sender_id === myId ? 'flex-row-reverse' : 'flex-row']">
|
||||
<span class="font-medium text-(--el-text-color-primary)">{{ msg.sender_id === myId ? "我" : msg.sender_name }}</span>
|
||||
<span class="text-(--el-text-color-secondary)">{{ formatTime(msg.created_time) }}</span>
|
||||
<div
|
||||
:class="[
|
||||
'mb-5 flex w-full items-start gap-2.5',
|
||||
msg.sender_id === myId ? 'flex-row-reverse' : 'flex-row',
|
||||
]"
|
||||
>
|
||||
<ElAvatar
|
||||
:size="32"
|
||||
:src="msg.sender_avatar || defaultAvatar"
|
||||
:icon="UserFilled"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<div
|
||||
:class="[
|
||||
'flex max-w-[70%] flex-col',
|
||||
msg.sender_id === myId ? 'items-end' : 'items-start',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mb-1 flex items-center gap-2 text-xs',
|
||||
msg.sender_id === myId ? 'flex-row-reverse' : 'flex-row',
|
||||
]"
|
||||
>
|
||||
<span class="font-medium text-(--el-text-color-primary)">{{
|
||||
msg.sender_id === myId ? "我" : msg.sender_name
|
||||
}}</span>
|
||||
<span class="text-(--el-text-color-secondary)">{{
|
||||
formatTime(msg.created_time)
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
@@ -137,14 +202,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="flex flex-1 flex-col items-center justify-center gap-3 text-(--el-text-color-secondary)">
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-(--el-text-color-secondary)"
|
||||
>
|
||||
<ElIcon :size="56"><ChatDotRound /></ElIcon>
|
||||
<span class="text-sm">选择一个会话开始聊天</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发起聊天 / 创建群 -->
|
||||
<ElDialog v-model="createDialogVisible" :title="createMode === 'group' ? '创建群聊' : '发起私聊'" width="460px" :close-on-click-modal="false">
|
||||
<ElDialog
|
||||
v-model="createDialogVisible"
|
||||
:title="createMode === 'group' ? '创建群聊' : '发起私聊'"
|
||||
width="460px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<template v-if="createMode === 'group'">
|
||||
<div class="mb-3">
|
||||
<div class="mb-1.5 text-xs text-(--el-text-color-secondary)">群名称</div>
|
||||
@@ -165,7 +238,12 @@
|
||||
placeholder="输入姓名或用户名搜索"
|
||||
class="w-full"
|
||||
>
|
||||
<ElOption v-for="u in userOptions" :key="u.id" :label="u.name + '(' + u.username + ')'" :value="u.id" />
|
||||
<ElOption
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
:label="u.name + '(' + u.username + ')'"
|
||||
:value="u.id"
|
||||
/>
|
||||
</ElSelect>
|
||||
<template #footer>
|
||||
<ElButton @click="createDialogVisible = false">取消</ElButton>
|
||||
@@ -180,18 +258,35 @@
|
||||
<ElAvatar :size="48" :src="groupDetail.avatar || defaultAvatar" :icon="UserFilled" />
|
||||
<div>
|
||||
<div class="text-sm font-medium">{{ groupDetail.name }}</div>
|
||||
<div class="text-xs text-(--el-text-color-secondary)">{{ groupDetail.member_count }} 名成员</div>
|
||||
<div class="text-xs text-(--el-text-color-secondary)">
|
||||
{{ groupDetail.member_count }} 名成员
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="groupDetail.announcement" class="mb-4 rounded-lg bg-(--el-fill-color-light) p-3 text-xs text-(--el-text-color-secondary)">
|
||||
<div
|
||||
v-if="groupDetail.announcement"
|
||||
class="mb-4 rounded-lg bg-(--el-fill-color-light) p-3 text-xs text-(--el-text-color-secondary)"
|
||||
>
|
||||
公告:{{ groupDetail.announcement }}
|
||||
</div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-sm font-medium">成员列表</span>
|
||||
<ElButton v-if="isOwner" size="small" type="primary" text :icon="Plus" @click="openAddMember">添加成员</ElButton>
|
||||
<ElButton
|
||||
v-if="isOwner"
|
||||
size="small"
|
||||
type="primary"
|
||||
text
|
||||
:icon="Plus"
|
||||
@click="openAddMember"
|
||||
>添加成员</ElButton
|
||||
>
|
||||
</div>
|
||||
<ElScrollbar max-height="320px">
|
||||
<div v-for="m in groupDetail.members" :key="m.id" class="mb-1 flex items-center justify-between rounded-lg px-2 py-2 hover:bg-(--el-fill-color-light)">
|
||||
<div
|
||||
v-for="m in groupDetail.members"
|
||||
:key="m.id"
|
||||
class="mb-1 flex items-center justify-between rounded-lg px-2 py-2 hover:bg-(--el-fill-color-light)"
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<ElAvatar :size="32" :src="m.avatar || defaultAvatar" :icon="UserFilled" />
|
||||
<div class="leading-tight">
|
||||
@@ -200,8 +295,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<ElTag v-if="m.id === groupDetail.owner_id" size="small" type="warning" effect="light">群主</ElTag>
|
||||
<ElButton v-if="isOwner && m.id !== groupDetail.owner_id" size="small" text type="danger" @click="removeMember(m)">移除</ElButton>
|
||||
<ElTag v-if="m.id === groupDetail.owner_id" size="small" type="warning" effect="light"
|
||||
>群主</ElTag
|
||||
>
|
||||
<ElButton
|
||||
v-if="isOwner && m.id !== groupDetail.owner_id"
|
||||
size="small"
|
||||
text
|
||||
type="danger"
|
||||
@click="removeMember(m)"
|
||||
>移除</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
@@ -223,7 +327,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { ChatDotRound, ChatLineRound, MoreFilled, Plus, Search, UserFilled } from "@element-plus/icons-vue";
|
||||
import {
|
||||
ChatDotRound,
|
||||
ChatLineRound,
|
||||
MoreFilled,
|
||||
Plus,
|
||||
Search,
|
||||
UserFilled,
|
||||
} from "@element-plus/icons-vue";
|
||||
import type { ElScrollbar as ElScrollbarType } from "element-plus";
|
||||
|
||||
import ChatAPI, {
|
||||
@@ -398,12 +509,12 @@ async function confirmCreate() {
|
||||
return;
|
||||
}
|
||||
const userId = selectedUserIds.value[0]!;
|
||||
const exists = conversations.value.some(
|
||||
(c) => c.conversation_type === 1 && c.id === userId
|
||||
);
|
||||
const exists = conversations.value.some((c) => c.conversation_type === 1 && c.id === userId);
|
||||
if (exists) {
|
||||
createDialogVisible.value = false;
|
||||
openConversation(conversations.value.find((c) => c.conversation_type === 1 && c.id === userId)!);
|
||||
openConversation(
|
||||
conversations.value.find((c) => c.conversation_type === 1 && c.id === userId)!
|
||||
);
|
||||
return;
|
||||
}
|
||||
const response = await ChatAPI.listUsers();
|
||||
@@ -437,7 +548,9 @@ async function confirmCreate() {
|
||||
ElMessage.success("创建群聊成功");
|
||||
await loadConversations();
|
||||
if (detail?.id) {
|
||||
const conv = conversations.value.find((c) => c.conversation_type === 2 && c.id === detail.id);
|
||||
const conv = conversations.value.find(
|
||||
(c) => c.conversation_type === 2 && c.id === detail.id
|
||||
);
|
||||
if (conv) openConversation(conv);
|
||||
}
|
||||
}
|
||||
@@ -475,9 +588,7 @@ async function openAddMember() {
|
||||
const members = (await ChatAPI.listUsers()).data?.data ?? [];
|
||||
const ids = keywords
|
||||
.flatMap((kw) =>
|
||||
members
|
||||
.filter((u) => u.name.includes(kw) || u.username.includes(kw))
|
||||
.map((u) => u.id)
|
||||
members.filter((u) => u.name.includes(kw) || u.username.includes(kw)).map((u) => u.id)
|
||||
)
|
||||
.filter((id, index, arr) => arr.indexOf(id) === index);
|
||||
if (ids.length === 0) {
|
||||
@@ -509,7 +620,9 @@ async function saveAnnouncement() {
|
||||
async function confirmDeleteGroup() {
|
||||
const conv = currentConversation.value;
|
||||
if (!conv || conv.id == null) return;
|
||||
await ElMessageBox.confirm("解散后群聊记录将无法继续查看,确定解散?", "解散群组", { type: "error" });
|
||||
await ElMessageBox.confirm("解散后群聊记录将无法继续查看,确定解散?", "解散群组", {
|
||||
type: "error",
|
||||
});
|
||||
await ChatAPI.deleteGroup(conv.id);
|
||||
ElMessage.success("群组已解散");
|
||||
groupDetailVisible.value = false;
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElSegmented class="mt-3" v-model="menuClientTab" :options="menuSegmentedOptions" @change="handleMenuClientTabChange" />
|
||||
<ElSegmented
|
||||
class="mt-3"
|
||||
v-model="menuClientTab"
|
||||
:options="menuSegmentedOptions"
|
||||
@change="handleMenuClientTabChange"
|
||||
/>
|
||||
|
||||
<ElCard
|
||||
class="fa-table-card"
|
||||
@@ -1080,13 +1085,39 @@ const rules = reactive({
|
||||
{ required: true, message: "请输入菜单名称", trigger: "blur" },
|
||||
{ min: 2, max: 50, message: "长度 2 到 50 个字符", trigger: "blur" },
|
||||
],
|
||||
parent_id: [{ required: true, message: "请选择父级菜单", trigger: "blur" }],
|
||||
// 父级菜单:顶级节点允许为空(后端 parent_id 可空),无需强制必填
|
||||
type: [{ required: true, message: "请选择菜单类型", trigger: "blur" }],
|
||||
order: [{ required: true, message: "请输入排序", trigger: "blur" }],
|
||||
permission: [{ required: true, message: "请输入权限标识", trigger: "blur" }],
|
||||
permission: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => {
|
||||
// 仅按钮/菜单类型需要权限标识,目录/外链不校验(与表单 hidden 条件一致)
|
||||
const t = formData.value.type as MenuTypeEnum;
|
||||
if ((t === MenuTypeEnum.BUTTON || t === MenuTypeEnum.MENU) && !String(value ?? "").trim()) {
|
||||
callback(new Error("请输入权限标识"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
route_name: [{ required: true, message: "请输入路由名称", trigger: "blur" }],
|
||||
route_path: [{ required: true, message: "请输入路由路径", trigger: "blur" }],
|
||||
component_path: [{ required: true, message: "请输入组件路径", trigger: "blur" }],
|
||||
component_path: [
|
||||
{
|
||||
validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => {
|
||||
// 仅菜单类型需要组件路径,目录/按钮/外链不校验(与表单 hidden 条件一致)
|
||||
const t = formData.value.type as MenuTypeEnum;
|
||||
if (t === MenuTypeEnum.MENU && !String(value ?? "").trim()) {
|
||||
callback(new Error("请输入组件路径"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
trigger: "blur",
|
||||
},
|
||||
],
|
||||
title: [
|
||||
{ required: true, message: "请输入菜单标题", trigger: "blur" },
|
||||
{ min: 2, max: 50, message: "长度 2 到 50 个字符", trigger: "blur" },
|
||||
|
||||
@@ -160,7 +160,10 @@
|
||||
:lg="4"
|
||||
class="mb-4"
|
||||
>
|
||||
<ElCard shadow="hover" :class="`fa-card job-card job-card--${getJobStatusClass(job.status)}`">
|
||||
<ElCard
|
||||
shadow="hover"
|
||||
:class="`fa-card job-card job-card--${getJobStatusClass(job.status)}`"
|
||||
>
|
||||
<template #header>
|
||||
<div class="job-card-title">
|
||||
<span
|
||||
@@ -851,17 +854,18 @@ const logDetailDialog = reactive({
|
||||
});
|
||||
const logDetailFormData = ref<JobLogTable>({} as JobLogTable);
|
||||
|
||||
const logDetailItems: import("@/components/display/fa-descriptions/index.vue").DescriptionsItem[] = [
|
||||
{ label: "任务ID", prop: "job_id" },
|
||||
{ label: "任务名称", prop: "job_name" },
|
||||
{ label: "触发方式", prop: "trigger_type" },
|
||||
{ label: "执行状态", prop: "status" },
|
||||
{ label: "下次执行时间", prop: "next_run_time" },
|
||||
{ label: "执行结果", prop: "result", span: 4 },
|
||||
{ label: "错误信息", prop: "error", span: 4 },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
const logDetailItems: import("@/components/display/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "任务ID", prop: "job_id" },
|
||||
{ label: "任务名称", prop: "job_name" },
|
||||
{ label: "触发方式", prop: "trigger_type" },
|
||||
{ label: "执行状态", prop: "status" },
|
||||
{ label: "下次执行时间", prop: "next_run_time" },
|
||||
{ label: "执行结果", prop: "result", span: 4 },
|
||||
{ label: "错误信息", prop: "error", span: 4 },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
async function handleOpenLogDetail(row: JobLogTable) {
|
||||
if (row.id == null) return;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<BaseEdge
|
||||
:id="props.id"
|
||||
:path="path"
|
||||
:marker-end="props.markerEnd"
|
||||
:style="props.style"
|
||||
:interaction-width="props.interactionWidth ?? 20"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
class="wf-edge-label nodrag nopan"
|
||||
:class="{ 'is-disabled': isDisabled }"
|
||||
:style="labelStyle"
|
||||
@click.stop="onChipClick"
|
||||
>
|
||||
<span class="wf-edge-label-text">{{ props.label }}</span>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, type CSSProperties } from "vue";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
getSmoothStepPath,
|
||||
useEdge,
|
||||
useVueFlow,
|
||||
} from "@vue-flow/core";
|
||||
import type { EdgeProps } from "@vue-flow/core";
|
||||
import { protocolColor } from "./protocol";
|
||||
|
||||
const props = defineProps<EdgeProps>();
|
||||
|
||||
const { edge } = useEdge();
|
||||
const { emits, addSelectedEdges, nodesSelectionActive } = useVueFlow();
|
||||
|
||||
// 与官网内置 SmoothStepEdge 一致的路径计算,节点拖动时随坐标变化重算
|
||||
const smoothPath = computed(() =>
|
||||
getSmoothStepPath({
|
||||
sourceX: props.sourceX,
|
||||
sourceY: props.sourceY,
|
||||
sourcePosition: props.sourcePosition,
|
||||
targetX: props.targetX,
|
||||
targetY: props.targetY,
|
||||
targetPosition: props.targetPosition,
|
||||
})
|
||||
);
|
||||
const path = computed(() => smoothPath.value[0]);
|
||||
const labelX = computed(() => smoothPath.value[1]);
|
||||
const labelY = computed(() => smoothPath.value[2]);
|
||||
|
||||
const edgeData = computed(() => (props.data ?? {}) as Record<string, any>);
|
||||
// 禁用的连线标签整体置灰,路径描边已在保存时置灰
|
||||
const isDisabled = computed(() => edgeData.value.enabled === false);
|
||||
// 标签配色跟随源节点协议色
|
||||
const chipColor = computed(() =>
|
||||
isDisabled.value ? "#c0c4cc" : protocolColor(edgeData.value.source_protocol as string | undefined)
|
||||
);
|
||||
|
||||
// EdgeLabelRenderer 渲染在画布 HTML 层,标签需内联定位到路径中心并允许点击
|
||||
const labelStyle = computed<CSSProperties>(() => ({
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${labelX.value}px, ${labelY.value}px)`,
|
||||
pointerEvents: "all",
|
||||
color: chipColor.value,
|
||||
borderColor: chipColor.value,
|
||||
}));
|
||||
|
||||
// 点击标签等价于点击连线:选中连线并触发 edgeClick(与官网内置 EdgeWrapper 行为一致),由画布打开连线配置面板
|
||||
function onChipClick(event: MouseEvent) {
|
||||
nodesSelectionActive.value = false;
|
||||
addSelectedEdges([edge]);
|
||||
emits.edgeClick({ event, edge });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wf-edge-label {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 220px;
|
||||
padding: 3px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 8%);
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
background 0.2s ease;
|
||||
|
||||
.wf-edge-label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
box-shadow: 0 2px 10px rgb(0 0 0 / 16%);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,42 +7,53 @@
|
||||
>
|
||||
<div class="node-body" :style="{ '--node-color': color }">
|
||||
<span class="node-flag" :style="{ background: color }" />
|
||||
<ElIcon :size="16" :color="color"><component :is="nodeIcon" /></ElIcon>
|
||||
<ElIcon :size="16" :color="color"><Folder /></ElIcon>
|
||||
<div class="node-text">
|
||||
<span class="node-label">{{ nodeData.label }}</span>
|
||||
<span class="node-category">{{ categoryText }}</span>
|
||||
<span
|
||||
ref="labelRef"
|
||||
class="node-label"
|
||||
:title="isLabelOverflow ? nodeData.label || '未命名存储' : ''"
|
||||
>{{ nodeData.label || "未命名存储" }}</span
|
||||
>
|
||||
<span class="node-category">{{ categoryLabel }}</span>
|
||||
<span v-if="endpoint" class="node-endpoint" :title="endpoint">{{ endpoint }}</span>
|
||||
<span v-if="sourcePath" class="node-path" :title="`默认源目录:${sourcePath}`">{{
|
||||
sourcePath
|
||||
}}</span>
|
||||
</div>
|
||||
<span v-if="badgeCount" class="node-badge" :style="{ background: color }">
|
||||
{{ badgeCount }}
|
||||
</span>
|
||||
<span
|
||||
v-if="totalConnections > 0"
|
||||
class="node-connections"
|
||||
:title="`入站连接 ${incomingCount} 条,出站连接 ${outgoingCount} 条`"
|
||||
>入 {{ incomingCount }} · 出 {{ outgoingCount }}</span
|
||||
>
|
||||
<ElTooltip content="双击查看该存储源文件" placement="top" :show-after="300">
|
||||
<span class="node-files" :style="{ color }"><FolderOpened :size="13" /></span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'top-' + id"
|
||||
id="target-top"
|
||||
type="target"
|
||||
:position="Position.Top"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'input'"
|
||||
:id="'left-' + id"
|
||||
id="target-left"
|
||||
type="target"
|
||||
:position="Position.Left"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'right-' + id"
|
||||
id="source-right"
|
||||
type="source"
|
||||
:position="Position.Right"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
<Handle
|
||||
v-if="nodeType.code !== 'output'"
|
||||
:id="'bottom-' + id"
|
||||
id="source-bottom"
|
||||
type="source"
|
||||
:position="Position.Bottom"
|
||||
:class="{ 'handle-visible': showHandles }"
|
||||
@@ -52,23 +63,11 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, type Component } from "vue";
|
||||
import { Handle, Position } from "@vue-flow/core";
|
||||
import { ElIcon } from "element-plus";
|
||||
import {
|
||||
Bell,
|
||||
ChatDotRound,
|
||||
CircleCheckFilled,
|
||||
Connection,
|
||||
Delete,
|
||||
Download,
|
||||
Link,
|
||||
Odometer,
|
||||
Promotion,
|
||||
QuestionFilled,
|
||||
SetUp,
|
||||
UploadFilled,
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { Handle, Position, useNodeConnections } from "@vue-flow/core";
|
||||
import { ElIcon, ElTooltip } from "element-plus";
|
||||
import { Folder, FolderOpened } from "@element-plus/icons-vue";
|
||||
import { protocolColor, protocolLabel } from "./protocol";
|
||||
|
||||
interface Props {
|
||||
id?: string;
|
||||
@@ -80,113 +79,67 @@ const props = withDefaults(defineProps<Props>(), {});
|
||||
|
||||
const showHandles = ref(false);
|
||||
|
||||
const nodeData = computed(() => props.data ?? { label: "", config: {}, type: undefined, category: undefined });
|
||||
const nodeData = computed(() => props.data ?? {});
|
||||
|
||||
const nodeType = computed(() => {
|
||||
const category = nodeData.value?.category || "action";
|
||||
return {
|
||||
code: nodeData.value?.type || "custom",
|
||||
name: nodeData.value?.label || "自定义节点",
|
||||
category,
|
||||
color: getCategoryColor(category),
|
||||
};
|
||||
const color = computed(() => protocolColor(nodeData.value.protocol as string | undefined));
|
||||
|
||||
const isObjectStorage = computed(() =>
|
||||
["s3", "oss", "obs", "cos"].includes((nodeData.value.protocol || "").toLowerCase())
|
||||
);
|
||||
|
||||
// 协议 · 主机/桶:对象存储显示桶名,其余协议显示主机地址
|
||||
const categoryLabel = computed(() => {
|
||||
const label = protocolLabel(nodeData.value.protocol);
|
||||
const sub = isObjectStorage.value
|
||||
? nodeData.value.bucket || nodeData.value.host
|
||||
: nodeData.value.host;
|
||||
return sub ? `${label} · ${sub}` : label;
|
||||
});
|
||||
|
||||
function getCategoryColor(category: string | undefined) {
|
||||
const colorMap: Record<string, string> = {
|
||||
trigger: "#e6a23c",
|
||||
action: "#409eff",
|
||||
condition: "#67c23a",
|
||||
control: "#909399",
|
||||
};
|
||||
return colorMap[category || "action"] || "#409eff";
|
||||
// 对象存储地址(endpoint),供节点回显
|
||||
const endpoint = computed(() => nodeData.value.endpoint || "");
|
||||
|
||||
// 默认源目录:流程节点上配置的优先,未配置则回退存储源的路径前缀
|
||||
const sourcePath = computed(() => nodeData.value.source_path || nodeData.value.path_prefix || "");
|
||||
|
||||
// 节点连接数:入站(目标)与出站(源)分别统计,供画布上快速了解拓扑
|
||||
const incomingConnections = useNodeConnections({
|
||||
nodeId: () => props.id ?? "",
|
||||
handleType: "target",
|
||||
});
|
||||
const outgoingConnections = useNodeConnections({
|
||||
nodeId: () => props.id ?? "",
|
||||
handleType: "source",
|
||||
});
|
||||
const incomingCount = computed(() => incomingConnections.value.length);
|
||||
const outgoingCount = computed(() => outgoingConnections.value.length);
|
||||
const totalConnections = computed(() => incomingCount.value + outgoingCount.value);
|
||||
|
||||
// 节点名称超长省略时显示悬停提示全名
|
||||
const labelRef = ref<HTMLElement | null>(null);
|
||||
const isLabelOverflow = ref(false);
|
||||
let labelObserver: ResizeObserver | null = null;
|
||||
|
||||
function checkLabelOverflow() {
|
||||
const el = labelRef.value;
|
||||
isLabelOverflow.value = !!el && el.scrollWidth > el.clientWidth;
|
||||
}
|
||||
|
||||
const CATEGORY_TEXT: Record<string, string> = {
|
||||
trigger: "触发器",
|
||||
action: "动作",
|
||||
condition: "条件",
|
||||
control: "控制",
|
||||
};
|
||||
|
||||
const categoryText = computed(() => CATEGORY_TEXT[nodeType.value.category] || "节点");
|
||||
|
||||
const CATEGORY_ICON: Record<string, Component> = {
|
||||
trigger: Odometer,
|
||||
action: Promotion,
|
||||
condition: QuestionFilled,
|
||||
control: SetUp,
|
||||
};
|
||||
|
||||
const NODE_ICON: Record<string, Component> = {
|
||||
storage_upload: UploadFilled,
|
||||
storage_download: Download,
|
||||
storage_url: Link,
|
||||
storage_exists: CircleCheckFilled,
|
||||
storage_delete: Delete,
|
||||
notice_send: Bell,
|
||||
ai_chat: ChatDotRound,
|
||||
http_check: Connection,
|
||||
};
|
||||
|
||||
const nodeIcon = computed<Component>(() => {
|
||||
const code = nodeType.value.code;
|
||||
if (NODE_ICON[code]) return NODE_ICON[code];
|
||||
return CATEGORY_ICON[nodeType.value.category] || Promotion;
|
||||
onMounted(() => {
|
||||
checkLabelOverflow();
|
||||
if (labelRef.value) {
|
||||
labelObserver = new ResizeObserver(checkLabelOverflow);
|
||||
labelObserver.observe(labelRef.value);
|
||||
}
|
||||
});
|
||||
|
||||
const badgeCount = computed(() => {
|
||||
const config = nodeData.value?.config;
|
||||
return config && typeof config === "object" ? Object.keys(config).length : 0;
|
||||
onUnmounted(() => {
|
||||
labelObserver?.disconnect();
|
||||
labelObserver = null;
|
||||
});
|
||||
|
||||
const color = computed(() => nodeType.value.color);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vue-flow__node-input {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #fff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #67c23a 0%, #5daf34 100%) !important;
|
||||
border: 3px solid #5daf34 !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgb(103 194 58 / 40%),
|
||||
0 2px 4px rgb(0 0 0 / 10%) !important;
|
||||
}
|
||||
|
||||
.vue-flow__node-output {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
width: 80px !important;
|
||||
height: 80px !important;
|
||||
padding: 0 !important;
|
||||
color: #fff !important;
|
||||
cursor: pointer !important;
|
||||
background: linear-gradient(135deg, #f56c6c 0%, #e04e4e 100%) !important;
|
||||
border: 3px solid #e04e4e !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow:
|
||||
0 6px 12px rgb(245 108 108 / 40%),
|
||||
0 2px 4px rgb(0 0 0 / 10%) !important;
|
||||
}
|
||||
|
||||
.vue-flow__node-input .wf-node,
|
||||
.vue-flow__node-output .wf-node {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.wf-node {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
@@ -199,7 +152,7 @@ const color = computed(() => nodeType.value.color);
|
||||
min-width: 168px;
|
||||
padding: 10px 14px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
background: var(--el-bg-color);
|
||||
border: 1.5px solid color-mix(in srgb, var(--node-color) 45%, #d1d5db);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 1px 4px rgb(0 0 0 / 6%);
|
||||
@@ -231,6 +184,10 @@ const color = computed(() => nodeType.value.color);
|
||||
line-height: 1.3;
|
||||
|
||||
.node-label {
|
||||
display: inline-block;
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
@@ -238,19 +195,62 @@ const color = computed(() => nodeType.value.color);
|
||||
}
|
||||
|
||||
.node-category {
|
||||
max-width: 130px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-path {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--el-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.node-endpoint {
|
||||
display: inline-block;
|
||||
max-width: 140px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 10px;
|
||||
line-height: 1.3;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.node-badge {
|
||||
margin-left: auto;
|
||||
padding: 0 6px;
|
||||
.node-connections {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.node-files {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
margin-left: auto;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: color-mix(in srgb, var(--node-color) 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-selected .node-body {
|
||||
|
||||
@@ -3,13 +3,20 @@
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 font-semibold border-b border-(--el-border-color-lighter)"
|
||||
>
|
||||
<span>连线配置</span>
|
||||
<span>传输配置</span>
|
||||
<ElButton type="text" class="p-1" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="flex-1" view-class="p-4">
|
||||
<div
|
||||
class="mb-3 flex items-center gap-2 rounded-lg border border-(--el-border-color-lighter) bg-(--el-fill-color-light) px-3 py-2 text-xs text-(--el-text-color-regular)"
|
||||
>
|
||||
<ElIcon :size="14" :color="edgeColor"><Share /></ElIcon>
|
||||
<span class="min-w-0 truncate">{{ sourceLabel }} → {{ targetLabel }}</span>
|
||||
</div>
|
||||
|
||||
<FaForm
|
||||
v-model="formData"
|
||||
:items="edgeFormItems"
|
||||
@@ -27,6 +34,13 @@
|
||||
</template>
|
||||
</FaForm>
|
||||
|
||||
<div
|
||||
class="mt-3 rounded-lg border border-dashed border-(--el-border-color) bg-(--el-fill-color-lighter) px-3 py-2 text-xs text-(--el-text-color-secondary)"
|
||||
>
|
||||
连线只定义传输方式;禁用后执行流程将跳过该连线。源文件 /
|
||||
目录在执行流程时选择(节点可配置默认源目录),目标目录由目标节点的默认源目录决定。
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex gap-2 pt-4 border-t border-(--el-border-color-lighter) [&_.el-button]:flex-1"
|
||||
>
|
||||
@@ -40,7 +54,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
import { Close } from "@element-plus/icons-vue";
|
||||
import { Close, Share } from "@element-plus/icons-vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
|
||||
interface Props {
|
||||
@@ -54,37 +68,60 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits(["close", "save", "delete"]);
|
||||
|
||||
const formData = ref({
|
||||
label: props.edge?.label || "",
|
||||
type: props.edge?.type || "smoothstep",
|
||||
color: props.edge?.style?.stroke || "#000000",
|
||||
strokeWidth: props.edge?.style?.strokeWidth || 2,
|
||||
animated: props.edge?.animated || false,
|
||||
condition: props.edge?.data?.condition || "",
|
||||
description: props.edge?.data?.description || "",
|
||||
transfer_mode: (props.edge?.data?.transfer_mode as string) || "stream",
|
||||
multipart_part_size: (props.edge?.data?.multipart_part_size as number) ?? 50,
|
||||
multipart_concurrency: (props.edge?.data?.multipart_concurrency as number) ?? 6,
|
||||
enabled: props.edge?.data?.enabled !== false,
|
||||
color: props.edge?.style?.stroke || "#409eff",
|
||||
animated: props.edge?.animated !== false,
|
||||
});
|
||||
|
||||
const edgeColor = computed(() => formData.value.color);
|
||||
|
||||
const sourceLabel = computed(() => props.edge?.data?.source_label || "源节点");
|
||||
const targetLabel = computed(() => props.edge?.data?.target_label || "目标节点");
|
||||
|
||||
const edgeFormItems = computed(() => [
|
||||
{
|
||||
label: "连线名称",
|
||||
key: "label",
|
||||
type: "input",
|
||||
label: "启用连线",
|
||||
key: "enabled",
|
||||
type: "switch",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入连线名称" },
|
||||
props: {
|
||||
activeText: "启用",
|
||||
inactiveText: "禁用",
|
||||
activeValue: true,
|
||||
inactiveValue: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "连线类型",
|
||||
key: "type",
|
||||
type: "select",
|
||||
label: "传输方式",
|
||||
key: "transfer_mode",
|
||||
type: "radiogroup",
|
||||
span: 24,
|
||||
props: {
|
||||
placeholder: "请选择连线类型",
|
||||
options: [
|
||||
{ label: "折线", value: "smoothstep" },
|
||||
{ label: "曲线", value: "default" },
|
||||
{ label: "直线", value: "straight" },
|
||||
{ label: "流式传输", value: "stream" },
|
||||
{ label: "分片传输", value: "multipart" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "分片大小",
|
||||
key: "multipart_part_size",
|
||||
type: "number",
|
||||
span: 24,
|
||||
hidden: formData.value.transfer_mode !== "multipart",
|
||||
props: { min: 5, max: 5000, step: 1, placeholder: "MB,默认 50" },
|
||||
},
|
||||
{
|
||||
label: "并发线程",
|
||||
key: "multipart_concurrency",
|
||||
type: "number",
|
||||
span: 24,
|
||||
hidden: formData.value.transfer_mode !== "multipart",
|
||||
props: { min: 1, max: 64, step: 1, placeholder: "默认 6" },
|
||||
},
|
||||
{
|
||||
label: "连线颜色",
|
||||
key: "color",
|
||||
@@ -92,41 +129,12 @@ const edgeFormItems = computed(() => [
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "线条宽度",
|
||||
key: "strokeWidth",
|
||||
type: "number",
|
||||
span: 24,
|
||||
props: { min: 1, max: 10 },
|
||||
},
|
||||
{
|
||||
label: "启用动画",
|
||||
key: "animated",
|
||||
type: "switch",
|
||||
span: 24,
|
||||
},
|
||||
{
|
||||
label: "条件表达式",
|
||||
key: "condition",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
placeholder: "请输入条件表达式",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 2,
|
||||
placeholder: "请输入描述信息",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
@@ -134,13 +142,12 @@ watch(
|
||||
(newEdge) => {
|
||||
if (newEdge) {
|
||||
formData.value = {
|
||||
label: newEdge.label || "",
|
||||
type: newEdge.type || "smoothstep",
|
||||
color: newEdge.style?.stroke || "#000000",
|
||||
strokeWidth: newEdge.style?.strokeWidth || 2,
|
||||
animated: newEdge.animated || false,
|
||||
condition: newEdge.data?.condition || "",
|
||||
description: newEdge.data?.description || "",
|
||||
transfer_mode: (newEdge.data?.transfer_mode as string) || "stream",
|
||||
multipart_part_size: (newEdge.data?.multipart_part_size as number) ?? 50,
|
||||
multipart_concurrency: (newEdge.data?.multipart_concurrency as number) ?? 6,
|
||||
enabled: newEdge.data?.enabled !== false,
|
||||
color: newEdge.style?.stroke || "#409eff",
|
||||
animated: newEdge.animated !== false,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -152,7 +159,22 @@ function handleClose() {
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit("save", formData.value);
|
||||
emit("save", {
|
||||
animated: formData.value.animated,
|
||||
style: { stroke: formData.value.color },
|
||||
data: {
|
||||
enabled: formData.value.enabled,
|
||||
transfer_mode: formData.value.transfer_mode,
|
||||
multipart_part_size:
|
||||
formData.value.transfer_mode === "multipart"
|
||||
? formData.value.multipart_part_size
|
||||
: undefined,
|
||||
multipart_concurrency:
|
||||
formData.value.transfer_mode === "multipart"
|
||||
? formData.value.multipart_concurrency
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<FaDialog
|
||||
:model-value="visible"
|
||||
:title="title || `文件浏览 · ${sourceName || '存储源'}`"
|
||||
width="720px"
|
||||
top="8vh"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<ElBreadcrumb separator="/" class="flex-1 min-w-0">
|
||||
<ElBreadcrumbItem>
|
||||
<a
|
||||
class="cursor-pointer text-(--el-color-primary)"
|
||||
@click="navTo('')"
|
||||
title="回到存储根目录(登录目录/桶根)"
|
||||
>根目录</a
|
||||
>
|
||||
</ElBreadcrumbItem>
|
||||
<ElBreadcrumbItem v-if="props.bucket">
|
||||
<a
|
||||
class="cursor-pointer text-(--el-text-color-secondary) hover:text-(--el-color-primary)"
|
||||
title="点击返回桶根"
|
||||
@click="dir !== '' && navTo('')"
|
||||
>{{ props.bucket }}</a
|
||||
>
|
||||
</ElBreadcrumbItem>
|
||||
<ElBreadcrumbItem v-for="(seg, i) in segments" :key="i">
|
||||
<a
|
||||
class="cursor-pointer text-(--el-color-primary)"
|
||||
@click="navTo(segments.slice(0, i + 1).join('/'))"
|
||||
>
|
||||
{{ seg }}
|
||||
</a>
|
||||
</ElBreadcrumbItem>
|
||||
</ElBreadcrumb>
|
||||
<ElButton :icon="Refresh" size="small" circle @click="load" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="file-browser-body border border-(--el-border-color-lighter) rounded-lg overflow-hidden"
|
||||
style="height: 380px"
|
||||
>
|
||||
<ElEmpty
|
||||
v-if="!loading && entries.length === 0"
|
||||
description="该目录下没有文件"
|
||||
:image-size="60"
|
||||
/>
|
||||
<div v-else class="flex flex-col h-full">
|
||||
<div
|
||||
v-for="(item, idx) in entries"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 px-3 py-2 text-[13px] border-b border-(--el-border-color-lighter) last:border-0 cursor-pointer hover:bg-(--el-fill-color-light) transition-colors"
|
||||
:class="{ 'font-semibold': item.is_dir }"
|
||||
@dblclick="item.is_dir && navTo(joinPath(dir, item.name))"
|
||||
>
|
||||
<ElIcon :size="16" :color="item.is_dir ? '#e6a23c' : '#909399'">
|
||||
<Folder v-if="item.is_dir" />
|
||||
<Document v-else />
|
||||
</ElIcon>
|
||||
<span class="flex-1 min-w-0 truncate" :title="item.name">{{ item.name }}</span>
|
||||
<span v-if="!item.is_dir && item.size" class="text-xs text-(--el-text-color-secondary)">{{
|
||||
formatSize(item.size)
|
||||
}}</span>
|
||||
<span v-if="item.modified_time" class="text-xs text-(--el-text-color-secondary)">{{
|
||||
formatTime(item.modified_time)
|
||||
}}</span>
|
||||
<span v-if="item.is_dir" class="text-xs text-(--el-text-color-secondary)">目录</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="text-xs text-(--el-text-color-secondary) mr-auto">
|
||||
共 {{ entries.length }} 项 · 双击目录进入
|
||||
</div>
|
||||
<ElButton v-if="selectable" type="primary" @click="handleSelect">选择当前目录</ElButton>
|
||||
<ElButton @click="handleClose">关闭</ElButton>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
|
||||
import { Document, Folder, Refresh } from "@element-plus/icons-vue";
|
||||
import StorageAPI, { type StorageObject } from "@/api/module_task/workflow/storage";
|
||||
|
||||
interface Props {
|
||||
visible?: boolean;
|
||||
sourceId?: number | null;
|
||||
sourceName?: string;
|
||||
/** 自定义标题(默认「文件浏览 · 存储源名」) */
|
||||
title?: string;
|
||||
/** 打开时默认定位的目录(节点配置的路径前缀),空为存储真根 */
|
||||
initPath?: string;
|
||||
/** 存储桶(对象存储):面包屑中作为存储根标识展示,不参与路径导航 */
|
||||
bucket?: string;
|
||||
/** 工作根:文件系统协议为 path_prefix,对象存储为空(桶根);点击"根目录"回到该位置 */
|
||||
rootPath?: string;
|
||||
/** 选择模式:显示"选择当前目录"按钮,点击后 emit select(当前目录) 并关闭 */
|
||||
selectable?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
visible: false,
|
||||
sourceId: null,
|
||||
sourceName: "",
|
||||
title: "",
|
||||
initPath: "",
|
||||
bucket: "",
|
||||
rootPath: "",
|
||||
selectable: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:visible", "select"]);
|
||||
|
||||
const entries = ref<StorageObject[]>([]);
|
||||
const dir = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
const segments = computed(() => dir.value.split("/").filter(Boolean));
|
||||
|
||||
function joinPath(base: string, name?: string) {
|
||||
if (!name) return base;
|
||||
return base ? `${base}/${name}` : name;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!props.sourceId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await StorageAPI.listFiles({
|
||||
source_id: props.sourceId,
|
||||
prefix: dir.value || undefined,
|
||||
});
|
||||
const r = data.data;
|
||||
entries.value = Array.isArray(r) ? r : r?.items || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navTo(path: string) {
|
||||
dir.value = path;
|
||||
load();
|
||||
}
|
||||
|
||||
function formatTime(mtime?: string) {
|
||||
if (!mtime) return "";
|
||||
const d = new Date(mtime);
|
||||
if (Number.isNaN(d.getTime())) return mtime;
|
||||
const pad = (x: number) => String(x).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function formatSize(size?: number) {
|
||||
if (size == null || size <= 0) return "";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let n = size;
|
||||
let i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n.toFixed(n >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
emit("select", dir.value);
|
||||
emit("update:visible", false);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
dir.value = props.initPath || "";
|
||||
load();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.sourceId,
|
||||
() => {
|
||||
if (props.visible) {
|
||||
dir.value = props.initPath || "";
|
||||
load();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
@@ -1,282 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 font-semibold border-b border-(--el-border-color-lighter)"
|
||||
>
|
||||
<span>节点配置</span>
|
||||
<ElButton type="text" class="p-1" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="flex-1" view-class="p-4">
|
||||
<div
|
||||
v-if="currentType"
|
||||
class="mb-3 flex items-start gap-2 rounded-lg border border-(--el-border-color-lighter) bg-(--el-fill-color-light) px-3 py-2.5"
|
||||
>
|
||||
<ElIcon :size="16" :color="currentType.color"><component :is="currentType.icon" /></ElIcon>
|
||||
<div class="flex flex-col min-w-0 leading-tight">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-semibold text-(--el-text-color-primary)">{{ currentType.name }}</span>
|
||||
<ElTag size="small" :type="currentType.tagType" effect="light">{{ currentType.categoryText }}</ElTag>
|
||||
</div>
|
||||
<span v-if="currentType.description" class="mt-0.5 text-[11px] text-(--el-text-color-secondary)">
|
||||
{{ currentType.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<FaForm
|
||||
v-model="formData"
|
||||
:items="nodePanelFormItems"
|
||||
label-width="80px"
|
||||
size="small"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="12"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="panel-art-form"
|
||||
>
|
||||
<template #type>
|
||||
<ElSelect v-model="formData.type" placeholder="请选择节点类型" @change="handleTypeChange">
|
||||
<ElOption v-for="t in nodeTypes" :key="t.id ?? t.code" :label="t.name" :value="t.code" />
|
||||
</ElSelect>
|
||||
</template>
|
||||
<template #args>
|
||||
<div>
|
||||
<ElInput
|
||||
v-model="formData.args"
|
||||
placeholder="多个参数用逗号分隔,如: arg1, arg2, arg3"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-(--el-text-color-secondary)">多个参数用逗号分隔</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #kwargsStr>
|
||||
<div>
|
||||
<ElInput
|
||||
v-model="formData.kwargsStr"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder='JSON格式,如: {"key": "value", "count": 10}'
|
||||
/>
|
||||
<div class="mt-1 text-xs text-(--el-text-color-secondary)">JSON 格式的关键字参数</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaForm>
|
||||
|
||||
<div
|
||||
class="flex gap-2 pt-4 border-t border-(--el-border-color-lighter) [&_.el-button]:flex-1"
|
||||
>
|
||||
<ElButton type="primary" size="small" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" @click="handleDelete">删除节点</ElButton>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed, type Component } from "vue";
|
||||
import {
|
||||
ElButton,
|
||||
ElInput,
|
||||
ElSelect,
|
||||
ElOption,
|
||||
ElMessage,
|
||||
ElIcon,
|
||||
ElScrollbar,
|
||||
ElTag,
|
||||
} from "element-plus";
|
||||
import { Close, UploadFilled, Download, Link, CircleCheckFilled, Delete, Promotion, Bell, ChatDotRound, Connection, Odometer, QuestionFilled, SetUp } from "@element-plus/icons-vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import WorkflowNodeTypeAPI, { type WorkflowNodeTypeOption } from "@/api/module_task/workflow/nodes";
|
||||
|
||||
interface Props {
|
||||
node?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
node: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "save", "delete"]);
|
||||
|
||||
const nodeTypes = ref<WorkflowNodeTypeOption[]>([]);
|
||||
|
||||
const CATEGORY_META: Record<string, { label: string; color: string; tagType: any; icon: Component }> = {
|
||||
trigger: { label: "触发器", color: "#e6a23c", tagType: "warning", icon: Odometer },
|
||||
action: { label: "动作", color: "#409eff", tagType: "primary", icon: Promotion },
|
||||
condition: { label: "条件", color: "#67c23a", tagType: "success", icon: QuestionFilled },
|
||||
control: { label: "控制", color: "#909399", tagType: "info", icon: SetUp },
|
||||
};
|
||||
|
||||
const NODE_ICON: Record<string, Component> = {
|
||||
storage_upload: UploadFilled,
|
||||
storage_download: Download,
|
||||
storage_url: Link,
|
||||
storage_exists: CircleCheckFilled,
|
||||
storage_delete: Delete,
|
||||
notice_send: Bell,
|
||||
ai_chat: ChatDotRound,
|
||||
http_check: Connection,
|
||||
};
|
||||
|
||||
const currentType = computed(() => {
|
||||
const code = formData.value.type;
|
||||
if (!code) return null;
|
||||
const t = nodeTypes.value.find((item) => item.code === code);
|
||||
const category = t?.category || "action";
|
||||
const meta = CATEGORY_META[category] || CATEGORY_META.action!;
|
||||
return {
|
||||
name: t?.name || code,
|
||||
description: t?.description || "",
|
||||
color: meta.color,
|
||||
icon: NODE_ICON[code] || meta.icon,
|
||||
categoryText: meta.label,
|
||||
tagType: meta.tagType,
|
||||
};
|
||||
});
|
||||
|
||||
const formData = ref({
|
||||
type: props.node?.type || "",
|
||||
label: props.node?.data?.label || "",
|
||||
args: props.node?.data?.args || "",
|
||||
kwargsStr: props.node?.data?.kwargsStr || "{}",
|
||||
description: props.node?.data?.description || "",
|
||||
});
|
||||
|
||||
const nodePanelFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "节点类型",
|
||||
key: "type",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "节点名称",
|
||||
key: "label",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { placeholder: "请输入节点名称" },
|
||||
},
|
||||
{
|
||||
label: "位置参数",
|
||||
key: "args",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "关键字参数",
|
||||
key: "kwargsStr",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "描述",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 2,
|
||||
placeholder: "请输入描述信息",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const loadNodeTypes = async () => {
|
||||
try {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeOptions();
|
||||
if (res.data) {
|
||||
nodeTypes.value = res.data.data || [];
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("加载节点类型失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeChange = async (typeCode: string) => {
|
||||
const nodeType = nodeTypes.value.find((t) => t.code === typeCode);
|
||||
if (nodeType) {
|
||||
formData.value.args = nodeType.args || "";
|
||||
formData.value.kwargsStr = nodeType.kwargs || "{}";
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(newNode) => {
|
||||
if (newNode) {
|
||||
const kwargsData = newNode.data?.kwargs;
|
||||
let kwargsStr = "{}";
|
||||
if (kwargsData) {
|
||||
if (typeof kwargsData === "string") {
|
||||
kwargsStr = kwargsData;
|
||||
} else if (typeof kwargsData === "object") {
|
||||
kwargsStr = JSON.stringify(kwargsData, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(formData.value, {
|
||||
type: newNode.type || "",
|
||||
label: newNode.data?.label || "",
|
||||
args: newNode.data?.args || "",
|
||||
kwargsStr,
|
||||
description: newNode.data?.description || "",
|
||||
});
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
|
||||
function handleClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
try {
|
||||
if (formData.value.kwargsStr && formData.value.kwargsStr.trim()) {
|
||||
JSON.parse(formData.value.kwargsStr);
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error("关键字参数 JSON 格式错误");
|
||||
return;
|
||||
}
|
||||
|
||||
emit("save", {
|
||||
type: formData.value.type,
|
||||
label: formData.value.label,
|
||||
args: formData.value.args,
|
||||
kwargs: formData.value.kwargsStr,
|
||||
description: formData.value.description,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit("delete");
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadNodeTypes();
|
||||
if (props.node?.type) {
|
||||
handleTypeChange(props.node.type);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.panel-art-form :deep(.el-row > .el-col:last-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel-art-form :deep(.el-form-item__content) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.panel-art-form :deep(section) {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="flex items-center justify-between px-4 py-3 font-semibold border-b border-(--el-border-color-lighter)"
|
||||
>
|
||||
<span>节点信息</span>
|
||||
<ElButton type="text" class="p-1" @click="handleClose">
|
||||
<ElIcon><Close /></ElIcon>
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="flex-1" view-class="p-4">
|
||||
<div
|
||||
class="mb-3 flex items-center gap-2.5 rounded-lg border border-(--el-border-color-lighter) bg-(--el-fill-color-light) px-3 py-2.5"
|
||||
>
|
||||
<ElIcon :size="20" :color="color"><Folder /></ElIcon>
|
||||
<div class="flex flex-col min-w-0 leading-tight">
|
||||
<span class="truncate text-xs font-semibold text-(--el-text-color-primary)">{{
|
||||
node?.data?.label || "存储节点"
|
||||
}}</span>
|
||||
<span class="mt-0.5 text-[11px] text-(--el-text-color-secondary)">{{
|
||||
protocolText
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElDescriptions :column="1" size="small" border label-width="88px" class="node-info-desc">
|
||||
<ElDescriptionsItem label="存储源ID">{{ node?.data?.source_id ?? "—" }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="协议">{{ protocolLabel }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="地址">
|
||||
<span class="break-all">{{ hostText }}</span>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-xs font-medium text-(--el-text-color-primary)">默认源目录</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ElInput
|
||||
v-model="sourcePath"
|
||||
size="small"
|
||||
clearable
|
||||
class="flex-1"
|
||||
:placeholder="pathHint"
|
||||
@keyup.enter="handleSave"
|
||||
/>
|
||||
<ElButton :icon="FolderOpened" size="small" title="选择目录" @click="openPicker" />
|
||||
</div>
|
||||
<p class="mt-1.5 text-[11px] leading-relaxed text-(--el-text-color-secondary)">
|
||||
执行流程时自动回显,可修改;留空则执行时手动选择源文件 / 目录。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs leading-relaxed text-(--el-text-color-secondary)">
|
||||
<p>· 拖拽连线可定义「从哪里传输到哪里」</p>
|
||||
<p>· 点击连线配置目标路径与传输方式</p>
|
||||
<p>· 双击节点或点击下方按钮可浏览该存储源文件</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-4 border-t border-(--el-border-color-lighter)">
|
||||
<ElButton type="primary" plain size="small" class="flex-1" @click="handleViewFiles">
|
||||
<ElIcon class="mr-1"><FolderOpened /></ElIcon>
|
||||
查看文件
|
||||
</ElButton>
|
||||
<ElButton type="primary" size="small" class="flex-1" @click="handleSave">保存</ElButton>
|
||||
<ElButton type="danger" size="small" class="flex-1" @click="handleDelete"
|
||||
>删除节点</ElButton
|
||||
>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<FaFileBrowserDialog
|
||||
v-model:visible="browserVisible"
|
||||
:source-id="browserSourceId"
|
||||
:source-name="browserSourceName"
|
||||
:init-path="browserInitPath"
|
||||
:bucket="browserBucket"
|
||||
:root-path="browserRootPath"
|
||||
selectable
|
||||
@select="onSourceSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Folder, FolderOpened } from "@element-plus/icons-vue";
|
||||
import NodeAPI from "@/api/module_task/workflow/node";
|
||||
import {
|
||||
protocolColor,
|
||||
protocolLabel as getProtocolLabel,
|
||||
protocolText as getProtocolText,
|
||||
} from "./protocol";
|
||||
import FaFileBrowserDialog from "./FaFileBrowserDialog.vue";
|
||||
|
||||
interface Props {
|
||||
node?: Record<string, any>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
node: () => ({}),
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "delete", "view-files", "save"]);
|
||||
|
||||
const color = computed(() => protocolColor(props.node?.data?.protocol as string | undefined));
|
||||
|
||||
const protocol = computed(() => (props.node?.data?.protocol || "").toLowerCase());
|
||||
|
||||
const objectStorage = ["s3", "obs", "oss", "cos"];
|
||||
|
||||
// 地址:对象存储取接入点 endpoint,文件系统协议取主机地址 host
|
||||
const address = computed(() => {
|
||||
const v = objectStorage.includes(protocol.value)
|
||||
? props.node?.data?.endpoint
|
||||
: props.node?.data?.host;
|
||||
return (v as string | undefined) || "";
|
||||
});
|
||||
|
||||
const protocolLabel = computed(() => getProtocolLabel(protocol.value));
|
||||
|
||||
const protocolText = computed(() =>
|
||||
getProtocolText(props.node?.data?.protocol as string | undefined, address.value || undefined)
|
||||
);
|
||||
|
||||
const hostText = computed(() => address.value || "—");
|
||||
|
||||
const sourcePath = ref((props.node?.data?.source_path as string) || "");
|
||||
|
||||
watch(
|
||||
() => props.node,
|
||||
(n) => {
|
||||
sourcePath.value = (n?.data?.source_path as string) || "";
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
const pathHint = computed(() => {
|
||||
if (objectStorage.includes(protocol.value)) return "Key 前缀,如 dir/sub(目录以 / 结尾)";
|
||||
if (protocol.value === "local") return "本地目录,如 /data/source";
|
||||
return "目录,如 /source";
|
||||
});
|
||||
|
||||
// ── 默认源目录选择 ────────────────────────────────────────────────
|
||||
const browserVisible = ref(false);
|
||||
const browserSourceId = ref<number | null>(null);
|
||||
const browserSourceName = ref("");
|
||||
const browserInitPath = ref("");
|
||||
const browserBucket = ref("");
|
||||
const browserRootPath = ref("");
|
||||
|
||||
async function openPicker() {
|
||||
const sid = props.node?.data?.source_id as number | undefined;
|
||||
if (sid == null) {
|
||||
ElMessage.warning("节点未关联存储源,无法选择目录");
|
||||
return;
|
||||
}
|
||||
const current = (sourcePath.value || "").trim().replace(/^\/+|\/+$/g, "");
|
||||
const { data } = await NodeAPI.detailNode(sid);
|
||||
const src = data.data;
|
||||
const nodeRoot = (src?.path_prefix || "").replace(/^\/+|\/+$/g, "");
|
||||
const bucket = src?.bucket || "";
|
||||
const rootPath = bucket ? "" : nodeRoot;
|
||||
const init = current || nodeRoot;
|
||||
browserSourceId.value = sid;
|
||||
browserSourceName.value = props.node?.data?.label || "";
|
||||
browserInitPath.value = init;
|
||||
browserBucket.value = bucket;
|
||||
browserRootPath.value = rootPath;
|
||||
browserVisible.value = true;
|
||||
}
|
||||
|
||||
function onSourceSelected(path: string) {
|
||||
const p = path || "";
|
||||
sourcePath.value = p ? (p.endsWith("/") ? p : `${p}/`) : "";
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
emit("save", { source_path: sourcePath.value.trim() });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
emit("delete");
|
||||
}
|
||||
|
||||
function handleViewFiles() {
|
||||
emit("view-files");
|
||||
}
|
||||
</script>
|
||||
+504
-606
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 存储协议展示工具:颜色 / 标签
|
||||
* 供画布节点、节点配置面板、节点面板统一使用,避免三处重复定义。
|
||||
*/
|
||||
export const PROTOCOL_COLOR: Record<string, string> = {
|
||||
local: "#67c23a",
|
||||
ftp: "#409eff",
|
||||
ftps: "#409eff",
|
||||
sftp: "#409eff",
|
||||
s3: "#e6a23c",
|
||||
oss: "#e6a23c",
|
||||
obs: "#e6a23c",
|
||||
cos: "#e6a23c",
|
||||
};
|
||||
|
||||
export const PROTOCOL_LABEL: Record<string, string> = {
|
||||
local: "本地",
|
||||
ftp: "FTP",
|
||||
ftps: "FTPS",
|
||||
sftp: "SFTP",
|
||||
s3: "S3",
|
||||
oss: "OSS",
|
||||
obs: "OBS",
|
||||
cos: "COS",
|
||||
};
|
||||
|
||||
/** 协议主题色(未知协议回退灰色) */
|
||||
export function protocolColor(protocol?: string): string {
|
||||
return PROTOCOL_COLOR[(protocol || "").toLowerCase()] || "#909399";
|
||||
}
|
||||
|
||||
/** 协议中文标签(未知协议原样返回大写) */
|
||||
export function protocolLabel(protocol?: string): string {
|
||||
const p = (protocol || "").toLowerCase();
|
||||
return PROTOCOL_LABEL[p] || p || "存储";
|
||||
}
|
||||
|
||||
/** 协议标签 + 主机地址 */
|
||||
export function protocolText(protocol?: string, host?: string): string {
|
||||
const label = protocolLabel(protocol);
|
||||
return host ? `${label} · ${host}` : label;
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
<!-- 工作流定义:Art + useTable -->
|
||||
<!-- 传输流程管理:VueFlow 画布定义源节点 → 目标节点,执行生成传输任务 -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="workflowSearchItems"
|
||||
:items="flowSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
include-audit
|
||||
@search="handleSearchBarSearch"
|
||||
@@ -40,7 +39,6 @@
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
@@ -48,38 +46,230 @@
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
>
|
||||
</FaTable>
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<FaDialog
|
||||
v-model="detailVisible"
|
||||
title="流程详情"
|
||||
width="680px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
@cancel="detailVisible = false"
|
||||
>
|
||||
<FaDescriptions :column="2" :data="detailFormData" :items="flowDetailItems" max-height="70vh">
|
||||
<template #sources="{ row }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<ElTooltip
|
||||
v-for="(s, idx) in (row as unknown as FlowTable)?.sources || []"
|
||||
:key="idx"
|
||||
:content="nodeTip(s.source_id)"
|
||||
placement="top"
|
||||
:show-after="200"
|
||||
>
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:style="protocolTagStyle(sourceMap[s.source_id])"
|
||||
class="w-fit"
|
||||
>{{ sourceName(s.source_id) }}</ElTag
|
||||
>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
<template #task_type="{ row }">
|
||||
<span>{{ taskTypeLabel((row as unknown as FlowTable)?.task_type) }}</span>
|
||||
</template>
|
||||
<template #targets="{ row }">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="(t, idx) in (row as unknown as FlowTable)?.targets || []"
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 text-[13px]"
|
||||
>
|
||||
<ElTooltip :content="nodeTip(t.target_id)" placement="top" :show-after="200">
|
||||
<ElTag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:style="protocolTagStyle(sourceMap[t.target_id])"
|
||||
>{{ sourceName(t.target_id) }}</ElTag
|
||||
>
|
||||
</ElTooltip>
|
||||
<span class="text-xs text-[#909399]">{{ t.target_path }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #graph="{ row }">
|
||||
<span>{{ graphText(row as unknown as FlowTable) }}</span>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
<template #footer>
|
||||
<div class="fa-dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<ElButton type="primary" @click="detailVisible = false">关闭</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 画布设计器(新增 / 编辑) -->
|
||||
<FaWorkflowDesignDrawer
|
||||
v-model:visible="createVisible"
|
||||
:workflow="selectedWorkflow"
|
||||
@refresh="onDrawerRefresh"
|
||||
v-model:visible="drawerVisible"
|
||||
:flow="editingFlow"
|
||||
@refresh="refreshData"
|
||||
/>
|
||||
|
||||
<!-- 执行流程:为每个启用连线的源节点选择源文件/目录 -->
|
||||
<FaDialog
|
||||
v-model="executeVisible"
|
||||
:title="`执行流程 · ${executeRow?.name || ''}`"
|
||||
width="620px"
|
||||
@cancel="executeVisible = false"
|
||||
>
|
||||
<div class="mb-3 text-[13px] text-(--el-text-color-regular)">
|
||||
为以下源节点选择源文件 / 目录(回显节点默认源目录,可修改或浏览选择):
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="(s, idx) in executeSources"
|
||||
:key="s.source_id"
|
||||
class="flex items-center gap-2 rounded-lg border border-(--el-border-color-lighter) px-3 py-2"
|
||||
>
|
||||
<span class="w-24 shrink-0 truncate text-[13px] font-medium" :title="s.source_name">{{
|
||||
s.source_name
|
||||
}}</span>
|
||||
<ElInput
|
||||
v-model="s.path"
|
||||
size="small"
|
||||
placeholder="源文件 / 目录,留空使用节点默认源目录"
|
||||
/>
|
||||
<ElButton size="small" @click="openExecuteBrowser(idx)">浏览</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="fa-dialog-footer" style="padding-right: var(--el-dialog-padding-primary)">
|
||||
<ElButton type="primary" :loading="executing" @click="handleExecuteConfirm"
|
||||
>确认执行</ElButton
|
||||
>
|
||||
<ElButton @click="executeVisible = false">取消</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 执行时浏览某个源节点的目录 -->
|
||||
<FaFileBrowserDialog
|
||||
v-model:visible="executeBrowseVisible"
|
||||
:title="`选择源文件 / 目录 · ${executeSourceName}`"
|
||||
:source-id="executeSourceId"
|
||||
:source-name="executeSourceName"
|
||||
:init-path="executeInitPath"
|
||||
:bucket="executeBucket"
|
||||
:root-path="executeRootPath"
|
||||
selectable
|
||||
@select="handleExecuteSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
<script setup lang="ts">
|
||||
import { ElButton, ElTag, ElTooltip } from "element-plus";
|
||||
import { useRouter } from "vue-router";
|
||||
import { resolveStatusColumns, renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import FlowAPI, { type FlowTable } from "@/api/module_task/workflow/flow";
|
||||
import NodeAPI, { type SourceTable } from "@/api/module_task/workflow/node";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
import FaWorkflowDesignDrawer from "./components/FaWorkflowDesignDrawer.vue";
|
||||
import FaFileBrowserDialog from "./components/FaFileBrowserDialog.vue";
|
||||
import { protocolColor, protocolLabel } from "./components/protocol";
|
||||
|
||||
defineOptions({
|
||||
name: "Workflow",
|
||||
name: "WorkflowFlow",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import WorkflowDefinitionAPI, { type WorkflowTable } from "@/api/module_task/workflow/flow";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import FaWorkflowDesignDrawer from "./components/FaWorkflowDesignDrawer.vue";
|
||||
import type { TableOperationAction } from "@/utils/table";
|
||||
import { renderTableOperationCell } from "@utils";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { computed, ref } from "vue";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
const TASK_TYPE_OPTIONS = [
|
||||
{ label: "多目标(并行)", value: "parallel" },
|
||||
{ label: "链式(逐跳)", value: "chain" },
|
||||
] as const;
|
||||
|
||||
type WorkflowSearchForm = {
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
] as const;
|
||||
|
||||
function taskTypeLabel(value?: string): string {
|
||||
const opt = TASK_TYPE_OPTIONS.find((o) => o.value === value);
|
||||
return opt ? opt.label : (value ?? "-");
|
||||
}
|
||||
|
||||
/** 画布统计文案(列表用 graph_stats,详情用 graph 兜底) */
|
||||
function graphText(row?: FlowTable): string {
|
||||
const stats = row?.graph_stats;
|
||||
const graph = row?.graph;
|
||||
const nodeCount = stats?.node_count ?? graph?.nodes?.length ?? 0;
|
||||
const edgeCount = stats?.edge_count ?? graph?.edges?.length ?? 0;
|
||||
return nodeCount || edgeCount ? `${nodeCount} 节点 / ${edgeCount} 连线` : "—";
|
||||
}
|
||||
|
||||
// ── 节点选项 ─────────────────────────────────────────────────────────
|
||||
const router = useRouter();
|
||||
|
||||
const nodes = ref<SourceTable[]>([]);
|
||||
const sourceMap = computed<Record<number, SourceTable>>(() => {
|
||||
const map: Record<number, SourceTable> = {};
|
||||
nodes.value.forEach((n) => {
|
||||
if (n.id) map[n.id] = n;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
const sourceName = (id?: number | null) => (id ? sourceMap.value[id]?.name || `#${id}` : "—");
|
||||
|
||||
/** 节点地址摘要:对象存储取端点/桶,FTP/SFTP 取 host:port,本地取路径前缀 */
|
||||
function sourceAddress(node?: SourceTable): string {
|
||||
if (!node) return "";
|
||||
const p = (node.protocol || "").toLowerCase();
|
||||
if (["s3", "obs", "oss", "cos"].includes(p)) return node.endpoint || node.bucket || "";
|
||||
if (p === "local") return node.path_prefix || "本地目录";
|
||||
return node.port ? `${node.host}:${node.port}` : (node.host ?? "");
|
||||
}
|
||||
|
||||
/** 协议色样式(与画布节点/连线标签配色一致) */
|
||||
function protocolTagStyle(node?: SourceTable) {
|
||||
return {
|
||||
color: protocolColor(node?.protocol),
|
||||
borderColor: protocolColor(node?.protocol),
|
||||
};
|
||||
}
|
||||
|
||||
/** 协议色 Tag(与画布节点/连线标签配色一致) */
|
||||
function protocolTag(node?: SourceTable) {
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
size: "small",
|
||||
effect: "plain",
|
||||
style: protocolTagStyle(node),
|
||||
},
|
||||
() => node?.name ?? "#—"
|
||||
);
|
||||
}
|
||||
|
||||
/** 节点协议·地址提示(tooltip 内容) */
|
||||
function nodeTip(id?: number | null): string {
|
||||
const node = id ? sourceMap.value[id] : undefined;
|
||||
return node ? `${protocolLabel(node.protocol)} · ${sourceAddress(node)}` : id ? `#${id}` : "—";
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
const { data } = await NodeAPI.listNode({});
|
||||
nodes.value = data.data;
|
||||
}
|
||||
|
||||
// ── 搜索 ─────────────────────────────────────────────────────────────
|
||||
type FlowSearchForm = {
|
||||
name?: string;
|
||||
code?: string;
|
||||
task_type?: string;
|
||||
status?: number;
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
@@ -87,23 +277,9 @@ type WorkflowSearchForm = {
|
||||
updated_time?: string[];
|
||||
};
|
||||
|
||||
function buildWorkflowReplaceParams(u: WorkflowSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: u.name,
|
||||
code: u.code,
|
||||
status: u.status,
|
||||
created_id: u.created_id,
|
||||
updated_id: u.updated_id,
|
||||
created_time:
|
||||
Array.isArray(u.created_time) && u.created_time.length === 2 ? u.created_time : undefined,
|
||||
updated_time:
|
||||
Array.isArray(u.updated_time) && u.updated_time.length === 2 ? u.updated_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const searchForm = ref<WorkflowSearchForm>({
|
||||
const searchForm = ref<FlowSearchForm>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
task_type: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
@@ -115,7 +291,7 @@ const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const workflowSearchItems = computed<SearchFormItem[]>(() => [
|
||||
const flowSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "流程名称",
|
||||
key: "name",
|
||||
@@ -125,11 +301,14 @@ const workflowSearchItems = computed<SearchFormItem[]>(() => [
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "流程编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
placeholder: "请输入流程编码",
|
||||
clearable: true,
|
||||
label: "类型",
|
||||
key: "task_type",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择类型",
|
||||
options: TASK_TYPE_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
@@ -138,103 +317,272 @@ const workflowSearchItems = computed<SearchFormItem[]>(() => [
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: STATUS_OPTIONS,
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "草稿", value: 0 },
|
||||
{ label: "已发布", value: 1 },
|
||||
{ label: "已归档", value: 2 },
|
||||
],
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
function buildFlowReplaceParams(p: FlowSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: p.name,
|
||||
task_type: p.task_type,
|
||||
status: p.status,
|
||||
created_id: p.created_id,
|
||||
updated_id: p.updated_id,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
updated_time:
|
||||
Array.isArray(p.updated_time) && p.updated_time.length === 2 ? p.updated_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 表格 ─────────────────────────────────────────────────────────────
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
const selectedRows = ref<WorkflowTable[]>([]);
|
||||
const selectedIds = computed(() =>
|
||||
selectedRows.value.map((r) => r.id).filter((id): id is number => typeof id === "number")
|
||||
);
|
||||
const batchDeleting = ref(false);
|
||||
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
|
||||
useTableSelection<FlowTable>();
|
||||
const createLoading = ref(false);
|
||||
|
||||
function onTableSelectionChange(rows: WorkflowTable[]) {
|
||||
selectedRows.value = rows;
|
||||
}
|
||||
|
||||
async function deleteWorkflowRow(id: number | undefined, name: string | number) {
|
||||
if (id == null) return;
|
||||
try {
|
||||
await confirmDelete(`确定删除工作流「${name}」吗?`);
|
||||
await WorkflowDefinitionAPI.deleteWorkflow([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
function buildFlowRowActions(
|
||||
row: FlowTable,
|
||||
ctx: {
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onExecute: (row: FlowTable) => void;
|
||||
onDelete: (id: number, name: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(
|
||||
ids.length,
|
||||
selectedRows.value.map((r) => String(r?.name ?? r?.id ?? ""))
|
||||
);
|
||||
batchDeleting.value = true;
|
||||
await WorkflowDefinitionAPI.deleteWorkflow(ids);
|
||||
selectedRows.value = [];
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildWorkflowRowActions(row: WorkflowTable): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [];
|
||||
if (row.status === 0) {
|
||||
all.push({
|
||||
key: "publish",
|
||||
label: "发布",
|
||||
artType: "add",
|
||||
): TableOperationAction[] {
|
||||
return [
|
||||
{
|
||||
key: "detail",
|
||||
label: "查看",
|
||||
artType: "view",
|
||||
perm: "module_task:workflow:flow:query",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "设计",
|
||||
artType: "edit",
|
||||
icon: "ri:pen-nib-line",
|
||||
perm: "module_task:workflow:flow:update",
|
||||
run: () => handlePublish(row),
|
||||
});
|
||||
}
|
||||
if (row.status === 1) {
|
||||
all.push({
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
{
|
||||
key: "execute",
|
||||
label: "执行",
|
||||
artType: "view",
|
||||
perm: "module_task:workflow:flow:execute",
|
||||
run: () => handleExecute("execute", row),
|
||||
});
|
||||
}
|
||||
all.push(
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
perm: "module_task:workflow:flow:update",
|
||||
run: () => handleEdit(row),
|
||||
icon: "ri:play-line",
|
||||
iconColor: "var(--el-color-success)",
|
||||
perm: "module_task:workflow:transfer:create",
|
||||
run: () => ctx.onExecute(row),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_task:workflow:flow:delete",
|
||||
run: () => deleteWorkflowRow(row.id, String(row?.name ?? row?.id ?? "")),
|
||||
}
|
||||
);
|
||||
return all;
|
||||
run: () => ctx.onDelete(row.id!, row.name ?? ""),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatWorkflowOperationCell(row: WorkflowTable) {
|
||||
return renderTableOperationCell(buildWorkflowRowActions(row));
|
||||
function formatFlowOperationCell(row: FlowTable, ctx: Parameters<typeof buildFlowRowActions>[1]) {
|
||||
return renderTableOperationCell(buildFlowRowActions(row, ctx), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 flow-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFlowRow(id: number, name: string) {
|
||||
try {
|
||||
await confirmDelete(`确定删除流程「${name}」吗?`);
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
await FlowAPI.deleteFlow([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
}
|
||||
|
||||
/** 执行流程:列出所有启用连线的源节点,各自选择源文件/目录后按画布连线生成传输任务 */
|
||||
async function executeFlowRow(row: FlowTable) {
|
||||
const { data: flowDetail } = await FlowAPI.detailFlow(row.id!);
|
||||
const graph = flowDetail.data?.graph || {};
|
||||
const graphNodes = (graph.nodes || []) as Record<string, any>[];
|
||||
const graphEdges = (graph.edges || []) as Record<string, any>[];
|
||||
// 只取启用连线的源节点(去重),禁用的连线不参与执行
|
||||
const sourceIds: number[] = [];
|
||||
graphEdges.forEach((e) => {
|
||||
const ed = e.data || {};
|
||||
if (ed.enabled === false) return;
|
||||
const sid = graphNodes.find((n) => n.id === e.source)?.data?.source_id as number | undefined;
|
||||
if (sid != null && !sourceIds.includes(sid)) sourceIds.push(sid);
|
||||
});
|
||||
if (sourceIds.length === 0) {
|
||||
ElMessage.warning(`流程「${row.name}」没有启用的传输连线,无法执行`);
|
||||
return;
|
||||
}
|
||||
executeSources.value = sourceIds.map((sid) => {
|
||||
const srcNode = graphNodes.find((n) => (n.data?.source_id as number) === sid);
|
||||
const d = srcNode?.data || {};
|
||||
const bucket = (d.bucket as string) || "";
|
||||
const pathPrefix = ((d.path_prefix as string) || "").replace(/^\/+|\/+$/g, "");
|
||||
const nodeDefault = ((d.source_path as string) || "").replace(/^\/+|\/+$/g, "");
|
||||
return {
|
||||
source_id: sid,
|
||||
source_name: (d.label as string) || `#${sid}`,
|
||||
// 回显节点默认源目录,留空执行时后端回退使用节点默认源目录
|
||||
path: nodeDefault || pathPrefix,
|
||||
bucket,
|
||||
rootPath: bucket ? "" : pathPrefix,
|
||||
};
|
||||
});
|
||||
executeRow.value = row;
|
||||
executeVisible.value = true;
|
||||
}
|
||||
|
||||
/** 浏览某个源节点的目录,选择后回填该源的源文件/目录 */
|
||||
function openExecuteBrowser(index: number) {
|
||||
const src = executeSources.value[index];
|
||||
if (!src) return;
|
||||
executeBrowseIndex.value = index;
|
||||
executeSourceId.value = src.source_id;
|
||||
executeSourceName.value = src.source_name;
|
||||
executeInitPath.value = src.path;
|
||||
executeBucket.value = src.bucket;
|
||||
executeRootPath.value = src.rootPath;
|
||||
executeBrowseVisible.value = true;
|
||||
}
|
||||
|
||||
/** 用户选定源文件/目录后回填对应源节点 */
|
||||
function handleExecuteSelect(path: string) {
|
||||
const idx = executeBrowseIndex.value;
|
||||
if (idx >= 0 && executeSources.value[idx]) {
|
||||
executeSources.value[idx].path = path;
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认执行:组装 {源ID: 路径} 映射提交,禁用的连线由后端跳过 */
|
||||
async function handleExecuteConfirm() {
|
||||
const row = executeRow.value;
|
||||
if (!row?.id) return;
|
||||
executing.value = true;
|
||||
try {
|
||||
const source_paths: Record<string, string> = {};
|
||||
executeSources.value.forEach((s) => {
|
||||
const p = s.path.trim();
|
||||
if (p) source_paths[String(s.source_id)] = p;
|
||||
});
|
||||
const { data } = await FlowAPI.executeFlow(row.id, { source_paths });
|
||||
const count = (data.data || []).length;
|
||||
ElNotification({
|
||||
title: "执行成功",
|
||||
type: "success",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
message: h("div", { class: "flex items-center gap-3" }, [
|
||||
h("span", `已生成 ${count} 个传输任务`),
|
||||
h(
|
||||
ElButton,
|
||||
{
|
||||
type: "primary",
|
||||
size: "small",
|
||||
text: true,
|
||||
onClick: () => void router.push("/task/workflow/transfer"),
|
||||
},
|
||||
() => "查看任务"
|
||||
),
|
||||
]),
|
||||
});
|
||||
executeVisible.value = false;
|
||||
await refreshUpdate();
|
||||
} finally {
|
||||
executing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行流程状态(选择每个源的源文件/目录)────────────────────────────
|
||||
interface ExecuteSourceRow {
|
||||
source_id: number;
|
||||
source_name: string;
|
||||
path: string;
|
||||
bucket: string;
|
||||
rootPath: string;
|
||||
}
|
||||
|
||||
const executeVisible = ref(false);
|
||||
const executing = ref(false);
|
||||
const executeRow = ref<FlowTable | null>(null);
|
||||
const executeSources = ref<ExecuteSourceRow[]>([]);
|
||||
const executeBrowseVisible = ref(false);
|
||||
const executeBrowseIndex = ref(-1);
|
||||
const executeSourceId = ref<number | null>(null);
|
||||
const executeSourceName = ref("");
|
||||
const executeInitPath = ref("");
|
||||
const executeBucket = ref("");
|
||||
const executeRootPath = ref("");
|
||||
|
||||
// ── 详情 ─────────────────────────────────────────────────────────────
|
||||
const detailVisible = ref(false);
|
||||
const detailFormData = ref<FlowTable>({} as FlowTable);
|
||||
|
||||
const flowDetailItems: import("@/components/display/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "流程名称", prop: "name" },
|
||||
{ label: "源节点", prop: "sources", slot: "sources" },
|
||||
{ label: "类型", prop: "task_type", slot: "task_type" },
|
||||
{ label: "目标节点", prop: "targets", slot: "targets" },
|
||||
{ label: "流程图", prop: "graph", slot: "graph" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
tag: { map: { 0: { type: "success", text: "启用" }, 1: { type: "danger", text: "停用" } } },
|
||||
},
|
||||
{ label: "备注", prop: "description" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
async function openDetail(id: number) {
|
||||
const { data } = await FlowAPI.detailFlow(id);
|
||||
detailFormData.value = data.data || ({} as FlowTable);
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
// ── 画布设计器 ───────────────────────────────────────────────────────
|
||||
const drawerVisible = ref(false);
|
||||
const editingFlow = ref<FlowTable | null>(null);
|
||||
|
||||
async function openDrawer(id?: number) {
|
||||
if (id) {
|
||||
const { data } = await FlowAPI.detailFlow(id);
|
||||
editingFlow.value = data.data || null;
|
||||
} else {
|
||||
editingFlow.value = null;
|
||||
}
|
||||
drawerVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await openDrawer();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 表格配置 ─────────────────────────────────────────────────────────
|
||||
// 操作列回调集合(columnsFactory 渲染时按行注入)
|
||||
const opCtx = {
|
||||
onDetail: (id: number) => void openDetail(id),
|
||||
onEdit: (id: number) => void openDrawer(id),
|
||||
onExecute: executeFlowRow,
|
||||
onDelete: deleteFlowRow,
|
||||
};
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
@@ -247,82 +595,178 @@ const {
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshRemove,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: WorkflowDefinitionAPI.getWorkflowList,
|
||||
apiFn: FlowAPI.pageFlow,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<WorkflowTable>[] => [
|
||||
columnsFactory: resolveStatusColumns<FlowTable>(() => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "流程名称", minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "id",
|
||||
label: "ID",
|
||||
width: 88,
|
||||
align: "center",
|
||||
prop: "sources",
|
||||
label: "源节点",
|
||||
minWidth: 180,
|
||||
formatter: (row: FlowTable) => {
|
||||
const sources = row.sources || [];
|
||||
if (sources.length === 0) return "—";
|
||||
const visible = sources.slice(0, 2);
|
||||
const rest = sources.slice(2);
|
||||
return h("span", { class: "flex flex-wrap items-center gap-1" }, [
|
||||
...visible.map((s) =>
|
||||
h(
|
||||
ElTooltip,
|
||||
{
|
||||
key: s.source_id,
|
||||
content: nodeTip(s.source_id),
|
||||
placement: "top",
|
||||
showAfter: 200,
|
||||
},
|
||||
{ default: () => protocolTag(sourceMap.value[s.source_id]) }
|
||||
)
|
||||
),
|
||||
...(rest.length
|
||||
? [
|
||||
h(
|
||||
ElTooltip,
|
||||
{
|
||||
key: "__rest",
|
||||
content: rest.map((s) => sourceName(s.source_id)).join("、"),
|
||||
placement: "top",
|
||||
showAfter: 200,
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
ElTag,
|
||||
{ size: "small", type: "info", effect: "plain" },
|
||||
() => `+${rest.length}`
|
||||
),
|
||||
}
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "name",
|
||||
label: "名称",
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
prop: "task_type",
|
||||
label: "类型",
|
||||
width: 120,
|
||||
formatter: (row: FlowTable) =>
|
||||
h(
|
||||
ElTag,
|
||||
{
|
||||
type: row.task_type === "chain" ? "warning" : "primary",
|
||||
size: "small",
|
||||
effect: "plain",
|
||||
},
|
||||
() => taskTypeLabel(row.task_type)
|
||||
),
|
||||
},
|
||||
{
|
||||
prop: "code",
|
||||
label: "编码",
|
||||
minWidth: 120,
|
||||
showOverflowTooltip: true,
|
||||
prop: "targets",
|
||||
label: "目标节点",
|
||||
minWidth: 220,
|
||||
formatter: (row: FlowTable) => {
|
||||
const targets = row.targets || [];
|
||||
if (targets.length === 0) return "—";
|
||||
const visible = targets.slice(0, 2);
|
||||
const rest = targets.slice(2);
|
||||
return h("div", { class: "flex flex-col gap-0.5" }, [
|
||||
...visible.map((t) =>
|
||||
h("div", { key: t.target_id, class: "flex items-center gap-1.5" }, [
|
||||
h(
|
||||
ElTooltip,
|
||||
{ content: nodeTip(t.target_id), placement: "top", showAfter: 200 },
|
||||
{ default: () => protocolTag(sourceMap.value[t.target_id]) }
|
||||
),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: "max-w-32 truncate text-xs text-(--el-text-color-secondary)",
|
||||
title: t.target_path ?? "",
|
||||
},
|
||||
t.target_path ?? ""
|
||||
),
|
||||
])
|
||||
),
|
||||
...(rest.length
|
||||
? [
|
||||
h(
|
||||
"div",
|
||||
{ key: "__rest", class: "text-xs text-(--el-text-color-secondary)" },
|
||||
`+${rest.length} 个目标`
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "graph",
|
||||
label: "传输关系",
|
||||
width: 110,
|
||||
formatter: (row: FlowTable) => {
|
||||
const stats = row.graph_stats;
|
||||
const graph = row.graph;
|
||||
const nodeCount = stats?.node_count ?? graph?.nodes?.length ?? 0;
|
||||
const edgeCount = stats?.edge_count ?? graph?.edges?.length ?? 0;
|
||||
if (!nodeCount && !edgeCount) return "—";
|
||||
return h(
|
||||
ElTooltip,
|
||||
{ content: `${nodeCount} 节点 · ${edgeCount} 连线`, placement: "top", showAfter: 200 },
|
||||
{
|
||||
default: () =>
|
||||
h("span", { class: "text-(--el-text-color-regular)" }, `${edgeCount} 条连线`),
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
minWidth: 100,
|
||||
align: "center",
|
||||
width: 80,
|
||||
status: {
|
||||
0: { type: "info", text: "草稿" },
|
||||
1: { type: "success", text: "已发布" },
|
||||
2: { type: "warning", text: "已归档" },
|
||||
0: { type: "success", text: "启用" },
|
||||
1: { type: "danger", text: "停用" },
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "description",
|
||||
label: "描述",
|
||||
minWidth: 160,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{ prop: "description", label: "备注", minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "创建时间",
|
||||
minWidth: 180,
|
||||
width: 168,
|
||||
sortable: true,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 160,
|
||||
width: 230,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: WorkflowTable) => formatWorkflowOperationCell(row),
|
||||
formatter: (row: FlowTable) => formatFlowOperationCell(row, opCtx),
|
||||
},
|
||||
],
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSearchBarSearch(params: WorkflowSearchForm) {
|
||||
async function handleSearchBarSearch(params: FlowSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildWorkflowReplaceParams(params));
|
||||
replaceSearchParams(buildFlowReplaceParams(params));
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
task_type: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
@@ -332,69 +776,28 @@ async function onResetSearch() {
|
||||
await resetSearchParams();
|
||||
}
|
||||
|
||||
const selectedWorkflow = ref<WorkflowTable>();
|
||||
const createVisible = ref(false);
|
||||
|
||||
async function handleAdd() {
|
||||
createLoading.value = true;
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
handleCreate();
|
||||
await confirmBatchDelete(
|
||||
ids.length,
|
||||
selectedRows.value.map((r) => String(r.name ?? r.id))
|
||||
);
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
batchDeleting.value = true;
|
||||
try {
|
||||
await FlowAPI.deleteFlow(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
selectedWorkflow.value = undefined;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: WorkflowTable) {
|
||||
selectedWorkflow.value = record;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function onDrawerRefresh() {
|
||||
await refreshUpdate();
|
||||
}
|
||||
|
||||
async function handlePublish(record: WorkflowTable) {
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要发布此工作流吗?发布后可执行。", "确认发布", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
if (!record.id) {
|
||||
ElMessage.error("工作流ID不存在");
|
||||
return;
|
||||
}
|
||||
await WorkflowDefinitionAPI.publishWorkflow(record.id, {});
|
||||
await refreshUpdate();
|
||||
} catch {
|
||||
/* 接口错误已由拦截器提示 */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecute(action: string, record: WorkflowTable) {
|
||||
if (action !== "execute") return;
|
||||
try {
|
||||
await ElMessageBox.confirm("确定要立即执行此工作流吗?", "确认执行", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
if (!record.id) {
|
||||
ElMessage.error("工作流ID不存在");
|
||||
return;
|
||||
}
|
||||
await WorkflowDefinitionAPI.executeWorkflow({
|
||||
workflow_id: record.id,
|
||||
variables: {},
|
||||
});
|
||||
await refreshUpdate();
|
||||
} catch {
|
||||
/* 已由全局拦截器提示 */
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
loadNodes();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
<!-- 节点/存储源管理:存储源 CRUD + 连接测试 + 打开浏览(源项目风格) -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="sourceSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:default-expanded="false"
|
||||
include-audit
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<FaTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_task:workflow:node:create']"
|
||||
:perm-delete="['module_task:workflow:node:delete']"
|
||||
:delete-loading="batchDeleting"
|
||||
:create-loading="createLoading"
|
||||
@add="handleAdd"
|
||||
@delete="handleBatchDelete"
|
||||
/>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="dialogVisible.visible"
|
||||
:title="dialogVisible.title"
|
||||
width="720px"
|
||||
dialog-class="crud-embed-dialog"
|
||||
modal-class="crud-embed-dialog"
|
||||
:form-mode="dialogVisible.type"
|
||||
:confirm-loading="submitLoading"
|
||||
@cancel="handleCloseDialog"
|
||||
@confirm="dialogVisible.type === 'detail' ? handleCloseDialog() : handleSubmit()"
|
||||
>
|
||||
<template v-if="dialogVisible.type === 'detail'">
|
||||
<FaDescriptions
|
||||
:column="2"
|
||||
:data="detailFormData"
|
||||
:items="sourceDetailItems"
|
||||
max-height="70vh"
|
||||
>
|
||||
<template #protocol="{ row }">
|
||||
<span>{{ protocolLabel((row as unknown as SourceTable)?.protocol) }}</span>
|
||||
</template>
|
||||
<template #is_default="{ row }">
|
||||
<FaStatusTag v-if="row?.is_default" type="success" label="默认" />
|
||||
<FaStatusTag v-else type="info" label="否" />
|
||||
</template>
|
||||
<template #is_secure="{ row }">
|
||||
<span>{{ row?.is_secure ? "是" : "否" }}</span>
|
||||
</template>
|
||||
<template #has_password="{ row }">
|
||||
<span>{{ row?.has_password ? "已配置" : "未配置" }}</span>
|
||||
</template>
|
||||
</FaDescriptions>
|
||||
</template>
|
||||
<template v-else>
|
||||
<FaForm
|
||||
:key="sourceFormRenderKey"
|
||||
scrollbar
|
||||
max-height="70vh"
|
||||
ref="dataFormRef"
|
||||
v-model="formData"
|
||||
:items="sourceDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
:label-width="110"
|
||||
label-position="right"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form"
|
||||
>
|
||||
<template #advanced_config>
|
||||
<ElCollapse
|
||||
v-if="currentAdvancedFields.length"
|
||||
v-model="advancedCollapseActive"
|
||||
class="w-full border-none"
|
||||
>
|
||||
<ElCollapseItem name="advanced">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-800">SDK 高级设置</span>
|
||||
<ElTag size="small" type="info" effect="plain">留空使用 SDK 默认值</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
<ElRow :gutter="16">
|
||||
<ElCol
|
||||
v-for="field in currentAdvancedFields"
|
||||
:key="field.key"
|
||||
:span="12"
|
||||
class="mb-3"
|
||||
>
|
||||
<div class="mb-1.5 text-[13px] leading-5 text-gray-600">{{ field.label }}</div>
|
||||
<ElInput
|
||||
v-if="field.type === 'text'"
|
||||
:model-value="(formData.advanced_config?.[field.key] as string) ?? ''"
|
||||
:placeholder="`默认:${field.default ?? '-'}`"
|
||||
clearable
|
||||
@update:model-value="setAdvancedValue(field.key, $event)"
|
||||
/>
|
||||
<ElInputNumber
|
||||
v-else-if="field.type === 'number'"
|
||||
:model-value="(formData.advanced_config?.[field.key] as number) ?? undefined"
|
||||
:placeholder="`默认:${field.default ?? '-'}`"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
@update:model-value="setAdvancedValue(field.key, $event)"
|
||||
/>
|
||||
<ElSwitch
|
||||
v-else-if="field.type === 'boolean'"
|
||||
:model-value="Boolean(formData.advanced_config?.[field.key])"
|
||||
inline-prompt
|
||||
active-text="开"
|
||||
inactive-text="关"
|
||||
@update:model-value="setAdvancedValue(field.key, $event)"
|
||||
/>
|
||||
<ElSelect
|
||||
v-else
|
||||
:model-value="(formData.advanced_config?.[field.key] as string) ?? ''"
|
||||
:placeholder="`默认:${field.default ?? '-'}`"
|
||||
clearable
|
||||
class="w-full"
|
||||
@update:model-value="setAdvancedValue(field.key, $event)"
|
||||
>
|
||||
<ElOption v-for="opt in field.options" :key="opt" :label="opt" :value="opt" />
|
||||
</ElSelect>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElCollapseItem>
|
||||
</ElCollapse>
|
||||
</template>
|
||||
</FaForm>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
v-if="dialogVisible.type === 'detail'"
|
||||
class="fa-dialog-footer"
|
||||
style="padding-right: var(--el-dialog-padding-primary)"
|
||||
>
|
||||
<ElButton type="primary" @click="handleCloseDialog">关闭</ElButton>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="fa-dialog-footer"
|
||||
style="padding-right: var(--el-dialog-padding-primary)"
|
||||
>
|
||||
<ElButton :loading="testing" style="margin-right: auto" @click="handleTestConfig"
|
||||
>测试连接</ElButton
|
||||
>
|
||||
<ElButton type="primary" plain @click="handleCloseDialog">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitLoading" @click="handleSubmit">确定</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElTag } from "element-plus";
|
||||
import { useCrudForm } from "@/hooks/core/useCrudForm";
|
||||
import { resolveStatusColumns, renderTableOperationCell, type TableOperationAction } from "@utils";
|
||||
import NodeAPI, {
|
||||
type AdvancedFieldDef,
|
||||
type SourceForm,
|
||||
type SourceTable,
|
||||
} from "@/api/module_task/workflow/node";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
|
||||
defineOptions({
|
||||
name: "WorkflowNode",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 协议选项:默认内置,挂载后经 /node/protocols 刷新(保留友好中文标签)
|
||||
const PROTOCOL_LABELS: Record<string, string> = {
|
||||
ftp: "FTP",
|
||||
ftps: "FTPS",
|
||||
sftp: "SFTP",
|
||||
s3: "S3",
|
||||
obs: "OBS(华为云)",
|
||||
oss: "OSS(阿里云)",
|
||||
cos: "COS(腾讯云)",
|
||||
local: "本地目录",
|
||||
};
|
||||
const PROTOCOL_OPTIONS = ref([
|
||||
{ label: "FTP", value: "ftp" },
|
||||
{ label: "FTPS", value: "ftps" },
|
||||
{ label: "SFTP", value: "sftp" },
|
||||
{ label: "S3", value: "s3" },
|
||||
{ label: "OBS(华为云)", value: "obs" },
|
||||
{ label: "OSS(阿里云)", value: "oss" },
|
||||
{ label: "COS(腾讯云)", value: "cos" },
|
||||
{ label: "本地目录", value: "local" },
|
||||
]);
|
||||
async function loadProtocols() {
|
||||
const { data } = await NodeAPI.getProtocols();
|
||||
const items = data.data ?? [];
|
||||
PROTOCOL_OPTIONS.value = items.map((p) => ({
|
||||
label: PROTOCOL_LABELS[p.protocol] ?? p.name ?? p.protocol,
|
||||
value: p.protocol,
|
||||
}));
|
||||
}
|
||||
void loadProtocols();
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
] as const;
|
||||
|
||||
const ENCRYPT_OPTIONS = [
|
||||
{ label: "只要求明文", value: 0 },
|
||||
{ label: "显式TLS(可用时)", value: 1 },
|
||||
{ label: "要求显式TLS", value: 2 },
|
||||
{ label: "要求隐式TLS", value: 3 },
|
||||
] as const;
|
||||
|
||||
const CONNECTION_MODE_OPTIONS = [
|
||||
{ label: "默认", value: 0 },
|
||||
{ label: "主动 (PORT)", value: 1 },
|
||||
{ label: "被动 (PASV)", value: 2 },
|
||||
] as const;
|
||||
|
||||
// 各对象存储协议的区域字典(切换协议时自动带出默认区域)
|
||||
const regionDict: Record<string, { options: string[]; default: string }> = {
|
||||
s3: {
|
||||
options: ["us-east-1", "us-west-2", "ap-northeast-1", "ap-southeast-1", "ap-south-1"],
|
||||
default: "us-east-1",
|
||||
},
|
||||
oss: {
|
||||
options: ["cn-hangzhou", "cn-beijing", "cn-shanghai", "cn-shenzhen", "cn-hongkong"],
|
||||
default: "cn-beijing",
|
||||
},
|
||||
cos: {
|
||||
options: ["ap-beijing", "ap-shanghai", "ap-guangzhou", "ap-chengdu", "ap-singapore"],
|
||||
default: "ap-beijing",
|
||||
},
|
||||
obs: { options: ["cn-north-1", "cn-north-4", "cn-east-2", "cn-south-1"], default: "cn-north-4" },
|
||||
};
|
||||
|
||||
const isObjectStorageProtocol = (p?: string) => ["s3", "oss", "cos", "obs"].includes(p ?? "");
|
||||
const isFtpLikeProtocol = (p?: string) => ["ftp", "ftps"].includes(p ?? "");
|
||||
|
||||
function protocolLabel(value?: string): string {
|
||||
const opt = PROTOCOL_OPTIONS.value.find((o) => o.value === value);
|
||||
return opt ? opt.label : (value ?? "-");
|
||||
}
|
||||
|
||||
// 源项目风格:协议 → tag 颜色
|
||||
const protocolTagType = (protocol?: string) => {
|
||||
const map: Record<string, "primary" | "success" | "warning" | "info" | "danger"> = {
|
||||
ftp: "primary",
|
||||
ftps: "success",
|
||||
sftp: "warning",
|
||||
s3: "info",
|
||||
oss: "danger",
|
||||
cos: "warning",
|
||||
obs: "success",
|
||||
local: "info",
|
||||
};
|
||||
return map[protocol ?? ""] ?? "info";
|
||||
};
|
||||
|
||||
type SourceSearchForm = {
|
||||
name?: string;
|
||||
protocol?: string;
|
||||
status?: number;
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
};
|
||||
|
||||
function buildSourceReplaceParams(p: SourceSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: p.name,
|
||||
protocol: p.protocol,
|
||||
status: p.status,
|
||||
created_id: p.created_id,
|
||||
updated_id: p.updated_id,
|
||||
created_time:
|
||||
Array.isArray(p.created_time) && p.created_time.length === 2 ? p.created_time : undefined,
|
||||
updated_time:
|
||||
Array.isArray(p.updated_time) && p.updated_time.length === 2 ? p.updated_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSourceRowActions(
|
||||
row: SourceTable,
|
||||
ctx: {
|
||||
onOpen: (row: SourceTable) => void;
|
||||
onTest: (id: number) => void;
|
||||
onDetail: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number, name: string) => void;
|
||||
}
|
||||
): TableOperationAction[] {
|
||||
return [
|
||||
{
|
||||
key: "open",
|
||||
label: "打开",
|
||||
artType: "view",
|
||||
icon: "ri:folder-open-line",
|
||||
iconColor: "var(--el-color-success)",
|
||||
run: () => ctx.onOpen(row),
|
||||
},
|
||||
{
|
||||
key: "test",
|
||||
label: "测试连接",
|
||||
artType: "view",
|
||||
icon: "ri:link",
|
||||
iconColor: "var(--el-color-primary)",
|
||||
perm: "module_task:workflow:node:query",
|
||||
run: () => ctx.onTest(row.id!),
|
||||
},
|
||||
{
|
||||
key: "detail",
|
||||
label: "详情",
|
||||
artType: "view",
|
||||
perm: "module_task:workflow:node:query",
|
||||
run: () => ctx.onDetail(row.id!),
|
||||
},
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
icon: "ri:edit-2-line",
|
||||
perm: "module_task:workflow:node:update",
|
||||
run: () => ctx.onEdit(row.id!),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_task:workflow:node:delete",
|
||||
run: () => ctx.onDelete(row.id!, row.name ?? ""),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function formatSourceOperationCell(
|
||||
row: SourceTable,
|
||||
ctx: Parameters<typeof buildSourceRowActions>[1]
|
||||
) {
|
||||
return renderTableOperationCell(buildSourceRowActions(row, ctx), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 source-table-actions",
|
||||
});
|
||||
}
|
||||
|
||||
const searchForm = ref<SourceSearchForm>({
|
||||
name: undefined,
|
||||
protocol: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const sourceSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "存储名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入存储名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "协议",
|
||||
key: "protocol",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择协议",
|
||||
options: PROTOCOL_OPTIONS.value,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
options: STATUS_OPTIONS,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
|
||||
// ─── 表格多选 ───
|
||||
const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
|
||||
useTableSelection<SourceTable>();
|
||||
|
||||
const createLoading = ref(false);
|
||||
|
||||
async function deleteSourceRow(id: number, name: string) {
|
||||
try {
|
||||
await confirmDelete(`确定删除「${name}」吗?`);
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
await NodeAPI.deleteNode([id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
}
|
||||
|
||||
async function handleTest(id: number) {
|
||||
await NodeAPI.testNode(id);
|
||||
}
|
||||
|
||||
// 打开浏览:跳转到文件管理并携带存储源信息
|
||||
function handleOpen(row: SourceTable) {
|
||||
router.push({
|
||||
path: "/task/workflow/storage",
|
||||
query: { source_id: row.id, name: row.name, protocol: row.protocol },
|
||||
});
|
||||
}
|
||||
|
||||
const testing = ref(false);
|
||||
|
||||
async function handleTestConfig() {
|
||||
testing.value = true;
|
||||
try {
|
||||
await NodeAPI.testNodeConfig({ ...formData.value, source_id: formData.value.id });
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 对话框状态 ───
|
||||
const { dialogVisible } = useCrudDialog();
|
||||
|
||||
const detailFormData = ref<SourceTable>({} as SourceTable);
|
||||
|
||||
const sourceDetailItems: import("@/components/display/fa-descriptions/index.vue").DescriptionsItem[] =
|
||||
[
|
||||
{ label: "存储源名称", prop: "name" },
|
||||
{ label: "协议", prop: "protocol", slot: "protocol" },
|
||||
{ label: "主机地址", prop: "host" },
|
||||
{ label: "端口", prop: "port" },
|
||||
{ label: "用户名", prop: "username" },
|
||||
{ label: "密码", prop: "has_password", slot: "has_password" },
|
||||
{ label: "Bucket", prop: "bucket" },
|
||||
{ label: "端点地址", prop: "endpoint" },
|
||||
{ label: "区域", prop: "region" },
|
||||
{ label: "路径前缀", prop: "path_prefix" },
|
||||
{ label: "分片大小(MB)", prop: "multipart_part_size" },
|
||||
{ label: "分片并发", prop: "multipart_concurrency" },
|
||||
{ label: "内存预算(MB)", prop: "multipart_memory_budget" },
|
||||
{ label: "TLS(FTPS)", prop: "is_secure", slot: "is_secure" },
|
||||
{ label: "默认存储源", prop: "is_default", slot: "is_default" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
tag: { map: { 0: { type: "success", text: "启用" }, 1: { type: "danger", text: "停用" } } },
|
||||
},
|
||||
{ label: "备注", prop: "description" },
|
||||
{ label: "创建时间", prop: "created_time" },
|
||||
{ label: "更新时间", prop: "updated_time" },
|
||||
];
|
||||
|
||||
const initialFormData: SourceForm = {
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
protocol: "ftp",
|
||||
host: undefined,
|
||||
port: undefined,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
bucket: undefined,
|
||||
endpoint: undefined,
|
||||
region: undefined,
|
||||
path_prefix: undefined,
|
||||
scheme: "https",
|
||||
encrypt_type: 1,
|
||||
connection_mode: 0,
|
||||
encoding: "UTF-8",
|
||||
is_secure: false,
|
||||
implicit_tls: false,
|
||||
multipart_part_size: 50,
|
||||
multipart_concurrency: 6,
|
||||
multipart_memory_budget: 512,
|
||||
advanced_config: {},
|
||||
is_default: false,
|
||||
status: 0,
|
||||
description: undefined,
|
||||
};
|
||||
|
||||
const formData = ref<SourceForm>({ ...initialFormData });
|
||||
|
||||
// 主机地址仅非对象存储协议必填(对象存储用 endpoint 完整 URL)
|
||||
const rules = computed(() => ({
|
||||
name: [{ required: true, message: "请输入存储源名称", trigger: "blur" }],
|
||||
...(isObjectStorageProtocol(formData.value.protocol)
|
||||
? {}
|
||||
: { host: [{ required: true, message: "请输入主机地址/根目录", trigger: "blur" }] }),
|
||||
}));
|
||||
|
||||
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
|
||||
const sourceFormRenderKey = ref(0);
|
||||
|
||||
// ─── CRUD 表单 ───
|
||||
const { submitLoading, handleCloseDialog, handleOpenDialog, handleSubmit } =
|
||||
useCrudForm<SourceForm>({
|
||||
formData,
|
||||
initialFormData,
|
||||
dialogVisible,
|
||||
dataFormRef,
|
||||
formRenderKey: sourceFormRenderKey,
|
||||
detailApi: NodeAPI.detailNode,
|
||||
createApi: NodeAPI.createNode,
|
||||
updateApi: NodeAPI.updateNode,
|
||||
titles: { create: "新增存储源", update: "修改存储源", detail: "存储源详情" },
|
||||
detailFormData,
|
||||
onCreateSuccess: async () => {
|
||||
await refreshCreate();
|
||||
},
|
||||
onUpdateSuccess: async () => {
|
||||
await refreshUpdate();
|
||||
},
|
||||
});
|
||||
|
||||
async function handleAdd() {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await handleOpenDialog("create");
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 切换协议:重置协议专属字段并带出该协议默认配置(区域等),避免旧协议配置交叉残留
|
||||
function onProtocolChange(p: string) {
|
||||
// 仅复制协议专属字段,排除基础信息(ID/名称/备注/默认/状态),避免重置用户已填写内容
|
||||
const protocolSpecific = { ...initialFormData };
|
||||
delete protocolSpecific.id;
|
||||
delete protocolSpecific.name;
|
||||
delete protocolSpecific.description;
|
||||
delete protocolSpecific.is_default;
|
||||
delete protocolSpecific.status;
|
||||
Object.assign(formData.value, { ...protocolSpecific, protocol: p });
|
||||
// 对象存储自动带出默认区域(覆盖旧协议残留区域)
|
||||
const regionItem = regionDict[p];
|
||||
if (regionItem) formData.value.region = regionItem.default;
|
||||
formData.value.advanced_config = {};
|
||||
// 强制重渲染:清空旧协议字段的校验状态,刷新 SDK 高级设置
|
||||
sourceFormRenderKey.value++;
|
||||
}
|
||||
|
||||
// ─── SDK 高级设置(按协议动态渲染折叠面板) ───
|
||||
const advancedFields = ref<Record<string, AdvancedFieldDef[]>>({});
|
||||
const advancedCollapseActive = ref<string[]>([]);
|
||||
|
||||
const currentAdvancedFields = computed<AdvancedFieldDef[]>(() => {
|
||||
const p = formData.value.protocol;
|
||||
return (p && advancedFields.value[p]) || [];
|
||||
});
|
||||
|
||||
async function loadAdvancedFields() {
|
||||
const res = await NodeAPI.getAdvancedFields();
|
||||
advancedFields.value = res.data.data ?? {};
|
||||
}
|
||||
loadAdvancedFields();
|
||||
|
||||
function setAdvancedValue(key: string, value: string | number | boolean | undefined) {
|
||||
const cfg = (formData.value.advanced_config ??= {});
|
||||
if (value === undefined || value === null || value === "") {
|
||||
delete cfg[key]; // 留空即使用 SDK 默认值
|
||||
} else {
|
||||
cfg[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 动态表单:按协议分组渲染字段(源项目风格) ───
|
||||
const sourceDialogFormItems = computed<FormItem[]>(() => {
|
||||
const p = formData.value.protocol ?? "ftp";
|
||||
const isObject = isObjectStorageProtocol(p);
|
||||
const isFtp = isFtpLikeProtocol(p);
|
||||
const items: FormItem[] = [
|
||||
{
|
||||
label: "存储源名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "请输入存储源名称", maxlength: 64, showWordLimit: true },
|
||||
},
|
||||
{
|
||||
label: "协议",
|
||||
key: "protocol",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择协议",
|
||||
options: PROTOCOL_OPTIONS.value,
|
||||
onChange: onProtocolChange,
|
||||
},
|
||||
},
|
||||
];
|
||||
if (isObject) {
|
||||
items.push(
|
||||
{
|
||||
label: "端点地址",
|
||||
key: "endpoint",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder:
|
||||
p === "cos"
|
||||
? "完整接入点 URL(可留空,自动按区域拼接)"
|
||||
: "完整接入点 URL,如 https://oss-cn-beijing.aliyuncs.com(必填)",
|
||||
maxlength: 255,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "AccessKey",
|
||||
key: "username",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "AccessKeyID (AK)", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "SecretKey",
|
||||
key: "password",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: {
|
||||
type: "password",
|
||||
showPassword: true,
|
||||
placeholder: "SecretKey (SK),修改时留空则不修改",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "桶名",
|
||||
key: "bucket",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "桶名 / 空间名(必填)", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "区域",
|
||||
key: "region",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择或输入区域",
|
||||
options: regionDict[p]?.options ?? [],
|
||||
filterable: true,
|
||||
allowCreate: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "路径前缀",
|
||||
key: "path_prefix",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "统一路径前缀(可选)", maxlength: 255 },
|
||||
},
|
||||
// ── 分片传输参数(对象存储分片上传,每个端点独立配置) ──
|
||||
{
|
||||
label: "分片大小",
|
||||
key: "multipart_part_size",
|
||||
type: "number",
|
||||
span: 8,
|
||||
props: { controlsPosition: "right", min: 5, max: 5000, step: 1, placeholder: "单片(MB)" },
|
||||
},
|
||||
{
|
||||
label: "分片并发",
|
||||
key: "multipart_concurrency",
|
||||
type: "number",
|
||||
span: 8,
|
||||
props: { controlsPosition: "right", min: 1, max: 64, step: 1, placeholder: "路数" },
|
||||
},
|
||||
{
|
||||
label: "内存预算",
|
||||
key: "multipart_memory_budget",
|
||||
type: "number",
|
||||
span: 8,
|
||||
props: { controlsPosition: "right", min: 8, max: 10240, step: 1, placeholder: "MB" },
|
||||
}
|
||||
);
|
||||
} else {
|
||||
items.push(
|
||||
{
|
||||
label: "主机地址",
|
||||
key: "host",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: p === "local" ? "本地目录根路径,如 /data/storage" : "主机地址或 IP",
|
||||
maxlength: 255,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "端口",
|
||||
key: "port",
|
||||
type: "number",
|
||||
span: 12,
|
||||
props: {
|
||||
controlsPosition: "right",
|
||||
min: 0,
|
||||
max: 65535,
|
||||
placeholder: "留空使用协议默认端口",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (p !== "local") {
|
||||
items.push(
|
||||
{
|
||||
label: "账号",
|
||||
key: "username",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "账号(不填则为匿名)", maxlength: 255 },
|
||||
},
|
||||
{
|
||||
label: "密码",
|
||||
key: "password",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { type: "password", showPassword: true, placeholder: "密码,修改时留空则不修改" },
|
||||
},
|
||||
{
|
||||
label: "路径前缀",
|
||||
key: "path_prefix",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "统一路径前缀(可选)", maxlength: 255 },
|
||||
}
|
||||
);
|
||||
} else {
|
||||
items.push({
|
||||
label: "路径前缀",
|
||||
key: "path_prefix",
|
||||
type: "input",
|
||||
span: 12,
|
||||
props: { placeholder: "统一路径前缀(可选)", maxlength: 255 },
|
||||
});
|
||||
}
|
||||
if (isFtp) {
|
||||
items.push(
|
||||
{
|
||||
label: "传输模式",
|
||||
key: "connection_mode",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: { placeholder: "请选择传输模式", options: CONNECTION_MODE_OPTIONS },
|
||||
},
|
||||
{
|
||||
label: "编码",
|
||||
key: "encoding",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择编码",
|
||||
options: [
|
||||
{ label: "UTF-8", value: "UTF-8" },
|
||||
{ label: "GBK", value: "GBK" },
|
||||
],
|
||||
filterable: true,
|
||||
allowCreate: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
if (p === "ftps") {
|
||||
items.push({
|
||||
label: "加密方式",
|
||||
key: "encrypt_type",
|
||||
type: "select",
|
||||
span: 12,
|
||||
props: {
|
||||
placeholder: "请选择加密方式",
|
||||
// FTPS 后端强制 encrypt_type>=1(明文请使用 FTP 协议),故过滤 0
|
||||
options: ENCRYPT_OPTIONS.filter((o) => o.value !== 0),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
items.push(
|
||||
{
|
||||
label: "默认存储源",
|
||||
key: "is_default",
|
||||
type: "switch",
|
||||
span: 12,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "radiogroup",
|
||||
span: 12,
|
||||
props: { options: STATUS_OPTIONS },
|
||||
},
|
||||
{
|
||||
label: "备注",
|
||||
key: "description",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: {
|
||||
type: "textarea",
|
||||
rows: 3,
|
||||
maxlength: 255,
|
||||
showWordLimit: true,
|
||||
placeholder: "请输入备注",
|
||||
},
|
||||
}
|
||||
);
|
||||
// SDK 高级设置:通过命名插槽渲染折叠面板(见模板 #advanced_config)
|
||||
items.push({
|
||||
key: "advanced_config",
|
||||
label: "",
|
||||
span: 24,
|
||||
hidden: currentAdvancedFields.value.length === 0,
|
||||
});
|
||||
return items;
|
||||
});
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
refreshRemove,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: NodeAPI.pageNode,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: resolveStatusColumns<SourceTable>(() => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{ prop: "name", label: "存储源名称", minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "protocol",
|
||||
label: "协议",
|
||||
width: 110,
|
||||
// 源项目风格:协议彩色标签
|
||||
formatter: (row: SourceTable) =>
|
||||
h(ElTag, { type: protocolTagType(row.protocol), size: "small", effect: "plain" }, () =>
|
||||
protocolLabel(row.protocol)
|
||||
),
|
||||
},
|
||||
{ prop: "host", label: "主机地址", minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: "port", label: "端口", width: 80 },
|
||||
{ prop: "bucket", label: "Bucket", minWidth: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "is_default",
|
||||
label: "默认",
|
||||
width: 80,
|
||||
formatter: (row: SourceTable) => (row.is_default ? "是" : "否"),
|
||||
},
|
||||
{
|
||||
prop: "status",
|
||||
label: "状态",
|
||||
width: 80,
|
||||
status: {
|
||||
0: { type: "success", text: "启用" },
|
||||
1: { type: "danger", text: "停用" },
|
||||
},
|
||||
},
|
||||
{ prop: "description", label: "备注", minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "创建时间",
|
||||
width: 168,
|
||||
sortable: true,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 250,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: SourceTable) => formatSourceOperationCell(row, opCtx),
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSearchBarSearch(params: SourceSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildSourceReplaceParams(params));
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
protocol: undefined,
|
||||
status: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
};
|
||||
await resetSearchParams();
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(
|
||||
ids.length,
|
||||
selectedRows.value.map((r) => String(r.name ?? r.id))
|
||||
);
|
||||
} catch {
|
||||
return; // 用户取消
|
||||
}
|
||||
batchDeleting.value = true;
|
||||
try {
|
||||
await NodeAPI.deleteNode(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const opCtx = {
|
||||
onOpen: handleOpen,
|
||||
onTest: handleTest,
|
||||
onDetail: (id: number) => void handleOpenDialog("detail", id),
|
||||
onEdit: (id: number) => void handleOpenDialog("update", id),
|
||||
onDelete: deleteSourceRow,
|
||||
};
|
||||
</script>
|
||||
@@ -1,683 +0,0 @@
|
||||
<!-- 工作流节点类型:ElSplitter + Codemirror -->
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="nodeTypeSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:disabled-search="false"
|
||||
:default-expanded="false"
|
||||
include-audit
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<FaTableHeader
|
||||
v-model:columns="columnChecks"
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
:loading="loading"
|
||||
@refresh="refreshData"
|
||||
>
|
||||
<template #left>
|
||||
<FaTableHeaderLeft
|
||||
:remove-ids="selectedIds"
|
||||
:perm-create="['module_task:workflow:nodes:create']"
|
||||
:perm-delete="['module_task:workflow:nodes:delete']"
|
||||
:delete-loading="batchDeleting"
|
||||
:create-loading="createLoading"
|
||||
@add="handleAdd"
|
||||
@delete="handleBatchDelete"
|
||||
/>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<FaTable
|
||||
ref="faTableRef"
|
||||
row-key="id"
|
||||
:loading="loading"
|
||||
:data="data"
|
||||
:columns="columns"
|
||||
:pagination="pagination"
|
||||
@selection-change="onTableSelectionChange"
|
||||
@pagination:size-change="handleSizeChange"
|
||||
@pagination:current-change="handleCurrentChange"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<FaDialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="1000px"
|
||||
destroy-on-close
|
||||
@close="handleCloseDialog"
|
||||
@opened="handleDialogOpened"
|
||||
>
|
||||
<ElSplitter direction="horizontal" :style="'height: 500px'">
|
||||
<ElSplitterPanel size="320px" :min="240" :max="420">
|
||||
<ElScrollbar :style="'height: 100%'">
|
||||
<FaForm
|
||||
:key="nodeTypeFormRenderKey"
|
||||
ref="formRef"
|
||||
v-model="formData"
|
||||
:items="nodeTypeDialogFormItems"
|
||||
:rules="rules"
|
||||
label-suffix=":"
|
||||
label-width="85px"
|
||||
:span="24"
|
||||
:gutter="16"
|
||||
:show-reset="false"
|
||||
:show-submit="false"
|
||||
class="crud-dialog-art-form node-splitter-art-form"
|
||||
>
|
||||
<template #args>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(_item, index) in argsList"
|
||||
:key="index"
|
||||
class="flex gap-2 items-center [&_.el-input]:flex-1"
|
||||
>
|
||||
<ElInput v-model="argsList[index]" placeholder="参数值" />
|
||||
<ElButton
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
@click="argsList.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" icon="Plus" @click="argsList.push('')">
|
||||
添加位置参数
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<template #kwargs>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(item, index) in kwargsList"
|
||||
:key="item.key || index"
|
||||
class="flex gap-2 items-center [&_.el-input]:flex-1"
|
||||
>
|
||||
<ElInput v-model="item.key" placeholder="键" />
|
||||
<ElInput v-model="item.value" placeholder="值" />
|
||||
<ElButton
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
circle
|
||||
@click="kwargsList.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:icon="Plus"
|
||||
@click="kwargsList.push({ key: '', value: '' })"
|
||||
>
|
||||
添加关键词参数
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaForm>
|
||||
</ElScrollbar>
|
||||
</ElSplitterPanel>
|
||||
|
||||
<ElSplitterPanel>
|
||||
<div class="flex flex-col h-full">
|
||||
<div
|
||||
class="flex shrink-0 items-center justify-between px-3 py-2 bg-(--el-fill-color-light) border-b border-(--el-border-color-lighter)"
|
||||
>
|
||||
<span class="text-sm font-semibold">handler 代码</span>
|
||||
<span class="text-xs text-(--el-text-color-secondary)"
|
||||
>须定义 handler(*args, **kwargs) 函数</span
|
||||
>
|
||||
</div>
|
||||
<Codemirror
|
||||
ref="codeEditorRef"
|
||||
v-model:value="formData.func"
|
||||
:options="codeEditorOptions"
|
||||
border
|
||||
height="calc(100% - 40px)"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
</ElSplitterPanel>
|
||||
</ElSplitter>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<ElButton @click="dialogVisible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="submitting" @click="submitForm">保存</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: "WorkflowNodeType",
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
import WorkflowNodeTypeAPI, {
|
||||
type WorkflowNodeTypeForm,
|
||||
type WorkflowNodeTypeTable,
|
||||
} from "@/api/module_task/workflow/nodes";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
|
||||
import FaTable from "@/components/tables/fa-table/index.vue";
|
||||
import FaDialog from "@/components/modal/fa-dialog/index.vue";
|
||||
import FaForm from "@/components/forms/fa-form/index.vue";
|
||||
import type { TableOperationAction } from "@utils";
|
||||
import { renderTableOperationCell } from "@utils";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormRules } from "element-plus";
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import Codemirror, { CmComponentRef } from "codemirror-editor-vue3";
|
||||
import type { EditorConfiguration } from "codemirror";
|
||||
import "codemirror/mode/python/python.js";
|
||||
import "codemirror/theme/dracula.css";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import { Delete, Plus } from "@element-plus/icons-vue";
|
||||
|
||||
type NodeTypeSearchForm = {
|
||||
name?: string;
|
||||
code?: string;
|
||||
category?: string;
|
||||
created_id?: number;
|
||||
updated_id?: number;
|
||||
created_time?: string[];
|
||||
updated_time?: string[];
|
||||
};
|
||||
|
||||
function buildNodeTypeReplaceParams(u: NodeTypeSearchForm): Record<string, unknown> {
|
||||
return {
|
||||
name: u.name,
|
||||
code: u.code,
|
||||
category: u.category,
|
||||
created_id: u.created_id,
|
||||
updated_id: u.updated_id,
|
||||
created_time:
|
||||
Array.isArray(u.created_time) && u.created_time.length === 2 ? u.created_time : undefined,
|
||||
updated_time:
|
||||
Array.isArray(u.updated_time) && u.updated_time.length === 2 ? u.updated_time : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const searchForm = ref<NodeTypeSearchForm>({
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
category: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
});
|
||||
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const nodeTypeSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
placeholder: "编码",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "分类",
|
||||
key: "category",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "全部",
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "触发器", value: "trigger" },
|
||||
{ label: "动作", value: "action" },
|
||||
{ label: "条件", value: "condition" },
|
||||
{ label: "控制", value: "control" },
|
||||
],
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "请选择状态",
|
||||
clearable: true,
|
||||
options: [
|
||||
{ label: "启用", value: 0 },
|
||||
{ label: "停用", value: 1 },
|
||||
],
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "创建时间",
|
||||
key: "created_time",
|
||||
type: "datetimerange",
|
||||
span: 6,
|
||||
props: {
|
||||
type: "datetimerange",
|
||||
rangeSeparator: "至",
|
||||
startPlaceholder: "开始日期",
|
||||
endPlaceholder: "结束日期",
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
valueFormat: "YYYY-MM-DD HH:mm:ss",
|
||||
style: { width: "100%" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const faTableRef = ref<{ elTableRef?: { clearSelection: () => void } } | null>(null);
|
||||
const { selectedIds, batchDeleting, onTableSelectionChange } =
|
||||
useTableSelection<WorkflowNodeTypeTable>();
|
||||
const createLoading = ref(false);
|
||||
|
||||
function categoryLabel(c?: string) {
|
||||
const m: Record<string, string> = {
|
||||
trigger: "触发器",
|
||||
action: "动作",
|
||||
condition: "条件",
|
||||
control: "控制",
|
||||
};
|
||||
return c ? m[c] || c : "-";
|
||||
}
|
||||
|
||||
const deleteNodeTypeRow = async (row: WorkflowNodeTypeTable) => {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await confirmDelete(`确定删除节点类型「${row.name ?? row.id}」吗?`);
|
||||
await WorkflowNodeTypeAPI.deleteWorkflowNodeType([row.id]);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
};
|
||||
|
||||
async function handleBatchDelete() {
|
||||
const ids = selectedIds.value;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await confirmBatchDelete(
|
||||
ids.length,
|
||||
(data.value as WorkflowNodeTypeTable[])
|
||||
.filter((r) => r.id != null && selectedIds.value.includes(r.id))
|
||||
.map((r) => String(r?.name ?? r?.id ?? ""))
|
||||
);
|
||||
batchDeleting.value = true;
|
||||
await WorkflowNodeTypeAPI.deleteWorkflowNodeType(ids);
|
||||
faTableRef.value?.elTableRef?.clearSelection();
|
||||
await refreshRemove();
|
||||
} catch {
|
||||
// 用户取消
|
||||
} finally {
|
||||
batchDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildNodeTypeRowActions(row: WorkflowNodeTypeTable): TableOperationAction[] {
|
||||
const all: TableOperationAction[] = [
|
||||
{
|
||||
key: "edit",
|
||||
label: "编辑",
|
||||
artType: "edit",
|
||||
icon: "ri:edit-2-line",
|
||||
perm: "module_task:workflow:nodes:update",
|
||||
run: () => void openDialog(row.id),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
artType: "delete",
|
||||
icon: "ri:delete-bin-4-line",
|
||||
perm: "module_task:workflow:nodes:delete",
|
||||
run: () => void deleteNodeTypeRow(row),
|
||||
},
|
||||
];
|
||||
return all;
|
||||
}
|
||||
|
||||
function formatNodeTypeOperationCell(row: WorkflowNodeTypeTable) {
|
||||
return renderTableOperationCell(buildNodeTypeRowActions(row), {
|
||||
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
columns,
|
||||
columnChecks,
|
||||
data,
|
||||
loading,
|
||||
pagination,
|
||||
getData,
|
||||
replaceSearchParams,
|
||||
resetSearchParams,
|
||||
handleSizeChange,
|
||||
handleCurrentChange,
|
||||
refreshData,
|
||||
refreshRemove,
|
||||
refreshCreate,
|
||||
refreshUpdate,
|
||||
} = useTable({
|
||||
core: {
|
||||
apiFn: WorkflowNodeTypeAPI.getWorkflowNodeTypeList,
|
||||
apiParams: {
|
||||
page_no: 1,
|
||||
page_size: 10,
|
||||
},
|
||||
columnsFactory: (): ColumnOption<WorkflowNodeTypeTable>[] => [
|
||||
{ type: "selection", width: 48, fixed: "left" },
|
||||
{ type: "globalIndex", width: 56, label: "序号" },
|
||||
{
|
||||
prop: "id",
|
||||
label: "ID",
|
||||
width: 88,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
prop: "name",
|
||||
label: "名称",
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "code",
|
||||
label: "编码",
|
||||
minWidth: 120,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "category",
|
||||
label: "分类",
|
||||
minWidth: 100,
|
||||
formatter: (row) => categoryLabel(row.category),
|
||||
},
|
||||
{
|
||||
prop: "sort_order",
|
||||
label: "排序",
|
||||
width: 88,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
prop: "is_active",
|
||||
label: "启用",
|
||||
width: 88,
|
||||
align: "center",
|
||||
status: {
|
||||
true: { type: "success", text: "是" },
|
||||
false: { type: "info", text: "否" },
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: "created_time",
|
||||
label: "创建时间",
|
||||
minWidth: 170,
|
||||
sortable: true,
|
||||
showOverflowTooltip: true,
|
||||
},
|
||||
{
|
||||
prop: "operation",
|
||||
label: "操作",
|
||||
width: 160,
|
||||
fixed: "right",
|
||||
align: "center",
|
||||
formatter: (row: WorkflowNodeTypeTable) => formatNodeTypeOperationCell(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
async function handleSearchBarSearch(params: NodeTypeSearchForm) {
|
||||
await searchBarRef.value?.validate?.();
|
||||
replaceSearchParams(buildNodeTypeReplaceParams(params));
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function onResetSearch() {
|
||||
searchForm.value = {
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
category: undefined,
|
||||
created_id: undefined,
|
||||
updated_id: undefined,
|
||||
created_time: undefined,
|
||||
updated_time: undefined,
|
||||
};
|
||||
await resetSearchParams();
|
||||
}
|
||||
|
||||
// ─── Codemirror ──────────────────────────────────────────────────
|
||||
|
||||
const codeEditorOptions: EditorConfiguration = {
|
||||
mode: "python",
|
||||
lineNumbers: true,
|
||||
smartIndent: true,
|
||||
indentUnit: 4,
|
||||
tabSize: 4,
|
||||
theme: "dracula",
|
||||
lineWrapping: true,
|
||||
autofocus: false,
|
||||
};
|
||||
|
||||
const codeEditorRef = ref<CmComponentRef>();
|
||||
|
||||
// ─── 表单 ────────────────────────────────────────────────────────
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogTitle = ref("新增节点类型");
|
||||
const editingId = ref<number | null>(null);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref<InstanceType<typeof FaForm> | null>(null);
|
||||
const nodeTypeFormRenderKey = ref(0);
|
||||
|
||||
const defaultForm = (): WorkflowNodeTypeForm => ({
|
||||
name: "",
|
||||
code: "",
|
||||
category: "action",
|
||||
func: `def handler(*args, **kwargs):
|
||||
"""
|
||||
编写你的处理逻辑
|
||||
可访问:
|
||||
- args: 位置参数
|
||||
- kwargs: 关键字参数
|
||||
"""
|
||||
print("handler executed")
|
||||
return {"status": "success"}
|
||||
`,
|
||||
args: "",
|
||||
kwargs: "{}",
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const formData = ref<WorkflowNodeTypeForm>(defaultForm());
|
||||
const argsList = ref<string[]>([]);
|
||||
const kwargsList = ref<{ key: string; value: string }[]>([]);
|
||||
|
||||
const nodeTypeDialogFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
label: "名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { maxlength: 128, showWordLimit: true },
|
||||
},
|
||||
{
|
||||
label: "编码",
|
||||
key: "code",
|
||||
type: "input",
|
||||
span: 24,
|
||||
props: { maxlength: 64, showWordLimit: true, disabled: !!editingId.value },
|
||||
},
|
||||
{
|
||||
label: "分类",
|
||||
key: "category",
|
||||
type: "select",
|
||||
span: 24,
|
||||
props: {
|
||||
style: { width: "100%" },
|
||||
options: [
|
||||
{ label: "触发器", value: "trigger" },
|
||||
{ label: "动作", value: "action" },
|
||||
{ label: "条件", value: "condition" },
|
||||
{ label: "控制", value: "control" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "位置参数",
|
||||
key: "args",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "关键字参数",
|
||||
key: "kwargs",
|
||||
type: "input",
|
||||
span: 24,
|
||||
placeholder: "",
|
||||
},
|
||||
{
|
||||
label: "排序",
|
||||
key: "sort_order",
|
||||
type: "number",
|
||||
span: 24,
|
||||
props: { min: 0 },
|
||||
},
|
||||
{
|
||||
label: "启用",
|
||||
key: "is_active",
|
||||
type: "switch",
|
||||
span: 24,
|
||||
},
|
||||
]);
|
||||
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
code: [{ required: true, message: "请输入编码", trigger: "blur" }],
|
||||
category: [{ required: true, message: "请选择分类", trigger: "change" }],
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
Object.assign(formData.value, defaultForm());
|
||||
editingId.value = null;
|
||||
argsList.value = [];
|
||||
kwargsList.value = [];
|
||||
formRef.value?.resetFields();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
function handleCloseDialog() {
|
||||
resetForm();
|
||||
}
|
||||
|
||||
function handleDialogOpened() {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
codeEditorRef.value?.refresh?.();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await openDialog();
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDialog(id?: number) {
|
||||
resetForm();
|
||||
dialogTitle.value = id ? "编辑节点类型" : "新增节点类型";
|
||||
editingId.value = id ?? null;
|
||||
if (id) {
|
||||
try {
|
||||
const res = await WorkflowNodeTypeAPI.getWorkflowNodeTypeDetail(id);
|
||||
const d = res.data?.data as WorkflowNodeTypeTable | undefined;
|
||||
if (d) {
|
||||
formData.value.name = d.name || "";
|
||||
formData.value.code = d.code || "";
|
||||
formData.value.category = (d.category as WorkflowNodeTypeForm["category"]) || "action";
|
||||
formData.value.func = d.func || "";
|
||||
formData.value.sort_order = d.sort_order ?? 0;
|
||||
formData.value.is_active = d.is_active ?? true;
|
||||
argsList.value = d.args ? d.args.split(",").map((v: string) => v.trim()) : [];
|
||||
kwargsList.value = d.kwargs
|
||||
? Object.entries(JSON.parse(d.kwargs)).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
} catch {
|
||||
/* 已由全局拦截器提示 */
|
||||
return;
|
||||
}
|
||||
}
|
||||
nodeTypeFormRenderKey.value += 1;
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
|
||||
if (!formData.value.func || !formData.value.func.trim()) {
|
||||
ElMessage.error("请输入 handler 代码");
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const submitData = {
|
||||
...formData.value,
|
||||
args: argsList.value.filter((v) => v.trim()).join(",") || undefined,
|
||||
kwargs:
|
||||
kwargsList.value.filter((v) => v.key.trim()).length > 0
|
||||
? JSON.stringify(
|
||||
Object.fromEntries(
|
||||
kwargsList.value.filter((v) => v.key.trim()).map((v) => [v.key, v.value])
|
||||
)
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
if (editingId.value) {
|
||||
await WorkflowNodeTypeAPI.updateWorkflowNodeType(editingId.value, submitData);
|
||||
dialogVisible.value = false;
|
||||
await refreshUpdate();
|
||||
} else {
|
||||
await WorkflowNodeTypeAPI.createWorkflowNodeType(submitData);
|
||||
dialogVisible.value = false;
|
||||
await refreshCreate();
|
||||
}
|
||||
} catch {
|
||||
/* 接口错误已由拦截器提示 */
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,915 @@
|
||||
<template>
|
||||
<div class="fa-full-height">
|
||||
<FaSearchBar
|
||||
v-show="showSearchBar"
|
||||
ref="searchBarRef"
|
||||
v-model="searchForm"
|
||||
:items="transferSearchItems"
|
||||
:rules="searchBarRules"
|
||||
:is-expand="false"
|
||||
:show-expand="true"
|
||||
:show-reset="true"
|
||||
:show-search="true"
|
||||
:default-expanded="false"
|
||||
@search="handleSearchBarSearch"
|
||||
@reset="onResetSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="fa-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
|
||||
<FaTableHeader
|
||||
v-model:showSearchBar="showSearchBar"
|
||||
v-model:columns="columnChecks"
|
||||
:loading="loading"
|
||||
@refresh="refreshTasks"
|
||||
>
|
||||
<template #left>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="text-base font-semibold">传输任务列表</span>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.25 text-xs"
|
||||
:class="wsConnected ? 'text-(--el-color-success)' : 'text-(--el-color-info)'"
|
||||
>
|
||||
<i
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="wsConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'"
|
||||
/>{{ wsConnected ? "实时连接" : "连接中…" }}
|
||||
</span>
|
||||
<el-button type="primary" @click="openCreate">新建传输任务</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</FaTableHeader>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<FaTable
|
||||
:data="tasks"
|
||||
:columns="columns"
|
||||
:pagination="{ current: page, size: pageSize, total }"
|
||||
stripe
|
||||
border
|
||||
height="100%"
|
||||
@pagination:size-change="onPageSizeChange"
|
||||
@pagination:current-change="onPageChange"
|
||||
>
|
||||
<template #task_type="{ row }">
|
||||
<el-tag :type="row.task_type === 'chain' ? 'warning' : 'primary'" size="small">
|
||||
{{ row.task_type === "chain" ? "链式" : "多目标" }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #source="{ row }">
|
||||
<div class="min-w-0">
|
||||
<div>
|
||||
<el-tag v-if="row.source_type === 'local'" size="small" type="success">本地</el-tag>
|
||||
<el-tag v-else size="small" type="info">{{
|
||||
protoText(sourceById(row.source_id))
|
||||
}}</el-tag>
|
||||
<span class="ml-1 text-[#606266]">{{
|
||||
row.source_type === "local"
|
||||
? row.source_name || "本地文件"
|
||||
: sourceName(row.source_id)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="row.source_path" class="mt-0.5 truncate text-xs text-[#909399]">
|
||||
{{ row.source_path }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #target="{ row }">
|
||||
<span class="text-[#606266]" :title="targetFullText(row as TransferTaskItem)">{{
|
||||
targetShortText(row as TransferTaskItem)
|
||||
}}</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="statusTypeMap[row.status as TransferStatus] || 'info'">{{
|
||||
statusText[row.status as TransferStatus] || row.status
|
||||
}}</el-tag>
|
||||
</template>
|
||||
<template #progress="{ row }">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<el-progress
|
||||
:percentage="row.progress || 0"
|
||||
:status="row.status === 'failed' ? 'exception' : undefined"
|
||||
/>
|
||||
<div
|
||||
v-if="row.status === 'running' || row.speed > 0"
|
||||
class="flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<span
|
||||
class="shrink-0 rounded-[3px] bg-(--el-color-success) px-1.25 py-0.5 text-[11px] leading-none text-white"
|
||||
>{{ formatSpeed(row.speed) }}</span
|
||||
>
|
||||
<span class="whitespace-nowrap text-(--el-text-color-secondary)"
|
||||
>{{ formatSize(row.transferred_size) }} / {{ formatSize(row.total_size) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #info="{ row }">
|
||||
<div v-if="row.error_msg" class="flex min-w-0 items-center gap-1.5">
|
||||
<el-popover placement="top-start" width="360" trigger="hover">
|
||||
<div
|
||||
class="max-h-75 overflow-auto break-all whitespace-pre-line text-[#800000] leading-[1.6]"
|
||||
>
|
||||
{{ row.error_msg }}
|
||||
</div>
|
||||
<template #reference>
|
||||
<span class="inline-block max-w-full truncate align-bottom text-[#800000]">{{
|
||||
row.error_msg
|
||||
}}</span>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button
|
||||
v-if="row.status === 'failed'"
|
||||
size="small"
|
||||
type="primary"
|
||||
link
|
||||
@click="copyMessage(row.error_msg)"
|
||||
>复制</el-button
|
||||
>
|
||||
</div>
|
||||
<span v-else class="ml-1 text-[#606266]">{{
|
||||
stepSummaryText(row as TransferTaskItem)
|
||||
}}</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<el-button size="small" link type="primary" @click="openDetail(row as TransferTaskItem)"
|
||||
>详情</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 'pending' || row.status === 'running'"
|
||||
size="small"
|
||||
link
|
||||
type="warning"
|
||||
@click="cancelTask(row as TransferTaskItem)"
|
||||
>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button size="small" link type="danger" @click="removeTask(row as TransferTaskItem)"
|
||||
>删除</el-button
|
||||
>
|
||||
</template>
|
||||
</FaTable>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<!-- 新建传输任务 -->
|
||||
<FaDialog
|
||||
v-model="createVisible"
|
||||
title="新建传输任务"
|
||||
width="640px"
|
||||
form-mode="create"
|
||||
confirm-text="开始传输"
|
||||
:confirm-loading="submitting"
|
||||
@cancel="createVisible = false"
|
||||
@confirm="submit"
|
||||
>
|
||||
<FaForm
|
||||
ref="formRef"
|
||||
v-model="form"
|
||||
:items="createFormItems"
|
||||
:rules="formRules"
|
||||
label-width="86px"
|
||||
:span="24"
|
||||
scrollbar
|
||||
max-height="70vh"
|
||||
>
|
||||
<!-- 传输流程:具名插槽接管(需 @change 触发流程填充) -->
|
||||
<template #flow_id>
|
||||
<el-select
|
||||
v-model="selectedFlowId"
|
||||
placeholder="从已配置流程自动填充(可选,仍可手动调整)"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
@change="applyFlow"
|
||||
>
|
||||
<el-option
|
||||
v-for="f in enabledFlows"
|
||||
:key="f.id"
|
||||
:value="f.id!"
|
||||
:label="flowOptionLabel(f)"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
<!-- 本地文件:componentMap 无 upload 类型,具名插槽接管 -->
|
||||
<template #local_file>
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:limit="1"
|
||||
:on-change="onFileChange"
|
||||
:on-exceed="onExceed"
|
||||
:on-remove="onFileRemove"
|
||||
style="width: 100%"
|
||||
>
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<!-- 目标列表:动态增删行,具名插槽接管 -->
|
||||
<template #targets>
|
||||
<div style="width: 100%">
|
||||
<div v-for="(t, idx) in form.targets" :key="idx" class="mb-2 flex items-center gap-2">
|
||||
<el-tag
|
||||
v-if="form.task_type === 'chain'"
|
||||
size="small"
|
||||
:type="idx === 0 ? 'primary' : 'warning'"
|
||||
class="shrink-0"
|
||||
>
|
||||
#{{ idx + 1 }}
|
||||
</el-tag>
|
||||
<el-select
|
||||
v-model="t.target_id"
|
||||
placeholder="目标存储源"
|
||||
filterable
|
||||
style="width: 250px"
|
||||
>
|
||||
<el-option
|
||||
v-for="s in enabledSources"
|
||||
:key="s.id"
|
||||
:label="`${s.name}(${s.protocol}${s.host ? ' · ' + s.host : ''})`"
|
||||
:value="s.id!"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="t.target_path"
|
||||
:placeholder="
|
||||
selectedFlowId && !t.target_path
|
||||
? '目标节点未配置默认源目录,请填写目标路径'
|
||||
: '目标路径(对象存储填 Key 前缀)'
|
||||
"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-button link type="danger" @click="removeTarget(idx)">删除</el-button>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<el-button type="primary" plain size="small" @click="addTarget"
|
||||
>+ 添加目标</el-button
|
||||
>
|
||||
<span
|
||||
v-if="form.task_type === 'chain' && form.targets.length > 1"
|
||||
class="text-[#909399]"
|
||||
>
|
||||
将按顺序执行:{{ flowPreview }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</FaForm>
|
||||
</FaDialog>
|
||||
|
||||
<!-- 传输任务详情 -->
|
||||
<FaDialog
|
||||
v-model="detailVisible"
|
||||
title="传输任务详情"
|
||||
width="920px"
|
||||
form-mode="detail"
|
||||
@close="detailVisible = false"
|
||||
>
|
||||
<template v-if="detailRow">
|
||||
<FaDescriptions
|
||||
:column="2"
|
||||
:span="1"
|
||||
size="small"
|
||||
label-width="200px"
|
||||
:data="detailRowData"
|
||||
:items="transferDetailItems"
|
||||
max-height="70vh"
|
||||
>
|
||||
<template #name="{ row }">
|
||||
<el-tooltip :content="String(row?.name ?? '')" placement="top" :show-after="100">
|
||||
<div class="max-w-45 truncate">{{ row?.name }}</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #source="{ row }">
|
||||
<el-tooltip
|
||||
:content="row ? sourceShortText(row as unknown as TransferTaskItem) : ''"
|
||||
placement="top"
|
||||
:show-after="100"
|
||||
>
|
||||
<div class="max-w-55 truncate">
|
||||
{{ row ? sourceShortText(row as unknown as TransferTaskItem) : "" }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<template #total_size="{ row }">{{ formatSize(row?.total_size as number) }}</template>
|
||||
<template #speed="{ row }">{{ formatSpeed(row?.speed as number) }}</template>
|
||||
<template #started_at="{ row }">{{ formatTime(row?.started_at as string) }}</template>
|
||||
<template #finished_at="{ row }">{{ formatTime(row?.finished_at as string) }}</template>
|
||||
</FaDescriptions>
|
||||
<div
|
||||
v-if="detailRow.error_msg"
|
||||
class="mt-2.5 break-all rounded bg-(--el-color-danger-light-9) px-3 py-2 text-xs text-(--el-color-danger)"
|
||||
>
|
||||
{{ detailRow.error_msg }}
|
||||
</div>
|
||||
<div
|
||||
class="mb-2.5 mt-3.5 border-t border-(--el-border-color-lighter) pt-3 text-[13px] font-semibold text-(--el-text-color-primary)"
|
||||
>
|
||||
传输步骤
|
||||
</div>
|
||||
<div v-if="detailSteps.length" class="flex flex-col gap-2">
|
||||
<template v-for="(step, idx) in detailSteps" :key="step.id ?? idx">
|
||||
<div
|
||||
class="rounded-md border border-(--el-border-color-lighter) bg-(--el-fill-color-lighter) px-3 py-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<el-tag :type="idx === 0 ? 'primary' : 'warning'" size="small"
|
||||
>#{{ idx + 1 }}</el-tag
|
||||
>
|
||||
<el-tag :type="statusTypeMap[step.status] || 'info'" size="small">{{
|
||||
statusText[step.status] || step.status
|
||||
}}</el-tag>
|
||||
<span class="ml-auto text-xs text-(--el-text-color-secondary)"
|
||||
>{{ step.progress }}% · {{ formatSpeed(step.speed) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-1.5 break-all text-xs text-(--el-text-color-regular)">
|
||||
<span
|
||||
class="mr-1.5 inline-block rounded-[3px] bg-(--el-color-info) px-1.25 py-0.5 text-[11px] leading-none text-white"
|
||||
>源</span
|
||||
>{{ sourceName(step.source_id) }} · {{ step.source_path || "—" }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 text-center text-[14px] font-bold text-(--el-text-color-placeholder)"
|
||||
>
|
||||
↓
|
||||
</div>
|
||||
<div class="mt-1.5 break-all text-xs text-(--el-text-color-regular)">
|
||||
<span
|
||||
class="mr-1.5 inline-block rounded-[3px] bg-(--el-color-primary) px-1.25 py-0.5 text-[11px] leading-none text-white"
|
||||
>目标</span
|
||||
>{{ sourceName(step.target_id) }} · {{ step.target_path }}
|
||||
</div>
|
||||
<div v-if="step.error_msg" class="mt-1.5 break-all text-xs text-(--el-color-danger)">
|
||||
{{ step.error_msg }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="idx < detailSteps.length - 1"
|
||||
class="text-center text-[18px] font-bold text-(--el-text-color-placeholder)"
|
||||
>
|
||||
→
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<el-empty v-else description="暂无步骤" :image-size="60" />
|
||||
</template>
|
||||
</FaDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TransferAPI, {
|
||||
TransferSocket,
|
||||
type TransferPushMessage,
|
||||
type TransferSourceType,
|
||||
type TransferStatus,
|
||||
type TransferTaskItem,
|
||||
type TransferTaskType,
|
||||
} from "@/api/module_task/workflow/transfer";
|
||||
import NodeAPI, { type SourceTable } from "@/api/module_task/workflow/node";
|
||||
import FlowAPI, { type FlowTable } from "@/api/module_task/workflow/flow";
|
||||
import FaDescriptions, {
|
||||
type DescriptionsItem,
|
||||
} from "@/components/display/fa-descriptions/index.vue";
|
||||
import type { FormItem } from "@/components/forms/fa-form/index.vue";
|
||||
import type { ColumnOption } from "@/types/component";
|
||||
import type { FormItemRule, FormRules } from "element-plus";
|
||||
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
|
||||
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
|
||||
|
||||
// ── 存储源 ────────────────────────────────────────────────────────────
|
||||
const sources = ref<SourceTable[]>([]);
|
||||
const sourceMap = computed<Record<number, SourceTable>>(() => {
|
||||
const map: Record<number, SourceTable> = {};
|
||||
sources.value.forEach((s) => {
|
||||
if (s.id) map[s.id] = s;
|
||||
});
|
||||
return map;
|
||||
});
|
||||
/** 可用的存储源(status 0=启用 1=停用) */
|
||||
const enabledSources = computed(() => sources.value.filter((s) => s.status !== 1));
|
||||
const sourceById = (id?: number | null) => (id ? sourceMap.value[id] : undefined);
|
||||
const sourceName = (id?: number | null) => (id ? sourceMap.value[id]?.name || `#${id}` : "—");
|
||||
const protoText = (s?: SourceTable) => s?.protocol || "?";
|
||||
|
||||
async function loadSources() {
|
||||
const { data } = await NodeAPI.listNode({});
|
||||
sources.value = data.data;
|
||||
}
|
||||
|
||||
// ── 传输流程 ──────────────────────────────────────────────────────────
|
||||
const flows = ref<FlowTable[]>([]);
|
||||
const selectedFlowId = ref<number | null>(null);
|
||||
/** 启用的流程(status 0=启用 1=停用) */
|
||||
const enabledFlows = computed(() => flows.value.filter((f) => f.status !== 1));
|
||||
|
||||
function flowOptionLabel(f: FlowTable): string {
|
||||
const sources = (f.sources || []).map((s) => sourceName(s.source_id)).join("、");
|
||||
const targets = (f.targets || []).map((t) => sourceName(t.target_id)).join("、");
|
||||
return `${f.name}(${sources || "—"} → ${targets || "—"})`;
|
||||
}
|
||||
|
||||
/** 选中流程后自动填充目标列表(手动任务表单为单源模型:仅流程为单源时自动填充源) */
|
||||
function applyFlow(id?: number | null) {
|
||||
selectedFlowId.value = id ?? null;
|
||||
const flow = flows.value.find((f) => f.id === id);
|
||||
if (!flow) return;
|
||||
form.value.task_type = flow.task_type || "parallel";
|
||||
form.value.source_type = "remote";
|
||||
const sources = flow.sources || [];
|
||||
form.value.source_id = sources.length === 1 && sources[0] ? sources[0].source_id : null;
|
||||
form.value.source_path = "";
|
||||
form.value.targets = (flow.targets || []).map((t) => ({
|
||||
target_id: t.target_id,
|
||||
target_path: t.target_path,
|
||||
}));
|
||||
if (flow.name) form.value.name = flow.name;
|
||||
formRef.value?.validateField("targets").catch(() => {});
|
||||
}
|
||||
|
||||
async function loadFlows() {
|
||||
const { data } = await FlowAPI.listFlow({});
|
||||
flows.value = data.data;
|
||||
}
|
||||
|
||||
// ── 任务列表 ──────────────────────────────────────────────────────────
|
||||
const tasks = ref<TransferTaskItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
|
||||
type TransferSearchForm = {
|
||||
name?: string;
|
||||
status?: TransferStatus;
|
||||
};
|
||||
const searchForm = ref<TransferSearchForm>({ name: undefined, status: undefined });
|
||||
const showSearchBar = ref(true);
|
||||
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
|
||||
const searchBarRules: Record<string, unknown> = {};
|
||||
|
||||
const statusOptions = computed<{ label: string; value: string }[]>(() =>
|
||||
Object.entries(statusText).map(([value, label]) => ({ label, value }))
|
||||
);
|
||||
|
||||
const transferSearchItems = computed<SearchFormItem[]>(() => [
|
||||
{
|
||||
label: "任务名称",
|
||||
key: "name",
|
||||
type: "input",
|
||||
placeholder: "请输入任务名称",
|
||||
clearable: true,
|
||||
span: 6,
|
||||
},
|
||||
{
|
||||
label: "状态",
|
||||
key: "status",
|
||||
type: "select",
|
||||
props: {
|
||||
placeholder: "全部",
|
||||
options: statusOptions.value,
|
||||
clearable: true,
|
||||
},
|
||||
span: 6,
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 表格列配置(FaTable:复杂列走具名插槽,名称/来源列透传 show-overflow-tooltip) ──
|
||||
const columnChecks = ref<ColumnOption[]>([
|
||||
{ prop: "id", label: "ID", width: 70 },
|
||||
{ prop: "name", label: "任务名称", minWidth: 150, showOverflowTooltip: true },
|
||||
{ prop: "task_type", label: "类型", width: 90, useSlot: true },
|
||||
{ prop: "source", label: "来源", minWidth: 180, showOverflowTooltip: true, useSlot: true },
|
||||
{ prop: "target", label: "目标", minWidth: 190, useSlot: true },
|
||||
{ prop: "status", label: "状态", width: 92, useSlot: true },
|
||||
{ prop: "progress", label: "进度", minWidth: 240, useSlot: true },
|
||||
{ prop: "info", label: "信息", minWidth: 180, useSlot: true },
|
||||
{ prop: "actions", label: "操作", width: 150, fixed: "right", useSlot: true },
|
||||
]);
|
||||
/** 渲染列:仅保留列设置中可见的列 */
|
||||
const columns = computed(() => columnChecks.value.filter((c) => c.visible !== false));
|
||||
|
||||
async function loadTasks() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await TransferAPI.pageTask({
|
||||
page_no: page.value,
|
||||
page_size: pageSize.value,
|
||||
name: searchForm.value.name || undefined,
|
||||
status: searchForm.value.status,
|
||||
});
|
||||
tasks.value = data.data.items || [];
|
||||
total.value = data.data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) {
|
||||
page.value = p;
|
||||
loadTasks();
|
||||
}
|
||||
function onPageSizeChange(size: number) {
|
||||
pageSize.value = size;
|
||||
page.value = 1;
|
||||
loadTasks();
|
||||
}
|
||||
function refreshTasks() {
|
||||
page.value = 1;
|
||||
loadTasks();
|
||||
}
|
||||
function handleSearchBarSearch() {
|
||||
page.value = 1;
|
||||
loadTasks();
|
||||
}
|
||||
function onResetSearch() {
|
||||
searchForm.value = { name: undefined, status: undefined };
|
||||
page.value = 1;
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
// ── 状态 / 格式化 ─────────────────────────────────────────────────────
|
||||
const statusText: Record<TransferStatus, string> = {
|
||||
pending: "等待中",
|
||||
running: "传输中",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
canceled: "已取消",
|
||||
};
|
||||
const statusTypeMap: Record<TransferStatus, "info" | "warning" | "success" | "danger"> = {
|
||||
pending: "info",
|
||||
running: "warning",
|
||||
success: "success",
|
||||
failed: "danger",
|
||||
canceled: "info",
|
||||
};
|
||||
|
||||
function formatSize(bytes?: number | null): string {
|
||||
if (!bytes || bytes <= 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let n = bytes;
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024;
|
||||
i += 1;
|
||||
}
|
||||
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
function formatSpeed(bytesPerSec: number): string {
|
||||
if (!bytesPerSec || bytesPerSec <= 0) return "0 B/s";
|
||||
return `${formatSize(bytesPerSec)}/s`;
|
||||
}
|
||||
function formatTime(t?: string | null): string {
|
||||
if (!t) return "—";
|
||||
const d = new Date(t);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
// ── 任务展示辅助 ──────────────────────────────────────────────────────
|
||||
function sourceShortText(row: TransferTaskItem): string {
|
||||
const who = row.source_type === "local" ? "本地文件" : sourceName(row.source_id);
|
||||
const path = row.source_path || row.source_name || "—";
|
||||
return `${who} · ${path}`;
|
||||
}
|
||||
function targetShortText(row: TransferTaskItem): string {
|
||||
const steps = row.steps || [];
|
||||
const first = steps[0];
|
||||
if (!first) return "—";
|
||||
const text = `${sourceName(first.target_id)}/${first.target_path}`;
|
||||
return steps.length > 1 ? `${text} 等 ${steps.length} 个目标` : text;
|
||||
}
|
||||
function targetFullText(row: TransferTaskItem): string {
|
||||
const steps = row.steps || [];
|
||||
if (!steps.length) return "—";
|
||||
return steps.map((s) => `${sourceName(s.target_id)}:${s.target_path}`).join(" → ");
|
||||
}
|
||||
function stepSummaryText(row: TransferTaskItem): string {
|
||||
const steps = row.steps || [];
|
||||
const first = steps[0];
|
||||
if (!first) return "";
|
||||
if (steps.length > 1) {
|
||||
const done = steps.filter((s) => s.status === "success").length;
|
||||
return `${done}/${steps.length} 步完成`;
|
||||
}
|
||||
return first.status === "success" ? "传输完成" : "";
|
||||
}
|
||||
async function copyMessage(message: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = message;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
ElMessage.success("报错信息已复制");
|
||||
}
|
||||
|
||||
// ── 新建任务对话框 ────────────────────────────────────────────────────
|
||||
const createVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref();
|
||||
|
||||
interface TargetRow {
|
||||
target_id: number | null;
|
||||
target_path: string;
|
||||
}
|
||||
const form = ref({
|
||||
name: "",
|
||||
task_type: "parallel" as TransferTaskType,
|
||||
source_type: "remote" as TransferSourceType,
|
||||
source_id: null as number | null,
|
||||
source_path: "",
|
||||
local_file: null as File | null,
|
||||
targets: [] as TargetRow[],
|
||||
});
|
||||
|
||||
/** 创建表单 items:源相关字段随 source_type 显隐;flow/targets/local_file 走具名插槽 */
|
||||
const createFormItems = computed<FormItem[]>(() => [
|
||||
{
|
||||
key: "name",
|
||||
label: "任务名称",
|
||||
type: "input",
|
||||
props: { placeholder: "例如:传输_文件A_20260825" },
|
||||
},
|
||||
{ key: "flow_id", label: "传输流程" /* #flow_id 插槽接管 */ },
|
||||
{
|
||||
key: "task_type",
|
||||
label: "传输方式",
|
||||
type: "radiogroup",
|
||||
props: {
|
||||
options: [
|
||||
{ label: "多目标(同时传输到多个存储源)", value: "parallel" },
|
||||
{ label: "链式(按目标顺序逐跳传输)", value: "chain" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "source_type",
|
||||
label: "源类型",
|
||||
type: "radiogroup",
|
||||
props: {
|
||||
options: [
|
||||
{ label: "远端存储源", value: "remote" },
|
||||
{ label: "本地文件", value: "local" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "source_id",
|
||||
label: "源存储源",
|
||||
type: "select",
|
||||
hidden: form.value.source_type !== "remote",
|
||||
props: {
|
||||
placeholder: "选择源存储源",
|
||||
filterable: true,
|
||||
style: "width: 100%",
|
||||
options: enabledSources.value.map((s) => ({
|
||||
label: `${s.name}(${s.protocol}${s.host ? " · " + s.host : ""})`,
|
||||
value: s.id!,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "source_path",
|
||||
label: "源路径",
|
||||
type: "input",
|
||||
hidden: form.value.source_type !== "remote",
|
||||
props: { placeholder: "源文件/目录在存储源中的路径(对象存储填 Key)" },
|
||||
},
|
||||
{
|
||||
key: "local_file",
|
||||
label: "本地文件",
|
||||
hidden: form.value.source_type === "remote" /* #local_file 插槽接管 */,
|
||||
},
|
||||
{ key: "targets", label: "目标" /* #targets 插槽接管 */ },
|
||||
]);
|
||||
|
||||
function timeStamp(): string {
|
||||
const d = new Date();
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
form.value.name = `传输_${timeStamp()}`;
|
||||
form.value.task_type = "parallel";
|
||||
form.value.source_type = "remote";
|
||||
form.value.source_id = null;
|
||||
form.value.source_path = "";
|
||||
form.value.local_file = null;
|
||||
form.value.targets = [{ target_id: null, target_path: "" }];
|
||||
selectedFlowId.value = null;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function addTarget() {
|
||||
form.value.targets.push({ target_id: null, target_path: "" });
|
||||
}
|
||||
function removeTarget(idx: number) {
|
||||
form.value.targets.splice(idx, 1);
|
||||
formRef.value?.validateField("targets").catch(() => {});
|
||||
}
|
||||
function onFileChange(file: { raw?: File }) {
|
||||
if (!file.raw) return;
|
||||
form.value.local_file = file.raw;
|
||||
formRef.value?.validateField("local_file").catch(() => {});
|
||||
}
|
||||
function onFileRemove() {
|
||||
form.value.local_file = null;
|
||||
}
|
||||
function onExceed() {
|
||||
ElMessage.warning("仅支持选择一个文件");
|
||||
}
|
||||
|
||||
const validateTargets: NonNullable<FormItemRule["validator"]> = (_rule, _value, callback) => {
|
||||
if (!form.value.targets.length) {
|
||||
callback(new Error("请至少添加一个目标存储源"));
|
||||
return;
|
||||
}
|
||||
for (const t of form.value.targets) {
|
||||
if (!t.target_id) {
|
||||
callback(new Error("请选择目标存储源"));
|
||||
return;
|
||||
}
|
||||
if (!t.target_path.trim()) {
|
||||
callback(new Error("请填写目标路径"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const formRules = computed<FormRules>(() => {
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: "请填写任务名称", trigger: "blur" }],
|
||||
targets: [
|
||||
{ required: true, message: "请至少添加一个目标存储源", trigger: "change" },
|
||||
{ validator: validateTargets, trigger: "change" },
|
||||
],
|
||||
};
|
||||
if (form.value.source_type === "remote") {
|
||||
rules.source_id = [{ required: true, message: "请选择源存储源", trigger: "change" }];
|
||||
rules.source_path = [{ required: true, message: "请填写源路径", trigger: "blur" }];
|
||||
} else {
|
||||
rules.local_file = [{ required: true, message: "请选择本地文件", trigger: "change" }];
|
||||
}
|
||||
return rules;
|
||||
});
|
||||
|
||||
/** 链式预览:源 → 目标1 → 目标2 … */
|
||||
const flowPreview = computed(() => {
|
||||
const parts: string[] = [];
|
||||
parts.push(form.value.source_type === "remote" ? sourceName(form.value.source_id) : "本地文件");
|
||||
form.value.targets.forEach((t) => parts.push(t.target_id ? sourceName(t.target_id) : "目标"));
|
||||
return parts.join(" → ");
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
// 提交前先整体校验:目标存储源与目标路径必填(选择流程带入的空目标路径也会被拦截提示)
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return; // 校验未通过,错误提示由表单字段展示
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const targets = form.value.targets.map((t) => ({
|
||||
target_id: t.target_id!,
|
||||
target_path: t.target_path.trim(),
|
||||
}));
|
||||
if (form.value.source_type === "remote") {
|
||||
await TransferAPI.createTask({
|
||||
name: form.value.name.trim(),
|
||||
task_type: form.value.task_type,
|
||||
source_type: "remote",
|
||||
source_id: form.value.source_id!,
|
||||
source_path: form.value.source_path.trim(),
|
||||
targets,
|
||||
});
|
||||
} else {
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.value.local_file!);
|
||||
fd.append("name", form.value.name.trim());
|
||||
fd.append("task_type", form.value.task_type);
|
||||
fd.append("targets", JSON.stringify(targets));
|
||||
await TransferAPI.createLocalTask(fd);
|
||||
}
|
||||
createVisible.value = false;
|
||||
page.value = 1;
|
||||
await loadTasks();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 取消 / 删除 ───────────────────────────────────────────────────────
|
||||
async function cancelTask(row: TransferTaskItem) {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认取消任务「${row.name}」?`, "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await TransferAPI.cancelTask(row.id);
|
||||
await loadTasks();
|
||||
}
|
||||
async function removeTask(row: TransferTaskItem) {
|
||||
if (!row.id) return;
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除任务「${row.name}」?`, "提示", { type: "warning" });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await TransferAPI.deleteTask([row.id]);
|
||||
await loadTasks();
|
||||
}
|
||||
|
||||
// ── 任务详情 ──────────────────────────────────────────────────────────
|
||||
const detailVisible = ref(false);
|
||||
const detailRow = ref<TransferTaskItem | null>(null);
|
||||
const detailSteps = computed(() => detailRow.value?.steps || []);
|
||||
/** FaDescriptions 数据源:TransferTaskItem 无索引签名,转为 Record 供组件读取 */
|
||||
const detailRowData = computed<Record<string, unknown> | null>(() =>
|
||||
detailRow.value ? (detailRow.value as unknown as Record<string, unknown>) : null
|
||||
);
|
||||
/** 详情字段:类型/状态用 tag 渲染,其余格式化字段走具名插槽 */
|
||||
const transferDetailItems: DescriptionsItem[] = [
|
||||
{ label: "任务名称", prop: "name", slot: "name" },
|
||||
{
|
||||
label: "类型",
|
||||
prop: "task_type",
|
||||
tag: {
|
||||
map: {
|
||||
chain: { type: "warning", text: "链式" },
|
||||
parallel: { type: "primary", text: "多目标" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ label: "来源", prop: "source", slot: "source" },
|
||||
{
|
||||
label: "状态",
|
||||
prop: "status",
|
||||
tag: {
|
||||
map: {
|
||||
pending: { type: "info", text: "等待中" },
|
||||
running: { type: "warning", text: "传输中" },
|
||||
success: { type: "success", text: "成功" },
|
||||
failed: { type: "danger", text: "失败" },
|
||||
canceled: { type: "info", text: "已取消" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ label: "总大小", prop: "total_size", slot: "total_size" },
|
||||
{ label: "实时速度", prop: "speed", slot: "speed" },
|
||||
{ label: "开始时间", prop: "started_at", slot: "started_at" },
|
||||
{ label: "结束时间", prop: "finished_at", slot: "finished_at" },
|
||||
];
|
||||
async function openDetail(row: TransferTaskItem) {
|
||||
detailRow.value = row;
|
||||
detailVisible.value = true;
|
||||
if (!row.id) return;
|
||||
const { data } = await TransferAPI.detailTask(row.id);
|
||||
detailRow.value = data.data;
|
||||
}
|
||||
|
||||
// ── WebSocket 实时进度 ────────────────────────────────────────────────
|
||||
const wsConnected = ref(false);
|
||||
let transferSocket: TransferSocket | null = null;
|
||||
|
||||
function onMessage(msg: TransferPushMessage) {
|
||||
if (msg.type !== "task_update") return;
|
||||
const item = msg.data;
|
||||
const idx = tasks.value.findIndex((t) => t.id === item.id);
|
||||
if (idx >= 0) {
|
||||
tasks.value[idx] = item;
|
||||
} else {
|
||||
loadTasks();
|
||||
}
|
||||
// 详情对话框随 WS 实时刷新(无需关闭重开)
|
||||
if (detailVisible.value && detailRow.value?.id === item.id) {
|
||||
detailRow.value = item;
|
||||
}
|
||||
}
|
||||
function onStatus(connected: boolean) {
|
||||
wsConnected.value = connected;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSources();
|
||||
loadFlows();
|
||||
loadTasks();
|
||||
transferSocket = new TransferSocket({ onMessage, onStatus });
|
||||
transferSocket.connect();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
transferSocket?.disconnect();
|
||||
transferSocket = null;
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user