mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-30 16:03:50 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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>
|
||||
|
||||
@@ -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 || "",
|
||||
|
||||
+93
-42
@@ -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 || "{}",
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user