refactor: 重构存储与工作流模块,调整目录结构与初始化逻辑

1.  迁移存储模块功能到工作流模块,合并冗余代码
2.  调整环境配置加载路径与初始化脚本目录
3.  更新文档与前端组件代码适配重构
4.  新增工作流相关CRUD、模型与迁移文件
5.  移除过期模块与冗余代码,优化日志配置
This commit is contained in:
zhangtao
2026-09-03 21:55:31 +08:00
parent 001d1bdf4c
commit 3f472d1e89
212 changed files with 15329 additions and 11742 deletions
@@ -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;
}
+14 -5
View File
@@ -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;
};
@@ -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;
});
+4 -1
View File
@@ -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";
}
+20 -2
View File
@@ -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);
},
});
},
});
+108 -108
View File
@@ -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);
-21
View File
@@ -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("个人资料已保存");
+137 -126
View File
@@ -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>
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>