vue2server7 7.0.15 → 7.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ // axios配置 可自行根据项目进行更改,只需更改该文件即可,其他文件可以不动
2
+ import type { AxiosInstance } from 'axios';
3
+ import isString from 'lodash/isString';
4
+ import merge from 'lodash/merge';
5
+ import { MessagePlugin } from 'tdesign-vue-next';
6
+
7
+ import { ContentTypeEnum } from '@/constants';
8
+ import { useUserStore } from '@/store';
9
+
10
+ import { VAxios } from './Axios';
11
+ import type { AxiosTransform, CreateAxiosOptions } from './AxiosTransform';
12
+ import { formatRequestDate, joinTimestamp, setObjToUrlParams } from './utils';
13
+
14
+ const host = import.meta.env.VITE_IS_REQUEST_PROXY !== 'true' ? '' : import.meta.env.VITE_API_URL;
15
+
16
+ // 数据处理,方便区分多种处理方式
17
+ const transform: AxiosTransform = {
18
+ // 处理请求数据。如果数据不是预期格式,可直接抛出错误
19
+ transformRequestHook: (res, options) => {
20
+ const { isTransformResponse, isReturnNativeResponse } = options;
21
+
22
+ // 如果204无内容直接返回
23
+ const method = res.config.method?.toLowerCase();
24
+ if (res.status === 204 && ['put', 'patch', 'delete'].includes(method)) {
25
+ return res;
26
+ }
27
+
28
+ // 是否返回原生响应头 比如:需要获取响应头时使用该属性
29
+ if (isReturnNativeResponse) {
30
+ return res;
31
+ }
32
+ // 不进行任何处理,直接返回
33
+ // 用于页面代码可能需要直接获取code,data,message这些信息时开启
34
+ if (!isTransformResponse) {
35
+ return res.data;
36
+ }
37
+
38
+ // 错误的时候返回
39
+ const { data } = res;
40
+ if (!data) {
41
+ throw new Error('请求接口错误');
42
+ }
43
+
44
+ // 这里 code为 后台统一的字段,需要在 types.ts内修改为项目自己的接口返回格式
45
+ const { code } = data;
46
+
47
+ // 这里逻辑可以根据项目进行修改
48
+ const hasSuccess = data && code === 200;
49
+ if (hasSuccess) {
50
+ return data.data;
51
+ }
52
+
53
+ throw new Error(`请求接口错误, 错误码: ${code}`);
54
+ },
55
+
56
+ // 请求前处理配置
57
+ beforeRequestHook: (config, options) => {
58
+ const { apiUrl, isJoinPrefix, urlPrefix, joinParamsToUrl, formatDate, joinTime = true } = options;
59
+
60
+ // 添加接口前缀
61
+ if (isJoinPrefix && urlPrefix && isString(urlPrefix)) {
62
+ config.url = `${urlPrefix}${config.url}`;
63
+ }
64
+
65
+ // 将baseUrl拼接
66
+ if (apiUrl && isString(apiUrl)) {
67
+ config.url = `${apiUrl}${config.url}`;
68
+ }
69
+ const params = config.params || {};
70
+ const data = config.data || false;
71
+
72
+ if (formatDate && data && !isString(data)) {
73
+ formatRequestDate(data);
74
+ }
75
+ if (config.method?.toUpperCase() === 'GET') {
76
+ if (!isString(params)) {
77
+ // 给 get 请求加上时间戳参数,避免从缓存中拿数据。
78
+ config.params = Object.assign(params || {}, joinTimestamp(joinTime, false));
79
+ } else {
80
+ // 兼容restful风格
81
+ config.url = `${config.url + params}${joinTimestamp(joinTime, true)}`;
82
+ config.params = undefined;
83
+ }
84
+ } else if (!isString(params)) {
85
+ if (formatDate) {
86
+ formatRequestDate(params);
87
+ }
88
+ if (
89
+ Reflect.has(config, 'data') &&
90
+ config.data &&
91
+ (Object.keys(config.data).length > 0 || data instanceof FormData)
92
+ ) {
93
+ config.data = data;
94
+ config.params = params;
95
+ } else {
96
+ // 非GET请求如果没有提供data,则将params视为data
97
+ config.data = params;
98
+ config.params = undefined;
99
+ }
100
+ if (joinParamsToUrl) {
101
+ config.url = setObjToUrlParams(config.url as string, { ...config.params, ...config.data });
102
+ }
103
+ } else {
104
+ // 兼容restful风格
105
+ config.url += params;
106
+ config.params = undefined;
107
+ }
108
+ return config;
109
+ },
110
+
111
+ // 请求拦截器处理
112
+ requestInterceptors: (config, options) => {
113
+ // 请求之前处理config
114
+ const userStore = useUserStore();
115
+ const { token } = userStore;
116
+ config.headers['x-skip-crypto'] = 'dev_debug_token_123';
117
+ if (token && (config as Recordable)?.requestOptions?.withToken !== false) {
118
+ // jwt token
119
+ (config as Recordable).headers.Authorization = options.authenticationScheme
120
+ ? `${options.authenticationScheme} ${token}`
121
+ : token;
122
+ }
123
+ return config;
124
+ },
125
+
126
+ // 响应拦截器处理
127
+ responseInterceptors: (res) => {
128
+ return res;
129
+ },
130
+
131
+ // 响应错误处理
132
+ responseInterceptorsCatch: async (error: any, instance: AxiosInstance) => {
133
+ const { config, response } = error;
134
+ // 获取服务器返回的错误数据
135
+ const errorData = response?.data;
136
+ const errorMessage = errorData?.message || error.message || '请求失败';
137
+
138
+ // 401 未授权:清除 token 并跳转登录页
139
+ if (response?.status === 401) {
140
+ const userStore = useUserStore();
141
+ userStore.logout();
142
+ const currentPath = window.location.hash ? window.location.hash.slice(1) : window.location.pathname;
143
+ if (currentPath !== '/login') {
144
+ window.location.href = `/login?redirect=${encodeURIComponent(currentPath)}`;
145
+ }
146
+ return Promise.reject(error);
147
+ }
148
+
149
+ // 弹出错误提示
150
+ await MessagePlugin.error(errorMessage);
151
+
152
+ if (!config || !config.requestOptions.retry) {
153
+ // 将错误信息附加到 error 对象上,方便调用方获取
154
+ error.message = errorMessage;
155
+ error.data = errorData;
156
+ return Promise.reject(error);
157
+ }
158
+
159
+ config.retryCount = config.retryCount || 0;
160
+
161
+ if (config.retryCount >= config.requestOptions.retry.count) {
162
+ error.message = errorMessage;
163
+ error.data = errorData;
164
+ return Promise.reject(error);
165
+ }
166
+
167
+ config.retryCount += 1;
168
+
169
+ const backoff = new Promise((resolve) => {
170
+ setTimeout(() => {
171
+ resolve(config);
172
+ }, config.requestOptions.retry.delay || 1);
173
+ });
174
+ config.headers = { ...config.headers, 'Content-Type': ContentTypeEnum.Json };
175
+ return backoff.then((config) => instance.request(config));
176
+ },
177
+ };
178
+
179
+ function createAxios(opt?: Partial<CreateAxiosOptions>) {
180
+ return new VAxios(
181
+ merge(
182
+ <CreateAxiosOptions>{
183
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
184
+ // 例如: authenticationScheme: 'Bearer'
185
+ authenticationScheme: '',
186
+ // 超时
187
+ timeout: 10 * 1000,
188
+ // 携带Cookie
189
+ withCredentials: false,
190
+ // 头信息
191
+ headers: { 'Content-Type': ContentTypeEnum.Json },
192
+ // 数据处理方式
193
+ transform,
194
+ // 配置项,下面的选项都可以在独立的接口请求中覆盖
195
+ requestOptions: {
196
+ // 接口地址
197
+ apiUrl: host,
198
+ // 是否自动添加接口前缀
199
+ isJoinPrefix: true,
200
+ // 接口前缀
201
+ // 例如: https://www.baidu.com/api
202
+ // urlPrefix: '/api'
203
+ urlPrefix: import.meta.env.VITE_API_URL_PREFIX,
204
+ // 是否返回原生响应头 比如:需要获取响应头时使用该属性
205
+ isReturnNativeResponse: false,
206
+ // 需要对返回数据进行处理
207
+ isTransformResponse: true,
208
+ // post请求的时候添加参数到url
209
+ joinParamsToUrl: false,
210
+ // 格式化提交参数时间
211
+ formatDate: true,
212
+ // 是否加入时间戳
213
+ joinTime: true,
214
+ // 是否忽略请求取消令牌
215
+ // 如果启用,则重复请求时不进行处理
216
+ // 如果禁用,则重复请求时会取消当前请求
217
+ ignoreCancelToken: true,
218
+ // 是否携带token
219
+ withToken: true,
220
+ // 重试
221
+ retry: {
222
+ count: 0,
223
+ delay: 1000,
224
+ },
225
+ },
226
+ },
227
+ opt || {},
228
+ ),
229
+ );
230
+ }
231
+ export const request = createAxios();
@@ -0,0 +1,53 @@
1
+ import isObject from 'lodash/isObject';
2
+ import isString from 'lodash/isString';
3
+
4
+ const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
5
+
6
+ export function joinTimestamp<T extends boolean>(_join: boolean, _restful: T): T extends true ? string : object;
7
+
8
+ export function joinTimestamp(join: boolean, restful = false): string | object {
9
+ if (!join) {
10
+ return restful ? '' : {};
11
+ }
12
+ const now = new Date().getTime();
13
+ if (restful) {
14
+ return `?_t=${now}`;
15
+ }
16
+ return { _t: now };
17
+ }
18
+
19
+ // 格式化提交参数时间
20
+ export function formatRequestDate(params: Recordable) {
21
+ if (Object.prototype.toString.call(params) !== '[object Object]') {
22
+ return;
23
+ }
24
+
25
+ for (const key in params) {
26
+ if (params[key] && params[key]._isAMomentObject) {
27
+ params[key] = params[key].format(DATE_TIME_FORMAT);
28
+ }
29
+ if (isString(key)) {
30
+ const value = params[key];
31
+ if (value) {
32
+ try {
33
+ params[key] = isString(value) ? value.trim() : value;
34
+ } catch (error: any) {
35
+ throw new Error(error);
36
+ }
37
+ }
38
+ }
39
+ if (isObject(params[key])) {
40
+ formatRequestDate(params[key]);
41
+ }
42
+ }
43
+ }
44
+
45
+ // 将对象转为Url参数
46
+ export function setObjToUrlParams(baseUrl: string, obj: { [index: string]: any }): string {
47
+ let parameters = '';
48
+ for (const key in obj) {
49
+ parameters += `${key}=${encodeURIComponent(obj[key])}&`;
50
+ }
51
+ parameters = parameters.replace(/&$/, '');
52
+ return /\?$/.test(baseUrl) ? baseUrl + parameters : baseUrl.replace(/\/?$/, '?') + parameters;
53
+ }
@@ -0,0 +1,14 @@
1
+ export const LAYOUT = () => import('@/layouts/index.vue');
2
+ export const BLANK_LAYOUT = () => import('@/layouts/blank.vue');
3
+ export const IFRAME = () => import('@/layouts/components/FrameBlank.vue');
4
+ export const EXCEPTION_COMPONENT = () => import('@/pages/result/500/index.vue');
5
+ export const PARENT_LAYOUT = () =>
6
+ new Promise((resolve) => {
7
+ resolve({ name: 'ParentLayout' });
8
+ });
9
+
10
+ export const PAGE_NOT_FOUND_ROUTE = {
11
+ path: '/:w+',
12
+ name: '404Page',
13
+ redirect: '/result/404',
14
+ };
@@ -0,0 +1,110 @@
1
+ import cloneDeep from 'lodash/cloneDeep';
2
+
3
+ import type { RouteItem } from '@/api/model/permissionModel';
4
+ import type { RouteMeta } from '@/types/interface';
5
+ import {
6
+ BLANK_LAYOUT,
7
+ EXCEPTION_COMPONENT,
8
+ IFRAME,
9
+ LAYOUT,
10
+ PAGE_NOT_FOUND_ROUTE,
11
+ PARENT_LAYOUT,
12
+ } from '@/utils/route/constant';
13
+
14
+ // 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk
15
+ // const iconsPath = import.meta.glob('../../../node_modules/tdesign-icons-vue-next/esm/components/*.js');
16
+
17
+ // async function getMenuIcon(iconName: string): Promise<string> {
18
+ // const RenderIcon = iconsPath[`../../../node_modules/tdesign-icons-vue-next/esm/components/${iconName}.js`];
19
+
20
+ // const Icon = await RenderIcon();
21
+ // return shallowRef(Icon.default);
22
+ // }
23
+
24
+ const LayoutMap = new Map<string, () => Promise<typeof import('*.vue')>>();
25
+
26
+ LayoutMap.set('LAYOUT', LAYOUT);
27
+ LayoutMap.set('BLANK', BLANK_LAYOUT);
28
+ LayoutMap.set('IFRAME', IFRAME);
29
+
30
+ let dynamicViewsModules: Record<string, () => Promise<Recordable>>;
31
+
32
+ // 动态引入路由组件
33
+ function asyncImportRoute(routes: RouteItem[] | undefined) {
34
+ dynamicViewsModules = dynamicViewsModules || import.meta.glob('../../pages/**/*.vue');
35
+ if (!routes) return;
36
+
37
+ routes.forEach(async (item) => {
38
+ const { component, name } = item;
39
+ const { children } = item;
40
+
41
+ if (component) {
42
+ const layoutFound = LayoutMap.get(component.toUpperCase());
43
+ if (layoutFound) {
44
+ item.component = layoutFound;
45
+ } else {
46
+ item.component = dynamicImport(dynamicViewsModules, component);
47
+ }
48
+ } else if (name) {
49
+ item.component = PARENT_LAYOUT();
50
+ }
51
+
52
+ // 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk
53
+ // if (item.meta.icon) item.meta.icon = await getMenuIcon(item.meta.icon);
54
+
55
+ children && asyncImportRoute(children);
56
+ });
57
+ }
58
+
59
+ function dynamicImport(dynamicViewsModules: Record<string, () => Promise<Recordable>>, component: string) {
60
+ const keys = Object.keys(dynamicViewsModules);
61
+ const matchKeys = keys.filter((key) => {
62
+ const k = key.replace('../../pages', '');
63
+ const startFlag = component.startsWith('/');
64
+ const endFlag = component.endsWith('.vue') || component.endsWith('.tsx');
65
+ const startIndex = startFlag ? 0 : 1;
66
+ const lastIndex = endFlag ? k.length : k.lastIndexOf('.');
67
+ return k.substring(startIndex, lastIndex) === component;
68
+ });
69
+ if (matchKeys?.length === 1) {
70
+ const matchKey = matchKeys[0];
71
+ return dynamicViewsModules[matchKey];
72
+ }
73
+ if (matchKeys?.length > 1) {
74
+ throw new Error(
75
+ 'Please do not create `.vue` and `.TSX` files with the same file name in the same hierarchical directory under the views folder. This will cause dynamic introduction failure',
76
+ );
77
+ } else {
78
+ console.warn(`Can't find ${component} in pages folder`);
79
+ }
80
+ return EXCEPTION_COMPONENT;
81
+ }
82
+
83
+ // 将背景对象变成路由对象
84
+ export function transformObjectToRoute<T = RouteItem>(routeList: RouteItem[]): T[] {
85
+ routeList.forEach(async (route) => {
86
+ const component = route.component as string;
87
+
88
+ if (component) {
89
+ if (component.toUpperCase() === 'LAYOUT') {
90
+ route.component = LayoutMap.get(component.toUpperCase());
91
+ } else {
92
+ route.children = [cloneDeep(route)];
93
+ route.component = LAYOUT;
94
+ route.name = `${route.name}Parent`;
95
+ route.path = '';
96
+ route.meta = (route.meta || {}) as RouteMeta;
97
+ }
98
+ } else {
99
+ throw new Error('component is undefined');
100
+ }
101
+
102
+ route.children && asyncImportRoute(route.children);
103
+
104
+ // 动态从包内引入单个Icon,如果没有网络环境可以使用这种方式 但是会导致产物存在多个chunk
105
+ // if (route.meta.icon)
106
+ // route.meta.icon = await getMenuIcon(route.meta.icon);
107
+ });
108
+
109
+ return [PAGE_NOT_FOUND_ROUTE, ...routeList] as unknown as T[];
110
+ }
@@ -0,0 +1,58 @@
1
+ declare function postMessage(message: unknown): void;
2
+
3
+ let timer: ReturnType<typeof setInterval> | null = null;
4
+ let currentVersion = '';
5
+ let checkUrl = '';
6
+ let interval = 60_000;
7
+
8
+ async function check() {
9
+ try {
10
+ const res = await fetch(`${checkUrl}?t=${Date.now()}`);
11
+ if (!res.ok) return;
12
+ const data = await res.json();
13
+ if (data.version && data.version !== currentVersion) {
14
+ postMessage({ type: 'update-available', version: data.version });
15
+ stop();
16
+ }
17
+ } catch {
18
+ // Network error — silently ignore, next tick will retry
19
+ }
20
+ }
21
+
22
+ function stop() {
23
+ if (timer) {
24
+ clearInterval(timer);
25
+ timer = null;
26
+ }
27
+ }
28
+
29
+ function start() {
30
+ stop();
31
+ check();
32
+ timer = setInterval(check, interval);
33
+ }
34
+
35
+ onmessage = (e: MessageEvent) => {
36
+ const msg = e.data;
37
+
38
+ switch (msg.type) {
39
+ case 'start':
40
+ currentVersion = msg.version;
41
+ checkUrl = msg.url;
42
+ interval = msg.interval || 60_000;
43
+ start();
44
+ break;
45
+
46
+ case 'pause':
47
+ stop();
48
+ break;
49
+
50
+ case 'resume':
51
+ start();
52
+ break;
53
+
54
+ case 'stop':
55
+ stop();
56
+ break;
57
+ }
58
+ };
@@ -0,0 +1,135 @@
1
+ import { DialogPlugin } from 'tdesign-vue-next';
2
+
3
+ /**
4
+ * 前端版本热更新检测模块
5
+ *
6
+ * 工作原理:
7
+ * 1. 应用启动时,从 <meta name="app-version"> 读取当前构建版本号
8
+ * 2. 启动一个 Web Worker 按固定间隔轮询 version.json(构建时生成)
9
+ * 3. Worker 发现远端版本号与当前不一致时,通过 postMessage 通知主线程
10
+ * 4. 主线程弹出确认弹窗,用户可选择"立即刷新"或"稍后提醒"
11
+ * 5. 选择稍后提醒时,等待 SNOOZE_INTERVAL 后重新开始轮询
12
+ *
13
+ * 附加行为:
14
+ * - 页面切到后台时暂停轮询,回到前台时恢复(节省资源)
15
+ * - 开发环境(DEV)下不启用检测
16
+ */
17
+
18
+ /** 正常轮询间隔:60 秒 */
19
+ const POLL_INTERVAL = 60 * 1000;
20
+
21
+ /** 用户点击"稍后提醒"后的静默期:5 分钟,期间不再弹窗 */
22
+ const SNOOZE_INTERVAL = 5 * 60 * 1000;
23
+
24
+ /** 负责后台轮询的 Web Worker 实例 */
25
+ let worker: Worker | null = null;
26
+
27
+ /** 防重复弹窗标记:同一轮检测周期内只弹一次 */
28
+ let hasNotified = false;
29
+
30
+ /** 当前前端构建版本号,来自 HTML meta 标签 */
31
+ let currentVersion = '';
32
+
33
+ /** version.json 的完整 URL(含 BASE_URL 前缀,适配子路径部署) */
34
+ let versionUrl = '';
35
+
36
+ /**
37
+ * 初始化版本检测,应在应用挂载后调用一次
38
+ *
39
+ * 流程:
40
+ * 1. 开发环境直接跳过
41
+ * 2. 读取 meta 标签中的版本号,读不到则放弃
42
+ * 3. 创建 Web Worker;浏览器不支持时静默降级
43
+ * 4. 监听 Worker 消息,收到 update-available 则弹窗
44
+ * 5. 监听 visibilitychange 实现前后台暂停/恢复
45
+ */
46
+ export function initVersionCheck() {
47
+ if (import.meta.env.DEV) return;
48
+
49
+ currentVersion = document.querySelector('meta[name="app-version"]')?.getAttribute('content') || '';
50
+ if (!currentVersion) return;
51
+
52
+ versionUrl = `${import.meta.env.BASE_URL}version.json`;
53
+
54
+ try {
55
+ worker = new Worker(new URL('./version-check.worker.ts', import.meta.url), { type: 'module' });
56
+ } catch {
57
+ // 浏览器不支持 module Worker 或 URL 构造失败,静默降级
58
+ return;
59
+ }
60
+
61
+ worker.onmessage = (e: MessageEvent) => {
62
+ if (e.data.type === 'update-available' && !hasNotified) {
63
+ hasNotified = true;
64
+ showUpdateDialog();
65
+ }
66
+ };
67
+
68
+ // 启动 Worker 轮询,传入当前版本号、远端地址和轮询间隔
69
+ worker.postMessage({ type: 'start', version: currentVersion, url: versionUrl, interval: POLL_INTERVAL });
70
+
71
+ document.addEventListener('visibilitychange', handleVisibilityChange);
72
+ }
73
+
74
+ /**
75
+ * 页面可见性变化回调
76
+ * - 回到前台:恢复轮询
77
+ * - 切到后台:暂停轮询(避免不必要的网络请求)
78
+ */
79
+ function handleVisibilityChange() {
80
+ if (!worker || hasNotified) return;
81
+ if (document.visibilityState === 'visible') {
82
+ worker.postMessage({ type: 'resume', interval: POLL_INTERVAL });
83
+ } else {
84
+ worker.postMessage({ type: 'pause' });
85
+ }
86
+ }
87
+
88
+ /**
89
+ * 弹出版本更新确认弹窗
90
+ * - 确认:立即刷新页面加载新版本
91
+ * - 取消/关闭:进入静默期后重新开始检测
92
+ */
93
+ function showUpdateDialog() {
94
+ const dialog = DialogPlugin.confirm({
95
+ header: '发现新版本',
96
+ body: '系统已更新,请刷新页面以获取最新功能和修复。',
97
+ confirmBtn: '立即刷新',
98
+ cancelBtn: '稍后提醒',
99
+ theme: 'info',
100
+ onConfirm: () => {
101
+ dialog.destroy();
102
+ window.location.reload();
103
+ },
104
+ onCancel: () => {
105
+ dialog.destroy();
106
+ scheduleRecheck();
107
+ },
108
+ onClose: () => {
109
+ dialog.destroy();
110
+ scheduleRecheck();
111
+ },
112
+ });
113
+ }
114
+
115
+ /**
116
+ * 安排延迟重新检测
117
+ * 重置弹窗标记,等待 SNOOZE_INTERVAL 后重新启动 Worker 轮询
118
+ */
119
+ function scheduleRecheck() {
120
+ hasNotified = false;
121
+ setTimeout(() => {
122
+ worker?.postMessage({ type: 'start', version: currentVersion, url: versionUrl, interval: POLL_INTERVAL });
123
+ }, SNOOZE_INTERVAL);
124
+ }
125
+
126
+ /**
127
+ * 销毁版本检测,应在应用卸载时调用
128
+ * 移除事件监听、停止并终止 Worker,释放资源
129
+ */
130
+ export function destroyVersionCheck() {
131
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
132
+ worker?.postMessage({ type: 'stop' });
133
+ worker?.terminate();
134
+ worker = null;
135
+ }