From ac4d43fd015565fefcc86728b256cb8e94e4913a Mon Sep 17 00:00:00 2001 From: JiaoTianBo Date: Sat, 28 Mar 2026 17:24:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sse):=20=E9=9B=86=E6=88=90=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E6=8E=A8=E9=80=81=E4=BA=8B=E4=BB=B6=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E5=BC=82=E6=AD=A5=E4=BB=BB=E5=8A=A1=E8=BF=9B=E5=BA=A6?= =?UTF-8?q?=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增SSE客户端类,实现基于fetch API的事件流连接和自动重连 - 增加sse状态管理Pinia模块,支持连接管理、任务进度和状态跟踪 - 登录状态管理增加userId字段,完善用户信息结构 - 登录接口修改,支持接收和存储用户ID、角色和权限信息 - 登录mock禁用,切换为真实后端接口调用 - 主布局组件增加SSE连接初始化与关闭生命周期钩子 - 项目创建向导中改用SSE方式上传文件及监听解析进度和结果 - 文件上传界面增加上传及任务进度展示,包括状态提示和进度条 - token处理函数更新,支持后端多种token字段并正确存储用户信息 - 调整本地存储结构,适应新增的用户ID和权限字段管理 --- mock/login.ts | 43 +-- src/api/feishu.ts | 8 +- src/layout/index.vue | 34 ++ src/store/modules/sse.ts | 148 +++++++++ src/store/modules/user.ts | 56 ++-- src/store/types.ts | 1 + src/utils/auth.ts | 41 ++- src/utils/sse/SseClient.ts | 297 ++++++++++++++++++ src/views/login/index.vue | 15 +- .../components/CreateProjectWizard.vue | 80 ++++- 10 files changed, 633 insertions(+), 90 deletions(-) create mode 100644 src/store/modules/sse.ts create mode 100644 src/utils/sse/SseClient.ts diff --git a/mock/login.ts b/mock/login.ts index 2306ad8..e575a4a 100644 --- a/mock/login.ts +++ b/mock/login.ts @@ -1,44 +1,5 @@ // 根据角色动态生成路由 import { defineFakeRoute } from "vite-plugin-fake-server/client"; -export default defineFakeRoute([ - { - url: "/login", - method: "post", - response: ({ body }) => { - if (body.username === "admin") { - return { - code: 0, - message: "操作成功", - data: { - avatar: "https://avatars.githubusercontent.com/u/44761321", - username: "admin", - nickname: "小铭", - // 一个用户可能有多个角色 - roles: ["admin"], - // 按钮级别权限 - permissions: ["*:*:*"], - accessToken: "eyJhbGciOiJIUzUxMiJ9.admin", - refreshToken: "eyJhbGciOiJIUzUxMiJ9.adminRefresh", - expires: "2030/10/30 00:00:00" - } - }; - } else { - return { - code: 0, - message: "操作成功", - data: { - avatar: "https://avatars.githubusercontent.com/u/52823142", - username: "common", - nickname: "小林", - roles: ["common"], - permissions: ["permission:btn:add", "permission:btn:edit"], - accessToken: "eyJhbGciOiJIUzUxMiJ9.common", - refreshToken: "eyJhbGciOiJIUzUxMiJ9.commonRefresh", - expires: "2030/10/30 00:00:00" - } - }; - } - } - } -]); +// 禁用登录 mock,使用真实后端接口 +export default defineFakeRoute([]); diff --git a/src/api/feishu.ts b/src/api/feishu.ts index fd8ca8b..02c0bf6 100644 --- a/src/api/feishu.ts +++ b/src/api/feishu.ts @@ -11,13 +11,19 @@ export type FeishuLoginData = { /** 头像 */ avatar: string; /** 用户ID */ - userId: number; + userId: string; /** 邮箱 */ email: string; /** token */ token: string; /** 用户名 */ username: string; + /** 是否管理员 */ + isAdmin?: boolean; + /** 角色列表 */ + roles?: string[]; + /** 权限列表 */ + permissions?: string[]; }; /** 飞书登录响应结果 */ diff --git a/src/layout/index.vue b/src/layout/index.vue index 897df16..286b5f8 100644 --- a/src/layout/index.vue +++ b/src/layout/index.vue @@ -7,6 +7,8 @@ import { useI18n } from "vue-i18n"; import { useLayout } from "./hooks/useLayout"; import { useAppStoreHook } from "@/store/modules/app"; import { useSettingStoreHook } from "@/store/modules/settings"; +import { useUserStoreHook } from "@/store/modules/user"; +import { useSseStoreHook } from "@/store/modules/sse"; import { useDataThemeChange } from "@/layout/hooks/useDataThemeChange"; import { h, @@ -15,6 +17,7 @@ import { computed, onMounted, onBeforeMount, + onBeforeUnmount, defineComponent } from "vue"; import { @@ -120,8 +123,39 @@ onMounted(() => { if (isMobile) { toggle("mobile", false); } + // 初始化 SSE 连接 + initSse(); }); +onBeforeUnmount(() => { + // 关闭 SSE 连接 + useSseStoreHook().closeSse(); +}); + +// 初始化 SSE 连接 +function initSse() { + const userStore = useUserStoreHook(); + const sseStore = useSseStoreHook(); + const userId = userStore.userId; + console.log( + "[SSE] initSse called, userId:", + userId, + "isConnected:", + sseStore.getIsConnected + ); + if (userId && !sseStore.getIsConnected) { + console.log("[SSE] 正在建立连接..."); + sseStore.initSse(userId); + } else { + console.log( + "[SSE] 跳过连接 - userId:", + userId, + "isConnected:", + sseStore.getIsConnected + ); + } +} + onBeforeMount(() => { useDataThemeChange().dataThemeChange($storage.layout?.themeMode); }); diff --git a/src/store/modules/sse.ts b/src/store/modules/sse.ts new file mode 100644 index 0000000..03d5916 --- /dev/null +++ b/src/store/modules/sse.ts @@ -0,0 +1,148 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { SseClient, type ProjectInitTaskVO } from "@/utils/sse/SseClient"; +import { store } from "../utils"; + +export const useSseStore = defineStore("sse", () => { + // State + const sseClient = ref(null); + const isConnected = ref(false); + const currentTask = ref(null); + const taskProgress = ref(0); + const taskStatus = ref< + "idle" | "submitted" | "processing" | "completed" | "error" + >("idle"); + const errorMessage = ref(""); + + // Getters + const getIsConnected = computed(() => isConnected.value); + const getCurrentTask = computed(() => currentTask.value); + const getTaskProgress = computed(() => taskProgress.value); + const getTaskStatus = computed(() => taskStatus.value); + + // Actions + /** + * 初始化 SSE 连接 + * @param userId 用户ID + */ + async function initSse(userId: string) { + // 如果已有连接,先关闭 + if (sseClient.value) { + sseClient.value.close(); + } + + sseClient.value = new SseClient(userId); + + // 监听连接成功 + sseClient.value.on("connected", () => { + isConnected.value = true; + console.log("SSE Store: 连接成功"); + }); + + // 监听项目初始化进度 + sseClient.value.on("project-init-progress", (data: ProjectInitTaskVO) => { + currentTask.value = data; + taskProgress.value = data.progress; + taskStatus.value = "processing"; + console.log( + `SSE Store: 任务进度 ${data.progress}% - ${data.progressMessage}` + ); + }); + + // 监听任务完成 + sseClient.value.on("project-init-complete", (data: ProjectInitTaskVO) => { + currentTask.value = data; + taskProgress.value = 100; + taskStatus.value = "completed"; + console.log("SSE Store: 任务完成", data.result); + }); + + // 监听任务提交 + sseClient.value.on( + "project-init-submitted", + (data: { taskId: string; message: string }) => { + taskStatus.value = "submitted"; + console.log("SSE Store: 任务已提交", data.taskId); + } + ); + + // 监听错误 + sseClient.value.on("project-init-error", (data: { error: string }) => { + taskStatus.value = "error"; + errorMessage.value = data.error; + console.error("SSE Store: 任务错误", data.error); + }); + + // 监听连接错误 + sseClient.value.on("connection-error", () => { + isConnected.value = false; + }); + + // 建立连接(异步,在后台运行) + sseClient.value.connect().catch(err => { + console.error("SSE Store: 连接失败", err); + }); + } + + /** + * 关闭 SSE 连接 + */ + function closeSse() { + if (sseClient.value) { + sseClient.value.close(); + sseClient.value = null; + isConnected.value = false; + currentTask.value = null; + taskProgress.value = 0; + taskStatus.value = "idle"; + errorMessage.value = ""; + } + } + + /** + * 提交项目初始化任务 + */ + async function submitProjectInitTask(file: File) { + if (!sseClient.value) { + throw new Error("SSE 未连接"); + } + taskStatus.value = "submitted"; + taskProgress.value = 0; + errorMessage.value = ""; + return sseClient.value.submitProjectInitTask(file); + } + + /** + * 重置任务状态 + */ + function resetTaskStatus() { + currentTask.value = null; + taskProgress.value = 0; + taskStatus.value = "idle"; + errorMessage.value = ""; + } + + return { + // State + sseClient, + isConnected, + currentTask, + taskProgress, + taskStatus, + errorMessage, + // Getters + getIsConnected, + getCurrentTask, + getTaskProgress, + getTaskStatus, + // Actions + initSse, + closeSse, + submitProjectInitTask, + resetTaskStatus + }; +}); + +export function useSseStoreHook() { + return useSseStore(store); +} diff --git a/src/store/modules/user.ts b/src/store/modules/user.ts index 4791fff..7635446 100644 --- a/src/store/modules/user.ts +++ b/src/store/modules/user.ts @@ -17,27 +17,32 @@ import { useMultiTagsStoreHook } from "./multiTags"; import { type DataInfo, setToken, removeToken, userKey } from "@/utils/auth"; export const useUserStore = defineStore("pure-user", { - state: (): userType => ({ - // 头像 - avatar: storageLocal().getItem>(userKey)?.avatar ?? "", - // 用户名 - username: storageLocal().getItem>(userKey)?.username ?? "", - // 昵称 - nickname: storageLocal().getItem>(userKey)?.nickname ?? "", - // 页面级别权限 - roles: storageLocal().getItem>(userKey)?.roles ?? [], - // 按钮级别权限 - permissions: - storageLocal().getItem>(userKey)?.permissions ?? [], - // 前端生成的验证码(按实际需求替换) - verifyCode: "", - // 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码) - currentPage: 0, - // 是否勾选了登录页的免登录 - isRemembered: false, - // 登录页的免登录存储几天,默认7天 - loginDay: 7 - }), + state: (): userType => { + const userInfo = storageLocal().getItem>(userKey); + console.log("[UserStore] 从 localStorage 读取用户信息:", userInfo); + return { + // 头像 + avatar: userInfo?.avatar ?? "", + // 用户名 + username: userInfo?.username ?? "", + // 昵称 + nickname: userInfo?.nickname ?? "", + // 用户ID + userId: userInfo?.userId ?? "", + // 页面级别权限 + roles: userInfo?.roles ?? [], + // 按钮级别权限 + permissions: userInfo?.permissions ?? [], + // 前端生成的验证码(按实际需求替换) + verifyCode: "", + // 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码) + currentPage: 0, + // 是否勾选了登录页的免登录 + isRemembered: false, + // 登录页的免登录存储几天,默认7天 + loginDay: 7 + }; + }, actions: { /** 存储头像 */ SET_AVATAR(avatar: string) { @@ -51,6 +56,10 @@ export const useUserStore = defineStore("pure-user", { SET_NICKNAME(nickname: string) { this.nickname = nickname; }, + /** 存储用户ID */ + SET_USERID(userId: string) { + this.userId = userId; + }, /** 存储角色 */ SET_ROLES(roles: Array) { this.roles = roles; @@ -80,7 +89,8 @@ export const useUserStore = defineStore("pure-user", { return new Promise((resolve, reject) => { getLogin(data) .then(data => { - if (data.code === 0) { + // 后端返回 code: 200 表示成功 + if (data.code === 200 || data.code === 0) { setToken(data.data); resolve(data); } else { @@ -95,6 +105,8 @@ export const useUserStore = defineStore("pure-user", { /** 前端登出(不调用接口) */ logOut() { this.username = ""; + this.nickname = ""; + this.userId = ""; this.roles = []; this.permissions = []; removeToken(); diff --git a/src/store/types.ts b/src/store/types.ts index d6503d9..6362ac5 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -41,6 +41,7 @@ export type userType = { avatar?: string; username?: string; nickname?: string; + userId?: string; roles?: Array; permissions?: Array; verifyCode?: string; diff --git a/src/utils/auth.ts b/src/utils/auth.ts index f2b28cb..40fb31b 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -15,6 +15,8 @@ export interface DataInfo { username?: string; /** 昵称 */ nickname?: string; + /** 用户ID */ + userId?: string; /** 当前登录用户的角色 */ roles?: Array; /** 当前登录用户的按钮级别权限 */ @@ -45,11 +47,22 @@ export function getToken(): DataInfo { * 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁) * 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁) */ -export function setToken(data: DataInfo) { +export function setToken(data: any) { let expires = 0; - const { accessToken, refreshToken } = data; + // 适配后端返回的数据结构 + // 后端返回: token, userId, realName, avatar, roles, permissions + // 内部使用: accessToken, refreshToken, expires + const accessToken = data.accessToken || data.token || ""; + const refreshToken = data.refreshToken || ""; const { isRemembered, loginDay } = useUserStoreHook(); - expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo改成DataInfo即可 + + // 处理过期时间,如果后端没有返回则默认7天 + if (data.expires) { + expires = new Date(data.expires).getTime(); + } else { + expires = Date.now() + 7 * 24 * 60 * 60 * 1000; // 默认7天 + } + const cookieString = JSON.stringify({ accessToken, expires, refreshToken }); expires > 0 @@ -68,10 +81,18 @@ export function setToken(data: DataInfo) { : {} ); - function setUserKey({ avatar, username, nickname, roles, permissions }) { + function setUserKey({ + avatar, + username, + nickname, + userId, + roles, + permissions + }) { useUserStoreHook().SET_AVATAR(avatar); useUserStoreHook().SET_USERNAME(username); useUserStoreHook().SET_NICKNAME(nickname); + useUserStoreHook().SET_USERID(userId); useUserStoreHook().SET_ROLES(roles); useUserStoreHook().SET_PERMS(permissions); storageLocal().setItem(userKey, { @@ -80,18 +101,19 @@ export function setToken(data: DataInfo) { avatar, username, nickname, + userId, roles, permissions }); } if (data.username && data.roles) { - const { username, roles } = data; setUserKey({ avatar: data?.avatar ?? "", - username, - nickname: data?.nickname ?? "", - roles, + username: data.username, + nickname: data?.nickname || data?.realName || "", + userId: data?.userId ?? "", + roles: data.roles, permissions: data?.permissions ?? [] }); } else { @@ -101,6 +123,8 @@ export function setToken(data: DataInfo) { storageLocal().getItem>(userKey)?.username ?? ""; const nickname = storageLocal().getItem>(userKey)?.nickname ?? ""; + const userId = + storageLocal().getItem>(userKey)?.userId ?? ""; const roles = storageLocal().getItem>(userKey)?.roles ?? []; const permissions = @@ -109,6 +133,7 @@ export function setToken(data: DataInfo) { avatar, username, nickname, + userId, roles, permissions }); diff --git a/src/utils/sse/SseClient.ts b/src/utils/sse/SseClient.ts new file mode 100644 index 0000000..ef056b1 --- /dev/null +++ b/src/utils/sse/SseClient.ts @@ -0,0 +1,297 @@ +/** + * SSE 客户端类 + * 用于与后端 SSE 服务进行对接,实现异步任务的实时进度推送 + * 使用 fetch API 实现,支持自定义 Header(包括鉴权) + */ + +import { getToken, formatToken } from "@/utils/auth"; + +export interface SseMessage { + type: string; + event: string; + userId: string; + data: any; + timestamp: string; +} + +export interface ProjectInitTaskVO { + taskId: string; + status: string; + statusDesc: string; + progress: number; + progressMessage: string; + originalFilename: string; + createTime: string; + startTime: string; + completeTime: string; + result?: any; + errorMessage?: string; +} + +type SseEventCallback = (data: any, message?: SseMessage) => void; + +export class SseClient { + private userId: string; + private abortController: AbortController | null = null; + private listeners: Map = new Map(); + private reconnectAttempts = 0; + private maxReconnectAttempts = 3; + private reconnectDelay = 3000; + private isManualClose = false; + private _isConnected = false; + + constructor(userId: string) { + this.userId = userId; + } + + /** + * 获取鉴权 Header + */ + private getAuthHeaders(): Record { + const headers: Record = { + Accept: "text/event-stream", + "Cache-Control": "no-cache" + }; + + const tokenData = getToken(); + if (tokenData?.accessToken) { + headers["Authorization"] = formatToken(tokenData.accessToken); + } + + return headers; + } + + /** + * 建立 SSE 连接(使用 fetch API 支持自定义 Header) + */ + async connect(): Promise { + if (this.abortController) { + console.warn("SSE 连接已存在"); + return; + } + + this.isManualClose = false; + this.abortController = new AbortController(); + const url = `/api/v1/sse/connect/${this.userId}`; + console.log("正在建立 SSE 连接...", url); + + try { + const response = await fetch(url, { + method: "GET", + headers: this.getAuthHeaders(), + signal: this.abortController.signal + }); + + if (!response.ok) { + throw new Error( + `SSE 连接失败: ${response.status} ${response.statusText}` + ); + } + + if (!response.body) { + throw new Error("SSE 响应无 body"); + } + + this._isConnected = true; + this.reconnectAttempts = 0; + + // 读取 SSE 流 + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log("SSE 流结束"); + break; + } + + buffer += decoder.decode(value, { stream: true }); + + // 解析 SSE 消息(以双换行分隔) + const messages = buffer.split("\n\n"); + buffer = messages.pop() || ""; // 保留未完成的消息 + + for (const msg of messages) { + if (msg.trim()) { + this.parseAndDispatch(msg); + } + } + } + } catch (error: any) { + if (error.name === "AbortError") { + console.log("SSE 连接已取消"); + return; + } + + console.error("SSE 连接错误:", error); + this._isConnected = false; + this.emit("connection-error", error); + + // 自动重连 + if ( + !this.isManualClose && + this.reconnectAttempts < this.maxReconnectAttempts + ) { + this.reconnectAttempts++; + console.log( + `SSE 将在 ${this.reconnectDelay}ms 后重连 (第 ${this.reconnectAttempts} 次)` + ); + setTimeout(() => { + this.close(); + this.connect(); + }, this.reconnectDelay); + } else if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error("SSE 重连次数已达上限"); + this.emit("max-reconnect-reached", null); + } + } + } + + /** + * 解析并分发 SSE 消息 + */ + private parseAndDispatch(rawMessage: string): void { + const lines = rawMessage.split("\n"); + let eventName = "message"; + let data = ""; + + for (const line of lines) { + if (line.startsWith("event:")) { + eventName = line.slice(6).trim(); + } else if (line.startsWith("data:")) { + data = line.slice(5).trim(); + } + } + + if (!data) return; + + try { + const message = JSON.parse(data) as SseMessage; + + // 根据事件名称分发 + switch (eventName) { + case "connected": + console.log("SSE 连接成功:", message); + this.emit("connected", message.data, message); + break; + case "submitted": + console.log("任务已提交:", message); + if (message.type === "project-init") { + this.emit("project-init-submitted", message.data, message); + } + break; + case "progress": + if (message.type === "project-init") { + this.emit("project-init-progress", message.data, message); + } + break; + case "complete": + if (message.type === "project-init") { + this.emit("project-init-complete", message.data, message); + } + break; + case "error": + console.error("SSE 错误事件:", message); + if (message.type === "project-init") { + this.emit("project-init-error", message.data, message); + } + this.emit("error", message.data, message); + break; + default: + console.log("SSE 未知事件:", eventName, message); + } + } catch (e) { + console.error("SSE 消息解析失败:", data, e); + } + } + + /** + * 提交项目初始化任务(SSE 方式) + */ + async submitProjectInitTask( + file: File + ): Promise<{ code: number; data?: { taskId: string }; message?: string }> { + const formData = new FormData(); + formData.append("userId", this.userId); + formData.append("file", file); + + const tokenData = getToken(); + const headers: Record = {}; + if (tokenData?.accessToken) { + headers["Authorization"] = formatToken(tokenData.accessToken); + } + + const response = await fetch("/api/v1/project-init/sse/submit-task", { + method: "POST", + headers, + body: formData + }); + + return response.json(); + } + + /** + * 关闭 SSE 连接 + */ + close(): void { + this.isManualClose = true; + this._isConnected = false; + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + console.log("SSE 连接已关闭"); + } + } + + /** + * 注册事件监听 + */ + on(event: string, callback: SseEventCallback): void { + if (!this.listeners.has(event)) { + this.listeners.set(event, []); + } + this.listeners.get(event)!.push(callback); + } + + /** + * 移除事件监听 + */ + off(event: string, callback: SseEventCallback): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + /** + * 触发事件 + */ + private emit(event: string, data: any, message?: SseMessage): void { + const callbacks = this.listeners.get(event); + if (callbacks) { + callbacks.forEach(cb => cb(data, message)); + } + } + + /** + * 获取连接状态 + */ + get isConnected(): boolean { + return this._isConnected; + } + + /** + * 获取用户ID + */ + getUserId(): string { + return this.userId; + } +} + +export default SseClient; diff --git a/src/views/login/index.vue b/src/views/login/index.vue index f1c8a83..e5da119 100644 --- a/src/views/login/index.vue +++ b/src/views/login/index.vue @@ -155,8 +155,9 @@ const handleFeishuCallback = async (code: string) => { avatar: data.avatar, username: data.username, nickname: data.realName, - roles: ["admin"], // 根据实际业务调整 - permissions: [] + userId: data.userId, + roles: data.roles || ["admin"], + permissions: data.permissions || [] }); // 保存用户信息到本地存储 @@ -166,8 +167,9 @@ const handleFeishuCallback = async (code: string) => { avatar: data.avatar, username: data.username, nickname: data.realName, - roles: ["admin"], - permissions: [] + userId: data.userId, + roles: data.roles || ["admin"], + permissions: data.permissions || [] }); // 更新全局状态 @@ -175,8 +177,9 @@ const handleFeishuCallback = async (code: string) => { userStore.SET_AVATAR(data.avatar); userStore.SET_USERNAME(data.username); userStore.SET_NICKNAME(data.realName); - userStore.SET_ROLES(["admin"]); - userStore.SET_PERMS([]); + userStore.SET_USERID(data.userId); + userStore.SET_ROLES(data.roles || ["admin"]); + userStore.SET_PERMS(data.permissions || []); // 初始化路由 await initRouter(); diff --git a/src/views/project/components/CreateProjectWizard.vue b/src/views/project/components/CreateProjectWizard.vue index 2defa88..83125c1 100644 --- a/src/views/project/components/CreateProjectWizard.vue +++ b/src/views/project/components/CreateProjectWizard.vue @@ -1,7 +1,7 @@