feat: 添加自定义导航栏组件和相关 composables

This commit is contained in:
Ray.Hao
2026-03-06 13:24:34 +08:00
parent 5bf7f0561f
commit da47f98fe9
30 changed files with 2831 additions and 551 deletions

View File

@@ -0,0 +1,141 @@
import { ref, computed } from "vue"
/**
* 加载状态管理
*
* 提供全局或局部的加载状态管理,支持多个加载任务的并发控制。
*
* @example
* ```ts
* // 基本使用
* const loading = useLoading()
* loading.start()
* await fetchData()
* loading.stop()
*
* // 带消息
* loading.start('正在加载数据...')
*
* // 异步包装
* const result = await loading.wrap(fetchData())
*
* // 独立实例
* const localLoading = useLoading({ singleton: false })
* ```
*/
export interface UseLoadingOptions {
/** 是否使用全局单例,默认 true */
singleton?: boolean
/** 初始加载状态,默认 false */
initial?: boolean
/** 加载提示消息 */
message?: string
}
export interface UseLoadingReturn {
/** 是否处于加载状态 */
isLoading: ReturnType<typeof computed<boolean>>
/** 加载计数(支持并发) */
loadingCount: ReturnType<typeof ref<number>>
/** 当前加载消息 */
message: ReturnType<typeof ref<string>>
/** 开始加载 */
start: (msg?: string) => void
/** 结束加载 */
stop: () => void
/** 包装异步函数,自动管理加载状态 */
wrap: <T>(promise: Promise<T>) => Promise<T>
/** 设置加载消息 */
setMessage: (msg: string) => void
}
/**
* 创建加载状态实例
*/
function createLoadingState(options: UseLoadingOptions = {}) {
const { initial = false, message: initialMessage = "" } = options
const loadingCount = ref(0)
const message = ref(initialMessage)
const isLoading = computed(() => loadingCount.value > 0)
/**
* 开始加载
* @param msg 加载提示消息
*/
const start = (msg?: string): void => {
loadingCount.value++
if (msg) {
message.value = msg
}
}
/**
* 结束加载
*/
const stop = (): void => {
if (loadingCount.value > 0) {
loadingCount.value--
}
if (loadingCount.value === 0) {
message.value = ""
}
}
/**
* 包装异步函数,自动管理加载状态
* @param promise 异步 Promise
*/
const wrap = async <T>(promise: Promise<T>): Promise<T> => {
try {
start()
return await promise
} finally {
stop()
}
}
/**
* 设置加载消息
* @param msg 消息内容
*/
const setMessage = (msg: string): void => {
message.value = msg
}
return {
isLoading,
loadingCount,
message,
start,
stop,
wrap,
setMessage,
}
}
/** 全局单例实例 */
let loadingInstance: UseLoadingReturn | null = null
/**
* 加载状态管理 Hook
*
* 默认使用全局单例模式,确保整个应用共享同一加载状态。
* 如需独立状态(如组件内部的局部加载),可传入 `{ singleton: false }`。
*
* @param options 配置选项
*/
export function useLoading(options?: UseLoadingOptions): UseLoadingReturn {
const { singleton = true } = options ?? {}
if (singleton) {
if (!loadingInstance) {
loadingInstance = createLoadingState(options)
}
return loadingInstance
}
return createLoadingState(options)
}

View File

@@ -0,0 +1,242 @@
import { ref, computed, onMounted } from "vue";
/**
* 微信小程序导航栏高度计算
*
* 参考 uni-ui 官方实现https://github.com/dcloudio/uni-ui
*
* 重要说明:
* 1. CSS 变量 --status-bar-height 在某些情况下不准确(小程序端固定 25px
* 2. 推荐使用 uni.getSystemInfoSync().statusBarHeight 动态获取
* 3. 微信小程序需要考虑胶囊按钮位置
* 4. H5 没有状态栏statusBarHeight 为 0
*
* 导航栏结构:
* ┌─────────────────────────────┐
* │ 状态栏区域 │ statusBarHeight
* ├─────────────────────────────┤
* │ 上边距 │ menu.top - statusBarHeight
* ├─────────────────────────────┤
* │ 胶囊按钮 │ menu.height
* ├─────────────────────────────┤
* │ 下边距(与上边距相等) │
* └─────────────────────────────┘
*
* 导航栏高度计算公式uni-ui 官方):
* navBarHeight = menu.height + (menu.top - statusBarHeight) * 2
* totalHeight = statusBarHeight + navBarHeight
*/
interface MenuButtonRect {
width: number;
height: number;
top: number;
right: number;
bottom: number;
left: number;
}
export interface UseNavbarOptions {
/** 导航栏内容高度,默认 44 */
navBarHeight?: number;
/** 是否有 TabBar默认 false */
hasTabbar?: boolean;
}
export interface UseNavbarReturn {
// 高度信息
statusBarHeight: Ref<number>;
navBarHeight: number;
totalHeight: ComputedRef<number>;
contentPaddingTop: ComputedRef<string>;
safeAreaBottom: Ref<number>;
// 胶囊按钮信息
menuButton: Ref<MenuButtonRect | null>;
menuButtonRightGap: ComputedRef<number>;
menuButtonLeft: ComputedRef<number>;
menuButtonWidth: ComputedRef<number>;
contentWidth: ComputedRef<number>;
// 平台信息
platform: Ref<string>;
windowWidth: Ref<number>;
// 方法
init: () => void;
}
/**
* 导航栏高度计算 Hook
*
* @param options 配置选项
*/
export function useNavbar(options: UseNavbarOptions = {}): UseNavbarReturn {
const { navBarHeight = 44, hasTabbar = false } = options;
// 状态栏高度
const statusBarHeight = ref(0);
// 胶囊按钮信息
const menuButton = ref<MenuButtonRect | null>(null);
// 安全区域底部高度
const safeAreaBottom = ref(0);
// 窗口宽度
const windowWidth = ref(375);
// 平台标识
const platform = ref("");
// 计算导航栏总高度(参考 uni-ui 官方实现)
const totalHeight = computed(() => {
// #ifdef MP-WEIXIN
// 微信小程序:使用胶囊按钮精确计算
if (menuButton.value && menuButton.value.height > 0 && menuButton.value.top > 0) {
const { top, height } = menuButton.value;
// 导航栏高度 = 胶囊按钮高度 + 上下边距
// 上下边距 = (胶囊按钮top - 状态栏高度) * 2
const spaceHeight = top - statusBarHeight.value;
return statusBarHeight.value + height + spaceHeight * 2;
}
// #endif
// H5 / App状态栏高度 + 导航栏高度
return statusBarHeight.value + navBarHeight;
});
// 页面内容区域的 paddingTop用于避开固定导航栏
const contentPaddingTop = computed(() => `${totalHeight.value}px`);
// 胶囊按钮右侧距离屏幕右边的距离(用于避开胶囊按钮)
const menuButtonRightGap = computed(() => {
// #ifdef MP-WEIXIN
if (menuButton.value && menuButton.value.right > 0) {
return windowWidth.value - menuButton.value.right;
}
// #endif
// H5 / App 不需要避让胶囊
return 10;
});
// 胶囊按钮左侧距离屏幕左边的距离
const menuButtonLeft = computed(() => {
// #ifdef MP-WEIXIN
if (menuButton.value) {
return menuButton.value.left;
}
// #endif
return 0;
});
// 胶囊按钮宽度
const menuButtonWidth = computed(() => {
// #ifdef MP-WEIXIN
if (menuButton.value) {
return menuButton.value.width;
}
// #endif
return 0;
});
// 页面内容区域可用宽度(避开胶囊按钮)
const contentWidth = computed(() => {
// #ifdef MP-WEIXIN
if (menuButton.value) {
// 内容区域宽度 = 胶囊按钮左侧距离 - 间距
const gap = menuButtonRightGap.value;
return menuButton.value.left - gap;
}
// #endif
return windowWidth.value;
});
// #ifdef MP-WEIXIN
const isValidMenuButtonRect = (rect: ReturnType<typeof uni.getMenuButtonBoundingClientRect>) => {
return !!rect && rect.width > 0 && rect.height > 0 && rect.top > 0 && rect.right > 0;
};
const setMenuButtonRectWithRetry = (maxRetry = 6, delayMs = 50) => {
try {
const rect = uni.getMenuButtonBoundingClientRect();
if (isValidMenuButtonRect(rect)) {
menuButton.value = {
width: rect.width,
height: rect.height,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
};
return;
}
if (maxRetry > 0) {
setTimeout(() => {
setMenuButtonRectWithRetry(maxRetry - 1, delayMs);
}, delayMs);
} else {
console.warn("getMenuButtonBoundingClientRect 返回值无效,使用默认值");
}
} catch (e) {
console.warn("获取胶囊按钮位置失败,使用默认值", e);
}
};
// #endif
// 初始化
const init = () => {
try {
// 优先使用新版 APIuni.getWindowInfo降级到 uni.getSystemInfoSync
let systemInfo: UniApp.GetSystemInfoSyncSuccess;
try {
// @ts-expect-error uni.getWindowInfo 是新版 API
systemInfo = uni.getWindowInfo?.() || uni.getSystemInfoSync();
} catch {
systemInfo = uni.getSystemInfoSync();
}
statusBarHeight.value = systemInfo.statusBarHeight || 0;
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom || 0;
windowWidth.value = systemInfo.windowWidth || 375;
platform.value = systemInfo.platform || "";
// #ifdef MP-WEIXIN
// 微信小程序:获取胶囊按钮位置
setMenuButtonRectWithRetry();
// #endif
} catch (e) {
console.warn("获取系统信息失败", e);
}
};
// 在 onMounted 中调用,确保页面渲染完成
onMounted(() => {
init();
});
return {
// 高度信息
statusBarHeight,
navBarHeight,
totalHeight,
contentPaddingTop,
safeAreaBottom,
// 胶囊按钮信息
menuButton,
menuButtonRightGap,
menuButtonLeft,
menuButtonWidth,
contentWidth,
// 平台信息
platform,
windowWidth,
// 方法
init,
};
}
// 类型导出
type Ref<T> = import("vue").Ref<T>;
type ComputedRef<T> = import("vue").ComputedRef;

View File

@@ -0,0 +1,302 @@
import { ref, computed, shallowRef, type Ref, type ComputedRef } from "vue"
/**
* 请求状态管理
*
* 封装异步请求的加载状态、错误处理和数据缓存,提供统一的请求管理方式。
*
* @example
* ```ts
* const { data, loading, error, execute } = useRequest(
* () => UserAPI.getUserInfo()
* )
*
* // 立即执行
* const { data } = useRequest(() => UserAPI.getUserInfo(), { immediate: true })
*
* // 手动执行
* await execute()
*
* // 刷新数据
* await refresh()
* ```
*/
export interface UseRequestOptions<T> {
/** 是否立即执行,默认 false */
immediate?: boolean
/** 初始数据 */
initialData?: T | null
/** 请求前的回调 */
onBefore?: () => void
/** 请求成功的回调 */
onSuccess?: (data: T) => void
/** 请求失败的回调 */
onError?: (error: Error) => void
/** 请求完成的回调(无论成功失败) */
onFinally?: () => void
/** 是否重置数据在重新请求时,默认 false */
resetOnExecute?: boolean
}
export interface UseRequestReturn<T> {
/** 响应数据 */
data: Ref<T | null>
/** 是否正在加载 */
loading: ComputedRef<boolean>
/** 错误信息 */
error: Ref<Error | null>
/** 是否已请求过(用于区分初始状态和请求后状态) */
hasExecuted: Ref<boolean>
/** 执行请求 */
execute: () => Promise<T | null>
/** 刷新数据(重新执行请求) */
refresh: () => Promise<T | null>
/** 重置状态 */
reset: () => void
}
/**
* 请求状态管理 Hook
*
* @param requestFn 请求函数
* @param options 配置选项
*/
export function useRequest<T>(
requestFn: () => Promise<T>,
options: UseRequestOptions<T> = {}
): UseRequestReturn<T> {
const {
immediate = false,
initialData = null,
onBefore,
onSuccess,
onError,
onFinally,
resetOnExecute = false,
} = options
// 使用 shallowRef 避免深层响应式,提升性能
const data = shallowRef<T | null>(initialData) as Ref<T | null>
const error = ref<Error | null>(null)
const loadingCount = ref(0)
const hasExecuted = ref(false)
const loading = computed(() => loadingCount.value > 0)
/**
* 执行请求
*/
const execute = async (): Promise<T | null> => {
// 重置状态
if (resetOnExecute) {
data.value = initialData
error.value = null
}
loadingCount.value++
hasExecuted.value = true
try {
onBefore?.()
const result = await requestFn()
data.value = result
error.value = null
onSuccess?.(result)
return result
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
onError?.(err)
return null
} finally {
loadingCount.value--
onFinally?.()
}
}
/**
* 刷新数据
*/
const refresh = async (): Promise<T | null> => {
return execute()
}
/**
* 重置状态
*/
const reset = (): void => {
data.value = initialData
error.value = null
loadingCount.value = 0
hasExecuted.value = false
}
// 立即执行
if (immediate) {
execute()
}
return {
data,
loading,
error,
hasExecuted,
execute,
refresh,
reset,
}
}
/**
* 分页请求状态管理
*
* @example
* ```ts
* const { list, loading, loadMore, refresh, hasMore } = usePagination(
* (params) => UserAPI.getUserPage({ ...params, status: 1 }),
* { pageSize: 20 }
* )
*
* // 加载更多
* await loadMore()
*
* // 刷新
* await refresh()
* ```
*/
export interface PaginationParams {
pageNum: number
pageSize: number
}
export interface UsePaginationOptions<T> {
/** 每页条数,默认 10 */
pageSize?: number
/** 初始数据 */
initialData?: T[]
/** 请求成功的回调 */
onSuccess?: (list: T[], total: number) => void
/** 请求失败的回调 */
onError?: (error: Error) => void
}
export interface UsePaginationReturn<T> {
/** 数据列表 */
list: Ref<T[]>
/** 是否正在加载 */
loading: ComputedRef<boolean>
/** 错误信息 */
error: Ref<Error | null>
/** 是否还有更多数据 */
hasMore: ComputedRef<boolean>
/** 当前页码 */
pageNum: Ref<number>
/** 总条数 */
total: Ref<number>
/** 是否为空 */
isEmpty: ComputedRef<boolean>
/** 加载更多 */
loadMore: () => Promise<T[]>
/** 刷新(从第一页开始) */
refresh: () => Promise<T[]>
/** 重置 */
reset: () => void
}
/**
* 分页请求 Hook
*
* @param requestFn 分页请求函数
* @param options 配置选项
*/
export function usePagination<T>(
requestFn: (params: PaginationParams) => Promise<PageResult<T[]>>,
options: UsePaginationOptions<T> = {}
): UsePaginationReturn<T> {
const { pageSize = 10, initialData = [], onSuccess, onError } = options
const list = ref<T[]>(initialData) as Ref<T[]>
const loadingCount = ref(0)
const error = ref<Error | null>(null)
const pageNum = ref(1)
const total = ref(0)
const loading = computed(() => loadingCount.value > 0)
const hasMore = computed(() => list.value.length < total.value)
const isEmpty = computed(() => list.value.length === 0 && !loading.value)
/**
* 加载更多
*/
const loadMore = async (): Promise<T[]> => {
// 如果正在加载或没有更多数据,直接返回
if (loading.value || !hasMore.value) {
return list.value
}
loadingCount.value++
try {
const result = await requestFn({
pageNum: pageNum.value,
pageSize,
})
// 如果是第一页,替换数据;否则追加数据
if (pageNum.value === 1) {
list.value = result.list
} else {
list.value = [...list.value, ...result.list]
}
total.value = result.total
pageNum.value++
onSuccess?.(result.list, result.total)
return result.list
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e))
error.value = err
onError?.(err)
return []
} finally {
loadingCount.value--
}
}
/**
* 刷新(从第一页开始)
*/
const refresh = async (): Promise<T[]> => {
pageNum.value = 1
total.value = 0
list.value = []
return loadMore()
}
/**
* 重置
*/
const reset = (): void => {
list.value = initialData
loadingCount.value = 0
error.value = null
pageNum.value = 1
total.value = 0
}
return {
list,
loading,
error,
hasMore,
pageNum,
total,
isEmpty,
loadMore,
refresh,
reset,
}
}

View File

@@ -1,46 +1,104 @@
export interface TabbarItem {
name: string;
value: number | null;
active: boolean;
title: string;
icon: string;
import { ref, computed } from "vue"
/**
* TabBar 状态管理
*
* 提供底部导航栏的状态管理功能,包括激活状态切换、徽标数字设置等。
*
* @example
* ```ts
* const { tabbarList, setTabbarItemActive, setTabbarItem } = useTabbar()
*
* // 切换到工作台
* setTabbarItemActive('work')
*
* // 设置徽标数字
* setTabbarItem('work', 5)
* ```
*/
export interface TabbarItem {
/** 唯一标识 */
name: string
/** 徽标数字null 表示不显示 */
value: number | null
/** 是否激活 */
active: boolean
/** 显示标题 */
title: string
/** 图标名称 */
icon: string
}
const tabbarItems = ref<TabbarItem[]>([
{ name: "home", value: null, active: true, title: "首页", icon: "home" },
{ name: "work", value: null, active: false, title: "工作台", icon: "laptop" },
{ name: "mine", value: null, active: false, title: "我的", icon: "user" },
]);
/** 默认 TabBar 配置 */
const DEFAULT_TABBAR_ITEMS: Omit<TabbarItem, "active">[] = [
{ name: "home", value: null, title: "首页", icon: "home" },
{ name: "work", value: null, title: "工作台", icon: "laptop" },
{ name: "mine", value: null, title: "我的", icon: "user" },
]
export function useTabbar() {
const tabbarList = computed(() => tabbarItems.value);
/**
* 创建 TabBar 状态实例
*
* 使用工厂函数创建独立的状态实例,避免模块级别的状态污染。
* 每个调用者都可以获得独立的状态管理。
*/
function createTabbarState() {
const items = ref<TabbarItem[]>(
DEFAULT_TABBAR_ITEMS.map((item, index) => ({
...item,
active: index === 0,
}))
)
/** TabBar 列表 */
const tabbarList = computed(() => items.value)
/** 当前激活的 TabBar 项 */
const activeTabbar = computed(() => {
const item = tabbarItems.value.find((item) => item.active);
return item || tabbarItems.value[0];
});
return items.value.find((item) => item.active) || items.value[0]
})
const getTabbarItemValue = (name: string) => {
const item = tabbarItems.value.find((item) => item.name === name);
return item && item.value ? item.value : null;
};
/**
* 获取指定 TabBar 项的徽标数字
* @param name TabBar 项名称
*/
const getTabbarItemValue = (name: string): number | null => {
const item = items.value.find((item) => item.name === name)
return item?.value ?? null
}
const setTabbarItem = (name: string, value: number) => {
const tabbarItem = tabbarItems.value.find((item) => item.name === name);
if (tabbarItem) {
tabbarItem.value = value;
/**
* 设置 TabBar 项的徽标数字
* @param name TabBar 项名称
* @param value 徽标数字
*/
const setTabbarItem = (name: string, value: number): void => {
const item = items.value.find((item) => item.name === name)
if (item) {
item.value = value
}
};
}
const setTabbarItemActive = (name: string) => {
tabbarItems.value.forEach((item) => {
if (item.name === name) {
item.active = true;
} else {
item.active = false;
}
});
};
/**
* 设置激活的 TabBar
* @param name TabBar 项名称
*/
const setTabbarItemActive = (name: string): void => {
items.value.forEach((item) => {
item.active = item.name === name
})
}
/**
* 重置 TabBar 状态
*/
const resetTabbar = (): void => {
items.value = DEFAULT_TABBAR_ITEMS.map((item, index) => ({
...item,
active: index === 0,
}))
}
return {
tabbarList,
@@ -48,5 +106,31 @@ export function useTabbar() {
getTabbarItemValue,
setTabbarItem,
setTabbarItemActive,
};
resetTabbar,
}
}
/** 全局单例实例 */
let tabbarInstance: ReturnType<typeof createTabbarState> | null = null
/**
* TabBar 状态管理 Hook
*
* 默认使用全局单例模式,确保整个应用共享同一状态。
* 如需独立状态,可传入 `{ singleton: false }` 创建新实例。
*
* @param options 配置选项
* @param options.singleton 是否使用单例模式,默认 true
*/
export function useTabbar(options?: { singleton?: boolean }) {
const { singleton = true } = options ?? {}
if (singleton) {
if (!tabbarInstance) {
tabbarInstance = createTabbarState()
}
return tabbarInstance
}
return createTabbarState()
}