- 新增SSE客户端类,实现基于fetch API的事件流连接和自动重连 - 增加sse状态管理Pinia模块,支持连接管理、任务进度和状态跟踪 - 登录状态管理增加userId字段,完善用户信息结构 - 登录接口修改,支持接收和存储用户ID、角色和权限信息 - 登录mock禁用,切换为真实后端接口调用 - 主布局组件增加SSE连接初始化与关闭生命周期钩子 - 项目创建向导中改用SSE方式上传文件及监听解析进度和结果 - 文件上传界面增加上传及任务进度展示,包括状态提示和进度条 - token处理函数更新,支持后端多种token字段并正确存储用户信息 - 调整本地存储结构,适应新增的用户ID和权限字段管理
This commit is contained in:
@@ -1,44 +1,5 @@
|
|||||||
// 根据角色动态生成路由
|
// 根据角色动态生成路由
|
||||||
import { defineFakeRoute } from "vite-plugin-fake-server/client";
|
import { defineFakeRoute } from "vite-plugin-fake-server/client";
|
||||||
|
|
||||||
export default defineFakeRoute([
|
// 禁用登录 mock,使用真实后端接口
|
||||||
{
|
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"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|||||||
@@ -11,13 +11,19 @@ export type FeishuLoginData = {
|
|||||||
/** 头像 */
|
/** 头像 */
|
||||||
avatar: string;
|
avatar: string;
|
||||||
/** 用户ID */
|
/** 用户ID */
|
||||||
userId: number;
|
userId: string;
|
||||||
/** 邮箱 */
|
/** 邮箱 */
|
||||||
email: string;
|
email: string;
|
||||||
/** token */
|
/** token */
|
||||||
token: string;
|
token: string;
|
||||||
/** 用户名 */
|
/** 用户名 */
|
||||||
username: string;
|
username: string;
|
||||||
|
/** 是否管理员 */
|
||||||
|
isAdmin?: boolean;
|
||||||
|
/** 角色列表 */
|
||||||
|
roles?: string[];
|
||||||
|
/** 权限列表 */
|
||||||
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 飞书登录响应结果 */
|
/** 飞书登录响应结果 */
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { useI18n } from "vue-i18n";
|
|||||||
import { useLayout } from "./hooks/useLayout";
|
import { useLayout } from "./hooks/useLayout";
|
||||||
import { useAppStoreHook } from "@/store/modules/app";
|
import { useAppStoreHook } from "@/store/modules/app";
|
||||||
import { useSettingStoreHook } from "@/store/modules/settings";
|
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 { useDataThemeChange } from "@/layout/hooks/useDataThemeChange";
|
||||||
import {
|
import {
|
||||||
h,
|
h,
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
computed,
|
computed,
|
||||||
onMounted,
|
onMounted,
|
||||||
onBeforeMount,
|
onBeforeMount,
|
||||||
|
onBeforeUnmount,
|
||||||
defineComponent
|
defineComponent
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import {
|
import {
|
||||||
@@ -120,8 +123,39 @@ onMounted(() => {
|
|||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
toggle("mobile", false);
|
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(() => {
|
onBeforeMount(() => {
|
||||||
useDataThemeChange().dataThemeChange($storage.layout?.themeMode);
|
useDataThemeChange().dataThemeChange($storage.layout?.themeMode);
|
||||||
});
|
});
|
||||||
|
|||||||
148
src/store/modules/sse.ts
Normal file
148
src/store/modules/sse.ts
Normal file
@@ -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<SseClient | null>(null);
|
||||||
|
const isConnected = ref(false);
|
||||||
|
const currentTask = ref<ProjectInitTaskVO | null>(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);
|
||||||
|
}
|
||||||
@@ -17,18 +17,22 @@ import { useMultiTagsStoreHook } from "./multiTags";
|
|||||||
import { type DataInfo, setToken, removeToken, userKey } from "@/utils/auth";
|
import { type DataInfo, setToken, removeToken, userKey } from "@/utils/auth";
|
||||||
|
|
||||||
export const useUserStore = defineStore("pure-user", {
|
export const useUserStore = defineStore("pure-user", {
|
||||||
state: (): userType => ({
|
state: (): userType => {
|
||||||
|
const userInfo = storageLocal().getItem<DataInfo<number>>(userKey);
|
||||||
|
console.log("[UserStore] 从 localStorage 读取用户信息:", userInfo);
|
||||||
|
return {
|
||||||
// 头像
|
// 头像
|
||||||
avatar: storageLocal().getItem<DataInfo<number>>(userKey)?.avatar ?? "",
|
avatar: userInfo?.avatar ?? "",
|
||||||
// 用户名
|
// 用户名
|
||||||
username: storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "",
|
username: userInfo?.username ?? "",
|
||||||
// 昵称
|
// 昵称
|
||||||
nickname: storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "",
|
nickname: userInfo?.nickname ?? "",
|
||||||
|
// 用户ID
|
||||||
|
userId: userInfo?.userId ?? "",
|
||||||
// 页面级别权限
|
// 页面级别权限
|
||||||
roles: storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [],
|
roles: userInfo?.roles ?? [],
|
||||||
// 按钮级别权限
|
// 按钮级别权限
|
||||||
permissions:
|
permissions: userInfo?.permissions ?? [],
|
||||||
storageLocal().getItem<DataInfo<number>>(userKey)?.permissions ?? [],
|
|
||||||
// 前端生成的验证码(按实际需求替换)
|
// 前端生成的验证码(按实际需求替换)
|
||||||
verifyCode: "",
|
verifyCode: "",
|
||||||
// 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码)
|
// 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码)
|
||||||
@@ -37,7 +41,8 @@ export const useUserStore = defineStore("pure-user", {
|
|||||||
isRemembered: false,
|
isRemembered: false,
|
||||||
// 登录页的免登录存储几天,默认7天
|
// 登录页的免登录存储几天,默认7天
|
||||||
loginDay: 7
|
loginDay: 7
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
actions: {
|
actions: {
|
||||||
/** 存储头像 */
|
/** 存储头像 */
|
||||||
SET_AVATAR(avatar: string) {
|
SET_AVATAR(avatar: string) {
|
||||||
@@ -51,6 +56,10 @@ export const useUserStore = defineStore("pure-user", {
|
|||||||
SET_NICKNAME(nickname: string) {
|
SET_NICKNAME(nickname: string) {
|
||||||
this.nickname = nickname;
|
this.nickname = nickname;
|
||||||
},
|
},
|
||||||
|
/** 存储用户ID */
|
||||||
|
SET_USERID(userId: string) {
|
||||||
|
this.userId = userId;
|
||||||
|
},
|
||||||
/** 存储角色 */
|
/** 存储角色 */
|
||||||
SET_ROLES(roles: Array<string>) {
|
SET_ROLES(roles: Array<string>) {
|
||||||
this.roles = roles;
|
this.roles = roles;
|
||||||
@@ -80,7 +89,8 @@ export const useUserStore = defineStore("pure-user", {
|
|||||||
return new Promise<UserResult>((resolve, reject) => {
|
return new Promise<UserResult>((resolve, reject) => {
|
||||||
getLogin(data)
|
getLogin(data)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.code === 0) {
|
// 后端返回 code: 200 表示成功
|
||||||
|
if (data.code === 200 || data.code === 0) {
|
||||||
setToken(data.data);
|
setToken(data.data);
|
||||||
resolve(data);
|
resolve(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -95,6 +105,8 @@ export const useUserStore = defineStore("pure-user", {
|
|||||||
/** 前端登出(不调用接口) */
|
/** 前端登出(不调用接口) */
|
||||||
logOut() {
|
logOut() {
|
||||||
this.username = "";
|
this.username = "";
|
||||||
|
this.nickname = "";
|
||||||
|
this.userId = "";
|
||||||
this.roles = [];
|
this.roles = [];
|
||||||
this.permissions = [];
|
this.permissions = [];
|
||||||
removeToken();
|
removeToken();
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export type userType = {
|
|||||||
avatar?: string;
|
avatar?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
userId?: string;
|
||||||
roles?: Array<string>;
|
roles?: Array<string>;
|
||||||
permissions?: Array<string>;
|
permissions?: Array<string>;
|
||||||
verifyCode?: string;
|
verifyCode?: string;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface DataInfo<T> {
|
|||||||
username?: string;
|
username?: string;
|
||||||
/** 昵称 */
|
/** 昵称 */
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
/** 用户ID */
|
||||||
|
userId?: string;
|
||||||
/** 当前登录用户的角色 */
|
/** 当前登录用户的角色 */
|
||||||
roles?: Array<string>;
|
roles?: Array<string>;
|
||||||
/** 当前登录用户的按钮级别权限 */
|
/** 当前登录用户的按钮级别权限 */
|
||||||
@@ -45,11 +47,22 @@ export function getToken(): DataInfo<number> {
|
|||||||
* 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁)
|
* 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁)
|
||||||
* 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁)
|
* 将`avatar`、`username`、`nickname`、`roles`、`permissions`、`refreshToken`、`expires`这七条信息放在key值为`user-info`的localStorage里(利用`multipleTabsKey`当浏览器完全关闭后自动销毁)
|
||||||
*/
|
*/
|
||||||
export function setToken(data: DataInfo<Date>) {
|
export function setToken(data: any) {
|
||||||
let expires = 0;
|
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();
|
const { isRemembered, loginDay } = useUserStoreHook();
|
||||||
expires = new Date(data.expires).getTime(); // 如果后端直接设置时间戳,将此处代码改为expires = data.expires,然后把上面的DataInfo<Date>改成DataInfo<number>即可
|
|
||||||
|
// 处理过期时间,如果后端没有返回则默认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 });
|
const cookieString = JSON.stringify({ accessToken, expires, refreshToken });
|
||||||
|
|
||||||
expires > 0
|
expires > 0
|
||||||
@@ -68,10 +81,18 @@ export function setToken(data: DataInfo<Date>) {
|
|||||||
: {}
|
: {}
|
||||||
);
|
);
|
||||||
|
|
||||||
function setUserKey({ avatar, username, nickname, roles, permissions }) {
|
function setUserKey({
|
||||||
|
avatar,
|
||||||
|
username,
|
||||||
|
nickname,
|
||||||
|
userId,
|
||||||
|
roles,
|
||||||
|
permissions
|
||||||
|
}) {
|
||||||
useUserStoreHook().SET_AVATAR(avatar);
|
useUserStoreHook().SET_AVATAR(avatar);
|
||||||
useUserStoreHook().SET_USERNAME(username);
|
useUserStoreHook().SET_USERNAME(username);
|
||||||
useUserStoreHook().SET_NICKNAME(nickname);
|
useUserStoreHook().SET_NICKNAME(nickname);
|
||||||
|
useUserStoreHook().SET_USERID(userId);
|
||||||
useUserStoreHook().SET_ROLES(roles);
|
useUserStoreHook().SET_ROLES(roles);
|
||||||
useUserStoreHook().SET_PERMS(permissions);
|
useUserStoreHook().SET_PERMS(permissions);
|
||||||
storageLocal().setItem(userKey, {
|
storageLocal().setItem(userKey, {
|
||||||
@@ -80,18 +101,19 @@ export function setToken(data: DataInfo<Date>) {
|
|||||||
avatar,
|
avatar,
|
||||||
username,
|
username,
|
||||||
nickname,
|
nickname,
|
||||||
|
userId,
|
||||||
roles,
|
roles,
|
||||||
permissions
|
permissions
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.username && data.roles) {
|
if (data.username && data.roles) {
|
||||||
const { username, roles } = data;
|
|
||||||
setUserKey({
|
setUserKey({
|
||||||
avatar: data?.avatar ?? "",
|
avatar: data?.avatar ?? "",
|
||||||
username,
|
username: data.username,
|
||||||
nickname: data?.nickname ?? "",
|
nickname: data?.nickname || data?.realName || "",
|
||||||
roles,
|
userId: data?.userId ?? "",
|
||||||
|
roles: data.roles,
|
||||||
permissions: data?.permissions ?? []
|
permissions: data?.permissions ?? []
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -101,6 +123,8 @@ export function setToken(data: DataInfo<Date>) {
|
|||||||
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "";
|
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "";
|
||||||
const nickname =
|
const nickname =
|
||||||
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "";
|
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "";
|
||||||
|
const userId =
|
||||||
|
storageLocal().getItem<DataInfo<number>>(userKey)?.userId ?? "";
|
||||||
const roles =
|
const roles =
|
||||||
storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [];
|
storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [];
|
||||||
const permissions =
|
const permissions =
|
||||||
@@ -109,6 +133,7 @@ export function setToken(data: DataInfo<Date>) {
|
|||||||
avatar,
|
avatar,
|
||||||
username,
|
username,
|
||||||
nickname,
|
nickname,
|
||||||
|
userId,
|
||||||
roles,
|
roles,
|
||||||
permissions
|
permissions
|
||||||
});
|
});
|
||||||
|
|||||||
297
src/utils/sse/SseClient.ts
Normal file
297
src/utils/sse/SseClient.ts
Normal file
@@ -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<string, SseEventCallback[]> = 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<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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;
|
||||||
@@ -155,8 +155,9 @@ const handleFeishuCallback = async (code: string) => {
|
|||||||
avatar: data.avatar,
|
avatar: data.avatar,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
nickname: data.realName,
|
nickname: data.realName,
|
||||||
roles: ["admin"], // 根据实际业务调整
|
userId: data.userId,
|
||||||
permissions: []
|
roles: data.roles || ["admin"],
|
||||||
|
permissions: data.permissions || []
|
||||||
});
|
});
|
||||||
|
|
||||||
// 保存用户信息到本地存储
|
// 保存用户信息到本地存储
|
||||||
@@ -166,8 +167,9 @@ const handleFeishuCallback = async (code: string) => {
|
|||||||
avatar: data.avatar,
|
avatar: data.avatar,
|
||||||
username: data.username,
|
username: data.username,
|
||||||
nickname: data.realName,
|
nickname: data.realName,
|
||||||
roles: ["admin"],
|
userId: data.userId,
|
||||||
permissions: []
|
roles: data.roles || ["admin"],
|
||||||
|
permissions: data.permissions || []
|
||||||
});
|
});
|
||||||
|
|
||||||
// 更新全局状态
|
// 更新全局状态
|
||||||
@@ -175,8 +177,9 @@ const handleFeishuCallback = async (code: string) => {
|
|||||||
userStore.SET_AVATAR(data.avatar);
|
userStore.SET_AVATAR(data.avatar);
|
||||||
userStore.SET_USERNAME(data.username);
|
userStore.SET_USERNAME(data.username);
|
||||||
userStore.SET_NICKNAME(data.realName);
|
userStore.SET_NICKNAME(data.realName);
|
||||||
userStore.SET_ROLES(["admin"]);
|
userStore.SET_USERID(data.userId);
|
||||||
userStore.SET_PERMS([]);
|
userStore.SET_ROLES(data.roles || ["admin"]);
|
||||||
|
userStore.SET_PERMS(data.permissions || []);
|
||||||
|
|
||||||
// 初始化路由
|
// 初始化路由
|
||||||
await initRouter();
|
await initRouter();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed } from "vue";
|
import { ref, reactive, computed, watch } from "vue";
|
||||||
import { message } from "@/utils/message";
|
import { message } from "@/utils/message";
|
||||||
import { previewProjectInit, confirmProjectInit } from "@/api/project";
|
import { confirmProjectInit } from "@/api/project";
|
||||||
import type { ProjectInitResult } from "@/api/project";
|
import type { ProjectInitResult } from "@/api/project";
|
||||||
import {
|
import {
|
||||||
WizardStep,
|
WizardStep,
|
||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
ProjectTypeOptions
|
ProjectTypeOptions
|
||||||
} from "../utils/types";
|
} from "../utils/types";
|
||||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||||
|
import { useSseStoreHook } from "@/store/modules/sse";
|
||||||
import UploadIcon from "~icons/ri/upload-cloud-line";
|
import UploadIcon from "~icons/ri/upload-cloud-line";
|
||||||
import FileIcon from "~icons/ri/file-line";
|
import FileIcon from "~icons/ri/file-line";
|
||||||
import CheckIcon from "~icons/ri/check-line";
|
import CheckIcon from "~icons/ri/check-line";
|
||||||
import DeleteIcon from "~icons/ri/delete-bin-line";
|
import DeleteIcon from "~icons/ri/delete-bin-line";
|
||||||
import AddIcon from "~icons/ri/add-line";
|
import AddIcon from "~icons/ri/add-line";
|
||||||
|
import LoadingIcon from "~icons/ri/loader-4-line";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -35,6 +37,31 @@ const saving = ref(false);
|
|||||||
const uploadRef = ref();
|
const uploadRef = ref();
|
||||||
const fileList = ref<File[]>([]);
|
const fileList = ref<File[]>([]);
|
||||||
|
|
||||||
|
// SSE Store
|
||||||
|
const sseStore = useSseStoreHook();
|
||||||
|
const taskProgress = computed(() => sseStore.taskProgress);
|
||||||
|
const taskStatus = computed(() => sseStore.taskStatus);
|
||||||
|
const currentTask = computed(() => sseStore.currentTask);
|
||||||
|
|
||||||
|
// 监听任务完成
|
||||||
|
watch(
|
||||||
|
() => sseStore.taskStatus,
|
||||||
|
newStatus => {
|
||||||
|
if (newStatus === "completed" && currentTask.value?.result) {
|
||||||
|
Object.assign(projectData, currentTask.value.result);
|
||||||
|
currentStep.value = WizardStep.Preview;
|
||||||
|
uploading.value = false;
|
||||||
|
message("文件解析成功", { type: "success" });
|
||||||
|
sseStore.resetTaskStatus();
|
||||||
|
} else if (newStatus === "error") {
|
||||||
|
uploading.value = false;
|
||||||
|
message(sseStore.errorMessage || "文件解析失败", { type: "error" });
|
||||||
|
sseStore.resetTaskStatus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: false }
|
||||||
|
);
|
||||||
|
|
||||||
// 项目初始化数据
|
// 项目初始化数据
|
||||||
const projectData = reactive<ProjectInitResult>({
|
const projectData = reactive<ProjectInitResult>({
|
||||||
project: {
|
project: {
|
||||||
@@ -117,24 +144,28 @@ function handleRemove() {
|
|||||||
fileList.value = [];
|
fileList.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上传并预览
|
// 上传并预览(使用 SSE)
|
||||||
async function handleUploadAndPreview() {
|
async function handleUploadAndPreview() {
|
||||||
if (fileList.value.length === 0) {
|
if (fileList.value.length === 0) {
|
||||||
message("请先选择文件", { type: "warning" });
|
message("请先选择文件", { type: "warning" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!sseStore.getIsConnected) {
|
||||||
|
message("SSE 连接未建立,请刷新页面重试", { type: "warning" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uploading.value = true;
|
uploading.value = true;
|
||||||
try {
|
try {
|
||||||
const { code, data } = await previewProjectInit(fileList.value[0]);
|
const result = await sseStore.submitProjectInitTask(fileList.value[0]);
|
||||||
if (code === 200 && data) {
|
if (result.code !== 200) {
|
||||||
Object.assign(projectData, data);
|
message(result.message || "提交任务失败", { type: "error" });
|
||||||
currentStep.value = WizardStep.Preview;
|
uploading.value = false;
|
||||||
message("文件解析成功", { type: "success" });
|
|
||||||
}
|
}
|
||||||
|
// 进度和结果将通过 SSE 推送,由 watch 监听处理
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message("文件解析失败,请检查文件格式", { type: "error" });
|
message("文件上传失败,请检查文件格式", { type: "error" });
|
||||||
} finally {
|
|
||||||
uploading.value = false;
|
uploading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,14 +331,39 @@ function removeTag(tag: string) {
|
|||||||
</template>
|
</template>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
|
|
||||||
|
<!-- 进度显示 -->
|
||||||
|
<div v-if="uploading" class="progress-container mt-6">
|
||||||
|
<div class="flex-c gap-2 mb-2">
|
||||||
|
<el-icon class="is-loading">
|
||||||
|
<component :is="useRenderIcon(LoadingIcon)" />
|
||||||
|
</el-icon>
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
{{
|
||||||
|
taskStatus === "submitted"
|
||||||
|
? "任务已提交,等待处理..."
|
||||||
|
: currentTask?.progressMessage || "正在解析..."
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<el-progress
|
||||||
|
:percentage="taskProgress"
|
||||||
|
:stroke-width="8"
|
||||||
|
:status="taskStatus === 'error' ? 'exception' : undefined"
|
||||||
|
class="w-full max-w-md mx-auto"
|
||||||
|
/>
|
||||||
|
<div class="text-center mt-2 text-xs text-gray-400">
|
||||||
|
进度: {{ taskProgress }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-center mt-6">
|
<div class="flex justify-center mt-6">
|
||||||
<el-button
|
<el-button
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="uploading"
|
:loading="uploading"
|
||||||
:disabled="fileList.length === 0"
|
:disabled="fileList.length === 0 || uploading"
|
||||||
@click="handleUploadAndPreview"
|
@click="handleUploadAndPreview"
|
||||||
>
|
>
|
||||||
解析文件
|
{{ uploading ? "解析中..." : "解析文件" }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user