优化 usePage

This commit is contained in:
icssoa
2025-08-11 19:14:03 +08:00
parent 1ddfec2bd3
commit f2621341a4
19 changed files with 266 additions and 162 deletions

View File

@@ -1,5 +1,4 @@
export * from "./refs"; export * from "./refs";
export * from "./page";
export * from "./pager"; export * from "./pager";
export * from "./long-press"; export * from "./long-press";
export * from "./cache"; export * from "./cache";

View File

@@ -1,102 +0,0 @@
import { config } from "@/config";
import { router } from "../router";
import { getPx, isH5, isHarmony } from "../utils";
import { ctx } from "../ctx";
class Page {
scrolls: Map<string, ((top: number) => void)[]> = new Map();
path() {
return router.path();
}
/**
* 触发滚动事件
* @param top 滚动距离
*/
triggerScroll(top: number) {
const callbacks = this.scrolls.get(this.path()) ?? [];
callbacks.forEach((cb) => {
cb(top);
});
}
/**
* 注册滚动事件回调
* @param callback 回调函数
*/
onPageScroll(callback: (top: number) => void) {
const callbacks = this.scrolls.get(this.path()) ?? [];
callbacks.push(callback);
this.scrolls.set(this.path(), callbacks);
}
/**
* 是否需要计算 tabBar 高度
* @returns boolean
*/
hasCustomTabBar() {
if (router.isTabPage()) {
if (isHarmony()) {
return false;
}
return config.isCustomTabBar || isH5();
}
return false;
}
/**
* 是否存在自定义 topbar
* @returns boolean
*/
hasCustomTopbar() {
return router.route()?.isCustomNavbar ?? false;
}
/**
* 获取 tabBar 高度
* @returns tabBar 高度
*/
getTabBarHeight() {
let h = ctx.tabBar.height == null ? 50 : getPx(ctx.tabBar.height!);
if (this.hasCustomTabBar()) {
h += this.getSafeAreaHeight("bottom");
}
return h;
}
/**
* 获取安全区域高度
* @param type 类型
* @returns 安全区域高度
*/
getSafeAreaHeight(type: "top" | "bottom") {
const { safeAreaInsets } = uni.getWindowInfo();
let h: number;
if (type == "top") {
h = safeAreaInsets.top;
} else {
h = safeAreaInsets.bottom;
// #ifdef APP-ANDROID
if (h == 0) {
h = 16;
}
// #endif
}
return h;
}
}
export const page = new Page();
export function usePage(): Page {
return page;
}

View File

@@ -1,12 +1,8 @@
import { page } from "./hooks";
import { initTheme, setH5 } from "./theme"; import { initTheme, setH5 } from "./theme";
import { initLocale } from "@/locale"; import { initLocale } from "@/locale";
export function cool(app: VueApp) { export function cool(app: VueApp) {
app.mixin({ app.mixin({
onPageScroll(e) {
page.triggerScroll(e.scrollTop);
},
onShow() { onShow() {
// #ifdef H5 // #ifdef H5
setTimeout(() => { setTimeout(() => {

View File

@@ -81,18 +81,10 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
timeout, timeout,
success(res) { success(res) {
if (!isObject(res.data as any)) {
resolve(res.data as T);
return;
}
// 解析响应数据
const { code, message, data } = parse<Response>(res.data ?? { code: 0 })!;
// 401 无权限 // 401 无权限
if (res.statusCode == 401) { if (res.statusCode == 401) {
user.logout(); user.logout();
return reject({ message } as Response); return reject({ message: t("无权限") } as Response);
} }
// 502 服务异常 // 502 服务异常
@@ -111,6 +103,14 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
// 200 正常响应 // 200 正常响应
if (res.statusCode == 200) { if (res.statusCode == 200) {
if (!isObject(res.data as any)) {
resolve(res.data as T);
return;
}
// 解析响应数据
const { code, message, data } = parse<Response>(res.data ?? { code: 0 })!;
switch (code) { switch (code) {
case 1000: case 1000:
resolve(data as T); resolve(data as T);

View File

@@ -4,4 +4,5 @@ export * from "./device";
export * from "./file"; export * from "./file";
export * from "./parse"; export * from "./parse";
export * from "./path"; export * from "./path";
export * from "./rect";
export * from "./storage"; export * from "./storage";

68
cool/utils/rect.ts Normal file
View File

@@ -0,0 +1,68 @@
import { config } from "@/config";
import { router } from "../router";
import { isH5, isHarmony } from "./comm";
import { ctx } from "../ctx";
import { getPx } from "./parse";
/**
* 是否需要计算 tabBar 高度
* @returns boolean
*/
export function hasCustomTabBar() {
if (router.isTabPage()) {
if (isHarmony()) {
return false;
}
return config.isCustomTabBar || isH5();
}
return false;
}
/**
* 是否存在自定义 topbar
* @returns boolean
*/
export function hasCustomTopbar() {
return router.route()?.isCustomNavbar ?? false;
}
/**
* 获取安全区域高度
* @param type 类型
* @returns 安全区域高度
*/
export function getSafeAreaHeight(type: "top" | "bottom") {
const { safeAreaInsets } = uni.getWindowInfo();
let h: number;
if (type == "top") {
h = safeAreaInsets.top;
} else {
h = safeAreaInsets.bottom;
// #ifdef APP-ANDROID
if (h == 0) {
h = 16;
}
// #endif
}
return h;
}
/**
* 获取 tabBar 高度
* @returns tabBar 高度
*/
export function getTabBarHeight() {
let h = ctx.tabBar.height == null ? 50 : getPx(ctx.tabBar.height!);
if (hasCustomTabBar()) {
h += getSafeAreaHeight("bottom");
}
return h;
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="cl-back-top-wrapper" :style="{ bottom }"> <view class="cl-back-top-wrapper" :style="{ bottom }" @tap="toTop">
<view <view
class="cl-back-top" class="cl-back-top"
:class="{ :class="{
@@ -12,8 +12,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getTabBarHeight, hasCustomTabBar } from "@/cool";
import { computed, onMounted, ref, watch, type PropType } from "vue"; import { computed, onMounted, ref, watch, type PropType } from "vue";
import { usePage } from "@/cool"; import { usePage } from "../../hooks";
defineOptions({ defineOptions({
name: "cl-back-top" name: "cl-back-top"
@@ -26,9 +27,11 @@ const props = defineProps({
} }
}); });
const page = usePage();
const { screenHeight } = uni.getWindowInfo(); const { screenHeight } = uni.getWindowInfo();
// cl-page 上下文
const page = usePage();
// 是否显示回到顶部按钮 // 是否显示回到顶部按钮
const visible = ref(false); const visible = ref(false);
@@ -36,25 +39,30 @@ const visible = ref(false);
const bottom = computed(() => { const bottom = computed(() => {
let h = 20; let h = 20;
if (page.hasCustomTabBar()) { if (hasCustomTabBar()) {
h += page.getTabBarHeight(); h += getTabBarHeight();
} }
return h + "px"; return h + "px";
}); });
// 控制是否显示回到顶部按钮 // 控制是否显示
function update(top: number) { function onVisible(top: number) {
visible.value = top > screenHeight - 100; visible.value = top > screenHeight - 100;
} }
// 回到顶部
function toTop() {
page.scrollToTop();
}
onMounted(() => { onMounted(() => {
if (props.top != null) { if (props.top != null) {
// 监听参数变化 // 监听参数变化
watch( watch(
computed(() => props.top!), computed(() => props.top!),
(top: number) => { (top: number) => {
update(top); onVisible(top);
}, },
{ {
immediate: true immediate: true
@@ -63,7 +71,7 @@ onMounted(() => {
} else { } else {
// 监听页面滚动 // 监听页面滚动
page.onPageScroll((top) => { page.onPageScroll((top) => {
update(top); onVisible(top);
}); });
} }
}); });

View File

@@ -132,7 +132,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, reactive, nextTick, getCurrentInstance } from "vue"; import { computed, ref, reactive, nextTick, getCurrentInstance } from "vue";
import type { PassThroughProps } from "../../types"; import type { PassThroughProps } from "../../types";
import { canvasToPng, getDevicePixelRatio, parsePt, usePage, uuid } from "@/cool"; import { canvasToPng, getDevicePixelRatio, getSafeAreaHeight, parsePt, uuid } from "@/cool";
// 定义遮罩层样式类型 // 定义遮罩层样式类型
type MaskStyle = { type MaskStyle = {
@@ -225,9 +225,6 @@ const emit = defineEmits(["crop", "load", "error"]);
// 获取当前实例 // 获取当前实例
const { proxy } = getCurrentInstance()!; const { proxy } = getCurrentInstance()!;
// 获取页面实例,用于获取视图尺寸
const page = usePage();
// 创建唯一的canvas ID // 创建唯一的canvas ID
const canvasId = `cl-cropper__${uuid()}`; const canvasId = `cl-cropper__${uuid()}`;
@@ -382,7 +379,7 @@ const maskStyle = computed<MaskStyle>(() => {
// 底部按钮组样式 // 底部按钮组样式
const opStyle = computed(() => { const opStyle = computed(() => {
let bottom = page.getSafeAreaHeight("bottom"); let bottom = getSafeAreaHeight("bottom");
if (bottom == 0) { if (bottom == 0) {
bottom = 10; bottom = 10;

View File

@@ -12,7 +12,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { router, usePage } from "@/cool"; import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, router } from "@/cool";
import { computed, reactive } from "vue"; import { computed, reactive } from "vue";
defineOptions({ defineOptions({
@@ -60,9 +60,6 @@ const props = defineProps({
// 获取设备屏幕信息 // 获取设备屏幕信息
const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo(); const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo();
// 页面实例
const page = usePage();
/** /**
* 悬浮按钮位置状态类型定义 * 悬浮按钮位置状态类型定义
*/ */
@@ -110,8 +107,8 @@ const viewStyle = computed(() => {
let bottomOffset = 0; let bottomOffset = 0;
// 标签页需要额外减去标签栏高度和安全区域 // 标签页需要额外减去标签栏高度和安全区域
if (page.hasCustomTabBar()) { if (hasCustomTabBar()) {
bottomOffset += page.getTabBarHeight(); bottomOffset += getTabBarHeight();
} }
// 设置水平位置 // 设置水平位置
@@ -151,7 +148,7 @@ function calculateMaxY(): number {
// 标签页需要额外减去标签栏高度和安全区域 // 标签页需要额外减去标签栏高度和安全区域
if (router.isTabPage()) { if (router.isTabPage()) {
maxY -= page.getTabBarHeight(); maxY -= getTabBarHeight();
} }
return maxY; return maxY;
@@ -206,7 +203,7 @@ function onTouchMove(e: TouchEvent) {
let minY = 0; let minY = 0;
// 非标签页时,底部需要考虑安全区域 // 非标签页时,底部需要考虑安全区域
if (!router.isTabPage()) { if (!router.isTabPage()) {
minY += page.getSafeAreaHeight("bottom"); minY += getSafeAreaHeight("bottom");
} }
// 确保按钮不超出屏幕上下边界 // 确保按钮不超出屏幕上下边界

View File

@@ -20,7 +20,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { isDark, isHarmony, parsePt, usePage } from "@/cool"; import { getSafeAreaHeight, isDark, isHarmony, parsePt } from "@/cool";
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue"; import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import type { PassThroughProps } from "../../types"; import type { PassThroughProps } from "../../types";
@@ -46,7 +46,6 @@ const props = defineProps({
}); });
const { proxy } = getCurrentInstance()!; const { proxy } = getCurrentInstance()!;
const page = usePage();
type PassThrough = { type PassThrough = {
className?: string; className?: string;
@@ -77,7 +76,7 @@ function getHeight() {
height.value = h; height.value = h;
// 如果内容高度大于最小高度,则显示 // 如果内容高度大于最小高度,则显示
visible.value = h > props.minHeight + page.getSafeAreaHeight("bottom"); visible.value = h > props.minHeight + getSafeAreaHeight("bottom");
}) })
.exec(); .exec();
}, },

View File

@@ -1,5 +1,13 @@
<template> <template>
<view class="cl-form-item" :class="[pt.className]"> <view
class="cl-form-item"
:class="[
{
'cl-form-item--error': isError
},
pt.className
]"
>
<view class="cl-form-item__inner" :class="[`is-${labelPosition}`, pt.inner?.className]"> <view class="cl-form-item__inner" :class="[`is-${labelPosition}`, pt.inner?.className]">
<view <view
class="cl-form-item__label" class="cl-form-item__label"

View File

@@ -14,10 +14,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch, type PropType } from "vue"; import { computed, getCurrentInstance, nextTick, ref, watch, type PropType } from "vue";
import { isEmpty, isString, parsePt, parseToObject } from "@/cool"; import { isEmpty, isString, parsePt, parseToObject } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types"; import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
import { $t, t } from "@/locale"; import { $t, t } from "@/locale";
import { usePage } from "../../hooks";
defineOptions({ defineOptions({
name: "cl-form" name: "cl-form"
@@ -64,9 +65,19 @@ const props = defineProps({
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false
},
// 滚动到第一个错误位置
scrollToError: {
type: Boolean,
default: true
} }
}); });
const { proxy } = getCurrentInstance()!;
// cl-page 上下文
const page = usePage();
// 透传样式类型 // 透传样式类型
type PassThrough = { type PassThrough = {
className?: string; className?: string;
@@ -269,6 +280,31 @@ function validateField(prop: string): string | null {
return error; return error;
} }
// 滚动到第一个错误位置
function scrollToError(prop: string) {
if (props.scrollToError == false) {
return;
}
nextTick(() => {
let component = proxy;
// #ifdef MP
component = proxy?.$children.find((e: any) => e.prop == prop);
// #endif
uni.createSelectorQuery()
.in(component)
.select(".cl-form-item--error")
.boundingClientRect((res) => {
if (res != null) {
page.scrollTo(((res as NodeInfo).top ?? 0) + page.getScrollTop());
}
})
.exec();
});
}
// 验证整个表单 // 验证整个表单
function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) { function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) {
const errs = [] as ClFormValidateError[]; const errs = [] as ClFormValidateError[];
@@ -284,6 +320,11 @@ function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => v
} }
}); });
// 滚动到第一个错误位置
if (errs.length > 0) {
scrollToError(errs[0].field);
}
callback(errs.length == 0, errs); callback(errs.length == 0, errs);
} }

View File

@@ -23,11 +23,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { router, usePage } from "@/cool";
import Theme from "./theme.uvue"; import Theme from "./theme.uvue";
import Ui from "./ui.uvue"; import Ui from "./ui.uvue";
import { locale, t } from "@/locale"; import { locale, t } from "@/locale";
import { config } from "@/config"; import { config } from "@/config";
import { router } from "@/cool";
defineOptions({ defineOptions({
name: "cl-page" name: "cl-page"
@@ -41,34 +41,45 @@ defineProps({
} }
}); });
const page = usePage(); // 滚动距离
const scrollTop = ref(0);
// scroll-view 滚动位置 // scroll-view 滚动位置
const scrollViewTop = ref(0); const scrollViewTop = ref(0);
// view 滚动事件 // view 滚动事件
function onScroll(e: UniScrollEvent) { function onScroll(e: UniScrollEvent) {
page.triggerScroll(e.detail.scrollTop); scrollTop.value = e.detail.scrollTop;
} }
// 回到顶部 // 页面滚动事件
function scrollToTop() { onPageScroll((e) => {
scrollTop.value = e.scrollTop;
});
// 滚动到指定位置
function scrollTo(top: number) {
// #ifdef H5 // #ifdef H5
window.scrollTo({ top: 0, behavior: "smooth" }); window.scrollTo({ top, behavior: "smooth" });
// #endif // #endif
// #ifdef MP // #ifdef MP
uni.pageScrollTo({ uni.pageScrollTo({
scrollTop: 0, scrollTop: top,
duration: 300 duration: 300
}); });
// #endif // #endif
// #ifdef APP // #ifdef APP
scrollViewTop.value = 0 + Math.random() / 1000; scrollViewTop.value = top;
// #endif // #endif
} }
// 回到顶部
function scrollToTop() {
scrollTo(0 + Math.random() / 1000);
}
onMounted(() => { onMounted(() => {
// 标题多语言 // 标题多语言
// #ifdef H5 || APP // #ifdef H5 || APP
@@ -91,4 +102,10 @@ onMounted(() => {
); );
// #endif // #endif
}); });
defineExpose({
scrollTop,
scrollTo,
scrollToTop
});
</script> </script>

View File

@@ -100,7 +100,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, reactive, ref, watch, type PropType } from "vue"; import { computed, reactive, ref, watch, type PropType } from "vue";
import { parsePt, parseRpx, usePage } from "@/cool"; import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, parsePt, parseRpx } from "@/cool";
import type { ClPopupDirection, PassThroughProps } from "../../types"; import type { ClPopupDirection, PassThroughProps } from "../../types";
import { isDark, router } from "@/cool"; import { isDark, router } from "@/cool";
import { config } from "../../config"; import { config } from "../../config";
@@ -185,15 +185,12 @@ const props = defineProps({
// 定义组件事件 // 定义组件事件
const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]); const emit = defineEmits(["update:modelValue", "open", "opened", "close", "closed", "maskClose"]);
// 页面实例 // 透传样式类型定义
const page = usePage();
type HeaderPassThrough = { type HeaderPassThrough = {
className?: string; className?: string;
text?: PassThroughProps; text?: PassThroughProps;
}; };
// 透传样式类型定义
type PassThrough = { type PassThrough = {
className?: string; className?: string;
inner?: PassThroughProps; inner?: PassThroughProps;
@@ -255,7 +252,7 @@ const paddingBottom = computed(() => {
let h = 0; let h = 0;
if (props.direction == "bottom") { if (props.direction == "bottom") {
h += page.hasCustomTabBar() ? page.getTabBarHeight() : page.getSafeAreaHeight("bottom"); h += hasCustomTabBar() ? getTabBarHeight() : getSafeAreaHeight("bottom");
} }
return h + "px"; return h + "px";

View File

@@ -11,7 +11,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { isDark, page, parsePt } from "@/cool"; import { getSafeAreaHeight, isDark, parsePt } from "@/cool";
import { computed, type PropType } from "vue"; import { computed, type PropType } from "vue";
defineOptions({ defineOptions({
@@ -37,7 +37,7 @@ const pt = computed(() => parsePt<PassThrough>(props.pt));
// 高度 // 高度
const height = computed(() => { const height = computed(() => {
return page.getSafeAreaHeight(props.type) + "px"; return getSafeAreaHeight(props.type) + "px";
}); });
</script> </script>

View File

@@ -26,8 +26,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { isEmpty, router, usePage } from "@/cool"; import { isEmpty, router } from "@/cool";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue"; import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import { usePage } from "../../hooks";
defineOptions({ defineOptions({
name: "cl-sticky" name: "cl-sticky"
@@ -53,9 +54,11 @@ const props = defineProps({
} }
}); });
const page = usePage();
const { proxy } = getCurrentInstance()!; const { proxy } = getCurrentInstance()!;
// cl-page 上下文
const page = usePage();
// 定义Rect类型表示元素的位置信息 // 定义Rect类型表示元素的位置信息
type Rect = { type Rect = {
height: number; // 高度 height: number; // 高度

View File

@@ -1,3 +1,4 @@
export * from "./ui";
export * from "./component"; export * from "./component";
export * from "./form"; export * from "./form";
export * from "./page";
export * from "./ui";

View File

@@ -0,0 +1,68 @@
import { router, useParent } from "@/cool";
import { computed, watch } from "vue";
type PageScrollCallback = (top: number) => void;
class Page {
listeners: PageScrollCallback[] = [];
pageRef: ClPageComponentPublicInstance | null = null;
constructor() {
this.pageRef = useParent<ClPageComponentPublicInstance>("cl-page");
if (this.pageRef != null) {
// TODO: 小程序异常
watch(
computed(() => this.pageRef!.scrollTop),
(top: number) => {
this.listeners.forEach((listener) => {
listener(top);
});
}
);
}
}
/**
* 获取页面路径
* @returns 页面路径
*/
path() {
return router.path();
}
/**
* 获取滚动位置
* @returns 滚动位置
*/
getScrollTop(): number {
return this.pageRef!.scrollTop as number;
}
/**
* 滚动到指定位置
* @param top 滚动位置
*/
scrollTo(top: number) {
this.pageRef!.scrollTo(top);
}
/**
* 回到顶部
*/
scrollToTop() {
this.pageRef!.scrollToTop();
}
/**
* 监听页面滚动
* @param callback 回调函数
*/
onPageScroll(callback: PageScrollCallback) {
this.listeners.push(callback);
}
}
export function usePage(): Page {
return new Page();
}

View File

@@ -192,3 +192,9 @@ declare type ClFormItemComponentPublicInstance = {
prop: string; prop: string;
isError: boolean; isError: boolean;
}; };
declare type ClPageComponentPublicInstance = {
scrollTop: number;
scrollTo: (top: number) => void;
scrollToTop: () => void;
};