mirror of
https://github.com/fastapiadmin/FastapiAdmin.git
synced 2026-09-28 23:27:10 +00:00
refactor(backend): 调整模块结构、调度器逻辑与健康检查路由
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -118,7 +118,6 @@ export interface HeaderBarFeatureConfig {
|
||||
globalSearch: FeatureConfigItem;
|
||||
fullscreen: FeatureConfigItem;
|
||||
notification: FeatureConfigItem;
|
||||
chat: FeatureConfigItem;
|
||||
language: FeatureConfigItem;
|
||||
settings: FeatureConfigItem;
|
||||
themeToggle: FeatureConfigItem;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,7 +19,6 @@ export * from "./icons";
|
||||
|
||||
// 网络
|
||||
export * from "./http";
|
||||
export * from "./socket";
|
||||
|
||||
// 浏览器 / 系统
|
||||
export * from "./storage";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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" });
|
||||
|
||||
-1
@@ -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";
|
||||
|
||||
+6
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user