1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-07-27 04:20:43 +08:00

refactor: 项目整体重构与功能完善

1. 配置开发环境接口地址
2. 重构工具函数目录结构,迁移防抖节流等工具到base目录
3. 统一页面布局样式为全屏居中布局
4. 新增大量通用工具函数:深克隆、深合并、uuid、时间格式化、随机数、数组打乱、剪贴板操作等
5. 优化自定义tabbar实现,修复交互逻辑
6. 新增列表数据管理hook和请求loading封装
7. 移除无用测试文件和冗余代码
8. 完善首页内容,增加轮播图、作者卡片、金刚区等模块
This commit is contained in:
小莫唐尼
2026-06-11 23:33:54 +08:00
parent 3945ee611d
commit 862d176593
33 changed files with 1787 additions and 251 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ VITE_DELETE_CONSOLE=false
VITE_SHOW_SOURCEMAP=false
# development mode 后台请求地址
VITE_SERVER_BASEURL=''
VITE_SERVER_BASEURL='https://www.yijunzhao.cn'
+99
View File
@@ -0,0 +1,99 @@
import type { Ref } from 'vue'
interface IPaginationParams {
page: number
size: number
}
interface IUpdateDataOptions<T> {
/** 错误信息 */
error: boolean | Error
/** 是否还有更多数据 */
hasNext: boolean
/** 列表数据 */
items: T[]
}
interface IUseListDataOptions<T> {
params: IPaginationParams
/** 加载状态 */
loading: Ref<boolean>
onPullDownRefresh?: (params: IPaginationParams, updateData: (data: IUpdateDataOptions<T>) => void) => void
onReachBottom?: (params: IPaginationParams, updateData: (data: IUpdateDataOptions<T>) => void) => void
}
/**
* useListData是一个定制化的列表数据管理钩子,用于处理列表数据的添加和刷新。
* @param T 列表数据类型,默认为any。
* @returns 返回一个对象{list, append},包含列表数据和数据操作方法。
*/
export function useListData<T>(options: IUseListDataOptions<T>) {
const _hasNext = ref(false)
const _error = ref<boolean | Error>()
const _items = shallowRef<T[]>([])
/** 是否追加数据 */
const isAppend = ref(false)
/** 列表数据 */
const list = shallowRef<T[]>([])
/**
* 数据操作方法
* @param data 新数据数组
*/
function append(data: T[]) {
if (isAppend.value) {
list.value.push(...data)
return
}
list.value = data
}
function _updateData(data: IUpdateDataOptions<T>) {
_error.value = data.error
_hasNext.value = data.hasNext
_items.value = data.items
uni.stopPullDownRefresh()
}
function refresh() {
console.log('刷新数据', options)
if (options.loading.value) {
return
}
isAppend.value = false
options.params.page = 1
options.onPullDownRefresh?.(options.params, _updateData)
}
/** 下拉刷新 */
onPullDownRefresh(refresh)
/** 上拉加载更多 */
onReachBottom(() => {
if (options.loading.value) {
return
}
if (!_hasNext.value) {
return
}
options.params.page += 1
isAppend.value = true
options.onReachBottom?.(options.params, _updateData)
})
/** 监听items变化 */
watch(() => _items.value, (newVal) => {
console.log('监听数据变化', newVal)
console.log('监听数据变化', options)
if (_error.value) {
return
}
append(newVal)
})
return {
list,
refresh,
}
}
+19 -1
View File
@@ -6,6 +6,12 @@ interface IUseRequestOptions<T> {
immediate?: boolean
/** 初始化数据 */
initialData?: T
/** 是否显示加载提示 */
loadingToast?: boolean
/** 加载提示内容 */
loadingToastContent?: string
/** 加载提示是否显示mask */
loadingToastMask?: boolean
}
interface IUseRequestReturn<T, P = undefined> {
@@ -21,17 +27,26 @@ interface IUseRequestReturn<T, P = undefined> {
* @param options 包含请求选项的对象 {immediate, initialData}。
* @param options.immediate 是否立即执行请求,默认为false。
* @param options.initialData 初始化数据,默认为undefined。
* @param options.loadingToast 是否显示加载提示,默认为false。
* @param options.loadingToastContent 加载提示内容,默认为'加载中'。
* @param options.loadingToastMask 加载提示是否显示mask,默认为false。
* @returns 返回一个对象{loading, error, data, run},包含请求的加载状态、错误信息、响应数据和手动触发请求的函数。
*/
export default function useRequest<T, P = undefined>(
func: (args?: P) => Promise<T>,
options: IUseRequestOptions<T> = { immediate: false },
options: IUseRequestOptions<T> = { immediate: false, loadingToast: false, loadingToastContent: '加载中', loadingToastMask: false },
): IUseRequestReturn<T, P> {
const loading = ref(false)
const error = ref(false)
const data = ref<T | undefined>(options.initialData) as Ref<T | undefined>
const run = async (args?: P) => {
loading.value = true
if (options.loadingToast) {
uni.showLoading({
title: options.loadingToastContent,
mask: options.loadingToastMask,
})
}
return func(args)
.then((res) => {
data.value = res
@@ -44,6 +59,9 @@ export default function useRequest<T, P = undefined>(
})
.finally(() => {
loading.value = false
if (options.loadingToast) {
uni.hideLoading()
}
})
}
@@ -11,7 +11,7 @@ definePage({
</script>
<template>
<view class="mt-10 text-center text-green-500">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
页面模板页面
</view>
</template>
+2 -6
View File
@@ -16,7 +16,7 @@ async function doLogin() {
try {
// 调用登录接口
await tokenStore.login({
username: '菲鸽',
username: '小莫唐尼',
password: '123456',
})
uni.navigateBack()
@@ -28,7 +28,7 @@ async function doLogin() {
</script>
<template>
<view class="login">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
<!-- 本页面是非MP的登录页主要用于 h5 APP -->
<view class="text-center">
登录页
@@ -38,7 +38,3 @@ async function doLogin() {
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>
+1 -5
View File
@@ -19,7 +19,7 @@ function doRegister() {
</script>
<template>
<view class="login">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
<view class="text-center">
注册页
</view>
@@ -28,7 +28,3 @@ function doRegister() {
</button>
</view>
</template>
<style lang="scss" scoped>
//
</style>
+1 -1
View File
@@ -11,7 +11,7 @@ definePage({
</script>
<template>
<view class="mt-10 text-center text-green-500">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
相册页面
</view>
</template>
+259 -13
View File
@@ -1,4 +1,12 @@
<script lang="ts" setup>
import useRequest from '@/hooks/useRequest'
import { queryPostByName, queryPosts } from '@/api/halo-base/post'
import { completeUrl } from '@/utils/url'
import { timeFrom } from '@/utils/base/timeFrom'
import { useListData } from '@/hooks/useListData'
import type { ListedPostVo, ListedPostVoList, PostV1alpha1PublicApiQueryPostByNameRequest, PostV1alpha1PublicApiQueryPostsRequest, PostVo } from '@halo-dev/api-client'
defineOptions({
name: 'Home',
})
@@ -8,30 +16,268 @@ definePage({
style: {
navigationStyle: 'custom',
navigationBarTitleText: '首页',
enablePullDownRefresh: true,
},
})
function toRequestDemo() {
uni.navigateTo({
url: '/pages-demo/request-demo/request-demo',
const exampleImageUrls = reactive({
banner: 'https://fastly.picsum.photos/id/353/800/800.jpg?hmac=RaDuQ92sSXj4q5vOYna9G00J7KrBUC3eBS0slCWYZXA',
avatar: 'https://www.xiaoxiaomo.cn/logo.jpg',
postCover1: 'https://lh3.googleusercontent.com/aida-public/AB6AXuDg-QKAXOU1_Lv726MyeTiY2R1MAJWKzjiUy__RYoUw78YL5PctVg2sfMUCRMBybAzw_E0V0fEhir4IW2RHF7YRcxm89JFDKVMlfU_yQTijnr4o-praEXX_buxFWWxsSayLdA64X9pOmjYT5FDp9kfpW4tqZBWLfiyqhiYZnyHTCFeKZruH2auHZt4OlsOdzamOOkoib4CMGmUwTj112emdzlshRDLxGOSbThTWUOw61YirCl-RUrx1WFg2kZimP9Byq4pV7tv9YhtS',
postCover: 'https://www.yijunzhao.cn/upload/HermesWebUIHermesStudio%E8%83%8C%E5%90%8E%E7%9A%84%E5%93%81%E7%89%8C%E5%8D%87%E7%BA%A7%E4%B8%8E%E4%BA%A7%E5%93%81%E8%BF%9B%E5%8C%96-02.png',
})
const banner = reactive({
current: 0,
items: [
exampleImageUrls.banner,
exampleImageUrls.postCover1,
],
list: [
{
value: exampleImageUrls.banner,
type: 'image',
title: 'UNI HALO v3.0',
desc: '新全新探索简约的艺术风格',
time: timeFrom(new Date()),
},
{
value: exampleImageUrls.postCover1,
type: 'image',
title: 'UNI HALO v3.0 使用手册',
desc: 'uni-halo 使用手册,包含安装、配置、使用等信息666666666aaaa啊啊啊啊啊啊啊啊啊啊啊',
time: timeFrom(new Date()),
},
],
})
const quickList = reactive([
{
pagePath: 'pages/moments/moments',
text: '动态',
iconType: 'uiLib',
icon: 'gift',
},
{
pagePath: 'pages-blog/about/about',
text: '关于',
iconType: 'uiLib',
icon: 'image',
},
{
pagePath: 'pages/me/me',
text: '我的',
iconType: 'uiLib',
icon: 'user',
},
{
pagePath: 'pages/category/category',
text: '分类',
iconType: 'uiLib',
icon: 'image',
},
{
pagePath: 'pages/category/category',
text: '其他',
iconType: 'uiLib',
icon: 'image',
},
])
const { loading, error, data, run } = useRequest<ListedPostVoList, PostV1alpha1PublicApiQueryPostsRequest>(queryPosts, { loadingToast: true, loadingToastContent: '加载中...', loadingToastMask: true })
const { loading: loadingSingle, error: errorSingle, data: dataSingle, run: runSingle } = useRequest<PostVo, PostV1alpha1PublicApiQueryPostByNameRequest>(queryPostByName)
const { list: postList, refresh } = useListData<ListedPostVo>({
params: {
page: 1,
size: 10,
},
loading: toRef(loading),
onPullDownRefresh: async (params, updateData) => {
await run({ ...params })
updateData({
error: error.value,
hasNext: data.value?.hasNext,
items: data.value?.items ?? [],
})
}
},
onReachBottom: async (params, updateData) => {
await run({ ...params })
updateData({
error: error.value,
hasNext: data.value?.hasNext,
items: data.value?.items ?? [],
})
},
})
onLoad(() => {
console.log('测试 uni API 自动引入: onLoad')
console.log('home onLoad')
refresh()
runSingle({ name: '019eb1ca-e37c-7729-97af-e0b658aeef0f' })
})
</script>
<template>
<view class="min-h-screen flex flex-col items-center justify-center gap-y-6 bg-page pt-safe">
<image class="h-24 w-24 rounded-lg object-cover" src="/static/logo.png" />
<view class="text-2xl text-blue-500 font-bold">
Uni Halo v3.0.0
<view class="min-h-screen bg-page pb-safe">
<!-- 顶部banner -->
<view v-if="false" class="relative h-72 w-full">
<image class="h-72 w-full object-cover" :src="exampleImageUrls.banner" />
<view class="absolute bottom-4 left-4 flex flex-col gap-y-2">
<text class="text-xs text-white">welcome to new world</text>
<text class="text-2xl text-white font-bold">UNI HALO V3.0 </text>
<text class="text-xs text-white">新全新探索简约的艺术风格</text>
</view>
<view class="mt-4 flex flex-col items-center gap-y-2">
<button @click="toRequestDemo">
请求演示
</button>
</view>
<view>
<wd-swiper
v-model:current="banner.current" :list="banner.items" indicator-position="right" autoplay
:height="290"
>
<template #default="{ index }">
<view class="relative h-full w-full">
<image class="h-full w-full object-cover" :src="banner.list[index].value" />
<view class="absolute bottom-0 left-0 right-0 from-black/10 to-black/90 bg-gradient-to-b p-4">
<view class="w-5/6 flex flex-col gap-y-2">
<text class="text-xs text-white/90">{{ banner.list[index].time }}</text>
<text class="line-clamp-1 text-xl text-white font-bold">{{ banner.list[index].title }}</text>
<text class="line-clamp-1 text-xs text-white/95">{{ banner.list[index].desc }}</text>
</view>
</view>
</view>
</template>
<template #indicator="{ current, total }">
<view class="absolute bottom-4 right-4 text-xs text-white">
{{ current + 1 }}/{{ total }}
</view>
</template>
</wd-swiper>
</view>
<!-- 作者卡片 -->
<view class="mt-4 box-border px-4">
<view class="box-border flex flex-col items-center gap-y-2 rounded-2xl bg-white p-4 shadow-sm">
<view class="w-full flex items-center justify-between gap-x-2">
<image
class="uh-shadow-xs h-11 w-11 shrink-0 border-2 border-white rounded-full border-solid"
src="https://www.xiaoxiaomo.cn/logo.jpg" mode="scaleToFill"
/>
<view class="h-full flex flex-1 flex-col justify-between gap-y-1">
<view class="text-sm text-gray-900 font-bold">
小莫唐尼
</view>
<view class="line-clamp-1 text-xs text-gray-500">
一个爱凑热闹喜欢捣鼓前端的码农
</view>
</view>
<view class="h-full shrink-0 rounded-3xl bg-[#1A1A1A] px-4 py-2 text-xs text-white">
联系我
</view>
</view>
<view class="my-1 h-1px w-full bg-gray-100" />
<view class="grid grid-cols-4 w-full gap-x-4">
<view class="flex flex-col items-center justify-center gap-y-0.5">
<text class="text-sm text-gray-900 font-bold">128</text>
<text class="text-xs text-gray-600">文章</text>
</view>
<view class="flex flex-col items-center justify-center gap-y-0.5">
<text class="text-sm text-gray-900 font-bold">999</text>
<text class="text-xs text-gray-600">访客</text>
</view>
<view class="flex flex-col items-center justify-center gap-y-0.5">
<text class="text-sm text-gray-900 font-bold">1.2k</text>
<text class="text-xs text-gray-600">获赞</text>
</view>
<view class="flex flex-col items-center justify-center gap-y-0.5">
<text class="text-sm text-gray-900 font-bold">128k</text>
<text class="text-xs text-gray-600">收藏</text>
</view>
</view>
</view>
</view>
<!-- 金刚区 -->
<view class="mt-4 px-4">
<view class="flex items-center justify-around gap-x-4">
<view
v-for="item in quickList" :key="item.pagePath"
class="w-1/5 flex flex-col items-center justify-center gap-y-1"
>
<view class="flex items-center justify-center rounded-3xl bg-white p-3.5 shadow-sm">
<wd-icon :name="item.icon" :size="24" />
</view>
<text class="text-xs text-gray-800">{{ item.text }}</text>
</view>
</view>
</view>
<!-- 分类推荐 -->
<!-- 瞬间推荐 -->
<!-- 置顶文章 -->
<view v-if="dataSingle" class="mt-4 box-border px-4">
<view class="mb-2 box-border flex items-center justify-between">
<text class="text-lg text-gray-900 font-bold">置顶文章</text>
</view>
<view class="box-border rounded-xl bg-white p-4">
<image :src="completeUrl(dataSingle.spec.cover)" mode="aspectFill" class="h-36 w-full rounded-xl" />
<view class="box-border w-full flex flex-col gap-y-1 pt-2">
<text class="line-clamp-1 text-sm text-gray-900 font-bold">{{ dataSingle.spec.title }}</text>
<text class="line-clamp-2 text-xs text-gray-500">{{ dataSingle.status.excerpt }}</text>
</view>
</view>
</view>
<!-- 最新文章 -->
<view class="mt-4">
<view class="mb-2 box-border flex items-center justify-between px-4">
<text class="text-lg text-gray-900 font-bold">最新文章</text>
</view>
<view class="box-border w-full flex flex-col items-center justify-center gap-y-4 overflow-hidden px-4">
<view v-if="error && postList.length > 0" class="un-shadow-xs w-full rounded-2xl bg-white py-12">
<wd-empty :icon-size="60" icon="no-result" tip="暂无数据" />
</view>
<template v-else>
<view
v-for="post in postList" :key="post.metadata.name"
class="uh-shadow-xs flex justify-center overflow-hidden rounded-2xl bg-white p-4"
>
<image :src="completeUrl(post.spec.cover)" mode="aspectFill" class="h-22 w-22 shrink-0 rounded-lg" />
<view class="box-border flex-1 pl-4">
<view class="h-full flex flex-1 flex-col justify-between">
<view class="line-clamp-1 text-sm text-gray-900 font-bold">
{{ post.spec.title }}
</view>
<view class="line-clamp-2 text-xs text-gray-500">
{{ post.status.excerpt }}
</view>
<view class="w-full flex items-center gap-x-4">
<view class="text-xs text-gray-600">
{{ timeFrom(post.spec.publishTime) }}
</view>
<view class="text-xs text-gray-600">
·
</view>
<view class="text-xs text-gray-600">
{{ post.categories.map(c => c.spec.displayName).join('·') }}
</view>
</view>
</view>
</view>
</view>
</template>
</view>
</view>
<!-- 底部导航占位 -->
<view class="h-60px w-full" />
</view>
</template>
<style scoped>
.uh-shadow-xs {
box-shadow: 0 0 24rpx rgba(0, 0, 0, 0.035);
}
</style>
+1 -1
View File
@@ -57,7 +57,7 @@ function handleLogout() {
</script>
<template>
<view class="profile-container">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
<view class="mt-3 break-all px-3 text-center text-green-500">
{{ userInfo.username ? '已登录' : '未登录' }}
</view>
+1 -1
View File
@@ -11,7 +11,7 @@ definePage({
</script>
<template>
<view class="mt-10 text-center text-green-500">
<view class="min-h-screen w-full flex flex-col items-center justify-center gap-y-6 bg-page">
瞬间页面
</view>
</template>
-69
View File
@@ -1,69 +0,0 @@
import type { CustomTabBarItem } from './types'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import TabbarItem from './TabbarItem.vue'
// mock tabbar store,避免 uni.getStorageSync 在模块加载时执行
vi.mock('./store', () => ({
tabbarStore: { curIdx: 0 },
}))
const baseItem: CustomTabBarItem = {
text: '首页',
pagePath: 'pages/index/index',
iconType: 'unocss',
icon: 'i-carbon-home',
}
describe('TabbarItem', () => {
let wrapper: ReturnType<typeof mount>
afterEach(() => {
wrapper?.unmount()
})
it('渲染 text 文本', () => {
wrapper = mount(TabbarItem, {
props: { item: baseItem, index: 0 },
})
expect(wrapper.text()).toContain('首页')
})
it('isBulge=true 时不渲染文本', () => {
wrapper = mount(TabbarItem, {
props: { item: baseItem, index: 0, isBulge: true },
})
expect(wrapper.text()).not.toContain('首页')
})
it('iconType=unocss 时渲染图标 class', () => {
wrapper = mount(TabbarItem, {
props: { item: baseItem, index: 0 },
})
expect(wrapper.html()).toContain('i-carbon-home')
})
it('badge=dot 时渲染小红点(包含 rounded-full 样式)', () => {
const item: CustomTabBarItem = { ...baseItem, badge: 'dot' }
wrapper = mount(TabbarItem, {
props: { item, index: 0 },
})
expect(wrapper.html()).toContain('rounded-full')
})
it('badge 为数字时渲染数字角标', () => {
const item: CustomTabBarItem = { ...baseItem, badge: 5 }
wrapper = mount(TabbarItem, {
props: { item, index: 0 },
})
expect(wrapper.text()).toContain('5')
})
it('badge > 99 时显示 99+', () => {
const item: CustomTabBarItem = { ...baseItem, badge: 100 }
wrapper = mount(TabbarItem, {
props: { item, index: 0 },
})
expect(wrapper.text()).toContain('99+')
})
})
+56 -13
View File
@@ -1,7 +1,15 @@
<script setup lang="ts">
import type { CustomTabBarItem } from './types'
import { getI18nText } from './i18n'
import { tabbarStore } from './store'
import { tabbarList, tabbarStore } from './store'
import { tabbarCacheEnable } from './config'
defineOptions({
name: 'TabbarItem',
// #ifdef MP-WEIXIN
virtualHost: true,
// #endif
})
const props = defineProps<{
count: number
@@ -35,44 +43,69 @@ function activeBgColor() {
}, 0)
}
watch(() => tabbarStore.curIdx, (val) => {
watch(() => tabbarStore.curIdx, () => {
activeBgColor()
})
function handleClick(index: number) {
// 点击原来的不做操作
if (index === tabbarStore.curIdx) {
return
}
const list = tabbarList.value
if (!list[index]) {
return
}
const url = list[index].pagePath
tabbarStore.setCurIdx(index)
if (tabbarCacheEnable) {
uni.switchTab({ url })
}
else {
uni.navigateTo({ url })
}
}
</script>
<template>
<view
class="relative h-full flex items-center justify-center gap-x-1 rounded-full transition-all duration-200" :class="{
'!px-3 shrink-0': isActiveByIndex(index),
class="relative h-full flex items-center justify-center gap-x-1 rounded-full text-center transition-all duration-200"
:class="{
'!px-6 shrink-0': isActiveByIndex(index),
'text-white': isActiveByIndex(index) && isActive,
'flex-1': !isActiveByIndex(index),
// 'pl-3': index === 0,
// 'pr-3': index === count - 1,
}"
}" @click="handleClick(index)"
>
<!-- 背景 -->
<view
v-if="!props.globalActiveSliderBar"
class="absolute bottom-0 left-0 z-[-1] h-full w-full origin-center rounded-full bg-[#1A1A1A] transition-all duration-200"
class="absolute bottom-0 left-0 z-0 h-full w-full origin-center rounded-full bg-[#1A1A1A] transition-all duration-300"
:class="[
// isActiveByIndex(index) && isActive ? 'left-0 opacity-100' : '-left-12 opacity-0',
isActiveByIndex(index) && isActive ? 'scale-100' : 'scale-0',
isActiveByIndex(index) && isActive ? 'uh-scale-100' : 'uh-scale-0',
]"
/>
<template v-if="item.iconType === 'uiLib'">
<!-- <wd-icon name="home" /> (https://wot-design-uni.cn/component/icon.html) -->
<wd-icon :name="item.icon" :size="isActiveByIndex(index) ? 14 : 22" />
<wd-icon
class="relative z-10 transition-all duration-50" :name="item.icon"
:size="isActiveByIndex(index) ? 14 : 22"
/>
</template>
<template v-if="item.iconType === 'unocss' || item.iconType === 'iconfont'">
<view class="font-bold transition-all duration-200" :class="[item.icon, isActiveByIndex(index) ? 'text-sm' : 'text-lg']" />
<view
class="relative z-10 font-bold transition-all duration-50"
:class="[item.icon, isActiveByIndex(index) ? 'text-sm' : 'text-lg']"
/>
</template>
<template v-if="item.iconType === 'image'">
<image
:src="getImageByIndex(index, item)" mode="scaleToFill"
:src="getImageByIndex(index, item)" mode="scaleToFill" class="relative z-10 transition-all duration-50"
:class="isActiveByIndex(index) ? 'h-16px w-16px' : 'h-22px w-22px'"
/>
</template>
<view v-if="isActiveByIndex(index)" class="text-xs font-bold">
<view v-if="isActiveByIndex(index)" class="relative z-10 text-xs text-white font-bold">
{{ getI18nText(item.text) }}
</view>
<!-- 角标显示 -->
@@ -90,3 +123,13 @@ watch(() => tabbarStore.curIdx, (val) => {
</template>
</view>
</template>
<style scoped>
.uh-scale-0 {
transform: scale(0);
}
.uh-scale-100 {
transform: scale(1);
}
</style>
+7 -18
View File
@@ -43,19 +43,10 @@ export const customTabbarList: CustomTabBarItem[] = [
{
text: '%tabbar.home%',
pagePath: 'pages/index/index',
// 注意 unocss 图标需要如下处理:(二选一)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-home',
// badge: 'dot',
},
{
pagePath: 'pages-demo/i18n/index',
text: '%tabbar.i18n%',
iconType: 'unocss',
icon: 'i-carbon-ibm-watson-language-translator',
// badge: 10,
},
{
pagePath: 'pages/gallery/gallery',
text: '%tabbar.gallery%',
@@ -68,16 +59,14 @@ export const customTabbarList: CustomTabBarItem[] = [
iconType: 'uiLib',
icon: 'gift',
},
{
pagePath: 'pages-blog/about/about',
text: '%tabbar.about%',
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-menu',
// {
// pagePath: 'pages-blog/about/about',
// text: '%tabbar.about%',
// iconType: 'unocss',
// icon: 'i-carbon-menu',
// badge: 10,
roles: ['admin'],
},
// roles: ['admin'],
// },
{
pagePath: 'pages/me/me',
text: '%tabbar.me%',
+26 -45
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { getCurrentInstance } from 'vue'
// i-carbon-code
import { customTabbarEnable, needHideNativeTabbar, tabbarCacheEnable } from './config'
import { customTabbarEnable, needHideNativeTabbar } from './config'
import { setTabbarItem } from './i18n'
import { tabbarList, tabbarStore } from './store'
import { sleep } from '@/utils/common'
@@ -16,50 +15,18 @@ defineOptions({
const instance = getCurrentInstance()
// 临时配置
// tabBar配置
const tabBarConfig = reactive({
useFloat: true,
useGlobalActiveSliderBar: false,
})
/**
* 中间的鼓包tabbarItem的点击事件
*/
function handleClickBulge() {
uni.showToast({
title: '点击了中间的鼓包tabbarItem',
icon: 'none',
})
}
function handleClick(index: number) {
// 点击原来的不做操作
if (index === tabbarStore.curIdx) {
return
}
const list = tabbarList.value
if (!list[index]) {
return
}
if (list[index].isBulge) {
handleClickBulge()
return
}
const url = list[index].pagePath
tabbarStore.setCurIdx(index)
if (tabbarCacheEnable) {
uni.switchTab({ url })
}
else {
uni.navigateTo({ url })
}
}
// #ifndef MP-WEIXIN || MP-ALIPAY
// 因为有了 custom:true 微信里面不需要多余的hide操作
onLoad(() => {
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
needHideNativeTabbar
&& uni.hideTabBar({
if (needHideNativeTabbar) {
uni.hideTabBar({
fail(err) {
console.log('hideTabBar fail: ', err)
},
@@ -67,14 +34,16 @@ onLoad(() => {
// console.log('hideTabBar success: ', res)
},
})
}
})
// #endif
// #ifdef MP-ALIPAY
onMounted(() => {
// 解决支付宝自定义tabbar 未隐藏导致有2个 tabBar 的问题; 注意支付宝很特别,需要在 onMounted 钩子调用
customTabbarEnable // 另外,支付宝里面,只要是 customTabbar 都需要隐藏
&& uni.hideTabBar({
// 另外,支付宝里面,只要是 customTabbar 都需要隐藏
if (customTabbarEnable) {
uni.hideTabBar({
fail(err) {
console.log('hideTabBar fail: ', err)
},
@@ -82,6 +51,7 @@ onMounted(() => {
// console.log('hideTabBar success: ', res)
},
})
}
})
// #endif
@@ -92,6 +62,10 @@ const activeDomStyle = reactive({
width: '70px',
})
function isActiveByIndex(index: number) {
return tabbarStore.curIdx === index
}
function setActiveStyle(index: number) {
activeDomStyle.left = `${tabbarRect.value[index].left - 21}px`
activeDomStyle.width = `${tabbarRect.value[index].width + 9}px`
@@ -108,7 +82,6 @@ function getTabBarRect() {
query
.selectAll('.tabbar-item')
.boundingClientRect(async (data) => {
console.log(data)
tabbarRect.value = (data as UniApp.NodeInfo[]).map(item => ({
left: item.left,
width: item.width,
@@ -135,14 +108,16 @@ onMounted(() => {
</script>
<template>
<view v-if="customTabbarEnable" :class="{ 'h-50px pb-safe': !tabBarConfig.useFloat }">
<view class="fixed bottom-4 left-4 right-4 z-100 flex items-center justify-between gap-x-4" @touchmove.stop.prevent>
<view v-if="customTabbarEnable" :class="{ 'h-54px pb-safe': !tabBarConfig.useFloat }">
<view class="fixed bottom-6 left-4 right-4 z-100 flex items-center justify-between gap-x-4" @touchmove.stop.prevent>
<view
class="relative box-border h-50px flex flex-1 items-center overflow-hidden border border-white rounded-full border-solid bg-white/95 p-1 shadow-[0_0_10px_rgba(0,0,0,0.075)] backdrop-blur-[3px]"
class="uh-shadow-sm relative box-border h-54px flex flex-1 items-center overflow-hidden border border-white rounded-full border-solid bg-white/95 p-1 backdrop-blur-[3px]"
>
<TabbarItem
v-for="(item, index) in tabbarList" :key="index" :item="item" :count="tabbarList.length"
:index="index" :global-active-slider-bar="tabBarConfig.useGlobalActiveSliderBar" class="tabbar-item relative text-center" @click="handleClick(index)"
:index="index" :global-active-slider-bar="tabBarConfig.useGlobalActiveSliderBar"
class="tabbar-item h-full"
:class="[isActiveByIndex(index) ? 'shrink-0' : 'flex-1']"
/>
<!-- 激活滑块 -->
<view
@@ -155,7 +130,7 @@ onMounted(() => {
</view>
<!-- 右侧搜索框或者菜单 -->
<view
class="box-border h-50px w-50px flex items-center justify-center border border-white rounded-full border-solid bg-white/95 text-2xl shadow-[0_0_10px_rgba(0,0,0,0.075)] backdrop-blur-[3px]"
class="uh-shadow-sm box-border h-54px w-54px flex items-center justify-center border border-white rounded-full border-solid bg-white/95 text-2xl backdrop-blur-[3px]"
>
+
</view>
@@ -164,3 +139,9 @@ onMounted(() => {
</view>
</view>
</template>
<style scoped>
.uh-shadow-sm {
box-shadow: 0 0 24rpx rgba(0, 0, 0, 0.075);
}
</style>
+21
View File
@@ -0,0 +1,21 @@
/**
* 复制文本到剪贴板
* @param {string} content 要复制的文本
* @param {boolean} showToast 是否显示复制成功提示
*/
export function copy(content: string, showToast: boolean = false) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
if (showToast) {
uni.showToast({
title: '复制成功',
icon: 'success',
})
}
},
})
}
export default copy
+146
View File
@@ -0,0 +1,146 @@
/**
* 求两个颜色之间的渐变值
* @param startColor 开始的颜色
* @param endColor 结束的颜色
* @param step 颜色等分的份额
* @returns 渐变色数组
*/
export function colorGradient(
startColor: string = 'rgb(0, 0, 0)',
endColor: string = 'rgb(255, 255, 255)',
step: number = 10,
): string[] {
const startRGB = hexToRgb(startColor, false) as [number, number, number] // 转换为rgb数组模式
const [startR, startG, startB] = startRGB
const endRGB = hexToRgb(endColor, false) as [number, number, number]
const [endR, endG, endB] = endRGB
const sR = (endR - startR) / step // 总差值
const sG = (endG - startG) / step
const sB = (endB - startB) / step
const colorArr: string[] = []
for (let i = 0; i < step; i++) {
// 计算每一步的hex值
const hex = rgbToHex(
`rgb(${Math.round(sR * i + startR)},${Math.round(sG * i + startG)},${Math.round(sB * i + startB)})`,
)
colorArr.push(hex as string)
}
return colorArr
}
/**
* 将hex表示方式转换为rgb表示方式(返回rgb数组或字符串)
* @param sColor hex或rgb字符串
* @param str 是否返回字符串
* @returns rgb数组或字符串
*/
export function hexToRgb(sColor: string, str: boolean = true): [number, number, number] | string {
const reg = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
sColor = sColor.toLowerCase()
if (sColor && reg.test(sColor)) {
if (sColor.length === 4) {
let sColorNew = '#'
for (let i = 1; i < 4; i += 1) {
sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1))
}
sColor = sColorNew
}
// 处理六位的颜色值
const sColorChange: [number, number, number] = [
Number.parseInt(`0x${sColor.slice(1, 3)}`),
Number.parseInt(`0x${sColor.slice(3, 5)}`),
Number.parseInt(`0x${sColor.slice(5, 7)}`),
]
if (!str) {
return sColorChange
}
else {
return `rgb(${sColorChange[0]},${sColorChange[1]},${sColorChange[2]})`
}
}
else if (/^(?:rgb|RGB)/.test(sColor)) {
const arr = sColor.replace(/(?:\(|\)|rgb|RGB)*/g, '').split(',')
return arr.map(val => Number(val)) as [number, number, number]
}
else {
return sColor
}
}
/**
* rgb转hex
* @param rgb rgb字符串或hex字符串
* @returns hex字符串
*/
export function rgbToHex(rgb: string): string | undefined {
const reg = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
if (/^(?:rgb|RGB)/.test(rgb)) {
const aColor = rgb.replace(/(?:\(|\)|rgb|RGB)*/g, '').split(',')
let strHex = '#'
for (let i = 0; i < aColor.length; i++) {
let hex = Number(aColor[i]).toString(16)
hex = hex.length === 1 ? `0${hex}` : hex // 保证每个rgb的值为2位
strHex += hex
}
if (strHex.length !== 7) {
strHex = rgb
}
return strHex
}
else if (reg.test(rgb)) {
const aNum = rgb.replace(/#/, '').split('')
if (aNum.length === 6) {
return rgb
}
else if (aNum.length === 3) {
let numHex = '#'
for (let i = 0; i < aNum.length; i += 1) {
numHex += aNum[i] + aNum[i]
}
return numHex
}
}
else {
return rgb
}
// 默认返回原始值
return rgb
}
/**
* JS颜色十六进制转换为rgb或rgba,返回的格式为 rgba2552552550.5)字符串
* @param color 十六进制色值或rgb字符串
* @param alpha 透明度
* @returns rgba字符串
*/
function colorToRgba(color: string, alpha: number = 0.3): string {
color = rgbToHex(color) as string // 确保是hex格式
const reg = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i
let sColor = color.toLowerCase()
if (sColor && reg.test(sColor)) {
if (sColor.length === 4) {
let sColorNew = '#'
for (let i = 1; i < 4; i += 1) {
sColorNew += sColor.slice(i, i + 1).concat(sColor.slice(i, i + 1))
}
sColor = sColorNew
}
const sColorChange: [number, number, number] = [
Number.parseInt(`0x${sColor.slice(1, 3)}`),
Number.parseInt(`0x${sColor.slice(3, 5)}`),
Number.parseInt(`0x${sColor.slice(5, 7)}`),
]
return `rgba(${sColorChange.join(',')},${alpha})`
}
else {
return sColor
}
}
export default {
colorGradient,
hexToRgb,
rgbToHex,
colorToRgba,
}
+47
View File
@@ -0,0 +1,47 @@
// 判断arr是否为一个数组,返回一个bool值
export function isArray(arr: any): arr is any[] {
return Object.prototype.toString.call(arr) === '[object Array]'
}
/**
* 深度克隆
* @param obj 需要克隆的对象
* @param cache 用于处理循环引用的 WeakMap
* @returns 克隆后的对象
*/
export function deepClone<T>(obj: T, cache: WeakMap<any, any> = new WeakMap()): T {
if (obj === null || typeof obj !== 'object')
return obj
if (cache.has(obj))
return cache.get(obj)
let clone: any
if (obj instanceof Date) {
clone = new Date(obj.getTime())
}
else if (obj instanceof RegExp) {
clone = new RegExp(obj)
}
else if (obj instanceof Map) {
clone = new Map(Array.from(obj, ([key, value]) => [key, deepClone(value, cache)]))
}
else if (obj instanceof Set) {
clone = new Set(Array.from(obj, value => deepClone(value, cache)))
}
else if (Array.isArray(obj)) {
clone = obj.map(value => deepClone(value, cache))
}
else if (Object.prototype.toString.call(obj) === '[object Object]') {
clone = Object.create(Object.getPrototypeOf(obj))
cache.set(obj, clone)
for (const [key, value] of Object.entries(obj)) {
clone[key] = deepClone(value, cache)
}
}
else {
clone = Object.assign({}, obj)
}
cache.set(obj, clone)
return clone
}
export default deepClone
+41
View File
@@ -0,0 +1,41 @@
import deepClone from './deepClone'
/**
* JS对象深度合并
* @param target 目标对象
* @param source 源对象
* @returns 合并后的对象
*/
export function deepMerge<T extends object, S extends object>(target: T = {} as T, source: S = {} as S): T & S {
target = deepClone(target)
if (typeof target !== 'object' || target === null || typeof source !== 'object' || source === null)
return target as T & S
const merged: any = Array.isArray(target) ? target.slice() : Object.assign({}, target)
for (const prop in source) {
if (!Object.prototype.hasOwnProperty.call(source, prop))
continue
const sourceValue = (source as any)[prop]
const targetValue = merged[prop]
if (sourceValue instanceof Date) {
merged[prop] = new Date(sourceValue)
}
else if (sourceValue instanceof RegExp) {
merged[prop] = new RegExp(sourceValue)
}
else if (sourceValue instanceof Map) {
merged[prop] = new Map(sourceValue)
}
else if (sourceValue instanceof Set) {
merged[prop] = new Set(sourceValue)
}
else if (typeof sourceValue === 'object' && sourceValue !== null) {
merged[prop] = deepMerge(targetValue, sourceValue)
}
else {
merged[prop] = sourceValue
}
}
return merged as T & S
}
export default deepMerge
+29
View File
@@ -0,0 +1,29 @@
/**
* 获取元素的位置信息
* @param {any} selector 选择器
* @param {boolean} all 是否获取所有匹配元素
* @returns {Promise<any>} 返回一个 Promise,解析为元素的位置信息
*/
import { getCurrentInstance } from 'vue'
export function getRect(selector: any, _instance: any = null, all: boolean = false): Promise<any> {
const instance = _instance || getCurrentInstance()
return new Promise((resolve) => {
const query = uni.createSelectorQuery()
.in(instance?.proxy)
query[all ? 'selectAll' : 'select'](selector)
.boundingClientRect((rect: any) => {
if (all && Array.isArray(rect) && rect.length) {
resolve(rect)
}
if (!all && rect) {
resolve(rect)
}
})
.exec()
})
}
export default getRect
+44
View File
@@ -0,0 +1,44 @@
/**
* 本算法来源于简书开源代码,详见:https://www.jianshu.com/p/fdbf293d0a85
* 全局唯一标识符(uuidGlobally Unique Identifier,也称作 uuid(Universally Unique IDentifier)
* 一般用于多个组件之间,给它一个唯一的标识符,或者v-for循环的时候,如果使用数组的index可能会导致更新列表出现问题
* 最可能的情况是左滑删除item或者对某条信息流"不喜欢"并去掉它的时候,会导致组件内的数据可能出现错乱
* v-for的时候,推荐使用后端返回的id而不是循环的index
* @param len uuid的长度,默认32
* @param firstU 将返回的首字母置为"u",默认true
* @param radix 生成uuid的基数(意味着返回的字符串都是这个基数),2-二进制,8-八进制,10-十进制,16-十六进制
* @returns 生成的uuid字符串
*/
function guid(len: number = 32, firstU: boolean = true, radix?: number): string {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('')
const uuid: string[] = []
const base = radix || chars.length
if (len) {
// 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位
for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * base)]
}
else {
let r: number
// rfc4122标准要求返回的uuid中,某些位为固定的字符
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-'
uuid[14] = '4'
for (let i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | (Math.random() * 16)
uuid[i] = chars[i === 19 ? (r & 0x3) | 0x8 : r]
}
}
}
// 移除第一个字符,并用u替代,因为第一个字符为数值时,该guid不能用作id或者class
if (firstU) {
uuid.shift()
return `u${uuid.join('')}`
}
else {
return uuid.join('')
}
}
export default guid
+414
View File
@@ -0,0 +1,414 @@
/*
* A JavaScript implementation of the RSA Data Security, Inc. MD5 Message
* Digest Algorithm, as defined in RFC 1321.
* Version 2.2 Copyright (C) Paul Johnston 1999 - 2009
* Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet
* Distributed under the BSD License
* See http://pajhome.org.uk/crypt/md5 for more info.
*/
/*
* Configurable variables. You may need to tweak these to be compatible with
* the server-side, but the defaults work in most cases.
*/
let hexcase: number = 0 /* hex output format. 0 - lowercase; 1 - uppercase */
let b64pad: string = '' /* base-64 pad character. "=" for strict RFC compliance */
/**
* 这些是通常需要调用的函数
* @param s 输入字符串
* @returns 十六进制/BASE64/任意编码的MD5字符串
*/
function hex_md5(s: string): string {
return rstr2hex(rstr_md5(str2rstr_utf8(s)))
}
function b64_md5(s: string): string {
return rstr2b64(rstr_md5(str2rstr_utf8(s)))
}
function any_md5(s: string, e: string): string {
return rstr2any(rstr_md5(str2rstr_utf8(s)), e)
}
function hex_hmac_md5(k: string, d: string): string {
return rstr2hex(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d)))
}
function b64_hmac_md5(k: string, d: string): string {
return rstr2b64(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d)))
}
function any_hmac_md5(k: string, d: string, e: string): string {
return rstr2any(rstr_hmac_md5(str2rstr_utf8(k), str2rstr_utf8(d)), e)
}
/**
* 执行简单自测,判断 VM 是否正常
* @returns 是否通过测试
*/
function md5_vm_test(): boolean {
return hex_md5('abc').toLowerCase() === '900150983cd24fb0d6963f7d28e17f72'
}
/**
* 计算原始字符串的 MD5
* @param s 原始字符串
* @returns MD5 结果字符串
*/
function rstr_md5(s: string): string {
return binl2rstr(binl_md5(rstr2binl(s), s.length * 8))
}
/**
* 计算 HMAC-MD5
* @param key 密钥
* @param data 数据
* @returns HMAC-MD5 结果字符串
*/
function rstr_hmac_md5(key: string, data: string): string {
let bkey: number[] = rstr2binl(key)
if (bkey.length > 16)
bkey = binl_md5(bkey, key.length * 8)
const ipad: number[] = Array.from({ length: 16 })
const opad: number[] = Array.from({ length: 16 })
for (let i = 0; i < 16; i++) {
ipad[i] = bkey[i] ^ 0x36363636
opad[i] = bkey[i] ^ 0x5C5C5C5C
}
const hash: number[] = binl_md5(ipad.concat(rstr2binl(data)), 512 + data.length * 8)
return binl2rstr(binl_md5(opad.concat(hash), 512 + 128))
}
/**
* 原始字符串转十六进制字符串
* @param input 输入字符串
* @returns 十六进制字符串
*/
function rstr2hex(input: string): string {
try {
void hexcase
}
catch (e) {
hexcase = 0
}
const hex_tab: string = hexcase ? '0123456789ABCDEF' : '0123456789abcdef'
let output = ''
let x: number
for (let i = 0; i < input.length; i++) {
x = input.charCodeAt(i)
output += hex_tab.charAt((x >>> 4) & 0x0F) + hex_tab.charAt(x & 0x0F)
}
return output
}
/**
* 原始字符串转 BASE64 字符串
* @param input 输入字符串
* @returns BASE64 字符串
*/
function rstr2b64(input: string): string {
try {
void b64pad
}
catch (e) {
b64pad = ''
}
const tab: string = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
let output = ''
const len: number = input.length
for (let i = 0; i < len; i += 3) {
const triplet: number
= (input.charCodeAt(i) << 16)
| (i + 1 < len ? input.charCodeAt(i + 1) << 8 : 0)
| (i + 2 < len ? input.charCodeAt(i + 2) : 0)
for (let j = 0; j < 4; j++) {
if (i * 8 + j * 6 > input.length * 8)
output += b64pad
else output += tab.charAt((triplet >>> (6 * (3 - j))) & 0x3F)
}
}
return output
}
/**
* 原始字符串转任意编码字符串
* @param input 输入字符串
* @param encoding 编码表
* @returns 编码后的字符串
*/
function rstr2any(input: string, encoding: string): string {
const divisor: number = encoding.length
let i: number, j: number, q: number, x: number, quotient: number[]
/* Convert to an array of 16-bit big-endian values, forming the dividend */
let dividend: number[] = Array.from({ length: Math.ceil(input.length / 2) })
for (i = 0; i < dividend.length; i++) {
dividend[i] = (input.charCodeAt(i * 2) << 8) | input.charCodeAt(i * 2 + 1)
}
/*
* Repeatedly perform a long division. The binary array forms the dividend,
* the length of the encoding is the divisor. Once computed, the quotient
* forms the dividend for the next step. All remainders are stored for later
* use.
*/
const full_length: number = Math.ceil((input.length * 8) / (Math.log(encoding.length) / Math.log(2)))
const remainders: number[] = Array.from({ length: full_length })
for (j = 0; j < full_length; j++) {
quotient = []
x = 0
for (i = 0; i < dividend.length; i++) {
x = (x << 16) + dividend[i]
q = Math.floor(x / divisor)
x -= q * divisor
if (quotient.length > 0 || q > 0)
quotient[quotient.length] = q
}
remainders[j] = x
dividend = quotient
}
/* Convert the remainders to the output string */
let output = ''
for (i = remainders.length - 1; i >= 0; i--) output += encoding.charAt(remainders[i])
return output
}
/**
* 字符串转 UTF-8 编码
* @param input 输入字符串
* @returns UTF-8 编码字符串
*/
function str2rstr_utf8(input: string): string {
let output = ''
let i = -1
let x: number, y: number
while (++i < input.length) {
/* Decode utf-16 surrogate pairs */
x = input.charCodeAt(i)
y = i + 1 < input.length ? input.charCodeAt(i + 1) : 0
if (x >= 0xD800 && x <= 0xDBFF && y >= 0xDC00 && y <= 0xDFFF) {
x = 0x10000 + ((x & 0x03FF) << 10) + (y & 0x03FF)
i++
}
/* Encode output as utf-8 */
if (x <= 0x7F) {
output += String.fromCharCode(x)
}
else if (x <= 0x7FF) {
output += String.fromCharCode(0xC0 | ((x >>> 6) & 0x1F), 0x80 | (x & 0x3F))
}
else if (x <= 0xFFFF) {
output += String.fromCharCode(0xE0 | ((x >>> 12) & 0x0F), 0x80 | ((x >>> 6) & 0x3F), 0x80 | (x & 0x3F))
}
else if (x <= 0x1FFFFF) {
output += String.fromCharCode(
0xF0 | ((x >>> 18) & 0x07),
0x80 | ((x >>> 12) & 0x3F),
0x80 | ((x >>> 6) & 0x3F),
0x80 | (x & 0x3F),
)
}
}
return output
}
/**
* 字符串转 UTF-16LE 编码
* @param input 输入字符串
* @returns UTF-16LE 编码字符串
*/
function str2rstr_utf16le(input: string): string {
let output = ''
for (let i = 0; i < input.length; i++)
output += String.fromCharCode(input.charCodeAt(i) & 0xFF, (input.charCodeAt(i) >>> 8) & 0xFF)
return output
}
/**
* 字符串转 UTF-16BE 编码
* @param input 输入字符串
* @returns UTF-16BE 编码字符串
*/
function str2rstr_utf16be(input: string): string {
let output = ''
for (let i = 0; i < input.length; i++)
output += String.fromCharCode((input.charCodeAt(i) >>> 8) & 0xFF, input.charCodeAt(i) & 0xFF)
return output
}
/**
* 原始字符串转小端字数组
* @param input 输入字符串
* @returns 小端字数组
*/
function rstr2binl(input: string): number[] {
const output: number[] = Array.from({ length: input.length >> 2 })
for (let i = 0; i < output.length; i++) output[i] = 0
for (let i = 0; i < input.length * 8; i += 8) output[i >> 5] |= (input.charCodeAt(i / 8) & 0xFF) << i % 32
return output
}
/**
* 小端字数组转字符串
* @param input 小端字数组
* @returns 字符串
*/
function binl2rstr(input: number[]): string {
let output = ''
for (let i = 0; i < input.length * 32; i += 8) output += String.fromCharCode((input[i >> 5] >>> i % 32) & 0xFF)
return output
}
/**
* 计算小端字数组的 MD5
* @param x 小端字数组
* @param len 位长度
* @returns MD5 结果数组
*/
function binl_md5(x: number[], len: number): number[] {
/* append padding */
x[len >> 5] |= 0x80 << len % 32
x[(((len + 64) >>> 9) << 4) + 14] = len
let a = 1732584193
let b = -271733879
let c = -1732584194
let d = 271733878
for (let i = 0; i < x.length; i += 16) {
const olda = a
const oldb = b
const oldc = c
const oldd = d
a = md5_ff(a, b, c, d, x[i + 0], 7, -680876936)
d = md5_ff(d, a, b, c, x[i + 1], 12, -389564586)
c = md5_ff(c, d, a, b, x[i + 2], 17, 606105819)
b = md5_ff(b, c, d, a, x[i + 3], 22, -1044525330)
a = md5_ff(a, b, c, d, x[i + 4], 7, -176418897)
d = md5_ff(d, a, b, c, x[i + 5], 12, 1200080426)
c = md5_ff(c, d, a, b, x[i + 6], 17, -1473231341)
b = md5_ff(b, c, d, a, x[i + 7], 22, -45705983)
a = md5_ff(a, b, c, d, x[i + 8], 7, 1770035416)
d = md5_ff(d, a, b, c, x[i + 9], 12, -1958414417)
c = md5_ff(c, d, a, b, x[i + 10], 17, -42063)
b = md5_ff(b, c, d, a, x[i + 11], 22, -1990404162)
a = md5_ff(a, b, c, d, x[i + 12], 7, 1804603682)
d = md5_ff(d, a, b, c, x[i + 13], 12, -40341101)
c = md5_ff(c, d, a, b, x[i + 14], 17, -1502002290)
b = md5_ff(b, c, d, a, x[i + 15], 22, 1236535329)
a = md5_gg(a, b, c, d, x[i + 1], 5, -165796510)
d = md5_gg(d, a, b, c, x[i + 6], 9, -1069501632)
c = md5_gg(c, d, a, b, x[i + 11], 14, 643717713)
b = md5_gg(b, c, d, a, x[i + 0], 20, -373897302)
a = md5_gg(a, b, c, d, x[i + 5], 5, -701558691)
d = md5_gg(d, a, b, c, x[i + 10], 9, 38016083)
c = md5_gg(c, d, a, b, x[i + 15], 14, -660478335)
b = md5_gg(b, c, d, a, x[i + 4], 20, -405537848)
a = md5_gg(a, b, c, d, x[i + 9], 5, 568446438)
d = md5_gg(d, a, b, c, x[i + 14], 9, -1019803690)
c = md5_gg(c, d, a, b, x[i + 3], 14, -187363961)
b = md5_gg(b, c, d, a, x[i + 8], 20, 1163531501)
a = md5_gg(a, b, c, d, x[i + 13], 5, -1444681467)
d = md5_gg(d, a, b, c, x[i + 2], 9, -51403784)
c = md5_gg(c, d, a, b, x[i + 7], 14, 1735328473)
b = md5_gg(b, c, d, a, x[i + 12], 20, -1926607734)
a = md5_hh(a, b, c, d, x[i + 5], 4, -378558)
d = md5_hh(d, a, b, c, x[i + 8], 11, -2022574463)
c = md5_hh(c, d, a, b, x[i + 11], 16, 1839030562)
b = md5_hh(b, c, d, a, x[i + 14], 23, -35309556)
a = md5_hh(a, b, c, d, x[i + 1], 4, -1530992060)
d = md5_hh(d, a, b, c, x[i + 4], 11, 1272893353)
c = md5_hh(c, d, a, b, x[i + 7], 16, -155497632)
b = md5_hh(b, c, d, a, x[i + 10], 23, -1094730640)
a = md5_hh(a, b, c, d, x[i + 13], 4, 681279174)
d = md5_hh(d, a, b, c, x[i + 0], 11, -358537222)
c = md5_hh(c, d, a, b, x[i + 3], 16, -722521979)
b = md5_hh(b, c, d, a, x[i + 6], 23, 76029189)
a = md5_hh(a, b, c, d, x[i + 9], 4, -640364487)
d = md5_hh(d, a, b, c, x[i + 12], 11, -421815835)
c = md5_hh(c, d, a, b, x[i + 15], 16, 530742520)
b = md5_hh(b, c, d, a, x[i + 2], 23, -995338651)
a = md5_ii(a, b, c, d, x[i + 0], 6, -198630844)
d = md5_ii(d, a, b, c, x[i + 7], 10, 1126891415)
c = md5_ii(c, d, a, b, x[i + 14], 15, -1416354905)
b = md5_ii(b, c, d, a, x[i + 5], 21, -57434055)
a = md5_ii(a, b, c, d, x[i + 12], 6, 1700485571)
d = md5_ii(d, a, b, c, x[i + 3], 10, -1894986606)
c = md5_ii(c, d, a, b, x[i + 10], 15, -1051523)
b = md5_ii(b, c, d, a, x[i + 1], 21, -2054922799)
a = md5_ii(a, b, c, d, x[i + 8], 6, 1873313359)
d = md5_ii(d, a, b, c, x[i + 15], 10, -30611744)
c = md5_ii(c, d, a, b, x[i + 6], 15, -1560198380)
b = md5_ii(b, c, d, a, x[i + 13], 21, 1309151649)
a = md5_ii(a, b, c, d, x[i + 4], 6, -145523070)
d = md5_ii(d, a, b, c, x[i + 11], 10, -1120210379)
c = md5_ii(c, d, a, b, x[i + 2], 15, 718787259)
b = md5_ii(b, c, d, a, x[i + 9], 21, -343485551)
a = safe_add(a, olda)
b = safe_add(b, oldb)
c = safe_add(c, oldc)
d = safe_add(d, oldd)
}
return [a, b, c, d]
}
/**
* 四种基本操作
*/
function md5_cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s), b)
}
function md5_ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return md5_cmn((b & c) | (~b & d), a, b, x, s, t)
}
function md5_gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return md5_cmn((b & d) | (c & ~d), a, b, x, s, t)
}
function md5_hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return md5_cmn(b ^ c ^ d, a, b, x, s, t)
}
function md5_ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return md5_cmn(c ^ (b | ~d), a, b, x, s, t)
}
/**
* 安全整数加法,防止溢出
* @param x 整数
* @param y 整数
* @returns 加法结果
*/
function safe_add(x: number, y: number): number {
const lsw: number = (x & 0xFFFF) + (y & 0xFFFF)
const msw: number = (x >> 16) + (y >> 16) + (lsw >> 16)
return (msw << 16) | (lsw & 0xFFFF)
}
/**
* 左移位操作
* @param num 数字
* @param cnt 位数
* @returns 结果
*/
function bit_rol(num: number, cnt: number): number {
return (num << cnt) | (num >>> (32 - cnt))
}
/**
* 计算字符串的 MD5 值
* @param str 输入字符串
* @returns MD5 十六进制字符串
*/
function md5(str: string): string {
return hex_md5(str)
}
export default {
md5,
}
+17
View File
@@ -0,0 +1,17 @@
/**
* 生成指定范围的随机整数
* @param min 最小值(包含)
* @param max 最大值(包含)
* @returns 随机整数
*/
export function random(min: number, max: number): number {
if (min >= 0 && max > 0 && max >= min) {
const gab = max - min + 1
return Math.floor(Math.random() * gab + min)
}
else {
return 0
}
}
export default random
+11
View File
@@ -0,0 +1,11 @@
/**
* 打乱数组顺序
* @param array 需要打乱的数组
* @returns 打乱后的新数组
*/
export function randomArray<T>(array: T[] = []): T[] {
// 原理是sort排序,Math.random()产生0<= x < 1之间的数,会导致x-0.5大于或者小于0
return array.sort(() => Math.random() - 0.5)
}
export default randomArray
+90
View File
@@ -0,0 +1,90 @@
import type { CSSProperties } from 'vue'
import trim from './trim'
import test from './test'
export function isValidValue(value: any): boolean {
if (test.isEmpty(value))
return false
if (typeof value === 'string') {
const trimmed = trim(value).toLowerCase()
return trimmed !== 'null' && trimmed !== 'undefined' && trimmed !== ''
}
return true
}
/**
* 将 CSS 字符串解析为对象
*/
export function cssStrToObj(str: string): object {
const result = {}
if (!isValidValue(str))
return result
const styleStr = trim(String(str))
if (!isValidValue(styleStr))
return result
const declarations = styleStr.split(';')
declarations.forEach((decl) => {
const [prop, ...values] = decl.split(':').map(s => s.trim())
const value = values.join(':')
if (prop && value) {
const camelProp = prop.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
(result as Record<string, string>)[camelProp] = value
}
})
return result
}
/**
* 将 CSS 对象转换为 CSS 字符串
*/
export function cssObjToStr(obj: object): string {
if (!isValidValue(obj) || typeof obj !== 'object')
return ''
return (
`${Object.entries(obj)
.map(([key, value]) => {
if (!isValidValue(value))
return ''
// 驼峰转短横线
const kebab = key.replace(/([A-Z])/g, '-$1').toLowerCase()
return `${kebab}: ${value}`
})
.filter(Boolean)
.join('; ')};`
)
}
/**
* 合并多个 CSS 输入(对象或字符串),返回一个 CSS 对象
*/
export function mergeStyles(...args: (object | string)[]): CSSProperties {
const result = {} as Record<string, any>
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!isValidValue(arg))
return result as CSSProperties
if (typeof arg === 'string') {
Object.assign(result, cssStrToObj(arg))
}
else if (test.object(arg)) {
const cleanedObj: Record<string, any> = {}
Object.keys(arg).forEach((key) => {
const value = (arg as Record<string, any>)[key]
if (isValidValue(value)) {
// 有效
cleanedObj[key] = value
}
})
Object.assign(result, cleanedObj)
}
}
return result
}
/**
* 合并为 CSS 字符串
*/
export function mergeCssStrings(...args: (object | string)[]): string {
const obj = mergeStyles(...args)
return cssObjToStr(obj)
}
+249
View File
@@ -0,0 +1,249 @@
/**
* 验证电子邮箱格式
*/
function email(value: string): boolean {
return /[\w!#$%&'*+/=?^`{|}~-]+(?:\.[\w!#$%&'*+/=?^`{|}~-]+)*@(?:\w(?:[\w-]*\w)?\.)+\w(?:[\w-]*\w)?/.test(
value,
)
}
/**
* 验证手机格式
*/
function mobile(value: string): boolean {
return /^1[3-9]\d{9}$/.test(value)
}
/**
* 验证日期格式
*/
function date(value: string): boolean {
return !/Invalid|NaN/.test(new Date(value).toString())
}
/**
* 验证整数
*/
function digits(value: string): boolean {
return /^\d+$/.test(value)
}
/**
* 是否车牌号
*/
function carNo(value: string): boolean {
// 新能源车牌
const xreg
= /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z][A-Z](?:\d{5}[DF]$|[DF][A-HJ-NP-Z0-9]\d{4}$)/
// 旧车牌
const creg
= /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z][A-Z][A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]$/
if (value.length === 7) {
return creg.test(value)
}
else if (value.length === 8) {
return xreg.test(value)
}
else {
return false
}
}
/**
* 中文
*/
function chinese(value: string): boolean {
const reg = /^[\u4E00-\u9FA5]+$/
return reg.test(value)
}
/**
* 只能输入字母
*/
function letter(value: string): boolean {
return /^[a-z]*$/i.test(value)
}
/**
* 只能是字母或者数字
*/
function enOrNum(value: string): boolean {
// 英文或者数字
const reg = /^[0-9a-z]*$/i
return reg.test(value)
}
/**
* 验证是否包含某个值
*/
function contains(value: string, param: string): boolean {
return value.includes(param)
}
/**
* 验证一个值范围[min, max]
*/
function range(value: number, param: [number, number]): boolean {
return value >= param[0] && value <= param[1]
}
/**
* 验证一个长度范围[min, max]
*/
function rangeLength(value: string, param: [number, number]): boolean {
return value.length >= param[0] && value.length <= param[1]
}
/**
* 判断是否为空
*/
function empty(value: any): boolean {
switch (typeof value) {
case 'undefined':
return true
case 'string':
if (value.replace(/(^[ \t\n\r]*)|([ \t\n\r]*$)/g, '').length === 0)
return true
break
case 'boolean':
if (!value)
return true
break
case 'number':
if (value === 0 || Number.isNaN(value))
return true
break
case 'object':
if (value === null || value.length === 0)
return true
if (Object.keys(value).length > 0) {
return false
}
return true
}
return false
}
/**
* 是否json字符串
*/
function jsonString(value: string): boolean {
if (typeof value == 'string') {
try {
const obj = JSON.parse(value)
if (typeof obj == 'object' && obj) {
return true
}
else {
return false
}
}
catch (e) {
return false
}
}
return false
}
/**
* 是否数组
*/
function array(value: any): boolean {
if (typeof Array.isArray === 'function') {
return Array.isArray(value)
}
else {
return Object.prototype.toString.call(value) === '[object Array]'
}
}
/**
* 是否对象
*/
function object(value: any): boolean {
return Object.prototype.toString.call(value) === '[object Object]'
}
/**
* 是否短信验证码
*/
function code(value: string, len: number = 6): boolean {
return new RegExp(`^\\d{${len}}$`).test(value)
}
/**
* 是否函数方法
* @param {object} value
*/
function func(value: any) {
return typeof value === 'function'
}
/**
* 是否promise对象
* @param {object} value
*/
function promise(value: any) {
return object(value) && func(value.then) && func(value.catch)
}
/**
* 是否图片格式
* @param {object} value
*/
function image(value: any) {
const newValue = value.split('?')[0]
const IMAGE_REGEXP = /\.(?:jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg)/i
return IMAGE_REGEXP.test(newValue)
}
/**
* 是否视频格式
* @param {object} value
*/
function video(value: any) {
const VIDEO_REGEXP = /\.(?:mp4|mpg|mpeg|dat|asf|avi|rm|mov|wmv|flv|mkv|m3u8)/i
return VIDEO_REGEXP.test(value)
}
/**
* 是否为正则对象
* @param {object}
* @return {boolean}
*/
function regExp(o: any) {
return o && Object.prototype.toString.call(o) === '[object RegExp]'
}
/**
* 验证字符串
*/
function string(value: any) {
return typeof value === 'string'
}
export default {
email,
mobile,
date,
digits,
carNo,
chinese,
letter,
enOrNum,
contains,
range,
rangeLength,
empty,
isEmpty: empty,
jsonString,
object,
array,
code,
func,
promise,
video,
image,
regExp,
string,
}
+32
View File
@@ -0,0 +1,32 @@
let timer: ReturnType<typeof setTimeout> | undefined
let flag: boolean | undefined
/**
* 节流原理:在一定时间内,只能触发一次
* @param func 要执行的回调函数
* @param wait 延时的时间,单位ms,默认500
* @param immediate 是否立即执行,默认true
*/
export function throttle(func: () => void, wait: number = 500, immediate: boolean = true): void {
if (immediate) {
if (!flag) {
flag = true
// 如果是立即执行,则在wait毫秒内开始时执行
typeof func === 'function' && func()
timer = setTimeout(() => {
flag = false
}, wait)
}
}
else {
if (!flag) {
flag = true
// 如果是非立即执行,则在wait毫秒内的结束处执行
timer = setTimeout(() => {
flag = false
typeof func === 'function' && func()
}, wait)
}
}
}
export default throttle
+59
View File
@@ -0,0 +1,59 @@
// padStart 的 polyfill,因为某些机型或情况,还无法支持es7的padStart,比如电脑版的微信小程序
// 所以这里做一个兼容polyfill的兼容处理
if (!String.prototype.padStart) {
// 为了方便表示这里 fillString 用了ES6 的默认参数,不影响理解
String.prototype.padStart = function (this: string, maxLength: number, fillString: string = ' '): string {
if (Object.prototype.toString.call(fillString) !== '[object String]')
throw new TypeError('fillString must be String')
if (this.length >= maxLength) {
return String(this)
}
const fillLength = maxLength - this.length
let times = Math.ceil(fillLength / fillString.length)
while (times) {
times >>= 1
fillString += fillString
if (times === 1) {
fillString += fillString
}
}
return fillString.slice(0, fillLength) + this
}
}
/**
* 时间格式化
* @param dateTime 时间戳、Date对象或null,默认当前时间
* @param fmt 格式化字符串,默认 'yyyy-mm-dd'
* @returns 格式化后的时间字符串
*/
export function timeFormat(dateTime: number | string | Date | null = null, fmt: string = 'yyyy-mm-dd'): string {
// 如果为null,则格式化当前时间
if (!dateTime)
dateTime = Number(new Date())
// 如果dateTime长度为10或者13,则为秒和毫秒的时间戳,如果超过13位,则为其他的时间格式
if (typeof dateTime === 'number' || typeof dateTime === 'string') {
if (dateTime.toString().length === 10)
dateTime = Number(dateTime) * 1000
}
const date = new Date(dateTime)
let ret: RegExpExecArray | null
const opt: Record<string, string> = {
'y+': date.getFullYear().toString(), // 年
'm+': (date.getMonth() + 1).toString(), // 月
'd+': date.getDate().toString(), // 日
'h+': date.getHours().toString(), // 时
'M+': date.getMinutes().toString(), // 分
's+': date.getSeconds().toString(), // 秒
// 有其他格式化字符需求可以继续添加,必须转化成字符串
}
for (const k in opt) {
ret = new RegExp(`(${k})`).exec(fmt)
if (ret) {
fmt = fmt.replace(ret[1], ret[1].length === 1 ? opt[k] : opt[k].padStart(ret[1].length, '0'))
}
}
return fmt
}
export default timeFormat
+52
View File
@@ -0,0 +1,52 @@
import timeFormat from './timeFormat'
/**
* 时间戳转为多久之前
* @param dateTime 时间戳、Date对象或null,默认当前时间
* @param format 时间格式字符串或false,超出范围时返回指定格式,否则返回多久以前
* @returns 格式化后的时间字符串
*/
export function timeFrom(dateTime: number | string | Date | null = null, format: string | false = 'yyyy-mm-dd'): string {
// 如果为null,则格式化当前时间
if (!dateTime)
dateTime = Number(new Date())
// 如果dateTime长度为10或者13,则为秒和毫秒的时间戳,如果超过13位,则为其他的时间格式
if (typeof dateTime === 'number' || typeof dateTime === 'string') {
if (dateTime.toString().length === 10)
dateTime = Number(dateTime) * 1000
}
const timestamp = +new Date(Number(dateTime))
const timer = (Number(new Date()) - timestamp) / 1000
// 如果小于5分钟,则返回"刚刚",其他以此类推
let tips = ''
switch (true) {
case timer < 300:
tips = '刚刚'
break
case timer >= 300 && timer < 3600:
tips = `${Number.parseInt(String(timer / 60))}分钟前`
break
case timer >= 3600 && timer < 86400:
tips = `${Number.parseInt(String(timer / 3600))}小时前`
break
case timer >= 86400 && timer < 2592000:
tips = `${Number.parseInt(String(timer / 86400))}天前`
break
default:
// 如果format为false,则无论什么时间戳,都显示xx之前
if (format === false) {
if (timer >= 2592000 && timer < 365 * 86400) {
tips = `${Number.parseInt(String(timer / (86400 * 30)))}个月前`
}
else {
tips = `${Number.parseInt(String(timer / (86400 * 365)))}年前`
}
}
else {
tips = timeFormat(timestamp, format as string)
}
}
return tips
}
export default timeFrom
+25
View File
@@ -0,0 +1,25 @@
/**
* 去除字符串空格
* @param str 输入字符串
* @param pos 去除位置,'both' | 'left' | 'right' | 'all',默认'both'
* @returns 去除空格后的字符串
*/
export function trim(str: string, pos: 'both' | 'left' | 'right' | 'all' = 'both'): string {
if (pos === 'both') {
return str.replace(/^\s+|\s+$/g, '')
}
else if (pos === 'left') {
return str.replace(/^\s*/, '')
}
else if (pos === 'right') {
return str.replace(/(\s*$)/g, '')
}
else if (pos === 'all') {
return str.replace(/\s+/g, '')
}
else {
return str
}
}
export default trim
-59
View File
@@ -1,59 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { debounce } from './debounce'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('trailing edge(默认):多次调用后只触发一次,使用最后一次的参数', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100)
debouncedFn('first')
debouncedFn('second')
debouncedFn('last')
expect(fn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('last')
})
it('cancel:取消后计时器到期不执行', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100)
debouncedFn()
debouncedFn.cancel()
vi.advanceTimersByTime(100)
expect(fn).not.toHaveBeenCalled()
})
it('flush:立即执行待执行的调用并传递参数', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100)
debouncedFn('arg1')
debouncedFn.flush()
expect(fn).toHaveBeenCalledWith('arg1')
})
it('leading edge:第一次调用立即执行', () => {
const fn = vi.fn()
const debouncedFn = debounce(fn, 100, { edges: ['leading'] })
debouncedFn()
expect(fn).toHaveBeenCalledTimes(1)
})
})
+19
View File
@@ -0,0 +1,19 @@
export const randomImageUrl = {
ycy: 'https://t.alcy.cc/ycy',
pc: 'https://t.alcy.cc/pc',
moemp: 'https://t.alcy.cc/moemp',
picsum: 'https://picsum.photos/800/600',
bing: 'https://bing.img.run/rand_uhd.php',
xsot: 'https://api.xsot.cn/bing?jump=true',
}
export const randomVideosUrl = {
acg: 'https://t.alcy.cc/acg',
}
export function getPicsum(width: number = 800, height?: number) {
if (!height) {
return `https://picsum.photos/${width}`
}
return `https://picsum.photos/${width}/${height}`
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { getLastPage } from '@/utils'
import { debounce } from '@/utils/debounce'
import { debounce } from '@/utils/base/debounce'
interface ToLoginPageOptions {
/**