refactor(backend): 调整模块结构、调度器逻辑与健康检查路由

This commit is contained in:
zhangtao
2026-09-06 00:54:24 +08:00
parent 92081815f4
commit d470c7eb1f
324 changed files with 3435 additions and 7404 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ import { request } from "@utils";
const API_PATH = "/example/demo";
const DemoAPI = {
getDemoList(query: DemoPageQuery) {
listDemo(query: DemoPageQuery) {
return request<ApiResponse<PageResult<DemoTable>>>({
url: `${API_PATH}/list`,
method: "get",
@@ -11,7 +11,7 @@ const DemoAPI = {
});
},
getDemoDetail(query: number) {
detailDemo(query: number) {
return request<ApiResponse<DemoTable>>({
url: `${API_PATH}/detail/${query}`,
method: "get",
@@ -1,4 +1,6 @@
import { request } from "@utils";
import { createSSEClient, httpEndpoint, type SSEClient } from "@utils/sse";
import type { HealthItem } from "@/mock/dashboard";
const API_PATH = "/monitor/online";
@@ -26,6 +28,58 @@ const DashboardAPI = {
method: "get",
});
},
/**
* 订阅系统健康实时流(SSE,30s 一拍,免认证)
* 返回取消订阅函数;连接断开后客户端内部自动重连
*/
subscribeHealthStream(onItems: (items: HealthItem[]) => void): () => void {
const client = createSSEClient({
url: new URL(
"/api/v1/monitor/health/stream",
httpEndpoint(import.meta.env.VITE_APP_WS_ENDPOINT)
).toString(),
getToken: () => null, // 健康端点免认证,与 /check 一致
onEvent: (_event, data) => {
try {
onItems(mapReadinessToHealthItems(JSON.parse(data)));
} catch {
/* ignore */
}
},
});
return () => client.disconnect();
},
};
export default DashboardAPI;
/** 健康 SSE 载荷(对应后端 ServiceInfoOut:进程 + DB / Redis 连通状态) */
interface ServiceInfoPayload {
db_status: number;
redis_status: number;
}
const OK_ITEM_CLASS = "bg-success/12 text-success";
const ERROR_ITEM_CLASS = "bg-error/12 text-error";
function dependencyItem(title: string, icon: string, status: number): HealthItem {
const ok = status === 1;
return {
icon,
class: ok ? OK_ITEM_CLASS : ERROR_ITEM_CLASS,
title,
status: ok ? "正常" : "异常",
time: "",
};
}
/** 健康载荷 → 健康卡片列表(数据库 / Redis) */
function mapReadinessToHealthItems(payload: ServiceInfoPayload): HealthItem[] {
return [
dependencyItem("数据库", "ri:database-2-line", payload.db_status),
dependencyItem("Redis", "ri:server-line", payload.redis_status),
];
}
export type { SSEClient };
-287
View File
@@ -1,287 +0,0 @@
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();
// 令牌经 Sec-WebSocket-Protocol 传递,避免出现在 URL 与服务端 access log 中
this.ws = token
? new WebSocket(url.toString(), ["access_token", `access_token.${token}`])
: new WebSocket(url.toString());
this.ws.onopen = () => this.handleOpen();
this.ws.onmessage = (event) => this.handleMessage(event);
this.ws.onclose = (event) => this.handleClose(event);
this.ws.onerror = () => this.ws?.close();
} catch {
this.scheduleReconnect();
}
}
disconnect() {
this.stopped = true;
this.clearTimers();
if (this.ws) {
this.ws.onclose = null;
this.ws.close(1000, "close");
this.ws = null;
}
}
private handleOpen() {
this.attempt = 0;
this.handlers.onStatus(true);
this.heartbeatTimer = setTimeout(() => {
try {
this.ws?.send("ping");
} catch {
/* ignore */
}
}, 30000);
this.pingTimer = setInterval(() => {
try {
this.ws?.send("ping");
} catch {
/* ignore */
}
}, 60000);
}
private handleMessage(event: MessageEvent) {
if (event.data === "pong") return;
try {
this.handlers.onMessage(JSON.parse(event.data));
} catch {
/* ignore */
}
}
private handleClose(event?: CloseEvent) {
this.ws = null;
this.clearTimers();
this.handlers.onStatus(false);
// 4001 = 令牌无效(登出/失效),不再自动重连
if (event?.code === 4001) {
this.stopped = true;
return;
}
if (!this.stopped) this.scheduleReconnect();
}
private scheduleReconnect() {
if (this.stopped || this.reconnectTimer) return;
const delay = Math.min(2000 * Math.pow(1.5, this.attempt), 30000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.attempt += 1;
this.connect();
}, delay);
}
private clearTimers() {
if (this.pingTimer) clearInterval(this.pingTimer);
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
this.pingTimer = null;
this.heartbeatTimer = null;
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ const API_PATH = "/system/param";
const ParamsAPI = {
uploadFile(body: any) {
return request<ApiResponse<UploadFilePath>>({
url: `/common/file/upload?upload_type=param`,
url: `/file/upload?upload_type=param`,
method: "post",
data: body,
headers: { "Content-Type": "multipart/form-data" },
+2 -2
View File
@@ -1,5 +1,5 @@
import { request } from "@utils";
import { MenuTable, MenuForm } from "@/api/module_system/menu";
import { type MenuTable, type MenuForm } from "@/api/module_system/menu";
const API_PATH = "/system/user";
@@ -14,7 +14,7 @@ export const UserAPI = {
uploadCurrentUserAvatar(body: FormData) {
return request<ApiResponse<UploadFilePath>>({
url: `/common/file/upload?upload_type=avatar`,
url: `/file/upload?upload_type=avatar`,
method: "post",
data: body,
headers: { "Content-Type": "multipart/form-data" },
@@ -64,11 +64,10 @@ const NodeAPI = {
});
},
executeNode(id: number, params: ExecuteNodeParams = { trigger: "now" }) {
executeNode(id: number) {
return request<ApiResponse<ExecuteNodeResult>>({
url: `${API_PATH}/execute/${id}`,
method: "post",
data: params,
});
},
};
@@ -81,19 +80,13 @@ export interface NodePageQuery extends PageQuery, UserByQueryParams {
status?: number;
}
export type TriggerType = "now" | "cron" | "interval" | "date";
export interface ExecuteNodeParams {
trigger: TriggerType;
trigger_args?: string;
start_date?: string;
end_date?: string;
}
/** 正式排程的触发器类型(空 = 不排程,仅手动执行一次) */
export type TriggerType = "cron" | "interval" | "date";
export interface ExecuteNodeResult {
job_id: number;
status: number;
trigger: TriggerType;
job_id: string;
status: string;
trigger: string;
}
export interface NodeTable extends BaseType {
@@ -110,11 +103,11 @@ export interface NodeTable extends BaseType {
max_instances?: number;
start_date?: string;
end_date?: string;
created_by?: CommonType;
updated_by?: CommonType;
deleted_by?: CommonType;
status?: number;
description?: string;
next_run_time?: string;
last_run_time?: string;
last_run_status?: number;
}
export interface NodeForm extends BaseFormType {
@@ -127,9 +120,10 @@ export interface NodeForm extends BaseFormType {
kwargs?: string;
coalesce?: boolean;
max_instances?: number;
trigger?: TriggerType | "";
trigger_args?: string;
start_date?: string;
end_date?: string;
status?: number;
description?: string;
}
@@ -1,6 +1,6 @@
import { request } from "@utils";
const API_PATH = "/task/workflow/flow";
const API_PATH = "/workflow/flow";
const FlowAPI = {
pageFlow(query?: FlowPageQuery) {
@@ -1,6 +1,6 @@
import { request } from "@utils";
const API_PATH = "/task/workflow/node";
const API_PATH = "/workflow/source";
const NodeAPI = {
pageNode(query?: TablePageQuery) {
@@ -1,6 +1,6 @@
import { request } from "@utils";
const API_PATH = "/task/workflow/storage";
const API_PATH = "/workflow/storage";
const StorageAPI = {
/** 查询文件/目录列表。传 page_no/page_size 时分页返回(PageResult),否则返回全量数组。 */
@@ -1,7 +1,7 @@
import { request } from "@utils";
import { Auth } from "@utils/auth";
import { createSSEClient, httpEndpoint, type SSEClient } from "@utils/sse";
const API_PATH = "/task/workflow/transfer";
const API_PATH = "/workflow/transfer";
const TransferAPI = {
/** 创建传输任务(远端源,JSON) */
@@ -130,17 +130,12 @@ export interface TransferTaskQuery extends PageQuery {
status?: TransferStatus;
}
/** 服务端 WS 推送消息 */
/** 服务端 SSE 推送消息 */
export type TransferPushMessage = { type: "task_update"; data: TransferTaskItem };
/** 传输任务 WebSocket 客户端(自动重连 + 心跳,参照 ChatSocket) */
export class TransferSocket {
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pingTimer: ReturnType<typeof setInterval> | null = null;
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
private attempt = 0;
private stopped = false;
/** 传输任务 SSE 客户端(自动重连;令牌走 Authorization 头;连接生命周期由视图层按"是否存在进行中任务"控制) */
export class TransferStream {
private client: SSEClient | null = null;
private handlers: {
onMessage: (msg: TransferPushMessage) => void;
onStatus: (connected: boolean) => void;
@@ -153,101 +148,37 @@ export class TransferSocket {
this.handlers = handlers;
}
/** 建立连接(幂等:已存在连接实例时直接返回,重连由客户端内部负责) */
connect() {
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING)
return;
this.stopped = false;
try {
const url = new URL(
"/api/v1/task/workflow/transfer/ws",
import.meta.env.VITE_APP_WS_ENDPOINT
);
const token = Auth.getAccessToken();
// 令牌经 Sec-WebSocket-Protocol 传递,避免出现在 URL 与服务端 access log 中
this.ws = token
? new WebSocket(url.toString(), ["access_token", `access_token.${token}`])
: new WebSocket(url.toString());
this.ws.onopen = () => this.handleOpen();
this.ws.onmessage = (event) => this.handleMessage(event);
this.ws.onclose = (event) => this.handleClose(event);
this.ws.onerror = () => this.ws?.close();
} catch {
this.scheduleReconnect();
}
if (this.client) return;
const url = new URL(
"/api/v1/task/workflow/transfer/stream",
httpEndpoint(import.meta.env.VITE_APP_WS_ENDPOINT)
);
this.client = createSSEClient({
url: url.toString(),
onEvent: (event, data) => {
if (event !== "task_update") return;
try {
this.handlers.onMessage(JSON.parse(data));
} catch {
/* ignore */
}
},
onStatus: (connected) => this.handlers.onStatus(connected),
// 令牌失效:释放实例,视图层下次 sync 时以新令牌重建
onFatal: () => {
this.client = null;
},
});
}
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;
this.client?.disconnect();
this.client = null;
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
return this.client?.connected ?? false;
}
}
@@ -0,0 +1,144 @@
<!-- 单文件上传控件:绑定文件 URL,上传到 /file/upload(upload_type=file) -->
<template>
<div class="fa-file-upload">
<div v-if="modelValue" class="file-item">
<el-icon class="file-item-icon"><Document /></el-icon>
<el-link type="primary" :href="modelValue" target="_blank" :underline="false">
{{ fileName }}
</el-link>
<el-icon class="file-item-action" title="移除" @click="handleRemove"><CircleCloseFilled /></el-icon>
</div>
<el-button v-else type="primary" plain :loading="uploading" @click="fileInputRef?.click()">
<el-icon class="mr-1"><UploadFilled /></el-icon>
<span>选择文件</span>
</el-button>
<input
ref="fileInputRef"
class="file-input"
type="file"
:accept="accept"
@change="onFileChange"
/>
<div v-if="tip" class="file-tip">{{ tip }}</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { CircleCloseFilled, Document, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { request } from "@utils";
import type { AxiosResponse } from "axios";
defineOptions({ name: "FaFileUpload" });
interface Props {
/** 允许的文件类型(accept 语法,默认不限) */
accept?: string;
/** 文件大小上限(MB) */
maxSize?: number;
/** 提示文案 */
tip?: string;
}
const props = withDefaults(defineProps<Props>(), {
accept: "",
maxSize: 20,
tip: "",
});
const modelValue = defineModel<string>({ default: "" });
const uploading = ref(false);
const fileInputRef = ref<HTMLInputElement>();
const fileName = computed(() => {
const url = modelValue.value;
if (!url) return "";
try {
const { pathname } = new URL(url, window.location.origin);
return pathname.split("/").filter(Boolean).pop() || url;
} catch {
return url.split("/").pop() || url;
}
});
const handleRemove = () => {
modelValue.value = "";
};
const onFileChange = (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file) return;
if (file.size > props.maxSize * 1024 * 1024) {
ElMessage.warning(`文件大小不能超过 ${props.maxSize}MB`);
return;
}
void doUpload(file);
};
const doUpload = async (file: File) => {
uploading.value = true;
try {
const formData = new FormData();
formData.append("file", file);
const response = await request.post<
ApiResponse<UploadFilePath>,
AxiosResponse<ApiResponse<UploadFilePath>>
>("/file/upload", formData, {
params: { upload_type: "file" },
headers: { "Content-Type": "multipart/form-data" },
});
const fileUrl = response.data.data?.file_url;
if (!fileUrl) {
throw new Error("上传失败,未返回文件地址");
}
modelValue.value = fileUrl;
ElMessage.success("上传成功");
} catch (error) {
console.error("文件上传失败:", error);
ElMessage.error("文件上传失败");
} finally {
uploading.value = false;
}
};
</script>
<style scoped>
.fa-file-upload .file-input {
display: none;
}
.fa-file-upload .file-item {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.fa-file-upload .file-item-icon {
color: var(--el-color-primary);
flex-shrink: 0;
}
.fa-file-upload .file-item-action {
cursor: pointer;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.fa-file-upload .file-item-action:hover {
color: var(--el-color-danger);
}
.fa-file-upload .file-tip {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4;
}
</style>
@@ -226,6 +226,9 @@ import {
useSanitizeOutputOptions,
type SanitizeOutputOptions,
} from "../composables/useFormBase";
import FaFileUpload from "@/components/forms/fa-file-upload/index.vue";
import FaUpload from "@/components/actions/fa-upload/index.vue";
import FaWangEditor from "@/components/forms/fa-wang-editor/index.vue";
defineOptions({ name: "FaForm" });
@@ -252,6 +255,9 @@ const componentMap = {
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect, // 树选择器
editor: FaWangEditor, // 富文本编辑器
imageupload: FaUpload, // 单图上传
fileupload: FaFileUpload, // 单文件上传
};
const { t } = useI18n();
@@ -80,9 +80,7 @@ const DEFAULT_UPLOAD_CONFIG = {
} as const;
// 计算属性:上传服务器地址
const uploadServer = computed(
() => props.uploadConfig?.server || `${VITE_API_URL}/common/file/upload`
);
const uploadServer = computed(() => props.uploadConfig?.server || `${VITE_API_URL}/file/upload`);
// 合并上传配置
const mergedUploadConfig = computed(() => ({
@@ -39,12 +39,6 @@ export const globalComponentsConfig: GlobalComponentConfig[] = [
component: defineAsyncComponent(() => import("@/layouts/fa-screen-lock/index.vue")),
enabled: true,
},
{
name: "聊天窗口",
key: "chat-window",
component: defineAsyncComponent(() => import("@/layouts/fa-chat-window/index.vue")),
enabled: true,
},
{
name: "礼花效果",
key: "fireworks-effect",
@@ -42,10 +42,6 @@ export const headerBarConfig: HeaderBarFeatureConfig = {
enabled: true,
description: "通知中心,显示系统通知和消息",
},
chat: {
enabled: true,
description: "聊天功能,提供实时沟通",
},
language: {
enabled: true,
description: "多语言切换功能",
@@ -95,11 +95,6 @@ export function useHeaderBar() {
return isFeatureEnabled("notification") && showNotification.value;
});
// 检查聊天功能是否显示
const shouldShowChat = computed(() => {
return isFeatureEnabled("chat");
});
/** 语言:以新版设置里的 showLanguage 为准(优于旧版单独的 showLangSelect 双开关,避免两处含义重叠) */
const shouldShowLanguage = computed(() => {
return isFeatureEnabled("language") && showLanguage.value;
@@ -192,7 +187,6 @@ export function useHeaderBar() {
shouldShowGlobalSearch, // 是否显示全局搜索
shouldShowFullscreen, // 是否显示全屏按钮
shouldShowNotification, // 是否显示通知中心
shouldShowChat, // 是否显示聊天功能
shouldShowLanguage, // 是否显示语言切换
shouldShowSizeSelect, // 是否显示组件尺寸切换
shouldShowSettings, // 是否显示设置面板
@@ -1,263 +0,0 @@
<!-- 系统聊天窗口 -->
<template>
<div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
<div class="mb-5 flex items-center justify-between">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="mt-1.5 flex items-center gap-1">
<div class="h-2 w-2 rounded-full" :class="isOnline ? 'bg-success' : 'bg-danger'"></div>
<span class="text-xs text-g-600">{{ isOnline ? "在线" : "离线" }}</span>
</div>
</div>
<div>
<ElIcon class="cursor-pointer" :size="20" @click="closeChat">
<Close />
</ElIcon>
</div>
</div>
<div class="flex h-[calc(100%-70px)] flex-col">
<!-- 聊天消息区域 -->
<ElScrollbar
ref="messageContainer"
class="flex-1 border-t border-(--default-border) px-4 py-7.5"
>
<template v-for="message in messages" :key="message.id">
<div
:class="[
'mb-7.5 flex w-full items-start gap-2',
message.isMe ? 'flex-row-reverse' : 'flex-row',
]"
>
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
<div
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
>
<div
:class="[
'mb-1 flex gap-2 text-xs',
message.isMe ? 'flex-row-reverse' : 'flex-row',
]"
>
<span class="font-medium">{{ message.sender }}</span>
<span class="text-g-600">{{ message.time }}</span>
</div>
<div
:class="[
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50',
]"
>
{{ message.content }}
</div>
</div>
</div>
</template>
</ElScrollbar>
<!-- 聊天输入区域 -->
<div class="px-4 pt-4">
<ElInput
v-model="messageText"
type="textarea"
:rows="3"
placeholder="输入消息"
resize="none"
@keyup.enter.prevent="sendMessage"
>
<template #append>
<div class="flex gap-2 py-2">
<ElButton :icon="Paperclip" circle plain />
<ElButton :icon="Picture" circle plain />
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
</div>
</template>
</ElInput>
<div class="mt-3 flex items-center justify-between">
<div class="flex items-center">
<FaSvgIcon icon="ri:image-line" class="mr-5 cursor-pointer text-g-600 text-lg" />
<FaSvgIcon
icon="ri:emotion-happy-line"
class="mr-5 cursor-pointer text-g-600 text-lg"
/>
</div>
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
</div>
</div>
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { Picture, Paperclip, Close } from "@element-plus/icons-vue";
import { mittBus } from "@utils";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
import { MOBILE_BREAKPOINT } from "@/utils/constants/definitions";
defineOptions({ name: "FaChatWindow" });
// 类型定义
interface ChatMessage {
id: number;
sender: string;
content: string;
time: string;
isMe: boolean;
avatar: string;
}
// 常量定义
const SCROLL_DELAY = 100;
const BOT_NAME = "Art Bot";
const USER_NAME = "Ricky";
// 响应式布局
const { width } = useWindowSize();
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT);
// 组件状态
const isDrawerVisible = ref(false);
const isOnline = ref(true);
// 消息相关状态
const messageText = ref("");
const messageId = ref(10);
const messageContainer = ref<InstanceType<typeof ElScrollbar> | null>(null);
// 初始化聊天消息数据
const initializeMessages = (): ChatMessage[] => [
{
id: 1,
sender: BOT_NAME,
content: "你好!我是你的AI助手,有什么我可以帮你的吗?",
time: "10:00",
isMe: false,
avatar: aiAvatar,
},
{
id: 2,
sender: USER_NAME,
content: "我想了解一下系统的使用方法。",
time: "10:01",
isMe: true,
avatar: meAvatar,
},
{
id: 3,
sender: BOT_NAME,
content: "好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...",
time: "10:02",
isMe: false,
avatar: aiAvatar,
},
{
id: 4,
sender: USER_NAME,
content: "听起来很不错,能具体讲讲数据分析部分吗?",
time: "10:05",
isMe: true,
avatar: meAvatar,
},
{
id: 5,
sender: BOT_NAME,
content: "当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...",
time: "10:06",
isMe: false,
avatar: aiAvatar,
},
{
id: 6,
sender: USER_NAME,
content: "太好了,那我如何开始使用呢?",
time: "10:08",
isMe: true,
avatar: meAvatar,
},
{
id: 7,
sender: BOT_NAME,
content: "您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。",
time: "10:09",
isMe: false,
avatar: aiAvatar,
},
{
id: 8,
sender: USER_NAME,
content: "明白了,谢谢你的帮助!",
time: "10:10",
isMe: true,
avatar: meAvatar,
},
{
id: 9,
sender: BOT_NAME,
content: "不客气,有任何问题随时联系我。",
time: "10:11",
isMe: false,
avatar: aiAvatar,
},
];
const messages = ref<ChatMessage[]>(initializeMessages());
// 工具函数
const formatCurrentTime = (): string => {
return new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};
const scrollToBottom = (): void => {
nextTick(() => {
setTimeout(() => {
const wrap = messageContainer.value?.wrapRef;
if (wrap) wrap.scrollTop = wrap.scrollHeight;
}, SCROLL_DELAY);
});
};
// 消息处理方法
const sendMessage = (): void => {
const text = messageText.value.trim();
if (!text) return;
const newMessage: ChatMessage = {
id: messageId.value++,
sender: USER_NAME,
content: text,
time: formatCurrentTime(),
isMe: true,
avatar: meAvatar,
};
messages.value.push(newMessage);
messageText.value = "";
scrollToBottom();
};
// 聊天窗口控制方法
const openChat = (): void => {
isDrawerVisible.value = true;
scrollToBottom();
};
const closeChat = (): void => {
isDrawerVisible.value = false;
};
// 生命周期
onMounted(() => {
scrollToBottom();
mittBus.on("openChat", openChat);
});
onUnmounted(() => {
mittBus.off("openChat", openChat);
});
</script>
@@ -142,23 +142,6 @@
</ElBadge>
</FaIconButton>
<!-- 聊天按钮 -->
<FaIconButton
v-if="shouldShowChat"
icon="ri:message-3-line"
class="chat-button relative"
@click="openChat"
>
<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>
<!-- 设置按钮 -->
<div v-if="shouldShowSettings">
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
@@ -211,7 +194,6 @@ import {
useUserStore,
useNoticeStore,
useConfigStore,
useChatStore,
refreshAppCaches,
} from "@stores";
import AppConfig from "@/config";
@@ -236,7 +218,6 @@ const userStore = useUserStore();
const menuStore = useMenuStore();
const configStore = useConfigStore();
const noticeStore = useNoticeStore();
const chatStore = useChatStore();
/** 租户配置:logo_url / name */
const headerLogoSrc = computed(() => {
@@ -259,7 +240,6 @@ const {
shouldShowGlobalSearch,
shouldShowFullscreen,
shouldShowNotification,
shouldShowChat,
shouldShowLanguage,
shouldShowSettings,
shouldShowThemeToggle,
@@ -287,8 +267,6 @@ onMounted(() => {
initLanguage();
document.addEventListener("click", bodyCloseNotice);
noticeStore.getNotice();
chatStore.initChat();
chatStore.refreshUnread();
});
onUnmounted(() => {
@@ -397,13 +375,6 @@ const bodyCloseNotice = (e: any): void => {
const visibleNotice = (): void => {
showNotice.value = !showNotice.value;
};
/**
* 打开聊天窗口(fa-chat-window 全局组件监听 openChat 事件)
*/
const openChat = (): void => {
mittBus.emit("openChat");
};
</script>
<style lang="scss" scoped>
@@ -533,10 +504,6 @@ const openChat = (): void => {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover :deep(.fa-svg-icon) {
animation: shake 0.5s ease-in-out;
}
/* iPad breakpoint adjustments */
@media screen and (width <= 768px) {
.logo2 {
+2 -9
View File
@@ -171,21 +171,14 @@ export function getDashboardMock(): DashboardMock {
class: "bg-success/12 text-success",
title: "数据库",
status: "正常",
time: "2ms",
time: "",
},
{
icon: "ri:server-line",
class: "bg-success/12 text-success",
title: "Redis",
status: "正常",
time: "1ms",
},
{
icon: "ri:hard-drive-2-line",
class: "bg-success/12 text-success",
title: "磁盘",
status: "正常",
time: "45%",
time: "",
},
],
};
-1
View File
@@ -18,7 +18,6 @@ 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";
@@ -1,134 +0,0 @@
/**
* 聊天状态管理模块
*
* ## 主要功能
*
* - 全局未读数统计(顶部角标)
* - 聊天 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,7 +13,6 @@ 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";
@@ -326,8 +325,6 @@ export const useUserStore = defineStore(
prems.value = [];
/** 登出 / 认证失效:会话结束,工作栏与 KeepAlive exclude 一并清空(pinia 持久化随之写入) */
useWorktabStore().clearAll();
/** 登出:断开聊天 WebSocket 并清空未读/在线状态 */
useChatStore(store).clearUserInfo();
}
/**
-1
View File
@@ -118,7 +118,6 @@ export interface HeaderBarFeatureConfig {
globalSearch: FeatureConfigItem;
fullscreen: FeatureConfigItem;
notification: FeatureConfigItem;
chat: FeatureConfigItem;
language: FeatureConfigItem;
settings: FeatureConfigItem;
themeToggle: FeatureConfigItem;
+1 -1
View File
@@ -18,7 +18,7 @@ interface DownloadUtil {
const download: DownloadUtil = {
async name(name: string, isDelete: boolean = true) {
const url = baseURL + "/common/file/download";
const url = baseURL + "/file/download";
try {
const res = await axios.post<Blob>(
url,
-1
View File
@@ -19,7 +19,6 @@ export * from "./icons";
// 网络
export * from "./http";
export * from "./socket";
// 浏览器 / 系统
export * from "./storage";
-541
View File
@@ -1,541 +0,0 @@
/**
* WebSocket 服务管理
*
* @description
* 统一管理应用中的所有 WebSocket 连接
* - 字典同步 WebSocket
* - 在线用户计数 WebSocket
* - 其他业务 WebSocket
*
* @author fastapiadmin
*/
import { Auth } from "@utils/auth";
/**
* WebSocket 服务实例约定接口
*/
type WebSocketService = {
disconnect?: () => void;
closeWebSocket?: () => void;
cleanup?: () => void;
[key: string]: any;
};
/**
* 全局 WebSocket 实例管理
*/
const websocketInstances = new Map<string, WebSocketService>();
/**
* 防止重复初始化的状态标记
*/
let isInitialized = false;
/**
* 注册 WebSocket 实例
*/
export function registerWebSocketInstance(key: string, instance: WebSocketService) {
websocketInstances.set(key, instance);
}
/**
* 获取 WebSocket 实例
*/
export function getWebSocketInstance(key: string) {
return websocketInstances.get(key);
}
/**
* 初始化 WebSocket 服务
*/
export function setupWebSocket() {
if (isInitialized) {
console.warn("[WebSocket] 已初始化,跳过重复初始化");
return;
}
if (!Auth.getAccessToken()) {
console.warn("[WebSocket] 未登录,跳过 WebSocket 初始化");
return;
}
try {
isInitialized = true;
console.info("[WebSocket] 初始化成功");
} catch (error) {
console.error("[WebSocket] 初始化失败:", error);
}
}
/**
* 清理所有 WebSocket 连接
*/
export function cleanupWebSocket() {
console.info("[WebSocket] 开始清理连接...");
websocketInstances.forEach((instance, key) => {
try {
if (instance.disconnect) {
instance.disconnect();
} else if (instance.closeWebSocket) {
instance.closeWebSocket();
} else if (instance.cleanup) {
instance.cleanup();
}
console.info(`[WebSocket] ${key} 已断开`);
} catch (error) {
console.error(`[WebSocket] ${key} 清理失败:`, error);
}
});
websocketInstances.clear();
isInitialized = false;
console.info("[WebSocket] 清理完成");
}
/**
* 重新初始化 WebSocket
*/
export function reinitializeWebSocket() {
cleanupWebSocket();
setupWebSocket();
}
if (typeof window !== "undefined") {
window.addEventListener("beforeunload", () => {
cleanupWebSocket();
});
}
/**
* WebSocket 客户端类
*
* 提供 WebSocket 连接管理功能,支持自动重连、心跳检测和消息队列
*
* @module utils/socket
*/
interface WebSocketOptions {
url?: string;
messageHandler: (event: MessageEvent) => void;
reconnectInterval?: number; // 重连间隔(ms)
heartbeatInterval?: number; // 心跳检测间隔(ms)
pingInterval?: number; // 发送ping间隔(ms)
reconnectTimeout?: number; // 重连超时时间(ms)
maxReconnectAttempts?: number; // 最大重连次数
connectionTimeout?: number; // 连接建立超时时间(ms)
}
export default class WebSocketClient {
private static instance: WebSocketClient | null = null;
private ws: WebSocket | null = null;
private url: string;
private messageHandler: (event: MessageEvent) => void;
private reconnectInterval: number;
private heartbeatInterval: number;
private pingInterval: number;
private reconnectTimeout: number;
private maxReconnectAttempts: number;
private connectionTimeout: number;
private reconnectAttempts: number = 0; // 当前重连次数
// 消息队列 - 缓存连接建立前的消息
private messageQueue: Array<string | Blob | BufferSource> = [];
// 定时器
private detectionTimer: NodeJS.Timeout | null = null;
private timeoutTimer: NodeJS.Timeout | null = null;
private reconnectTimer: NodeJS.Timeout | null = null;
private pingTimer: NodeJS.Timeout | null = null;
private connectionTimer: NodeJS.Timeout | null = null; // 连接超时定时器
// 状态标识
private isConnected: boolean = false;
private isConnecting: boolean = false; // 是否正在连接中
private stopReconnect: boolean = false;
private isReconnecting: boolean = false;
private constructor(options: WebSocketOptions) {
this.url = options.url || (process.env.VUE_APP_LOGIN_WEBSOCKET as string);
this.messageHandler = options.messageHandler;
this.reconnectInterval = options.reconnectInterval ?? 20 * 1000; // 默认20秒
this.heartbeatInterval = options.heartbeatInterval ?? 5 * 1000; // 默认5秒
this.pingInterval = options.pingInterval ?? 10 * 1000; // 默认10秒
this.reconnectTimeout = options.reconnectTimeout ?? 30 * 1000; // 默认30秒
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10; // 默认最多重连10次
this.connectionTimeout = options.connectionTimeout ?? 10 * 1000; // 连接超时10秒
}
// 单例模式获取实例
static getInstance(options: WebSocketOptions): WebSocketClient {
if (!WebSocketClient.instance) {
WebSocketClient.instance = new WebSocketClient(options);
} else {
// 更新消息处理器
WebSocketClient.instance.messageHandler = options.messageHandler;
// 如果提供了新的URL,则更新并重新连接
if (options.url && WebSocketClient.instance.url !== options.url) {
WebSocketClient.instance.url = options.url;
WebSocketClient.instance.reconnectAttempts = 0;
WebSocketClient.instance.init();
}
}
return WebSocketClient.instance;
}
// 初始化连接
init(): void {
this.connect(true);
}
private connect(resetReconnectAttempts: boolean = false): void {
// 如果正在连接中,不重复连接
if (this.isConnecting) {
console.info("正在建立WebSocket连接中...");
return;
}
// 如果已连接,不重复连接
if (this.ws?.readyState === WebSocket.OPEN) {
console.warn("WebSocket连接已存在");
this.flushMessageQueue(); // 确保队列中的消息被发送
return;
}
try {
this.isConnecting = true;
this.stopReconnect = false;
if (resetReconnectAttempts) {
this.reconnectAttempts = 0;
this.isReconnecting = false;
this.clearTimer("reconnectTimer");
}
this.ws = new WebSocket(this.url);
// 设置连接超时检测
this.clearTimer("connectionTimer");
this.connectionTimer = setTimeout(() => {
console.error(`WebSocket连接超时 (${this.connectionTimeout}ms):${this.url}`);
this.handleConnectionTimeout();
}, this.connectionTimeout);
this.ws.onopen = (event) => this.handleOpen(event);
this.ws.onmessage = (event) => this.handleMessage(event);
this.ws.onclose = (event) => this.handleClose(event);
this.ws.onerror = (event) => this.handleError(event);
} catch (error) {
console.error("WebSocket初始化失败:", error);
this.isConnecting = false;
this.reconnect();
}
}
// 处理连接超时
private handleConnectionTimeout(): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.error("WebSocket连接超时,强制关闭连接");
this.ws?.close(1000, "Connection timeout");
this.isConnecting = false;
this.reconnect();
}
}
// 关闭连接
close(force?: boolean): void {
this.clearAllTimers();
this.stopReconnect = true;
this.isReconnecting = false;
this.isConnecting = false;
if (this.ws) {
// 1000 表示正常关闭
this.ws.close(force ? 1001 : 1000, force ? "Force closed" : "Normal close");
this.ws = null;
}
this.isConnected = false;
}
// 发送消息 - 增加消息队列
send(data: string | Blob | BufferSource, immediate: boolean = false): void {
// 如果要求立即发送且未连接,则直接报错
if (immediate && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
console.error("WebSocket未连接,无法立即发送消息");
return;
}
// 如果未连接且不要求立即发送,则加入消息队列
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.info("WebSocket未连接,消息已加入队列等待发送");
this.messageQueue.push(data);
// 如果未在重连中,则尝试重连
if (!this.isConnecting && !this.stopReconnect) {
this.init();
}
return;
}
try {
this.ws.send(data);
} catch (error) {
console.error("WebSocket发送消息失败:", error);
// 发送失败时将消息加入队列,等待重连后重试
this.messageQueue.push(data);
this.reconnect();
}
}
// 发送队列中的消息
private flushMessageQueue(): void {
if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
console.info(`发送队列中的${this.messageQueue.length}条消息`);
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift();
if (data) {
try {
this.ws?.send(data);
} catch (error) {
console.error("发送队列消息失败:", error);
// 如果发送失败,将消息放回队列头部
if (data) this.messageQueue.unshift(data);
break;
}
}
}
}
}
// 处理连接打开
private handleOpen(event: Event): void {
console.info("WebSocket连接成功", event);
this.clearTimer("connectionTimer"); // 清除连接超时定时器
this.isConnected = true;
this.isConnecting = false;
this.isReconnecting = false;
this.stopReconnect = false;
this.reconnectAttempts = 0; // 重置重连次数
this.startHeartbeat();
this.startPing();
this.flushMessageQueue(); // 发送队列中的消息
}
// 处理收到的消息
private handleMessage(event: MessageEvent): void {
console.debug("收到WebSocket消息:", event);
this.resetHeartbeat();
this.messageHandler(event);
}
// 处理连接关闭
private handleClose(event: CloseEvent): void {
console.info(
`WebSocket断开: 代码=${event.code}, 原因=${event.reason}, 干净关闭=${event.wasClean}`
);
// 1000 是正常关闭代码
const isNormalClose = event.code === 1000;
this.isConnected = false;
this.isConnecting = false;
this.clearConnectionTimers();
this.ws = null;
if (!this.stopReconnect && !isNormalClose) {
this.reconnect();
}
}
// 处理错误 - 增加详细错误信息
private handleError(event: Event): void {
console.error("WebSocket连接错误:");
console.error("错误事件:", event);
console.error(
"当前连接状态:",
this.ws?.readyState ? this.getReadyStateText(this.ws.readyState) : "未初始化"
);
this.isConnected = false;
this.isConnecting = false;
// 只有在未停止重连的情况下才尝试重连
if (!this.stopReconnect) {
this.reconnect();
}
}
private closeCurrentSocketForReconnect(): void {
this.clearConnectionTimers();
this.isConnected = false;
this.isConnecting = false;
if (this.ws) {
this.ws.onopen = null;
this.ws.onmessage = null;
this.ws.onclose = null;
this.ws.onerror = null;
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close(1001, "Reconnect");
}
this.ws = null;
}
}
// 转换连接状态为文本描述
private getReadyStateText(state: number): string {
switch (state) {
case WebSocket.CONNECTING:
return "CONNECTING (0) - 正在连接";
case WebSocket.OPEN:
return "OPEN (1) - 已连接";
case WebSocket.CLOSING:
return "CLOSING (2) - 正在关闭";
case WebSocket.CLOSED:
return "CLOSED (3) - 已关闭";
default:
return `未知状态 (${state})`;
}
}
// 开始心跳检测
private startHeartbeat(): void {
this.clearTimer("detectionTimer");
this.clearTimer("timeoutTimer");
this.detectionTimer = setTimeout(() => {
this.isConnected = this.ws?.readyState === WebSocket.OPEN;
if (!this.isConnected) {
console.warn("WebSocket心跳检测失败,尝试重连");
this.reconnect();
this.timeoutTimer = setTimeout(() => {
console.warn("WebSocket重连超时");
this.close();
}, this.reconnectTimeout);
}
}, this.heartbeatInterval);
}
// 重置心跳检测
private resetHeartbeat(): void {
this.clearTimer("detectionTimer");
this.clearTimer("timeoutTimer");
this.startHeartbeat();
}
// 开始发送ping消息
private startPing(): void {
this.clearTimer("pingTimer");
this.pingTimer = setInterval(() => {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn("WebSocket未连接,停止发送ping");
this.clearTimer("pingTimer");
this.reconnect();
return;
}
try {
this.ws.send("ping");
console.debug("发送ping消息");
} catch (error) {
console.error("发送ping消息失败:", error);
this.clearTimer("pingTimer");
this.reconnect();
}
}, this.pingInterval);
}
// 重连 - 增加重连次数限制
private reconnect(): void {
if (this.stopReconnect || this.isConnecting || this.reconnectInterval <= 0) {
return;
}
// 检查是否超过最大重连次数
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`);
this.close(true);
return;
}
this.reconnectAttempts++;
this.isReconnecting = true;
this.closeCurrentSocketForReconnect();
const delay = this.calculateReconnectDelay();
console.info(
`将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)`
);
this.clearTimer("reconnectTimer");
this.reconnectTimer = setTimeout(() => {
console.info(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`);
this.connect(false);
}, delay);
}
// 计算重连延迟 - 指数退避策略
private calculateReconnectDelay(): number {
// 基础延迟 + 随机值,避免多个客户端同时重连
const jitter = Math.random() * 1000; // 0-1秒的随机延迟
const baseDelay = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
this.reconnectInterval * 5
);
return baseDelay + jitter;
}
// 清除指定定时器
private clearTimer(
timerName:
| "detectionTimer"
| "timeoutTimer"
| "reconnectTimer"
| "pingTimer"
| "connectionTimer"
): void {
if (this[timerName]) {
clearTimeout(this[timerName] as NodeJS.Timeout);
this[timerName] = null;
}
}
// 清除所有定时器
private clearAllTimers(): void {
this.clearConnectionTimers();
this.clearTimer("reconnectTimer");
}
private clearConnectionTimers(): void {
this.clearTimer("detectionTimer");
this.clearTimer("timeoutTimer");
this.clearTimer("pingTimer");
this.clearTimer("connectionTimer");
}
// 获取当前连接状态
get isWebSocketConnected(): boolean {
return this.isConnected;
}
// 获取当前连接状态文本
get connectionStatusText(): string {
if (this.isConnecting) return "正在连接";
if (this.isConnected) return "已连接";
if (this.isReconnecting && this.reconnectAttempts > 0)
return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})`;
return "已断开";
}
// 销毁实例
static destroyInstance(): void {
if (WebSocketClient.instance) {
WebSocketClient.instance.close();
WebSocketClient.instance = null;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* 通用 SSE(Server-Sent Events)客户端
* 基于 fetch 流式读取:支持 Authorization 头携带令牌(浏览器原生 EventSource
* 无法自定义请求头)、命名事件分发、空闲注释行(心跳)忽略与指数退避自动重连。
*/
import { Auth } from "@utils/auth";
export interface SSEClient {
/** 断开连接并停止重连(幂等) */
disconnect: () => void;
/** 当前是否处于连接打开状态 */
readonly connected: boolean;
}
export interface SSEClientOptions {
/** 完整 SSE 端点 URL(http/https) */
url: string;
/** 事件回调:event 为 SSE 事件名(缺省 "message"),data 为原始载荷 */
onEvent: (event: string, data: string) => void;
/** 连接状态变化回调 */
onStatus?: (connected: boolean) => void;
/** 不可恢复错误回调(如 401/403):收到后停止自动重连 */
onFatal?: (reason: string) => void;
/** 访问令牌提供者;缺省取本地登录态令牌,返回空则不带 Authorization 头 */
getToken?: () => string | null;
}
/** ws(s):// 基址转 http(s)://:SSE 走普通 HTTP,复用同一环境变量配置 */
export function httpEndpoint(endpoint: string): string {
return endpoint.replace(/^ws/, "http");
}
const RECONNECT_BASE_DELAY = 2000;
const RECONNECT_MAX_DELAY = 30000;
export function createSSEClient(options: SSEClientOptions): SSEClient {
let controller: AbortController | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let attempt = 0;
let stopped = false;
let open = false;
const setConnected = (value: boolean) => {
if (open !== value) {
open = value;
options.onStatus?.(value);
}
};
async function connect() {
if (stopped) return;
controller = new AbortController();
const token = (options.getToken ?? (() => Auth.getAccessToken()))();
const headers: Record<string, string> = { Accept: "text/event-stream" };
if (token) headers.Authorization = `Bearer ${token}`;
try {
const res = await fetch(options.url, { headers, signal: controller.signal });
// 令牌无效/无权限:重连也不会成功,交由上层处理(对齐原 WS 4001 语义)
if (res.status === 401 || res.status === 403) {
setConnected(false);
options.onFatal?.(`认证失败(${res.status})`);
return;
}
if (!res.ok || !res.body) throw new Error(`SSE 连接失败(${res.status})`);
attempt = 0;
setConnected(true);
await consume(res.body);
setConnected(false);
if (!stopped) scheduleReconnect(); // 服务端正常关流:继续重连
} catch (err) {
setConnected(false);
if (stopped || (err instanceof DOMException && err.name === "AbortError")) return;
scheduleReconnect();
}
}
/** 消费响应流:按空行分帧交由 dispatch 解析 */
async function consume(body: ReadableStream<Uint8Array>) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let index: number;
while ((index = buffer.indexOf("\n\n")) >= 0) {
dispatch(buffer.slice(0, index));
buffer = buffer.slice(index + 2);
}
}
}
/** 解析单帧:`:` 开头为注释行(服务端心跳保活)直接忽略 */
function dispatch(frame: string) {
let event = "message";
const dataLines: string[] = [];
for (const line of frame.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).replace(/^ /, ""));
}
if (dataLines.length > 0) options.onEvent(event, dataLines.join("\n"));
}
function scheduleReconnect() {
if (stopped || reconnectTimer) return;
const delay = Math.min(RECONNECT_BASE_DELAY * Math.pow(1.5, attempt), RECONNECT_MAX_DELAY);
attempt += 1;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, delay);
}
connect();
return {
disconnect() {
stopped = true;
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
controller?.abort();
controller = null;
setConnected(false);
},
get connected() {
return open;
},
};
}
-1
View File
@@ -37,7 +37,6 @@ type SysEvents = {
triggerFireworks: string | undefined;
openSetting: void;
openSearchDialog: void;
openChat: void;
openLockScreen: void;
};
@@ -159,9 +159,10 @@
<script setup lang="ts">
defineOptions({ name: "Home", inheritAttrs: false });
import { ref, onMounted, defineAsyncComponent } from "vue";
import { ref, onMounted, onUnmounted, defineAsyncComponent } from "vue";
import { ElMessage } from "element-plus";
import { getDashboardMock } from "@/mock/dashboard";
import DashboardAPI from "@/api/module_monitor/dashboard";
import { getDashboardMock, type HealthItem } from "@/mock/dashboard";
import ImageCards from "./modules/image_cards.vue";
import ItBanners from "./modules/it_banners.vue";
import Banner from "./modules/banner.vue";
@@ -173,13 +174,23 @@ import QuickLinks from "./modules/quick-links.vue";
const mock = getDashboardMock();
const loading = ref(false);
const healthList = ref(mock.health);
const healthList = ref<HealthItem[]>(mock.health); // 初值 mock 兜底,健康流首帧到达即覆盖
const timelineData = ref(mock.timeline);
let unsubscribeHealth: (() => void) | null = null;
onMounted(() => {
// 后续替换为真实接口:
// const { data } = await DashboardAPI.getStats();
// 并删除 getDashboardMock() 调用
unsubscribeHealth = DashboardAPI.subscribeHealthStream((items) => {
healthList.value = items;
});
});
onUnmounted(() => {
unsubscribeHealth?.();
unsubscribeHealth = null;
});
// 图表组件异步导入,减少首屏 echarts 加载
@@ -166,7 +166,6 @@
<script setup lang="ts">
import { Picture, Paperclip, ArrowDown } from "@element-plus/icons-vue";
import { mittBus } from "@utils";
import meAvatar from "@imgs/avatar/avatar5.webp";
import aiAvatar from "@imgs/avatar/avatar10.webp";
import avatar2 from "@imgs/avatar/avatar2.webp";
@@ -198,7 +197,6 @@ interface Person {
}
const searchQuery = ref("");
const isDrawerVisible = ref(false);
const isOnline = ref(true);
const selectedPerson = ref<Person | null>(null);
const messageText = ref("");
@@ -446,21 +444,9 @@ const scrollToBottom = () => {
}, 100);
};
/**
* 打开聊天窗口
*/
const openChat = () => {
isDrawerVisible.value = true;
};
onMounted(() => {
scrollToBottom();
mittBus.on("openChat", openChat);
const first = personList.value[0];
if (first) selectedPerson.value = first;
});
onUnmounted(() => {
mittBus.off("openChat", openChat);
});
</script>
@@ -253,8 +253,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import {
Plus,
@@ -478,9 +477,7 @@ const handleUseDefault = async () => {
const handleDelete = async (item: AiModelConfigItem) => {
try {
await ElMessageBox.confirm(`确认删除模型「${item.name}」?此操作不可恢复`, "删除确认", {
type: "warning",
});
await confirmDelete(`确认删除模型「${item.name}」?此操作不可恢复`);
} catch {
return;
}
@@ -117,7 +117,6 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { ElMessage } from "element-plus";
import {
Promotion,
@@ -16,8 +16,6 @@
</template>
<script setup lang="ts">
import { ref, nextTick, watch } from "vue";
import WelcomeScreen from "./FaWelcomeScreen.vue";
import FaMessageItem from "./FaMessageItem.vue";
import type { ChatMessage } from "../types";
@@ -40,7 +40,6 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { Connection, Loading, Warning, Delete, Setting } from "@element-plus/icons-vue";
import { resolveIconForFaSvgIcon } from "@utils";
@@ -62,7 +62,6 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { ElMessage } from "element-plus";
import {
User,
@@ -120,7 +120,6 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import {
@@ -303,7 +302,7 @@ const loadSessions = async () => {
title: item.session_data?.session_name || item.session_id?.slice(0, 8) || "新会话",
created_at: item.created_at,
updated_at: item.updated_at,
message_count: item.runs?.length || 0,
message_count: item.message_count ?? item.runs?.length ?? 0,
session_id: item.session_id,
session_type: item.session_type,
agent_id: item.agent_id,
@@ -320,7 +319,7 @@ const loadSessions = async () => {
workflow_data: item.workflow_data,
created_time: item.created_at ? new Date(item.created_at * 1000).toISOString() : null,
updated_time: item.updated_at ? new Date(item.updated_at * 1000).toISOString() : null,
messages: item.runs?.flatMap((run: any) => run.messages || []) || [],
messages: item.messages || [],
}));
}
} catch (error) {
@@ -58,9 +58,8 @@ defineOptions({
inheritAttrs: false,
});
import { ref, onMounted, onUnmounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import AiChatAPI, { ChatSession } from "@/api/module_ai/chat";
import AiChatAPI, { type ChatSession } from "@/api/module_ai/chat";
import { Auth } from "@utils/auth";
import type { ChatMessage, UploadedFile } from "./types";
import FaSidebar from "./components/FaSidebar.vue";
@@ -295,9 +294,9 @@ const handleSelectSession = async (session: ChatSession) => {
const sessionData = response.data.data || {};
const runs = sessionData.runs || [];
runs.forEach((run: any) => {
runs.forEach((run: { messages?: { role: string; content: string }[] }) => {
const runMessages = run.messages || [];
runMessages.forEach((msg: any) => {
runMessages.forEach((msg: { role: string; content: string }) => {
if (msg.role === "user" || msg.role === "assistant") {
addMessage(msg.role, msg.content);
}
@@ -145,7 +145,6 @@ defineOptions({
inheritAttrs: false,
});
import { ref, reactive, computed, nextTick } from "vue";
import { Edit } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import AiChatAPI, { type ChatSession, type ChatSessionDetail } from "@/api/module_ai/chat";
@@ -35,11 +35,12 @@
:perm-patch="['module_example:demo:patch']"
:delete-loading="batchDeleting"
:create-loading="createLoading"
:more-loading="moreLoading"
@add="handleAdd"
@import="openImport"
@export="openExport"
@delete="handleBatchDelete"
@more="runBatchStatus"
@more="handleMoreClick"
/>
</template>
</FaTableHeader>
@@ -153,8 +154,13 @@
<script setup lang="ts">
import { Plus, Delete } from "@element-plus/icons-vue";
import type { TableOperationAction } from "@/utils/table";
import { renderTableOperationCell, stripPaginationParams, toCrudCols } from "@utils";
import {
renderTableOperationCell,
resolveStatusColumns,
stripPaginationParams,
toCrudCols,
type TableOperationAction,
} from "@utils";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { ResultEnum } from "@/enums/api/result.enum";
import type { IContentConfig, IObject } from "@/components/modal/types";
@@ -165,7 +171,6 @@ import DemoAPI, {
type DemoPageQuery,
type DemoTable,
} from "@/api/module_example/demo";
import type { ColumnOption } from "@/types/component";
import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
@@ -245,6 +250,7 @@ const { selectedRows, selectedIds, batchDeleting, onTableSelectionChange } =
useTableSelection<DemoTable>();
const createLoading = ref(false);
const moreLoading = ref(false);
const {
columns,
@@ -264,14 +270,14 @@ const {
refreshRemove,
} = useTable({
core: {
apiFn: DemoAPI.getDemoList,
apiFn: DemoAPI.listDemo,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<DemoTable>[] => [
{ type: "globalIndex", width: 56, label: "序号" },
columnsFactory: resolveStatusColumns<DemoTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{ prop: "name", label: "名称", minWidth: 120, showOverflowTooltip: true },
{
prop: "status",
@@ -279,7 +285,7 @@ const {
width: 88,
status: {
0: { type: "success", text: "启用" },
1: { type: "info", text: "停用" },
1: { type: "danger", text: "停用" },
},
},
{ prop: "int_val", label: "整数", minWidth: 88, showOverflowTooltip: true },
@@ -314,13 +320,13 @@ const {
showOverflowTooltip: true,
},
{
prop: "created_by",
prop: "created_id",
label: "创建人",
minWidth: 100,
formatter: (row: DemoTable) => row.created_by?.name ?? "—",
},
{
prop: "updated_by",
prop: "updated_id",
label: "更新人",
minWidth: 100,
formatter: (row: DemoTable) => row.updated_by?.name ?? "—",
@@ -333,7 +339,7 @@ const {
align: "center",
formatter: (row: DemoTable) => formatDemoOperationCell(row),
},
],
]),
},
});
@@ -489,7 +495,7 @@ const crud = useCrudForm<DemoForm>({
dialogVisible,
dataFormRef,
formRenderKey: demoFormRenderKey,
detailApi: DemoAPI.getDemoDetail,
detailApi: DemoAPI.detailDemo,
createApi: DemoAPI.createDemo,
updateApi: DemoAPI.updateDemo,
titles: { create: "新增", update: "修改", detail: "详情" },
@@ -640,7 +646,12 @@ async function handleBatchDelete() {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmBatchDelete(ids.length);
await confirmBatchDelete(
ids.length,
(data.value as DemoTable[])
.filter((r) => ids.includes(r.id!))
.map((r) => String(r.name ?? r.id))
);
batchDeleting.value = true;
await DemoAPI.deleteDemo(ids);
faTableRef.value?.elTableRef?.clearSelection();
@@ -652,24 +663,24 @@ async function handleBatchDelete() {
}
}
async function runBatchStatus(value: "enable" | "disable") {
async function handleMoreClick(value: "enable" | "disable") {
const ids = selectedIds.value;
if (ids.length === 0) {
ElMessage.warning("请先在列表中勾选数据");
return;
}
try {
await confirmAction(
`确认对选中的 ${ids.length} 条数据${value === "enable" ? "启用" : "停用"}?`,
"批量设置"
);
await confirmToggleStatus(value);
moreLoading.value = true;
const status = value === "enable" ? 0 : 1;
await DemoAPI.batchDemo({ ids, status });
// 成功 / 失败提示由 axios 拦截器统一处理
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
// 用户取消
// 用户取消或操作失败
} finally {
moreLoading.value = false;
}
}
@@ -64,7 +64,6 @@
<script setup lang="ts">
import "codemirror/mode/sql/sql.js";
import "codemirror/theme/dracula.css";
import { ref, watch } from "vue";
import Codemirror from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
@@ -261,7 +261,6 @@
</template>
<script setup lang="ts">
import { computed, inject, onUnmounted, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { QuestionFilled } from "@element-plus/icons-vue";
import type { GenTableSchema } from "@/api/module_generator/gencode";
@@ -276,7 +275,13 @@ const props = defineProps<{
menuOptions: OptionType[];
}>();
function findOptionByValue(options: OptionType[], value: number | string): any | null {
/** 菜单树选项在通用 OptionType 基础上携带 route_path(见 formatMenuTreeWithMeta) */
type MenuOptionNode = OptionType & { route_path?: string };
function findOptionByValue(
options: MenuOptionNode[],
value: number | string
): MenuOptionNode | null {
for (const opt of options) {
if (String(opt.value) === String(value)) return opt;
if (opt.children?.length) {
@@ -80,7 +80,6 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { FormRules } from "element-plus";
import { Close, Right, FolderOpened, Back, Download } from "@element-plus/icons-vue";
import type { EditorConfiguration } from "codemirror";
@@ -246,7 +246,6 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from "vue";
import { Rank, Search } from "@element-plus/icons-vue";
import { useDraggable } from "vue-draggable-plus";
import type { GenTableSchema, GenTableColumnSchema } from "@/api/module_generator/gencode";
@@ -293,7 +292,7 @@ const displayColumns = computed(() => {
function syncSortNumbers() {
const cols = columnsModel.value || [];
cols.forEach((c: any, idx: number) => {
cols.forEach((c, idx) => {
c.sort = idx + 1;
});
}
@@ -87,7 +87,6 @@ import "codemirror/mode/javascript/javascript.js";
import "codemirror/mode/python/python.js";
import "codemirror/mode/htmlmixed/htmlmixed.js";
import "codemirror/theme/dracula.css";
import { computed, inject, onUnmounted, ref, watch } from "vue";
import Codemirror from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import type { CmComponentRef } from "codemirror-editor-vue3";
@@ -85,7 +85,6 @@
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { FormInstance, TableInstance } from "element-plus";
import { Search, Refresh } from "@element-plus/icons-vue";
import type { DBTableSchema, GenTablePageQuery } from "@/api/module_generator/gencode";
@@ -137,7 +137,6 @@ defineOptions({
inheritAttrs: false,
});
import { ref, reactive, computed, watchEffect, onActivated, nextTick, provide } from "vue";
import "codemirror/theme/dracula.css";
import { useClipboard } from "@vueuse/core";
import { useRoute } from "vue-router";
@@ -151,8 +150,8 @@ import GencodeAPI, {
type DBTableSchema,
type GenTablePageQuery,
} from "@/api/module_generator/gencode";
import MenuAPI, { MenuTable } from "@/api/module_system/menu";
import DictAPI, { DictTable } from "@/api/module_system/dict";
import MenuAPI, { type MenuTable } from "@/api/module_system/menu";
import DictAPI, { type DictTable } from "@/api/module_system/dict";
import { MenuTypeEnum } from "@/enums";
import { useSettingsStore } from "@stores";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
@@ -161,8 +160,7 @@ import FaGenCodeDrawer from "./components/FaGenCodeDrawer.vue";
import FaImportDbTableDialog from "./components/FaImportDbTableDialog.vue";
import FaCreateTableDialog from "./components/FaCreateTableDialog.vue";
import { GENCODE_BASIC_FORM_KEY, GENCODE_CM_KEY } from "./gencodeInjectionKeys";
import type { TableOperationAction } from "@utils";
import { renderTableOperationCell } from "@utils";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import type { TreeNode } from "./types";
import type { ColumnOption } from "@/types/component";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
@@ -601,22 +599,30 @@ function handleImportTableSelectionChange(rows: ImportTableSelectionRow[]): void
}
/** 代码生成「上级菜单」仅展示目录节点,便于挂到目录下生成新菜单(不选菜单/按钮作为父级) */
const filterMenuTypes = (nodes: MenuTable[]) => {
const filterMenuTypes = (nodes: MenuTable[]): MenuTable[] => {
return nodes
.filter((node) => node.type === MenuTypeEnum.CATALOG)
.map((node: any): any => ({
.map((node) => ({
...node,
children: node.children ? filterMenuTypes(node.children) : [],
}));
};
/** 代码生成专用:保留 route_path,便于实时推断分系统 module_xxx */
function formatMenuTreeWithMeta(nodes: any[]): any[] {
type MenuTreeOption = {
value: number;
label: string;
disabled: boolean;
route_path?: string;
children?: MenuTreeOption[];
};
function formatMenuTreeWithMeta(nodes: MenuTable[]): MenuTreeOption[] {
return nodes.map((node) => {
const formattedNode: any = {
value: node.id,
label: node.name,
disabled: node.status === false || String(node.status) === "false",
const formattedNode: MenuTreeOption = {
value: node.id ?? 0,
label: node.name ?? "",
disabled: String(node.status) === "false",
route_path: node.route_path,
};
if (node.children && node.children.length > 0) {
@@ -686,11 +692,7 @@ async function handleDelete(row?: GenTableSchema): Promise<void> {
}
try {
await ElMessageBox.confirm(`是否确认删除选中的${tableIds.length}条数据?`, "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`是否确认删除选中的${tableIds.length}条数据?`);
await GencodeAPI.deleteTable(tableIds);
faTableRef.value?.elTableRef?.clearSelection();
+5 -5
View File
@@ -123,7 +123,7 @@
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
@click="getCacheKeyList(row)"
@click="getCacheKeyList(row as CacheInfo)"
>
{{ row.cache_name }}
</ElButton>
@@ -135,7 +135,7 @@
<ElPopconfirm
:title="`确认删除缓存 ${row.cache_name} 吗?`"
placement="top"
@confirm="handleClearCacheName(row)"
@confirm="handleClearCacheName(row as CacheInfo)"
>
<template #reference>
<ElButton
@@ -201,7 +201,7 @@
<ElPopconfirm
:title="`确认删除键 ${row.cacheKey} 吗?`"
placement="top"
@confirm="handleClearCacheKey(row.cacheKey)"
@confirm="handleClearCacheKey(row.cacheKey as string)"
>
<template #reference>
<ElButton
@@ -328,7 +328,7 @@ const getCacheNameList = async () => {
}
};
const handleClearCacheName = async (row: any) => {
const handleClearCacheName = async (row: CacheInfo) => {
try {
await CacheAPI.deleteCacheName(row.cache_name);
getCacheNameList();
@@ -338,7 +338,7 @@ const handleClearCacheName = async (row: any) => {
}
};
const getCacheKeyList = async (row?: any) => {
const getCacheKeyList = async (row?: CacheInfo) => {
try {
const cacheName = row?.cache_name || nowCacheName.value;
if (!cacheName) return;
@@ -56,11 +56,8 @@ defineOptions({
inheritAttrs: false,
});
import { h, ref, computed } from "vue";
import OnlineAPI, { type OnlineUserTable } from "@/api/module_monitor/online";
import { ElMessageBox } from "element-plus";
import type { TableOperationAction } from "@/utils/table";
import { renderTableOperationCell } from "@utils";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import FaCopyButton from "@/components/actions/fa-copy-button/index.vue";
@@ -122,11 +119,7 @@ const clearAllLoading = ref(false);
async function kickSession(sessionId: string) {
try {
await ElMessageBox.confirm(`确认强制退出会话 ${sessionId}?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete(`确认强制退出会话 ${sessionId}?`);
await OnlineAPI.deleteOnline(sessionId);
// 成功 / 失败提示由 axios 拦截器统一处理
await refreshData();
@@ -259,11 +252,7 @@ async function onResetSearch() {
async function handleClearAll() {
try {
await ElMessageBox.confirm("确认强制退出所有用户?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete("确认强制退出所有用户?");
clearAllLoading.value = true;
await OnlineAPI.clearOnline();
// 成功 / 失败提示由 axios 拦截器统一处理
@@ -186,7 +186,6 @@
</template>
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import ServerAPI, { type ServerInfo } from "@/api/module_monitor/server";
defineOptions({ name: "ServerMonitor" });
@@ -91,7 +91,6 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import AppConfig from "@/config";
import loginIcon from "@fa_imgs/background.svg";
import { useConfigStore } from "@stores";
@@ -113,11 +113,9 @@
<script setup lang="ts">
import { LanguageEnum } from "@/enums/appEnum";
import { computed } from "vue";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n";
import { useSettingsStore, useUserStore, useConfigStore } from "@stores";
import { useHeaderBar } from "@/hooks/core/useHeaderBar";
import { themeAnimation } from "@utils";
import { languageOptions } from "@/locales";
@@ -1,657 +0,0 @@
<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>
@@ -101,9 +101,6 @@
</template>
<script setup lang="ts">
import { useTableColumns } from "@/hooks/core/useTableColumns";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import DeptAPI, {
type DeptForm,
type DeptPageQuery,
@@ -187,20 +187,6 @@
</ElOption>
</ElSelect>
</template>
<template #is_default>
<ElRadioGroup v-model="formData.is_default">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<template #status>
<ElSwitch
v-model="formData.status"
inline-prompt
:active-value="0"
:inactive-value="1"
/>
</template>
</FaForm>
</template>
</FaDialog>
@@ -209,7 +195,6 @@
</template>
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from "vue";
import DictAPI, {
type DictDataForm,
type DictDataPageQuery,
@@ -483,19 +468,22 @@ const dictDataDialogFormItems = computed<FormItem[]>(() => [
label: "样式属性",
key: "css_class",
type: "input",
placeholder: "",
},
{
label: "列表类样式",
key: "list_class",
type: "input",
placeholder: "",
},
{
label: "是否默认",
key: "is_default",
type: "radiogroup",
placeholder: "",
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
label: "排序",
@@ -511,7 +499,11 @@ const dictDataDialogFormItems = computed<FormItem[]>(() => [
label: "状态",
key: "status",
type: "switch",
placeholder: "",
props: {
activeValue: 0,
inactiveValue: 1,
inlinePrompt: true,
},
},
{
label: "描述",
@@ -114,7 +114,6 @@
</template>
<script setup lang="ts">
import { computed, h, ref, watch } from "vue";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import DictAPI, { type DictForm, type DictTable } from "@/api/module_system/dict";
import { useDictStore } from "@stores";
@@ -179,8 +179,6 @@
</template>
<script setup lang="ts">
import { h } from "vue";
import OperationLogAPI, {
type OperationLogPageQuery,
type OperationLogTable,
@@ -185,29 +185,6 @@
</ElText>
</template>
<!-- 外链地址 -->
<template #link>
<ElInput v-model="formData.link" placeholder="请输入外链完整路径" />
</template>
<!-- 嵌入iframe -->
<template #is_iframe>
<ElRadioGroup v-model="formData.is_iframe">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 路由名称 -->
<template #route_name>
<ElInput v-model="formData.route_name" placeholder="请输入路由名称" />
</template>
<!-- 路由路径 -->
<template #route_path>
<ElInput v-model="formData.route_path" placeholder="请输入路由路径,如 system" />
</template>
<!-- 组件路径 -->
<template #component_path>
<ElInput
@@ -220,14 +197,6 @@
</ElInput>
</template>
<!-- 激活菜单路径 -->
<template #active_path>
<ElInput
v-model="formData.active_path"
placeholder="请输入激活菜单路径,用于高亮父级菜单"
/>
</template>
<!-- 路由参数(动态键值编辑器) -->
<template #params>
<template
@@ -262,35 +231,6 @@
</div>
</template>
</template>
<!-- 是否隐藏 -->
<template #hidden>
<ElRadioGroup v-model="formData.hidden">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 始终显示 -->
<template #always_show>
<ElRadioGroup v-model="formData.always_show">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 缓存页面 -->
<template #keep_alive>
<ElRadioGroup v-model="formData.keep_alive">
<ElRadio :value="true">开启</ElRadio>
<ElRadio :value="false">关闭</ElRadio>
</ElRadioGroup>
</template>
<!-- 权限标识 -->
<template #permission>
<ElInput v-model="formData.permission" placeholder="请输入权限标识,如sys:user:add" />
</template>
<!-- 图标 -->
<template #icon>
<FaIconSelect v-model="formData.icon" />
@@ -307,35 +247,6 @@
"
/>
</template>
<!-- 常驻标签栏 -->
<template #affix>
<ElRadioGroup v-model="formData.affix">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 隐藏标签页 -->
<template #is_hide_tab>
<ElRadioGroup v-model="formData.is_hide_tab">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 显示红点角标 -->
<template #show_badge>
<ElRadioGroup v-model="formData.show_badge">
<ElRadio :value="true">是</ElRadio>
<ElRadio :value="false">否</ElRadio>
</ElRadioGroup>
</template>
<!-- 文字角标内容 -->
<template #show_text_badge>
<ElInput v-model="formData.show_text_badge" placeholder="请输入文字角标内容" />
</template>
</FaForm>
</template>
</FaDrawer>
@@ -343,7 +254,6 @@
</template>
<script setup lang="ts">
import { h } from "vue";
defineOptions({
name: "SysMenu",
inheritAttrs: false,
@@ -366,7 +276,7 @@ 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 FaMenuRouteIcon from "@/components/navigation/fa-menu-route-icon/index.vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import FaDescriptions from "@/components/display/fa-descriptions/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
@@ -598,19 +508,33 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
{ key: "name", label: "菜单名称", type: "input", props: { placeholder: "请输入菜单名称" } },
{ key: "title", label: "菜单标题", type: "input", props: { placeholder: "请输入菜单标题" } },
{ key: "icon", label: "图标", type: "input", hidden: t === MenuTypeEnum.BUTTON },
{ key: "link", label: "外链地址", type: "input", hidden: t !== MenuTypeEnum.EXTLINK },
{ key: "route_name", label: "路由名称", type: "input", hidden: t === MenuTypeEnum.BUTTON },
{
key: "link",
label: "外链地址",
type: "input",
hidden: t !== MenuTypeEnum.EXTLINK,
props: { placeholder: "请输入外链完整路径" },
},
{
key: "route_name",
label: "路由名称",
type: "input",
hidden: t === MenuTypeEnum.BUTTON,
props: { placeholder: "请输入路由名称" },
},
{
key: "permission",
label: "权限标识",
type: "input",
hidden: t !== MenuTypeEnum.BUTTON && t !== MenuTypeEnum.MENU,
props: { placeholder: "请输入权限标识,如sys:user:add" },
},
{
key: "route_path",
label: "路由路径",
type: "input",
hidden: t !== MenuTypeEnum.CATALOG && t !== MenuTypeEnum.MENU,
props: { placeholder: "请输入路由路径,如 system" },
},
{
key: "component_path",
@@ -623,6 +547,7 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
label: "激活菜单路径",
type: "input",
hidden: t !== MenuTypeEnum.CATALOG && t !== MenuTypeEnum.MENU,
props: { placeholder: "请输入激活菜单路径,用于高亮父级菜单" },
},
{
key: "redirect",
@@ -637,6 +562,12 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
label: "嵌入iframe",
type: "radiogroup",
hidden: t !== MenuTypeEnum.EXTLINK,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
key: "status",
@@ -649,37 +580,84 @@ const menuDialogFormItems = computed<FormItem[]>(() => {
],
},
},
{ key: "hidden", label: "是否隐藏", type: "radiogroup", hidden: t === MenuTypeEnum.BUTTON },
{
key: "hidden",
label: "是否隐藏",
type: "radiogroup",
hidden: t === MenuTypeEnum.BUTTON,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
key: "always_show",
label: "始终显示",
type: "radiogroup",
hidden: t !== MenuTypeEnum.CATALOG && t !== MenuTypeEnum.MENU,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
key: "keep_alive",
label: "缓存页面",
type: "radiogroup",
hidden: t !== MenuTypeEnum.MENU,
props: {
options: [
{ label: "开启", value: true },
{ label: "关闭", value: false },
],
},
},
{
key: "affix",
label: "常驻标签栏",
type: "radiogroup",
hidden: t === MenuTypeEnum.BUTTON,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{ key: "affix", label: "常驻标签栏", type: "radiogroup", hidden: t === MenuTypeEnum.BUTTON },
{
key: "is_hide_tab",
label: "隐藏标签页",
type: "radiogroup",
hidden: t === MenuTypeEnum.BUTTON,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
key: "show_badge",
label: "显示红点角标",
type: "radiogroup",
hidden: t === MenuTypeEnum.BUTTON,
props: {
options: [
{ label: "是", value: true },
{ label: "否", value: false },
],
},
},
{
key: "show_text_badge",
label: "文字角标内容",
type: "input",
hidden: t === MenuTypeEnum.BUTTON || !formData.value.show_badge,
props: { placeholder: "请输入文字角标内容" },
},
{
key: "params",
@@ -876,7 +854,7 @@ const opCtx = {
const { columnChecks, columns } = useTableColumns<MenuTable>(
resolveStatusColumns(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "index", label: "序号", width: 60, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
// ─── 基础信息 ───
{
prop: "name",
@@ -1126,7 +1104,6 @@ const rules = reactive({
hidden: [{ required: true, message: "请选择是否隐藏", trigger: "change" }],
always_show: [{ required: true, message: "请选择始终显示", trigger: "change" }],
status: [{ required: true, message: "请选择状态", trigger: "change" }],
client: [{ required: true, message: "请选择终端", trigger: "change" }],
redirect: [
{
validator: (_rule: unknown, value: string | undefined, callback: (e?: Error) => void) => {
@@ -1322,11 +1299,7 @@ async function handleMoreClick(value: "enable" | "disable") {
return;
}
try {
await ElMessageBox.confirm(`确认${value === "enable" ? "启用" : "停用"}该项数据?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmToggleStatus(value);
moreLoading.value = true;
const status = value === "enable" ? 0 : 1;
await MenuAPI.batchMenu({ ids, status });
@@ -231,7 +231,6 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, onMounted } from "vue";
import { ElMessage } from "element-plus";
import ParamsAPI from "@/api/module_system/params";
import { useConfigStore } from "@stores";
@@ -391,7 +390,7 @@ async function saveField(field: ParamField) {
config_value: field.localValue,
config_name: field.configName,
config_key: field.configKey,
} as any);
});
field.modified = false;
field.displayValue = field.localValue;
configStore.isConfigLoaded = false;
@@ -414,7 +413,7 @@ async function saveGroup(fields: ParamField[]) {
config_value: field.localValue,
config_name: field.configName,
config_key: field.configKey,
} as any);
});
field.modified = false;
field.displayValue = field.localValue;
successCount++;
@@ -93,12 +93,6 @@
:show-submit="false"
class="crud-dialog-art-form"
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
</template>
</FaDialog>
@@ -114,8 +108,6 @@
</template>
<script setup lang="ts">
import { useCrudForm } from "@/hooks/core/useCrudForm";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import PositionAPI, {
type PositionForm,
type PositionPageQuery,
@@ -135,6 +127,7 @@ import {
stripPaginationParams,
cleanEmptyArrayParams,
toCrudCols,
type TableOperationAction,
} from "@utils";
defineOptions({
@@ -170,16 +163,6 @@ function buildPositionReplaceParams(p: PositionSearchForm): Record<string, unkno
};
}
type RowAction = {
key: string;
label: string;
artType: "add" | "edit" | "delete" | "view" | "more";
icon?: string;
perm: string;
disabled?: boolean;
run: () => void;
};
function buildPositionRowActions(
row: PositionTable,
ctx: {
@@ -187,8 +170,8 @@ function buildPositionRowActions(
onEdit: (id: number) => void;
onDelete: (id: number, name: string) => void;
}
): RowAction[] {
const all: RowAction[] = [
): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
@@ -480,7 +463,18 @@ const positionDialogFormItems = computed<FormItem[]>(() => [
span: 24,
props: { controlsPosition: "right", min: 1 },
},
{ key: "status", label: "状态", type: "radiogroup", span: 24 },
{
label: "状态",
key: "status",
type: "radiogroup",
span: 24,
props: {
options: [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
],
},
},
{
label: "描述",
key: "description",
@@ -82,7 +82,6 @@
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { QuestionFilled } from "@element-plus/icons-vue";
import RoleAPI, { permissionDataType } from "@/api/module_system/role";
import MenuAPI, { MenuTable } from "@/api/module_system/menu";
@@ -134,7 +133,7 @@ const init = async () => {
rawMenuTree.value = menuResponse.data.data || [];
const roleResponse = await RoleAPI.detailRole(props.roleId);
const savedMenuIds = roleResponse.data.data.menus?.map((menu: any) => menu.id) || [];
const savedMenuIds = roleResponse.data.data.menus?.map((menu) => menu.id) || [];
permissionState.value = {
role_ids: [props.roleId],
@@ -120,12 +120,6 @@
:show-submit="false"
class="crud-dialog-art-form"
>
<template #status>
<ElRadioGroup v-model="formData.status">
<ElRadio :value="0">启用</ElRadio>
<ElRadio :value="1">停用</ElRadio>
</ElRadioGroup>
</template>
</FaForm>
</template>
</FaDialog>
@@ -515,6 +509,12 @@ const roleDialogFormItems = computed<FormItem[]>(() => [
key: "status",
type: "radiogroup",
span: 24,
props: {
options: [
{ label: "启用", value: 0 },
{ label: "停用", value: 1 },
],
},
},
{
label: "描述",
@@ -372,7 +372,6 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from "vue";
import { useCrudForm } from "@/hooks/core/useCrudForm";
import TicketAPI, {
getTicketComments,
@@ -382,6 +381,7 @@ import TicketAPI, {
type TicketTable,
type TicketCommentTable,
} from "@/api/module_system/ticket";
import type { ColumnOption } from "@/types/component";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import {
@@ -530,7 +530,7 @@ function onPageCurrentChange(page: number) {
fetchData();
}
const columnChecks = ref([]);
const columnChecks = ref<ColumnOption[]>([]);
async function handleSearchBarSearch(params: Record<string, unknown>) {
searchForm.value = {
@@ -198,7 +198,6 @@ defineOptions({
inheritAttrs: false,
});
import { h } from "vue";
import { UserFilled } from "@element-plus/icons-vue";
import { useAppStore } from "@stores";
@@ -137,14 +137,9 @@
</template>
<script setup lang="ts">
import {
renderTableOperationCell,
resolveStatusColumns,
type TableOperationAction,
} from "@/utils/table";
import { renderTableOperationCell, resolveStatusColumns, type TableOperationAction } from "@utils";
import VersionAPI, { type VersionForm, type VersionTable } from "@/api/module_system/version";
import { Plus } from "@element-plus/icons-vue";
import { reactive } from "vue";
import type { AuditSearchFormParams } from "@/components/forms/fa-search-bar/auditSearchFormItems";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import type { ColumnOption } from "@/types/component";
@@ -377,14 +377,17 @@ defineOptions({
inheritAttrs: false,
});
import JobAPI, { SchedulerStatus, SchedulerJob, JobLogTable } from "@/api/module_task/cronjob/job";
import JobAPI, {
type SchedulerStatus,
type SchedulerJob,
type JobLogTable,
} from "@/api/module_task/cronjob/job";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import FaDrawer from "@/components/modal/fa-drawer/index.vue";
import { ElMessageBox } from "element-plus";
import {
VideoPlay,
VideoPause,
@@ -396,9 +399,7 @@ import {
Edit,
Close,
} from "@element-plus/icons-vue";
import type { TableOperationAction } from "@/utils/table";
import { renderTableOperationCell } from "@utils";
import { computed, h, nextTick, onMounted, ref } from "vue";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import { Terminal, TerminalApi } from "vue-web-terminal";
import type { ColumnOption } from "@/types/component";
@@ -811,7 +812,7 @@ const consoleVisible = ref(false);
const executionLogDrawerVisible = ref(false);
const jobStateVisible = ref(false);
const jobStateData = ref<any>(null);
const jobStateData = ref<string | Record<string, unknown> | undefined>(undefined);
// ─── 修改任务弹窗 ───
const modifyDialog = reactive({
@@ -1001,11 +1002,7 @@ async function handleResumeScheduler() {
async function handleShutdownScheduler() {
try {
await ElMessageBox.confirm("确定要关闭调度器吗?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
await confirmDelete("确定要关闭调度器吗?");
await JobAPI.shutdownScheduler();
await refreshJobList();
} catch (error: unknown) {
@@ -1017,17 +1014,8 @@ async function handleShutdownScheduler() {
async function handleClearAllJobs() {
try {
await ElMessageBox.confirm(
"确定要清空所有任务吗?\n" +
"此操作会将所有待执行任务的日志标记为已取消,不会删除历史执行记录。\n" +
"如需删除所有执行记录,请使用执行记录的批量删除功能。",
"警告",
{
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
dangerouslyUseHTMLString: false,
}
await confirmDelete(
"确定要清空所有任务吗?此操作会将所有待执行任务的日志标记为已取消,不会删除历史执行记录。"
);
await JobAPI.clearAllJobs();
await refreshJobList();
@@ -1107,6 +1095,8 @@ async function handleOpenExecutionLogDrawer(job: SchedulerJob) {
logSearchForm.value = {
status: undefined,
trigger_type: undefined,
created_time: undefined,
updated_time: undefined,
};
executionLogDrawerVisible.value = true;
await nextTick();
@@ -30,10 +30,13 @@
:remove-ids="selectedIds"
:perm-create="['module_task:cronjob:node:create']"
:perm-delete="['module_task:cronjob:node:delete']"
:perm-patch="['module_task:cronjob:node:update']"
:delete-loading="batchDeleting"
:more-loading="moreLoading"
:create-loading="createLoading"
@add="handleAdd"
@delete="handleBatchDelete"
@more="handleBatchMoreStatus"
/>
</template>
</FaTableHeader>
@@ -78,20 +81,20 @@
<template #jobstore>
<ElSelect v-model="formData.jobstore" placeholder="请选择存储器">
<ElOption
v-for="item in dictStore.getDictArray('sys_job_store')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
v-for="item in jobStoreOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</template>
<template #executor>
<ElSelect v-model="formData.executor" placeholder="请选择执行器">
<ElOption
v-for="item in dictStore.getDictArray('sys_job_executor')"
:key="item.dict_value"
:label="item.dict_label"
:value="item.dict_value"
v-for="item in jobExecutorOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</template>
@@ -154,6 +157,60 @@
:max="10"
/>
</template>
<template #trigger_args>
<template v-if="formData.trigger === 'cron'">
<ElInput
v-model="formData.trigger_args"
placeholder="请输入 * * * * * ? *"
readonly
@click="openCron = true"
/>
<FaDialog
v-model="openCron"
title="Cron 表达式"
width="min(700px, calc(100vw - 48px))"
append-to-body
>
<FaCron v-model="cronTempValue" />
<template #footer>
<ElButton @click="openCron = false">取消</ElButton>
<ElButton type="primary" @click="confirmCron">确定</ElButton>
</template>
</FaDialog>
</template>
<template v-else-if="formData.trigger === 'interval'">
<ElPopover
:visible="openInterval"
width="600px"
trigger="click"
:persistent="false"
placement="auto-end"
>
<template #reference>
<ElInput
v-model="formData.trigger_args"
placeholder="请点击设置间隔时间"
@click="openInterval = true"
/>
</template>
<FaIntervalTab
:cron-value="formData.trigger_args"
@confirm="handleIntervalConfirm"
@cancel="openInterval = false"
/>
</ElPopover>
</template>
<template v-else-if="formData.trigger === 'date'">
<ElDatePicker
v-model="formData.trigger_args"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择执行时间"
:style="'width: 100%'"
/>
</template>
</template>
</FaForm>
</ElScrollbar>
</ElSplitterPanel>
@@ -183,96 +240,6 @@
</div>
</template>
</FaDialog>
<FaDialog
v-model="executeDialogVisible"
title="调试节点"
width="700px"
@close="handleCloseExecuteDialog"
>
<FaForm
:key="executeFormRenderKey"
ref="executeFormRef"
v-model="executeFormData"
:items="executeDialogFormItems"
:rules="executeRules"
label-suffix=":"
label-width="85px"
:span="24"
:gutter="16"
:show-reset="false"
:show-submit="false"
class="crud-dialog-art-form execute-debug-art-form"
>
<template #trigger>
<ElRadioGroup v-model="executeFormData.trigger">
<ElRadio value="now">立即执行</ElRadio>
<ElRadio value="cron">Cron表达式</ElRadio>
<ElRadio value="interval">时间间隔</ElRadio>
<ElRadio value="date">固定日期</ElRadio>
</ElRadioGroup>
</template>
<template #trigger_args>
<template v-if="executeFormData.trigger === 'cron'">
<ElInput
v-model="executeFormData.trigger_args"
placeholder="请输入 * * * * * ? *"
readonly
@click="openCron = true"
/>
<FaDialog
v-model="openCron"
title="Cron 表达式"
width="min(700px, calc(100vw - 48px))"
append-to-body
>
<FaCron v-model="cronTempValue" />
<template #footer>
<ElButton @click="openCron = false">取消</ElButton>
<ElButton type="primary" @click="confirmCron">确定</ElButton>
</template>
</FaDialog>
</template>
<template v-else-if="executeFormData.trigger === 'interval'">
<ElPopover
:visible="openInterval"
width="600px"
trigger="click"
:persistent="false"
placement="auto-end"
>
<template #reference>
<ElInput
v-model="executeFormData.trigger_args"
placeholder="请点击设置间隔时间"
@click="openInterval = true"
/>
</template>
<FaIntervalTab
:cron-value="executeFormData.trigger_args"
@confirm="handleIntervalConfirm"
@cancel="openInterval = false"
/>
</ElPopover>
</template>
<template v-else-if="executeFormData.trigger === 'date'">
<ElDatePicker
v-model="executeFormData.trigger_args"
type="datetime"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
placeholder="请选择执行时间"
:style="'width: 100%'"
/>
</template>
</template>
</FaForm>
<template #footer>
<ElButton @click="handleCloseExecuteDialog">取消</ElButton>
<ElButton type="primary" :loading="submitLoading" @click="handleExecuteNode">确认</ElButton>
</template>
</FaDialog>
</div>
</template>
@@ -282,8 +249,8 @@ defineOptions({
inheritAttrs: false,
});
import NodeAPI, { NodeTable, NodeForm, TriggerType } from "@/api/module_task/cronjob/node";
import { useDictStore } from "@stores";
import NodeAPI, { type NodeTable, type NodeForm } from "@/api/module_task/cronjob/node";
import { confirmToggleStatus } from "@/hooks/core/useConfirm";
import type { FormItem } from "@/components/forms/fa-form/index.vue";
import FaForm from "@/components/forms/fa-form/index.vue";
import type { SearchFormItem } from "@/components/forms/fa-search-bar/index.vue";
@@ -291,18 +258,29 @@ import type FaSearchBar from "@/components/forms/fa-search-bar/index.vue";
import FaTableHeader from "@/components/tables/fa-table-header/index.vue";
import FaTable from "@/components/tables/fa-table/index.vue";
import FaDialog from "@/components/modal/fa-dialog/index.vue";
import type { TableOperationAction } from "@utils";
import { renderTableOperationCell } from "@utils";
import { computed, nextTick, onMounted, reactive, ref, watch } from "vue";
import { renderTableOperationCell, resolveStatusColumns, type TableOperationAction } from "@utils";
import { Delete, Plus } from "@element-plus/icons-vue";
import FaCron from "@/components/others/fa-cron/index.vue";
import Codemirror, { CmComponentRef } from "codemirror-editor-vue3";
import type { EditorConfiguration } from "codemirror";
import "codemirror/mode/python/python.js";
import "codemirror/theme/dracula.css";
import type { ColumnOption } from "@/types/component";
const dictStore = useDictStore();
/** 可选对象字面量 */
type OptionItem = { label: string; value: string };
/** 存储器选项(对应 ap_scheduler jobstores 配置,不再依赖字典) */
const jobStoreOptions: OptionItem[] = [
{ label: "默认(Redis)", value: "default" },
{ label: "数据库(Sqlalchemy)", value: "sqlalchemy" },
{ label: "内存(Memory)", value: "memory" },
];
/** 执行器选项(对应 ap_scheduler executors 配置,不再依赖字典) */
const jobExecutorOptions: OptionItem[] = [
{ label: "线程池", value: "default" },
{ label: "进程池", value: "processpool" },
];
type NodeSearchForm = {
name?: string;
@@ -339,6 +317,19 @@ const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof FaSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const nodeTriggerLabelMap: Record<string, string> = {
cron: "Cron",
interval: "间隔",
date: "单次",
};
function formatNodeTriggerCell(row: NodeTable): string {
const trigger = row.trigger;
if (!trigger) return "不排程";
const label = nodeTriggerLabelMap[trigger] ?? trigger;
return row.trigger_args ? `${label} ${row.trigger_args}` : label;
}
const nodeSearchItems = computed<SearchFormItem[]>(() => [
{
label: "节点名称",
@@ -378,6 +369,7 @@ const selectedIds = computed(() =>
selectedRows.value.map((r) => r.id).filter((id): id is number => typeof id === "number")
);
const batchDeleting = ref(false);
const moreLoading = ref(false);
function onTableSelectionChange(rows: NodeTable[]) {
selectedRows.value = rows;
@@ -396,15 +388,29 @@ async function deleteNodeRow(id: number | undefined, name: string | number) {
}
function buildNodeRowActions(row: NodeTable): TableOperationAction[] {
const disabled = row.status === 1;
const all: TableOperationAction[] = [
{
key: "execute",
label: "调试",
label: "立即执行",
artType: "more",
icon: "ri:play-circle-line",
iconColor: "var(--el-color-primary)",
perm: "module_task:cronjob:node:execute",
run: () => handleOpenExecuteDialog(row),
run: () => {
if (row.id != null) void handleExecuteOnce(row.id);
},
},
{
key: disabled ? "enable" : "disable",
label: disabled ? "启用" : "停用",
artType: "more",
icon: disabled ? "ri:play-line" : "ri:pause-circle-line",
iconColor: disabled ? "var(--el-color-success)" : "var(--el-color-warning)",
perm: "module_task:cronjob:node:update",
run: () => {
if (row.id != null) void handleToggleRowStatus(row);
},
},
{
key: "edit",
@@ -430,6 +436,47 @@ function buildNodeRowActions(row: NodeTable): TableOperationAction[] {
return all;
}
/** 立即执行一次(走后端临时 job,不影响已保存的执行计划) */
async function handleExecuteOnce(id: number) {
try {
await NodeAPI.executeNode(id);
await refreshData();
} catch (error: unknown) {
if (import.meta.env.DEV) console.error(error);
}
}
/** 单行启停联动(后端会同步注册/卸载调度) */
async function handleToggleRowStatus(row: NodeTable) {
if (row.id == null) return;
const enable = row.status === 1;
try {
await confirmToggleStatus(enable ? "enable" : "disable");
await NodeAPI.batchNode({ ids: [row.id], status: enable ? 0 : 1 });
await refreshData();
} catch {
// 用户取消
}
}
/** 批量启停联动 */
async function handleBatchMoreStatus(type: "enable" | "disable") {
const ids = selectedIds.value;
if (ids.length === 0) return;
try {
await confirmToggleStatus(type);
moreLoading.value = true;
await NodeAPI.batchNode({ ids, status: type === "enable" ? 0 : 1 });
selectedRows.value = [];
faTableRef.value?.elTableRef?.clearSelection();
await refreshData();
} catch {
// 用户取消
} finally {
moreLoading.value = false;
}
}
function formatNodeOperationCell(row: NodeTable) {
return renderTableOperationCell(buildNodeRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1 cronjob-node-table-actions",
@@ -477,7 +524,7 @@ const {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<NodeTable>[] => [
columnsFactory: resolveStatusColumns<NodeTable>(() => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
@@ -489,35 +536,48 @@ const {
{
prop: "code",
label: "节点编码",
minWidth: 120,
minWidth: 100,
showOverflowTooltip: true,
},
{
prop: "jobstore",
label: "存储器",
minWidth: 80,
prop: "trigger",
label: "执行计划",
minWidth: 160,
showOverflowTooltip: true,
formatter: formatNodeTriggerCell,
},
{
prop: "executor",
label: "执行器",
minWidth: 80,
prop: "status",
label: "状态",
width: 86,
align: "center",
status: {
0: { type: "success", text: "启用" },
1: { type: "danger", text: "停用" },
},
},
{
prop: "next_run_time",
label: "下次运行",
minWidth: 165,
showOverflowTooltip: true,
},
{
prop: "created_time",
label: "创建时间",
minWidth: 180,
minWidth: 165,
sortable: true,
showOverflowTooltip: true,
},
{
prop: "operation",
label: "操作",
width: 220,
width: 250,
fixed: "right",
align: "center",
formatter: (row: NodeTable) => formatNodeOperationCell(row),
},
],
]),
},
});
@@ -551,15 +611,13 @@ const codeEditorOptions: EditorConfiguration = {
};
const dataFormRef = ref<InstanceType<typeof FaForm> | null>(null);
const executeFormRef = ref<InstanceType<typeof FaForm> | null>(null);
const nodeFormRenderKey = ref(0);
const executeFormRenderKey = ref(0);
const submitLoading = ref(false);
const createLoading = ref(false);
const openCron = ref(false);
const cronTempValue = ref("");
const confirmCron = () => {
executeFormData.value.trigger_args = cronTempValue.value;
if (cronTempValue.value) formData.value.trigger_args = cronTempValue.value;
openCron.value = false;
};
const openInterval = ref(false);
@@ -626,6 +684,8 @@ const formData = ref<NodeForm>({
kwargs: undefined,
coalesce: false,
max_instances: 1,
trigger: undefined,
trigger_args: undefined,
start_date: undefined,
end_date: undefined,
});
@@ -633,59 +693,71 @@ const formData = ref<NodeForm>({
const argsList = ref<string[]>([]);
const kwargsList = ref<{ key: string; value: string }[]>([]);
const executeDialogVisible = ref(false);
const currentExecuteNode = ref<NodeTable | null>(null);
const executeFormData = ref<{
node_display_name: string;
trigger: TriggerType;
trigger_args?: string;
start_date?: string;
end_date?: string;
}>({
node_display_name: "",
trigger: "now",
trigger_args: undefined,
start_date: undefined,
end_date: undefined,
const dialogVisible = reactive({
title: "",
visible: false,
type: "create" as "create" | "update" | "detail",
});
const executeDialogFormItems = computed<FormItem[]>(() => {
const trig = executeFormData.value.trigger;
const showRange = !!trig && trig !== "now" && trig !== "date";
let triggerArgsLabel = "执行参数";
if (trig === "cron") triggerArgsLabel = "Cron表达式";
else if (trig === "interval") triggerArgsLabel = "间隔时间";
else if (trig === "date") triggerArgsLabel = "执行时间";
const rules = reactive({
name: [{ required: true, message: "请输入节点名称", trigger: "blur" }],
code: [{ required: true, message: "请输入节点编码", trigger: "blur" }],
trigger_args: [{ required: true, message: "请先配置执行计划参数", trigger: "blur" }],
});
const nodeDialogFormItems = computed<FormItem[]>(() => {
const trig = formData.value.trigger;
let planArgsLabel = "执行计划参数";
if (trig === "cron") planArgsLabel = "Cron表达式";
else if (trig === "interval") planArgsLabel = "间隔时间";
else if (trig === "date") planArgsLabel = "执行时间";
const isRangePlan = trig === "cron" || trig === "interval";
return [
{
label: "节点名称",
key: "node_display_name",
key: "name",
type: "input",
span: 24,
props: { disabled: true },
props: { placeholder: "请输入节点名称", maxlength: 50 },
},
{
label: "执行方式",
key: "trigger",
label: "节点编码",
key: "code",
type: "input",
span: 24,
props: { placeholder: "请输入节点编码", maxlength: 32 },
},
{
label: "执行计划",
key: "trigger",
type: "select",
span: 24,
placeholder: "",
props: {
clearable: true,
placeholder: "不排程:仅保存定义,可手动执行一次",
options: [
{ label: "Cron表达式", value: "cron" },
{ label: "时间间隔", value: "interval" },
{ label: "固定时间", value: "date" },
],
},
},
{
label: triggerArgsLabel,
label: planArgsLabel,
key: "trigger_args",
type: "input",
span: 24,
placeholder: "",
hidden: trig === "now" || !trig,
hidden: !trig,
},
{
label: "开始时间",
key: "start_date",
type: "datetime",
span: 24,
hidden: !showRange,
hidden: !isRangePlan,
props: {
type: "datetime",
format: "YYYY-MM-DD HH:mm:ss",
@@ -699,7 +771,7 @@ const executeDialogFormItems = computed<FormItem[]>(() => {
key: "end_date",
type: "datetime",
span: 24,
hidden: !showRange,
hidden: !isRangePlan,
props: {
type: "datetime",
format: "YYYY-MM-DD HH:mm:ss",
@@ -708,86 +780,53 @@ const executeDialogFormItems = computed<FormItem[]>(() => {
style: { width: "100%" },
},
},
];
});
const dialogVisible = reactive({
title: "",
visible: false,
type: "create" as "create" | "update" | "detail",
});
const rules = reactive({
name: [{ required: true, message: "请输入节点名称", trigger: "blur" }],
code: [{ required: true, message: "请输入节点编码", trigger: "blur" }],
});
const nodeDialogFormItems = computed<FormItem[]>(() => [
{
label: "节点名称",
key: "name",
type: "input",
span: 24,
props: { placeholder: "请输入节点名称", maxlength: 50 },
},
{
label: "节点编码",
key: "code",
type: "input",
span: 24,
props: { placeholder: "请输入节点编码", maxlength: 32 },
},
{
label: "存储器",
key: "jobstore",
type: "select",
span: 24,
placeholder: "",
},
{
label: "执行器",
key: "executor",
type: "select",
span: 24,
placeholder: "",
},
{
label: "位置参数",
key: "args",
type: "input",
span: 24,
placeholder: "",
},
{
label: "关键字参数",
key: "kwargs",
type: "input",
span: 24,
placeholder: "",
},
{
label: "合并运行",
key: "coalesce",
type: "radiogroup",
span: 24,
placeholder: "",
},
{
label: "最大实例数",
key: "max_instances",
type: "number",
span: 24,
props: {
controlsPosition: "right",
min: 1,
max: 10,
{
label: "存储器",
key: "jobstore",
type: "select",
span: 24,
placeholder: "",
},
},
]);
const executeRules = reactive({
trigger: [{ required: true, message: "请选择执行方式", trigger: "change" }],
trigger_args: [{ required: true, message: "请设置执行参数", trigger: "blur" }],
{
label: "执行器",
key: "executor",
type: "select",
span: 24,
placeholder: "",
},
{
label: "位置参数",
key: "args",
type: "input",
span: 24,
placeholder: "",
},
{
label: "关键字参数",
key: "kwargs",
type: "input",
span: 24,
placeholder: "",
},
{
label: "合并运行",
key: "coalesce",
type: "radiogroup",
span: 24,
placeholder: "",
},
{
label: "最大实例数",
key: "max_instances",
type: "number",
span: 24,
props: {
controlsPosition: "right",
min: 1,
max: 10,
},
},
];
});
const initialFormData: Partial<NodeForm> = {
@@ -801,6 +840,8 @@ const initialFormData: Partial<NodeForm> = {
kwargs: undefined,
coalesce: false,
max_instances: 5,
trigger: undefined,
trigger_args: undefined,
start_date: undefined,
end_date: undefined,
};
@@ -811,6 +852,8 @@ async function resetForm() {
Object.assign(formData.value, initialFormData);
argsList.value = [];
kwargsList.value = [];
openCron.value = false;
openInterval.value = false;
}
async function handleCloseDialog() {
@@ -860,22 +903,35 @@ function handleDialogOpened() {
}
async function handleSubmit() {
dataFormRef.value?.validate(async (valid: any) => {
dataFormRef.value?.validate(async (valid: boolean) => {
if (valid) {
submitLoading.value = true;
const id = formData.value.id;
const trig = formData.value.trigger;
const kvList = kwargsList.value.filter((v) => v.key.trim());
try {
// 显式组装:执行计划随节点定义一起保存,trigger 为空则不排程
const submitData = {
...formData.value,
name: formData.value.name,
code: formData.value.code || undefined,
jobstore: formData.value.jobstore,
executor: formData.value.executor,
func: formData.value.func,
coalesce: formData.value.coalesce,
max_instances: formData.value.max_instances,
args: argsList.value.filter((v) => v.trim()).join(",") || undefined,
kwargs:
kwargsList.value.filter((v) => v.key.trim()).length > 0
? JSON.stringify(
Object.fromEntries(
kwargsList.value.filter((v) => v.key.trim()).map((v) => [v.key, v.value])
)
)
kvList.length > 0
? JSON.stringify(Object.fromEntries(kvList.map((v) => [v.key, v.value])))
: undefined,
trigger: trig || undefined,
trigger_args: trig ? formData.value.trigger_args || undefined : undefined,
start_date:
trig === "cron" || trig === "interval"
? formData.value.start_date || undefined
: undefined,
end_date:
trig === "cron" || trig === "interval" ? formData.value.end_date || undefined : undefined,
};
if (id) {
await NodeAPI.updateNode(id, submitData);
@@ -899,77 +955,21 @@ async function handleSubmit() {
}
const handleIntervalConfirm = (value: string) => {
executeFormData.value.trigger_args = value;
formData.value.trigger_args = value;
openInterval.value = false;
};
function handleOpenExecuteDialog(row: NodeTable) {
currentExecuteNode.value = row;
executeFormData.value.node_display_name = row.name ?? "";
executeFormData.value.trigger = "now";
executeFormData.value.trigger_args = undefined;
executeFormData.value.start_date = undefined;
executeFormData.value.end_date = undefined;
executeFormRenderKey.value += 1;
executeDialogVisible.value = true;
}
function handleCloseExecuteDialog() {
executeDialogVisible.value = false;
currentExecuteNode.value = null;
executeFormRef.value?.resetFields();
}
async function handleExecuteNode() {
if (executeFormData.value.trigger !== "now") {
const execForm = executeFormRef.value;
const elForm = execForm?.ref;
if (!elForm) return;
const valid = await elForm.validate().catch(() => false);
if (!valid) return;
}
try {
submitLoading.value = true;
const params: any = {
trigger: executeFormData.value.trigger,
};
if (executeFormData.value.trigger !== "now") {
params.trigger_args = executeFormData.value.trigger_args;
params.start_date = executeFormData.value.start_date;
params.end_date = executeFormData.value.end_date;
}
await NodeAPI.executeNode(currentExecuteNode.value?.id as number, params);
handleCloseExecuteDialog();
await refreshUpdate();
} catch {
/* 已由全局拦截器提示 */
} finally {
submitLoading.value = false;
}
}
watch(openCron, (val) => {
if (val) cronTempValue.value = executeFormData.value.trigger_args || "";
});
onMounted(async () => {
await dictStore.getDict(["sys_job_store", "sys_job_executor"]);
if (val) cronTempValue.value = formData.value.trigger_args || "";
});
</script>
<style scoped lang="scss">
.node-splitter-art-form :deep(.el-row > .el-col:last-child),
.execute-debug-art-form :deep(.el-row > .el-col:last-child) {
.node-splitter-art-form :deep(.el-row > .el-col:last-child) {
display: none;
}
.node-splitter-art-form :deep(.el-form-item__content),
.execute-debug-art-form :deep(.el-form-item__content) {
.node-splitter-art-form :deep(.el-form-item__content) {
max-width: 100%;
}
@@ -19,7 +19,7 @@
</template>
<script setup lang="ts">
import { computed, type CSSProperties } from "vue";
import type { CSSProperties } from "vue";
import {
BaseEdge,
EdgeLabelRenderer,
@@ -63,7 +63,6 @@
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { Handle, Position, useNodeConnections } from "@vue-flow/core";
import { ElIcon, ElTooltip } from "element-plus";
import { Folder, FolderOpened } from "@element-plus/icons-vue";
@@ -52,8 +52,6 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { Close, Share } from "@element-plus/icons-vue";
import FaForm from "@/components/forms/fa-form/index.vue";
@@ -82,8 +82,6 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { Document, Folder, Refresh } from "@element-plus/icons-vue";
import StorageAPI, { type StorageObject } from "@/api/module_task/workflow/storage";
@@ -82,8 +82,6 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { Close, Folder, FolderOpened } from "@element-plus/icons-vue";
import NodeAPI from "@/api/module_task/workflow/node";
@@ -221,7 +221,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, onMounted, nextTick, markRaw, type Component } from "vue";
import type { Component } from "vue";
import { ElMessage } from "element-plus";
import { MarkerType, Panel, Position, SelectionMode, VueFlow, useVueFlow } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
@@ -626,7 +626,11 @@ function handleDeleteNode() {
})();
}
function handleSaveEdge(data: any) {
function handleSaveEdge(data: {
data?: Record<string, unknown>;
animated?: boolean;
style?: Record<string, any>;
}) {
if (!selectedEdge.value) return;
const edgeId = selectedEdge.value!.id;
const currentEdges = getEdgesRef.value;
@@ -27,12 +27,12 @@
<span class="text-base font-semibold">传输任务列表</span>
<span
class="inline-flex items-center gap-1.25 text-xs"
:class="wsConnected ? 'text-(--el-color-success)' : 'text-(--el-color-info)'"
:class="sseConnected ? 'text-(--el-color-success)' : 'text-(--el-color-info)'"
>
<i
class="h-2 w-2 rounded-full"
:class="wsConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'"
/>{{ wsConnected ? "实时连接" : "连接中…" }}
:class="sseConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'"
/>{{ sseConnected ? "实时连接" : "连接中…" }}
</span>
<el-button type="primary" @click="openCreate">新建传输任务</el-button>
</div>
@@ -361,7 +361,7 @@
<script setup lang="ts">
import TransferAPI, {
TransferSocket,
TransferStream,
type TransferPushMessage,
type TransferSourceType,
type TransferStatus,
@@ -502,6 +502,7 @@ async function loadTasks() {
});
tasks.value = data.data.items || [];
total.value = data.data.total || 0;
syncStream(); // 列表刷新后重新评估:有进行中任务才保持连接
} finally {
loading.value = false;
}
@@ -879,9 +880,20 @@ async function openDetail(row: TransferTaskItem) {
detailRow.value = data.data;
}
// ── WebSocket 实时进度 ────────────────────────────────────────────────
const wsConnected = ref(false);
let transferSocket: TransferSocket | null = null;
// ── SSE 实时进度(按需连接:仅存在进行中任务时保持连接,全部终态即断开) ──
const sseConnected = ref(false);
let transferStream: TransferStream | null = null;
const hasActiveTask = computed(() =>
tasks.value.some((t) => t.status === "pending" || t.status === "running")
);
/** 连接生命周期跟随任务列表:有 pending/running 任务才连;全部终态即断开(终态前服务端已推过一次收尾帧) */
function syncStream() {
if (!transferStream) return;
if (hasActiveTask.value) transferStream.connect();
else transferStream.disconnect();
}
function onMessage(msg: TransferPushMessage) {
if (msg.type !== "task_update") return;
@@ -896,20 +908,20 @@ function onMessage(msg: TransferPushMessage) {
if (detailVisible.value && detailRow.value?.id === item.id) {
detailRow.value = item;
}
syncStream(); // 收到终态帧且无其他进行中任务时自动断开
}
function onStatus(connected: boolean) {
wsConnected.value = connected;
sseConnected.value = connected;
}
onMounted(() => {
loadSources();
loadFlows();
loadTasks();
transferSocket = new TransferSocket({ onMessage, onStatus });
transferSocket.connect();
loadTasks(); // 末尾 syncStream 决定是否连接
transferStream = new TransferStream({ onMessage, onStatus });
});
onUnmounted(() => {
transferSocket?.disconnect();
transferSocket = null;
transferStream?.disconnect();
transferStream = null;
});
</script>