This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
export const UPDATE_MODEL_EVENT = 'update:modelValue'
export const UPDATE_VISIBLE_EVENT = 'update:visible'
export const CHANGE_EVENT = 'change'
export const INPUT_EVENT = 'input'
export const CLICK_EVENT = 'click'
export const OPEN_EVENT = 'open'
export const CLOSE_EVENT = 'close'
export const OPENED_EVENT = 'opened'
export const CLOSED_EVENT = 'closed'
export const FOCUS_EVENT = 'focus'
export const BLUR_EVENT = 'blur'
export const CONFIRM_EVENT = 'confirm'
export const SEARCH_EVENT = 'search'
export const CLEAR_EVENT = 'clear'
export const CANCEL_EVENT = 'cancel'
export const CHOOSE_EVENT = 'choose'
export const SELECT_EVENT = 'select'
export const SELECTED_EVENT = 'selected'

View File

@@ -0,0 +1,3 @@
export * from './event'
export * from './prefix'
export * from './types'

View File

@@ -0,0 +1 @@
export const PREFIX = 'nut'

View File

@@ -0,0 +1,11 @@
import type { NutAnimationName } from '../transition'
export type Position = 'center' | 'top' | 'bottom' | 'left' | 'right'
export const animationName: Record<Position, NutAnimationName> = {
center: 'fade',
top: 'slide-down',
bottom: 'slide-up',
left: 'slide-left',
right: 'slide-right',
}

View File

@@ -0,0 +1,11 @@
export * from './useExpose'
export * from './useGlobalZIndex'
export * from './useInject'
export * from './useLockScroll'
export * from './useProvide'
export * from './useRect'
export * from './useRelation'
export * from './useRouter'
export * from './useSelectorQuery'
export * from './useStyle'
export * from './useTouch'

View File

@@ -0,0 +1,8 @@
import type { ComponentPublicInstance } from 'vue'
import { getCurrentInstance } from 'vue'
export function useExpose(apis: Record<string, any>) {
const instance = getCurrentInstance()
if (instance)
Object.assign(instance.proxy as ComponentPublicInstance, apis)
}

View File

@@ -0,0 +1,9 @@
let globalZIndex = 2000
export function useGlobalZIndex() {
return ++globalZIndex
}
export function setGlobalZIndex(value: number) {
globalZIndex = value
}

View File

@@ -0,0 +1,32 @@
import { computed, getCurrentInstance, inject, onUnmounted, ref } from 'vue'
import type { ComponentInternalInstance, InjectionKey } from 'vue'
type ParentProvide<T> = T & {
add: (child: ComponentInternalInstance) => void
remove: (child: ComponentInternalInstance) => void
internalChildren: ComponentInternalInstance[]
}
export function useInject<T>(key: InjectionKey<ParentProvide<T>>) {
const parent = inject(key, null)
if (parent) {
const instance = getCurrentInstance()!
const { add, remove, internalChildren } = parent
add(instance)
onUnmounted(() => remove(instance))
const index = computed(() => internalChildren.indexOf(instance))
return {
parent,
index,
}
}
return {
parent: null,
index: ref(-1),
}
}

View File

@@ -0,0 +1,31 @@
let count = 0
const CLSNAME = 'nut-overflow-hidden'
export function useLockScroll(isLock: () => boolean) {
const lock = () => {
if (isLock()) {
try {
!count && document.body.classList.add(CLSNAME)
count++
}
catch (error) {
console.error(error)
}
}
}
const unlock = () => {
if (isLock() && count) {
try {
count--
!count && document.body.classList.remove(CLSNAME)
}
catch (error) {
console.error(error)
}
}
}
return [lock, unlock]
}

View File

@@ -0,0 +1,93 @@
import { getCurrentInstance, markRaw, provide, shallowReactive } from 'vue'
import type {
ComponentInternalInstance,
ConcreteComponent,
InjectionKey,
VNode,
VNodeNormalizedChildren,
} from 'vue'
// TODO: uniapp 不支持 vue 直接导出的 isVNode
export function isVNode(value: any): value is VNode {
return value ? value.__v_isVNode === true : false
}
export function flattenVNodes(shouldTraverseChildren: VNodeNormalizedChildren, childName?: string) {
const result: VNode[] = []
const traverse = (children: VNodeNormalizedChildren) => {
if (!Array.isArray(children))
return
children.forEach((child) => {
if (!isVNode(child))
return
if (childName) {
if (child.type && (child.type as ConcreteComponent).name === childName) {
result.push(child)
return
}
}
else {
result.push(child)
}
if (child.component?.subTree)
traverse(child.component.subTree.children)
if (child.children)
traverse(child.children)
})
}
traverse(shouldTraverseChildren)
return result
}
export function sortChildren(
parent: ComponentInternalInstance,
internalChildren: ComponentInternalInstance[],
childName?: string,
) {
const vnodes = flattenVNodes(parent && parent.subTree && parent.subTree.children, childName)
internalChildren.sort((a, b) => {
return vnodes.indexOf(a.vnode) - vnodes.indexOf(b.vnode)
})
}
// 如果指定组件名称,则只查找此组件并且查到后结束。也就是不关心此组件下的内容,在大部分场景下节省查找消耗。
export function useProvide<ProvideValue>(key: InjectionKey<ProvideValue>, childName?: string) {
const internalChildren: ComponentInternalInstance[] = shallowReactive([])
const publicChildren = shallowReactive<any[]>([])
const parent = getCurrentInstance()!
const add = (child: ComponentInternalInstance) => {
if (!child.proxy)
return
internalChildren.push(markRaw(child))
publicChildren.push(markRaw(child.proxy))
sortChildren(parent, internalChildren, childName)
}
const remove = (child: ComponentInternalInstance) => {
if (child.proxy) {
internalChildren.splice(internalChildren.indexOf(markRaw(child)), 1)
publicChildren.splice(publicChildren.indexOf(markRaw(child.proxy)), 1)
}
}
return (value?: ProvideValue) => {
provide(key, {
add,
remove,
internalChildren,
...value,
} as any)
return {
internalChildren,
children: publicChildren,
}
}
}

View File

@@ -0,0 +1,7 @@
import type { ComponentInternalInstance } from 'vue'
import { useSelectorQuery } from './useSelectorQuery'
export function useRect(id: string, instance?: ComponentInternalInstance): Promise<UniApp.NodeInfo> {
const { getSelectorNodeInfo } = useSelectorQuery(instance)
return getSelectorNodeInfo(`#${id}`)
}

View File

@@ -0,0 +1,8 @@
import type { ComponentPublicInstance } from 'vue'
import { getCurrentInstance } from 'vue'
export function useExtend<T>(apis: T) {
const instance = getCurrentInstance()
if (instance)
Object.assign(instance.proxy as ComponentPublicInstance, apis)
}

View File

@@ -0,0 +1,28 @@
export type NavigateToOptions = string | UniApp.NavigateToOptions
export type RedirectToOptions = string | UniApp.RedirectToOptions
export type RouterOptions = UniApp.NavigateToOptions | UniApp.RedirectToOptions
export function useRouter() {
const push = (options: NavigateToOptions) => {
if (typeof options === 'string') {
uni.navigateTo({ url: options })
return
}
uni.navigateTo(options)
}
const replace = (options: RedirectToOptions) => {
if (typeof options === 'string') {
uni.redirectTo({ url: options })
return
}
uni.redirectTo(options)
}
return {
push,
replace,
}
}

View File

@@ -0,0 +1,67 @@
import { getCurrentInstance } from 'vue'
import type { ComponentInternalInstance } from 'vue'
export function useSelectorQuery(instance?: ComponentInternalInstance | null) {
let query: UniApp.SelectorQuery | null = null
if (!instance)
instance = getCurrentInstance()
if (!instance)
console.warn('useSelectorQuery', 'useSelectorQuery必须在setup函数中使用')
// #ifndef MP-ALIPAY
query = uni.createSelectorQuery().in(instance)
// #endif
// #ifdef MP-ALIPAY
query = uni.createSelectorQuery().in(null)
// #endif
const getSelectorNodeInfo = (selector: string): Promise<UniApp.NodeInfo> => {
return new Promise((resolve, reject) => {
if (query) {
query
.select(selector)
.boundingClientRect((res) => {
const selectRes: UniApp.NodeInfo = res as UniApp.NodeInfo
if (selectRes)
resolve(selectRes)
else
reject(new Error(`未找到对应节点: ${selector}`))
})
.exec()
}
else {
reject(new Error('未找到对应的SelectorQuery实例'))
}
})
}
const getSelectorNodeInfos = (
selector: string,
): Promise<UniApp.NodeInfo[]> => {
return new Promise((resolve, reject) => {
if (query) {
query
.selectAll(selector)
.boundingClientRect((res) => {
const selectRes: UniApp.NodeInfo[] = res as UniApp.NodeInfo[]
if (selectRes && selectRes.length > 0)
resolve(selectRes)
else
reject(new Error(`未找到对应节点: ${selector}`))
})
.exec()
}
else {
reject(new Error('未找到对应的SelectorQuery实例'))
}
})
}
return {
query,
getSelectorNodeInfo,
getSelectorNodeInfos,
}
}

View File

@@ -0,0 +1,29 @@
import { computed, normalizeClass, normalizeStyle } from 'vue'
import { stringifyStyle } from '../_utils'
export function useStyleContext(props: any, componentName: string) {
const mainClass = computed(() => {
const cls = normalizeClass([props.customClass, { [componentName]: true }])
return cls
})
const mainStyle = computed(() => {
return stringifyStyle(normalizeStyle(props.customStyle))
})
const getMainClass = (cls: unknown) => {
return normalizeClass([props.customClass, { [componentName]: true }, cls])
}
const getMainStyle = (style: unknown) => {
return stringifyStyle(normalizeStyle([props.customStyle, style]))
}
return {
mainClass,
mainStyle,
getMainClass,
getMainStyle,
}
}

View File

@@ -0,0 +1,74 @@
import { ref } from 'vue'
const MIN_DISTANCE = 10
type Direction = '' | 'vertical' | 'horizontal'
function getDirection(x: number, y: number) {
if (x > y && x > MIN_DISTANCE)
return 'horizontal'
if (y > x && y > MIN_DISTANCE)
return 'vertical'
return ''
}
export function useTouch() {
const startX = ref(0)
const startY = ref(0)
const moveX = ref(0)
const moveY = ref(0)
const deltaX = ref(0)
const deltaY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
const direction = ref<Direction>('')
const isVertical = () => direction.value === 'vertical'
const isHorizontal = () => direction.value === 'horizontal'
const reset = () => {
deltaX.value = 0
deltaY.value = 0
offsetX.value = 0
offsetY.value = 0
direction.value = ''
}
const start = ((event: TouchEvent) => {
reset()
startX.value = event.touches[0].clientX
startY.value = event.touches[0].clientY
}) as EventListener
const move = ((event: TouchEvent) => {
const touch = event.touches[0]
deltaX.value = touch.clientX - startX.value
deltaY.value = touch.clientY - startY.value
moveX.value = touch.clientX
moveY.value = touch.clientY
offsetX.value = Math.abs(deltaX.value)
offsetY.value = Math.abs(deltaY.value)
if (!direction.value)
direction.value = getDirection(offsetX.value, offsetY.value)
}) as EventListener
return {
move,
start,
reset,
startX,
startY,
moveX,
moveY,
deltaX,
deltaY,
offsetX,
offsetY,
direction,
isVertical,
isHorizontal,
}
}

View File

@@ -0,0 +1,277 @@
import { isArray, isDef, isObject } from './is'
// 变量类型判断
export function TypeOfFun(value: any) {
if (value === null)
return 'null'
const type = typeof value
if (type === 'undefined' || type === 'string')
return type
const typeString = toString.call(value)
switch (typeString) {
case '[object Array]':
return 'array'
case '[object Date]':
return 'date'
case '[object Boolean]':
return 'boolean'
case '[object Number]':
return 'number'
case '[object Function]':
return 'function'
case '[object RegExp]':
return 'regexp'
case '[object Object]':
if (undefined !== value.nodeType) {
if (value.nodeType === 3)
return /\S/.test(value.nodeValue) ? 'textnode' : 'whitespace'
else
return 'element'
}
else {
return 'object'
}
default:
return 'unknow'
}
}
//
export const objectToString = Object.prototype.toString
export const toTypeString = (value: unknown): string => objectToString.call(value)
export function toRawType(value: unknown): string {
// extract "RawType" from strings like "[object RawType]"
return toTypeString(value).slice(8, -1)
}
export const win = window
export const docu = document
export const body = docu.body
export function getPropByPath(obj: any, keyPath: string) {
try {
return keyPath.split('.').reduce((prev, curr) => prev[curr], obj)
}
// eslint-disable-next-line unused-imports/no-unused-vars
catch (error) {
return ''
}
}
export function floatData(format: any, dataOp: any, mapOps: any) {
const mergeFormat = Object.assign({}, format)
const mergeMapOps = Object.assign({}, mapOps)
if (Object.keys(dataOp).length > 0) {
Object.keys(mergeFormat).forEach((keys) => {
if (Object.prototype.hasOwnProperty.call(mergeMapOps, keys)) {
const tof = TypeOfFun(mergeMapOps[keys])
if (tof === 'function')
mergeFormat[keys] = mergeMapOps[keys](dataOp)
if (tof === 'string')
mergeFormat[keys] = dataOp[mergeMapOps[keys]]
}
else {
if (dataOp[keys])
mergeFormat[keys] = dataOp[keys]
}
})
return mergeFormat
}
return format
}
export function myFixed(num: any, digit = 2) {
if (Object.is(Number.parseFloat(num), Number.NaN))
return console.warn(`传入的值:${num}不是一个数字`)
num = Number.parseFloat(num)
return (Math.round((num + Number.EPSILON) * 10 ** digit) / 10 ** digit).toFixed(digit)
}
export function preventDefault(event: Event, isStopPropagation?: boolean) {
if (typeof event.cancelable !== 'boolean' || event.cancelable)
event.preventDefault()
if (isStopPropagation)
event.stopPropagation()
}
function cacheStringFunction<T extends (str: string) => string>(fn: T): T {
const cache: Record<string, string> = Object.create(null)
return ((str: string) => {
const hit = cache[str]
return hit || (cache[str] = fn(str))
}) as T
}
const hyphenateRE = /\B([A-Z])/g
export const hyphenate = cacheStringFunction((str: string) =>
str.replace(hyphenateRE, '-$1').toLowerCase(),
)
export function padZero(num: number | string, length = 2): string {
num += ''
while ((num as string).length < length)
num = `0${num}`
return num.toString()
}
export const clamp = (num: number, min: number, max: number): number => Math.min(Math.max(num, min), max)
export function getScrollTopRoot(): number {
return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
}
type ObjectIndex = Record<string, unknown>
const { hasOwnProperty } = Object.prototype
function assignKey(to: ObjectIndex, from: ObjectIndex, key: string) {
const val = from[key]
if (!isDef(val))
return
if (!hasOwnProperty.call(to, key) || !isObject(val))
to[key] = val
else
// eslint-disable-next-line unicorn/new-for-builtins
to[key] = deepAssign(Object(to[key]), val)
}
export function deepAssign(to: ObjectIndex, from: ObjectIndex): ObjectIndex {
Object.keys(from).forEach((key) => {
assignKey(to, from, key)
})
return to
}
export function omit(obj: Record<string, unknown>, keys: string[]) {
if (Object.prototype.toString.call(obj) === '[object Object]')
return obj
return Object.keys(obj).reduce((prev, key) => {
if (!keys.includes(key))
prev[key] = obj[key]
return prev
}, {} as Record<string, unknown>)
}
export interface Deferred<T> extends Promise<T> {
resolve: (value?: T) => void
reject: (value?: any) => void
}
export function createDeferred<T>(): Deferred<T> {
let resolve: Deferred<T>['resolve'] = noop
let reject: Deferred<T>['reject'] = noop
const promise = new Promise((_resolve, _reject) => {
resolve = _resolve
reject = _reject
}) as unknown as Deferred<T>
promise.resolve = resolve
promise.reject = reject
return promise
}
export function toArray<T>(value?: T | T[]): T[] {
if (!value)
return []
return Array.isArray(value) ? value : [value]
}
export function noop() { }
export function getRandomId() {
return Math.random().toString(36).slice(-8)
}
export function isLooseEqual(a: any, b: any): boolean {
if (a === b)
return true
const isObjectA = isObject(a)
const isObjectB = isObject(b)
if (isObjectA && isObjectB)
return JSON.stringify(a) === JSON.stringify(b)
else if (!isObjectA && !isObjectB)
return String(a) === String(b)
else
return false
}
export function isEqualArray(a: any, b: any): boolean {
if (a === b)
return true
if (!isArray(a) || !isArray(b))
return false
if (a.length !== b.length)
return false
for (let i = 0; i < a.length; i++) {
if (!isLooseEqual(a[i], b[i]))
return false
}
return true
}
export function isEqualValue(a: any, b: any): boolean {
if (a === b)
return true
if (isArray(a) && isArray(b))
return isEqualArray(a, b)
return isLooseEqual(a, b)
}
export function cloneDeep<T = any>(obj: T, cache = new WeakMap()): T {
if (obj === null || typeof obj !== 'object')
return obj
if (cache.has(obj))
return cache.get(obj)
let clone
if (obj instanceof Date) {
clone = new Date(obj.getTime())
}
else if (obj instanceof RegExp) {
clone = new RegExp(obj)
}
else if (obj instanceof Map) {
clone = new Map(Array.from(obj, ([key, value]) => [key, cloneDeep(value, cache)]))
}
else if (obj instanceof Set) {
clone = new Set(Array.from(obj, value => cloneDeep(value, cache)))
}
else if (Array.isArray(obj)) {
clone = obj.map(value => cloneDeep(value, cache))
}
else if (Object.prototype.toString.call(obj) === '[object Object]') {
clone = Object.create(Object.getPrototypeOf(obj))
cache.set(obj, clone)
for (const [key, value] of Object.entries(obj))
clone[key] = cloneDeep(value, cache)
}
else {
clone = Object.assign({}, obj)
}
cache.set(obj, clone)
return clone
}

View File

@@ -0,0 +1,167 @@
/**
* 时间戳转换 或 获取当前时间的时间戳
*/
export function getTimeStamp(timeStr?: string | number) {
if (!timeStr)
return Date.now()
let t = timeStr
t = (t as number > 0) ? +t : t.toString().replace(/-/g, '/')
return new Date(t).getTime()
}
/**
* 是否为闫年
* @return {Boolse} true|false
*/
export function isLeapYear(y: number): boolean {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0
}
/**
* 返回星期数
* @return {string}
*/
export function getWhatDay(year: number, month: number, day: number): string {
const date = new Date(`${year}/${month}/${day}`)
const index = date.getDay()
const dayNames = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return dayNames[index]
}
/**
* 返回星期数
* @return {number}
*/
export function getMonthPreDay(year: number, month: number): number {
const date = new Date(`${year}/${month}/01`)
let day = date.getDay()
if (day === 0)
day = 7
return day
}
/**
* 返回月份天数
* @return {number}
*/
export function getMonthDays(year: string, month: string): number {
if (month.startsWith('0'))
month = month.split('')[1]
return ([0, 31, isLeapYear(Number(year)) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] as number[])[
month as any
]
}
/**
* 补齐数字位数
* @return {string}
*/
export function getNumTwoBit(n: number): string {
n = Number(n)
return (n > 9 ? '' : '0') + n
}
/**
* 日期对象转成字符串
* @return {string}
*/
export function date2Str(date: Date, split?: string): string {
split = split || '-'
const y = date.getFullYear()
const m = getNumTwoBit(date.getMonth() + 1)
const d = getNumTwoBit(date.getDate())
return [y, m, d].join(split)
}
/**
* 返回日期格式字符串
* @param i 0返回今天的日期、1返回明天的日期2返回后天得日期依次类推
* @return {string} '2014-12-31'
*/
export function getDay(i: number): string {
i = i || 0
let date = new Date()
const diff = i * (1000 * 60 * 60 * 24)
date = new Date(date.getTime() + diff)
return date2Str(date)
}
/**
* 时间比较
* @return {boolean}
*/
export function compareDate(date1: string, date2: string): boolean {
const startTime = new Date(date1.replace('-', '/').replace('-', '/'))
const endTime = new Date(date2.replace('-', '/').replace('-', '/'))
if (startTime >= endTime)
return false
return true
}
/**
* 时间是否相等
* @return {boolean}
*/
export function isEqual(date1: string, date2: string): boolean {
const startTime = new Date(date1).getTime()
const endTime = new Date(date2).getTime()
if (startTime === endTime)
return true
return false
}
export function getMonthWeek(year: string, month: string, date: string, firstDayOfWeek = 0): number {
const dateNow = new Date(Number(year), Number.parseInt(month) - 1, Number(date))
let w = dateNow.getDay() // 星期数
const d = dateNow.getDate()
let remainder = 6 - w
if (firstDayOfWeek !== 0) {
w = w === 0 ? 7 : w
remainder = 7 - w
}
return Math.ceil((d + remainder) / 7)
}
export function getYearWeek(year: string, month: string, date: string): number {
const dateNow = new Date(Number(year), Number.parseInt(month) - 1, Number(date))
const dateFirst = new Date(Number(year), 0, 1)
const dataNumber = Math.round((dateNow.valueOf() - dateFirst.valueOf()) / 86400000)
return Math.ceil((dataNumber + (dateFirst.getDay() + 1 - 1)) / 7)
}
export function getWeekDate(year: string, month: string, date: string, firstDayOfWeek = 0): string[] {
const dateNow = new Date(Number(year), Number.parseInt(month) - 1, Number(date))
const nowTime = dateNow.getTime()
let day = dateNow.getDay()
if (firstDayOfWeek === 0) {
const oneDayTime = 24 * 60 * 60 * 1000
// 显示周日
const SundayTime = nowTime - day * oneDayTime // 本周的周日
// 显示周六
const SaturdayTime = nowTime + (6 - day) * oneDayTime // 本周的周六
const sunday = date2Str(new Date(SundayTime))
const saturday = date2Str(new Date(SaturdayTime))
return [sunday, saturday]
}
else {
day = day === 0 ? 7 : day
const oneDayTime = 24 * 60 * 60 * 1000
// 显示周一
const MondayTime = nowTime - (day - 1) * oneDayTime // 本周的周一
// 显示周日
const SundayTime = nowTime + (7 - day) * oneDayTime // 本周的周日
const monday = date2Str(new Date(MondayTime))
const sunday = date2Str(new Date(SundayTime))
return [monday, sunday]
}
}
export function formatResultDate(date: string) {
const days = [...date.split('-')]
days[2] = getNumTwoBit(Number(days[2]))
days[3] = `${days[0]}-${days[1]}-${days[2]}`
days[4] = getWhatDay(+days[0], +days[1], +days[2])
return days
}

View File

@@ -0,0 +1,100 @@
/** 枚举EPlatform */
enum EPlatform {
/** App */
AppPlus = 'APP-PLUS',
/** App nvue */
AppPlusNvue = 'APP-PLUS-NVUE',
/** H5 */
H5 = 'H5',
/** 微信小程序 */
MpWeixin = 'MP-WEIXIN',
/** 支付宝小程序 */
MpAlipay = 'MP-ALIPAY',
/** 百度小程序 */
MpBaidu = 'MP-BAIDU',
/** 字节跳动小程序 */
MpToutiao = 'MP-TOUTIAO',
/** QQ小程序 */
MpQq = 'MP-QQ',
/** 360小程序 */
Mp360 = 'MP-360',
/** 微信小程序/支付宝小程序/百度小程序/字节跳动小程序/QQ小程序/360小程序 */
Mp = 'MP',
/** 快应用通用(包含联盟、华为) */
QuickappWebview = 'quickapp-webview',
/** 快应用联盟 */
QuickappWebviewUnion = 'quickapp-webview-union',
/** 快应用华为 */
QuickappWebviewHuawei = 'quickapp-webview-huawei',
}
/** 使用条件编译获取平台信息 */
function ifDefPlatform(): EPlatform {
let platform: EPlatform
// #ifdef APP-PLUS
platform = EPlatform.AppPlus
// #endif
// #ifdef APP-PLUS-NVUE
platform = EPlatform.AppPlusNvue
// #endif
// #ifdef H5
platform = EPlatform.H5
// #endif
// #ifdef MP-WEIXIN
platform = EPlatform.MpWeixin
// #endif
// #ifdef MP-ALIPAY
platform = EPlatform.MpAlipay
// #endif
// #ifdef MP-BAIDU
platform = EPlatform.MpBaidu
// #endif
// #ifdef MP-TOUTIAO
platform = EPlatform.MpToutiao
// #endif
// #ifdef MP-QQ
platform = EPlatform.MpQq
// #endif
// #ifdef MP-360
platform = EPlatform.Mp360
// #endif
// #ifdef MP
platform = EPlatform.Mp
// #endif
// #ifdef quickapp-webview
platform = EPlatform.QuickappWebview
// #endif
// #ifdef quickapp-webview-union
platform = EPlatform.QuickappWebviewUnion
// #endif
// #ifdef quickapp-webview-huawei
platform = EPlatform.QuickappWebviewHuawei
// #endif
return platform
}
/** 平台类型 */
export const platform: EPlatform = ifDefPlatform()
/** H5 */
export const isH5 = platform === EPlatform.H5
/** 微信小程序 */
export const isMpWeixin = platform === EPlatform.MpWeixin
/** 支付宝小程序 */
export const isMpAlipay = platform === EPlatform.MpAlipay
/** 百度小程序 */
export const isMpBaidu = platform === EPlatform.MpBaidu
/** 字节跳动小程序 */
export const isMpToutiao = platform === EPlatform.MpToutiao
/** QQ小程序 */
export const isMpQq = platform === EPlatform.MpQq
/** 360小程序 */
export const isMp360 = platform === EPlatform.Mp360
/** 微信小程序/支付宝小程序/百度小程序/字节跳动小程序/QQ小程序/360小程序 */
export const isMp = platform === EPlatform.Mp
/** 快应用通用(包含联盟、华为) */
export const isQuickappWebview = platform === EPlatform.QuickappWebview
/** 快应用联盟 */
export const isQuickappWebviewUnion = platform === EPlatform.QuickappWebviewUnion
/** 快应用华为 */
export const isQuickappWebviewHuawei = platform === EPlatform.QuickappWebviewHuawei

View File

@@ -0,0 +1,9 @@
export * from './common'
export * from './date'
export * from './env'
export * from './interceptor'
export * from './is'
export * from './props'
export * from './pxCheck'
export * from './raf'
export * from './style'

View File

@@ -0,0 +1,37 @@
import { isPromise } from './is'
export type Interceptor = (...args: any[]) => Promise<boolean> | boolean | undefined | void
export function funInterceptor(interceptor: Interceptor | undefined, {
args = [],
done,
canceled,
}: {
args?: unknown[]
done: (val?: any) => void
canceled?: () => void
}) {
if (interceptor) {
const returnVal = interceptor(null, ...args)
if (isPromise(returnVal)) {
returnVal
.then((value) => {
if (value)
done(value)
else if (canceled)
canceled()
})
.catch(() => {})
}
else if (returnVal) {
done()
}
else if (canceled) {
canceled()
}
}
else {
done()
}
}

View File

@@ -0,0 +1,96 @@
const toString = Object.prototype.toString
export function is(val: unknown, type: string) {
return toString.call(val) === `[object ${type}]`
}
export function isDef<T = unknown>(val?: T): val is T {
return typeof val !== 'undefined'
}
export function isUnDef<T = unknown>(val?: T): val is T {
return !isDef(val)
}
export function isObject(val: any): val is Record<any, any> {
return val !== null && is(val, 'Object')
}
export function isEmpty<T = unknown>(val: T): val is T {
if (isArray(val) || isString(val))
return val.length === 0
if (val instanceof Map || val instanceof Set)
return val.size === 0
if (isObject(val))
return Object.keys(val).length === 0
return false
}
export function isDate(val: unknown): val is Date {
return is(val, 'Date')
}
export function isNull(val: unknown): val is null {
return val === null
}
export function isNullAndUnDef(val: unknown): val is null | undefined {
return isUnDef(val) && isNull(val)
}
export function isNullOrUnDef(val: unknown): val is null | undefined {
return isUnDef(val) || isNull(val)
}
export function isNumber(val: unknown): val is number {
return is(val, 'Number')
}
export function isPromise<T = any>(val: unknown): val is Promise<T> {
return (
is(val, 'Promise')
|| ((isObject(val) || isFunction(val))
&& isFunction((val as any).then)
&& isFunction((val as any).catch))
)
}
export function isString(val: unknown): val is string {
return is(val, 'String')
}
export function isFunction(val: unknown): val is () => void {
return typeof val === 'function'
}
export function isBoolean(val: unknown): val is boolean {
return is(val, 'Boolean')
}
export function isRegExp(val: unknown): val is RegExp {
return is(val, 'RegExp')
}
export function isArray(val: any): val is Array<any> {
return val && Array.isArray(val)
}
export function isWindow(val: any): val is Window {
return typeof window !== 'undefined' && is(val, 'Window')
}
export function isElement(val: unknown): val is Element {
return isObject(val) && !!val.tagName
}
export function isMap(val: unknown): val is Map<any, any> {
return is(val, 'Map')
}
export function isUrl(path: string): boolean {
const reg = /^http(s)?:\/\/([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*)?/
return reg.test(path)
}

View File

@@ -0,0 +1,83 @@
/**
* prop type helpers
* help us to write less code and reduce bundle size
* copy from https://github.com/youzan/vant/blob/main/packages/vant/src/utils/props.ts
*/
import type { ExtractPropTypes, PropType, StyleValue } from 'vue'
export const unknownProp = null as unknown as PropType<unknown>
export const numericProp = [Number, String]
export const truthProp = {
type: Boolean,
default: true as const,
}
export const nullableBooleanProp = {
type: Boolean as PropType<boolean | undefined>,
default: undefined,
}
export function makeRequiredProp<T>(type: T) {
return {
type,
required: true as const,
}
}
export function makeArrayProp<T>(defaultVal: T[] = []) {
return {
type: Array as PropType<T[]>,
default: () => defaultVal,
}
}
export function makeObjectProp<T>(defaultVal: T) {
return {
type: Object as PropType<T>,
default: () => defaultVal,
}
}
export function makeNumberProp<T>(defaultVal: T) {
return {
type: Number,
default: defaultVal,
}
}
export function makeNumericProp<T>(defaultVal: T) {
return {
type: numericProp,
default: defaultVal,
}
}
export function makeStringProp<T>(defaultVal: T) {
return {
type: String as unknown as PropType<T>,
default: defaultVal,
}
}
export type ClassType = string | object | Array<ClassType>
export const commonProps = {
/**
* @description 自定义类名
*/
customClass: {
type: [String, Object, Array] as PropType<ClassType>,
default: '',
},
/**
* @description 自定义样式
*/
customStyle: {
type: [String, Object, Array] as PropType<StyleValue>,
default: '',
},
}
export type CommonProps = ExtractPropTypes<typeof commonProps>

View File

@@ -0,0 +1,3 @@
export function pxCheck(value: string | number): string {
return Number.isNaN(Number(value)) ? String(value) : `${value}px`
}

View File

@@ -0,0 +1,30 @@
const _window = window as any
export const inBrowser = typeof window !== 'undefined'
function requestAniFrame() {
if (typeof _window !== 'undefined') {
return (
_window.requestAnimationFrame
|| _window.webkitRequestAnimationFrame
|| function (callback: () => void) {
_window.setTimeout(callback, 1000 / 60)
}
)
}
else {
return function (callback: () => void) {
setTimeout(callback, 1000 / 60)
}
}
}
export function cancelRaf(id: number) {
if (inBrowser)
cancelAnimationFrame(id)
else
clearTimeout(id)
}
export default requestAniFrame()

View File

@@ -0,0 +1,167 @@
import type { CSSProperties } from 'vue'
import { hyphenate } from './common'
import { isArray, isEmpty, isNumber, isObject, isString } from './is'
import type { CommonProps } from './props'
export type NormalizedStyle = Record<string, string | number>
const listDelimiterRE = /;(?![^(]*\))/g
const propertyDelimiterRE = /:([\s\S]+)/
const styleCommentRE = /\/\*.*?\*\//g
export function parseStringStyle(cssText: string): NormalizedStyle {
const ret: NormalizedStyle = {}
cssText
.replace(styleCommentRE, '')
.split(listDelimiterRE)
.forEach((item) => {
if (item) {
const tmp = item.split(propertyDelimiterRE)
tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim())
}
})
return ret
}
export function stringifyStyle(styles: NormalizedStyle | string | undefined): string {
let ret = ''
if (!styles || isString(styles))
return ret
for (const key in styles) {
const value = styles[key]
const normalizedKey = key.startsWith('--') ? key : hyphenate(key)
if (isString(value) || typeof value === 'number') {
// only render valid values
ret += `${normalizedKey}:${value};`
}
}
return ret
}
export function getPx(value: string | number, unit = false) {
if (isNumber(value))
return unit ? `${value}px` : Number(value)
return unit ? `${Number.parseInt(value)}px` : Number.parseInt(value)
}
/**
* @description 样式转换
* 对象转字符串,或者字符串转对象
* @param {object | string} customStyle 需要转换的目标
* @param {string} target 转换的目的object-转为对象string-转为字符串
*/
export function addStyle(customStyle: string | object, target = 'object') {
// 字符串转字符串,对象转对象情形,直接返回
if (
isEmpty(customStyle)
|| (typeof customStyle === 'object' && target === 'object')
|| (target === 'string' && typeof customStyle === 'string')
) {
return customStyle
}
// 字符串转对象
if (target === 'object') {
// 去除字符串样式中的两端空格(中间的空格不能去掉比如padding: 20px 0如果去掉了就错了),空格是无用的
customStyle = trim(customStyle)
// 根据";"将字符串转为数组形式
const styleArray = customStyle.split(';')
const style: any = {}
// 历遍数组,拼接成对象
for (let i = 0; i < styleArray.length; i++) {
// 'font-size:20px;color:red;',如此最后字符串有";"的话会导致styleArray最后一个元素为空字符串这里需要过滤
if (styleArray[i]) {
const item = styleArray[i].split(':')
style[trim(item[0])] = trim(item[1])
}
}
return style
}
// 这里为对象转字符串形式
let string = ''
for (const i in customStyle as any) {
// 驼峰转为中划线的形式否则css内联样式无法识别驼峰样式属性名
const key = i.replace(/([A-Z])/g, '-$1').toLowerCase()
string += `${key}:${customStyle[i]};`
}
// 去除两端空格
return trim(string)
}
/**
* @description 去除空格
* @param str 需要去除空格的字符串
* @param pos both(左右)|left|right|all 默认both
*/
export function trim(str: string, pos = 'both') {
str = String(str)
if (pos === 'both')
return str.replace(/^\s+|\s+$/g, '')
if (pos === 'left')
return str.replace(/^\s*/, '')
if (pos === 'right')
return str.replace(/(\s*$)/g, '')
if (pos === 'all')
return str.replace(/\s+/g, '')
return str
}
export function normalizeStyle(value: unknown): NormalizedStyle | string | undefined {
if (isArray(value)) {
const res: NormalizedStyle = {}
for (let i = 0; i < value.length; i++) {
const item = value[i]
const normalized = isString(item)
? parseStringStyle(item)
: (normalizeStyle(item) as NormalizedStyle)
if (normalized) {
for (const key in normalized) {
if (!isEmpty(normalized[key]))
res[key] = normalized[key]
}
}
}
return res
}
if (isString(value))
return value
if (isObject(value))
return value
}
export function normalizeClass(value: unknown): string {
let res = ''
if (isString(value)) {
res = value
}
else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const normalized = normalizeClass(value[i])
if (normalized)
res += `${normalized} `
}
}
else if (isObject(value)) {
for (const name in value) {
if (value[name])
res += `${name} `
}
}
return res.trim()
}
export function getMainClass(props: CommonProps, componentName: string, cls?: object) {
return normalizeClass([props.customClass, { [componentName]: true }, cls])
}
export function getMainStyle(props: CommonProps, style?: CSSProperties) {
return stringifyStyle(normalizeStyle([props.customStyle, style]))
}

View File

@@ -0,0 +1,82 @@
import type { CSSProperties, ExtractPropTypes, PropType } from 'vue'
import { CANCEL_EVENT, CHOOSE_EVENT, CLOSE_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { commonProps, isBoolean, isNumber, makeArrayProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup/popup'
export interface ActionSheetOption {
disable?: boolean
loading?: boolean
color?: string
name: string
subname?: string
}
export const actionsheetProps = {
...popupProps,
...commonProps,
/**
* @description 是否显示圆角
*/
round: truthProp,
/**
* @description 是否开启 iPhone 系列全面屏底部安全区适配,仅当 `position` 为 `bottom` 时有效
*/
safeAreaInsetBottom: truthProp,
/**
* @description 遮罩显示时的背景是否锁定
*/
lockScroll: truthProp,
/**
* @description 自定义 popup 弹框样式
*/
popStyle: {
type: Object as PropType<CSSProperties>,
},
/**
* @description 取消文案
*/
cancelTxt: makeStringProp(''),
/**
* @description 设置列表项标题展示使用参数
*/
optionTag: makeStringProp<keyof ActionSheetOption>('name'),
/**
* @description 设置列表项二级标题展示使用参数
*/
optionSubTag: makeStringProp<keyof ActionSheetOption>('subname'),
/**
* @description 设置选中项的值,和 'option-tag' 的值对应
*/
chooseTagValue: makeStringProp(''),
/**
* @description 设置列表项标题
*/
title: makeStringProp(''),
/**
* @description 选中项颜色,当 choose-tag-value == option-tag 的值 生效
*/
customColor: makeStringProp('#ee0a24'),
/**
* @description 设置列表项副标题/描述
*/
description: makeStringProp(''),
/**
* @description 列表项
*/
menuItems: makeArrayProp<ActionSheetOption>([]),
/**
* @description 遮罩层是否可关闭
*/
closeAbled: truthProp,
}
export type ActionsheetProps = ExtractPropTypes<typeof actionsheetProps>
export const actionsheetEmits = {
[CLOSE_EVENT]: () => true,
[UPDATE_VISIBLE_EVENT]: (val: boolean) => isBoolean(val),
[CANCEL_EVENT]: () => true,
[CHOOSE_EVENT]: (item: ActionSheetOption, index: number) => item instanceof Object && isNumber(index),
}
export type ActionsheetEmits = typeof actionsheetEmits

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import { computed, defineComponent, useSlots } from 'vue'
import { CANCEL_EVENT, CHOOSE_EVENT, CLOSE_EVENT, PREFIX, UPDATE_VISIBLE_EVENT } from '../_constants'
import { getMainClass } from '../_utils'
import NutIcon from '../icon/icon.vue'
import NutPopup from '../popup/popup.vue'
import type { ActionSheetOption } from './actionsheet'
import { actionsheetEmits, actionsheetProps } from './actionsheet'
const props = defineProps(actionsheetProps)
const emit = defineEmits(actionsheetEmits)
const slotDefault = !!useSlots().default
const classes = computed(() => {
return getMainClass(props, componentName)
})
function isHighlight(item: ActionSheetOption) {
return props.chooseTagValue && props.chooseTagValue === item[props.optionTag] ? props.customColor : ''
}
function cancelActionSheet() {
emit(CANCEL_EVENT)
emit(UPDATE_VISIBLE_EVENT, false)
}
function chooseItem(item: ActionSheetOption, index: number) {
if (!item.disable && !item.loading) {
emit(CHOOSE_EVENT, item, index)
emit(UPDATE_VISIBLE_EVENT, false)
}
}
function close() {
if (props.closeAbled) {
emit(CLOSE_EVENT)
emit(UPDATE_VISIBLE_EVENT, false)
}
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-action-sheet`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<NutPopup
:pop-class="props.popClass"
:custom-style="props.popStyle"
:visible="props.visible"
position="bottom"
:overlay="props.overlay"
:round="props.round"
:safe-area-inset-bottom="props.safeAreaInsetBottom"
:z-index="props.zIndex"
:duration="props.duration"
:overlay-class="props.overlayClass"
:overlay-style="props.overlayStyle"
:lock-scroll="props.lockScroll"
:close-on-click-overlay="props.closeAbled"
@click-overlay="close"
>
<view :class="classes" :style="props.customStyle">
<view v-if="props.title" class="nut-action-sheet__title">
{{ props.title }}
</view>
<slot />
<view v-if="!slotDefault">
<view v-if="props.description" class="nut-action-sheet__item nut-action-sheet__desc">
{{ props.description }}
</view>
<view v-if="props.menuItems.length" class="nut-action-sheet__menu">
<view
v-for="(item, index) of props.menuItems"
:key="index"
class="nut-action-sheet__item"
:class="{
'nut-action-sheet__item--disabled': item.disable,
'nut-action-sheet__item--loading': item.loading,
}"
:style="{ color: isHighlight(item) || item.color }"
@click="chooseItem(item, index)"
>
<NutIcon v-if="item.loading" name="loading" />
<view v-else>
{{ item[props.optionTag] }}
</view>
<view class="nut-action-sheet__subdesc">
{{ item[props.optionSubTag] }}
</view>
</view>
</view>
<view v-if="props.cancelTxt" class="nut-action-sheet__cancel" @click="cancelActionSheet">
{{ props.cancelTxt }}
</view>
</view>
</view>
</NutPopup>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,82 @@
@import "../popup/index";
.nut-theme-dark {
.nut-action-sheet {
.nut-action-sheet__cancel {
border-top: 1px solid $dark-background2;
}
.nut-action-sheet__title {
border-bottom: 1px solid $dark-background2;
}
.nut-action-sheet__cancel,
.nut-action-sheet__item,
.nut-action-sheet__title {
color: $dark-color;
background: $dark-background;
}
}
}
.nut-action-sheet {
display: block;
.nut-action-sheet__title {
display: block;
padding: 10px;
margin: 0;
font-size: $font-size-base;
color: $title-color;
text-align: center;
background-color: $white;
border-bottom: 1px solid $actionsheet-light-color;
}
.nut-action-sheet__menu {
display: block;
padding: 0;
margin: 0;
list-style: none;
}
.nut-action-sheet__cancel,
.nut-action-sheet__item {
display: block;
padding: 10px;
font-size: $actionsheet-item-font-size;
line-height: $actionsheet-item-line-height;
color: $actionsheet-item-font-color;
text-align: center;
cursor: pointer;
background-color: #fff;
border-bottom: $actionsheet-item-border-bottom;
}
.nut-action-sheet__desc {
font-size: $actionsheet-item-font-size;
color: #999;
cursor: default;
}
.nut-action-sheet__subdesc {
display: block;
font-size: $actionsheet-item-subdesc-font-size;
color: #999;
}
.nut-action-sheet__item--disabled {
color: #e1e1e1 !important;
cursor: not-allowed;
}
.nut-action-sheet__item--loading {
cursor: default;
}
.nut-action-sheet__cancel {
margin-top: 5px;
border-top: $actionsheet-item-cancel-border-top;
}
}

View File

@@ -0,0 +1 @@
export * from './actionsheet'

View File

@@ -0,0 +1,104 @@
import type { ExtractPropTypes } from 'vue'
import { CHANGE_EVENT, CLOSE_EVENT, SELECTED_EVENT, UPDATE_MODEL_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { commonProps, isBoolean, makeArrayProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup'
import type { AddressExistRegionData, AddressRegionData, AddressType } from './type'
export const addressProps = {
...popupProps,
...commonProps,
/**
* @description 设置默认选中值
*/
modelValue: makeArrayProp<any>([]),
/**
* @description 是否打开地址选择
*/
visible: Boolean,
/**
* @description 地址选择类型:'exist' | 'custom' | 'custom2'
*/
type: makeStringProp<AddressType>('custom'),
/**
* @description 自定义地址选择标题
*/
customAddressTitle: makeStringProp(''),
/**
* @description 省份数据
*/
province: makeArrayProp<AddressRegionData>([]),
/**
* @description 城市数据
*/
city: makeArrayProp<AddressRegionData>([]),
/**
* @description 县区数据
*/
country: makeArrayProp<AddressRegionData>([]),
/**
* @description 乡镇数据
*/
town: makeArrayProp<AddressRegionData>([]),
/**
* @description 是否显示 '选择其他地区' 按钮。仅在类型为 'exist' 时生效
*/
isShowCustomAddress: truthProp,
/**
* @description 现存地址列表
*/
existAddress: makeArrayProp<AddressExistRegionData>([]),
/**
* @description 已有地址标题
*/
existAddressTitle: makeStringProp(''),
/**
* @description 切换自定义地址和已有地址的按钮标题
*/
customAndExistTitle: makeStringProp(''),
/**
* @description 弹层中内容容器的高度
*/
height: makeNumericProp('200'),
/**
* @description 列提示文字
*/
columnsPlaceholder: {
type: [String, Array],
default: '',
},
}
export type AddressProps = ExtractPropTypes<typeof addressProps>
export const addressEmits = {
[UPDATE_VISIBLE_EVENT]: (val: boolean) => isBoolean(val),
[UPDATE_MODEL_EVENT]: () => true,
[CLOSE_EVENT]: (val: {
data: any
type: string
}) => val instanceof Object,
[CHANGE_EVENT]: (val: {
next?: string
value?: AddressRegionData
custom: string
}) => val instanceof Object,
switchModule: (val: { type: AddressType }) => val instanceof Object,
closeMask: (val: { closeWay: 'self' | 'mask' | 'cross' }) => val instanceof Object,
[SELECTED_EVENT]: (prevExistAdd: AddressExistRegionData, item: AddressExistRegionData, copyExistAdd: AddressExistRegionData[]) => prevExistAdd instanceof Object && item instanceof Object && copyExistAdd instanceof Object,
}
export type AddressEmits = typeof addressEmits

View File

@@ -0,0 +1,443 @@
<script setup lang="ts">
import type { ScrollViewOnScrollEvent } from '@uni-helper/uni-app-types'
import { computed, defineComponent, reactive, ref, watch } from 'vue'
import { CHANGE_EVENT, CLOSE_EVENT, PREFIX, SELECTED_EVENT, UPDATE_MODEL_EVENT, UPDATE_VISIBLE_EVENT } from '../_constants'
import { getMainClass } from '../_utils'
import requestAniFrame from '../_utils/raf'
import { useTranslate } from '../../locale'
import NutElevator from '../elevator/elevator.vue'
import NutIcon from '../icon/icon.vue'
import NutPopup from '../popup/popup.vue'
import { addressEmits, addressProps } from './address'
import type { AddressExistRegionData, AddressRegionData, CustomRegionData } from './type'
const props = defineProps(addressProps)
const emit = defineEmits(addressEmits)
const classes = computed(() => {
return getMainClass(props, componentName)
})
const showPopup = ref(props.visible)
const privateType = ref(props.type)
const tabIndex = ref(0)
const prevTabIndex = ref(0)
const tabName = ref(['province', 'city', 'country', 'town'])
const scrollDis = ref([0, 0, 0, 0])
const scrollTop = ref(0)
const regionData = reactive<Array<AddressRegionData[]>>([])
const regionList = computed(() => {
switch (tabIndex.value) {
case 0:
return props.province
case 1:
return props.city
case 2:
return props.country
default:
return props.town
}
})
function transformData(data: AddressRegionData[]) {
if (!Array.isArray(data))
throw new TypeError('params muse be array.')
if (!data.length)
return []
data.forEach((item: AddressRegionData) => {
if (!item.title)
console.warn('[NutUI] <Address> 请检查数组选项的 title 值是否有设置 ,title 为必填项 .')
})
const newData: CustomRegionData[] = []
data = data.sort((a: AddressRegionData, b: AddressRegionData) => {
return a.title.localeCompare(b.title)
})
data.forEach((item: AddressRegionData) => {
const index = newData.findIndex((value: CustomRegionData) => value.title === item.title)
if (index <= -1) {
newData.push({
title: item.title,
list: ([] as any).concat(item),
})
}
else {
newData[index].list.push(item)
}
})
return newData
}
const selectedRegion = ref<AddressRegionData[]>([])
let selectedExistAddress = reactive({}) // 当前选择的地址
const closeWay = ref<'self' | 'mask' | 'cross'>('self')
// 设置选中省市县
function initCustomSelected() {
regionData[0] = props.province || []
regionData[1] = props.city || []
regionData[2] = props.country || []
regionData[3] = props.town || []
const defaultValue = props.modelValue
const num = defaultValue.length
if (num > 0) {
tabIndex.value = num - 1
if (regionList.value.length === 0) {
tabIndex.value = 0
return
}
for (let index = 0; index < num; index++) {
const arr: AddressRegionData[] = regionData[index]
selectedRegion.value[index] = arr.filter((item: AddressRegionData) => item.id === defaultValue[index])[0]
}
scrollTo()
}
}
function getTabName(item: AddressRegionData | null, index: number) {
if (item && item.name)
return item.name
if (tabIndex.value < index && item)
return item.name
else
return props.columnsPlaceholder[index] || translate('select')
}
// 手动关闭 点击叉号(cross),或者蒙层(mask)
function handClose(type = 'self') {
closeWay.value = type === 'cross' ? 'cross' : 'self'
showPopup.value = false
}
// 点击遮罩层关闭
function clickOverlay() {
closeWay.value = 'mask'
}
// 切换下一级列表
function nextAreaList(item: AddressRegionData) {
const tab = tabIndex.value
prevTabIndex.value = tabIndex.value
const callBackParams: {
next?: string
value?: AddressRegionData
custom: string
} = {
custom: tabName.value[tab],
}
selectedRegion.value[tab] = item
// 删除右边已选择数据
selectedRegion.value.splice(tab + 1, selectedRegion.value.length - (tab + 1))
callBackParams.value = item
if (regionData[tab + 1]?.length > 0) {
tabIndex.value = tab + 1
callBackParams.next = tabName.value[tabIndex.value]
scrollToTop()
}
else {
handClose()
emit(UPDATE_MODEL_EVENT)
}
emit(CHANGE_EVENT, callBackParams)
}
// 切换地区Tab
function changeRegionTab(item: AddressRegionData, index: number) {
prevTabIndex.value = tabIndex.value
if (getTabName(item, index)) {
tabIndex.value = index
scrollTo()
}
}
function scrollChange(e: ScrollViewOnScrollEvent) {
scrollDis.value[tabIndex.value] = e.detail.scrollTop
}
function scrollToTop() {
// scrollTop 不会实时变更。当再次赋值时scrollTop无变化时不会触发滚动
scrollTop.value += 1
requestAniFrame(() => {
setTimeout(() => {
// 直接设置为0无效
scrollTop.value = 0.01
}, 100)
})
}
function scrollTo() {
// scrollTop 不会实时变更。当再次赋值时scrollTop无变化时不会触发滚动
scrollTop.value += 1
requestAniFrame(() => {
setTimeout(() => {
scrollTop.value = scrollDis.value[tabIndex.value]
}, 10)
})
}
// 选择现有地址
function selectedExist(item: AddressExistRegionData) {
const copyExistAdd = props.existAddress
let prevExistAdd: AddressExistRegionData = {} as AddressExistRegionData
copyExistAdd.forEach((list: AddressExistRegionData) => {
if (list && list.selectedAddress)
prevExistAdd = list
list.selectedAddress = false
})
item.selectedAddress = true
selectedExistAddress = item
emit(SELECTED_EVENT, prevExistAdd, item, copyExistAdd)
handClose()
}
// 初始化
function initAddress() {
selectedRegion.value = []
tabIndex.value = 0
scrollTo()
}
// 关闭
function close() {
const data = {
addressIdStr: '',
addressStr: '',
province: selectedRegion.value[0],
city: selectedRegion.value[1],
country: selectedRegion.value[2],
town: selectedRegion.value[3],
}
const callBackParams = {
data: {},
type: privateType.value,
}
if (['custom', 'custom2'].includes(privateType.value)) {
[0, 1, 2, 3].forEach((i) => {
const item = selectedRegion.value[i]
data.addressIdStr += `${i ? '_' : ''}${(item && item.id) || 0}`
data.addressStr += (item && item.name) || ''
})
callBackParams.data = data
}
else {
callBackParams.data = selectedExistAddress
}
initAddress()
if (closeWay.value === 'self')
emit(CLOSE_EVENT, callBackParams)
else
emit('closeMask', { closeWay: closeWay.value })
emit(UPDATE_VISIBLE_EVENT, false)
}
// 选择其他地址
function switchModule() {
const type = privateType.value
privateType.value = type === 'exist' ? 'custom' : 'exist'
initAddress()
emit('switchModule', { type: privateType.value })
}
function handleElevatorItem(key: string, item: AddressRegionData) {
nextAreaList(item)
}
watch(
() => props.visible,
(value) => {
showPopup.value = value
},
)
watch(
() => showPopup.value,
(value) => {
if (value)
initCustomSelected()
},
)
</script>
<script lang="ts">
const componentName = `${PREFIX}-address`
const { translate } = useTranslate(componentName)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<NutPopup
v-model:visible="showPopup"
:z-index="zIndex"
position="bottom"
:lock-scroll="lockScroll"
:round="round"
@close="close"
@click-overlay="clickOverlay"
@open="closeWay = 'self'"
>
<view :class="classes" :style="customStyle">
<view class="nut-address__header">
<view class="nut-address__header-back" @click="switchModule">
<slot v-if="type === 'exist' && privateType === 'custom'" name="backIcon">
<NutIcon name="left" size="14px" />
</slot>
</view>
<view class="nut-address__header__title">
{{
privateType === 'custom'
? customAddressTitle || translate('selectRegion')
: existAddressTitle || translate('deliveryTo')
}}
</view>
<view class="nut-address__header-close" @click="handClose('cross')">
<slot name="closeIcon">
<NutIcon name="close" custom-color="#cccccc" size="14px" />
</slot>
</view>
</view>
<!-- 请选择 -->
<view v-if="['custom', 'custom2'].includes(privateType)" class="nut-address__custom">
<view class="nut-address__region">
<view
v-for="(item, index) in selectedRegion"
:key="index"
class="nut-address__region-item "
:class="[index === tabIndex ? 'active' : '']"
@click="changeRegionTab(item, index)"
>
<view>{{ getTabName(item, index) }} </view>
<view class="nut-address__region-line--mini" :class="{ active: index === tabIndex }" />
</view>
<view v-if="tabIndex === selectedRegion.length" class="active nut-address__region-item">
<view>{{ getTabName(null, selectedRegion.length) }} </view>
<view class="nut-address__region-line--mini active" />
</view>
</view>
<view v-if="privateType === 'custom'" class="nut-address__detail">
<div class="nut-address__detail-list">
<scroll-view
:scroll-y="true"
:style="{ height: '100%' }"
:scroll-top="scrollTop"
@scroll="scrollChange"
>
<div
v-for="(item, index) in regionList"
:key="index"
class="nut-address__detail-item"
:class="[selectedRegion[tabIndex]?.id === item.id ? 'active' : '']"
@click="nextAreaList(item)"
>
<view>
<slot v-if="selectedRegion[tabIndex]?.id === item.id" name="icon">
<NutIcon name="Check" custom-class="nut-address-select-icon" width="13px" />
</slot>{{ item.name }}
</view>
</div>
</scroll-view>
</div>
</view>
<view v-else class="nut-address__elevator-group">
<NutElevator
:height="height"
:index-list="transformData(regionList)"
@click-item="handleElevatorItem"
/>
</view>
</view>
<!-- 配送至 -->
<view v-else-if="privateType === 'exist'" class="nut-address__exist">
<div class="nut-address__exist-group">
<ul class="nut-address__exist-group-list">
<li
v-for="(item, index) in existAddress"
:key="index"
class="nut-address__exist-group-item"
:class="[item.selectedAddress ? 'active' : '']"
@click="selectedExist(item)"
>
<slot v-if="!item.selectedAddress" name="unselectedIcon">
<NutIcon name="location2" custom-class="nut-address-select-icon" width="13px" />
</slot>
<slot v-if="item.selectedAddress" name="icon">
<NutIcon name="Check" custom-class="nut-address-select-icon" width="13px" />
</slot>
<div class="nut-address__exist-item-info">
<div v-if="item.name && item.phone" class="nut-address__exist-item-info-top">
<div class="nut-address__exist-item-info-name">
{{ item.name }}
</div>
<div class="nut-address__exist-item-info-phone">
{{ item.phone }}
</div>
</div>
<div class="nut-address__exist-item-info-bottom">
<view>
{{ item.provinceName + item.cityName + item.countyName + item.townName + item.addressDetail }}
</view>
</div>
</div>
</li>
</ul>
</div>
<div v-if="isShowCustomAddress" class="nut-address__exist-choose" @click="switchModule">
<div class="nut-address__exist-choose-btn">
{{
customAndExistTitle || translate('chooseAnotherAddress')
}}
</div>
</div>
<template v-if="!isShowCustomAddress">
<slot name="bottom" />
</template>
</view>
</view>
</NutPopup>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,232 @@
@import '../popup/index';
@import '../elevator/index';
.nut-theme-dark {
.nut-address {
&__header {
color: $dark-color;
&__title {
color: $dark-color;
}
}
.nut-address__custom {
.nut-address__region {
color: $dark-color;
}
.nut-address__detail {
.nut-address__detail-list {
.nut-address__detail-item {
color: $dark-color;
}
}
}
}
.nut-address__exist {
.nut-address__exist-group {
.nut-address__exist-group-list {
.nut-address__exist-group-item {
color: $dark-color;
}
}
}
.nut-address__exist-choose {
border-top: 1px solid $dark-background;
}
}
&-custom-buttom {
border-top: 1px solid $dark-background;
}
}
}
.nut-address {
display: block;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
height: 68px;
padding: 0 20px;
font-weight: bold;
color: #333;
text-align: center;
&__title {
display: block;
font-size: $address-header-title-font-size;
color: $address-header-title-color;
}
}
// 请选择
.nut-address__custom {
display: block;
.nut-address__region {
position: relative;
display: flex;
// margin-top: 32px;
padding: 0 20px;
font-size: $address-region-tab-font-size;
color: $address-region-tab-color;
.nut-address__region-item {
position: relative;
display: block;
min-width: 2px;
margin-right: 30px;
&.active {
font-weight: $address-region-tab-active-item-font-weight;
}
view {
display: block;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nut-address__region-line--mini {
position: absolute;
bottom: -10px;
left: 0;
display: inline-block;
width: 0;
height: 3px;
margin-top: 5px;
background: $address-region-tab-line;
transition: 0.2s all linear;
&.active {
width: 26px;
}
}
}
.nut-address__region-line {
position: absolute;
bottom: -10px;
left: 20px;
display: inline-block;
width: 26px;
height: 3px;
margin-top: 5px;
background: $address-region-tab-line;
border-radius: $address-region-tab-line-border-radius;
opacity: $address-region-tab-line-opacity;
transition: 0.2s all linear;
}
}
.nut-address__detail {
display: block;
margin: 20px 20px 0;
.nut-address__detail-list {
// overflow-y: auto;
box-sizing: border-box;
height: 270px;
padding: 0;
padding-top: 15px;
.nut-address__detail-item {
display: flex;
align-items: center;
font-size: $address-region-item-font-size;
color: $address-region-item-color;
&.active {
font-weight: bold;
}
> view {
display: flex;
align-items: center;
margin: 10px 0;
}
}
}
}
.nut-address__elevator-group {
display: flex;
margin-top: 20px;
}
}
// 配送至
.nut-address__exist {
display: block;
margin-top: 15px;
.nut-address__exist-group {
height: 279px;
padding: 15px 20px 0;
overflow-y: scroll;
.nut-address__exist-group-list {
box-sizing: border-box;
padding: 0;
.nut-address__exist-group-item {
display: flex;
align-items: center;
margin-bottom: 20px;
font-size: $font-size-1;
line-height: 14px;
color: #333;
&.active {
font-weight: bold;
}
.exist-item-icon {
margin-right: $address-item-margin-right;
color: $address-icon-color !important;
}
// span {
// display: inline-block;
// flex: 1;
// }
}
}
}
.nut-address__exist-choose {
width: 100%;
height: 54px;
padding: 6px 0 0;
border-top: 1px solid #f2f2f2;
.nut-address__exist-choose-btn {
width: 90%;
height: 42px;
margin: auto;
font-size: 15px;
line-height: 42px;
color: $white;
text-align: center;
background: $button-primary-background-color;
border-radius: 21px;
}
}
}
&-select-icon {
margin-right: $address-item-margin-right;
color: $address-icon-color !important;
}
}

View File

@@ -0,0 +1,2 @@
export * from './address'
export * from './type'

View File

@@ -0,0 +1,23 @@
export interface AddressRegionData {
name: string
[key: string]: any
}
export interface CustomRegionData {
title: string
list: any[]
}
export interface AddressExistRegionData {
id?: string | number
provinceName: string
cityName: string
countyName: string
townName: string
addressDetail: string
selectedAddress: boolean
[key: string]: any
}
export const addressType = ['exist', 'custom', 'custom2'] as const
export type AddressType = (typeof addressType)[number]

View File

@@ -0,0 +1,42 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, isNumber, isString, makeArrayProp, makeObjectProp, truthProp } from '../_utils'
import type { AddressListOptions } from './type'
export const addresslistProps = {
...commonProps,
/**
* @description 地址数组
*/
data: makeArrayProp<any>([]),
/**
* @description 长按功能
*/
longPress: Boolean,
/**
* @description 右滑功能
*/
swipeEdition: Boolean,
/**
* @description 是否展示底部按钮
*/
showBottomButton: truthProp,
/**
* @description 自定义 `key` 值时,设置映射关系
*/
options: makeObjectProp<AddressListOptions>({}),
}
export type AddressListProps = ExtractPropTypes<typeof addresslistProps>
export const addresslistEmits = {
delIcon: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
editIcon: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
clickItem: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
longCopy: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
longSet: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
longDel: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
swipeDel: (val: Event, item: unknown, index: number | string) => (val instanceof Object) && (item instanceof Object) && (isNumber(index) || isString(index)),
add: (val: Event) => val instanceof Object,
}
export type AddressListEmits = typeof addresslistEmits

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, defineComponent, onMounted, reactive, ref, useSlots, watch } from 'vue'
import { PREFIX } from '../_constants'
import { floatData, getMainClass } from '../_utils'
import { useTranslate } from '../../locale'
import NutButton from '../button/button.vue'
import { addresslistEmits, addresslistProps } from './addresslist'
import GeneralShell from './compoents/generalshell.vue'
const props = defineProps(addresslistProps)
const emit = defineEmits(addresslistEmits)
const slots = useSlots()
function hasSlot(name: string) {
return Boolean(slots[name])
}
const dataArray = ref<any[]>([])
const dataInfo = reactive({
id: 2,
addressName: '姓名',
phone: '123****4567',
defaultAddress: false,
fullAddress: '北京市通州区测试测试测试测试测试测试测试测试测试',
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
// 磨平参数差异
function trowelData() {
if (Object.keys(props.options).length > 0) {
dataArray.value = props.data.map((item) => {
return floatData(dataInfo, item, props.options)
})
}
}
watch(
() => props.data,
() => trowelData(),
{ deep: true },
)
function handleDelIconClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('delIcon', event, item, index)
}
function handleEditIconClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('editIcon', event, item, index)
}
function handleContentItemClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('clickItem', event, item, index)
}
function handleLongCopyClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('longCopy', event, item, index)
}
function handleLongSetClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('longSet', event, item, index)
}
function handleLongDelClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('longDel', event, item, index)
}
function handleSwipeDelClick(event: any, item: any, index: number | string) {
event.stopPropagation()
emit('swipeDel', event, item, index)
}
function handleAddressAdd(event: any) {
event.stopPropagation()
emit('add', event)
}
onMounted(() => {
trowelData()
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-address-list`
const { translate } = useTranslate(componentName)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
// #ifndef H5
styleIsolation: 'shared',
// #endif
},
})
</script>
<template>
<view :class="classes" :style="customStyle">
<GeneralShell
v-for="(item, index) in dataArray"
:key="index"
:address="item"
:long-press="props.longPress"
:swipe-edition="props.swipeEdition"
:use-content-info-slot="hasSlot('itemInfos')"
:use-content-icons-slot="hasSlot('itemIcon')"
:use-content-addrs-slot="hasSlot('itemAddr')"
:use-longpress-all-slot="hasSlot('longpressBtns')"
:use-swipe-right-btn-slot="hasSlot('swipeRight')"
@del-icon="handleDelIconClick($event, item, index)"
@edit-icon="handleEditIconClick($event, item, index)"
@click-item="handleContentItemClick($event, item, index)"
@swipe-del="handleSwipeDelClick($event, item, index)"
@long-copy="handleLongCopyClick($event, item, index)"
@long-set="handleLongSetClick($event, item, index)"
@long-del="handleLongDelClick($event, item, index)"
>
<template #content-info>
<slot name="itemInfos" :item="item" />
</template>
<template #content-icons>
<slot name="itemIcon" :item="item" />
</template>
<template #content-addrs>
<slot name="itemAddr" :item="item" />
</template>
<template v-if="props.longPress" #longpress-all>
<slot name="longpressBtns" :item="item" />
</template>
<template v-if="props.swipeEdition" #swipe-right-btn>
<slot name="swipeRight" :item="item" />
</template>
</GeneralShell>
<view v-if="props.showBottomButton" class="nut-address-list__bottom" @click="handleAddressAdd">
<NutButton type="danger" block>
{{ translate('addAddress') }}
</NutButton>
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,154 @@
<script lang="ts" setup>
import { defineComponent } from 'vue'
import { PREFIX } from '../../_constants'
import { useTranslate } from '../../../locale'
import NutIcon from '../../icon/icon.vue'
const props = defineProps({
item: {
type: Object,
default: () => ({}),
},
useContentTopSlot: Boolean,
useContentIconSlot: Boolean,
useContentAddrSlot: Boolean,
})
const emit = defineEmits(['delIcon', 'editIcon', 'clickItem'])
function handleDelIconClick(event: any) {
event.stopPropagation()
emit('delIcon', event, props.item)
}
function handleEditIconClick(event: any) {
event.stopPropagation()
emit('editIcon', event, props.item)
}
function handleContentsClick(event: any) {
event.stopPropagation()
emit('clickItem', event, props.item)
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-address-list-item`
const { translate } = useTranslate(`${PREFIX}-address-list`)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
// #ifndef H5
styleIsolation: 'shared',
// #endif
},
})
</script>
<template>
<view class="nut-address-list-item" @click="handleContentsClick">
<view class="nut-address-list-item__info">
<view class="nut-address-list-item__info-contact">
<slot v-if="props.useContentTopSlot" name="content-top" />
<template v-else>
<view class="nut-address-list-item__info-contact-name">
{{ props.item.addressName }}
</view>
<view class="nut-address-list-item__info-contact-tel">
{{ props.item.phone }}
</view>
<view v-if="props.item.defaultAddress" class="nut-address-list-item__info-contact-default">
{{ translate('default') }}
</view>
</template>
</view>
<view class="nut-address-list-item__info-handle">
<slot v-if="props.useContentIconSlot" name="content-icon" />
<template v-else>
<NutIcon name="del" custom-class="nut-address-list-item__info-handle-del" @tap.stop="handleDelIconClick" />
<NutIcon name="edit" custom-class="nut-address-list-item__info-handle-edit" @tap.stop="handleEditIconClick" />
</template>
</view>
</view>
<view class="nut-address-list-item__addr">
<slot v-if="props.useContentAddrSlot" name="content-addr" />
<template v-else>
{{ props.item.fullAddress }}
</template>
</view>
</view>
</template>
<style lang="scss">
.nut-theme-dark {
.nut-address-list {
&-item {
&__addr {
color: $dark-color-gray;
}
}
}
}
.nut-address-list {
&-item {
width: 100%;
&__info {
display: flex;
justify-content: space-between;
&-contact {
display: flex;
align-items: center;
justify-content: flex-start;
font-weight: bold;
&-name {
max-width: 145px;
word-wrap: break-word;
}
&-tel {
max-width: 110px;
margin-left: 8px;
word-wrap: break-word;
}
&-default {
height: 16px;
padding: 0 6px;
margin-left: 5px;
font-size: 12px;
line-height: 16px;
color: $addresslist-contnts-contact-color;
background: $addresslist-contnts-contact-default;
border-radius: 2px;
}
}
&-handle {
&-edit {
margin-left: 15px;
}
}
}
&__addr {
margin-top: 5px;
font-size: $addresslist-addr-font-size;
color: $addresslist-addr-font-color;
}
}
}
</style>

View File

@@ -0,0 +1,335 @@
<script lang="ts" setup>
import { defineComponent, ref } from 'vue'
import { PREFIX } from '../../_constants'
import NutButton from '../../button/button.vue'
import NutSwipe from '../../swipe/swipe.vue'
import ItemContents from './Itemcontents.vue'
const props = defineProps({
address: {
type: Object,
},
longPress: {
type: Boolean,
default: false,
},
swipeEdition: {
type: Boolean,
default: false,
},
useContentInfoSlot: Boolean,
useContentIconsSlot: Boolean,
useContentAddrsSlot: Boolean,
useLongpressAllSlot: Boolean,
useSwipeRightBtnSlot: Boolean,
})
const emit = defineEmits(['delIcon', 'editIcon', 'clickItem', 'longDown', 'longCopy', 'longSet', 'longDel', 'swipeDel'])
const moveRef = ref<boolean>(false)
const showMaskRef = ref<boolean>(false)
function handleDelIconClick(event: any) {
event.stopPropagation()
emit('delIcon', event, props.address)
}
function handleEditIconClick(event: any) {
event.stopPropagation()
emit('editIcon', event, props.address)
}
function handleItemClick(event: any) {
event.stopPropagation()
if (moveRef.value)
return
emit('clickItem', event, props.address)
}
function handleLongDelClick(event: any) {
event.stopPropagation()
emit('longDel', event, props.address)
}
let timer: NodeJS.Timeout | null = null
function destroyTimer() {
if (timer == null)
return
clearTimeout(timer)
timer = null
}
function startTimer(event: any) {
timer = setTimeout(() => {
showMaskRef.value = true
emit('longDown', event, props.address)
}, 300)
}
// 长按功能实现
function handleTouchStart(event: any) {
startTimer(event)
}
function handleTouchMove() {
// 滑动不触发长按
destroyTimer()
}
function handleTouchEnd() {
// 删除定时器,防止重复注册
destroyTimer()
}
function handleHideMaskClick() {
showMaskRef.value = false
}
function handleLongCopyClick(event: any) {
event.stopPropagation()
emit('longCopy', event, props.address)
}
function handleLongSetClick(event: any) {
event.stopPropagation()
emit('longSet', event, props.address)
}
function handleMaskClick(event: any) {
event.stopPropagation()
event.preventDefault()
if (timer != null) {
// 排除长按时触发点击的情况
showMaskRef.value = false
}
}
function handleSwipeDelClick(event: any) {
event.stopPropagation()
emit('swipeDel', event, props.address)
}
function handleSwipeStart() {
moveRef.value = false
}
function handleSwipeMove() {
moveRef.value = true
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-address-list-general`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
// #ifndef H5
styleIsolation: 'shared',
// #endif
},
})
</script>
<template>
<view v-if="!props.swipeEdition" class="nut-address-list-general">
<ItemContents
:item="props.address"
:use-content-top-slot="props.useContentInfoSlot"
:use-content-icon-slot="props.useContentIconsSlot"
:use-content-addr-slot="props.useContentAddrsSlot"
@del-icon="handleDelIconClick"
@edit-icon="handleEditIconClick"
@click-item="handleItemClick"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<template #content-top>
<slot name="content-info" />
</template>
<template #content-icon>
<slot name="content-icons" />
</template>
<template #content-addr>
<slot name="content-addrs" />
</template>
</ItemContents>
<view v-if="props.longPress && showMaskRef" class="nut-address-list-general__mask" @click="handleMaskClick">
<slot v-if="props.useLongpressAllSlot" name="longpress-all" />
<template v-else>
<view class="nut-address-list-general__mask-copy" @click="handleLongCopyClick">
复制地址
</view>
<view class="nut-address-list-general__mask-set" @click="handleLongSetClick">
设置默认
</view>
<view class="nut-address-list-general__mask-del" @click="handleLongDelClick">
删除地址
</view>
</template>
</view>
<view v-if="showMaskRef" class="nut-address-list__mask-bottom" @click="handleHideMaskClick" />
</view>
<NutSwipe v-else>
<view class="nut-address-list-swipe">
<ItemContents
:item="props.address"
:use-content-top-slot="props.useContentInfoSlot"
:use-content-icon-slot="props.useContentIconsSlot"
:use-content-addr-slot="props.useContentAddrsSlot"
@del-icon="handleDelIconClick"
@edit-icon="handleEditIconClick"
@click-item="handleItemClick"
@touchstart="handleSwipeStart"
@touchmove="handleSwipeMove"
>
<template #content-top>
<slot name="content-info" />
</template>
<template #content-icon>
<slot name="content-icons" />
</template>
<template #content-addr>
<slot name="content-addrs" />
</template>
</ItemContents>
</view>
<template #right>
<view style="height: 100%;">
<slot v-if="props.useSwipeRightBtnSlot" name="swipe-right-btn" />
<template v-else>
<NutButton
shape="square"
custom-style="height: 100%;"
type="danger"
@tap.stop="handleSwipeDelClick"
>
删除
</NutButton>
</template>
</view>
</template>
</NutSwipe>
</template>
<style lang="scss">
.nut-theme-dark {
.nut-address-list {
&-swipe,
&-general {
color: $dark-color;
background-color: $dark-background2;
border-bottom: 1px solid $dark-color-gray;
&__mask {
background-color: $dark-color3;
&-copy {
color: $dark-color-gray;
background-color: $dark-color;
}
}
}
}
}
.nut-address-list {
&-swipe,
&-general {
position: relative;
display: flex;
align-items: center;
min-height: 76px;
padding: 5px 10px;
font-size: $addresslist-font-size;
color: $addresslist-font-color;
background-color: $addresslist-bg;
border-bottom: 1px solid $addresslist-border;
&__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2001;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0 40px;
background-color: $addresslist-mask-bg;
&-copy,
&-set,
&-del {
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
width: 55px;
height: 55px;
padding: 0 10px;
font-size: 14px;
text-align: center;
background-color: $white;
border-radius: 50%;
}
&-set {
color: $white;
background-color: $addresslist-set-bg;
}
&-del {
color: $white;
background-color: $addresslist-del-bg;
}
}
}
&-general {
&:last-child {
border-bottom: none;
}
}
.nut-swipe {
&:last-of-type {
.nut-address-list-swipe {
border-bottom: none;
}
}
}
.nut-address-list__mask-bottom {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2000;
background-color: transparent;
}
}
</style>

View File

@@ -0,0 +1,42 @@
@import "../button/index";
@import "../swipe/index";
.nut-theme-dark {
.nut-address-list {
&__bottom {
background-color: $dark-background2;
}
}
}
.nut-address-list {
overflow: hidden;
&:last-child {
padding-bottom: 84px;
}
&__bottom {
position: fixed;
right: 0;
bottom: 0;
bottom: constant(safe-area-inset-bottom);
bottom: env(safe-area-inset-bottom);
left: 0;
z-index: 100000;
box-sizing: border-box;
width: 100%;
padding: 12px 18px 24px;
background-color: $addresslist-bg;
}
.nut-address-list__mask-bottom {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 2000;
background-color: transparent;
}
}

View File

@@ -0,0 +1,2 @@
export * from './addresslist'
export * from './type'

View File

@@ -0,0 +1,3 @@
export interface AddressListOptions {
[key: string]: string
}

View File

@@ -0,0 +1,44 @@
import type { ExtractPropTypes } from 'vue'
import { CLICK_EVENT } from '../_constants'
import { commonProps, makeNumericProp, makeStringProp } from '../_utils'
import type { AnimateAction, AnimateType } from './type'
export const animateProps = {
...commonProps,
/**
* @description 控制动画,当值从 false 变为 true 时会触发一次动画
*/
show: Boolean,
/**
* @description 动画类型
* @values 'fade', 'slide', 'zoom', ...
*/
type: makeStringProp<AnimateType | ''>(''),
/**
* @description 是否循环执行。`true`-循环执行; `false`-执行一次
*/
loop: Boolean,
/**
* @description 动画时长,单位 ms
*/
duration: makeNumericProp(500),
/**
* @description (不能与 show 同时使用)触发方式,`initial`-初始化执行; `click`-点击执行
* @values 'initial', 'click'
* @default initial
*/
action: makeStringProp<AnimateAction>('initial'),
}
export type AnimateProps = ExtractPropTypes<typeof animateProps>
export const animateEmits = {
[CLICK_EVENT]: (evt: MouseEvent) => evt instanceof Object,
animate: () => true,
}
export type AnimateEmits = typeof animateEmits

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed, defineComponent, ref, watch } from 'vue'
import { CLICK_EVENT, PREFIX } from '../_constants'
import { getMainClass, getMainStyle } from '../_utils'
import requestAniFrame from '../_utils/raf'
import { animateEmits, animateProps } from './animate'
const props = defineProps(animateProps)
const emit = defineEmits(animateEmits)
const animated = ref(props.action === 'initial' || props.show === true || props.loop)
const classes = computed(() => {
const obj = {
[`${componentName}__container`]: true,
[`${componentName}-${props.type}`]: animated.value,
loop: props.loop,
}
return getMainClass(props, componentName, obj)
})
const getStyle = computed(() => {
return getMainStyle(props, {
animationDuration: props.duration ? `${props.duration}ms` : undefined,
})
})
function animate() {
animated.value = false
// #ifdef H5
requestAniFrame(() => {
requestAniFrame(() => {
animated.value = true
})
})
}
function handleClick(event: unknown) {
if (props.action === 'click') {
animate()
emit(CLICK_EVENT, event as MouseEvent)
emit('animate')
}
}
watch(
() => props.show,
(val) => {
if (val) {
animate()
emit('animate')
}
},
)
</script>
<script lang="ts">
const componentName = `${PREFIX}-animate`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view class="nut-animate">
<view
:class="classes"
:style="getStyle"
@click="handleClick"
>
<slot />
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,316 @@
.nut-animate {
.nut-animate__container {
display: inline-block;
}
/* Animation css */
[class*="nut-animate-"] {
animation-duration: 0.5s;
animation-timing-function: ease-out;
animation-fill-mode: both;
}
// 抖动
.nut-animate-shake {
animation-name: shake;
}
// 心跳
.nut-animate-ripple {
animation-name: ripple;
}
// 漂浮
.nut-animate-float {
position: relative;
animation-name: float-pop;
}
// 呼吸灯
.nut-animate-breath {
animation-name: breath;
animation-duration: 2700ms;
animation-timing-function: ease-in-out;
animation-direction: alternate;
}
// 右侧向左侧划入
.nut-animate-slide-right {
animation-name: slide-right;
}
// 右侧向左侧划入
.nut-animate-slide-left {
animation-name: slide-left;
}
// 上面向下面划入
.nut-animate-slide-top {
animation-name: slide-top;
}
// 下面向上面划入
.nut-animate-slide-bottom {
animation-name: slide-bottom;
}
.nut-animate-jump {
transform-origin: center center;
animation: jump 0.7s linear;
}
// 循环
.loop {
animation-iteration-count: infinite;
}
// 抖动动画
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10% {
transform: translateX(-9px);
}
20% {
transform: translateX(8px);
}
30% {
transform: translateX(-7px);
}
40% {
transform: translateX(6px);
}
50% {
transform: translateX(-5px);
}
60% {
transform: translateX(4px);
}
70% {
transform: translateX(-3px);
}
80% {
transform: translateX(2px);
}
90% {
transform: translateX(-1px);
}
}
// 心跳
@keyframes ripple {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
// 呼吸
@keyframes breath {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
// 右侧向左侧划入
// stylelint-disable-next-line keyframes-name-pattern
@keyframes slide-right {
0% {
opacity: 0;
transform: translateX(100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
// 左侧向右侧划入
// stylelint-disable-next-line keyframes-name-pattern
@keyframes slide-left {
0% {
opacity: 0;
transform: translateX(-100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
// 上面向下面划入
// stylelint-disable-next-line keyframes-name-pattern
@keyframes slide-top {
0% {
opacity: 0;
transform: translateY(-100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
// 下面向上面划入
// stylelint-disable-next-line keyframes-name-pattern
@keyframes slide-bottom {
0% {
opacity: 0;
transform: translateY(100%);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
// 漂浮 float
// stylelint-disable-next-line keyframes-name-pattern
@keyframes float-pop {
0% {
top: 0;
}
25% {
top: 1px;
}
50% {
top: 4px;
}
75% {
top: 1px;
}
100% {
top: 0;
}
}
// 跳跃
@keyframes jump {
0% {
transform: rotate(0deg) translateY(0);
animation-timing-function: ease-in;
}
25% {
transform: rotate(10deg) translateY(20 * 1px);
animation-timing-function: ease-out;
}
50% {
transform: rotate(0deg) translateY(-10 * 1px);
animation-timing-function: ease-in;
}
75% {
transform: rotate(-10deg) translateY(20 * 1px);
animation-timing-function: ease-out;
}
100% {
transform: rotate(0deg) translateY(0);
animation-timing-function: ease-in;
}
}
.nut-animate-twinkle {
position: relative;
&::after,
&::before {
position: absolute;
right: 50%;
z-index: 1;
box-sizing: border-box;
width: 60 * 1px;
height: 60 * 1px;
margin-top: calc(-30 / 2) * 1px;
margin-right: calc(-60 / 2) * 1px;
content: "";
border: 4 * 1px solid rgb(255 255 255 / 60%);
border-radius: calc(60 / 2) * 1px;
transform: scale(0);
animation: twinkle 2s ease-out infinite;
}
&::after {
animation-delay: 0.4s;
}
}
// 水波
@keyframes twinkle {
0% {
transform: scale(0);
}
20% {
opacity: 1;
}
50%,
100% {
opacity: 0;
transform: scale(1.4);
}
}
.nut-animate-flicker {
position: relative;
overflow: hidden;
&::after {
position: absolute;
top: 0;
left: 0;
width: 100 * 1px;
height: 60 * 1px;
content: "";
background-image: linear-gradient(106deg, rgb(232 224 255 / 0%) 24%, #e8e0ff 91%);
filter: blur(3 * 1px);
opacity: 0.73;
transform: skewX(-20deg);
animation: flicker 1.5s linear infinite;
}
}
@keyframes flicker {
0% {
transform: translateX(-100 * 1px) skewX(-20deg);
}
40%,
100% {
transform: translateX(150 * 1px) skewX(-20deg);
}
}
}

View File

@@ -0,0 +1 @@
export * from './animate'

View File

@@ -0,0 +1,4 @@
export const animateType = ['shake', 'ripple', 'breath', 'float', 'slide-right', 'slide-left', 'slide-top', 'slide-bottom', 'jump', 'twinkle', 'flicker'] as const
export type AnimateType = (typeof animateType)[number]
export const animateAction = ['initial', 'click', ''] as const
export type AnimateAction = (typeof animateAction)[number]

View File

@@ -0,0 +1,25 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, makeNumericProp, makeStringProp } from '../_utils'
import type { AvatarShape, AvatarSize } from './type'
export const avatarProps = {
...commonProps,
/**
* @description 头像的大小,可选值为:`large`、`normal`、`small`,支持直接输入数字
*/
size: makeNumericProp<AvatarSize | string | number | undefined>(undefined),
/**
* @description 头像的形状,可选值为:`square`、`round`
*/
shape: makeStringProp<AvatarShape | undefined>(undefined),
/**
* @description 背景色
*/
bgColor: makeStringProp('#eee'),
/**
* @description 字体颜色
*/
customColor: makeStringProp('#666'),
}
export type AvatarProps = ExtractPropTypes<typeof avatarProps>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, defineComponent, getCurrentInstance, ref, watch } from 'vue'
import { PREFIX } from '../_constants'
import { useInject } from '../_hooks'
import { getMainClass, getMainStyle, pxCheck } from '../_utils'
import type { AvatarGroupProps } from '../avatargroup'
import { AVATAR_GROUP_KEY } from '../avatargroup'
import { avatarProps } from './avatar'
import type { AvatarFinalSize, AvatarShape, AvatarSize } from './type'
import { avatarSize } from './type'
const props = defineProps(avatarProps)
const instance = getCurrentInstance()
const { parent } = useInject<{ props: Required<AvatarGroupProps> }>(AVATAR_GROUP_KEY)
const show = ref(true)
const innerZIndex = ref<number | undefined>(undefined)
watch(() => ({
maxCount: parent?.props.maxCount,
children: parent?.internalChildren,
}), ({ maxCount, children }) => {
if (maxCount == null || Number(maxCount) <= 0 || children == null || instance == null) {
show.value = true
innerZIndex.value = undefined
return
}
const index = children.findIndex((item) => {
return item.uid === instance.uid && !(item.props.customClass as string)?.includes('avatar-fold')
})
if (index < 0) {
show.value = true
innerZIndex.value = undefined
return
}
show.value = index < Number(maxCount)
if (parent?.props.zIndex === 'right')
innerZIndex.value = children.length - index
else
innerZIndex.value = undefined
}, {
immediate: true,
deep: true,
})
function getTrulySize() {
if (props.size != null)
return props.size
if (parent != null && parent.props.size != null)
return parent.props.size
return 'normal'
}
const finalSize = computed<AvatarFinalSize>(() => {
const size: string | number = getTrulySize()
const preset: boolean = avatarSize.includes(size as AvatarSize)
return {
preset,
value: preset ? (size as AvatarSize) : pxCheck(size),
}
})
const finalShape = computed<AvatarShape>(() => {
if (props.shape != null)
return props.shape
if (parent != null && parent.props.shape != null)
return parent.props.shape
return 'round'
})
const classes = computed(() => {
const value: Record<string, boolean> = {
[`nut-avatar-${finalShape.value}`]: true,
'nut-hidden': !show.value,
}
if (finalSize.value.preset)
value[`nut-avatar-${finalSize.value.value}`] = true
return getMainClass(props, componentName, value)
})
const styles = computed(() => {
const value: CSSProperties = {
backgroundColor: props.bgColor,
color: props.customColor,
}
if (!finalSize.value.preset) {
value.width = finalSize.value.value
value.height = finalSize.value.value
}
if (parent?.props.span)
value.marginLeft = pxCheck(parent?.props.span)
if (innerZIndex.value !== undefined)
value.zIndex = innerZIndex.value
return getMainStyle(props, value)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-avatar`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :style="styles" :class="classes">
<slot />
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,55 @@
.nut-avatar {
position: relative;
display: inline-block;
flex: 0 0 auto; // 防止被压缩
text-align: center;
vertical-align: top;
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
image {
display: block;
width: 100%;
height: 100%;
}
.nut-icon {
position: absolute;
top: 50%;
left: 50%;
background-size: 100% 100%;
transform: translate(-50%, -50%);
}
}
.nut-avatar-large {
width: $avatar-large-width;
height: $avatar-large-height;
line-height: $avatar-large-height;
}
.nut-avatar-normal {
width: $avatar-normal-width;
height: $avatar-normal-height;
line-height: $avatar-normal-height;
}
.nut-avatar-small {
width: $avatar-small-width;
height: $avatar-small-height;
line-height: $avatar-small-height;
}
.nut-avatar-square {
border-radius: $avatar-square;
}
.nut-avatar-round {
border-radius: 50%;
}
.nut-avatar-square,
.nut-avatar-round {
overflow: hidden;
}

View File

@@ -0,0 +1,2 @@
export * from './avatar'
export * from './type'

View File

@@ -0,0 +1,16 @@
export const avatarSize = ['large', 'normal', 'small'] as const
export type AvatarSize = (typeof avatarSize)[number]
export const avatarShape = ['round', 'square'] as const
export type AvatarShape = (typeof avatarShape)[number]
export interface AvatarFinalSize {
/**
* 是否为预设尺寸
*/
preset: boolean
/**
* 尺寸值
*/
value: string
}

View File

@@ -0,0 +1,50 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, makeNumericProp, makeStringProp } from '../_utils'
import type { AvatarShape, AvatarSize } from '../avatar'
export const AVATAR_GROUP_KEY = Symbol('avatarGroup')
export const avatargroupProps = {
...commonProps,
/**
* @description 显示的最大头像个数
*/
maxCount: makeNumericProp(-1),
/**
* @description 头像数量超出时,会出现一个头像折叠元素,该元素内容可为`...`、`more`、`+N`
*/
maxContent: makeStringProp(''),
/**
* @description 头像的大小,可选值为:`large`、`normal`、`small`,支持直接输入数字
*/
size: makeNumericProp<AvatarSize | string | number>('normal'),
/**
* @description 头像的形状,可选值为:`square`、`round`
*/
shape: makeStringProp<AvatarShape>('round'),
/**
* @description 头像折叠元素的字体颜色
*/
maxColor: makeStringProp('#666'),
/**
* @description 头像折叠元素的背景色
*/
maxBgColor: makeStringProp('#eee'),
/**
* @description 头像之间的间距
*/
span: makeNumericProp('-8'),
/**
* @description 组合头像之间的层级方向,可选值为:`left`、`right`
*/
zIndex: makeStringProp<'left' | 'right'>('left'),
}
export type AvatarGroupProps = ExtractPropTypes<typeof avatargroupProps>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { computed, defineComponent, ref, watch } from 'vue'
import { PREFIX } from '../_constants'
import { useProvide } from '../_hooks'
import { getMainClass, getMainStyle, pxCheck } from '../_utils'
import NutAvatar from '../avatar/avatar.vue'
import { AVATAR_GROUP_KEY, avatargroupProps } from './avatargroup'
const props = defineProps(avatargroupProps)
const { internalChildren } = useProvide(AVATAR_GROUP_KEY, `${PREFIX}-avatar`)({ props })
const innerMaxCount = computed<number>(() => {
return Number(props.maxCount)
})
const foldCount = ref(0)
watch(() => ({
maxCount: props.maxCount,
children: internalChildren,
}), ({ children }) => {
if (innerMaxCount.value > 0)
foldCount.value = Math.min(99, children.length - innerMaxCount.value)
else
foldCount.value = 0
}, {
immediate: true,
deep: true,
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
const styles = computed(() => {
return getMainStyle(props, {
marginLeft: `calc(0px - ${pxCheck(props.span)})`,
})
})
const foldStyles = computed<CSSProperties>(() => {
return {
marginLeft: pxCheck(props.span),
}
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-avatar-group`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="styles">
<slot />
<NutAvatar
v-if="foldCount > 0"
custom-class="avatar-fold"
:custom-style="foldStyles"
:size="props.size"
:shape="props.shape"
:bg-color="props.maxBgColor"
:custom-color="props.maxColor"
>
{{ props.maxContent || foldCount }}
</NutAvatar>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,12 @@
.nut-avatar-group {
position: relative;
display: flex;
flex: 0 0 auto; // 防止被压缩
background-repeat: no-repeat;
background-position: center center;
background-size: 100% 100%;
.nut-avatar {
border: 1px solid #fff;
}
}

View File

@@ -0,0 +1 @@
export * from './avatargroup'

View File

@@ -0,0 +1,44 @@
import type { ExtractPropTypes } from 'vue'
import { CLICK_EVENT } from '../_constants'
import { commonProps, makeNumberProp, makeStringProp } from '../_utils'
export const backtopProps = {
...commonProps,
/**
* @description 滚动区域的高度
*/
height: makeStringProp('100vh'),
/**
* @description 距离页面底部距离
*/
bottom: makeNumberProp(20),
/**
* @description 距离页面右侧距离
*/
right: makeNumberProp(10),
/**
* @description 页面垂直滚动多高后出现
*/
distance: makeNumberProp(200),
/**
* @description 设置组件页面层级
*/
zIndex: makeNumberProp(10),
/**
* @description 自定义图标颜色
*/
customColor: String,
}
export type BacktopProps = ExtractPropTypes<typeof backtopProps>
export const backtopEmits = {
[CLICK_EVENT]: (evt: MouseEvent) => evt instanceof Object,
}
export type BacktopEmits = typeof backtopEmits

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import type { ScrollViewOnScrollEvent } from '@uni-helper/uni-app-types'
import { computed, defineComponent, ref } from 'vue'
import { CLICK_EVENT, PREFIX } from '../_constants'
import { getMainClass, getMainStyle } from '../_utils'
import NutIcon from '../icon/icon.vue'
import { backtopEmits, backtopProps } from './backtop'
const props = defineProps(backtopProps)
const emit = defineEmits(backtopEmits)
const backTop = ref(false)
const scrollTop = ref(1)
const classes = computed(() => {
return getMainClass(props, componentName, {
show: backTop.value,
})
})
const style = computed(() => {
return getMainStyle(props, {
right: `${props.right}px`,
bottom: `${props.bottom}px`,
zIndex: props.zIndex,
})
})
function scroll(e: ScrollViewOnScrollEvent) {
scrollTop.value = 2
backTop.value = e.detail.scrollTop >= props.distance
}
function click(e: unknown) {
scrollTop.value = 1
emit(CLICK_EVENT, e as MouseEvent)
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-backtop`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view>
<scroll-view
:scroll-y="true"
:style="{ height }"
:scroll-top="scrollTop"
:scroll-with-animation="true"
@scroll="scroll"
>
<slot name="content" />
</scroll-view>
<view :class="classes" :style="style" @click.stop="click">
<slot name="icon">
<NutIcon
:custom-color="customColor"
name="top"
:size="19"
custom-class="nut-backtop-main"
/>
</slot>
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,40 @@
.nut-theme-dark {
.nut-backtop {
&.show {
color: $dark-color;
background: $dark-background;
border: 1px solid $dark-background;
}
&-main {
color:'#ffffff';
}
}
}
.nut-backtop {
position: fixed;
display: none;
&.show {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: $white;
border: 1px solid $backtop-border-color;
border-radius: 50%;
}
&.show :active {
background: rgb(0 0 0 / 10%);
}
&-main {
color:'#000000';
transition: all 0.2s ease-in-out
}
}

View File

@@ -0,0 +1 @@
export type * from './backtop'

View File

@@ -0,0 +1,52 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, makeNumberProp, makeStringProp } from '../_utils'
export const badgeProps = {
...commonProps,
/**
* @description 显示的内容
*/
value: [String, Number],
/**
* @description `value` 为数值时,最大值
*/
max: makeNumberProp(10000),
/**
* @description 是否为小点
*/
dot: Boolean,
/**
* @description 是否为气泡形状
* @since >v4.0.0
*/
bubble: Boolean,
/**
* @description 是否隐藏
*/
hidden: Boolean,
/**
* @description 上下偏移量,支持单位设置,可设置为:`5px` 等
*/
top: makeStringProp('0'),
/**
* @description 左右偏移量,支持单位设置,可设置为:`5px` 等
*/
right: makeStringProp('0'),
/**
* @description 徽标的 `z-index` 值
*/
zIndex: makeNumberProp(10),
/**
* @description 徽标背景颜色
*/
customColor: makeStringProp(''),
}
export type BadgeProps = ExtractPropTypes<typeof badgeProps>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, defineComponent } from 'vue'
import { PREFIX } from '../_constants'
import { getMainClass, getMainStyle, pxCheck } from '../_utils'
import { badgeProps } from './badge'
const props = defineProps(badgeProps)
const getStyle = computed(() => {
return getMainStyle(props, {
top: pxCheck(props.top),
right: pxCheck(props.right),
zIndex: props.zIndex,
background: props.customColor,
})
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
const content = computed(() => {
if (props.dot)
return
const value = props.value
const max = props.max
if (typeof value === 'number' && typeof max === 'number')
return max < value ? `${max}+` : value
return value
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-badge`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes">
<view v-if="!props.hidden && !props.dot && $slots.icon" class="nut-badge__icon" :style="getStyle">
<slot name="icon" />
</view>
<slot />
<view
v-if="!props.hidden && (content || props.dot)"
class="nut-badge__content nut-badge__content--sup"
:class="{ 'nut-badge__content--dot': props.dot, 'nut-badge__content--bubble': !props.dot && props.bubble }"
:style="getStyle"
>
{{ content }}
</view>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,54 @@
.nut-theme-dark {
.nut-badge {
&.show {
color: $dark-color;
background: $dark-background;
}
}
}
.nut-badge {
position: relative;
display: inline-block;
.nut-badge__icon {
position: absolute;
z-index: $badge-z-index;
display: flex;
align-items: center;
padding: $badge-icon-padding;
line-height: normal;
text-align: center;
background: $badge-background-color;
border-radius: $badge-border-radius;
transform: $badge-content-transform;
}
.nut-badge__content {
display: flex;
align-items: center;
transform: $badge-content-transform;
&--sup {
position: absolute;
padding: $badge-padding;
font-size: $badge-font-size;
font-weight: normal;
color: $badge-color;
text-align: center;
background: $badge-background-color;
border-radius: $badge-border-radius;
}
&--dot {
width: $badge-dot-width;
height: $badge-dot-height;
padding: $badge-dot-padding;
border-radius: $badge-dot-border-radius;
}
&--bubble {
border-bottom-left-radius: 0;
}
}
}

View File

@@ -0,0 +1 @@
export * from './badge'

View File

@@ -0,0 +1,41 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, makeArrayProp, makeNumberProp, truthProp } from '../_utils'
export const barrageProps = {
...commonProps,
/**
* @description 弹幕列表数据
*/
danmu: makeArrayProp<string>([]),
/**
* @description 可视区域内每个弹幕出现的时间间隔
*/
frequency: makeNumberProp<number>(500),
/**
* @description 每个弹幕的滚动时间
*/
speeds: makeNumberProp<number>(5000),
/**
* @description 弹幕行数,分几行展示
*/
rows: makeNumberProp<number>(3),
/**
* @description 弹幕垂直距离
*/
top: makeNumberProp<number>(10),
/**
* @description 是否循环播放
*/
loop: truthProp,
}
export type BarrageProps = ExtractPropTypes<typeof barrageProps>
export interface BarrageInst {
add: (word: string) => void
}

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import type { ComponentInternalInstance } from 'vue'
import { computed, defineComponent, getCurrentInstance, onMounted, reactive, ref, useSlots, watch } from 'vue'
import { PREFIX } from '../_constants'
import { useSelectorQuery } from '../_hooks'
import { getMainClass } from '../_utils'
import { barrageProps } from './barrage'
const props = defineProps(barrageProps)
defineExpose({ add })
const instance = getCurrentInstance() as ComponentInternalInstance
const { getSelectorNodeInfo } = useSelectorQuery(instance)
const classTime = new Date().getTime()
const slotDefault = !!useSlots().default
const timeId = ref(new Date().getTime())
const danmuList = ref<any>(props.danmu)
const rows = ref<number>(props.rows)
const top = ref<number>(props.top)
const speeds = props.speeds
const classes = computed(() => {
return getMainClass(props, componentName, {
[`nut-barrage--dmBody${timeId.value}`]: true,
})
})
onMounted(() => {
// #ifdef H5
if (slotDefault) {
const list = document
.getElementsByClassName(`nut-barrage__slotBody${classTime}`)[0]
.getElementsByClassName('nut-barrage__item')
const childrens = list?.[0]?.children || []
danmuList.value = childrens
}
// #endif
runStep()
})
watch(
() => props.danmu,
(newValue) => {
danmuList.value = [...newValue]
},
)
function add(word: string) {
danmuList.value = [...danmuList.value, word]
runStep()
}
function getNode(index: number) {
setTimeout(async () => {
let width = 100
const dmBodyNodeInfo = await getSelectorNodeInfo(`.nut-barrage--dmBody${timeId.value}`)
width = dmBodyNodeInfo?.width || 300
const itemNodeInfo = await getSelectorNodeInfo(`.nut-barrage__item${index}`)
const height = itemNodeInfo?.height
const nodeTop = `${(index % rows.value) * (height! + top.value) + 20}px`
styleInfo(index, nodeTop, width)
}, 500)
}
function runStep() {
danmuList.value.forEach((item: any, index: number) => {
getNode(index)
})
}
const styleList: any[] = reactive([])
function styleInfo(index: number, nodeTop: string, width: number) {
const timeIndex = index - rows.value > 0 ? index - rows.value : 0
const list = styleList
const time = list[timeIndex] ? Number(list[timeIndex]['--time']) : 0
// distance.value = '-' + (speeds / 1000) * 200 + '%';
const obj = {
'top': nodeTop,
'--time': `${props.frequency * index + time}`,
'animationDuration': `${speeds}ms`,
'animationIterationCount': `${props.loop ? 'infinite' : 1}`,
'animationDelay': `${props.frequency * index + time}ms`,
'--move-distance': `-${width}px`,
}
if (slotDefault && danmuList.value[index]?.el) {
const orginalSty = danmuList.value[index].el.style
danmuList.value[index].el.style = Object.assign(orginalSty, obj)
}
else {
styleList.push(obj)
}
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-barrage`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="customStyle">
<div>
<div :class="[`nut-barrage__slotBody${classTime}`]">
<view
v-for="(item, index) of danmuList"
:key="`danmu${index}`"
class="nut-barrage__item move"
:class="[`nut-barrage__item${index}`]"
:style="styleList[index]"
>
{{ item.length > 8 ? `${item.substr(0, 8)}...` : item }}
</view>
</div>
</div>
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,60 @@
.nut-barrage {
position: absolute;
top: 0;
left: 0;
box-sizing: border-box;
width: 100%;
height: 100%;
overflow: hidden;
--move-distance: '300%';
&__item {
position: absolute;
right: 0;
display: block;
width: 100px;
padding: 3px 25px;
font-size: 12px;
text-align: center;
white-space: pre;
background: linear-gradient(to right, rgb(0 0 0 / 15%), rgb(0 0 0 / 0%));
border-radius: 50px;
transform: translateX(100%);
&.move {
animation-name: moving;
animation-play-state: running;
animation-timing-function: linear;
will-change: transform;
}
@keyframes moving {
from {
transform: translateX(100%);
}
to {
transform: translateX(var(--move-distance));
}
}
@keyframes moving {
from {
transform: translateX(100%);
}
to {
transform: translateX(var(--move-distance));
}
}
}
}
.nut-theme-dark {
.nut-barrage {
.nut-barrage__item {
color: $dark-color-gray;
}
}
}

View File

@@ -0,0 +1 @@
export type * from './barrage'

View File

@@ -0,0 +1,139 @@
import type { ButtonLang, ButtonOnAddgroupappEvent, ButtonOnAgreeprivacyauthorizationEvent, ButtonOnChooseaddressEvent, ButtonOnChooseavatarEvent, ButtonOnChooseinvoicetitleEvent, ButtonOnErrorEvent, ButtonOnGetphonenumberEvent, ButtonOnLaunchappEvent, ButtonOnLoginEvent, ButtonOnOpensettingEvent, ButtonOnSubscribeEvent, ButtonOpenType } from '@uni-helper/uni-app-types'
import type { ExtractPropTypes, PropType } from 'vue'
import { CLICK_EVENT } from '../_constants'
import { commonProps, makeNumberProp, makeStringProp } from '../_utils'
import type { ButtonFormType, ButtonShape, ButtonSize, ButtonType } from './type'
export const buttonProps = {
...commonProps,
/**
* @description 指定按钮按下去的样式类
*/
hoverClass: makeStringProp('button-hover'),
/**
* @description 按住后多久出现点击态,单位毫秒
*/
hoverStartTime: makeNumberProp(20),
/**
* @description 手指松开后点击态保留时间,单位毫秒
*/
hoverStayTime: makeNumberProp(70),
/**
* @description 按钮颜色,支持传入 `linear-gradient` 渐变色
*/
customColor: String,
/**
* @description 形状,可选值为 `square` `round`
*/
shape: makeStringProp<ButtonShape>('round'),
/**
* @description 是否为朴素按钮
*/
plain: Boolean,
/**
* @description 按钮 `loading` 状态
*/
loading: Boolean,
/**
* @description 是否禁用按钮
*/
disabled: Boolean,
/**
* @description 按钮类型,可选值为 `primary` `info` `warning` `danger` `success` `default`
*/
type: makeStringProp<ButtonType>('default'),
/**
* @description 表单类型,可选值 `button` `submit` `reset`
*/
formType: makeStringProp<ButtonFormType>('button'),
/**
* @description 尺寸,可选值为 `large` `small` `mini` `normal`
*/
size: makeStringProp<ButtonSize>('normal'),
/**
* @description 是否为块级元素
*/
block: Boolean,
/**
* @description 小程序开放能力
*/
openType: String as PropType<ButtonOpenType>,
/**
* @description 指定返回用户信息的语言zh_CN 简体中文zh_TW 繁体中文en 英文
*/
lang: makeStringProp<ButtonLang>('en'),
/**
* @description 会话来源openType="contact"时有效
*/
sessionFrom: String,
/**
* @description 会话内消息卡片标题openType="contact"时有效
*/
sendMessageTitle: String,
/**
* @description 会话内消息卡片点击跳转小程序路径openType="contact"时有效
*/
sendMessagePath: String,
/**
* @description 会话内消息卡片图片openType="contact"时有效
*/
sendMessageImg: String,
/**
* @description 是否显示会话内消息卡片,设置此参数为 true用户进入客服会话会在右下角显示"可能要发送的小程序"提示用户点击后可以快速发送小程序消息openType="contact"时有效
*/
showMessageCard: Boolean,
/**
* @description 打开群资料卡时传递的群号openType="openGroupProfile"时有效
*/
groupId: String,
/**
* @description 打开频道页面时,传递的频道号 openType="openGuildProfile"时有效
*/
guildId: makeStringProp(''),
/**
* @description 打开公众号资料卡时,传递的号码 openType="openPublicProfile"时有效
*/
publicId: String,
/**
* @description 客服的抖音号,openType="im"时有效
*/
dataImId: String,
/**
* @description IM卡片类型,openType="im"时有效
*/
dataImType: String,
/**
* @description 商品的id仅支持泛知识课程库和生活服务商品库中的商品,openType="im"时有效
*/
dataGoodsId: String,
/**
* @description 订单的id仅支持交易2.0订单, openType="im"时有效
*/
dataOrderId: String,
/**
* @description 商品类型“1”代表生活服务“2”代表泛知识。openType="im"时有效
*/
dataBizLine: String,
} as const
export type ButtonProps = ExtractPropTypes<typeof buttonProps>
export const buttonEmits = {
[CLICK_EVENT]: (evt: MouseEvent) => evt instanceof Object,
getphonenumber: (evt: ButtonOnGetphonenumberEvent) => evt instanceof Object,
getuserinfo: (evt: any) => evt instanceof Object,
error: (evt: ButtonOnErrorEvent) => evt instanceof Object,
opensetting: (evt: ButtonOnOpensettingEvent) => evt instanceof Object,
launchapp: (evt: ButtonOnLaunchappEvent) => evt instanceof Object,
contact: (evt: any) => evt instanceof Object,
chooseavatar: (evt: ButtonOnChooseavatarEvent) => evt instanceof Object,
agreeprivacyauthorization: (evt: ButtonOnAgreeprivacyauthorizationEvent) => evt instanceof Object,
addgroupapp: (evt: ButtonOnAddgroupappEvent) => evt instanceof Object,
chooseaddress: (evt: ButtonOnChooseaddressEvent) => evt instanceof Object,
chooseinvoicetitle: (evt: ButtonOnChooseinvoicetitleEvent) => evt instanceof Object,
subscribe: (evt: ButtonOnSubscribeEvent) => evt instanceof Object,
login: (evt: ButtonOnLoginEvent) => evt instanceof Object,
im: (evt: any) => evt instanceof Object,
}
export type ButtonEmits = typeof buttonEmits

View File

@@ -0,0 +1,121 @@
<script lang="ts" setup>
import type { CSSProperties } from 'vue'
import { computed, defineComponent } from 'vue'
import { CLICK_EVENT, PREFIX } from '../_constants'
import { getMainClass, getMainStyle } from '../_utils'
import Icon from '../icon/icon.vue'
import { buttonEmits, buttonProps } from './button'
const props = defineProps(buttonProps)
const emit = defineEmits(buttonEmits)
const classes = computed(() => {
return getMainClass(props, componentName, {
[`${componentName}--${props.type}`]: !!props.type,
[`${componentName}--${props.size}`]: !!props.size,
[`${componentName}--${props.shape}`]: !!props.shape,
[`${componentName}--plain`]: props.plain,
[`${componentName}--block`]: props.block,
[`${componentName}--disabled`]: props.disabled,
[`${componentName}--loading`]: props.loading,
[`${componentName}--hovercls`]: props.hoverClass !== 'button-hover',
})
})
const styles = computed(() => {
const value: CSSProperties = {}
if (props.customColor) {
if (props.plain) {
value.color = props.customColor
value.background = '#fff'
if (!props.customColor.includes('gradient'))
value.borderColor = props.customColor
}
else {
value.color = '#fff'
value.background = props.customColor
}
}
return getMainStyle(props, value)
})
function handleClick(event: any) {
if (props.disabled || props.loading)
return
emit(CLICK_EVENT, event)
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-button`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
// #ifndef H5
styleIsolation: 'shared',
// #endif
},
})
</script>
<template>
<button
:class="classes"
:style="styles"
:form-type="props.formType === 'button' ? undefined : props.formType"
:open-type="props.disabled || props.loading ? undefined : props.openType"
:hover-class="props.hoverClass"
:hover-start-time="props.hoverStartTime"
:hover-stay-time="props.hoverStayTime"
hover-stop-propagation
:lang="props.lang"
:session-from="props.sessionFrom"
:send-message-title="props.sendMessageTitle"
:send-message-path="props.sendMessagePath"
:send-message-img="props.sendMessageImg"
:show-message-card="props.showMessageCard"
:group-id="props.groupId"
:guild-id="props.guildId"
:public-id="props.publicId"
:data-im-id="props.dataImId"
:data-im-type="props.dataImType"
:data-goods-id="props.dataGoodsId"
:data-order-id="props.dataOrderId"
:data-biz-line="props.dataBizLine"
@click="handleClick"
@getphonenumber="emit('getphonenumber', $event)"
@getuserinfo="emit('getuserinfo', $event)"
@error="emit('error', $event)"
@opensetting="emit('opensetting', $event)"
@addgroupapp="emit('addgroupapp', $event)"
@chooseaddress="emit('chooseaddress', $event)"
@chooseavatar="emit('chooseavatar', $event)"
@chooseinvoicetitle="emit('chooseinvoicetitle', $event)"
@launchapp="emit('launchapp', $event)"
@login="emit('login', $event)"
@subscribe="emit('subscribe', $event)"
@contact="emit('contact', $event)"
@agreeprivacyauthorization="emit('agreeprivacyauthorization', $event)"
@im="emit('im', $event)"
>
<view class="nut-button__wrap">
<Icon v-if="loading" name="loading" class="nut-icon-loading" />
<slot v-if="$slots.icon && !loading" name="icon" />
<view v-if="$slots.default" :class="{ 'nut-button__text': $slots.icon || loading }">
<slot />
</view>
</view>
</button>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,292 @@
.nut-theme-dark {
.nut-button {
&--default {
color: $dark-color3;
background: $dark-background2;
border: $button-border-width solid $dark-background2;
}
&--plain {
background: $dark-background2;
}
&:not(.nut-button--hovercls) {
.nut-button--plain:not([disabled]):active {
background: $dark-background2;
}
.nut-button--default:not([disabled]):active {
color: $dark-color3;
background: $dark-background2;
border: $button-border-width solid $dark-background2;
}
}
}
}
.nut-button {
position: relative;
box-sizing: border-box;
display: inline-block;
flex-shrink: 0;
width: auto;
height: $button-default-height;
padding: 0;
margin: 0;
font-size: $button-default-font-size;
line-height: $button-default-line-height;
text-align: center;
vertical-align: bottom;
appearance: none;
touch-action: manipulation;
cursor: pointer;
user-select: none;
transition: opacity 0.2s;
-webkit-tap-highlight-color: rgb(0 0 0 / 0%);
-webkit-tap-highlight-color: transparent;
.nut-button__text {
margin-left: 5px;
}
&::before {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
content: "";
background-color: $black;
border: inherit;
border-color: $black;
border-radius: inherit;
opacity: 0;
transform: translate(-50%, -50%);
}
&::after {
display: none;
}
&:not(.nut-button--hovercls) {
&:active::before {
opacity: 0.1;
}
}
&__wrap {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&--loading,
&--disabled {
&::before {
display: none;
}
}
&--default {
color: $button-default-color;
background: $button-default-bg-color;
background-origin: border-box;
border: $button-border-width solid $button-default-border-color;
}
&--primary {
color: $button-primary-color;
background: $button-primary-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
&--info {
color: $button-info-color;
background: $button-info-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
&--success {
color: $button-success-color;
background: $button-success-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
&--danger {
color: $button-danger-color;
background: $button-danger-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
&--warning {
color: $button-warning-color;
background: $button-warning-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
&--plain {
background: $button-plain-background-color;
background-origin: border-box;
&.nut-button--primary {
color: $button-primary-border-color;
border-color: $button-primary-border-color;
}
&.nut-button--info {
color: $button-info-border-color;
border-color: $button-info-border-color;
}
&.nut-button--success {
color: $button-success-border-color;
border-color: $button-success-border-color;
}
&.nut-button--danger {
color: $button-danger-border-color;
border-color: $button-danger-border-color;
}
&.nut-button--warning {
color: $button-warning-border-color;
border-color: $button-warning-border-color;
}
&:not(.nut-button--hovercls) {
&.nut-button--primary:not([disabled]):active {
color: $button-primary-border-color;
border-color: $button-primary-border-color;
}
&.nut-button--info:not([disabled]):active {
color: $button-info-border-color;
border-color: $button-info-border-color;
}
&.nut-button--success:not([disabled]):active {
color: $button-success-border-color;
border-color: $button-success-border-color;
}
&.nut-button--danger:not([disabled]):active {
color: $button-danger-border-color;
border-color: $button-danger-border-color;
}
&.nut-button--warning:not([disabled]):active {
color: $button-warning-border-color;
border-color: $button-warning-border-color;
}
}
}
&--large {
width: 100%;
height: $button-large-height;
font-size: $button-large-font-size;
line-height: $button-large-line-height;
}
&--normal {
padding: $button-default-padding;
font-size: $button-default-font-size;
}
&--small {
height: $button-small-height;
padding: $button-small-padding;
font-size: $button-small-font-size;
line-height: $button-small-line-height;
&.nut-button--round {
border-radius: $button-small-round-border-radius;
}
}
&--mini {
height: $button-mini-height;
padding: $button-mini-padding;
font-size: $button-mini-font-size;
line-height: $button-mini-line-height;
}
&--block {
display: block;
width: 100%;
}
&--disabled {
cursor: not-allowed;
opacity: $button-disabled-opacity;
}
&--loading {
cursor: default;
opacity: 0.9;
}
&--round {
border-radius: $button-border-radius;
}
&--square {
border-radius: 0;
}
&:not(.nut-button--hovercls) {
.nut-button--default:not([disabled]):active {
color: $button-default-color;
background: $button-default-bg-color;
background-origin: border-box;
border: $button-border-width solid $button-default-border-color;
}
.nut-button--primary:not([disabled]):active {
color: $button-primary-color;
background: $button-primary-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
.nut-button--info:not([disabled]):active {
color: $button-info-color;
background: $button-info-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
.nut-button--success:not([disabled]):active {
color: $button-success-color;
background: $button-success-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
.nut-button--danger:not([disabled]):active {
color: $button-danger-color;
background: $button-danger-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
.nut-button--warning:not([disabled]):active {
color: $button-warning-color;
background: $button-warning-background-color;
background-origin: border-box;
border: $button-border-width solid transparent;
}
.nut-button--plain:not([disabled]):active {
background: $button-plain-background-color;
background-origin: border-box;
}
}
}

View File

@@ -0,0 +1,2 @@
export * from './button'
export * from './type'

View File

@@ -0,0 +1,8 @@
export const buttonType = ['default', 'primary', 'info', 'success', 'warning', 'danger'] as const
export type ButtonType = (typeof buttonType)[number]
export const buttonSize = ['large', 'normal', 'small', 'mini'] as const
export type ButtonSize = (typeof buttonSize)[number]
export const buttonShape = ['square', 'round'] as const
export type ButtonShape = (typeof buttonShape)[number]
export const buttonFormType = ['button', 'submit', 'reset'] as const
export type ButtonFormType = (typeof buttonFormType)[number]

View File

@@ -0,0 +1,125 @@
import type { ExtractPropTypes, PropType, StyleValue } from 'vue'
import {
CHOOSE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
SELECT_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { commonProps, getDay, makeNumberProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup/popup'
export const calendarProps = {
...popupProps,
...commonProps,
/**
* @description 是否可见
*/
visible: Boolean,
/**
* @description 类型,日期单选 `one`,区间选择 `range`,日期多选 `multiple`,周选择 `week`
*/
type: makeStringProp<'one' | 'range' | 'multiple' | 'week'>('one'),
/**
* @description 是否弹窗状态展示
*/
poppable: truthProp,
/**
* @description 自动回填
*/
isAutoBackFill: Boolean,
/**
* @description 显示标题
*/
title: makeStringProp('日期选择'),
/**
* @description 默认值,单个日期选择为 `string`,其他为 `string[]`
*/
defaultValue: {
type: [String, Array] as PropType<string | string[]>,
},
/**
* @description 开始日期
*/
startDate: makeStringProp(getDay(0)),
/**
* @description 结束日期
*/
endDate: makeStringProp(getDay(365)),
/**
* @description 范围选择,开始信息文案
*/
startText: makeStringProp('开始'),
/**
* @description 范围选择,结束信息文案
*/
endText: makeStringProp('结束'),
/**
* @description 底部确认按钮文案
*/
confirmText: makeStringProp('确认'),
/**
* @description 是否展示今天标记
*/
showToday: truthProp,
/**
* @description 是否在展示日历标题
*/
showTitle: truthProp,
/**
* @description 是否展示日期标题
*/
showSubTitle: truthProp,
/**
* @description 是否启动滚动动画
*/
toDateAnimation: truthProp,
/**
* @description 设置周起始日
*/
firstDayOfWeek: makeNumberProp(0),
/**
* @description 一个用来判断该日期是否被禁用的函数,接受一个 `年 - 月 - 日` 作为参数。 应该返回一个 Boolean 值。
* @default undefined
*/
disabledDate: Function,
/**
* @description 是否使用 footer 插槽,如果使用,此值必须为 true
*/
footerSlot: Boolean,
/**
* @description 是否使用 btn 插槽,如果使用,此值必须为 true
*/
btnSlot: Boolean,
/**
* @description 自定义弹窗样式
*/
popStyle: {
type: [String, Object, Array] as PropType<StyleValue>,
default: '',
},
/**
* @description 遮罩显示时的背景是否锁定
*/
lockScroll: truthProp,
}
export type CalendarProps = ExtractPropTypes<typeof calendarProps>
/* eslint-disable unused-imports/no-unused-vars */
export const calendarEmits = {
[UPDATE_VISIBLE_EVENT]: (value: boolean) => true,
[CHOOSE_EVENT]: (value: string | object) => true,
[SELECT_EVENT]: (value: any) => true,
clickCloseIcon: () => true,
clickOverlay: () => true,
[OPEN_EVENT]: () => true,
[OPENED_EVENT]: () => true,
[CLOSE_EVENT]: () => true,
[CLOSED_EVENT]: () => true,
}
/* eslint-enable unused-imports/no-unused-vars */
export type CalendarEmits = typeof calendarEmits

View File

@@ -0,0 +1,262 @@
<script lang="ts" setup>
import { computed, defineComponent, ref, useSlots } from 'vue'
import {
CHOOSE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
PREFIX,
SELECT_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { getMainClass } from '../_utils'
import NutCalendarItem from '../calendaritem/calendaritem.vue'
import type { CalendarInst } from '../calendaritem/types'
import NutPopup from '../popup/popup.vue'
import { calendarEmits, calendarProps } from './calendar'
const props = defineProps(calendarProps)
const emit = defineEmits(calendarEmits)
const slots = useSlots()
const innerVisible = computed({
get() {
return props.visible
},
set(value) {
emit('update:visible', value)
},
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
const popClasses = computed(() => {
return `${componentName}__popup ${props.popClass}`
})
const popStyles = computed(() => {
return [{
height: '85vh',
}, props.popStyle]
})
const overlayClasses = computed(() => {
return `${componentName}__overlay ${props.overlayClass}`
})
const calendarRef = ref<CalendarInst | null>(null)
function scrollToDate(date: string) {
calendarRef.value?.scrollToDate(date)
}
function initPosition() {
calendarRef.value?.initPosition()
}
function close() {
emit(UPDATE_VISIBLE_EVENT, false)
emit(CLOSE_EVENT)
}
function choose(param: string | object) {
close()
emit(CHOOSE_EVENT, param)
}
function select(param: string) {
emit(SELECT_EVENT, param)
}
function update() {
emit(UPDATE_VISIBLE_EVENT, false)
}
function handleCloseIconClick() {
emit('clickCloseIcon')
}
function handleOverlayClick() {
emit('clickOverlay')
}
function handleOpen() {
emit(OPEN_EVENT)
}
function handleOpened() {
emit(OPENED_EVENT)
if (props.defaultValue) {
if (Array.isArray(props.defaultValue)) {
if (props.defaultValue.length > 0) {
scrollToDate(props.defaultValue[0])
}
}
else {
scrollToDate(props.defaultValue)
}
}
}
function handleClose() {
emit(CLOSE_EVENT)
}
function handleClosed() {
emit(CLOSED_EVENT)
}
defineExpose({
scrollToDate,
initPosition,
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-calendar`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="props.customStyle">
<template v-if="props.poppable">
<NutPopup
v-model:visible="innerVisible"
:custom-class="popClasses"
:custom-style="popStyles"
:overlay-class="overlayClasses"
:overlay-style="props.overlayStyle"
position="bottom"
round
:closeable="props.closeable"
:close-icon="props.closeIcon"
:close-icon-position="props.closeIconPosition"
:z-index="props.zIndex"
:lock-scroll="props.lockScroll"
:overlay="props.overlay"
:close-on-click-overlay="props.closeOnClickOverlay"
:destroy-on-close="false"
@click-close-icon="handleCloseIconClick"
@click-overlay="handleOverlayClick"
@open="handleOpen"
@opened="handleOpened"
@close="handleClose"
@closed="handleClosed"
>
<NutCalendarItem
ref="calendarRef"
:visible="innerVisible"
:type="props.type"
:poppable="props.poppable"
:is-auto-back-fill="props.isAutoBackFill"
:title="props.title"
:default-value="props.defaultValue"
:start-date="props.startDate"
:end-date="props.endDate"
:start-text="props.startText"
:end-text="props.endText"
:confirm-text="props.confirmText"
:show-today="props.showToday"
:show-title="props.showTitle"
:show-sub-title="props.showSubTitle"
:to-date-animation="props.toDateAnimation"
:first-day-of-week="props.firstDayOfWeek"
:disabled-date="props.disabledDate"
:footer-slot="props.footerSlot"
:btn-slot="props.btnSlot"
@choose="choose"
@select="select"
@update="update"
@close="close"
>
<template v-if="slots.btn" #btn>
<slot name="btn" />
</template>
<template v-if="slots.day" #day="{ date }">
<slot name="day" :date="date" />
</template>
<template v-if="slots.topInfo" #topInfo="{ date }">
<slot name="topInfo" :date="date" />
</template>
<template v-if="slots.bottomInfo" #bottomInfo="{ date }">
<slot name="bottomInfo" :date="date" />
</template>
<template v-if="slots.footer" #footer="{ date }">
<slot name="footer" :date="date" />
</template>
</NutCalendarItem>
</NutPopup>
</template>
<template v-else>
<NutCalendarItem
ref="calendarRef"
:visible="innerVisible"
:type="props.type"
:poppable="props.poppable"
:is-auto-back-fill="props.isAutoBackFill"
:title="props.title"
:default-value="props.defaultValue"
:start-date="props.startDate"
:end-date="props.endDate"
:start-text="props.startText"
:end-text="props.endText"
:confirm-text="props.confirmText"
:show-today="props.showToday"
:show-title="props.showTitle"
:show-sub-title="props.showSubTitle"
:to-date-animation="props.toDateAnimation"
:first-day-of-week="props.firstDayOfWeek"
:disabled-date="props.disabledDate"
:footer-slot="props.footerSlot"
:btn-slot="props.btnSlot"
@choose="choose"
@select="select"
@close="close"
>
<template v-if="slots.btn" #btn>
<slot name="btn" />
</template>
<template v-if="slots.day" #day="{ date }">
<slot name="day" :date="date" />
</template>
<template v-if="slots.topInfo" #topInfo="{ date }">
<slot name="topInfo" :date="date" />
</template>
<template v-if="slots.bottomInfo" #bottomInfo="{ date }">
<slot name="bottomInfo" :date="date" />
</template>
<template v-if="slots.footer" #footer="{ date }">
<slot name="footer" :date="date" />
</template>
</NutCalendarItem>
</template>
</view>
</template>
<style lang="scss">
@import "./index";
</style>

View File

@@ -0,0 +1 @@
@import "../popup/index";

View File

@@ -0,0 +1 @@
export * from './calendar'

View File

@@ -0,0 +1,99 @@
import type { ExtractPropTypes } from 'vue'
import { CHOOSE_EVENT, SELECT_EVENT } from '../_constants'
import { commonProps, getDay, makeNumberProp, makeStringProp, truthProp } from '../_utils'
export const calendaritemProps = {
...commonProps,
/**
* @description 是否可见
*/
visible: Boolean,
/**
* @description 类型,日期单选 `one`,区间选择 `range`,日期多选 `multiple`,周选择 `week`
*/
type: makeStringProp<'one' | 'range' | 'multiple' | 'week'>('one'),
/**
* @description 是否弹窗状态展示
*/
poppable: truthProp,
/**
* @description 自动回填
*/
isAutoBackFill: Boolean,
/**
* @description 显示标题
*/
title: makeStringProp('日期选择'),
/**
* @description 默认值,单个日期选择为 `string`,其他为 `string[]`
*/
defaultValue: {
type: [String, Array],
},
/**
* @description 开始日期
*/
startDate: makeStringProp(getDay(0)),
/**
* @description 结束日期
*/
endDate: makeStringProp(getDay(365)),
/**
* @description 范围选择,开始信息文案
*/
startText: makeStringProp('开始'),
/**
* @description 范围选择,结束信息文案
*/
endText: makeStringProp('结束'),
/**
* @description 底部确认按钮文案
*/
confirmText: makeStringProp('确认'),
/**
* @description 是否展示今天标记
*/
showToday: truthProp,
/**
* @description 是否在展示日历标题
*/
showTitle: truthProp,
/**
* @description 是否展示日期标题
*/
showSubTitle: truthProp,
/**
* @description 是否启动滚动动画
*/
toDateAnimation: truthProp,
/**
* @description 设置周起始日
*/
firstDayOfWeek: makeNumberProp(0),
/**
* @description 一个用来判断该日期是否被禁用的函数,接受一个 `年 - 月 - 日` 作为参数。 应该返回一个 Boolean 值。
* @default undefined
*/
disabledDate: Function,
/**
* @description 是否使用 footer 插槽,如果使用,此值必须为 true
*/
footerSlot: Boolean,
/**
* @description 是否使用 btn 插槽,如果使用,此值必须为 true
*/
btnSlot: Boolean,
}
export type CalendarItemProps = ExtractPropTypes<typeof calendaritemProps>
/* eslint-disable unused-imports/no-unused-vars */
export const calendaritemEmits = {
[CHOOSE_EVENT]: (value: string | object) => true,
[SELECT_EVENT]: (value: any) => true,
update: () => true,
close: () => true,
}
/* eslint-enable unused-imports/no-unused-vars */
export type CalendarItemEmits = typeof calendaritemEmits

View File

@@ -0,0 +1,860 @@
<script lang="ts" setup>
import type { ScrollViewOnScrollEvent } from '@uni-helper/uni-app-types'
import { computed, defineComponent, onMounted, reactive, ref, useSlots, watch } from 'vue'
import { CHOOSE_EVENT, PREFIX, SELECT_EVENT } from '../_constants'
import {
compareDate,
date2Str,
formatResultDate,
getDay,
getMainClass,
getMonthDays,
getMonthPreDay,
getMonthWeek,
getNumTwoBit,
getWeekDate,
getWhatDay,
getYearWeek,
isEqual,
isH5,
} from '../_utils'
import requestAniFrame from '../_utils/raf'
import { useTranslate } from '../../locale'
import { calendaritemEmits, calendaritemProps } from './calendaritem'
import type { CalendarDateProp, CalendarTaroState, Day, MonthInfo, StringArr } from './types'
const props = defineProps(calendaritemProps)
const emit = defineEmits(calendaritemEmits)
const slots = useSlots()
const componentName = `${PREFIX}-calendar-item`
const { translate } = useTranslate(componentName)
const state: CalendarTaroState = reactive({
yearMonthTitle: '',
defaultRange: [],
containerHeight: '100%',
currDate: '',
propStartDate: '',
propEndDate: '',
unLoadPrev: false,
touchParams: {
startY: 0,
endY: 0,
startTime: 0,
endTime: 0,
lastY: 0,
lastTime: 0,
},
transformY: 0,
translateY: 0,
scrollDistance: 0,
defaultData: [],
chooseData: [],
monthsData: [],
dayPrefix: 'nut-calendar__day',
startData: '',
endData: '',
isRange: props.type === 'range',
timer: 0,
currentIndex: 0,
avgHeight: 0,
scrollTop: 0,
monthsNum: 0,
})
const classes = computed(() => {
return getMainClass(props, componentName, {
'nut-calendar--nopop': !props.poppable,
'nut-calendar--nofooter': props.isAutoBackFill,
})
})
// 新增:自定义周起始日
const weekdays = (translate('weekdays') as any).map((day: string, index: number) => ({
day,
weekend: index === 0 || index === 6,
}))
const weeks = ref([...weekdays.slice(props.firstDayOfWeek, 7), ...weekdays.slice(0, props.firstDayOfWeek)])
const months = ref<HTMLElement | null>(null)
const scalePx = ref(2)
const viewHeight = ref(0)
const compConthsData = computed(() => {
return state.monthsData.slice(state.defaultRange[0], state.defaultRange[1])
})
const scrollWithAnimation = ref(false)
// 日期转化成数组
function splitDate(date: string) {
return date.split('-')
}
// 判断是否为开始时间
function isStart(currDate: string) {
return isEqual(state.currDate[0], currDate)
}
// 判断是否为结束时间
function isEnd(currDate: string) {
return isEqual(state.currDate[1], currDate)
}
function isMultiple(currDate: string) {
if (state.currDate?.length > 0) {
return (state.currDate as StringArr)?.some((item: string) => {
return isEqual(item, currDate)
})
}
else {
return false
}
}
// 获取当前数据
function getCurrDate(day: Day, month: MonthInfo) {
return `${month.curData[0]}-${month.curData[1]}-${getNumTwoBit(+day.day)}`
}
// 获取样式
function getClass(day: Day, month: MonthInfo, index?: number) {
const res = []
if (
typeof index === 'number'
&& ((index + 1 + props.firstDayOfWeek) % 7 === 0 || (index + props.firstDayOfWeek) % 7 === 0)
) {
res.push('weekend')
}
const currDate = getCurrDate(day, month)
const { type } = props
if (day.type === 'curr') {
if (
isEqual(state.currDate as string, currDate)
|| ((type === 'range' || type === 'week') && (isStart(currDate) || isEnd(currDate)))
|| (type === 'multiple' && isMultiple(currDate))
) {
res.push(`${state.dayPrefix}--active`)
}
else if (
(state.propStartDate && compareDate(currDate, state.propStartDate))
|| (state.propEndDate && compareDate(state.propEndDate, currDate))
|| (props.disabledDate && props.disabledDate(currDate))
) {
res.push(`${state.dayPrefix}--disabled`)
}
else if (
(type === 'range' || type === 'week')
&& Array.isArray(state.currDate)
&& Object.values(state.currDate).length === 2
&& compareDate(state.currDate[0], currDate)
&& compareDate(currDate, state.currDate[1])
) {
res.push(`${state.dayPrefix}--choose`)
}
}
else {
res.push(`${state.dayPrefix}--disabled`)
}
return res
}
// 确认选择时触发
function confirm() {
const { type } = props
if ((type === 'range' && state.chooseData.length === 2) || type !== 'range') {
let selectData: any = state.chooseData.slice(0)
if (type === 'week') {
selectData = {
weekDate: [handleWeekDate(state.chooseData[0] as string[]), handleWeekDate(state.chooseData[1] as string[])],
}
}
emit(CHOOSE_EVENT, selectData)
if (props.poppable)
emit('update')
}
}
// 选中数据
function chooseDay(day: Day, month: MonthInfo, isFirst = false) {
if (!getClass(day, month).includes(`${state.dayPrefix}--disabled`)) {
const { type } = props
const [y, m] = month.curData
const days = [...month.curData]
days[2] = getNumTwoBit(Number(day.day))
days[3] = `${days[0]}-${days[1]}-${days[2]}`
days[4] = getWhatDay(+days[0], +days[1], +days[2])
if (type === 'multiple') {
if (state.currDate?.length > 0) {
let hasIndex: number | undefined;
(state.currDate as StringArr)?.forEach((item: string, index: number) => {
if (item === days[3])
hasIndex = index
})
if (isFirst) {
state.chooseData.push([...days])
}
else {
if (hasIndex !== undefined) {
(state.currDate as StringArr).splice(hasIndex, 1)
state.chooseData.splice(hasIndex, 1)
}
else {
(state.currDate as StringArr).push(days[3])
state.chooseData.push([...days])
}
}
}
else {
state.currDate = [days[3]]
state.chooseData = [[...days]]
}
}
else if (type === 'range') {
const curDataLength = Object.values(state.currDate).length
if (curDataLength === 2 || curDataLength === 0) {
state.currDate = [days[3]]
}
else {
if (compareDate(state.currDate[0], days[3]))
Array.isArray(state.currDate) && state.currDate.push(days[3])
else
Array.isArray(state.currDate) && state.currDate.unshift(days[3])
}
if (state.chooseData.length === 2 || !state.chooseData.length) {
state.chooseData = [[...days]]
}
else {
if (compareDate(state.chooseData[0][3], days[3]))
state.chooseData = [...state.chooseData, [...days]]
else
state.chooseData = [[...days], ...state.chooseData]
}
}
else if (type === 'week') {
const weekArr = getWeekDate(y, m, day.day, props.firstDayOfWeek)
if (state.propStartDate && compareDate(weekArr[0], state.propStartDate))
weekArr.splice(0, 1, state.propStartDate)
if (state.propEndDate && compareDate(state.propEndDate, weekArr[1]))
weekArr.splice(1, 1, state.propEndDate)
state.currDate = weekArr
state.chooseData = [formatResultDate(weekArr[0]), formatResultDate(weekArr[1])]
}
else {
state.currDate = days[3]
state.chooseData = [...days]
}
if (!isFirst) {
let selectData: any = state.chooseData
if (type === 'week') {
selectData = {
weekDate: [
handleWeekDate(state.chooseData[0] as string[]),
handleWeekDate(state.chooseData[1] as string[]),
],
}
}
// 点击日期 触发
emit(SELECT_EVENT, selectData)
if (props.isAutoBackFill || !props.poppable)
confirm()
}
}
}
function handleWeekDate(weekDate: string[]) {
const [y, m, d] = weekDate
return {
date: weekDate,
monthWeekNum: getMonthWeek(y, m, d, props.firstDayOfWeek),
yearWeekNum: getYearWeek(y, m, d),
}
}
// 获取当前月数据
function getCurrData(type: string) {
const monthData = type === 'prev' ? state.monthsData[0] : state.monthsData[state.monthsData.length - 1]
let year = Number.parseInt(monthData.curData[0])
let month = Number.parseInt(monthData.curData[1].toString().replace(/^0/, ''))
switch (type) {
case 'prev':
month === 1 && (year -= 1)
month = month === 1 ? 12 : --month
break
case 'next':
month === 12 && (year += 1)
month = month === 12 ? 1 : ++month
break
}
return [`${year}`, getNumTwoBit(month), `${getMonthDays(String(year), String(month))}`]
}
// 获取日期状态
function getDaysStatus(days: number, type: string, dateInfo: CalendarDateProp) {
// 修复当某个月的1号是周日时月份下方会空出来一行
const { year, month } = dateInfo
if (type === 'prev' && days >= 7)
days -= 7
return Array.from(Array.from({ length: days }), (v, k) => {
return {
day: String(k + 1),
type,
year,
month,
}
})
}
// 获取上一个月的最后一周天数,填充当月空白
function getPreDaysStatus(days: number, type: string, dateInfo: CalendarDateProp, preCurrMonthDays: number) {
// 新增:自定义周起始日
days = days - props.firstDayOfWeek
// 修复当某个月的1号是周日时月份下方会空出来一行
const { year, month } = dateInfo
if (type === 'prev' && days >= 7)
days -= 7
const months = Array.from(Array.from({ length: preCurrMonthDays }), (v, k) => {
return {
day: String(k + 1),
type,
year,
month,
}
})
return months.slice(preCurrMonthDays - days)
}
// 获取月数据
function getMonth(curData: string[], type: string) {
// 一号为周几
const preMonthDays = getMonthPreDay(+curData[0], +curData[1])
let preMonth = Number(curData[1]) - 1
let preYear = Number(curData[0])
if (preMonth <= 0) {
preMonth = 12
preYear += 1
}
// 当月天数与上个月天数
const currMonthDays = getMonthDays(String(curData[0]), String(curData[1]))
const preCurrMonthDays = getMonthDays(`${preYear}`, `${preMonth}`)
const title = {
year: curData[0],
month: curData[1],
}
const monthInfo: MonthInfo = {
curData,
title: translate('monthTitle', title.year, title.month),
monthData: [
...(getPreDaysStatus(
preMonthDays,
'prev',
{ month: String(preMonth), year: String(preYear) },
preCurrMonthDays,
) as Day[]),
...(getDaysStatus(currMonthDays, 'curr', title) as Day[]),
],
cssHeight: 0,
cssScrollHeight: 0,
}
let titleHeight, itemHeight
if (isH5) {
titleHeight = 46 * scalePx.value + 16 * scalePx.value * 2
itemHeight = 128 * scalePx.value
}
else {
titleHeight = Math.floor(46 * scalePx.value) + Math.floor(16 * scalePx.value) * 2
itemHeight = Math.floor(128 * scalePx.value)
}
monthInfo.cssHeight = titleHeight + (monthInfo.monthData.length > 35 ? itemHeight * 6 : itemHeight * 5)
let cssScrollHeight = 0
if (state.monthsData.length > 0) {
cssScrollHeight
= (state.monthsData[state.monthsData.length - 1] as MonthInfo).cssScrollHeight
+ (state.monthsData[state.monthsData.length - 1] as MonthInfo).cssHeight
}
monthInfo.cssScrollHeight = cssScrollHeight
if (type === 'next') {
// 判断当前日期 是否大于 最后一天
if (
!state.endData
|| !compareDate(
`${state.endData[0]}-${state.endData[1]}-${getMonthDays(state.endData[0], state.endData[1])}`,
`${curData[0]}-${curData[1]}-${curData[2]}`,
)
) {
state.monthsData.push(monthInfo)
}
}
else {
// 判断当前日期 是否小于 第一天
if (
!state.startData
|| !compareDate(
`${curData[0]}-${curData[1]}-${curData[2]}`,
`${state.startData[0]}-${state.startData[1]}-01`,
)
) {
state.monthsData.unshift(monthInfo)
}
else {
state.unLoadPrev = true
}
}
}
// 初始化数据
function initData() {
// 初始化开始结束数据
const propStartDate = props.startDate ? props.startDate : getDay(0)
const propEndDate = props.endDate ? props.endDate : getDay(365)
state.propStartDate = propStartDate
state.propEndDate = propEndDate
state.startData = splitDate(propStartDate)
state.endData = splitDate(propEndDate)
// 根据是否存在默认时间,初始化当前日期,
if (props.defaultValue || (Array.isArray(props.defaultValue) && (props.defaultValue as any[]).length > 0)) {
state.currDate
= props.type !== 'one' ? ([...props.defaultValue] as StringArr) : (props.defaultValue as string | StringArr)
}
// 判断时间范围内存在多少个月
const startDate = {
year: Number(state.startData[0]),
month: Number(state.startData[1]),
}
const endDate = {
year: Number(state.endData[0]),
month: Number(state.endData[1]),
}
let monthsNum = endDate.month - startDate.month
if (endDate.year - startDate.year > 0)
monthsNum = monthsNum + 12 * (endDate.year - startDate.year)
if (monthsNum <= 0)
monthsNum = 1
// 设置月份数据
getMonth(state.startData, 'next')
let i = 1
do
getMonth(getCurrData('next'), 'next')
while (i++ < monthsNum)
state.monthsNum = monthsNum
// 日期转化为数组,限制初始日期。判断时间范围
if (props.type === 'range' && Array.isArray(state.currDate)) {
if (state.currDate.length > 0) {
if (propStartDate && compareDate(state.currDate[0], propStartDate))
state.currDate.splice(0, 1, propStartDate)
if (propEndDate && compareDate(propEndDate, state.currDate[1]))
state.currDate.splice(1, 1, propEndDate)
state.defaultData = [...splitDate(state.currDate[0]), ...splitDate(state.currDate[1])]
}
}
else if (props.type === 'multiple' && Array.isArray(state.currDate)) {
if (state.currDate.length > 0) {
const defaultArr: string[] = []
const obj: any = {}
state.currDate.forEach((item: string) => {
if (
propStartDate
&& !compareDate(item, propStartDate)
&& propEndDate
&& !compareDate(propEndDate, item)
) {
if (!Object.hasOwnProperty.call(obj, item)) {
defaultArr.push(item)
obj[item] = item
}
}
})
state.currDate = [...defaultArr]
state.defaultData = [...splitDate(defaultArr[0])]
}
}
else if (props.type === 'week' && Array.isArray(state.currDate)) {
if (state.currDate.length > 0) {
const [y, m, d] = splitDate(state.currDate[0])
state.currDate = getWeekDate(y, m, d, props.firstDayOfWeek)
if (propStartDate && compareDate(state.currDate[0], propStartDate))
state.currDate.splice(0, 1, propStartDate)
if (propEndDate && compareDate(propEndDate, state.currDate[1]))
state.currDate.splice(1, 1, propEndDate)
state.defaultData = [...splitDate(state.currDate[0]), ...splitDate(state.currDate[1])]
}
}
else {
if (state.currDate) {
if (propStartDate && compareDate(state.currDate as string, propStartDate))
state.currDate = propStartDate
else if (propEndDate && !compareDate(state.currDate as string, propEndDate))
state.currDate = propEndDate
state.defaultData = [...splitDate(state.currDate as string)]
}
}
// 设置默认可见区域
let current = 0
let lastCurrent = 0
if (state.defaultData.length > 0) {
state.monthsData.forEach((item, index) => {
if (item.title === translate('monthTitle', state.defaultData[0], state.defaultData[1]))
current = index
if (props.type === 'range' || props.type === 'week') {
if (item.title === translate('monthTitle', state.defaultData[3], state.defaultData[4]))
lastCurrent = index
}
})
}
setDefaultRange(monthsNum, current)
state.currentIndex = current
state.yearMonthTitle = state.monthsData[state.currentIndex].title
if (state.defaultData.length > 0) {
// 设置当前选中日期
if (state.isRange) {
chooseDay({ day: state.defaultData[2], type: 'curr' }, state.monthsData[state.currentIndex], true)
chooseDay({ day: state.defaultData[5], type: 'curr' }, state.monthsData[lastCurrent], true)
}
else if (props.type === 'week') {
chooseDay({ day: state.defaultData[2], type: 'curr' }, state.monthsData[state.currentIndex], true)
}
else if (props.type === 'multiple') {
[...state.currDate].forEach((item: string) => {
const dateArr = splitDate(item)
let current = state.currentIndex
state.monthsData.forEach((item, index) => {
if (item.title === translate('monthTitle', dateArr[0], dateArr[1]))
current = index
})
chooseDay({ day: dateArr[2], type: 'curr' }, state.monthsData[current], true)
})
}
else {
chooseDay({ day: state.defaultData[2], type: 'curr' }, state.monthsData[state.currentIndex], true)
}
}
const lastItem = state.monthsData[state.monthsData.length - 1]
const containerHeight = lastItem.cssHeight + lastItem.cssScrollHeight
state.containerHeight = `${containerHeight}px`
state.scrollTop = Math.ceil(state.monthsData[state.currentIndex].cssScrollHeight)
state.avgHeight = Math.floor(containerHeight / (monthsNum + 1))
if (months?.value)
viewHeight.value = months.value.clientHeight
}
function scrollToDate(date: string) {
if (compareDate(date, state.propStartDate))
date = state.propStartDate
else if (!compareDate(date, state.propEndDate))
date = state.propEndDate
const dateArr = splitDate(date)
state.monthsData.forEach((item, index) => {
if (item.title === translate('monthTitle', dateArr[0], dateArr[1])) {
// scrollTop 不会实时变更。当再次赋值时scrollTop无变化时不会触发滚动
state.scrollTop += 1
scrollWithAnimation.value = props.toDateAnimation
requestAniFrame(() => {
setTimeout(() => {
state.scrollTop = state.monthsData[index].cssScrollHeight
setTimeout(() => {
scrollWithAnimation.value = false
}, 200)
}, 10)
})
}
})
}
function initPosition() {
state.scrollTop = Math.ceil(state.monthsData[state.currentIndex].cssScrollHeight)
}
// 设置当前可见月份
function setDefaultRange(monthsNum: number, current: number) {
if (monthsNum >= 3) {
if (current > 0 && current < monthsNum)
state.defaultRange = [current - 1, current + 3]
else if (current === 0)
state.defaultRange = [current, current + 4]
else if (current === monthsNum)
state.defaultRange = [current - 2, current + 2]
}
else {
state.defaultRange = [0, monthsNum + 2]
}
state.translateY = state.monthsData[state.defaultRange[0]].cssScrollHeight
}
// 区间选择&&当前月&&选中态
function isActive(day: Day, month: MonthInfo) {
return (
(props.type === 'range' || props.type === 'week')
&& day.type === 'curr'
&& getClass(day, month).includes('nut-calendar__day--active')
)
}
// 是否有开始提示
function isStartTip(day: Day, month: MonthInfo) {
return isActive(day, month) && isStart(getCurrDate(day, month))
}
// 是否有结束提示
function isEndTip(day: Day, month: MonthInfo) {
if (state.currDate.length >= 2 && isEnd(getCurrDate(day, month)))
return isActive(day, month)
return false
}
// 开始结束时间是否相等
function rangeTip() {
if (state.currDate.length >= 2)
return isEqual(state.currDate[0], state.currDate[1])
}
// 是否有 当前日期
function isCurrDay(dateInfo: Day) {
const date = `${dateInfo.year}-${dateInfo.month}-${Number(dateInfo.day) < 10 ? `0${dateInfo.day}` : dateInfo.day}`
return isEqual(date, date2Str(new Date()))
}
// 滚动处理事件
function mothsViewScroll(e: ScrollViewOnScrollEvent) {
if (state.monthsData.length <= 1)
return
const currentScrollTop = e.detail.scrollTop
let current = Math.floor(currentScrollTop / state.avgHeight)
if (current === 0) {
if (currentScrollTop >= state.monthsData[current + 1].cssScrollHeight)
current += 1
}
else if (current > 0 && current < state.monthsNum - 1) {
if (currentScrollTop >= state.monthsData[current + 1].cssScrollHeight)
current += 1
if (currentScrollTop < state.monthsData[current].cssScrollHeight)
current -= 1
}
if (state.currentIndex !== current) {
state.currentIndex = current
setDefaultRange(state.monthsNum, current)
}
state.yearMonthTitle = state.monthsData[current].title
}
// 重新渲染
function resetRender() {
state.chooseData.splice(0)
state.monthsData.splice(0)
initData()
}
// 监听 默认值更改
watch(() => props.defaultValue, (value) => {
if (value) {
if (props.poppable) {
resetRender()
}
}
})
onMounted(() => {
// 初始化数据
uni.getSystemInfo({
success(res) {
let scale = 2
let toFixed = 3
if (isH5) {
toFixed = 5
const fontSize = document.documentElement.style.fontSize
scale = Number((Number.parseInt(fontSize) / 40).toFixed(toFixed))
}
else {
const screenWidth = res.screenWidth
scale = Number((screenWidth / 750).toFixed(toFixed))
}
scalePx.value = scale
initData()
},
})
})
defineExpose({
scrollToDate,
initPosition,
})
</script>
<script lang="ts">
export default defineComponent({
name: `${PREFIX}-calendar-item`,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="props.customStyle">
<view class="nut-calendar__header">
<view v-if="props.showTitle" class="nut-calendar__header-title">
{{ props.title || translate('title') }}
</view>
<view v-if="props.btnSlot" class="nut-calendar__header-slot">
<slot name="btn" />
</view>
<view v-if="props.showSubTitle" class="nut-calendar__header-subtitle">
{{ state.yearMonthTitle }}
</view>
<view class="nut-calendar__weekdays">
<view
v-for="(item, index) of weeks"
:key="index"
class="nut-calendar__weekday"
:class="{ weekend: item.weekend }"
>
{{ item.day }}
</view>
</view>
</view>
<scroll-view
ref="months"
class="nut-calendar__content"
:scroll-y="true"
:scroll-top="state.scrollTop"
:scroll-with-animation="scrollWithAnimation"
@scroll="mothsViewScroll"
>
<view class="nut-calendar__panel" :style="{ height: state.containerHeight }">
<view class="nut-calendar__body" :style="{ transform: `translateY(${state.translateY}px)` }">
<view v-for="(month, index) of compConthsData" :key="index" class="nut-calendar__month">
<view class="nut-calendar__month-title">
{{ month.title }}
</view>
<view class="nut-calendar__days">
<view
class="nut-calendar__days-item"
:class="{ 'nut-calendar__days-item--range': props.type === 'range' }"
>
<template v-for="(day, i) of month.monthData" :key="i">
<view
class="nut-calendar__day"
:class="getClass(day, month, i)"
@click="chooseDay(day, month)"
>
<!-- 日期显示slot -->
<view class="nut-calendar__day-value">
<!-- #ifdef MP -->
{{ day.type === 'curr' ? day.day : '' }}
<!-- #endif -->
<!-- #ifndef MP -->
<slot name="day" :date="day.type === 'curr' ? day : ''">
{{ day.type === 'curr' ? day.day : '' }}
</slot>
<!-- #endif -->
</view>
<!-- #ifdef H5 -->
<view v-if="slots.topInfo" class="nut-calendar__day-tips nut-calendar__day-tips--top">
<slot name="topInfo" :date="day.type === 'curr' ? day : ''" />
</view>
<view v-if="slots.bottomInfo" class="nut-calendar__day-tips nut-calendar__day-tips--bottom">
<slot name="bottomInfo" :date="day.type === 'curr' ? day : ''" />
</view>
<!-- #endif -->
<!-- #ifndef MP -->
<view
v-if="!slots.bottomInfo && props.showToday && isCurrDay(day)"
class="nut-calendar__day-tips--curr"
>
{{ translate('today') }}
</view>
<!-- #endif -->
<!-- #ifdef MP -->
<view v-if="props.showToday && isCurrDay(day)" class="nut-calendar__day-tips--curr">
{{ translate('today') }}
</view>
<!-- #endif -->
<view
v-if="isStartTip(day, month)"
class="nut-calendar__day-tip"
:class="{ 'nut-calendar__day-tips--top': rangeTip() }"
>
{{ props.startText || translate('start') }}
</view>
<view v-if="isEndTip(day, month)" class="nut-calendar__day-tip">
{{ props.endText || translate('end') }}
</view>
</view>
</template>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<view v-if="props.poppable && !props.isAutoBackFill" class="nut-calendar__footer">
<slot v-if="props.footerSlot" name="footer" :date="state.chooseData" />
<view v-else class="nut-calendar__confirm" @click="confirm">
{{ props.confirmText || translate('confirm') }}
</view>
</view>
</view>
</template>
<style lang="scss">
@import "./index";
</style>

View File

@@ -0,0 +1,270 @@
.nut-theme-dark {
.nut-calendar {
$block: &;
&-item {
color: $dark-color;
background: $dark-background;
}
&__header {
color: $dark-color;
background: $dark-background;
}
&__content {
#{$block}__panel {
#{$block}__days {
#{$block}__day {
&--disabled {
color: $dark-calendar-disabled !important;
}
}
.calendar-month-day {
&-choose {
color: $calendar-choose-font-color;
background-color: $dark-calendar-choose-color;
}
}
}
}
}
&__footer {
color: $dark-color;
background: $dark-background2;
}
}
}
.nut-calendar {
$block: &;
&-item {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
overflow: hidden;
font-size: 16px;
color: #333;
background-color: $white;
}
&#{$block}--nopop {
#{$block}__header {
#{$block}__header-title {
font-size: $calendar-base-font;
}
}
}
.popup-box {
height: 100%;
}
::-webkit-scrollbar {
display: none;
}
&__header {
display: flex;
flex-direction: column;
padding-top: 1px;
text-align: center;
background-color: $white;
&-title {
font-size: $calendar-title-font;
font-weight: $calendar-title-font-weight;
line-height: 44px;
}
&-slot {
min-height: 24px;
}
&-subtitle {
padding: 7px 0;
font-size: $calendar-sub-title-font;
line-height: 22px;
}
#{$block}__weekdays {
display: flex;
align-items: center;
justify-content: space-around;
height: 36px;
border-radius: 0 0 12px 12px;
box-shadow: 0 4px 10px 0 rgba($color: #000, $alpha: 6%);
#{$block}__weekday {
&.weekend {
color: $calendar-day67-font-color;
}
}
}
}
&__content {
display: block;
flex: 1;
width: 100%;
overflow-y: auto;
#{$block}__panel {
position: relative;
box-sizing: border-box;
display: block;
width: 100%;
height: auto;
#{$block}__body {
display: block;
}
#{$block}__month {
display: flex;
flex-direction: column;
text-align: center;
}
view:nth-of-type(2) {
#{$block}__month-title {
padding-top: 0;
}
}
.calendar-loading-tip {
position: absolute;
top: -50px;
right: 0;
left: 0;
height: 50px;
font-size: $calendar-text-font;
line-height: 50px;
color: $text-color;
text-align: center;
}
#{$block}__month-title {
height: 23px;
margin: 8px 0;
font-size: $calendar-month-title-font-size;
line-height: 23px;
}
#{$block}__days {
overflow: hidden;
#{$block}__day {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
float: left;
width: 14.28%;
height: 64px;
font-weight: $calendar-day-font-weight;
&.weekend {
color: $calendar-day67-font-color;
}
#{$block}__day-tips {
position: absolute;
width: 100%;
}
#{$block}__day-tips--curr {
position: absolute;
bottom: 6px;
width: 100%;
font-size: 12px;
line-height: 14px;
}
#{$block}__day-tip {
position: absolute;
bottom: 6px;
width: 100%;
font-size: 12px;
line-height: 14px;
color: $calendar-primary-color;
}
#{$block}__day-tips--top {
top: 6px;
}
#{$block}__day-tips--bottom {
bottom: 6px;
}
&--active {
color: $white !important;
background-color: $calendar-primary-color;
border-radius: $calendar-day-active-border-radius;
#{$block}__day-tips {
visibility: hidden;
}
#{$block}__day-tips--curr {
visibility: hidden;
}
#{$block}__day-tip {
color: $white;
}
}
&--disabled {
color: $calendar-disable-color !important;
}
&--choose {
color: $calendar-choose-font-color;
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
content: "";
background-color: $calendar-choose-color;
opacity: 0.09;
}
}
#{$block}__day-value {
padding: 2px 0;
font-size: $calendar-day-font;
}
}
}
}
}
&__footer {
display: flex;
width: 100%;
height: 62px;
background-color: $white;
#{$block}__confirm {
width: 100%;
height: 44px;
margin: 10px 18px;
line-height: 44px;
color: $white;
text-align: center;
background: $button-primary-background-color;
border-radius: 22px;
}
}
}

View File

@@ -0,0 +1 @@
export * from './calendaritem'

View File

@@ -0,0 +1,65 @@
export interface TouchParam {
startY: number
endY: number
startTime: number
endTime: number
lastY: number
lastTime: number
}
export type InputDate = string | string[]
export type StringArr = string[]
export interface CalendarState {
yearMonthTitle: string
currDate: string | string[]
propStartDate: string
propEndDate: string
currentIndex: number
unLoadPrev: boolean
touchParams: TouchParam
transformY: number
translateY: number
scrollDistance: number
defaultData: InputDate
chooseData: (string | string[])[]
monthsData: MonthInfo[]
dayPrefix: string
startData: InputDate
endData: InputDate
isRange: boolean
timer: number
avgHeight: number
monthsNum: number
defaultRange: number[]
}
export interface CalendarTaroState extends CalendarState {
scrollTop: number
containerHeight: string
}
export interface CalendarDateProp {
year: string
month: string
}
export interface Day {
day: string
type: string
year?: string
month?: string
}
export interface MonthInfo {
curData: string[] | string
title: string
monthData: Day[]
cssHeight: number
cssScrollHeight: number
}
export interface CalendarInst extends HTMLElement {
scrollToDate: (date: string) => void
initPosition: () => void
}

View File

@@ -0,0 +1,47 @@
import type { ExtractPropTypes } from 'vue'
import { commonProps, makeStringProp, truthProp } from '../_utils'
export const cardProps = {
...commonProps,
/**
* @description 左侧图片 `Url`
*/
imgUrl: makeStringProp(''),
/**
* @description 标题
*/
title: makeStringProp(''),
/**
* @description 商品价格
*/
price: makeStringProp(''),
/**
* @description 会员价格
*/
vipPrice: makeStringProp(''),
/**
* @description 店铺介绍
*/
shopDesc: makeStringProp(''),
/**
* @description 配送方式
*/
delivery: makeStringProp(''),
/**
* @description 店铺名称
*/
shopName: makeStringProp(''),
/**
* @description 是否需要价格展示
*/
isNeedPrice: truthProp,
}
export type CardProps = ExtractPropTypes<typeof cardProps>

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { computed, defineComponent } from 'vue'
import { PREFIX } from '../_constants'
import { getMainClass } from '../_utils'
import NutPrice from '../price/price.vue'
import NutTag from '../tag/tag.vue'
import { cardProps } from './card'
const props = defineProps(cardProps)
const classes = computed(() => {
return getMainClass(props, componentName)
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-card`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<div :class="classes" :style="customStyle">
<div class="nut-card__left">
<image :src="imgUrl" alt="" />
</div>
<div class="nut-card__right">
<div class="nut-card__right__title">
{{ title }}
</div>
<slot name="prolist" />
<div v-if="isNeedPrice" class="nut-card__right__price">
<slot name="price">
<NutPrice v-if="price" :price="price" />
</slot>
<slot name="origin">
<view class="nut-card__right__price__origin">
<NutPrice v-if="vipPrice" :price="vipPrice" />
</view>
</slot>
</div>
<div class="nut-card__right__other">
<slot name="shopTag">
<NutTag type="danger">
{{ shopDesc }}
</NutTag>
<NutTag plain>
{{ delivery }}
</NutTag>
</slot>
</div>
<div class="nut-card__right__shop">
<slot name="shopName">
<div class="nut-card__right__shop__name">
{{ shopName }}
</div>
</slot>
<slot name="footer" />
</div>
</div>
</div>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,112 @@
@import '../price/index';
@import '../tag/index';
.nut-theme-dark {
.nut-card {
.nut-card__right {
color: $dark-color;
}
}
}
.nut-card {
display: flex;
width: 100%;
.nut-card__left {
flex-shrink: 0;
width: 120px;
height: 120px;
background-color: $card-left-background-color;
border-radius: $card-left-border-radius;
> image {
display: block;
width: 100%;
height: 100%;
}
}
.nut-card__right {
flex: 1;
padding: 0 10px 8px;
.nut-card__right__title {
display: -webkit-box;
overflow: hidden;
font-size: 14px;
line-height: 1.5;
word-break: break-all;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.nut-card__right__price {
display: flex;
align-items: center;
height: 18px;
margin-top: 9px;
line-height: 18px;
.nut-price {
.nut-price--symbol-large {
font-size: 12px;
}
.nut-price--large {
font-size: 18px;
}
.nut-price--decimal-large {
font-size: 12px;
}
}
.nut-card__right__price__origin {
:deep(.nut-price) {
margin-left: 2px;
color: #d2a448;
.nut-price--symbol-large {
font-size: 12px;
}
.nut-price--large {
font-size: 12px;
}
.nut-price--decimal-large {
font-size: 12px;
}
}
}
}
.nut-card__right__other {
display: flex;
align-items: center;
padding: 5px 0 2px;
.nut-tag {
padding: 0 2px;
margin-right: 5px;
font-size: $card-font-size-0;
border: none;
}
}
.nut-card__right__shop {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
.nut-card__right__shop__name {
font-size: 12px;
line-height: 1.5;
color: #999;
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './card'

View File

@@ -0,0 +1,106 @@
import type { ExtractPropTypes, PropType, StyleValue } from 'vue'
import {
CHANGE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
UPDATE_MODEL_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { commonProps, makeArrayProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import { popupProps } from '../popup/popup'
import type { CascaderOption, CascaderValue } from './types'
export const cascaderProps = {
...popupProps,
...commonProps,
/**
* @description 选中值,双向绑定
*/
modelValue: Array,
/**
* @description 显示选择层
*/
visible: Boolean,
/**
* @description 级联数据
*/
options: makeArrayProp<any>([]),
/**
* @description 是否开启动态加载
*/
lazy: Boolean,
/**
* @description 动态加载回调,开启动态加载时生效
*/
lazyLoad: Function,
/**
* @description 自定义 `options` 结构中 `value` 的字段
*/
valueKey: makeStringProp('value'),
/**
* @description 自定义 `options` 结构中 `text` 的字段
*/
textKey: makeStringProp('text'),
/**
* @description 自定义 `options` 结构中 `children` 的字段
*/
childrenKey: makeStringProp('children'),
/**
* @description 当 `options` 为可转换为树形结构的扁平结构时,配置转换规则
*/
convertConfig: Object,
/**
* @description 是否需要弹层展示(设置为 `false` 后,`title` 失效)
*/
poppable: truthProp,
/**
* @description 标题
*/
title: String,
/**
* @description 选中底部展示样式 可选值: 'line', 'smile'
*/
titleType: makeStringProp<'line' | 'card' | 'smile'>('line'),
/**
* @description 标签栏字体尺寸大小 可选值: 'large', 'normal', 'small'
*/
titleSize: makeStringProp<'large' | 'normal' | 'small'>('normal'),
/**
* @description 标签间隙
*/
titleGutter: makeNumericProp(0),
/**
* @description 是否省略过长的标题文字
*/
titleEllipsis: truthProp,
/**
* @description 自定义弹窗样式
*/
popStyle: {
type: [String, Object, Array] as PropType<StyleValue>,
default: '',
},
/**
* @description 遮罩显示时的背景是否锁定
*/
lockScroll: truthProp,
}
export type CascaderProps = ExtractPropTypes<typeof cascaderProps>
/* eslint-disable unused-imports/no-unused-vars */
export const cascaderEmits = {
[UPDATE_MODEL_EVENT]: (value: CascaderValue) => true,
[UPDATE_VISIBLE_EVENT]: (value: boolean) => true,
[CHANGE_EVENT]: (value: CascaderValue, nodes: CascaderOption[]) => true,
pathChange: (nodes: CascaderOption[]) => true,
[OPEN_EVENT]: () => true,
[OPENED_EVENT]: () => true,
[CLOSE_EVENT]: () => true,
[CLOSED_EVENT]: () => true,
}
/* eslint-enable unused-imports/no-unused-vars */
export type CascaderEmits = typeof cascaderEmits

View File

@@ -0,0 +1,171 @@
<script lang="ts" setup>
import { computed, defineComponent, ref, useSlots, watch } from 'vue'
import {
CHANGE_EVENT,
CLOSE_EVENT,
CLOSED_EVENT,
OPEN_EVENT,
OPENED_EVENT,
PREFIX,
UPDATE_MODEL_EVENT,
UPDATE_VISIBLE_EVENT,
} from '../_constants'
import { getMainClass } from '../_utils'
import NutCascaderItem from '../cascaderitem/cascaderitem.vue'
import NutPopup from '../popup/popup.vue'
import { cascaderEmits, cascaderProps } from './cascader'
import type { CascaderOption, CascaderValue } from './types'
const props = defineProps(cascaderProps)
const emit = defineEmits(cascaderEmits)
const slots = useSlots()
const innerValue = ref(props.modelValue as CascaderValue)
const innerVisible = computed({
get() {
return props.visible
},
set(value) {
emit(UPDATE_VISIBLE_EVENT, value)
},
})
const classes = computed(() => {
return getMainClass(props, componentName)
})
const popClasses = computed(() => {
return `${componentName}__popup ${props.popClass}`
})
const overlayClasses = computed(() => {
return `${componentName}__overlay ${props.overlayClass}`
})
function handleChange(value: CascaderValue, pathNodes: CascaderOption[]) {
innerValue.value = value
innerVisible.value = false
emit(UPDATE_MODEL_EVENT, value)
emit(CHANGE_EVENT, value, pathNodes)
}
function handlePathChange(pathNodes: CascaderOption[]) {
emit('pathChange', pathNodes)
}
function handleOpen() {
emit(OPEN_EVENT)
}
function handleOpened() {
emit(OPENED_EVENT)
}
function handleClose() {
emit(CLOSE_EVENT)
}
function handleClosed() {
emit(CLOSED_EVENT)
}
watch(() => props.modelValue, (value) => {
if (value !== innerValue.value) {
innerValue.value = value as CascaderValue
}
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-cascader`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="classes" :style="props.customStyle">
<template v-if="props.poppable">
<NutPopup
v-model:visible="innerVisible"
:custom-class="popClasses"
:custom-style="props.popStyle"
:overlay-class="overlayClasses"
:overlay-style="props.overlayStyle"
position="bottom"
round
:closeable="props.closeable"
:close-icon="props.closeIcon"
:close-icon-position="props.closeIconPosition"
:z-index="props.zIndex"
:lock-scroll="props.lockScroll"
:overlay="props.overlay"
:close-on-click-overlay="props.closeOnClickOverlay"
:destroy-on-close="false"
@open="handleOpen"
@opened="handleOpened"
@close="handleClose"
@closed="handleClosed"
>
<slot v-if="slots.title" name="title" />
<template v-else>
<rich-text v-if="props.title" class="nut-cascader__bar" :nodes="props.title" />
</template>
<NutCascaderItem
:model-value="innerValue"
:visible="innerVisible"
:options="props.options"
:lazy="props.lazy"
:lazy-load="props.lazyLoad"
:value-key="props.valueKey"
:text-key="props.textKey"
:children-key="props.childrenKey"
:convert-config="props.convertConfig"
:title-type="props.titleType"
:title-size="props.titleSize"
:title-gutter="props.titleGutter"
:title-ellipsis="props.titleEllipsis"
@change="handleChange"
@path-change="handlePathChange"
/>
</NutPopup>
</template>
<template v-else>
<NutCascaderItem
:model-value="innerValue"
:visible="innerVisible"
:options="props.options"
:lazy="props.lazy"
:lazy-load="props.lazyLoad"
:value-key="props.valueKey"
:text-key="props.textKey"
:children-key="props.childrenKey"
:convert-config="props.convertConfig"
:title-type="props.titleType"
:title-size="props.titleSize"
:title-gutter="props.titleGutter"
:title-ellipsis="props.titleEllipsis"
@change="handleChange"
@path-change="handlePathChange"
/>
</template>
</view>
</template>
<style lang="scss">
@import "./index";
@import "../popup/index";
</style>

View File

@@ -0,0 +1,80 @@
import type { CascaderConfig, CascaderOption, ConvertConfig } from './types'
export function formatTree(tree: CascaderOption[], parent: CascaderOption | null, config: CascaderConfig): CascaderOption[] {
return tree.map((node: CascaderOption) => {
const { value: valueKey = 'value', text: textKey = 'text', children: childrenKey = 'children' } = config
const { [valueKey]: value, [textKey]: text, [childrenKey]: children, ...others } = node
const newNode: CascaderOption = {
loading: false,
...others,
level: parent ? ((parent && parent.level) || 0) + 1 : 0,
value,
text,
children,
_parent: parent,
}
if (newNode.children && newNode.children.length)
newNode.children = formatTree(newNode.children, newNode, config)
return newNode
})
}
export function eachTree(tree: CascaderOption[], cb: (node: CascaderOption) => any): void {
let i = 0
let node: CascaderOption
/* eslint-disable no-cond-assign */
while ((node = tree[i++])) {
if (cb(node) === true)
break
if (node.children && node.children.length)
eachTree(node.children, cb)
}
}
const defaultConvertConfig = {
topId: null,
idKey: 'id',
pidKey: 'pid',
sortKey: '',
}
export function convertListToOptions(list: CascaderOption[], options: ConvertConfig): CascaderOption[] {
const mergedOptions = {
...defaultConvertConfig,
...(options || {}),
}
const { topId, idKey, pidKey, sortKey } = mergedOptions
let result: CascaderOption[] = []
let map: any = {}
list.forEach((node: CascaderOption) => {
node = { ...node }
const { [idKey]: id, [pidKey]: pid } = node
const children = (map[pid] = map[pid] || [])
if (!result.length && pid === topId)
result = children
children.push(node)
node.children = map[id] || (map[id] = [])
})
if (sortKey) {
Object.keys(map).forEach((i) => {
if (map[i].length > 1)
map[i].sort((a: CascaderOption, b: CascaderOption) => a[sortKey] - b[sortKey])
})
}
map = null
return result
}

View File

@@ -0,0 +1,110 @@
.nut-theme-dark {
.nut-cascader {
.nut-tabs__titles {
background: $dark-background3 !important;
}
&__bar {
color: $dark-color;
background: $dark-background2;
}
&-item {
&__inner {
color: $dark-color-gray;
}
}
}
}
.nut-cascader {
.nut-tab-pane {
padding: 0;
}
.nut-tabs__titles {
padding: $cascader-tabs-item-padding;
background: #fff;
}
&-item {
width: 100%;
font-size: $cascader-font-size;
line-height: $cascader-line-height;
$block: &;
&.nut-tabs {
&.horizontal {
.nut-tabs__titles {
.nut-tabs__titles-item {
flex: initial;
padding: $cascader-tabs-item-padding;
white-space: nowrap;
}
}
}
}
&__inner {
display: flex;
align-items: center;
padding: $cascader-item-padding;
margin: 0;
font-size: $cascader-item-font-size;
color: $cascader-item-color;
cursor: pointer;
}
&__title {
flex: 1;
}
&__icon-check {
margin-left: 10px;
visibility: hidden;
}
&__icon-loading {
margin-left: 10px;
}
&.active {
&:not(.disabled) {
color: $cascader-item-active-color;
}
#{$block}__icon-check {
color: $cascader-item-active-color;
visibility: visible;
}
}
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__bar {
display: flex;
align-items: center;
justify-content: center;
padding: $cascader-bar-padding;
font-size: $cascader-bar-font-size;
font-weight: bold;
line-height: $cascader-bar-line-height;
color: $cascader-bar-color;
text-align: center;
}
&-pane {
display: block;
width: 100%;
height: 342px;
padding: 10px 0 0;
margin: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}

View File

@@ -0,0 +1 @@
export * from './cascader'

View File

@@ -0,0 +1,73 @@
import { eachTree, formatTree } from './helper'
import type { CascaderConfig, CascaderOption, CascaderValue } from './types'
class Tree {
nodes: CascaderOption[]
readonly config: CascaderConfig
constructor(nodes: CascaderOption[], config?: CascaderConfig) {
this.config = {
value: 'value',
text: 'text',
children: 'children',
...(config || {}),
}
this.nodes = formatTree(nodes, null, this.config)
}
updateChildren(nodes: CascaderOption[], parent: CascaderOption | null): void {
if (!parent)
this.nodes = formatTree(nodes, null, this.config)
else
parent.children = formatTree(nodes, parent, this.config)
}
// for test
getNodeByValue(value: CascaderOption['value']): CascaderOption | void {
let foundNode
eachTree(this.nodes, (node: CascaderOption) => {
if (node.value === value) {
foundNode = node
return true
}
})
return foundNode
}
getPathNodesByValue(value: CascaderValue): CascaderOption[] {
if (!value.length)
return []
const pathNodes = []
let currentNodes: CascaderOption[] | void = this.nodes
while (currentNodes && currentNodes.length) {
const foundNode: CascaderOption | void = currentNodes.find(node => node.value === value[node.level as number])
if (!foundNode)
break
pathNodes.push(foundNode)
currentNodes = foundNode.children
}
return pathNodes
}
isLeaf(node: CascaderOption, lazy: boolean): boolean {
const { leaf, children } = node
const hasChildren = Array.isArray(children) && Boolean(children.length)
return leaf == null ? !hasChildren && !lazy : leaf
}
hasChildren(node: CascaderOption, lazy: boolean): boolean {
if (lazy)
return Array.isArray(node.children) && Boolean(node.children.length)
return !this.isLeaf(node, lazy)
}
}
export default Tree

View File

@@ -0,0 +1,37 @@
export interface CascaderPane {
nodes: CascaderOption[]
selectedNode: CascaderOption | null
}
export interface CascaderConfig {
value?: string
text?: string
children?: string
}
export interface CascaderTabs {
title: string
paneKey: string
disabled: boolean
}
export interface CascaderOption {
text?: string
value?: number | string
disabled?: boolean
children?: CascaderOption[]
leaf?: boolean
level?: number
loading?: boolean
[key: PropertyKey]: any
}
export type CascaderValue = CascaderOption['value'][]
export interface ConvertConfig {
topId?: string | number | null
idKey?: string
pidKey?: string
sortKey?: string
}

View File

@@ -0,0 +1,72 @@
import type { ExtractPropTypes } from 'vue'
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '../_constants'
import { commonProps, makeArrayProp, makeNumericProp, makeStringProp, truthProp } from '../_utils'
import type { CascaderOption, CascaderValue } from '../cascader/types'
export const cascaderitemProps = {
...commonProps,
/**
* @description 选中值,双向绑定
*/
modelValue: Array,
/**
* @description 显示选择层
*/
visible: Boolean,
/**
* @description 级联数据
*/
options: makeArrayProp<any>([]),
/**
* @description 是否开启动态加载
*/
lazy: Boolean,
/**
* @description 动态加载回调,开启动态加载时生效
*/
lazyLoad: Function,
/**
* @description 自定义 `options` 结构中 `value` 的字段
*/
valueKey: makeStringProp('value'),
/**
* @description 自定义 `options` 结构中 `text` 的字段
*/
textKey: makeStringProp('text'),
/**
* @description 自定义 `options` 结构中 `children` 的字段
*/
childrenKey: makeStringProp('children'),
/**
* @description 当 `options` 为可转换为树形结构的扁平结构时,配置转换规则
*/
convertConfig: Object,
/**
* @description 选中底部展示样式 可选值: 'line', 'smile'
*/
titleType: makeStringProp<'line' | 'card' | 'smile'>('line'),
/**
* @description 标签栏字体尺寸大小 可选值: 'large', 'normal', 'small'
*/
titleSize: makeStringProp<'large' | 'normal' | 'small'>('normal'),
/**
* @description 标签间隙
*/
titleGutter: makeNumericProp(0),
/**
* @description 是否省略过长的标题文字
*/
titleEllipsis: truthProp,
}
export type CascaderItemProps = ExtractPropTypes<typeof cascaderitemProps>
/* eslint-disable unused-imports/no-unused-vars */
export const cascaderitemEmits = {
[UPDATE_MODEL_EVENT]: (value: CascaderValue) => true,
[CHANGE_EVENT]: (value: CascaderValue, nodes: CascaderOption[]) => true,
pathChange: (value: CascaderOption[]) => true,
}
/* eslint-enable unused-imports/no-unused-vars */
export type CascaderItemEmits = typeof cascaderitemEmits

View File

@@ -0,0 +1,339 @@
<script lang="ts" setup>
import { computed, defineComponent, ref, watch } from 'vue'
import { CHANGE_EVENT, PREFIX, UPDATE_MODEL_EVENT } from '../_constants'
import { getMainClass } from '../_utils'
import { useTranslate } from '../../locale'
import { convertListToOptions } from '../cascader/helper'
import Tree from '../cascader/tree'
import type { CascaderOption, CascaderPane, CascaderTabs, CascaderValue, ConvertConfig } from '../cascader/types'
import NutIcon from '../icon/icon.vue'
import NutTabPane from '../tabpane/tabpane.vue'
import NutTabs from '../tabs/tabs.vue'
import { cascaderitemEmits, cascaderitemProps } from './cascaderitem'
const props = defineProps(cascaderitemProps)
const emit = defineEmits(cascaderitemEmits)
const classes = computed(() => {
return getMainClass(props, componentName)
})
const configs = computed(() => ({
lazy: props.lazy,
lazyLoad: props.lazyLoad,
valueKey: props.valueKey,
textKey: props.textKey,
childrenKey: props.childrenKey,
convertConfig: props.convertConfig,
}))
const tabsCursor = ref(0)
const initLoading = ref(false)
const innerValue = ref(props.modelValue as CascaderValue)
const tree = ref(new Tree([], {}))
const panes = ref<CascaderPane[]>([])
const isLazy = computed(() => configs.value.lazy && Boolean(configs.value.lazyLoad))
const lazyLoadMap = new Map()
let currentProcessNode: CascaderOption | null
async function init() {
lazyLoadMap.clear()
panes.value = []
tabsCursor.value = 0
initLoading.value = false
currentProcessNode = null
let { options } = props
if (configs.value.convertConfig) {
options = convertListToOptions(options as CascaderOption[], configs.value.convertConfig as ConvertConfig)
}
tree.value = new Tree(options as CascaderOption[], {
value: configs.value.valueKey,
text: configs.value.textKey,
children: configs.value.childrenKey,
})
if (isLazy.value && !tree.value.nodes.length) {
await invokeLazyLoad({
root: true,
loading: true,
text: '',
value: '',
})
}
panes.value = [{ nodes: tree.value.nodes, selectedNode: null }]
syncValue()
}
const methods = {
// 选中一个节点,静默模式不触发事件
async handleNode(node: CascaderOption, silent?: boolean) {
const { disabled, loading } = node
if ((!silent && disabled) || !panes.value[tabsCursor.value])
return
if (tree.value.isLeaf(node, isLazy.value)) {
node.leaf = true
panes.value[tabsCursor.value].selectedNode = node
panes.value = panes.value.slice(0, (node.level as number) + 1)
if (!silent) {
const pathNodes = panes.value.map(pane => pane.selectedNode)
emitChange(pathNodes as CascaderOption[])
emit('pathChange', pathNodes as CascaderOption[])
}
return
}
if (tree.value.hasChildren(node, isLazy.value)) {
const level = (node.level as number) + 1
panes.value[tabsCursor.value].selectedNode = node
panes.value = panes.value.slice(0, level)
panes.value.push({
nodes: node.children || [],
selectedNode: null,
})
tabsCursor.value = level
if (!silent) {
const pathNodes = panes.value.map(pane => pane.selectedNode)
emit('pathChange', pathNodes as CascaderOption[])
}
return
}
currentProcessNode = node
if (loading)
return
await invokeLazyLoad(node)
if (currentProcessNode === node) {
panes.value[tabsCursor.value].selectedNode = node
methods.handleNode(node, silent)
}
},
handleTabClick(tab: CascaderTabs) {
currentProcessNode = null
tabsCursor.value = Number(tab.paneKey)
},
isSelected(pane: CascaderPane, node: CascaderOption) {
return pane?.selectedNode?.value === node.value
},
}
async function syncValue() {
const currentValue = innerValue.value
if (currentValue === undefined || !tree.value.nodes.length) {
return
}
if (currentValue.length === 0) {
tabsCursor.value = 0
panes.value = [{ nodes: tree.value.nodes, selectedNode: null }]
return
}
let needToSync = currentValue
if (isLazy.value && Array.isArray(currentValue) && currentValue.length) {
needToSync = []
const parent = tree.value.nodes.find(node => node.value === currentValue[0])
if (parent) {
needToSync = [parent.value]
initLoading.value = true
const last = await currentValue.slice(1).reduce(async (p: Promise<CascaderOption | void>, value) => {
const parent = await p
await invokeLazyLoad(parent)
const node = parent?.children?.find(item => item.value === value)
if (node)
needToSync.push(value)
return Promise.resolve(node)
}, Promise.resolve(parent))
await invokeLazyLoad(last)
initLoading.value = false
}
}
if (needToSync.length && currentValue === props.modelValue) {
const pathNodes = tree.value.getPathNodesByValue(needToSync)
pathNodes.forEach((node, index) => {
tabsCursor.value = index
methods.handleNode(node, true)
})
}
}
async function invokeLazyLoad(node?: CascaderOption | void) {
if (!node)
return
if (!configs.value.lazyLoad) {
node.leaf = true
return
}
if (tree.value.isLeaf(node, isLazy.value) || tree.value.hasChildren(node, isLazy.value))
return
node.loading = true
const parent = node.root ? null : node
let lazyLoadPromise = lazyLoadMap.get(node)
if (!lazyLoadPromise) {
lazyLoadPromise = new Promise((resolve) => {
// 外部必须resolve
configs.value.lazyLoad?.(node, resolve)
})
lazyLoadMap.set(node, lazyLoadPromise)
}
const nodes: CascaderOption[] | void = await lazyLoadPromise
if (Array.isArray(nodes) && nodes.length > 0) {
tree.value.updateChildren(nodes, parent)
}
else {
// 如果加载完成后没有提供子节点,作为叶子节点处理
node.leaf = true
}
node.loading = false
lazyLoadMap.delete(node)
}
function emitChange(pathNodes: CascaderOption[]) {
const emitValue = pathNodes.map(node => node.value)
innerValue.value = emitValue
emit(UPDATE_MODEL_EVENT, emitValue)
emit(CHANGE_EVENT, emitValue, pathNodes)
}
function formatTabTitle(pane: CascaderPane) {
return pane.selectedNode ? pane.selectedNode.text : translate('select')
}
watch(() => [configs.value, props.options], () => {
init()
}, {
deep: true,
immediate: true,
})
watch(() => props.modelValue, (value) => {
if (value !== innerValue.value) {
innerValue.value = value as CascaderValue
syncValue()
}
})
watch(() => props.visible, (value) => {
// TODO: value为空时保留上次选择记录修复单元测试问题
if (value && Array.isArray(innerValue.value) && innerValue.value.length > 0) {
syncValue()
}
})
</script>
<script lang="ts">
const componentName = `${PREFIX}-cascader-item`
const { translate } = useTranslate(componentName)
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<NutTabs
v-model="tabsCursor"
:custom-class="classes"
:custom-style="props.customStyle"
:type="props.titleType"
:size="props.titleSize"
:title-gutter="props.titleGutter"
:ellipsis="props.titleEllipsis"
title-scroll
@click="methods.handleTabClick"
>
<template v-if="!initLoading && panes.length">
<NutTabPane v-for="(pane, index) in panes" :key="index" :title="formatTabTitle(pane)">
<view class="nut-cascader-pane" role="menu">
<scroll-view style="height: 100%" :scroll-y="true">
<template v-for="node in pane.nodes" :key="node.value">
<view
class="nut-cascader-item__inner"
:class="{ active: methods.isSelected(pane, node), disabled: node.disabled }"
role="menuitemradio"
:aria-checked="methods.isSelected(pane, node)"
:aria-disabled="node.disabled || undefined"
@click="methods.handleNode(node, false)"
>
<view class="nut-cascader-item__title">
{{ node.text }}
</view>
<NutIcon
v-if="node.loading"
custom-class="nut-cascader-item__icon-loading"
loading
name="loading"
/>
<NutIcon
v-else
custom-class="nut-cascader-item__icon-check"
name="checklist"
/>
</view>
</template>
</scroll-view>
</view>
</NutTabPane>
</template>
<template v-else>
<NutTabPane title="Loading...">
<view class="nut-cascader-pane" />
</NutTabPane>
</template>
</NutTabs>
</template>
<style lang="scss">
@import "./index";
</style>

View File

@@ -0,0 +1,3 @@
@import "../cascader/index";
@import "../tabs/index";
@import "../tabpane/index";

View File

@@ -0,0 +1 @@
export * from './cascaderitem'

View File

@@ -0,0 +1,29 @@
import type { ExtractPropTypes } from 'vue'
import { CHANGE_EVENT } from '../_constants'
import { commonProps, isNumber, makeArrayProp, makeStringProp } from '../_utils'
export interface CategoryType {
catName?: string
[key: string]: any
}
export const categoryProps = {
...commonProps,
/**
* @description 分类模式:`classify``text``custom`
*/
type: makeStringProp<'classify' | 'text' | 'custom'>('classify'),
/**
* @description 左侧导航栏数据列表
*/
category: makeArrayProp<any>([]),
}
export type CategoryProps = ExtractPropTypes<typeof categoryProps>
export const categoryEmits = {
[CHANGE_EVENT]: (index: number) => isNumber(index),
}
export type CategoryEmits = typeof categoryEmits

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed, defineComponent, ref } from 'vue'
import { CHANGE_EVENT, PREFIX } from '../_constants'
import { getMainClass } from '../_utils'
import { categoryEmits, categoryProps } from './category'
const props = defineProps(categoryProps)
const emit = defineEmits(categoryEmits)
const checkIndex = ref(0)
const classes = computed(() => {
return getMainClass(props, componentName)
})
function getChildList(index: any) {
checkIndex.value = index
emit(CHANGE_EVENT, index)
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-category`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<div :class="classes" :style="customStyle">
<div class="nut-category__cateList">
<div v-if="type === 'classify' || type === 'text'">
<div v-for="(item, index) in category" :key="index" class="nut-category__cateListLeft">
<div
:class="[checkIndex === index ? 'nut-category__cateListItemChecked' : 'nut-category__cateListItem']"
@click="getChildList(index)"
>
{{ item.catName }}
</div>
</div>
</div>
<slot />
</div>
</div>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,64 @@
.nut-theme-dark {
.nut-category {
&__cateList {
background: $dark-background2;
}
&__cateListLeft {
background: $dark-background4;
}
&__cateListItem {
color: $dark-color-gray;
}
&__cateListItemChecked {
color: $dark-color;
background: $dark-background2;
&::before {
background: $category-list-item-checked-img-bg-color;
}
}
}
}
.nut-category {
&__cateList {
display: flex;
background: $category-bg-color;
}
&__cateListLeft {
background: $category-list-left-bg-color;
}
&__cateListItemChecked,
&__cateListItem {
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 50px;
font-size: 13px;
font-weight: normal;
color: $category-list-item-color;
transition: all 0.3s;
}
&__cateListItemChecked {
position: relative;
font-weight: 500;
background: $category-list-item-checked-color;
transition: all 0.3s;
&::before {
position: absolute;
left: 0;
width: 5px;
height: 18px;
content: "";
background: $category-list-item-checked-img-bg-color;
}
}
}

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