feat: add storage module, internal chat and workflow enhancements

- 新增多协议存储模块(支持FTP/SFTP/S3/OBS/OSS/COS/本地存储),包含存储源管理、文件操作、传输任务功能
- 新增系统内部聊天模块,支持WebSocket实时通信与未读统计
- 优化工作流节点系统,支持内置业务节点并完善配置面板
- 修复健康检查接口路径与菜单角标显示逻辑
- 新增SQLite WAL模式与busy_timeout配置优化并发性能
- 更新依赖与文档,支持模块裁剪配置
This commit is contained in:
zhangtao
2026-08-19 23:20:39 +08:00
parent 11160b05e4
commit 001d1bdf4c
72 changed files with 7672 additions and 550 deletions
@@ -0,0 +1,71 @@
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;
}
@@ -0,0 +1,111 @@
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;
}
+278
View File
@@ -0,0 +1,278 @@
import { request } from "@utils";
import { Auth } from "@utils/auth";
const API_PATH = "/system/chat";
const ChatAPI = {
/** 会话列表 */
listConversations() {
return request<ApiResponse<ConversationItem[]>>({
url: `${API_PATH}/conversations`,
method: "get",
});
},
/** 历史消息(before_id 为分页游标,取该 ID 之前更早的消息) */
listMessages(params: ChatMessageQuery) {
return request<ApiResponse<{ items: ChatMessageItem[]; has_more: boolean }>>({
url: `${API_PATH}/messages`,
method: "get",
params,
});
},
/** 发送消息 */
sendMessage(body: { conversation_type: number; receiver_id: number; content: string }) {
return request<ApiResponse<ChatMessageItem>>({
url: `${API_PATH}/messages`,
method: "post",
data: body,
});
},
/** 标记已读 */
markRead(body: { conversation_type: number; receiver_id: number }) {
return request<ApiResponse>({
url: `${API_PATH}/read`,
method: "post",
data: body,
});
},
/** 用户选择器 */
listUsers(keyword?: string) {
return request<ApiResponse<ChatUserItem[]>>({
url: `${API_PATH}/users`,
method: "get",
params: { keyword },
});
},
/** 创建群组 */
createGroup(body: { name: string; member_ids: number[] }) {
return request<ApiResponse<ChatGroupDetail>>({
url: `${API_PATH}/groups`,
method: "post",
data: body,
});
},
/** 群组详情 */
getGroupDetail(groupId: number) {
return request<ApiResponse<ChatGroupDetail>>({
url: `${API_PATH}/groups/${groupId}`,
method: "get",
});
},
/** 修改群组 */
updateGroup(groupId: number, body: { name?: string; announcement?: string }) {
return request<ApiResponse>({
url: `${API_PATH}/groups/${groupId}`,
method: "put",
data: body,
});
},
/** 解散群组 */
deleteGroup(groupId: number) {
return request<ApiResponse>({
url: `${API_PATH}/groups/${groupId}`,
method: "delete",
});
},
/** 添加群成员 */
addGroupMembers(groupId: number, memberIds: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/groups/${groupId}/members`,
method: "post",
data: { member_ids: memberIds },
});
},
/** 移除群成员 */
removeGroupMembers(groupId: number, memberIds: number[]) {
return request<ApiResponse>({
url: `${API_PATH}/groups/${groupId}/members`,
method: "delete",
data: memberIds,
});
},
/** 退出群组 */
quitGroup(groupId: number) {
return request<ApiResponse>({
url: `${API_PATH}/groups/${groupId}/quit`,
method: "post",
});
},
};
export default ChatAPI;
/** 会话项 */
export interface ConversationItem {
id: number | null;
conversation_type: 1 | 2;
name: string;
avatar?: string | null;
online: boolean;
member_count: number;
last_message?: string | null;
last_time?: string | null;
unread: number;
}
/** 消息项 */
export interface ChatMessageItem {
id: number;
conversation_type: 1 | 2;
sender_id: number;
sender_name?: string;
sender_avatar?: string | null;
receiver_id: number;
content: string;
status: 0 | 1;
created_time: string;
}
/** 历史消息查询 */
export interface ChatMessageQuery {
conversation_type: 1 | 2;
receiver_id: number;
before_id?: number;
page_size?: number;
}
/** 用户项 */
export interface ChatUserItem {
id: number;
name: string;
username: string;
avatar?: string | null;
}
/** 群组详情 */
export interface ChatGroupDetail {
id: number;
name: string;
avatar?: string | null;
announcement?: string | null;
owner_id: number;
member_count: number;
members: ChatUserItem[];
}
/** 服务端推送消息 */
export type ChatPushMessage =
| { type: "message"; data: ChatMessageItem }
| { type: "read"; conversation_type: 1; peer_id: number; target_id: number }
| { type: "presence"; user_id: number; online: boolean };
/** 聊天 WebSocket 客户端(轻量封装:自动重连 + 心跳) */
export class ChatSocket {
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: 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;
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());
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;
}
}
@@ -61,12 +61,13 @@ const WorkflowNodeTypeAPI = {
export default WorkflowNodeTypeAPI;
export { WorkflowNodeTypeAPI };
/** 节点类型选项(对应后端 task_workflow_node_type) */
/** 节点类型选项(对应后端 task_workflow_node_type,内置业务节点 id 为 null) */
export interface WorkflowNodeTypeOption {
id: number;
id: number | null;
code: string;
name: string;
category: string;
description?: string;
args?: string;
kwargs?: string;
}
@@ -149,7 +149,14 @@
class="chat-button relative"
@click="openChat"
>
<div class="breathing-dot absolute top-2 right-2 size-1.5 bg-success! rounded-full"></div>
<ElBadge
v-if="chatStore.unreadTotal > 0"
:value="chatStore.unreadTotal > 99 ? '99+' : chatStore.unreadTotal"
:max="99"
class="absolute top-0 right-0"
>
<div class="size-1.5"></div>
</ElBadge>
</FaIconButton>
<!-- 设置按钮 -->
@@ -188,7 +195,7 @@
<FaWorkTab />
<!-- 通知 -->
<FaNotification v-model:value="showNotice" ref="notice" />
<FaNotification v-model:value="showNotice" />
</div>
</template>
@@ -204,6 +211,7 @@ import {
useUserStore,
useNoticeStore,
useConfigStore,
useChatStore,
refreshAppCaches,
} from "@stores";
import AppConfig from "@/config";
@@ -228,6 +236,7 @@ const userStore = useUserStore();
const menuStore = useMenuStore();
const configStore = useConfigStore();
const noticeStore = useNoticeStore();
const chatStore = useChatStore();
/** 租户配置:logo_url / name */
const headerLogoSrc = computed(() => {
@@ -265,7 +274,6 @@ const { language } = storeToRefs(userStore);
const { visibleMenus: menuList } = storeToRefs(menuStore);
const showNotice = ref(false);
const notice = ref(null);
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT);
@@ -279,6 +287,8 @@ onMounted(() => {
initLanguage();
document.addEventListener("click", bodyCloseNotice);
noticeStore.getNotice();
chatStore.initChat();
chatStore.refreshUnread();
});
onUnmounted(() => {
@@ -389,7 +399,7 @@ const visibleNotice = (): void => {
};
/**
* 打开聊天窗口
* 打开聊天窗口(fa-chat-window 全局组件监听 openChat 事件)
*/
const openChat = (): void => {
mittBus.emit("openChat");
@@ -514,7 +524,7 @@ const openChat = (): void => {
animation: shrink 0.6s forwards;
}
/* 搜索触发按钮 hover 边框变主题色 */
/* 会话列表 hover 边框变主题色 */
.search-bar-trigger:hover {
border-color: var(--el-color-primary) !important;
}
@@ -527,11 +537,6 @@ const openChat = (): void => {
animation: shake 0.5s ease-in-out;
}
/* Breathing animation for chat dot */
.breathing-dot {
animation: breathing 1.5s ease-in-out infinite;
}
/* iPad breakpoint adjustments */
@media screen and (width <= 768px) {
.logo2 {
@@ -3,8 +3,8 @@
<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" class="fa-badge fa-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="fa-text-badge">
<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>
</template>
@@ -33,11 +33,11 @@
/>
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div
v-if="item.meta.showBadge"
v-if="item.meta.showBadge && !item.meta.showTextBadge"
class="fa-badge"
:style="{ right: level === 0 ? '10px' : '20px' }"
/>
<div v-if="item.meta.showTextBadge && level !== 0" class="fa-text-badge">
<div v-if="item.meta.showBadge && item.meta.showTextBadge && level !== 0" class="fa-text-badge">
{{ item.meta.showTextBadge }}
</div>
</ElMenuItem>
@@ -37,7 +37,7 @@
>
{{ item.formattedTitle }}
</span>
<div v-if="item.meta.showBadge" 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,7 @@
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="fa-badge" :style="'right: 10px'" />
<div v-if="item.meta.showBadge && !item.meta.showTextBadge" class="fa-badge" :style="'right: 10px'" />
</template>
<FaSidebarSubmenu
@@ -37,7 +37,7 @@
/>
</div>
<div
v-show="item.meta.showBadge && level === 0 && !menuOpen"
v-show="item.meta.showBadge && !item.meta.showTextBadge && level === 0 && !menuOpen"
class="fa-badge"
:style="'right: 5px'"
/>
@@ -46,8 +46,8 @@
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="fa-badge" />
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="fa-text-badge">
<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">
{{ item.meta.showTextBadge }}
</div>
</template>
+1
View File
@@ -18,6 +18,7 @@ export function initStore(app: App<Element>) {
}
export * from "./modules/app.store";
export * from "./modules/chat.store";
export * from "./modules/config.store";
export * from "./modules/dict.store";
export * from "./modules/menu.store";
@@ -0,0 +1,134 @@
/**
* 聊天状态管理模块
*
* ## 主要功能
*
* - 全局未读数统计(顶部角标)
* - 聊天 WebSocket 生命周期管理
* - 在线状态缓存
* - 当前会话消息实时分发
*
* @module store/modules/chat.store
*/
import { store } from "@stores";
import ChatAPI, { ChatMessageItem, ChatPushMessage, ChatSocket } from "@/api/module_system/chat";
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>>({});
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;
}
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);
}
@@ -13,6 +13,7 @@ import type { MenuTable } from "@/api/module_system/menu";
import type { AppRouteRecord } from "@/types/router";
import { ElNotification } from "element-plus";
import { store, useDictStore } from "@stores";
import { useChatStore } from "@/store/modules/chat.store";
import type { UserInfo } from "@/api/module_system/user";
import { ResultEnum } from "@/enums/api/result.enum";
import { resetRouteInitState, resetRouterState } from "@/router/refresh";
@@ -325,6 +326,8 @@ export const useUserStore = defineStore(
prems.value = [];
/** 登出 / 认证失效:会话结束,工作栏与 KeepAlive exclude 一并清空(pinia 持久化随之写入) */
useWorktabStore().clearAll();
/** 登出:断开聊天 WebSocket 并清空未读/在线状态 */
useChatStore(store).clearUserInfo();
}
/**
@@ -0,0 +1,353 @@
<!-- 文件管理:存储源文件浏览/上传/下载/删除 -->
<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>
@@ -0,0 +1,607 @@
<!-- 存储源管理:存储源 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>
@@ -0,0 +1,544 @@
<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 gap-2 border-b border-(--el-border-color-lighter) p-2.5">
<ElInput v-model="searchKeyword" placeholder="搜索会话" clearable size="default">
<template #prefix>
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElDropdown trigger="click" @command="handleCreate">
<ElButton type="primary" :icon="Plus" />
<template #dropdown>
<ElDropdownMenu>
<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>
<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)'"
@click="openConversation(conv)"
>
<div class="relative shrink-0">
<ElAvatar :size="40" :src="conv.avatar || defaultAvatar" :icon="UserFilled" />
<span
v-if="conv.conversation_type === 1"
class="absolute right-0 bottom-0 size-2.5 rounded-full border-2 border-white"
:class="conv.online ? 'bg-success' : 'bg-(--el-border-color)'"
></span>
</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>
</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">
{{ conv.unread > 99 ? "99+" : conv.unread }}
</span>
</div>
</div>
</div>
</ElScrollbar>
</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)">
<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 gap-2.5">
<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-xs text-(--el-text-color-secondary)">
<template v-if="currentConversation.conversation_type === 1">
{{ currentConversation.online ? "在线" : "离线" }}
</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>
<ElButton
v-if="currentConversation.conversation_type === 2"
size="small"
text
type="primary"
:icon="MoreFilled"
@click="openGroupDetail"
>
群管理
</ElButton>
</div>
</div>
<!-- 消息区 -->
<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>
</div>
<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>
<div
:class="[
'rounded-lg px-3.5 py-2.5 text-sm leading-normal wrap-break-word whitespace-pre-wrap',
msg.sender_id === myId
? 'rounded-tr-sm bg-(--el-color-primary) text-white'
: 'rounded-tl-sm bg-(--el-fill-color-light) text-(--el-text-color-primary)',
]"
>
{{ msg.content }}
</div>
</div>
</div>
</template>
</ElScrollbar>
<!-- 输入区 -->
<div class="border-t border-(--el-border-color-lighter) p-3">
<ElInput
v-model="draft"
type="textarea"
:rows="3"
resize="none"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
:disabled="sending"
@keydown.enter.exact.prevent="handleSend"
/>
<div class="mt-2 flex items-center justify-between">
<span class="text-xs text-(--el-text-color-secondary)">Enter 发送</span>
<ElButton type="primary" :loading="sending" @click="handleSend">发送</ElButton>
</div>
</div>
</template>
<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">
<template v-if="createMode === 'group'">
<div class="mb-3">
<div class="mb-1.5 text-xs text-(--el-text-color-secondary)">群名称</div>
<ElInput v-model="groupName" placeholder="请输入群名称" maxlength="64" />
</div>
<div class="mb-1.5 text-xs text-(--el-text-color-secondary)">选择成员(可选)</div>
</template>
<template v-else>
<div class="mb-1.5 text-xs text-(--el-text-color-secondary)">选择用户</div>
</template>
<ElSelect
v-model="selectedUserIds"
multiple
filterable
remote
:remote-method="searchUsers"
:loading="userLoading"
placeholder="输入姓名或用户名搜索"
class="w-full"
>
<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>
<ElButton type="primary" :loading="createLoading" @click="confirmCreate">确定</ElButton>
</template>
</ElDialog>
<!-- 群详情管理 -->
<ElDrawer v-model="groupDetailVisible" title="群管理" size="380px">
<template v-if="groupDetail">
<div class="mb-4 flex items-center gap-3">
<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>
</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)">
公告:{{ 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>
</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 class="flex items-center gap-2.5">
<ElAvatar :size="32" :src="m.avatar || defaultAvatar" :icon="UserFilled" />
<div class="leading-tight">
<div class="text-sm">{{ m.name }}</div>
<div class="text-[10px] text-(--el-text-color-secondary)">{{ m.username }}</div>
</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>
</div>
</div>
</ElScrollbar>
<div class="mt-5 border-t border-(--el-border-color-lighter) pt-4">
<template v-if="isOwner">
<ElInput v-model="announcementDraft" placeholder="修改群公告" class="mb-2" />
<div class="flex gap-2">
<ElButton type="primary" @click="saveAnnouncement">保存公告</ElButton>
<ElButton type="danger" plain @click="confirmDeleteGroup">解散群组</ElButton>
</div>
</template>
<ElButton v-else type="danger" plain @click="confirmQuitGroup">退出群组</ElButton>
</div>
</template>
</ElDrawer>
</div>
</template>
<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 type { ElScrollbar as ElScrollbarType } from "element-plus";
import ChatAPI, {
ChatMessageItem,
ChatUserItem,
ConversationItem,
ChatGroupDetail,
} from "@/api/module_system/chat";
import { useUserStore } from "@stores";
import { useChatStore } from "@/store/modules/chat.store";
import defaultAvatar from "@imgs/avatar/avatar5.webp";
defineOptions({ name: "SystemChat" });
const userStore = useUserStore();
const chatStore = useChatStore();
const myId = computed(() => userStore.basicInfo?.id ?? userStore.basicInfo?.user_id ?? 0);
// ── 会话列表 ──────────────────────────────────────────────────
const conversations = ref<ConversationItem[]>([]);
const searchKeyword = ref("");
const currentConversation = ref<ConversationItem | null>(null);
const filteredConversations = computed(() => {
const kw = searchKeyword.value.trim().toLowerCase();
if (!kw) return conversations.value;
return conversations.value.filter((c) => c.name.toLowerCase().includes(kw));
});
const isActive = (conv: ConversationItem) =>
currentConversation.value?.conversation_type === conv.conversation_type &&
currentConversation.value?.id === conv.id;
async function loadConversations() {
const response = await ChatAPI.listConversations();
conversations.value = Array.isArray(response.data?.data) ? response.data.data : [];
chatStore.unreadTotal = conversations.value.reduce((sum, c) => sum + (c.unread || 0), 0);
// 更新在线状态缓存
const online: Record<number, boolean> = {};
conversations.value.forEach((c) => {
if (c.conversation_type === 1 && c.id) online[c.id] = c.online;
});
chatStore.onlineUsers = { ...chatStore.onlineUsers, ...online };
}
// ── 聊天窗 ──────────────────────────────────────────────────
const messages = ref<ChatMessageItem[]>([]);
const draft = ref("");
const sending = ref(false);
const hasMore = ref(false);
const loadingHistory = ref(false);
const messageScrollbarRef = ref<InstanceType<typeof ElScrollbarType>>();
function scrollToBottom() {
setTimeout(() => {
const wrap = messageScrollbarRef.value?.wrapRef;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}, 50);
}
async function openConversation(conv: ConversationItem) {
if (isActive(conv)) return;
currentConversation.value = conv;
messages.value = [];
hasMore.value = false;
await loadHistory(false);
chatStore.setActiveConversation({ type: conv.conversation_type, id: conv.id! }, (msg) => {
if (messages.value.some((m) => m.id === msg.id)) return;
messages.value.push(msg);
scrollToBottom();
});
await chatStore.markRead({ type: conv.conversation_type, id: conv.id! });
conv.unread = 0;
}
async function loadHistory(appendTop = true) {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
loadingHistory.value = true;
try {
const beforeId = appendTop ? messages.value[0]?.id : undefined;
const response = await ChatAPI.listMessages({
conversation_type: conv.conversation_type,
receiver_id: conv.id,
before_id: beforeId,
page_size: 20,
});
const data = response.data?.data;
const items = data?.items ?? [];
hasMore.value = data?.has_more ?? false;
if (appendTop) {
messages.value = [...items, ...messages.value];
} else {
messages.value = items;
scrollToBottom();
}
} finally {
loadingHistory.value = false;
}
}
async function handleSend() {
const conv = currentConversation.value;
const content = draft.value.trim();
if (!conv || conv.id == null || !content) return;
sending.value = true;
try {
const response = await ChatAPI.sendMessage({
conversation_type: conv.conversation_type,
receiver_id: conv.id,
content,
});
const msg = response.data?.data;
if (msg) {
messages.value.push(msg);
conv.last_message = msg.content;
conv.last_time = msg.created_time;
scrollToBottom();
}
draft.value = "";
} finally {
sending.value = false;
}
}
// ── 时间格式化 ──────────────────────────────────────────────────
function formatTime(value?: string | null) {
if (!value) return "";
const date = new Date(value);
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
const pad = (n: number) => String(n).padStart(2, "0");
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
if (sameDay) return hm;
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${hm}`;
}
// ── 发起聊天 / 创建群 ──────────────────────────────────────────────────
const createDialogVisible = ref(false);
const createMode = ref<"private" | "group">("private");
const groupName = ref("");
const selectedUserIds = ref<number[]>([]);
const userOptions = ref<ChatUserItem[]>([]);
const userLoading = ref(false);
const createLoading = ref(false);
function handleCreate(command: string) {
createMode.value = command as "private" | "group";
groupName.value = "";
selectedUserIds.value = [];
userOptions.value = [];
createDialogVisible.value = true;
searchUsers("");
}
async function searchUsers(keyword: string) {
userLoading.value = true;
try {
const response = await ChatAPI.listUsers(keyword);
userOptions.value = Array.isArray(response.data?.data) ? response.data.data : [];
} finally {
userLoading.value = false;
}
}
async function confirmCreate() {
createLoading.value = true;
try {
if (createMode.value === "private") {
if (selectedUserIds.value.length !== 1) {
ElMessage.warning("请选择一个用户");
return;
}
const userId = selectedUserIds.value[0]!;
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)!);
return;
}
const response = await ChatAPI.listUsers();
const users = Array.isArray(response.data?.data) ? response.data.data : [];
const user = users.find((u) => u.id === userId);
const conv: ConversationItem = {
id: userId,
conversation_type: 1,
name: user?.name ?? "用户",
avatar: user?.avatar ?? null,
online: !!chatStore.onlineUsers[userId],
member_count: 0,
last_message: null,
last_time: null,
unread: 0,
};
conversations.value.unshift(conv);
createDialogVisible.value = false;
openConversation(conv);
} else {
if (!groupName.value.trim()) {
ElMessage.warning("请输入群名称");
return;
}
const response = await ChatAPI.createGroup({
name: groupName.value.trim(),
member_ids: selectedUserIds.value,
});
const detail = response.data?.data;
createDialogVisible.value = false;
ElMessage.success("创建群聊成功");
await loadConversations();
if (detail?.id) {
const conv = conversations.value.find((c) => c.conversation_type === 2 && c.id === detail.id);
if (conv) openConversation(conv);
}
}
} finally {
createLoading.value = false;
}
}
// ── 群管理 ──────────────────────────────────────────────────
const groupDetailVisible = ref(false);
const groupDetail = ref<ChatGroupDetail | null>(null);
const announcementDraft = ref("");
const isOwner = computed(() => groupDetail.value?.owner_id === myId.value);
async function openGroupDetail() {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
const response = await ChatAPI.getGroupDetail(conv.id);
groupDetail.value = response.data?.data ?? null;
announcementDraft.value = groupDetail.value?.announcement ?? "";
groupDetailVisible.value = true;
}
async function openAddMember() {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
const result = await ElMessageBox.prompt("输入用户名或姓名(多个用英文逗号分隔)", "添加成员", {
confirmButtonText: "添加",
cancelButtonText: "取消",
inputPlaceholder: "例:zhangsan, lisi",
}).catch(() => null);
if (!result) return;
const keywords = result.value.split(/[,,\s]+/).filter(Boolean);
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)
)
.filter((id, index, arr) => arr.indexOf(id) === index);
if (ids.length === 0) {
ElMessage.warning("未找到匹配用户");
return;
}
await ChatAPI.addGroupMembers(conv.id, ids);
ElMessage.success("添加成员成功");
await openGroupDetail();
}
async function removeMember(m: ChatUserItem) {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
await ElMessageBox.confirm(`确定移除成员「${m.name}」吗?`, "移除成员", { type: "warning" });
await ChatAPI.removeGroupMembers(conv.id, [m.id]);
ElMessage.success("已移除");
await openGroupDetail();
}
async function saveAnnouncement() {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
await ChatAPI.updateGroup(conv.id, { announcement: announcementDraft.value });
ElMessage.success("公告已保存");
await openGroupDetail();
}
async function confirmDeleteGroup() {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
await ElMessageBox.confirm("解散后群聊记录将无法继续查看,确定解散?", "解散群组", { type: "error" });
await ChatAPI.deleteGroup(conv.id);
ElMessage.success("群组已解散");
groupDetailVisible.value = false;
currentConversation.value = null;
await loadConversations();
}
async function confirmQuitGroup() {
const conv = currentConversation.value;
if (!conv || conv.id == null) return;
await ElMessageBox.confirm("确定退出该群组吗?", "退出群组", { type: "warning" });
await ChatAPI.quitGroup(conv.id);
ElMessage.success("已退出群组");
groupDetailVisible.value = false;
currentConversation.value = null;
await loadConversations();
}
// ── 生命周期 ──────────────────────────────────────────────────
onMounted(async () => {
chatStore.initChat();
await loadConversations();
// 私聊在线状态实时同步
if (currentConversation.value?.conversation_type === 1 && currentConversation.value.id) {
currentConversation.value.online = !!chatStore.onlineUsers[currentConversation.value.id];
}
});
onUnmounted(() => {
chatStore.clearActiveConversation();
});
</script>
@@ -1,14 +1,19 @@
<template>
<div
class="dynamic-node"
:class="nodeClass"
class="wf-node"
:class="{ 'is-selected': selected }"
@mouseenter="showHandles = true"
@mouseleave="showHandles = false"
>
<div class="node-content">
<span class="node-label">{{ nodeData.label }}</span>
<span v-if="nodeData.config && Object.keys(nodeData.config).length > 0" class="node-badge">
{{ Object.keys(nodeData.config).length }}
<div class="node-body" :style="{ '--node-color': color }">
<span class="node-flag" :style="{ background: color }" />
<ElIcon :size="16" :color="color"><component :is="nodeIcon" /></ElIcon>
<div class="node-text">
<span class="node-label">{{ nodeData.label }}</span>
<span class="node-category">{{ categoryText }}</span>
</div>
<span v-if="badgeCount" class="node-badge" :style="{ background: color }">
{{ badgeCount }}
</span>
</div>
<Handle
@@ -17,7 +22,7 @@
type="target"
:position="Position.Top"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
:style="{ background: color }"
/>
<Handle
v-if="nodeType.code !== 'input'"
@@ -25,7 +30,7 @@
type="target"
:position="Position.Left"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
:style="{ background: color }"
/>
<Handle
v-if="nodeType.code !== 'output'"
@@ -33,7 +38,7 @@
type="source"
:position="Position.Right"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
:style="{ background: color }"
/>
<Handle
v-if="nodeType.code !== 'output'"
@@ -41,56 +46,101 @@
type="source"
:position="Position.Bottom"
:class="{ 'handle-visible': showHandles }"
:style="{ background: nodeType.color || '#3b82f6' }"
:style="{ background: color }"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from "vue";
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";
interface Props {
id?: string;
data?: Record<string, any>;
nodeStatus?: number;
selected?: boolean;
}
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 ?? { label: "", config: {}, type: undefined, category: undefined });
const nodeType = computed(() => {
const category = nodeData.value?.category || "action";
return {
code: nodeData.value?.type || "custom",
name: nodeData.value?.label || "自定义节点",
color: getCategoryColor(nodeData.value?.category),
category,
color: getCategoryColor(category),
};
});
function getCategoryColor(category: string | undefined) {
const colorMap = {
const colorMap: Record<string, string> = {
trigger: "#e6a23c",
action: "#409eff",
condition: "#67c23a",
control: "#909399",
};
return colorMap[category as keyof typeof colorMap] || "#409eff";
return colorMap[category || "action"] || "#409eff";
}
const nodeClass = computed(() => {
if (nodeData.value?.type === "input") {
return "start-node";
}
if (nodeData.value?.type === "output") {
return "end-node";
}
return "custom-node";
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;
});
const badgeCount = computed(() => {
const config = nodeData.value?.config;
return config && typeof config === "object" ? Object.keys(config).length : 0;
});
const color = computed(() => nodeType.value.color);
</script>
<style scoped lang="scss">
@@ -128,16 +178,8 @@ const nodeClass = computed(() => {
0 2px 4px rgb(0 0 0 / 10%) !important;
}
.dynamic-node {
padding: 12px 16px;
cursor: pointer;
border: 2px solid #409eff;
border-radius: 6px;
box-shadow: 0 2px 8px rgb(64 158 255 / 20%);
}
.vue-flow__node-input .dynamic-node,
.vue-flow__node-output .dynamic-node {
.vue-flow__node-input .wf-node,
.vue-flow__node-output .wf-node {
padding: 0;
background: transparent;
border: none;
@@ -145,26 +187,78 @@ const nodeClass = computed(() => {
box-shadow: none;
}
.node-content {
display: flex;
gap: 8px;
align-items: center;
}
.wf-node {
position: relative;
cursor: pointer;
.node-label {
font-size: 12px;
font-weight: 600;
text-align: center;
letter-spacing: 0.5px;
}
.node-body {
position: relative;
display: flex;
gap: 8px;
align-items: center;
min-width: 168px;
padding: 10px 14px;
overflow: hidden;
background: #fff;
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%);
transition:
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease;
.node-badge {
padding: 0 6px;
font-size: 10px;
font-weight: 500;
color: #fff;
background: #409eff;
border-radius: 10px;
&:hover {
box-shadow: 0 6px 16px rgb(0 0 0 / 14%);
transform: translateY(-1px);
}
}
.node-flag {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: 70%;
border-radius: 0 4px 4px 0;
transform: translateY(-50%);
}
.node-text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
.node-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
white-space: nowrap;
}
.node-category {
font-size: 10px;
color: var(--el-text-color-secondary);
}
}
.node-badge {
margin-left: auto;
padding: 0 6px;
font-size: 10px;
font-weight: 500;
line-height: 16px;
color: #fff;
border-radius: 10px;
}
&.is-selected .node-body {
border-color: var(--node-color);
box-shadow:
0 0 0 2px color-mix(in srgb, var(--node-color) 22%, transparent),
0 6px 18px rgb(0 0 0 / 14%);
}
}
.vue-flow__handle {
@@ -10,6 +10,21 @@
</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"
@@ -24,7 +39,7 @@
>
<template #type>
<ElSelect v-model="formData.type" placeholder="请选择节点类型" @change="handleTypeChange">
<ElOption v-for="t in nodeTypes" :key="t.id" :label="t.name" :value="t.code" />
<ElOption v-for="t in nodeTypes" :key="t.id ?? t.code" :label="t.name" :value="t.code" />
</ElSelect>
</template>
<template #args>
@@ -60,7 +75,7 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from "vue";
import { ref, watch, onMounted, computed, type Component } from "vue";
import {
ElButton,
ElInput,
@@ -69,8 +84,9 @@ import {
ElMessage,
ElIcon,
ElScrollbar,
ElTag,
} from "element-plus";
import { Close } from "@element-plus/icons-vue";
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";
@@ -87,6 +103,40 @@ 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 || "",
@@ -45,23 +45,31 @@
<ElIcon><Search /></ElIcon>
</template>
</ElInput>
<ElSpace direction="vertical" :size="8" fill :style="'width: 100%; margin-top: 8px'">
<ElTag
v-for="item in filteredNodes"
:key="item.id"
:type="getCategoryType(item.category)"
effect="plain"
<div v-if="filteredGroups.length === 0" class="py-6 text-center text-xs text-(--el-text-color-secondary)">
未找到匹配的节点
</div>
<template v-for="group in filteredGroups" :key="group.category">
<div class="mt-3 mb-1.5 flex items-center gap-1.5">
<ElIcon :size="13" :color="group.color"><component :is="group.icon" /></ElIcon>
<span class="text-xs font-semibold text-(--el-text-color-primary)">{{ group.label }}</span>
<span class="text-[10px] text-(--el-text-color-secondary)">{{ group.items.length }}</span>
</div>
<div
v-for="item in group.items"
:key="item.id ?? item.type"
class="mb-1.5 flex items-center gap-2.5 rounded-lg border border-(--el-border-color-lighter) bg-white px-2.5 py-2 cursor-move select-none transition-all hover:border-(--el-color-primary) hover:shadow-sm"
draggable="true"
:style="'justify-content: center; cursor: move; user-select: none'"
:title="item.description"
@dragstart="onDragStart($event, item)"
@dragend="onDragEnd"
>
{{ item.name }}
<span :style="'margin-left: 4px; font-size: 10px; opacity: 0.7'">
[{{ getCategoryText(item.category) }}]
</span>
</ElTag>
</ElSpace>
<ElIcon :size="15" :color="group.color"><component :is="getNodeIcon(item)" /></ElIcon>
<div class="flex flex-col min-w-0 leading-tight">
<span class="truncate text-xs font-medium text-(--el-text-color-primary)">{{ item.name }}</span>
<span class="truncate text-[10px] text-(--el-text-color-secondary)">{{ item.description || "自定义节点类型" }}</span>
</div>
</div>
</template>
</div>
</ElScrollbar>
</ElSplitterPanel>
@@ -198,7 +206,26 @@ import { Background } from "@vue-flow/background";
import { MiniMap } from "@vue-flow/minimap";
import { Controls } from "@vue-flow/controls";
import type { Node, Edge, DefaultEdgeOptions, MarkerType } from "@vue-flow/core";
import { Search, Share, VideoPlay, Rank, Grid } from "@element-plus/icons-vue";
import {
Bell,
ChatDotRound,
CircleCheckFilled,
Connection,
Delete,
Download,
Grid,
Link,
Odometer,
Promotion,
QuestionFilled,
Rank,
Search,
SetUp,
Share,
UploadFilled,
VideoPlay,
} from "@element-plus/icons-vue";
import dagre from "dagre";
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
@@ -446,45 +473,68 @@ const canvasReady = ref(false);
const searchKeyword = ref("");
type LoadedNodeType = {
id: number;
id: number | null;
type: string;
name: string;
category: string;
description?: string;
args?: string;
kwargs?: string;
};
const allNodes = ref<LoadedNodeType[]>([]);
const filteredNodes = computed(() => {
if (!searchKeyword.value) {
return allNodes.value;
}
const keyword = searchKeyword.value.toLowerCase();
return allNodes.value.filter((node) => node.name.toLowerCase().includes(keyword));
const CATEGORY_META: Record<string, { label: string; color: string; icon: Component }> = {
trigger: { label: "触发器", color: "#e6a23c", icon: Odometer },
action: { label: "动作", color: "#409eff", icon: Promotion },
condition: { label: "条件", color: "#67c23a", icon: QuestionFilled },
control: { label: "控制", color: "#909399", 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 getNodeIcon = (item: LoadedNodeType): Component => NODE_ICON[item.type] || CATEGORY_META[item.category]?.icon || Promotion;
const groupedNodes = computed(() => {
const order = ["trigger", "action", "condition", "control"];
const groups = new Map<string, LoadedNodeType[]>();
allNodes.value.forEach((node) => {
const category = node.category || "action";
if (!groups.has(category)) groups.set(category, []);
groups.get(category)!.push(node);
});
return order
.filter((category) => groups.has(category))
.map((category) => ({
category,
label: CATEGORY_META[category]?.label || category,
color: CATEGORY_META[category]?.color || "#409eff",
icon: CATEGORY_META[category]?.icon || Promotion,
items: groups.get(category)!,
}));
});
type ElTagType = "success" | "warning" | "info" | "primary" | "danger";
const getCategoryType = (category: string): ElTagType => {
const typeMap: Record<string, ElTagType> = {
trigger: "warning",
action: "primary",
condition: "success",
control: "info",
};
return typeMap[category] || "info";
};
const getCategoryText = (category: string) => {
const textMap: Record<string, string> = {
trigger: "触发器",
action: "动作",
condition: "条件",
control: "控制",
};
return textMap[category] || category;
};
const filteredGroups = computed(() => {
if (!searchKeyword.value) {
return groupedNodes.value;
}
const keyword = searchKeyword.value.toLowerCase();
return groupedNodes.value
.map((group) => ({
...group,
items: group.items.filter((node) => node.name.toLowerCase().includes(keyword)),
}))
.filter((group) => group.items.length > 0);
});
const nodeTypesRegistry = ref<Record<string, Component>>({});
@@ -516,6 +566,7 @@ const loadNodeTypes = async () => {
type: nodeType.code,
name: nodeType.name,
category: nodeType.category || "action",
description: nodeType.description || "",
args: nodeType.args || "",
kwargs: nodeType.kwargs || "{}",
}));