feat(common): ✨ 添加工具
This commit is contained in:
@@ -47,13 +47,13 @@
|
||||
"release": "standard-version",
|
||||
"commit": "cz",
|
||||
"lint-staged": "lint-staged",
|
||||
"test": "jest"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@r-utils/common": "workspace:^",
|
||||
"chroma-js": "^3.2.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"qs": "^6.15.0"
|
||||
"chroma-js": "catalog:",
|
||||
"lodash-es": "catalog:",
|
||||
"qs": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dcloudio/uni-app": ">=3.0.0",
|
||||
@@ -67,9 +67,9 @@
|
||||
"devDependencies": {
|
||||
"@dcloudio/types": "^3.0.7",
|
||||
"@dcloudio/uni-app": "3.0.0-4070520250711001",
|
||||
"@types/chroma-js": "^3.1.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/qs": "^6.15.0",
|
||||
"@types/chroma-js": "catalog:",
|
||||
"@types/lodash-es": "catalog:",
|
||||
"@types/qs": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-dts": "catalog:",
|
||||
"vue": "^3.3.0"
|
||||
|
||||
@@ -24,7 +24,6 @@ export function getCurrentPage() {
|
||||
return pages[pages.length - 1];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 使用 TypeScript 工具类型实现自动类型推断的 toPromise
|
||||
*
|
||||
@@ -39,10 +38,17 @@ export function getCurrentPage() {
|
||||
// type ExtractFirstParameter<T> = Parameters<T>[0]; // 这样也可以,但需要T是函数类型
|
||||
|
||||
// 方法2:使用条件类型实现(更灵活,等价于 Parameters<T>[0])
|
||||
type ExtractFirstParameter<T> = T extends (arg: infer P, ...args: unknown[]) => unknown ? P : never;
|
||||
type ExtractFirstParameter<T> = T extends (
|
||||
arg: infer P,
|
||||
...args: unknown[]
|
||||
) => unknown
|
||||
? P
|
||||
: never;
|
||||
|
||||
// 提取 success 回调的参数类型(类似 ReturnType 的思路,但提取回调参数)
|
||||
type ExtractSuccessResult<T> = T extends { success?: (result: infer R) => void } ? R : never;
|
||||
type ExtractSuccessResult<T> = T extends { success?: (result: infer R) => void }
|
||||
? R
|
||||
: never;
|
||||
|
||||
// 提取函数参数类型并排除回调函数(用于args参数)
|
||||
type ExtractOptions<T> = T extends (options: infer P) => unknown
|
||||
@@ -103,7 +109,6 @@ export function toPromise<F extends (options: Record<string, unknown>) => void>(
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function showToast(
|
||||
title: string,
|
||||
icon: "success" | "loading" | "error" | "none" = "none",
|
||||
@@ -139,12 +144,9 @@ export async function getCanvasImageData(
|
||||
) {
|
||||
const context = uni.createCanvasContext(canvasId, thisArg);
|
||||
|
||||
const imgRes = await toPromise(
|
||||
uni.downloadFile,
|
||||
{
|
||||
url,
|
||||
},
|
||||
);
|
||||
const imgRes = await toPromise(uni.downloadFile, {
|
||||
url,
|
||||
});
|
||||
|
||||
console.log(imgRes);
|
||||
context.drawImage(imgRes.tempFilePath, x, y, width, height);
|
||||
@@ -243,3 +245,45 @@ export function checkAndroidPermission(permissionList: string[]) {
|
||||
});
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步计算元素距离
|
||||
*/
|
||||
export async function calculateDistanceBySelectorAsync(
|
||||
scrollViewSelector: string,
|
||||
selector: string,
|
||||
offsetTop = 0,
|
||||
): Promise<number> {
|
||||
const scrollViewScrollOffsetPromise = new Promise<UniApp.NodeInfo>(
|
||||
(resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(scrollViewSelector).scrollOffset((res) => {
|
||||
resolve(res as UniApp.NodeInfo);
|
||||
});
|
||||
query.exec();
|
||||
},
|
||||
);
|
||||
|
||||
const targetElementPromise = new Promise<UniApp.NodeInfo>((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(selector).boundingClientRect((rect) => {
|
||||
resolve(rect as UniApp.NodeInfo);
|
||||
});
|
||||
query.exec();
|
||||
});
|
||||
|
||||
const [scrollViewScrollOffset, targetElementRect] = await Promise.all([
|
||||
scrollViewScrollOffsetPromise,
|
||||
targetElementPromise,
|
||||
]);
|
||||
|
||||
if (!scrollViewScrollOffset || !targetElementRect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const distance =
|
||||
(targetElementRect.top ?? 0) +
|
||||
(scrollViewScrollOffset.scrollTop ?? 0) -
|
||||
offsetTop;
|
||||
return Math.max(0, Math.round(distance));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
* @file /src/vue3/hooks/components.ts
|
||||
* @description
|
||||
* @author tsl (randy1924@163.com)
|
||||
* @date 2026-03-19 11:11:11
|
||||
* @lastModified 2026-03-26 09:16:44
|
||||
* @lastModifiedBy tsl (randy1924@163.com)
|
||||
*/
|
||||
|
||||
import chroma from "chroma-js";
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import type { GetCityListSuccessResultResult } from "types/qqmap-wx-jssdk";
|
||||
|
||||
/** UniSwiperDot组件hook */
|
||||
export function useUniSwiperDot() {
|
||||
/** 当前索引 */
|
||||
const current = ref(0);
|
||||
|
||||
/** 当Swiper组件发生变化时,改变当前索引 */
|
||||
const handleSwiperChange = (e: UniApp.SwiperEvent) => {
|
||||
// #ifndef MP-WEIXIN
|
||||
// 微信使用 handleSwiperAnimationfinish 切换
|
||||
current.value = e.detail.current;
|
||||
// #endif
|
||||
};
|
||||
|
||||
/** 当Swiper组件动画结束时,改变当前索引 */
|
||||
const handleSwiperAnimationfinish = (e: UniApp.SwiperEvent) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
// 微信使用 handleSwiperAnimationfinish 切换
|
||||
current.value = e.detail.current;
|
||||
// #endif
|
||||
};
|
||||
|
||||
return { current, handleSwiperChange, handleSwiperAnimationfinish };
|
||||
}
|
||||
|
||||
/** UniSegmentedControl组件hook */
|
||||
export function useUniSegmentedControl() {
|
||||
/** 当前索引 */
|
||||
const current = ref(0);
|
||||
|
||||
/** 当当前索引被点击时,改变当前索引 */
|
||||
const handleClickItem = (e: { currentIndex: number }) => {
|
||||
console.log(e);
|
||||
current.value = e.currentIndex;
|
||||
};
|
||||
|
||||
return { current, handleClickItem };
|
||||
}
|
||||
|
||||
/**
|
||||
* UniDataPicker组件的时间偏移hook
|
||||
*/
|
||||
export function useUniDataPickerForHourOffset() {
|
||||
/** 当前所选小时 */
|
||||
const hour = ref(0);
|
||||
/** 当前所选持续小时 */
|
||||
const offset = ref(0);
|
||||
/** 最多持续几个小时 */
|
||||
const maxOffset = 4;
|
||||
/** 小时列表 */
|
||||
const hourList: UniApp.LocaldataItem<string>[] = [];
|
||||
// 填充小时列表
|
||||
for (let i = 1, h = 6; h < 24; i++, h++) {
|
||||
// 持续时间列表
|
||||
const offsetList: UniApp.LocaldataItem<string>[] = [];
|
||||
// 填充持续时间列表
|
||||
for (let j = 1, o = 1; o <= Math.min(24 - h, maxOffset); j++, o++) {
|
||||
offsetList.push({ text: `${o}小时`, value: `${i}-${j}` });
|
||||
}
|
||||
|
||||
hourList.push({ text: `${h}:00`, value: `${i}`, children: offsetList });
|
||||
}
|
||||
console.log(hourList);
|
||||
|
||||
/** UniDataPicker 的 localdata */
|
||||
const localdata = ref<UniApp.LocaldataItem<string>[]>(hourList);
|
||||
|
||||
/** 当 uni-data-picker 的选项改变时,设置 hour 与 offset */
|
||||
const handleChange = (e: UniApp.UniDataPickerEvent) => {
|
||||
console.log(e);
|
||||
hour.value = parseInt(e.detail.value[0].text.split(":")[0]);
|
||||
offset.value = parseInt(e.detail.value[1].text);
|
||||
console.log(hour.value, offset.value);
|
||||
};
|
||||
|
||||
return { localdata, handleChange, hour, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* AppDataPicker组件的时间偏移hook
|
||||
*/
|
||||
export function useAppDataPickerForHourOffset() {
|
||||
/**
|
||||
* 时间格式化
|
||||
* @example "14:00-16:00 2小时"
|
||||
*/
|
||||
const timeFormatter = (e: UniApp.UniDataPickerEvent) => {
|
||||
console.log(e);
|
||||
if (!e) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const [hour, offset] = e.detail.value.map((v) => v.text);
|
||||
const [h, m] = hour.split(":");
|
||||
const hInt = parseInt(h);
|
||||
const oInt = parseInt(offset);
|
||||
|
||||
return `${hour}-${hInt + oInt}:${m} ${offset}`;
|
||||
};
|
||||
|
||||
return { ...useUniDataPickerForHourOffset(), timeFormatter };
|
||||
}
|
||||
|
||||
/**
|
||||
* UniDataPicker组件的地址hook
|
||||
*/
|
||||
export function useUniDataPickerForLocation() {
|
||||
/**
|
||||
* uni-data-picker数据格式的地址列表
|
||||
*/
|
||||
const localdata = ref<UniApp.LocaldataItem<number>[]>([]);
|
||||
const province = ref("");
|
||||
const city = ref("");
|
||||
/**
|
||||
* 把 qqmap 返回的地址列表转换成 uni-data-picker 的数据格式
|
||||
* @param qqMapList qqmap 返回的地址列表
|
||||
*/
|
||||
const setLocaldata = (qqMapList: GetCityListSuccessResultResult[][]) => {
|
||||
console.log(qqMapList);
|
||||
|
||||
const list = qqMapList[0].map((p) => {
|
||||
const cityList = qqMapList[1]
|
||||
.slice(p.cidx?.[0] ?? 0, p.cidx?.[1] ?? 0)
|
||||
.map((v) => {
|
||||
return {
|
||||
text: v.fullname,
|
||||
value: v.id,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
// ...p,
|
||||
text: p.fullname,
|
||||
value: p.id,
|
||||
children: cityList,
|
||||
};
|
||||
});
|
||||
|
||||
localdata.value = list;
|
||||
console.log(localdata);
|
||||
};
|
||||
|
||||
/** 当 uni-data-picker 的选项改变时,设置 省 与 市 */
|
||||
const handleChange = (e: UniApp.UniDataPickerEvent) => {
|
||||
console.log({ e });
|
||||
province.value = e.detail.value[0].text;
|
||||
city.value = e.detail.value[1].text;
|
||||
console.log(province.value, city.value);
|
||||
};
|
||||
|
||||
return { localdata, setLocaldata, province, city, handleChange };
|
||||
}
|
||||
|
||||
/**
|
||||
* UniDataPicker组件的hook
|
||||
*
|
||||
* @param type 类型
|
||||
* @see useUniDataPickerForLocation
|
||||
* @see useUniDataPickerForHourOffset
|
||||
*/
|
||||
export function useUniDataPicker(type: "hourOffset" | "location") {
|
||||
console.log(type);
|
||||
|
||||
let data;
|
||||
switch (type) {
|
||||
case "hourOffset": {
|
||||
data = useUniDataPickerForHourOffset();
|
||||
break;
|
||||
}
|
||||
case "location": {
|
||||
data = useUniDataPickerForLocation();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("参数错误");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Picker 组件 hook */
|
||||
export function usePicker({ list } = { list: [] }) {
|
||||
/** 当前索引 */
|
||||
const value = ref("0");
|
||||
|
||||
const handleChange = (e: UniApp.PickerEvent) => {
|
||||
value.value = e.detail.value;
|
||||
};
|
||||
|
||||
const item = computed(() => {
|
||||
return list[parseInt(value.value)];
|
||||
});
|
||||
|
||||
return { value, handleChange, item };
|
||||
}
|
||||
|
||||
/** input 组件 hook */
|
||||
export function useInput() {
|
||||
const value = ref("");
|
||||
|
||||
const handleChange = (e: UniApp.InputEvent) => {
|
||||
value.value = e.detail.value;
|
||||
};
|
||||
|
||||
const handleInput = (e: UniApp.InputEvent) => {
|
||||
value.value = e.detail.value;
|
||||
};
|
||||
|
||||
return { value, handleChange, handleInput };
|
||||
}
|
||||
|
||||
/** 头部根据滚动高度设置背景色透明度 */
|
||||
export function useHeader(finalHeight = 32, finalColor = "#fff") {
|
||||
const {
|
||||
color,
|
||||
scrollTop,
|
||||
end,
|
||||
handlePageScroll: _handlePageScroll,
|
||||
} = useNavbar(finalHeight, finalColor);
|
||||
|
||||
const handlePageScroll = (e: UniApp.ScrollEvent) => {
|
||||
_handlePageScroll(e.detail.scrollTop);
|
||||
};
|
||||
|
||||
return { color, scrollTop, end, handlePageScroll };
|
||||
}
|
||||
|
||||
/** 头部根据滚动高度设置背景色透明度 */
|
||||
export function useNavbar(
|
||||
finalHeight = 32,
|
||||
finalColor = "#fff",
|
||||
endChange?: (end: boolean) => void,
|
||||
) {
|
||||
const end = ref(false);
|
||||
const scrollTop = ref(0);
|
||||
|
||||
const color = computed(() => {
|
||||
return chroma(finalColor)
|
||||
.alpha(scrollTop.value / finalHeight)
|
||||
.css();
|
||||
});
|
||||
|
||||
const handlePageScroll = (st: number) => {
|
||||
scrollTop.value = st;
|
||||
|
||||
if (scrollTop.value > finalHeight) {
|
||||
end.value = true;
|
||||
} else {
|
||||
end.value = false;
|
||||
}
|
||||
endChange?.(end.value);
|
||||
};
|
||||
|
||||
return { color, scrollTop, end, handlePageScroll };
|
||||
}
|
||||
|
||||
// export function usePickerView(){
|
||||
// const value = ref("");
|
||||
|
||||
// const handleChange = (e: UniApp.PickerViewEvent) => {
|
||||
// value.value = e.detail.value;
|
||||
// };
|
||||
|
||||
// return { value, handleChange };
|
||||
// }
|
||||
|
||||
/** PickerView 的 日期 hook */
|
||||
export function usePickerViewForDate() {
|
||||
const date = new Date();
|
||||
|
||||
/** 是否可见 */
|
||||
const visible = ref(false);
|
||||
const value = ref<number[]>([0, 0, 0]);
|
||||
const years = ref<number[]>([]);
|
||||
const months = ref<number[]>([]);
|
||||
const days = ref<number[]>([]);
|
||||
|
||||
const year = ref(date.getFullYear());
|
||||
const month = ref(date.getMonth() + 1);
|
||||
const day = ref(date.getDate());
|
||||
|
||||
/** 起始年 */
|
||||
const startYear = ref(date.getFullYear() - 50);
|
||||
for (let i = startYear.value; i <= date.getFullYear(); i++) {
|
||||
years.value.push(i);
|
||||
}
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
months.value.push(i);
|
||||
}
|
||||
for (let i = 1; i <= 31; i++) {
|
||||
days.value.push(i);
|
||||
}
|
||||
|
||||
const handleChange = (e: UniApp.PickerViewEvent<number>) => {
|
||||
const [newYear, newMonth, newDay] = e.detail.value;
|
||||
year.value = years.value[newYear];
|
||||
month.value = months.value[newMonth];
|
||||
day.value = days.value[newDay];
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// NOTE 只能在组件挂载完成后才能响应式修改值
|
||||
// 初始化日期为今天
|
||||
const currentYear = years.value.findIndex((v: number) => v === year.value);
|
||||
const currentMonth = months.value.findIndex(
|
||||
(v: number) => v === month.value,
|
||||
);
|
||||
const currentDay = days.value.findIndex((v: number) => v === day.value);
|
||||
value.value = [currentYear, currentMonth, currentDay];
|
||||
});
|
||||
|
||||
return {
|
||||
value,
|
||||
handleChange,
|
||||
visible,
|
||||
years,
|
||||
months,
|
||||
days,
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比新旧数组变化的索引
|
||||
* @param oldArr 旧数组
|
||||
* @param newArr 新数组
|
||||
* @returns 返回新旧数组变化的第一个索引,如果没有变化,返回 -1
|
||||
*/
|
||||
function comparisonChangedIndex(
|
||||
oldArr: unknown[],
|
||||
newArr: unknown[],
|
||||
fun?: (newValue: unknown, oldValue: unknown) => boolean,
|
||||
): number {
|
||||
for (let i = 0; i < oldArr.length; i++) {
|
||||
if (fun ? fun(oldArr[i], newArr[i]) : oldArr[i] !== newArr[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldArr.length !== newArr.length) {
|
||||
return (
|
||||
Math.max(oldArr.length, newArr.length) -
|
||||
Math.abs(oldArr.length - newArr.length)
|
||||
);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** PickerView 的 qqmap hook */
|
||||
export function usePickerViewForQqMapList(
|
||||
qqmapList: GetCityListSuccessResultResult[][],
|
||||
) {
|
||||
/** 是否可见 */
|
||||
const visible = ref(false);
|
||||
const value = ref<number[]>([0, 0, 0]);
|
||||
const [allProvinceList, allCityList, allDistrictList] = qqmapList;
|
||||
const provinceList = ref(allProvinceList);
|
||||
const cityList = ref(allCityList);
|
||||
const districtList = ref(allDistrictList);
|
||||
|
||||
const province = ref(provinceList.value[0]);
|
||||
const city = ref(cityList.value[0]);
|
||||
const district = ref(districtList.value[0]);
|
||||
|
||||
let lastSelect = [0, 0, 0];
|
||||
const handleChange = (e: UniApp.PickerViewEvent<number>) => {
|
||||
const changedIndex = comparisonChangedIndex(lastSelect, e.detail.value);
|
||||
lastSelect = e.detail.value;
|
||||
const [newProvinceIndex, newCityIndex, newDistrictIndex] = lastSelect;
|
||||
|
||||
if (changedIndex === 0) {
|
||||
// 变化了省,更新市和区
|
||||
// provinceList.value = allProvinceList;
|
||||
province.value = provinceList.value[newProvinceIndex];
|
||||
|
||||
cityList.value = allCityList.slice(
|
||||
province.value.cidx?.[0] ?? 0,
|
||||
province.value.cidx?.[1] ?? 0,
|
||||
);
|
||||
city.value = cityList.value[0];
|
||||
|
||||
districtList.value = allDistrictList.slice(
|
||||
city.value.cidx?.[0] ?? 0,
|
||||
city.value.cidx?.[1] ?? 0,
|
||||
);
|
||||
district.value = districtList.value[0];
|
||||
value.value = [newProvinceIndex, 0, 0];
|
||||
} else if (changedIndex === 1) {
|
||||
// 变化了市,则只变化区
|
||||
city.value = cityList.value[newCityIndex];
|
||||
districtList.value = allDistrictList.slice(
|
||||
city.value?.cidx?.[0] ?? 0,
|
||||
city.value?.cidx?.[1] ?? 0,
|
||||
);
|
||||
district.value = districtList.value[0];
|
||||
value.value = [newProvinceIndex, newCityIndex, 0];
|
||||
} else if (changedIndex === 2) {
|
||||
district.value = districtList.value[newDistrictIndex];
|
||||
value.value = [newProvinceIndex, newCityIndex, newDistrictIndex];
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// NOTE 只能在组件挂载完成后才能响应式修改值
|
||||
const [newProvinceIndex, newCityIndex, newDistrictIndex] = lastSelect;
|
||||
provinceList.value = allProvinceList;
|
||||
province.value = provinceList.value[newProvinceIndex];
|
||||
|
||||
cityList.value = allCityList.slice(
|
||||
province.value.cidx?.[0],
|
||||
province.value.cidx?.[1],
|
||||
);
|
||||
|
||||
city.value = cityList.value?.[newCityIndex];
|
||||
|
||||
districtList.value = allDistrictList.slice(
|
||||
city.value?.cidx?.[0] ?? 0,
|
||||
city.value?.cidx?.[1] ?? 0,
|
||||
);
|
||||
district.value = districtList.value?.[newDistrictIndex];
|
||||
value.value = [newProvinceIndex, newCityIndex, newDistrictIndex];
|
||||
});
|
||||
|
||||
return {
|
||||
value,
|
||||
handleChange,
|
||||
visible,
|
||||
provinceList,
|
||||
cityList,
|
||||
districtList,
|
||||
province,
|
||||
city,
|
||||
district,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* @file /src/vue3/hooks/index.ts
|
||||
* @description
|
||||
* @author tsl (randy1924@163.com)
|
||||
* @date 2026-03-20 09:09:42
|
||||
* @lastModified 2026-03-20 10:42:54
|
||||
* @lastModifiedBy tsl (randy1924@163.com)
|
||||
*/
|
||||
|
||||
export * from "./components";
|
||||
export * from "./page";
|
||||
export * from "./page-tab";
|
||||
export * from "./top-sticky";
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* @file /src/vue3/hooks/page-tab.ts
|
||||
* @description 页面tab点击自动滚动到对应位置
|
||||
* @author tsl (randy1924@163.com)
|
||||
* @date 2026-03-26 10:12:41
|
||||
* @lastModified 2026-04-20 14:08:14
|
||||
* @lastModifiedBy tsl (randy1924@163.com)
|
||||
*/
|
||||
|
||||
import { slowlyScroll } from "@r-utils/common";
|
||||
import { debounce } from "lodash-es";
|
||||
import { ref, reactive, toRef } from "vue";
|
||||
import { calculateDistanceBySelectorAsync } from "../../uni-helper";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
/**
|
||||
* Tab 配置项
|
||||
* @since 1.2.2
|
||||
*/
|
||||
export type TabConfig<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
> = {
|
||||
/** Tab 名称(显示文本) */
|
||||
name: string;
|
||||
/** Tab 值(唯一标识) */
|
||||
value: string;
|
||||
/** 对应的选择器 */
|
||||
selector: string;
|
||||
/** 锚点偏移量,用于微调滚动位置 */
|
||||
offset?: number;
|
||||
} & T;
|
||||
|
||||
/**
|
||||
* 页面 Tab 联动配置
|
||||
* @since 1.2.2
|
||||
*/
|
||||
export interface PageTabOptions {
|
||||
/** 当前激活的 tab */
|
||||
currentPageTab?: Ref<string>;
|
||||
/** 滚动容器的选择器 */
|
||||
scrollViewSelector?: string;
|
||||
/** 基础偏移高度(导航栏高度等) */
|
||||
baseOffsetTop?: number;
|
||||
/** 防抖延迟时间(毫秒) */
|
||||
debounceDelay?: number;
|
||||
/** 切换偏移阈值 */
|
||||
switchOffset?: number;
|
||||
/** Tab 值的键名 */
|
||||
tabValueKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面 Tab 联动返回值
|
||||
* @since 1.2.2
|
||||
*/
|
||||
export interface PageTabReturn<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
> {
|
||||
/** 当前滚动位置,<scroll-view> 的 scroll-top */
|
||||
scrollTop: ReturnType<typeof ref<number>>;
|
||||
/** 实时滚动位置 */
|
||||
currentScrollTop: ReturnType<typeof ref<number>>;
|
||||
/** 当前激活的 tab */
|
||||
currentPageTab: ReturnType<typeof ref<string>>;
|
||||
/** Tab 锚点选择器映射 */
|
||||
pageTabAnchorSelectorMap: Record<string, string>;
|
||||
/** Tab 锚点偏移映射 */
|
||||
pageTabAnchorOffsetMap: Record<string, number>;
|
||||
/** Tab 锚点距离映射(计算后的实际距离) */
|
||||
pageTabAnchorDistanceMap: Record<string, number>;
|
||||
/** 是否正在进行 tab 滚动 */
|
||||
isPageTabScrolling: ReturnType<typeof ref<boolean>>;
|
||||
/** 滚动事件处理函数 */
|
||||
handlePageScroll: (e: number) => void;
|
||||
/** 更新所有锚点位置 */
|
||||
updatePageTabAnchorDistance: (tabConfigs?: TabConfig<T>[]) => Promise<void>;
|
||||
/** 滚动到指定 tab */
|
||||
scrollToPageTab: (tabValue: string) => Promise<void>;
|
||||
/** Tab 切换事件处理 */
|
||||
tabChange: (tabValue: string) => void;
|
||||
/** 页面 Tab 滚动处理(防抖) */
|
||||
handlePageTabScroll: (scrollTopValue: number) => void;
|
||||
/** 设置基础偏移高度 */
|
||||
setBaseOffsetTop: (offsetTop: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用页面 Tab 联动功能
|
||||
*
|
||||
* @param tabConfigs - Tab 配置数组
|
||||
* @param options - 配置选项
|
||||
* @param onPageScroll - 额外的页面滚动回调(如导航栏渐变处理)
|
||||
* @returns Tab 联动相关的响应式数据和方法
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const tabConfigs = [
|
||||
* { name: "活动详情", value: "活动详情", selector: ".activity-desc-wrapper", offset: 45 },
|
||||
* { name: "俱乐部评价", value: "俱乐部评价", selector: ".club-comment-list", offset: 50 },
|
||||
* ];
|
||||
*
|
||||
* const {
|
||||
* scrollTop,
|
||||
* currentPageTab,
|
||||
* handlePageScroll,
|
||||
* updatePageTabAnchorDistance,
|
||||
* tabChange,
|
||||
* } = usePageTab(tabConfigs, {
|
||||
* baseOffsetTop: 92,
|
||||
* debounceDelay: 50,
|
||||
* }, (scrollTop) => {
|
||||
* // 处理导航栏渐变等额外逻辑
|
||||
* handleNavbarPageScroll(scrollTop);
|
||||
* });
|
||||
* ```
|
||||
* @since 1.2.2
|
||||
*/
|
||||
export function usePageTab<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(tabConfigs: TabConfig<T>[], options: PageTabOptions = {}): PageTabReturn<T> {
|
||||
const {
|
||||
scrollViewSelector = ".scroll-view",
|
||||
debounceDelay = 50,
|
||||
switchOffset = 5,
|
||||
} = options;
|
||||
const tabValueKey = options.tabValueKey ?? "value";
|
||||
let baseOffsetTop: number = options.baseOffsetTop ?? 80 + 12;
|
||||
|
||||
// 响应式状态
|
||||
const scrollTop = ref(0);
|
||||
const currentScrollTop = ref(0);
|
||||
const currentPageTab = toRef(options.currentPageTab);
|
||||
const isPageTabScrolling = ref(false);
|
||||
|
||||
// 初始化映射对象
|
||||
const pageTabAnchorSelectorMap: Record<string, string> = reactive({});
|
||||
const pageTabAnchorOffsetMap: Record<string, number> = reactive({});
|
||||
const pageTabAnchorDistanceMap: Record<string, number> = reactive({});
|
||||
setTabConfigs(tabConfigs);
|
||||
|
||||
function setTabConfigs(configs: TabConfig<T>[]): void {
|
||||
for (const config of configs) {
|
||||
const tabValue = config[tabValueKey] as string;
|
||||
pageTabAnchorSelectorMap[tabValue] = config.selector;
|
||||
pageTabAnchorOffsetMap[tabValue] = config.offset ?? 0;
|
||||
pageTabAnchorDistanceMap[tabValue] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新所有 Tab 的锚点位置
|
||||
*/
|
||||
async function updatePageTabAnchorDistance(
|
||||
configs?: TabConfig<T>[],
|
||||
): Promise<void> {
|
||||
const _configs = configs ?? tabConfigs;
|
||||
for (const config of _configs) {
|
||||
const tabValue = config[tabValueKey] as string;
|
||||
try {
|
||||
const offset = config.offset ?? pageTabAnchorOffsetMap[tabValue] ?? 0;
|
||||
const distance = await calculateDistanceBySelectorAsync(
|
||||
scrollViewSelector,
|
||||
config.selector,
|
||||
offset + baseOffsetTop,
|
||||
);
|
||||
pageTabAnchorDistanceMap[tabValue] = distance;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to calculate distance for tab: ${tabValue}`,
|
||||
error,
|
||||
);
|
||||
pageTabAnchorDistanceMap[tabValue] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 滚动到指定的 Tab
|
||||
*/
|
||||
async function scrollToPageTab(tabValue: string): Promise<void> {
|
||||
console.log("scrollToPageTab", tabValue);
|
||||
|
||||
// 避免点击 tab 时触发 handlePageScroll 再反向修改 currentPageTab
|
||||
isPageTabScrolling.value = true;
|
||||
|
||||
try {
|
||||
const target = pageTabAnchorDistanceMap[tabValue] || 0;
|
||||
|
||||
if (Math.abs(target - currentScrollTop.value) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
await slowlyScroll(currentScrollTop.value, target, 500, (value) => {
|
||||
scrollTop.value = value;
|
||||
});
|
||||
} finally {
|
||||
// 给 scroll-view 一点时间完成滚动
|
||||
setTimeout(() => {
|
||||
isPageTabScrolling.value = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab 切换事件处理
|
||||
*/
|
||||
function tabChange(tabValue: string): void {
|
||||
console.log("tabChange", tabValue);
|
||||
scrollToPageTab(tabValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面滚动时自动切换 Tab(防抖处理)
|
||||
*/
|
||||
const handlePageTabScroll = debounce((scrollTopValue: number) => {
|
||||
if (isPageTabScrolling.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有 tab 的值,并按距离从大到小排序
|
||||
const sortedTabs = Object.keys(pageTabAnchorDistanceMap)
|
||||
.map((tabValue) => ({
|
||||
value: tabValue,
|
||||
distance: pageTabAnchorDistanceMap[tabValue],
|
||||
}))
|
||||
.filter((tab) => tab.distance > 0)
|
||||
.sort((a, b) => b.distance - a.distance);
|
||||
|
||||
// 从下往上检查各个 tab 的位置,找到当前应该显示的 tab
|
||||
|
||||
for (const tab of sortedTabs) {
|
||||
// console.log("handlePageTabScroll", scrollTopValue, tab, tab.distance - switchOffset);
|
||||
if (scrollTopValue >= tab.distance - switchOffset) {
|
||||
if (currentPageTab.value !== tab.value) {
|
||||
currentPageTab.value = tab.value;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有匹配上,显示第一个 tab
|
||||
if (sortedTabs.length > 0) {
|
||||
const firstTab = sortedTabs[sortedTabs.length - 1];
|
||||
if (currentPageTab.value !== firstTab.value) {
|
||||
currentPageTab.value = firstTab.value;
|
||||
}
|
||||
}
|
||||
}, debounceDelay);
|
||||
|
||||
/**
|
||||
* 滚动事件处理
|
||||
*/
|
||||
function handlePageScroll(scrollTop: number): void {
|
||||
currentScrollTop.value = scrollTop;
|
||||
|
||||
// 处理 Tab 滚动
|
||||
handlePageTabScroll(scrollTop);
|
||||
}
|
||||
|
||||
function setBaseOffsetTop(offsetTop: number): void {
|
||||
baseOffsetTop = offsetTop;
|
||||
updatePageTabAnchorDistance();
|
||||
}
|
||||
|
||||
return {
|
||||
scrollTop,
|
||||
currentScrollTop,
|
||||
currentPageTab,
|
||||
pageTabAnchorSelectorMap,
|
||||
pageTabAnchorOffsetMap,
|
||||
pageTabAnchorDistanceMap,
|
||||
isPageTabScrolling,
|
||||
handlePageScroll,
|
||||
updatePageTabAnchorDistance,
|
||||
scrollToPageTab,
|
||||
tabChange,
|
||||
setBaseOffsetTop,
|
||||
handlePageTabScroll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { onReady } from "@dcloudio/uni-app";
|
||||
import qs from "qs";
|
||||
|
||||
/**
|
||||
* 分享配置
|
||||
* NOTE 待优化,title,query应该与withShareTicket,menus分开配置
|
||||
*/
|
||||
export interface ShareOptions {
|
||||
title: string;
|
||||
path?: string;
|
||||
query: Record<string, string>;
|
||||
imageUrl?: string;
|
||||
withShareTicket?: boolean;
|
||||
menus?: UniApp.ShowShareMenuOptionsMenu[];
|
||||
}
|
||||
|
||||
function getDefaultShareOptions(): ShareOptions {
|
||||
return {
|
||||
title: "",
|
||||
query: {},
|
||||
withShareTicket: true,
|
||||
menus: ["shareAppMessage", "shareTimeline"],
|
||||
};
|
||||
}
|
||||
|
||||
function getOptions(
|
||||
options?: ShareOptions | (() => ShareOptions),
|
||||
): ShareOptions {
|
||||
const oOptions = typeof options === "function" ? options() : options;
|
||||
const shareOptions = Object.assign(getDefaultShareOptions(), oOptions);
|
||||
return shareOptions;
|
||||
}
|
||||
|
||||
export function useShare(options?: ShareOptions | (() => ShareOptions)) {
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages.at(-1) as Page.PageInstance;
|
||||
const currentRoute = currentPage.route;
|
||||
|
||||
const shareOptions = getOptions(options);
|
||||
|
||||
const withShareTicket = shareOptions.withShareTicket;
|
||||
const menus = shareOptions.menus;
|
||||
|
||||
onReady(() => {
|
||||
const launchOptions = uni.getLaunchOptionsSync();
|
||||
|
||||
if ([1154].includes(launchOptions.scene)) {
|
||||
return;
|
||||
}
|
||||
uni.showShareMenu({
|
||||
withShareTicket,
|
||||
menus,
|
||||
});
|
||||
});
|
||||
|
||||
function getShareOptions() {
|
||||
const shareOptions = getOptions(options);
|
||||
const query = qs.stringify(shareOptions.query);
|
||||
|
||||
const path = shareOptions.path || `/${currentRoute}`;
|
||||
|
||||
return {
|
||||
title: shareOptions.title,
|
||||
imageUrl: shareOptions.imageUrl,
|
||||
path: `${path}?${query}`,
|
||||
query,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
getShareOptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* @file /src/vue3/hooks/top-sticky.ts
|
||||
* @description 吸顶工具
|
||||
* @author tsl (randy1924@163.com)
|
||||
* @date 2026-03-20 17:51:50
|
||||
* @lastModified 2026-03-26 10:24:21
|
||||
* @lastModifiedBy tsl (randy1924@163.com)
|
||||
*/
|
||||
|
||||
import { ref } from "vue";
|
||||
|
||||
export interface UseTopStickyOptions {
|
||||
context?: never;
|
||||
offsetTop?: number;
|
||||
targetSelector: string;
|
||||
onToTopChange?: (toTop: boolean) => void;
|
||||
}
|
||||
|
||||
export function useTopSticky(options: UseTopStickyOptions) {
|
||||
const context = options.context;
|
||||
const offsetTop = options.offsetTop ?? 0;
|
||||
const targetSelector = options.targetSelector;
|
||||
const onToTopChange = options.onToTopChange;
|
||||
|
||||
// 是否达到了顶部
|
||||
const isToTop = ref(false);
|
||||
|
||||
const systemInfo = uni.getSystemInfoSync();
|
||||
const intersectionObserver = uni.createIntersectionObserver(context);
|
||||
|
||||
// 观察目标元素
|
||||
intersectionObserver
|
||||
.relativeToViewport({
|
||||
top: 0,
|
||||
bottom: -systemInfo.windowHeight + offsetTop,
|
||||
})
|
||||
.observe(targetSelector, (res) => {
|
||||
console.log("intersectionObserver", res);
|
||||
// 当目标与视口顶部距离小于0px时,isToTop 设为 true
|
||||
isToTop.value = res.intersectionRatio > 0;
|
||||
onToTopChange?.(isToTop.value);
|
||||
});
|
||||
|
||||
return { isToTop };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
* @file: /src/vue3/index.ts
|
||||
* @description:
|
||||
* @author: tsl (randy1924@163.com)
|
||||
* @date: 2026-03-20 09:09:08
|
||||
* @lastModified: 2026-03-20 09:45:42
|
||||
* @lastModifiedBy: tsl (randy1924@163.com)
|
||||
*/
|
||||
|
||||
export * from "./hooks";
|
||||
@@ -8,6 +8,5 @@
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+4
-4
@@ -1,10 +1,10 @@
|
||||
/// <reference types="@dcloudio/types" />
|
||||
|
||||
declare namespace UniNamespace {
|
||||
type LocaldataItem = {
|
||||
type LocaldataItem<T=string> = {
|
||||
text: string;
|
||||
value: string;
|
||||
children?: LocaldataItem[];
|
||||
value: T;
|
||||
children?: LocaldataItem<T>[];
|
||||
};
|
||||
type LocaldataNodeclickValue = {
|
||||
text: string;
|
||||
@@ -15,7 +15,7 @@ declare namespace UniNamespace {
|
||||
type Event<T> = { detail: T };
|
||||
type InputEvent = Event<{ value: string }>;
|
||||
type PickerEvent = Event<{ value: string }>;
|
||||
type PickerViewEvent = Event<{ value: unknown[] }>;
|
||||
type PickerViewEvent<T> = Event<{ value: T[] }>;
|
||||
type ScrollEvent = Event<{ scrollTop: number }>;
|
||||
type SwiperEvent = Event<{ current: number }>;
|
||||
type UniDataPickerEvent = Event<{ value: LocaldataChangeValue[] }>;
|
||||
|
||||
Reference in New Issue
Block a user