2025/2/8第一次更新

This commit is contained in:
爱吃咸鱼小猫咪
2025-02-08 18:50:38 +08:00
commit d7af560866
26519 changed files with 5046029 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
Copyright (c) 2023-present Tuniao Technology
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+56
View File
@@ -0,0 +1,56 @@
# 图鸟 UI vue3 uniapp 版本
![TuniaoUI vue3 uniapp](https://resource.tuniaokj.com/images/vue3/market/vue3-banner-min.jpg 'TuniaoUI vue3 uniapp')
![TuniaoUI vue3 uniapp](https://resource.tuniaokj.com/images/vue3/market/vue3-code-min.jpg 'TuniaoUI vue3 uniapp')
<p align="center">
<a href="https://www.npmjs.com/package/@tuniao/tnui-vue3-uniapp">
<img src="https://img.shields.io/npm/v/%40tuniao%2Ftnui-vue3-uniapp.svg" alt="Version" />
</a>
<a href="https://www.npmjs.com/package/@tuniao/tnui-vue3-uniapp">
<img src="https://img.shields.io/npm/l/%40tuniao%2Ftnui-vue3-uniapp.svg" alt="Version" />
</a>
<a href="https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp">
<img src="https://img.shields.io/github/forks/tuniaoTech/tuniaoui-rc-vue3-uniapp.svg" alt="GitHub Forks" />
</a>
<a href="https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp">
<img src="https://img.shields.io/github/stars/tuniaoTech/tuniaoui-rc-vue3-uniapp.svg" alt="GitHub Star" />
</a>
<a href="https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp">
<img src="https://img.shields.io/badge/Platform-uniapp-%2301beff.svg" alt="Platform" />
</a>
<a href="https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp">
<img src="https://img.shields.io/badge/Language-vue3-%2301beff.svg" alt="Language" />
</a>
<a href="https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp">
<img src="https://img.shields.io/badge/Language-Typescript-%2301beff.svg" alt="Language" />
</a>
</p>
## 介绍
`TuniaoUI vue3 uniapp`是一款基于`uniapp``vue3``Typescript`进行开发的 UI 组件库,提供丰富的组件进行快速开发,支持`微信小程序``APP``H5`,包含常用表单组件、信息展示组件等,并提供丰富的酷炫页面模板。
## 特性
- 包含基础常用的布局元素,flex、grid、浮动
- 完整一体的配色体系,包含 4 种色深模式,同时包含 4 套渐变配色
- 700+风格统一的图标 icon,60+精选组件,让开发者可以快速进行开发,icon 提供了单独的 npm 包,让开发者更加方便的更新图鸟 UI 的 icon
- 酷炫常用的页面模板,更有让你眼前一亮的界面效果
- 图片素材语雀便捷下载,图鸟生态共同成长
- 使用文档详尽说明,让你一文读懂图鸟 UI
## 链接
- [github 地址](https://github.com/tuniaoTech/tuniaoui-rc-vue3-uniapp)
- [图鸟 UI vue3 uniapp 演示代码](https://github.com/tuniaoTech/tuniaoui-uniapp-v3-demo)
- [图鸟 UI vue3 uniapp 使用文档](https://vue3.tuniaokj.com)
- [图鸟社区 - 资源下载](https://www.yuque.com/tuniao)
## 联系作者
联系微信: `tnkewo`
<p style="color: #01beff;">群内气氛挺不错的,应该或许可能大概,算是为数不多的,专搞技术的前端群,偶尔聊天摸鱼</p>
![扫码进群](https://resource.tuniaokj.com/images/about_tuniao/tn_author_qrcode.jpg '扫码进群')
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import ActionSheet from './src/action-sheet.vue'
export const TnActionSheet = withNoopInstall(ActionSheet)
export default TnActionSheet
export * from './src/action-sheet'
export type { TnActionSheetInstance } from './src/instance'
@@ -0,0 +1,68 @@
import { ZIndex } from '../../../constants'
import { buildProps } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export interface ActionSheetAction {
/**
* @description 选项文字
*/
text: string
/**
* @description 选项备注
*/
desc?: string
/**
* @description 选项的值
*/
value?: string | number
}
/**
* @description ActionSheet options配置项
*/
export interface ActionSheetOptions {
/**
* @description 选项列表
*/
actions: ActionSheetAction[]
/**
* @description 标题
*/
title?: string
/**
* @description 取消按钮文字,如果为空则不显示取消按钮
*/
cancelText?: string
/**
* @description 是否显示遮罩
*/
mask?: boolean
/**
* @description 点击蒙层是否允许关闭
*/
maskClosable?: boolean
/**
* @description 点击取消按钮触发的回调函数,返回 false 或者返回 Promise 且被 reject 则取消关闭
*/
cancel?: () => (Promise<boolean> | void) | boolean
/**
* @description 点击选项触发的回调函数,返回 false 或者返回 Promise 且被 reject 则取消关闭
*/
select?: (
index: number,
value: string | number
) => (Promise<boolean> | void) | boolean
}
export const actionSheetProps = buildProps({
/**
* @description ZIndex
*/
zIndex: {
type: Number,
default: ZIndex.popup,
},
})
export type ActionSheetProps = ExtractPropTypes<typeof actionSheetProps>
@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import TnPopup from '../../popup/src/popup.vue'
import { actionSheetProps } from './action-sheet'
import { useActionSheet } from './composables'
defineProps(actionSheetProps)
const ns = useNamespace('action-sheet')
const {
data,
showTitle,
title,
showCancel,
cancelText,
overlay,
overlayClosable,
openPopup,
showActionSheet,
popupCloseEvent,
actionClickEvent,
} = useActionSheet()
defineExpose({
/**
* @description: 打开/显示 actionSheet 操作菜单
*/
show: showActionSheet,
})
</script>
<template>
<TnPopup
:model-value="openPopup"
open-direction="bottom"
:overlay="overlay"
:z-index="zIndex"
bg-color="transparent"
:safe-area-inset-bottom="false"
:overlay-closeable="overlayClosable"
@overlay-click="popupCloseEvent"
>
<view class="tn-u-safe-area" :class="[ns.b(), ns.is('shadow', !overlay)]">
<!-- 标题 -->
<view v-if="showTitle" :class="[ns.e('title')]">
<slot name="title">
{{ title }}
</slot>
</view>
<!-- 选项 -->
<view :class="[ns.e('actions')]">
<view
v-for="(item, index) in data"
:key="index"
:class="[ns.e('action')]"
hover-class="tn-u-btn-hover"
:hover-stay-time="150"
@tap.stop="actionClickEvent(index)"
>
<!-- 选项显示内容 -->
<view class="text">{{ item.text }}</view>
<!-- 选项描述 -->
<view v-if="item.desc" class="desc">{{ item.desc }}</view>
</view>
</view>
<!-- 取消按钮 -->
<view
v-if="showCancel"
:class="[ns.e('cancel')]"
hover-class="tn-u-btn-hover"
:hover-stay-time="150"
@tap.stop="popupCloseEvent"
>
<slot name="cancel">
{{ cancelText }}
</slot>
</view>
</view>
</TnPopup>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/action-sheet.scss';
</style>
@@ -0,0 +1 @@
export * from './use-action-sheet'
@@ -0,0 +1,141 @@
import { computed, getCurrentInstance, reactive, ref } from 'vue'
import {
debugWarn,
isBoolean,
isEmptyVariableInDefault,
isPromise,
} from '../../../../utils'
import type { ActionSheetOptions } from '../action-sheet'
export const useActionSheet = () => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnActionSheet', '请在 setup 中使用 useActionSheet')
}
const { slots } = instance!
// 默认配置项
const defaultOptions: ActionSheetOptions = {
actions: [],
title: '',
cancelText: '取 消',
mask: true,
maskClosable: false,
cancel: undefined,
select: undefined,
}
// 配置项
const options = reactive<ActionSheetOptions>({
actions: [],
title: '',
cancelText: '取 消',
mask: true,
cancel: undefined,
select: undefined,
})
// 操作菜单数据
const data = computed(() => options.actions)
// 是否显示标题
const showTitle = computed(() => !!slots?.title || !!options.title)
const title = computed(() => options.title)
// 是否显示取消按钮
const showCancel = computed(() => !!slots?.cancel || !!options.cancelText)
const cancelText = computed(() => options.cancelText)
// 是否显示遮罩
const overlay = computed(() => isEmptyVariableInDefault(options.mask, true))
// 点击遮罩是否允许关闭
const overlayClosable = computed(() =>
isEmptyVariableInDefault(options.maskClosable, false)
)
// 弹出popup弹框
const openPopup = ref<boolean>(false)
// popup弹框关闭事件
const popupCloseEvent = () => {
if (!options.cancel || overlayClosable.value) {
openPopup.value = false
return
}
const shouldCancel = options.cancel()
const isPromiseOrBoolean = [
isPromise(shouldCancel),
isBoolean(shouldCancel),
].includes(true)
if (!isPromiseOrBoolean) {
debugWarn(
'TnActionSheet',
'cancel 函数返回值必须是 Promise 或者 Boolean 类型'
)
return
}
if (isPromise(shouldCancel)) {
shouldCancel.then((res) => {
if (res) {
openPopup.value = false
}
})
} else {
if (shouldCancel) {
openPopup.value = false
}
}
}
// 选项点击事件
const actionClickEvent = (index: number) => {
if (!options.select) {
openPopup.value = false
return
}
const shouldSelect = options.select(index, options.actions[index].value!)
const isPromiseOrBoolean = [
isPromise(shouldSelect),
isBoolean(shouldSelect),
].includes(true)
if (!isPromiseOrBoolean) {
debugWarn(
'TnActionSheet',
'select 函数返回值必须是 Promise 或者 Boolean 类型'
)
return
}
if (isPromise(shouldSelect)) {
shouldSelect.then((res) => {
if (res) {
openPopup.value = false
}
})
} else {
if (shouldSelect) {
openPopup.value = false
}
}
}
const showActionSheet = (_options: ActionSheetOptions) => {
Object.assign(options, defaultOptions, _options)
openPopup.value = true
}
return {
data,
showTitle,
title,
showCancel,
cancelText,
overlay,
overlayClosable,
openPopup,
showActionSheet,
popupCloseEvent,
actionClickEvent,
}
}
@@ -0,0 +1,3 @@
import type ActionSheet from './action-sheet.vue'
export type TnActionSheetInstance = InstanceType<typeof ActionSheet>
+14
View File
@@ -0,0 +1,14 @@
import { withInstall, withNoopInstall } from '../../utils'
import Avatar from './src/avatar.vue'
import AvatarGroup from './src/avatar-group.vue'
export const TnAvatar = withInstall(Avatar, {
AvatarGroup,
})
export const TnAvatarGroup = withNoopInstall(AvatarGroup)
export default TnAvatar
export * from './src/avatar'
export * from './src/avatar-group'
export type { AvatarInstance, AvatarGroupInstance } from './src/instance'
@@ -0,0 +1,77 @@
import { buildProps } from '../../../utils'
import { avatarProps } from './avatar'
import type { ExtractPropTypes } from 'vue'
export const avatarGroupProps = buildProps({
/**
* @description 头像图标配置
*/
iconConfig: avatarProps.iconConfig,
/**
* @description 头像颜色类型
*/
type: avatarProps.type,
/**
* @description 头像大小
*/
size: avatarProps.size,
/**
* @description 头像形状
*/
shape: avatarProps.shape,
/**
* @description 头像图片模式
*/
imgMode: avatarProps.imgMode,
/**
* @description 背景颜色
*/
bgColor: avatarProps.bgColor,
/**
* @description 显示边框
*/
border: {
type: Boolean,
default: true,
},
/**
* @description 边框颜色
*/
borderColor: {
type: String,
default: 'tn-white',
},
/**
* @description 是否加粗边框
*/
borderBold: avatarProps.borderBold,
/**
* @description 显示阴影
*/
shadow: avatarProps.shadow,
/**
* @description 阴影颜色
*/
shadowColor: avatarProps.shadowColor,
/**
* @description 头像角标配置
*/
badgeConfig: avatarProps.badgeConfig,
/**
* @description 头像之间遮挡比例
*/
gap: {
type: [String, Number],
default: 0.4,
},
})
export const avatarGroupEmits = {
/**
* @description 点击头像
*/
click: (index: number) => typeof index === 'number',
}
export type AvatarGroupProps = ExtractPropTypes<typeof avatarGroupProps>
export type AvatarGroupEmits = typeof avatarGroupEmits
@@ -0,0 +1,23 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import { avatarGroupEmits, avatarGroupProps } from './avatar-group'
import { useAvatarGroup } from './composables'
const props = defineProps(avatarGroupProps)
const emits = defineEmits(avatarGroupEmits)
const ns = useNamespace('avatar')
useAvatarGroup(props, emits)
</script>
<template>
<view :class="`${ns.b('group')}`">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/avatar-group.scss';
</style>
+144
View File
@@ -0,0 +1,144 @@
import { useComponentBoolean } from '../../base/composables/use-component-common-props'
import { buildProps, definePropType } from '../../../utils'
import { componentImgModes, componentTypes } from '../../../constants'
import type { ExtractPropTypes } from 'vue'
import type { BadgeProps } from '../../badge'
import type { IconProps } from '../../icon'
/**
* @description 头像形状
*/
export const avatarShape = ['circle', 'square'] as const
/**
* @description 图标参数配置
*/
export interface AvatarIconProps {
/**
* @description 图标颜色
*/
color?: IconProps['color']
/**
* @description 图标大小
*/
size?: IconProps['size']
/**
* @description 图标加粗
*/
bold?: IconProps['bold']
}
/**
* @description 徽标参数属性
*/
export type AvatarBadgeProps = Partial<
Pick<
BadgeProps,
| 'max'
| 'type'
| 'size'
| 'bgColor'
| 'textColor'
| 'fontSize'
| 'bold'
| 'dot'
| 'absolute'
| 'absolutePosition'
| 'absoluteCenter'
>
>
export const avatarProps = buildProps({
/**
* @description 头像地址(url地址和绝对地址)
*/
url: String,
/**
* @descripttion 头像图标
*/
icon: String,
/**
* @description 头像图标配置
*/
iconConfig: {
type: definePropType<AvatarIconProps>(Object),
default: () => ({}),
},
/**
* @description 头像颜色类型
*/
type: {
type: String,
values: componentTypes,
default: '',
},
/**
* @description 头像大小
*/
size: {
type: [String, Number],
},
/**
* @description 头像形状
*/
shape: {
type: String,
values: avatarShape,
default: 'circle',
},
/**
* @description 头像图片模式
*/
imgMode: {
type: String,
values: componentImgModes,
default: 'aspectFill',
},
/**
* @description 背景颜色
*/
bgColor: String,
/**
* @description 显示边框
*/
border: useComponentBoolean,
/**
* @description 边框颜色
*/
borderColor: String,
/**
* @description 是否加粗边框
*/
borderBold: useComponentBoolean,
/**
* @description 显示阴影
*/
shadow: useComponentBoolean,
/**
* @description 阴影颜色
*/
shadowColor: String,
/**
* @description 角标内容
*/
badge: {
type: [String, Number],
},
/**
* @description 角标配置
*/
badgeConfig: {
type: definePropType<AvatarBadgeProps>(Object),
default: () => ({}),
},
})
export const avatarEmits = {
/**
* @description 点击事件
*/
click: () => true,
}
export type AvatarProps = ExtractPropTypes<typeof avatarProps>
export type AvatarEmits = typeof avatarEmits
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import TnBadge from '../../badge/src/badge.vue'
import { avatarEmits, avatarProps } from './avatar'
import {
useAvatar,
useAvatarBadgeProps,
useAvatarCustomStyle,
useAvatarIconConfig,
useAvatarProps,
} from './composables'
const props = defineProps(avatarProps)
const emits = defineEmits(avatarEmits)
const { componentId, avatarGroupIndex, avatarWidth, avatarClick } = useAvatar(
props,
emits
)
const { ns, avatarClass, avatarStyle } = useAvatarCustomStyle(
props,
avatarGroupIndex,
avatarWidth
)
const { imgMode } = useAvatarProps(props)
const { iconSize, iconColor, iconBold } = useAvatarIconConfig(props.iconConfig)
const { badgeConfig } = useAvatarBadgeProps(props)
</script>
<template>
<view
:id="componentId"
:class="[avatarClass]"
:style="avatarStyle"
@tap="avatarClick"
>
<!-- 图片头像 -->
<view v-if="url" :class="[ns.e('image')]">
<image class="image" :src="url" :mode="imgMode" />
</view>
<!-- 图标头像 -->
<view v-else-if="icon" :class="[ns.e('icon')]">
<TnIcon
:name="icon"
:color="iconColor"
:size="iconSize"
:bold="iconBold"
/>
</view>
<!-- 自定义 -->
<view v-else :class="[ns.e('custom')]">
<slot />
</view>
<!-- 角标 -->
<TnBadge v-bind="badgeConfig" />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/avatar.scss';
</style>
@@ -0,0 +1,119 @@
import { computed } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import { useAvatarProps } from './use-avatar-props'
import type { CSSProperties, Ref } from 'vue'
import type { AvatarProps } from '../avatar'
export const useAvatarCustomStyle = (
props: AvatarProps,
groupIndex: Ref<number>,
avatarWidth: Ref<number>
) => {
const ns = useNamespace('avatar')
const {
type,
size,
shape,
bgColor,
border,
borderColor,
shadow,
shadowColor,
avatarGap,
} = useAvatarProps(props)
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(bgColor, 'bg')
// 解析边框颜色
const [borderColorClass, borderColorStyle] = useComponentColor(
borderColor,
'border'
)
// 解析阴影颜色
const [shadowColorClass] = useComponentColor(shadowColor, 'shadow')
// 解析头像尺寸
const { sizeType } = useComponentSize(size.value)
// 头像动态类
const avatarClass = computed<string>(() => {
const cls: string[] = []
cls.push(ns.b())
// 设置头像颜色类型
if (type.value) cls.push(`tn-type-${type.value}_bg`)
// 设置背景颜色
if (!type.value && bgColorClass.value) cls.push(bgColorClass.value)
// 设置头像尺寸
if (sizeType.value === 'inner') cls.push(ns.m(size.value as string))
// 设置头像形状
if (shape.value) cls.push(ns.m(shape.value))
// 设置边框
if (border.value) {
cls.push('tn-border')
// 设置边框颜色
if (borderColorClass.value) cls.push(borderColorClass.value)
}
// 设置阴影
if (shadow.value) {
cls.push('tn-shadow')
// 设置阴影颜色
if (shadowColorClass.value) cls.push(shadowColorClass.value)
}
return cls.join(' ')
})
// 头像动态样式
const avatarStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置头像尺寸
if (sizeType.value === 'custom') {
style.width = formatDomSizeValue(size.value)
style.height = style.width
}
// 设置背景颜色
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
// 设置边框颜色
if (border.value && borderColorStyle.value)
style.borderColor = borderColorStyle.value
// 如果是头像组,设置头像间距
if (groupIndex.value != -1) {
style.zIndex = groupIndex.value + 1
// style.transform = `translateX(calc(${
// (groupAvatarCount.value - groupIndex.value - 1) * 100
// }% * ${avatarGap.value}))`
if (groupIndex.value > 0) {
style.marginLeft = `calc(-${avatarWidth.value * avatarGap.value}px)`
} else {
style.marginLeft = '0px'
}
}
return style
})
return {
ns,
avatarClass,
avatarStyle,
}
}
@@ -0,0 +1,6 @@
export * from './use-avatar-icon-props'
export * from './use-avatar-props'
export * from './avatar-custom'
export * from './use-avatar'
export * from './use-avatar-group'
export * from './use-avatar-badge-props'
@@ -0,0 +1,120 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { AvatarProps } from '../avatar'
import type { BadgeProps } from '../../../badge'
export const useAvatarBadgeProps = (props: AvatarProps) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 徽标最大值
const max = computed<BadgeProps['max']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.max,
avatarGroup?.badgeConfig?.max
)
})
// 徽标类型
const type = computed<BadgeProps['type']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.type,
avatarGroup?.badgeConfig?.type,
'primary'
)
})
// 徽标背景颜色
const bgColor = computed<BadgeProps['bgColor']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.bgColor,
avatarGroup?.badgeConfig?.bgColor
)
})
// 徽标文本颜色
const textColor = computed<BadgeProps['textColor']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.textColor,
avatarGroup?.badgeConfig?.textColor
)
})
// 徽标字体大小
const fontSize = computed<BadgeProps['fontSize']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.fontSize,
avatarGroup?.badgeConfig?.fontSize
)
})
// 徽标大小
const size = computed<BadgeProps['size']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.size,
avatarGroup?.badgeConfig?.size
)
})
// 徽标加粗
const bold = computed<BadgeProps['bold']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.bold,
avatarGroup?.badgeConfig?.bold,
false
)
})
// 设置点徽标
const dot = computed<BadgeProps['dot']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.dot,
avatarGroup?.badgeConfig?.dot,
false
)
})
// 设置徽标的位置
const absolutePosition = computed<BadgeProps['absolutePosition']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.absolutePosition,
avatarGroup?.badgeConfig?.absolutePosition,
{}
)
})
// 设置徽标是否居中
const absoluteCenter = computed<BadgeProps['absoluteCenter']>(() => {
return isEmptyDoubleVariableInDefault(
props?.badgeConfig?.absoluteCenter,
avatarGroup?.badgeConfig?.absoluteCenter,
true
)
})
// 徽标配置
const badgeConfig = computed<BadgeProps>(() => {
return {
value: props.badge,
max: max.value,
type: type.value,
bgColor: bgColor.value,
textColor: textColor.value,
fontSize: fontSize.value,
size: size.value,
bold: bold.value,
customClass: '',
customStyle: {},
dot: dot.value,
absolute: true,
absolutePosition: absolutePosition.value,
absoluteCenter: absoluteCenter.value,
index: '',
}
})
return {
badgeConfig,
}
}
@@ -0,0 +1,36 @@
import { provide, reactive, toRefs } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { useOrderedChildren } from '../../../../hooks'
import type { SetupContext } from 'vue'
import type { AvatarContext } from '../../../../tokens'
import type { AvatarGroupEmits, AvatarGroupProps } from '../avatar-group'
export const useAvatarGroup = (
props: AvatarGroupProps,
emits: SetupContext<AvatarGroupEmits>['emit']
) => {
const {
children: avatarItems,
addChild: addItem,
removeChild: removeItem,
} = useOrderedChildren<AvatarContext>()
const handleItemClick = (uid: number) => {
// 查找出对应头像的索引
const index = avatarItems.value.findIndex((item) => item.uid === uid)
emits('click', index)
}
provide(
avatarGroupContextKey,
reactive({
...toRefs(props),
avatarItems,
addItem,
removeItem,
handleItemClick,
})
)
}
@@ -0,0 +1,29 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import type { AvatarProps } from '../avatar'
export const useAvatarIconConfig = (config: AvatarProps['iconConfig']) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 图标颜色
const iconColor = computed<string>(() => {
return config?.color || avatarGroup?.iconConfig?.color || ''
})
// 图标大小
const iconSize = computed<string | number>(() => {
return config?.size || avatarGroup?.iconConfig?.size || ''
})
// 图标加粗
const iconBold = computed<boolean>(() => {
return config?.bold || avatarGroup?.iconConfig?.bold || false
})
return {
iconColor,
iconSize,
iconBold,
}
}
@@ -0,0 +1,116 @@
import { computed, inject } from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import {
isEmptyDoubleVariableInDefault,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { AvatarProps } from '../avatar'
export const useAvatarProps = (props: AvatarProps) => {
const avatarGroup = inject(avatarGroupContextKey, undefined)
// 头像颜色类型
const type = computed<string>(() => {
return isEmptyDoubleVariableInDefault(props?.type, avatarGroup?.type, '')
})
// 头像尺寸
const size = computed<string | number>(() => {
return isEmptyDoubleVariableInDefault(props?.size, avatarGroup?.size, '')
})
// 头像形状
const shape = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.shape,
avatarGroup?.shape,
'circle'
)
})
// 头像图片模式
const imgMode = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.imgMode,
avatarGroup?.imgMode,
'aspectFill'
)
})
// 背景颜色
const bgColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.bgColor,
avatarGroup?.bgColor,
'tn-gray-light'
)
})
// 显示边框
const border = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.border,
avatarGroup?.border,
false
)
})
// 边框颜色
const borderColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.borderColor,
avatarGroup?.borderColor,
''
)
})
// 是否加粗边框
const borderBold = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.borderBold,
avatarGroup?.borderBold,
false
)
})
// 显示阴影
const shadow = computed<boolean>(() => {
return isEmptyDoubleVariableInDefault(
props?.shadow,
avatarGroup?.shadow,
false
)
})
// 阴影颜色
const shadowColor = computed<string>(() => {
return isEmptyDoubleVariableInDefault(
props?.shadowColor,
avatarGroup?.shadowColor,
''
)
})
// 头像间的间距
const avatarGap = computed<number>(() => {
let gap = Number(isEmptyVariableInDefault(avatarGroup?.gap, 0))
if (gap < 0) gap = 0
if (gap > 1) gap = 1
return gap
})
return {
type,
size,
shape,
imgMode,
bgColor,
border,
borderColor,
borderBold,
shadow,
shadowColor,
avatarGap,
}
}
@@ -0,0 +1,97 @@
import {
computed,
getCurrentInstance,
inject,
nextTick,
onUnmounted,
ref,
} from 'vue'
import { avatarGroupContextKey } from '../../../../tokens'
import { useSelectorQuery } from '../../../../hooks'
import {
debugWarn,
generateId,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { SetupContext } from 'vue'
import type { AvatarEmits, AvatarProps } from '../avatar'
export const useAvatar = (
props: AvatarProps,
emits: SetupContext<AvatarEmits>['emit']
) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnAvatarGroup', '请在 setup 中使用 useAvatarGroup')
}
const { uid } = instance!
const avatarGroup = inject(avatarGroupContextKey, undefined)
avatarGroup?.addItem({ uid })
const componentId = `ta-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 头像组头像数量
const groupAvatarCount = computed<number>(() => {
return isEmptyVariableInDefault(avatarGroup?.avatarItems.length, 0)
})
const avatarGroupIndex = ref<number>(-1)
nextTick(() => {
// 获取当前头像的索引
const avatarIndex = avatarGroup?.avatarItems.findIndex(
(item) => item.uid === uid
)
avatarGroupIndex.value = isEmptyVariableInDefault(avatarIndex, -1)
if (!avatarWidth.value && avatarGroupIndex.value !== -1) {
getAvatarWidthNodeInfo()
}
})
// 头像宽度信息
const avatarWidth = ref<number>(0)
// 获取头像的宽度信息
let initCount = 0
const getAvatarWidthNodeInfo = async () => {
try {
const rectInfo = await getSelectorNodeInfo(`#${componentId}`)
if (!rectInfo.width) {
throw new Error('获取头像宽度信息失败')
}
avatarWidth.value = rectInfo.width || 0
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnAvatar', `获取头像宽度信息失败:${err}`)
return
}
initCount++
setTimeout(() => {
getAvatarWidthNodeInfo()
}, 150)
}
}
const avatarClick = () => {
avatarGroup?.handleItemClick(uid)
emits('click')
}
onUnmounted(() => {
avatarGroup?.removeItem(uid)
})
return {
componentId,
groupAvatarCount,
avatarGroupIndex,
avatarWidth,
avatarClick,
}
}
@@ -0,0 +1,5 @@
import type Avatar from './avatar.vue'
import type AvatarGroup from './avatar-group.vue'
export type AvatarInstance = InstanceType<typeof Avatar>
export type AvatarGroupInstance = InstanceType<typeof AvatarGroup>
+9
View File
@@ -0,0 +1,9 @@
import { withNoopInstall } from '../../utils'
import Badge from './src/badge.vue'
export const TnBadge = withNoopInstall(Badge)
export default TnBadge
export * from './src/badge'
export type { BadgeInstance } from './src/instance'
+117
View File
@@ -0,0 +1,117 @@
import {
useComponentCustomStyleProp,
useComponentIndexProp,
} from '../../base/composables/use-component-common-props'
import { buildProps, definePropType } from '../../../utils'
import { componentTypes } from '../../../constants'
import type { ExtractPropTypes } from 'vue'
import type { ComponentIndex } from '../../base/composables/use-component-common-props'
/**
* @description 绝对定位的位置配置
*/
export interface BadgeAbsolutePositionConfig {
/**
* @description 上边距
*/
top?: string | number
/**
* @description 右边距
*/
right?: string | number
}
export const badgeProps = buildProps({
/**
* @description 徽标内容,可以是数字或者字符串,当为数字时,超过max会显示{max}+,以icon-开头则显示图标
*/
value: {
type: [String, Number],
},
/**
* @description 徽标内容最大值,在value为number时有效,超过最大值会显示{max}+
*/
max: {
type: [String, Number],
},
/**
* @description 徽标颜色类型
*/
type: {
type: String,
values: componentTypes,
default: 'primary',
},
/**
* @description 徽标背景颜色, 以tn开头则使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description 徽标文字颜色, 以tn开头则使用图鸟内置的颜色
*/
textColor: String,
/**
* @description 徽标大小
*/
size: {
type: [String, Number],
},
/**
* @description 字体大小
*/
fontSize: {
type: [String, Number],
},
/**
* @description 徽标加粗
*/
bold: Boolean,
/**
* @description 自定义徽标样式
*/
customStyle: useComponentCustomStyleProp,
/**
* @description 自定义徽标类
*/
customClass: String,
/**
* @description 是否显示点徽标
*/
dot: Boolean,
/**
* @description 是否绝对定位徽标
*/
absolute: {
type: Boolean,
default: true,
},
/**
* @description 绝对定位的位置
*/
absolutePosition: {
type: definePropType<BadgeAbsolutePositionConfig>(Object),
default: () => ({}),
},
/**
* @description 绝对居中对齐
*/
absoluteCenter: {
type: Boolean,
default: true,
},
/**
* @description 点击标识
*/
index: useComponentIndexProp,
})
export const badgeEmits = {
/**
* @description 点击事件
*/
click: (index: ComponentIndex) =>
typeof index === 'number' || typeof index === 'string',
}
export type BadgeProps = ExtractPropTypes<typeof badgeProps>
export type BadgeEmits = typeof badgeEmits
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue'
import TnIcon from '../../icon/src/icon.vue'
import { badgeEmits, badgeProps } from './badge'
import { useBadge, useBadgeCustomStyle } from './composables'
const props = defineProps(badgeProps)
const emits = defineEmits(badgeEmits)
const slots = useSlots()
const { ns, contentNs, badgeContentClass, badgeContentStyle } =
useBadgeCustomStyle(props)
const { showBadge, contentType, content, badgeClick } = useBadge(props, emits)
// badge所属类
const badgeClass = computed<string>(() => {
const cls: string[] = []
cls.push(ns.b())
if (!slots?.default) {
// 绝对定位
if (props.absolute) {
cls.push(ns.e('absolute'))
if (props.absoluteCenter) cls.push(ns.em('absolute', 'center'))
}
}
return cls.join(' ')
})
</script>
<template>
<view :class="[badgeClass]">
<slot />
<!-- 徽标 -->
<view
v-if="showBadge"
:class="[badgeContentClass]"
:style="badgeContentStyle"
@tap.stop="badgeClick"
>
<template v-if="content">
<TnIcon v-if="contentType === 'icon'" :name="content" />
<span v-else :class="`${contentNs.e('data')}`">{{ content }}</span>
</template>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/badge.scss';
</style>
@@ -0,0 +1,105 @@
import { computed, toRef } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue, isEmpty } from '../../../../utils'
import { useBadge } from './use-badge'
import type { CSSProperties } from 'vue'
import type { BadgeProps } from '../badge'
export const useBadgeCustomStyle = (props: BadgeProps) => {
const ns = useNamespace('badge')
const contentNs = useNamespace('badge-content')
const { contentType } = useBadge(props)
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
// 解析文字颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
// 解析尺寸大小
const { sizeType } = useComponentSize(props.size)
// 徽标内容对应的类
const badgeContentClass = computed<string>(() => {
const cls: string[] = []
cls.push(contentNs.b())
// 点徽标
if (props.dot) cls.push(contentNs.m('dot'))
// 图标徽标
if (contentType.value === 'icon') cls.push(contentNs.m('icon'))
// 绝对定位
if (props.absolute) {
cls.push(contentNs.e('absolute'))
if (props.absoluteCenter) cls.push(contentNs.em('absolute', 'center'))
}
// 设置类型颜色
if (props.type) cls.push(`tn-type-${props.type}_bg`)
// 背景颜色
if (bgColorClass.value) cls.push(bgColorClass.value)
// 字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
// 尺寸大小
if (props.size && sizeType.value === 'inner')
cls.push(contentNs.m(props.size as string))
// 加粗字体
if (props.bold) cls.push('tn-text-bold')
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
})
// 徽标对应的样式
const badgeContentStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 背景颜色
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
// 字体颜色
if (textColorStyle.value) style.color = textColorStyle.value
// 尺寸大小
if (
props.size &&
(sizeType.value === 'custom' || contentType.value === 'icon')
)
style.width = style.height = formatDomSizeValue(props.size)
// 字体尺寸
if (props.fontSize) style.fontSize = formatDomSizeValue(props.fontSize)
// 绝对定位是徽标偏移量
if (props.absolutePosition.top)
style.top = formatDomSizeValue(props.absolutePosition.top)
if (props.absolutePosition.right)
style.right = formatDomSizeValue(props.absolutePosition.right)
if (!isEmpty(props.customStyle)) {
Object.assign(style, props.customStyle)
}
return style
})
return {
ns,
contentNs,
badgeContentClass,
badgeContentStyle,
}
}
@@ -0,0 +1,2 @@
export * from './badge-custom'
export * from './use-badge'
@@ -0,0 +1,52 @@
import { computed } from 'vue'
import { isNumber, isString } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { BadgeEmits, BadgeProps } from '../badge'
export const badgeContentTypes = ['number', 'string', 'icon'] as const
export type BadgeContentType = (typeof badgeContentTypes)[number]
export const useBadge = (
props: BadgeProps,
emits?: SetupContext<BadgeEmits>['emit']
) => {
// 判断是否需要显示角标
const showBadge = computed<boolean>(() => {
return !!props.dot || (props.value !== '' && props.value !== undefined)
})
// 显示的内容类型
const contentType = computed<BadgeContentType>(() => {
let type: BadgeContentType = 'string'
if (isNumber(props.value)) type = 'number'
if (isString(props.value) && props.value.startsWith('icon-')) type = 'icon'
return type
})
// 显示的内容
const content = computed<string>(() => {
if (props.dot) return ''
if (contentType.value === 'number' && props.max) {
const value = Number(props.value || 0)
const max = Number(props.max || 0)
return value > max ? `${max}+` : `${value}`
}
if (contentType.value === 'icon')
return (props.value as string).replace('icon-', '')
return props.value as string
})
// 角标点击事件
const badgeClick = () => {
if (emits) emits('click', props.index)
}
return {
showBadge,
contentType,
content,
badgeClick,
}
}
@@ -0,0 +1,3 @@
import type Badge from './badge.vue'
export type BadgeInstance = InstanceType<typeof Badge>
@@ -0,0 +1,36 @@
import { buildProps } from '../../../../utils'
import { useFormSizeProps } from '../../composables/use-component-common-props'
import { checkboxCheckedShapes } from '../../types/checkbox'
export const checkboxBaseProps = buildProps({
/**
* @description 复选框尺寸
*/
size: useFormSizeProps,
/**
* @description 复选框的形状
*/
checkedShape: {
type: String,
values: checkboxCheckedShapes,
},
/**
* @description 是否禁用
*/
disabled: Boolean,
/**
* @description 是否禁用点击标签切换
*/
labelDisabled: Boolean,
/**
* @description 是否显示边框
*/
border: Boolean,
/**
* @description 激活时的颜色,以tn开头则使用图鸟内置的颜色只支持普通颜色
*/
activeColor: {
type: String,
default: '',
},
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
import { formComponentSizes } from '../../../../constants'
export const formMetaProps = buildProps({
/**
* @description 设置表单下组件的尺寸
*/
size: {
type: String,
values: formComponentSizes,
},
/**
* @description 是否禁用表单内的所有组件,优先级比组件自身的禁用属性高
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,52 @@
import { buildProps } from '../../../../utils'
import { ZIndex } from '../../../../constants'
export const pickerBaseProps = buildProps({
/**
* @description 显示取消按钮
*/
showCancel: {
type: Boolean,
default: true,
},
/**
* @description 取消按钮的文本
*/
cancelText: {
type: String,
default: '取 消',
},
/**
* @description 取消按钮的字体颜色,支持图鸟内置的字体颜色
*/
cancelColor: String,
/**
* @description 显示确定按钮
*/
showConfirm: {
type: Boolean,
default: true,
},
/**
* @description 确定按钮的文本
*/
confirmText: {
type: String,
default: '确 定',
},
/**
* @description 确定按钮的字体颜色,支持图鸟内置的字体颜色
*/
confirmColor: String,
/**
* @description 显示遮罩
*/
mask: Boolean,
/**
* zIndex
*/
zIndex: {
type: Number,
default: ZIndex.popup,
},
} as const)
@@ -0,0 +1,34 @@
import { buildProps } from '../../../../utils'
import type { ExtractPropTypes } from 'vue'
export const propgressBaseProps = buildProps({
/**
* @description 当前进度百分比
*/
percent: {
type: Number,
default: 0,
},
/**
* @description 激活时的颜色,以tn开头则使用图鸟内置的颜色,在为圆环进度条是无法使用内置的颜色
*/
activeColor: String,
/**
* @description 未激活时的颜色,以tn开头则使用图鸟内置的颜色,在为圆环进度条是无法使用内置的颜色
*/
inactiveColor: String,
/**
* @description 显示当前进度
*/
showPercent: Boolean,
/**
* @description 动画执行时间,单位ms
*/
duration: {
type: Number,
default: 1500,
},
} as const)
export type ProgressBaseProps = ExtractPropTypes<typeof propgressBaseProps>
@@ -0,0 +1,28 @@
import { buildProps } from '../../../../utils'
import { useFormSizeProps } from '../../composables/use-component-common-props'
export const radioBaseProps = buildProps({
/**
* @description radio单选框尺寸
*/
size: useFormSizeProps,
/**
* @description radio单选框是否禁用
*/
disabled: Boolean,
/**
* @description radio禁止点击标签进行选择
*/
labelDisabled: Boolean,
/**
* @description 是否显示边框
*/
border: Boolean,
/**
* @description radio激活时的颜色,以tn开头则使用图鸟内置的颜色只支持普通颜色
*/
activeColor: {
type: String,
default: '',
},
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
export const stepsBaseProps = buildProps({
/**
* @description 默认颜色,以tn开头使用图鸟内置颜色
*/
color: String,
/**
* @description 激活时颜色,以tn开头使用图鸟内置颜色
*/
activeColor: String,
/**
* @description 禁止点击
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,16 @@
import { buildProps } from '../../../../utils'
export const subsectionBaseProps = buildProps({
/**
* @description 默认颜色,以tn开头使用图鸟内置的颜色
*/
color: String,
/**
* @description 激活时的颜色,以tn开头使用图鸟内置的颜色
*/
activeColor: String,
/**
* @description 是否禁用
*/
disabled: Boolean,
} as const)
@@ -0,0 +1,11 @@
import { buildProps } from '../../../../utils'
export const swipeActionBaseProps = buildProps({
/**
* @description 自动关闭菜单
*/
autoClose: {
type: Boolean,
default: true,
},
} as const)
@@ -0,0 +1,20 @@
import { buildProps } from '../../../../utils'
export const tabbarBaseProps = buildProps({
/**
* @description 未选中时的颜色
*/
inactiveColor: String,
/**
* @description 选中时的颜色
*/
activeColor: String,
/**
* @description 图标大小
*/
iconSize: String,
/**
* @description 文字大小
*/
fontSize: String,
} as const)
@@ -0,0 +1,20 @@
import { buildProps } from '../../../../utils'
export const tabsBaseProps = buildProps({
/**
* @description 默认颜色,以tn开头时使用图鸟内置的颜色
*/
color: String,
/**
* @description 选中颜色,以tn开头时使用图鸟内置的颜色
*/
activeColor: String,
/**
* @description 字体大小
*/
fontSize: String,
/**
* @description 选中字体大小
*/
activeFontSize: String,
} as const)
@@ -0,0 +1,54 @@
import { buildProp, definePropType, generateId } from '../../../../utils'
import { componentSizes, formComponentSizes } from '../../../../constants'
export type ComponentIndex = string | number
/**
* @description 组件Boolean类型定义
*/
export const useComponentBoolean = buildProp({
type: [Boolean, undefined],
default: undefined,
})
/**
* @description 组件尺寸
*/
export const useComponentSizeProp = buildProp({
type: String,
values: componentSizes,
required: false,
} as const)
/**
* @description 表单组件尺寸
*/
export const useFormSizeProps = buildProp({
type: String,
values: formComponentSizes,
required: false,
} as const)
/**
* @description 组件自定义样式
*/
export const useComponentCustomStyleProp = buildProp({
type: Object,
default: () => ({}),
})
/**
* @description 组件自定义index
*/
export const useComponentIndexProp = buildProp({
type: definePropType<ComponentIndex>([String, Number]),
default: () => generateId(),
})
/**
* @description 组件是否开启底部安全区域
*/
export const useComponentSafeAreaInsetBottomProp = buildProp({
type: Boolean,
default: true,
})
@@ -0,0 +1,48 @@
import { ref, toRef, watch } from 'vue'
import { useComponentColor } from '../../../../hooks'
import type { ProgressBaseProps } from '../../common-props/progress'
interface ProgressPropsType extends ProgressBaseProps {
[key: string]: any
}
export const useProgressProps = (props: ProgressPropsType) => {
const [activeColorClass, activeColorStyle] = useComponentColor(
toRef(props, 'activeColor'),
'bg'
)
const [inactiveColorClass, inactiveColorStyle] = useComponentColor(
toRef(props, 'inactiveColor'),
'bg'
)
// 当前的进度百分比
const percent = ref(0)
let firstInitPercent = true
// 为了有动画效果,需要在下一次渲染时再设置进度条的长度
watch(
() => props.percent,
(val) => {
if (!firstInitPercent) {
percent.value = val
} else {
setTimeout(() => {
percent.value = val
firstInitPercent = false
}, 50)
}
},
{
immediate: true,
}
)
return {
percent,
activeColorClass,
activeColorStyle,
inactiveColorClass,
inactiveColorStyle,
}
}
@@ -0,0 +1,4 @@
export const checkboxCheckedShapes = ['square', 'circle'] as const
export type CheckboxCheckedShape = (typeof checkboxCheckedShapes)[number]
export type CheckboxValueType = string | number | boolean
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import BubbleBox from './src/bubble-box.vue'
export const TnBubbleBox = withNoopInstall(BubbleBox)
export default TnBubbleBox
export * from './src/bubble-box'
export type { TnBubbleBoxInstance } from './src/instance'
@@ -0,0 +1,102 @@
import { ZIndex } from '../../../constants'
import { buildProps, definePropType, isNumber } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export interface BubbleBoxOptionItem {
/**
* @description 内容
*/
text: string
/**
* @description 图标
*/
icon?: string
/**
* @description 气泡弹出框文字颜色,以tn开头使用图鸟内置的颜色
*/
textColor?: string
/**
* @description 禁止点击
*/
disabled?: boolean
}
export type BubbleBoxOption = BubbleBoxOptionItem[]
export const bubbleBoxPosition = ['top', 'bottom', 'left', 'right'] as const
export const bubbleBoxProps = buildProps({
/**
* @description 气泡弹出框选项数据
*/
options: {
type: definePropType<BubbleBoxOption>(Array),
default: () => [],
},
/**
* @description 气泡弹出框位置
*/
position: {
type: String,
values: bubbleBoxPosition,
default: 'top',
},
/**
* @description 气泡弹出框宽度,默认单位 rpx
*/
width: String,
/**
* @description 气泡弹出框高度,默认单位 rpx
*/
height: String,
/**
* @description 气泡弹出框背景颜色,以tn开头使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description 气泡弹出框文字颜色,以tn开头使用图鸟内置的颜色
*/
textColor: String,
/**
* @description 选项的内边距
*/
optionItemPadding: String,
/**
* @description 是否禁止点击气泡弹出框选项
*/
disabled: Boolean,
/**
* @description 点击选项后自动关闭气泡弹出框
*/
autoClose: {
type: Boolean,
default: true,
},
/**
* @description ZIndex
*/
zIndex: {
type: Number,
default: ZIndex.bubble,
},
})
export const bubbleBoxEmits = {
/**
* @description 气泡框打开事件
*/
open: () => true,
/**
* @description 气泡框关闭事件
*/
close: () => true,
/**
* @description 气泡框选项点击事件
*/
click: (index: number) => isNumber(index),
}
export type BubbleBoxProps = ExtractPropTypes<typeof bubbleBoxProps>
export type BubbleBoxEmits = typeof bubbleBoxEmits
export type BubbleBoxPosition = (typeof bubbleBoxPosition)[number]
@@ -0,0 +1,75 @@
<script lang="ts" setup>
import TnOverlay from '../../overlay/src/overlay.vue'
import TnIcon from '../../icon/src/icon.vue'
import { bubbleBoxEmits, bubbleBoxProps } from './bubble-box'
import {
useBubbleBox,
useBubbleBoxCustomStyle,
useBubbleOptions,
} from './composables'
const props = defineProps(bubbleBoxProps)
const emits = defineEmits(bubbleBoxEmits)
const {
showBubble,
openBubbleOptions,
closeBubbleOptions,
bubbleOptionClickEvent,
} = useBubbleBox(props, emits)
const {
ns,
optionsClass,
optionsStyle,
optionsAuxiliaryElementClass,
optionsAuxiliaryElementStyle,
optionItemClass,
optionItemStyle,
} = useBubbleBoxCustomStyle(props, showBubble)
const { bubbleOptions } = useBubbleOptions(props)
</script>
<template>
<view :class="[ns.b()]">
<!-- 遮罩 -->
<TnOverlay
:show="showBubble"
:opacity="0"
:z-index="zIndex - 1"
@click="closeBubbleOptions"
/>
<!-- 内容 -->
<view :class="[ns.e('content')]" @tap.stop="openBubbleOptions">
<slot />
<!-- 气泡弹框选项 -->
<view :class="[optionsClass]" :style="optionsStyle">
<!-- 小三角 -->
<view
:class="[optionsAuxiliaryElementClass]"
:style="optionsAuxiliaryElementStyle"
/>
<!-- 选项内容 -->
<scroll-view class="scroll-view" scroll-y>
<view class="options-content">
<view
v-for="(item, index) in bubbleOptions"
:key="index"
:class="[optionItemClass(item)]"
:style="optionItemStyle(item)"
@tap.stop="bubbleOptionClickEvent(item, index)"
>
<view v-if="item.icon" class="icon">
<TnIcon :name="item.icon" />
</view>
<view class="text">{{ item.text }}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/bubble-box.scss';
</style>
@@ -0,0 +1,131 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import type { CSSProperties, Ref } from 'vue'
import type { BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionItemData } from '../types'
type OptionsClassType = (item: BubbleBoxOptionItemData) => string
type OptionsStyleType = (item: BubbleBoxOptionItemData) => CSSProperties
export const useBubbleBoxCustomStyle = (
props: BubbleBoxProps,
showBubble: Ref<boolean>
) => {
const ns = useNamespace('bubble-box')
// 解析颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'border'
)
// 选项的类
const optionsClass = computed<string>(() => {
const cls: string[] = [
ns.e('options'),
ns.em('options', props.position),
ns.is('show', showBubble.value),
]
if (bgColorClass.value) cls.push(bgColorClass.value)
return cls.join(' ')
})
// 选项的样式
const optionsStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!bgColorClass.value)
style.backgroundColor = bgColorStyle.value || 'var(--tn-color-white)'
if (props.zIndex) style.zIndex = props.zIndex
if (props.width) style.width = formatDomSizeValue(props.width)
if (props.height) style.height = formatDomSizeValue(props.height)
return style
})
// 选项辅助元素的类
const optionsAuxiliaryElementClass = computed<string>(() => {
const cls: string[] = ['auxiliary-element']
if (borderColorClass.value) cls.push(borderColorClass.value)
return cls.join(' ')
})
// 选项辅助元素的样式
const optionsAuxiliaryElementStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!borderColorClass.value)
style.borderColor = borderColorStyle.value || 'var(--tn-color-white)'
// 根据不同的位置,设置不同的边框样式
if (props.position === 'top') {
style.borderRightColor = 'transparent'
style.borderBottomColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'right') {
style.borderTopColor = 'transparent'
style.borderBottomColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'bottom') {
style.borderTopColor = 'transparent'
style.borderRightColor = 'transparent'
style.borderLeftColor = 'transparent'
}
if (props.position === 'left') {
style.borderTopColor = 'transparent'
style.borderRightColor = 'transparent'
style.borderBottomColor = 'transparent'
}
return style
})
// 选项item的类
const optionItemClass = computed<OptionsClassType>(() => {
return (item: BubbleBoxOptionItemData) => {
const cls: string[] = [
ns.e('option-item'),
ns.is('disabled', item.disabled),
]
if (item.color.class) cls.push(item.color.class)
return cls.join(' ')
}
})
// 选项item的样式
const optionItemStyle = computed<OptionsStyleType>(() => {
return (item: BubbleBoxOptionItemData) => {
const style: CSSProperties = {}
if (!item.color.class)
style.color = item.color.style || 'var(--tn-text-color-primary)'
if (props.optionItemPadding) style.padding = props.optionItemPadding
return style
}
})
return {
ns,
optionsClass,
optionsStyle,
optionsAuxiliaryElementClass,
optionsAuxiliaryElementStyle,
optionItemClass,
optionItemStyle,
}
}
@@ -0,0 +1,3 @@
export * from './bubble-box-custom'
export * from './use-bubble-options'
export * from './use-bubble-box'
@@ -0,0 +1,41 @@
import { ref } from 'vue'
import type { SetupContext } from 'vue'
import type { BubbleBoxEmits, BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionItemData } from '../types'
export const useBubbleBox = (
props: BubbleBoxProps,
emits: SetupContext<BubbleBoxEmits>['emit']
) => {
// 显示气泡弹出框
const showBubble = ref<boolean>(false)
const openBubbleOptions = () => {
emits('open')
showBubble.value = true
}
// 关闭气泡弹出框
const closeBubbleOptions = () => {
emits('close')
showBubble.value = false
}
// 气泡框选项点击事件
const bubbleOptionClickEvent = (
item: BubbleBoxOptionItemData,
index: number
) => {
if (props.disabled || item.disabled) return
emits('click', index)
if (props.autoClose) closeBubbleOptions()
}
return {
showBubble,
openBubbleOptions,
closeBubbleOptions,
bubbleOptionClickEvent,
}
}
@@ -0,0 +1,33 @@
import { computed, ref } from 'vue'
import { useComponentColor } from '../../../../hooks'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { BubbleBoxProps } from '../bubble-box'
import type { BubbleBoxOptionData } from '../types'
export const useBubbleOptions = (props: BubbleBoxProps) => {
const bubbleOptions = computed<BubbleBoxOptionData>(() => {
return props.options.map((item) => {
const textColor = ref(
isEmptyDoubleVariableInDefault(item.textColor, props.textColor)
)
const [textColorClass, textColorStyle] = useComponentColor(
textColor,
'text'
)
return {
text: item.text || '',
icon: item?.icon || '',
disabled: props.disabled || item.disabled || false,
color: {
class: textColorClass.value,
style: textColorStyle.value,
},
}
})
})
return {
bubbleOptions,
}
}
@@ -0,0 +1,3 @@
import type BubbleBox from './bubble-box.vue'
export type TnBubbleBoxInstance = InstanceType<typeof BubbleBox>
@@ -0,0 +1,13 @@
export interface BubbleBoxOptionItemData {
text: string
icon: string
disabled: boolean
color: BubbleBoxOptionDataColor
}
export interface BubbleBoxOptionDataColor {
class: string
style: string
}
export type BubbleBoxOptionData = BubbleBoxOptionItemData[]
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import Button from './src/button.vue'
export const TnButton = withNoopInstall(Button)
export default TnButton
export * from './src/button'
export type { TnButtonInstance } from './src/instance'
+268
View File
@@ -0,0 +1,268 @@
import { buildProps, iconPropType } from '../../../utils'
import { componentShapes, componentTypes } from '../../../constants'
import {
useComponentCustomStyleProp,
useComponentSizeProp,
} from '../../base/composables/use-component-common-props'
import type { ExtractPropTypes } from 'vue'
/**
* 按钮FormType有效值
*/
export const buttonFormTypes = ['submit', 'reset'] as const
/**
* 按钮OpenType有效值
*/
export const buttonOpenTypes = [
'feedback',
'share',
'contact',
'getPhoneNumber',
'getRealtimePhoneNumber',
'launchApp',
'openSetting',
'getUserInfo',
'chooseAvatar',
'agreePrivacyAuthorization',
] as const
export const buttonProps = buildProps({
/**
* @description 按钮宽度
*/
width: {
type: [String, Number],
},
/**
* @description 按钮高度
*/
height: {
type: [String, Number],
},
/**
* @description 按钮尺寸
*/
size: useComponentSizeProp,
/**
* @description 按钮形状
*/
shape: {
type: String,
values: componentShapes,
default: '',
},
/**
* @description 按钮颜色类型
*/
type: {
type: String,
values: componentTypes,
default: 'primary',
},
/**
* @description 按钮图标
*/
icon: {
type: iconPropType,
},
/**
* @description 是否加粗字体
*/
bold: Boolean,
/**
* @description 字体大小
*/
fontSize: {
type: [String, Number],
},
/**
* @description 背景颜色,以tn开头则使用图鸟内置的颜色
*/
bgColor: String,
/**
* @description 文字颜色,以tn开头则使用图鸟内置的颜色
*/
textColor: String,
/**
* @description 是否显示为文本按钮
*/
text: Boolean,
/**
* @description 是否为朴素按钮
*/
plain: Boolean,
/**
* @description 边框颜色,以tn开头则使用图鸟内置的颜色
*/
borderColor: String,
/**
* @description 是否加粗边框
*/
borderBold: Boolean,
/**
* @description 是否显示阴影
*/
shadow: Boolean,
/**
* @description 阴影颜色,以tn开头则使用图鸟内置的颜色
*/
shadowColor: String,
/**
* @description 点击时触发的类
*/
hoverClass: {
type: String,
default: 'tn-u-btn-hover',
},
/**
* @description 自定义样式
*/
customStyle: useComponentCustomStyleProp,
/**
* @description 自定义类
*/
customClass: String,
/**
* @description 是否禁用按钮
*/
disabled: Boolean,
/**
* @description 是否只为一个按钮,不作用任何样式
*/
onlyButton: Boolean,
/**
* @description 是否显示加载中
*/
loading: Boolean,
/**
* @description 是否防抖
*/
debounce: {
type: Boolean,
default: false,
},
/**
* @description 触发form表单的事件类型
*/
formType: {
type: String,
values: buttonFormTypes,
},
/**
* @description 按钮开放能力,具体能力参考官网https://uniapp.dcloud.io/component/button.html
*/
openType: {
type: String,
values: buttonOpenTypes,
},
/**
* @description 打开app时向app传递的参数, 在微信、QQ小程序和openType为launchApp时生效
*/
appParameter: {
type: String,
default: '',
},
/**
* @description 会话来源, 在微信小程序和openType为contact时生效
*/
sessionFrom: {
type: String,
default: '',
},
/**
* @description 会话内消息卡片标题, 默认为当前标题, 在微信小程序和openType为contact时生效
*/
sendMessageTitle: {
type: String,
default: '',
},
/**
* @description 会话内消息卡片点击跳转小程序路径, 默认为当前路径, 在微信小程序和openType为contact时生效
*/
sendMessagePath: {
type: String,
default: '',
},
/**
* @description 会话内消息卡片图片, 默认为截图, 在微信小程序和openType为contact时生效
*/
sendMessageImg: {
type: String,
default: '',
},
/**
* @description 是否显示会话内消息卡片, 设置此参数为true, 用户进入客服会话会在右下角显示"可能要发送的小程序"提示, 用户点击后可以快速发送小程序消息, 在微信小程序和openType为contact时生效
*/
showMessageCard: {
type: Boolean,
default: false,
},
/**
* @description 当手机号快速验证或手机号实时验证额度用尽时,是否对用户展示“申请获取你的手机号,但该功能使用次数已达当前小程序上限,暂时无法使用”的提示
*/
phoneNumberNoQuotaToast: {
type: Boolean,
default: true,
},
clickModifiers: {
type: String,
},
})
export const buttonEmits = {
/**
* @description 按钮点击事件
*/
click: () => true,
/**
* @description 获取用户手机号码回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getphonenumber: (e: any) => true,
/**
* @description 获取用户手机号实时验证回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getrealtimephonenumber: (e: any) => true,
/**
* @description 打开权限设置面板并关闭时回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
opensetting: (e: any) => true,
/**
* @description 打开APP成功时回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
launchapp: (e: any) => true,
/**
* @description 获取用户信息回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getuserinfo: (e: any) => true,
/**
* @description 获取用户头像回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
chooseavatar: (e: any) => true,
/**
* @description 同意隐私授权回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
agreeprivacyauthorization: (e: any) => true,
/**
* @description 客服消息回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
contact: (e: any) => true,
/**
* @description 开放能力调用发生错误时回调
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
error: (e: any) => true,
}
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
export type ButtonEmits = typeof buttonEmits
export type ButtonFormType = ButtonProps['formType']
export type ButtonOpenType = ButtonProps['openType']
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import TnLoading from '../../loading/src/loading.vue'
import { buttonEmits, buttonProps } from './button'
import { useButton, useButtonCustomStyle } from './composables'
const props = defineProps(buttonProps)
const emits = defineEmits(buttonEmits)
const {
buttonClick,
getPhoneNumber,
getRealTimePhoneNumber,
openSetting,
launchApp,
getUserInfo,
chooseAvatar,
agreePrivacyAuthorization,
contact,
openTypeError,
} = useButton(props, emits)
const { ns, buttonClass, buttonStyle } = useButtonCustomStyle(props)
</script>
// #ifdef MP-WEIXIN
<script lang="ts">
export default {
options: {
// 在微信小程序中将组件节点渲染为虚拟节点,更加接近Vue组件的表现(不会出现shadow节点下再去创建元素)
virtualHost: true,
},
}
</script>
// #endif
<template>
<button
v-if="props.clickModifiers === 'stop'"
class="tn-u-btn-clear"
:class="[buttonClass]"
:style="buttonStyle"
:hover-class="
props.disabled || props.loading || props.onlyButton ? '' : hoverClass
"
:disabled="disabled"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
@tap.stop="buttonClick"
@getphonenumber="getPhoneNumber"
@getrealtimephonenumber="getRealTimePhoneNumber"
@opensetting="openSetting"
@launchapp="launchApp"
@getuserinfo="getUserInfo"
@chooseavatar="chooseAvatar"
@agreeprivacyauthorization="agreePrivacyAuthorization"
@contact="contact"
@error="openTypeError"
>
<!-- TODO: loading状态 -->
<view v-if="loading" :class="[ns.m('loading')]">
<TnLoading show animation mode="flower" color="tn-gray" />
</view>
<!-- icon显示 -->
<view v-if="icon" :class="[ns.e('icon')]">
<TnIcon :name="icon" />
</view>
<slot v-else />
</button>
<button
v-else
class="tn-u-btn-clear"
:class="[buttonClass]"
:style="buttonStyle"
:hover-class="
props.disabled || props.loading || props.onlyButton ? '' : hoverClass
"
:disabled="disabled"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
@tap="buttonClick"
@getphonenumber="getPhoneNumber"
@getrealtimephonenumber="getRealTimePhoneNumber"
@opensetting="openSetting"
@launchapp="launchApp"
@getuserinfo="getUserInfo"
@chooseavatar="chooseAvatar"
@agreeprivacyauthorization="agreePrivacyAuthorization"
@contact="contact"
@error="openTypeError"
>
<!-- TODO: loading状态 -->
<view v-if="loading" :class="[ns.m('loading')]">
<TnLoading show animation mode="flower" color="tn-gray" />
</view>
<!-- icon显示 -->
<view v-if="icon" :class="[ns.e('icon')]">
<TnIcon :name="icon" />
</view>
<slot v-else />
</button>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/button.scss';
</style>
@@ -0,0 +1,135 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { formatDomSizeValue, isEmpty } from '../../../../utils'
import type { CSSProperties } from 'vue'
import type { ButtonProps } from '../button'
export const useButtonCustomStyle = (props: ButtonProps) => {
const ns = useNamespace('button')
// 解析背景颜色
const [bgColorClass, bgColorStyle] = useComponentColor(
toRef(props, 'bgColor'),
'bg'
)
// 解析字体颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
// 解析边框颜色
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'borderColor'),
'border'
)
// 解析阴影颜色
const [shadowColorClass, shadowColorStyle] = useComponentColor(
toRef(props, 'shadowColor'),
'shadow'
)
// 按钮动态类
const buttonClass = computed<string>(() => {
const cls: string[] = [ns.b()]
if (props.onlyButton) {
cls.push(ns.m('only-button'))
return cls.join(' ')
}
// 设置文字按钮
if (props.text) cls.push(ns.m('text'))
// 设置朴素按钮
if (props.plain) {
cls.push(ns.m('plain'))
if (props.borderBold) cls.push(ns.m('plain-bold'))
}
// 设置按钮颜色类型
if (props.type) {
if (props.text) {
if (!props.textColor) cls.push(`tn-type-${props.type}_text`)
} else if (props.plain) {
if (!props.borderColor) cls.push(`tn-type-${props.type}_border`)
} else {
if (!props.bgColor) cls.push(`tn-type-${props.type}_bg`)
}
}
// 设置按钮尺寸
if (props.size) cls.push(ns.m(props.size))
// 设置按钮形状
if (!props.text && props.shape) cls.push(ns.m(props.shape))
// 设置字体是否加粗
if (props.bold) cls.push('tn-text-bold')
// 设置背景颜色
if (!props.text && !props.plain) {
if (bgColorClass.value) cls.push(bgColorClass.value)
}
// 设置字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
if (props.plain) {
// 设置边框颜色
if (borderColorClass.value) cls.push(borderColorClass.value)
}
// 设置阴影信息
if (props.shadow) {
cls.push('tn-shadow')
// 设置阴影颜色
if (shadowColorClass.value) cls.push(shadowColorClass.value)
}
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
})
// 按钮样式
const buttonStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (props.onlyButton) return style
// 设置按钮宽高
if (props.width) {
style.width = formatDomSizeValue(props.width)
if (props.shape === 'circle') style.height = style.width
}
if (props.height && props.shape !== 'circle')
style.height = formatDomSizeValue(props.height)
// 设置按钮字体大小
if (props.fontSize) style.fontSize = formatDomSizeValue(props.fontSize)
// 设置背景颜色
if (!props.text && !props.plain) {
if (bgColorStyle.value) style.backgroundColor = bgColorStyle.value
}
// 设置字体颜色
if (textColorStyle.value) {
style.color = textColorStyle.value
}
// 设置边框颜色
if (props.plain && borderColorStyle.value) {
style.borderColor = borderColorStyle.value
}
// 设置阴影颜色
if (props.shadow && shadowColorStyle.value)
style.boxShadow = shadowColorStyle.value
if (!isEmpty(props.customStyle)) {
Object.assign(style, props.customStyle)
}
return style
})
return {
ns,
buttonClass,
buttonStyle,
}
}
@@ -0,0 +1,2 @@
export * from './button-custom'
export * from './use-button'
@@ -0,0 +1,67 @@
import { debounce } from '../../../../libs/lodash'
import type { SetupContext } from 'vue'
import type { ButtonEmits, ButtonProps } from '../button'
export const useButton = (
props: ButtonProps,
emits: SetupContext<ButtonEmits>['emit']
) => {
// 按钮点击事件
const buttonClickHandle = () => {
if (props.disabled || props.loading) return
emits('click')
}
const buttonClick = props.debounce
? debounce(buttonClickHandle, 250)
: buttonClickHandle
// 获取手机号码回调
const getPhoneNumber = (e: any) => {
emits('getphonenumber', e)
}
// 获取手机号实时验证回调
const getRealTimePhoneNumber = (e: any) => {
emits('getrealtimephonenumber', e)
}
// 打开设置面板
const openSetting = (e: any) => {
emits('opensetting', e)
}
// 打开App成功回调
const launchApp = (e: any) => {
emits('launchapp', e)
}
// 获取用户信息回调
const getUserInfo = (e: any) => {
emits('getuserinfo', e)
}
// 获取用户头像回调
const chooseAvatar = (e: any) => {
emits('chooseavatar', e)
}
// 同意隐私授权回调
const agreePrivacyAuthorization = (e: any) => {
emits('agreeprivacyauthorization', e)
}
// 客服消息回调
const contact = (e: any) => {
emits('contact', e)
}
// 当使用开放能力时,发生错误的回调
const openTypeError = (e: any) => {
emits('error', e)
}
return {
buttonClick,
getPhoneNumber,
getRealTimePhoneNumber,
openSetting,
launchApp,
getUserInfo,
chooseAvatar,
agreePrivacyAuthorization,
contact,
openTypeError,
}
}
@@ -0,0 +1,3 @@
import type Button from './button.vue'
export type TnButtonInstance = InstanceType<typeof Button>
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import Calendar from './src/calendar.vue'
export const TnCalendar = withNoopInstall(Calendar)
export default TnCalendar
export * from './src/calendar'
export type { TnCalendarInstance } from './src/instance'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import {
buildProps,
definePropType,
isArray,
isNumber,
isString,
} from '../../../utils'
import type { ExtractPropTypes } from 'vue'
import type { Arrayable } from '../../../utils'
export const calendarModes = ['date', 'multi', 'range'] as const
export type CalendarModelValueType = Arrayable<string>
export const calendarProps = buildProps({
/**
* @description 日期绑定的值,支持的格式为 YYYY/MM/DD 2023/01/01 或者 YYYY-MM-DD 2023-01-01 [2023/01/01, 2023/01/02] [2023-01-01, 2023-01-02]
*/
modelValue: {
type: definePropType<CalendarModelValueType>([String, Array]),
default: '',
},
/**
* @description 选中时的背景颜色,以tn开头使用图鸟内置的颜色
*/
activeBgColor: String,
/**
* @description 选中时的文字颜色,以tn开头使用图鸟内置的颜色
*/
activeTextColor: String,
/**
* @description 在日期范围内的背景颜色,以tn开头使用图鸟内置的颜色
*/
rangeBgColor: String,
/**
* @description 在日期范围内的文字颜色,以tn开头使用图鸟内置的颜色
*/
rangeTextColor: String,
/**
* @description 日期模式
*/
mode: {
type: String,
values: calendarModes,
default: 'date',
},
/**
* @description 日期最小值,支持的格式为 YYYY/MM/DD 或者 YYYY-MM-DD,如果没有设置最小值则以当前日期作为最小值
*/
minDate: String,
/**
* @description 日期最大值,支持的格式为 YYYY/MM/DD 或者 YYYY-MM-DD,如果没有设置最大值则以当前年份最后一天作为最大值
*/
maxDate: String,
/**
* @description 是否允许切换年份
*/
allowChangeYear: {
type: Boolean,
default: true,
},
/**
* @description 是否允许切换月份
*/
allowChangeMonth: {
type: Boolean,
default: true,
},
/**
* @description 是否显示农历
*/
showLunar: Boolean,
/**
* @description 当 mode 为 range 时,开始时间的提示信息
*/
rangeStartDesc: {
type: String,
default: '开始',
},
/**
* @description 当 mode 为 range 时,结束时间的提示信息
*/
rangeEndDesc: {
type: String,
default: '结束',
},
})
export const calendarEmits = {
[UPDATE_MODEL_EVENT]: (value: CalendarModelValueType) =>
isArray(value) || isString(value),
[CHANGE_EVENT]: (value: CalendarModelValueType) =>
isArray(value) || isString(value),
/**
* @description 年份切换事件
*/
'change-year': (year: number) => isNumber(year),
/**
* @description 月份切换事件
*/
'change-month': (month: number) => isNumber(month),
}
export type CalendarProps = ExtractPropTypes<typeof calendarProps>
export type CalendarEmits = typeof calendarEmits
export type CalendarMode = (typeof calendarModes)[number]
@@ -0,0 +1,150 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { calendarEmits, calendarProps } from './calendar'
import { useCalendar, useCalendarCustomStyle } from './composables'
const props = defineProps(calendarProps)
const emit = defineEmits(calendarEmits)
const {
reloadMonthSwiper,
calendarId,
dateContainerHeight,
calendarData,
weekText,
currentMonthIndex,
currentSelectedDate,
minDate,
maxDate,
swiperSwitchMonthEvent,
swiperSwitchMonthAnimationFinishEvent,
dateItemClickEvent,
switchMonth,
switchYear,
} = useCalendar(props, emit)
const { ns, itemClass, itemStyle } = useCalendarCustomStyle(props)
</script>
<template>
<view :id="calendarId" :class="[ns.b(), ns.m(mode)]">
<!-- 操作区域 -->
<view :class="[ns.e('operation')]">
<!-- 年切换按钮 -->
<view
v-if="allowChangeYear && currentSelectedDate.year !== minDate.year"
:class="[ns.e('operation__year-btn')]"
@tap.stop="switchYear('prev')"
>
<TnIcon name="left-triangle" />
</view>
<!-- 年月显示 -->
<view :class="[ns.e('operation__value')]">
{{ currentSelectedDate.year }}
</view>
<!-- 年切换按钮 -->
<view
v-if="allowChangeYear && currentSelectedDate.year !== maxDate.year"
:class="[ns.e('operation__year-btn')]"
@tap.stop="switchYear('next')"
>
<TnIcon name="right-triangle" />
</view>
</view>
<!-- 星期文字提示 -->
<view :class="[ns.e('week-text')]">
<view
v-for="(item, index) in weekText"
:key="index"
:class="[ns.e('week-text__item')]"
>
{{ item }}
</view>
</view>
<!-- 数据展示区域 -->
<view
:class="[ns.e('data')]"
:style="{
height: `${dateContainerHeight ? `${dateContainerHeight}px` : 'auto'}`,
}"
>
<!-- 月份背景 -->
<view :class="[ns.e('data__month-bg')]">
{{ currentSelectedDate.month }}
</view>
<!-- 月份切换按钮 -->
<view
v-if="
allowChangeMonth &&
!(
(currentSelectedDate.year === minDate.year &&
currentSelectedDate.month === minDate.month) ||
currentSelectedDate.month === 1
)
"
class="left"
:class="[ns.e('data__month-btn')]"
@tap.stop="switchMonth('prev')"
>
<TnIcon name="left" />
</view>
<view
v-if="
allowChangeMonth &&
!(
(currentSelectedDate.year === maxDate.year &&
currentSelectedDate.month === maxDate.month) ||
currentSelectedDate.month === 12
)
"
class="right"
:class="[ns.e('data__month-btn')]"
@tap.stop="switchMonth('next')"
>
<TnIcon name="right" />
</view>
<swiper
v-if="!reloadMonthSwiper"
:class="[ns.e('data__swiper')]"
:current="currentMonthIndex"
:indicator-dots="false"
:autoplay="false"
:circular="false"
adjust-height="none"
@change="swiperSwitchMonthEvent"
@animationfinish="swiperSwitchMonthAnimationFinishEvent"
>
<swiper-item
v-for="(item, index) in calendarData"
:key="index"
:class="[ns.e('data__swiper-item')]"
>
<view :class="[ns.e('data__dates')]">
<view
v-for="(dateItem, dateIndex) in item.data"
:key="dateIndex"
:class="[ns.e('data__date'), itemClass(dateItem.status)]"
:style="itemStyle(dateItem.status)"
>
<view
v-if="dateItem.date != 0"
:class="[ns.e('data__date__content')]"
@tap.stop="dateItemClickEvent(dateItem)"
>
<view class="date">{{ dateItem.date }}</view>
<view v-if="dateItem.desc" class="desc">
{{ dateItem.desc }}
</view>
</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/calendar.scss';
</style>
@@ -0,0 +1,94 @@
import { computed, toRef } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import type { CSSProperties } from 'vue'
import type { CalendarProps } from '../calendar'
import type { CalendarItemDateStatus } from '../types'
type itemClassType = (status: CalendarItemDateStatus) => string
type itemStyleType = (status: CalendarItemDateStatus) => CSSProperties
export const useCalendarCustomStyle = (props: CalendarProps) => {
const ns = useNamespace('calendar')
// 解析颜色
const [activeBgColorClass, activeBgColorStyle] = useComponentColor(
toRef(props, 'activeBgColor'),
'bg'
)
const [activeTextColorClass, activeTextColorStyle] = useComponentColor(
toRef(props, 'activeTextColor'),
'text'
)
const [rangeBgColorClass, rangeBgColorStyle] = useComponentColor(
toRef(props, 'rangeBgColor'),
'bg'
)
const [rangeTextColorClass, rangeTextColorStyle] = useComponentColor(
toRef(props, 'rangeTextColor'),
'text'
)
// dateItem对应的类
const itemClass = computed<itemClassType>(() => {
return (status: CalendarItemDateStatus) => {
const cls: string[] = [ns.is(status)]
if (status === 'active') {
if (activeBgColorClass.value) {
cls.push(activeBgColorClass.value)
}
if (activeTextColorClass.value) {
cls.push(activeTextColorClass.value)
}
} else if (status === 'range') {
if (rangeBgColorClass.value) {
cls.push(rangeBgColorClass.value)
}
if (rangeTextColorClass.value) {
cls.push(rangeTextColorClass.value)
}
}
return cls.join(' ')
}
})
// dateItem对应的样式
const itemStyle = computed<itemStyleType>(() => {
return (status: CalendarItemDateStatus) => {
const style: CSSProperties = {}
if (status === 'active') {
if (!activeBgColorClass.value) {
style.backgroundColor =
activeBgColorStyle.value || 'var(--tn-color-primary)'
}
if (activeTextColorStyle.value) {
style.color = activeTextColorStyle.value
} else if (!activeBgColorClass.value && !activeTextColorClass.value) {
style.color = 'var(--tn-color-white)'
}
} else if (status === 'range') {
if (!rangeBgColorClass.value) {
style.backgroundColor =
rangeBgColorStyle.value || 'var(--tn-color-primary-light-7)'
}
if (rangeTextColorStyle.value) {
style.color = rangeTextColorStyle.value
} else if (!rangeBgColorClass.value && !rangeTextColorClass.value) {
style.color = 'var(--tn-color-primary)'
}
}
return style
}
})
return {
ns,
itemClass,
itemStyle,
}
}
@@ -0,0 +1,2 @@
export * from './calendar-custom'
export * from './use-calendar'
@@ -0,0 +1,67 @@
import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
import { useSelectorQuery } from '../../../../hooks'
import { debugWarn, generateId } from '../../../../utils'
import type { Ref } from 'vue'
import type { CalendarMode } from '../calendar'
export const useCalendarSelector = (
currentDateCount: Ref<number>,
mode: CalendarMode
) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnCalendar', '请在 setup 函数中使用 useCalendarSelector')
}
const calendarId = `tc-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 单个日期的高度
const singleDateItemHeight = ref<number>(0)
// 日期容器的高度
const dateContainerHeight = computed<number>(
() => Math.ceil(currentDateCount.value / 7) * singleDateItemHeight.value
)
let initCount = 0
// 获取单个日期容器的信息
const getDateItemComponentRectInfo = async () => {
try {
const rectInfo = await getSelectorNodeInfo(
`#${calendarId} .tn-calendar__data__date`
)
initCount = 0
singleDateItemHeight.value = rectInfo.width || 0
if (mode === 'date' || mode === 'multi') {
singleDateItemHeight.value += uni.upx2px(12)
}
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnCalendar', `获取单个日期容器信息失败: ${err}`)
return
}
initCount++
setTimeout(() => {
getDateItemComponentRectInfo()
}, 150)
}
}
onMounted(() => {
nextTick(() => {
setTimeout(() => {
getDateItemComponentRectInfo()
}, 50)
})
})
return {
calendarId,
dateContainerHeight,
}
}
@@ -0,0 +1,643 @@
import { computed, nextTick, reactive, ref, watch } from 'vue'
import dayjs from '../../../../libs/dayjs'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { debugWarn, isString } from '../../../../utils'
import { solar2lunar } from '../../libs/lunar-calendar'
import { useCalendarSelector } from './use-calendar-selector'
import type { SetupContext } from 'vue'
import type { Dayjs } from '../../../../libs/dayjs'
import type {
CalendarEmits,
CalendarModelValueType,
CalendarProps,
} from '../calendar'
import type {
CalendarData,
CalendarItem,
CalendarItemDateStatus,
CalendarMonthData,
CalendarRangeSelectData,
CalendarSelectDataMap,
DateData,
} from '../types'
// 默认的日期格式
const DEFAULT_DATE_FORMAT = 'YYYY/MM/DD'
// 格式化日期数据,将-替换为/
const formatDate = <T extends CalendarModelValueType>(date: T): T => {
if (!date || !date.length) return date
if (isString(date)) {
return date.replace(/-/g, '/') as T
}
return date.map((item) => item.replace(/-/g, '/')) as T
}
// 填充0
const fillDateZero = (date: number) => String(date).padStart(2, '0')
// 根据年月日生成日期字符串
const generateDateStr = (date: DateData) =>
`${date.year}/${fillDateZero(date.month)}/${fillDateZero(date.date)}`
export const useCalendar = (
props: CalendarProps,
emits: SetupContext<CalendarEmits>['emit']
) => {
// 重新加载月份swiper
const reloadMonthSwiper = ref<boolean>(false)
// 日历的数据
const calendarData = ref<CalendarData>([])
// 星期提示文字
const weekText = ref<string[]>(['日', '一', '二', '三', '四', '五', '六'])
// 最小年月日
const minDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 最大年月日
const maxDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 当前年月日
const currentDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 当前选中的年月日
const currentSelectedDate = reactive<DateData>({
year: 0,
month: 0,
date: 0,
})
// 日期范围内数据
const rangeDate = reactive<CalendarRangeSelectData>({})
// 更新modelValue
// 标记是否需要重新渲染日历
let needRenderCalendar = false
const updateModelValue = (changeEmit = true) => {
const { modelValue, mode } = props
const formatModelValue = formatDate(modelValue)
let value: CalendarModelValueType = ''
// 根据不同的模式,返回不同的值
switch (mode) {
case 'date':
// 如果当前选中的日期不为空,则先将之前选中日期的选中状态修改为normal
if (modelValue) {
const dateDayjs = dayjs(
formatModelValue as string,
DEFAULT_DATE_FORMAT
)
updateDateStatus(dateDayjs, 'normal')
}
// 设置当前选中日期为激活状态
updateDateStatus(currentSelectedDate, 'active')
value = generateDateStr(currentSelectedDate)
break
case 'multi':
// 判断当前选中的日期是否已经存在于modelValue中,如果存在,则将其从modelValue中移除
// eslint-disable-next-line no-case-declarations
const activeIndex = (formatModelValue as string[]).indexOf(
generateDateStr(currentSelectedDate)
)
if (formatModelValue.length && activeIndex !== -1) {
updateDateStatus(currentSelectedDate, 'normal')
;(formatModelValue as string[]).splice(activeIndex, 1)
value = [...(formatModelValue as string[])]
} else {
updateDateStatus(currentSelectedDate, 'active')
value = [
...(formatModelValue as string[]),
generateDateStr(currentSelectedDate),
]
}
break
case 'range':
// 如果已选的值为空或者长度为2,则将当前选中的日期设置为开始日期
if (
!formatModelValue ||
formatModelValue.length === 0 ||
formatModelValue.length === 2
) {
if (formatModelValue.length === 2) {
// 清空原来设置的时间
const startDateDayjs = dayjs(
(formatModelValue as string[])[0],
DEFAULT_DATE_FORMAT
)
const endDateDayjs = dayjs(
(formatModelValue as string[])[1],
DEFAULT_DATE_FORMAT
)
updateDateStatus(startDateDayjs, 'normal')
updateDateStatus(endDateDayjs, 'normal')
}
value = [generateDateStr(currentSelectedDate)]
} else {
// 判断当前选中的日期是否比开始日期还要着,如果是则重新设置开始日期
const currentDate = generateDateStr(currentSelectedDate)
if (rangeDate.start) {
if (rangeDate.start.isAfter(generateDateStr(currentSelectedDate))) {
value = [currentDate]
} else {
// 将选择后的日添加为结束日期
value = [
...(formatModelValue as string[]),
generateDateStr(currentSelectedDate),
]
}
}
}
needRenderCalendar = true
break
}
emits(UPDATE_MODEL_EVENT, value)
if (changeEmit) {
nextTick(() => {
emits(CHANGE_EVENT, value)
})
}
}
// 当前月份在中数据中的索引
const currentMonthIndex = computed(() => {
return calendarData.value.findIndex(
(item) => item.month === currentSelectedDate.month
)
})
// 当前月份中日期数据的长度
const currentMonthDateLength = computed(() => {
return calendarData.value[currentMonthIndex.value]?.data?.length || 0
})
// 处理容器信息
const { calendarId, dateContainerHeight } = useCalendarSelector(
currentMonthDateLength,
props.mode
)
// 保存当前设置的日期信息
const activeDateValueMap = new Map<number, CalendarSelectDataMap>()
// 生成日历数据
const generateCalendarData = () => {
const data: CalendarData = []
// 填充日期数据
const _fillDateData = (
month: number,
minDisabledDate = 0,
maxDisabledDate = 0
) => {
const monthData: CalendarMonthData = {
month,
data: [],
}
// 获取对应年份和月份
const activeDates = activeDateValueMap
.get(currentSelectedDate.year)
?.get(month)
// 获取当前月份的天数
const days = new Date(currentSelectedDate.year, month, 0).getDate()
// 获取当前月份的第一天是星期几
const firstDayWeek = new Date(
`${currentSelectedDate.year}/${month}/1`
).getDay()
// 填充空白数据
for (let i = 0; i < firstDayWeek; i++) {
monthData.data.push({
date: 0,
status: 'disabled',
})
}
// 填充日期数据
for (let i = 1; i <= days; i++) {
let status: CalendarItemDateStatus =
i < minDisabledDate || (maxDisabledDate && i > maxDisabledDate)
? 'disabled'
: 'normal'
if (activeDates?.includes(i)) status = 'active'
let desc = ''
if (props.showLunar) {
const lunarValue = solar2lunar(currentSelectedDate.year, month, i)
if (lunarValue !== -1) {
desc =
lunarValue.IDayCn === '初一'
? lunarValue.IMonthCn
: lunarValue.IDayCn
}
}
// 设置日期范围数据
if (props.mode === 'range') {
const { start, end } = rangeDate
if (
start &&
start.isSame(`${currentSelectedDate.year}/${month}/${i}`)
) {
status = 'active'
desc = props.rangeStartDesc
}
if (end && end.isSame(`${currentSelectedDate.year}/${month}/${i}`)) {
status = 'active'
desc = props.rangeEndDesc
}
if (start && end) {
// 判断是否在范围内
const currentDateDayjs = dayjs(
`${currentSelectedDate.year}/${month}/${i}`,
DEFAULT_DATE_FORMAT
)
if (
currentDateDayjs.isAfter(start) &&
currentDateDayjs.isBefore(end)
) {
status = 'range'
}
}
}
monthData.data.push({
date: i,
status,
desc,
})
}
return monthData
}
// 填充月份数据
let minMonth = 1
let maxMonth = 12
// 如果当前选中的年份等于最小年份,那么最小月份就是最小年份的月份
if (currentSelectedDate.year === minDate.year) {
minMonth = minDate.month
}
if (currentSelectedDate.year === maxDate.year) {
maxMonth = maxDate.month
}
if (minMonth === 0 || maxMonth === 0) return
for (let i = minMonth; i <= maxMonth; i++) {
let minDisabledDate = 0
let maxDisabledDate = 0
// 如果当前选中的年份等于最小年份,那么最小月份就是最小年份的月份
if (currentSelectedDate.year === minDate.year && i === minMonth) {
minDisabledDate = minDate.date
maxDisabledDate = 0
}
if (currentSelectedDate.year === maxDate.year && i === maxMonth) {
minDisabledDate = 0
maxDisabledDate = maxDate.date
}
data.push(_fillDateData(i, minDisabledDate, maxDisabledDate))
}
calendarData.value = data
}
watch(
() => props.modelValue,
(val: CalendarModelValueType) => {
if (val || val.length) {
const { mode } = props
let modelValue: string[] = []
if (mode === 'date') modelValue = [val as string]
else modelValue = val as string[]
activeDateValueMap.clear()
// 遍历获取对应已设置的日期信息
modelValue.forEach((item) => {
const dateDayjs = dayjs(formatDate(item), DEFAULT_DATE_FORMAT)
const year = dateDayjs.year()
const month = dateDayjs.month() + 1
const date = dateDayjs.date()
// 设置当前激活的年份和月份
if (currentSelectedDate.year === 0) {
currentSelectedDate.year = year
currentSelectedDate.month = month
}
if (activeDateValueMap.has(year)) {
let monthDates = activeDateValueMap.get(year)?.get(month)
if (!monthDates) monthDates = [date]
else monthDates.push(date)
activeDateValueMap.get(year)?.set(month, monthDates)
} else {
activeDateValueMap.set(year, new Map([[month, [date]]]))
}
})
// 如果是选择日期范围,则设置开始和结束时间
if (mode === 'range') {
rangeDate.start = undefined
rangeDate.end = undefined
if (modelValue?.[0]) {
// 开始时间
rangeDate.start = dayjs(modelValue[0], DEFAULT_DATE_FORMAT)
}
if (modelValue?.[1]) {
// 结束时间
rangeDate.end = dayjs(modelValue[1], DEFAULT_DATE_FORMAT)
}
}
if (needRenderCalendar) {
needRenderCalendar = false
nextTick(() => {
generateCalendarData()
})
}
}
},
{
immediate: true,
deep: true,
}
)
// 根据传递的日期,设置当前日期的状态
const updateDateStatus = (
dateData: DateData | Dayjs,
status: CalendarItemDateStatus
) => {
let month = 0
let date = 0
if (dateData.toString() === '[object Object]') {
month = (dateData as DateData).month
date = (dateData as DateData).date
} else {
month = (dateData as Dayjs).month() + 1
date = (dateData as Dayjs).date()
}
const monthIndex = calendarData.value.findIndex(
(item) => item.month === month
)
if (monthIndex === -1) return
const dateIndex = calendarData.value[monthIndex].data.findIndex(
(item) => item.date === date
)
if (dateIndex === -1) return
calendarData.value[monthIndex].data[dateIndex].status = status
}
// 初始化最小、最大年月日
const initDateData = () => {
let { minDate: _minDate, maxDate: _maxDate, modelValue, mode } = props
// 当前时间的 dayjs 对象
const currentDayjs = dayjs()
currentDate.year = currentDayjs.year()
currentDate.month = currentDayjs.month() + 1
currentDate.date = currentDayjs.date()
// 如果没有设置最小值,那么最小值就是当前年月日
if (!_minDate) {
_minDate = currentDayjs.format(DEFAULT_DATE_FORMAT)
} else {
_minDate = formatDate(_minDate)
}
const minDateDayjs = dayjs(_minDate, DEFAULT_DATE_FORMAT)
minDate.year = minDateDayjs.year()
minDate.month = minDateDayjs.month() + 1
minDate.date = minDateDayjs.date()
// 如果没有设置最大值,那么最大值就是当前年份最后一个月的最后一天
if (!_maxDate) {
_maxDate = currentDayjs.endOf('year').format(DEFAULT_DATE_FORMAT)
} else {
_maxDate = formatDate(_maxDate)
}
const maxDateDayjs = dayjs(_maxDate, DEFAULT_DATE_FORMAT)
maxDate.year = maxDateDayjs.year()
maxDate.month = maxDateDayjs.month() + 1
maxDate.date = maxDateDayjs.date()
let noGenerateAfterInit = false
// 如果没有设置默认值,那么默认值就是当前年月
if (!modelValue || !modelValue?.length) {
currentSelectedDate.year = Math.max(currentDate.year, minDate.year)
currentSelectedDate.month =
currentDate.year === minDate.year
? Math.max(currentDate.month, minDate.month)
: currentDate.month
// 如果当前是单选日期模式,那么默认选中当前日期
if (
mode === 'date' &&
currentSelectedDate.year === currentDate.year &&
currentSelectedDate.month === currentDate.month
) {
currentSelectedDate.date = currentDate.date
emits(UPDATE_MODEL_EVENT, generateDateStr(currentSelectedDate))
}
} else {
let initDefaultDate = ''
switch (mode) {
case 'date':
initDefaultDate = formatDate(modelValue as string)
// 判断设置的默认值是否在最小、最大年月日范围内,如果不在,那么默认值就是最小、最大年月日
if (minDateDayjs.isAfter(initDefaultDate)) {
initDefaultDate = minDateDayjs.format(DEFAULT_DATE_FORMAT)
emits(UPDATE_MODEL_EVENT, initDefaultDate)
}
if (maxDateDayjs.isBefore(initDefaultDate)) {
initDefaultDate = maxDateDayjs.format(DEFAULT_DATE_FORMAT)
emits(UPDATE_MODEL_EVENT, initDefaultDate)
}
break
case 'multi':
// 筛选出在最小、最大年月日范围内的日期
// eslint-disable-next-line no-case-declarations
const multiDefaultModelValue = (modelValue as string[]).filter(
(date) => {
const dateDayjs = dayjs(date)
return (
(dateDayjs.isAfter(minDateDayjs) &&
dateDayjs.isBefore(maxDateDayjs)) ||
dateDayjs.isSame(minDateDayjs) ||
dateDayjs.isSame(maxDateDayjs)
)
}
)
if (
multiDefaultModelValue.length !== (modelValue as string[]).length
) {
emits(UPDATE_MODEL_EVENT, multiDefaultModelValue)
}
initDefaultDate = multiDefaultModelValue[0]
break
case 'range':
if (modelValue.length !== 2) {
debugWarn('TnCalendar', '在 range 模式下 modelValue 长度必须为 2')
return
}
// eslint-disable-next-line no-case-declarations
const rangeMinDateDayjs = dayjs((modelValue as string[])[0])
// eslint-disable-next-line no-case-declarations
const rangeMaxDateDayjs = dayjs((modelValue as string[])[1])
// 判断是否超过最大范围
if (
rangeMinDateDayjs.isAfter(maxDateDayjs) ||
rangeMaxDateDayjs.isBefore(minDateDayjs)
) {
debugWarn('TnCalendar', '在 range 模式下 modelValue 超过最大范围')
return
}
// eslint-disable-next-line no-case-declarations
const rangeDefaultModelValue = modelValue as string[]
if (rangeMinDateDayjs.isBefore(minDateDayjs)) {
rangeDefaultModelValue[0] = minDateDayjs.format(DEFAULT_DATE_FORMAT)
initDefaultDate = rangeDefaultModelValue[0]
}
if (rangeMaxDateDayjs.isAfter(maxDateDayjs)) {
rangeDefaultModelValue[1] = maxDateDayjs.format(DEFAULT_DATE_FORMAT)
}
emits(UPDATE_MODEL_EVENT, rangeDefaultModelValue)
noGenerateAfterInit = true
break
}
if (initDefaultDate) {
const initDefaultDateDayjs = dayjs(initDefaultDate, DEFAULT_DATE_FORMAT)
currentSelectedDate.year = initDefaultDateDayjs.year()
currentSelectedDate.month = initDefaultDateDayjs.month() + 1
currentSelectedDate.date = initDefaultDateDayjs.date()
}
}
if (!noGenerateAfterInit || calendarData.value.length === 0) {
nextTick(() => {
// 生成日历数据
generateCalendarData()
})
}
}
initDateData()
// 如果修改了最小、最大年月日,那么重新初始化最小、最大年月日
watch(
() => [props.minDate, props.maxDate],
() => {
initDateData()
}
)
// dateItem点击事件
const dateItemClickEvent = (date: CalendarItem) => {
if (
(date.status === 'active' && props.mode === 'date') ||
date.status === 'disabled' ||
date.date === 0
)
return
currentSelectedDate.date = date.date
updateModelValue()
}
// 滑动切换月份事件
const swiperSwitchMonthEvent = (event: any) => {
const { current } = event.detail
currentSelectedDate.month = calendarData.value[current].month
clearSwiperAnimationFlagTimer = setTimeout(() => {
swiperSwitchAnimationFinish = true
if (clearSwiperAnimationFlagTimer) {
clearTimeout(clearSwiperAnimationFlagTimer)
clearSwiperAnimationFlagTimer = null
}
}, 300)
emits('change-month', currentSelectedDate.month)
}
// 月份切换动画结束
let swiperSwitchAnimationFinish = true
let clearSwiperAnimationFlagTimer: ReturnType<typeof setTimeout> | null = null
const swiperSwitchMonthAnimationFinishEvent = () => {
swiperSwitchAnimationFinish = true
if (clearSwiperAnimationFlagTimer) {
clearTimeout(clearSwiperAnimationFlagTimer)
clearSwiperAnimationFlagTimer = null
}
}
// 切换月份
const switchMonth = (type: 'prev' | 'next') => {
if (!props.allowChangeMonth || !swiperSwitchAnimationFinish) return
swiperSwitchAnimationFinish = false
if (type === 'prev') {
if (
(currentSelectedDate.year === minDate.year &&
currentSelectedDate.month === minDate.month) ||
currentSelectedDate.month === 1
)
return
currentSelectedDate.month--
} else {
if (
(currentSelectedDate.year === maxDate.year &&
currentSelectedDate.month === maxDate.month) ||
currentSelectedDate.month === 12
)
return
currentSelectedDate.month++
}
}
// 切换年份
const switchYear = (type: 'prev' | 'next') => {
if (!props.allowChangeYear) return
if (type === 'prev') {
if (currentSelectedDate.year === minDate.year) return
currentSelectedDate.year--
currentSelectedDate.month = 12
currentSelectedDate.date = 31
} else {
if (currentSelectedDate.year === maxDate.year) return
currentSelectedDate.year++
currentSelectedDate.month = 1
currentSelectedDate.date = 1
}
emits('change-year', currentSelectedDate.year)
reloadMonthSwiper.value = true
setTimeout(() => {
reloadMonthSwiper.value = false
}, 0)
// 重新生产日历数据
generateCalendarData()
}
return {
reloadMonthSwiper,
calendarId,
weekText,
calendarData,
currentMonthIndex,
dateContainerHeight,
minDate,
maxDate,
currentSelectedDate,
swiperSwitchMonthEvent,
swiperSwitchMonthAnimationFinishEvent,
dateItemClickEvent,
switchMonth,
switchYear,
}
}
@@ -0,0 +1,3 @@
import type Calendar from './calendar.vue'
export type TnCalendarInstance = InstanceType<typeof Calendar>
+43
View File
@@ -0,0 +1,43 @@
import type { Dayjs } from '../../../libs/dayjs'
export type CalendarItemDateStatus = 'normal' | 'active' | 'range' | 'disabled'
export interface CalendarItem {
/**
* @description 日期
*/
date: number
/**
* @description 日期状态
*/
status: CalendarItemDateStatus
/**
* @description 日期描述,可以存放农历、开始结束提示信息
*/
desc?: string
}
export interface CalendarMonthData {
/**
* @description 月份
*/
month: number
/**
* @description 月份的数据
*/
data: CalendarItem[]
}
export type CalendarData = CalendarMonthData[]
export interface DateData {
year: number
month: number
date: number
}
export type CalendarSelectDataMap = Map<number, number[]>
export type CalendarRangeSelectData = {
start?: Dayjs
end?: Dayjs
}
+13
View File
@@ -0,0 +1,13 @@
import { withInstall, withNoopInstall } from '../../utils'
import Checkbox from './src/checkbox.vue'
import CheckboxGroup from './src/checkbox-group.vue'
export const TnCheckbox = withInstall(Checkbox, {
CheckboxGroup,
})
export default TnCheckbox
export const TnCheckboxGroup = withNoopInstall(CheckboxGroup)
export * from './src/checkbox'
export * from './src/checkbox-group'
export type { CheckboxInstance, CheckboxGroupInstance } from './src/instance'
@@ -0,0 +1,49 @@
import { buildProps, definePropType, isArray } from '../../../utils'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import { checkboxBaseProps } from '../../base/common-props/checkbox'
import type { ExtractPropTypes } from 'vue'
import type { CheckboxValueType } from '../../base/types/checkbox'
export type CheckboxGroupValueType = Exclude<CheckboxValueType, boolean>[]
export const checkboxGroupProps = buildProps({
...checkboxBaseProps,
/**
* @description 绑定的值
*/
modelValue: {
type: definePropType<CheckboxGroupValueType>(Array),
default: () => [],
},
/**
* @description 可被勾选的复选框最小值
*/
min: Number,
/**
* @description 可被勾选的复选框最大值
*/
max: Number,
/**
* @description 每个选项独占一行
*/
wrap: {
type: Boolean,
default: false,
},
/**
* @description 值发生修改时是否触发表单验证
*/
validateEvent: {
type: Boolean,
default: true,
},
})
export const checkboxGroupEmits = {
[UPDATE_MODEL_EVENT]: (value: CheckboxGroupValueType) => isArray(value),
[CHANGE_EVENT]: (value: CheckboxGroupValueType) => isArray(value),
}
export type CheckboxGroupProps = ExtractPropTypes<typeof checkboxGroupProps>
export type CheckboxGroupEmits = typeof checkboxGroupEmits
@@ -0,0 +1,32 @@
<script lang="ts" setup>
import { provide, reactive, toRefs } from 'vue'
import { checkboxGroupKey } from '../../../tokens'
import { useNamespace } from '../../../hooks'
import { checkboxGroupEmits, checkboxGroupProps } from './checkbox-group'
import { useCheckboxGroup } from './composables'
const props = defineProps(checkboxGroupProps)
const emits = defineEmits(checkboxGroupEmits)
const ns = useNamespace('checkbox-group')
const { changeEvent } = useCheckboxGroup(props, emits)
provide(
checkboxGroupKey,
reactive({
...toRefs(props),
changeEvent,
})
)
</script>
<template>
<view :class="[ns.b(), ns.is('wrap', props.wrap)]">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/checkbox-group.scss';
</style>
@@ -0,0 +1,67 @@
import { buildProps, isBoolean, isNumber, isString } from '../../../utils'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import { checkboxBaseProps } from '../../base/common-props/checkbox'
import { useComponentCustomStyleProp } from '../../base/composables/use-component-common-props'
import type { ExtractPropTypes } from 'vue'
import type { CheckboxValueType } from '../../base/types/checkbox'
export const checkboxProps = buildProps({
...checkboxBaseProps,
/**
* @description 绑定的值
*/
modelValue: {
type: [String, Number, Boolean],
default: undefined,
},
/**
* @description 用于标记多个复选框时的唯一标识
*/
label: {
type: [String, Number],
},
/**
* @description 用于标记当前复选框是否为不确定状态,一般用于全选
*/
indeterminate: Boolean,
/**
* @description 复选框选中时的值
*/
activeValue: {
type: [String, Number, Boolean],
default: true,
},
/**
* @description 复选框未选中时的值
*/
inactiveValue: {
type: [String, Number, Boolean],
default: false,
},
/**
* @description 自定义样式
*/
customStyle: useComponentCustomStyleProp,
/**
* @description 自定义类名
*/
customClass: String,
/**
* @description 值发生修改时是否触发表单验证
*/
validateEvent: {
type: Boolean,
default: true,
},
})
export const checkboxEmits = {
[UPDATE_MODEL_EVENT]: (value: CheckboxValueType) =>
isString(value) || isNumber(value) || isBoolean(value),
[CHANGE_EVENT]: (value: CheckboxValueType) =>
isString(value) || isNumber(value) || isBoolean(value),
}
export type CheckboxProps = ExtractPropTypes<typeof checkboxProps>
export type CheckboxEmits = typeof checkboxEmits
@@ -0,0 +1,53 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { checkboxEmits, checkboxProps } from './checkbox'
import { useCheckbox, useCheckboxCustomStyle } from './composables'
const props = defineProps(checkboxProps)
defineEmits(checkboxEmits)
const { isGroup, selected, handleCheckboxClick } = useCheckbox(props)
const {
ns,
checkboxClass,
checkboxStyle,
checkboxCheckedBoxClass,
checkboxCheckedBoxStyle,
} = useCheckboxCustomStyle(Object.assign(props))
</script>
<template>
<view
:class="[checkboxClass(selected), { [ns.m('group')]: isGroup }]"
:style="checkboxStyle(selected)"
@tap.stop="handleCheckboxClick('label')"
>
<!-- 左边内容 -->
<view
v-if="$slots.left && !$slots.default"
:class="[ns.em('content', 'left')]"
>
<slot name="left" />
</view>
<!-- 选择框 -->
<view
:class="[
checkboxCheckedBoxClass(selected),
{ [ns.em('checked-box', 'indeterminate')]: !selected && indeterminate },
]"
:style="checkboxCheckedBoxStyle(selected)"
@tap.stop="handleCheckboxClick('checkbox')"
>
<TnIcon v-if="selected" name="check" />
</view>
<!-- 右边内容 -->
<view v-if="$slots.default" :class="[ns.em('content', 'right')]">
<slot />
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/checkbox.scss';
</style>
@@ -0,0 +1,116 @@
import { computed } from 'vue'
import { useComponentColor, useNamespace } from '../../../../hooks'
import { isEmpty } from '../../../../utils'
import { useCheckboxCommonProps } from './use-checkbox-common-props'
import type { CSSProperties } from 'vue'
import type { CheckboxProps } from '../checkbox'
type selectedClass = (selected: boolean) => string
type selectedStyle = (selected: boolean) => CSSProperties
export const useCheckboxCustomStyle = (props: CheckboxProps) => {
const ns = useNamespace('checkbox')
const { activeColor, disabled, maxDisabled, size, border, checkedShape } =
useCheckboxCommonProps(props)
// 解析颜色
const [bgColorClass, bgColorStyle] = useComponentColor(activeColor, 'bg')
const [textColorClass, textColorStyle] = useComponentColor(
activeColor,
'text'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
activeColor,
'border'
)
// 复选框所属类
const checkboxClass = computed<selectedClass>(() => {
return (selected: boolean) => {
const cls: string[] = [ns.b()]
// 禁止选择
if (disabled.value || maxDisabled.value) cls.push(ns.m('disabled'))
// 设置尺寸
if (size.value) cls.push(ns.m(size.value))
// 激活样式
if (selected) {
cls.push(ns.m('selected'))
if (textColorClass.value) cls.push(textColorClass.value)
}
// 设置激活时的边框颜色
if (border.value) {
cls.push('tn-border')
if (selected && borderColorClass.value) cls.push(borderColorClass.value)
else cls.push('tn-gray-disabled_border')
} else {
cls.push(ns.m('no-border'))
}
if (props.customClass) cls.push(props.customClass)
return cls.join(' ')
}
})
// 复选框所属样式
const checkboxStyle = computed<selectedStyle>(() => {
return (selected: boolean) => {
const style: CSSProperties = {}
// 设置激活时的颜色
if (selected) {
if (border.value && !borderColorClass.value)
style.borderColor =
borderColorStyle.value || 'var(--tn-color-primary)'
if (!textColorClass.value) {
style.color = textColorStyle.value || 'var(--tn-color-primary)'
}
}
if (!isEmpty(props.customStyle)) Object.assign(style, props.customStyle)
return style
}
})
// 复选框选框所属类
const checkboxCheckedBoxClass = computed<selectedClass>(() => {
return (selected: boolean) => {
const cls: string[] = [ns.e('checked-box')]
// 复选框选框的形状
if (checkedShape.value) cls.push(ns.em('checked-box', checkedShape.value))
if (selected || props.indeterminate) {
cls.push(ns.em('checked-box', 'selected'))
if (bgColorClass.value) cls.push(bgColorClass.value)
} else {
cls.push('tn-border tn-gray-disabled_border')
}
return cls.join(' ')
}
})
// 复选框选框所属样式
const checkboxCheckedBoxStyle = computed<selectedStyle>(() => {
return (selected: boolean) => {
const style: CSSProperties = {}
if ((selected || props.indeterminate) && !bgColorClass.value) {
style.backgroundColor = bgColorStyle.value || 'var(--tn-color-primary)'
}
return style
}
})
return {
ns,
checkboxClass,
checkboxStyle,
checkboxCheckedBoxClass,
checkboxCheckedBoxStyle,
}
}
@@ -0,0 +1,4 @@
export * from './use-checkbox-common-props'
export * from './use-checkbox'
export * from './use-checkbox-group'
export * from './checkbox-custom'
@@ -0,0 +1,63 @@
import { computed, inject } from 'vue'
import { checkboxGroupKey } from '../../../../tokens'
import { useFormDisabled, useFormSize } from '../../../form'
import { isEmptyDoubleVariableInDefault } from '../../../../utils'
import type { CheckboxProps } from '../checkbox'
export const useCheckboxCommonProps = (props: CheckboxProps) => {
const checkboxGroup = inject(checkboxGroupKey, undefined)
// 组件尺寸
const size = useFormSize(
computed(() =>
isEmptyDoubleVariableInDefault(props?.size, checkboxGroup?.size)
)
)
// 复选框选框的形状
const checkedShape = computed(() =>
isEmptyDoubleVariableInDefault(
props?.checkedShape,
checkboxGroup?.checkedShape,
'square'
)
)
// 是否禁用
const disabled = useFormDisabled(
computed(() => props?.disabled || checkboxGroup?.disabled || false)
)
const maxDisabled = computed(
() =>
checkboxGroup?.modelValue &&
checkboxGroup?.max &&
checkboxGroup?.modelValue.length >= checkboxGroup?.max &&
!checkboxGroup?.modelValue.includes(props.label!)
)
// 禁止点击标签进行选择
const labelDisabled = computed(
() => props?.labelDisabled || checkboxGroup?.labelDisabled || false
)
// 是否显示边框
const border = computed(() => props?.border || checkboxGroup?.border || false)
// radio激活时的颜色
const activeColor = computed(
() => props?.activeColor || checkboxGroup?.activeColor
)
return {
checkboxGroup,
size,
checkedShape,
disabled,
maxDisabled,
labelDisabled,
border,
activeColor,
}
}
@@ -0,0 +1,56 @@
import { nextTick, watch } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { useFormItem } from '../../../form'
import { debugWarn } from '../../../../utils'
import type { SetupContext } from 'vue'
import type {
CheckboxGroupEmits,
CheckboxGroupProps,
CheckboxGroupValueType,
} from '../checkbox-group'
export const useCheckboxGroup = (
props: CheckboxGroupProps,
emits: SetupContext<CheckboxGroupEmits>['emit']
) => {
const { formItem } = useFormItem()
// 更新复选框组的值
const changeEvent = (val: CheckboxGroupValueType[number]) => {
const { modelValue, max } = props
const selectValues = [...modelValue]
// 判断更新的值是否已经存在于复选框组中
const hasLabel = selectValues.includes(val)
if (hasLabel) {
// 存在则删除
selectValues.splice(selectValues.indexOf(val), 1)
} else {
// 不存在则添加,判断是否超过最大值
if (max && selectValues.length >= max) {
return
}
// 如果没有设置最大值,则直接添加
selectValues.push(val)
}
emits(UPDATE_MODEL_EVENT, selectValues)
nextTick(() => {
emits(CHANGE_EVENT, selectValues)
})
}
watch(
() => props.modelValue,
() => {
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => {
debugWarn(err)
})
}
}
)
return {
changeEvent,
}
}
@@ -0,0 +1,64 @@
import { computed, getCurrentInstance, nextTick } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { useFormItem } from '../../../form'
import { debugWarn } from '../../../../utils'
import { useCheckboxCommonProps } from './use-checkbox-common-props'
import type { CheckboxProps } from '../checkbox'
import type { CheckboxValueType } from '../../../base/types/checkbox'
// 判断复选框组中是否包含某个值
const hasLabelInGroup = (
groupValue: CheckboxValueType[],
label: CheckboxValueType
) => groupValue.includes(label)
export const useCheckbox = (props: CheckboxProps) => {
const { emit } = getCurrentInstance()!
const { checkboxGroup, disabled, maxDisabled, labelDisabled } =
useCheckboxCommonProps(props)
const { formItem } = useFormItem()
// 判断是否为复选组
const isGroup = computed(() => !!checkboxGroup)
// 在复选组中是否选中当前复选框
const selected = computed(() => {
if (isGroup.value) {
return hasLabelInGroup(checkboxGroup!.modelValue, props.label!)
} else {
return props.modelValue === props.activeValue
}
})
// 复选框点击事件
const handleCheckboxClick = (type: 'checkbox' | 'label') => {
if (disabled.value || maxDisabled.value) return
if (type === 'label' && labelDisabled.value) return
if (isGroup.value) {
checkboxGroup!.changeEvent(props.label!)
} else {
const modelValue = selected.value
? props.inactiveValue
: props.activeValue
emit(UPDATE_MODEL_EVENT, modelValue)
nextTick(() => {
emit(CHANGE_EVENT, modelValue)
})
if (props.validateEvent) {
formItem?.validate?.('change').catch((err) => {
debugWarn(err)
})
}
}
}
return {
isGroup,
selected,
handleCheckboxClick,
}
}
@@ -0,0 +1,5 @@
import type Checkbox from './checkbox.vue'
import type CheckboxGroup from './checkbox-group.vue'
export type CheckboxInstance = InstanceType<typeof Checkbox>
export type CheckboxGroupInstance = InstanceType<typeof CheckboxGroup>
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import circleProgress from './src/circle-progress.vue'
export const TnCircleProgress = withNoopInstall(circleProgress)
export default TnCircleProgress
export * from './src/circle-progress'
export type { TnCircleProgressInstance } from './src/instance'
@@ -0,0 +1,24 @@
import { buildProps } from '../../../utils'
import { propgressBaseProps } from '../../base/common-props/progress'
import type { ExtractPropTypes } from 'vue'
export const circleProgressProps = buildProps({
...propgressBaseProps,
/**
* @description 圆环的半径,单位 px,只支持传递固定的值
*/
radius: {
type: Number,
default: 50,
},
/**
* @description 圆环的宽度,单位 px,只支持传递固定的值
*/
ringWidth: {
type: Number,
default: 7,
},
})
export type CircleProgressProps = ExtractPropTypes<typeof circleProgressProps>
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import { circleProgressProps } from './circle-progress'
import { useCircleProgress } from './composables'
const props = defineProps(circleProgressProps)
const { ns, canvasId, radius, activeCircleColor } = useCircleProgress(props)
</script>
<template>
<view
:class="[ns.b()]"
:style="{
width: `${radius * 2}px`,
height: `${radius * 2}px`,
color: activeCircleColor,
}"
>
<!-- 默认圆环 -->
<canvas :id="canvasId" :class="[ns.e('canvas')]" :canvas-id="canvasId" />
<!-- 数值 -->
<view v-if="showPercent || $slots.default" :class="[ns.e('precent-value')]">
<slot> {{ percent }}% </slot>
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/circle-progress.scss';
</style>
@@ -0,0 +1 @@
export * from './use-circle-progress'
@@ -0,0 +1,146 @@
import { computed, getCurrentInstance, nextTick, watch } from 'vue'
import { useNamespace } from '../../../../hooks'
import { generateId, isEmptyVariableInDefault } from '../../../../utils'
import type { ComponentInternalInstance } from 'vue'
import type { CircleProgressProps } from '../circle-progress'
export const useCircleProgress = (props: CircleProgressProps) => {
const instance = getCurrentInstance() as ComponentInternalInstance
const ns = useNamespace('circle-progress')
// 圆环的直径
const radius = computed<number>(() => {
return isEmptyVariableInDefault(props?.radius, 50)
})
// 圆环的宽度
const ringWidth = computed<number>(() => {
return isEmptyVariableInDefault(props?.ringWidth, 14)
})
// 圆环的颜色
const circleColor = computed<string>(() => {
return isEmptyVariableInDefault(props?.inactiveColor, '#e6e6e6')
})
// 圆环激活时的颜色
const activeCircleColor = computed<string>(() => {
return isEmptyVariableInDefault(props?.activeColor, '#01beff')
})
// 动画执行时间
const duration = computed<number>(() => {
return isEmptyVariableInDefault(props?.duration, 1500)
})
// 进度信息,为了产生动画效果,需要在进度条变化时,将进度信息保存下来
let currentPercent = 0
let prevPercent = 0
// 生成canvas圆环id
const canvasId = String(generateId())
// canvas容器对象
let progressCanvas: UniApp.CanvasContext | null = null
// 圆环开始角度
const startAngle = -90 * (Math.PI / 180)
// 绘制progress圆环
const drawProgressCircle = (percent: number) => {
if (!progressCanvas) {
progressCanvas = uni.createCanvasContext(canvasId, instance)
}
// 清空画布
progressCanvas.clearRect(0, 0, radius.value * 2, radius.value * 2)
// 绘制底部圆环
progressCanvas.beginPath()
// 设置颜色\线框
progressCanvas.setLineWidth(ringWidth.value)
progressCanvas.setStrokeStyle(circleColor.value)
// 绘制圆环
progressCanvas.arc(
radius.value,
radius.value,
radius.value - ringWidth.value / 2,
startAngle,
Math.PI * 1.5,
false
)
progressCanvas.stroke()
// 如果进度为0,不绘制进度圆环
if (percent === 0) {
progressCanvas.draw()
return
}
// 绘制进度圆环
progressCanvas.beginPath()
// 设置颜色\线框
progressCanvas.setLineCap('round')
progressCanvas.setLineWidth(ringWidth.value)
progressCanvas.setStrokeStyle(activeCircleColor.value)
// 结束角度
const endAngle = (Math.PI * 2 * percent) / 100 - Math.PI / 2
progressCanvas.arc(
radius.value,
radius.value,
radius.value - ringWidth.value / 2,
startAngle,
endAngle,
false
)
progressCanvas.stroke()
progressCanvas.draw()
}
// 计算缓动动画时间函数
function easeOutCubic(t: number, b: number, c: number, d: number) {
return c * ((t = t / d - 1) * t * t + 1) + b
}
// 开始执行动画
let startTime: number | null = null
const progressAnimation = () => {
if (!startTime) startTime = Date.now()
const elapsed = Date.now() - startTime
let percent = easeOutCubic(
elapsed,
prevPercent,
currentPercent - prevPercent,
duration.value
)
if (percent < 0) percent = 0
drawProgressCircle(percent)
if (elapsed < duration.value) {
setTimeout(progressAnimation, 16)
}
}
watch(
() => props.percent,
(nVal: number, oVal: number | undefined) => {
currentPercent = nVal > 100 ? 100 : nVal
prevPercent = !oVal || oVal < 0 ? 0 : oVal
nextTick(() => {
startTime = null
progressAnimation()
})
},
{
immediate: true,
}
)
return {
ns,
canvasId,
radius,
activeCircleColor,
}
}
@@ -0,0 +1,3 @@
import type CircleProgress from './circle-progress.vue'
export type TnCircleProgressInstance = InstanceType<typeof CircleProgress>
+14
View File
@@ -0,0 +1,14 @@
import { withInstall, withNoopInstall } from '../../utils'
import Collapse from './src/collapse.vue'
import CollapseItem from './src/collapse-item.vue'
export const TnCollapse = withInstall(Collapse, {
CollapseItem,
})
export default TnCollapse
export const TnCollapseItem = withNoopInstall(CollapseItem)
export * from './src/collapse'
export * from './src/collapse-item'
export type { TnCollapseInstance, TnCollapseItemInstance } from './src/instance'
@@ -0,0 +1,16 @@
import { buildProps } from '../../../utils'
import type { ExtractPropTypes } from 'vue'
export const collapseItemProps = buildProps({
/**
* @description CollapseItem标题
*/
title: String,
/**
* @description CollapseItem是否禁用
*/
disabled: Boolean,
})
export type CollapseItemProps = ExtractPropTypes<typeof collapseItemProps>
@@ -0,0 +1,60 @@
<script lang="ts" setup>
import TnIcon from '../../icon/src/icon.vue'
import { collapseItemProps } from './collapse-item'
import { useCollapseItem, useCollapseItemCustomStyle } from './composables'
const props = defineProps(collapseItemProps)
const {
componentContentId,
showArrow,
isActive,
componentHeight,
collapseItemClick,
} = useCollapseItem(props)
const { ns, arrowClass, arrowStyle } = useCollapseItemCustomStyle()
</script>
// #ifdef MP-WEIXIN
<script lang="ts">
export default {
options: {
// 在微信小程序中将组件节点渲染为虚拟节点,更加接近Vue组件的表现(不会出现shadow节点下再去创建元素)
virtualHost: true,
},
}
</script>
// #endif
<template>
<view
:class="[ns.b(), ns.is('active', isActive), ns.is('disabled', disabled)]"
:style="{ height: componentHeight }"
>
<!-- 标题 -->
<view :class="[ns.e('title')]" @tap.stop="collapseItemClick">
<view class="content tn-text-ellipsis-1">
<slot name="title">
{{ title }}
</slot>
</view>
<view
v-if="showArrow"
class="arrow"
:class="[arrowClass]"
:style="arrowStyle"
>
<TnIcon name="right" />
</view>
</view>
<!-- 内容区域 -->
<view :id="componentContentId" :class="[ns.e('content')]">
<slot />
</view>
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/collapse-item.scss';
</style>
@@ -0,0 +1,50 @@
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../constants'
import {
buildProps,
definePropType,
isArray,
isNumber,
isString,
} from '../../../utils'
import type { ExtractPropTypes } from 'vue'
import type { Arrayable } from '../../../utils'
export type CollapseModelValue = Arrayable<number>
export const collapseProps = buildProps({
/**
* @description 当前激活的面板,如果是手风琴效果则传递string,否则传递string[]
*/
modelValue: {
type: definePropType<CollapseModelValue>([Number, Array]),
},
/**
* @description 是否开启手风琴效果,每次只能打开一个面板
*/
accordion: {
type: Boolean,
default: true,
},
/**
* @description 是否显示折叠面板的箭头
*/
showArrow: {
type: Boolean,
default: true,
},
/**
* @description 折叠面板箭头的颜色,show-arrow为true时生效,以tn开头的颜色使用图鸟内置的颜色
*/
arrowColor: String,
})
export const collapseEmits = {
[UPDATE_MODEL_EVENT]: (value: CollapseModelValue) =>
isArray(value) || isString(value) || isNumber(value),
[CHANGE_EVENT]: (value: CollapseModelValue) =>
isArray(value) || isString(value) || isNumber(value),
}
export type CollapseProps = ExtractPropTypes<typeof collapseProps>
export type CollapseEmits = typeof collapseEmits
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import { useNamespace } from '../../../hooks'
import { collapseEmits, collapseProps } from './collapse'
import { useCollapse } from './composables'
const props = defineProps(collapseProps)
const emits = defineEmits(collapseEmits)
const ns = useNamespace('collapse')
useCollapse(props, emits)
</script>
<template>
<view :class="[ns.b()]">
<slot />
</view>
</template>
<style lang="scss" scoped>
@import '../../../theme-chalk/src/collapse.scss';
</style>
@@ -0,0 +1,41 @@
import { computed, inject, toRef } from 'vue'
import { collapseContextKey } from '../../../../tokens'
import { useComponentColor, useNamespace } from '../../../../hooks'
import type { CSSProperties } from 'vue'
export const useCollapseItemCustomStyle = () => {
const ns = useNamespace('collapse-item')
const collapse = inject(collapseContextKey)
// 解析颜色
const [arrowColorClass, arrowColorStyle] = useComponentColor(
toRef(collapse!, 'arrowColor'),
'text'
)
// 折叠面板图标对应样式
const arrowClass = computed<string>(() => {
const cls: string[] = []
if (arrowColorClass.value) cls.push(arrowColorClass.value)
return cls.join(' ')
})
// 折叠面板图标对应样式
const arrowStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!arrowColorClass.value)
style.color = arrowColorStyle.value || 'var(--tn-color-gray)'
return style
})
return {
ns,
arrowClass,
arrowStyle,
}
}
@@ -0,0 +1,3 @@
export * from './collapse-item-custom'
export * from './use-collapse'
export * from './use-collapse-item'
@@ -0,0 +1,98 @@
import {
computed,
getCurrentInstance,
inject,
nextTick,
onMounted,
ref,
} from 'vue'
import { collapseContextKey } from '../../../../tokens'
import { useSelectorQuery } from '../../../../hooks'
import {
debugWarn,
generateId,
isEmptyVariableInDefault,
} from '../../../../utils'
import type { CollapseItemProps } from '../collapse-item'
export const useCollapseItem = (props: CollapseItemProps) => {
const instance = getCurrentInstance()
if (!instance) {
debugWarn('TnCollapseItem', '请在 setup 函数中使用 TnCollapseItem')
}
const { uid } = instance!
const collapse = inject(collapseContextKey)
if (!collapse) {
debugWarn('TnCollapseItem', '请在 TnCollapse 中使用 TnCollapseItem')
}
collapse?.addItem({ uid })
const componentContentId = `tcic-${generateId()}`
const { getSelectorNodeInfo } = useSelectorQuery(instance)
// 当前组件是否为激活状态
const isActive = computed<boolean>(() => {
if (!collapse) return false
return collapse.activeUid.includes(uid)
})
// 是否显示折叠面板箭头
const showArrow = computed<boolean>(() =>
isEmptyVariableInDefault(collapse?.showArrow, false)
)
// 组件内容的高度
const compoenntContentDefaultHeight = ref<number>(0)
const componentTitleHeight = ref<number>(uni.upx2px(100))
const componentHeight = computed<string>(() => {
if (!isActive.value) return `${componentTitleHeight.value}px`
else
return `${
componentTitleHeight.value + compoenntContentDefaultHeight.value
}px`
})
let initCount = 0
// 获取内容容器高度
const getComponentContentHeight = async () => {
try {
const rectInfo = await getSelectorNodeInfo(`#${componentContentId}`)
initCount = 0
compoenntContentDefaultHeight.value = rectInfo.height || 0
} catch (err) {
if (initCount > 10) {
initCount = 0
debugWarn('TnCollapseItem', `获取内容高度失败: ${err}`)
return
}
initCount++
setTimeout(() => {
getComponentContentHeight()
}, 150)
}
}
// CollapseItem 点击事件
const collapseItemClick = () => {
if (props.disabled) return
collapse?.handleItemClick(uid)
}
onMounted(() => {
nextTick(() => {
getComponentContentHeight()
})
})
return {
componentContentId,
showArrow,
isActive,
componentHeight,
collapseItemClick,
}
}
@@ -0,0 +1,118 @@
import { nextTick, provide, reactive, ref, toRefs, watch } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../../../../constants'
import { collapseContextKey } from '../../../../tokens'
import { useOrderedChildren } from '../../../../hooks'
import { isArray, isNumber } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { CollapseItemContext } from '../../../../tokens'
import type {
CollapseEmits,
CollapseModelValue,
CollapseProps,
} from '../collapse'
export const useCollapse = (
props: CollapseProps,
emits: SetupContext<CollapseEmits>['emit']
) => {
const {
children: items,
addChild: addItem,
removeChild: removeItem,
} = useOrderedChildren<CollapseItemContext>()
// 当前已激活的面板对应的uid
const activeUid = ref<number[]>([])
const currentActiveIndex = ref<CollapseModelValue>()
// 根据modelValue更新activeUID的值
const updateActiveUIDWithModelValue = (val?: CollapseModelValue) => {
nextTick(() => {
let activeIndex: number[]
if (val === undefined || val === -1) {
activeIndex = []
} else if (isNumber(val)) {
activeIndex = [val]
} else {
activeIndex = [...val]
}
activeUid.value = items.value
.filter((uid, index) => activeIndex.includes(index))
.map((item) => item.uid)
})
}
let innerUpdate = false
watch(
() => props.modelValue,
(val) => {
if (innerUpdate) {
innerUpdate = false
return
}
currentActiveIndex.value = val
updateActiveUIDWithModelValue(val)
},
{
immediate: true,
}
)
// 处理CollapseItem点击事件
const handleItemClick = (uid: number) => {
const { accordion } = props
// 获取对应uid对应的索引
const uidIndex = items.value.findIndex((item) => item.uid === uid)
const isActive = activeUid.value.includes(uid)
let value: CollapseModelValue
// 判断是否为手风琴效果
if (accordion) {
// 判断是否已经为激活状态,如果是激活状态则取消激活,否则设置为其他面板激活
if (isActive) {
value = -1
} else {
value = uidIndex
}
} else {
// 判断是否存在于激活面板中,如果存在则取消激活,否则添加到激活面板中
if (isActive) {
value = (currentActiveIndex.value as number[]).filter(
(item) => item !== uidIndex
)
} else {
if (currentActiveIndex.value && isArray(currentActiveIndex.value)) {
value = [...currentActiveIndex.value, uidIndex]
} else {
value = !currentActiveIndex.value
? [uidIndex]
: [currentActiveIndex.value, uidIndex]
}
}
}
// 触发更新事件
innerUpdate = true
currentActiveIndex.value = value
emits(UPDATE_MODEL_EVENT, value)
updateActiveUIDWithModelValue(value)
nextTick(() => {
emits(CHANGE_EVENT, value)
})
}
provide(
collapseContextKey,
reactive({
...toRefs(props),
items,
addItem,
removeItem,
activeUid,
handleItemClick,
})
)
}
@@ -0,0 +1,5 @@
import type Collapse from './collapse.vue'
import type CollapseItem from './collapse-item.vue'
export type TnCollapseInstance = InstanceType<typeof Collapse>
export type TnCollapseItemInstance = InstanceType<typeof CollapseItem>
+8
View File
@@ -0,0 +1,8 @@
import { withNoopInstall } from '../../utils'
import CountDown from './src/count-down.vue'
export const TnCountDown = withNoopInstall(CountDown)
export default TnCountDown
export * from './src/count-down'
export type { TnCountDownInstance } from './src/instance'
@@ -0,0 +1,114 @@
import { computed, toRef } from 'vue'
import {
useComponentColor,
useComponentSize,
useNamespace,
} from '../../../../hooks'
import { formatDomSizeValue } from '../../../../utils'
import type { CSSProperties } from 'vue'
import type { CountDownProps } from '../count-down'
export const useCountDownCustomStyle = (props: CountDownProps) => {
const ns = useNamespace('count-down')
// 解析颜色
const [textColorClass, textColorStyle] = useComponentColor(
toRef(props, 'textColor'),
'text'
)
const [separatorColorClass, separatorColorStyle] = useComponentColor(
toRef(props, 'separatorColor'),
'text'
)
const [borderColorClass, borderColorStyle] = useComponentColor(
toRef(props, 'borderColor'),
'border'
)
const { sizeType } = useComponentSize(props.size)
// 倒计时的类
const countDownClass = computed<string>(() => {
const cls: string[] = [ns.b()]
if (props.size && sizeType.value === 'inner') cls.push(ns.m(props.size))
return cls.join(' ')
})
// 倒计时的样式
const countDownStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置字体大小
if (props.size && sizeType.value === 'custom')
style.fontSize = formatDomSizeValue(props.size)
return style
})
// 倒计时文字的类
const textClass = computed<string>(() => {
const cls: string[] = [ns.e('text')]
// 设置字体颜色
if (textColorClass.value) cls.push(textColorClass.value)
// 设置边框颜色
if (props.border) {
cls.push(ns.is('border'))
if (borderColorClass.value) cls.push(borderColorClass.value)
}
return cls.join(' ')
})
// 倒计时文字的样式
const textStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
// 设置字体颜色
if (!textColorClass.value)
style.color = textColorStyle.value || 'var(--tn-text-color-primary)'
// 设置边框颜色
if (props.border) {
if (!borderColorClass.value)
style.borderColor =
borderColorStyle.value || 'var(--tn-color-gray-disabled)'
}
return style
})
// 分割符的类
const separatorClass = computed<string>(() => {
const cls: string[] = [
ns.e('separator'),
ns.em('separator', props.separatorMode),
]
if (separatorColorClass.value) cls.push(separatorColorClass.value)
return cls.join(' ')
})
// 分割符的样式
const separatorStyle = computed<CSSProperties>(() => {
const style: CSSProperties = {}
if (!separatorColorClass.value)
style.color =
separatorColorStyle.value || 'var(--tn-text-color-secondary)'
return style
})
return {
ns,
countDownClass,
countDownStyle,
textClass,
textStyle,
separatorClass,
separatorStyle,
}
}
@@ -0,0 +1,40 @@
import type { CountDownSeparatorMode } from '../count-down'
interface CountDownSeparatorItem {
day: string
hour: string
minute: string
second: string
}
type CountDownSeparatorData = Record<
CountDownSeparatorMode,
CountDownSeparatorItem
>
export const useCountDownSeparatorData = () => {
const countDownSeparatorData: CountDownSeparatorData = {
cn: {
day: '天',
hour: '时',
minute: '分',
second: '秒',
},
en: {
day: ':',
hour: ':',
minute: ':',
second: '',
},
}
const getSeparatorData = (
mode: CountDownSeparatorMode,
key: keyof CountDownSeparatorItem
) => {
return countDownSeparatorData[mode][key]
}
return {
getSeparatorData,
}
}
@@ -0,0 +1,3 @@
export * from './count-down-custom'
export * from './count-down-separator-data'
export * from './use-count-down'
@@ -0,0 +1,109 @@
import { ref, watch } from 'vue'
import { formatNumber } from '../../../../utils'
import type { SetupContext } from 'vue'
import type { CountDownEmits, CountDownProps } from '../count-down'
const SECOND = 1
const MINUTE = 60 * SECOND
const HOUR = 60 * MINUTE
const DAY = 24 * HOUR
export const useCountDown = (
props: CountDownProps,
emits: SetupContext<CountDownEmits>['emit']
) => {
// 当前倒计时时间
let time = 0
// 时间定义
const day = ref<string>('')
const hour = ref<string>('')
const minute = ref<string>('')
const second = ref<string>('')
// 将时间戳转换为对应的时间
const splitCountDownData = () => {
const { showDay, showHour, showMinute, showSecond, autoHideDay } = props
const _day = Math.floor(time / DAY)
// 如果不显示天数则将天的时间加到小时上,后面的分钟和秒如此累加
const _hour = showDay
? Math.floor((time % DAY) / HOUR)
: Math.floor(time / HOUR)
const _minute = Math.floor((time % HOUR) / MINUTE)
const _second = Math.floor(time % MINUTE)
// 判断是否自动隐藏天数
if (!showDay || (autoHideDay && _day === 0)) day.value = ''
else day.value = formatNumber(_day, 4)
hour.value = showHour ? formatNumber(_hour, 4) : ''
minute.value = showMinute ? formatNumber(_minute) : ''
second.value = showSecond ? formatNumber(_second) : ''
}
// 倒计时定时器
let countDownTimer: ReturnType<typeof setInterval> | null = null
// 开始倒计时
const startCountDown = () => {
// 如果倒计时时间为0,重新设置倒计时时间
if (time <= 0) time = props.time
else {
// 先渲染一次
time--
}
// 先停止之前的倒计时
stopCountDown()
emits('start')
// 开始倒计时
countDownTimer = setInterval(() => {
if (time < 0) {
stopCountDown()
emits('end')
return
}
splitCountDownData()
time--
}, 1000)
}
// 停止倒计时
const stopCountDown = () => {
if (countDownTimer) {
clearInterval(countDownTimer)
countDownTimer = null
}
}
// 重置定时器
const resetCountDown = () => {
stopCountDown()
time = props.time
splitCountDownData()
}
watch(
() => props.time,
(_time) => {
resetCountDown()
// 自动开始倒计时
if (props.autoStart && _time > 0) {
startCountDown()
}
},
{
immediate: true,
}
)
return {
day,
hour,
minute,
second,
startCountDown,
stopCountDown,
resetCountDown,
}
}

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