feat: Architecture initialization
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export * from './useEffect';
|
||||
export * from './useState';
|
||||
export * from './usePagination';
|
||||
export * from './useRequest';
|
||||
export * from './useDebounce';
|
||||
export * from './useAuth';
|
||||
export * from './useWebSocket';
|
||||
export * from './useBasicLayout';
|
||||
export * from './useBreadcrumb';
|
||||
export * from './useContext';
|
||||
@@ -0,0 +1,34 @@
|
||||
// src/hooks/useAuth.ts
|
||||
import { useState } from './useState';
|
||||
import { watch } from 'vue';
|
||||
|
||||
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
|
||||
|
||||
function createAuth() {
|
||||
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
|
||||
|
||||
watch(token, (newToken) => {
|
||||
if (newToken) {
|
||||
window.localStorage.setItem(TOKEN_KEY, newToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
});
|
||||
|
||||
const login = (newToken: string) => {
|
||||
setToken(newToken);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setToken(null);
|
||||
};
|
||||
|
||||
return {
|
||||
token,
|
||||
isLoggedIn: () => !!token.value,
|
||||
login,
|
||||
logout,
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuth();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { h } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
|
||||
import { useBreadcrumb } from './useBreadcrumb';
|
||||
|
||||
export function useBasicLayout() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const { breadcrumbItems } = useBreadcrumb();
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/events',
|
||||
icon: h(TrophyOutlined),
|
||||
label: '我的赛事',
|
||||
},
|
||||
// { TODO 本期先做隐藏
|
||||
// key: '/wallet',
|
||||
// icon: h(WalletOutlined),
|
||||
// label: '钱包管理',
|
||||
// },
|
||||
];
|
||||
|
||||
const getActiveMenuKey = () => {
|
||||
const matchedRoutes = route.matched;
|
||||
for (const r of matchedRoutes) {
|
||||
if (r.meta?.activeMenu) {
|
||||
return r.meta.activeMenu as string;
|
||||
}
|
||||
}
|
||||
if (route.path.startsWith('/events')) return '/events';
|
||||
if (route.path.startsWith('/wallet')) return '/wallet';
|
||||
return route.path;
|
||||
};
|
||||
|
||||
const handleMenuClick = (info: any) => {
|
||||
router.push(String(info.key));
|
||||
};
|
||||
|
||||
return {
|
||||
breadcrumbItems,
|
||||
menuItems,
|
||||
getActiveMenuKey,
|
||||
handleMenuClick,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { computed, h } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
export function useBreadcrumb() {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
/**
|
||||
* 根据当前路径和记录路径,计算拼接后的路径
|
||||
*/
|
||||
const buildPath = (currentPath: string, recordPath: string): string => {
|
||||
if (recordPath.startsWith('/')) {
|
||||
return recordPath;
|
||||
}
|
||||
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
|
||||
};
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched;
|
||||
const items: Array<{ title: string; path?: string }> = [];
|
||||
let currentPath = '';
|
||||
|
||||
for (const record of matched) {
|
||||
currentPath = buildPath(currentPath, record.path);
|
||||
if (record.meta && record.meta.title) {
|
||||
items.push({
|
||||
title: record.meta.title as string,
|
||||
path: currentPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const breadcrumbItems = computed(() => {
|
||||
return breadcrumbs.value.map((item, index) => ({
|
||||
key: index,
|
||||
title:
|
||||
item.path && index !== breadcrumbs.value.length - 1
|
||||
? h(
|
||||
'a',
|
||||
{
|
||||
onClick: (e: Event) => {
|
||||
e.preventDefault();
|
||||
// 最保守方案:只有当前路径包含 /bracket/ 且目标路径是 /events/bracket 时才保留 query
|
||||
// 也就是只有:/events/bracket/xxx → /events/bracket 时才保留
|
||||
const targetPath = item.path || '/';
|
||||
const isBracketChildPage =
|
||||
route.path.includes('/bracket/') && targetPath === '/events/bracket';
|
||||
router.push({
|
||||
path: targetPath,
|
||||
query: isBracketChildPage ? route.query : {},
|
||||
});
|
||||
},
|
||||
},
|
||||
item.title,
|
||||
)
|
||||
: item.title,
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
breadcrumbs,
|
||||
breadcrumbItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { reactive, provide, inject, type InjectionKey } from 'vue';
|
||||
|
||||
/**
|
||||
* 深合并:将 source 的属性递归合并到 target 中
|
||||
* - 对于原始值和数组,直接赋值(数组整体替换,不做元素级 diff)
|
||||
* - 对于普通对象,递归合并,保留 target 中已有的响应式 Proxy
|
||||
* - 跳过值为 undefined 的属性
|
||||
*/
|
||||
function deepSyncState<T extends Record<string, any>>(target: T, source: Partial<T>): void {
|
||||
const keys = Object.keys(source) as (keyof T)[];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (!(key in target) || source[key] === undefined) continue;
|
||||
|
||||
const sourceVal = source[key];
|
||||
const targetVal = target[key];
|
||||
|
||||
// 两边都是普通对象 → 递归合并,保留响应式
|
||||
if (
|
||||
isPlainObject(sourceVal) &&
|
||||
isPlainObject(targetVal) &&
|
||||
!Array.isArray(sourceVal) &&
|
||||
!Array.isArray(targetVal)
|
||||
) {
|
||||
deepSyncState(targetVal as Record<string, any>, sourceVal as Record<string, any>);
|
||||
} else {
|
||||
// 原始值、数组、或类型不匹配 → 直接赋值
|
||||
(target as any)[key] = sourceVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为普通对象(Plain Object)
|
||||
* 排除 Array、Date、RegExp、Map、Set 等内置类型
|
||||
*/
|
||||
function isPlainObject(val: unknown): val is Record<string, any> {
|
||||
if (val === null || typeof val !== 'object') return false;
|
||||
const proto = Object.getPrototypeOf(val);
|
||||
// Object.create(null) 的 proto 为 null,也视为普通对象
|
||||
return proto === null || proto === Object.prototype;
|
||||
}
|
||||
|
||||
export interface Context<T extends Record<string, any>> {
|
||||
/**
|
||||
* 在当前组件及所有子组件中提供 Context 数据
|
||||
*
|
||||
* @param value - 可选的部分更新,会深合并到初始值中
|
||||
* @returns dispose 清理函数,调用后恢复初始值
|
||||
*/
|
||||
provide: (value?: Partial<T>) => () => void;
|
||||
/** 在子组件中消费 Context 数据,返回响应式对象 */
|
||||
useContext: () => T;
|
||||
/** 底层 InjectionKey,用于自定义 provide/inject 场景 */
|
||||
readonly key: InjectionKey<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个 Context 实例
|
||||
*
|
||||
* @param initialValue - 初始状态对象,所有属性将具备响应式能力
|
||||
* @param options.displayName - 可选,用于 DevTools 调试时的标识
|
||||
*/
|
||||
export function createContext<T extends Record<string, any>>(
|
||||
initialValue: T,
|
||||
options?: { displayName?: string },
|
||||
): Context<T> {
|
||||
const name = options?.displayName || 'Context';
|
||||
const key: InjectionKey<T> = Symbol(name) as InjectionKey<T>;
|
||||
|
||||
// 深拷贝初始值,确保源数据不被污染,同时作为后续恢复的快照
|
||||
const snapshot = deepClone(initialValue);
|
||||
// 创建全局响应式容器
|
||||
const initialReactive = reactive(snapshot) as T;
|
||||
|
||||
// 记录当前是否已被 dispose,防止重复调用
|
||||
let disposed = false;
|
||||
|
||||
return {
|
||||
key,
|
||||
|
||||
/**
|
||||
* 在当前组件树中提供 Context
|
||||
* 调用后,所有子组件可通过 useContext() 获取到响应式状态
|
||||
*
|
||||
* @param value - 可选的部分更新,会深合并到初始值中
|
||||
* @returns 清理函数,调用后将状态恢复为 initialValue
|
||||
*/
|
||||
provide(value?: Partial<T>): () => void {
|
||||
disposed = false;
|
||||
|
||||
// 如果传入了 value,深合并到全局 reactive 对象
|
||||
if (value) {
|
||||
deepSyncState(initialReactive, value);
|
||||
}
|
||||
// 通过 Vue 原生 provide 向下注入
|
||||
provide(key, initialReactive);
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
// 恢复初始值
|
||||
deepSyncState(initialReactive, initialValue);
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 在子组件中消费 Context
|
||||
* 必须在 Provider 所在组件的子级 setup 中调用
|
||||
*/
|
||||
useContext(): T {
|
||||
const injected = inject(key);
|
||||
if (!injected) {
|
||||
// 降级返回初始值,保证组件不会崩溃
|
||||
return initialReactive;
|
||||
}
|
||||
return injected;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝普通对象,保留嵌套结构
|
||||
* 仅用于创建初始快照,不处理特殊对象(Date、RegExp 等保持引用)
|
||||
*/
|
||||
function deepClone<T>(obj: T): T {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
|
||||
const result = {} as T;
|
||||
const keys = Object.keys(obj) as (keyof T)[];
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const k = keys[i];
|
||||
result[k] = deepClone(obj[k]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// src/hooks/useDebounce.ts
|
||||
import { watch, onUnmounted } from 'vue';
|
||||
import { useState } from './useState';
|
||||
|
||||
export interface UseDebounceOptions {
|
||||
delay?: number;
|
||||
immediate?: boolean;
|
||||
maxWait?: number;
|
||||
}
|
||||
|
||||
export function useDebounce<T>(
|
||||
source: any, // Ref<T>
|
||||
options: number | UseDebounceOptions = {},
|
||||
) {
|
||||
const config = typeof options === 'number' ? { delay: options } : options;
|
||||
const { delay = 300, immediate = false, maxWait } = config;
|
||||
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
|
||||
let timer: any = null;
|
||||
let maxWaitTimer: any = null;
|
||||
let isFirstCall = true; // 标记是否为首次调用,用于 immediate 模式
|
||||
|
||||
// 清理
|
||||
const clearTimers = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (maxWaitTimer) {
|
||||
clearTimeout(maxWaitTimer);
|
||||
maxWaitTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 更新
|
||||
const updateValue = () => {
|
||||
setDebouncedValue(source.value);
|
||||
setIsPending(false);
|
||||
clearTimers();
|
||||
// 安静期结束后重置 isFirstCall,使下次触发能立即执行(immediate 模式)
|
||||
isFirstCall = true;
|
||||
};
|
||||
|
||||
watch(source, () => {
|
||||
setIsPending(true);
|
||||
|
||||
// 处理 maxWait
|
||||
if (maxWait && !maxWaitTimer) {
|
||||
maxWaitTimer = setTimeout(() => {
|
||||
updateValue();
|
||||
}, maxWait);
|
||||
}
|
||||
|
||||
// 处理 delay
|
||||
if (timer) clearTimeout(timer);
|
||||
|
||||
if (immediate && isFirstCall) {
|
||||
isFirstCall = false;
|
||||
updateValue();
|
||||
} else {
|
||||
timer = setTimeout(() => {
|
||||
updateValue();
|
||||
}, delay);
|
||||
}
|
||||
});
|
||||
|
||||
// 卸载
|
||||
onUnmounted(() => {
|
||||
clearTimers();
|
||||
});
|
||||
|
||||
return {
|
||||
debouncedValue,
|
||||
isPending,
|
||||
refresh: updateValue,
|
||||
cancel: clearTimers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { watchEffect, watch, WatchSource, isRef, isReactive } from 'vue';
|
||||
|
||||
export function useEffect(effect: () => void | (() => void), deps?: WatchSource<any>[]) {
|
||||
if (!deps) {
|
||||
return watchEffect((onCleanup) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) onCleanup(cleanup);
|
||||
});
|
||||
}
|
||||
|
||||
const validDeps = deps.map((dep) => {
|
||||
if (dep === null || dep === undefined) {
|
||||
return () => null;
|
||||
}
|
||||
if (isRef(dep) || isReactive(dep) || typeof dep === 'function') {
|
||||
return dep;
|
||||
}
|
||||
return () => dep;
|
||||
});
|
||||
|
||||
return watch(
|
||||
validDeps,
|
||||
() => {
|
||||
const cleanup = effect();
|
||||
return cleanup;
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
flush: 'post',
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
// src/hooks/usePagination.ts
|
||||
import { computed, ref, watch, Ref, triggerRef } from 'vue';
|
||||
|
||||
/**
|
||||
* 分页配置项
|
||||
*/
|
||||
export interface UsePaginationOptions {
|
||||
/** 当前页码 (支持 v-model 双向绑定) */
|
||||
current?: number;
|
||||
/** 每页条数 (支持 v-model 双向绑定) */
|
||||
pageSize?: number;
|
||||
/** 数据总条数 (支持 v-model 双向绑定) */
|
||||
total?: number;
|
||||
/** 默认页码 (非受控模式下使用) */
|
||||
defaultCurrent?: number;
|
||||
/** 默认每页条数 (非受控模式下使用) */
|
||||
defaultPageSize?: number;
|
||||
/** 每页条数切换选项 (用于生成下拉菜单) */
|
||||
pageSizeOptions?: number[];
|
||||
/** 是否显示快速跳转 (可选功能) */
|
||||
showQuickJumper?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页操作 API
|
||||
*/
|
||||
export interface UsePaginationActions {
|
||||
/** 设置当前页 */
|
||||
setCurrent: (current: number) => void;
|
||||
/** 设置每页条数 (会自动重置页码为 1) */
|
||||
setPageSize: (size: number) => void;
|
||||
/** 设置总条数 */
|
||||
setTotal: (total: number) => void;
|
||||
/** 下一页 */
|
||||
next: () => void;
|
||||
/** 上一页 */
|
||||
prev: () => void;
|
||||
/** 跳转到指定页 */
|
||||
jump: (page: number) => void;
|
||||
/** 重置到初始状态 */
|
||||
reset: () => void;
|
||||
/** 强制刷新 (用于某些极端响应式场景) */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页计算属性
|
||||
*/
|
||||
export interface UsePaginationComputed {
|
||||
/** 响应式请求参数对象 (给 API 用的) */
|
||||
params: Ref<{ page: number; pageSize: number }>;
|
||||
/** 总页数 */
|
||||
totalPages: Ref<number>;
|
||||
/** 是否有上一页 */
|
||||
hasPrev: Ref<boolean>;
|
||||
/** 是否有下一页 */
|
||||
hasNext: Ref<boolean>;
|
||||
/** 当前页起始索引 (用于显示 "显示 1-10 条") */
|
||||
startIndex: Ref<number>;
|
||||
/** 当前页结束索引 */
|
||||
endIndex: Ref<number>;
|
||||
/** 是否为第一页 */
|
||||
isFirstPage: Ref<boolean>;
|
||||
/** 是否为最后一页 */
|
||||
isLastPage: Ref<boolean>;
|
||||
}
|
||||
|
||||
export type UsePaginationReturn = UsePaginationOptions &
|
||||
UsePaginationActions &
|
||||
UsePaginationComputed;
|
||||
|
||||
export function usePagination(options: UsePaginationOptions = {}): UsePaginationReturn {
|
||||
const { defaultCurrent = 1, defaultPageSize = 10, pageSizeOptions = [10, 20, 50, 100] } = options;
|
||||
|
||||
const currentRef = ref(options.current ?? defaultCurrent);
|
||||
const pageSizeRef = ref(options.pageSize ?? defaultPageSize);
|
||||
const totalRef = ref(options.total ?? 0);
|
||||
|
||||
watch(
|
||||
() => options.current,
|
||||
(val) => {
|
||||
if (val !== undefined) currentRef.value = val;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => options.pageSize,
|
||||
(val) => {
|
||||
if (val !== undefined) pageSizeRef.value = val;
|
||||
},
|
||||
);
|
||||
watch(
|
||||
() => options.total,
|
||||
(val) => {
|
||||
if (val !== undefined) totalRef.value = val;
|
||||
},
|
||||
);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
const total = totalRef.value;
|
||||
const size = pageSizeRef.value;
|
||||
return size === 0 ? 0 : Math.ceil(total / size);
|
||||
});
|
||||
|
||||
const hasPrev = computed(() => currentRef.value > 1);
|
||||
const hasNext = computed(() => currentRef.value < totalPages.value);
|
||||
const isFirstPage = computed(() => currentRef.value === 1);
|
||||
const isLastPage = computed(() => currentRef.value === totalPages.value);
|
||||
|
||||
const startIndex = computed(() =>
|
||||
totalPages.value === 0 ? 0 : (currentRef.value - 1) * pageSizeRef.value + 1,
|
||||
);
|
||||
|
||||
const endIndex = computed(() => Math.min(currentRef.value * pageSizeRef.value, totalRef.value));
|
||||
|
||||
const params = computed(() => ({
|
||||
page: currentRef.value,
|
||||
pageSize: pageSizeRef.value,
|
||||
}));
|
||||
|
||||
const setCurrent = (page: number) => {
|
||||
const safePage = Math.max(1, Math.min(page, totalPages.value || 1));
|
||||
if (currentRef.value !== safePage) {
|
||||
currentRef.value = safePage;
|
||||
}
|
||||
};
|
||||
|
||||
const setPageSize = (size: number) => {
|
||||
if (pageSizeRef.value !== size) {
|
||||
pageSizeRef.value = size;
|
||||
currentRef.value = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setTotal = (total: number) => {
|
||||
totalRef.value = total;
|
||||
const maxPage = Math.max(1, Math.ceil(total / pageSizeRef.value));
|
||||
if (currentRef.value > maxPage) {
|
||||
currentRef.value = maxPage;
|
||||
}
|
||||
};
|
||||
|
||||
const next = () => setCurrent(currentRef.value + 1);
|
||||
const prev = () => setCurrent(currentRef.value - 1);
|
||||
|
||||
const jump = (page: number) => {
|
||||
if (!Number.isInteger(page) || page < 1) return;
|
||||
setCurrent(page);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
currentRef.value = defaultCurrent;
|
||||
pageSizeRef.value = defaultPageSize;
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
triggerRef(currentRef);
|
||||
};
|
||||
|
||||
return {
|
||||
// @ts-ignore
|
||||
current: currentRef,
|
||||
// @ts-ignore
|
||||
pageSize: pageSizeRef,
|
||||
// @ts-ignore
|
||||
total: totalRef,
|
||||
pageSizeOptions,
|
||||
params,
|
||||
totalPages,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
isFirstPage,
|
||||
isLastPage,
|
||||
startIndex,
|
||||
endIndex,
|
||||
setCurrent,
|
||||
setPageSize,
|
||||
setTotal,
|
||||
next,
|
||||
prev,
|
||||
jump,
|
||||
reset,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
import { ref, Ref, watch, WatchSource, onUnmounted, computed } from 'vue';
|
||||
|
||||
export interface UseRequestReturn<T> {
|
||||
/** 纯净的数据,不包含后端外层壳 */
|
||||
data: Ref<T | undefined>;
|
||||
/** 加载状态 */
|
||||
loading: Ref<boolean>;
|
||||
/** 错误对象 */
|
||||
error: Ref<Error | null>;
|
||||
/** 手动触发请求 */
|
||||
run: (...args: any[]) => Promise<T | undefined>;
|
||||
/** 取消请求 */
|
||||
cancel: () => void;
|
||||
/** 重置数据 */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function useRequest<T>(
|
||||
fetcher: (...args: any[]) => Promise<any>,
|
||||
options: {
|
||||
/** 是否手动触发,默认 false (即自动触发) */
|
||||
manual?: boolean;
|
||||
/** 初始数据 */
|
||||
initialData?: T;
|
||||
/** 依赖数组,变化时重新请求 */
|
||||
refreshDeps?: WatchSource[];
|
||||
/**
|
||||
* 数据格式化函数
|
||||
* 默认逻辑:如果返回是对象且有 data 属性,则返回 res.data,否则返回原数据
|
||||
*/
|
||||
formatResult?: (res: any) => T;
|
||||
/** 是否准备好可以发起请求,默认 true */
|
||||
ready?: Ref<boolean>;
|
||||
} = {},
|
||||
): UseRequestReturn<T> & {
|
||||
/** 获取当前 AbortController 的 signal,可传递给 fetcher */
|
||||
getSignal: () => AbortSignal | undefined;
|
||||
} {
|
||||
const {
|
||||
manual = false,
|
||||
initialData,
|
||||
refreshDeps,
|
||||
formatResult = (res) => (res && typeof res === 'object' && 'data' in res ? res.data : res),
|
||||
ready = ref(true),
|
||||
} = options;
|
||||
|
||||
const data = ref<T | undefined>(initialData);
|
||||
const loading = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
let abortController: AbortController | null = null;
|
||||
let isUnmounted = false;
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true;
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const run = async (...args: any[]): Promise<T | undefined> => {
|
||||
if (isUnmounted) return undefined;
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
abortController = new AbortController();
|
||||
const currentSignal = abortController.signal;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// 将 signal 作为最后一个参数传递给 fetcher
|
||||
// 调用方可以通过最后一个参数获取 signal: async (...args, { signal }) => {...}
|
||||
const res = await fetcher(...args, { signal: currentSignal });
|
||||
|
||||
if (currentSignal.aborted || isUnmounted) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const formattedData = formatResult(res);
|
||||
|
||||
if (!isUnmounted) {
|
||||
data.value = formattedData;
|
||||
}
|
||||
|
||||
return formattedData;
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isUnmounted) {
|
||||
error.value = e;
|
||||
}
|
||||
return undefined;
|
||||
} finally {
|
||||
// 只有当前请求未被取消时才重置 loading
|
||||
if (!currentSignal.aborted && !isUnmounted) {
|
||||
loading.value = false;
|
||||
}
|
||||
// 如果这是当前活动的 controller,清理引用
|
||||
if (abortController?.signal === currentSignal) {
|
||||
abortController = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
loading.value = false;
|
||||
abortController = null;
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
cancel();
|
||||
data.value = initialData;
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
// 使用 shouldAutoRun 统一管理自动请求,避免 refreshDeps 和 immediate watch 重复触发 run
|
||||
const shouldAutoRun = computed(() => !manual && ready.value);
|
||||
let hasAutoRun = false; // 标记是否已自动执行过,防止重复触发
|
||||
|
||||
watch(
|
||||
shouldAutoRun,
|
||||
(val) => {
|
||||
if (val && !hasAutoRun) {
|
||||
hasAutoRun = true;
|
||||
run();
|
||||
} else if (!val) {
|
||||
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
|
||||
hasAutoRun = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// refreshDeps 变化时重新请求(初始化时不再重复触发)
|
||||
if (refreshDeps && refreshDeps.length > 0) {
|
||||
watch(
|
||||
refreshDeps,
|
||||
() => {
|
||||
if (shouldAutoRun.value) {
|
||||
run();
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
}
|
||||
|
||||
const getSignal = () => abortController?.signal;
|
||||
|
||||
return {
|
||||
// @ts-ignore
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
run,
|
||||
cancel,
|
||||
reset,
|
||||
getSignal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ref, Ref } from 'vue';
|
||||
/**
|
||||
* 返回一个 [state, setState] 元组
|
||||
*/
|
||||
export function useState<T>(initialValue: T): [Ref<T>, (newVal: T) => void] {
|
||||
const state: any = ref<T>(initialValue);
|
||||
|
||||
const setState = (newVal: T) => {
|
||||
if (typeof newVal === 'function') {
|
||||
state.value = (newVal as (prev: T) => T)(state.value);
|
||||
} else {
|
||||
state.value = newVal;
|
||||
}
|
||||
};
|
||||
|
||||
return [state, setState];
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ref, watch, onBeforeUnmount, toValue } from 'vue';
|
||||
import type { Ref, MaybeRefOrGetter } from 'vue';
|
||||
|
||||
// --- 类型定义 ---
|
||||
export type WebSocketStatus = 'connecting' | 'open' | 'closed' | 'error';
|
||||
|
||||
export interface UseWebSocketOptions {
|
||||
// 是否自动连接
|
||||
autoConnect?: boolean;
|
||||
// 子协议
|
||||
protocols?: string[];
|
||||
|
||||
// --- 重连配置 ---
|
||||
reconnectLimit?: number; // 最大重连次数,默认 -1 (无限)
|
||||
reconnectInterval?: number; // 初始重连间隔 ms
|
||||
reconnectIncrement?: number; // 每次重连增加的间隔 ms (用于指数退避)
|
||||
|
||||
// --- 心跳配置 ---
|
||||
heartbeat?: boolean; // 是否开启心跳
|
||||
heartbeatInterval?: number; // 心跳间隔 ms
|
||||
heartbeatMessage?: any; // 心跳发送的数据
|
||||
|
||||
// --- 回调钩子 ---
|
||||
onOpen?: (event: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onMessage?: (data: any, event: MessageEvent) => void;
|
||||
onError?: (event: Event) => void;
|
||||
}
|
||||
|
||||
// --- 返回值接口 ---
|
||||
export interface UseWebSocketReturn {
|
||||
status: Ref<WebSocketStatus>;
|
||||
latestData: Ref<any>; // 最新接收到的数据
|
||||
send: (data: any) => void; // 发送方法
|
||||
connect: () => void; // 手动连接
|
||||
disconnect: () => void; // 手动断开
|
||||
}
|
||||
|
||||
/**
|
||||
* useWebSocket Hook
|
||||
* 仿照 ahooks 设计,支持动态配置、指数退避重连、心跳检测
|
||||
*/
|
||||
// 模块级变量,替代 window 全局挂载,避免全局污染和内存泄漏
|
||||
const webSocketList: WebSocket[] = [];
|
||||
|
||||
export function useWebSocket(
|
||||
url: MaybeRefOrGetter<string>,
|
||||
options: UseWebSocketOptions = {},
|
||||
): UseWebSocketReturn {
|
||||
const {
|
||||
autoConnect = true,
|
||||
protocols = [],
|
||||
reconnectLimit = -1,
|
||||
reconnectInterval = 1000,
|
||||
reconnectIncrement = 1000,
|
||||
heartbeat = true,
|
||||
heartbeatInterval = 30000,
|
||||
heartbeatMessage = 'ping',
|
||||
onOpen,
|
||||
onClose,
|
||||
onMessage,
|
||||
onError,
|
||||
} = options;
|
||||
|
||||
// --- 响应式状态 ---
|
||||
const status = ref<WebSocketStatus>('closed');
|
||||
const latestData = ref<any>(null);
|
||||
const wsRef = ref<WebSocket | null>(null);
|
||||
|
||||
// --- 内部变量 ---
|
||||
let reconnectCount = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
// 清除所有定时器
|
||||
const clearTimers = () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||
};
|
||||
|
||||
// 启动心跳
|
||||
const startHeartbeat = () => {
|
||||
if (!heartbeat) return;
|
||||
stopHeartbeat(); // 防止重复启动
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
|
||||
wsRef.value.send(
|
||||
typeof heartbeatMessage === 'string'
|
||||
? heartbeatMessage
|
||||
: JSON.stringify(heartbeatMessage),
|
||||
);
|
||||
}
|
||||
}, heartbeatInterval);
|
||||
};
|
||||
|
||||
// 停止心跳
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer);
|
||||
heartbeatTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 建立连接的核心逻辑
|
||||
const connect = () => {
|
||||
// 如果已经有连接且是开启状态,不重复创建
|
||||
if (wsRef.value?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
// 关闭旧连接(如果有)
|
||||
if (wsRef.value) {
|
||||
wsRef.value.close();
|
||||
wsRef.value = null;
|
||||
}
|
||||
|
||||
status.value = 'connecting';
|
||||
|
||||
try {
|
||||
// 使用 toValue 支持响应式 URL
|
||||
const wsUrl = toValue(url);
|
||||
wsRef.value = new WebSocket(wsUrl, protocols);
|
||||
|
||||
// 追踪所有 WebSocket 实例用于调试和清理(使用模块级变量,避免全局污染)
|
||||
webSocketList.push(wsRef.value);
|
||||
|
||||
wsRef.value.onopen = (event) => {
|
||||
status.value = 'open';
|
||||
reconnectCount = 0; // 重置重连计数
|
||||
startHeartbeat(); // 启动心跳
|
||||
onOpen?.(event);
|
||||
};
|
||||
|
||||
wsRef.value.onmessage = (event) => {
|
||||
// 简单的心跳回显处理:如果收到的消息等于发送的心跳消息,则忽略(或者根据业务协议处理 pong)
|
||||
// 这里假设服务端会原样返回 ping 或者返回特定的 pong
|
||||
// 实际项目中建议检查 event.data
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
latestData.value = data;
|
||||
onMessage?.(data, event);
|
||||
} catch (e) {
|
||||
latestData.value = event.data;
|
||||
onMessage?.(event.data, event);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.value.onerror = (event) => {
|
||||
status.value = 'error';
|
||||
onError?.(event);
|
||||
};
|
||||
|
||||
wsRef.value.onclose = (event) => {
|
||||
status.value = 'closed';
|
||||
stopHeartbeat();
|
||||
onClose?.(event);
|
||||
|
||||
// 触发重连逻辑
|
||||
handleReconnect();
|
||||
};
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
status.value = 'error';
|
||||
onError?.(err as Event);
|
||||
handleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理重连(指数退避策略)
|
||||
const handleReconnect = () => {
|
||||
// 如果是用户手动调用的 disconnect,不应该重连(可以通过标记位控制,这里简化处理:只要没达到限制就重连)
|
||||
// 如果需要彻底断开,请调用 disconnect()
|
||||
|
||||
if (reconnectLimit !== -1 && reconnectCount >= reconnectLimit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算延迟时间:线性增长 1s, 2s, 3s, 4s... (最大 10s)
|
||||
const delay = Math.min(10000, reconnectInterval + reconnectCount * reconnectIncrement);
|
||||
|
||||
reconnectCount++;
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const send = (data: any) => {
|
||||
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
|
||||
const strData = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
wsRef.value.send(strData);
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,无法发送消息');
|
||||
}
|
||||
};
|
||||
|
||||
// 手动断开(通常意味着不再自动重连,除非再次调用 connect)
|
||||
const disconnect = () => {
|
||||
reconnectCount = reconnectLimit; // 设置计数为最大值,阻止后续重连逻辑
|
||||
clearTimers();
|
||||
if (wsRef.value) {
|
||||
// 从模块级列表中移除这个实例
|
||||
const index = webSocketList.indexOf(wsRef.value);
|
||||
if (index > -1) {
|
||||
webSocketList.splice(index, 1);
|
||||
}
|
||||
wsRef.value.close();
|
||||
wsRef.value = null;
|
||||
}
|
||||
status.value = 'closed';
|
||||
};
|
||||
|
||||
// --- 监听与副作用 ---
|
||||
|
||||
// 监听 URL 变化,如果变了则重新连接
|
||||
watch(
|
||||
() => toValue(url),
|
||||
() => {
|
||||
if (status.value !== 'closed') {
|
||||
connect();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onBeforeUnmount(() => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
// 初始化:如果不是手动模式,则自动连接
|
||||
if (autoConnect) {
|
||||
connect();
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
latestData,
|
||||
send,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user