diff --git a/package.json b/package.json index 80d4257..2d5aa6c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@pixi/graphics-extras": "^7.2.4", "@quasar/extras": "^1.0.0", "@stomp/stompjs": "^7.0.0", + "axios": "^1.4.0", "google-protobuf": "^3.21.2", "js-base64": "^3.7.5", "pinia": "^2.0.11", diff --git a/quasar.config.js b/quasar.config.js index c8f1936..c5be092 100644 --- a/quasar.config.js +++ b/quasar.config.js @@ -28,7 +28,7 @@ module.exports = configure(function (/* ctx */) { // app boot file (/src/boot) // --> boot files are part of "main.js" // https://v2.quasar.dev/quasar-cli-vite/boot-files - boot: ['@pixi/graphics-extras'], + boot: ['axios', '@pixi/graphics-extras'], // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css css: ['app.scss'], diff --git a/src/boot/axios.ts b/src/boot/axios.ts new file mode 100644 index 0000000..d9a9d34 --- /dev/null +++ b/src/boot/axios.ts @@ -0,0 +1,111 @@ +import axios, { AxiosInstance } from 'axios'; +import { AxiosError } from 'axios'; +import { Dialog } from 'quasar'; +import { boot } from 'quasar/wrappers'; +import { getJwtToken } from 'src/examples/app/configs/TokenManage'; +import { getHttpBase } from 'src/examples/app/configs/UrlManage'; + +declare module '@vue/runtime-core' { + interface ComponentCustomProperties { + $axios: AxiosInstance; + } +} + +interface ErrorData { + status: number; + title: string; + detail: string; + code: number; +} + +export class ApiError { + origin: AxiosError; + /** + * 业务错误代码 + */ + code: number; + /** + * 错误信息 + */ + title: string; + /** + * 相关问题描述 + */ + detail?: string; + constructor(origin: AxiosError) { + this.origin = origin; + const response = origin.response; + if (response) { + const err = response.data as ErrorData; + this.code = err.code; + this.title = err.title; + this.detail = err.detail; + } else { + this.code = origin.status || -1; + this.title = origin.message; + } + } + + static from(err: AxiosError): ApiError { + return new ApiError(err); + } + + /** + * 是否认证失败(登录过期) + * @returns + */ + isAuthError(): boolean { + return this.origin.response?.status === 401; + } +} + +// Be careful when using SSR for cross-request state pollution +// due to creating a Singleton instance here; +// If any client changes this (global) instance, it might be a +// good idea to move this instance creation inside of the +// "export default () => {}" function below (which runs individually +// for each client) +const api = axios.create({ baseURL: getHttpBase() }); + +export default boot(({ app, router }) => { + // for use inside Vue files (Options API) through this.$axios and this.$api + + // 拦截请求,添加 + api.interceptors.request.use( + (config) => { + config.headers.Authorization = getJwtToken(); + return config; + }, + (err: AxiosError) => { + return Promise.reject(ApiError.from(err)); + } + ); + + api.interceptors.response.use( + (response) => { + return response; + }, + (err) => { + if (err.response && err.response.status === 401) { + Dialog.create({ + title: '认证失败', + message: '认证失败或登录超时,请重新登录', + persistent: true, + }).onOk(() => { + router.push({ name: 'login' }); + }); + } + return Promise.reject(ApiError.from(err)); + } + ); + + app.config.globalProperties.$axios = axios; + // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form) + // so you won't necessarily have to import axios in each vue file + + app.config.globalProperties.$api = api; + // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form) + // so you can easily perform requests against your app's API +}); + +export { api }; diff --git a/src/examples/app/configs/TokenManage.ts b/src/examples/app/configs/TokenManage.ts new file mode 100644 index 0000000..11dddf9 --- /dev/null +++ b/src/examples/app/configs/TokenManage.ts @@ -0,0 +1,13 @@ +const JwtTokenKey = 'jwttoken'; + +export function saveJwtToken(token: string) { + sessionStorage.setItem(JwtTokenKey, `Bearer ${token}`); +} + +export function getJwtToken(): string | null { + return sessionStorage.getItem(JwtTokenKey); +} + +export function clearJwtToken(): void { + sessionStorage.removeItem(JwtTokenKey); +} diff --git a/src/examples/app/configs/UrlManage.ts b/src/examples/app/configs/UrlManage.ts new file mode 100644 index 0000000..6b4280b --- /dev/null +++ b/src/examples/app/configs/UrlManage.ts @@ -0,0 +1,13 @@ +function getHost(): string { + // return '192.168.3.7:9081'; + // return '192.168.3.47:9081'; + return '192.168.3.7:9081'; +} + +export function getHttpBase() { + return `http://${getHost()}`; +} + +export function getWebsocketUrl() { + return `ws://${getHost()}/ws-default`; +} diff --git a/src/jlgraphic/message/WsMsgBroker.ts b/src/jlgraphic/message/WsMsgBroker.ts index dc172f8..8b8cd53 100644 --- a/src/jlgraphic/message/WsMsgBroker.ts +++ b/src/jlgraphic/message/WsMsgBroker.ts @@ -9,8 +9,19 @@ import type { GraphicApp } from '../app/JlGraphicApp'; import { GraphicState } from '../core/JlGraphic'; export interface StompCliOption { - wsUrl: string; // websocket url - token: string; // 认证token + /** + * websocket url地址 + */ + wsUrl: string; + /** + * 认证token + */ + token?: string; + /** + * 认证失败处理 + * @returns + */ + onAuthenticationFailed?: () => void; reconnectDelay?: number; // 重连延时,默认3秒 heartbeatIncoming?: number; // 服务端过来的心跳间隔,默认30秒 heartbeatOutgoing?: number; // 到服务端的心跳间隔,默认30秒 @@ -32,7 +43,7 @@ export class StompCli { private static connected = false; static new(options: StompCliOption) { if (StompCli.enabled) { - // 以及启用 + // 已经启用 return; // throw new Error('websocket 已连接,若确实需要重新连接,请先断开StompCli.close再重新StompCli.new') } @@ -41,12 +52,8 @@ export class StompCli { StompCli.client = new StompClient({ brokerURL: StompCli.options.wsUrl, connectHeaders: { - Authorization: StompCli.options.token, - // Authorization: '' + Authorization: StompCli.options.token ? StompCli.options.token : '', }, - // debug: (str) => { - // console.log(str) - // } reconnectDelay: StompCli.options.reconnectDelay, heartbeatIncoming: StompCli.options.heartbeatIncoming, heartbeatOutgoing: StompCli.options.heartbeatOutgoing, @@ -61,19 +68,21 @@ export class StompCli { }; StompCli.client.onStompError = (frame: Frame) => { - console.error( - 'Stomp收到error消息,可能是认证失败(暂时没有判断具体错误类型,后需添加判断),关闭Stomp客户端', - frame - ); - StompCli.close(); + const errMsg = frame.headers['message']; + if (errMsg === '401') { + console.warn('认证失败,断开WebSocket连接'); + StompCli.close(); + if (StompCli.options.onAuthenticationFailed) { + StompCli.options.onAuthenticationFailed(); + } + } else { + console.error('收到Stomp错误消息', frame); + } }; StompCli.client.onDisconnect = (frame: Frame) => { console.log('Stomp 断开连接', frame); StompCli.connected = false; - // StompCli.appMsgBroker.forEach(broker => { - // broker.close(); - // }); }; StompCli.client.onWebSocketClose = (evt: CloseEvent) => { console.log('websocket 关闭', evt); @@ -82,9 +91,6 @@ export class StompCli { // websocket错误处理 StompCli.client.onWebSocketError = (err: Event) => { console.log('websocket错误', err); - // StompCli.appMsgBroker.forEach(broker => { - // broker.unsbuscribeAll(); - // }); }; StompCli.client.activate(); @@ -140,19 +146,10 @@ export class StompCli { // 状态订阅消息转换器 export type MessageConverter = (message: Uint8Array) => GraphicState[]; // 图形app状态订阅 -export class AppStateSubscription { +export interface AppStateSubscription { destination: string; messageConverter: MessageConverter; subscription?: StompSubscription; // 订阅成功对象,用于取消订阅 - constructor( - destination: string, - messageConverter: MessageConverter, - subscription?: StompSubscription - ) { - this.destination = destination; - this.messageConverter = messageConverter; - this.subscription = subscription; - } } /** diff --git a/src/layouts/DrawLayout.vue b/src/layouts/DrawLayout.vue index 8d23ae4..b6bc4a4 100644 --- a/src/layouts/DrawLayout.vue +++ b/src/layouts/DrawLayout.vue @@ -109,6 +109,8 @@