- 新增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";
|
||||
|
||||
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([]);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
/** 飞书登录响应结果 */
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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,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<DataInfo<number>>(userKey)?.avatar ?? "",
|
||||
// 用户名
|
||||
username: storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "",
|
||||
// 昵称
|
||||
nickname: storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "",
|
||||
// 页面级别权限
|
||||
roles: storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [],
|
||||
// 按钮级别权限
|
||||
permissions:
|
||||
storageLocal().getItem<DataInfo<number>>(userKey)?.permissions ?? [],
|
||||
// 前端生成的验证码(按实际需求替换)
|
||||
verifyCode: "",
|
||||
// 判断登录页面显示哪个组件(0:登录(默认)、1:手机登录、2:二维码登录、3:注册、4:忘记密码)
|
||||
currentPage: 0,
|
||||
// 是否勾选了登录页的免登录
|
||||
isRemembered: false,
|
||||
// 登录页的免登录存储几天,默认7天
|
||||
loginDay: 7
|
||||
}),
|
||||
state: (): userType => {
|
||||
const userInfo = storageLocal().getItem<DataInfo<number>>(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<string>) {
|
||||
this.roles = roles;
|
||||
@@ -80,7 +89,8 @@ export const useUserStore = defineStore("pure-user", {
|
||||
return new Promise<UserResult>((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();
|
||||
|
||||
@@ -41,6 +41,7 @@ export type userType = {
|
||||
avatar?: string;
|
||||
username?: string;
|
||||
nickname?: string;
|
||||
userId?: string;
|
||||
roles?: Array<string>;
|
||||
permissions?: Array<string>;
|
||||
verifyCode?: string;
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface DataInfo<T> {
|
||||
username?: string;
|
||||
/** 昵称 */
|
||||
nickname?: string;
|
||||
/** 用户ID */
|
||||
userId?: string;
|
||||
/** 当前登录用户的角色 */
|
||||
roles?: Array<string>;
|
||||
/** 当前登录用户的按钮级别权限 */
|
||||
@@ -45,11 +47,22 @@ export function getToken(): DataInfo<number> {
|
||||
* 将`accessToken`、`expires`、`refreshToken`这三条信息放在key值为authorized-token的cookie里(过期自动销毁)
|
||||
* 将`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;
|
||||
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<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 });
|
||||
|
||||
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_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<Date>) {
|
||||
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<Date>) {
|
||||
storageLocal().getItem<DataInfo<number>>(userKey)?.username ?? "";
|
||||
const nickname =
|
||||
storageLocal().getItem<DataInfo<number>>(userKey)?.nickname ?? "";
|
||||
const userId =
|
||||
storageLocal().getItem<DataInfo<number>>(userKey)?.userId ?? "";
|
||||
const roles =
|
||||
storageLocal().getItem<DataInfo<number>>(userKey)?.roles ?? [];
|
||||
const permissions =
|
||||
@@ -109,6 +133,7 @@ export function setToken(data: DataInfo<Date>) {
|
||||
avatar,
|
||||
username,
|
||||
nickname,
|
||||
userId,
|
||||
roles,
|
||||
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,
|
||||
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();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from "vue";
|
||||
import { ref, reactive, computed, watch } from "vue";
|
||||
import { message } from "@/utils/message";
|
||||
import { previewProjectInit, confirmProjectInit } from "@/api/project";
|
||||
import { confirmProjectInit } from "@/api/project";
|
||||
import type { ProjectInitResult } from "@/api/project";
|
||||
import {
|
||||
WizardStep,
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
ProjectTypeOptions
|
||||
} from "../utils/types";
|
||||
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
|
||||
import { useSseStoreHook } from "@/store/modules/sse";
|
||||
import UploadIcon from "~icons/ri/upload-cloud-line";
|
||||
import FileIcon from "~icons/ri/file-line";
|
||||
import CheckIcon from "~icons/ri/check-line";
|
||||
import DeleteIcon from "~icons/ri/delete-bin-line";
|
||||
import AddIcon from "~icons/ri/add-line";
|
||||
import LoadingIcon from "~icons/ri/loader-4-line";
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
@@ -35,6 +37,31 @@ const saving = ref(false);
|
||||
const uploadRef = ref();
|
||||
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>({
|
||||
project: {
|
||||
@@ -117,24 +144,28 @@ function handleRemove() {
|
||||
fileList.value = [];
|
||||
}
|
||||
|
||||
// 上传并预览
|
||||
// 上传并预览(使用 SSE)
|
||||
async function handleUploadAndPreview() {
|
||||
if (fileList.value.length === 0) {
|
||||
message("请先选择文件", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sseStore.getIsConnected) {
|
||||
message("SSE 连接未建立,请刷新页面重试", { type: "warning" });
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const { code, data } = await previewProjectInit(fileList.value[0]);
|
||||
if (code === 200 && data) {
|
||||
Object.assign(projectData, data);
|
||||
currentStep.value = WizardStep.Preview;
|
||||
message("文件解析成功", { type: "success" });
|
||||
const result = await sseStore.submitProjectInitTask(fileList.value[0]);
|
||||
if (result.code !== 200) {
|
||||
message(result.message || "提交任务失败", { type: "error" });
|
||||
uploading.value = false;
|
||||
}
|
||||
// 进度和结果将通过 SSE 推送,由 watch 监听处理
|
||||
} catch (error) {
|
||||
message("文件解析失败,请检查文件格式", { type: "error" });
|
||||
} finally {
|
||||
message("文件上传失败,请检查文件格式", { type: "error" });
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
@@ -300,14 +331,39 @@ function removeTag(tag: string) {
|
||||
</template>
|
||||
</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">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="uploading"
|
||||
:disabled="fileList.length === 0"
|
||||
:disabled="fileList.length === 0 || uploading"
|
||||
@click="handleUploadAndPreview"
|
||||
>
|
||||
解析文件
|
||||
{{ uploading ? "解析中..." : "解析文件" }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user