完善生产查询与登录权限配置

This commit is contained in:
2026-09-03 19:56:19 +08:00
parent 793f140a45
commit 02c8fb7021
438 changed files with 1595 additions and 573 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 标题 */
text: { type: String, default: '' },
/** 描述 */
subText: { type: String, default: '' },
/** 边距 */
padding: { type: [Number, String] as PropType<number | string>, default: '34rpx 0' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否禁用 */
disabled: { type: Boolean, default: false },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetItemProps = ExtractPropTypes<typeof ActionSheetItemProps>;
@@ -0,0 +1,85 @@
<template>
<view
class="u-action-sheet-item u-line-1"
:class="[!isLast ? 'u-border-bottom' : '', disabled ? 'u-action-sheet-item--disabled' : '', customClass]"
:style="$u.toStyle(itemStyle, customStyle)"
@click="handleClick"
@touchmove.stop.prevent
hover-class="u-hover-class"
:hover-stay-time="150"
>
<slot>
<text>{{ text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="subText">{{ subText }}</text>
</slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ActionSheetItemProps } from './types';
import { $u, useChildren } from '../../libs';
const props = defineProps(ActionSheetItemProps);
const emit = defineEmits(['click']);
const { parent, parentExposed, childIndex, emitToParent } = useChildren('u-action-sheet-item', 'u-action-sheet');
const isLast = computed(() => {
return (childIndex?.value ?? 0) + 1 >= (parent?.value?.getChildren()?.length ?? 0);
});
const itemStyle = computed(() => {
const style: Record<string, string> = {};
style.color = props.color || parentExposed?.value?.props?.color;
style.fontSize = $u.addUnit(props.fontSize || parentExposed?.value?.props?.fontSize);
style.padding = $u.addUnit(props.padding);
if (props.disabled) style.color = 'var(--u-light-color)';
return style;
});
function handleClick() {
if (props.disabled) return;
emit('click', childIndex?.value || 0);
if (!props.asyncClose && !parentExposed?.value?.props?.asyncClose) {
emitToParent('itemClick', childIndex?.value || 0);
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item--disabled {
color: $u-light-color;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,52 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ActionSheetItem, ActionSheetTips } from '../../types/global';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 点击遮罩是否可以关闭actionSheet */
maskCloseAble: { type: Boolean, default: true },
/** 按钮的文字数组,可以自定义颜色和字体大小,字体单位为rpx */
list: {
type: Array as unknown as PropType<ActionSheetItem[]>,
default: () => []
},
/** 顶部的提示文字 */
tips: {
type: Object as unknown as PropType<ActionSheetTips>,
default: () => ({ text: '', color: getColor('tipsColor'), fontSize: '26rpx' })
},
/** 底部的取消按钮 */
cancelBtn: { type: Boolean, default: true },
/** 是否开启底部安全区适配,开启的话,会在iPhoneX机型底部添加一定的内边距 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 通过双向绑定控制组件的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 弹出的顶部圆角值 */
borderRadius: { type: [String, Number], default: 0 },
/** 弹出的z-index值 */
zIndex: { type: [String, Number], default: 0 },
/** 取消按钮的文字提示 */
cancelText: { type: String, default: '' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetProps = ExtractPropTypes<typeof ActionSheetProps>;
@@ -0,0 +1,196 @@
<template>
<u-popup
mode="bottom"
:border-radius="borderRadius"
:popup="false"
v-model="popupValue"
:maskCloseAble="maskCloseAble"
length="auto"
:safeAreaInsetBottom="safeAreaInsetBottom"
@close="popupClose"
:z-index="uZIndex"
:custom-class="customClass"
:custom-style="customStyle"
>
<view class="u-tips u-border-bottom" v-if="tips.text" :style="[tipsStyle]">
{{ tips.text }}
</view>
<block v-if="list && list.length > 0" v-for="(item, index) in list" :key="index">
<view
@touchmove.stop.prevent
@tap="itemClick(index)"
:style="[itemStyle(index)]"
class="u-action-sheet-item u-line-1"
:class="[index < list.length - 1 ? 'u-border-bottom' : '']"
:hover-stay-time="150"
>
<text>{{ item.text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="item.subText">{{ item.subText }}</text>
</view>
</block>
<template v-else>
<slot></slot>
</template>
<view class="u-gab" v-if="cancelBtn"> </view>
<view
@touchmove.stop.prevent
class="u-action-sheet-cancel u-action-sheet-item"
hover-class="u-hover-class"
:hover-stay-time="150"
v-if="cancelBtn"
@tap="close"
>
{{ getCancelText }}
</view>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u, useParent, useLocale } from '../..';
import { ActionSheetProps } from './types';
import uPopup from '../u-popup/u-popup.vue';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
* @tutorial https://uviewpro.cn/zh/components/actionSheet.html
* @property {Array<{ text: string; subText?: string; color?: string; fontSize?: string; disabled?: boolean }>} list 按钮的文字数组,见官方文档示例
* @property {{text: string; color?: string; fontSize?: string}} tips 顶部的提示文字,见官方文档示例
* @property {String} cancel-text 取消按钮的提示文字
* @property {Boolean} cancel-btn 是否显示底部的取消按钮(默认true)
* @property {Number String} border-radius 弹出部分顶部左右的圆角值,单位rpx(默认0)
* @property {Boolean} mask-close-able 点击遮罩是否可以关闭(默认true)
* @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
* @property {Number String} z-index z-index值(默认1075)
* @property {String} cancel-text 取消按钮的提示文字
* @event {Function} click 点击ActionSheet列表项时触发
* @event {Function} close 点击取消按钮时触发
* @example <u-action-sheet :list="list" @click="click" v-model="show"></u-action-sheet>
*/
const props = defineProps(ActionSheetProps);
const emit = defineEmits(['update:modelValue', 'click', 'close']);
const { t } = useLocale();
useParent('u-action-sheet');
const popupValue = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
});
// 国际化计算属性
const getCancelText = computed(() => props.cancelText || t('uActionSheet.cancelText'));
// 顶部提示的样式
const tipsStyle = computed(() => {
let style: Record<string, string> = {};
style.color = String(props.tips?.color || $u.color.tipsColor);
style.fontSize = $u.addUnit(String(props.tips?.fontSize || '26rpx'));
return style;
});
// 操作项目的样式
const itemStyle = (index: number) => {
let style: Record<string, string> = {};
style.color = String(props.list[index]?.color || props.color);
style.fontSize = $u.addUnit(String(props.list[index]?.fontSize || props.fontSize));
// 选项被禁用的样式
if (props.list[index]?.disabled) style.color = String($u.color.lightColor || '');
return style;
};
// 计算z-index
const uZIndex = computed(() => {
// 如果用户有传递z-index值,优先使用
return props.zIndex ? props.zIndex : $u.zIndex.popup;
});
/**
* 点击取消按钮
*/
function close() {
// 发送input事件,并不会作用于父组件,而是要设置组件内部通过props传递的value参数
// 这是一个vue发送事件的特殊用法
popupClose();
emit('close');
}
/**
* 弹窗关闭
*/
function popupClose() {
emit('update:modelValue', false);
}
/**
* 点击某一个item
* @param index 选项索引
*/
function itemClick(index: number) {
// disabled的项禁止点击
if (props.list[index]?.disabled) return;
emit('click', index);
if (props.asyncClose) return;
emit('update:modelValue', false);
}
defineExpose({
props,
close,
itemClick
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-tips {
font-size: 26rpx;
text-align: center;
padding: 34rpx 0;
line-height: 1;
color: $u-tips-color;
}
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
.u-gab {
height: 12rpx;
background-color: rgb(234, 234, 236);
}
.u-action-sheet-cancel {
color: $u-main-color;
}
</style>
@@ -0,0 +1,49 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
*/
export const AlertTipsProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 显示文字 */
title: { type: String, default: '' },
/** 主题,success/warning/info/error */
type: { type: String as PropType<ThemeType>, default: 'warning' },
/** 辅助性文字 */
description: { type: String, default: '' },
/** 是否可关闭 */
closeAble: { type: Boolean, default: false },
/** 关闭按钮自定义文本 */
closeText: { type: String, default: '' },
/** 是否显示图标 */
showIcon: { type: Boolean, default: false },
/** 文字颜色,如果定义了color值,icon会失效 */
color: { type: String, default: '' },
/** 背景颜色 */
bgColor: { type: String, default: '' },
/** 边框颜色 */
borderColor: { type: String, default: '' },
/** 是否显示 */
show: { type: Boolean, default: true },
/** 左边显示的icon */
icon: { type: String, default: '' },
/** icon的样式 */
iconStyle: { type: Object, default: () => ({}) },
/** 标题的样式 */
titleStyle: { type: Object, default: () => ({}) },
/** 描述文字的样式 */
descStyle: { type: Object, default: () => ({}) }
};
export type AlertTipsProps = ExtractPropTypes<typeof AlertTipsProps>;
@@ -0,0 +1,239 @@
<template>
<view
class="u-alert-tips"
v-if="show"
:class="[
!show ? 'u-close-alert-tips' : '',
type ? 'u-alert-tips--bg--' + type + '-light' : '',
type ? 'u-alert-tips--border--' + type + '-disabled' : '',
customClass
]"
:style="
$u.toStyle(
{
backgroundColor: bgColor,
borderColor: borderColor
},
customStyle
)
"
>
<view class="u-icon-wrap">
<u-icon
v-if="showIcon"
:name="uIconName"
:size="description ? 40 : 32"
:color="uIconType"
:custom-style="iconStyle"
custom-class="u-tips-icon"
></u-icon>
</view>
<view class="u-alert-content" @tap.stop="onClick">
<view class="u-alert-title" :style="uTitleStyle">
{{ title }}
</view>
<view v-if="description" class="u-alert-desc" :style="descStyle">
{{ description }}
</view>
</view>
<view class="u-icon-wrap">
<u-icon
@click="onClose"
v-if="closeAble && !closeText"
hoverClass="u-type-error-hover-color"
name="close"
color="var(--u-tips-color)"
:size="22"
:custom-style="{
top: description ? '18rpx' : '24rpx'
}"
custom-class="u-close-icon"
></u-icon>
</view>
<text
v-if="closeAble && closeText"
class="u-close-text"
:style="{
top: description ? '18rpx' : '24rpx'
}"
>
{{ closeText }}
</text>
</view>
</template>
<script lang="ts">
export default {
name: 'u-alert-tips',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u } from '../..';
import { AlertTipsProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
* @tutorial https://uviewpro.cn/zh/components/alertTips.html
* @property {String} title 显示的标题文字
* @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
* @property {String} type 关闭按钮(默认为叉号icon图标)
* @property {String} icon 图标名称
* @property {Object} icon-style 图标的样式,对象形式
* @property {Object} title-style 标题的样式,对象形式
* @property {Object} desc-style 描述的样式,对象形式
* @property {String} close-able 用文字替代关闭图标,close-able为true时有效
* @property {Boolean} show-icon 是否显示左边的辅助图标
* @property {Boolean} show 显示或隐藏组件
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
*/
const props = defineProps(AlertTipsProps);
const emit = defineEmits(['click', 'close']);
/**
* 标题样式,合并加粗和用户自定义样式
*/
const uTitleStyle = computed(() => {
let style: Record<string, any> = {};
// 如果有描述文字的话,标题进行加粗
style.fontWeight = props.description ? 500 : 'normal';
// 将用户传入样式对象和style合并,传入的优先级比style高,同属性会被覆盖
return $u.deepMerge(style, props.titleStyle);
});
/**
* 图标名称,优先使用用户传入,否则根据type主题推定默认图标
*/
const uIconName = computed(() => {
// 如果有设置icon名称就使用,否则根据type主题,推定一个默认的图标
return props.icon ? props.icon : $u.type2icon(props.type as any);
});
/**
* 图标类型,优先使用iconStyle,否则用type
*/
const uIconType = computed(() => {
// 如果有设置图标的样式,优先使用,没有的话,则用type的样式
return Object.keys(props.iconStyle).length ? '' : props.type;
});
/**
* 点击内容
*/
function onClick() {
emit('click');
}
/**
* 点击关闭按钮
*/
function onClose() {
emit('close');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-alert-tips {
@include vue-flex;
align-items: center;
padding: 16rpx 30rpx;
border-radius: 8rpx;
position: relative;
transition: all 0.3s linear;
border: 1px solid var(--u-white-color);
&--bg--primary-light {
background-color: $u-type-primary-light;
}
&--bg--info-light {
background-color: $u-type-info-light;
}
&--bg--success-light {
background-color: $u-type-success-light;
}
&--bg--warning-light {
background-color: $u-type-warning-light;
}
&--bg--error-light {
background-color: $u-type-error-light;
}
&--border--primary-disabled {
border-color: $u-type-primary-disabled;
}
&--border--success-disabled {
border-color: $u-type-success-disabled;
}
&--border--error-disabled {
border-color: $u-type-error-disabled;
}
&--border--warning-disabled {
border-color: $u-type-warning-disabled;
}
&--border--info-disabled {
border-color: $u-type-info-disabled;
}
:deep(.u-tips-icon) {
margin-right: 16rpx;
}
:deep(.u-close-icon) {
position: absolute;
right: 20rpx;
cursor: pointer;
}
}
.u-close-alert-tips {
opacity: 0;
visibility: hidden;
}
.u-alert-title {
font-size: 28rpx;
color: $u-main-color;
}
.u-alert-desc {
font-size: 26rpx;
text-align: left;
color: $u-content-color;
}
.u-close-hover {
color: red;
}
.u-close-text {
font-size: 24rpx;
color: $u-tips-color;
position: absolute;
top: 20rpx;
right: 20rpx;
line-height: 1;
}
</style>
@@ -0,0 +1,23 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { AvatarCropperBoundStyle } from '../../types/global';
export const AvatarCropperProps = {
/** 裁剪矩形框的样式 */
boundStyle: {
type: Object as PropType<AvatarCropperBoundStyle>,
default: (): AvatarCropperBoundStyle => ({
lineWidth: 4,
borderColor: 'rgb(245, 245, 245)',
mask: 'rgba(0, 0, 0, 0.35)'
})
}
// 其他 props 可参考注释,后续如需开放直接补充
// rectWidth: { type: [String, Number], default: 400 },
// rectHeight: { type: [String, Number], default: 400 },
// destWidth: { type: [String, Number], default: 400 },
// destHeight: { type: [String, Number], default: 400 },
// fileType: { type: String, default: 'jpg' },
// quality: { type: [Number, String], default: 1 }
};
export type AvatarCropperProps = ExtractPropTypes<typeof AvatarCropperProps>;
@@ -0,0 +1,299 @@
<template>
<view class="content">
<view class="cropper-wrapper" :style="{ height: cropperOpt.height + 'px' }">
<canvas
class="cropper"
:disable-scroll="true"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:style="{ width: cropperOpt.width, height: cropperOpt.height, backgroundColor: 'rgba(0, 0, 0, 0.8)' }"
canvas-id="cropper"
id="cropper"
></canvas>
<canvas
class="cropper"
:disable-scroll="true"
:style="{
position: 'fixed',
top: `-${cropperOpt.width * cropperOpt.pixelRatio}px`,
left: `-${cropperOpt.height * cropperOpt.pixelRatio}px`,
width: `${cropperOpt.width * cropperOpt.pixelRatio}px`,
height: `${cropperOpt.height * cropperOpt.pixelRatio}`
}"
canvas-id="targetId"
id="targetId"
></canvas>
</view>
<view class="cropper-buttons safe-area-padding" :style="{ height: bottomNavHeight + 'px' }">
<!-- #ifdef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.select') }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.reselect') }}</view>
<!-- #endif -->
<view class="getCropperImage" @tap="() => getCropperImage(false)">{{ t('avatarCropper.confirm') }}</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-avatar-cropper',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
// @ts-nocheck
import { ref, reactive, onMounted } from 'vue';
import { $u, useLocale } from '../..';
// 兼容 UMD/ESM 导入 weCropper.js
import WeCropper from './weCropper';
import { AvatarCropperProps } from './types';
/**
* 裁剪矩形框的样式,其中可包含的属性为lineWidth-边框宽度(单位rpx),color: 边框颜色,
* mask-遮罩颜色,一般设置为一个rgba的透明度,如"rgba(0, 0, 0, 0.35)"
*/
const props = defineProps(AvatarCropperProps);
const { t } = useLocale();
/**
* 组合式API变量声明
* 保留所有说明注释
*/
const bottomNavHeight = ref(50); // 底部导航的高度
const originWidth = ref(200);
const width = ref(0);
const height = ref(0);
const src = ref(''); // 选择的图片路径,用于在点击确定时,判断是否选择了图片
const destWidth = ref(200); // 输出图片宽度,单位px
const rectWidth = ref(200); // 裁剪框宽度,单位px
const fileType = ref('jpg'); // 输出的图片类型,如果'png'类型发现裁剪的图片太大,改成"jpg"即可
// cropperOpt 需响应式,且类型安全
const cropperOpt = reactive({
id: 'cropper',
targetId: 'targetCropper',
pixelRatio: 1,
width: 0,
height: 0,
scale: 2.5,
zoom: 8,
cut: {
x: 0,
y: 0,
width: originWidth.value,
height: originWidth.value
},
boundStyle: {
lineWidth: 0,
mask: '',
color: ''
}
});
let cropper: any = null; // WeCropper 实例,类型 any,建议后续补充类型声明
/**
* 组件挂载时初始化 cropper
* 保留所有说明注释
*/
onMounted(() => {
// 获取系统信息
const rectInfo = uni.getSystemInfoSync();
width.value = rectInfo.windowWidth;
height.value = rectInfo.windowHeight - bottomNavHeight.value;
cropperOpt.width = width.value;
cropperOpt.height = height.value;
cropperOpt.pixelRatio = rectInfo.pixelRatio;
// 初始化裁剪框
cropperOpt.cut = {
x: (width.value - originWidth.value) / 2,
y: (height.value - originWidth.value) / 2,
width: originWidth.value,
height: originWidth.value
};
cropperOpt.boundStyle = {
lineWidth: uni.upx2px(props.boundStyle.lineWidth),
mask: props.boundStyle.mask,
color: props.boundStyle.borderColor
};
// 设置导航栏样式,以免用户在page.json中没有设置为黑色背景
uni.setNavigationBarColor({
frontColor: 'var(--u-white-color)',
backgroundColor: 'var(--u-bg-black)'
});
// 初始化 cropper 实例
cropper = new WeCropper(cropperOpt)
.on('ready', (ctx: any) => {
// wecropper is ready for work!
})
.on('beforeImageLoad', (ctx: any) => {
// before picture loaded, i can do something
})
.on('imageLoad', (ctx: any) => {
// picture loaded
})
.on('beforeDraw', (ctx: any, instance: any) => {
// before canvas draw,i can do something
});
// 选择图片
uni.chooseImage({
count: 1, // 默认9
sizeType: ['compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
});
/**
* 触摸开始事件
* @param e 事件对象
*/
function touchStart(e: any) {
cropper && cropper.touchStart(e);
}
/**
* 触摸移动事件
* @param e 事件对象
*/
function touchMove(e: any) {
cropper && cropper.touchMove(e);
}
/**
* 触摸结束事件
* @param e 事件对象
*/
function touchEnd(e: any) {
cropper && cropper.touchEnd(e);
}
/**
* 获取裁剪后的图片
* @param isPre 是否预览
*/
function getCropperImage(isPre = false) {
if (!src.value) return $u.toast(t('avatarCropper.noSelect'));
const cropper_opt = {
destHeight: Number(destWidth.value), // uni.canvasToTempFilePath要求这些参数为数值
destWidth: Number(destWidth.value),
fileType: fileType.value
};
cropper.getCropperImage(cropper_opt, (path: string, err: any) => {
if (err) {
uni.showModal({
title: t('avatarCropper.modalTitle'),
content: err.message
});
} else {
if (isPre) {
uni.previewImage({
current: '', // 当前显示图片的 http 链接
urls: [path] // 需要预览的图片 http 链接列表
});
} else {
uni.$emit('uAvatarCropper', path);
$u.route({
type: 'back'
});
}
}
});
}
/**
* 重新选择图片
*/
function uploadTap() {
uni.chooseImage({
count: 1, // 默认9
sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.content {
background: rgba(255, 255, 255, 1);
}
.cropper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 11;
}
.cropper-buttons {
background-color: var(--u-bg-black);
color: var(--u-divider-color);
}
.cropper-wrapper {
position: relative;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--u-bg-black);
}
.cropper-buttons {
width: 100vw;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
font-size: 28rpx;
}
.cropper-buttons .upload,
.cropper-buttons .getCropperImage {
width: 50%;
text-align: center;
}
.cropper-buttons .upload {
text-align: left;
padding-left: 50rpx;
}
.cropper-buttons .getCropperImage {
text-align: right;
padding-right: 50rpx;
}
</style>
@@ -0,0 +1,62 @@
// @ts-nocheck
/**
* we-cropper 类型声明文件
* 适用于 Vue3 + TypeScript 项目中 weCropper.js 的类型提示
*/
export interface WeCropperOptions {
id?: string;
targetId?: string;
pixelRatio?: number;
width?: number;
height?: number;
scale?: number;
zoom?: number;
cut?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
boundStyle?: {
lineWidth?: number;
borderColor?: string;
mask?: string;
color?: string;
};
[key: string]: any;
}
export type WeCropperEvent = (ctx: any, instance?: any) => void;
export interface WeCropperInstance {
pushOrign(src: string): Promise<void>;
pushOrigin(src: string): Promise<void>;
removeImage(): Promise<void>;
getCropperBase64(done?: (base64: string) => void): void;
getCropperImage(opt: any, fn: (path: string, err: any) => void): void;
update(): void;
touchStart(e: any): void;
touchMove(e: any): void;
touchEnd(e: any): void;
on(event: 'ready' | 'beforeImageLoad' | 'imageLoad' | 'beforeDraw', fn: WeCropperEvent): WeCropperInstance;
// 其它动态方法
[key: string]: any;
}
// WeCropper 类型声明文件
declare class WeCropper {
constructor(params: any);
// 事件方法
on(event: string, callback: (ctx?: any) => void): WeCropper;
// 其他方法
pushOrign(src: string): void;
getCropperImage(callback?: (tempFilePath: string) => void): void;
// 属性
[key: string]: any;
}
export default WeCropper;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ImgMode } from '../../types/global';
// u-avatar 组件 props
export const AvatarProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 头像路径 */
src: { type: String, default: '' },
/** 尺寸,large-大,default-中等,mini-小,如果为数值,则单位为rpx,宽度等于高度 */
size: { type: [String, Number], default: 'default' },
/** 头像模型,square-带圆角方形,circle-圆形 */
mode: { type: String, default: 'circle' },
/** 文字内容 */
text: { type: String, default: '' },
/** 图片的裁剪模型 */
imgMode: { type: String as PropType<ImgMode>, default: 'aspectFill' },
/** 标识符 */
index: { type: [String, Number], default: '' },
/** 右上角性别角标,man-男,woman-女 */
sexIcon: { type: String as PropType<'man' | 'woman'>, default: 'man' },
/** 右下角的等级图标 */
levelIcon: { type: String, default: 'level' },
/** 右下角等级图标背景颜色 */
levelBgColor: { type: String, default: '' },
/** 右上角性别图标的背景颜色 */
sexBgColor: { type: String, default: '' },
/** 是否显示性别图标 */
showSex: { type: Boolean, default: false },
/** 是否显示等级图标 */
showLevel: { type: Boolean, default: false }
};
export type AvatarProps = ExtractPropTypes<typeof AvatarProps>;
@@ -0,0 +1,218 @@
<template>
<view class="u-avatar" :class="customClass" :style="$u.toStyle(wrapStyle, customStyle)" @tap="onClick">
<image
@error="onLoadError"
:style="imgStyle"
class="u-avatar__img"
v-if="!uText && avatar"
:src="avatar"
:mode="imgMode"
></image>
<text class="u-line-1" v-else-if="uText" :style="{ fontSize: '38rpx' }">{{ uText }}</text>
<slot v-else></slot>
<view class="u-avatar__sex" v-if="showSex" :class="['u-avatar__sex--' + sexIcon]" :style="uSexStyle">
<u-icon :name="sexIcon" size="20"></u-icon>
</view>
<view class="u-avatar__level" v-if="showLevel" :style="uLevelStyle">
<u-icon :name="levelIcon" size="20"></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-avatar',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { AvatarProps } from './types';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
const base64Avatar =
'data:image/jpg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjREMEQwRkY0RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjREMEQwRkY1RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NEQwRDBGRjJGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NEQwRDBGRjNGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwMDAwMDBAMDg8QDw4MExMUFBMTHBsbGxwfHx8fHx8fHx8fAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCADIAMgDAREAAhEBAxEB/8QAcQABAQEAAwEBAAAAAAAAAAAAAAUEAQMGAgcBAQAAAAAAAAAAAAAAAAAAAAAQAAIBAwICBgkDBQAAAAAAAAABAhEDBCEFMVFBYXGREiKBscHRMkJSEyOh4XLxYjNDFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A/fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHbHFyZ/Dam+yLA+Z2L0Pjtyj2poD4AAAAAAAAAAAAAAAAAAAAAAAAKWFs9y6lcvvwQeqj8z9wFaziY1n/HbUX9XF97A7QAGXI23EvJ1goyfzR0YEfN269jeZ+a03pNe0DIAAAAAAAAAAAAAAAAAAAACvtO3RcVkXlWutuL9YFYAAAAAOJRjKLjJVi9GmB5/csH/mu1h/in8PU+QGMAAAAAAAAAAAAAAAAAAaMDG/6MmMH8C80+xAelSSVFolwQAAAAAAAHVlWI37ErUulaPk+hgeYnCUJuElSUXRrrQHAAAAAAAAAAAAAAAAABa2Oz4bM7r4zdF2ICmAAAAAAAAAg7zZ8GX41wuJP0rRgYAAAAAAAAAAAAAAAAAD0m2R8ODaXU33tsDSAAAAAAAAAlb9HyWZcnJd9PcBHAAAAAAAAAAAAAAAAAPS7e64Vn+KA0AAAAAAAAAJm+v8Ftf3ewCKAAAAAAAAAAAAAAAAAX9muqeGo9NttP06+0DcAAAAAAAAAjb7dTu2ra+VOT9P8AQCWAAAAAAAAAAAAAAAAAUNmyPt5Ltv4bui/kuAF0AAAAAAADiUlGLlJ0SVW+oDzOXfd/Ind6JPRdS0QHSAAAAAAAAAAAAAAAAAE2nVaNcGB6Lbs6OTao9LsF51z60BrAAAAAABJ3jOVHjW3r/sa9QEgAAAAAAAAAAAAAAAAAAAPu1duWriuW34ZR4MC9hbnZyEoy8l36XwfYBsAAADaSq9EuLAlZ+7xSdrGdW9Hc5dgEdtt1erfFgAAAAAAAAAAAAAAAAADVjbblX6NR8MH80tEBRs7HYivyzlN8lovaBPzduvY0m6eK10TXtAyAarO55lpJK54orolr+4GqO/Xaea1FvqbXvA+Z77kNeW3GPbV+4DJfzcm/pcm3H6Vou5AdAFLC2ed2Pjv1txa8sV8T6wOL+yZEKu1JXFy4MDBOE4ScZxcZLinoB8gAAAAAAAAAAAB242LeyJ+C3GvN9C7QLmJtePYpKS+5c+p8F2IDYAANJqj1T4oCfk7Nj3G5Wn9qXJax7gJ93Z82D8sVNc4v30A6Xg5i42Z+iLfqARwcyT0sz9MWvWBps7LlTf5Grce9/oBTxdtxseklHxT+uWr9AGoAB138ezfj4bsFJdD6V2MCPm7RdtJzs1uW1xXzL3gTgAAAAAAAAADRhYc8q74I6RWs5ckB6GxYtWLat21SK731sDsAAAAAAAAAAAAAAAASt021NO/YjrxuQXT1oCOAAAAAAABzGLlJRSq26JAelwsWONYjbXxcZvmwO8AAAAAAAAAAAAAAAAAAef3TEWPkVivx3NY9T6UBiAAAAAABo2+VmGXblddIJ8eivRUD0oAAAAAAAAAAAAAAAAAAAYt4tKeFKVNYNSXfRgefAAAAAAAAr7VuSSWPedKaW5v1MCsAAAAAAAAAAAAAAAAAAIe6bj96Ts2n+JPzSXzP3ATgAAAAAAAAFbbt1UUrOQ9FpC4/UwK6aaqtU+DAAAAAAAAAAAAAAA4lKMIuUmoxWrb4ARNx3R3q2rLpa4Sl0y/YCcAAAAAAAAAAANmFud7G8r89r6X0dgFvGzLGRGtuWvTF6NAdwAAAAAAAAAAAy5W442PVN+K59EePp5ARMvOv5MvO6QXCC4AZwAAAAAAAAAAAAAcxlKLUotprg1owN+PvORborq+7Hnwl3gUbO74VzRydt8pKn68ANcJwmqwkpLmnUDkAAAAfNy9atqtyagut0AxXt5xIV8Fbj6lRd7Am5G65V6qUvtwfyx94GMAAAAAAAAAAAAAAAAAAAOU2nVOj5gdsc3LiqRvTpyqwOxbnnrhdfpSfrQB7pnv/AGvuS9gHXPMy5/Fem1yq0v0A6W29XqwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//Z';
/**
* avatar 头像
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uviewpro.cn/zh/components/avatar.html
* @property {String} bg-color 背景颜色,一般显示文字时用(默认var(--u-bg-white))
* @property {String} src 头像路径,如加载失败,将会显示默认头像
* @property {String Number} size 头像尺寸,可以为指定字符串(large, default, mini),或者数值,单位rpx(默认default)
* @property {String} mode 显示类型,见上方说明(默认circle)
* @property {String} sex-icon 性别图标,man-男,woman-女(默认man)
* @property {String} level-icon 等级图标(默认level)
* @property {String} sex-bg-color 性别图标背景颜色
* @property {String} level-bg-color 等级图标背景颜色
* @property {String} show-sex 是否显示性别图标(默认false)
* @property {String} show-level 是否显示等级图标(默认false)
* @property {String} img-mode 头像图片的裁剪类型,与uni的image组件的mode参数一致,如效果达不到需求,可尝试传widthFix值(默认aspectFill)
* @property {String} index 用户传递的标识符值,如果是列表循环,可穿v-for的index值
* @event {Function} click 头像被点击
* @example <u-avatar :src="src"></u-avatar>
*/
const props = defineProps(AvatarProps);
const emit = defineEmits(['click']);
// 头像的地址,因为如果加载错误,需要赋值为默认图片,props值无法修改,所以需要一个中间值
const avatar = ref(props.src ? props.src : base64Avatar);
const error = ref(false);
// 用户可能会在头像加载失败时,再次修改头像值,所以需要重新赋值
watch(
() => props.src,
n => {
if (!n) {
// 如果传入null或者'',或者undefined,显示默认头像
avatar.value = base64Avatar;
error.value = true;
} else {
avatar.value = n;
error.value = false;
}
}
);
/**
* 头像外层样式
*/
const wrapStyle = computed(() => {
let style: Record<string, string> = {};
style.height =
props.size === 'large'
? '120rpx'
: props.size === 'default'
? '90rpx'
: props.size === 'mini'
? '70rpx'
: props.size + 'rpx';
style.width = style.height;
style.flex = `0 0 ${style.height}`;
style.backgroundColor = props.bgColor;
style.borderRadius = props.mode === 'circle' ? '500px' : '5px';
if (props.text) style.padding = '0 6rpx';
return style;
});
/**
* 图片样式
*/
const imgStyle = computed(() => {
let style: Record<string, string> = {};
style.borderRadius = props.mode === 'circle' ? '500px' : '5px';
return style;
});
/**
* 取字符串的第一个字符
*/
const uText = computed(() => {
return String(props.text)[0];
});
/**
* 性别图标的自定义样式
*/
const uSexStyle = computed(() => {
let style: Record<string, string> = {};
if (props.sexBgColor) style.backgroundColor = props.sexBgColor;
return style;
});
/**
* 等级图标的自定义样式
*/
const uLevelStyle = computed(() => {
let style: Record<string, string> = {};
if (props.levelBgColor) style.backgroundColor = props.levelBgColor;
return style;
});
/**
* 图片加载错误时,显示默认头像
*/
function onLoadError() {
error.value = true;
avatar.value = base64Avatar;
}
/**
* 头像被点击
*/
function onClick() {
emit('click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-avatar {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
justify-content: center;
font-size: 28rpx;
color: $u-content-color;
border-radius: 10px;
position: relative;
&__img {
width: 100%;
height: 100%;
}
&__sex {
position: absolute;
width: 32rpx;
color: var(--u-white-color);
height: 32rpx;
@include vue-flex;
justify-content: center;
align-items: center;
border-radius: 100rpx;
top: 5%;
z-index: 1;
right: -7%;
border: 1px var(--u-white-color) solid;
&--man {
background-color: $u-type-primary;
}
&--woman {
background-color: $u-type-error;
}
&--none {
background-color: $u-type-warning;
}
}
&__level {
position: absolute;
width: 32rpx;
color: var(--u-white-color);
height: 32rpx;
@include vue-flex;
justify-content: center;
align-items: center;
border-radius: 100rpx;
bottom: 5%;
z-index: 1;
right: -7%;
border: 1px var(--u-white-color) solid;
background-color: $u-type-warning;
}
}
</style>
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape } from '../../types/global';
// u-back-top 组件 props
export const BackTopProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 返回顶部的形状,circle-圆形,square-方形 */
mode: { type: String as PropType<Shape>, default: 'circle' },
/** 自定义图标 */
icon: { type: String, default: 'arrow-upward' },
/** 提示文字 */
tips: { type: String, default: '' },
/** 返回顶部滚动时间 */
duration: { type: [Number, String], default: 100 },
/** 滚动距离 */
scrollTop: { type: [Number, String], default: 0 },
/** 距离顶部多少距离显示,单位rpx */
top: { type: [Number, String], default: 400 },
/** 返回顶部按钮到底部的距离,单位rpx */
bottom: { type: [Number, String], default: 200 },
/** 返回顶部按钮到右边的距离,单位rpx */
right: { type: [Number, String], default: 40 },
/** 层级 */
zIndex: { type: [Number, String], default: '9' },
/** 图标的样式,对象形式 */
iconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({
color: 'var(--u-tips-color)',
fontSize: '38rpx'
})
}
};
export type BackTopProps = ExtractPropTypes<typeof BackTopProps>;
@@ -0,0 +1,141 @@
<template>
<view
@tap="backToTop"
class="u-back-top"
:class="['u-back-top--mode--' + mode, customClass]"
:style="
$u.toStyle(
{
bottom: bottom + 'rpx',
right: right + 'rpx',
borderRadius: mode == 'circle' ? '10000rpx' : '8rpx',
zIndex: uZIndex,
opacity: opacity
},
customStyle
)
"
>
<view class="u-back-top__content" v-if="!slots.default">
<u-icon @click="backToTop" :name="icon" :custom-style="iconStyle"></u-icon>
<view class="u-back-top__content__tips">
{{ tips }}
</view>
</view>
<slot v-else />
</view>
</template>
<script lang="ts">
export default {
name: 'u-back-top',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, useSlots } from 'vue';
import { BackTopProps } from './types';
import { $u } from '../../';
import uIcon from '../u-icon/u-icon.vue';
/**
* back-top 返回顶部按钮
* @description 页面滚动时显示的返回顶部按钮
* @property {String} mode 返回顶部的形状,circle-圆形,square-方形
* @property {String} icon 自定义图标
* @property {String} tips 提示文字
* @property {Number|String} duration 返回顶部滚动时间
* @property {Number|String} scrollTop 滚动距离
* @property {Number|String} top 距离顶部多少距离显示,单位rpx
* @property {Number|String} bottom 返回顶部按钮到底部的距离,单位rpx
* @property {Number|String} right 返回顶部按钮到右边的距离,单位rpx
* @property {Number|String} zIndex 层级
* @property {Object} iconStyle 图标的样式,对象形式
* @property {Object} customStyle 整个组件的样式
*/
const props = defineProps(BackTopProps);
const slots = useSlots();
// 不透明度,为了让组件有一个显示和隐藏的过渡动画
const opacity = ref(0);
// 组件的z-index值,隐藏时设置为-1,就会看不到
const uZIndex = ref(-1);
/**
* 计算是否显示返回顶部按钮
* 由于scrollTop为页面的滚动距离,默认为px单位,这里将用于传入的top(rpx)值
* 转为px用于比较,如果滚动条到顶的距离大于设定的距离,就显示返回顶部的按钮
*/
const showBackTop = computed(() => {
// uni.upx2px 用于将 rpx 转为 px
return (
Number(props.scrollTop) >
(typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.top)) : Number(props.top))
);
// #else
// return Number(props.scrollTop) > Number(props.top)
});
// 监听组件的显示与隐藏状态,修改组件的层级和不透明度
// 让组件有显示和消失的动画效果,如果用v-if控制组件状态,将无设置动画效果
watch(showBackTop, nVal => {
if (nVal) {
uZIndex.value = Number(props.zIndex);
opacity.value = 1;
} else {
uZIndex.value = -1;
opacity.value = 0;
}
});
/**
* 返回顶部方法
*/
function backToTop() {
// uni.pageScrollTo 用于页面滚动到顶部
if (typeof uni !== 'undefined' && uni.pageScrollTo) {
uni.pageScrollTo({
scrollTop: 0,
duration: Number(props.duration)
});
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-back-top {
width: 80rpx;
height: 80rpx;
position: fixed;
z-index: 9;
@include vue-flex;
flex-direction: column;
justify-content: center;
background-color: var(--u-divider-color);
color: $u-content-color;
align-items: center;
transition: opacity 0.4s;
&__content {
@include vue-flex;
flex-direction: column;
align-items: center;
&__tips {
font-size: 24rpx;
transform: scale(0.8);
line-height: 1;
}
}
}
</style>
@@ -0,0 +1,46 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { BadgeSize, ThemeType } from '../../types/global';
/**
* badge 角标类型定义
* @description 供 u-badge 组件 props 使用
*/
export const BadgeProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 使用预设的背景颜色 primary,warning,success,error,info */
type: { type: String as PropType<ThemeType>, default: 'error' },
/** Badge的尺寸,default, mini */
size: { type: String as PropType<BadgeSize>, default: 'default' },
/** 是否是圆点 */
isDot: { type: Boolean, default: false },
/** 显示的数值内容 */
count: { type: [Number, String], default: undefined },
/** 展示封顶的数字值 */
overflowCount: { type: Number, default: 99 },
/** 当数值为 0 时,是否展示 Badge */
showZero: { type: Boolean, default: false },
/** 位置偏移 [number, number] */
offset: { type: Array as unknown as PropType<[number, number]>, default: () => [20, 20] },
/** 是否开启绝对定位,开启了offset才会起作用 */
absolute: { type: Boolean, default: true },
/** 字体大小 */
fontSize: { type: [String, Number], default: '24' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-white-color)' },
/** badge的背景颜色 */
bgColor: { type: String, default: '' },
/** 是否让badge组件的中心点和父组件右上角重合,配置的话,offset将会失效 */
isCenter: { type: Boolean, default: false }
};
export type BadgeProps = ExtractPropTypes<typeof BadgeProps>;
@@ -0,0 +1,153 @@
<template>
<view
v-if="show"
class="u-badge"
:class="[
isDot ? 'u-badge-dot' : '',
size === 'mini' ? 'u-badge-mini' : '',
type ? 'u-badge--bg--' + type : '',
customClass
]"
:style="
$u.toStyle(
{
top: (offset as number[])[0] + 'rpx',
right: (offset as number[])[1] + 'rpx',
fontSize: fontSize + 'rpx',
position: absolute ? 'absolute' : 'static',
color: color,
backgroundColor: bgColor
},
boxStyle,
customStyle
)
"
>
{{ showText }}
</view>
</template>
<script lang="ts">
export default {
name: 'u-badge',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { BadgeProps } from './types';
import { $u } from '../..';
/**
* badge 角标
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uviewpro.cn/zh/components/badge.html
* @property {String|Number} count 展示的数字,大于 overflowCount 时显示为 ${overflowCount}+,为0且show-zero为false时隐藏
* @property {Boolean} is-dot 不展示数字,只有一个小点(默认false)
* @property {Boolean} absolute 组件是否绝对定位,为true时,offset参数才有效(默认true)
* @property {String|Number} overflow-count 展示封顶的数字值(默认99)
* @property {String} type 使用预设的背景颜色(默认error)
* @property {Boolean} show-zero 当数值为 0 时,是否展示 Badge(默认false)
* @property {String} size Badge的尺寸,设为mini会得到小一号的Badge(默认default)
* @property {Array} offset 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,单位rpx。absolute为true时有效(默认[20, 20])
* @property {String} color 字体颜色(默认var(--u-white-color))
* @property {String} bgColor 背景颜色,优先级比type高,如设置,type参数会失效
* @property {Boolean} is-center 组件中心点是否和父组件右上角重合,优先级比offset高,如设置,offset参数会失效(默认false)
* @example <u-badge type="error" count="7"></u-badge>
*/
const props = defineProps(BadgeProps);
/**
* 计算 badge 的定位和变换样式
*/
const boxStyle = computed(() => {
let style: Record<string, any> = {};
if (props.isCenter) {
style.top = 0;
style.right = 0;
// Y轴-50%,意味着badge向上移动了badge自身高度一半,X轴50%,意味着向右移动了自身宽度一半
style.transform = 'translateY(-50%) translateX(50%)';
} else {
style.top = (props.offset as number[])[0] + 'rpx';
style.right = (props.offset as number[])[1] + 'rpx';
style.transform = 'translateY(0) translateX(0)';
}
// 如果尺寸为mini,后接上scale()
if (props.size === 'mini') {
style.transform = style.transform + ' scale(0.8)';
}
return style;
});
/**
* 计算显示的文本内容
*/
const showText = computed(() => {
if (props.isDot) return '';
else {
if (Number(props.count) > props.overflowCount) return `${props.overflowCount}+`;
else return props.count;
}
});
/**
* 是否显示组件
*/
const show = computed(() => {
// 如果count的值为0,并且showZero设置为false,不显示组件
if (Number(props.count) === 0 && props.showZero === false) return false;
else return true;
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-badge {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
justify-content: center;
align-items: center;
line-height: 24rpx;
padding: 4rpx 8rpx;
border-radius: 100rpx;
z-index: 9;
&--bg--primary {
background-color: $u-type-primary;
}
&--bg--error {
background-color: $u-type-error;
}
&--bg--success {
background-color: $u-type-success;
}
&--bg--info {
background-color: $u-type-info;
}
&--bg--warning {
background-color: $u-type-warning;
}
}
.u-badge-dot {
height: 16rpx;
width: 16rpx;
border-radius: 100rpx;
line-height: 1;
}
.u-badge-mini {
transform: scale(0.8);
transform-origin: center center;
}
</style>
@@ -0,0 +1,76 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ButtonFormType, ButtonOpenType, ButtonScope, ButtonSize, ButtonType, Shape } from '../../types/global';
/**
* button 按钮类型定义
* @description 供 u-button 组件 props 使用
*/
export const ButtonProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 按钮文本 */
text: { type: String, default: '' },
/** 是否细边框 */
hairLine: { type: Boolean, default: true },
/** 按钮的预置样式,default,primary,error,warning,success */
type: { type: String as PropType<ButtonType>, default: 'default' },
/** 按钮尺寸,default,large, medium=small,mini */
size: { type: String as PropType<ButtonSize>, default: 'default' },
/** 按钮形状,circle(两边为半圆),square(带圆角) */
shape: { type: String as PropType<Shape>, default: 'square' },
/** 按钮是否镂空 */
plain: { type: Boolean, default: false },
/** 是否禁止状态 */
disabled: { type: Boolean, default: false },
/** 是否加载中 */
loading: { type: Boolean, default: false },
/** 支付宝小程序,当 open-type 为 getAuthorize 时有效 */
scope: { type: String as PropType<ButtonScope>, default: '' },
/** 开放能力,具体请看uniapp稳定关于button组件部分说明 */
openType: { type: String as PropType<ButtonOpenType>, default: '' },
/** 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件 */
formType: { type: String as PropType<ButtonFormType>, default: '' },
/** 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效 */
appParameter: { type: String, default: '' },
/** 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效 */
hoverStopPropagation: { type: Boolean, default: false },
/** 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效 */
lang: { type: String, default: 'en' },
/** 会话来源,open-type="contact"时有效。只微信小程序有效 */
sessionFrom: { type: String, default: '' },
/** 会话内消息卡片标题,open-type="contact"时有效 */
sendMessageTitle: { type: String, default: '' },
/** 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效 */
sendMessagePath: { type: String, default: '' },
/** 会话内消息卡片图片,open-type="contact"时有效 */
sendMessageImg: { type: String, default: '' },
/** 是否显示会话内消息卡片,open-type="contact"时有效 */
showMessageCard: { type: Boolean, default: false },
/** 手指按(触摸)按钮时按钮时的背景颜色 */
hoverBgColor: { type: String, default: '' },
/** 水波纹的背景颜色 */
rippleBgColor: { type: String, default: '' },
/** 是否开启水波纹效果 */
ripple: { type: Boolean, default: false },
/** 按下的类名 */
hoverClass: { type: String, default: '' },
/** 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取 */
dataName: { type: String, default: '' },
/** 节流,一定时间内只能触发一次 */
throttleTime: { type: [String, Number], default: 0 },
/** 按住后多久出现点击态,单位毫秒 */
hoverStartTime: { type: [String, Number], default: 20 },
/** 手指松开后点击态保留时间,单位毫秒 */
hoverStayTime: { type: [String, Number], default: 150 }
};
export type ButtonProps = ExtractPropTypes<typeof ButtonProps>;
@@ -0,0 +1,608 @@
<template>
<!-- prettier-ignore -->
<button
id="u-wave-btn"
class="u-btn u-line-1 u-fix-ios-appearance"
data-eventsync="true"
:class="[
'u-size-' + size,
plain ? 'u-btn--' + type + '--plain' : '',
loading ? 'u-loading' : '',
shape === 'circle' ? 'u-round-circle' : '',
hairLine ? showHairLineBorder : 'u-btn--bold-border',
'u-btn--' + type,
disabled ? `u-btn--${type}--disabled` : '',
customClass
]"
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
:disabled="disabled"
:form-type="(formType as any)"
:open-type="disabled || loading ? undefined : openType"
:app-parameter="appParameter"
:hover-stop-propagation="hoverStopPropagation"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:lang="(lang as any)"
:data-name="dataName"
:session-from="sessionFrom"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
@getAuthorize="getAuthorize"
@getuserinfo="getuserinfo"
@contact="contact"
@getphonenumber="getphonenumber"
@error="error"
@launchapp="launchapp"
@opensetting="opensetting"
@chooseavatar="chooseavatar"
@agreeprivacyauthorization="agreeprivacyauthorization"
:style="
$u.toStyle(
{
overflow: ripple ? 'hidden' : 'visible'
},
customStyle
)
"
@tap.stop="click($event)"
:hover-class="getHoverClass"
:loading="loading"
>
<slot>{{ props.text }}</slot>
<view
v-if="ripple"
class="u-wave-ripple"
:class="[waveActive ? 'u-wave-active' : '']"
:style="{
top: rippleTop + 'px',
left: rippleLeft + 'px',
width: fields.targetWidth + 'px',
height: fields.targetWidth + 'px',
'background-color': rippleBgColor || 'rgba(0, 0, 0, 0.15)'
}"
></view>
</button>
</template>
<script lang="ts">
export default {
name: 'u-button',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { $u } from '../../';
import { ButtonProps } from './types';
/**
* button 按钮
* @description Button 按钮
* @tutorial https://uviewpro.cn/zh/components/button.html
* @property {String} size 按钮的大小
* @property {Boolean} ripple 是否开启点击水波纹效果
* @property {String} ripple-bg-color 水波纹的背景色,ripple为true时有效
* @property {String} type 按钮的样式类型
* @property {Boolean} plain 按钮是否镂空,背景色透明
* @property {Boolean} disabled 是否禁用
* @property {Boolean} hair-line 是否显示按钮的细边框(默认true)
* @property {String} shape 按钮外观形状,见文档说明
* @property {Boolean} loading 按钮名称前是否带 loading 图标(App-nvue 平台,在 ios 上为雪花,Android上为圆圈)
* @property {String} form-type 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
* @property {String} open-type 开放能力
* @property {String} data-name 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
* @property {String} hover-class 指定按钮按下去的样式类。当 hover-class="none" 时,没有点击态效果(App-nvue 平台暂不支持)
* @property {Number} hover-start-time 按住后多久出现点击态,单位毫秒
* @property {Number} hover-stay-time 手指松开后点击态保留时间,单位毫秒
* @property {Object} custom-style 对按钮的自定义样式,对象形式,见文档说明
* @property {String} app-parameter 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效
* @property {Boolean} hover-stop-propagation 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效
* @property {String} session-from 会话来源,open-type="contact"时有效。只微信小程序有效
* @property {String} send-message-title 会话内消息卡片标题,open-type="contact"时有效
* @property {String} send-message-path 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效
* @property {String} send-message-img 会话内消息卡片图片,open-type="contact"时有效
* @property {Boolean} show-message-card 是否显示会话内消息卡片,open-type="contact"时有效
* @property {Number|String} throttle-time 节流,一定时间内只能触发一次,单位毫秒
* @property {String} scope 支付宝小程序,当 open-type 为 getAuthorize 时有效。可选值:'phoneNumber' | 'userInfo'
* @event {Function} click 按钮点击
* @event {Function} getphonenumber open-type="getPhoneNumber"时有效
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
* @event {Function} error 当使用开放能力时,发生错误的回调
* @event {Function} opensetting 在打开授权设置页并关闭后回调
* @event {Function} launchapp 打开 APP 成功的回调
* @event {Function} contact 客服消息回调
* @event {Function} chooseavatar 头像选择回调
* @event {Function} agreeprivacyauthorization 用户点击允许授权回调
* @example <u-button>月落</u-button>
*/
const emit = defineEmits([
'click',
'getuserinfo',
'contact',
'getphonenumber',
'error',
'launchapp',
'opensetting',
'chooseavatar',
'agreeprivacyauthorization'
]);
const props = defineProps(ButtonProps);
/**
* 按钮水波纹相关状态
*/
const rippleTop = ref(0); // 水波纹的起点Y坐标到按钮上边界的距离
const rippleLeft = ref(0); // 水波纹起点X坐标到按钮左边界的距离
const fields = ref<Record<string, any>>({}); // 波纹按钮节点信息
const waveActive = ref(false); // 激活水波纹
/**
* 当没有传bgColor变量时,按钮按下去的颜色类名
* @returns {string}
*/
const getHoverClass = computed(() => {
// 如果开启水波纹效果,则不启用hover-class效果
if (props.loading || props.disabled || props.ripple) return '';
// 如果用户传了 hoverClass,优先使用用户的
if (props.hoverClass) return props.hoverClass;
let hoverClass = '';
hoverClass = props.plain ? 'u-' + props.type + '-plain-hover' : 'u-' + props.type + '-hover';
return hoverClass;
});
/**
* 在'primary', 'success', 'error', 'warning'类型下,不显示边框,否则会造成四角有毛刺现象
* @returns {string}
*/
const showHairLineBorder = computed(() => {
if (['primary', 'success', 'error', 'warning'].indexOf(props.type) >= 0 && !props.plain) {
return '';
} else {
return 'u-hairline-border';
}
});
/**
* 按钮点击
* @param e 事件对象
* @emits click
*/
function click(e: any) {
if (Number(props.throttleTime)) {
$u.throttle(() => {
clickAction(e);
}, Number(props.throttleTime));
} else {
clickAction(e);
}
}
function clickAction(e: any) {
// 如果按钮时disabled和loading状态,不触发水波纹效果
if (props.loading === true || props.disabled === true) return;
// 是否开启水波纹效果
if (props.ripple) {
// 每次点击时,移除上一次的类,再次添加,才能触发动画效果
waveActive.value = false;
nextTick(() => {
getWaveQuery(e);
});
}
emit('click', e);
}
/**
* 查询按钮的节点信息
* @param e 事件对象
*/
function getWaveQuery(e: any) {
getElQuery().then((res: any[]) => {
// 查询返回的是一个数组节点
let data = res[0];
// 查询不到节点信息,不操作
if (!data.width || !data.width) return;
// 水波纹的最终形态是一个正方形(通过border-radius让其变为一个圆形),这里要保证正方形的边长等于按钮的最长边
// 最终的方形(变换后的圆形)才能覆盖整个按钮
data.targetWidth = data.height > data.width ? data.height : data.width;
if (!data.targetWidth) return;
fields.value = data;
let touchesX = '',
touchesY = '';
// #ifdef MP-BAIDU
// @ts-ignore
touchesX = e.changedTouches[0].clientX;
touchesY = e.changedTouches[0].clientY;
// #endif
// #ifdef MP-ALIPAY
// @ts-ignore
touchesX = e.detail.clientX;
touchesY = e.detail.clientY;
// #endif
// #ifndef MP-BAIDU || MP-ALIPAY
// @ts-ignore
touchesX = e.touches[0].clientX;
touchesY = e.touches[0].clientY;
// #endif
// 获取触摸点相对于按钮上边和左边的x和y坐标,原理是通过屏幕的触摸点(touchesY),减去按钮的上边界data.top
// 但是由于`transform-origin`默认是center,所以这里再减去半径才是水波纹view应该的位置
// 总的来说,就是把水波纹的矩形(变换后的圆形)的中心点,移动到我们的触摸点位置
rippleTop.value = Number(touchesY) - data.top - data.targetWidth / 2;
rippleLeft.value = Number(touchesX) - data.left - data.targetWidth / 2;
nextTick(() => {
waveActive.value = true;
});
});
}
/**
* 获取节点信息
* @returns {Promise<any[]>}
*/
function getElQuery(): Promise<any[]> {
return new Promise(resolve => {
let queryInfo: any = '';
// 获取元素节点信息,请查看uniapp相关文档
// https://uniapp.dcloud.io/api/ui/nodes-info?id=nodesrefboundingclientrect
// @ts-ignore
queryInfo = uni.createSelectorQuery().in(null);
// #ifdef MP-ALIPAY
// @ts-ignore
queryInfo = uni.createSelectorQuery();
// #endif
queryInfo.select('.u-btn').boundingClientRect();
queryInfo.exec((data: any) => {
resolve(data);
});
});
}
// 下面为对接uniapp官方按钮开放能力事件回调的对接
/**
* open-type="getPhoneNumber"时有效
*/
function getphonenumber(event: any) {
emit('getphonenumber', event);
}
/**
* 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
*/
function getuserinfo(event: any) {
emit('getuserinfo', event);
}
/**
* 当使用开放能力时,发生错误的回调
*/
function error(event: any) {
emit('error', event);
}
/**
* 在打开授权设置页并关闭后回调
*/
function opensetting(event: any) {
emit('opensetting', event);
}
/**
* 打开 APP 成功的回调
*/
function launchapp(event: any) {
emit('launchapp', event);
}
/**
* 支付宝小程序授权
* @param event
*/
function getAuthorize(event: any) {
if (props.scope === 'phoneNumber') {
getphonenumber(event);
} else if (props.scope === 'userInfo') {
getuserinfo(event);
}
}
/**
* 客服消息回调
* @param event
*/
function contact(event: any) {
emit('contact', event);
}
/**
* 头像选择回调
* @param event
*/
function chooseavatar(event: any) {
emit('chooseavatar', event);
}
/**
* 用户点击允许授权回调
* @param event
*/
function agreeprivacyauthorization(event: any) {
emit('agreeprivacyauthorization', event);
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-btn::after {
border: none;
}
.u-btn {
position: relative;
border: 0;
//border-radius: 10rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
// 避免边框某些场景可能被“裁剪”,不能设置为hidden
overflow: visible;
line-height: 1;
@include vue-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 40rpx;
z-index: 1;
box-sizing: border-box;
transition: all 0.15s;
&--bold-border {
border: 1px solid var(--u-white-color);
}
&--default {
color: $u-content-color;
border-color: var(--u-light-color);
background-color: var(--u-bg-white);
}
&--primary {
color: var(--u-white-color);
border-color: $u-type-primary;
background-color: $u-type-primary;
}
&--success {
color: var(--u-white-color);
border-color: $u-type-success;
background-color: $u-type-success;
}
&--error {
color: var(--u-white-color);
border-color: $u-type-error;
background-color: $u-type-error;
}
&--warning {
color: var(--u-white-color);
border-color: $u-type-warning;
background-color: $u-type-warning;
}
&--default--disabled {
color: var(--u-white-color);
border-color: var(--u-border-color);
background-color: var(--u-bg-white);
}
&--primary--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-primary-disabled !important;
background-color: $u-type-primary-disabled !important;
}
&--success--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-success-disabled !important;
background-color: $u-type-success-disabled !important;
}
&--error--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-error-disabled !important;
background-color: $u-type-error-disabled !important;
}
&--warning--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-warning-disabled !important;
background-color: $u-type-warning-disabled !important;
}
&--primary--plain {
color: $u-type-primary !important;
border-color: $u-type-primary-disabled !important;
background-color: $u-type-primary-light !important;
}
&--success--plain {
color: $u-type-success !important;
border-color: $u-type-success-disabled !important;
background-color: $u-type-success-light !important;
}
&--error--plain {
color: $u-type-error !important;
border-color: $u-type-error-disabled !important;
background-color: $u-type-error-light !important;
}
&--warning--plain {
color: $u-type-warning !important;
border-color: $u-type-warning-disabled !important;
background-color: $u-type-warning-light !important;
}
}
.u-hairline-border:after {
content: ' ';
position: absolute;
pointer-events: none;
// 设置为border-box,意味着下面的scale缩小为0.5,实际上缩小的是伪元素的内容(border-box意味着内容不含border)
box-sizing: border-box;
// 中心点作为变形(scale())的原点
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
left: 0;
top: 0;
width: 199.8%;
height: 199.7%;
-webkit-transform: scale(0.5, 0.5);
transform: scale(0.5, 0.5);
border: 1px solid currentColor;
z-index: 1;
}
.u-wave-ripple {
z-index: 0;
position: absolute;
border-radius: 100%;
background-clip: padding-box;
pointer-events: none;
user-select: none;
transform: scale(0);
opacity: 1;
transform-origin: center;
}
.u-wave-ripple.u-wave-active {
opacity: 0;
transform: scale(2);
transition:
opacity 1s linear,
transform 0.4s linear;
}
.u-round-circle {
border-radius: 100rpx;
}
.u-round-circle::after {
border-radius: 100rpx;
}
.u-loading::after {
background-color: hsla(0, 0%, 100%, 0.35);
}
.u-size-large {
font-size: 36rpx;
height: 100rpx;
line-height: 100rpx;
}
.u-size-default {
font-size: 30rpx;
height: 80rpx;
line-height: 80rpx;
}
.u-size-medium {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 26rpx;
height: 70rpx;
line-height: 70rpx;
padding: 0 80rpx;
}
.u-size-small {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 24rpx;
height: 60rpx;
line-height: 60rpx;
padding: 0 30rpx;
}
.u-size-mini {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 22rpx;
padding-top: 1px;
height: 50rpx;
line-height: 50rpx;
padding: 0 20rpx;
}
.u-primary-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-primary-dark !important;
}
.u-default-plain-hover {
color: $u-type-primary-dark !important;
background: $u-type-primary-light !important;
}
.u-success-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-success-dark !important;
}
.u-warning-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-warning-dark !important;
}
.u-error-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-error-dark !important;
}
.u-info-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-info-dark !important;
}
.u-default-hover {
color: $u-type-primary-dark !important;
border-color: $u-type-primary-dark !important;
background-color: $u-type-primary-light !important;
}
.u-primary-hover {
background: $u-type-primary-dark !important;
color: var(--u-white-color);
}
.u-success-hover {
background: $u-type-success-dark !important;
color: var(--u-white-color);
}
.u-info-hover {
background: $u-type-info-dark !important;
color: var(--u-white-color);
}
.u-warning-hover {
background: $u-type-warning-dark !important;
color: var(--u-white-color);
}
.u-error-hover {
background: $u-type-error-dark !important;
color: var(--u-white-color);
}
</style>
@@ -0,0 +1,126 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { CalendarChangeDate, CalendarChangeRange, CalendarMode, ThemeType } from '../../types/global';
import { getColor } from '../../';
/**
* calendar 日历类型定义
* @description 供 u-calendar 组件 props 使用
*/
export const CalendarProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否开启底部安全区适配 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 是否允许通过点击遮罩关闭Picker */
maskCloseAble: { type: Boolean, default: true },
/** 通过双向绑定控制组件的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 弹出的z-index值 */
zIndex: { type: [String, Number], default: 0 },
/** 是否允许切换年份 */
changeYear: { type: Boolean, default: true },
/** 是否允许切换月份 */
changeMonth: { type: Boolean, default: true },
/** date-单个日期选择,range-开始日期+结束日期选择 */
mode: { type: String as PropType<CalendarMode>, default: 'date' },
/** 可切换的最大年份 */
maxYear: { type: [Number, String], default: 2050 },
/** 可切换的最小年份 */
minYear: { type: [Number, String], default: 1950 },
/** 最小可选日期(不在范围内日期禁用不可选) */
minDate: { type: [Number, String], default: '1950-01-01' },
/** 最大可选日期,默认最大值为今天,之后的日期不可选 */
maxDate: { type: [Number, String], default: '' },
/** 弹窗顶部左右两边的圆角值 */
borderRadius: { type: [String, Number], default: 20 },
/** 月份切换按钮箭头颜色 */
monthArrowColor: { type: String, default: 'var(--u-content-color)' },
/** 年份切换按钮箭头颜色 */
yearArrowColor: { type: String, default: 'var(--u-tips-color)' },
/** 默认日期字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 选中|起始结束日期背景色 */
activeBgColor: { type: String, default: () => getColor('primary') },
/** 选中|起始结束日期字体颜色 */
activeColor: { type: String, default: 'var(--u-white-color)' },
/** 范围内日期背景色 */
rangeBgColor: { type: String, default: 'var(--u-type-primary-light)' },
/** 范围内日期字体颜色 */
rangeColor: { type: String, default: () => getColor('primary') },
/** mode=range时生效,起始日期自定义文案 */
startText: { type: String, default: '' },
/** mode=range时生效,结束日期自定义文案 */
endText: { type: String, default: '' },
/** 按钮样式类型 */
btnType: { type: String as PropType<ThemeType>, default: 'primary' },
/** 当前选中日期带选中效果 */
isActiveCurrent: { type: Boolean, default: true },
/** 初始化时是否默认选中今天,优先级低于 defaultDate/startDate/endDate */
defaultSelectToday: { type: Boolean, default: true },
/** 切换年月是否触发事件 mode=date时生效 */
isChange: { type: Boolean, default: false },
/** 是否显示右上角的关闭图标 */
closeable: { type: Boolean, default: true },
/** 顶部的提示文字 */
toolTip: { type: String, default: '' },
/** 是否显示农历 */
showLunar: { type: Boolean, default: false },
/** 是否在页面中显示 */
isPage: { type: Boolean, default: false },
/** 默认选中的日期,mode=date时生效,格式:2024-01-01 */
defaultDate: { type: String, default: '' },
/** 默认选中的开始日期,mode=range时生效,格式:2024-01-01 */
startDate: { type: String, default: '' },
/** 默认选中的结束日期,mode=range时生效,格式:2024-01-01 */
endDate: { type: String, default: '' },
/** 是否只读,只读模式下禁止点击选择日期 */
readonly: { type: Boolean, default: false },
/** 已打卡日期列表,格式:['2024-01-01', '2024-01-02'] */
checkedDates: { type: Array as PropType<string[]>, default: () => [] },
/** 打卡日期背景色 */
checkedBgColor: { type: String, default: 'var(--u-type-warning)' },
/** 打卡日期字体颜色 */
checkedColor: { type: String, default: 'var(--u-white-color)' },
/** 今日是否已打卡 */
todayChecked: { type: Boolean, default: false },
/** 今日已打卡背景色 */
todayCheckedBgColor: { type: String, default: 'var(--u-type-success)' },
/** 未打卡日期背景色 */
uncheckedBgColor: { type: String, default: 'var(--u-light-color)' },
/** 未打卡日期字体颜色 */
uncheckedColor: { type: String, default: 'var(--u-white-color)' },
/** 是否启用打卡签到模式(未打卡日期显示灰色) */
checkinMode: { type: Boolean, default: false },
/** 节假日列表,格式:['2024-01-01', '2024-01-02'] */
holidays: { type: Array as PropType<string[]>, default: () => [] },
/** 节假日文字颜色 */
holidayColor: { type: String, default: 'var(--u-type-error)' },
/** 加班日列表,格式:['2024-01-06', '2024-01-07'] */
workdays: { type: Array as PropType<string[]>, default: () => [] },
/** 加班日文字颜色 */
workdayColor: { type: String, default: 'var(--u-type-primary)' },
/** 节日配置,格式:{ '2024-04-04': '清明节', '2024-04-01': '愚人节' } */
festivals: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
/** 节日文字颜色 */
festivalColor: { type: String, default: 'var(--u-type-primary)' },
/** 是否显示内置节日(中国传统节日) */
showFestival: { type: Boolean, default: false },
/** 是否启用自定义日期内容插槽(微信小程序需要显式声明) */
useDateSlot: { type: Boolean, default: false }
};
export type CalendarProps = ExtractPropTypes<typeof CalendarProps>;
export type CalendarEmits = {
(e: 'update:modelValue', value: boolean): void;
(e: 'input', value: boolean): void;
(e: 'change', value: CalendarChangeDate | CalendarChangeRange): void;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-car-keyboard 车牌号键盘类型定义
* @description 供 u-car-keyboard 组件 props 使用
*/
export const CarKeyboardProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否打乱键盘按键的顺序 */
random: { type: Boolean, default: false }
};
export type CarKeyboardProps = ExtractPropTypes<typeof CarKeyboardProps>;
@@ -0,0 +1,263 @@
<template>
<view class="u-keyboard" :class="customClass" :style="$u.toStyle(customStyle)" @touchmove.stop.prevent="() => {}">
<view class="u-keyboard-grids">
<view>
<view class="u-keyboard-grids-item" v-for="(group, i) in abc ? EngKeyBoardList : areaList" :key="i">
<view
:hover-stay-time="100"
@tap="carInputClick(i, j)"
hover-class="u-carinput-hover"
class="u-keyboard-grids-btn"
v-for="(item, j) in group"
:key="j"
>
{{ item }}
</view>
</view>
<view
@touchstart="backspaceClick"
@touchend="clearTimer"
:hover-stay-time="100"
class="u-keyboard-back"
hover-class="u-hover-class"
>
<u-icon :size="38" name="backspace" :bold="true"></u-icon>
</view>
<view
:hover-stay-time="100"
class="u-keyboard-change"
hover-class="u-carinput-hover"
@tap="changeCarInputMode"
>
<text class="zh" :class="[!abc ? 'active' : 'inactive']">中</text>
/
<text class="en" :class="[abc ? 'active' : 'inactive']">英</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-car-keyboard',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { $u } from '../..';
import { CarKeyboardProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* u-car-keyboard 车牌号键盘
* @description 车牌号输入专用键盘,支持省份简称和字母数字切换,支持按键顺序打乱。
* @property {Boolean} random 是否打乱键盘按键的顺序(默认false)
* @event {Function} change 按键被点击
* @event {Function} backspace 退格键被点击
*/
const props = defineProps(CarKeyboardProps);
const emit = defineEmits(['change', 'backspace']);
// 车牌输入时,abc=true为输入车牌号码,abc=false为输入省份中文简称
const abc = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
const areaList = computed(() => {
let data = [
'京',
'沪',
'粤',
'津',
'冀',
'豫',
'云',
'辽',
'黑',
'湘',
'皖',
'鲁',
'苏',
'浙',
'赣',
'鄂',
'桂',
'甘',
'晋',
'陕',
'蒙',
'吉',
'闽',
'贵',
'渝',
'川',
'青',
'琼',
'宁',
'挂',
'藏',
'港',
'澳',
'新',
'使',
'学'
];
let tmp: string[][] = [];
// 打乱顺序
if (props.random) {
data = $u.randomArray(data);
}
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
});
const EngKeyBoardList = computed(() => {
// prettier-ignore
let data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M'];
let tmp: (string | number)[][] = [];
if (props.random) {
data = $u.randomArray(data);
}
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
});
/**
* 点击键盘按钮
*/
function carInputClick(i: number, j: number) {
let value = '';
// 不同模式,获取不同数组的值
if (abc.value) value = String(EngKeyBoardList.value[i][j]);
else value = String(areaList.value[i][j]);
emit('change', value);
}
/**
* 修改汽车牌键盘的输入模式,中文|英文
*/
function changeCarInputMode() {
abc.value = !abc.value;
}
/**
* 点击退格键
*/
function backspaceClick() {
emit('backspace');
if (timer) clearInterval(timer);
timer = setInterval(() => {
emit('backspace');
}, 250);
}
function clearTimer() {
if (timer) clearInterval(timer);
timer = null;
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-keyboard-grids {
background: rgb(215, 215, 217);
padding: 24rpx 0;
position: relative;
}
.u-keyboard-grids-item {
@include vue-flex;
align-items: center;
justify-content: center;
}
.u-keyboard-grids-btn {
text-decoration: none;
width: 62rpx;
flex: 0 0 64rpx;
height: 80rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
font-size: 36rpx;
text-align: center;
line-height: 80rpx;
background-color: var(--u-bg-white);
margin: 8rpx 5rpx;
border-radius: 8rpx;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
font-weight: 500;
justify-content: center;
}
.u-carinput-hover {
background-color: rgb(185, 188, 195) !important;
}
.u-keyboard-back {
position: absolute;
width: 96rpx;
right: 22rpx;
bottom: 32rpx;
height: 80rpx;
background-color: rgb(185, 188, 195);
@include vue-flex;
align-items: center;
border-radius: 8rpx;
justify-content: center;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
}
.u-keyboard-change {
font-size: 24rpx;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
position: absolute;
width: 96rpx;
left: 22rpx;
line-height: 1;
bottom: 32rpx;
height: 80rpx;
background-color: var(--u-bg-white);
@include vue-flex;
align-items: center;
border-radius: 8rpx;
justify-content: center;
}
.u-keyboard-change .inactive.zh {
transform: scale(0.85) translateY(-10rpx);
}
.u-keyboard-change .inactive.en {
transform: scale(0.85) translateY(10rpx);
}
.u-keyboard-change .active {
color: rgb(237, 112, 64);
font-size: 30rpx;
}
.u-keyboard-change .zh {
transform: translateY(-10rpx);
}
.u-keyboard-change .en {
transform: translateY(10rpx);
}
</style>
@@ -0,0 +1,69 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* card 卡片类型定义
* @description 供 u-card 组件 props 使用
*/
export type CardIndex = string | number | Record<string, any>;
export type CardStyle = Record<string, any>;
export const CardProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 与屏幕两侧是否留空隙 */
full: { type: Boolean, default: false },
/** 标题 */
title: { type: String, default: '' },
/** 标题颜色 */
titleColor: { type: String, default: 'var(--u-main-color)' },
/** 标题字体大小,单位rpx */
titleSize: { type: [Number, String], default: '30' },
/** 副标题 */
subTitle: { type: String, default: '' },
/** 副标题颜色 */
subTitleColor: { type: String, default: 'var(--u-tips-color)' },
/** 副标题字体大小,单位rpx */
subTitleSize: { type: [Number, String], default: '26' },
/** 是否显示外部边框,只对full=false时有效(卡片与边框有空隙时) */
border: { type: Boolean, default: false },
/** 用于标识点击了第几个 */
index: { type: [String, Number, Object] as PropType<CardIndex>, default: '' },
/** 用于隔开上下左右的边距,带单位的写法,如:"30rpx 30rpx","20rpx 20rpx 30rpx 30rpx" */
margin: { type: String, default: '30rpx' },
/** card卡片的圆角 */
borderRadius: { type: [Number, String], default: '16' },
/** 头部自定义样式,对象形式 */
headStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 主体自定义样式,对象形式 */
bodyStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 底部自定义样式,对象形式 */
footStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 头部是否下边框 */
headBorderBottom: { type: Boolean, default: true },
/** 底部是否有上边框 */
footBorderTop: { type: Boolean, default: true },
/** 标题左边的缩略图 */
thumb: { type: String, default: '' },
/** 缩略图宽高,单位rpx */
thumbWidth: { type: [String, Number], default: '60' },
/** 缩略图是否为圆形 */
thumbCircle: { type: Boolean, default: false },
/** 给head,body,foot的内边距 */
padding: { type: [String, Number], default: '30' },
/** 是否显示头部 */
showHead: { type: Boolean, default: true },
/** 是否显示尾部 */
showFoot: { type: Boolean, default: true },
/** 卡片外围阴影,字符串形式 */
boxShadow: { type: String, default: 'none' }
};
export type CardProps = ExtractPropTypes<typeof CardProps>;
@@ -0,0 +1,210 @@
<template>
<view
class="u-card"
@tap.stop="onClick"
:class="[{ 'u-border': border, 'u-card-full': full, 'u-card--border': Number(borderRadius) > 0 }, customClass]"
:style="
$u.toStyle(
{
borderRadius: borderRadius + 'rpx',
margin: margin,
boxShadow: boxShadow
},
customStyle
)
"
>
<view
v-if="showHead"
class="u-card__head"
:style="[{ padding: padding + 'rpx' }, headStyle]"
:class="{
'u-border-bottom': headBorderBottom
}"
@tap="onHeadClick"
>
<view v-if="!slots.head" class="u-flex u-row-between">
<view class="u-card__head--left u-flex u-line-1" v-if="title">
<image
:src="thumb"
class="u-card__head--left__thumb"
mode="aspectFill"
v-if="thumb"
:style="{
height: thumbWidth + 'rpx',
width: thumbWidth + 'rpx',
borderRadius: thumbCircle ? '100rpx' : '6rpx'
}"
></image>
<text
class="u-card__head--left__title u-line-1"
:style="{
fontSize: titleSize + 'rpx',
color: titleColor
}"
>
{{ title }}
</text>
</view>
<view class="u-card__head--right u-line-1" v-if="subTitle">
<text
class="u-card__head__title__text"
:style="{
fontSize: subTitleSize + 'rpx',
color: subTitleColor
}"
>
{{ subTitle }}
</text>
</view>
</view>
<slot name="head" v-else />
</view>
<view @tap="onBodyClick" class="u-card__body" :style="[{ padding: padding + 'rpx' }, bodyStyle]">
<slot />
<slot name="body" />
</view>
<view
v-if="showFoot"
class="u-card__foot"
@tap="onFootClick"
:style="[{ padding: slots.foot ? padding + 'rpx' : 0 }, footStyle]"
:class="{
'u-border-top': footBorderTop
}"
>
<slot name="foot" />
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-card',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { useSlots } from 'vue';
import { CardProps } from './types';
import { $u } from '../..';
/**
* card 卡片
* @description 卡片组件一般用于多个列表条目,且风格统一的场景
* @tutorial https://uviewpro.cn/zh/components/card.html
* @property {Boolean} full 卡片与屏幕两侧是否留空隙(默认false)
* @property {String} title 头部左边的标题
* @property {String} title-color 标题颜色(默认var(--u-main-color))
* @property {String | Number} title-size 标题字体大小,单位rpx(默认30)
* @property {String} sub-title 头部右边的副标题
* @property {String} sub-title-color 副标题颜色(默认var(--u-tips-color))
* @property {String | Number} sub-title-size 副标题字体大小(默认26)
* @property {Boolean} border 是否显示边框(默认true)
* @property {String | Number} index 用于标识点击了第几个卡片
* @property {String} box-shadow 卡片外围阴影,字符串形式(默认none)
* @property {String} margin 卡片与屏幕两边和上下元素的间距,需带单位,如"30rpx 20rpx"(默认30rpx)
* @property {String | Number} border-radius 卡片整体的圆角值,单位rpx(默认16)
* @property {Object} head-style 头部自定义样式,对象形式
* @property {Object} body-style 中部自定义样式,对象形式
* @property {Object} foot-style 底部自定义样式,对象形式
* @property {Boolean} head-border-bottom 是否显示头部的下边框(默认true)
* @property {Boolean} foot-border-top 是否显示底部的上边框(默认true)
* @property {Boolean} show-head 是否显示头部(默认true)
* @property {Boolean} show-foot 是否显示尾部(默认true)
* @property {String} thumb 缩略图路径,如设置将显示在标题的左边,不建议使用相对路径
* @property {String | Number} thumb-width 缩略图的宽度,高等于宽,单位rpx(默认60)
* @property {Boolean} thumb-circle 缩略图是否为圆形(默认false)
* @event {Function} click 整个卡片任意位置被点击时触发
* @event {Function} head-click 卡片头部被点击时触发
* @event {Function} body-click 卡片主体部分被点击时触发
* @event {Function} foot-click 卡片底部部分被点击时触发
* @example <u-card padding="30" title="card"></u-card>
*/
const props = defineProps(CardProps);
const emit = defineEmits(['click', 'head-click', 'body-click', 'foot-click']);
const slots = useSlots();
/**
* 整个卡片任意位置被点击时触发
*/
function onClick() {
emit('click', props.index);
}
/**
* 卡片头部被点击时触发
*/
function onHeadClick() {
emit('head-click', props.index);
}
/**
* 卡片主体部分被点击时触发
*/
function onBodyClick() {
emit('body-click', props.index);
}
/**
* 卡片底部部分被点击时触发
*/
function onFootClick() {
emit('foot-click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-card {
position: relative;
overflow: hidden;
font-size: 28rpx;
background-color: var(--u-bg-white);
box-sizing: border-box;
&-full {
// 如果是与屏幕之间不留空隙,应该设置左右边距为0
margin-left: 0 !important;
margin-right: 0 !important;
width: 100%;
}
&--border:after {
border-radius: 16rpx;
}
&__head {
&--left {
color: $u-main-color;
&__thumb {
margin-right: 16rpx;
}
&__title {
max-width: 400rpx;
}
}
&--right {
color: $u-tips-color;
margin-left: 6rpx;
}
}
&__body {
color: $u-content-color;
}
&__foot {
color: $u-tips-color;
}
}
</style>
@@ -0,0 +1,27 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* cell-group 组件 props 类型定义
* @description 供 u-cell-group 组件 props 使用
*/
export const CellGroupProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 分组标题 */
title: { type: String, default: '' },
/** 是否显示分组list上下边框 */
border: { type: Boolean, default: true },
/** 分组标题的样式,对象形式,注意驼峰属性写法 */
titleStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) }
};
export type CellGroupProps = ExtractPropTypes<typeof CellGroupProps>;
@@ -0,0 +1,60 @@
<template>
<view class="u-cell-box" :class="customClass" :style="$u.toStyle(customStyle)">
<view class="u-cell-title" v-if="title" :style="[titleStyle]">
{{ title }}
</view>
<view class="u-cell-item-box" :class="{ 'u-border-bottom u-border-top': border }">
<slot />
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-cell-group',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { CellGroupProps } from './types';
import { $u } from '../..';
/**
* cellGroup 单元格父组件Group
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-item
* @tutorial https://uviewpro.cn/zh/components/cell.html
* @property {String} title 分组标题
* @property {Boolean} border 是否显示外边框(默认true)
* @property {Object} title-style 分组标题的的样式,对象形式,如{'font-size': '24rpx'} 或 {'fontSize': '24rpx'}
* @example <u-cell-group title="设置喜好">
*/
defineProps(CellGroupProps);
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-cell-box {
width: 100%;
}
.u-cell-title {
padding: 30rpx 32rpx 10rpx 32rpx;
font-size: 30rpx;
text-align: left;
color: $u-tips-color;
}
.u-cell-item-box {
background-color: var(--u-bg-white);
flex-direction: row;
}
</style>
@@ -0,0 +1,64 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { CellItemArrowDirection } from '../../types/global';
/**
* cell-item 组件 props 类型定义
* @description 供 u-cell-item 组件 props 使用
*/
export type CellItemIndex = string | number;
export type CellItemStyle = Record<string, any>;
export const CellItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 左侧图标名称(只能uView内置图标),或者图标src */
icon: { type: String, default: '' },
/** 左侧标题 */
title: { type: [String, Number], default: '' },
/** 右侧内容 */
value: { type: [String, Number], default: '' },
/** 标题下方的描述信息 */
label: { type: [String, Number], default: '' },
/** 是否显示下边框 */
borderBottom: { type: Boolean, default: true },
/** 是否显示上边框 */
borderTop: { type: Boolean, default: false },
/** 是否开启点击反馈,即点击时cell背景为灰色,none为无效果 */
hoverClass: { type: String, default: 'u-cell-hover' },
/** 是否显示右侧箭头 */
arrow: { type: Boolean, default: true },
/** 内容是否垂直居中 */
center: { type: Boolean, default: false },
/** 是否显示左边表示必填的星号 */
required: { type: Boolean, default: false },
/** 标题的宽度,单位rpx */
titleWidth: { type: [Number, String], default: '' },
/** 右侧箭头方向,可选值:right|up|down,默认为right */
arrowDirection: { type: String as PropType<CellItemArrowDirection>, default: 'right' },
/** 控制标题的样式 */
titleStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 右侧显示内容的样式 */
valueStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 描述信息的样式 */
labelStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 用于识别被点击的是第几个cell */
index: { type: [String, Number] as PropType<CellItemIndex>, default: '' },
/** 是否使用label插槽 */
useLabelSlot: { type: Boolean, default: false },
/** 左边图标的大小,单位rpx,只对传入icon字段时有效 */
iconSize: { type: [Number, String], default: 34 },
/** 左边图标的样式,对象形式 */
iconStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) }
};
export type CellItemProps = ExtractPropTypes<typeof CellItemProps>;
@@ -0,0 +1,221 @@
<template>
<view
@tap="onClick"
class="u-cell"
:class="[
{
'u-border-bottom': borderBottom,
'u-border-top': borderTop,
'u-col-center': center,
'u-cell--required': required
},
customClass
]"
hover-stay-time="150"
:hover-class="hoverClass"
:style="$u.toStyle({ backgroundColor: bgColor }, customStyle)"
>
<view v-if="icon" class="u-cell__left-icon-wrap">
<u-icon :size="iconSize" :name="icon" :custom-style="iconStyle"></u-icon>
</view>
<view class="u-flex" v-else>
<slot name="icon"></slot>
</view>
<view class="u-cell_title" :style="[{ width: titleWidth ? titleWidth + 'rpx' : 'auto' }, titleStyle]">
<template v-if="title !== ''">{{ title }}</template>
<slot name="title" v-else></slot>
<view class="u-cell__label" v-if="label || $slots.label" :style="[labelStyle]">
<template v-if="label !== ''">{{ label }}</template>
<slot name="label" v-else></slot>
</view>
</view>
<view class="u-cell__value" :style="[valueStyle]">
<template v-if="value !== ''">{{ value }}</template>
<slot v-else></slot>
</view>
<view class="u-flex u-cell_right" v-if="$slots['right-icon']">
<slot name="right-icon"></slot>
</view>
<view v-if="arrow" class="u-icon-wrap u-cell__right-icon-wrap">
<u-icon name="arrow-right" :style="[arrowStyle]"></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-cell-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { CellItemProps } from './types';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
/**
* cellItem 单元格Item
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-group使用
* @tutorial https://uviewpro.cn/zh/components/cell.html
* @property {String} title 左侧标题
* @property {String} icon 左侧图标名,只支持uView内置图标,见Icon 图标
* @property {Object} icon-style 左边图标的样式,对象形式
* @property {String} value 右侧内容
* @property {String} label 标题下方的描述信息
* @property {Boolean} border-bottom 是否显示cell的下边框(默认true)
* @property {Boolean} border-top 是否显示cell的上边框(默认false)
* @property {Boolean} center 是否使内容垂直居中(默认false)
* @property {String} hover-class 是否开启点击反馈,none为无效果(默认true)
* // @property {Boolean} border-gap border-bottom为true时,Cell列表中间的条目的下边框是否与左边有一个间隔(默认true)
* @property {Boolean} arrow 是否显示右侧箭头(默认true)
* @property {Boolean} required 箭头方向,可选值(默认right)
* @property {Boolean} arrow-direction 是否显示左边表示必填的星号(默认false)
* @property {Object} title-style 标题样式,对象形式
* @property {Object} value-style 右侧内容样式,对象形式
* @property {Object} label-style 标题下方描述信息的样式,对象形式
* @property {String} bg-color 背景颜色(默认transparent)
* @property {String|Number} index 用于在click事件回调中返回,标识当前是第几个Item
* @property {String|Number} title-width 标题的宽度,单位rpx
* @example <u-cell-item icon="integral-fill" title="会员等级" value="新版本"></u-cell-item>
*/
const emit = defineEmits<{ (e: 'click', index: string | number): void }>();
const props = defineProps(CellItemProps);
const $slots = useSlots();
/**
* 箭头样式
*/
const arrowStyle = computed(() => {
let style: Record<string, any> = {};
if (props.arrowDirection === 'up') style.transform = 'rotate(-90deg)';
else if (props.arrowDirection === 'down') style.transform = 'rotate(90deg)';
else style.transform = 'rotate(0deg)';
return style;
});
/**
* 点击事件
*/
function onClick() {
emit('click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-cell {
@include vue-flex;
align-items: center;
position: relative;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
width: 100%;
padding: 26rpx 32rpx;
font-size: 28rpx;
line-height: 54rpx;
color: $u-content-color;
background-color: var(--u-bg-white);
text-align: left;
}
.u-cell_title {
font-size: 28rpx;
}
.u-cell__left-icon-wrap {
margin-right: 10rpx;
font-size: 32rpx;
}
.u-cell__right-icon-wrap {
margin-left: 10rpx;
color: var(--u-tips-color);
font-size: 28rpx;
}
.u-cell__left-icon-wrap,
.u-cell__right-icon-wrap {
@include vue-flex;
align-items: center;
height: 48rpx;
}
.u-cell-border:after {
position: absolute;
/* #ifndef APP-NVUE */
box-sizing: border-box;
content: ' ';
pointer-events: none;
border-bottom: 1px solid $u-border-color;
/* #endif */
right: 0;
left: 0;
top: 0;
transform: scaleY(0.5);
}
.u-cell-border {
position: relative;
}
.u-cell__label {
margin-top: 6rpx;
font-size: 26rpx;
line-height: 36rpx;
color: $u-tips-color;
/* #ifndef APP-NVUE */
word-wrap: break-word;
/* #endif */
}
.u-cell__value {
overflow: hidden;
text-align: right;
/* #ifndef APP-NVUE */
vertical-align: middle;
/* #endif */
color: $u-tips-color;
font-size: 26rpx;
}
.u-cell__title,
.u-cell__value {
flex: 1;
}
.u-cell--required {
/* #ifndef APP-NVUE */
overflow: visible;
/* #endif */
@include vue-flex;
align-items: center;
}
.u-cell--required:before {
position: absolute;
/* #ifndef APP-NVUE */
content: '*';
/* #endif */
left: 8px;
margin-top: 4rpx;
font-size: 14px;
color: $u-type-error;
}
.u-cell_right {
line-height: 1;
}
</style>
@@ -0,0 +1,45 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape } from '../../types/global';
import { getColor } from '../../';
/**
* checkbox-group 复选框组类型定义
* @description 供 u-checkbox-group 组件 props 使用
*/
export const CheckboxGroupProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 绑定值,选中的复选框name组成的数组 */
modelValue: { type: Array as PropType<(string | number)[]>, default: undefined },
/** 最多能选中多少个checkbox */
max: { type: Number, default: 999 },
/** 是否禁用所有复选框 */
disabled: { type: Boolean, default: false },
/** 在表单内提交时的标识符 */
name: { type: [Boolean, String], default: '' },
/** 是否禁止点击提示语选中复选框 */
labelDisabled: { type: Boolean, default: false },
/** 形状,square为方形,circle为原型 */
shape: { type: String as PropType<Shape>, default: 'square' },
/** 选中状态下的颜色 */
activeColor: { type: String, default: () => getColor('primary') },
/** 组件的整体大小,单位rpx,默认34rpx */
size: { type: [String, Number], default: '' },
/** 每个checkbox占u-checkbox-group的宽度 */
width: { type: String, default: 'auto' },
/** 是否每个checkbox都换行 */
wrap: { type: Boolean, default: false },
/** 图标的大小,单位rpx,默认20rpx */
iconSize: { type: [String, Number], default: '' }
};
export type CheckboxGroupProps = ExtractPropTypes<typeof CheckboxGroupProps>;
@@ -0,0 +1,172 @@
<template>
<view class="u-checkbox-group u-clearfix" :class="customClass" :style="$u.toStyle(customStyle)">
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-checkbox-group',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, watch, nextTick, onMounted } from 'vue';
import { $u, useParent, useChildren, useDebounce } from '../..';
import { CheckboxGroupProps } from './types';
/**
* checkboxGroup 开关选择器父组件Group
* @description 复选框组件一般用于需要多个选择的场景,该组件功能完整,使用方便
* @tutorial https://uviewpro.cn/zh/components/checkbox.html
* @property {Array} modelValue 绑定值,选中的复选框name组成的数组(支持v-model双向绑定)
* @property {String Number} max 最多能选中多少个checkbox(默认999)
* @property {String Number} size 组件整体的大小,单位rpx(默认40)
* @property {Boolean} disabled 是否禁用所有checkbox(默认false)
* @property {String Number} icon-size 图标大小,单位rpx(默认20)
* @property {Boolean} label-disabled 是否禁止点击文本操作checkbox(默认false)
* @property {String} width 宽度,需带单位
* @property {String} shape 外观形状,shape-方形,circle-圆形(默认circle)
* @property {Boolean} wrap 是否每个checkbox都换行(默认false)
* @property {String} active-color 选中时的颜色,应用到所有子Checkbox组件(默认主题色primary)
* @event {Function} change 任一个checkbox状态发生变化时触发,回调为选中的name数组
* @example <u-checkbox-group v-model="selectedValues">
* <u-checkbox name="apple">苹果</u-checkbox>
* <u-checkbox name="banana">香蕉</u-checkbox>
* </u-checkbox-group>
*/
const props = defineProps(CheckboxGroupProps);
const emit = defineEmits(['update:modelValue', 'change']);
// 使用父组件Hook
const { children, broadcast } = useParent('u-checkbox-group');
const { emitToParent } = useChildren('u-checkbox-group', 'u-form-item');
const { debounce } = useDebounce(1);
/**
* 根据modelValue设置子组件状态
*/
function syncChildrenSelection() {
if (!children || children.length === 0 || !props.modelValue) return;
const modelValueSet = new Set(props.modelValue);
children.forEach((child: any) => {
const childValue = child.getExposed?.()?.value;
const shouldBeChecked = modelValueSet.has(childValue);
const isCurrentlyChecked = child.getExposed?.()?.isChecked.value;
if (shouldBeChecked !== isCurrentlyChecked) {
child.getExposed?.()?.setChecked({ checked: shouldBeChecked });
}
});
}
/**
* 监听modelValue变化,同步子组件状态
*/
watch(
() => props.modelValue,
() => {
syncChildrenSelection();
}
);
/**
* 派发 change 事件和表单校验
*/
function emitEvent() {
debounce(() => {
// 收集所有选中的 name
let values: any[] = [];
children.forEach((child: any) => {
if (child.getExposed?.()?.isChecked.value) {
values.push(child.getExposed?.()?.value);
}
});
emit('change', values);
emit('update:modelValue', values);
setTimeout(() => {
emitToParent('onFormChange', values);
}, 60);
});
}
/**
* 全选/全不选方法
*/
function setAllChecked(checked: boolean) {
if (props.disabled) {
console.warn('u-checkbox-group已禁用,无法操作');
return;
}
broadcast('setChecked', { checked });
}
/**
* 获取选中的值
*/
function getSelectedValues() {
return children
.filter(child => child.getExposed?.()?.isChecked.value)
.map(child => child.getExposed?.()?.name)
.filter(Boolean);
}
/**
* 验证选择是否超过最大数量
*/
function validateSelection() {
const selectedCount = children.filter(child => child.getExposed?.()?.isChecked.value).length;
if (props.max && selectedCount >= props.max) {
$u.toast(`超过最大选择数量: ${props.max}`);
return false;
}
return true;
}
onMounted(() => {
nextTick(() => {
syncChildrenSelection();
});
});
// 使用defineExpose暴露给外部
defineExpose({
// props
props,
// 方法
emitEvent,
setAllChecked,
getSelectedValues,
validateSelection,
syncChildrenSelection,
// 计算属性
selectedCount: computed(() => children.filter(child => child.getExposed?.()?.isChecked.value).length),
isFull: computed(() => {
const selectedCount = children.filter(child => child.getExposed?.()?.isChecked.value).length;
return props.max && selectedCount >= props.max;
}),
isEmpty: computed(() => children.filter(child => child.getExposed?.()?.isChecked.value).length === 0),
// 工具方法
getChildrenCount: () => children.length
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-checkbox-group {
/* #ifndef MP || APP-NVUE */
display: inline-flex;
flex-wrap: wrap;
/* #endif */
}
</style>
@@ -0,0 +1,45 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape, SizeType } from '../../types/global';
/**
* checkbox 复选框类型定义
* @description 供 u-checkbox 组件 props 使用
*/
export type CheckboxValue = string | number | boolean;
export const CheckboxProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** checkbox的标签 */
label: { type: String, default: '' },
/** checkbox的值 */
name: { type: [String, Number], default: '' },
/** checkbox的值,不传递时为name的属性 */
value: { type: [String, Number], default: '' },
/** 形状,square为方形,circle为原型 */
shape: { type: String as PropType<Shape>, default: '' },
/** 是否为选中状态 */
modelValue: { type: Boolean, default: false },
/** 是否禁用 */
disabled: { type: [String, Boolean], default: '' },
/** 是否禁止点击提示语选中复选框 */
labelDisabled: { type: [String, Boolean], default: '' },
/** 选中状态下的颜色,如设置此值,将会覆盖checkboxGroup的activeColor值 */
activeColor: { type: String, default: '' },
/** 图标的大小,单位rpx */
iconSize: { type: [String, Number], default: '' },
/** label的字体大小,rpx单位 */
labelSize: { type: [String, Number], default: '' },
/** 组件的整体大小 */
size: { type: [String, Number] as PropType<SizeType | string | number>, default: '' }
};
export type CheckboxProps = ExtractPropTypes<typeof CheckboxProps>;
@@ -0,0 +1,363 @@
<template>
<view class="u-checkbox" :style="$u.toStyle(checkboxStyle, customStyle)" :class="customClass">
<view class="u-checkbox__icon-wrap" @tap="toggle" :class="iconClass" :style="$u.toStyle(iconStyle)">
<u-icon name="checkbox-mark" :size="checkboxIconSize" :color="iconColor" />
</view>
<view
class="u-checkbox__label"
@tap="onClickLabel"
:style="{
fontSize: labelFontSize
}"
>
<slot>
{{ label }}
</slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-checkbox',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $u, useChildren } from '../..';
import { CheckboxProps } from './types';
import type { SizeType } from '../../types/global';
import uIcon from '../u-icon/u-icon.vue';
/**
* checkbox 复选框
* @description 该组件需要搭配checkboxGroup组件使用,以便用户进行操作时,获得当前复选框组的选中情况。
* @tutorial https://uviewpro.cn/zh/components/checkbox.html
* @property {String Number} icon-size 图标大小,单位rpx(默认20)
* @property {String Number} label-size label字体大小,单位rpx(默认28)
* @property {String Number} name checkbox组件的标示符
* @property {String} shape 形状,见官网说明(默认circle)
* @property {Boolean} disabled 是否禁用
* @property {Boolean} label-disabled 是否禁止点击文本操作checkbox
* @property {String} active-color 选中时的颜色,如设置CheckboxGroup的active-color将失效
* @event {Function} change 某个checkbox状态发生变化时触发,回调为一个对象
* @example <u-checkbox v-model="checked" :disabled="false">天涯</u-checkbox>
*/
const props = defineProps(CheckboxProps);
const emit = defineEmits(['change', 'update:modelValue']);
// checkbox 是否选中,true/false
const checkedValue = ref(props.modelValue);
// 使用子组件Hook
const { parentExposed } = useChildren('u-checkbox', 'u-checkbox-group');
const { parentExposed: formExposed } = useChildren('u-checkbox', 'u-form');
// checkbox 的value值,id
const checkboxValue = computed(() => {
if (props.value !== '') return props.value;
return props.name;
});
watch(
() => props.modelValue,
(newVal: boolean) => {
checkedValue.value = newVal;
}
);
watch(
() => checkedValue.value,
(newVal: boolean) => {
emit('update:modelValue', newVal);
},
{ immediate: true }
);
// 根据 size 定义不同的配置
const sizeConfig = {
small: {
size: 28,
fontSize: 24,
iconSize: 16
},
default: {
size: 34,
fontSize: 28,
iconSize: 20
},
large: {
size: 40,
fontSize: 32,
iconSize: 24
}
};
// 获取实际使用的 size 值(优先级:props.size > u-form.size)
const actualSize = computed(() => {
// 优先使用 props 的 size 属性
if (props.size !== '') {
return String(props.size);
}
// 次优先:使用 u-checkbox-group 的 size 属性
if (parentExposed.value?.props?.size) {
return String(parentExposed.value.props.size);
}
// 最后:使用 u-form 的 size 属性(u-form 的 size 只支持预设值)
if (formExposed.value?.props?.size) {
return String(formExposed.value.props.size);
}
// 默认值
return 'default';
});
// 判断实际使用的 size 是否在预设配置中
const isInSizeConfig = computed(() => actualSize.value in sizeConfig);
// 获取预设 size(用于查找 sizeConfig 配置,如图标大小、高度等)
const presetSize = computed(() => {
return (isInSizeConfig.value ? actualSize.value : 'default') as SizeType;
});
// 获取当前尺寸配置
const currentSizeConfig = computed(() => sizeConfig[presetSize.value]);
// 是否禁用,如果父组件u-checkbox-group禁用的话,将会忽略子组件的配置
const isDisabled = computed(() => {
return props.disabled !== '' ? props.disabled : (parentExposed.value?.props?.disabled ?? false);
});
// 是否禁用label点击
const isLabelDisabled = computed(() => {
return props.labelDisabled !== '' ? props.labelDisabled : (parentExposed.value?.props?.labelDisabled ?? false);
});
// 组件尺寸,对应size的值,默认值为34rpx
const checkboxSize = computed(() => currentSizeConfig.value.size);
// 组件的勾选图标的尺寸,默认20rpx
const checkboxIconSize = computed(() => {
if (props.iconSize) {
return props.iconSize;
}
if (parentExposed.value?.props?.iconSize) {
return parentExposed.value?.props?.iconSize;
}
if (isInSizeConfig.value) {
return currentSizeConfig.value.iconSize;
}
return 20;
});
// label字体大小,默认28rpx
const labelFontSize = computed(() => {
if (isInSizeConfig.value) {
return $u.addUnit(currentSizeConfig.value.fontSize);
}
return $u.addUnit(props.labelSize);
});
// 组件选中激活时的颜色
const elActiveColor = computed(() => {
return props.activeColor ? props.activeColor : (parentExposed.value?.props?.activeColor ?? 'primary');
});
// 组件的形状
const elShape = computed(() => {
return props.shape ? props.shape : (parentExposed.value?.props?.shape ?? 'square');
});
// 图标样式
const iconStyle = computed(() => {
let style: Record<string, string> = {};
if (elActiveColor.value && checkedValue.value && !isDisabled.value) {
style.borderColor = elActiveColor.value;
style.backgroundColor = elActiveColor.value;
}
style.width = $u.addUnit(checkboxSize.value);
style.height = $u.addUnit(checkboxSize.value);
return style;
});
// checkbox内部的勾选图标,如果选中状态,为白色,否则为透明色即可
const iconColor = computed(() => {
return checkedValue.value ? 'var(--u-white-color)' : 'transparent';
});
const iconClass = computed(() => {
let classes: string[] = [];
classes.push('u-checkbox__icon-wrap--' + elShape.value);
if (checkedValue.value == true) classes.push('u-checkbox__icon-wrap--checked');
if (isDisabled.value) classes.push('u-checkbox__icon-wrap--disabled');
if (checkedValue.value && isDisabled.value) classes.push('u-checkbox__icon-wrap--disabled--checked');
return classes.join(' ');
});
const checkboxStyle = computed(() => {
let style: Record<string, string> = {};
if (parentExposed.value?.props?.width) {
style.width = parentExposed.value.props.width;
// #ifdef MP
style.float = 'left';
// #endif
// #ifndef MP
style.flex = `0 0 ${parentExposed.value.props.width}`;
// #endif
}
if (parentExposed.value?.props?.wrap) {
style.width = '100%';
// #ifndef MP
style.flex = '0 0 100%';
// #endif
}
return style;
});
/**
* 点击label
*/
function onClickLabel() {
if (!isLabelDisabled.value && !isDisabled.value) {
setValue();
}
}
/**
* 点击icon
*/
function toggle() {
if (!isDisabled.value) {
setValue();
}
}
/**
* 触发change事件
*/
function emitEvent() {
emit('change', !checkedValue.value);
// 通知父组件状态变化
setTimeout(() => {
if (parentExposed.value?.emitEvent) {
parentExposed.value.emitEvent();
}
}, 80);
}
/**
* 设置通过v-model绑定的组件的值
*/
function setValue() {
// 判断是否超过了可选的最大数量
if (checkedValue.value == true) {
emitEvent();
checkedValue.value = false;
} else {
if (parentExposed?.value?.validateSelection && !parentExposed?.value?.validateSelection()) {
return;
}
emitEvent();
checkedValue.value = true;
}
}
// 设置组件的modelValue值
function setChecked(data: any) {
if (!isDisabled.value) {
const needEmit = checkedValue.value !== data.checked;
checkedValue.value = data.checked;
if (needEmit) {
emitEvent();
}
}
}
defineExpose({
isChecked: computed(() => checkedValue.value),
label: props.label,
name: props.name,
value: checkboxValue.value,
setValue,
emitEvent,
props,
setChecked
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-checkbox {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
overflow: hidden;
user-select: none;
line-height: 1.8;
&__icon-wrap {
color: $u-content-color;
flex: none;
display: -webkit-flex;
@include vue-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 42rpx;
height: 42rpx;
color: transparent;
text-align: center;
transition-property: color, border-color, background-color;
font-size: 20px;
border: 1px solid var(--u-border-color);
transition-duration: 0.2s;
/* #ifdef MP-TOUTIAO */
// 头条小程序兼容性问题,需要设置行高为0,否则图标偏下
line-height: 0;
/* #endif */
&--circle {
border-radius: 100%;
}
&--square {
border-radius: 6rpx;
}
&--checked {
color: var(--u-white-color);
background-color: $u-type-primary;
border-color: $u-type-primary;
}
&--disabled {
background-color: var(--u-bg-gray-light);
border-color: var(--u-border-color);
}
&--disabled--checked {
color: var(--u-bg-gray-light) !important;
}
}
&__label {
word-wrap: break-word;
margin-left: 10rpx;
margin-right: 24rpx;
color: $u-content-color;
font-size: 30rpx;
&--disabled {
color: var(--u-bg-gray-light);
}
}
}
</style>
@@ -0,0 +1,63 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
import { $u } from '../../libs';
/**
* circleProgress 环形进度条 Props
* @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度条。注意:此组件的percent值只能动态增加,不能动态减少。
*/
export const CircleProgressProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 圆环进度百分比值 */
percent: {
type: Number,
default: 0,
validator: (val: number) => val >= 0 && val <= 100
},
/** 底部圆环的颜色(灰色的圆环) */
inactiveColor: {
type: String,
default: () => $u.getColor('dividerColor')
},
/** 圆环激活部分的颜色 */
activeColor: {
type: String,
default: 'var(--u-type-success)'
},
/** 圆环线条的宽度,单位rpx */
borderWidth: {
type: [Number, String] as PropType<number | string>,
default: 14
},
/** 整个圆形的宽度,单位rpx */
width: {
type: [Number, String] as PropType<number | string>,
default: 200
},
/** 整个圆环执行一圈的时间,单位ms */
duration: {
type: [Number, String] as PropType<number | string>,
default: 1500
},
/** 主题类型 */
type: {
type: String as PropType<ThemeType>,
default: ''
},
/** 整个圆环进度区域的背景色 */
bgColor: {
type: String,
default: 'var(--u-bg-white)'
}
};
export type CircleProgressProps = ExtractPropTypes<typeof CircleProgressProps>;
@@ -0,0 +1,299 @@
<template>
<view
class="u-circle-progress"
:class="customClass"
:style="
$u.toStyle(
{
width: widthPx + 'px',
height: widthPx + 'px',
backgroundColor: bgColor
},
customStyle
)
"
>
<!-- 支付宝小程序不支持canvas-id属性,必须用id属性 -->
<!-- #ifdef MP-WEIXIN || MP-TOUTIAO -->
<canvas
class="u-canvas-bg"
type="2d"
:canvas-id="elBgId"
:id="elBgId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<canvas
class="u-canvas"
type="2d"
:canvas-id="elId"
:id="elId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN || MP-TOUTIAO -->
<canvas
class="u-canvas-bg"
:canvas-id="elBgId"
:id="elBgId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<canvas
class="u-canvas"
:canvas-id="elId"
:id="elId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<!-- #endif -->
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-circle-progress',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, getCurrentInstance, onBeforeMount } from 'vue';
import { $u } from '../..';
import { CircleProgressProps } from './types';
// #ifdef MP-WEIXIN || MP-TOUTIAO
import { canvas2d } from '../../libs/util/canvas-2d';
// #endif
/**
* circleProgress 环形进度条
* @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度条。注意:此组件的percent值只能动态增加,不能动态减少。
* @tutorial https://uviewpro.cn/zh/components/circleProgress.html
* @property {String Number} percent 圆环进度百分比值,为数值类型,0-100
* @property {String} inactive-color 圆环的底色,默认为灰色(该值无法动态变更)(默认var(--u-divider-color))
* @property {String} active-color 圆环激活部分的颜色(该值无法动态变更)(默认var(--u-type-success))
* @property {String Number} width 整个圆环组件的宽度,高度默认等于宽度值,单位rpx(默认200)
* @property {String Number} border-width 圆环的边框宽度,单位rpx(默认14)
* @property {String Number} duration 整个圆环执行一圈的时间,单位ms(默认呢1500)
* @property {String} type 如设置,active-color值将会失效
* @property {String} bg-color 整个组件背景颜色,默认为白色
* @example <u-circle-progress active-color="var(--u-type-primary)" :percent="80"></u-circle-progress>
*/
const props = defineProps(CircleProgressProps);
let elBgId = $u.guid(); // 非微信端的时候,需用动态的id,否则一个页面多个圆形进度条组件数据会混乱
let elId = $u.guid();
// #ifdef MP-WEIXIN || MP-TOUTIAO
// elBgId = 'uCircleProgressBgId'; // 微信小程序中不能使用$u.guid()形式动态生成id值,否则会报错
// elId = 'uCircleProgressElId';
// #endif
const instance = getCurrentInstance();
const pixelRatio = ref<number>(1); // 像素比
// 存储 MP-WEIXIN 下通过 selectorQuery 获取到的 canvas node,方便在绘制前清空
const canvasNodeMap = new Map<string, any>();
const widthPx = computed(() =>
typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.width)) : Number(props.width)
);
const borderWidthPx = computed(() =>
typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.borderWidth)) : Number(props.borderWidth)
);
const startAngle = -Math.PI / 2; // canvas画圆的起始角度,默认为3点钟方向,定位到12点钟方向
const progressContext = ref<any>(null); // 活动圆的canvas上下文
const newPercent = ref(props.percent); // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
const oldPercent = ref(0); // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
/**
* 有type主题时,优先起作用
*/
const circleColor = computed(() => {
if (props.type && ['success', 'error', 'info', 'primary', 'warning'].includes(props.type)) {
return $u.color[props.type as keyof typeof $u.color] as string;
}
return props.activeColor;
});
onBeforeMount(() => {
pixelRatio.value = uni.getSystemInfoSync().pixelRatio;
});
// 监听percent变化,动态绘制进度
watch(
() => props.percent,
(nVal, oVal = 0) => {
let next = nVal > 100 ? 100 : nVal;
let prev = oVal < 0 ? 0 : oVal;
newPercent.value = next;
oldPercent.value = prev;
setTimeout(() => {
// 无论是百分比值增加还是减少,需要操作还是原来的旧的百分比值
// 将此值减少或者新增到新的百分比值
drawCircleByProgress(prev);
}, 50);
}
);
onMounted(() => {
// 赋值,用于加载后第一个画圆使用
newPercent.value = props.percent;
oldPercent.value = 0;
// 在h5端,必须要做一点延时才起作用,this.$nextTick()无效(HX2.4.7)
setTimeout(() => {
drawProgressBg();
drawCircleByProgress(oldPercent.value);
}, 50);
});
/**
* 获取canvas上下文
*/
function getContext(canvasId: any) {
return new Promise<UniApp.CanvasContext>(resolve => {
let ctx = null;
// #ifndef MP-WEIXIN || MP-TOUTIAO
ctx = uni.createCanvasContext(canvasId, instance);
resolve(ctx);
// #endif
// #ifdef MP-WEIXIN || MP-TOUTIAO
uni.createSelectorQuery()
.in(instance?.proxy)
.select(`#${canvasId}`)
.node(res => {
if (res && res.node) {
const canvas = res.node;
ctx = canvas2d(canvas.getContext('2d') as CanvasRenderingContext2D);
canvas.width = widthPx.value * pixelRatio.value;
canvas.height = widthPx.value * pixelRatio.value;
ctx.scale(pixelRatio.value, pixelRatio.value);
// 存储 canvas node,后续绘制时用于清空画布
canvasNodeMap.set(canvasId, canvas);
resolve(ctx);
}
})
.exec();
// #endif
});
}
/**
* 绘制底部灰色圆环
*/
async function drawProgressBg() {
const ctx = await getContext(elBgId);
// #ifdef MP-WEIXIN || MP-TOUTIAO
// 清空背景画布(如果可用)以确保绘制一致性
try {
if (typeof ctx.clearRect === 'function') {
const node = canvasNodeMap.get(elBgId);
const w = node ? node.width : widthPx.value * pixelRatio.value;
const h = node ? node.height : widthPx.value * pixelRatio.value;
ctx.clearRect(0, 0, w, h);
}
} catch (e) {
// ignore
}
// #endif
ctx.setLineWidth(borderWidthPx.value); // 设置圆环宽度
ctx.setStrokeStyle(props.inactiveColor); // 线条颜色
ctx.beginPath(); // 开始描绘路径
const radius = widthPx.value / 2; // 设置一个原点(110,110),半径为100的圆的路径到当前路径
ctx.arc(radius, radius, radius - borderWidthPx.value, 0, 2 * Math.PI, false);
ctx.stroke(); // 对路径进行描绘
ctx.draw();
}
/**
* 绘制进度圆环
* @param progress 当前进度
*/
async function drawCircleByProgress(progress: number) {
// 第一次操作进度环时将上下文保存到了this.data中,直接使用即可
let ctx = progressContext.value;
if (!ctx) {
ctx = await getContext(elId);
progressContext.value = ctx;
}
// #ifdef MP-WEIXIN || MP-TOUTIAO
// 清空进度画布,避免旧的更大进度残留,导致无法降低的视觉错误
try {
if (typeof ctx.clearRect === 'function') {
const node = canvasNodeMap.get(elId);
const w = node ? node.width : widthPx.value * pixelRatio.value;
const h = node ? node.height : widthPx.value * pixelRatio.value;
ctx.clearRect(0, 0, w, h);
}
} catch (e) {
// ignore
}
// #endif
// 表示进度的两端为圆形
ctx.setLineCap('round');
// 设置线条的宽度和颜色
ctx.setLineWidth(borderWidthPx.value);
ctx.setStrokeStyle(circleColor.value);
// 将总过渡时间除以100,得出每修改百分之一进度所需的时间
let time = Math.floor(Number(props.duration) / 100);
// 结束角的计算依据为:将2π分为100份,乘以当前的进度值,得出终止点的弧度值,加起始角,为整个圆从默认的
// 3点钟方向开始画图,转为更好理解的12点钟方向开始作图,这需要起始角和终止角同时加上this.startAngle值
let endAngle = ((2 * Math.PI) / 100) * progress + startAngle;
ctx.beginPath();
// 半径为整个canvas宽度的一半
let radius = widthPx.value / 2;
ctx.arc(radius, radius, radius - borderWidthPx.value, startAngle, endAngle, false);
ctx.stroke();
ctx.draw();
// 如果变更后新值大于旧值,意味着增大了百分比
if (newPercent.value > oldPercent.value) {
// 每次递增百分之一
progress++;
// 如果新增后的值,大于需要设置的值百分比值,停止继续增加
if (progress > newPercent.value) return;
} else {
// 同理于上面
progress--;
if (progress < newPercent.value) return;
}
setTimeout(() => {
// 定时器,每次操作间隔为time值,为了让进度条有动画效果
drawCircleByProgress(progress);
}, time);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-circle-progress {
position: relative;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
justify-content: center;
}
.u-canvas-bg {
position: absolute;
}
.u-canvas {
position: absolute;
}
</style>
@@ -0,0 +1,30 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-city-select 城市选择器 Props
* @description 用于选择省、市、区三级行政区域,支持回显和自定义初始值。
*/
export const CitySelectProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 控制弹窗显示与隐藏(v-model) */
modelValue: { type: Boolean, default: false },
/** 默认选中的省市区名称数组 */
defaultRegion: { type: Array as PropType<string[]>, default: () => [] },
/** 默认选中的省市区编码数组 */
areaCode: { type: Array as PropType<string[]>, default: () => [] },
/** 是否允许点击遮罩关闭弹窗 */
maskCloseAble: { type: Boolean, default: true },
/** 弹窗层级 */
zIndex: { type: [String, Number] as PropType<string | number>, default: 0 }
};
export type CitySelectProps = ExtractPropTypes<typeof CitySelectProps>;
@@ -0,0 +1,281 @@
<template>
<u-popup
v-model="popupValue"
:class="customClass"
:style="$u.toStyle(customStyle)"
mode="bottom"
:popup="false"
:mask="true"
:closeable="true"
:safe-area-inset-bottom="true"
close-icon-color="var(--u-white-color)"
:z-index="uZIndex"
:maskCloseAble="props.maskCloseAble"
@close="close"
>
<u-tabs
v-if="popupValue"
:list="genTabsList"
:is-scroll="true"
:current="tabsIndex"
@change="tabsChange"
ref="tabs"
></u-tabs>
<view class="area-box">
<view class="u-flex" :class="{ change: isChange }">
<!-- 省 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group>
<u-cell-item
v-for="(item, index) in provincesList"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="provinceChange(index)"
>
<template v-if="isChooseP && province === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
<!-- 市 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group v-if="isChooseP">
<u-cell-item
v-for="(item, index) in citys"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="cityChange(index)"
>
<template v-if="isChooseC && city === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
<!-- 区 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group v-if="isChooseC">
<u-cell-item
v-for="(item, index) in areas"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="areaChange(index)"
>
<template v-if="isChooseA && area === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
</view>
</view>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-city-select',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import provinces from '../../libs/util/province';
import citysData from '../../libs/util/city';
import areasData from '../../libs/util/area';
import { CitySelectProps } from './types';
import { $u } from '../..';
import uCellGroup from '../u-cell-group/u-cell-group.vue';
import uCellItem from '../u-cell-item/u-cell-item.vue';
import uIcon from '../u-icon/u-icon.vue';
import uPopup from '../u-popup/u-popup.vue';
import uTabs from '../u-tabs/u-tabs.vue';
/**
* u-city-select 城市选择器
* @description 用于选择省、市、区三级行政区域,支持回显和自定义初始值。
* @property {Boolean} modelValue 控制弹窗显示与隐藏(v-model)
* @property {Array} defaultRegion 默认选中的省市区名称数组,如 ['山东省', '青岛市', '崂山区']
* @property {Array} areaCode 默认选中的省市区编码数组,如 ['440000', '440100', '440106']
* @property {Boolean} maskCloseAble 是否允许点击遮罩关闭弹窗(默认 true)
* @property {String|Number} zIndex 弹窗层级(默认 0,自动适配)
* @event update:modelValue v-model 绑定值变化时触发
* @event city-change 选择省市区后触发,返回选中的省市区对象
* @example <u-city-select v-model="show" :defaultRegion="['山东省', '青岛市', '崂山区']" @city-change="onChange"></u-city-select>
*/
const props = defineProps(CitySelectProps);
// emits 定义
const emit = defineEmits(['update:modelValue', 'city-change']);
// 省市区选择相关响应式数据
const cityValue = ref('');
const isChooseP = ref(false); // 是否已选省
const province = ref(0); // 省下标
const provincesList = ref(provinces);
const isChooseC = ref(false); // 是否已选市
const city = ref(0); // 市下标
const citys = ref(citysData[0]);
const isChooseA = ref(false); // 是否已选区
const area = ref(0); // 区下标
const areas = ref(areasData[0][0]);
const tabsIndex = ref(0);
// v-model 双向绑定
const popupValue = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val)
});
// 是否切换到区级
const isChange = computed(() => tabsIndex.value > 1);
// 顶部 tab 列表
const genTabsList = computed(() => {
const tabsList = [{ name: '请选择' }];
if (isChooseP.value) {
tabsList[0].name = provincesList.value[province.value].label;
tabsList[1] = { name: '请选择' };
}
if (isChooseC.value) {
tabsList[1].name = citys.value[city.value].label;
tabsList[2] = { name: '请选择' };
}
if (isChooseA.value) {
tabsList[2].name = areas.value[area.value].label;
}
return tabsList;
});
// z-index 计算
const uZIndex = computed(() => (props.zIndex ? props.zIndex : (uni as any).$u?.zIndex?.popup || 1075));
// 初始化选中项
function init() {
if (props.areaCode.length === 3) {
setProvince('', props.areaCode[0]);
setCity('', props.areaCode[1]);
setArea('', props.areaCode[2]);
} else if (props.defaultRegion.length === 3) {
setProvince(props.defaultRegion[0], '');
setCity(props.defaultRegion[1], '');
setArea(props.defaultRegion[2], '');
}
}
// 选中省
function setProvince(label = '', value = '') {
provincesList.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
provinceChange(k);
}
});
}
// 选中市
function setCity(label = '', value = '') {
citys.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
cityChange(k);
}
});
}
// 选中区
function setArea(label = '', value = '') {
areas.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
isChooseA.value = true;
area.value = k;
}
});
}
// 关闭弹窗
function close() {
emit('update:modelValue', false);
}
// tab 切换
function tabsChange(index: number) {
tabsIndex.value = index;
}
// 省点击
function provinceChange(index: number) {
isChooseP.value = true;
isChooseC.value = false;
isChooseA.value = false;
province.value = index;
citys.value = citysData[index];
tabsIndex.value = 1;
}
// 市点击
function cityChange(index: number) {
isChooseC.value = true;
isChooseA.value = false;
city.value = index;
areas.value = areasData[province.value][index];
tabsIndex.value = 2;
}
// 区点击
function areaChange(index: number) {
isChooseA.value = true;
area.value = index;
const result = {
province: provincesList.value[province.value],
city: citys.value[city.value],
area: areas.value[area.value]
};
emit('city-change', result);
close();
}
// 挂载时初始化
onMounted(init);
</script>
<style lang="scss">
.area-box {
width: 100%;
overflow: hidden;
height: 800rpx;
> view {
width: 150%;
transition: transform 0.3s ease-in-out 0s;
transform: translateX(0);
&.change {
transform: translateX(-33.3333333%);
}
}
.area-item {
width: 33.3333333%;
height: 800rpx;
}
}
</style>
@@ -0,0 +1,40 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { AlignType, JustifyType } from '../../types/global';
/**
* u-col 布局单元格 Props
* @description 通过基础的 12 分栏,迅速简便地创建布局(搭配<u-row>使用)
* @property {String|Number} span 栅格占据的列数,总12等分(默认12)
* @property {String|Number} offset 分栏左边偏移,计算方式与span相同(默认0)
* @property {JustifyType} justify 水平排列方式,可选值为start、end、center、around、between(默认start)
* @property {AlignType} align 垂直对齐方式,可选值为top、center、bottom(默认center)
* @property {String} textAlign 文字对齐方式(默认left)
* @property {Boolean} stop 是否阻止事件传播(默认true)
*/
export const ColProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 占父容器宽度的多少等分,总分为12份 */
span: { type: [Number, String] as PropType<number | string>, default: 12 },
/** 指定栅格左侧的间隔数(总12栏) */
offset: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 水平排列方式 */
justify: { type: String as PropType<JustifyType>, default: 'start' },
/** 垂直对齐方式 */
align: { type: String as PropType<AlignType>, default: 'center' },
/** 文字对齐方式 */
textAlign: { type: String, default: 'left' },
/** 是否阻止事件传播 */
stop: { type: Boolean, default: true }
};
export type ColProps = ExtractPropTypes<typeof ColProps>;
@@ -0,0 +1,142 @@
<template>
<view
class="u-col"
:class="['u-col-' + span, customClass]"
:style="$u.toStyle(colStyle, customStyle)"
@tap="onClick"
>
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-col',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ColProps } from './types';
import { $u, useChildren } from '../../';
/**
* col 布局单元格
* @description 通过基础的 12 分栏,迅速简便地创建布局(搭配<u-row>使用)
* @tutorial https://uviewpro.cn/zh/components/layout.html
* @property {String|Number} span 栅格占据的列数,总12等分(默认0)
* @property {String} text-align 文字水平对齐方式(默认left)
* @property {String|Number} offset 分栏左边偏移,计算方式与span相同(默认0)
* @example <u-col span="3"><view class="demo-layout bg-purple"></view></u-col>
*/
const emit = defineEmits<{ (e: 'click'): void }>();
const props = defineProps(ColProps);
const { parentExposed } = useChildren('u-col', 'u-row');
/**
* gutter 给col添加间距,左右边距各占一半,从父组件u-row获取
*/
const gutter = computed(() => {
return parentExposed?.value?.props?.gutter ?? 20;
});
/**
* 计算水平排列方式
* @returns {string}
*/
const uJustify = computed(() => {
if (props.justify === 'end' || props.justify === 'start') return 'flex-' + props.justify;
else if (props.justify === 'around' || props.justify === 'between') return 'space-' + props.justify;
else return props.justify;
});
/**
* 计算垂直对齐方式
* @returns {string}
*/
const uAlignItem = computed(() => {
if (props.align === 'top') return 'flex-start';
if (props.align === 'bottom') return 'flex-end';
else return props.align;
});
/**
* 计算样式对象
*/
const colStyle = computed<any>(() => ({
padding: `0 ${Number(gutter.value) / 2}px`,
marginLeft: `${(100 / 12) * Number(props.offset)}%`,
flex: `0 0 ${(100 / 12) * Number(props.span)}%`,
alignItems: uAlignItem.value,
justifyContent: uJustify.value,
textAlign: props.textAlign
}));
/**
* 点击事件
* @param e 事件对象
*/
function onClick(e: Event) {
emit('click');
}
</script>
<style lang="scss">
@import '../../libs/css/style.components.scss';
.u-col {
/* #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO */
float: left;
/* #endif */
}
.u-col-0 {
width: 0;
}
.u-col-1 {
width: calc(100% / 12);
}
.u-col-2 {
width: calc(100% / 12 * 2);
}
.u-col-3 {
width: calc(100% / 12 * 3);
}
.u-col-4 {
width: calc(100% / 12 * 4);
}
.u-col-5 {
width: calc(100% / 12 * 5);
}
.u-col-6 {
width: calc(100% / 12 * 6);
}
.u-col-7 {
width: calc(100% / 12 * 7);
}
.u-col-8 {
width: calc(100% / 12 * 8);
}
.u-col-9 {
width: calc(100% / 12 * 9);
}
.u-col-10 {
width: calc(100% / 12 * 10);
}
.u-col-11 {
width: calc(100% / 12 * 11);
}
.u-col-12 {
width: calc(100% / 12 * 12);
}
</style>
@@ -0,0 +1,35 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { TextAlign } from '../../types/global';
/**
* u-collapse-item 手风琴Item Props
* @description 通过折叠面板收纳内容区域(搭配u-collapse使用)
*/
export const CollapseItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 标题 */
title: { type: String, default: '' },
/** 标题的对齐方式 */
align: { type: String as PropType<TextAlign>, default: 'left' },
/** 是否可以点击收起 */
disabled: { type: Boolean, default: false },
/** collapse显示与否 */
open: { type: Boolean, default: false },
/** 唯一标识符 */
name: { type: [Number, String] as PropType<number | string>, default: '' },
/** 活动样式 */
activeStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 标识当前为第几个 */
index: { type: [String, Number] as PropType<string | number>, default: '' }
};
export type CollapseItemProps = ExtractPropTypes<typeof CollapseItemProps>;
@@ -0,0 +1,291 @@
<template>
<view class="u-collapse-item" :style="$u.toStyle(itemStyle, customStyle)" :class="customClass">
<view
:hover-stay-time="200"
class="u-collapse-head"
@tap.stop="headClick"
:hover-class="hoverClass"
:style="headStyle"
>
<template v-if="!slots['title-all']">
<view v-if="!slots['title']" class="u-collapse-title u-line-1" :style="titleStyle">
{{ title }}
</view>
<slot v-else name="title" />
<view class="u-icon-wrap">
<u-icon v-if="showArrow" :color="arrowColor" :name="isShow ? 'arrow-up' : 'arrow-down'" />
</view>
</template>
<slot v-else name="title-all" />
</view>
<view class="u-collapse-body" :style="{ height: isShow ? height : '0' }">
<view class="u-collapse-content" :id="elId" :style="bodyStyle">
<slot></slot>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-collapse-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, watch, onMounted, useSlots, getCurrentInstance, nextTick, computed } from 'vue';
import { $u, useChildren } from '../..';
import { CollapseItemProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* collapseItem 手风琴Item
* @description 通过折叠面板收纳内容区域(搭配u-collapse使用)
* @tutorial https://uviewpro.cn/zh/components/collapse.html
* @property {String} title 面板标题
* @property {String Number} index 主要用于事件的回调,标识那个Item被点击
* @property {Boolean} disabled 面板是否可以打开或收起(默认false)
* @property {Boolean} open 设置某个面板的初始状态是否打开(默认false)
* @property {String Number} name 唯一标识符,如不设置,默认用当前collapse-item的索引值
* @property {String} align 标题的对齐方式(默认left)
* @property {Object} active-style 不显示箭头时,可以添加当前选择的collapse-item活动样式,对象形式
* @event {Function} change 某个item被打开或者收起时触发
* @example <u-collapse-item :title="item.head" v-for="(item, index) in itemList" :key="index">{{item.body}}</u-collapse-item>
*/
const props = defineProps(CollapseItemProps);
const emit = defineEmits(['change']);
const slots = useSlots();
const instance = getCurrentInstance();
const isShow = ref(false);
const elId = ref('');
const height = ref('0');
const headStyle = ref<Record<string, any>>({});
const bodyStyle = ref<Record<string, any>>({});
const itemStyle = ref<Record<string, any>>({});
const arrowColor = ref('');
const hoverClass = ref('');
// 使用通信库的子组件Hook
const { childId, parentExposed } = useChildren('u-collapse-item', 'u-collapse');
// 计算属性
const showArrow = computed(() => {
return parentExposed.value?.props ? parentExposed.value.props.arrow : true;
});
const titleStyle = computed(() => {
let style = { textAlign: props.align ? props.align : 'left' };
if (isShow.value && props.activeStyle && !showArrow.value) {
style = $u.deepMerge(style, props.activeStyle);
}
return $u.toStyle(style);
});
// 获取唯一标识符
const itemName = computed(() => {
// 优先级:name > index > childId
if (props.name !== undefined && props.name !== '') {
return props.name;
} else if (props.index !== undefined && props.index !== '') {
return props.index;
} else {
return childId;
}
});
/**
* 设置显示状态
*/
function setShowState(show: boolean) {
if (isShow.value !== show) {
isShow.value = show;
// 如果展开,需要重新计算高度
if (show) {
nextTick(() => {
queryRect();
});
}
// 本地触发change事件
emit('change', {
index: props.index,
name: itemName.value,
show: isShow.value
});
}
}
/**
* 异步获取内容,或者动态修改了内容时,需要重新初始化
*/
function init() {
if (parentExposed.value?.props) {
headStyle.value = parentExposed.value.props.headStyle || {};
bodyStyle.value = parentExposed.value.props.bodyStyle || {};
arrowColor.value = parentExposed.value.props.arrowColor || 'var(--u-tips-color)';
hoverClass.value = parentExposed.value.props.hoverClass || 'u-hover-class';
itemStyle.value = parentExposed.value.props.itemStyle || {};
}
elId.value = $u.guid();
nextTick(() => {
queryRect();
});
}
/**
* 点击collapse head头部
*/
function headClick() {
if (props.disabled) return;
// 通知父组件状态变化
parentExposed?.value?.onChange(itemName.value);
}
/**
* 查询内容高度
*/
function queryRect() {
$u.getRect('#' + elId.value, instance)
.then((res: any) => {
if (res && res.height) {
height.value = res.height + 'px';
}
// #ifdef MP-TOUTIAO
if (isShow.value) {
height.value = 'auto';
}
// #endif
})
.catch((err: any) => {
console.warn('queryRect error:', err);
height.value = 'auto';
});
}
// 单选
function openSingle(data: any) {
// 只有目标项展开,其他都关闭
const shouldShow = data.targetName === itemName.value;
setShowState(shouldShow);
}
// 关闭所有
function closeAll() {
setShowState(false);
}
// 多选
function setMultiple(data: any) {
const shouldShow = data.targetNames.includes(itemName.value);
setShowState(shouldShow);
}
// 切换单个
function toggleSingle(data: any) {
// 只有目标项才切换状态
if (data.targetName === itemName.value) {
setShowState(!isShow.value);
}
}
onMounted(() => {
// 关键修复:根据 open 属性设置初始状态
setShowState(props.open);
// 初始化
init();
});
// 监听 open 属性变化
watch(
() => props.open,
newVal => {
setShowState(newVal);
}
);
// 监听父组件exposed的变化
watch(
parentExposed,
newExposed => {
if (newExposed) {
init();
}
},
{ deep: true, immediate: true }
);
defineExpose({
init,
isShow,
elId,
height,
headStyle,
bodyStyle,
itemStyle,
arrowColor,
hoverClass,
itemName: itemName.value,
queryRect,
setShowState,
openSingle,
closeAll,
setMultiple,
toggleSingle
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-collapse-head {
position: relative;
@include vue-flex;
justify-content: space-between;
align-items: center;
color: $u-main-color;
font-size: 30rpx;
line-height: 1;
padding: 24rpx 0;
text-align: left;
}
.u-collapse-title {
flex: 1;
overflow: hidden;
}
.u-arrow-down-icon {
transition: all 0.3s;
margin-right: 20rpx;
margin-left: 14rpx;
}
.u-arrow-down-icon-active {
transform: rotate(180deg);
transform-origin: center center;
}
.u-collapse-body {
overflow: hidden;
transition: all 0.3s;
}
.u-collapse-content {
overflow: hidden;
font-size: 28rpx;
color: $u-tips-color;
text-align: left;
}
</style>
@@ -0,0 +1,41 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-collapse 手风琴 Props
* @description 通过折叠面板收纳内容区域
* @property {Boolean} accordion 是否手风琴模式(默认true)
* @property {Object} headStyle 标题自定义样式,对象形式
* @property {Object} bodyStyle 主体自定义样式,对象形式
* @property {Object} itemStyle 每一个item的样式,对象形式
* @property {Boolean} arrow 是否显示标题右侧的箭头(默认true)
* @property {String} arrowColor 标题右侧箭头的颜色(默认var(--u-tips-color))
* @property {String} hoverClass 样式类名,按下时有效(默认u-hover-class)
*/
export const CollapseProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否手风琴模式 */
accordion: { type: Boolean, default: true },
/** 头部的样式 */
headStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 主体的样式 */
bodyStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 每一个item的样式 */
itemStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 是否显示右侧的箭头 */
arrow: { type: Boolean, default: true },
/** 箭头的颜色 */
arrowColor: { type: String, default: 'var(--u-tips-color)' },
/** 标题部分按压时的样式类,"none"为无效果 */
hoverClass: { type: String, default: 'u-hover-class' }
};
export type CollapseProps = ExtractPropTypes<typeof CollapseProps>;
@@ -0,0 +1,190 @@
<template>
<view class="u-collapse" :style="$u.toStyle(customStyle)" :class="customClass">
<slot />
</view>
</template>
<script lang="ts">
export default {
name: 'u-collapse',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { CollapseProps } from './types';
import { $u, useParent } from '../../';
/**
* collapse 手风琴
* @description 通过折叠面板收纳内容区域
* @tutorial https://uviewpro.cn/zh/components/collapse.html
* @property {Boolean} accordion 是否手风琴模式(默认true)
* @property {Boolean} arrow 是否显示标题右侧的箭头(默认true)
* @property {String} arrow-color 标题右侧箭头的颜色(默认var(--u-tips-color))
* @property {Object} head-style 标题自定义样式,对象形式
* @property {Object} body-style 主体自定义样式,对象形式
* @property {String} hover-class 样式类名,按下时有效(默认u-hover-class)
* @event {Function} change 当前激活面板展开时触发(如果是手风琴模式,参数activeNames类型为String,否则为Array)
* @example <u-collapse></u-collapse>
*/
const props = defineProps(CollapseProps);
const emit = defineEmits(['change']);
// 使用通信库的父组件Hook
const { children, broadcast, getChildrenExposed } = useParent('u-collapse');
// 当前激活的面板 - 只在手风琴模式下使用
const activeName = ref<string | number>('');
onMounted(() => {
nextTick(() => {
// 初始化:收集所有open为true的项
setTimeout(() => {
initializeActiveName();
}, 100);
});
});
/**
* 初始化activeName - 找到第一个open为true的项
*/
function initializeActiveName() {
if (props.accordion) {
// 手风琴模式下,取第一个open为true的项作为初始激活项
const childrenExposed = getChildrenExposed();
const openChild = childrenExposed.find(child => child.exposed.isShow === true);
if (openChild) {
activeName.value = openChild.exposed.itemName || '';
}
}
}
/**
* collapse item被点击,由collapse item调用父组件方法
*/
function onChange(name: string | number) {
if (props.accordion) {
// 手风琴模式
const childrenExposed = getChildrenExposed();
const targetChild = childrenExposed.find(child => child.exposed.itemName === name);
if (targetChild?.exposed.isShow.value === true) {
// 目标项当前是展开状态,点击后要关闭它
activeName.value = '';
broadcast('closeAll', {});
} else {
// 目标项当前是关闭状态,点击后要展开它并关闭其他
activeName.value = name;
broadcast('openSingle', { targetName: name });
}
} else {
// 非手风琴模式 - 只通知目标项切换状态
broadcast('toggleSingle', { targetName: name });
}
// 收集当前所有展开的项
let currentActiveNames: (string | number)[] = [];
if (props.accordion) {
currentActiveNames = activeName.value === 0 || activeName.value ? [activeName.value] : [];
} else {
// 对于非手风琴模式,我们不知道所有项的状态
currentActiveNames = [];
children.forEach(child => {
if (child.getExposed().isShow.value) {
currentActiveNames.push(child.getExposed().itemName);
}
});
}
currentActiveNames.length > 0 && emit('change', props.accordion ? activeName.value || '' : currentActiveNames);
}
/**
* 设置激活的面板
*/
function setActiveNames(names: string | number | (string | number)[]) {
if (props.accordion) {
// 手风琴模式
const name = Array.isArray(names) ? names[0] : names;
activeName.value = name || '';
if (name) {
broadcast('openSingle', { targetName: name });
} else {
broadcast('closeAll', {});
}
} else {
// 非手风琴模式
const namesArray = Array.isArray(names) ? names : [names];
broadcast('setMultiple', { targetNames: namesArray });
}
}
/**
* 打开所有面板
*/
function openAll() {
if (props.accordion) {
console.warn('手风琴模式下不能打开所有面板');
return;
}
const childrenExposed = getChildrenExposed();
const allNames = childrenExposed.map(child => child.exposed.itemName).filter(Boolean);
broadcast('setMultiple', { targetNames: allNames });
}
/**
* 关闭所有面板
*/
function closeAll() {
broadcast('closeAll', {});
if (props.accordion) {
activeName.value = '';
}
}
/**
* 重新初始化,用于动态内容变化
*/
function init() {
const childrenExposed = getChildrenExposed();
childrenExposed.forEach(child => {
if (child.exposed.init) {
child.exposed.init();
}
});
// 重新初始化activeName
setTimeout(() => {
initializeActiveName();
}, 150);
}
// 使用defineExpose暴露给外部
defineExpose({
// props
props,
// 状态
activeName,
// 方法
onChange,
setActiveNames,
openAll,
closeAll,
init,
// 计算属性
childrenCount: () => children.length
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,58 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Direction, PlayState, ScrollDirection, ThemeType } from '../../types/global';
/**
* u-column-notice 通告栏 Props
*/
export const ColumnNoticeProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 显示的内容,数组 */
list: { type: Array as PropType<string[]>, default: () => [] },
/** 显示的主题,success|error|primary|info|warning */
type: { type: String as PropType<ThemeType | 'none'>, default: 'warning' },
/** 是否显示左侧的音量图标 */
volumeIcon: { type: Boolean, default: true },
/** 是否显示右侧的右箭头图标 */
moreIcon: { type: Boolean, default: false },
/** 是否显示右侧的关闭图标 */
closeIcon: { type: Boolean, default: false },
/** 是否自动播放 */
autoplay: { type: Boolean, default: true },
/** 文字颜色,各图标也会使用文字颜色 */
color: { type: String, default: '' },
/** 背景颜色 */
bgColor: { type: String, default: '' },
/** 滚动方向,row-水平滚动,column-垂直滚动 */
direction: { type: String as PropType<ScrollDirection>, default: 'row' },
/** 是否显示 */
show: { type: Boolean, default: true },
/** 字体大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/** 滚动一个周期的时间长,单位ms */
duration: { type: [Number, String] as PropType<number | string>, default: 2000 },
/** 音量喇叭的大小 */
volumeSize: { type: [Number, String] as PropType<number | string>, default: 34 },
/** 水平滚动时的滚动速度,即每秒滚动多少rpx,这有利于控制文字无论多少时,都能有一个恒定的速度 */
speed: { type: Number, default: 160 },
/** 水平滚动时,是否采用衔接形式滚动 */
isCircular: { type: Boolean, default: true },
/** 滚动方向,horizontal-水平滚动,vertical-垂直滚动 */
mode: { type: String as PropType<Direction>, default: 'horizontal' },
/** 播放状态,play-播放,paused-暂停 */
playState: { type: String as PropType<PlayState>, default: 'play' },
/** 是否禁止用手滑动切换 */
disableTouch: { type: Boolean, default: true },
/** 通知的边距 */
padding: { type: [Number, String] as PropType<number | string>, default: '18rpx 24rpx' }
};
export type ColumnNoticeProps = ExtractPropTypes<typeof ColumnNoticeProps>;
@@ -0,0 +1,223 @@
<template>
<view
class="u-notice-bar"
:style="$u.toStyle({ background: computeBgColor, padding: props.padding }, customStyle)"
:class="[props.type ? `u-type-${props.type}-light-bg` : '', customClass]"
>
<view class="u-icon-wrap">
<u-icon
class="u-left-icon"
v-if="props.volumeIcon"
name="volume-fill"
:size="props.volumeSize"
:color="computeColor"
></u-icon>
</view>
<swiper
:disable-touch="props.disableTouch"
@change="change"
:autoplay="props.autoplay && props.playState === 'play'"
:vertical="vertical"
circular
:interval="props.duration"
class="u-swiper"
>
<swiper-item v-for="(item, index) in props.list" :key="index" class="u-swiper-item">
<view
class="u-news-item u-line-1"
:style="textStyle"
@tap="click(index)"
:class="['u-type-' + props.type]"
>
{{ item }}
</view>
</swiper-item>
</swiper>
<view class="u-icon-wrap">
<u-icon
@click="getMore"
class="u-right-icon"
v-if="props.moreIcon"
name="arrow-right"
:size="26"
:color="computeColor"
></u-icon>
<u-icon
@click="close"
class="u-right-icon"
v-if="props.closeIcon"
name="close"
:size="24"
:color="computeColor"
></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-column-notice',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ColumnNoticeProps } from './types';
import { $u } from '../../';
import uIcon from '../u-icon/u-icon.vue';
/**
* u-column-notice 通告栏
* @property {Array} list 显示的内容,数组
* @property {String} type 显示的主题,success|error|primary|info|warning
* @property {Boolean} volumeIcon 是否显示左侧的音量图标
* @property {Boolean} moreIcon 是否显示右侧的右箭头图标
* @property {Boolean} closeIcon 是否显示右侧的关闭图标
* @property {Boolean} autoplay 是否自动播放
* @property {String} color 文字颜色,各图标也会使用文字颜色
* @property {String} bgColor 背景颜色
* @property {String} direction 滚动方向,row-水平滚动,column-垂直滚动
* @property {Boolean} show 是否显示
* @property {String|Number} fontSize 字体大小,单位rpx
* @property {String|Number} duration 滚动一个周期的时间长,单位ms
* @property {String|Number} volumeSize 音量喇叭的大小
* @property {Number} speed 水平滚动时的滚动速度,即每秒滚动多少rpx
* @property {Boolean} isCircular 水平滚动时,是否采用衔接形式滚动
* @property {String} mode 滚动方向,horizontal-水平滚动,vertical-垂直滚动
* @property {String} playState 播放状态,play-播放,paused-暂停
* @property {Boolean} disableTouch 是否禁止用手滑动切换
* @property {String|Number} padding 通知的边距
*/
const props = defineProps(ColumnNoticeProps);
const emit = defineEmits<{
(e: 'click', index: number): void;
(e: 'close'): void;
(e: 'getMore'): void;
(e: 'end'): void;
}>();
/**
* 计算字体颜色,如果没有自定义的,就用uview主题颜色
*/
const computeColor = computed(() => {
if (props.color) return props.color;
// 如果是无主题,就默认使用content-color
else if (props.type === 'none') return 'var(--u-content-color)';
else return props.type;
});
/**
* 文字内容的样式
*/
const textStyle = computed(() => {
const style: Record<string, any> = {};
if (props.color) style.color = props.color;
else if (props.type === 'none') style.color = 'var(--u-content-color)';
style.fontSize = props.fontSize + 'rpx';
return style;
});
/**
* 垂直或者水平滚动
*/
const vertical = computed(() => {
return props.mode !== 'horizontal';
});
/**
* 计算背景颜色
*/
const computeBgColor = computed(() => {
if (props.bgColor) return props.bgColor;
else if (props.type === 'none') return 'transparent';
else return '';
});
/**
* 点击通告栏
*/
function click(index: number) {
emit('click', index);
}
/**
* 点击关闭按钮
*/
function close() {
emit('close');
}
/**
* 点击更多箭头按钮
*/
function getMore() {
emit('getMore');
}
/**
* swiper 切换事件
*/
function change(e: { detail: { current: number } }) {
const index = e.detail.current;
if (index === props.list.length - 1) {
emit('end');
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-notice-bar {
width: 100%;
@include vue-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
padding: 18rpx 24rpx;
overflow: hidden;
}
.u-swiper {
font-size: 26rpx;
height: 32rpx;
@include vue-flex;
align-items: center;
flex: 1;
margin-left: 12rpx;
}
.u-swiper-item {
@include vue-flex;
align-items: center;
overflow: hidden;
}
.u-news-item {
overflow: hidden;
}
.u-right-icon {
margin-left: 12rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
.u-left-icon {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
</style>
@@ -0,0 +1,53 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { config, configProvider } from '../../libs';
import type { Theme, DarkMode } from '../../types/global';
export const ConfigProviderProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/**
* 主题风格,可选值:
* - 'light': 强制亮色模式(默认)
* - 'dark': 强制暗黑模式
* - 'auto': 自动跟随系统设置
*/
darkMode: {
type: String as PropType<DarkMode>,
default: () => configProvider.getDarkMode()
},
/**
* 当前主题名称(用于多主题切换)
*/
currentTheme: {
type: String,
default: () => configProvider.getCurrentTheme()?.name ?? config.defaultTheme
},
/**
* 自定义主题列表
*/
themes: {
type: Array as PropType<Theme[]>,
default: () => configProvider.getThemes()
},
/**
* 国际化配置:传入 locale 列表(数组)并可指定默认语言 currentLocale
*/
locales: {
type: Array as PropType<any[]>,
default: () => []
},
currentLocale: {
type: String,
default: () => configProvider.getCurrentLocale()?.name ?? ''
}
};
export type ConfigProviderProps = ExtractPropTypes<typeof ConfigProviderProps>;
@@ -0,0 +1,185 @@
<template>
<view :class="['u-config-provider', `u-theme-${darkMode}`, customClass]" :style="mergedThemeStyle">
<slot />
</view>
</template>
<script lang="ts">
/**
* u-config-provider
*
* 说明(简要):
* - 初始化场景(推荐在应用入口执行):使用 `useTheme().initTheme(themes)` 进行一次性初始化与全局设置(例如在 `main.ts` 或 `App.vue` 中)。
* - 组件/页面场景(推荐):使用 `useTheme()` 组合式函数在组件内部读取响应式 `currentTheme`、`themes`、`darkMode` 并通过 `setTheme()` 和 `setDarkMode()` 切换主题/模式。
*
* 该组件的行为:
* - 如果在挂载时传入 `themes`,会调用 `configProvider.initTheme(themes)`
* - 如果传入 `currentTheme`,会优先设置当前主题
* - 如果传入 `darkMode`,会设置当前的暗黑模式状态,同时在 document 上添加 `u-theme-dark` 或 `u-theme-light` 类名
*
* 详尽说明请参考:`docs/config-provider-usage.md`
*/
export default {
name: 'u-config-provider',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script lang="ts" setup>
import { computed, watch, onMounted } from 'vue';
import { ConfigProviderProps } from './types';
import { $u, configProvider } from '../../libs';
import { useTheme } from '../../libs/hooks/useTheme';
import { useLocale } from '../../libs/hooks/useLocale';
const props = defineProps(ConfigProviderProps);
const emit = defineEmits<{
'theme-change': [themeName: string];
'mode-change': [mode: string];
}>();
const { initTheme } = useTheme();
const { initLocales, setLocale, getLocales, getCurrentLocale } = useLocale();
const bootstrapTheme = () => {
// 初始化主题
try {
// 如果已经初始化过主题,不再重复初始化,只更新 props 相关配置
const existingThemes = configProvider.getThemes();
if (existingThemes.length > 0) {
// 已初始化,只在值不同时才更新(避免覆盖用户之前的设置)
if (props.currentTheme && props.currentTheme !== configProvider.getCurrentTheme()?.name) {
configProvider.setTheme(props.currentTheme as string);
}
if (props.darkMode && props.darkMode !== configProvider.getDarkMode()) {
configProvider.setDarkMode(props.darkMode);
}
return;
}
// 未初始化,进行初始化
if (props.themes && props.themes.length) {
configProvider.initTheme(props.themes, props.currentTheme as any);
} else {
// 使用 useTheme 的 initTheme,它会处理默认主题
initTheme(undefined, props.currentTheme as any);
}
} catch (e) {
console.warn('[u-config-provider] init theme failed', e);
}
};
const bootstrapLocale = () => {
// 初始化国际化
try {
const existingLocales = getLocales();
if (existingLocales.length > 0) {
if (props.currentLocale && props.currentLocale !== getCurrentLocale()?.name) {
setLocale(props.currentLocale as string);
}
} else {
if (props.locales && props.locales.length) {
initLocales(props.locales, props.currentLocale as any);
} else {
initLocales(undefined, props.currentLocale as any);
}
}
} catch (e) {
console.warn('[u-config-provider] init locales failed', e);
}
};
// 当传入自定义 themes 时,初始化全局 configProvider(覆盖已有)
onMounted(() => {
bootstrapTheme();
bootstrapLocale();
});
// 监听外部 props 变化(如果上层修改 prop)
watch(
() => props.themes,
val => {
// 如果传入的 themes 来自于 configProvider 自身(常见于模板中使用 useTheme() 直接透传),
// 那么对其做深度监听会在我们内部更新主题对象时触发该回调,进而再次调用 init 导致循环更新。
// 为避免该情况,先做简单保护:当传入对象正是 configProvider.themesRef.value 时不重复初始化。
if (val && val.length && val !== configProvider.themesRef.value) {
configProvider.initTheme(val, props.currentTheme as any);
}
},
{ deep: true }
);
watch(
() => props.currentTheme,
val => {
if (val) {
configProvider.setTheme(val);
}
}
);
watch(
() => props.darkMode,
val => {
if (val && val !== configProvider.getDarkMode()) {
configProvider.setDarkMode(val);
}
}
);
// 监听 locales prop 变化
watch(
() => props.locales,
val => {
if (val && val.length) {
initLocales(val, props.currentLocale as any);
}
},
{ deep: true }
);
watch(
() => props.currentLocale,
val => {
if (val) {
setLocale(val);
}
}
);
// 监听全局主题变更并触发事件
watch(
() => configProvider.currentThemeRef.value,
(val, oldVal) => {
if (val && val.name !== (oldVal as any)?.name) {
emit('theme-change', val.name);
}
},
{ immediate: true }
);
// 监听暗黑模式变更并触发事件
watch(
() => configProvider.darkModeRef.value,
(val, oldVal) => {
if (val && val !== oldVal) {
emit('mode-change', val);
}
},
{ immediate: true }
);
// 计算合并样式(作为局部 fallback),configProvider 已经会把变量注入到 document 上
const mergedThemeStyle = computed(() => {
return $u.toStyle(configProvider.cssVarsRef.value, props.customStyle);
});
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,52 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-count-down 倒计时 Props
* @description 该组件一般使用于某个活动的截止时间上,通过数字的变化,给用户明确的时间感受,提示用户进行某一个行为操作。
*/
export const CountDownProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 倒计时的时间,秒为单位 */
timestamp: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 是否自动开始倒计时 */
autoplay: { type: Boolean, default: true },
/** 分隔符 */
separator: { type: String, default: 'colon' },
/** 分隔符的大小,单位rpx */
separatorSize: { type: [Number, String] as PropType<number | string>, default: 30 },
/** 分隔符颜色 */
separatorColor: { type: String, default: 'var(--u-main-color)' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 字体大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 30 },
/** 背景颜色 */
bgColor: { type: String, default: 'var(--u-bg-white)' },
/** 数字框高度,单位rpx */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 是否显示数字框 */
showBorder: { type: Boolean, default: false },
/** 边框颜色 */
borderColor: { type: String, default: 'var(--u-main-color)' },
/** 是否显示秒 */
showSeconds: { type: Boolean, default: true },
/** 是否显示分钟 */
showMinutes: { type: Boolean, default: true },
/** 是否显示小时 */
showHours: { type: Boolean, default: true },
/** 是否显示“天” */
showDays: { type: Boolean, default: true },
/** 当"天"的部分为0时,不显示 */
hideZeroDay: { type: Boolean, default: false }
};
export type CountDownProps = ExtractPropTypes<typeof CountDownProps>;
@@ -0,0 +1,288 @@
<template>
<view class="u-countdown" :class="customClass" :style="$u.toStyle(customStyle)">
<view
class="u-countdown-item"
:style="$u.toStyle(itemStyle)"
v-if="props.showDays && (props.hideZeroDay || (!props.hideZeroDay && d != '00'))"
>
<view class="u-countdown-time" :style="$u.toStyle(letterStyle)">
{{ d }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showDays && (props.hideZeroDay || (!props.hideZeroDay && d != '00'))"
>
{{ props.separator == 'colon' && props.showHours ? ':' : t('uCountDown.day') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showHours">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ h }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showHours"
>
{{ props.separator == 'colon' && props.showMinutes ? ':' : t('uCountDown.hour') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showMinutes">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ i }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showMinutes"
>
{{ props.separator == 'colon' && props.showSeconds ? ':' : t('uCountDown.minute') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showSeconds">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ s }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{ fontSize: props.separatorSize + 'rpx', color: props.separatorColor, paddingBottom: 0 }"
v-if="props.showSeconds && props.separator == 'zh'"
>
{{ t('uCountDown.second') }}
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-count-down',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { CountDownProps } from './types';
import { $u, useLocale } from '../../';
const { t } = useLocale();
/**
* countDown 倒计时
* @description 该组件一般使用于某个活动的截止时间上,通过数字的变化,给用户明确的时间感受,提示用户进行某一个行为操作。
* @tutorial https://uviewpro.cn/zh/components/countDown.html
* @property {String | Number} timestamp 倒计时,单位为秒
* @property {Boolean} autoplay 是否自动开始倒计时,如果为false,需手动调用开始方法。见官网说明(默认true)
* @property {String} separator 分隔符,colon为英文冒号,zh为中文(默认colon)
* @property {String | Number} separator-size 分隔符的字体大小,单位rpx(默认30)
* @property {String} separator-color 分隔符的颜色(默认var(--u-main-color))
* @property {String | Number} font-size 倒计时字体大小,单位rpx(默认30)
* @property {Boolean} show-border 是否显示倒计时数字的边框(默认false)
* @property {Boolean} hide-zero-day 当"天"的部分为0时,隐藏该字段 (默认true)
* @property {String} border-color 数字边框的颜色(默认var(--u-main-color))
* @property {String} bg-color 倒计时数字的背景颜色(默认var(--u-bg-white))
* @property {String} color 倒计时数字的颜色(默认var(--u-main-color))
* @property {String | Number} height 数字高度值(宽度等同此值),设置边框时看情况是否需要设置此值,单位rpx(默认auto)
* @property {Boolean} show-days 是否显示倒计时的"天"部分(默认true)
* @property {Boolean} show-hours 是否显示倒计时的"时"部分(默认true)
* @property {Boolean} show-minutes 是否显示倒计时的"分"部分(默认true)
* @property {Boolean} show-seconds 是否显示倒计时的"秒"部分(默认true)
* @event {Function} end 倒计时结束
* @event {Function} change 每秒触发一次,回调为当前剩余的倒计秒数
* @example <u-count-down ref="uCountDown" :timestamp="86400" :autoplay="false"></u-count-down>
*/
const emit = defineEmits(['end', 'change']);
const props = defineProps(CountDownProps);
const d = ref('00'); // 天
const h = ref('00'); // 小时
const i = ref('00'); // 分钟
const s = ref('00'); // 秒
const timer = ref<number | ReturnType<typeof setInterval> | null>(null); // 定时器
const seconds = ref(0);
/**
* 倒计时item的样式,item为分别的时分秒部分的数字
*/
const itemStyle = computed(() => {
const style: Record<string, string> = {};
if (props.height) {
style.height = props.height + 'rpx';
style.width = props.height + 'rpx';
}
if (props.showBorder) {
style.borderStyle = 'solid';
style.borderColor = props.borderColor;
style.borderWidth = '1px';
}
if (props.bgColor) {
style.backgroundColor = props.bgColor;
}
return style;
});
/**
* 倒计时数字的样式
*/
const letterStyle = computed(() => {
const style: Record<string, string> = {};
if (props.fontSize) style.fontSize = props.fontSize + 'rpx';
if (props.color) style.color = props.color;
return style;
});
// 监听时间戳的变化
watch(
() => props.timestamp,
(newVal, oldVal) => {
// 如果倒计时间发生变化,清除定时器,重新开始倒计时
clearTimer();
start();
}
);
onMounted(() => {
// 如果自动倒计时
if (props.autoplay && props.timestamp) {
start();
}
});
/**
* 倒计时
*/
function start() {
// 避免可能出现的倒计时重叠情况
clearTimer();
if (Number(props.timestamp) <= 0) return;
seconds.value = Number(props.timestamp);
formatTime(seconds.value);
timer.value = setInterval(() => {
seconds.value--;
// 发出change事件
emit('change', seconds.value);
if (seconds.value < 0) {
end();
return;
}
formatTime(seconds.value);
}, 1000);
}
/**
* 格式化时间
*/
function formatTime(sec: number) {
// 小于等于0的话,结束倒计时
if (sec <= 0) end();
let dayNum = Math.floor(sec / (60 * 60 * 24));
let hourNum = Math.floor(sec / (60 * 60)) - dayNum * 24;
// showHour为需要显示的小时
let showHour: string;
// 判断是否显示“天”参数,如果不显示,将天部分的值,加入到小时中
// hour为给后面计算秒和分等用的(基于显示天的前提下计算)
if (props.showDays) {
showHour = hourNum < 10 ? '0' + hourNum : String(hourNum);
} else {
// 如果不显示天数,将“天”部分的时间折算到小时中去
// 如果小于10,在前面补上一个"0"
const hourTotal = Math.floor(sec / (60 * 60));
showHour = hourTotal < 10 ? '0' + hourTotal : String(hourTotal);
}
let minuteNum = Math.floor(sec / 60) - hourNum * 60 - dayNum * 24 * 60;
let secondNum = Math.floor(sec) - dayNum * 24 * 60 * 60 - hourNum * 60 * 60 - minuteNum * 60;
const minute = minuteNum < 10 ? '0' + minuteNum : String(minuteNum);
const second = secondNum < 10 ? '0' + secondNum : String(secondNum);
const day = dayNum < 10 ? '0' + dayNum : String(dayNum);
d.value = day;
h.value = showHour;
i.value = minute;
s.value = second;
}
/**
* 停止倒计时
*/
function end() {
clearTimer();
emit('end', {});
}
/**
* 清除定时器
*/
function clearTimer() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
defineExpose({
start,
end
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-countdown {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
.u-countdown-item {
@include vue-flex;
align-items: center;
justify-content: center;
padding: 2rpx;
border-radius: 6rpx;
white-space: nowrap;
transform: translateZ(0);
}
.u-countdown-time {
margin: 0;
padding: 0;
line-height: 1;
}
.u-countdown-colon {
@include vue-flex;
justify-content: center;
padding: 0 5rpx;
line-height: 1;
align-items: center;
padding-bottom: 4rpx;
}
.u-countdown-scale {
transform: scale(0.9);
transform-origin: center center;
}
</style>
@@ -0,0 +1,42 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-count-to 数字滚动 Props
* @description 该组件一般用于需要滚动数字到某一个值的场景,目标要求是一个递增的值。
*/
export const CountToProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 开始的数值,默认从0增长到某一个数 */
startVal: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 要滚动的目标数值,必须 */
endVal: { type: [Number, String] as PropType<number | string>, default: 0, required: true },
/** 滚动到目标数值的动画持续时间,单位为毫秒(ms) */
duration: { type: [Number, String] as PropType<number | string>, default: 2000 },
/** 设置数值后是否自动开始滚动 */
autoplay: { type: Boolean, default: true },
/** 要显示的小数位数 */
decimals: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 是否在即将到达目标数值的时候,使用缓慢滚动的效果 */
useEasing: { type: Boolean, default: true },
/** 十进制分割符号 */
decimal: { type: [Number, String] as PropType<number | string>, default: '.' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 字体大小 */
fontSize: { type: [Number, String] as PropType<number | string>, default: 50 },
/** 是否加粗字体 */
bold: { type: Boolean, default: false },
/** 千位分隔符,类似金额的分割(¥23,321.05中的",") */
separator: { type: String, default: '' }
};
export type CountToProps = ExtractPropTypes<typeof CountToProps>;
@@ -0,0 +1,266 @@
<template>
<view
class="u-count-num"
:class="customClass"
:style="
$u.toStyle(
{
fontSize: props.fontSize + 'rpx',
fontWeight: props.bold ? 'bold' : 'normal',
color: props.color
},
customStyle
)
"
>
{{ displayValue }}
</view>
</template>
<script lang="ts">
export default {
name: 'u-count-to',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { CountToProps } from './types';
import { $u } from '../../';
/**
* countTo 数字滚动
* @description 该组件一般用于需要滚动数字到某一个值的场景,目标要求是一个递增的值。
* @tutorial https://uviewpro.cn/zh/components/countTo.html
* @property {String | Number} start-val 开始值
* @property {String | Number} end-val 结束值
* @property {String | Number} duration 滚动过程所需的时间,单位ms(默认2000)
* @property {Boolean} autoplay 是否自动开始滚动(默认true)
* @property {String | Number} decimals 要显示的小数位数,见官网说明(默认0)
* @property {Boolean} use-easing 滚动结束时,是否缓动结尾,见官网说明(默认true)
* @property {String} separator 千位分隔符,见官网说明
* @property {String} color 字体颜色(默认var(--u-main-color))
* @property {String | Number} font-size 字体大小,单位rpx(默认50)
* @property {Boolean} bold 字体是否加粗(默认false)
* @event {Function} end 数值滚动到目标值时触发
* @example <u-count-to ref="uCountTo" :end-val="endVal" :autoplay="autoplay"></u-count-to>
*/
const emit = defineEmits(['end']);
const props = defineProps(CountToProps);
const localStartVal = ref(Number(props.startVal));
const displayValue = ref(formatNumber(props.startVal));
const printVal = ref<number | null>(null);
const paused = ref(false); // 是否暂停
const localDuration = ref(Number(props.duration));
const startTime = ref<number | null>(null); // 开始的时间
const timestamp = ref<number | null>(null); // 时间戳
const remaining = ref<number | null>(null); // 停留的时间
const rAF = ref<number | null>(null);
const lastTime = ref(0); // 上一次的时间
/**
* 是否倒计时
*/
const countDown = computed(() => Number(props.startVal) > Number(props.endVal));
watch(
() => props.startVal,
() => {
if (props.autoplay) start();
}
);
watch(
() => props.endVal,
() => {
if (props.autoplay) start();
}
);
onMounted(() => {
if (props.autoplay) start();
});
/**
* 缓动函数
*/
function easingFn(t: number, b: number, c: number, d: number): number {
return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b;
}
/**
* requestAnimationFrame polyfill
*/
function requestAnimationFrame(callback: (ts: number) => void): number {
const currTime = new Date().getTime();
// 为了使setTimteout的尽可能的接近每秒60帧的效果
const timeToCall = Math.max(0, 16 - (currTime - lastTime.value));
const id = setTimeout(() => {
callback(currTime + timeToCall);
}, timeToCall);
lastTime.value = currTime + timeToCall;
return id as unknown as number;
}
/**
* 取消动画帧
*/
function cancelAnimationFrame(id: number | null) {
if (id) clearTimeout(id);
}
/**
* 开始滚动数字
*/
function start() {
localStartVal.value = Number(props.startVal);
startTime.value = null;
localDuration.value = Number(props.duration);
paused.value = false;
rAF.value = requestAnimationFrame(count);
}
/**
* 暂停/恢复滚动
* 暂定状态,重新再开始滚动;或者滚动状态下,暂停
*/
function reStart() {
if (paused.value) {
resume();
paused.value = false;
} else {
stop();
paused.value = true;
}
}
/**
* 暂停
*/
function stop() {
cancelAnimationFrame(rAF.value);
}
/**
* 重新开始(暂停的情况下)
*/
function resume() {
startTime.value = null;
localDuration.value = remaining.value || Number(props.duration);
localStartVal.value = printVal.value || Number(props.startVal);
rAF.value = requestAnimationFrame(count);
}
/**
* 重置
*/
function reset() {
startTime.value = null;
cancelAnimationFrame(rAF.value);
displayValue.value = formatNumber(props.startVal);
}
/**
* 数字滚动动画主逻辑
*/
function count(ts: number) {
if (!startTime.value) startTime.value = ts;
timestamp.value = ts;
const progress = ts - (startTime.value || 0);
remaining.value = localDuration.value - progress;
let val: number;
if (props.useEasing) {
if (countDown.value) {
val =
localStartVal.value -
easingFn(progress, 0, localStartVal.value - Number(props.endVal), localDuration.value);
} else {
val = easingFn(
progress,
localStartVal.value,
Number(props.endVal) - localStartVal.value,
localDuration.value
);
}
} else {
if (countDown.value) {
val = localStartVal.value - (localStartVal.value - Number(props.endVal)) * (progress / localDuration.value);
} else {
val = localStartVal.value + (Number(props.endVal) - localStartVal.value) * (progress / localDuration.value);
}
}
if (countDown.value) {
val = val < Number(props.endVal) ? Number(props.endVal) : val;
} else {
val = val > Number(props.endVal) ? Number(props.endVal) : val;
}
printVal.value = val;
displayValue.value = formatNumber(val);
if (progress < localDuration.value) {
rAF.value = requestAnimationFrame(count);
} else {
emit('end');
}
}
/**
* 判断是否数字
*/
function isNumber(val: unknown): boolean {
return !isNaN(parseFloat(String(val)));
}
/**
* 格式化数字
*/
function formatNumber(num: unknown): string {
// 将num转为Number类型,因为其值可能为字符串数值,调用toFixed会报错
let n = Number(num);
n = Number(n.toFixed(Number(props.decimals)));
let str = n + '';
const x = str.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? String(props.decimal) + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (props.separator && !isNumber(props.separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + props.separator + '$2');
}
}
return x1 + x2;
}
// 销毁时清理动画帧
onUnmounted(() => {
cancelAnimationFrame(rAF.value);
});
// 暴露给父组件的函数
defineExpose({
start,
stop,
reStart,
resume,
reset
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-count-num {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
text-align: center;
}
</style>
@@ -0,0 +1,41 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
/**
* u-divider 分割线 Props
* @description 区隔内容的分割线,一般用于页面底部"没有更多"的提示。
*/
export const DividerProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 单一边divider横线的宽度(数值),单位rpx。或者百分比 */
halfWidth: { type: [Number, String] as PropType<number | string>, default: 150 },
/** divider横线的颜色,如设置 */
borderColor: { type: String, default: 'var(--u-border-color)' },
/** 主题色,可以是primary|info|success|warning|error之一值 */
type: { type: String as PropType<ThemeType>, default: 'primary' },
/** 文字颜色 */
color: { type: String, default: 'var(--u-tips-color)' },
/** 文字大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/** 整个divider的背景颜色 */
bgColor: { type: String, default: 'var(--u-bg-white)' },
/** 整个divider的高度单位rpx */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 上边距 */
marginTop: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 下边距 */
marginBottom: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 是否使用slot传入内容,如果不用slot传入内容,先的中间就不会有空隙 */
useSlot: { type: Boolean, default: true }
};
export type DividerProps = ExtractPropTypes<typeof DividerProps>;
@@ -0,0 +1,146 @@
<template>
<view
class="u-divider"
:class="customClass"
:style="
$u.toStyle(
{
height: height === 'auto' ? 'auto' : height + 'rpx',
backgroundColor: bgColor,
marginBottom: marginBottom + 'rpx',
marginTop: marginTop + 'rpx'
},
customStyle
)
"
@tap="onClick"
>
<view
class="u-divider-line"
:class="[type ? 'u-divider-line--bordercolor--' + type : '']"
:style="lineStyle"
></view>
<view
v-if="useSlot"
class="u-divider-text"
:style="{
color: color,
fontSize: fontSize + 'rpx'
}"
>
<slot />
</view>
<view
class="u-divider-line"
:class="[type ? 'u-divider-line--bordercolor--' + type : '']"
:style="lineStyle"
></view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-divider',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { DividerProps } from './types';
import { $u } from '../../';
/**
* divider 分割线
* @description 区隔内容的分割线,一般用于页面底部"没有更多"的提示。
* @tutorial https://uviewpro.cn/zh/components/divider.html
* @property {String Number} half-width 文字左或右边线条宽度,数值或百分比,数值时单位为rpx
* @property {String} border-color 线条颜色,优先级高于type(默认var(--u-border-color))
* @property {String} color 文字颜色(默认var(--u-tips-color))
* @property {String Number} fontSize 字体大小,单位rpx(默认26)
* @property {String} bg-color 整个divider的背景颜色(默认呢var(--u-bg-white))
* @property {String Number} height 整个divider的高度,单位rpx(默认40)
* @property {String} type 将线条设置主题色(默认primary)
* @property {Boolean} useSlot 是否使用slot传入内容,如果不传入,中间不会有空隙(默认true)
* @property {String Number} margin-top 与前一个组件的距离,单位rpx(默认0)
* @property {String Number} margin-bottom 与后一个组件的距离,单位rpx(0)
* @event {Function} click divider组件被点击时触发
* @example <u-divider color="var(--u-type-error)">长河落日圆</u-divider>
*/
const props = defineProps(DividerProps);
const emit = defineEmits(['click']);
/**
* divider横线样式
*/
const lineStyle = computed(() => {
let style: Record<string, string> = {};
if (String(props.halfWidth).indexOf('%') !== -1) style.width = String(props.halfWidth);
else style.width = props.halfWidth + 'rpx';
// borderColor优先级高于type值
if (props.borderColor) style.borderColor = props.borderColor;
return style;
});
/**
* divider组件被点击时触发
*/
function onClick() {
emit('click');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-divider {
width: 100%;
position: relative;
text-align: center;
@include vue-flex;
justify-content: center;
align-items: center;
overflow: hidden;
flex-direction: row;
}
.u-divider-line {
border-bottom: 1px solid $u-border-color;
transform: scale(1, 0.5);
transform-origin: center;
&--bordercolor--primary {
border-color: $u-type-primary;
}
&--bordercolor--success {
border-color: $u-type-success;
}
&--bordercolor--error {
border-color: $u-type-primary;
}
&--bordercolor--info {
border-color: $u-type-info;
}
&--bordercolor--warning {
border-color: $u-type-warning;
}
}
.u-divider-text {
white-space: nowrap;
padding: 0 16rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
}
</style>
@@ -0,0 +1,40 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { OptionType } from '../../types/global';
/**
* u-dropdown-item 下拉菜单项 Props
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
* @property {String|Number|Array} modelValue 双向绑定选项卡选择值
* @property {String|Number} title 菜单项标题
* @property {OptionType[]} options 选项数据,如果传入了默认slot,此参数无效
* @property {Boolean} disabled 是否禁用此选项卡(默认false)
* @property {String|Number} height 弹窗下拉内容的高度(内容超出将会滚动)(默认auto)
* @property {Boolean} show 控制是否显示菜单项(默认true)
*/
export const DropdownItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 当前选中项的value值 */
modelValue: { type: [Number, String, Array] as PropType<number | string | any[]>, default: '' },
/** 菜单项标题 */
title: { type: [String, Number] as PropType<string | number>, default: '' },
/** 选项数据,如果传入了默认slot,此参数无效 */
options: { type: Array as PropType<OptionType[]>, default: () => [] },
/** 是否禁用此菜单项 */
disabled: { type: Boolean, default: false },
/** 下拉弹窗的高度 */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 控制是否显示菜单项 */
show: { type: Boolean, default: true }
};
export type DropdownItemProps = ExtractPropTypes<typeof DropdownItemProps>;
@@ -0,0 +1,124 @@
<template>
<view
v-if="show && active"
class="u-dropdown-item"
:class="customClass"
:style="$u.toStyle(customStyle)"
@touchmove.stop.prevent
@tap.stop.prevent
>
<block v-if="!slots.default && !slots.$default">
<scroll-view :scroll-y="true" :style="{ height: $u.addUnit(String(height)) }">
<view class="u-dropdown-item__options">
<u-cell-group>
<u-cell-item
@click="cellClick(item.value)"
:arrow="false"
:title="item.label"
v-for="(item, index) in options"
:key="index"
:title-style="{ color: modelValue == item.value ? activeColor : inactiveColor }"
>
<u-icon
v-if="modelValue == item.value"
name="checkbox-mark"
:color="activeColor"
size="32"
></u-icon>
</u-cell-item>
</u-cell-group>
</view>
</scroll-view>
</block>
<slot v-else />
</view>
</template>
<script lang="ts">
export default {
name: 'u-dropdown-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, useSlots, watch } from 'vue';
import { $u, useChildren } from '../..';
import { DropdownItemProps } from './types';
import uCellGroup from '../u-cell-group/u-cell-group.vue';
import uCellItem from '../u-cell-item/u-cell-item.vue';
import uIcon from '../u-icon/u-icon.vue';
/**
* dropdown-item 下拉菜单
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
* @tutorial https://uviewpro.cn/zh/components/dropdown.html
* @property {String | Number} v-model 双向绑定选项卡选择值
* @property {String} title 菜单项标题
* @property {Array[Object]} options 选项数据,如果传入了默认slot,此参数无效
* @property {Boolean} disabled 是否禁用此选项卡(默认false)
* @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
* @property {String | Number} height 弹窗下拉内容的高度(内容超出将会滚动)(默认auto)
* @property {Boolean} show 控制是否显示菜单项(默认true)
* @example <u-dropdown-item title="标题"></u-dropdown-item>
*/
// props 定义
const props = defineProps(DropdownItemProps);
// emits 定义
const emit = defineEmits(['update:modelValue', 'change']);
// 插槽
const slots = useSlots();
const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');
// 当前项是否处于展开状态
const active = ref(false);
// 激活时左边文字和右边对勾图标的颜色
const activeColor = computed(() => parentExposed.value?.props?.activeColor || $u.color.primary);
// 未激活时左边文字和右边对勾图标的颜色
const inactiveColor = computed(() => parentExposed.value?.props?.inactiveColor || $u.color.contentColor);
// 监听props变化,通知父组件重新初始化
const propsChange = computed(() => `${props.title}-${props.disabled}-${props.show}`);
// 监听propsChange变化,通知父组件重新init
watch(propsChange, () => {
emitToParent('init');
});
/**
* cell被点击
* @param value 选中值
*/
function cellClick(value: string | number | any) {
// 修改通过v-model绑定的值
emit('update:modelValue', value);
// 通知父组件(u-dropdown)收起菜单
emitToParent('close');
// 发出事件,抛出当前勾选项的value
emit('change', value);
}
function setActive(value: boolean) {
active.value = value;
}
defineExpose({
cellClick,
setActive,
props
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,67 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
import zIndex from '../../libs/config/zIndex';
/**
* u-dropdown 下拉菜单 Props
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
*/
export const DropdownProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 菜单标题和选项的激活态颜色 */
activeColor: { type: String, default: () => getColor('primary') },
/** 菜单标题和选项的未激活态颜色 */
inactiveColor: { type: String, default: () => getColor('contentColor') },
/** 点击遮罩是否关闭菜单 */
closeOnClickMask: { type: Boolean, default: true },
/** 点击当前激活项标题是否关闭菜单 */
closeOnClickSelf: { type: Boolean, default: true },
/** 过渡时间 */
duration: { type: [Number, String] as PropType<number | string>, default: 300 },
/** 标题菜单的高度,单位任意,数值默认为rpx单位 */
height: { type: [Number, String] as PropType<number | string>, default: 80 },
/** 是否显示下边框 */
borderBottom: { type: Boolean, default: false },
/** 标题的字体大小 */
titleSize: { type: [Number, String] as PropType<number | string>, default: 28 },
/** 下拉出来的内容部分的圆角值 */
borderRadius: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 菜单右侧的icon图标 */
menuIcon: { type: String, default: 'arrow-down' },
/** 菜单右侧图标的大小 */
menuIconSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/**
* 是否使用fixed定位,开启后下拉菜单固定在顶部,
* 自动适配状态栏和导航栏高度,并生成占位区域防止页面塌陷
*/
fixed: { type: Boolean, default: false },
/**
* fixed定位时,在自动计算的偏移量基础上的额外顶部偏移量(px),用于用户微调位置。
* 实际top = statusBarHeight + navbarHeight + offsetTop
*/
offsetTop: { type: [Number, String] as PropType<number | string>, default: 0 },
/**
* fixed定位时,状态栏和导航栏所占的总高度(px)。
* 默认不设置(空字符串),组件会自动获取系统状态栏高度 + 默认导航栏高度(44px)。
* 设置任意数值(包括0)则使用用户指定值,不再自动计算。
*/
navbarHeight: { type: [Number, String] as PropType<number | string>, default: '' },
/**
* 沉浸式模式,仅在fixed下生效。
* 开启后不生成占位区域,允许页面内容延伸到dropdown下方(类似沉浸式导航栏)
*/
immersive: { type: Boolean, default: false },
/** fixed定位时的z-index值 */
zIndex: { type: [Number, String] as PropType<number | string>, default: zIndex.dropdown }
};
export type DropdownProps = ExtractPropTypes<typeof DropdownProps>;
@@ -0,0 +1,436 @@
<template>
<view class="u-dropdown-wrapper">
<view class="u-dropdown" :style="dropdownRootStyle" :class="[{ 'u-dropdown__fixed': fixed }, customClass]">
<view
class="u-dropdown__menu"
:style="{ height: $u.addUnit(height) }"
:class="{ 'u-border-bottom': borderBottom }"
>
<view
class="u-dropdown__menu__item"
v-for="(item, index) in menuList"
:key="index"
@tap.stop="menuClick(index)"
>
<view class="u-flex">
<text
class="u-dropdown__menu__item__text"
:style="{
color: item.disabled
? 'var(--u-light-color)'
: index === current || highlightIndex == index
? activeColor
: inactiveColor,
fontSize: $u.addUnit(titleSize)
}"
>{{ item.title }}</text
>
<view
class="u-dropdown__menu__item__arrow"
:class="{
'u-dropdown__menu__item__arrow--rotate': index === current
}"
>
<u-icon
:custom-style="{ display: 'flex' }"
:name="menuIcon"
:size="$u.addUnit(menuIconSize)"
:color="
index === current || highlightIndex == index ? activeColor : 'var(--u-light-color)'
"
></u-icon>
</view>
</view>
</view>
</view>
<view
class="u-dropdown__content"
:style="[
contentStyle,
{
transition: `opacity ${Number(duration) / 1000}s linear`,
top: $u.addUnit(height),
height: contentHeight + 'px'
}
]"
@tap="maskClick"
@touchmove.stop.prevent
>
<view @tap.stop.prevent class="u-dropdown__content__popup" :style="[popupStyle]">
<slot></slot>
</view>
<view class="u-dropdown__content__mask"></view>
</view>
</view>
<!-- fixed定位时的占位区域,防止页面内容塌陷 -->
<view
v-if="fixed && !immersive"
class="u-dropdown__placeholder"
:style="{ height: dropdownPlaceholderHeight + 'px' }"
></view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-dropdown',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import { $u, useParent } from '../..';
import { DropdownProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* dropdown 下拉菜单
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景。
* 支持fixed定位,自动适配状态栏和导航栏高度,并提供占位区域防止页面塌陷。
* @tutorial https://uviewpro.cn/zh/components/dropdown.html
* @property {String} active-color 标题和选项卡选中的颜色(默认主题色primary)
* @property {String} inactive-color 标题和选项卡未选中的颜色(默认var(--u-content-color))
* @property {Boolean} close-on-click-mask 点击遮罩是否关闭菜单(默认true)
* @property {Boolean} close-on-click-self 点击当前激活项标题是否关闭菜单(默认true)
* @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
* @property {String | Number} height 标题菜单的高度,单位任意(默认80)
* @property {String | Number} border-radius 菜单展开内容下方的圆角值,单位任意(默认0)
* @property {Boolean} border-bottom 标题菜单是否显示下边框(默认false)
* @property {String | Number} title-size 标题的字体大小,单位任意,数值默认为rpx单位(默认28)
* @property {Boolean} fixed 是否使用fixed定位固定在顶部,自动适配状态栏和导航栏高度(默认false)
* @property {String | Number} offset-top fixed定位时,在自动计算的偏移量基础上的额外顶部偏移量,单位px(默认0)
* @property {String | Number} navbar-height fixed定位时,状态栏+导航栏的总高度(px)。不设置则自动获取,设置任意数值(包括0)则使用指定值
* @property {Boolean} immersive 沉浸式模式,仅在fixed下生效,开启后不生成占位区域(默认false)
* @property {String | Number} z-index fixed定位时的z-index值(默认985)
* @event {Function} open 下拉菜单被打开时触发
* @event {Function} close 下拉菜单被关闭时触发
* @example <u-dropdown :fixed="true"></u-dropdown>
*/
const props = defineProps(DropdownProps);
const emit = defineEmits(['open', 'close']);
const { children } = useParent('u-dropdown');
// 菜单列表
const menuList = ref<any[]>([]);
// 下拉菜单的状态
const active = ref(false);
// 当前激活菜单索引
// 当前是第几个菜单处于激活状态,小程序中此处不能写成false或者"",否则后续将current赋值为0,
// 无能的TX没有使用===而是使用==判断,导致程序认为前后二者没有变化,从而不会触发视图更新
const current = ref<number>(99999);
// 外层内容样式
const contentStyle = ref<any>({ zIndex: -1, opacity: 0 });
// 高亮菜单索引
const highlightIndex = ref<number>(99999);
// 下拉内容高度
const contentHeight = ref<number>(0);
// ==================== fixed定位相关 ====================
// 获取系统状态栏高度
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = ref(0);
// #ifdef APP-HARMONY || MP-WEIXIN
const windowInfo = uni.getWindowInfo();
statusBarHeight.value = windowInfo.statusBarHeight || 0;
// #endif
// #ifndef APP-HARMONY || MP-WEIXIN
statusBarHeight.value = systemInfo.statusBarHeight || 0;
// #endif
// 默认导航栏高度(与 u-navbar 保持一致)
const defaultNavbarHeight = computed(() => {
// #ifdef APP || H5
return 44;
// #endif
// #ifdef MP
return systemInfo.platform === 'ios' ? 44 : 48;
// #endif
});
/**
* 计算 fixed 定位时的总偏移高度(px)
* - 若用户通过 navbarHeight 指定了任意数值(包括0),则使用用户指定值
* - 否则(空字符串)自动计算:状态栏高度 + 默认导航栏高度
* - 最后加上 offsetTop 作为额外偏移
*/
const dropdownOffsetHeight = computed(() => {
let baseHeight: number;
if (props.navbarHeight !== '' && props.navbarHeight !== undefined && props.navbarHeight !== null) {
// 用户手动指定了 navbar 高度(包括0),直接使用
baseHeight = Number(props.navbarHeight);
} else {
// 自动计算:状态栏高度 + 默认导航栏高度
baseHeight = statusBarHeight.value + defaultNavbarHeight.value;
}
// 加上用户设置的额外 offsetTop 偏移
return baseHeight + Number(props.offsetTop);
});
/**
* 占位区域高度 = dropdown菜单栏自身高度(px)
* 不包含状态栏+导航栏偏移,因为页面上已有 navbar 等元素占据该空间
*/
const dropdownPlaceholderHeight = computed(() => {
return uni.upx2px(Number(props.height));
});
/**
* dropdown 根节点样式
* fixed 时设置 top 和 z-index
*/
const dropdownRootStyle = computed(() => {
const style: Record<string, any> = {};
// 兼容头条小程序
// #ifdef MP-TOUTIAO
style.width = '100vw';
// #endif
// fixed 定位时,计算顶部偏移和 z-index
if (props.fixed) {
style.top = dropdownOffsetHeight.value + 'px';
style.zIndex = props.zIndex ? Number(props.zIndex) : $u.zIndex.dropdown;
}
return style;
});
// 下拉出来部分的样式
const popupStyle = computed<any>(() => {
const style: any = {};
// 进行Y轴位移,展开状态时,恢复原位。收齐状态时,往上位移100%,进行隐藏
style.transform = `translateY(${active.value ? 0 : '-100%'})`;
style['transition-duration'] = Number(props.duration) / 1000 + 's';
style.borderRadius = `0 0 ${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)}`;
return style;
});
// 生命周期
onMounted(() => {
getContentHeight();
});
/**
* 初始化所有子组件
* 当某个子组件内容变化时,触发父组件的init,父组件再让每一个子组件重新初始化一遍
* 以保证数据的正确性
*/
function init() {
menuList.value = [];
children.forEach(child => {
// 过滤不显示的项
const show = child?.getExposed()?.props.show !== false;
if (!show) return;
menuList.value.push({
title: child?.getExposed()?.props.title ?? '',
disabled: child?.getExposed()?.props.disabled ?? false,
childIndex: children.indexOf(child)
});
});
}
/**
* 点击菜单
* @param index 菜单索引
*/
function menuClick(index: number) {
// 判断是否被禁用
if (menuList.value[index]?.disabled) return;
// 如果点击时的索引和当前激活项索引相同,意味着点击了激活项,需要收起下拉菜单
if (index === current.value && props.closeOnClickSelf) {
close();
// 等动画结束后,再移除下拉菜单中的内容,否则直接移除,也就没有下拉菜单收起的效果了
const childIndex = menuList.value[index]?.childIndex; //避免访问到show为false的项
setTimeout(() => {
if (childIndex !== undefined && children[childIndex]) {
children[childIndex]?.getExposed()?.setActive(false);
}
}, Number(props.duration));
return;
}
open(index);
}
/**
* 打开下拉菜单
* @param index 菜单索引
*/
function open(index: number) {
// 嵌套popup使用时可能获取不到正确的高度,重新计算
if (contentHeight.value < 1) getContentHeight();
// 重置高亮索引,否则会造成多个菜单同时高亮
// highlightIndex.value = 9999;
// 展开时,设置下拉内容的样式
contentStyle.value = { zIndex: 11, display: 'block' };
// 标记展开状态以及当前展开项的索引
active.value = true;
current.value = index;
// 历遍所有的子元素,将索引匹配的项标记为激活状态,因为子元素是通过v-if控制切换的
// 之所以不是因display: none,是因为nvue没有display这个属性
const childIndex = menuList.value[index]?.childIndex; //避免访问到show为false的项
children.forEach((child, idx) => {
child?.getExposed()?.setActive(childIndex === idx ? true : false);
});
emit('open', current.value);
}
/**
* 设置下拉菜单处于收起状态
*/
function close() {
emit('close', current.value);
// 设置为收起状态,同时current归位,设置为空字符串
active.value = false;
current.value = 99999;
// 下拉内容的样式进行调整,不透明度设置为0,fixed模式下隐藏display避免遮挡页面元素
if (props.fixed) {
contentStyle.value = { zIndex: -1, opacity: 0, display: 'none' };
} else {
contentStyle.value = { zIndex: -1, opacity: 0 };
}
}
/**
* 点击遮罩
*/
function maskClick() {
// 如果不允许点击遮罩,直接返回
if (!props.closeOnClickMask) return;
close();
}
/**
* 外部手动设置某个菜单高亮
* @param index 菜单索引
*/
function highlight(index?: number) {
highlightIndex.value = index !== undefined ? index : 99999;
}
/**
* 获取下拉菜单内容的高度
* 这里的原理为,因为dropdown组件是相对定位的,它的下拉出来的内容,必须给定一个高度
* 才能让遮罩占满菜单一下,直到屏幕底部的高度
* this.$u.sys()为uView封装的获取设备信息的方法
*/
function getContentHeight() {
const windowHeight = $u.sys().windowHeight;
contentHeight.value = windowHeight;
// $u.getRect('.u-dropdown__menu', instance).then((res: any) => {
// // 这里获取的是dropdown的尺寸,在H5上,uniapp获取尺寸是有bug的(以前提出修复过,后来又出现了此bug,目前hx2.8.11版本)
// // H5端bug表现为元素尺寸的top值为导航栏底部到到元素的上边沿的距离,但是元素的bottom值确是导航栏顶部到元素底部的距离
// // 二者是互相矛盾的,本质原因是H5端导航栏非原生,uni的开发者大意造成
// // 这里取菜单栏的botton值合理的,不能用res.top,否则页面会造成滚动
// contentHeight.value = windowHeight - res.bottom;
// });
}
onMounted(() => {
nextTick(() => {
init();
});
});
// 暴露方法
defineExpose({
props,
init,
close,
open,
highlight,
getContentHeight,
children,
menuList
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-dropdown-wrapper {
width: 100%;
}
.u-dropdown {
flex: 1;
width: 100%;
position: relative;
&__fixed {
position: fixed;
left: 0;
right: 0;
background-color: #fff;
}
&__placeholder {
width: 100%;
}
&__menu {
@include vue-flex;
position: relative;
z-index: 11;
height: 80rpx;
&__item {
flex: 1;
@include vue-flex;
justify-content: center;
align-items: center;
&__text {
font-size: 28rpx;
color: $u-content-color;
}
&__arrow {
margin-left: 6rpx;
transition: transform 0.3s;
align-items: center;
@include vue-flex;
&--rotate {
transform: rotate(180deg);
}
}
}
}
&__content {
position: absolute;
width: 100%;
left: 0px;
bottom: 0;
overflow: hidden;
&__mask {
position: absolute;
z-index: 9;
background: rgba(0, 0, 0, 0.3);
width: 100%;
left: 0;
top: 0;
bottom: 0;
}
&__popup {
position: relative;
z-index: 10;
transition: all 0.3s;
transform: translate3D(0, -100%, 0);
overflow: hidden;
}
}
}
</style>
@@ -0,0 +1,46 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-empty 组件 Props 类型定义
*/
export const EmptyProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 图标路径 */
src: { type: String, default: '' },
/** 提示文字 */
text: { type: String, default: '' },
/** 文字颜色 */
color: { type: String, default: 'var(--u-light-color)' },
/** 图标的颜色 */
iconColor: { type: String, default: 'var(--u-light-color)' },
/** 图标的大小 */
iconSize: { type: [String, Number] as PropType<string | number>, default: 120 },
/** 文字大小,单位rpx */
fontSize: { type: [String, Number] as PropType<string | number>, default: 26 },
/** 选择预置的图标类型 */
mode: { type: String, default: 'data' },
/** 图标宽度,单位rpx */
imgWidth: { type: [String, Number] as PropType<string | number>, default: 120 },
/** 图标高度,单位rpx */
imgHeight: { type: [String, Number] as PropType<string | number>, default: 'auto' },
/** 是否显示组件 */
show: { type: Boolean, default: true },
/** 组件距离上一个元素之间的距离 */
marginTop: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 图标自定义样式 */
iconStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) }
};
/**
* u-empty 组件 Props 类型
*/
export type EmptyProps = ExtractPropTypes<typeof EmptyProps>;
@@ -0,0 +1,109 @@
<template>
<view
v-if="show"
class="u-empty"
:class="customClass"
:style="$u.toStyle({ marginTop: marginTop + 'rpx' }, customStyle)"
>
<u-icon
:name="src ? src : 'empty-' + mode"
:custom-style="iconStyle"
:label="text ? text : (icons as any)[mode]"
label-pos="bottom"
:label-color="color"
:label-size="fontSize"
:size="iconSize"
:color="iconColor"
margin-top="14"
></u-icon>
<view class="u-slot-wrap">
<slot name="bottom"></slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-empty',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { EmptyProps } from './types';
import { $u, useLocale } from '../../';
import { computed } from 'vue';
import uIcon from '../u-icon/u-icon.vue';
const { t } = useLocale();
/**
* empty 内容为空
* @description 该组件用于需要加载内容,但是加载的第一页数据就为空,提示一个"没有内容"的场景, 我们精心挑选了十几个场景的图标,方便您使用。
* @tutorial https://uviewpro.cn/zh/components/empty.html
* @property {String} color 文字颜色(默认var(--u-light-color))
* @property {String} text 文字提示(默认“无内容”)
* @property {String} src 自定义图标路径,如定义,mode参数会失效
* @property {String Number} font-size 提示文字的大小,单位rpx(默认28)
* @property {String} mode 内置的图标,见官网说明(默认data)
* @property {String Number} img-width 图标的宽度,单位rpx(默认240)
* @property {String} img-height 图标的高度,单位rpx(默认auto)
* @property {String Number} margin-top 组件距离上一个元素之间的距离(默认0)
* @property {Boolean} show 是否显示组件(默认true)
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
* @example <u-empty text="所谓伊人,在水一方" mode="list"></u-empty>
*/
const props = defineProps(EmptyProps);
/**
* 预置图标对应的提示文字
*/
const icons = computed(() => {
return {
car: t('uEmpty.car'),
page: t('uEmpty.page'),
search: t('uEmpty.search'),
address: t('uEmpty.address'),
wifi: t('uEmpty.wifi'),
order: t('uEmpty.order'),
coupon: t('uEmpty.coupon'),
favor: t('uEmpty.favor'),
permission: t('uEmpty.permission'),
history: t('uEmpty.history'),
news: t('uEmpty.news'),
message: t('uEmpty.message'),
list: t('uEmpty.list'),
data: t('uEmpty.data')
};
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-empty {
@include vue-flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.u-image {
margin-bottom: 20rpx;
}
.u-slot-wrap {
@include vue-flex;
justify-content: center;
align-items: center;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,83 @@
import type { CSSProperties, ExtractPropTypes, PropType } from 'vue';
import type { FabDirection, FabGap, FabPosition, ThemeType } from '../../types/global';
/**
* fab 悬浮按钮类型定义
* @description 供 u-fab 组件 props 使用
*/
export const FabProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 按钮的预置样式,primary,info,error,warning,success */
type: { type: String as PropType<ThemeType>, default: 'primary' },
/** 是否禁止状态 */
disabled: { type: Boolean, default: false },
/** 按钮能否可以拖动 */
draggable: { type: Boolean, default: false },
/** 按钮与边缘的间距,单位 px。支持 number 或对象 {top,left,right,bottom},优先级按键存在顺序 */
gap: {
type: Object as PropType<FabGap>,
default: () => ({
top: 16,
left: 16,
right: 16,
bottom: 16
})
},
/** 拖动结束时是否自动吸边(仅当 draggable 为 true 时生效) */
autoStick: { type: Boolean, default: true },
/** 预设定位:控制组件默认停靠位置 */
position: {
type: String as PropType<FabPosition>,
default: 'right-bottom'
},
/** 菜单出现的方向 */
direction: { type: String as PropType<FabDirection>, default: 'top' },
/** 按钮自定义层级 */
zIndex: { type: Number, default: 99 }
};
export type FabProps = ExtractPropTypes<typeof FabProps>;
interface DirectionConfig {
opposite: keyof CSSProperties;
sizeKey: 'width' | 'height';
positionKey: 'left' | 'top';
flexBase: 'row' | 'row-reverse' | 'column' | 'column-reverse';
}
export const directionConfig: Record<FabDirection, DirectionConfig> = {
top: {
opposite: 'bottom',
sizeKey: 'width',
positionKey: 'left',
flexBase: 'column'
},
bottom: {
opposite: 'top',
sizeKey: 'width',
positionKey: 'left',
flexBase: 'column-reverse'
},
left: {
opposite: 'right',
sizeKey: 'height',
positionKey: 'top',
flexBase: 'row'
},
right: {
opposite: 'left',
sizeKey: 'height',
positionKey: 'top',
flexBase: 'row-reverse'
}
};
@@ -0,0 +1,330 @@
<template>
<view
class="u-fab"
:style="$u.toStyle(fabStyle, customStyle)"
:class="[customClass]"
@touchstart="handleTouchstart"
@touchmove.stop.prevent="handleTouchmove"
@touchend="handleTouchend"
>
<view class="u-fab-trigger" id="trigger">
<slot name="trigger">
<u-button
custom-class="u-fab-trigger-btn"
custom-style="width:112rpx;height:112rpx;border-radius:112rpx;"
:type="type"
:disabled="disabled"
:throttle-time="0"
@click="handleBtnClick"
>
<u-icon :name="expansion ? 'close' : 'plus'" size="36rpx"></u-icon>
</u-button>
</slot>
</view>
<view class="u-fab-actions" id="actions" :class="{ 'u-fab-actions__show': expansion }" :style="actionsStyle">
<slot></slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-fab',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { directionConfig, FabProps } from './types';
import { computed, getCurrentInstance, onMounted, reactive, ref, useSlots, watch } from 'vue';
import { $u } from '../../';
import uButton from '../u-button/u-button.vue';
import uIcon from '../u-icon/u-icon.vue';
const props = defineProps(FabProps);
const emit = defineEmits(['trigger']);
const slots = useSlots();
const instance = getCurrentInstance();
const sysInfo = $u.sys();
const dragging = ref(true);
const minLeft = ref(0);
const maxLeft = ref(0);
const minTop = ref(0);
const maxTop = ref(0);
const expansion = ref(false);
const direction = ref(props.direction);
const effectiveWindowHeight = ref(sysInfo.windowHeight);
// #ifdef H5
effectiveWindowHeight.value = sysInfo.windowTop + sysInfo.windowHeight;
// #endif
const position = reactive({
top: 0,
left: 0
});
const actions = ref();
const btnInfo = ref({
width: 56,
height: 56
});
const start = reactive({
x: 0,
y: 0
});
// 计算悬浮按钮样式
const fabStyle = computed(() => {
const style: Record<string, any> = {
transition: dragging.value ? 'none' : 'all 0.3s ease',
zIndex: props.zIndex,
left: `${position.left}px`,
top: `${position.top}px`
};
return style;
});
// 动画样式
const actionsStyle = computed(() => {
const config = directionConfig[direction.value];
const base: Record<string, any> = {
[config.opposite]: '100%',
[config.sizeKey]: '100%',
[config.positionKey]: 0,
flexDirection: config.flexBase,
transition: 'all 0.28s cubic-bezier(0.3, 0, 0.2, 1)'
};
// 展开/收缩动画:根据方向设置初始偏移
const offset = 12; // rpx/px 相对轻微偏移,视觉更顺滑
if (expansion.value) {
base.transform = 'translate3d(0,0,0) scale(1)';
base.opacity = 1;
} else {
// 隐藏时做一个方向上的微位移并缩放以优化动画感
if (direction.value === 'top') base.transform = `translate3d(0, ${offset}px, 0) scale(0.96)`;
else if (direction.value === 'bottom') base.transform = `translate3d(0, -${offset}px, 0) scale(0.96)`;
else if (direction.value === 'left') base.transform = `translate3d(${offset}px, 0, 0) scale(0.96)`;
else base.transform = `translate3d(-${offset}px, 0, 0) scale(0.96)`;
base.opacity = 0;
}
return base;
});
watch(
() => [props.position, props.gap],
() => {
initPosition();
}
);
watch(
() => props.direction,
() => {
if (expansion.value) direction.value = calcDirection();
}
);
// helper:支持 gap 为 number 或对象形式
function getGap(side: 'top' | 'left' | 'right' | 'bottom') {
const g = props.gap as any;
if (typeof g === 'number') return g;
if (g && typeof g === 'object' && g[side] !== undefined) return Number(g[side]);
return 0;
}
// 拖动开始事件
function handleTouchstart(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
start.x = touches.clientX;
start.y = touches.clientY;
dragging.value = true;
}
// 拖动移动事件
function handleTouchmove(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
const deltaX = touches.clientX - start.x;
const deltaY = touches.clientY - start.y;
position.left += deltaX;
position.top += deltaY;
start.x = touches.clientX;
start.y = touches.clientY;
// 设置边界,防止拖出边界
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
}
// 拖动结束事件
function handleTouchend() {
if (props.disabled || !props.draggable) return;
dragging.value = false;
// 如果 autoStick 为 false,则释放后不进行自动吸边,仅做边界限制
if (props.autoStick === false) {
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
return;
}
const middle = sysInfo.windowWidth / 2;
const buttonCenter = position.left + btnInfo.value.width / 2;
// 自动吸边,按钮中心位置大于视口的一半时,自动依附在右边,不然就是左边
position.left =
buttonCenter > middle ? sysInfo.windowWidth - btnInfo.value.width - getGap('right') : getGap('left');
if (expansion.value) direction.value = calcDirection();
}
// 按钮点击事件
function handleBtnClick() {
if (slots?.default) {
expansion.value = !expansion.value;
if (expansion.value) direction.value = calcDirection();
} else {
emit('trigger');
}
}
// 计算方向
function calcDirection() {
if (!actions.value) return props.direction;
let dir = props.direction;
const actionsHeight = actions.value?.height || 0;
const actionsWidth = actions.value?.width || 0;
// 菜单展开时,如果位置不够,则反转方向
if (dir === 'top') {
// 按钮上方剩余空间: 按钮顶部 - 顶部边距
if (position.top - minTop.value < actionsHeight) dir = 'bottom';
} else if (dir === 'bottom') {
// 按钮下方剩余空间: 有效窗口高度 - (按钮顶部 + 按钮高 + 边距)
const bottom = effectiveWindowHeight.value - (position.top + btnInfo.value.height + getGap('bottom'));
if (bottom < actionsHeight) dir = 'top';
} else if (dir === 'left') {
// 按钮左侧剩余空间: 有效窗口宽度 - 边距
if (position.left - getGap('left') < actionsWidth) dir = 'right';
} else if (dir === 'right') {
// 按钮右侧剩余空间: 有效窗口宽度 - (按钮左侧 + 按钮宽 + 边距)
const right = sysInfo.windowWidth - (position.left + btnInfo.value.width + getGap('right'));
if (right < actionsWidth) dir = 'left';
}
return dir;
}
// 初始化位置
function initPosition() {
// 根据 props.position 计算初始 left/top
const pos = props.position || 'right-bottom';
const winW = sysInfo.windowWidth;
const winH = effectiveWindowHeight.value;
switch (pos) {
case 'left-top':
position.left = getGap('left');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'right-top':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'left-bottom':
position.left = getGap('left');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'right-bottom':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'left-center':
position.left = getGap('left');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'right-center':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'top-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'bottom-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
default:
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
}
}
onMounted(async () => {
btnInfo.value = await $u.getRect('#trigger', instance);
actions.value = await $u.getRect('#actions', instance);
initPosition();
minLeft.value = getGap('left');
minTop.value = getGap('top') + sysInfo.windowTop;
maxLeft.value = sysInfo.windowWidth - btnInfo.value.width - getGap('right');
maxTop.value = effectiveWindowHeight.value - btnInfo.value.height - getGap('bottom');
});
defineExpose({
toggle: handleBtnClick
});
</script>
<style scoped lang="scss">
.u-fab {
position: fixed;
.u-fab-trigger {
:deep(.u-fab-trigger-btn) {
width: 112rpx;
height: 112rpx;
border-radius: 112rpx;
&::after {
border-radius: 112rpx;
}
}
}
.u-fab-actions {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
opacity: 0;
transform-origin: center;
will-change: transform, opacity;
transition: all 0.28s cubic-bezier(0.3, 0, 0.2, 1);
&.u-fab-actions__show {
visibility: visible;
opacity: 1;
}
}
}
</style>
@@ -0,0 +1,81 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { InputAlign, InputConfirmType, InputLabelPosition, InputType } from '../../types/global';
/**
* u-field 组件 Props 类型定义
* @description 表单输入框属性
*/
export const FieldProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** label左边的图标,限uView的图标名称 */
icon: { type: String, default: '' },
/** 输入框右边的图标名称,限uView的图标名称(默认false) */
rightIcon: { type: String, default: '' },
/** 方向属性 */
arrowDirection: { type: String, default: 'right' },
/** 是否必填,左边显示红色"*"号(默认false) */
required: { type: Boolean, default: false },
/** 输入框左边的文字提示 */
label: { type: String, default: '' },
/** 是否密码输入方式(用点替换文字),type为text时有效(默认false) */
password: { type: Boolean, default: false },
/** 是否显示右侧清空内容的图标控件(默认true) */
clearable: { type: Boolean, default: true },
/** label的宽度,单位rpx(默认130) */
labelWidth: { type: [Number, String] as PropType<string | number>, default: 130 },
/** label的文字对齐方式(默认left) */
labelAlign: { type: String as PropType<InputAlign>, default: 'left' },
/** 输入框内容对齐方式(默认left) */
inputAlign: { type: String as PropType<InputAlign>, default: 'left' },
/** 左边通过icon配置的图标的颜色(默认var(--u-content-color)) */
iconColor: { type: String, default: 'var(--u-content-color)' },
/** 是否自动增高输入区域,type为textarea时有效(默认true) */
autoHeight: { type: Boolean, default: true },
/** 显示的错误提示内容,如果为空字符串或者false,则不显示错误信息 */
errorMessage: { type: [String, Boolean] as PropType<string | boolean>, default: '' },
/** 输入框的提示文字 */
placeholder: { type: String, default: '' },
/** placeholder的样式(内联样式,字符串),如"color: var(--u-divider-color)" */
placeholderStyle: { type: String, default: '' },
/** 是否自动获得焦点(默认false) */
focus: { type: Boolean, default: false },
/** 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false) */
fixed: { type: Boolean, default: false },
/** 输入框绑定值 */
modelValue: [Number, String],
/** 输入框类型(text/textarea/password等,默认text) */
type: { type: String as PropType<InputType>, default: 'text' },
/** 是否不可输入(默认false) */
disabled: { type: Boolean, default: false },
/** 是否只读,禁止输入但可点击,样式不变,可触发click事件(默认false) */
readonly: { type: Boolean, default: false },
/** 最大输入长度,设置为 -1 的时候不限制最大长度(默认140) */
maxlength: { type: [Number, String] as PropType<string | number>, default: 140 },
/** 设置键盘右下角按钮的文字,仅在type="text"时生效(默认done) */
confirmType: { type: String as PropType<InputConfirmType>, default: 'done' },
/** label位置(默认left)left-左边,top-上边 */
labelPosition: { type: String as PropType<InputLabelPosition>, default: 'left' },
/** 自定义输入框的样式,对象形式 */
fieldStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 清除图标的大小,单位rpx(默认30) */
clearSize: { type: [Number, String] as PropType<string | number>, default: 30 },
/** 左侧图标样式 */
iconStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 是否显示field的上边框(默认false) */
borderTop: { type: Boolean, default: false },
/** 是否显示field的下边框(默认true) */
borderBottom: { type: Boolean, default: true },
/** 是否自动去除输入内容首尾空格(默认true) */
trim: { type: Boolean, default: true }
};
export type FieldProps = ExtractPropTypes<typeof FieldProps>;
@@ -0,0 +1,398 @@
<template>
<view
class="u-field"
:class="[
{
'u-border-top': props.borderTop,
'u-border-bottom': props.borderBottom,
'u-field--disabled': props.disabled
},
customClass
]"
:style="$u.toStyle(customStyle)"
>
<view
class="u-field-inner"
:class="[props.type === 'textarea' ? 'u-textarea-inner' : '', 'u-label-postion-' + props.labelPosition]"
>
<view
class="u-label"
:class="[props.required ? 'u-required' : '']"
:style="{
justifyContent: justifyContent,
flex: props.labelPosition === 'left' ? `0 0 ${props.labelWidth}rpx` : '1'
}"
>
<view class="u-icon-wrap" v-if="props.icon">
<u-icon
size="32"
:custom-style="props.iconStyle"
:name="props.icon"
:color="props.iconColor"
class="u-icon"
></u-icon>
</view>
<slot name="icon"></slot>
<text class="u-label-text" :class="[$slots.icon || props.icon ? 'u-label-left-gap' : '']">
{{ props.label }}
</text>
</view>
<view class="fild-body">
<view class="u-flex-1 u-flex u-field-input-wrap" :style="[inputWrapStyle]">
<textarea
v-if="props.type === 'textarea'"
class="u-flex-1 u-textarea-class"
:style="$u.toStyle(props.fieldStyle)"
:value="inputValue"
:placeholder="String(props.placeholder)"
:placeholderStyle="props.placeholderStyle"
:disabled="props.disabled"
:readonly="props.readonly"
:maxlength="inputMaxlength"
:focus="props.focus"
:autoHeight="props.autoHeight"
:fixed="props.fixed"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
@confirm="onConfirm"
/>
<!-- prettier-ignore -->
<input
v-else
class="u-flex-1 u-field__input-wrap"
:style="$u.toStyle(props.fieldStyle)"
:type="(props.type as any)"
:value="inputValue"
:password="props.password || props.type === 'password'"
:placeholder="String(props.placeholder)"
:placeholderStyle="props.placeholderStyle"
:disabled="props.disabled"
:readonly="props.readonly"
:maxlength="inputMaxlength"
:focus="props.focus"
:confirmType="props.confirmType"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
@confirm="onConfirm"
/>
<!-- 透明遮罩,在disabled或readonly时显示,用于捕获点击事件(原生input设置disabled会阻止点击冒泡) -->
<view v-if="props.readonly" class="u-field__readonly-overlay" @tap.stop="fieldClick"></view>
</view>
<u-icon
v-if="props.clearable && inputValue !== '' && focused && !props.disabled"
:size="props.clearSize"
name="close-circle-fill"
color="var(--u-light-color)"
class="u-clear-icon"
@click="onClear"
/>
<view class="u-button-wrap"><slot name="right" /></view>
<u-icon
v-if="props.rightIcon"
@click="rightIconClick"
:name="props.rightIcon"
color="var(--u-light-color)"
:style="[rightIconStyle]"
size="26"
class="u-arror-right"
/>
</view>
</view>
<view
v-if="props.errorMessage !== false && props.errorMessage != ''"
class="u-error-message"
:style="{
paddingLeft: props.labelWidth + 'rpx'
}"
>{{ props.errorMessage }}</view
>
</view>
</template>
<script lang="ts">
export default {
name: 'u-field',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { FieldProps } from './types';
import { ref, computed } from 'vue';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
/**
* field 输入框
* @description 借助此组件,可以实现表单的输入, 有"text"和"textarea"类型的,此外,借助uView的picker和actionSheet组件可以快速实现上拉菜单,时间,地区选择等, 为表单解决方案的利器。
* @tutorial https://uviewpro.cn/zh/components/field.html
* @property {String} type 输入框的类型(默认text)
* @property {String} icon label左边的图标,限uView的图标名称
* @property {Object} icon-style 左边图标的样式,对象形式
* @property {Boolean} right-icon 输入框右边的图标名称,限uView的图标名称(默认false)
* @property {Boolean} required 是否必填,左侧显示红色"*"号(默认false)
* @property {String} label 输入框左边的文字提示
* @property {Boolean} password 是否密码输入方式(用点替换文字),type为text时有效(默认false)
* @property {Boolean} clearable 是否显示右侧清空内容的图标控件(输入框有内容,且获得焦点时才显示),点击可清空输入框内容(默认true)
* @property {Number | String} label-width label的宽度,单位rpx(默认130)
* @property {String} label-align label的文字对齐方式(默认left)
* @property {Object} field-style 自定义输入框的样式,对象形式
* @property {Number | String} clear-size 清除图标的大小,单位rpx(默认30)
* @property {String} input-align 输入框内容对齐方式(默认left)
* @property {Boolean} border-bottom 是否显示field的下边框(默认true)
* @property {Boolean} border-top 是否显示field的上边框(默认false)
* @property {String} icon-color 左边通过icon配置的图标的颜色(默认var(--u-content-color))
* @property {Boolean} auto-height 是否自动增高输入区域,type为textarea时有效(默认true)
* @property {String Boolean} error-message 显示的错误提示内容,如果为空字符串或者false,则不显示错误信息
* @property {String} placeholder 输入框的提示文字
* @property {String} placeholder-style placeholder的样式(内联样式,字符串),如"color: var(--u-divider-color)"
* @property {Boolean} focus 是否自动获得焦点(默认false)
* @property {Boolean} fixed 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false)
* @property {Boolean} disabled 是否不可输入(默认false)
* @property {Number String} maxlength 最大输入长度,设置为 -1 的时候不限制最大长度(默认140)
* @property {String} confirm-type 设置键盘右下角按钮的文字,仅在type="text"时生效(默认done)
* @event {Function} input 输入框内容发生变化时触发
* @event {Function} focus 输入框获得焦点时触发
* @event {Function} blur 输入框失去焦点时触发
* @event {Function} confirm 点击完成按钮时触发
* @event {Function} right-icon-click 通过right-icon生成的图标被点击时触发
* @event {Function} click 输入框被点击或者通过right-icon生成的图标被点击时触发,这样设计是考虑到传递右边的图标,一般都为需要弹出"picker"等操作时的场景,点击倒三角图标,理应发出此事件,见上方说明
* @example <u-field v-model="mobile" label="手机号" required :error-message="errorMessage"></u-field>
*/
const emit = defineEmits(['update:modelValue', 'input', 'focus', 'blur', 'confirm', 'right-icon-click', 'click']);
const props = defineProps(FieldProps);
const focused = ref(false);
const inputValue = computed<string>(() => {
if (props.modelValue === undefined || props.modelValue === null) return '';
return String(props.modelValue);
});
const inputWrapStyle = computed(() => {
const style: Record<string, string> = {};
style.textAlign = props.inputAlign;
// 判断label的位置,如果是left的话,让input左边两边有间隙
if (props.labelPosition === 'left') {
style.margin = '0 8rpx';
} else {
// 如果label是top的,input的左边就没必要有间隙了
style.marginRight = '8rpx';
}
return style;
});
const rightIconStyle = computed(() => {
const style: Record<string, string> = {};
// 方向属性已注释,保留原有逻辑
if (props.arrowDirection === 'top') style.transform = 'rotate(-90deg)';
if (props.arrowDirection === 'bottom') style.transform = 'rotate(90deg)';
else style.transform = 'rotate(0deg)';
return style;
});
const labelStyle = computed(() => {
const style: Record<string, string> = {};
if (props.labelAlign === 'left') style.justifyContent = 'flex-start';
if (props.labelAlign === 'center') style.justifyContent = 'center';
if (props.labelAlign === 'right') style.justifyContent = 'flex-end';
return style;
});
// uni不支持在computed中写style.justifyContent = 'center'的形式,故用此方法
const justifyContent = computed(() => {
if (props.labelAlign === 'left') return 'flex-start';
if (props.labelAlign === 'center') return 'center';
if (props.labelAlign === 'right') return 'flex-end';
return 'flex-start';
});
// 因为uniapp的input组件的maxlength组件必须要数值,这里转为数值,给用户可以传入字符串数值
const inputMaxlength = computed(() => Number(props.maxlength));
// label的位置
const fieldInnerStyle = computed(() => {
const style: Record<string, string> = {};
style.flexDirection = props.labelPosition === 'left' ? 'row' : 'column';
return style;
});
function onInput(event: any) {
// 兼容 uni-app input/textarea 事件参数
let value = event?.detail?.value ?? '';
// 判断是否去除空格
if (props.trim) value = $u.trim(value);
emit('update:modelValue', value);
emit('input', value);
}
function onFocus(event: any) {
focused.value = true;
emit('focus', event);
}
function onBlur(event: any) {
// 最开始使用的是监听图标@touchstart事件,自从hx2.8.4后,此方法在微信小程序出错
// 这里改为监听点击事件,手点击清除图标时,同时也发生了@blur事件,导致图标消失而无法点击,这里做一个延时
setTimeout(() => {
focused.value = false;
}, 100);
emit('blur', event);
}
function onConfirm(e: any) {
emit('confirm', e?.detail?.value ?? '');
}
function onClear() {
emit('update:modelValue', '');
}
function rightIconClick() {
emit('right-icon-click');
emit('click');
}
function fieldClick() {
if (props.disabled) return;
emit('click');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-field {
font-size: 28rpx;
padding: 20rpx 28rpx;
text-align: left;
position: relative;
color: $u-main-color;
}
.u-field-inner {
@include vue-flex;
align-items: center;
}
.u-textarea-inner {
align-items: flex-start;
}
.u-textarea-class {
min-height: 96rpx;
width: auto;
font-size: 28rpx;
}
.fild-body {
@include vue-flex;
flex: 1;
align-items: center;
}
.u-arror-right {
margin-left: 8rpx;
}
.u-label-text {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
}
.u-label-left-gap {
margin-left: 6rpx;
}
.u-label-postion-top {
flex-direction: column;
align-items: flex-start;
}
.u-label {
width: 130rpx;
flex: 1 1 130rpx;
text-align: left;
position: relative;
@include vue-flex;
align-items: center;
}
.u-required::before {
content: '*';
position: absolute;
left: -16rpx;
font-size: 14px;
color: $u-type-error;
height: 9px;
line-height: 1;
}
.u-field__input-wrap {
position: relative;
overflow: hidden;
font-size: 28rpx;
height: 48rpx;
flex: 1;
width: auto;
}
.u-clear-icon {
@include vue-flex;
align-items: center;
}
.u-error-message {
color: $u-type-error;
font-size: 26rpx;
text-align: left;
}
.placeholder-style {
color: rgb(150, 151, 153);
}
.u-input-class {
font-size: 28rpx;
}
.u-button-wrap {
margin-left: 8rpx;
}
.u-field-input-wrap {
position: relative;
}
.u-field__readonly-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 1;
}
.u-field--disabled {
background-color: $u-bg-gray-light;
}
.u-field--disabled .u-textarea-class,
.u-field--disabled .u-field__input-wrap {
background-color: transparent;
color: $u-light-color;
-webkit-text-fill-color: $u-light-color;
}
</style>
@@ -0,0 +1,95 @@
import type { PropType, ExtractPropTypes } from 'vue';
import type { InputAlign, InputLabelPosition, SizeType } from '../../types/global';
/**
* form-item 表单item Props
*/
export const FormItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** input的label提示语 */
label: {
type: String,
default: ''
},
/** 绑定的值 */
prop: {
type: String,
default: ''
},
/** 是否显示表单域的下划线边框 */
borderBottom: {
type: [String, Boolean] as PropType<string | boolean>,
default: ''
},
/** label的位置,left-左边,top-上边 */
labelPosition: {
type: String as PropType<InputLabelPosition>,
default: ''
},
/** label的宽度,单位rpx */
labelWidth: {
type: [String, Number] as PropType<string | number>,
default: ''
},
/** label的样式,对象形式 */
labelStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** label字体的对齐方式 */
labelAlign: {
type: String as PropType<InputAlign>,
default: ''
},
/** 右侧图标 */
rightIcon: {
type: String,
default: ''
},
/** 左侧图标 */
leftIcon: {
type: String,
default: ''
},
/** 左侧图标的自定义前缀 */
leftIconPrefix: {
type: String,
default: 'uicon'
},
/** 右侧图标的自定义前缀 */
rightIconPrefix: {
type: String,
default: 'uicon'
},
/** 左侧图标的样式 */
leftIconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 右侧图标的样式 */
rightIconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 是否显示左边的必填星号,只作显示用,具体校验必填的逻辑,请在rules中配置 */
required: {
type: Boolean,
default: false
},
/** 表单内组件的大小,仅支持预设值 default/small/large */
size: {
type: String as PropType<SizeType | string>,
default: ''
}
};
export type FormItemProps = ExtractPropTypes<typeof FormItemProps>;
@@ -0,0 +1,563 @@
<template>
<view
class="u-form-item"
:class="[
{
'u-border-bottom': elBorderBottom,
'u-form-item__border-bottom--error': validateState === 'error' && showError('border-bottom'),
'u-form-item__border--error': validateState === 'error' && showError('border')
},
customClass
]"
:style="$u.toStyle(customStyle)"
>
<view
class="u-form-item__body"
:style="{
flexDirection: elLabelPosition == 'left' ? 'row' : 'column'
}"
>
<!-- 微信小程序中,将一个参数设置空字符串,结果会变成字符串"true" -->
<view
class="u-form-item--left"
:style="{
width: uLabelWidth,
flex: `0 0 ${uLabelWidth}`,
marginBottom: elLabelPosition == 'left' ? 0 : '10rpx'
}"
>
<!-- 为了块对齐 -->
<view
v-if="required || leftIcon || label || $slots.label || $slots.leftIcon"
class="u-form-item--left__content"
>
<!-- nvue不支持伪元素before -->
<text v-if="required" class="u-form-item--left__content--required">*</text>
<view class="u-form-item--left__content__icon" v-if="leftIcon || $slots.leftIcon">
<slot name="leftIcon">
<u-icon
:name="leftIcon"
:size="currentIconSize"
:custom-prefix="leftIconPrefix"
:custom-style="leftIconStyle"
></u-icon>
</slot>
</view>
<view
class="u-form-item--left__content__label"
:style="[
elLabelStyle,
{ 'font-size': actualFontSize },
{
'justify-content':
elLabelAlign == 'left'
? 'flex-start'
: elLabelAlign == 'center'
? 'center'
: 'flex-end'
}
]"
>
<slot name="label">
{{ label }}
</slot>
</view>
</view>
</view>
<view class="u-form-item--right u-flex">
<view class="u-form-item--right__content">
<view class="u-form-item--right__content__slot">
<slot />
</view>
<view
class="u-form-item--right__content__icon u-flex"
v-if="$slots.right || $slots.rightIcon || rightIcon"
>
<slot name="rightIcon">
<u-icon
v-if="rightIcon"
:name="rightIcon"
:size="currentIconSize"
:custom-prefix="rightIconPrefix"
:custom-style="rightIconStyle"
></u-icon>
</slot>
<slot name="right" />
</view>
</view>
</view>
</view>
<view
class="u-form-item__message"
v-if="validateState === 'error' && showError('message')"
:style="{
paddingLeft: elLabelPosition == 'left' && label ? $u.addUnit(elLabelWidth) : '0',
fontSize: $u.addUnit(currentSizeConfig.messageSize),
lineHeight: $u.addUnit(currentSizeConfig.messageSize)
}"
>
{{ validateMessage }}
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-form-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick, useSlots } from 'vue';
import { $u, useChildren, useParent } from '../..';
// @ts-ignore
import schema from '../../libs/util/async-validator';
import { FormItemProps } from './types';
import type { SizeType } from '../../types/global';
import uIcon from '../u-icon/u-icon.vue';
// 去除警告信息
schema.warning = function () {};
/**
* form-item 表单item
* @description 此组件一般用于表单场景,可以配置Input输入框,Select弹出框,进行表单验证等。
* @tutorial https://uviewpro.cn/zh/components/form.html
* @property {String} label 左侧提示文字
* @property {Object} prop 表单域model对象的属性名,在使用 validate、resetFields 方法的情况下,该属性是必填的
* @property {Boolean} border-bottom 是否显示表单域的下划线边框
* @property {String} label-position 表单域提示文字的位置,left-左侧,top-上方
* @property {String Number} label-width 提示文字的宽度,单位rpx(默认90)
* @property {Object} label-style label的样式,对象形式
* @property {String} label-align label的对齐方式
* @property {String} right-icon 右侧自定义字体图标(限uView内置图标)或图片地址
* @property {String} left-icon 左侧自定义字体图标(限uView内置图标)或图片地址
* @property {Object} left-icon-style 左侧图标的样式,对象形式
* @property {Object} right-icon-style 右侧图标的样式,对象形式
* @property {Boolean} required 是否显示左边的"*"号,这里仅起展示作用,如需校验必填,请通过rules配置必填规则(默认false)
* @example <u-form-item label="姓名"><u-input v-model="form.name" /></u-form-item>
*/
const props = defineProps(FormItemProps);
// 插槽
const $slots = useSlots();
const { broadcast } = useParent('u-form-item');
const { parentExposed } = useChildren('u-form-item', 'u-form');
// 组件状态
const initialValue = ref(''); // 存储初始值,用于重置
const validateState = ref(''); // 校验状态 success/error/validating
const validateMessage = ref(''); // 校验失败的提示语
const errorType = ref<string[]>(['message']); // 错误提示方式,默认 message
const fieldValue = ref(''); // 当前表单项的值
const parentData = ref({
borderBottom: true, // 父表单下划线边框
labelWidth: 90, // 父表单 label 宽度
labelPosition: 'left', // 父表单 label 位置
labelStyle: {}, // 父表单 label 样式
labelAlign: 'left' // 父表单 label 对齐
});
// 显示错误提示
// errorType: ['message', 'toast', 'border-bottom', 'none']
const showError = computed(() => (type: string) => {
// 如果errorType数组中含有none,或者toast提示类型
if (errorType.value.indexOf('none') >= 0) return false;
else if (errorType.value.indexOf(type) >= 0) return true;
else return false;
});
// 根据 size 定义不同的配置
const sizeConfig = {
small: {
fontSize: 24,
iconSize: 28,
messageSize: 20
},
default: {
fontSize: 28,
iconSize: 32,
messageSize: 24
},
large: {
fontSize: 32,
iconSize: 36,
messageSize: 28
}
};
// 获取实际使用的 size 值(优先级:u-textarea.size > u-form.size)
const actualSize = computed(() => {
// 优先使用 props 的 size 属性
if (props.size !== '') {
return String(props.size);
}
// 次优先:使用 u-form 的 size 属性(u-form 的 size 只支持预设值)
if (parentExposed.value?.props?.size) {
return String(parentExposed.value.props.size);
}
// 默认值
return 'default';
});
// 判断实际使用的 size 是否在预设配置中
const isInSizeConfig = computed(() => actualSize.value in sizeConfig);
// 获取预设 size(用于查找 sizeConfig 配置,如图标大小、高度等)
const presetSize = computed(() => {
return (isInSizeConfig.value ? actualSize.value : 'default') as SizeType;
});
// 获取当前尺寸配置
const currentSizeConfig = computed(() => sizeConfig[presetSize.value]);
// 获取实际要使用的 font-size(如果是预设值使用配置值,否则作为自定义值处理)
const actualFontSize = computed(() => {
if (isInSizeConfig.value) {
return $u.addUnit(currentSizeConfig.value.fontSize);
}
// 自定义size值,直接作为fontSize处理
return $u.addUnit(actualSize.value);
});
// 计算当前图标大小
const currentIconSize = computed(() => currentSizeConfig.value.iconSize);
// 监听校验状态和父表单 errorType 变化
watch(validateState, () => {
broadcastInputError();
});
// 监听u-form组件的errorType的变化
watch(
() => parentExposed?.value?.props?.errorType,
val => {
if (val) errorType.value = val;
broadcastInputError();
},
{ immediate: true }
);
// 计算属性
const uLabelWidth = computed(() => {
// 如果用户设置label为空字符串(微信小程序空字符串最终会变成字符串的'true'),意味着要将label的位置宽度设置为auto
return elLabelPosition.value == 'left'
? (props.label === 'true' || props.label === '') && !$slots.label
? 'auto'
: $u.addUnit(elLabelWidth.value)
: '100%';
});
// label的宽度
const elLabelWidth = computed(() => {
// label默认宽度为90,优先使用本组件的值,如果没有(如果设置为0,也算是配置了值,依然起效),则用u-form的值
return props.labelWidth != 0 && props.labelWidth !== ''
? props.labelWidth
: parentData.value.labelWidth
? parentData.value.labelWidth
: 90;
});
// label的样式
const elLabelStyle = computed(() => {
return Object.keys(props.labelStyle).length
? props.labelStyle
: parentData.value.labelStyle
? parentData.value.labelStyle
: {};
});
// label的位置,左侧或者上方
const elLabelPosition = computed(() => {
return props.labelPosition
? props.labelPosition
: parentData.value.labelPosition
? parentData.value.labelPosition
: 'left';
});
// label的对齐方式
const elLabelAlign = computed(() => {
return props.labelAlign ? props.labelAlign : parentData.value.labelAlign ? parentData.value.labelAlign : 'left';
});
// label的下划线
const elBorderBottom = computed(() => {
// 子组件的borderBottom默认为空字符串,如果不等于空字符串,意味着子组件设置了值,优先使用子组件的值
return props.borderBottom !== ''
? props.borderBottom
: parentData.value.borderBottom
? parentData.value.borderBottom
: false;
});
// 事件派发/广播工具
function broadcastInputError() {
// 子组件发出事件,参数为true或者false,true代表有错误
broadcast('onFormItemError', validateState.value === 'error' && showError.value('border'));
}
/**
* 根据路径获取对象的值(支持嵌套路径,如 'a.b.c')
* @param obj 对象
* @param path 路径字符串
*/
function getPropByPath(obj: any, path: string) {
if (!obj || !path) return { o: obj, k: '', v: undefined };
const paths = path.split('.');
let current = obj;
let key = '';
for (let i = 0; i < paths.length; i++) {
key = paths[i];
if (!current) break;
if (i === paths.length - 1) {
return { o: current, k: key, v: current[key] };
}
current = current[key];
}
return { o: current, k: key, v: undefined };
}
/**
* 获取当前u-form-item的校验规则
*/
function getRules() {
// 父组件的所有规则
let rules = parentExposed?.value?.rules?.value || {};
rules = rules
? rules[props.prop] || getPropByPath(rules, props.prop.replace(/(\.|^)(\d+)\./, '.defaultField.fields.')).v
: [];
// 保证返回的是一个数组形式
return [].concat(rules || []);
}
// blur事件时进行表单校验
function onFieldBlur() {
validation('blur');
}
// change事件进行表单校验
function onFieldChange() {
validation('change');
}
function onFormBlur() {
onFieldBlur();
}
function onFormChange() {
onFieldChange();
}
/**
* 过滤出符合要求的rule规则
* @param triggerType 触发类型
*/
function getFilteredRule(triggerType = '') {
// 获取所有规则
const rules = getRules();
// 整体验证表单时,triggerType为空字符串,此时返回所有规则进行验证
if (!triggerType) return rules;
// 历遍判断规则是否有对应的事件,比如blur,change触发等的事件
// 使用indexOf判断,是因为某些时候设置的验证规则的trigger属性可能为多个,比如['blur','change']
// 某些场景可能的判断规则,可能不存在trigger属性,故先判断是否存在此属性
return rules.filter((res: any) => res.trigger && res.trigger.indexOf(triggerType) !== -1);
}
/**
* 校验数据
* @param trigger 触发类型
* @param callback 校验回调
*/
function validation(trigger: string, callback: (msg: string) => void = () => {}) {
// 检验之前,先获取需要校验的值(支持嵌套路径)
const propPath = getPropByPath(parentExposed?.value?.model?.value, props.prop);
fieldValue.value = propPath.v;
// blur和change是否有当前方式的校验规则
let rules = getFilteredRule(trigger);
// 判断是否有验证规则,如果没有规则,也调用回调方法,否则父组件u-form会因为
// 对count变量的统计错误而无法进入上一层的回调
if (!rules || rules.length === 0) {
callback('');
return;
}
// 设置当前的状态,标识为校验中
validateState.value = 'validating';
// 调用async-validator的方法
let validator = new schema({ [props.prop]: rules });
validator.validate({ [props.prop]: fieldValue.value }, { firstFields: true }, (errors: any) => {
// 记录状态和报错信息
validateState.value = !errors ? 'success' : 'error';
validateMessage.value = errors ? errors[0].message : '';
// 调用回调方法
callback(validateMessage.value);
});
}
/**
* 清空当前的u-form-item
*/
function resetField() {
if (parentExposed?.value?.model?.value && props.prop) {
// 支持嵌套路径的重置
const propPath = getPropByPath(parentExposed.value.model.value, props.prop);
if (propPath.o && propPath.k) {
propPath.o[propPath.k] = initialValue.value;
}
}
// 设置为`success`状态,只是为了清空错误标记
// 延时50毫秒,如果立即清空状态,则无法清空错误标记
setTimeout(() => {
validateState.value = 'success';
}, 50);
}
// 组件挂载时注册到父表单
onMounted(() => {
nextTick(() => {
if (parentExposed.value) {
// 继承父表单配置
// 历遍parentData中的属性,将parent中的同名属性赋值给parentData
Object.keys(parentData.value).forEach(key => {
(parentData.value as any)[key] = parentExposed?.value?.props[key];
});
// 如果没有传入prop,或者uForm为空(如果u-form-input单独使用,就不会有uForm注入),就不进行校验
if (props.prop) {
// 将本实例添加到父组件中
parentExposed?.value?.addField &&
parentExposed?.value?.addField({
validation,
resetField,
prop: props.prop
});
errorType.value = parentExposed?.value?.errorType || errorType.value;
// 设置初始值(支持嵌套路径)
const propPath = getPropByPath(parentExposed?.value?.model?.value, props.prop);
fieldValue.value = propPath.v;
// 设置初始值
initialValue.value = fieldValue.value;
}
}
});
});
// 组件销毁前,将实例从u-form的缓存中移除
onBeforeUnmount(() => {
// 如果当前没有prop的话表示当前不要进行删除(因为没有注入)
if (parentExposed?.value && props.prop) {
parentExposed?.value?.removeField && parentExposed?.value?.removeField({ prop: props.prop });
}
});
defineExpose({
validation,
resetField,
onFormBlur,
onFormChange
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-form-item {
@include vue-flex;
// align-items: flex-start;
padding: 20rpx 0;
font-size: 28rpx;
color: $u-main-color;
box-sizing: border-box;
// line-height: 70rpx;
flex-direction: column;
&__border-bottom--error:after {
border-color: $u-type-error;
}
&__border--error {
:deep(.u-input--border) {
border-color: $u-type-error !important;
}
}
&__body {
@include vue-flex;
}
&--left {
@include vue-flex;
align-items: center;
&__content {
position: relative;
@include vue-flex;
align-items: center;
padding-right: 10rpx;
flex: 1;
&__icon {
margin-right: 8rpx;
@include vue-flex;
align-items: center;
}
&--required {
position: absolute;
left: -16rpx;
vertical-align: middle;
color: $u-type-error;
padding-top: 6rpx;
}
&__label {
@include vue-flex;
align-items: center;
flex: 1;
}
}
}
&--right {
flex: 1;
&__content {
@include vue-flex;
align-items: center;
flex: 1;
&__slot {
flex: 1;
@include vue-flex;
align-items: center;
}
&__icon {
margin-left: 10rpx;
color: $u-light-color;
font-size: 30rpx;
@include vue-flex;
align-items: center;
}
}
}
&__message {
font-size: 24rpx;
line-height: 24rpx;
color: $u-type-error;
margin-top: 12rpx;
}
}
</style>
@@ -0,0 +1,39 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { FormRules, InputAlign, FormErrorType, InputLabelPosition, SizeType } from '../../types/global';
/**
* u-form 组件 Props 类型定义
* @description 表单组件属性
*/
export const FormProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 当前form的需要验证字段的集合 */
model: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 表单验证规则 */
rules: { type: Object as PropType<FormRules>, default: () => ({}) },
/** 有错误时的提示方式,message-提示信息,border-如果input设置了边框,变成呈红色,border-bottom-下边框呈现红色,none-无提示 */
errorType: { type: Array as PropType<FormErrorType[]>, default: () => ['message', 'toast'] },
/** 是否显示表单域的下划线边框 */
borderBottom: { type: Boolean, default: true },
/** label的位置,left-左边,top-上边 */
labelPosition: { type: String as PropType<InputLabelPosition>, default: 'left' },
/** label的宽度,单位rpx */
labelWidth: { type: [String, Number] as PropType<string | number>, default: 90 },
/** label字体的对齐方式 */
labelAlign: { type: String as PropType<InputAlign>, default: 'left' },
/** label的样式,对象形式 */
labelStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 表单内组件的大小,仅支持预设值 default/small/large(默认 default) */
size: { type: String as PropType<SizeType>, default: 'default' }
};
export type FormProps = ExtractPropTypes<typeof FormProps>;
@@ -0,0 +1,135 @@
<template>
<view class="u-form" :class="customClass" :style="$u.toStyle(customStyle)">
<slot />
</view>
</template>
<script lang="ts">
export default {
name: 'u-form',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { FormProps } from './types';
import { computed, ref } from 'vue';
import { $u, useParent } from '../..';
/**
* form 表单
* @description 此组件一般用于表单场景,可以配置Input输入框,Select弹出框,进行表单验证等。
* @tutorial https://uviewpro.cn/zh/components/form.html
* @property {Object} model 表单数据对象
* @property {Boolean} border-bottom 是否显示表单域的下划线边框
* @property {String} label-position 表单域提示文字的位置,left-左侧,top-上方
* @property {String Number} label-width 提示文字的宽度,单位rpx(默认90)
* @property {Object} label-style label的样式,对象形式
* @property {String} label-align label的对齐方式
* @property {Object} rules 通过ref设置,见官网说明
* @property {Array} error-type 错误的提示方式,数组形式,见上方说明(默认['message'])
* @example <u-form :model="form" ref="uForm"></u-form>
*/
const props = defineProps(FormProps);
useParent('u-form');
interface ErrorItem {
prop: string;
message: string;
}
// 存储当前form下的所有u-form-item的实例
const fields = ref<any[]>([]);
// 校验规则
const rules = ref<Record<string, any>>(props.rules);
/**
* 设置校验规则
* @param newRules 校验规则对象
*/
function setRules(newRules: Record<string, any>) {
rules.value = newRules;
}
/**
* 清空所有u-form-item组件的内容,本质上是调用了u-form-item组件中的resetField()方法
*/
function resetFields() {
fields.value.forEach((field: any) => {
field.resetField && field.resetField();
});
}
/**
* 校验全部数据
* @param callback 校验回调
* @returns Promise<boolean>
*/
function validate(callback?: (valid: boolean, errorArr: ErrorItem[]) => void): Promise<boolean> {
return new Promise(resolve => {
// 对所有的u-form-item进行校验
let valid = true; // 默认通过
let count = 0; // 用于标记是否检查完毕
let errorArr: ErrorItem[] = []; // 存放错误信息
if (fields.value.length === 0) {
resolve(true);
if (typeof callback === 'function') callback(true, []);
return;
}
// 调用每一个u-form-item实例的validation的校验方法
fields.value.forEach((field: any) => {
// 如果任意一个u-form-item校验不通过,就意味着整个表单不通过
field.validation &&
field.validation('', (error: any) => {
if (error) {
valid = false;
errorArr.push({ prop: field.prop, message: error });
}
// 当历遍了所有的u-form-item时,调用promise的then方法
if (++count === fields.value.length) {
resolve(valid); // 进入promise的then方法
// 判断是否设置了toast的提示方式,只提示最前面的表单域的第一个错误信息
if (
props.errorType.indexOf('none') === -1 &&
props.errorType.indexOf('toast') >= 0 &&
errorArr.length > 0
) {
errorArr[0].message && $u.toast(errorArr[0].message);
}
// 调用回调方法
if (typeof callback === 'function') callback(valid, errorArr);
}
});
});
});
}
defineExpose({
setRules,
resetFields,
validate,
addField(field: any) {
if (!fields.value.includes(field)) fields.value.push(field);
},
removeField(field: any) {
fields.value = fields.value.filter(f => f.prop !== field.prop);
},
fields,
rules,
props,
model: computed(() => props.model)
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,26 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-full-screen 组件 Props 类型定义
* @description 用于APP弹窗遮盖导航栏和底部tabbar,提示新版本升级内容
*/
export const FullScreenProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否显示弹窗 */
show: { type: Boolean, default: false },
/** 弹窗标题 */
title: { type: String, default: '' },
/** 升级内容,支持富文本 */
content: { type: String, default: '' }
};
export type FullScreenProps = ExtractPropTypes<typeof FullScreenProps>;
@@ -0,0 +1,100 @@
<template>
<u-modal
v-model="show"
:class="customClass"
:style="$u.toStyle(customStyle)"
:show-cancel-button="true"
:confirm-text="t('uFullScreen.upgrade')"
:title="title || t('uFullScreen.title')"
@cancel="cancel"
@confirm="confirm"
>
<view class="u-update-content">
<rich-text :nodes="content"></rich-text>
</view>
</u-modal>
</template>
<script lang="ts">
export default {
name: 'u-full-screen',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { FullScreenProps } from './types';
import { ref, onMounted } from 'vue';
import { $u, useLocale } from '../../';
import uModal from '../u-modal/u-modal.vue';
/**
* 压窗屏升级弹窗组件
* @description 用于APP弹窗遮盖导航栏和底部tabbar,提示新版本升级内容
* @property {boolean} show 是否显示弹窗
* @property {string} content 升级内容,支持富文本
*/
defineProps(FullScreenProps);
/**
* 是否显示弹窗
*/
const show = ref(false);
/**
* 升级内容,支持富文本
*/
const { t } = useLocale();
/**
* 页面加载完成后自动显示弹窗
*/
onMounted(() => {
show.value = true;
});
/**
* 取消按钮点击事件
* @description 关闭弹窗并返回上一页
*/
function cancel() {
closeModal();
}
/**
* 升级按钮点击事件
* @description 关闭弹窗并返回上一页
*/
function confirm() {
closeModal();
}
/**
* 关闭弹窗方法
* @description 返回上一页
*/
function closeModal() {
uni.navigateBack();
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-full-content {
background-color: var(--u-type-success);
}
.u-update-content {
font-size: 26rpx;
color: $u-content-color;
line-height: 1.7;
padding: 30rpx;
}
</style>
@@ -0,0 +1,28 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-gap 组件 Props 类型定义
* @description 间隔槽组件属性
*/
export const GapProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 高度 */
height: { type: [String, Number] as PropType<string | number>, default: 30 },
/** 与上一个组件的距离 */
marginTop: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 与下一个组件的距离 */
marginBottom: { type: [String, Number] as PropType<string | number>, default: 0 }
};
export type GapProps = ExtractPropTypes<typeof GapProps>;
@@ -0,0 +1,50 @@
<template>
<view class="u-gap" :style="$u.toStyle(gapStyle, customStyle)"></view>
</template>
<script lang="ts">
export default {
name: 'u-gap',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { GapProps } from './types';
import { computed } from 'vue';
import { $u } from '../../';
/**
* gap 间隔槽
* @description 该组件一般用于内容块之间的用一个灰色块隔开的场景,方便用户风格统一,减少工作量
* @tutorial https://uviewpro.cn/zh/components/gap.html
* @property {String} bg-color 背景颜色(默认var(--u-bg-color))
* @property {String Number} height 分割槽高度,单位rpx(默认30)
* @property {String Number} margin-top 与前一个组件的距离,单位rpx(默认0)
* @property {String Number} margin-bottom 与后一个组件的距离,单位rpx(0)
* @example <u-gap height="80" bg-color="var(--u-light-color)"></u-gap>
*/
const props = defineProps(GapProps);
/**
* 间隔槽样式
*/
const gapStyle = computed(() => {
return {
backgroundColor: props.bgColor,
height: $u.addUnit(props.height),
marginTop: $u.addUnit(props.marginTop),
marginBottom: $u.addUnit(props.marginBottom)
};
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,24 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-grid-item 组件 Props 类型定义
* @description 宫格项组件属性
*/
export const GridItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 背景颜色 */
bgColor: { type: String, default: 'var(--u-bg-white)' },
/** 点击时返回的index */
index: { type: [Number, String] as PropType<string | number>, default: '' }
};
export type GridItemProps = ExtractPropTypes<typeof GridItemProps>;
@@ -0,0 +1,130 @@
<template>
<view
class="u-grid-item"
:class="customClass"
:hover-class="hoverClass"
:hover-stay-time="200"
@tap="click"
:style="
$u.toStyle(
{
background: bgColor,
width: width
},
customStyle
)
"
>
<view class="u-grid-item-box" :style="[customStyle]" :class="[border ? 'u-border-right u-border-bottom' : '']">
<slot />
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-grid-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { GridItemProps } from './types';
import { computed } from 'vue';
import { $u, useChildren } from '../..';
/**
* gridItem 宫格项
* @description 宫格组件一般用于同时展示多个同类项目的场景,可以给宫格的项目设置徽标组件(badge),或者图标等,也可以扩展为左右滑动的轮播形式。搭配u-grid使用
* @tutorial https://uviewpro.cn/zh/components/grid.html
* @property {String} bg-color 宫格的背景颜色(默认var(--u-bg-white))
* @property {String|Number} index 点击宫格时,返回的值
* @property {Object} custom-style 自定义样式,对象形式
* @event {Function} click 点击宫格触发
* @example <u-grid-item></u-grid-item>
*/
const props = defineProps(GridItemProps);
const { parentExposed } = useChildren('u-grid-item', 'u-grid');
const emit = defineEmits(['click']);
// 宫格按压时的样式类,"none"为无效果
const hoverClass = computed(() => {
return parentExposed?.value?.props?.hoverClass ?? '';
});
// 是否显示边框
const border = computed(() => {
return parentExposed?.value?.props?.border ?? true;
});
// 分成几列
const col = computed(() => {
return parentExposed?.value?.props?.col ?? 3;
});
// 计算每个grid-item的宽度
const width = computed(() => 100 / Number(col.value) + '%');
/**
* 点击宫格
*/
function click() {
emit('click', props.index);
parentExposed?.value?.click(props.index);
}
defineExpose({ click });
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-grid-item {
box-sizing: border-box;
background-color: var(--u-bg-white);
@include vue-flex;
align-items: center;
justify-content: center;
position: relative;
flex-direction: column;
/* #ifdef MP */
position: relative;
float: left;
/* #endif */
}
.u-grid-item-hover {
background: var(--u-bg-gray-light) !important;
}
.u-grid-marker-box {
position: absolute;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
line-height: 0;
}
.u-grid-marker-wrap {
position: absolute;
}
.u-grid-item-box {
padding: 30rpx 0;
@include vue-flex;
align-items: center;
justify-content: center;
flex-direction: column;
flex: 1;
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,29 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { InputAlign } from '../../types/global';
/**
* u-grid 组件 Props 类型定义
* @description 宫格组件属性
*/
export const GridProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 分成几列 */
col: { type: [Number, String] as PropType<string | number>, default: 3 },
/** 是否显示边框 */
border: { type: Boolean, default: true },
/** 宫格对齐方式,表现为数量少的时候,靠左,居中,还是靠右 */
align: { type: String as PropType<InputAlign>, default: 'left' },
/** 宫格按压时的样式类,"none"为无效果 */
hoverClass: { type: String, default: 'u-hover-class' }
};
export type GridProps = ExtractPropTypes<typeof GridProps>;
@@ -0,0 +1,91 @@
<template>
<view
class="u-grid"
:class="{ 'u-border-top u-border-left': border, customClass }"
:style="$u.toStyle(gridStyle, customStyle)"
>
<slot />
</view>
</template>
<script lang="ts">
export default {
name: 'u-grid',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { useParent, $u } from '../../';
import { GridProps } from './types';
import { computed } from 'vue';
/**
* grid 宫格布局
* @description 宫格组件一般用于同时展示多个同类项目的场景,可以给宫格的项目设置徽标组件(badge),或者图标等,也可以扩展为左右滑动的轮播形式。
* @tutorial https://uviewpro.cn/zh/components/grid.html
* @property {String|Number} col 宫格的列数(默认3)
* @property {Boolean} border 是否显示宫格的边框(默认true)
* @property {Boolean} hover-class 点击宫格的时候,是否显示按下的灰色背景(默认false)
* @event {Function} click 点击宫格触发
* @example <u-grid :col="3" @click="click"></u-grid>
*/
const props = defineProps(GridProps);
const emit = defineEmits(['click']);
useParent('u-grid');
// 宫格对齐方式
const gridStyle = computed(() => {
const style: Record<string, string> = {};
switch (props.align) {
case 'left':
style.justifyContent = 'flex-start';
break;
case 'center':
style.justifyContent = 'center';
break;
case 'right':
style.justifyContent = 'flex-end';
break;
default:
style.justifyContent = 'flex-start';
}
return style;
});
/**
* 点击宫格
* @param index 子项索引
*/
function click(index: number) {
emit('click', index);
}
defineExpose({ click, props });
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-grid {
width: 100%;
/* #ifdef MP */
position: relative;
box-sizing: border-box;
overflow: hidden;
/* #endif */
/* #ifndef MP */
@include vue-flex;
flex-wrap: wrap;
align-items: center;
/* #endif */
}
</style>
@@ -0,0 +1,69 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { IconLabelPosition, ImgMode } from '../../types/global';
/**
* u-icon 组件 Props 类型定义
*/
export const IconProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 图标名称,见示例图标集 */
name: { type: String, default: '' },
/** 图标颜色,可接受主题色 */
color: { type: String, default: '' },
/** 字体大小,单位rpx(默认32) */
size: { type: [Number, String] as PropType<string | number>, default: 'inherit' },
/** 是否显示粗体 */
bold: { type: Boolean, default: false },
/** 点击图标的时候传递事件出去的index(用于区分点击了哪一个) */
index: { type: [Number, String] as PropType<string | number>, default: '' },
/** 触摸图标时的类名 */
hoverClass: { type: String, default: '' },
/** 自定义扩展前缀,方便用户扩展自己的图标库 */
customPrefix: { type: String, default: 'uicon' },
/** 图标右边或者下面的文字 */
label: { type: [String, Number] as PropType<string | number>, default: '' },
/** label的位置,只能右边或者下边 */
labelPos: { type: String as PropType<IconLabelPosition>, default: 'right' },
/** label的大小,单位rpx(默认28) */
labelSize: { type: [String, Number] as PropType<string | number>, default: '28' },
/** label的颜色 */
labelColor: { type: String, default: 'var(--u-content-color)' },
/** label与图标的距离(横向排列),单位rpx(默认6) */
marginLeft: { type: [String, Number] as PropType<string | number>, default: '6' },
/** label与图标的距离(竖向排列),单位rpx(默认6) */
marginTop: { type: [String, Number] as PropType<string | number>, default: '6' },
/** label与图标的距离(竖向排列),单位rpx(默认6) */
marginRight: { type: [String, Number] as PropType<string | number>, default: '6' },
/** label与图标的距离(竖向排列),单位rpx(默认6) */
marginBottom: { type: [String, Number] as PropType<string | number>, default: '6' },
/** label与图标的距离,单位rpx,权重高于 margin */
space: { type: [String, Number] as PropType<string | number>, default: '' },
/** 图片的mode,参考uni-app image组件 */
imgMode: { type: String as PropType<ImgMode>, default: 'widthFix' },
/** 用于显示图片小图标时,图片的宽度,单位rpx */
width: { type: [String, Number] as PropType<string | number>, default: '' },
/** 用于显示图片小图标时,图片的高度,单位rpx */
height: { type: [String, Number] as PropType<string | number>, default: '' },
/** 用于解决某些情况下,让图标垂直居中的用途,单位rpx */
top: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 是否为DecimalIcon */
showDecimalIcon: { type: Boolean, default: false },
/** 背景颜色,可接受主题色,仅Decimal时有效 */
inactiveColor: { type: String, default: 'var(--u-divider-color)' },
/** 显示的百分比,仅Decimal时有效 */
percent: { type: [Number, String] as PropType<string | number>, default: '50' }
};
/**
* u-icon 组件 Props 类型
*/
export type IconProps = ExtractPropTypes<typeof IconProps>;
@@ -0,0 +1,301 @@
<template>
<view
:style="$u.toStyle(customStyle)"
class="u-icon"
@click="onClick"
:class="['u-icon--' + labelPos, customClass]"
>
<image class="u-icon__img" v-if="isImg" :src="props.name" :mode="imgMode" :style="[imgStyle]" />
<text
v-else
class="u-icon__icon"
:class="iconClass"
:style="$u.toStyle(iconStyle)"
:hover-class="hoverClass"
@touchstart="onTouchstart"
>
<text
v-if="showDecimalIcon"
:style="$u.toStyle(decimalIconStyle)"
:class="decimalIconClass"
:hover-class="hoverClass"
class="u-icon__decimal"
></text>
</text>
<text v-if="label !== ''" class="u-icon__label" :style="labelStyle">
{{ label }}
</text>
</view>
</template>
<script lang="ts">
export default {
name: 'u-icon',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u } from '../..';
import { IconProps } from './types';
/**
* icon 图标
* @description 基于字体的图标集,包含了大多数常见场景的图标。
* @tutorial https://uviewpro.cn/zh/components/icon.html
* @property {String} name 图标名称,见示例图标集
* @property {String} color 图标颜色(默认inherit)
* @property {String | Number} size 图标字体大小,单位rpx(默认32)
* @property {String | Number} label-size label字体大小,单位rpx(默认28)
* @property {String} label 图标右侧的label文字(默认28)
* @property {String} label-pos label文字相对于图标的位置,只能right或bottom(默认right)
* @property {String} label-color label字体颜色(默认var(--u-content-color))
* @property {Object} custom-style icon的样式,对象形式
* @property {String} custom-prefix 自定义字体图标库时,需要写上此值
* @property {String | Number} margin-left label在右侧时与图标的距离,单位rpx(默认6)
* @property {String | Number} margin-top label在下方时与图标的距离,单位rpx(默认6)
* @property {String | Number} margin-bottom label在上方时与图标的距离,单位rpx(默认6)
* @property {String | Number} margin-right label在左侧时与图标的距离,单位rpx(默认6)
* @property {String | Number} space label与图标的距离,单位rpx,权重高于 margin
* @property {String} index 一个用于区分多个图标的值,点击图标时通过click事件传出
* @property {String} hover-class 图标按下去的样式类,用法同uni的view组件的hover-class参数,详情见官网
* @property {String} width 显示图片小图标时的宽度
* @property {String} height 显示图片小图标时的高度
* @property {String | Number} top 图标在垂直方向上的定位
* @property {Boolean} show-decimal-icon 是否为DecimalIcon
* @property {String} inactive-color 背景颜色,可接受主题色,仅Decimal时有效
* @property {String | Number} percent 显示的百分比,仅Decimal时有效
* @event {Function} click 点击图标时触发
* @event {Function} touchstart 图标触摸时触发
* @example <u-icon name="photo" color="var(--u-type-primary)" size="28"></u-icon>
*/
const emit = defineEmits<{
(e: 'click', index: string | number): void;
(e: 'touchstart', index: string | number): void;
}>();
const props = defineProps(IconProps);
/**
* 计算图标的类名集合
* @returns {string[]}
*/
const iconClass = computed(() => {
let classes: string[] | string = [];
classes.push(props.customPrefix + '-' + props.name);
// uView的自定义图标类名为u-iconfont
if (props.customPrefix === 'uicon') {
classes.push('u-iconfont');
} else {
classes.push(props.customPrefix);
}
// 主题色,通过类配置
if (props.showDecimalIcon && props.inactiveColor && $u.config.type.includes(props.inactiveColor)) {
classes.push('u-icon__icon--' + props.inactiveColor);
} else if (props.color && $u.config.type.includes(props.color)) {
classes.push('u-icon__icon--' + props.color);
}
// 阿里,头条,百度小程序通过数组绑定类名时,无法直接使用[a, b, c]的形式,否则无法识别
// 故需将其拆成一个字符串的形式,通过空格隔开各个类名
// #ifdef MP-ALIPAY || MP-TOUTIAO || MP-BAIDU
classes = (classes as string[]).join(' ');
// #endif
return classes;
});
/**
* 计算图标的样式
* @returns {CSSProperties}
*/
const iconStyle = computed(() => {
const style: Record<string, any> = {
fontSize: props.size === 'inherit' ? 'inherit' : $u.addUnit(props.size),
fontWeight: props.bold ? 'bold' : 'normal',
// 某些特殊情况需要设置一个到顶部的距离,才能更好的垂直居中
top: $u.addUnit(props.top)
};
// 非主题色值时,才当作颜色值
if (props.showDecimalIcon && props.inactiveColor && !$u.config.type.includes(props.inactiveColor)) {
style.color = props.inactiveColor;
} else if (props.color && !$u.config.type.includes(props.color)) {
style.color = props.color;
}
return style;
});
/**
* 判断传入的name属性是否为图片路径
* @returns {boolean}
*/
const isImg = computed(() => {
return props.name.indexOf('/') !== -1;
});
/**
* 计算图片图标的样式
* @returns {any}
*/
const imgStyle = computed(() => {
// 如果设置width和height属性,则优先使用,否则使用size属性
const style: any = {
width: props.width ? $u.addUnit(props.width) : $u.addUnit(props.size),
height: props.height ? $u.addUnit(props.height) : $u.addUnit(props.size)
};
return style;
});
/**
* 计算小数图标的样式,仅DecimalIcon时有效
* @returns {CSSProperties}
*/
const decimalIconStyle = computed(() => {
const style: any = {
fontSize: props.size === 'inherit' ? 'inherit' : $u.addUnit(props.size),
fontWeight: props.bold ? 'bold' : 'normal',
// 某些特殊情况需要设置一个到顶部的距离,才能更好的垂直居中
top: $u.addUnit(props.top),
width: props.percent + '%'
};
// 非主题色值时,才当作颜色值
if (props.color && !$u.config.type.includes(props.color)) {
style.color = props.color;
}
return style;
});
/**
* 计算小数图标的类名,仅DecimalIcon时有效
* @returns {string | string[]}
*/
const decimalIconClass = computed(() => {
let classes: string[] | string = [];
classes.push(props.customPrefix + '-' + props.name);
// uView的自定义图标类名为u-iconfont
if (props.customPrefix === 'uicon') {
classes.push('u-iconfont');
} else {
classes.push(props.customPrefix);
}
if (props.color && $u.config.type.includes(props.color)) {
classes.push('u-icon__icon--' + props.color);
} else {
classes.push('u-icon__icon--primary');
}
// 阿里,头条,百度小程序通过数组绑定类名时,无法直接使用[a, b, c]的形式,否则无法识别
// 故需将其拆成一个字符串的形式,通过空格隔开各个类名
// #ifdef MP-ALIPAY || MP-TOUTIAO || MP-BAIDU
classes = (classes as string[]).join(' ');
// #endif
return classes;
});
/**
* 计算label的样式
* @returns {any}
*/
const labelStyle = computed(() => {
return {
color: props.labelColor,
fontSize: $u.addUnit(props.labelSize),
marginLeft: props.labelPos === 'right' ? $u.addUnit(props.space || props.marginLeft) : 0,
marginTop: props.labelPos === 'bottom' ? $u.addUnit(props.space || props.marginTop) : 0,
marginRight: props.labelPos === 'left' ? $u.addUnit(props.space || props.marginRight) : 0,
marginBottom: props.labelPos === 'top' ? $u.addUnit(props.space || props.marginBottom) : 0
};
});
/**
* 点击图标时触发
* @emits click(index)
*/
function onClick(event: any) {
emit('click', props.index || event);
}
/**
* 图标触摸时触发
* @emits touchstart(index)
*/
function onTouchstart(event: any) {
emit('touchstart', props.index || event);
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
@import '../../iconfont.css';
.u-icon {
display: inline-flex;
align-items: center;
&--left {
flex-direction: row-reverse;
align-items: center;
}
&--right {
flex-direction: row;
align-items: center;
}
&--top {
flex-direction: column-reverse;
justify-content: center;
}
&--bottom {
flex-direction: column;
justify-content: center;
}
&__icon {
position: relative;
&--primary {
color: $u-type-primary;
}
&--success {
color: $u-type-success;
}
&--error {
color: $u-type-error;
}
&--warning {
color: $u-type-warning;
}
&--info {
color: $u-type-info;
}
}
&__decimal {
position: absolute;
top: 0;
left: 0;
display: inline-block;
overflow: hidden;
}
&__img {
height: auto;
will-change: transform;
}
&__label {
line-height: 1;
}
}
</style>
@@ -0,0 +1,61 @@
import type { ComponentPublicInstance, ExtractPropTypes, PropType } from 'vue';
import type { ImgMode, Shape } from '../../types/global';
// 定义 ImageProps 的类型
export const ImageProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 图片地址 */
src: { type: String, default: '' },
/** 裁剪模式 */
mode: { type: String as PropType<ImgMode>, default: 'aspectFill' },
/** 宽度,单位任意,如果为数值,则为rpx单位(默认100%) */
width: { type: [String, Number], default: '100%' },
/** 高度,单位任意,如果为数值,则为rpx单位(默认 auto) */
height: { type: [String, Number], default: 'auto' },
/** 图片形状,circle-圆形,square-方形(默认square) */
shape: { type: String as PropType<Shape>, default: 'square' },
/** 圆角值,单位任意,如果为数值,则为rpx单位(默认 0) */
borderRadius: { type: [String, Number], default: 0 },
/** 是否懒加载,仅微信小程序、App、百度小程序、字节跳动小程序有效(默认 true) */
lazyLoad: { type: Boolean, default: true },
/** 是否开启长按图片显示识别小程序码菜单,仅微信小程序有效(默认 true) */
showMenuByLongpress: { type: Boolean, default: true },
/** 加载中的图标,或者小图片(默认 photo) */
loadingIcon: { type: String, default: 'photo' },
/** 加载失败的图标,或者小图片(默认 error-circle) */
errorIcon: { type: String, default: 'error-circle' },
/** 是否显示加载中的图标或者自定义的slot(默认 true) */
showLoading: { type: Boolean, default: true },
/** 是否显示加载错误的图标或者自定义的slot(默认 true) */
showError: { type: Boolean, default: true },
/** 是否需要淡入效果(默认 true) */
fade: { type: Boolean, default: true },
/** 只支持网络资源,只对微信小程序有效(默认 false) */
webp: { type: Boolean, default: false },
/** 搭配fade参数的过渡时间,单位ms(默认 500) */
duration: { type: [String, Number], default: 500 },
/** 背景颜色,用于深色页面加载图片时,为了和背景色融合(默认 var(--u-bg-color)) */
bgColor: { type: String, default: 'var(--u-bg-color)' },
/** 使用插槽名称对象,用于自定义插槽,默认 undefined,当动态切换slot隐藏时,需要使用useSlots使用,兼容头条小程序 */
useSlots: { type: Object as PropType<Record<string, boolean>>, default: undefined }
};
// 将 ImageProps 转换为类型
export type ImageProps = ExtractPropTypes<typeof ImageProps>;
// 暴露的组件实例方法
export type ImageExpose = {
changeStatus: (status: 'loading' | 'error' | 'normal') => void;
};
// 组件实例类型
export type ImageInstance = ComponentPublicInstance<ImageProps, ImageExpose>;
@@ -0,0 +1,245 @@
<template>
<view
class="u-image"
@tap="onClick"
:style="$u.toStyle(wrapStyle, backgroundStyle, customStyle)"
:class="customClass"
>
<image
v-if="!isError"
:src="src"
:mode="mode"
@error="onErrorHandler"
@load="onLoadHandler"
:lazy-load="lazyLoad"
class="u-image__image"
:show-menu-by-longpress="showMenuByLongpress"
:style="{ borderRadius: shape === 'circle' ? '50%' : $u.addUnit(borderRadius) }"
></image>
<view
v-if="showLoading && loading"
class="u-image__loading"
:style="{ borderRadius: shape === 'circle' ? '50%' : $u.addUnit(borderRadius), backgroundColor: bgColor }"
>
<slot v-if="hasSlot('loading')" name="loading" />
<u-icon v-else :name="loadingIcon" :width="width" :height="height"></u-icon>
</view>
<view
v-if="showError && isError && !loading"
class="u-image__error"
:style="{ borderRadius: shape === 'circle' ? '50%' : $u.addUnit(borderRadius) }"
>
<slot v-if="hasSlot('error')" name="error" />
<u-icon v-else :name="errorIcon" :width="width" :height="height"></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-image',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, useSlots } from 'vue';
import { type ImageExpose, ImageProps } from './types';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
/**
* Image 图片
* @description 此组件为uni-app的image组件的加强版,在继承了原有功能外,还支持淡入动画、加载中、加载失败提示、圆角值和形状等。
* @tutorial https://uviewpro.cn/zh/components/image.html
* @property {String} src 图片地址
* @property {String} mode 裁剪模式,见官网说明
* @property {String | Number} width 宽度,单位任意,如果为数值,则为rpx单位(默认100%)
* @property {String | Number} height 高度,单位任意,如果为数值,则为rpx单位(默认 auto)
* @property {String} shape 图片形状,circle-圆形,square-方形(默认square)
* @property {String | Number} border-radius 圆角值,单位任意,如果为数值,则为rpx单位(默认 0)
* @property {Boolean} lazy-load 是否懒加载,仅微信小程序、App、百度小程序、字节跳动小程序有效(默认 true)
* @property {Boolean} show-menu-by-longpress 是否开启长按图片显示识别小程序码菜单,仅微信小程序有效(默认 false)
* @property {String} loading-icon 加载中的图标,或者小图片(默认 photo)
* @property {String} error-icon 加载失败的图标,或者小图片(默认 error-circle)
* @property {Boolean} show-loading 是否显示加载中的图标或者自定义的slot(默认 true)
* @property {Boolean} show-error 是否显示加载错误的图标或者自定义的slot(默认 true)
* @property {Boolean} fade 是否需要淡入效果(默认 true)
* @property {Boolean} webp 只支持网络资源,只对微信小程序有效(默认 false)
* @property {String | Number} duration 搭配fade参数的过渡时间,单位ms(默认 500)
* @property {String} bg-color 背景颜色,用于深色页面加载图片时,为了和背景色融合(默认 var(--u-bg-color))
* @event {Function} click 点击图片时触发
* @event {Function} error 图片加载失败时触发
* @event {Function} load 图片加载成功时触发
* @example <u-image width="100%" height="300rpx" :src="src"></u-image>
*/
const emit = defineEmits<{
(e: 'click'): void;
(e: 'error', err: any): void;
(e: 'load'): void;
}>();
const props = defineProps(ImageProps);
// 图片是否加载错误,如果是,则显示错误占位图
const isError = ref(false);
// 初始化组件时,默认为加载中状态
const loading = ref(true);
// 不透明度,为了实现淡入淡出的效果
const opacity = ref(1);
// 过渡时间,因为props的值无法修改,故需要一个中间值
const durationTime = ref(props.duration);
// 图片加载完成时,去掉背景颜色,因为如果是png图片,就会显示灰色的背景
const backgroundStyle = ref<Record<string, any>>({});
// 监听src变化,处理加载状态
watch(
() => props.src,
n => {
if (!n) {
// 如果传入null或者'',或者false,或者undefined,标记为错误状态
isError.value = true;
loading.value = false;
} else {
isError.value = false;
loading.value = true;
}
},
{ immediate: true }
);
/**
* 计算图片外层包裹样式
* @returns {Record<string, any>}
*/
const wrapStyle = computed(() => {
let style: Record<string, any> = {};
// 通过调用addUnit()方法,如果有单位,如百分比,px单位等,直接返回,如果是纯粹的数值,则加上rpx单位
style.width = $u.addUnit(props.width);
style.height = $u.addUnit(props.height);
// 如果是配置了圆形,设置50%的圆角,否则按照默认的配置值
style.borderRadius = props.shape === 'circle' ? '50%' : $u.addUnit(props.borderRadius);
// 如果设置圆角,必须要有hidden,否则可能圆角无效
style.overflow = Number(props.borderRadius) > 0 ? 'hidden' : 'visible';
if (props.fade) {
style.opacity = opacity.value;
style.transition = `opacity ${Number(durationTime.value) / 1000}s ease-in-out`;
}
return style;
});
/**
* 点击图片
* @emits click
*/
function onClick() {
emit('click');
}
/**
* 图片加载失败
* @param err 失败事件对象
* @emits error
*/
function onErrorHandler(err: any) {
loading.value = false;
isError.value = true;
emit('error', err);
}
/**
* 图片加载完成,标记loading结束
* @emits load
*/
function onLoadHandler() {
loading.value = false;
isError.value = false;
emit('load');
// 如果不需要动画效果,就不执行下方代码,同时移除加载时的背景颜色
// 否则无需fade效果时,png图片依然能看到下方的背景色
if (!props.fade) return removeBgColor();
// 原来opacity为1(不透明,是为了显示占位图),改成0(透明,意味着该元素显示的是背景颜色,默认的灰色),再改成1,是为了获得过渡效果
opacity.value = 0;
// 这里设置为0,是为了图片展示到背景全透明这个过程时间为0,延时之后延时之后重新设置为duration,是为了获得背景透明(灰色)
// 到图片展示的过程中的淡入效果
durationTime.value = 0;
// 延时50ms,否则在浏览器H5,过渡效果无效
setTimeout(() => {
durationTime.value = props.duration;
opacity.value = 1;
setTimeout(() => {
removeBgColor();
}, Number(durationTime.value));
}, 50);
}
/**
* 移除图片的背景色
* 淡入动画过渡完成后,将背景设置为透明色,否则png图片会看到灰色的背景
*/
function removeBgColor() {
backgroundStyle.value = {
backgroundColor: 'transparent'
};
}
function changeStatus(status: 'loading' | 'error' | 'normal') {
if (status === 'loading') {
loading.value = true;
isError.value = false;
} else if (status === 'error') {
loading.value = false;
isError.value = true;
} else {
loading.value = false;
isError.value = false;
}
}
// 暴露给模板
const $slots = useSlots();
function hasSlot(name: string) {
return props.useSlots ? !!$slots[name] && props.useSlots[name] : !!$slots[name];
}
defineExpose<ImageExpose>({
changeStatus
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-image {
position: relative;
transition: opacity 0.5s ease-in-out;
&__image {
width: 100%;
height: 100%;
}
&__loading,
&__error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
@include vue-flex;
align-items: center;
justify-content: center;
background-color: $u-bg-color;
color: $u-tips-color;
font-size: 46rpx;
}
}
</style>
@@ -0,0 +1,24 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-index-anchor 组件 Props 类型定义
* @description 索引锚点属性
*/
export const IndexAnchorProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否使用自定义内容的插槽 */
useSlot: { type: Boolean, default: false },
/** 索引字符,如果定义了use-slot,此参数自动失效 */
index: { type: String, default: '' }
};
export type IndexAnchorProps = ExtractPropTypes<typeof IndexAnchorProps>;
@@ -0,0 +1,120 @@
<template>
<!-- 支付宝小程序使用$u.getRect()获取组件的根元素尺寸,所以在外面套一个"壳" -->
<view>
<view class="u-index-anchor-wrapper" :id="$u.guid()" :style="wrapperStyle">
<view
class="u-index-anchor"
:class="[active ? 'u-index-anchor--active' : '', customClass]"
:style="$u.toStyle(anchorStyle, customStyle)"
>
<slot v-if="useSlot" />
<template v-else>
<text>{{ index }}</text>
</template>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-index-anchor',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { IndexAnchorProps } from './types';
import { ref, onMounted } from 'vue';
import { $u, useChildren } from '../..';
/**
* indexAnchor 索引列表锚点
* @description 通过折叠面板收纳内容区域,搭配<u-index-anchor>使用
* @tutorial https://uviewpro.cn/zh/components/indexList.html#indexanchor-props
* @property {Boolean} use-slot 是否使用自定义内容的插槽(默认false)
* @property {String|Number} index 索引字符,如果定义了use-slot,此参数自动失效
* @property {Object} customStyle 自定义样式,对象形式,如"{color: 'red'}"
* @event {Function} default 锚点位置显示内容,默认为索引字符
* @example <u-index-anchor :index="item" />
*/
const props = defineProps(IndexAnchorProps);
const { parentExposed } = useChildren('u-index-anchor', 'u-index-list');
// 响应式变量
const active = ref(false);
const wrapperStyle = ref<Record<string, any>>({});
const anchorStyle = ref<Record<string, any>>({});
const top = ref(0);
const height = ref(0);
// 挂载时查找父组件并注册
onMounted(() => {
if (parentExposed) {
parentExposed?.value?.updateData();
}
});
function setTop(val: any) {
top.value = val;
}
function setHeight(val: any) {
height.value = val;
}
function setActive(val: any) {
active.value = val;
}
function setAnchorStyle(val: any) {
anchorStyle.value = val;
}
function setWrapperStyle(val: any) {
wrapperStyle.value = val;
}
defineExpose({
props,
top,
height,
active,
wrapperStyle,
anchorStyle,
setTop,
setHeight,
setActive,
setAnchorStyle,
setWrapperStyle
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-index-anchor {
box-sizing: border-box;
padding: 14rpx 24rpx;
color: var(--u-content-color);
width: 100%;
font-weight: 500;
font-size: 28rpx;
line-height: 1.2;
background-color: $u-bg-color;
}
.u-index-anchor--active {
right: 0;
left: 0;
color: $u-type-primary;
background-color: var(--u-bg-white);
}
</style>
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
/**
* u-index-list 组件 props 类型定义
* @description 通过折叠面板收纳内容区域,搭配<u-index-anchor>使用
*/
export const IndexListProps = {
/** 是否开启锚点自动吸顶 */
sticky: {
type: Boolean,
default: true
},
/** 锚点吸顶时的层级 */
zIndex: {
type: [Number, String] as PropType<number | string>,
default: ''
},
/** 当前滚动高度 */
scrollTop: {
type: [Number, String] as PropType<number | string>,
default: 0
},
/** 锚点自动吸顶时与顶部的距离 */
offsetTop: {
type: [Number, String] as PropType<number | string>,
default: 0
},
/** 索引字符列表 */
indexList: {
type: Array as PropType<string[]>,
default: undefined // 组件内用 getIndexList() 兜底
},
/** 锚点和右边索引字符高亮颜色 */
activeColor: {
type: String,
default: () => getColor('primary')
}
};
/**
* u-index-list 组件 props 类型
*/
export type IndexListProps = ExtractPropTypes<typeof IndexListProps>;
@@ -0,0 +1,346 @@
<template>
<!-- 支付宝小程序使用$u.getRect()获取组件的根元素尺寸,所以在外面套一个"壳" -->
<view>
<view class="u-index-bar">
<slot />
<view
v-if="showSidebar"
class="u-index-bar__sidebar"
@touchstart.stop.prevent="onTouchMove"
@touchmove.stop.prevent="onTouchMove"
@touchend.stop.prevent="onTouchStop"
@touchcancel.stop.prevent="onTouchStop"
>
<view
v-for="(item, index) in indexList"
:key="index"
class="u-index-bar__index"
:style="{ zIndex: Number(zIndex) + 1, color: activeAnchorIndex === index ? activeColor : '' }"
:data-index="index"
>
{{ item }}
</view>
</view>
<view
class="u-indexed-list-alert"
v-if="touchmove && indexList[touchmoveIndex]"
:style="{
zIndex: alertZIndex
}"
>
<text>{{ indexList[touchmoveIndex] }}</text>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-index-list',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted, getCurrentInstance } from 'vue';
import { $u, useParent } from '../..';
import { IndexListProps } from './types';
/**
* indexList 索引列表
* @description 通过折叠面板收纳内容区域,搭配<u-index-anchor>使用
* @tutorial https://uviewpro.cn/zh/components/indexList.html#indexanchor-props
* @property {Number|String} scroll-top 当前滚动高度,自定义组件无法获得滚动条事件,所以依赖接入方传入
* @property {Array} index-list 索引字符列表,数组(默认A-Z)
* @property {Number|String} z-index 锚点吸顶时的层级(默认965)
* @property {Boolean} sticky 是否开启锚点自动吸顶(默认true)
* @property {Number|String} offset-top 锚点自动吸顶时与顶部的距离(默认0)
* @property {String} highlight-color 锚点和右边索引字符高亮颜色(默认主题色primary)
* @event {Function} select 选中右边索引字符时触发
* @example <u-index-list :scrollTop="scrollTop"></u-index-list>
*/
const props = defineProps(IndexListProps);
const emit = defineEmits(['select']);
const instance = getCurrentInstance();
// 索引列表生成函数
function getIndexList() {
const indexList: string[] = [];
const charCodeOfA = 'A'.charCodeAt(0);
for (let i = 0; i < 26; i++) {
indexList.push(String.fromCharCode(charCodeOfA + i));
}
return indexList;
}
// 变量定义
const activeAnchorIndex = ref(0);
const showSidebar = ref(true);
const touchmove = ref(false);
const touchmoveIndex = ref(0);
const sidebar = reactive<{ height: number; top: number }>({ height: 0, top: 0 });
const scrollToAnchorIndex = ref<number | null>(null);
const timer = ref<any>(null);
const top = ref(0);
const height = ref(0);
const stickyOffsetTop = ref(0);
// 计算属性
// 弹出toast的z-index值
const alertZIndex = computed(() => $u.zIndex.toast).value;
// indexList 响应式
const indexList = computed(() => props?.indexList ?? getIndexList());
const zIndex = computed(() => props.zIndex).value;
const activeColor = computed(() => props.activeColor).value;
const { children, broadcast } = useParent('u-index-anchor');
// 兼容 H5/非H5 stickyOffsetTop
onMounted(() => {
const offsetTopNum = typeof props.offsetTop === 'string' ? Number(props.offsetTop) : props.offsetTop;
// #ifdef H5
stickyOffsetTop.value = offsetTopNum ? uni.upx2px(offsetTopNum) : 44;
// #endif
// #ifndef H5
stickyOffsetTop.value = offsetTopNum ? uni.upx2px(offsetTopNum) : 0;
// #endif
});
// 监听 scrollTop
watch(
() => props.scrollTop,
() => {
updateData();
}
);
function updateData() {
if (timer.value) clearTimeout(timer.value);
timer.value = setTimeout(() => {
showSidebar.value = !!children.length;
setRect().then(() => {
onScroll();
});
}, 0);
}
/**
* 获取各区域尺寸
*/
function setRect() {
return Promise.all([setAnchorsRect(), setListRect(), setSiderbarRect()]);
}
/**
* 获取锚点尺寸
*/
function setAnchorsRect() {
return Promise.all(
children.map((anchor, index) => {
$u.getRect('.u-index-anchor-wrapper', anchor.getInstance()).then((rect: any) => {
broadcast('setTop', rect.top, anchor.id);
broadcast('setHeight', rect.height, anchor.id);
});
})
);
}
/**
* 获取列表尺寸
*/
function setListRect() {
return $u.getRect('.u-index-bar', instance).then((rect: any) => {
height.value = rect.height;
top.value = rect.top + Number(props.scrollTop);
});
}
/**
* 获取侧边栏尺寸
*/
function setSiderbarRect() {
return $u.getRect('.u-index-bar__sidebar', instance).then((rect: any) => {
sidebar.height = rect.height;
sidebar.top = rect.top;
});
}
/**
* 获取当前激活锚点索引
*/
function getActiveAnchorIndex() {
const sticky = props.sticky;
for (let i = children.length - 1; i >= 0; i--) {
const preAnchorHeight = i > 0 ? children[i - 1].getExposed().height.value : 0;
const reachTop = sticky ? preAnchorHeight : 0;
if (reachTop >= children[i].getExposed().top.value) {
return i;
}
}
return -1;
}
/**
* 滚动时处理锚点吸顶和样式
*/
function onScroll() {
if (!children.length) return;
const sticky = props.sticky;
const scrollTopNum = Number(props.scrollTop);
const active = getActiveAnchorIndex();
activeAnchorIndex.value = active;
if (sticky) {
let isActiveAnchorSticky = false;
if (active !== -1) {
isActiveAnchorSticky = children[active].getExposed().top.value <= 0;
}
children.forEach((item, index) => {
if (index === active) {
let wrapperStyle: any = '';
let anchorStyle: any = {
color: `${activeColor}`
};
if (isActiveAnchorSticky) {
wrapperStyle = { height: `${children[index].getExposed().height.value}px` };
anchorStyle = {
position: 'fixed',
top: `${stickyOffsetTop.value}px`,
zIndex: `${zIndex ? zIndex : $u.zIndex.indexListSticky}`,
color: `${activeColor}`
};
}
broadcast('setActive', active, item.id);
broadcast('setAnchorStyle', anchorStyle, item.id);
broadcast('setWrapperStyle', wrapperStyle, item.id);
} else if (index === active - 1) {
const currentAnchor = children[index];
const currentOffsetTop = currentAnchor.getExposed().top.value;
const targetOffsetTop =
index === children.length - 1 ? top.value : children[index + 1].getExposed().top.value;
const parentOffsetHeight = targetOffsetTop - currentOffsetTop;
const translateY = parentOffsetHeight - currentAnchor.getExposed().height.value;
const anchorStyle = {
position: 'relative',
transform: `translate3d(0, ${translateY}px, 0)`,
zIndex: `${zIndex ? zIndex : $u.zIndex.indexListSticky}`,
color: `${activeColor}`
};
broadcast('setActive', active, currentAnchor.id);
broadcast('setAnchorStyle', anchorStyle, currentAnchor.id);
broadcast('setWrapperStyle', '', item.id);
} else {
broadcast('setActive', false, item.id);
broadcast('setAnchorStyle', '', item.id);
broadcast('setWrapperStyle', '', item.id);
}
});
}
}
/**
* 侧边栏触摸移动
*/
function onTouchMove(event: TouchEvent) {
touchmove.value = true;
const sidebarLength = indexList.value.length;
const touch = event.touches[0];
const itemHeight = sidebar.height / sidebarLength;
let clientY = touch.clientY;
let index = Math.floor((clientY - sidebar.top) / itemHeight);
if (index < 0) {
index = 0;
} else if (index > sidebarLength - 1) {
index = sidebarLength - 1;
}
touchmoveIndex.value = index;
scrollToAnchor(index);
}
/**
* 侧边栏触摸结束
*/
function onTouchStop() {
touchmove.value = false;
scrollToAnchorIndex.value = null;
}
/**
* 滚动到指定锚点
*/
function scrollToAnchor(index: number) {
if (scrollToAnchorIndex.value === index) {
return;
}
scrollToAnchorIndex.value = index;
const anchor = children.find(item => item.getExposed().props.index === indexList.value[index]);
if (anchor) {
emit('select', anchor.getExposed().props.index);
uni.pageScrollTo({
duration: 0,
scrollTop: anchor.getExposed().top.value + Number(props.scrollTop)
});
}
}
defineExpose({
updateData,
setRect,
onScroll,
children
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-index-bar {
position: relative;
}
.u-index-bar__sidebar {
position: fixed;
top: 50%;
right: 0;
@include vue-flex;
flex-direction: column;
text-align: center;
transform: translateY(-50%);
user-select: none;
z-index: 99;
}
.u-index-bar__index {
font-weight: 500;
padding: 8rpx 18rpx;
font-size: 22rpx;
line-height: 1;
}
.u-indexed-list-alert {
position: fixed;
width: 120rpx;
height: 120rpx;
right: 90rpx;
top: 50%;
margin-top: -60rpx;
border-radius: 24rpx;
font-size: 50rpx;
color: var(--u-white-color);
background-color: rgba(0, 0, 0, 0.65);
@include vue-flex;
justify-content: center;
align-items: center;
padding: 0;
z-index: 9999999;
}
.u-indexed-list-alert text {
line-height: 50rpx;
}
</style>
@@ -0,0 +1,170 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { InputAlign, InputConfirmType, InputType, SizeType } from '../../types/global';
/**
* u-input 组件 props 类型定义
* @description 此组件为一个输入框,默认没有边框和样式,是专门为配合表单组件u-form而设计的,利用它可以快速实现表单验证,输入内容,下拉选择等功能。
*/
export const InputProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 用于双向绑定输入框的值 */
modelValue: {
type: [String, Number] as PropType<string | number>,
default: ''
},
/** 输入框的类型,textarea,text,number */
type: {
type: String as PropType<InputType>,
default: 'text'
},
/** 输入框文字的对齐方式(默认left) */
inputAlign: {
type: String as PropType<InputAlign>,
default: 'left'
},
/** 输入框文字的大小(默认default),支持 small/default/large 预设值,也支持 16/16px/16rpx 等自定义值 */
size: {
type: [String, Number] as PropType<SizeType | string | number>,
default: ''
},
/** placeholder显示值(默认 '请输入内容') */
placeholder: {
type: String,
default: ''
},
/** 是否禁用输入框(默认false) */
disabled: {
type: Boolean,
default: false
},
/** 是否只读,禁止输入但可点击,样式不变,可触发click事件(默认false) */
readonly: {
type: Boolean,
default: false
},
/** 原生输入模式,PDA 模式可设置为 none 以隐藏软键盘 */
inputMode: {
type: String,
default: ''
},
/** 输入框的最大可输入长度(默认140) */
maxlength: {
type: [Number, String] as PropType<number | string>,
default: 140
},
// 是否显示统计字数,仅textarea有效
count: {
type: Boolean,
default: false
},
/** placeholder的样式,字符串形式,如"color: red;"(默认 "color: var(--u-light-color);") */
placeholderStyle: {
type: String,
default: 'color: var(--u-light-color);'
},
/** 设置键盘右下角按钮的文字,仅在type为text时生效(默认done) */
confirmType: {
type: String as PropType<InputConfirmType>,
default: 'done'
},
/** 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true */
fixed: {
type: Boolean,
default: false
},
/** 是否自动获得焦点(默认false) */
focus: {
type: Boolean,
default: false
},
/** 密码类型时,是否显示右侧的密码图标(默认true) */
passwordIcon: {
type: Boolean,
default: true
},
/** input|textarea是否显示边框(默认false) */
border: {
type: Boolean,
default: false
},
/** 输入框的边框颜色(默认var(--u-border-color)) */
borderColor: {
type: String,
default: 'var(--u-border-color)'
},
/** 是否自动增高输入区域,type为textarea时有效(默认true) */
autoHeight: {
type: Boolean,
default: true
},
/** type=select时,旋转右侧的图标,标识当前处于打开还是关闭select的状态 */
selectOpen: {
type: Boolean,
default: false
},
/** 高度,单位rpx */
height: {
type: [Number, String] as PropType<number | string>,
default: ''
},
/** 是否可清空(默认true) */
clearable: {
type: Boolean,
default: true
},
/** 指定光标与键盘的距离,单位 px(默认0) */
cursorSpacing: {
type: [Number, String] as PropType<number | string>,
default: 0
},
/** 光标起始位置,自动聚焦时有效,需与selection-end搭配使用(默认-1) */
selectionStart: {
type: [Number, String] as PropType<number | string>,
default: -1
},
/** 光标结束位置,自动聚焦时有效,需与selection-start搭配使用(默认-1) */
selectionEnd: {
type: [Number, String] as PropType<number | string>,
default: -1
},
/** 是否自动去除两端的空格(默认true) */
trim: {
type: Boolean,
default: true
},
/** 是否显示键盘上方带有”完成“按钮那一栏(默认true) */
showConfirmbar: {
type: Boolean,
default: true
},
/** 弹出键盘时是否自动调节高度,uni-app默认值是true */
adjustPosition: {
type: Boolean,
default: true
},
/** 点击键盘右下角按钮时是否保持键盘不收起(默认false) */
confirmHold: {
type: Boolean,
default: false
},
/** 输入框的验证状态,用于错误时,边框是否改为红色 */
validateState: {
type: Boolean,
default: false
}
};
/**
* u-input 组件 props 类型
*/
export type InputProps = ExtractPropTypes<typeof InputProps>;
@@ -0,0 +1,444 @@
<template>
<view
class="u-input"
:class="[
{
'u-input--border': border,
'u-input--error': validateState,
'u-input--disabled': disabled
},
customClass
]"
:style="{
padding: type === 'textarea' ? (border ? '20rpx' : '0') : `0 ${border ? 20 : 0}rpx`,
borderColor: borderColor,
textAlign: inputAlign
}"
@tap.stop="inputClick"
>
<textarea
v-if="type == 'textarea'"
class="u-input__input u-input__textarea"
:style="getStyle"
:value="inputValue"
:placeholder="getPlaceholder"
:placeholderStyle="getPlaceholderStyle"
:disabled="disabled"
:readonly="readonly"
:inputmode="props.inputMode"
:maxlength="inputMaxlength"
:fixed="fixed"
:focus="focus"
:autoHeight="autoHeight"
:selection-end="Number(uSelectionEnd)"
:selection-start="Number(uSelectionStart)"
:cursor-spacing="getCursorSpacing"
:show-confirm-bar="showConfirmbar"
:adjust-position="adjustPosition"
:confirm-hold="confirmHold"
@input="handleInput"
@blur="handleBlur"
@focus="onFocus"
@confirm="onConfirm"
/>
<!-- prettier-ignore -->
<input
v-else
class="u-input__input"
:type="((type == 'password' ? 'text' : type) as any)"
:style="getStyle"
:value="inputValue"
:password="type == 'password' && !showPassword"
:placeholder="getPlaceholder"
:placeholderStyle="getPlaceholderStyle"
:disabled="disabled || type === 'select'"
:readonly="readonly"
:inputmode="props.inputMode"
:maxlength="inputMaxlength"
:focus="focus"
:confirmType="confirmType"
:cursor-spacing="getCursorSpacing"
:selection-end="Number(uSelectionEnd)"
:selection-start="Number(uSelectionStart)"
:show-confirm-bar="showConfirmbar"
:adjust-position="adjustPosition"
:confirm-hold="confirmHold"
@focus="onFocus"
@blur="handleBlur"
@input="handleInput"
@confirm="onConfirm"
/>
<!-- 透明遮罩,在readonly时显示,用于捕获点击事件(原生input设置disabled会阻止点击冒泡) -->
<view v-if="readonly || type === 'select'" class="u-input__readonly-overlay" @tap.stop="inputClick"></view>
<view class="u-input__right-icon u-flex">
<view
class="u-input__right-icon__clear u-input__right-icon__item"
v-if="clearable && inputValue !== '' && !disabled"
:style="{ marginLeft: $u.addUnit(iconSpacing) }"
:class="{ 'u-hidden': !focused && type !== 'select' }"
@click.stop="onClear"
>
<u-icon :size="currentIconSize" name="close-circle-fill" color="var(--u-light-color)" />
</view>
<view
class="u-input__right-icon__clear u-input__right-icon__item"
v-if="passwordIcon && type == 'password'"
:style="{ marginLeft: $u.addUnit(iconSpacing) }"
>
<u-icon
:size="currentIconSize"
:name="!showPassword ? 'eye' : 'eye-fill'"
color="var(--u-light-color)"
@click="showPassword = !showPassword"
/>
</view>
<view
class="u-input__right-icon--select u-input__right-icon__item"
v-if="type == 'select'"
:class="{
'u-input__right-icon--select--reverse': selectOpen
}"
:style="{ marginLeft: $u.addUnit(iconSpacing) }"
>
<u-icon name="arrow-down-fill" :size="selectIconSize" color="var(--u-light-color)"></u-icon>
</view>
</view>
<text
class="u-input__count"
:style="{
'background-color': props.disabled ? 'transparent' : 'var(--u-bg-white)'
}"
v-if="props.type === 'textarea' && props.count"
>
{{ inputValue.length }}/{{ props.maxlength }}
</text>
</view>
</template>
<script lang="ts">
export default {
name: 'u-input',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { $u, useChildren, useLocale } from '../..';
import { InputProps } from './types';
import type { SizeType } from '../../types/global';
import uIcon from '../u-icon/u-icon.vue';
const props = defineProps(InputProps);
const emit = defineEmits(['update:modelValue', 'input', 'blur', 'focus', 'confirm', 'click', 'clear']);
const { t } = useLocale();
const { emitToParent } = useChildren('u-input', 'u-form-item');
const { parentExposed } = useChildren('u-input', 'u-form');
const validateState = ref(props.validateState); // 当前input的验证状态,用于错误时,边框是否改为红色
const focused = ref(false); // 当前是否处于获得焦点的状态
const showPassword = ref(false); // 是否预览密码
const lastValue = ref(''); // 用于头条小程序,判断@input中,前后的值是否发生了变化
// 根据 size 定义不同的配置
const sizeConfig = {
small: {
inputHeight: 56,
textareaHeight: 70,
fontSize: 24,
iconSize: 28,
iconSpacing: 6
},
default: {
inputHeight: 70,
textareaHeight: 100,
fontSize: 28,
iconSize: 32,
iconSpacing: 10
},
large: {
inputHeight: 84,
textareaHeight: 130,
fontSize: 32,
iconSize: 36,
iconSpacing: 14
}
};
// 获取实际使用的 size 值(优先级:props.size > u-form.size)
const actualSize = computed(() => {
// 优先使用 props 的 size 属性
if (props.size !== '') {
return String(props.size);
}
// 次优先:使用 u-form 的 size 属性(u-form 的 size 只支持预设值)
if (parentExposed.value?.props?.size) {
return String(parentExposed.value.props.size);
}
// 默认值
return 'default';
});
// 判断实际使用的 size 是否在预设配置中
const isInSizeConfig = computed(() => actualSize.value in sizeConfig);
// 获取预设 size(用于查找 sizeConfig 配置,如图标大小、高度等)
const presetSize = computed(() => {
return (isInSizeConfig.value ? actualSize.value : 'default') as SizeType;
});
// 获取当前尺寸配置
const currentSizeConfig = computed(() => sizeConfig[presetSize.value]);
// 获取实际要使用的 font-size(如果是预设值使用配置值,否则作为自定义值处理)
const actualFontSize = computed(() => {
if (isInSizeConfig.value) {
return $u.addUnit(currentSizeConfig.value.fontSize);
}
// 自定义size值,直接作为fontSize处理
return $u.addUnit(actualSize.value);
});
// 计算当前图标大小
const currentIconSize = computed(() => currentSizeConfig.value.iconSize);
// 计算 select 图标大小(比常规图标稍小)
const selectIconSize = computed(() => currentSizeConfig.value.iconSize - 4);
// 计算图标间距
const iconSpacing = computed(() => currentSizeConfig.value.iconSpacing);
const inputValue = computed<string>(() => {
if (props.modelValue === undefined || props.modelValue === null) return '';
return String(props.modelValue);
});
// 监听 value 变化
watch(
() => inputValue.value,
(nVal, oVal) => {
// 当值发生变化,且为select类型时(此时input被设置为disabled,不会触发@input事件),模拟触发@input事件
if (nVal != oVal && props.type == 'select') handleInput({ detail: { value: nVal } });
}
);
// 监听 validateState 变化
watch(
() => props.validateState,
val => {
validateState.value = val;
}
);
// 计算属性
const inputMaxlength = computed(() => Number(props.maxlength));
const getStyle = computed(() => {
let style: Record<string, any> = {};
// 如果没有自定义高度,就根据type为input还是textarea来分配一个默认的高度
style.minHeight = props.height
? $u.addUnit(props.height)
: props.type === 'textarea'
? $u.addUnit(currentSizeConfig.value.textareaHeight)
: $u.addUnit(currentSizeConfig.value.inputHeight);
// 根据 size 属性设置字体大小
style.fontSize = actualFontSize.value;
return $u.toStyle(style, props.customStyle);
});
const getPlaceholderStyle = computed(() => {
return $u.toStyle(
{
fontSize: actualFontSize.value
},
props.placeholderStyle
);
});
// 计算光标与键盘的距离
const getCursorSpacing = computed(() => Number(props.cursorSpacing));
// 光标起始位置
const uSelectionStart = computed(() => String(props.selectionStart));
// 光标结束位置
const uSelectionEnd = computed(() => String(props.selectionEnd));
// placeholder 国际化
const getPlaceholder = computed(() => props.placeholder || t('uInput.placeholder'));
/**
* change 事件
* @param event
*/
function handleInput(event: any) {
let value = event.detail.value;
// 判断是否去除空格
if (props.trim) value = $u.trim(value);
emit('update:modelValue', value);
emit('input', value);
// 过一个生命周期再发送事件给u-form-item,否则this.$emit('update:modelValue')更新了父组件的值,但是微信小程序上
// 尚未更新到u-form-item,导致获取的值为空,从而校验混论
// 这里不能延时时间太短,或者使用this.$nextTick,否则在头条上,会造成混乱
setTimeout(() => {
// 头条小程序由于自身bug,导致中文下,每按下一个键(尚未完成输入),都会触发一次@input,导致错误,这里进行判断处理
// #ifdef MP-TOUTIAO
if ($u.trim(value) == lastValue.value) return;
lastValue.value = value;
// #endif
// 通过 emitter 派发事件
emitToParent('onFormChange', value);
}, 40);
}
/**
* blur 事件
* @param event
*/
function handleBlur(event: any) {
// 最开始使用的是监听图标@touchstart事件,自从hx2.8.4后,此方法在微信小程序出错
// 这里改为监听点击事件,手点击清除图标时,同时也发生了@blur事件,导致图标消失而无法点击,这里做一个延时
setTimeout(() => {
focused.value = false;
}, 100);
setTimeout(() => {
let value = inputValue.value;
emit('blur', value);
// 头条小程序由于自身bug,导致中文下,每按下一个键(尚未完成输入),都会触发一次@input,导致错误,这里进行判断处理
// #ifdef MP-TOUTIAO
if ($u.trim(value) == lastValue.value) return;
lastValue.value = value;
// #endif
emitToParent('onFormBlur', value);
}, 40);
}
function onFormItemError(status: boolean) {
validateState.value = status;
}
function onFocus(e: any) {
focused.value = true;
emit('focus', e.detail.value);
}
function onConfirm(e: any) {
emit('confirm', e.detail.value);
}
function onClear(event: any) {
try {
event.stopPropagation();
} catch (e) {
console.log(e);
}
emit('clear');
handleInput({ detail: { value: '' } });
}
function inputClick() {
if (props.disabled) return;
emit('click');
}
defineExpose({
onFormItemError
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-input {
position: relative;
flex: 1;
@include vue-flex;
align-items: center;
&__readonly-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
}
&__input {
//height: 70rpx;
font-size: 28rpx;
color: $u-main-color;
flex: 1;
align-self: center;
line-height: normal;
}
&__textarea {
width: auto;
font-size: 28rpx;
color: $u-main-color;
// padding: 10rpx 0;
line-height: normal;
flex: 1;
}
&__count {
position: absolute;
right: 1px;
bottom: 0;
font-size: 12px;
color: $u-tips-color;
background-color: var(--u-bg-white);
padding: 1px 4px;
border-radius: 10px;
line-height: 16px;
z-index: 3;
}
&--border {
border-radius: 4px;
border: 1px solid $u-border-color;
}
&--error {
border-color: $u-type-error !important;
}
&--disabled {
background-color: $u-bg-gray-light;
}
&--disabled &__input {
background-color: transparent;
color: $u-light-color;
-webkit-text-fill-color: $u-light-color;
padding-left: 10rpx;
}
&--disabled &__textarea {
background-color: transparent;
color: $u-light-color;
-webkit-text-fill-color: $u-light-color;
padding-left: 10rpx;
padding-top: 10rpx;
}
&__right-icon {
position: relative;
z-index: 3;
&--select {
transition: transform 0.4s;
&--reverse {
transform: rotate(-180deg);
}
}
}
}
</style>
@@ -0,0 +1,40 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-keyboard 组件 Props 类型定义
* @description 自定义键盘面板属性
*/
export const KeyboardProps = {
/** 键盘的类型,number-数字键盘,card-身份证键盘,car-车牌号键盘 */
mode: { type: String, default: 'number' },
/** 是否显示键盘的"."符号 */
dotEnabled: { type: Boolean, default: true },
/** 是否显示顶部工具条 */
tooltip: { type: Boolean, default: true },
/** 是否显示工具条中间的提示 */
showTips: { type: Boolean, default: true },
/** 工具条中间的提示文字 */
tips: { type: String, default: '' },
/** 是否显示工具条左边的"取消"按钮 */
cancelBtn: { type: Boolean, default: true },
/** 是否显示工具条右边的"完成"按钮 */
confirmBtn: { type: Boolean, default: true },
/** 是否打乱键盘按键的顺序 */
random: { type: Boolean, default: false },
/** 是否开启底部安全区适配 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 是否允许通过点击遮罩关闭键盘 */
maskCloseAble: { type: Boolean, default: true },
/** 通过双向绑定控制键盘的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 是否显示遮罩 */
mask: { type: Boolean, default: true },
/** z-index值 */
zIndex: { type: [Number, String] as PropType<string | number>, default: '' },
/** 取消按钮的文字 */
cancelText: { type: String, default: '' },
/** 确认按钮的文字 */
confirmText: { type: String, default: '' }
};
export type KeyboardProps = ExtractPropTypes<typeof KeyboardProps>;
@@ -0,0 +1,201 @@
<template>
<u-popup
:mask="mask"
:maskCloseAble="maskCloseAble"
mode="bottom"
:popup="false"
v-model="popupValue"
length="auto"
:safeAreaInsetBottom="safeAreaInsetBottom"
@close="popupClose"
:zIndex="uZIndex"
>
<slot />
<view class="u-tooltip" v-if="tooltip">
<view class="u-tooltip-item u-tooltip-cancel" hover-class="u-tooltip-cancel-hover" @tap="onCancel">
{{ cancelBtn ? getCancelText : '' }}
</view>
<view v-if="showTips" class="u-tooltip-item u-tooltip-tips">
{{
tips
? tips
: mode == 'number'
? t('uKeyboard.number')
: mode == 'card'
? t('uKeyboard.idCard')
: t('uKeyboard.plate')
}}
</view>
<view
v-if="confirmBtn"
@tap="onConfirm"
class="u-tooltip-item u-tooltips-submit"
hover-class="u-tooltips-submit-hover"
>
{{ confirmBtn ? getConfirmText : '' }}
</view>
</view>
<block v-if="mode == 'number' || mode == 'card'">
<u-number-keyboard
:random="random"
@backspace="backspace"
@change="change"
:mode="mode"
:dotEnabled="dotEnabled"
></u-number-keyboard>
</block>
<block v-else>
<u-car-keyboard :random="random" @backspace="backspace" @change="change"></u-car-keyboard>
</block>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-keyboard',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { KeyboardProps } from './types';
import { computed } from 'vue';
import { $u, useLocale } from '../..';
import uCarKeyboard from '../u-car-keyboard/u-car-keyboard.vue';
import uNumberKeyboard from '../u-number-keyboard/u-number-keyboard.vue';
import uPopup from '../u-popup/u-popup.vue';
/**
* keyboard 键盘
* @description 此为uViw自定义的键盘面板,内含了数字键盘,车牌号键,身份证号键盘3中模式,都有可以打乱按键顺序的选项。
* @tutorial https://uviewpro.cn/zh/components/keyboard.html
* @property {String} mode 键盘类型,见官网基本使用的说明(默认number)
* @property {Boolean} dot-enabled 是否显示"."按键,只在mode=number时有效(默认true)
* @property {Boolean} tooltip 是否显示键盘顶部工具条(默认true)
* @property {String} tips 工具条中间的提示文字,见上方基本使用的说明,如不需要,请传""空字符
* @property {Boolean} cancel-btn 是否显示工具条左边的"取消"按钮(默认true)
* @property {Boolean} confirm-btn 是否显示工具条右边的"完成"按钮(默认true)
* @property {Boolean} mask 是否显示遮罩(默认true)
* @property {String} confirm-text 确认按钮的文字
* @property {String} cancel-text 取消按钮的文字
* @property {Number String} z-index 弹出键盘的z-index值(默认1075)
* @property {Boolean} random 是否打乱键盘按键的顺序(默认false)
* @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
* @property {Boolean} mask-close-able 是否允许点击遮罩收起键盘(默认true)
* @event {Function} change 按键被点击(不包含退格键被点击)
* @event {Function} cancel 键盘顶部工具条左边的"取消"按钮被点击
* @event {Function} confirm 键盘顶部工具条右边的"完成"按钮被点击
* @event {Function} backspace 键盘退格键被点击
* @example <u-keyboard mode="number" v-model="show"></u-keyboard>
*/
const props = defineProps(KeyboardProps);
const emit = defineEmits(['change', 'update:modelValue', 'confirm', 'cancel', 'backspace']);
const { t } = useLocale();
const uZIndex = computed(() => (props.zIndex ? props.zIndex : $u.zIndex.popup));
// 国际化计算属性
const getCancelText = computed(() => props.cancelText || t('uKeyboard.cancelText'));
const getConfirmText = computed(() => props.confirmText || t('uKeyboard.confirmText'));
const popupValue = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
});
/**
* 按键被点击(不包含退格键被点击)
*/
function change(e: any) {
emit('change', e);
}
/**
* 键盘关闭
*/
function popupClose() {
emit('update:modelValue', false);
}
/**
* 输入完成
*/
function onConfirm() {
popupClose();
emit('confirm');
}
/**
* 取消输入
*/
function onCancel() {
popupClose();
emit('cancel');
}
/**
* 退格键
*/
function backspace() {
emit('backspace');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-keyboard {
position: relative;
z-index: 1003;
}
.u-tooltip {
@include vue-flex;
justify-content: space-between;
}
.u-tooltip-item {
color: var(--u-main-color);
flex: 0 0 30%;
text-align: center;
padding: 20rpx 10rpx;
font-size: 28rpx;
}
.u-tooltip-tips {
flex-basis: 40%;
word-break: break-all;
}
.u-tooltips-submit {
text-align: right;
flex-grow: 1;
flex-wrap: 0;
padding-right: 40rpx;
color: $u-type-primary;
}
.u-tooltip-cancel {
text-align: left;
flex-grow: 1;
flex-wrap: 0;
padding-left: 40rpx;
color: var(--u-tips-color);
}
.u-tooltips-submit-hover {
color: $u-type-success;
}
.u-tooltip-cancel-hover {
color: var(--u-main-color);
}
</style>
@@ -0,0 +1,37 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Effect, ImgMode } from '../../types/global';
/**
* u-lazy-load 组件 Props 类型定义
* @description 懒加载图片组件属性
*/
export const LazyLoadProps = {
/** 用户自定义值,在事件触发时回调,用以区分是哪个图片 */
index: [Number, String],
/** 要显示的图片 */
image: { type: String, default: '' },
/** 图片裁剪模式 */
imgMode: { type: String as PropType<ImgMode>, default: 'widthFix' },
/** 占位图片路径 */
loadingImg: {
type: String,
default:
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAZAAAAGQCAMAAAC3Ycb+AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1zbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1zbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6OUM0QjNBQjkyQUQ2MTFFQTlCNUQ4RTIzNDE5RUIxNjciIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6OUM0QjNBQkEyQUQ2MTFFQTlCNUQ4RTIzNDE5RUIxNjciPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo5QzRCM0FCNzJBRDYxMUVBOUI1RDhFMjM0MTlFQjE2NyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo5QzRCM0FCODJBRDYxMUVBOUI1RDhFMjM0MTlFQjE2NyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PtRHfPcAAAAzUExURZWVldfX18PDw62trZubm9zc3Li4uKGhoebm5tLS0uHh4c3Nzaenp729vcjIyLKysuvr6141L40AAAcXSURBVHja7NzZlqpGAEBR5lG0//9rIw7IJKJi4or7PGTdtN10wr5SVAEGf/qqArsAiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAIiIAAERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAgAgJEQIAICBABERAg+nmQFMi5Jis+sIniED23jSzIgLTtg2D//iYme/8QBM/9lQ+CAEhbNLM3N9hEHAThX7GPCiBfAxK1b51kD+R7QMLjXg7iCsgWIPUh7pfVozG791oeBPngm48G583uW5GkBvI+SBaM2xXDn1oqum423bX/mgF5FySc2cv93Voug9TdZotsggnkBZB2NzbhrSY5HnoG07jei8dvzsJB/c3W60SALILE46+WCztsbhPR7R2VJq0ukEcT49nyy8QhaKcRa3fYHZD4+ufqOJAcgDz8/59vtw1I3QP5K6JsOG0vm3hce4I8LQp/BaRZGJC3AAn7IKOKXbC+7EdA5vdmmVwOLksgRThqOqiH4XEGsht+peoPUE8U/jJIO5OLH4GEwUslV5G0PTBG5Uiw/Y2jyigO3l9HAHKv9PYb82LloH74dZBoBUgar+l48NsNvtD0fkez9iwrAvIYZDRCl+Xs149Hm/KZmQ+QjUCiO1ei4ru7EsgnQYrkznlQb7thCuRfAzlOAPN72427P4VA/i2Q/DKT/Ls/VR8fvIBsDZIuz7TPF6TCbnk4GJkB2RokejTjuE7/unlgCuSTIO0Cy+Plp6vDfnQlBchy8QtjSHVd3EgmK1bHLm+H6+nXYbz2DuQRSPnqoL7vvq0u70on4zvxgCyWD3b9UyDVdW24PaWaiGTnFZJwPIQAebDpIKheBIm7n124ZthMJipAlkqHO+IZkP1tbfzOJark/A7MgKyvvl60fRqkvXfhuow+t9+q00+0/yyBrK8ZngOtBzldhw2X9tvpNGty0gvkmbPeJ0Cy/r09s/stbmfo0yMWkEdjevgKyOn2t2pxv7UXoibTdCDLje9/Ww1ymqzn87dbp92242ZmMRjI8hASvwKSLq4udqN6ksw8nxXN3tszD9L8Gkg+2mFrQYql5az4tvFj5xOx4VwnSdeBtGdyPwUytxK77pBVlNHdO7OK3rh/eTPUvdutT3fO52tuHMqD4N7llv8pyOQQ//w19YVDfX27+Sfuby9/6nau4pdA8vEdOZuChEH/quHt0Jg+IRJ/5+PrHwKZXfjbDiS73Zo7mu5UkzX7uTsXe0e/7nC3ePf1O69+BUg2XDfZCqSqOu7rGVf8cHBe8zhC2b61dtUHXv0OkGo6ZL4JkpbRYXdUaFevivx2M/1GIOctNh949TtAoumQ+TpIHMX54CJu+8BDd8FkE5BqcZh/59XvAClmTvKfB0nDqIlHo3T70SftyW1eX9dXtgQJqs1f/Q6QaOa/7wmQKtxH8eiGoCRuovODIO3VxOMmruZbHrLyD7z6DSDtGyT7ew1kf9hNn07c986JTovzzem0Id9wUG+Vk/IDr34DSNR7huZJkMFT6vEhqrPx/j5cnlZML8N6/PAzh9Y99Flm5Yde/c9BquDOkvkKkMP58dA4qi9vivE8JOvGz/j8FokfPpr288+pH2ZPOZrLmeGD+7KOh6dqYWJ48ki7yUg0tz0go/fv/LLddfV3sgOLJyaGPY/zrSlh1a36Arkzoue9CyG35ze6E6/dzO2Ga0EGHqdRJIkfn9/8OEjTW8Vq91ZWh39FeehWA7Nu9ft8CpUEk1WWOyDF0OPyEU2Pnzf/bZC0P6IPzmAvu7KauQBVrgKpJ0tG2arHzX8e5Pb3PezNs/PrX+3JMyCLn9XXf37tPFHvt09WfCDDjx+yyn1/p1V11j7GnB/q3leLuVva79S/tzed+db08YpF4uOZtmz/9oXWMq6BCAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAERECACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiACAgQAQEiIEAEBIiAALELvqt/BBgACqVeUBXxcCkAAAAASUVORK5CYII='
},
/** 加载失败的错误占位图 */
errorImg: { type: String },
/** 图片进入可见区域前多少像素时,单位rpx,开始加载图片 */
threshold: { type: [String, Number] as PropType<string | number>, default: 100 },
/** 淡入淡出动画的过渡时间 */
duration: { type: [String, Number] as PropType<string | number>, default: 500 },
/** 渡效果的速度曲线 */
effect: { type: String as PropType<Effect>, default: 'ease-in-out' },
/** 是否使用过渡效果 */
isEffect: { type: Boolean, default: true },
/** 圆角值 */
borderRadius: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 图片高度,单位rpx */
height: { type: [String, Number] as PropType<string | number>, default: 450 }
};
export type LazyLoadProps = ExtractPropTypes<typeof LazyLoadProps>;
@@ -0,0 +1,246 @@
<template>
<view
class="u-wrap"
:style="{
opacity: Number(opacity),
borderRadius: props.borderRadius + 'rpx',
// 因为time值需要改变,所以不直接用duration值(不能改变父组件prop传过来的值)
transition: `opacity ${Number(time) / 1000}s ${props.effect}`
}"
:class="'u-lazy-item-' + elIndex"
>
<view :class="'u-lazy-item-' + elIndex">
<image
:style="{ borderRadius: props.borderRadius + 'rpx', height: imgHeight }"
v-if="!isError"
class="u-lazy-item"
:src="isShow ? props.image : props.loadingImg"
:mode="props.imgMode"
@load="imgLoaded"
@error="loadError"
@tap="clickImg"
></image>
<image
:style="{ borderRadius: props.borderRadius + 'rpx', height: imgHeight }"
class="u-lazy-item error"
v-else
:src="props.errorImg"
:mode="props.imgMode"
@load="errorImgLoaded"
@tap="clickImg"
></image>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-lazy-load',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { LazyLoadProps } from './types';
import { ref, computed, watch, onMounted, nextTick, getCurrentInstance } from 'vue';
import { $u } from '../..';
/**
* lazyLoad 懒加载
* @description 懒加载使用的场景为:页面有很多图片时,APP会同时加载所有的图片,导致页面卡顿,各个位置的图片出现前后不一致等.
* @tutorial https://uviewpro.cn/zh/components/lazyLoad.html
* @property {String | Number} index 用户自定义值,在事件触发时回调,用以区分是哪个图片
* @property {String} image 图片路径
* @property {String} loading-img 预加载时的占位图
* @property {String} error-img 图片加载出错时的占位图
* @property {String | Number} threshold 触发加载时的位置,单位 rpx(默认300)
* @property {String | Number} duration 图片加载成功时,淡入淡出时间,单位ms(默认)
* @property {String} effect 图片加载成功时,淡入淡出的css动画效果(默认ease-in-out)
* @property {Boolean} is-effect 图片加载成功时,是否启用淡入淡出效果(默认true)
* @property {String | Number} border-radius 图片圆角值,单位rpx(默认0)
* @property {String | Number} height 图片高度,注意:实际高度可能受img-mode参数影响(默认450)
* @property {String} img-mode 图片的裁剪模式,详见image组件裁剪模式(默认widthFix)
* @event {Function} click 点击图片时触发
* @event {Function} load 图片加载成功时触发
* @event {Function} error 图片加载失败时触发
* @example <u-lazy-load :image="image" :loading-img="loadingImg" :error-img="errorImg"></u-lazy-load>
*/
const emit = defineEmits(['click', 'load', 'error']);
const props = defineProps(LazyLoadProps);
const instance = getCurrentInstance();
// 懒加载相关响应式变量
const isShow = ref(false);
const opacity = ref(1);
const time = ref(Number(props.duration));
const loadStatus = ref(''); // 默认是懒加载中的状态
const isError = ref(false); // 图片加载失败
const elIndex = ref('');
// IntersectionObserver 实例
let contentObserver: any = null;
/**
* 将 threshold 从 rpx 转为 px
*/
const getThreshold = computed(() => {
// 先取绝对值,因为 threshold 可能是负数,最后根据 props.threshold 是正数或者负数,重新还原
const thresholdPx = uni.upx2px(Math.abs(Number(props.threshold)));
return Number(props.threshold) < 0 ? -thresholdPx : thresholdPx;
});
/**
* 计算图片的高度,可能为 auto,带 %,或者直接数值
*/
const imgHeight = computed(() => {
return $u.addUnit(props.height);
});
// 监听 isShow 变化,处理淡入淡出动画
watch(isShow, nVal => {
// 如果是不开启过渡效果,直接返回
if (!props.isEffect) return;
time.value = 0;
// 原来opacity为1(不透明,是为了显示占位图),改成0(透明,意味着该元素显示的是背景颜色,默认的白色),再改成1,是为了获得过渡效果
opacity.value = 0;
// 延时30ms,否则在浏览器H5,过渡效果无效
setTimeout(() => {
time.value = Number(props.duration);
opacity.value = 1;
}, 30);
});
// 监听图片路径变化,重置状态,图片路径发生变化时,需要重新标记一些变量,否则会一直卡在某一个状态,比如isError
watch(
() => props.image,
n => {
if (!n) {
// 如果传入null或者'',或者undefined,标记为错误状态
isError.value = true;
} else {
init();
isError.value = false;
}
}
);
/**
* 用于重新初始化
*/
function init() {
isError.value = false;
loadStatus.value = '';
}
/**
* 点击图片触发的事件
* loadlazy-还是懒加载中状态,loading-图片正在加载,loaded-图片加加载完成
*/
function clickImg() {
let whichImg = '';
// 如果isShow为false,意味着图片还没开始加载,点击的只能是最开始的占位图
if (!isShow.value) whichImg = 'lazyImg';
// 如果isError为true,意味着图片加载失败,这是只剩下错误的占位图,所以点击的只能是错误占位图
// 当然,也可以给错误的占位图元素绑定点击事件,看你喜欢~
else if (isError.value) whichImg = 'errorImg';
// 总共三张图片,除了两个占位图,剩下的只能是正常的那张图片了
else whichImg = 'realImg';
// 只通知当前图片的index
emit('click', props.index);
}
/**
* 图片加载完成事件
* 可能是加载占位图时触发,也可能是加载真正的图片完成时触发,通过 isShow 区分
*/
function imgLoaded() {
// 占位图加载完成
if (loadStatus.value === '') {
loadStatus.value = 'lazyed';
} else if (loadStatus.value === 'lazyed') {
// 真正的图片加载完成
loadStatus.value = 'loaded';
emit('load', props.index);
}
}
/**
* 错误的图片加载完成
*/
function errorImgLoaded() {
emit('error', props.index);
}
/**
* 图片加载失败
*/
function loadError() {
isError.value = true;
}
/**
* 断开 IntersectionObserver
*/
function disconnectObserver(observerName: string) {
if (observerName === 'contentObserver' && contentObserver) {
contentObserver.disconnect();
contentObserver = null;
}
}
onMounted(() => {
// 生成唯一 id
elIndex.value = $u.guid();
nextTick(() => {
uni.$once('uOnReachBottom', () => {
if (!isShow.value) isShow.value = true;
});
// mounted的时候,不一定挂载了这个元素,延时30ms,否则会报错或者不报错,但是也没有效果
setTimeout(() => {
// 这里是组件内获取布局状态,不能用uni.createIntersectionObserver,而必须用this.createIntersectionObserver
disconnectObserver('contentObserver');
contentObserver = uni.createIntersectionObserver(instance?.proxy);
// 要理解这里怎么计算的,请看这个:
// https://blog.csdn.net/qq_25324335/article/details/83687695
contentObserver
.relativeToViewport({ bottom: getThreshold.value })
.observe('.u-lazy-item-' + elIndex.value, (res: any) => {
if (res.intersectionRatio > 0) {
// 懒加载状态改变
isShow.value = true;
// 如果图片已经加载,去掉监听,减少性能的消耗
disconnectObserver('contentObserver');
}
});
}, 30);
});
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-wrap {
background-color: var(--u-divider-color);
overflow: hidden;
}
.u-lazy-item {
width: 100%;
// 骗系统开启硬件加速
transform: transition3d(0, 0, 0);
// 防止图片加载“闪一下”
will-change: transform;
/* #ifndef APP-NVUE */
display: block;
/* #endif */
}
</style>
@@ -0,0 +1,58 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-line-progress 组件 props 类型定义
* @description 线型进度条,支持多种样式
*/
export const LineProgressProps = {
/** 两端是否显示半圆形 */
round: {
type: Boolean,
default: true
},
/** 主题颜色 */
type: {
type: String,
default: ''
},
/** 激活部分的颜色 */
activeColor: {
type: String,
default: 'var(--u-type-success)'
},
/** 进度条的底色 */
inactiveColor: {
type: String,
default: 'var(--u-divider-color)'
},
/** 进度百分比,数值 */
percent: {
type: [Number, String] as PropType<number | string>,
default: 0
},
/** 是否在进度条内部显示百分比的值 */
showPercent: {
type: Boolean,
default: true
},
/** 进度条的高度,单位rpx */
height: {
type: [Number, String] as PropType<number | string>,
default: 28
},
/** 是否显示条纹 */
striped: {
type: Boolean,
default: false
},
/** 条纹是否显示活动状态 */
stripedActive: {
type: Boolean,
default: false
}
};
/**
* u-line-progress 组件 props 类型
*/
export type LineProgressProps = ExtractPropTypes<typeof LineProgressProps>;
@@ -0,0 +1,131 @@
<template>
<view
class="u-progress"
:style="{
borderRadius: round ? '100rpx' : 0,
height: height + 'rpx',
backgroundColor: inactiveColor
}"
>
<view
:class="[
type ? `u-type-${type}-bg` : '',
striped ? 'u-striped' : '',
striped && stripedActive ? 'u-striped-active' : ''
]"
class="u-active"
:style="progressStyle"
>
<slot v-if="slots.default" />
<text class="u-progress-text" v-else-if="showPercent">
{{ percent + '%' }}
</text>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-line-progress',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { LineProgressProps } from './types';
/**
* lineProgress 线型进度条
* @description 展示操作或任务的当前进度,比如上传文件,是一个线形的进度条。
* @tutorial https://uviewpro.cn/zh/components/lineProgress.html
* @property {String Number} percent 进度条百分比值,为数值类型,0-100
* @property {Boolean} round 进度条两端是否为半圆(默认true)
* @property {String} type 如设置,active-color值将会失效
* @property {String} active-color 进度条激活部分的颜色(默认var(--u-type-success))
* @property {String} inactive-color 进度条的底色(默认var(--u-divider-color))
* @property {Boolean} show-percent 是否在进度条内部显示当前的百分比值数值(默认true)
* @property {String Number} height 进度条的高度,单位rpx(默认28)
* @property {Boolean} striped 是否显示进度条激活部分的条纹(默认false)
* @property {Boolean} striped-active 条纹是否具有动态效果(默认false)
* @example <u-line-progress :percent="70" :show-percent="true"></u-line-progress>
*/
const props = defineProps(LineProgressProps);
const slots = useSlots();
/**
* 进度条激活部分的样式
*/
const progressStyle = computed(() => {
let style: Record<string, string> = {};
style.width = props.percent + '%';
if (!props.type && props.activeColor) style.backgroundColor = props.activeColor;
return style;
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-progress {
overflow: hidden;
height: 15px;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
width: 100%;
border-radius: 100rpx;
}
.u-active {
width: 0;
height: 100%;
align-items: center;
@include vue-flex;
justify-items: flex-end;
justify-content: space-around;
font-size: 20rpx;
color: var(--u-white-color);
transition: all 0.4s ease;
}
.u-striped {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 39px 39px;
}
.u-striped-active {
animation: progress-stripes 2s linear infinite;
}
.u-progress-text {
color: var(--u-light-color);
padding: 0 10rpx;
}
@keyframes progress-stripes {
0% {
background-position: 0 0;
}
100% {
background-position: 39px 0;
}
}
</style>
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { LineBorderStyle, LineDirection } from '../../types/global';
/**
* u-line 组件 props 类型定义
* @description 此组件一般用于显示一根线条,用于分隔内容块,有横向和竖向两种模式,且能设置0.5px线条,使用也很简单
*/
export const LineProps = {
/** 线条的颜色 */
color: {
type: String,
default: 'var(--u-border-color)'
},
/** 长度,竖向时表现为高度,横向时表现为长度,可以为百分比,带rpx单位的值等 */
length: {
type: String,
default: '100%'
},
/** 线条方向,col-竖向,row-横向 */
direction: {
type: String as PropType<LineDirection>,
default: 'row'
},
/** 是否显示细边框 */
hairLine: {
type: Boolean,
default: true
},
/** 线条与上下左右元素的间距,字符串形式,如"30rpx"、"20rpx 30rpx" */
margin: {
type: String,
default: '0'
},
/** 线条的类型,solid-实线,dashed-方形虚线,dotted-圆点虚线 */
borderStyle: {
type: String as PropType<LineBorderStyle>,
default: 'solid'
}
};
/**
* u-line 组件 props 类型
*/
export type LineProps = ExtractPropTypes<typeof LineProps>;
@@ -0,0 +1,68 @@
<template>
<view class="u-line" :style="lineStyle"></view>
</template>
<script lang="ts">
export default {
name: 'u-line',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u } from '../..';
import { LineProps } from './types';
/**
* line 线条
* @description 此组件一般用于显示一根线条,用于分隔内容块,有横向和竖向两种模式,且能设置0.5px线条,使用也很简单
* @tutorial https://uviewpro.cn/zh/components/line.html
* @property {String} color 线条的颜色(默认var(--u-border-color))
* @property {String} length 长度,竖向时表现为高度,横向时表现为长度,可以为百分比,带rpx单位的值等
* @property {String} direction 线条的方向,row-横向,col-竖向(默认row)
* @property {String} border-style 线条的类型,solid-实线,dashed-方形虚线,dotted-圆点虚线(默认solid)
* @property {Boolean} hair-line 是否显示细线条(默认true)
* @property {String} margin 线条与上下左右元素的间距,字符串形式,如"30rpx"
* @example <u-line color="red"></u-line>
*/
const props = defineProps(LineProps);
/**
* 线条样式
*/
const lineStyle = computed(() => {
let style: Record<string, string> = {};
style.margin = props.margin;
// 如果是水平线条,边框高度为1px,再通过transform缩小一半,就是0.5px了
if (props.direction === 'row') {
// 此处采用兼容分开写,兼容nvue的写法
style.borderBottomWidth = '1px';
style.borderBottomStyle = props.borderStyle;
style.width = $u.addUnit(props.length);
if (props.hairLine) style.transform = 'scaleY(0.5)';
} else {
// 如果是竖向线条,边框宽度为1px,再通过transform缩小一半,就是0.5px了
style.borderLeftWidth = '1px';
style.borderLeftStyle = props.borderStyle;
style.height = $u.addUnit(props.length);
if (props.hairLine) style.transform = 'scaleX(0.5)';
}
style.borderColor = props.color;
return style;
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-line {
vertical-align: middle;
}
</style>
@@ -0,0 +1,49 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
/**
* u-link 组件 props 类型定义
* @description 超链接组件,支持多端跳转/复制/提示
*/
export const LinkProps = {
/** 文字颜色 */
color: {
type: String,
default: () => getColor('primary')
},
/** 字体大小,单位rpx */
fontSize: {
type: [String, Number] as PropType<string | number>,
default: 28
},
/** 是否显示下划线 */
underLine: {
type: Boolean,
default: false
},
/** 要跳转的链接 */
href: {
type: String,
default: ''
},
/** 小程序中复制到粘贴板的提示语 */
mpTips: {
type: String,
default: ''
},
/** 下划线颜色 */
lineColor: {
type: String,
default: ''
},
/** 是否默认点击跳转 */
defaultClick: {
type: Boolean,
default: true
}
};
/**
* u-link 组件 props 类型
*/
export type LinkProps = ExtractPropTypes<typeof LinkProps>;
@@ -0,0 +1,95 @@
<template>
<text
class="u-link"
@tap.stop="openLink"
:style="{
color: color,
fontSize: fontSize + 'rpx',
borderBottom: underLine ? `1px solid ${lineColor ? lineColor : color}` : 'none',
paddingBottom: underLine ? '0rpx' : '0'
}"
>
<slot></slot>
</text>
</template>
<script lang="ts">
export default {
name: 'u-link',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { $u, useLocale } from '../..';
import { LinkProps } from './types';
import { computed } from 'vue';
/**
* link 超链接
* @description 该组件为超链接组件,在不同平台有不同表现形式:在APP平台会通过plus环境打开内置浏览器,在小程序中把链接复制到粘贴板,同时提示信息,在H5中通过window.open打开链接。
* @tutorial https://uviewpro.cn/zh/components/link.html
* @property {String} color 文字颜色(默认var(--u-content-color))
* @property {String Number} font-size 字体大小,单位rpx(默认28)
* @property {Boolean} under-line 是否显示下划线(默认false)
* @property {String} href 跳转的链接,要带上http(s)
* @property {String} line-color 下划线颜色,默认同color参数颜色
* @property {String} mp-tips 各个小程序平台把链接复制到粘贴板后的提示语(默认“链接已复制,请在浏览器打开”)
* @example <u-link href="https://uviewpro.cn">蜀道难,难于上青天</u-link>
*/
const props = defineProps(LinkProps);
const emit = defineEmits(['click']);
const { t } = useLocale();
// 国际化计算属性
const getMpTips = computed(() => props.mpTips || t('uLink.mpTips'));
/**
* 打开链接方法
* 不同平台有不同表现形式
*/
function openLink() {
if (!props.defaultClick) {
emit('click', props.href);
return;
}
// #ifdef APP-PLUS
if (typeof plus !== 'undefined' && plus.runtime) {
plus.runtime.openURL(props.href);
}
// #endif
// #ifdef H5
if (typeof window !== 'undefined') {
window.open(props.href);
}
// #endif
// #ifdef MP
if (typeof uni !== 'undefined' && uni.setClipboardData) {
uni.setClipboardData({
data: props.href,
success: () => {
uni.hideToast();
if (typeof $u !== 'undefined' && $u.toast) {
$u.toast(getMpTips.value);
}
}
});
}
// #endif
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-link {
line-height: 1;
}
</style>
@@ -0,0 +1,26 @@
import type { ExtractPropTypes, PropType } from 'vue';
type LoadingMode = 'circle' | 'flower';
/**
* u-loading-popup 组件 props 类型定义
*/
export const LoadingPopupProps = {
/** 是否显示加载弹窗 */
modelValue: { type: Boolean, default: false },
/** 加载提示文本 */
text: { type: String, default: '' },
/** 方向,可选 'vertical' | 'horizontal' */
direction: { type: String as PropType<'vertical' | 'horizontal'>, default: 'vertical' },
/** 自动关闭的持续时间(ms),0为不自动关闭 */
duration: { type: Number, default: 0 },
/** 允许点击遮罩关闭的最短时间(ms) */
cancelTime: { type: Number, default: 10000 },
/** 动画的类型 */
mode: { type: String as PropType<LoadingMode>, default: 'circle' },
/** 动画的颜色 */
color: { type: String, default: 'var(--u-light-color)' },
/** 加载图标的大小,单位rpx */
size: { type: [String, Number] as PropType<string | number>, default: '48' }
};
export type LoadingPopupProps = ExtractPropTypes<typeof LoadingPopupProps>;
@@ -0,0 +1,254 @@
<template>
<view @touchmove.stop.prevent>
<view v-if="popupValue" class="u-loading-init" :class="[direction]">
<u-loading :mode="mode" :color="color" :size="size" />
<view v-if="currentText" class="u-loading-tips">
{{ currentText }}
</view>
</view>
<view class="u-loading-mask" :class="[popupValue ? 'u-mask-show' : '']" @click="onMaskClick" />
</view>
</template>
<script lang="ts">
export default {
name: 'u-loading-popup',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import { LoadingPopupProps } from './types';
import uLoading from '../u-loading/u-loading.vue';
// 组件props类型
const props = defineProps(LoadingPopupProps);
const emit = defineEmits(['update:modelValue', 'cancel']);
// 自动关闭的持续时间定时器
let durationTimer: ReturnType<typeof setTimeout> | null = null;
// 关闭按钮倒计时定时器
let cancelTimer: ReturnType<typeof setTimeout> | null = null;
// 记录弹窗显示的时间戳
const now = ref(0);
// 点击遮罩层是否可关闭(超时后)
const canClose = ref(false);
// 当前显示的text,优先级:loading参数 > props.text
const currentText = ref(props.text);
const popupValue = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
});
watch(
() => popupValue.value,
val => {
if (val) {
doOpen(currentText.value);
} else {
doClose();
}
}
);
// 响应 props 变更,自动刷新当前 text
watch(
() => props.text,
val => {
currentText.value = val;
}
);
// 响应 cancelTime/duration 变化,重启定时器
watch(
() => [props.cancelTime, props.duration],
() => {
if (popupValue.value) {
clearDurationTimer();
startCancelTime();
startDurationTime();
}
}
);
/**
* 启动超时关闭按钮计时
*/
function startCancelTime() {
clearCancelTimer();
canClose.value = false;
if (props.cancelTime > 0) {
cancelTimer = setTimeout(() => {
canClose.value = true;
}, props.cancelTime);
}
}
/**
* 启动持续时间计时
*/
function startDurationTime() {
clearDurationTimer();
if (props.duration) {
durationTimer = setTimeout(() => {
close();
}, props.duration);
}
}
/**
* 内部显示逻辑,初始化所有状态
*/
function doOpen(text?: string) {
canClose.value = false;
clearDurationTimer();
clearCancelTimer();
now.value = Date.now();
if (typeof text === 'string' && text !== '') {
currentText.value = text;
} else {
currentText.value = props.text;
}
startCancelTime();
startDurationTime();
}
/**
* 内部关闭逻辑,重置所有状态
*/
function doClose() {
canClose.value = false;
currentText.value = props.text;
clearDurationTimer();
clearCancelTimer();
}
/**
* 显示弹窗
* @param text 可选,优先显示的文案
*/
function open(text?: string) {
currentText.value = text || props.text;
popupValue.value = true;
}
/**
* 隐藏弹窗
*/
function close() {
popupValue.value = false;
}
// 清理定时器
function clearDurationTimer() {
if (durationTimer) {
clearTimeout(durationTimer);
durationTimer = null;
}
}
function clearCancelTimer() {
if (cancelTimer) {
clearTimeout(cancelTimer);
cancelTimer = null;
}
}
// 遮罩点击事件
function onMaskClick() {
// 只有显示关闭按钮时才允许关闭
if (canClose.value) {
emit('cancel');
close();
}
}
onUnmounted(() => {
clearDurationTimer();
clearCancelTimer();
});
defineExpose({
open,
close
});
</script>
<style lang="scss">
.u-loading-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0);
z-index: 9999996;
transition: all 0.3s ease-in-out;
opacity: 0;
visibility: hidden;
}
.u-mask-show {
visibility: visible;
opacity: 1;
}
.u-loading-init {
position: relative;
min-width: 200rpx;
min-height: 200rpx;
max-width: 500rpx;
padding: 15rpx 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
font-size: 30rpx;
color: var(--u-white-color);
background: rgba(0, 0, 0, 0.7);
border-radius: 7px;
.u-icon-close {
position: absolute;
top: 4rpx;
right: 2rpx;
color: var(--u-white-color);
opacity: 0.8;
}
&.horizontal {
flex-direction: row;
align-items: center;
justify-content: left;
width: 600rpx;
max-width: 600rpx;
min-height: 150rpx;
padding-left: 40rpx;
.u-loading-tips {
margin-top: 0;
margin-left: 20rpx;
font-size: 32rpx;
}
}
}
.u-loading-tips {
text-align: center;
padding: 0 20rpx;
box-sizing: border-box;
margin-top: 36rpx;
}
</style>
@@ -0,0 +1,45 @@
import type { ExtractPropTypes, PropType } from 'vue';
type LoadingMode = 'circle' | 'flower';
/**
* u-loading 组件 props 类型定义
* @description 加载动画,支持多种模式
*/
export const LoadingProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 动画的类型 */
mode: {
type: String as PropType<LoadingMode>,
default: 'circle'
},
/** 动画的颜色 */
color: {
type: String,
default: 'var(--u-light-color)'
},
/** 加载图标的大小,单位rpx */
size: {
type: [String, Number] as PropType<string | number>,
default: '34'
},
/** 是否显示动画 */
show: {
type: Boolean,
default: true
}
};
/**
* u-loading 组件 props 类型
*/
export type LoadingProps = ExtractPropTypes<typeof LoadingProps>;
@@ -0,0 +1,106 @@
<template>
<view
v-if="show"
class="u-loading"
:class="[mode === 'circle' ? 'u-loading-circle' : 'u-loading-flower', customClass]"
:style="$u.toStyle(cricleStyle, customStyle)"
>
</view>
</template>
<script lang="ts">
export default {
name: 'u-loading',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { LoadingProps } from './types';
import { $u } from '../../';
/**
* loading 加载动画
* @description 此组件为一个小动画,目前用在uView的loadmore加载更多和switch开关等组件的正在加载状态场景。
* @tutorial https://uviewpro.cn/zh/components/loading.html
* @property {String} mode 模式选择,见官网说明(默认circle)
* @property {String} color 动画活动区域的颜色,只对 mode = flower 模式有效(默认var(--u-light-color))
* @property {String|Number} size 加载图标的大小,单位rpx(默认34)
* @property {Boolean} show 是否显示动画(默认true)
* @example <u-loading mode="circle"></u-loading>
*/
const props = defineProps(LoadingProps);
/**
* 加载中圆圈动画的样式
* @returns {Record<string, any>}
*/
const cricleStyle = computed(() => {
let style: Record<string, any> = {};
style.width = props.size + 'rpx';
style.height = props.size + 'rpx';
// 只对圆圈模式生效
if (props.mode === 'circle') {
style.borderColor = `var(--u-divider-color) var(--u-divider-color) var(--u-divider-color) ${props.color ? props.color : 'var(--u-light-color)'}`;
}
return style;
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-loading-circle {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
vertical-align: middle;
width: 28rpx;
height: 28rpx;
background: 0 0;
border-radius: 50%;
border: 2px solid;
border-color: var(--u-divider-color) var(--u-divider-color) var(--u-divider-color) var(--u-tips-color);
animation: u-circle 1s linear infinite;
}
.u-loading-flower {
width: 20px;
height: 20px;
display: inline-block;
vertical-align: middle;
-webkit-animation: u-flower 1s steps(12) infinite;
animation: u-flower 1s steps(12) infinite;
background: transparent
url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMjAiIGhlaWdodD0iMTIwIiB2aWV3Qm94PSIwIDAgMTAwIDEwMCI+PHBhdGggZmlsbD0ibm9uZSIgZD0iTTAgMGgxMDB2MTAwSDB6Ii8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjRTlFOUU5IiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgLTMwKSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iIzk4OTY5NyIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgzMCAxMDUuOTggNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjOUI5OTlBIiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKDYwIDc1Ljk4IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0EzQTFBMiIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSg5MCA2NSA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNBQkE5QUEiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoMTIwIDU4LjY2IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0IyQjJCMiIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgxNTAgNTQuMDIgNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjQkFCOEI5IiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKDE4MCA1MCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNDMkMwQzEiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTE1MCA0NS45OCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNDQkNCQ0IiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTEyMCA0MS4zNCA2NSkiLz48cmVjdCB3aWR0aD0iNyIgaGVpZ2h0PSIyMCIgeD0iNDYuNSIgeT0iNDAiIGZpbGw9IiNEMkQyRDIiIHJ4PSI1IiByeT0iNSIgdHJhbnNmb3JtPSJyb3RhdGUoLTkwIDM1IDY1KSIvPjxyZWN0IHdpZHRoPSI3IiBoZWlnaHQ9IjIwIiB4PSI0Ni41IiB5PSI0MCIgZmlsbD0iI0RBREFEQSIgcng9IjUiIHJ5PSI1IiB0cmFuc2Zvcm09InJvdGF0ZSgtNjAgMjQuMDIgNjUpIi8+PHJlY3Qgd2lkdGg9IjciIGhlaWdodD0iMjAiIHg9IjQ2LjUiIHk9IjQwIiBmaWxsPSIjRTJFMkUyIiByeD0iNSIgcnk9IjUiIHRyYW5zZm9ybT0icm90YXRlKC0zMCAtNS45OCA2NSkiLz48L3N2Zz4=)
no-repeat;
background-size: 100%;
}
@keyframes u-flower {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(1turn);
transform: rotate(1turn);
}
}
@keyframes u-circle {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More