This commit is contained in:
Zy
2026-06-27 08:11:10 +08:00
591 changed files with 80049 additions and 212 deletions

View File

@@ -1,15 +1,26 @@
<script setup>
import { onLaunch } from '@dcloudio/uni-app'
import { onLaunch,onShow,onHide } from '@dcloudio/uni-app'
// 应用启动时执行
onLaunch(() => {
console.log('App 启动')
uni.onPushMessage((res) => {
console.log("收到推送消息:",res) //监听推送消息
})
// #ifdef APP-PLUS
// 只有 App 平台才获取 CID
getPushCID()
// #endif
})
onShow(() =>{
console.log('App Show')
})
onHide(() =>{
console.log('App Hide')
})
// 获取 uniPush 2.0 CID
function getPushCID() {
@@ -27,6 +38,9 @@ function getPushCID() {
}
</script>
<style lang="scss">
@import "@/uni_modules/uview-plus/index.scss";
</style>
<style>
/*每个页面公共css */
/* 全局清除默认样式 */

View File

@@ -9,7 +9,10 @@
<view class="popup-content">
<view class="section-text">
我已仔细阅读并同意
<text class="privacy-link" @click="goToPrivacy">《用户协议》和《隐私政策》</text>
<view class="privacy-link" >
<text @click="goToUserAgreement">《用户协议》</text>和
<text @click="goToPrivacy">《隐私政策》</text>
</view>
全部内容。
</view>
</view>
@@ -49,9 +52,24 @@ const handleMaskClick = () => {
emit('close')
emit('update:show', false)
}
// 打开用户协议
const goToUserAgreement = () => {
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
}
// 打开隐私政策
const goToPrivacy = () => {
uni.navigateTo({ url: '/pageSubPack/loginSub/privacy' })
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
}
</script>

View File

@@ -1,4 +1,5 @@
import App from './App'
import uviewPlus from '@/uni_modules/uview-plus'
// #ifndef VUE3
import Vue from 'vue'
@@ -17,6 +18,7 @@ import {
} from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.use(uviewPlus)
return {
app
}

View File

@@ -0,0 +1,10 @@
{
"dependencies": {
"clipboard": "^2.0.11",
"dayjs": "^1.11.21"
},
"devDependencies": {
"sass": "^1.101.0",
"sass-loader": "^17.0.0"
}
}

View File

@@ -72,7 +72,7 @@
<text class="agreePrivacy">《用户隐私政策》</text>
</text> -->
<text class="agreement-text" >我已阅读并同意
<text class="agreePrivacy" @click.stop="">《用户协议》</text>和
<text class="agreePrivacy" @click.stop="goToUserAgreement">《用户协议》</text>和
<text class="agreePrivacy" @click.stop="goToPrivacy">《隐私政策》</text>
</text>
</view>
@@ -139,9 +139,24 @@ const checkForm = () => {
const toggleAgreement = () => {
agreeAgreement.value = !agreeAgreement.value
}
// 打开隐私协议弹窗
// 打开用户协议
const goToUserAgreement = () => {
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
}
// 打开隐私政策
const goToPrivacy = () => {
showPrivacyPopup.value = true
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
}

View File

@@ -46,7 +46,7 @@
<uni-icons type="checkbox" size="24" color="#999" v-else></uni-icons>
</view>
<text class="agreement-text" >我已阅读并同意
<text class="agreePrivacy" @click.stop="">《用户协议》</text>和
<text class="agreePrivacy" @click.stop="goToUserAgreement">《用户协议》</text>和
<text class="agreePrivacy" @click.stop="goToPrivacy">《隐私政策》</text>
</text>
</view>
@@ -104,11 +104,32 @@
const toggleRemember = () => {
rememberPwd.value = !rememberPwd.value;
};
// 打开隐私协议弹窗
const goToPrivacy = () => {
showPrivacyPopup.value = true
// 打开用户协议
const goToUserAgreement = () => {
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler_xieyi.html')
// #endif
}
// 打开隐私政策
const goToPrivacy = () => {
// #ifdef APP-PLUS
plus.runtime.openURL('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
// #ifdef H5
window.open('https://wuye.ckdzkj.com/parivw/butler.html')
// #endif
}
// 打开隐私协议弹窗
// const goToPrivacy = () => {
// showPrivacyPopup.value = true
// }
// 不同意隐私协议
const disagreePrivacy = () => {

View File

@@ -268,7 +268,9 @@
"easycom": {
"autoscan": true,
"custom": {
"^uni-(.*)": "@/components/uni-$1.vue"
"^uni-(.*)": "@/components/uni-$1.vue",
"^u-(.*)": "@/uni_modules/uview-plus/components/u-$1/u-$1.vue",
"^up-(.*)": "@/uni_modules/uview-plus/components/u-$1/u-$1.vue"
}
},
"tabBar": {

View File

@@ -39,30 +39,11 @@
<view class="scroll-content">
<!-- banner -->
<view class="bannarClass" v-if="bannerList && bannerList.length > 0">
<uni-swiper-dot
class="uni-swiper-dot-box"
@clickItem="clickItem"
:info="bannerList"
:current="current"
mode="default"
>
<swiper
class="swiper-box"
:key="bannerKey"
:current="current"
@change="onSwiperChange"
autoplay
circular
:interval="5000"
:duration="500"
>
<swiper-item v-for="(item, index) in bannerList" :key="item.id || item.url || index">
<view class="swiper-item">
<image :src="item.url" style="width: 100%; height: 100%"></image>
</view>
</swiper-item>
</swiper>
</uni-swiper-dot>
<up-swiper :list="bannerList" indicator indicatorMode="dot" radius="15" height="180" keyName="url">
<template #default="{ item, index }">
<image :src="item.url || item" mode="aspectFill" class="banner-img"></image>
</template>
</up-swiper>
</view>
<!-- 巡检 -->
@@ -164,7 +145,7 @@
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { ref } from 'vue'
import { onLoad, onUnload, onShow } from '@dcloudio/uni-app'
import {
getCurrentVillage,
@@ -174,7 +155,6 @@ import {
} from '../../api/api.js'
// 列表数据状态
const current = ref(0)
const currentCommunity = ref('请先绑定房屋')
const bannerList = ref([])
const noticeText = ref('暂无公告')
@@ -183,21 +163,6 @@ const pendingCount = ref(0) //待巡检
const InspectedCount = ref(0) //已巡检
const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight || 0)
// 用于强制重新创建 swiper,避免列表变化时内部节点复用导致 getBoundingClientRect 空指针
const bannerKey = computed(() => bannerList.value.map(item => item.id || item.url).join('_'))
// banner 数据变化时重置当前索引,防止 current 越界
watch(
() => bannerList.value,
newVal => {
if (!newVal || newVal.length === 0) {
current.value = 0
} else if (current.value >= newVal.length) {
current.value = 0
}
},
{ deep: true }
)
// =======方法=======
// 跳转小区
@@ -318,16 +283,13 @@ function getbannarData() {
const newData = res.data
const oldData = bannerList.value
if (JSON.stringify(newData) !== JSON.stringify(oldData)) {
current.value = 0
bannerList.value = newData
}
} else {
current.value = 0
bannerList.value = []
}
})
.catch(() => {
current.value = 0
bannerList.value = []
})
}
@@ -357,19 +319,6 @@ onUnload(() => {
uni.$off('confirm', slelctHomeData)
})
const clickItem = e => {
const idx = e.index !== undefined ? e.index : e
if (bannerList.value && idx >= 0 && idx < bannerList.value.length) {
current.value = idx
}
}
const onSwiperChange = e => {
const idx = e.detail.current
if (bannerList.value && idx >= 0 && idx < bannerList.value.length) {
current.value = idx
}
}
</script>
<style lang="scss" scoped>
@@ -483,20 +432,12 @@ const onSwiperChange = e => {
padding: 20rpx 0;
}
.swiper-box {
height: 300rpx;
border-radius: 16rpx !important;
overflow: hidden !important;
.banner-img {
width: 100%;
height: 100%;
border-radius: 15rpx;
}
.swiper-item {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 300rpx;
color: #fff;
}
.inspectionClass {
background: linear-gradient(to right, #7b87fa, #4d5dfa);

View File

@@ -23,6 +23,10 @@
</view>
</view>
</scroll-view>
<!-- 底部备案号展示 -->
<view class="icp-footer">
<text class="icp-text">ICP备案号:鲁ICP备2024131497号-5A</text>
</view>
</view>
</template>
@@ -179,4 +183,15 @@ const onItemClick = item => {
margin-right: 10rpx;
flex-shrink: 0;
}
.icp-footer {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx 0;
}
.icp-text {
font-size: 22rpx;
color: #999999;
}
</style>

View File

@@ -1,3 +1,4 @@
@import "@/uni_modules/uview-plus/theme.scss";
/**
* 这里是uni-app内置的常用样式变量
*

View File

@@ -0,0 +1,14 @@
'use strict';
const uniPush = uniCloud.getPushManager({
appId: "__UNI__5F224F3"
})
exports.main = async (event, context) => {
const res = await uniPush.sendMessage({
push_clientid: event.cid,
title: "通知标题",
content: "通知内容",
force_notification: true
})
return res
}

View File

@@ -0,0 +1,8 @@
{
"name": "push-send-butler",
"dependencies": {},
"extensions": {
"uni-cloud-jql": {},
"uni-cloud-push": {}
}
}

View File

@@ -0,0 +1,12 @@
// 本文件用于,使用JQL语法操作项目关联的uniCloud空间的数据库,方便开发调试和远程数据库管理
// 编写clientDB的js API(也支持常规js语法,比如var),可以对云数据库进行增删改查操作。不支持uniCloud-db组件写法
// 可以全部运行,也可以选中部分代码运行。点击工具栏上的运行按钮或者按下【F5】键运行代码
// 如果文档中存在多条JQL语句,只有最后一条语句生效
// 如果混写了普通js,最后一条语句需是数据库操作语句
// 此处代码运行不受DB Schema的权限控制,移植代码到实际业务中注意在schema中配好permission
// 不支持clientDB的action
// 数据库查询有最大返回条数限制,详见:https://uniapp.dcloud.net.cn/uniCloud/cf-database.html#limit
// 详细JQL语法,请参考:https://uniapp.dcloud.net.cn/uniCloud/jql.html
// 下面示例查询uni-id-users表的所有数据
db.collection('uni-id-users').get();

View File

@@ -0,0 +1,6 @@
## 0.0.3(2022-11-11)
- 修复 config 方法获取根节点为数组格式配置时错误的转化为了对象的Bug
## 0.0.2(2021-04-16)
- 修改插件package信息
## 0.0.1(2021-03-15)
- 初始化项目

View File

@@ -0,0 +1,81 @@
{
"id": "uni-config-center",
"displayName": "uni-config-center",
"version": "0.0.3",
"description": "uniCloud 配置中心",
"keywords": [
"配置",
"配置中心"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"type": "unicloud-template-function"
},
"directories": {
"example": "../../../scripts/dist"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"App": {
"app-vue": "u",
"app-nvue": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
},
"Vue": {
"vue2": "y",
"vue3": "u"
}
}
}
}
}

View File

@@ -0,0 +1,93 @@
# 为什么使用uni-config-center
实际开发中很多插件需要配置文件才可以正常运行,如果每个插件都单独进行配置的话就会产生下面这样的目录结构
```bash
cloudfunctions
└─────common 公共模块
├─plugin-a // 插件A对应的目录
│ ├─index.js
│ ├─config.json // plugin-a对应的配置文件
│ └─other-file.cert // plugin-a依赖的其他文件
└─plugin-b // plugin-b对应的目录
├─index.js
└─config.json // plugin-b对应的配置文件
```
假设插件作者要发布一个项目模板,里面使用了很多需要配置的插件,无论是作者发布还是用户使用都是一个大麻烦。
uni-config-center就是用了统一管理这些配置文件的,使用uni-config-center后的目录结构如下
```bash
cloudfunctions
└─────common 公共模块
├─plugin-a // 插件A对应的目录
│ └─index.js
├─plugin-b // plugin-b对应的目录
│ └─index.js
└─uni-config-center
├─index.js // config-center入口文件
├─plugin-a
│ ├─config.json // plugin-a对应的配置文件
│ └─other-file.cert // plugin-a依赖的其他文件
└─plugin-b
└─config.json // plugin-b对应的配置文件
```
使用uni-config-center后的优势
- 配置文件统一管理,分离插件主体和配置信息,更新插件更方便
- 支持对config.json设置schema,插件使用者在HBuilderX内编写config.json文件时会有更好的提示(后续HBuilderX会提供支持)
# 用法
在要使用uni-config-center的公共模块或云函数内引入uni-config-center依赖,请参考:[使用公共模块](https://uniapp.dcloud.net.cn/uniCloud/cf-common)
```js
const createConfig = require('uni-config-center')
const uniIdConfig = createConfig({
pluginId: 'uni-id', // 插件id
defaultConfig: { // 默认配置
tokenExpiresIn: 7200,
tokenExpiresThreshold: 600,
},
customMerge: function(defaultConfig, userConfig) { // 自定义默认配置和用户配置的合并规则,不设置的情况侠会对默认配置和用户配置进行深度合并
// defaudltConfig 默认配置
// userConfig 用户配置
return Object.assign(defaultConfig, userConfig)
}
})
// 以如下配置为例
// {
// "tokenExpiresIn": 7200,
// "passwordErrorLimit": 6,
// "bindTokenToDevice": false,
// "passwordErrorRetryTime": 3600,
// "app-plus": {
// "tokenExpiresIn": 2592000
// },
// "service": {
// "sms": {
// "codeExpiresIn": 300
// }
// }
// }
// 获取配置
uniIdConfig.config() // 获取全部配置,注意:uni-config-center内不存在对应插件目录时会返回空对象
uniIdConfig.config('tokenExpiresIn') // 指定键值获取配置,返回:7200
uniIdConfig.config('service.sms.codeExpiresIn') // 指定键值获取配置,返回:300
uniIdConfig.config('tokenExpiresThreshold', 600) // 指定键值获取配置,如果不存在则取传入的默认值,返回:600
// 获取文件绝对路径
uniIdConfig.resolve('custom-token.js') // 获取uni-config-center/uni-id/custom-token.js文件的路径
// 引用文件(require)
uniIDConfig.requireFile('custom-token.js') // 使用require方式引用uni-config-center/uni-id/custom-token.js文件。文件不存在时返回undefined,文件内有其他错误导致require失败时会抛出错误。
// 判断是否包含某文件
uniIDConfig.hasFile('custom-token.js') // 配置目录是否包含某文件,true: 文件存在,false: 文件不存在
```

View File

@@ -0,0 +1,13 @@
{
"name": "uni-config-center",
"version": "0.0.3",
"description": "配置中心",
"main": "index.js",
"keywords": [],
"author": "DCloud",
"license": "Apache-2.0",
"origin-plugin-dev-name": "uni-config-center",
"origin-plugin-version": "0.0.3",
"plugin-dev-name": "uni-config-center",
"plugin-version": "0.0.3"
}

View File

@@ -0,0 +1,38 @@
## 1.0.19(2025-12-16)
- 增加配置参数缺失时的错误提示,指明配置文件路径
## 1.0.18(2024-07-08)
- checkToken时如果传入的token为空则返回uni-id-check-token-failed错误码以便uniIdRouter能正常跳转
## 1.0.17(2024-04-26)
- 兼容uni-app-x对客户端uniPlatform的调整(uni-app-x内uniPlatform区分app-android、app-ios)
## 1.0.16(2023-04-25)
- 新增maxTokenLength配置,用于限制数据库用户记录token数组的最大长度
## 1.0.15(2023-04-06)
- 修复部分语言国际化出错的Bug
## 1.0.14(2023-03-07)
- 修复 admin用户包含其他角色时未包含在token的Bug
## 1.0.13(2022-07-21)
- 修复 创建token时未传角色权限信息生成的token不正确的bug
## 1.0.12(2022-07-15)
- 提升与旧版本uni-id的兼容性(补充读取配置文件时回退平台app-plus、h5),但是仍推荐使用新平台名进行配置(app、web)
## 1.0.11(2022-07-14)
- 修复 部分情况下报`read property 'reduce' of undefined`的错误
## 1.0.10(2022-07-11)
- 将token存储在用户表的token字段内,与旧版本uni-id保持一致
## 1.0.9(2022-07-01)
- checkToken兼容token内未缓存角色权限的情况,此时将查库获取角色权限
## 1.0.8(2022-07-01)
- 修复clientDB默认依赖时部分情况下获取不到uni-id配置的Bug
## 1.0.7(2022-06-30)
- 修复config文件不合法时未抛出具体错误的Bug
## 1.0.6(2022-06-28)
- 移除插件内的数据表schema
## 1.0.5(2022-06-27)
- 修复使用多应用配置时报`Cannot read property 'appId' of undefined`的Bug
## 1.0.4(2022-06-27)
- 修复使用自定义token内容功能报错的Bug [详情](https://ask.dcloud.net.cn/question/147945)
## 1.0.2(2022-06-23)
- 对齐旧版本uni-id默认配置
## 1.0.1(2022-06-22)
- 补充对uni-config-center的依赖
## 1.0.0(2022-06-21)
- 提供uni-id token创建、校验、刷新接口,简化旧版uni-id公共模块

View File

@@ -0,0 +1,101 @@
{
"id": "uni-id-common",
"displayName": "uni-id-common",
"version": "1.0.19",
"description": "包含uni-id token生成、校验、刷新功能的云函数公共模块",
"keywords": [
"uni-id-common",
"uniCloud",
"token",
"权限"
],
"repository": "https://gitcode.net/dcloud/uni-id-common",
"engines": {
"uni-app": "^3.1.0",
"uni-app-x": "^3.1.0"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"type": "unicloud-template-function",
"darkmode": "-",
"i18n": "-",
"widescreen": "-"
},
"uni_modules": {
"dependencies": [
"uni-config-center"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "√"
},
"client": {
"uni-app": {
"vue": {
"vue2": "-",
"vue3": "-"
},
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"vue": "-",
"nvue": "-",
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}

View File

@@ -0,0 +1,3 @@
# uni-id-common
文档请参考:[uni-id-common](https://uniapp.dcloud.net.cn/uniCloud/uni-id-common.html)

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
{
"name": "uni-id-common",
"version": "1.0.19",
"description": "uni-id token生成、校验、刷新",
"main": "index.js",
"homepage": "https:\/\/uniapp.dcloud.io\/uniCloud\/uni-id-common.html",
"repository": {
"type": "git",
"url": "git+https:\/\/gitee.com\/dcloud\/uni-id-common.git"
},
"author": "DCloud",
"license": "Apache-2.0",
"dependencies": {
"uni-config-center": "file:..\/..\/..\/..\/..\/uni-config-center\/uniCloud\/cloudfunctions\/common\/uni-config-center"
},
"origin-plugin-dev-name": "uni-id-common",
"origin-plugin-version": "1.0.19",
"plugin-dev-name": "uni-id-common",
"plugin-version": "1.0.19"
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 https://uiadmin.net/uview-plus
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,90 @@
<p align="center">
<img alt="logo" src="https://uiadmin.net/uview-plus/common/logo.png" width="120" height="120" style="margin-bottom: 10px;">
</p>
<h3 align="center" style="margin: 30px 0 30px;font-weight: bold;font-size:40px;">uview-plus 3.0</h3>
<h3 align="center">多平台快速开发的UI框架</h3>
[![stars](https://img.shields.io/github/stars/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus)
[![forks](https://img.shields.io/github/forks/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus)
[![issues](https://img.shields.io/github/issues/ijry/uview-plus?style=flat-square&logo=GitHub)](https://github.com/ijry/uview-plus/issues)
[![release](https://img.shields.io/github/v/release/ijry/uview-plus?style=flat-square)](https://gitee.com/jry/uview-plus/releases)
[![license](https://img.shields.io/github/license/ijry/uview-plus?style=flat-square)](https://en.wikipedia.org/wiki/MIT_License)
## 说明
【文档有广告,不喜勿入谢谢】
uview-plus,是uni-app全面兼容vue3/nvue/鸿蒙/uni-app-x(已经发布https://ext.dcloud.net.cn/plugin?name=uview-ultra)的uni-app生态框架,全面的组件和便捷的工具会让您信手拈来,如鱼得水。uview-plus是基于uView2.x移植的支持vue3的版本,感谢uView。
## 版本
### uview-plus v3 (uni-app版本)
1. 保持选项式API,尽可能兼容原有uview2各种API,比如mixins,方便大家升级更容易;
2. 新手友好,保持原汁原味js,二次开发方便,未使用typescript重写,但带有独立的类型申明包;
### uview-ultra v4 (uni-app-x版本)
1. 尽可能保持原有组件的API一致,方便大家升级;
2. 使用uts + 组合式API架构级全新重构,但仍然保持源码的简易,不封装过于复杂,便于修改。
## 可视化设计
uview-plus现已推出免费可视化设计,可以方便的进行页面可视化设计,导出源码即可使用。极大提高前端页面开发效率;如产品经理设计师直接使用更可作为高保真高可用原型制作工具,让设计稿即代码,无需传统的设计稿开发还原步骤。
<img src="https://s3.bmp.ovh/imgs/2024/11/24/fd58d00071e6e5df.png" width="900" height="auto" >
<img src="https://s3.bmp.ovh/imgs/2024/11/24/8e85a519fe627fb1.png" width="900" height="auto" >
## 文档
[官方文档:https://uview-plus.jiangruyi.com](https://uview-plus.jiangruyi.com)
[备用文档:https://uiadmin.net/uview-plus](https://uiadmin.net/uview-plus)
## 预览
您可以通过**微信**扫码,查看最佳的演示效果。
<br>
<br>
| H5 | 微信小程序 | 安卓 | 鸿蒙Next(已上架) |
| --- | --- | --- | --- |
| <img src="https://uview-plus.jiangruyi.com/common/h5_qrcode.png" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/weixin_mini_qrcode.jpg" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/android_qrcode.png" width="160" height="160" /> | <img src="https://uview-plus.jiangruyi.com/common/hos_qrcode.jpg" width="160" height="160" /> |
<br>
<img src="https://uview-plus.jiangruyi.com/common/hos.png">
## 链接
- [官方文档](https://uview-plus.jiangruyi.com)
- [更新日志](https://uview-plus.jiangruyi.com/components/changelog.html)
- [升级指南](https://uview-plus.jiangruyi.com/components/changeGuide.html)
- [关于我们](https://uview-plus.jiangruyi.com/cooperation/about.html)
## 关于PR
> 我们非常乐意接受各位的优质PR,但在此之前我希望您了解uview-plus是一个需要兼容多个平台的(小程序、h5、ios app、android app)包括nvue页面、vue页面。
> 所以希望在您修复bug并提交之前尽可能的去这些平台测试一下兼容性。最好能携带测试截图以方便审核。非常感谢!
## 安装
#### **uni-app插件市场链接** —— [https://ext.dcloud.net.cn/plugin?name=uview-plus](https://ext.dcloud.net.cn/plugin?name=uview-plus)
请通过[官网安装文档](https://uview-plus.jiangruyi.com/components/install.html)了解更详细的内容
## 快速上手
请通过[快速上手](https://uview-plus.jiangruyi.com/components/quickstart.html)了解更详细的内容
## 使用方法
配置easycom规则后,自动按需引入,无需`import`组件,直接引用即可。
```html
<template>
<u-button text="按钮"></u-button>
</template>
```
## 版权信息
uview-plus遵循[MIT](https://en.wikipedia.org/wiki/MIT_License)开源协议,意味着您无需支付任何费用,也无需授权,即可将uview-plus应用到您的产品中。
Root 组件基于上游项目 `uni-ku/root` 二次开发,已按 MIT 要求保留其原始授权文件:`libs/root/LICENSE`。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,109 @@
<template>
<view class="u-action-sheet-data">
<view class="u-action-sheet-data__trigger">
<slot name="trigger"></slot>
<up-input
v-if="!$slots['trigger']"
:modelValue="current"
disabled
disabledColor="#ffffff"
:placeholder="title"
border="none"
></up-input>
<view @click="show = true"
class="u-action-sheet-data__trigger__cover"></view>
</view>
<up-action-sheet
:show="show"
:actions="options"
:title="title"
safeAreaInsetBottom
:description="description"
@close="show = false"
@select="select"
>
</up-action-sheet>
</view>
</template>
<script>
export default {
props: {
modelValue: {
type: [String, Number],
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
options: {
type: Array,
default: () => {
return []
}
},
valueKey: {
type: String,
default: 'value'
},
labelKey: {
type: String,
default: 'name'
}
},
data() {
return {
show: false,
current: '',
}
},
created() {
if (this.modelValue) {
this.options.forEach((ele) => {
if (ele[this.valueKey] == this.modelValue) {
this.current = ele[this.labelKey]
}
})
}
},
emits: ['update:modelValue'],
watch: {
modelValue() {
this.options.forEach((ele) => {
if (ele[this.valueKey] == this.modelValue) {
this.current = ele[this.labelKey]
}
})
}
},
methods: {
hideKeyboard() {
uni.hideKeyboard()
},
select(e) {
this.$emit('update:modelValue', e[this.valueKey])
this.current = e[this.labelKey]
},
}
}
</script>
<style lang="scss" scoped>
.u-action-sheet-data {
&__trigger {
position: relative;
&__cover {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
}
</style>

View File

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-16 10:52:35
* @FilePath : /uview-plus/libs/config/props/actionSheet.js
*/
export default {
// action-sheet组件
actionSheet: {
show: false,
title: '',
description: '',
actions: [],
nameKey: 'name',
subnameKey: 'subnameKey',
index: '',
cancelText: '',
closeOnClickAction: true,
safeAreaInsetBottom: true,
openType: '',
closeOnClickOverlay: true,
round: 0,
wrapMaxHeight: '600px'
}
}

View File

@@ -0,0 +1,80 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-16 10:52:35
* @FilePath : /uview-plus/libs/config/props/props.js
*/
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 操作菜单是否展示 (默认false)
show: {
type: Boolean,
default: () => defProps.actionSheet.show
},
// 标题
title: {
type: String,
default: () => defProps.actionSheet.title
},
// 选项上方的描述信息
description: {
type: String,
default: () => defProps.actionSheet.description
},
// 数据
actions: {
type: Array,
default: () => defProps.actionSheet.actions
},
// 选项名称key
nameKey: {
type: String,
default: () => defProps.actionSheet.nameKey
},
// 选项子名称key
subnameKey: {
type: String,
default: () => defProps.actionSheet.subnameKey
},
// 取消按钮的文字,不为空时显示按钮
cancelText: {
type: String,
default: () => defProps.actionSheet.cancelText
},
// 点击某个菜单项时是否关闭弹窗
closeOnClickAction: {
type: Boolean,
default: () => defProps.actionSheet.closeOnClickAction
},
// 处理底部安全区(默认true)
safeAreaInsetBottom: {
type: Boolean,
default: () => defProps.actionSheet.safeAreaInsetBottom
},
// 小程序的打开方式
openType: {
type: String,
default: () => defProps.actionSheet.openType
},
// 点击遮罩是否允许关闭 (默认true)
closeOnClickOverlay: {
type: Boolean,
default: () => defProps.actionSheet.closeOnClickOverlay
},
// 圆角值
round: {
type: [Boolean, String, Number],
default: () => defProps.actionSheet.round
},
// 选项区域最大高度
wrapMaxHeight: {
type: [String],
default: () => defProps.actionSheet.wrapMaxHeight
},
}
})

View File

@@ -0,0 +1,340 @@
<template>
<u-popup
:show="show"
mode="bottom"
@close="closeHandler"
:safeAreaInsetBottom="safeAreaInsetBottom"
:round="round"
>
<view class="u-action-sheet">
<!-- 顶部标题区域 -->
<view
class="u-action-sheet__header"
v-if="title"
>
<text
class="u-action-sheet__header__title u-line-1"
:style="titleDynamicStyle"
>{{title}}</text>
<view
class="u-action-sheet__header__icon-wrap"
@tap.stop="cancel"
>
<up-icon
name="close"
size="17"
:color="closeIconColor"
bold
></up-icon>
</view>
</view>
<!-- 描述信息 -->
<text
class="u-action-sheet__description"
:style="[{
marginTop: `${title && description ? 0 : '18px'}`
}, descriptionDynamicStyle]"
v-if="description"
>{{description}}</text>
<slot>
<!-- 分割线 -->
<u-line v-if="description" :color="dividerColor"></u-line>
<!-- 操作项列表 -->
<scroll-view scroll-y class="u-action-sheet__item-wrap" :style="{maxHeight: wrapMaxHeight}">
<view :key="index" v-for="(item, index) in actions">
<!-- #ifdef MP -->
<button
class="u-reset-button"
:openType="item.openType"
@getuserinfo="onGetUserInfo"
@contact="onContact"
@getphonenumber="onGetPhoneNumber"
@error="onError"
@launchapp="onLaunchApp"
@opensetting="onOpenSetting"
:lang="lang"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:app-parameter="appParameter"
@tap="selectHandler(index)"
:hover-class="!item.disabled && !item.loading ? 'u-action-sheet--hover' : ''"
>
<!-- #endif -->
<view
class="u-action-sheet__item-wrap__item"
@tap.stop="selectHandler(index)"
:hover-class="!item.disabled && !item.loading ? 'u-action-sheet--hover' : ''"
:hover-stay-time="150"
:style="getItemHoverStyle(index)"
>
<template v-if="!item.loading">
<text
class="u-action-sheet__item-wrap__item__name"
:style="[itemStyle(index)]"
>{{ item[nameKey] }}</text>
<text
v-if="item[subnameKey]"
class="u-action-sheet__item-wrap__item__subname"
:style="[subnameStyle(index)]"
>{{ item[subnameKey] }}</text>
</template>
<!-- 加载状态图标 -->
<u-loading-icon
v-else
custom-class="van-action-sheet__loading"
size="18"
mode="circle"
/>
</view>
<!-- #ifdef MP -->
</button>
<!-- #endif -->
<!-- 选项间分割线 -->
<u-line v-if="index !== actions.length - 1" :color="dividerColor"></u-line>
</view>
</scroll-view>
</slot>
<!-- 取消按钮前的分割区域 -->
<u-gap
:bgColor="cancelGapColor"
height="6"
v-if="cancelText"
></u-gap>
<!-- 取消按钮 -->
<view class="u-action-sheet__item-wrap__item u-action-sheet__cancel"
hover-class="u-action-sheet--hover" @tap="cancel" v-if="cancelText">
<text
@touchmove.stop.prevent
:hover-stay-time="150"
class="u-action-sheet__cancel-text"
:style="cancelTextDynamicStyle"
>{{cancelText}}</text>
</view>
</view>
</u-popup>
</template>
<script>
import { openType } from '../../libs/mixin/openType'
import { buttonMixin } from '../../libs/mixin/button'
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit } from '../../libs/function/index';
/**
* ActionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
* @tutorial https://uview-plus.jiangruyi.com/components/actionSheet.html
*
* @property {Boolean} show 操作菜单是否展示 (默认 false )
* @property {String} title 操作菜单标题
* @property {String} description 选项上方的描述信息
* @property {Array<Object>} actions 按钮的文字数组,见官方文档示例
* @property {String} cancelText 取消按钮的提示文字,不为空时显示按钮
* @property {Boolean} closeOnClickAction 点击某个菜单项时是否关闭弹窗 (默认 true )
* @property {Boolean} safeAreaInsetBottom 处理底部安全区 (默认 true )
* @property {String} openType 小程序的打开方式 (contact | launchApp | getUserInfo | openSetting |getPhoneNumber |error )
* @property {Boolean} closeOnClickOverlay 点击遮罩是否允许关闭 (默认 true )
* @property {Number|String} round 圆角值,默认无圆角 (默认 0 )
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文
* @property {String} sessionFrom 会话来源,openType="contact"时有效
* @property {String} sendMessageTitle 会话内消息卡片标题,openType="contact"时有效
* @property {String} sendMessagePath 会话内消息卡片点击跳转小程序路径,openType="contact"时有效
* @property {String} sendMessageImg 会话内消息卡片图片,openType="contact"时有效
* @property {Boolean} showMessageCard 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,用户点击后可以快速发送小程序消息,openType="contact"时有效 (默认 false )
* @property {String} appParameter 打开 APP 时,向 APP 传递的参数,openType=launchApp 时有效
*
* @event {Function} select 点击ActionSheet列表项时触发
* @event {Function} close 点击取消按钮时触发
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,回调的 detail 数据与 wx.getUserInfo 返回的一致,openType="getUserInfo"时有效
* @event {Function} contact 客服消息回调,openType="contact"时有效
* @event {Function} getphonenumber 获取用户手机号回调,openType="getPhoneNumber"时有效
* @event {Function} error 当使用开放能力时,发生错误的回调,openType="error"时有效
* @event {Function} launchapp 打开 APP 成功的回调,openType="launchApp"时有效
* @event {Function} opensetting 在打开授权设置页后回调,openType="openSetting"时有效
* @example <u-action-sheet :actions="list" :title="title" :show="show"></u-action-sheet>
*/
export default {
name: "u-action-sheet",
// 一些props参数和methods方法,通过mixin混入,因为其他文件也会用到
mixins: [openType, buttonMixin, mixin, props],
data() {
return {
}
},
computed: {
titleDynamicStyle() {
return {
color: this.upThemeVar('--up-main-color', '#303133')
}
},
descriptionDynamicStyle() {
return {
color: this.upThemeVar('--up-tips-color', '#909193')
}
},
closeIconColor() {
return this.upThemeVar('--up-content-color', '#606266')
},
dividerColor() {
return this.upThemeVar('--up-border-color', this.upThemeIsDark ? '#3a3a3c' : '#dadbde')
},
cancelGapColor() {
return this.upThemeVar('--up-gap-bg-color', this.upThemeIsDark ? '#111111' : '#eaeaec')
},
cancelTextDynamicStyle() {
return {
color: this.upThemeVar('--up-main-color', '#303133')
}
},
// 操作项目的样式
itemStyle() {
return (index) => {
const style = {
color: this.upThemeVar('--up-main-color', '#303133')
};
if (this.actions[index].color) style.color = this.actions[index].color
if (this.actions[index].fontSize) style.fontSize = addUnit(this.actions[index].fontSize)
// 选项被禁用的样式
if (this.actions[index].disabled) style.color = this.upThemeVar('--up-light-color', '#c0c4cc')
return style;
}
},
subnameStyle() {
return (index) => ({
color: this.actions[index].disabled
? this.upThemeVar('--up-light-color', '#c0c4cc')
: this.upThemeVar('--up-tips-color', '#909193')
})
}
},
emits: ["close", "select", "update:show"],
methods: {
// 关闭操作菜单事件处理
closeHandler() {
// 允许点击遮罩关闭时,才发出close事件
if(this.closeOnClickOverlay) {
this.$emit('update:show', false)
this.$emit('close')
}
},
// 点击取消按钮
cancel() {
this.$emit('update:show', false)
this.$emit('close')
},
// 选择操作项处理
selectHandler(index) {
const item = this.actions[index]
if (item && !item.disabled && !item.loading) {
this.$emit('select', item)
if (this.closeOnClickAction) {
this.$emit('update:show', false)
this.$emit('close')
}
}
},
// 动态处理Hover时候第一个item的圆角
getItemHoverStyle(index) {
if (index === 0 && this.round && !this.title && !this.description) {
return {
borderTopLeftRadius: `${this.round}px`,
borderTopRightRadius: `${this.round}px`,
}
}
return {}
},
}
}
</script>
<style lang="scss" scoped>
$u-action-sheet-reset-button-width:100% !default;
$u-action-sheet-title-font-size: 16px !default;
$u-action-sheet-title-padding: 12px 30px !default;
$u-action-sheet-title-color: var(--up-main-color, #303133) !default;
$u-action-sheet-header-icon-wrap-right:15px !default;
$u-action-sheet-header-icon-wrap-top:15px !default;
$u-action-sheet-description-font-size:13px !default;
$u-action-sheet-description-color: var(--up-tips-color, #909193) !default;
$u-action-sheet-description-margin: 18px 15px !default;
$u-action-sheet-item-wrap-item-padding:17px !default;
$u-action-sheet-item-wrap-name-font-size:16px !default;
$u-action-sheet-item-wrap-subname-font-size:13px !default;
$u-action-sheet-item-wrap-subname-color: var(--up-tips-color, #909193) !default;
$u-action-sheet-item-wrap-subname-margin-top:10px !default;
$u-action-sheet-cancel-text-font-size:16px !default;
$u-action-sheet-cancel-text-color: var(--up-main-color, #303133) !default;
$u-action-sheet-cancel-text-font-size:15px !default;
$u-action-sheet-cancel-text-hover-background-color: var(--up-hover-bg-color, rgb(242, 243, 245)) !default;
.u-reset-button {
width: $u-action-sheet-reset-button-width;
}
.u-action-sheet {
text-align: center;
&__header {
position: relative;
padding: $u-action-sheet-title-padding;
&__title {
font-size: $u-action-sheet-title-font-size;
color: $u-action-sheet-title-color;
font-weight: bold;
text-align: center;
}
&__icon-wrap {
position: absolute;
right: $u-action-sheet-header-icon-wrap-right;
top: $u-action-sheet-header-icon-wrap-top;
}
}
&__description {
font-size: $u-action-sheet-description-font-size;
color: $u-action-sheet-description-color;
margin: $u-action-sheet-description-margin;
text-align: center;
}
&__item-wrap {
&__item {
padding: $u-action-sheet-item-wrap-item-padding;
@include flex;
align-items: center;
justify-content: center;
flex-direction: column;
&__name {
font-size: $u-action-sheet-item-wrap-name-font-size;
color: var(--up-main-color, #303133);
text-align: center;
}
&__subname {
font-size: $u-action-sheet-item-wrap-subname-font-size;
color: $u-action-sheet-item-wrap-subname-color;
margin-top: $u-action-sheet-item-wrap-subname-margin-top;
text-align: center;
}
}
}
&__cancel-text {
font-size: $u-action-sheet-cancel-text-font-size;
color: $u-action-sheet-cancel-text-color;
text-align: center;
// padding: $u-action-sheet-cancel-text-font-size;
}
&--hover {
background-color: $u-action-sheet-cancel-text-hover-background-color;
}
}
</style>

View File

@@ -0,0 +1,77 @@
<style scoped lang="scss">
.agreement-content {
width: 100%;;
display: inline-block;
flex-direction: column;
color: var(--up-main-color, #303133);
.agreement-url {
display: inline-block;
color: var(--up-primary, #2979ff);
// #ifdef H5
cursor: pointer;
// #endif
}
}
</style>
<template>
<view class="up-agreement">
<up-modal v-model:show="show" showCancelButton @confirm="confirm" @cancel="close" confirmText="阅读并同意">
<view class="agreement-content">
<slot>
我们非常重视您的个人信息和隐私保护。为了更好地保障您的个人权益,在您使用我们的产品前,
请务必审慎阅读《<text class="agreement-url" @click="urlClick('urlProtocol')">用户协议</text>》
和《<text class="agreement-url" @click="urlClick('urlPrivacy')">隐私政策</text>》内的所有条款,
尤其是:1.我们对您的个人信息的收集/保存/使用/对外提供/保护等规则条款,以及您的用户权利等条款;2. 约定我们的限制责任、免责
条款;3.其他以颜色或加粗进行标识的重要条款。如您对以上协议有任何疑问,请先不要同意,您点击“同意并继续”的行为即表示您已阅读
完毕并同意以上协议的全部内容。
</slot>
</view>
</up-modal>
</view>
</template>
<script>
export default {
name: 'up-agreement',
props: {
urlProtocol: {
type: String,
default: '/pages/user_agreement/agreement/info?title=用户协议'
},
urlPrivacy: {
type: String,
default: '/pages/user_agreement/agreement/info?title=隐私政策'
},
},
emits: ['confirm'],
data() {
return {
show: false
}
},
methods: {
close() {
// #ifdef H5
window.opener = null;
window.close();
// #endif
// #ifdef APP-PLUS
plus.runtime.quit();
// #endif
},
confirm() {
this.show = false;
this.$emit('confirm', 1);
},
showModal() {
this.show = true;
},
urlClick(type) {
uni.navigateTo({
url: this[type]
});
}
}
}
</script>

View File

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-16 16:32:24
* @FilePath : /uview-plus/libs/config/props/album.js
*/
export default {
// album 组件
album: {
urls: [],
keyName: '',
singleSize: 180,
multipleSize: 70,
space: 6,
singleMode: 'scaleToFill',
multipleMode: 'aspectFill',
maxCount: 9,
previewFullImage: true,
rowCount: 3,
showMore: true,
autoWrap: false,
unit: 'px',
stop: true,
}
}

View File

@@ -0,0 +1,95 @@
/*
* @Author : jry
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-16 16:35:24
* @FilePath : /uview-plus/components/u-album/props.js
*/
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 图片地址,Array<String>|Array<Object>形式
urls: {
type: Array,
default: () => defProps.album.urls
},
// 指定从数组的对象元素中读取哪个属性作为图片地址
keyName: {
type: String,
default: () => defProps.album.keyName
},
// 单图时,图片长边的长度
singleSize: {
type: [String, Number],
default: () => defProps.album.singleSize
},
// 多图时,图片边长
multipleSize: {
type: [String, Number],
default: () => defProps.album.multipleSize
},
// 多图时,图片水平和垂直之间的间隔
space: {
type: [String, Number],
default: () => defProps.album.space
},
// 单图时,图片缩放裁剪的模式
singleMode: {
type: String,
default: () => defProps.album.singleMode
},
// 多图时,图片缩放裁剪的模式
multipleMode: {
type: String,
default: () => defProps.album.multipleMode
},
// 最多展示的图片数量,超出时最后一个位置将会显示剩余图片数量
maxCount: {
type: [String, Number],
default: () => defProps.album.maxCount
},
// 是否可以预览图片
previewFullImage: {
type: Boolean,
default: () => defProps.album.previewFullImage
},
// 每行展示图片数量,如设置,singleSize和multipleSize将会无效
rowCount: {
type: [String, Number],
default: () => defProps.album.rowCount
},
// 超出maxCount时是否显示查看更多的提示
showMore: {
type: Boolean,
default: () => defProps.album.showMore
},
// 图片形状,circle-圆形,square-方形
shape: {
type: String,
default: () => defProps.image.shape
},
// 圆角,单位任意
radius: {
type: [String, Number],
default: () => defProps.image.radius
},
// 自适应换行
autoWrap: {
type: Boolean,
default: () => defProps.album.autoWrap
},
// 单位
unit: {
type: [String],
default: () => defProps.album.unit
},
// 阻止点击冒泡
stop: {
type: Boolean,
default: () => defProps.album.stop
}
}
})

View File

@@ -0,0 +1,344 @@
<template>
<view class="u-album">
<!-- 相册行容器,每行显示 rowCount 个图片 -->
<view
class="u-album__row"
ref="u-album__row"
v-for="(arr, index) in showUrls"
:forComputedUse="albumWidth"
:key="index"
:style="{flexWrap: autoWrap ? 'wrap' : 'nowrap'}"
>
<!-- 图片包装容器 -->
<view
class="u-album__row__wrapper"
v-for="(item, index1) in arr"
:key="index1"
:style="[imageStyle(index + 1, index1 + 1)]"
@tap="onPreviewTap($event, getSrc(item))"
>
<!-- 图片显示 -->
<image
:src="getSrc(item)"
:mode="
urls.length === 1
? imageHeight > 0
? singleMode
: 'widthFix'
: multipleMode
"
:style="[
{
width: imageWidth,
height: imageHeight,
borderRadius: shape == 'circle' ? '10000px' : addUnit(radius)
}
]"
></image>
<!-- 超出最大显示数量时的更多提示 -->
<view
v-if="
showMore &&
urls.length > rowCount * showUrls.length &&
index === showUrls.length - 1 &&
index1 === showUrls[showUrls.length - 1].length - 1
"
class="u-album__row__wrapper__text"
:style="{
borderRadius: shape == 'circle' ? '50%' : addUnit(radius),
}"
>
<up-text
:text="`+${urls.length - maxCount}`"
color="#fff"
:size="multipleSize * 0.3"
align="center"
customStyle="justify-content: center"
></up-text>
</view>
</view>
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, sleep } from '../../libs/function/index';
import test from '../../libs/function/test';
// #ifdef APP-NVUE
// 不支持百分比单位,这里需要通过dom查询组件的宽度
const dom = uni.requireNativePlugin('dom')
// #endif
/**
* Album 相册
* @description 本组件提供一个类似相册的功能,让开发者开发起来更加得心应手。减少重复的模板代码
* @tutorial https://uview-plus.jiangruyi.com/components/album.html
*
* @property {Array} urls 图片地址列表 Array<String>|Array<Object>形式
* @property {String} keyName 指定从数组的对象元素中读取哪个属性作为图片地址
* @property {String | Number} singleSize 单图时,图片长边的长度 (默认 180 )
* @property {String | Number} multipleSize 多图时,图片边长 (默认 70 )
* @property {String | Number} space 多图时,图片水平和垂直之间的间隔 (默认 6 )
* @property {String} singleMode 单图时,图片缩放裁剪的模式 (默认 'scaleToFill' )
* @property {String} multipleMode 多图时,图片缩放裁剪的模式 (默认 'aspectFill' )
* @property {String | Number} maxCount 取消按钮的提示文字 (默认 9 )
* @property {Boolean} previewFullImage 是否可以预览图片 (默认 true )
* @property {String | Number} rowCount 每行展示图片数量,如设置,singleSize和multipleSize将会无效 (默认 3 )
* @property {Boolean} showMore 超出maxCount时是否显示查看更多的提示 (默认 true )
* @property {String} shape 图片形状,circle-圆形,square-方形 (默认 'square' )
* @property {String | Number} radius 圆角值,单位任意,如果为数值,则为px单位 (默认 0 )
* @property {Boolean} autoWrap 自适应换行模式,不受rowCount限制,图片会自动换行 (默认 false )
* @property {String} unit 图片单位 (默认 px )
* @event {Function} albumWidth 某些特殊的情况下,需要让文字与相册的宽度相等,这里事件的形式对外发送 (回调参数 width )
* @example <u-album :urls="urls2" @albumWidth="width => albumWidth = width" multipleSize="68" ></u-album>
*/
export default {
name: 'u-album',
mixins: [mpMixin, mixin, props],
data() {
return {
// 单图的宽度
singleWidth: 0,
// 单图的高度
singleHeight: 0,
// 单图时,如果无法获取图片的尺寸信息,让图片宽度默认为容器的一定百分比
singlePercent: 0.6
}
},
watch: {
urls: {
immediate: true,
handler(newVal) {
// 当只有一张图片时,获取图片尺寸信息
if (newVal.length === 1) {
this.getImageRect()
}
}
}
},
computed: {
/**
* 计算图片样式
* @param {Number} index1 - 行索引
* @param {Number} index2 - 列索引
* @returns {Object} 图片样式对象
*/
imageStyle() {
return (index1, index2) => {
const { space, rowCount, multipleSize, urls } = this,
rowLen = this.showUrls.length,
allLen = this.urls.length
const style = {
marginRight: addUnit(space),
marginBottom: addUnit(space)
}
// 如果为最后一行,则每个图片都无需下边框
if (index1 === rowLen && !this.autoWrap) style.marginBottom = 0
// 每行的最右边一张和总长度的最后一张无需右边框
if (!this.autoWrap) {
if (
index2 === rowCount ||
(index1 === rowLen &&
index2 === this.showUrls[index1 - 1].length)
)
style.marginRight = 0
}
return style
}
},
/**
* 将图片地址数组划分为二维数组,用于按行显示
* @returns {Array} 二维数组,每个子数组代表一行图片
*/
showUrls() {
if (this.autoWrap) {
// 自动换行模式下,所有图片放在一行中显示
return [ this.urls.slice(0, this.maxCount) ];
} else {
// 固定行数模式下,按 rowCount 分割图片
const arr = []
this.urls.map((item, index) => {
// 限制最大展示数量
if (index + 1 <= this.maxCount) {
// 计算该元素为第几个素组内
const itemIndex = Math.floor(index / this.rowCount)
// 判断对应的索引是否存在
if (!arr[itemIndex]) {
arr[itemIndex] = []
}
arr[itemIndex].push(item)
}
})
return arr
}
},
/**
* 计算图片宽度
* @returns {String} 图片宽度样式值
*/
imageWidth() {
return addUnit(
this.urls.length === 1 ? this.singleWidth : this.multipleSize, this.unit
)
},
/**
* 计算图片高度
* @returns {String} 图片高度样式值
*/
imageHeight() {
return addUnit(
this.urls.length === 1 ? this.singleHeight : this.multipleSize, this.unit
)
},
/**
* 计算相册总宽度,用于外部组件对齐
* 此变量无实际用途,仅仅是为了利用computed特性,让其在urls长度等变化时,重新计算图片的宽度
* @returns {Number} 相册宽度
*/
albumWidth() {
let width = 0
if (this.urls.length === 1) {
width = this.singleWidth
} else {
width =
this.showUrls[0].length * this.multipleSize +
this.space * (this.showUrls[0].length - 1)
}
this.$emit('albumWidth', width)
return width
}
},
emits: ['preview', 'albumWidth'],
methods: {
addUnit,
/**
* 点击图片预览
* @param {Event} e - 点击事件对象
* @param {String} url - 当前点击图片的地址
*/
onPreviewTap(e, url) {
// 获取所有图片地址
const urls = this.urls.map((item) => {
return this.getSrc(item)
})
if (this.previewFullImage) {
// 使用系统默认预览图片功能
uni.previewImage({
current: url,
urls
})
// 是否阻止事件传播
this.stop && this.preventEvent(e)
} else {
// 发送自定义预览事件
this.$emit('preview', {
urls,
currentIndex: urls.indexOf(url)
})
}
},
/**
* 获取图片地址
* @param {String|Object} item - 图片项,可以是字符串或对象
* @returns {String} 图片地址
*/
getSrc(item) {
return test.object(item)
? (this.keyName && item[this.keyName]) || item.src
: item
},
/**
* 单图时,获取图片的尺寸
* 在小程序中,需要将网络图片的的域名添加到小程序的download域名才可能获取尺寸
* 在没有添加的情况下,让单图宽度默认为盒子的一定宽度(singlePercent)
*/
getImageRect() {
const src = this.getSrc(this.urls[0])
uni.getImageInfo({
src,
success: (res) => {
let singleSize = this.singleSize;
// 单位
let unit = '';
if (Number.isNaN(Number(this.singleSize))) {
// 大小中有字符 则记录字符
unit = this.singleSize.replace(/\d+/g, ''); // 单位
singleSize = Number(this.singleSize.replace(/\D+/g, ''), 10); // 具体值
}
// 判断图片横向还是竖向展示方式
const isHorizotal = res.width >= res.height
this.singleWidth = isHorizotal
? singleSize
: (res.width / res.height) * singleSize
this.singleHeight = !isHorizotal
? singleSize
: (res.height / res.width) * this.singleWidth
// 如果有单位统一设置单位
if(unit != null && unit !== ''){
this.singleWidth = this.singleWidth + unit
this.singleHeight = this.singleHeight + unit
}
},
fail: () => {
// 获取图片信息失败时,通过组件宽度计算
this.getComponentWidth()
}
})
},
/**
* 获取组件的宽度,用于计算单图显示尺寸
*/
async getComponentWidth() {
// 延时一定时间,以获取dom尺寸
await sleep(30)
// #ifndef APP-NVUE
// H5、小程序等平台通过 $uGetRect 获取组件宽度
this.$uGetRect('.u-album__row').then((size) => {
this.singleWidth = size.width * this.singlePercent
})
// #endif
// #ifdef APP-NVUE
// NVUE 平台通过 dom 插件获取组件宽度
// 这里ref="u-album__row"所在的标签为通过for循环出来,导致this.$refs['u-album__row']是一个数组
const ref = this.$refs['u-album__row'][0]
ref &&
dom.getComponentRect(ref, (res) => {
this.singleWidth = res.size.width * this.singlePercent
})
// #endif
}
}
}
</script>
<style lang="scss" scoped>
.u-album {
@include flex(column);
&__row {
@include flex(row);
&__wrapper {
position: relative;
&__text {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
@include flex(row);
justify-content: center;
align-items: center;
}
}
}
}
</style>

View File

@@ -0,0 +1,26 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-08-17 17:23:53
* @FilePath : /uview-plus/libs/config/props/alert.js
*/
export default {
// alert警告组件
alert: {
title: '',
type: 'warning',
description: '',
closable: false,
showIcon: false,
effect: 'light',
center: false,
fontSize: 14,
transitionMode: 'fade',
duration: 0,
icon: '',
value: true
}
}

View File

@@ -0,0 +1,75 @@
/*
* @Author : jry
* @Description :
* @version : 3.0
* @LastAuthor : jry
* @lastTime : 2025-08-17 17:23:53
* @FilePath : /uview-plus/libs/config/props/props.js
*/
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 显示文字
title: {
type: String,
default: () => defProps.alert.title
},
// 主题,success/warning/info/error
type: {
type: String,
default: () => defProps.alert.type
},
// 辅助性文字
description: {
type: String,
default: () => defProps.alert.description
},
// 是否可关闭
closable: {
type: Boolean,
default: () => defProps.alert.closable
},
// 是否显示图标
showIcon: {
type: Boolean,
default: () => defProps.alert.showIcon
},
// 浅或深色调,light-浅色,dark-深色
effect: {
type: String,
default: () => defProps.alert.effect
},
// 文字是否居中
center: {
type: Boolean,
default: () => defProps.alert.center
},
// 字体大小
fontSize: {
type: [String, Number],
default: () => defProps.alert.fontSize
},
// 动画类型
transitionMode: {
type: [String],
default: () => defProps.alert.transitionMode
},
// 自动定时关闭毫秒
duration: {
type: [Number],
default: () => defProps.alert.duration
},
// 自定义图标
icon: {
type: [String],
default: () => defProps.alert.icon
},
// 是否显示
modelValue: {
type: [Boolean],
default: () => defProps.alert.value
}
}
})

View File

@@ -0,0 +1,291 @@
<template>
<up-transition
:mode="transitionMode"
:show="show"
>
<view
class="u-alert"
:class="[`u-alert--${type}--${effect}`]"
@tap.stop="clickHandler"
:style="[addStyle(customStyle)]"
>
<!-- 左侧图标 -->
<view
class="u-alert__icon"
v-if="showIcon"
>
<up-icon
:name="iconName"
size="18"
:color="iconColor"
></up-icon>
</view>
<!-- 内容区域 -->
<view
class="u-alert__content"
:style="[{
paddingRight: closable ? '20px' : 0
}]"
>
<!-- 标题 -->
<text
class="u-alert__content__title"
v-if="title"
:style="[{
fontSize: addUnit(fontSize),
textAlign: center ? 'center' : 'left'
}]"
:class="[effect === 'dark' ? 'u-alert__text--dark' : `u-alert__text--${type}--light`]"
>{{ title }}</text>
<!-- 描述信息 -->
<text
class="u-alert__content__desc"
v-if="description"
:style="[{
fontSize: addUnit(fontSize),
textAlign: center ? 'center' : 'left'
}]"
:class="[effect === 'dark' ? 'u-alert__text--dark' : `u-alert__text--${type}--light`]"
>{{ description }}</text>
</view>
<!-- 关闭按钮 -->
<view
class="u-alert__close"
v-if="closable"
@tap.stop="closeHandler"
>
<slot name="close">
<up-icon
name="close"
:color="iconColor"
size="15"
></up-icon>
</slot>
</view>
</view>
</up-transition>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, addStyle } from '../../libs/function/index';
/**
* Alert 警告提示
* @description 警告提示,展现需要关注的信息。
* @tutorial https://uview-plus.jiangruyi.com/components/alertTips.html
*
* @property {String} title 显示的文字
* @property {String} type 使用预设的颜色 (默认 'warning' )
* @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
* @property {Boolean} closable 关闭按钮(默认为叉号icon图标) (默认 false )
* @property {Boolean} showIcon 是否显示左边的辅助图标 ( 默认 false )
* @property {String} effect 多图时,图片缩放裁剪的模式 (默认 'light' )
* @property {Boolean} center 文字是否居中 (默认 false )
* @property {String | Number} fontSize 字体大小 (默认 14 )
* @property {Object} customStyle 定义需要用到的外部样式
* @property {String} transitionMode 过渡动画模式 (默认 'fade' )
* @property {String | Number} duration 自动关闭延时(毫秒),设置为0或负数则不自动关闭 (默认 0 )
* @property {String} icon 自定义图标名称,优先级高于type默认图标
* @property {Boolean} modelValue/v-model 绑定值,控制是否显示 (默认 true )
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
* @event {Function} closed 关闭动画结束时触发
* @example <up-alert :title="title" type = "warning" :closable="closable" :description = "description"></up-alert>
*/
export default {
name: 'u-alert',
mixins: [mpMixin, mixin, props],
data() {
return {
// 控制组件显示隐藏
show: true
}
},
computed: {
// 根据不同的主题类型返回对应的图标颜色
iconColor() {
return this.effect === 'light' ? this.type : '#fff'
},
// 不同主题对应不同的图标
iconName() {
// 如果用户自定义了图标,则优先使用自定义图标
if (this.icon) return this.icon;
switch (this.type) {
case 'success':
return 'checkmark-circle-fill';
break;
case 'error':
return 'close-circle-fill';
break;
case 'warning':
return 'error-circle-fill';
break;
case 'info':
return 'info-circle-fill';
break;
case 'primary':
return 'more-circle-fill';
break;
default:
return 'error-circle-fill';
}
}
},
emits: ["click","close", "closed", "update:modelValue"],
watch: {
modelValue: {
handler(newVal) {
this.show = newVal;
},
immediate: true
},
show: {
handler(newVal) {
this.$emit('update:modelValue', newVal);
// 如果是从显示到隐藏,且启用了自动关闭功能
if (!newVal && this.duration > 0) {
this.$emit('closed');
}
}
}
},
mounted() {
// 如果设置了自动关闭时间,则在指定时间后自动关闭
if (this.duration > 0) {
setTimeout(() => {
this.closeHandler();
}, this.duration);
}
},
methods: {
addUnit,
addStyle,
// 点击内容区域触发click事件
clickHandler() {
this.$emit('click')
},
// 点击关闭按钮触发close事件并隐藏组件
closeHandler() {
this.show = false
this.$emit('close');
}
}
}
</script>
<style lang="scss" scoped>
.u-alert {
position: relative;
background-color: $u-primary;
padding: 8px 10px;
@include flex(row);
align-items: center;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
&--primary--dark {
background-color: $u-primary;
}
&--primary--light {
background-color: var(--up-primary-light, #ecf5ff);
}
&--error--dark {
background-color: $u-error;
}
&--error--light {
background-color: var(--up-error-light, #FEF0F0);
}
&--success--dark {
background-color: $u-success;
}
&--success--light {
background-color: var(--up-success-light, #f5fff0);
}
&--warning--dark {
background-color: $u-warning;
}
&--warning--light {
background-color: var(--up-warning-light, #FDF6EC);
}
&--info--dark {
background-color: $u-info;
}
&--info--light {
background-color: var(--up-info-light, #f4f4f5);
}
&__icon {
margin-right: 5px;
}
&__content {
@include flex(column);
flex: 1;
&__title {
color: var(--up-main-color, $u-main-color);
font-size: 14px;
font-weight: bold;
margin-bottom: 2px;
}
&__desc {
color: var(--up-main-color, $u-main-color);
font-size: 14px;
flex-wrap: wrap;
}
}
&__title--dark,
&__desc--dark {
color: var(--up-white, #FFFFFF);
}
&__text--primary--light,
&__text--primary--light {
color: $u-primary;
}
&__text--success--light,
&__text--success--light {
color: $u-success;
}
&__text--warning--light,
&__text--warning--light {
color: $u-warning;
}
&__text--error--light,
&__text--error--light {
color: $u-error;
}
&__text--info--light,
&__text--info--light {
color: $u-info;
}
&__close {
position: absolute;
top: 11px;
right: 10px;
}
}
</style>

View File

@@ -0,0 +1,23 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/avatarGroup.js
*/
export default {
// avatarGroup 组件
avatarGroup: {
urls: [],
maxCount: 5,
shape: 'circle',
mode: 'scaleToFill',
showMore: true,
size: 40,
keyName: '',
gap: 0.5,
extraValue: 0
}
}

View File

@@ -0,0 +1,54 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 头像图片组
urls: {
type: Array,
default: () => defProps.avatarGroup.urls
},
// 最多展示的头像数量
maxCount: {
type: [String, Number],
default: () => defProps.avatarGroup.maxCount
},
// 头像形状
shape: {
type: String,
default: () => defProps.avatarGroup.shape
},
// 图片裁剪模式
mode: {
type: String,
default: () => defProps.avatarGroup.mode
},
// 超出maxCount时是否显示查看更多的提示
showMore: {
type: Boolean,
default: () => defProps.avatarGroup.showMore
},
// 头像大小
size: {
type: [String, Number],
default: () => defProps.avatarGroup.size
},
// 指定从数组的对象元素中读取哪个属性作为图片地址
keyName: {
type: String,
default: () => defProps.avatarGroup.keyName
},
// 头像之间的遮挡比例
gap: {
type: [String, Number],
validator(value) {
return value >= 0 && value <= 1
},
default: () => defProps.avatarGroup.gap
},
// 需额外显示的值
extraValue: {
type: [Number, String],
default: () => defProps.avatarGroup.extraValue
}
}
})

View File

@@ -0,0 +1,109 @@
<template>
<view class="u-avatar-group">
<view
class="u-avatar-group__item"
v-for="(item, index) in showUrl"
:key="index"
:style="{
marginLeft: index === 0 ? 0 : addUnit(-size * gap)
}"
>
<u-avatar
:size="size"
:shape="shape"
:mode="mode"
:src="testObject(item) ? keyName && item[keyName] || item.url : item"
></u-avatar>
<view
class="u-avatar-group__item__show-more"
v-if="showMore && index === showUrl.length - 1 && (urls.length > maxCount || extraValue > 0)"
@tap="clickHandler"
>
<up-text
color="#ffffff"
:size="size * 0.4"
:text="`+${extraValue || urls.length - showUrl.length}`"
align="center"
customStyle="justify-content: center"
></up-text>
</view>
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* AvatarGroup 头像组
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uview-plus.jiangruyi.com/components/avatar.html
*
* @property {Array} urls 头像图片组 (默认 [] )
* @property {String | Number} maxCount 最多展示的头像数量 ( 默认 5 )
* @property {String} shape 头像形状( 'circle' (默认) | 'square' )
* @property {String} mode 图片裁剪模式(默认 'scaleToFill' )
* @property {Boolean} showMore 超出maxCount时是否显示查看更多的提示 (默认 true )
* @property {String | Number} size 头像大小 (默认 40 )
* @property {String} keyName 指定从数组的对象元素中读取哪个属性作为图片地址
* @property {String | Number} gap 头像之间的遮挡比例(0.4代表遮挡40%) (默认 0.5 )
* @property {String | Number} extraValue 需额外显示的值
* @event {Function} showMore 头像组更多点击
* @example <u-avatar-group:urls="urls" size="35" gap="0.4" ></u-avatar-group:urls=>
*/
export default {
name: 'u-avatar-group',
mixins: [mpMixin, mixin, props],
data() {
return {
}
},
computed: {
showUrl() {
return this.urls.slice(0, this.maxCount)
}
},
emits: ["showMore"],
methods: {
addUnit,
testObject: test.object,
clickHandler() {
this.$emit('showMore')
}
},
}
</script>
<style lang="scss" scoped>
.u-avatar-group {
@include flex;
&__item {
margin-left: -10px;
position: relative;
&--no-indent {
// 如果你想质疑作者不会使用:first-child,说明你太年轻,因为nvue不支持
margin-left: 0;
}
&__show-more {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
@include flex;
align-items: center;
justify-content: center;
border-radius: 100px;
}
}
}
</style>

View File

@@ -0,0 +1,28 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/avatar.js
*/
export default {
// avatar 组件
avatar: {
src: '',
shape: 'circle',
size: 40,
mode: 'scaleToFill',
text: '',
bgColor: '#c0c4cc',
color: '#ffffff',
fontSize: 18,
icon: '',
mpAvatar: false,
randomBgColor: false,
defaultUrl: '',
colorIndex: '',
name: ''
}
}

View File

@@ -0,0 +1,81 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
import test from '../../libs/function/test';
export const props = defineMixin({
props: {
// 头像图片路径(不能为相对路径)
src: {
type: String,
default: () => defProps.avatar.src
},
// 头像形状,circle-圆形,square-方形
shape: {
type: String,
default: () => defProps.avatar.shape
},
// 头像尺寸
size: {
type: [String, Number],
default: () => defProps.avatar.size
},
// 裁剪模式
mode: {
type: String,
default: () => defProps.avatar.mode
},
// 显示的文字
text: {
type: String,
default: () => defProps.avatar.text
},
// 背景色
bgColor: {
type: String,
default: () => defProps.avatar.bgColor
},
// 文字颜色
color: {
type: String,
default: () => defProps.avatar.color
},
// 文字大小
fontSize: {
type: [String, Number],
default: () => defProps.avatar.fontSize
},
// 显示的图标
icon: {
type: String,
default: () => defProps.avatar.icon
},
// 显示小程序头像,只对百度,微信,QQ小程序有效
mpAvatar: {
type: Boolean,
default: () => defProps.avatar.mpAvatar
},
// 是否使用随机背景色
randomBgColor: {
type: Boolean,
default: () => defProps.avatar.randomBgColor
},
// 加载失败的默认头像(组件有内置默认图片)
defaultUrl: {
type: String,
default: () => defProps.avatar.defaultUrl
},
// 如果配置了randomBgColor为true,且配置了此值,则从默认的背景色数组中取出对应索引的颜色值,取值0-19之间
colorIndex: {
type: [String, Number],
// 校验参数规则,索引在0-19之间
validator(n) {
return test.range(n, [0, 19]) || n === ''
},
default: () => defProps.avatar.colorIndex
},
// 组件标识符
name: {
type: String,
default: () => defProps.avatar.name
}
}
})

View File

@@ -0,0 +1,179 @@
<template>
<view
class="u-avatar"
:class="[`u-avatar--${shape}`]"
:style="[{
backgroundColor: (text || icon) ? (randomBgColor ? colors[colorIndex !== '' ? colorIndex : random(0, 19)] : bgColor) : 'transparent',
width: addUnit(size),
height: addUnit(size),
}, addStyle(customStyle)]"
@tap="clickHandler"
>
<slot>
<!-- #ifdef MP-WEIXIN || MP-QQ || MP-BAIDU -->
<open-data
v-if="mpAvatar && allowMp"
type="userAvatarUrl"
:style="[{
width: addUnit(size),
height: addUnit(size)
}]"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN && MP-QQ && MP-BAIDU -->
<template v-if="mpAvatar && allowMp"></template>
<!-- #endif -->
<up-icon
v-else-if="icon"
:name="icon"
:size="fontSize"
:color="color"
></up-icon>
<up-text
v-else-if="text"
:text="text"
:size="fontSize"
:color="color"
align="center"
customStyle="justify-content: center"
></up-text>
<image
class="u-avatar__image"
v-else
:class="[`u-avatar__image--${shape}`]"
:src="avatarUrl || defaultUrl"
:mode="mode"
@error="errorHandler"
:style="[{
width: addUnit(size),
height: addUnit(size)
}]"
></image>
</slot>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit, random } from '../../libs/function/index';
const base64Avatar =
"data:image/jpg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjREMEQwRkY0RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjREMEQwRkY1RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NEQwRDBGRjJGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NEQwRDBGRjNGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwMDAwMDBAMDg8QDw4MExMUFBMTHBsbGxwfHx8fHx8fHx8fAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCADIAMgDAREAAhEBAxEB/8QAcQABAQEAAwEBAAAAAAAAAAAAAAUEAQMGAgcBAQAAAAAAAAAAAAAAAAAAAAAQAAIBAwICBgkDBQAAAAAAAAABAhEDBCEFMVFBYXGREiKBscHRMkJSEyOh4XLxYjNDFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A/fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHbHFyZ/Dam+yLA+Z2L0Pjtyj2poD4AAAAAAAAAAAAAAAAAAAAAAAAKWFs9y6lcvvwQeqj8z9wFaziY1n/HbUX9XF97A7QAGXI23EvJ1goyfzR0YEfN269jeZ+a03pNe0DIAAAAAAAAAAAAAAAAAAAACvtO3RcVkXlWutuL9YFYAAAAAOJRjKLjJVi9GmB5/csH/mu1h/in8PU+QGMAAAAAAAAAAAAAAAAAAaMDG/6MmMH8C80+xAelSSVFolwQAAAAAAAHVlWI37ErUulaPk+hgeYnCUJuElSUXRrrQHAAAAAAAAAAAAAAAAABa2Oz4bM7r4zdF2ICmAAAAAAAAAg7zZ8GX41wuJP0rRgYAAAAAAAAAAAAAAAAAD0m2R8ODaXU33tsDSAAAAAAAAAlb9HyWZcnJd9PcBHAAAAAAAAAAAAAAAAAPS7e64Vn+KA0AAAAAAAAAJm+v8Ftf3ewCKAAAAAAAAAAAAAAAAAX9muqeGo9NttP06+0DcAAAAAAAAAjb7dTu2ra+VOT9P8AQCWAAAAAAAAAAAAAAAAAUNmyPt5Ltv4bui/kuAF0AAAAAAADiUlGLlJ0SVW+oDzOXfd/Ind6JPRdS0QHSAAAAAAAAAAAAAAAAAE2nVaNcGB6Lbs6OTao9LsF51z60BrAAAAAABJ3jOVHjW3r/sa9QEgAAAAAAAAAAAAAAAAAAAPu1duWriuW34ZR4MC9hbnZyEoy8l36XwfYBsAAADaSq9EuLAlZ+7xSdrGdW9Hc5dgEdtt1erfFgAAAAAAAAAAAAAAAAADVjbblX6NR8MH80tEBRs7HYivyzlN8lovaBPzduvY0m6eK10TXtAyAarO55lpJK54orolr+4GqO/Xaea1FvqbXvA+Z77kNeW3GPbV+4DJfzcm/pcm3H6Vou5AdAFLC2ed2Pjv1txa8sV8T6wOL+yZEKu1JXFy4MDBOE4ScZxcZLinoB8gAAAAAAAAAAAB242LeyJ+C3GvN9C7QLmJtePYpKS+5c+p8F2IDYAANJqj1T4oCfk7Nj3G5Wn9qXJax7gJ93Z82D8sVNc4v30A6Xg5i42Z+iLfqARwcyT0sz9MWvWBps7LlTf5Grce9/oBTxdtxseklHxT+uWr9AGoAB138ezfj4bsFJdD6V2MCPm7RdtJzs1uW1xXzL3gTgAAAAAAAAADRhYc8q74I6RWs5ckB6GxYtWLat21SK731sDsAAAAAAAAAAAAAAAASt021NO/YjrxuQXT1oCOAAAAAAABzGLlJRSq26JAelwsWONYjbXxcZvmwO8AAAAAAAAAAAAAAAAAAef3TEWPkVivx3NY9T6UBiAAAAAABo2+VmGXblddIJ8eivRUD0oAAAAAAAAAAAAAAAAAAAYt4tKeFKVNYNSXfRgefAAAAAAAAr7VuSSWPedKaW5v1MCsAAAAAAAAAAAAAAAAAAIe6bj96Ts2n+JPzSXzP3ATgAAAAAAAAFbbt1UUrOQ9FpC4/UwK6aaqtU+DAAAAAAAAAAAAAAA4lKMIuUmoxWrb4ARNx3R3q2rLpa4Sl0y/YCcAAAAAAAAAAANmFud7G8r89r6X0dgFvGzLGRGtuWvTF6NAdwAAAAAAAAAAAy5W442PVN+K59EePp5ARMvOv5MvO6QXCC4AZwAAAAAAAAAAAAAcxlKLUotprg1owN+PvORborq+7Hnwl3gUbO74VzRydt8pKn68ANcJwmqwkpLmnUDkAAAAfNy9atqtyagut0AxXt5xIV8Fbj6lRd7Am5G65V6qUvtwfyx94GMAAAAAAAAAAAAAAAAAAAOU2nVOj5gdsc3LiqRvTpyqwOxbnnrhdfpSfrQB7pnv/AGvuS9gHXPMy5/Fem1yq0v0A6W29XqwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//Z";
/**
* Avatar 头像
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uview-plus.jiangruyi.com/components/avatar.html
*
* @property {String} src 头像路径,如加载失败,将会显示默认头像(不能为相对路径)
* @property {String} shape 头像形状 ( circle (默认) | square)
* @property {String | Number} size 头像尺寸,可以为指定字符串(large, default, mini),或者数值 (默认 40 )
* @property {String} mode 头像图片的裁剪类型,与uni的image组件的mode参数一致,如效果达不到需求,可尝试传widthFix值 (默认 'scaleToFill' )
* @property {String} text 用文字替代图片,级别优先于src
* @property {String} bgColor 背景颜色,一般显示文字时用 (默认 '#c0c4cc' )
* @property {String} color 文字颜色 (默认 '#ffffff' )
* @property {String | Number} fontSize 文字大小 (默认 18 )
* @property {String} icon 显示的图标
* @property {Boolean} mpAvatar 显示小程序头像,只对百度,微信,QQ小程序有效 (默认 false )
* @property {Boolean} randomBgColor 是否使用随机背景色 (默认 false )
* @property {String} defaultUrl 加载失败的默认头像(组件有内置默认图片)
* @property {String | Number} colorIndex 如果配置了randomBgColor为true,且配置了此值,则从默认的背景色数组中取出对应索引的颜色值,取值0-19之间
* @property {String} name 组件标识符 (默认 'level' )
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击组件时触发 index: 用户传递的标识符
* @example <u-avatar :src="src" mode="square"></u-avatar>
*/
export default {
name: 'u-avatar',
mixins: [mpMixin, mixin, props],
data() {
return {
// 如果配置randomBgColor参数为true,在图标或者文字的模式下,会随机从中取出一个颜色值当做背景色
colors: ['#ffb34b', '#f2bba9', '#f7a196', '#f18080', '#88a867', '#bfbf39', '#89c152', '#94d554', '#f19ec2',
'#afaae4', '#e1b0df', '#c38cc1', '#72dcdc', '#9acdcb', '#77b1cc', '#448aca', '#86cefa', '#98d1ee',
'#73d1f1',
'#80a7dc'
],
avatarUrl: this.src,
allowMp: false
}
},
watch: {
// 监听头像src的变化,赋值给内部的avatarUrl变量,因为图片加载失败时,需要修改图片的src为默认值
// 而组件内部不能直接修改props的值,所以需要一个中间变量
src: {
immediate: true,
handler(newVal) {
this.avatarUrl = newVal
// 如果没有传src,则主动触发error事件,用于显示默认的头像,否则src为''空字符等的时候,会无内容展示
if(!newVal) {
this.errorHandler()
}
}
}
},
computed: {
imageStyle() {
const style = {}
return style
}
},
created() {
this.init()
},
emits: ["click"],
methods: {
addStyle,
addUnit,
random,
init() {
// 目前只有这几个小程序平台具有open-data标签
// 其他平台可以通过uni.getUserInfo类似接口获取信息,但是需要弹窗授权(首次),不合符组件逻辑
// 故目前自动获取小程序头像只支持这几个平台
// #ifdef MP-WEIXIN || MP-QQ || MP-BAIDU
this.allowMp = true
// #endif
},
// 判断传入的name属性,是否图片路径,只要带有"/"均认为是图片形式
isImg() {
return this.src.indexOf('/') !== -1
},
// 图片加载时失败时触发
errorHandler() {
this.avatarUrl = this.defaultUrl || base64Avatar
},
clickHandler(e) {
this.$emit('click', this.name, e)
}
}
}
</script>
<style lang="scss" scoped>
.u-avatar {
@include flex;
align-items: center;
justify-content: center;
&--circle {
border-radius: 100px;
}
&--square {
border-radius: 4px;
}
&__image {
&--circle {
border-radius: 100px;
overflow: hidden;
}
&--square {
border-radius: 4px;
}
}
}
</style>

View File

@@ -0,0 +1,27 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/backtop.js
*/
export default {
// backtop组件
backtop: {
mode: 'circle',
icon: 'arrow-upward',
text: '',
duration: 100,
scrollTop: 0,
top: 400,
bottom: 100,
right: 20,
zIndex: 9,
iconStyle: {
color: '#909399',
fontSize: '19px'
}
}
}

View File

@@ -0,0 +1,56 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 返回顶部的形状,circle-圆形,square-方形
mode: {
type: String,
default: () => defProps.backtop.mode
},
// 自定义图标
icon: {
type: String,
default: () => defProps.backtop.icon
},
// 提示文字
text: {
type: String,
default: () => defProps.backtop.text
},
// 返回顶部滚动时间
duration: {
type: [String, Number],
default: () => defProps.backtop.duration
},
// 滚动距离
scrollTop: {
type: [String, Number],
default: () => defProps.backtop.scrollTop
},
// 距离顶部多少距离显示,单位px
top: {
type: [String, Number],
default: () => defProps.backtop.top
},
// 返回顶部按钮到底部的距离,单位px
bottom: {
type: [String, Number],
default: () => defProps.backtop.bottom
},
// 返回顶部按钮到右边的距离,单位px
right: {
type: [String, Number],
default: () => defProps.backtop.right
},
// 层级
zIndex: {
type: [String, Number],
default: () => defProps.backtop.zIndex
},
// 图标的样式,对象形式
iconStyle: {
type: Object,
default: () => defProps.backtop.iconStyle
}
}
})

View File

@@ -0,0 +1,132 @@
<template>
<u-transition
mode="fade"
:customStyle="backTopStyle"
:show="show"
>
<view
class="u-back-top"
:style="[contentStyle]"
v-if="!$slots.default && !$slots.$default"
@click="backToTop"
>
<up-icon
:name="icon"
:custom-style="iconStyle"
></up-icon>
<text
v-if="text"
class="u-back-top__text"
>{{text}}</text>
</view>
<slot v-else />
</u-transition>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, addStyle, getPx, deepMerge, error } from '../../libs/function/index';
// #ifdef APP-NVUE
const dom = weex.requireModule('dom')
// #endif
/**
* backTop 返回顶部
* @description 本组件一个用于长页面,滑动一定距离后,出现返回顶部按钮,方便快速返回顶部的场景。
* @tutorial https://uview-plus.jiangruyi.com/components/backTop.html
*
* @property {String} mode 返回顶部的形状,circle-圆形,square-方形 (默认 'circle' )
* @property {String} icon 自定义图标 (默认 'arrow-upward' ) 见官方文档示例
* @property {String} text 提示文字
* @property {String | Number} duration 返回顶部滚动时间 (默认 100)
* @property {String | Number} scrollTop 滚动距离 (默认 0 )
* @property {String | Number} top 距离顶部多少距离显示,单位px (默认 400 )
* @property {String | Number} bottom 返回顶部按钮到底部的距离,单位px (默认 100 )
* @property {String | Number} right 返回顶部按钮到右边的距离,单位px (默认 20 )
* @property {String | Number} zIndex 层级 (默认 9 )
* @property {Object<Object>} iconStyle 图标的样式,对象形式 (默认 {color: '#909399',fontSize: '19px'})
* @property {Object} customStyle 定义需要用到的外部样式
*
* @example <u-back-top :scrollTop="scrollTop"></u-back-top>
*/
export default {
name: 'u-back-top',
mixins: [mpMixin, mixin, props],
computed: {
backTopStyle() {
// 动画组件样式
const style = {
bottom: addUnit(this.bottom),
right: addUnit(this.right),
width: '40px',
height: '40px',
position: 'fixed',
zIndex: 10,
}
return style
},
show() {
return getPx(this.scrollTop) > getPx(this.top)
},
contentStyle() {
const style = {}
let radius = 0
// 是否圆形
if(this.mode === 'circle') {
radius = '100px'
} else {
radius = '4px'
}
// 为了兼容安卓nvue,只能这么分开写
style.borderTopLeftRadius = radius
style.borderTopRightRadius = radius
style.borderBottomLeftRadius = radius
style.borderBottomRightRadius = radius
return deepMerge(style, addStyle(this.customStyle))
}
},
emits: ["click"],
methods: {
backToTop() {
// #ifdef APP-NVUE
if (!this.$parent.$refs['u-back-top']) {
error(`nvue页面需要给页面最外层元素设置"ref='u-back-top'`)
}
dom.scrollToElement(this.$parent.$refs['u-back-top'], {
offset: 0
})
// #endif
// #ifndef APP-NVUE
uni.pageScrollTo({
scrollTop: 0,
duration: this.duration
});
// #endif
this.$emit('click')
}
}
}
</script>
<style lang="scss" scoped>
$u-back-top-flex:1 !default;
$u-back-top-height:100% !default;
$u-back-top-background-color:#E1E1E1 !default;
$u-back-top-tips-font-size:12px !default;
.u-back-top {
@include flex;
flex-direction: column;
align-items: center;
flex:$u-back-top-flex;
height: $u-back-top-height;
justify-content: center;
background-color: $u-back-top-background-color;
&__tips {
font-size:$u-back-top-tips-font-size;
transform: scale(0.8);
}
}
</style>

View File

@@ -0,0 +1,27 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/badge.js
*/
export default {
// 徽标数组件
badge: {
isDot: false,
value: '',
show: true,
max: 999,
type: 'error',
showZero: false,
bgColor: null,
color: null,
shape: 'circle',
numberType: 'overflow',
offset: [],
inverted: false,
absolute: false
}
}

View File

@@ -0,0 +1,79 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 是否显示圆点
isDot: {
type: Boolean,
default: () => defProps.badge.isDot
},
// 显示的内容
value: {
type: [Number, String],
default: () => defProps.badge.value
},
// 显示的内容
modelValue: {
type: [Number, String],
default: () => defProps.badge.modelValue
},
// 是否显示
show: {
type: Boolean,
default: () => defProps.badge.show
},
// 最大值,超过最大值会显示 '{max}+'
max: {
type: [Number, String],
default: () => defProps.badge.max
},
// 主题类型,error|warning|success|primary
type: {
type: String,
default: () => defProps.badge.type
},
// 当数值为 0 时,是否展示 Badge
showZero: {
type: Boolean,
default: () => defProps.badge.showZero
},
// 背景颜色,优先级比type高,如设置,type参数会失效
bgColor: {
type: [String, null],
default: () => defProps.badge.bgColor
},
// 字体颜色
color: {
type: [String, null],
default: () => defProps.badge.color
},
// 徽标形状,circle-四角均为圆角,horn-左下角为直角
shape: {
type: String,
default: () => defProps.badge.shape
},
// 设置数字的显示方式,overflow|ellipsis|limit
// overflow会根据max字段判断,超出显示`${max}+`
// ellipsis会根据max判断,超出显示`${max}...`
// limit会依据1000作为判断条件,超出1000,显示`${value/1000}K`,比如2.2k、3.34w,最多保留2位小数
numberType: {
type: String,
default: () => defProps.badge.numberType
},
// 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,absolute为true时有效
offset: {
type: Array,
default: () => defProps.badge.offset
},
// 是否反转背景和字体颜色
inverted: {
type: Boolean,
default: () => defProps.badge.inverted
},
// 是否绝对定位
absolute: {
type: Boolean,
default: () => defProps.badge.absolute
}
}
})

View File

@@ -0,0 +1,176 @@
<template>
<text
v-if="show && ((Number(value) === 0 ? showZero : true) || isDot)"
:class="[isDot ? 'u-badge--dot' : 'u-badge--not-dot', inverted && 'u-badge--inverted', shape === 'horn' && 'u-badge--horn', `u-badge--${type}${inverted ? '--inverted' : ''}`]"
:style="[addStyle(customStyle), badgeStyle]"
class="u-badge"
>{{ isDot ? '' :showValue }}</text>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit } from '../../libs/function/index';
/**
* badge 徽标数
* @description 该组件一般用于图标右上角显示未读的消息数量,提示用户点击,有圆点和圆包含文字两种形式。
* @tutorial https://uview-plus.jiangruyi.com/components/badge.html
*
* @property {Boolean} isDot 是否显示圆点 (默认 false )
* @property {String | Number} value 显示的内容
* @property {Boolean} show 是否显示 (默认 true )
* @property {String | Number} max 最大值,超过最大值会显示 '{max}+' (默认999)
* @property {String} type 主题类型,error|warning|success|primary (默认 'error' )
* @property {Boolean} showZero 当数值为 0 时,是否展示 Badge (默认 false )
* @property {String} bgColor 背景颜色,优先级比type高,如设置,type参数会失效
* @property {String} color 字体颜色 (默认 '#ffffff' )
* @property {String} shape 徽标形状,circle-四角均为圆角,horn-左下角为直角 (默认 'circle' )
* @property {String} numberType 设置数字的显示方式,overflow|ellipsis|limit (默认 'overflow' )
* @property {Array}} offset 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,absolute为true时有效
* @property {Boolean} inverted 是否反转背景和字体颜色(默认 false )
* @property {Boolean} absolute 是否绝对定位(默认 false )
* @property {Object} customStyle 定义需要用到的外部样式
* @example <u-badge :type="type" :count="count"></u-badge>
*/
export default {
name: 'u-badge',
mixins: [mpMixin, props, mixin],
computed: {
// 是否将badge中心与父组件右上角重合
boxStyle() {
let style = {};
return style;
},
// 整个组件的样式
badgeStyle() {
const style = {}
if(this.color) {
style.color = this.color
}
if (this.bgColor && !this.inverted) {
style.backgroundColor = this.bgColor
}
if (this.absolute) {
style.position = 'absolute'
// 如果有设置offset参数
if(this.offset.length) {
// top和right分为为offset的第一个和第二个值,如果没有第二个值,则right等于top
const top = this.offset[0]
const right = this.offset[1] || top
style.top = addUnit(top)
style.right = addUnit(right)
}
}
return style
},
showValue() {
switch (this.numberType) {
case "overflow":
return Number(this.value) > Number(this.max) ? this.max + "+" : this.value
break;
case "ellipsis":
return Number(this.value) > Number(this.max) ? "..." : this.value
break;
case "limit":
return Number(this.value) > 999 ? Number(this.value) >= 9999 ?
Math.floor(this.value / 1e4 * 100) / 100 + "w" : Math.floor(this.value /
1e3 * 100) / 100 + "k" : this.value
break;
default:
return Number(this.value)
}
},
},
methods: {
addStyle
}
}
</script>
<style lang="scss" scoped>
$u-badge-primary: $u-primary !default;
$u-badge-error: $u-error !default;
$u-badge-success: $u-success !default;
$u-badge-info: $u-info !default;
$u-badge-warning: $u-warning !default;
$u-badge-dot-radius: 100px !default;
$u-badge-dot-size: 8px !default;
$u-badge-dot-right: 4px !default;
$u-badge-dot-top: 0 !default;
$u-badge-text-font-size: 11px !default;
$u-badge-text-right: 10px !default;
$u-badge-text-padding: 2px 5px !default;
$u-badge-text-align: center !default;
$u-badge-text-color: #FFFFFF !default;
.u-badge {
border-top-right-radius: $u-badge-dot-radius;
border-top-left-radius: $u-badge-dot-radius;
border-bottom-left-radius: $u-badge-dot-radius;
border-bottom-right-radius: $u-badge-dot-radius;
@include flex;
line-height: $u-badge-text-font-size;
text-align: $u-badge-text-align;
font-size: $u-badge-text-font-size;
color: $u-badge-text-color;
&--dot {
height: $u-badge-dot-size;
width: $u-badge-dot-size;
}
&--inverted {
font-size: 13px;
}
&--not-dot {
padding: $u-badge-text-padding;
}
&--horn {
border-bottom-left-radius: 0;
}
&--primary {
background-color: $u-badge-primary;
}
&--primary--inverted {
color: $u-badge-primary;
}
&--error {
background-color: $u-badge-error;
}
&--error--inverted {
color: $u-badge-error;
}
&--success {
background-color: $u-badge-success;
}
&--success--inverted {
color: $u-badge-success;
}
&--info {
background-color: $u-badge-info;
}
&--info--inverted {
color: $u-badge-info;
}
&--warning {
background-color: $u-badge-warning;
}
&--warning--inverted {
color: $u-badge-warning;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
/*
* @Author : ly
* @Description :
* @version : 4.0
* @Date : 2026-02-02 23:54:58
* @LastAuthor : ly
* @lastTime : 2026-02-02 23:54:58
* @FilePath : /uview-ultra/components/up-box/box.js
*/
export default {
// box 组件
box: {
bgColors: ['#EEFCFF', '#FCF8FF', '#FDF8F2'],
height: '160px',
borderRadius: '6px',
gap: '15px',
leftIcon: '',
leftTitle: '左',
rightTopIcon: '',
rightTopTitle: '右上',
rightBottomIcon: '',
rightBottomTitle: '右下'
}
}

View File

@@ -0,0 +1,57 @@
import { defineMixin } from '../../libs/vue.js'
import defProps from './box.js'
export const propsBox = defineMixin({
props: {
// 背景色
bgColors: {
type: [Array],
default: () => defProps.box.bgColors
},
// 高度
height: {
type: [String],
default: () => defProps.box.height
},
// 圆角
borderRadius: {
type: [String],
default: () => defProps.box.borderRadius
},
// 间隔
gap: {
type: [String],
default: () => defProps.box.gap
},
// 左侧图标
leftIcon: {
type: [String],
default: () => defProps.box.leftIcon
},
// 左侧文案
leftTitle: {
type: [String],
default: () => defProps.box.leftTitle
},
// 右上图标
rightTopIcon: {
type: [String],
default: () => defProps.box.rightTopIcon
},
// 右上文案
rightTopTitle: {
type: [String],
default: () => defProps.box.rightTopTitle
},
// 右下图标
rightBottomIcon: {
type: [String],
default: () => defProps.box.rightBottomIcon
},
// 右下文案
rightBottomTitle: {
type: [String],
default: () => defProps.box.rightBottomTitle
},
}
})

View File

@@ -0,0 +1,106 @@
<template>
<view class="u-box" :style="[{height: height}, addStyle(customStyle)]">
<view class="u-box__left" :style="{borderRadius: borderRadius, backgroundColor: bgColors[0]}">
<slot name="left">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="leftIcon"></u-icon>
<text class="ml-2 text-16px">{{leftTitle}}</text>
</view>
</slot>
</view>
<view class="u-box__gap" :style="{width: gap, height: height}"></view>
<view class="u-box__right">
<view class="u-box__right-top" :style="{borderRadius: borderRadius, backgroundColor: bgColors[1]}">
<slot name="rightTop">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="rightTopIcon"></u-icon>
<text class="ml-2 text-15px">{{rightTopTitle}}</text>
</view>
</slot>
</view>
<view class="u-box__right-gap" :style="{height: gap}"></view>
<view class="u-box__right-bottom" :style="{borderRadius: borderRadius, backgroundColor: bgColors[2]}">
<slot name="rightBottom">
<view class="flex flex-row items-center justify-center">
<u-icon size="36" :name="rightBottomIcon"></u-icon>
<text class="ml-2 text-15px">{{rightBottomTitle}}</text>
</view>
</slot>
</view>
</view>
</view>
</template>
<script>
import { propsBox } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* box 盒子
* @description box盒子一般为左边一个盒子,右侧两个等高的半盒组成,常用于App首页座位重点突出。
* @tutorial https://uview-plus.jiangruyi.com/components/box.html
* @property {Array} bgColors 背景色
* @property {String} height 高度
* @property {String} borderRadius 圆角
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击cell列表时触发
* @example <up-box colors=['blue', 'red', 'yellow'] height="200px"></up-box>
*/
export default {
name: 'up-box',
data() {
return {
}
},
mixins: [mpMixin, mixin, propsBox],
computed: {
},
emits: [],
methods: {
addStyle,
}
}
</script>
<style lang="scss" scoped>
.u-box {
/* #ifndef APP-NVUE */
/* #endif */
@include flex();
flex: 1;
&__left {
@include flex();
justify-content: center;
align-items: center;
flex: 1;
}
&__gap {
@include flex();
flex-direction: column;
}
&__right {
@include flex();
flex-direction: column;
flex: 1;
}
&__right-top {
@include flex();
flex: 1;
justify-content: center;
align-items: center;
}
&__right-bottom {
@include flex();
flex: 1;
justify-content: center;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,43 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/button.js
*/
export default {
// button组件
button: {
hairline: false,
type: 'info',
size: 'normal',
shape: 'square',
plain: false,
disabled: false,
loading: false,
loadingText: '',
loadingMode: 'spinner',
loadingSize: 15,
openType: '',
formType: '',
appParameter: '',
hoverStopPropagation: true,
lang: 'en',
sessionFrom: '',
sendMessageTitle: '',
sendMessagePath: '',
sendMessageImg: '',
showMessageCard: false,
dataName: '',
throttleTime: 0,
hoverStartTime: 0,
hoverStayTime: 200,
text: '',
icon: '',
iconColor: '',
color: '',
stop: true,
}
}

View File

@@ -0,0 +1,46 @@
$u-button-active-opacity:0.75 !default;
$u-button-loading-text-margin-left:4px !default;
$u-button-text-color: #FFFFFF !default;
$u-button-text-plain-error-color:$u-error !default;
$u-button-text-plain-warning-color:$u-warning !default;
$u-button-text-plain-success-color:$u-success !default;
$u-button-text-plain-info-color:$u-info !default;
$u-button-text-plain-primary-color:$u-primary !default;
.u-button {
&--active {
opacity: $u-button-active-opacity;
}
&--active--plain {
background-color: rgb(217, 217, 217);
}
&__loading-text {
margin-left:$u-button-loading-text-margin-left;
}
&__text,
&__loading-text {
color:$u-button-text-color;
}
&__text--plain--error {
color:$u-button-text-plain-error-color;
}
&__text--plain--warning {
color:$u-button-text-plain-warning-color;
}
&__text--plain--success{
color:$u-button-text-plain-success-color;
}
&__text--plain--info {
color:$u-button-text-plain-info-color;
}
&__text--plain--primary {
color:$u-button-text-plain-primary-color;
}
}

View File

@@ -0,0 +1,159 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 是否细边框
hairline: {
type: Boolean,
default: () => defProps.button.hairline
},
// 按钮的预置样式,info,primary,error,warning,success
type: {
type: String,
default: () => defProps.button.type
},
// 按钮尺寸,large,normal,small,mini
size: {
type: String,
default: () => defProps.button.size
},
// 按钮形状,circle(两边为半圆),square(带圆角)
shape: {
type: String,
default: () => defProps.button.shape
},
// 按钮是否镂空
plain: {
type: Boolean,
default: () => defProps.button.plain
},
// 是否禁止状态
disabled: {
type: Boolean,
default: () => defProps.button.disabled
},
// 是否加载中
loading: {
type: Boolean,
default: () => defProps.button.loading
},
// 加载中提示文字
loadingText: {
type: [String, Number],
default: () => defProps.button.loadingText
},
// 加载状态图标类型
loadingMode: {
type: String,
default: () => defProps.button.loadingMode
},
// 加载图标大小
loadingSize: {
type: [String, Number],
default: () => defProps.button.loadingSize
},
// 开放能力,具体请看uniapp稳定关于button组件部分说明
// https://uniapp.dcloud.io/component/button
openType: {
type: String,
default: () => defProps.button.openType
},
// 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
// 取值为submit(提交表单),reset(重置表单)
formType: {
type: String,
default: () => defProps.button.formType
},
// 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效
// 只微信小程序、QQ小程序有效
appParameter: {
type: String,
default: () => defProps.button.appParameter
},
// 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效
hoverStopPropagation: {
type: Boolean,
default: () => defProps.button.hoverStopPropagation
},
// 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效
lang: {
type: String,
default: () => defProps.button.lang
},
// 会话来源,open-type="contact"时有效。只微信小程序有效
sessionFrom: {
type: String,
default: () => defProps.button.sessionFrom
},
// 会话内消息卡片标题,open-type="contact"时有效
// 默认当前标题,只微信小程序有效
sendMessageTitle: {
type: String,
default: () => defProps.button.sendMessageTitle
},
// 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效
// 默认当前分享路径,只微信小程序有效
sendMessagePath: {
type: String,
default: () => defProps.button.sendMessagePath
},
// 会话内消息卡片图片,open-type="contact"时有效
// 默认当前页面截图,只微信小程序有效
sendMessageImg: {
type: String,
default: () => defProps.button.sendMessageImg
},
// 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,
// 用户点击后可以快速发送小程序消息,open-type="contact"时有效
showMessageCard: {
type: Boolean,
default: () => defProps.button.showMessageCard
},
// 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
dataName: {
type: String,
default: () => defProps.button.dataName
},
// 节流,一定时间内只能触发一次
throttleTime: {
type: [String, Number],
default: () => defProps.button.throttleTime
},
// 按住后多久出现点击态,单位毫秒
hoverStartTime: {
type: [String, Number],
default: () => defProps.button.hoverStartTime
},
// 手指松开后点击态保留时间,单位毫秒
hoverStayTime: {
type: [String, Number],
default: () => defProps.button.hoverStayTime
},
// 按钮文字,之所以通过props传入,是因为slot传入的话
// nvue中无法控制文字的样式
text: {
type: [String, Number],
default: () => defProps.button.text
},
// 按钮图标
icon: {
type: String,
default: () => defProps.button.icon
},
// 按钮图标
iconColor: {
type: String,
default: () => defProps.button.icon
},
// 按钮颜色,支持传入linear-gradient渐变色
color: {
type: String,
default: () => defProps.button.color
},
// 停止冒泡
stop: {
type: Boolean,
default: () => defProps.button.stop
},
}
})

View File

@@ -0,0 +1,577 @@
<template>
<!-- #ifndef APP-NVUE -->
<button
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
:form-type="formType"
:open-type="openType"
:app-parameter="appParameter"
:hover-stop-propagation="hoverStopPropagation"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:lang="lang"
:data-name="dataName"
:session-from="sessionFrom"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
@getphonenumber="getphonenumber"
@getuserinfo="getuserinfo"
@error="error"
@opensetting="opensetting"
@launchapp="launchapp"
@agreeprivacyauthorization="agreeprivacyauthorization"
:hover-class="!disabled && !loading ? 'u-button--active' : ''"
class="u-button u-reset-button"
:style="[baseColor, addStyle(customStyle)]"
@tap="clickHandler"
:class="bemClass"
>
<template v-if="loading">
<u-loading-icon
:mode="loadingMode"
:size="loadingSize * 1.15"
:color="loadingColor"
></u-loading-icon>
<text
class="u-button__loading-text"
:style="[{ fontSize: textSize + 'px' }]"
>{{ loadingText || text }}</text
>
</template>
<template v-else>
<up-icon
v-if="icon"
:name="icon"
:color="iconColorCom"
:size="textSize * 1.35"
:customStyle="{ marginRight: '2px' }"
></up-icon>
<slot>
<text
class="u-button__text"
:style="[{ fontSize: textSize + 'px' }]"
>{{ text }}</text
>
</slot>
</template>
</button>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<view
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
class="u-button"
:hover-class="
!disabled && !loading && !color && (plain || type === 'info')
? 'u-button--active--plain'
: !disabled && !loading && !plain
? 'u-button--active'
: ''
"
@tap="clickHandler"
:class="bemClass"
:style="[baseColor, addStyle(customStyle)]"
>
<template v-if="loading">
<u-loading-icon
:mode="loadingMode"
:size="loadingSize * 1.15"
:color="loadingColor"
></u-loading-icon>
<text
class="u-button__loading-text"
:style="[nvueTextStyle]"
:class="[plain && `u-button__text--plain--${type}`]"
>{{ loadingText || text }}</text
>
</template>
<template v-else>
<up-icon
v-if="icon"
:name="icon"
:color="iconColorCom"
:size="textSize * 1.35"
></up-icon>
<text
class="u-button__text"
:style="[
{
marginLeft: icon ? '2px' : 0,
},
nvueTextStyle,
]"
:class="[plain && `u-button__text--plain--${type}`]"
>{{ text }}</text
>
</template>
</view>
<!-- #endif -->
</template>
<script lang="ts">
import { buttonMixin } from "../../libs/mixin/button";
import { openType } from "../../libs/mixin/openType";
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { props } from "./props";
import { addStyle } from '../../libs/function/index';
import { throttle } from '../../libs/function/throttle';
import { getThemeVar } from '../../libs/theme/runtime.js';
/**
* button 按钮
* @description Button 按钮
* @tutorial https://uview-plus.jiangruyi.com/components/button.html
*
* @property {Boolean} hairline 是否显示按钮的细边框 (默认 true )
* @property {String} type 按钮的预置样式,info,primary,error,warning,success (默认 'info' )
* @property {String} size 按钮尺寸,large,normal,mini (默认 normal)
* @property {String} shape 按钮形状,circle(两边为半圆),square(带圆角) (默认 'square' )
* @property {Boolean} plain 按钮是否镂空,背景色透明 (默认 false)
* @property {Boolean} disabled 是否禁用 (默认 false)
* @property {Boolean} loading 按钮名称前是否带 loading 图标(App-nvue 平台,在 ios 上为雪花,Android上为圆圈) (默认 false)
* @property {String | Number} loadingText 加载中提示文字
* @property {String} loadingMode 加载状态图标类型 (默认 'spinner' )
* @property {String | Number} loadingSize 加载图标大小 (默认 15 )
* @property {String} openType 开放能力,具体请看uniapp稳定关于button组件部分说明
* @property {String} formType 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
* @property {String} appParameter 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效 (注:只微信小程序、QQ小程序有效)
* @property {Boolean} hoverStopPropagation 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效(默认 true )
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文(默认 en )
* @property {String} sessionFrom 会话来源,openType="contact"时有效
* @property {String} sendMessageTitle 会话内消息卡片标题,openType="contact"时有效
* @property {String} sendMessagePath 会话内消息卡片点击跳转小程序路径,openType="contact"时有效
* @property {String} sendMessageImg 会话内消息卡片图片,openType="contact"时有效
* @property {Boolean} showMessageCard 是否显示会话内消息卡片,设置此参数为 true,用户进入客服会话会在右下角显示"可能要发送的小程序"提示,用户点击后可以快速发送小程序消息,openType="contact"时有效(默认false)
* @property {String} dataName 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
* @property {String | Number} throttleTime 节流,一定时间内只能触发一次 (默认 0 )
* @property {String | Number} hoverStartTime 按住后多久出现点击态,单位毫秒 (默认 0 )
* @property {String | Number} hoverStayTime 手指松开后点击态保留时间,单位毫秒 (默认 200 )
* @property {String | Number} text 按钮文字,之所以通过props传入,是因为slot传入的话(注:nvue中无法控制文字的样式)
* @property {String} icon 按钮图标
* @property {String} iconColor 按钮图标颜色
* @property {String} color 按钮颜色,支持传入linear-gradient渐变色
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 非禁止并且非加载中,才能点击
* @event {Function} getphonenumber open-type="getPhoneNumber"时有效
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
* @event {Function} error 当使用开放能力时,发生错误的回调
* @event {Function} opensetting 在打开授权设置页并关闭后回调
* @event {Function} launchapp 打开 APP 成功的回调
* @event {Function} agreeprivacyauthorization 用户同意隐私协议事件回调
* @example <u-button>月落</u-button>
*/
export default {
name: "u-button",
// #ifdef MP
mixins: [mpMixin, mixin, buttonMixin, openType, props],
// #endif
// #ifndef MP
mixins: [mpMixin, mixin, props],
// #endif
data() {
return {};
},
computed: {
// 生成bem风格的类名
bemClass() {
// this.bem为一个computed变量,在mixin中
if (!this.color) {
return this.bem(
"button",
["type", "shape", "size"],
["disabled", "plain", "hairline"]
);
} else {
// 由于nvue的原因,在有color参数时,不需要传入type,否则会生成type相关的类型,影响最终的样式
return this.bem(
"button",
["shape", "size"],
["disabled", "plain", "hairline"]
);
}
},
isDarkTheme() {
return this.$u.theme && this.$u.theme.mode === 'dark';
},
nvueMainColor() {
return this.resolveNvueColor(this.$u.color.mainColor, this.isDarkTheme ? '#f5f5f5' : '#303133');
},
nvueBorderColor() {
return this.resolveNvueColor(this.$u.color.borderColor, this.isDarkTheme ? '#3a3a3c' : '#dadbde');
},
themeTypeColor() {
const fallbackMap = {
primary: '#3c9cff',
success: '#5ac725',
warning: '#f9ae3d',
error: '#f56c6c',
info: '#909399',
};
const type = fallbackMap[this.type] ? this.type : 'info';
return this.resolveNvueColor(
this.upThemeVar(`--up-${type}`, fallbackMap[type]),
fallbackMap[type]
);
},
nvueInfoBackgroundColor() {
return this.upThemeVar(
'--up-button-info-background-color',
this.upThemeVar(
'--up-card-bg-color',
this.isDarkTheme ? '#1c1c1e' : '#ffffff'
)
);
},
nvuePlainBackgroundColor() {
return this.upThemeVar(
'--up-button-plain-background-color',
this.upThemeVar(
'--up-card-bg-color',
this.isDarkTheme ? '#1c1c1e' : '#ffffff'
)
);
},
loadingColor() {
if (this.plain) {
// 如果有设置color值,则用color值,否则使用type主题颜色
return this.color
? this.color
: this.themeTypeColor;
}
if (this.type === "info") {
return this.isDarkTheme ? "#9ca3af" : "#c9c9c9";
}
return "rgb(200, 200, 200)";
},
iconColorCom() {
// 如果是镂空状态,设置了color就用color值,否则使用主题颜色,
// up-icon的color能接受一个主题颜色的值
if (this.iconColor) return this.iconColor;
if (this.plain) {
return this.color ? this.color : this.type;
} else {
return this.type === "info" ? this.nvueMainColor : "#ffffff";
}
},
baseColor() {
let style = {};
if (this.color) {
// 针对自定义了color颜色的情况,镂空状态下,就是用自定义的颜色
style.color = this.plain ? this.color : "white";
if (!this.plain) {
// 非镂空,背景色使用自定义的颜色
style["background-color"] = this.color;
}
if (this.color.indexOf("gradient") !== -1) {
// 如果自定义的颜色为渐变色,不显示边框,以及通过backgroundImage设置渐变色
// weex文档说明可以写borderWidth的形式,为什么这里需要分开写?
// 因为weex是阿里巴巴为了部门业绩考核而做的你懂的东西,所以需要这么写才有效
style.borderTopWidth = 0;
style.borderRightWidth = 0;
style.borderBottomWidth = 0;
style.borderLeftWidth = 0;
if (!this.plain) {
style.backgroundImage = this.color;
}
} else {
// 非渐变色,则设置边框相关的属性
style.borderColor = this.color;
style.borderWidth = "1px";
style.borderStyle = "solid";
}
return style;
}
// #ifdef APP-NVUE
const typeColor = this.themeTypeColor;
if (this.plain) {
style.color = this.type === 'info' ? this.nvueMainColor : typeColor;
style.backgroundColor = this.nvuePlainBackgroundColor;
style.borderColor = this.type === 'info' ? this.nvueBorderColor : typeColor;
style.borderWidth = this.hairline ? '0.5px' : '1px';
style.borderStyle = 'solid';
} else {
style.borderWidth = this.hairline ? '0.5px' : '1px';
style.borderStyle = 'solid';
if (this.type === 'info') {
style.color = this.nvueMainColor;
style.backgroundColor = this.nvueInfoBackgroundColor;
style.borderColor = this.nvueBorderColor;
} else {
style.color = '#ffffff';
style.backgroundColor = typeColor;
style.borderColor = typeColor;
}
}
// #endif
return style;
},
// nvue版本按钮的字体不会继承父组件的颜色,需要对每一个text组件进行单独的设置
nvueTextStyle() {
let style = {};
// 针对自定义了color颜色的情况,镂空状态下,就是用自定义的颜色
if (this.type === "info") {
style.color = this.nvueMainColor;
}
if (this.color) {
style.color = this.plain ? this.color : "white";
} else if (this.plain && this.type !== 'info') {
style.color = this.themeTypeColor;
}
style.fontSize = this.textSize + "px";
return style;
},
// 字体大小
textSize() {
let fontSize = 14,
{ size } = this;
if (size === "large") fontSize = 16;
if (size === "normal") fontSize = 14;
if (size === "small") fontSize = 12;
if (size === "mini") fontSize = 10;
return fontSize;
},
},
emits: ['click', 'getphonenumber', 'getuserinfo',
'error', 'opensetting', 'launchapp', 'agreeprivacyauthorization'],
methods: {
addStyle,
resolveNvueColor(colorValue, fallbackColor) {
if (typeof colorValue !== 'string') return fallbackColor;
if (colorValue.indexOf('var(') > -1) return fallbackColor;
return colorValue;
},
upThemeVar(varName, fallbackColor) {
return getThemeVar(varName, fallbackColor, this.$u);
},
clickHandler(e: any) {
// 非禁止并且非加载中,才能点击
if (!this.disabled && !this.loading) {
// 进行节流控制,每this.throttle毫秒内,只在开始处执行
throttle(() => {
this.$emit("click", e);
}, this.throttleTime);
}
// 是否阻止事件传播
this.stop && this.preventEvent(e)
},
// 下面为对接uniapp官方按钮开放能力事件回调的对接
getphonenumber(res: any) {
this.$emit("getphonenumber", res);
},
getuserinfo(res: any) {
this.$emit("getuserinfo", res);
},
error(res: any) {
this.$emit("error", res);
},
opensetting(res: any) {
this.$emit("opensetting", res);
},
launchapp(res: any) {
this.$emit("launchapp", res);
},
agreeprivacyauthorization(res) {
this.$emit("agreeprivacyauthorization", res);
},
},
};
</script>
<style lang="scss" scoped>
/* #ifndef APP-NVUE */
@import "./vue.scss";
/* #endif */
/* #ifdef APP-NVUE */
@import "./nvue.scss";
/* #endif */
$u-button-u-button-height: 40px !default;
$u-button-text-font-size: 15px !default;
$u-button-loading-text-font-size: 15px !default;
$u-button-loading-text-margin-left: 4px !default;
$u-button-large-width: 100% !default;
$u-button-large-height: 50px !default;
$u-button-normal-padding: 0 12px !default;
$u-button-large-padding: 0 15px !default;
$u-button-normal-font-size: 14px !default;
$u-button-small-min-width: 60px !default;
$u-button-small-height: 30px !default;
$u-button-small-padding: 0px 8px !default;
$u-button-mini-padding: 0px 8px !default;
$u-button-small-font-size: 12px !default;
$u-button-mini-height: 22px !default;
$u-button-mini-font-size: 10px !default;
$u-button-mini-min-width: 50px !default;
$u-button-disabled-opacity: 0.5 !default;
$u-button-info-color: $u-main-color !default;
$u-button-info-background-color: var(--up-button-info-background-color, var(--up-card-bg-color, #fff)) !default;
$u-button-info-border-color: $u-border-color !default;
$u-button-info-border-width: 1px !default;
$u-button-info-border-style: solid !default;
$u-button-success-color: #fff !default;
$u-button-success-background-color: $u-success !default;
$u-button-success-border-color: $u-button-success-background-color !default;
$u-button-success-border-width: 1px !default;
$u-button-success-border-style: solid !default;
$u-button-primary-color: #fff !default;
$u-button-primary-background-color: $u-primary !default;
$u-button-primary-border-color: $u-button-primary-background-color !default;
$u-button-primary-border-width: 1px !default;
$u-button-primary-border-style: solid !default;
$u-button-error-color: #fff !default;
$u-button-error-background-color: $u-error !default;
$u-button-error-border-color: $u-button-error-background-color !default;
$u-button-error-border-width: 1px !default;
$u-button-error-border-style: solid !default;
$u-button-warning-color: #fff !default;
$u-button-warning-background-color: $u-warning !default;
$u-button-warning-border-color: $u-button-warning-background-color !default;
$u-button-warning-border-width: 1px !default;
$u-button-warning-border-style: solid !default;
$u-button-block-width: 100% !default;
$u-button-circle-border-top-right-radius: 100px !default;
$u-button-circle-border-top-left-radius: 100px !default;
$u-button-circle-border-bottom-left-radius: 100px !default;
$u-button-circle-border-bottom-right-radius: 100px !default;
$u-button-square-border-top-right-radius: 3px !default;
$u-button-square-border-top-left-radius: 3px !default;
$u-button-square-border-bottom-left-radius: 3px !default;
$u-button-square-border-bottom-right-radius: 3px !default;
$u-button-icon-min-width: 1em !default;
$u-button-plain-background-color: var(--up-button-plain-background-color, var(--up-card-bg-color, #fff)) !default;
$u-button-hairline-border-width: 0.5px !default;
.u-button {
height: $u-button-u-button-height;
position: relative;
align-items: center;
justify-content: center;
@include flex;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
flex-direction: row;
&__text {
font-size: $u-button-text-font-size;
}
&__loading-text {
font-size: $u-button-loading-text-font-size;
margin-left: $u-button-loading-text-margin-left;
}
&--large {
/* #ifndef APP-NVUE */
width: $u-button-large-width;
/* #endif */
height: $u-button-large-height;
padding: $u-button-large-padding;
}
&--normal {
padding: $u-button-normal-padding;
font-size: $u-button-normal-font-size;
}
&--small {
/* #ifndef APP-NVUE */
min-width: $u-button-small-min-width;
/* #endif */
height: $u-button-small-height;
padding: $u-button-small-padding;
font-size: $u-button-small-font-size;
}
&--mini {
height: $u-button-mini-height;
font-size: $u-button-mini-font-size;
/* #ifndef APP-NVUE */
min-width: $u-button-mini-min-width;
/* #endif */
padding: $u-button-mini-padding;
}
&--disabled {
opacity: $u-button-disabled-opacity;
}
&--info {
color: $u-button-info-color;
background-color: $u-button-info-background-color;
border-color: $u-button-info-border-color;
border-width: $u-button-info-border-width;
border-style: $u-button-info-border-style;
}
&--success {
color: $u-button-success-color;
background-color: $u-button-success-background-color;
border-color: $u-button-success-border-color;
border-width: $u-button-success-border-width;
border-style: $u-button-success-border-style;
}
&--primary {
color: $u-button-primary-color;
background-color: $u-button-primary-background-color;
border-color: $u-button-primary-border-color;
border-width: $u-button-primary-border-width;
border-style: $u-button-primary-border-style;
}
&--error {
color: $u-button-error-color;
background-color: $u-button-error-background-color;
border-color: $u-button-error-border-color;
border-width: $u-button-error-border-width;
border-style: $u-button-error-border-style;
}
&--warning {
color: $u-button-warning-color;
background-color: $u-button-warning-background-color;
border-color: $u-button-warning-border-color;
border-width: $u-button-warning-border-width;
border-style: $u-button-warning-border-style;
}
&--block {
@include flex;
width: $u-button-block-width;
}
&--circle {
border-top-right-radius: $u-button-circle-border-top-right-radius;
border-top-left-radius: $u-button-circle-border-top-left-radius;
border-bottom-left-radius: $u-button-circle-border-bottom-left-radius;
border-bottom-right-radius: $u-button-circle-border-bottom-right-radius;
}
&--square {
border-bottom-left-radius: $u-button-square-border-top-right-radius;
border-bottom-right-radius: $u-button-square-border-top-left-radius;
border-top-left-radius: $u-button-square-border-bottom-left-radius;
border-top-right-radius: $u-button-square-border-bottom-right-radius;
}
&__icon {
/* #ifndef APP-NVUE */
min-width: $u-button-icon-min-width;
line-height: inherit !important;
vertical-align: top;
/* #endif */
}
&--plain {
background-color: $u-button-plain-background-color;
}
&--hairline {
border-width: $u-button-hairline-border-width !important;
}
}
</style>

View File

@@ -0,0 +1,81 @@
// nvue下hover-class无效
$u-button-before-top:50% !default;
$u-button-before-left:50% !default;
$u-button-before-width:100% !default;
$u-button-before-height:100% !default;
$u-button-before-transform:translate(-50%, -50%) !default;
$u-button-before-opacity:0 !default;
$u-button-before-background-color:#000 !default;
$u-button-before-border-color:#000 !default;
$u-button-active-before-opacity:.15 !default;
$u-button-icon-margin-left:4px !default;
$u-button-plain-u-button-info-color:$u-info;
$u-button-plain-u-button-success-color:$u-success;
$u-button-plain-u-button-error-color:$u-error;
$u-button-plain-u-button-warning-color:$u-warning;
.u-button {
width: 100%;
white-space: nowrap;
&__text {
white-space: nowrap;
line-height: 1;
}
&:before {
position: absolute;
top:$u-button-before-top;
left:$u-button-before-left;
width:$u-button-before-width;
height:$u-button-before-height;
border: inherit;
border-radius: inherit;
transform:$u-button-before-transform;
opacity:$u-button-before-opacity;
content: " ";
background-color:$u-button-before-background-color;
border-color:$u-button-before-border-color;
}
&--active {
&:before {
opacity: .15
}
}
&__icon+&__text:not(:empty),
&__loading-text {
margin-left:$u-button-icon-margin-left;
}
&--plain {
&.u-button--primary {
color: $u-primary;
}
}
&--plain {
&.u-button--info {
color:$u-button-plain-u-button-info-color;
}
}
&--plain {
&.u-button--success {
color:$u-button-plain-u-button-success-color;
}
}
&--plain {
&.u-button--error {
color:$u-button-plain-u-button-error-color;
}
}
&--plain {
&.u-button--warning {
color:$u-button-plain-u-button-warning-color;
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* @Author : uview-plus
* @Description : calendarStrip default props
* @version : 3.0
* @Date : 2026-05-29 14:40:00
*/
import { t } from '../../libs/i18n'
export default {
calendarStrip: {
modelValue: null,
minDate: 0,
maxDate: 0,
color: '#3c9cff',
weekText: [
t('up.week.one'),
t('up.week.two'),
t('up.week.three'),
t('up.week.four'),
t('up.week.five'),
t('up.week.six'),
t('up.week.seven')
],
fullCalendar: true,
fullCalendarProps: {},
fullMonthNum: 24,
pullDownThreshold: 40,
collapseAfterSelect: true,
readonly: false,
showToday: true,
monthFormat: '',
expandHint: '下拉展开月历',
collapseHint: '上拉收起月历'
}
}

View File

@@ -0,0 +1,68 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
modelValue: {
type: [String, Number, Date, null],
default: () => defProps.calendarStrip.modelValue
},
minDate: {
type: [String, Number],
default: () => defProps.calendarStrip.minDate
},
maxDate: {
type: [String, Number],
default: () => defProps.calendarStrip.maxDate
},
color: {
type: String,
default: () => defProps.calendarStrip.color
},
weekText: {
type: Array,
default: () => defProps.calendarStrip.weekText
},
fullCalendar: {
type: Boolean,
default: () => defProps.calendarStrip.fullCalendar
},
fullCalendarProps: {
type: Object,
default: () => defProps.calendarStrip.fullCalendarProps
},
fullMonthNum: {
type: [String, Number],
default: () => defProps.calendarStrip.fullMonthNum
},
pullDownThreshold: {
type: [String, Number],
default: () => defProps.calendarStrip.pullDownThreshold
},
collapseAfterSelect: {
type: Boolean,
default: () => defProps.calendarStrip.collapseAfterSelect
},
readonly: {
type: Boolean,
default: () => defProps.calendarStrip.readonly
},
showToday: {
type: Boolean,
default: () => defProps.calendarStrip.showToday
},
monthFormat: {
type: String,
default: () => defProps.calendarStrip.monthFormat
},
expandHint: {
type: String,
default: () => defProps.calendarStrip.expandHint
},
collapseHint: {
type: String,
default: () => defProps.calendarStrip.collapseHint
}
}
})

View File

@@ -0,0 +1,507 @@
<template>
<view class="u-calendar-strip">
<view v-if="!fullCalendar || !innerShowFull">
<view class="u-calendar-strip__header u-border-bottom">
<text
class="u-calendar-strip__header__switch"
:class="{ 'u-calendar-strip__header__switch--disabled': switchPrevDisabled }"
@tap="prevMonth"
>‹</text>
<text class="u-calendar-strip__header__title">{{ monthLabel }}</text>
<text
class="u-calendar-strip__header__switch"
:class="{ 'u-calendar-strip__header__switch--disabled': switchNextDisabled }"
@tap="nextMonth"
>›</text>
<text
v-if="fullCalendar"
class="u-calendar-strip__header__toggle"
@click="toggleFull('button')"
>▾</text>
</view>
<view
class="u-calendar-strip__scroll-wrap"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
>
<scroll-view
class="u-calendar-strip__scroll"
scroll-x
enable-flex
:show-scrollbar="false"
:scroll-into-view="scrollIntoView"
>
<view class="u-calendar-strip__scroll__inner">
<view
v-for="(item, index) in monthDays"
:key="index"
class="u-calendar-strip__day"
:id="getDateId(item.date)"
:class="[
item.disabled && 'u-calendar-strip__day--disabled',
item.selected && 'u-calendar-strip__day--selected',
item.today && showToday && !item.selected && 'u-calendar-strip__day--today',
]"
:style="[dayStyle(item)]"
@tap="onDayTap(item)"
>
<text class="u-calendar-strip__day__date">{{ item.day }}</text>
<text class="u-calendar-strip__day__week">{{ getWeekLabel(item.week) }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
<view
v-if="fullCalendar && innerShowFull"
class="u-calendar-strip__panel-wrap"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
>
<view class="u-calendar-strip__panel">
<u-calendar
:show="true"
:pageInline="true"
:showTitle="false"
:showConfirm="false"
:closeOnClickOverlay="false"
:monthSwitch="true"
mode="single"
:defaultDate="innerSelectedDate"
:minDate="panelMinDate"
:maxDate="panelMaxDate"
:monthNum="panelMonthNum"
:readonly="readonly"
:showToday="showToday"
:color="color"
v-bind="fullCalendarProps"
@confirm="onPanelConfirm"
></u-calendar>
</view>
<view class="u-calendar-strip__hint u-calendar-strip__hint--panel">
<text class="u-calendar-strip__hint__text" @click="toggleFull('hint')">{{ collapseHint }}</text>
</view>
</view>
</view>
</template>
<script>
import { props } from './props'
import { mpMixin } from '../../libs/mixin/mpMixin'
import { mixin } from '../../libs/mixin/mixin'
import dayjs from '../u-datetime-picker/dayjs.esm.min.js'
import test from '../../libs/function/test'
/**
* CalendarStrip 单行日历
* @description 单行横向日期日历,支持切月、下拉展开完整月历
*/
export default {
name: 'u-calendar-strip',
mixins: [mpMixin, mixin, props],
data() {
return {
innerSelectedDate: '',
currentMonth: '',
scrollIntoView: '',
innerShowFull: false,
touchStartX: 0,
touchStartY: 0
}
},
computed: {
innerMaxDate() {
return test.number(this.maxDate) ? Number(this.maxDate) : this.maxDate
},
innerMinDate() {
return test.number(this.minDate) ? Number(this.minDate) : this.minDate
},
rangeChange() {
return [this.innerMinDate, this.innerMaxDate]
},
hasMaxDate() {
return !!this.innerMaxDate && dayjs(this.innerMaxDate).isValid()
},
hasMinDate() {
return !!this.innerMinDate && dayjs(this.innerMinDate).isValid()
},
minDateDay() {
if (!this.hasMinDate) return ''
return dayjs(this.innerMinDate).format('YYYY-MM-DD')
},
maxDateDay() {
if (!this.hasMaxDate) return ''
return dayjs(this.innerMaxDate).format('YYYY-MM-DD')
},
todayDate() {
return dayjs().format('YYYY-MM-DD')
},
monthLabel() {
if (!this.currentMonth) return ''
const date = dayjs(`${this.currentMonth}-01`)
if (this.monthFormat) return date.format(this.monthFormat)
if (uni.getLocale() == 'zh-Hans' || uni.getLocale() == 'zh-Hant') {
return date.format('YYYY年MM月')
}
return date.format('MM/YYYY')
},
monthDays() {
if (!this.currentMonth) return []
const start = dayjs(`${this.currentMonth}-01`)
const days = start.daysInMonth()
return new Array(days).fill(0).map((item, index) => {
const date = start.date(index + 1).format('YYYY-MM-DD')
return {
day: index + 1,
date,
week: start.date(index + 1).day(),
disabled: this.isDateDisabled(date),
selected: this.dateSame(date, this.innerSelectedDate),
today: this.dateSame(date, this.todayDate)
}
})
},
switchPrevDisabled() {
if (!this.hasMinDate || !this.currentMonth) return false
const current = dayjs(`${this.currentMonth}-01`)
const minMonth = dayjs(this.minDateDay).startOf('month')
return current.isSame(minMonth, 'month') || current.isBefore(minMonth, 'month')
},
switchNextDisabled() {
if (!this.hasMaxDate || !this.currentMonth) return false
const current = dayjs(`${this.currentMonth}-01`)
const maxMonth = dayjs(this.maxDateDay).startOf('month')
return current.isSame(maxMonth, 'month') || current.isAfter(maxMonth, 'month')
},
panelMinDate() {
if (this.hasMinDate) return this.minDateDay
const monthNum = Math.max(1, Number(this.fullMonthNum) || 24)
const anchor = this.currentMonth || dayjs().format('YYYY-MM')
return dayjs(`${anchor}-01`).subtract(monthNum - 1, 'month').startOf('month').format('YYYY-MM-DD')
},
panelMaxDate() {
if (this.hasMaxDate) return this.maxDateDay
const monthNum = Math.max(1, Number(this.fullMonthNum) || 24)
const anchor = this.currentMonth || dayjs().format('YYYY-MM')
return dayjs(`${anchor}-01`).add(monthNum - 1, 'month').endOf('month').format('YYYY-MM-DD')
},
panelMonthNum() {
return this.getMonths(this.panelMinDate, this.panelMaxDate)
},
pullHintText() {
return this.innerShowFull ? this.collapseHint : this.expandHint
}
},
watch: {
modelValue: {
immediate: true,
handler(n) {
this.syncByValue(n, false, 'sync')
}
},
rangeChange() {
this.syncByValue(this.innerSelectedDate || this.modelValue, true, 'range')
}
},
emits: ['change', 'confirm', 'monthChange', 'toggleFull', 'update:modelValue'],
methods: {
dateSame(date1, date2) {
if (!date1 || !date2) return false
return dayjs(date1).isSame(dayjs(date2), 'day')
},
normalizeDate(value) {
if (!value) return ''
const parsed = dayjs(value)
if (!parsed.isValid()) return ''
return parsed.format('YYYY-MM-DD')
},
clampDate(date) {
let next = this.normalizeDate(date)
if (!next) return ''
if (this.hasMinDate && dayjs(next).isBefore(dayjs(this.minDateDay), 'day')) {
next = this.minDateDay
}
if (this.hasMaxDate && dayjs(next).isAfter(dayjs(this.maxDateDay), 'day')) {
next = this.maxDateDay
}
return next
},
isDateDisabled(date) {
if (!date) return true
if (this.hasMinDate && dayjs(date).isBefore(dayjs(this.minDateDay), 'day')) {
return true
}
if (this.hasMaxDate && dayjs(date).isAfter(dayjs(this.maxDateDay), 'day')) {
return true
}
return false
},
getDateId(date) {
return `u-calendar-strip-day-${dayjs(date).format('YYYYMMDD')}`
},
getWeekLabel(week) {
const index = week === 0 ? 6 : week - 1
return this.weekText[index] || ''
},
dayStyle(item) {
const style = {}
if (item.selected) {
style.backgroundColor = this.color
}
if (!item.selected && item.today && this.showToday) {
style.borderColor = this.color
}
return style
},
scrollToDate(date) {
if (!date) {
this.scrollIntoView = ''
return
}
const target = this.getDateId(date)
this.scrollIntoView = ''
this.$nextTick(() => {
this.scrollIntoView = target
})
},
syncByValue(value, emit = false, scene = 'sync') {
let next = this.clampDate(value)
if (!next) {
next = this.clampDate(this.todayDate)
}
if (!next) return
this.setSelectedDate(next, scene, emit)
},
setSelectedDate(date, scene = 'tap', emit = true) {
const next = this.clampDate(date)
if (!next || this.isDateDisabled(next)) return
const prevDate = this.innerSelectedDate
const prevMonth = this.currentMonth
this.innerSelectedDate = next
this.currentMonth = dayjs(next).format('YYYY-MM')
this.scrollToDate(next)
if (!emit) return
const payload = {
date: next,
month: this.currentMonth,
scene
}
if (!this.dateSame(prevDate, next)) {
this.$emit('update:modelValue', next)
}
this.$emit('change', payload)
this.$emit('confirm', payload)
if (prevMonth !== this.currentMonth) {
this.$emit('monthChange', {
month: this.currentMonth,
scene
})
}
},
getMonths(minDate, maxDate) {
const minYear = dayjs(minDate).year()
const minMonth = dayjs(minDate).month() + 1
const maxYear = dayjs(maxDate).year()
const maxMonth = dayjs(maxDate).month() + 1
return Math.max(1, (maxYear - minYear) * 12 + (maxMonth - minMonth) + 1)
},
findFirstEnabledDate(month) {
const start = dayjs(`${month}-01`)
const days = start.daysInMonth()
for (let i = 1; i <= days; i++) {
const date = start.date(i).format('YYYY-MM-DD')
if (!this.isDateDisabled(date)) {
return date
}
}
return ''
},
switchMonth(step = 0) {
if (step < 0 && this.switchPrevDisabled) return
if (step > 0 && this.switchNextDisabled) return
const baseMonth = this.currentMonth || dayjs(this.todayDate).format('YYYY-MM')
const target = dayjs(`${baseMonth}-01`).add(step, 'month')
const targetMonth = target.format('YYYY-MM')
const selectedDay = dayjs(this.innerSelectedDate || this.todayDate).date()
const dayInTargetMonth = Math.min(selectedDay, target.daysInMonth())
let next = target.date(dayInTargetMonth).format('YYYY-MM-DD')
next = this.clampDate(next)
if (!next || !dayjs(next).isSame(target, 'month') || this.isDateDisabled(next)) {
next = this.findFirstEnabledDate(targetMonth)
}
if (!next) return
this.setSelectedDate(next, 'switch', true)
},
prevMonth() {
this.switchMonth(-1)
},
nextMonth() {
this.switchMonth(1)
},
onDayTap(item) {
if (this.readonly || item.disabled) return
this.setSelectedDate(item.date, 'tap', true)
},
setFullVisible(show, source = 'button') {
if (!this.fullCalendar) return
if (this.innerShowFull === show) return
this.innerShowFull = show
this.$emit('toggleFull', {
show,
source
})
},
toggleFull(source = 'button') {
this.setFullVisible(!this.innerShowFull, source)
},
onPanelConfirm(e) {
if (!Array.isArray(e) || !e.length) return
this.setSelectedDate(e[0], 'full', true)
if (this.collapseAfterSelect) {
this.setFullVisible(false, 'auto')
}
},
onTouchStart(event) {
if (!this.fullCalendar) return
const point = event?.changedTouches?.[0] || event?.touches?.[0]
if (!point) return
this.touchStartX = point.clientX
this.touchStartY = point.clientY
},
onTouchEnd(event) {
if (!this.fullCalendar) return
const point = event?.changedTouches?.[0] || event?.touches?.[0]
if (!point) return
const deltaX = point.clientX - this.touchStartX
const deltaY = point.clientY - this.touchStartY
const threshold = Number(this.pullDownThreshold) || 40
if (Math.abs(deltaY) < threshold || Math.abs(deltaY) <= Math.abs(deltaX)) {
return
}
if (deltaY > 0 && !this.innerShowFull) {
this.setFullVisible(true, 'pull-down')
}
if (deltaY < 0 && this.innerShowFull) {
this.setFullVisible(false, 'pull-up')
}
}
}
}
</script>
<style lang="scss" scoped>
.u-calendar-strip {
background-color: var(--up-card-bg-color, #ffffff);
border-radius: 10px;
margin: 0 8px;
&__header {
@include flex;
align-items: center;
padding: 0 4px;
height: 40px;
&__switch {
width: 40px;
text-align: center;
font-size: 20px;
line-height: 40px;
color: var(--up-main-color, $u-main-color);
&--disabled {
opacity: 0.35;
}
}
&__title {
flex: 1;
text-align: center;
font-size: 14px;
font-weight: bold;
color: var(--up-main-color, $u-main-color);
}
&__toggle {
width: 40px;
text-align: center;
font-size: 18px;
line-height: 40px;
color: var(--up-content-color, $u-content-color);
}
}
&__scroll-wrap {
padding: 10px 0 6px;
}
&__scroll {
&__inner {
@include flex;
padding: 0 8px;
}
}
&__day {
width: 54px;
height: 62px;
margin-right: 8px;
border-radius: 8px;
border: 1px solid transparent;
@include flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
&__date {
font-size: 18px;
line-height: 22px;
color: var(--up-main-color, $u-main-color);
}
&__week {
margin-top: 4px;
font-size: 12px;
line-height: 16px;
color: var(--up-content-color, $u-content-color);
}
&--selected {
.u-calendar-strip__day__date,
.u-calendar-strip__day__week {
color: #ffffff;
}
}
&--today {
border-style: solid;
}
&--disabled {
opacity: 0.4;
}
}
&__hint {
padding: 4px 0 10px;
text-align: center;
&__text {
font-size: 12px;
color: var(--up-tips-color, $u-tips-color);
}
}
&__panel {
padding: 0 8px 8px;
}
&__panel-wrap {
padding-top: 4px;
}
&__hint--panel {
padding: 0 0 8px;
}
}
</style>

View File

@@ -0,0 +1,55 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/calendar.js
*/
import { t } from '../../libs/i18n'
export default {
// calendar 组件
calendar: {
title: t("up.calendar.chooseDates"),
showTitle: true,
showSubtitle: true,
mode: 'single',
startText: t("up.common.start"),
endText: t("up.common.end"),
customList: [],
color: '#3c9cff',
minDate: 0,
maxDate: 0,
defaultDate: null,
maxCount: Number.MAX_SAFE_INTEGER, // Infinity
rowHeight: 56,
formatter: null,
showLunar: false,
showMark: true,
confirmText: t("up.common.confirm"),
confirmDisabledText: t("up.common.confirm"),
show: false,
closeOnClickOverlay: false,
readonly: false,
showConfirm: true,
maxRange: Number.MAX_SAFE_INTEGER, // Infinity
rangePrompt: '',
showRangePrompt: true,
allowSameDay: false,
rangeResultMode: 'all',
enableTime: false,
timePrecision: 'minute',
defaultTime: '',
round: 0,
monthNum: 3,
monthSwitch: false,
showToday: true,
todayColor: '',
weekText: [t("up.week.one"), t("up.week.two"), t("up.week.three"), t("up.week.four"), t("up.week.five"), t("up.week.six"), t("up.week.seven")],
forbidDays: [],
forbidDaysToast: t("up.calendar.disabled"),
monthFormat: '',
pageInline: false
}
}

View File

@@ -0,0 +1,240 @@
<template>
<view class="u-calendar-header u-border-bottom">
<text
class="u-calendar-header__title"
v-if="showTitle"
>{{ title }}</text>
<view
class="u-calendar-header__subtitle-wrap"
v-if="showSubtitle"
>
<text
v-if="showSwitch"
class="u-calendar-header__switch"
:class="{ 'u-calendar-header__switch--disabled': prevYearDisabled }"
@click="prevYear"
>«</text>
<text
v-if="showSwitch"
class="u-calendar-header__switch"
:class="{ 'u-calendar-header__switch--disabled': prevDisabled }"
@click="prev"
>‹</text>
<text class="u-calendar-header__subtitle">{{ subtitle }}</text>
<text
v-if="showSwitch"
class="u-calendar-header__switch"
:class="{ 'u-calendar-header__switch--disabled': nextDisabled }"
@click="next"
>›</text>
<text
v-if="showSwitch"
class="u-calendar-header__switch"
:class="{ 'u-calendar-header__switch--disabled': nextYearDisabled }"
@click="nextYear"
>»</text>
<text
v-if="showToday"
class="u-calendar-header__today"
:class="{ 'u-calendar-header__today--disabled': todayDisabled }"
@click="today"
>{{ todayText }}</text>
</view>
<view class="u-calendar-header__weekdays">
<text class="u-calendar-header__weekdays__weekday">{{ weekText[0] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[1] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[2] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[3] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[4] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[5] }}</text>
<text class="u-calendar-header__weekdays__weekday">{{ weekText[6] }}</text>
</view>
</view>
</template>
<script>
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
export default {
name: 'u-calendar-header',
mixins: [mpMixin, mixin],
props: {
// 标题
title: {
type: String,
default: ''
},
// 副标题
subtitle: {
type: String,
default: ''
},
// 是否显示标题
showTitle: {
type: Boolean,
default: true
},
// 是否显示副标题
showSubtitle: {
type: Boolean,
default: true
},
// 星期文本
weekText: {
type: Array,
default: () => {
return []
}
},
// 是否显示月份切换按钮
showSwitch: {
type: Boolean,
default: false
},
// 上个月按钮是否禁用
prevDisabled: {
type: Boolean,
default: false
},
// 下个月按钮是否禁用
nextDisabled: {
type: Boolean,
default: false
},
// 上一年按钮是否禁用
prevYearDisabled: {
type: Boolean,
default: false
},
// 下一年按钮是否禁用
nextYearDisabled: {
type: Boolean,
default: false
},
// 是否显示今天按钮
showToday: {
type: Boolean,
default: true
},
// 今天按钮文案
todayText: {
type: String,
default: ''
},
// 今天按钮是否禁用
todayDisabled: {
type: Boolean,
default: false
}
},
data() {
return {
}
},
methods: {
prev() {
if (!this.prevDisabled) {
this.$emit('prev')
}
},
next() {
if (!this.nextDisabled) {
this.$emit('next')
}
},
prevYear() {
if (!this.prevYearDisabled) {
this.$emit('prevYear')
}
},
nextYear() {
if (!this.nextYearDisabled) {
this.$emit('nextYear')
}
},
today() {
if (!this.todayDisabled) {
this.$emit('today')
}
}
},
}
</script>
<style lang="scss" scoped>
.u-calendar-header {
display: flex;
flex-direction: column;
padding-bottom: 4px;
&__title {
font-size: 16px;
color: var(--up-main-color, $u-main-color);
text-align: center;
height: 42px;
line-height: 42px;
font-weight: bold;
}
&__subtitle-wrap {
@include flex;
align-items: center;
justify-content: center;
height: 40px;
}
&__subtitle {
font-size: 14px;
color: var(--up-main-color, $u-main-color);
text-align: center;
font-weight: bold;
min-width: 120px;
}
&__switch {
width: 44px;
height: 40px;
line-height: 40px;
text-align: center;
color: var(--up-main-color, $u-main-color);
font-size: 22px;
font-weight: bold;
flex-shrink: 0;
&--disabled {
opacity: 0.35;
}
}
&__weekdays {
@include flex;
justify-content: space-between;
&__weekday {
font-size: 13px;
color: var(--up-main-color, $u-main-color);
line-height: 30px;
flex: 1;
text-align: center;
}
}
&__today {
margin-left: 4px;
height: 28px;
line-height: 28px;
padding: 0 10px;
border-radius: 14px;
font-size: 13px;
color: var(--up-primary, $u-primary);
border: 1px solid var(--up-primary, $u-primary);
flex-shrink: 0;
&--disabled {
opacity: 0.35;
}
}
}
</style>

View File

@@ -0,0 +1,663 @@
<template>
<view class="u-calendar-month-wrapper" ref="u-calendar-month-wrapper">
<view v-for="(item, index) in months" :key="index" :class="[`u-calendar-month-${index}`]"
:ref="`u-calendar-month-${index}`" :id="`month-${index}`">
<text v-if="index !== 0" class="u-calendar-month__title">{{ monthTitle(item) }}</text>
<view class="u-calendar-month__days">
<view v-if="showMark" class="u-calendar-month__days__month-mark-wrapper">
<text class="u-calendar-month__days__month-mark-wrapper__text">{{ item.month }}</text>
</view>
<view class="u-calendar-month__days__day" v-for="(item1, index1) in item.date" :key="index1"
:style="[dayStyle(index, index1, item1)]" @tap="clickHandler(index, index1, item1)"
:class="[item1.selected && 'u-calendar-month__days__day__select--selected']">
<view class="u-calendar-month__days__day__select" :style="[daySelectStyle(index, index1, item1)]">
<text class="u-calendar-month__days__day__select__info"
:class="[(item1.disabled || isForbid(item1) ) ? 'u-calendar-month__days__day__select__info--disabled' : '']"
:style="[textStyle(item1)]">{{ item1.day }}</text>
<text v-if="getBottomInfo(index, index1, item1)"
class="u-calendar-month__days__day__select__buttom-info"
:class="[(item1.disabled || isForbid(item1) ) ? 'u-calendar-month__days__day__select__buttom-info--disabled' : '']"
:style="[textStyle(item1)]">{{ getBottomInfo(index, index1, item1) }}</text>
<text v-if="item1.dot" class="u-calendar-month__days__day__select__dot"></text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
// #ifdef APP-NVUE
// 由于nvue不支持百分比单位,需要查询宽度来计算每个日期的宽度
const dom = uni.requireNativePlugin('dom')
// #endif
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, deepClone, toast, sleep, getWindowInfo } from '../../libs/function/index';
import { colorGradient } from '../../libs/function/colorGradient';
import test from '../../libs/function/test';
import defProps from '../../libs/config/props';
import dayjs from '../u-datetime-picker/dayjs.esm.min.js';
import { t } from '../../libs/i18n'
export default {
name: 'u-calendar-month',
mixins: [mpMixin, mixin],
props: {
// 是否显示月份背景色
showMark: {
type: Boolean,
default: true
},
// 主题色,对底部按钮和选中日期有效
color: {
type: String,
default: '#3c9cff'
},
// 月份数据
months: {
type: Array,
default: () => []
},
// 日期选择类型
mode: {
type: String,
default: 'single'
},
// 日期行高
rowHeight: {
type: [String, Number],
default: 58
},
// mode=multiple时,最多可选多少个日期
maxCount: {
type: [String, Number],
default: Infinity
},
// mode=range时,第一个日期底部的提示文字
startText: {
type: String,
default: '开始'
},
// mode=range时,最后一个日期底部的提示文字
endText: {
type: String,
default: '结束'
},
// 默认选中的日期,mode为multiple或range是必须为数组格式
defaultDate: {
type: [Array, String, Date],
default: null
},
// 最小的可选日期
minDate: {
type: [String, Number],
default: 0
},
// 最大可选日期
maxDate: {
type: [String, Number],
default: 0
},
// 如果没有设置maxDate,则往后推多少个月
maxMonth: {
type: [String, Number],
default: 2
},
// 是否为只读状态,只读状态下禁止选择日期
readonly: {
type: Boolean,
default: () => defProps.calendar.readonly
},
// 日期区间最多可选天数,默认无限制,mode = range时有效
maxRange: {
type: [Number, String],
default: Infinity
},
// 范围选择超过最多可选天数时的提示文案,mode = range时有效
rangePrompt: {
type: String,
default: ''
},
// 范围选择超过最多可选天数时,是否展示提示文案,mode = range时有效
showRangePrompt: {
type: Boolean,
default: true
},
// 是否允许日期范围的起止时间为同一天,mode = range时有效
allowSameDay: {
type: Boolean,
default: false
},
forbidDays: {
type: Array,
default: () => []
},
forbidDaysToast: {
type: String,
default: ''
},
// 今天日期,用于独立高亮
todayDate: {
type: String,
default: ''
},
// 今天日期的独立高亮颜色
todayColor: {
type: String,
default: ''
}
},
data() {
return {
// 每个日期的宽度
width: 0,
// 当前选中的日期item
item: {},
selected: []
}
},
watch: {
selectedChange: {
immediate: true,
handler(n) {
this.setDefaultDate()
}
}
},
computed: {
// 多个条件的变化,会引起选中日期的变化,这里统一管理监听
selectedChange() {
return [this.minDate, this.maxDate, this.defaultDate]
},
dayStyle(index1, index2, item) {
return (index1, index2, item) => {
const style = {}
let week = item.week
// 隐藏挂载场景下节点宽度可能拿到0,兜底使用窗口宽度避免首日偏移归零
const wrapperWidth = this.width > 0 ? this.width : (getWindowInfo().windowWidth || 0)
// 不进行四舍五入的形式保留2位小数
const dayWidth = Number(parseFloat(wrapperWidth / 7).toFixed(3).slice(0, -1))
// 得出每个日期的宽度
// #ifdef APP-NVUE
style.width = addUnit(dayWidth, 'px')
// #endif
style.height = addUnit(this.rowHeight, 'px')
if (index2 === 0) {
// 获取当前为星期几,如果为0,则为星期天,减一为每月第一天时,需要向左偏移的item个数
week = (week === 0 ? 7 : week) - 1
// #ifdef APP-NVUE
style.marginLeft = addUnit(week * dayWidth, 'px')
// #endif
// #ifndef APP-NVUE
style.marginLeft = `${(week / 7) * 100}%`
// #endif
}
if (this.mode === 'range') {
// 之所以需要这么写,是因为DCloud公司的iOS客户端导致的bug
style.paddingLeft = 0
style.paddingRight = 0
style.paddingBottom = 0
style.paddingTop = 0
}
return style
}
},
daySelectStyle() {
return (index1, index2, item) => {
let date = dayjs(item.date).format("YYYY-MM-DD"),
style = {}
// 判断date是否在selected数组中,因为月份可能会需要补0,所以使用dateSame判断,而不用数组的includes判断
if (this.selected.some(item => this.dateSame(item, date))) {
style.backgroundColor = this.color
}
if (this.todayDate && this.dateSame(date, this.todayDate)) {
style.border = `1px solid ${this.resolvedTodayColor}`
style.boxSizing = 'border-box'
}
if (this.mode === 'single') {
if (date === this.selected[0]) {
// 因为需要对nvue的兼容,只能这么写,无法缩写,也无法通过类名控制等等
style.borderTopLeftRadius = '3px'
style.borderBottomLeftRadius = '3px'
style.borderTopRightRadius = '3px'
style.borderBottomRightRadius = '3px'
}
} else if (this.mode === 'range') {
if (this.selected.length >= 2) {
const len = this.selected.length - 1
// 第一个日期设置左上角和左下角的圆角
if (this.dateSame(date, this.selected[0])) {
style.borderTopLeftRadius = '3px'
style.borderBottomLeftRadius = '3px'
}
// 最后一个日期设置右上角和右下角的圆角
if (this.dateSame(date, this.selected[len])) {
style.borderTopRightRadius = '3px'
style.borderBottomRightRadius = '3px'
}
// 处于第一和最后一个之间的日期,背景色设置为浅色,通过将对应颜色进行等分,再取其尾部的颜色值
if (dayjs(date).isAfter(dayjs(this.selected[0])) && dayjs(date).isBefore(dayjs(this
.selected[len]))) {
const rangeEndColor = this.upThemeVar('--up-card-bg-color', this.upThemeIsDark ? '#1c1c1e' : '#ffffff')
style.backgroundColor = colorGradient(this.color, rangeEndColor, 100)[90]
// 增加一个透明度,让范围区间的背景色也能看到底部的mark水印字符
style.opacity = this.upThemeIsDark ? 0.85 : 0.7
}
} else if (this.selected.length === 1) {
// 之所以需要这么写,是因为uni-app的iOS客户端的bug
// 进行还原操作,否则在nvue的iOS,uni-app有bug,会导致诡异的表现
style.borderTopLeftRadius = '3px'
style.borderBottomLeftRadius = '3px'
}
} else {
if (this.selected.some(item => this.dateSame(item, date))) {
style.borderTopLeftRadius = '3px'
style.borderBottomLeftRadius = '3px'
style.borderTopRightRadius = '3px'
style.borderBottomRightRadius = '3px'
}
}
return style
}
},
resolvedTodayColor() {
return this.todayColor || this.color
},
// 某个日期是否被选中
textStyle() {
return (item) => {
const date = dayjs(item.date).format("YYYY-MM-DD"),
style = {}
// 选中的日期,提示文字设置白色
if (this.selected.some(item => this.dateSame(item, date))) {
style.color = '#ffffff'
}
if (this.mode === 'range') {
const len = this.selected.length - 1
// 如果是范围选择模式,第一个和最后一个之间的日期,文字颜色设置为高亮的主题色
if (dayjs(date).isAfter(dayjs(this.selected[0])) && dayjs(date).isBefore(dayjs(this
.selected[len]))) {
style.color = this.color
}
}
if (this.todayDate && this.dateSame(date, this.todayDate) && !this.isSelectedDate(date)) {
style.color = this.resolvedTodayColor
}
return style
}
},
// 获取底部的提示文字
getBottomInfo() {
return (index1, index2, item) => {
const date = dayjs(item.date).format("YYYY-MM-DD")
const bottomInfo = item.bottomInfo
// 当为日期范围模式时,且选择的日期个数大于0时
if (this.mode === 'range' && this.selected.length > 0) {
if (this.selected.length === 1) {
// 选择了一个日期时,如果当前日期为数组中的第一个日期,则显示底部文字为“开始”
if (this.dateSame(date, this.selected[0])) return this.startText
else return bottomInfo
} else {
const len = this.selected.length - 1
// 如果数组中的日期大于2个时,第一个和最后一个显示为开始和结束日期
if (this.dateSame(date, this.selected[0]) && this.dateSame(date, this.selected[1]) &&
len === 1) {
// 如果长度为2,且第一个等于第二个日期,则提示语放在同一个item中
return `${this.startText}/${this.endText}`
} else if (this.dateSame(date, this.selected[0])) {
return this.startText
} else if (this.dateSame(date, this.selected[len])) {
return this.endText
} else {
return bottomInfo
}
}
} else {
return bottomInfo
}
}
}
},
mounted() {
this.init()
},
emits: ['monthSelected', 'updateMonthTop'],
methods: {
init() {
// 初始化默认选中
this.$emit('monthSelected', this.selected)
this.$nextTick(() => {
// 这里需要另一个延时,因为获取宽度后,会进行月份数据渲染,只有渲染完成之后,才有真正的高度
// 因为nvue下,$nextTick并不是100%可靠的
sleep(10).then(() => {
this.getWrapperWidth()
this.getMonthRect()
})
})
},
monthTitle(item) {
if (uni.getLocale() == 'zh-Hans' || uni.getLocale() == 'zh-Hant') {
return item.year + '年' + (item.month < 10 ? '0' + item.month : item.month) + '月'
} else {
return (item.month < 10 ? '0' + item.month : item.month) + '/' + item.year
}
},
isForbid(item) {
let date = dayjs(item.date).format("YYYY-MM-DD")
if (this.mode !== 'range' && this.forbidDays.includes(date)) {
return true
}
return false
},
// 判断两个日期是否相等
dateSame(date1, date2) {
return dayjs(date1).isSame(dayjs(date2))
},
isSelectedDate(date) {
return this.selected.some(item => this.dateSame(item, date))
},
// 获取月份数据区域的宽度,因为nvue不支持百分比,所以无法通过css设置每个日期item的宽度
getWrapperWidth() {
// #ifdef APP-NVUE
dom.getComponentRect(this.$refs['u-calendar-month-wrapper'], res => {
const width = res && res.size ? Number(res.size.width) : 0
this.width = width > 0 ? width : (getWindowInfo().windowWidth || 0)
})
// #endif
// #ifndef APP-NVUE
this.$uGetRect('.u-calendar-month-wrapper').then(size => {
const width = size ? Number(size.width) : 0
this.width = width > 0 ? width : (getWindowInfo().windowWidth || 0)
})
// #endif
},
getMonthRect() {
// 获取每个月份数据的尺寸,用于父组件在scroll-view滚动事件中,监听当前滚动到了第几个月份
const promiseAllArr = this.months.map((item, index) => this.getMonthRectByPromise(
`u-calendar-month-${index}`))
// 一次性返回
Promise.all(promiseAllArr).then(
sizes => {
let height = 1
const topArr = []
for (let i = 0; i < this.months.length; i++) {
// 添加到months数组中,供scroll-view滚动事件中,判断当前滚动到哪个月份
topArr[i] = height
height += sizes[i].height
}
// 由于微信下,无法通过this.months[i].top的形式(引用类型)去修改父组件的month的top值,所以使用事件形式对外发出
this.$emit('updateMonthTop', topArr)
})
},
// 获取每个月份区域的尺寸
getMonthRectByPromise(el) {
// #ifndef APP-NVUE
// $uGetRect为uView自带的节点查询简化方法,详见文档介绍:https://uview-plus.jiangruyi.com/js/getRect.html
// 组件内部一般用this.$uGetRect,对外的为uni.$u.getRect,二者功能一致,名称不同
return new Promise(resolve => {
this.$uGetRect(`.${el}`).then(size => {
resolve(size)
})
})
// #endif
// #ifdef APP-NVUE
// nvue下,使用dom模块查询元素高度
// 返回一个promise,让调用此方法的主体能使用then回调
return new Promise(resolve => {
dom.getComponentRect(this.$refs[el][0], res => {
resolve(res.size)
})
})
// #endif
},
// 点击某一个日期
clickHandler(index1, index2, item) {
if (this.readonly) {
return;
}
this.item = item
const date = dayjs(item.date).format("YYYY-MM-DD")
if (item.disabled) return
if (this.isForbid(item)) {
uni.showToast({
title: this.forbidDaysToast
})
return
}
// 对上一次选择的日期数组进行深度克隆
let selected = deepClone(this.selected)
if (this.mode === 'single') {
// 单选情况下,让数组中的元素为当前点击的日期
selected = [date]
} else if (this.mode === 'multiple') {
if (selected.some(item => this.dateSame(item, date))) {
// 如果点击的日期已在数组中,则进行移除操作,也就是达到反选的效果
const itemIndex = selected.findIndex(item => item === date)
selected.splice(itemIndex, 1)
} else {
// 如果点击的日期不在数组中,且已有的长度小于总可选长度时,则添加到数组中去
if (selected.length < this.maxCount) selected.push(date)
}
} else {
// 选择区间形式
if (selected.length === 0 || selected.length >= 2) {
// 如果原来就为0或者大于2的长度,则当前点击的日期,就是开始日期
selected = [date]
} else if (selected.length === 1) {
// 如果已经选择了开始日期
const existsDate = selected[0]
// 如果当前选择的日期小于上一次选择的日期,则当前的日期定为开始日期
if (dayjs(date).isBefore(existsDate)) {
selected = [date]
} else if (dayjs(date).isAfter(existsDate)) {
// 当前日期减去最大可选的日期天数,如果大于起始时间,则进行提示
if(dayjs(dayjs(date).subtract(this.maxRange, 'day')).isAfter(dayjs(selected[0])) && this.showRangePrompt) {
if(this.rangePrompt) {
toast(this.rangePrompt)
} else {
toast(t("up.calendar.daysExceed", { days: this.maxRange }))
}
return
}
// 如果当前日期大于已有日期,将当前的添加到数组尾部
selected.push(date)
const startDate = selected[0]
const endDate = selected[1]
const arr = []
let i = 0
do {
// 将开始和结束日期之间的日期添加到数组中
arr.push(dayjs(startDate).add(i, 'day').format("YYYY-MM-DD"))
i++
// 累加的日期小于结束日期时,继续下一次的循环
} while (dayjs(startDate).add(i, 'day').isBefore(dayjs(endDate)))
// 为了一次性修改数组,避免computed中多次触发,这里才用arr变量一次性赋值的方式,同时将最后一个日期添加近来
arr.push(endDate)
selected = arr
} else {
// 选择区间时,只有一个日期的情况下,且不允许选择起止为同一天的话,不允许选择自己
if (selected[0] === date && !this.allowSameDay) return
selected.push(date)
}
}
}
this.setSelected(selected)
},
// 设置默认日期
setDefaultDate() {
if (!this.defaultDate) {
// 如果没有设置默认日期,则将当天日期设置为默认选中的日期
const selected = [dayjs().format("YYYY-MM-DD")]
return this.setSelected(selected, false)
}
let defaultDate = []
const minDate = this.minDate || dayjs().format("YYYY-MM-DD")
const maxDate = this.maxDate || dayjs(minDate).add(this.maxMonth - 1, 'month').format("YYYY-MM-DD")
if (this.mode === 'single') {
// 单选模式,可以是字符串或数组,Date对象等
if (!test.array(this.defaultDate)) {
defaultDate = [dayjs(this.defaultDate).format("YYYY-MM-DD")]
} else {
defaultDate = [this.defaultDate[0]]
}
} else {
// 如果为非数组,则不执行
if (!test.array(this.defaultDate)) return
defaultDate = this.defaultDate
}
// 过滤用户传递的默认数组,取出只在可允许最大值与最小值之间的元素
defaultDate = defaultDate.filter(item => {
return dayjs(item).isAfter(dayjs(minDate).subtract(1, 'day')) && dayjs(item).isBefore(dayjs(
maxDate).add(1, 'day'))
})
this.setSelected(defaultDate, false)
},
setSelected(selected, event = true) {
this.selected = selected
event && this.$emit('monthSelected', this.selected,'tap')
},
selectDate(date) {
const targetDate = dayjs(date).format("YYYY-MM-DD")
for (let monthIndex = 0; monthIndex < this.months.length; monthIndex++) {
const dayIndex = this.months[monthIndex].date.findIndex(item => {
return this.dateSame(item.date, targetDate)
})
if (dayIndex !== -1) {
this.clickHandler(monthIndex, dayIndex, this.months[monthIndex].date[dayIndex])
return
}
}
}
}
}
</script>
<style lang="scss" scoped>
.u-calendar-month-wrapper {
margin-top: 4px;
}
.u-calendar-month {
&__title {
display: flex;
flex-direction: column;
font-size: 14px;
line-height: 42px;
height: 42px;
color: var(--up-main-color, $u-main-color);
text-align: center;
font-weight: bold;
}
&__days {
position: relative;
@include flex;
flex-wrap: wrap;
&__month-mark-wrapper {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
@include flex;
justify-content: center;
align-items: center;
&__text {
font-size: 155px;
color: var(--up-calendar-month-mark-color, rgba(231, 232, 234, 0.83));
}
}
&__day {
@include flex;
padding: 2px;
/* #ifndef APP-NVUE */
// vue下使用css进行宽度计算,因为某些安卓机会无法进行js获取父元素宽度进行计算得出,会有偏移
width: calc(100% / 7);
box-sizing: border-box;
/* #endif */
&__select {
flex: 1;
@include flex;
align-items: center;
justify-content: center;
position: relative;
&__dot {
width: 7px;
height: 7px;
border-radius: 100px;
background-color: $u-error;
position: absolute;
top: 12px;
right: 7px;
}
&__buttom-info {
color: var(--up-content-color, $u-content-color);
text-align: center;
position: absolute;
bottom: 5px;
font-size: 10px;
text-align: center;
left: 0;
right: 0;
&--selected {
color: #ffffff;
}
&--disabled {
color: var(--up-disabled-color, #cacbcd);
}
}
&__info {
text-align: center;
font-size: 16px;
color: var(--up-main-color, $u-main-color);
&--selected {
color: #ffffff;
}
&--disabled {
color: var(--up-disabled-color, #cacbcd);
}
}
&--selected {
background-color: $u-primary;
@include flex;
justify-content: center;
align-items: center;
flex: 1;
border-radius: 3px;
}
&--range-selected {
opacity: 0.3;
border-radius: 0;
}
&--range-start-selected {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
&--range-end-selected {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,204 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 日历顶部标题
title: {
type: String,
default: () => defProps.calendar.title
},
// 是否显示标题
showTitle: {
type: Boolean,
default: () => defProps.calendar.showTitle
},
// 是否显示副标题
showSubtitle: {
type: Boolean,
default: () => defProps.calendar.showSubtitle
},
// 日期类型选择,single-选择单个日期,multiple-可以选择多个日期,range-选择日期范围
mode: {
type: String,
default: () => defProps.calendar.mode
},
// mode=range时,第一个日期底部的提示文字
startText: {
type: String,
default: () => defProps.calendar.startText
},
// mode=range时,最后一个日期底部的提示文字
endText: {
type: String,
default: () => defProps.calendar.endText
},
// 自定义列表
customList: {
type: Array,
default: () => defProps.calendar.customList
},
// 主题色,对底部按钮和选中日期有效
color: {
type: String,
default: () => defProps.calendar.color
},
// 最小的可选日期
minDate: {
type: [String, Number],
default: () => defProps.calendar.minDate
},
// 最大可选日期
maxDate: {
type: [String, Number],
default: () => defProps.calendar.maxDate
},
// 默认选中的日期,mode为multiple或range是必须为数组格式
defaultDate: {
type: [Array, String, Date, null],
default: () => defProps.calendar.defaultDate
},
// mode=multiple时,最多可选多少个日期
maxCount: {
type: [String, Number],
default: () => defProps.calendar.maxCount
},
// 日期行高
rowHeight: {
type: [String, Number],
default: () => defProps.calendar.rowHeight
},
// 日期格式化函数
formatter: {
type: [Function, null],
default: () => defProps.calendar.formatter
},
// 是否显示农历
showLunar: {
type: Boolean,
default: () => defProps.calendar.showLunar
},
// 是否显示月份背景色
showMark: {
type: Boolean,
default: () => defProps.calendar.showMark
},
// 确定按钮的文字
confirmText: {
type: String,
default: () => defProps.calendar.confirmText
},
// 确认按钮处于禁用状态时的文字
confirmDisabledText: {
type: String,
default: () => defProps.calendar.confirmDisabledText
},
// 是否显示日历弹窗
show: {
type: Boolean,
default: () => defProps.calendar.show
},
// 是否允许点击遮罩关闭日历
closeOnClickOverlay: {
type: Boolean,
default: () => defProps.calendar.closeOnClickOverlay
},
// 是否为只读状态,只读状态下禁止选择日期
readonly: {
type: Boolean,
default: () => defProps.calendar.readonly
},
// 是否展示确认按钮
showConfirm: {
type: Boolean,
default: () => defProps.calendar.showConfirm
},
// 日期区间最多可选天数,默认无限制,mode = range时有效
maxRange: {
type: [Number, String],
default: () => defProps.calendar.maxRange
},
// 范围选择超过最多可选天数时的提示文案,mode = range时有效
rangePrompt: {
type: String,
default: () => defProps.calendar.rangePrompt
},
// 范围选择超过最多可选天数时,是否展示提示文案,mode = range时有效
showRangePrompt: {
type: Boolean,
default: () => defProps.calendar.showRangePrompt
},
// 是否允许日期范围的起止时间为同一天,mode = range时有效
allowSameDay: {
type: Boolean,
default: () => defProps.calendar.allowSameDay
},
// 区间模式下确认返回值格式,all-返回区间内所有日期,boundary-仅返回起止日期
rangeResultMode: {
type: String,
default: () => defProps.calendar.rangeResultMode
},
// 是否开启时分秒选择
enableTime: {
type: Boolean,
default: () => defProps.calendar.enableTime
},
// 时间精度:hour-仅时,minute-时分,second-时分秒
timePrecision: {
type: String,
default: () => defProps.calendar.timePrecision
},
// 默认时间,格式支持 HH / HH:mm / HH:mm:ss
defaultTime: {
type: String,
default: () => defProps.calendar.defaultTime
},
// 圆角值
round: {
type: [Boolean, String, Number],
default: () => defProps.calendar.round
},
// 最多展示月份数量
monthNum: {
type: [Number, String],
default: 3
},
// 是否启用非滚动的单月切换模式
monthSwitch: {
type: Boolean,
default: () => defProps.calendar.monthSwitch
},
// 是否显示今天按钮
showToday: {
type: Boolean,
default: () => defProps.calendar.showToday
},
// 今天日期的独立高亮颜色,默认跟随主题色
todayColor: {
type: String,
default: () => defProps.calendar.todayColor
},
// 星期文案
weekText: {
type: Array,
default: defProps.calendar.weekText
},
forbidDays: {
type: Array,
default: defProps.calendar.forbidDays
},
forbidDaysToast:{
type: String,
default: defProps.calendar.forbidDaysToast
},
monthFormat:{
type: String,
default: defProps.calendar.monthFormat
},
// 是否页面内展示
pageInline:{
type: Boolean,
default: defProps.calendar.pageInline
}
}
})

View File

@@ -0,0 +1,879 @@
<template>
<u-popup
:show="show"
mode="bottom"
:closeable="!pageInline"
@close="close"
:round="round"
:pageInline="pageInline"
:closeOnClickOverlay="closeOnClickOverlay"
>
<view class="u-calendar">
<uHeader
:title="title"
:subtitle="subtitle"
:showSubtitle="showSubtitle"
:showTitle="showTitle"
:weekText="weekText"
:showSwitch="monthSwitch"
:showToday="showToday"
:todayText="todayText"
:todayDisabled="todayDisabled"
:prevDisabled="switchPrevDisabled"
:nextDisabled="switchNextDisabled"
:prevYearDisabled="switchPrevYearDisabled"
:nextYearDisabled="switchNextYearDisabled"
@prev="prevMonth"
@next="nextMonth"
@prevYear="prevYear"
@nextYear="nextYear"
@today="jumpToToday"
></uHeader>
<view v-if="showTimePanel" class="u-calendar__time-panel">
<view v-if="mode === 'single'" class="u-calendar__time-row">
<text class="u-calendar__time-date">{{ singleDateLabel }}</text>
<view class="u-calendar__time-trigger" @click="openTimePicker('single')">
<text class="u-calendar__time-text">{{ singleTime }}</text>
</view>
</view>
<view v-else-if="mode === 'range' && rangeResultMode === 'boundary'">
<view class="u-calendar__time-row">
<text class="u-calendar__time-date">{{ rangeStartDateLabel }}</text>
<view class="u-calendar__time-trigger" @click="openTimePicker('start')">
<text class="u-calendar__time-text">{{ rangeStartTime }}</text>
</view>
</view>
<view class="u-calendar__time-row">
<text class="u-calendar__time-date">{{ rangeEndDateLabel }}</text>
<view class="u-calendar__time-trigger" @click="openTimePicker('end')">
<text class="u-calendar__time-text">{{ rangeEndTime }}</text>
</view>
</view>
</view>
</view>
<scroll-view
v-if="!monthSwitch"
:style="{
height: addUnit(listHeight, 'px')
}"
scroll-y
@scroll="onScroll"
:scroll-top="scrollTop"
:scrollIntoView="scrollIntoView"
>
<uMonth
:color="color"
:rowHeight="rowHeight"
:showMark="showMark"
:months="months"
:mode="mode"
:maxCount="maxCount"
:startText="startText"
:endText="endText"
:defaultDate="defaultDate"
:minDate="innerMinDate"
:maxDate="innerMaxDate"
:maxMonth="monthNum"
:readonly="readonly"
:maxRange="maxRange"
:rangePrompt="rangePrompt"
:showRangePrompt="showRangePrompt"
:allowSameDay="allowSameDay"
:forbidDays="forbidDays"
:forbidDaysToast="forbidDaysToast"
:monthFormat="monthFormat"
:todayDate="todayDate"
:todayColor="todayColor"
ref="month"
@monthSelected="monthSelected"
@updateMonthTop="onUpdateMonthTop"
></uMonth>
</scroll-view>
<view
v-else
:style="{
height: addUnit(listHeight, 'px')
}"
>
<uMonth
:color="color"
:rowHeight="rowHeight"
:showMark="showMark"
:months="currentMonths"
:mode="mode"
:maxCount="maxCount"
:startText="startText"
:endText="endText"
:defaultDate="defaultDate"
:minDate="innerMinDate"
:maxDate="innerMaxDate"
:maxMonth="monthNum"
:readonly="readonly"
:maxRange="maxRange"
:rangePrompt="rangePrompt"
:showRangePrompt="showRangePrompt"
:allowSameDay="allowSameDay"
:forbidDays="forbidDays"
:forbidDaysToast="forbidDaysToast"
:monthFormat="monthFormat"
:todayDate="todayDate"
:todayColor="todayColor"
ref="month"
@monthSelected="monthSelected"
@updateMonthTop="onUpdateMonthTop"
></uMonth>
</view>
<slot name="footer" v-if="showConfirm">
<view class="u-calendar__confirm">
<u-button
shape="circle"
:text="
buttonDisabled ? confirmDisabledText : confirmText
"
:color="color"
@click="confirm"
:disabled="buttonDisabled"
></u-button>
</view>
</slot>
</view>
</u-popup>
<u-popup
:show="timePickerShow"
mode="center"
:round="8"
:closeOnClickOverlay="true"
@close="closeTimePicker"
>
<view class="u-calendar__time-picker">
<view class="u-calendar__time-picker__header">
<text class="u-calendar__time-picker__cancel" @click="closeTimePicker">取消</text>
<text class="u-calendar__time-picker__title">选择时间</text>
<text class="u-calendar__time-picker__confirm" @click="confirmTimePicker">确定</text>
</view>
<picker-view class="u-calendar__time-picker__body" :value="timePickerValue" @change="onTimePickerChange">
<picker-view-column>
<view v-for="(item, index) in hourOptions" :key="`h-${index}`" class="u-calendar__time-picker__item">{{ item }}</view>
</picker-view-column>
<picker-view-column v-if="timePrecision !== 'hour'">
<view v-for="(item, index) in minuteOptions" :key="`m-${index}`" class="u-calendar__time-picker__item">{{ item }}</view>
</picker-view-column>
<picker-view-column v-if="timePrecision === 'second'">
<view v-for="(item, index) in secondOptions" :key="`s-${index}`" class="u-calendar__time-picker__item">{{ item }}</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
</template>
<script>
import uHeader from './header.vue'
import uMonth from './month.vue'
import { props } from './props.js'
import util from './util.js'
import dayjs from '../u-datetime-picker/dayjs.esm.min.js';
import Calendar from '../../libs/util/calendar.js'
import { mpMixin } from '../../libs/mixin/mpMixin.js'
import { mixin } from '../../libs/mixin/mixin.js'
import { addUnit, getPx, range, error, padZero } from '../../libs/function/index';
import test from '../../libs/function/test';
import { t } from '../../libs/i18n'
/**
* Calendar 日历
* @description 此组件用于单个选择日期,范围选择日期等,日历被包裹在底部弹起的容器中.
* @tutorial https://uview-plus.jiangruyi.com/components/calendar.html
*
* @property {String} title 标题内容 (默认 日期选择 )
* @property {Boolean} showTitle 是否显示标题 (默认 true )
* @property {Boolean} showSubtitle 是否显示副标题 (默认 true )
* @property {String} mode 日期类型选择 single-选择单个日期,multiple-可以选择多个日期,range-选择日期范围 ( 默认 'single' )
* @property {String} startText mode=range时,第一个日期底部的提示文字 (默认 '开始' )
* @property {String} endText mode=range时,最后一个日期底部的提示文字 (默认 '结束' )
* @property {Array} customList 自定义列表
* @property {String} color 主题色,对底部按钮和选中日期有效 (默认 ‘#3c9cff' )
* @property {String | Number} minDate 最小的可选日期 (默认 0 )
* @property {String | Number} maxDate 最大可选日期 (默认 0 )
* @property {Array | String| Date} defaultDate 默认选中的日期,mode为multiple或range是必须为数组格式
* @property {String | Number} maxCount mode=multiple时,最多可选多少个日期 (默认 Number.MAX_SAFE_INTEGER )
* @property {String | Number} rowHeight 日期行高 (默认 56 )
* @property {Function} formatter 日期格式化函数
* @property {Boolean} showLunar 是否显示农历 (默认 false )
* @property {Boolean} showMark 是否显示月份背景色 (默认 true )
* @property {String} confirmText 确定按钮的文字 (默认 '确定' )
* @property {String} confirmDisabledText 确认按钮处于禁用状态时的文字 (默认 '确定' )
* @property {Boolean} show 是否显示日历弹窗 (默认 false )
* @property {Boolean} closeOnClickOverlay 是否允许点击遮罩关闭日历 (默认 false )
* @property {Boolean} readonly 是否为只读状态,只读状态下禁止选择日期 (默认 false )
* @property {String | Number} maxRange 日期区间最多可选天数,默认无限制,mode = range时有效
* @property {String} rangePrompt 范围选择超过最多可选天数时的提示文案,mode = range时有效
* @property {Boolean} showRangePrompt 范围选择超过最多可选天数时,是否展示提示文案,mode = range时有效 (默认 true )
* @property {Boolean} allowSameDay 是否允许日期范围的起止时间为同一天,mode = range时有效 (默认 false )
* @property {String} rangeResultMode 区间模式下确认返回值格式,all-返回区间内所有日期,boundary-仅返回起止日期 (默认 'all' )
* @property {Boolean} enableTime 是否开启时分秒选择 (默认 false )
* @property {String} timePrecision 时间精度,hour-仅时,minute-时分,second-时分秒 (默认 'minute' )
* @property {String} defaultTime 默认时间,支持HH/HH:mm/HH:mm:ss,不传则按00:00补齐
* @property {Number|String} round 圆角值,默认无圆角 (默认 0 )
* @property {Number|String} monthNum 最多展示的月份数量 (默认 3 )
* @property {Boolean} monthSwitch 是否启用非滚动的单月切换模式 (默认 false )
* @property {String} todayColor 今天日期的独立高亮颜色,默认跟随主题色
* @property {Array} weekText 星期文案 (默认 ['一', '二', '三', '四', '五', '六', '日'] )
*
* @event {Function()} confirm 点击确定按钮时触发 选择日期相关的返回参数
* @event {Function()} close 日历关闭时触发 可定义页面关闭时的回调事件
* @example <u-calendar :defaultDate="defaultDateMultiple" :show="show" mode="multiple" @confirm="confirm">
</u-calendar>
* */
export default {
name: 'u-calendar',
mixins: [mpMixin, mixin, props],
components: {
uHeader,
uMonth
},
data() {
return {
// 需要显示的月份的数组
months: [],
// 在月份滚动区域中,当前视图中月份的index索引
monthIndex: 0,
// 月份滚动区域的高度
listHeight: 0,
// month组件中选择的日期数组
selected: [],
scrollIntoView: '',
scrollIntoViewScroll: '',
scrollTop:0,
timePickerShow: false,
timePickerTarget: 'single',
timePickerValue: [0, 0, 0],
hourOptions: [],
minuteOptions: [],
secondOptions: [],
singleTime: '00:00',
rangeStartTime: '00:00',
rangeEndTime: '00:00',
// 过滤处理方法
innerFormatter: (value) => value
}
},
watch: {
scrollIntoView: {
immediate: true,
handler(n) {
// console.log('scrollIntoView', n)
}
},
selectedChange: {
immediate: true,
handler(n) {
this.setMonth()
}
},
timePrecision() {
this.initTimeOptions()
this.initTimeValues()
},
defaultTime() {
this.initTimeValues()
},
// 打开弹窗时,设置月份数据
show: {
immediate: true,
handler(n) {
if (n) {
this.setMonth()
} else {
// 关闭时重置scrollIntoView,否则会出现二次打开日历,当前月份数据显示不正确。
// scrollIntoView需要有一个值变动过程,才会产生作用。
this.scrollIntoView = ''
}
}
}
},
computed: {
// 由于maxDate和minDate可以为字符串(2021-10-10),或者数值(时间戳),但是dayjs如果接受字符串形式的时间戳会有问题,这里进行处理
innerMaxDate() {
return test.number(this.maxDate)
? Number(this.maxDate)
: this.maxDate
},
innerMinDate() {
return test.number(this.minDate)
? Number(this.minDate)
: this.minDate
},
todayDate() {
return dayjs().format('YYYY-MM-DD')
},
todayText() {
return t('up.calendar.today')
},
todayDisabled() {
const today = dayjs(this.todayDate)
const minDate = this.innerMinDate
? dayjs(this.innerMinDate)
: null
const maxDate = this.innerMaxDate
? dayjs(this.innerMaxDate)
: null
if (minDate && today.isBefore(minDate, 'day')) {
return true
}
if (maxDate && today.isAfter(maxDate, 'day')) {
return true
}
return false
},
// 多个条件的变化,会引起选中日期的变化,这里统一管理监听
selectedChange() {
return [this.innerMinDate, this.innerMaxDate, this.defaultDate]
},
subtitle() {
// 初始化时,this.months为空数组,所以需要特别判断处理
if (this.months.length) {
if (uni.getLocale() == 'zh-Hans' || uni.getLocale() == 'zh-Hant') {
return this.months[this.monthIndex].year + '年' + (this.months[this.monthIndex].month < 10 ? '0' + this.months[this.monthIndex].month : this.months[this.monthIndex].month) + '月'
} else {
return (this.months[this.monthIndex].month < 10 ? '0' + this.months[this.monthIndex].month : this.months[this.monthIndex].month) + '/' + this.months[this.monthIndex].year
}
} else {
return ''
}
},
currentMonths() {
if (this.monthSwitch && this.months.length) {
return [this.months[this.monthIndex]]
}
return this.months
},
switchPrevDisabled() {
return this.monthIndex <= 0
},
switchNextDisabled() {
return this.monthIndex >= this.months.length - 1
},
switchPrevYearDisabled() {
return this.monthIndex - 12 < 0
},
switchNextYearDisabled() {
return this.monthIndex + 12 > this.months.length - 1
},
showTimePanel() {
if (!this.enableTime) return false
if (this.mode === 'single') return true
if (this.mode === 'range' && this.rangeResultMode === 'boundary') {
return true
}
return false
},
singleDateLabel() {
return this.selected[0] || '--'
},
rangeStartDateLabel() {
return this.selected[0] || '--'
},
rangeEndDateLabel() {
if (this.selected.length >= 2) {
return this.selected[this.selected.length - 1]
}
return '--'
},
buttonDisabled() {
// 如果为range类型,且选择的日期个数不足1个时,让底部的按钮出于disabled状态
if (this.mode === 'range') {
if (this.selected.length <= 1) {
return true
} else {
return false
}
} else {
return false
}
}
},
mounted() {
this.start = Date.now()
this.init()
},
emits: ["confirm", "close"],
methods: {
addUnit,
padTime(num) {
return String(num).padStart(2, '0')
},
initTimeOptions() {
this.hourOptions = Array.from({ length: 24 }, (_, i) => this.padTime(i))
this.minuteOptions = Array.from({ length: 60 }, (_, i) => this.padTime(i))
this.secondOptions = Array.from({ length: 60 }, (_, i) => this.padTime(i))
},
parseTimeValue(value = '') {
const raw = String(value || '').trim()
const parts = raw.length ? raw.split(':') : []
const getNumber = (index, max) => {
const current = Number(parts[index] || 0)
if (Number.isNaN(current)) return 0
return Math.max(0, Math.min(max, current))
}
const hour = getNumber(0, 23)
const minute = getNumber(1, 59)
const second = getNumber(2, 59)
return [hour, minute, second]
},
getDefaultTimeValue() {
const [hour, minute, second] = this.parseTimeValue(this.defaultTime)
if (this.timePrecision === 'hour') {
return this.padTime(hour)
}
if (this.timePrecision === 'second') {
return `${this.padTime(hour)}:${this.padTime(minute)}:${this.padTime(second)}`
}
return `${this.padTime(hour)}:${this.padTime(minute)}`
},
initTimeValues() {
const value = this.getDefaultTimeValue()
this.singleTime = value
this.rangeStartTime = value
this.rangeEndTime = value
},
timeToPickerValue(value = '') {
const [hour, minute, second] = this.parseTimeValue(value)
return [hour, minute, second]
},
pickerValueToTime(value = []) {
const hour = Number(value[0] || 0)
const minute = Number(value[1] || 0)
const second = Number(value[2] || 0)
if (this.timePrecision === 'hour') {
return this.padTime(hour)
}
if (this.timePrecision === 'second') {
return `${this.padTime(hour)}:${this.padTime(minute)}:${this.padTime(second)}`
}
return `${this.padTime(hour)}:${this.padTime(minute)}`
},
openTimePicker(target) {
this.timePickerTarget = target
let currentValue = this.singleTime
if (target === 'start') currentValue = this.rangeStartTime
if (target === 'end') currentValue = this.rangeEndTime
this.timePickerValue = this.timeToPickerValue(currentValue)
this.timePickerShow = true
},
onTimePickerChange(e) {
this.timePickerValue = e.detail.value
},
closeTimePicker() {
this.timePickerShow = false
},
confirmTimePicker() {
const value = this.pickerValueToTime(this.timePickerValue)
if (this.timePickerTarget === 'single') this.singleTime = value
if (this.timePickerTarget === 'start') this.rangeStartTime = value
if (this.timePickerTarget === 'end') this.rangeEndTime = value
this.timePickerShow = false
},
timeToSecond(timeText = '') {
const [hour, minute, second] = this.parseTimeValue(timeText)
return hour * 3600 + minute * 60 + second
},
validateSameDayRangeTime() {
if (!this.enableTime || this.mode !== 'range' || this.rangeResultMode !== 'boundary') {
return true
}
if (this.selected.length < 2) return true
const startDate = this.selected[0]
const endDate = this.selected[this.selected.length - 1]
if (startDate !== endDate) return true
const startSeconds = this.timeToSecond(this.rangeStartTime)
const endSeconds = this.timeToSecond(this.rangeEndTime)
if (endSeconds < startSeconds) {
uni.showToast({
title: '结束时间不能早于开始时间',
icon: 'none'
})
return false
}
return true
},
appendTime(dateText, timeText) {
return `${dateText} ${timeText}`
},
getConfirmValue(selected = this.selected) {
let result = selected
if (
this.mode === 'range' &&
this.rangeResultMode === 'boundary' &&
selected.length >= 2
) {
const len = selected.length - 1
result = [selected[0], selected[len]]
}
if (!this.showTimePanel || !this.enableTime) {
return result
}
if (this.mode === 'single' && result.length >= 1) {
return [this.appendTime(result[0], this.singleTime)]
}
if (this.mode === 'range' && this.rangeResultMode === 'boundary' && result.length >= 2) {
return [
this.appendTime(result[0], this.rangeStartTime),
this.appendTime(result[1], this.rangeEndTime)
]
}
return result
},
// 在微信小程序中,不支持将函数当做props参数,故只能通过ref形式调用
setFormatter(e) {
this.innerFormatter = e
},
// month组件内部选择日期后,通过事件通知给父组件
monthSelected(e,scene ='init') {
this.selected = e
if (!this.showConfirm) {
// 在不需要确认按钮的情况下,如果为单选,或者范围多选且已选长度大于2,则直接进行返还
if (
this.mode === 'multiple' ||
this.mode === 'single' ||
(this.mode === 'range' && this.selected.length >= 2)
) {
if( scene === 'init'){
return
}
if( scene === 'tap') {
if (!this.validateSameDayRangeTime()) return
this.$emit('confirm', this.getConfirmValue())
}
}
}
},
init() {
// 校验maxDate,不能小于minDate。
if (
this.innerMaxDate &&
this.innerMinDate &&
new Date(this.innerMaxDate).getTime() < new Date(this.innerMinDate).getTime()
) {
return error('maxDate不能小于minDate时间')
}
// 滚动区域的高度
let bottomPadding = 0;
if (this.pageInline) {
bottomPadding = 0
} else {
bottomPadding = 30
}
this.listHeight = this.rowHeight * (this.monthSwitch ? 6 : 5) + bottomPadding
this.initTimeOptions()
this.initTimeValues()
this.setMonth()
},
close() {
this.$emit('close')
},
// 点击确定按钮
confirm() {
if (!this.buttonDisabled) {
if (!this.validateSameDayRangeTime()) return
this.$emit('confirm', this.getConfirmValue())
}
},
// 获得两个日期之间的月份数
getMonths(minDate, maxDate) {
const minYear = dayjs(minDate).year()
const minMonth = dayjs(minDate).month() + 1
const maxYear = dayjs(maxDate).year()
const maxMonth = dayjs(maxDate).month() + 1
return (maxYear - minYear) * 12 + (maxMonth - minMonth) + 1
},
// 设置月份数据
setMonth() {
// 最小日期的毫秒数
const minDate = this.innerMinDate || dayjs().valueOf()
// 如果没有指定最大日期,则往后推3个月
const maxDate =
this.innerMaxDate ||
dayjs(minDate)
.add(this.monthNum - 1, 'month')
.valueOf()
// 最大最小月份之间的共有多少个月份,
const months = range(
1,
this.monthNum,
this.getMonths(minDate, maxDate)
)
// 先清空数组
this.months = []
for (let i = 0; i < months; i++) {
this.months.push({
date: new Array(
dayjs(minDate).add(i, 'month').daysInMonth()
)
.fill(1)
.map((item, index) => {
// 日期,取值1-31
let day = index + 1
// 星期,0-6,0为周日
const week = dayjs(minDate)
.add(i, 'month')
.date(day)
.day()
const date = dayjs(minDate)
.add(i, 'month')
.date(day)
.format('YYYY-MM-DD')
let bottomInfo = ''
if (this.showLunar) {
// 将日期转为农历格式
const lunar = Calendar.solar2lunar(
dayjs(date).year(),
dayjs(date).month() + 1,
dayjs(date).date()
)
bottomInfo = lunar.IDayCn
}
let config = {
day,
week,
// 小于最小允许的日期,或者大于最大的日期,则设置为disabled状态
disabled:
dayjs(date).isBefore(
dayjs(minDate).format('YYYY-MM-DD')
) ||
dayjs(date).isAfter(
dayjs(maxDate).format('YYYY-MM-DD')
),
// 返回一个日期对象,供外部的formatter获取当前日期的年月日等信息,进行加工处理
date: new Date(date),
bottomInfo,
dot: false,
month:
dayjs(minDate).add(i, 'month').month() + 1
}
const formatter =
this.formatter || this.innerFormatter
return formatter(config)
}),
// 当前所属的月份
month: dayjs(minDate).add(i, 'month').month() + 1,
// 当前年份
year: dayjs(minDate).add(i, 'month').year()
})
}
if (this.monthSwitch) {
this.monthIndex = this.getDefaultMonthIndex()
}
},
getDefaultMonthIndex() {
let selected = dayjs().format('YYYY-MM')
if (this.defaultDate) {
if (!test.array(this.defaultDate)) {
selected = dayjs(this.defaultDate).format('YYYY-MM')
} else if (this.defaultDate.length) {
selected = dayjs(this.defaultDate[0]).format('YYYY-MM')
}
}
const index = this.months.findIndex(({ year, month }) => {
return `${year}-${padZero(month)}` === selected
})
return index === -1 ? 0 : index
},
prevMonth() {
if (!this.switchPrevDisabled) {
this.monthIndex -= 1
}
},
nextMonth() {
if (!this.switchNextDisabled) {
this.monthIndex += 1
}
},
prevYear() {
if (!this.switchPrevYearDisabled) {
this.monthIndex -= 12
}
},
nextYear() {
if (!this.switchNextYearDisabled) {
this.monthIndex += 12
}
},
jumpToToday() {
if (this.todayDisabled) {
return
}
const targetMonth = dayjs(this.todayDate).format('YYYY-MM')
const selectToday = () => {
if (this.mode === 'range') {
return
}
this.$refs.month && this.$refs.month.selectDate(this.todayDate)
}
if (this.monthSwitch) {
const todayMonthIndex = this.months.findIndex(({ year, month }) => {
return `${year}-${padZero(month)}` === targetMonth
})
if (todayMonthIndex !== -1) {
this.monthIndex = todayMonthIndex
this.$nextTick(selectToday)
}
return
}
this.scrollIntoDefaultMonth(targetMonth)
this.$nextTick(selectToday)
},
// 滚动到默认设置的月份
scrollIntoDefaultMonth(selected) {
// 查询默认日期在可选列表的下标
const _index = this.months.findIndex(({
year,
month
}) => {
month = padZero(month)
return `${year}-${month}` === selected
})
if (_index !== -1) {
// #ifndef MP-WEIXIN
this.$nextTick(() => {
this.scrollIntoView = ''
this.scrollIntoView = `month-${_index}`
this.scrollIntoViewScroll = this.scrollIntoView
})
// #endif
// #ifdef MP-WEIXIN
this.scrollTop = this.months[_index].top || 0;
// #endif
}
},
// scroll-view滚动监听
onScroll(event) {
// 不允许小于0的滚动值,如果scroll-view到顶了,继续下拉,会出现负数值
const scrollTop = Math.max(0, event.detail.scrollTop)
// 将当前滚动条数值,除以滚动区域的高度,可以得出当前滚动到了哪一个月份的索引
for (let i = 0; i < this.months.length; i++) {
if (scrollTop >= (this.months[i].top || this.listHeight)) {
this.monthIndex = i
this.scrollIntoViewScroll = `month-${i}`
}
}
},
// 更新月份的top值
onUpdateMonthTop(topArr = []) {
if (this.monthSwitch) {
return
}
this.updateMonthTop(topArr)
},
updateMonthTop(topArr = []) {
// 设置对应月份的top值,用于onScroll方法更新月份
topArr.map((item, index) => {
this.months[index].top = item
})
// 获取默认日期的下标
if (!this.defaultDate) {
// 如果没有设置默认日期,则将当天日期设置为默认选中的日期
const selected = dayjs().format("YYYY-MM")
this.scrollIntoDefaultMonth(selected)
return
}
let selected = dayjs().format("YYYY-MM");
// 单选模式,可以是字符串或数组,Date对象等
if (!test.array(this.defaultDate)) {
selected = dayjs(this.defaultDate).format("YYYY-MM")
} else {
selected = dayjs(this.defaultDate[0]).format("YYYY-MM");
}
this.scrollIntoDefaultMonth(selected)
}
}
}
</script>
<style lang="scss" scoped>
.u-calendar {
color: var(--up-main-color, $u-main-color);
background-color: var(--up-card-bg-color, #ffffff);
&__time-panel {
padding: 8px 16px 0;
}
&__time-row {
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
&__time-date {
font-size: 13px;
color: #303133;
}
&__time-trigger {
min-width: 92px;
height: 30px;
padding: 0 10px;
border-radius: 15px;
border: 1px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
}
&__time-text {
font-size: 14px;
color: #303133;
}
&__time-picker {
width: 320px;
background-color: #ffffff;
border-radius: 8px;
overflow: hidden;
}
&__time-picker__header {
height: 44px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f0f0;
}
&__time-picker__cancel {
font-size: 14px;
color: #909399;
}
&__time-picker__title {
font-size: 15px;
color: #303133;
font-weight: 600;
}
&__time-picker__confirm {
font-size: 14px;
color: #3c9cff;
}
&__time-picker__body {
width: 320px;
height: 200px;
}
&__time-picker__item {
height: 40px;
line-height: 40px;
text-align: center;
font-size: 16px;
color: #303133;
}
&__confirm {
padding: 7px 18px;
}
}
</style>

View File

@@ -0,0 +1,86 @@
import dayjs from '../u-datetime-picker/dayjs.esm.min.js';
export default {
methods: {
// 设置月份数据
setMonth() {
// 月初是周几
const day = dayjs(this.date).date(1).day()
const start = day == 0 ? 6 : day - 1
// 本月天数
const days = dayjs(this.date).endOf('month').format('D')
// 上个月天数
const prevDays = dayjs(this.date).endOf('month').subtract(1, 'month').format('D')
// 日期数据
const arr = []
// 清空表格
this.month = []
// 添加上月数据
arr.push(
...new Array(start).fill(1).map((e, i) => {
const day = prevDays - start + i + 1
return {
value: day,
disabled: true,
date: dayjs(this.date).subtract(1, 'month').date(day).format('YYYY-MM-DD')
}
})
)
// 添加本月数据
arr.push(
...new Array(days - 0).fill(1).map((e, i) => {
const day = i + 1
return {
value: day,
date: dayjs(this.date).date(day).format('YYYY-MM-DD')
}
})
)
// 添加下个月
arr.push(
...new Array(42 - days - start).fill(1).map((e, i) => {
const day = i + 1
return {
value: day,
disabled: true,
date: dayjs(this.date).add(1, 'month').date(day).format('YYYY-MM-DD')
}
})
)
// 分割数组
for (let n = 0; n < arr.length; n += 7) {
this.month.push(
arr.slice(n, n + 7).map((e, i) => {
e.index = i + n
// 自定义信息
const custom = this.customList.find((c) => c.date == e.date)
// 农历
if (this.lunar) {
const {
IDayCn,
IMonthCn
} = this.getLunar(e.date)
e.lunar = IDayCn == '初一' ? IMonthCn : IDayCn
}
return {
...e,
...custom
}
})
)
}
}
}
}

View File

@@ -0,0 +1,423 @@
<template>
<view class="u-canvas"
:id="rootId"
:style="{
width: useRootHeightAndWidth ? '100%' : 'auto',
height: useRootHeightAndWidth ? '100%' : 'auto',
}">
<!-- #ifdef MP || H5 -->
<canvas
class="u-canvas__canvas"
:id="canvasId"
:canvas-id="canvasId"
type="2d"
:style="{ width: width + unit, height: height + unit }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"/>
<!-- #endif -->
<!-- #ifdef APP-PLUS -->
<canvas
class="u-canvas__canvas"
:id="canvasId"
:canvas-id="canvasId"
:style="{ width: width + unit, height: height + unit }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"/>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<gcanvas class="u-canvas__canvas" ref="gcanvess"
:style="{ width: width + unit, height: height + unit }"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd">
</gcanvas>
<!-- #endif -->
</view>
</template>
<script>
// #ifdef APP-NVUE
// https://github.com/dcloudio/NvueCanvasDemo/blob/master/README.md
import {
enable,
WeexBridge,
Image as GImage
} from '../../libs/util/gcanvas/index.js';
// #endif
let canvasNode = null;
export default {
name: "u-canvas",
props: {
canvasId: {
type: String,
default: () => {
return `u-canvas${Math.floor(Math.random() * 1000000)}`
}
},
width: {
type: [String, Number],
default: 300
},
height: {
type: [String, Number],
default: 300
},
unit: {
type: String,
default: 'px'
},
useRootHeightAndWidth: {
type: Boolean,
default: false
},
// 背景色
bgColor: {
type: String,
default: '#ffffff'
}
},
data() {
return {
rootId: `rootId${Number(Math.random() * 100).toFixed(0)}`,
ganvas: null,
canvasContext: null,
widthLocal: this.width,
heightLocal: this.height,
ctx: null
};
},
computed: {
// 计算实际画布尺寸
actualWidth() {
return this.useRootHeightAndWidth ? this.widthLocal : Number(this.width);
},
actualHeight() {
return this.useRootHeightAndWidth ? this.heightLocal : Number(this.height);
}
},
methods: {
// 添加触摸事件处理方法
onTouchStart(e) {
this.$emit('touchstart', e);
},
onTouchMove(e) {
this.$emit('touchmove', e);
},
onTouchEnd(e) {
this.$emit('touchend', e);
},
/**
* 获取节点
* @param id 节点id
* @param isCanvas 是否为Canvas节点
* @return {Promise<unknown>}
*/
async getCanvasNode(id, isCanvas = true) {
let that = this
return new Promise((resolve, reject) => {
try {
// #ifdef APP-NVUE
setTimeout(() => {
/*获取元素引用*/
this.ganvas = this.$refs["gcanvess"]
/*通过元素引用获取canvas对象*/
let canvasNode = enable(this.ganvas, {
bridge: WeexBridge
})
resolve(canvasNode)
}, 200)
// #endif
// #ifndef APP-PLUS-NVUE
const query = uni.createSelectorQuery().in(that).select(`#${id}`);
query.fields({
node: true,
size: true
})
.exec((res) => {
if (isCanvas) {
if (res[0]?.node) {
resolve(res[0].node)
} else {
resolve(false)
console.error("获取节点出错", res)
}
} else {
resolve(res[0])
}
})
// #endif
} catch (e) {
console.error("获取节点失败", e)
}
})
},
/**
* 获取Canvas上下文
*/
getCanvasContext() {
// #ifdef APP-PLUS
return uni.createCanvasContext(this.canvasId, this);
// #endif
// #ifdef APP-PLUS-NVUE || MP || H5
return canvasNode.getContext('2d');
// #endif
},
/**
* 初始化Canvas
*/
async initCanvas() {
try {
canvasNode = await this.getCanvasNode(this.canvasId);
// #ifdef MP-WEIXIN
// 在微信小程序中,为了提高清晰度,需要考虑设备像素比
const dpr = uni.getSystemInfoSync().pixelRatio;
if(canvasNode) {
// 设置canvas实际绘制尺寸为显示尺寸的dpr倍
canvasNode.width = this.actualWidth * dpr;
canvasNode.height = this.actualHeight * dpr;
}
// #endif
this.ctx = this.getCanvasContext();
// #ifdef MP-WEIXIN
if(this.ctx) {
this.ctx.scale(dpr, dpr);
}
// #endif
// 初始化背景,但不在微信小程序中调用draw
this.clearCanvas();
} catch (error) {
console.error("初始化Canvas失败:", error);
}
},
/**
* 清空画布
*/
clearCanvas() {
if (!this.ctx) return;
this.clearRect(0, 0, this.actualWidth, this.actualHeight);
// 填充背景色
this.beginPath();
this.rect(0, 0, this.actualWidth, this.actualHeight);
this.setFillStyle(this.bgColor);
this.fill();
this.draw();
},
rect(x, y, width, height) {
if (!this.ctx) return;
this.ctx.rect(x, y, width, height);
},
clearRect(x, y, width, height) {
if (!this.ctx) return;
this.ctx.clearRect(x, y, width, height);
},
fill() {
if (!this.ctx) return;
this.ctx.fill();
},
setFillStyle(color) {
if (!this.ctx) return;
// #ifndef APP-PLUS-NVUE
if (this.ctx.setFillStyle) {
this.ctx.setFillStyle(color);
} else {
this.ctx.fillStyle = color;
}
// #endif
// #ifdef APP-PLUS-NVUE
this.ctx.setFillStyle(color);
// #endif
},
/**
* 设置线条样式
*/
setLineStyle(lineColor, lineWidth) {
if (!this.ctx) return;
this.setLineCap('round');
this.setLineJoin('round');
this.setStrokeStyle(lineColor);
this.setLineWidth(lineWidth);
},
setLineCap(lineCap = 'round') {
if (!this.ctx) return;
if (this.ctx.setLineCap) {
this.ctx.setLineCap(lineCap);
} else {
this.ctx.lineCap = lineCap;
}
},
setLineJoin(lineJoin = 'round') {
if (!this.ctx) return;
if (this.ctx.setLineJoin) {
this.ctx.setLineJoin(lineJoin);
} else {
this.ctx.lineJoin = lineJoin;
}
},
setStrokeStyle(color) {
if (!this.ctx) return;
if (this.ctx.setStrokeStyle) {
this.ctx.setStrokeStyle(color);
} else {
this.ctx.strokeStyle = color;
}
},
setLineWidth(width) {
if (!this.ctx) return;
if (this.ctx.setLineWidth) {
this.ctx.setLineWidth(width);
} else {
this.ctx.lineWidth = width;
}
},
/**
* 开始路径
*/
beginPath() {
if (!this.ctx) return;
this.ctx.beginPath();
},
/**
* 移动到某点
*/
moveTo(x, y) {
if (!this.ctx) return;
this.ctx.moveTo(x, y);
},
/**
* 画线到某点
*/
lineTo(x, y) {
if (!this.ctx) return;
this.ctx.lineTo(x, y);
},
/**
* 描边
*/
stroke() {
if (!this.ctx) return;
this.ctx.stroke();
},
/**
* 关闭路径
*/
closePath() {
if (!this.ctx) return;
this.ctx.closePath();
},
/**
* 绘制操作
*/
draw(isLastDraw = false) {
// #ifndef MP-WEIXIN
if (this.ctx && typeof this.ctx.draw === 'function') {
this.ctx.draw(isLastDraw);
}
// #endif
},
/**
* 导出图片
*/
exportImage(fileType = 'png', quality = 1) {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
// 微信小程序中需要先完成绘制,然后导出图片
setTimeout(() => {
uni.canvasToTempFilePath({
x: 0,
y: 0,
width: this.actualWidth,
height: this.actualHeight,
destWidth: this.actualWidth * 2, // 使用双倍尺寸以提高清晰度
destHeight: this.actualHeight * 2,
canvas: canvasNode, // 2d必须
canvasId: this.canvasId,
fileType: fileType,
quality: quality,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
console.error('导出图片失败:', err);
reject(err);
}
}, this);
}, 50); // 等待50毫秒确保绘制完成
// #endif
// #ifndef MP-WEIXIN
uni.canvasToTempFilePath({
canvas: canvasNode, // 2d必须
canvasId: this.canvasId,
fileType: fileType,
quality: quality,
success: (res) => {
resolve(res.tempFilePath);
},
fail: (err) => {
console.error('导出图片失败:', err);
reject(err);
}
}, this);
// #endif
});
},
/**
* 使用根节点宽高 设置新的size
* @return {Promise<void>}
*/
async setNewSize(){
const rootNode = await this.getCanvasNode(this.rootId, false);
const { width , height } = rootNode;
this.widthLocal = height;
this.heightLocal = width;
}
},
async mounted() {
// 如果使用根节点的宽高 则 重新设置 size
if(this.useRootHeightAndWidth){
await this.setNewSize();
}
// 初始化Canvas
await this.initCanvas();
}
};
</script>
<style lang="scss" scoped>
.u-canvas {
position: relative;
overflow: hidden;
}
.u-canvas__canvas {
display: block;
}
</style>

View File

@@ -0,0 +1,15 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/carKeyboard.js
*/
export default {
// 车牌号键盘
carKeyboard: {
random: false
}
}

View File

@@ -0,0 +1,17 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 是否打乱键盘按键的顺序
random: {
type: Boolean,
default: false
},
// 输入一个中文后,是否自动切换到英文
autoChange: {
type: Boolean,
default: false
}
}
})

View File

@@ -0,0 +1,314 @@
<template>
<view
class="u-keyboard"
@touchmove.stop.prevent="noop"
>
<view
v-for="(group, i) in abc ? engKeyBoardList : areaList"
:key="i"
class="u-keyboard__button"
:index="i"
:class="[i + 1 === 4 && 'u-keyboard__button--center']"
>
<view
v-if="i === 3"
class="u-keyboard__button__inner-wrapper"
>
<view
class="u-keyboard__button__inner-wrapper__left"
hover-class="u-hover-class"
:hover-stay-time="200"
@tap="changeCarInputMode"
>
<text
class="u-keyboard__button__inner-wrapper__left__lang"
:class="[!abc && 'u-keyboard__button__inner-wrapper__left__lang--active']"
>中</text>
<text class="u-keyboard__button__inner-wrapper__left__line">/</text>
<text
class="u-keyboard__button__inner-wrapper__left__lang"
:class="[abc && 'u-keyboard__button__inner-wrapper__left__lang--active']"
>英</text>
</view>
</view>
<view
class="u-keyboard__button__inner-wrapper"
v-for="(item, j) in group"
:key="j"
>
<view
class="u-keyboard__button__inner-wrapper__inner"
:hover-stay-time="200"
@tap="carInputClick(i, j)"
hover-class="u-hover-class"
>
<text class="u-keyboard__button__inner-wrapper__inner__text">{{ item }}</text>
</view>
</view>
<view
v-if="i === 3"
@touchstart="backspaceClick"
@touchend="clearTimer"
class="u-keyboard__button__inner-wrapper"
>
<view
class="u-keyboard__button__inner-wrapper__right"
hover-class="u-hover-class"
:hover-stay-time="200"
>
<up-icon
size="28"
name="backspace"
:color="upThemeVar('--up-main-color', '#303133')"
></up-icon>
</view>
</view>
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { randomArray, sleep } from '../../libs/function/index';
/**
* keyboard 键盘组件
* @description 此为uview-plus自定义的键盘面板,内含了数字键盘,车牌号键,身份证号键盘3种模式,都有可以打乱按键顺序的选项。
* @tutorial https://uview-plus.jiangruyi.com/components/keyboard.html
* @property {Boolean} random 是否打乱键盘的顺序
* @event {Function} change 点击键盘触发
* @event {Function} backspace 点击退格键触发
* @example <u-keyboard ref="uKeyboard" mode="car" v-model="show"></u-keyboard>
*/
export default {
name: "u-car-keyboard",
mixins: [mpMixin, mixin, props],
data() {
return {
// 车牌输入时,abc=true为输入车牌号码,bac=false为输入省份中文简称
abc: false
};
},
computed: {
areaList() {
let data = [
'京',
'沪',
'粤',
'津',
'冀',
'豫',
'云',
'辽',
'黑',
'湘',
'皖',
'鲁',
'苏',
'浙',
'赣',
'鄂',
'桂',
'甘',
'晋',
'陕',
'蒙',
'吉',
'闽',
'贵',
'渝',
'川',
'青',
'琼',
'宁',
'挂',
'藏',
'港',
'澳',
'新',
'使',
'学'
];
let tmp = [];
// 打乱顺序
if (this.random) data = randomArray(data);
// 切割成二维数组
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
},
engKeyBoardList() {
let data = [
1,
2,
3,
4,
5,
6,
7,
8,
9,
0,
'Q',
'W',
'E',
'R',
'T',
'Y',
'U',
'I',
'O',
'P',
'A',
'S',
'D',
'F',
'G',
'H',
'J',
'K',
'L',
'Z',
'X',
'C',
'V',
'B',
'N',
'M'
];
let tmp = [];
if (this.random) data = randomArray(data);
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
}
},
emits: ["change", "backspace"],
methods: {
// 点击键盘按钮
carInputClick(i, j) {
let value = '';
// 不同模式,获取不同数组的值
if (this.abc) value = this.engKeyBoardList[i][j];
else value = this.areaList[i][j];
// 如果允许自动切换,则将中文状态切换为英文
if (!this.abc && this.autoChange) sleep(200).then(() => this.abc = true)
this.$emit('change', value);
},
// 修改汽车牌键盘的输入模式,中文|英文
changeCarInputMode() {
this.abc = !this.abc;
},
// 点击退格键
backspaceClick() {
this.$emit('backspace');
clearInterval(this.timer); //再次清空定时器,防止重复注册定时器
this.timer = null;
this.timer = setInterval(() => {
this.$emit('backspace');
}, 250);
},
clearTimer() {
clearInterval(this.timer);
this.timer = null;
},
}
};
</script>
<style lang="scss" scoped>
$u-car-keyboard-background-color: var(--up-bg-color, rgb(224, 228, 230)) !default;
$u-car-keyboard-padding:6px 0 6px !default;
$u-car-keyboard-button-inner-width:64rpx !default;
$u-car-keyboard-button-inner-background-color:var(--up-card-bg-color, #ffffff) !default;
$u-car-keyboard-button-height:80rpx !default;
$u-car-keyboard-button-inner-box-shadow:0 1px 0px #999992 !default;
$u-car-keyboard-button-border-radius:4px !default;
$u-car-keyboard-button-inner-margin:8rpx 5rpx !default;
$u-car-keyboard-button-text-font-size:16px !default;
$u-car-keyboard-button-text-color:var(--up-main-color, #303133) !default;
$u-car-keyboard-center-inner-margin: 0 4rpx !default;
$u-car-keyboard-special-button-width:134rpx !default;
$u-car-keyboard-lang-font-size:16px !default;
$u-car-keyboard-lang-color:var(--up-main-color, #303133) !default;
$u-car-keyboard-active-color:$u-primary !default;
$u-car-keyboard-line-font-size:15px !default;
$u-car-keyboard-line-color:var(--up-main-color, #303133) !default;
$u-car-keyboard-line-margin:0 1px !default;
$u-car-keyboard-u-hover-class-background-color:var(--up-border-color, #BBBCC6) !default;
.u-keyboard {
@include flex(column);
justify-content: space-around;
background-color: $u-car-keyboard-background-color;
align-items: stretch;
padding: $u-car-keyboard-padding;
&__button {
@include flex;
justify-content: center;
flex: 1;
/* #ifndef APP-NVUE */
/* #endif */
&__inner-wrapper {
box-shadow: $u-car-keyboard-button-inner-box-shadow;
margin: $u-car-keyboard-button-inner-margin;
border-radius: $u-car-keyboard-button-border-radius;
&__inner {
@include flex;
justify-content: center;
align-items: center;
width: $u-car-keyboard-button-inner-width;
background-color: $u-car-keyboard-button-inner-background-color;
height: $u-car-keyboard-button-height;
border-radius: $u-car-keyboard-button-border-radius;
&__text {
font-size: $u-car-keyboard-button-text-font-size;
color: $u-car-keyboard-button-text-color;
}
}
&__left,
&__right {
border-radius: $u-car-keyboard-button-border-radius;
width: $u-car-keyboard-special-button-width;
height: $u-car-keyboard-button-height;
background-color: $u-car-keyboard-u-hover-class-background-color;
@include flex;
justify-content: center;
align-items: center;
box-shadow: $u-car-keyboard-button-inner-box-shadow;
}
&__left {
&__line {
font-size: $u-car-keyboard-line-font-size;
color: $u-car-keyboard-line-color;
margin: $u-car-keyboard-line-margin;
}
&__lang {
font-size: $u-car-keyboard-lang-font-size;
color: $u-car-keyboard-lang-color;
&--active {
color: $u-car-keyboard-active-color;
}
}
}
}
}
}
.u-hover-class {
background-color: $u-car-keyboard-u-hover-class-background-color;
}
</style>

View File

@@ -0,0 +1,40 @@
/*
* @Author : jry
* @Description :
* @version : 3.0
* @Date : 2025-04-26 16:37:21
* @LastAuthor : jry
* @lastTime : 2025-04-26 16:37:21
* @FilePath : /uview-plus/libs/config/props/card.js
*/
export default {
// card组件的props
card: {
full: false,
title: '',
titleColor: '#303133',
titleSize: '15px',
subTitle: '',
subTitleColor: '#909399',
subTitleSize: '13px',
border: true,
index: '',
margin: '15px',
borderRadius: '8px',
headStyle: {},
bodyStyle: {},
footStyle: {},
headBorderBottom: true,
footBorderTop: true,
thumb: '',
thumbWidth: '30px',
thumbCircle: false,
padding: '15px',
paddingHead: '',
paddingBody: '',
paddingFoot: '',
showHead: true,
showFoot: true,
boxShadow: 'none'
}
}

View File

@@ -0,0 +1,134 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const propsCard = defineMixin({
props: {
// 与屏幕两侧是否留空隙
full: {
type: Boolean,
default: () => defProps.card.full
},
// 标题
title: {
type: String,
default: () => defProps.card.title
},
// 标题颜色
titleColor: {
type: String,
default: () => defProps.card.titleColor
},
// 标题字体大小
titleSize: {
type: [Number, String],
default: () => defProps.card.titleSize
},
// 副标题
subTitle: {
type: String,
default: () => defProps.card.subTitle
},
// 副标题颜色
subTitleColor: {
type: String,
default: () => defProps.card.subTitleColor
},
// 副标题字体大小
subTitleSize: {
type: [Number, String],
default: () => defProps.card.subTitleSize
},
// 是否显示外部边框,只对full=false时有效(卡片与边框有空隙时)
border: {
type: Boolean,
default: () => defProps.card.border
},
// 用于标识点击了第几个
index: {
type: [Number, String, Object],
default: () => defProps.card.index
},
// 用于隔开上下左右的边距,带单位的写法,如:"30px 30px","20px 20px 30px 30px"
margin: {
type: String,
default: () => defProps.card.margin
},
// card卡片的圆角
borderRadius: {
type: [Number, String],
default: () => defProps.card.borderRadius
},
// 头部自定义样式,对象形式
headStyle: {
type: Object,
default: () => defProps.card.headStyle
},
// 主体自定义样式,对象形式
bodyStyle: {
type: Object,
default: () => defProps.card.bodyStyle
},
// 底部自定义样式,对象形式
footStyle: {
type: Object,
default: () => defProps.card.footStyle
},
// 头部是否下边框
headBorderBottom: {
type: Boolean,
default: () => defProps.card.headBorderBottom
},
// 底部是否有上边框
footBorderTop: {
type: Boolean,
default: () => defProps.card.footBorderTop
},
// 标题左边的缩略图
thumb: {
type: String,
default: () => defProps.card.thumb
},
// 缩略图宽高
thumbWidth: {
type: [String, Number],
default: () => defProps.card.thumbWidth
},
// 缩略图是否为圆形
thumbCircle: {
type: Boolean,
default: () => defProps.card.thumbCircle
},
// 给head,body,foot的内边距
padding: {
type: [String, Number],
default: () => defProps.card.padding
},
paddingHead: {
type: [String, Number],
default: () => defProps.card.paddingHead
},
paddingBody: {
type: [String, Number],
default: () => defProps.card.paddingBody
},
paddingFoot: {
type: [String, Number],
default: () => defProps.card.paddingFoot
},
// 是否显示头部
showHead: {
type: Boolean,
default: () => defProps.card.showHead
},
// 是否显示尾部
showFoot: {
type: Boolean,
default: () => defProps.card.showFoot
},
// 卡片外围阴影,字符串形式
boxShadow: {
type: String,
default: () => defProps.card.boxShadow
}
}
})

View File

@@ -0,0 +1,184 @@
<template>
<view
class="u-card"
@tap.stop="click"
:class="{ 'u-border': border, 'u-card-full': full, 'u-card--border': getPx(borderRadius) > 0 }"
:style="{
borderRadius: addUnit(borderRadius),
margin: margin,
boxShadow: boxShadow
}"
>
<view
v-if="showHead"
class="u-card__head"
:style="[{padding: addUnit(paddingHead || padding)}, headStyle]"
:class="{
'u-border-bottom': headBorderBottom
}"
@tap="headClick"
>
<view v-if="!$slots.head" class="u-flex u-flex-between">
<view class="u-card__head--left u-flex u-line-1" v-if="title">
<image
:src="thumb"
class="u-card__head--left__thumb"
mode="aspectFill"
v-if="thumb"
:style="{
height: addUnit(thumbWidth),
width: addUnit(thumbWidth),
borderRadius: thumbCircle ? '50px' : '4px'
}"
></image>
<text
class="u-card__head--left__title u-line-1"
:style="{
fontSize: addUnit(titleSize),
color: titleColor
}"
>
{{ title }}
</text>
</view>
<view class="u-card__head--right u-line-1" v-if="subTitle">
<text
class="u-card__head__title__text"
:style="{
fontSize: addUnit(subTitleSize),
color: subTitleColor
}"
>
{{ subTitle }}
</text>
</view>
</view>
<slot name="head" v-else />
</view>
<view @tap="bodyClick" class="u-card__body"
:style="[{padding: addUnit(paddingBody || padding)}, bodyStyle]"><slot name="body" /></view>
<view
v-if="showFoot"
class="u-card__foot"
@tap="footClick"
:style="[{padding: $slots.foot ? addUnit(paddingFoot || padding) : 0}, footStyle]"
:class="{
'u-border-top': footBorderTop
}"
>
<slot name="foot" />
</view>
</view>
</template>
<script>
import { propsCard } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit, getPx } from '../../libs/function/index';
/**
* card 卡片
* @description 卡片组件一般用于多个列表条目,且风格统一的场景
* @tutorial https://uview-plus.jiangruyi.com/components/card.html
* @property {Boolean} full 卡片与屏幕两侧是否留空隙(默认false)
* @property {String} title 头部左边的标题
* @property {String} title-color 标题颜色(默认#303133)
* @property {String | Number} title-size 标题字体大小,单位rpx(默认15px)
* @property {String} sub-title 头部右边的副标题
* @property {String} sub-title-color 副标题颜色(默认#909399)
* @property {String | Number} sub-title-size 副标题字体大小(默认13px
* @property {Boolean} border 是否显示边框(默认true)
* @property {String | Number} index 用于标识点击了第几个卡片
* @property {String} box-shadow 卡片外围阴影,字符串形式(默认none)
* @property {String} margin 卡片与屏幕两边和上下元素的间距,需带单位,如"30px 20px"(默认15px)
* @property {String | Number} border-radius 卡片整体的圆角值,单位rpx(默认8px)
* @property {Object} head-style 头部自定义样式,对象形式
* @property {Object} body-style 中部自定义样式,对象形式
* @property {Object} foot-style 底部自定义样式,对象形式
* @property {Boolean} head-border-bottom 是否显示头部的下边框(默认true)
* @property {Boolean} foot-border-top 是否显示底部的上边框(默认true)
* @property {Boolean} show-head 是否显示头部(默认true)
* @property {Boolean} show-foot 是否显示尾部(默认true)
* @property {String} thumb 缩略图路径,如设置将显示在标题的左边,不建议使用相对路径
* @property {String | Number} thumb-width 缩略图的宽度,高等于宽,单位px(默认30px)
* @property {Boolean} thumb-circle 缩略图是否为圆形(默认false)
* @event {Function} click 整个卡片任意位置被点击时触发
* @event {Function} head-click 卡片头部被点击时触发
* @event {Function} body-click 卡片主体部分被点击时触发
* @event {Function} foot-click 卡片底部部分被点击时触发
* @example <u-card paddingFoot="2px 15px" title="card"></u-card>
*/
export default {
name: 'up-card',
data() {
return {};
},
mixins: [mpMixin, mixin, propsCard],
emits: ['click', 'head-click', 'body-click', 'foot-click'],
methods: {
addStyle,
addUnit,
getPx,
click() {
this.$emit('click', this.index);
},
headClick() {
this.$emit('head-click', this.index);
},
bodyClick() {
this.$emit('body-click', this.index);
},
footClick() {
this.$emit('foot-click', this.index);
}
}
};
</script>
<style lang="scss" scoped>
.u-card {
position: relative;
overflow: hidden;
font-size: 28rpx;
background-color: var(--up-card-bg-color, #ffffff);
box-sizing: border-box;
&-full {
// 如果是与屏幕之间不留空隙,应该设置左右边距为0
margin-left: 0 !important;
margin-right: 0 !important;
width: 100%;
}
&--border:after {
border-radius: 16rpx;
}
&__head {
&--left {
color: $u-main-color;
&__thumb {
margin-right: 16rpx;
}
&__title {
max-width: 400rpx;
}
}
&--right {
color: $u-tips-color;
margin-left: 6rpx;
}
}
&__body {
color: $u-content-color;
}
&__foot {
color: $u-tips-color;
}
}
</style>

View File

@@ -0,0 +1,371 @@
<template>
<up-popup :show="popupShow" mode="bottom" :popup="false"
:mask="true" :closeable="closeable" :safe-area-inset-bottom="true"
:close-icon-color="closeIconColor" :z-index="uZIndex"
:maskCloseAble="maskCloseAble" @close="close">
<view class="up-p-t-30 up-p-l-20 up-m-b-10" v-if="headerDirection ==='column'">
<up-steps v-if="popupShow" dot direction="column" v-model:current="tabsIndex">
<view v-for="(item, index) in genTabsList" @click="toFatherIndex(index)">
<up-steps-item
:title="item.name"/>
</view>
</up-steps>
</view>
<view class="up-p-t-20 up-m-b-10" v-else>
<up-tabs v-if="popupShow" :list="genTabsList"
:scrollable="true" v-model:current="tabsIndex" @change="tabsChange" ref="tabs"></up-tabs>
</view>
<view class="area-box">
<view class="u-flex" :class="{ 'change':isChange }"
:style="{transform: optionsCols == 2 && isChange ? 'translateX(-33.3333333%)' : ''}">
<template v-for="(levelData, levelIndex) in levelList" :key="levelIndex">
<view v-if="optionsCols == 2 || levelIndex == tabsIndex" class="area-item"
:style="{ width: optionsCols == 2 ? '33.33333%' : '750rpx'}">
<view class="u-padding-10" :style="[levelPaneStyle, { height: '100%' }]">
<scroll-view :scroll-y="true" style="height: 100%">
<up-cell-group v-if="levelIndex === 0 || selectedValueIndexs[levelIndex - 1] !== undefined">
<up-cell v-for="(item,index) in levelData"
:title="item[labelKey]" :arrow="false"
:index="index" :key="index"
@click="levelChange(levelIndex, index)">
<template v-slot:right-icon>
<up-icon v-if="selectedValueIndexs[levelIndex] === index"
size="17" name="checkbox-mark"></up-icon>
</template>
</up-cell>
</up-cell-group>
</scroll-view>
</view>
</view>
</template>
</view>
</view>
<!-- 添加按钮区域 -->
<view class="u-cascader-action up-flex up-flex-between">
<view class="u-padding-20 up-flex-fill">
<up-button @click="handleCancel" type="default">{{ t("up.common.cancel") }}</up-button>
</view>
<view class="u-padding-20 up-flex-fill">
<up-button @click="handleConfirm" type="primary">{{ t("up.common.confirm") }}</up-button>
</view>
</view>
</up-popup>
</template>
<script>
/**
* u-cascader 通用无限级联选择器
* @property {String Number} z-index 弹出时的z-index值(默认1075)
* @property {Boolean} mask-close-able 是否允许通过点击遮罩关闭Picker(默认true)
* @property {Array} data 级联数据
* @property {Array} default-value 默认选中的值
* @property {String} valueKey 指定选项的值为选项对象中的哪个属性值
* @property {String} labelKey 指定选项标签为选项对象中的哪个属性值
* @property {String} childrenKey 指定选项的子选项为选项对象中的哪个属性值
* @property {Boolean} autoClose 是否在选择最后一级时自动关闭并触发confirm(默认false)
* @property {Boolean} closeable 是否显示关闭图标(默认true)
*/
import { t } from '../../libs/i18n'
export default {
name: 'up-cascader',
props: {
// 通过双向绑定控制组件的弹出与收起
show: {
type: Boolean,
default: false
},
// 级联数据
data: {
type: Array,
default() {
return [];
}
},
// 默认选中的值
modelValue: {
type: Array,
default() {
return [];
}
},
// 指定选项的值为选项对象中的哪个属性值
valueKey: {
type: String,
default: 'value'
},
// 指定选项标签为选项对象中的哪个属性值
labelKey: {
type: String,
default: 'label'
},
// 指定选项的子选项为选项对象中的哪个属性值
childrenKey: {
type: String,
default: 'children'
},
// 是否允许通过点击遮罩关闭Picker
maskCloseAble: {
type: Boolean,
default: true
},
// 弹出的z-index值
zIndex: {
type: [String, Number],
default: 0
},
// 是否在选择最后一级时自动关闭并触发confirm
autoClose: {
type: Boolean,
default: false
},
// 选中项目的展示方向direction垂直方向适合文字长度过长
headerDirection: {
type: String,
default: 'row'
},
// 选项区域列数,支持1列和2列,默认为2列
optionsCols: {
type: [Number],
default: 2
},
// 是否显示关闭图标
closeable: {
type: Boolean,
default: true
}
},
data() {
return {
// 存储每一级的数据
levelList: [],
// 存储每一级选中的索引
selectedValueIndexs: [],
tabsIndex: 0,
popupShow: false,
// 新增confirmValues用于存储确认的值
confirmValues: []
}
},
watch: {
data: {
handler() {
this.initLevelList();
this.setDefaultValue();
},
immediate: true
},
show() {
this.popupShow = this.show;
},
modelValue: {
handler() {
// 初始化选中值
this.setDefaultValue();
},
immediate: true
}
},
computed: {
isChange() {
return this.tabsIndex > 1;
},
genTabsList() {
let tabsList = [{
name: "请选择"
}];
// 根据选中的值动态生成tabs
for (let i = 0; i < this.selectedValueIndexs.length; i++) {
if (this.selectedValueIndexs[i] !== undefined && this.levelList[i]) {
const selectedItem = this.levelList[i][this.selectedValueIndexs[i]];
if (selectedItem) {
tabsList[i] = {
name: selectedItem[this.labelKey]
};
// 如果还有下一级,则添加"请选择"
if (i === this.selectedValueIndexs.length - 1 &&
selectedItem[this.childrenKey] &&
selectedItem[this.childrenKey].length > 0) {
tabsList.push({
name: "请选择"
});
}
}
}
}
return tabsList;
},
uZIndex() {
// 如果用户有传递z-index值,优先使用
return this.zIndex ? this.zIndex : this.$u.zIndex.popup;
},
closeIconColor() {
return 'var(--up-main-color, #303133)';
},
levelPaneStyle() {
return {
backgroundColor: 'var(--up-bg-color, #f7f7f7)'
};
}
},
// 新增confirm事件
emits: ['update:modelValue', 'update:show', 'change', 'confirm', 'cancel'],
methods: {
t,
getSelectedValues() {
const result = [];
for (let i = 0; i < this.selectedValueIndexs.length; i++) {
const selectedIndex = this.selectedValueIndexs[i];
if (selectedIndex === undefined) continue;
if (!this.levelList[i] || !this.levelList[i][selectedIndex]) continue;
result.push(this.levelList[i][selectedIndex][this.valueKey]);
}
return result;
},
initLevelList() {
// 初始化第一级数据
if (this.data && this.data.length > 0) {
this.levelList = [this.data];
this.selectedValueIndexs = [];
}
},
setDefaultValue() {
// 检查data是否为空
if (!this.data || this.data.length == 0) return;
// 检查modelValue是否为空
if (!this.modelValue || this.modelValue.length == 0) {
this.confirmValues = [];
return;
}
// 根据默认值设置选中项
// 根据modelValue获取indexs给selectedValueIndexs
this.selectedValueIndexs = [];
this.levelList = []; // 设置层级数据为空
let currentLevelData = this.data;
for (let i = 0; i < this.modelValue.length; i++) {
const value = this.modelValue[i];
const index = currentLevelData.findIndex(item => item[this.valueKey] === value);
this.levelList[i] = currentLevelData; // 设置每一层级的数据
if (index !== -1) {
this.selectedValueIndexs.push(index);
// 更新下一级的数据
if (currentLevelData[index][this.childrenKey]) {
currentLevelData = currentLevelData[index][this.childrenKey];
} else {
// 如果没有子级数据,则停止处理
break;
}
} else {
// 如果找不到匹配项,则停止处理
break;
}
}
// 同步确认值,避免“仅回显未改动时确认返回空数组”
this.confirmValues = this.getSelectedValues();
},
close() {
this.$emit('cancel');
this.$emit('update:show', false);
},
tabsChange(item) {
},
levelChange(levelIndex, index) {
// 设置当前级的选中值
this.$set(this.selectedValueIndexs, levelIndex, index);
// 清除后续级别的选中值
this.selectedValueIndexs.splice(levelIndex + 1);
this.tabsIndex = Math.min(this.tabsIndex, levelIndex);
// 清除后续级别的列表
this.levelList.splice(levelIndex + 1);
// 获取当前选中项
const currentItem = this.levelList[levelIndex][index];
// 如果有子级数据,则初始化下一级
if (currentItem && currentItem[this.childrenKey] && currentItem[this.childrenKey].length > 0) {
// 确保levelList数组足够长
if (this.levelList.length <= levelIndex + 1) {
this.levelList.push(currentItem[this.childrenKey]);
} else {
this.$set(this.levelList, levelIndex + 1, currentItem[this.childrenKey]);
}
// 切换到下一级tab
this.tabsIndex = levelIndex + 1;
} else {
// 没有子级数据,说明是最后一级
if (this.autoClose) {
// 如果启用自动关闭,则触发change事件并关闭
this.emitChange();
this.handleConfirm();
} else {
// 否则只触发change事件,不关闭
this.emitChange(false);
}
}
},
// 修改emitChange方法,增加closePopup参数
emitChange(closePopup = true) {
// 构造选中结果
const result = this.getSelectedValues();
// 更新confirmValues
this.confirmValues = [...result];
// 触发change事件,返回value数组
this.$emit('change', this.confirmValues);
// 根据参数决定是否关闭弹窗
if (closePopup) {
this.close();
}
},
handleCancel() {
this.close();
},
handleConfirm() {
const values = this.confirmValues.length ? this.confirmValues : this.getSelectedValues();
this.confirmValues = [...values];
// 确认时触发confirm事件
this.$emit('update:modelValue', values);
this.$emit('confirm', values);
this.close();
},
// 跳转父节点
toFatherIndex(index){
this.tabsIndex = index;
},
}
}
</script>
<style lang="scss">
.area-box {
width: 100%;
overflow: hidden;
height: 800rpx;
>view {
width: 150%;
transition: transform 0.3s ease-in-out 0s;
transform: translateX(0);
&.change {
// transform: translateX(-33.3333333%);
}
}
.area-item {
// width: 750rpx;
height: 800rpx;
}
}
// 添加按钮区域样式
.u-cascader-action {
border-top: 1px solid var(--up-border-color, #eee);
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<view class="u-cate-tab" :style="{ height: addUnit(height) }">
<view class="u-cate-tab__wrap">
<scroll-view class="u-cate-tab__view u-cate-tab__menu-scroll-view"
scroll-y scroll-with-animation :scroll-top="scrollTop"
:scroll-into-view="itemId">
<view v-for="(item, index) in tabList" :key="index" class="u-cate-tab__item"
:class="[innerCurrent == index ? 'u-cate-tab__item-active' : '']"
@tap.stop="swichMenu(index)">
<slot name="tabItem" :item="item">
</slot>
<text v-if="!$slots['tabItem']" class="u-line-1">{{item[tabKeyName]}}</text>
</view>
</scroll-view>
<scroll-view :scroll-top="scrollRightTop" scroll-with-animation :scroll-into-view="scrollIntoView"
scroll-y class="u-cate-tab__right-box" @scroll="rightScroll">
<view class="u-cate-tab__right-top">
<slot name="rightTop" :tabList="tabList">
</slot>
</view>
<view class="u-cate-tab__page-view">
<template :key="index" v-for="(item , index) in tabList">
<view v-if="mode == 'follow' || ( mode == 'tab' && index == innerCurrent)"
class="u-cate-tab__page-item" :id="'item' + index">
<slot name="itemList" :item="item">
</slot>
<template v-if="!$slots['itemList']">
<view class="item-title">
<text>{{item[tabKeyName]}}</text>
</view>
<view class="item-container">
<template v-for="(item1, index1) in item.children" :key="index1">
<slot name="pageItem" :pageItem="item1">
<view class="thumb-box" >
<image class="item-menu-image" :src="item1.icon" mode=""></image>
<view class="item-menu-name">{{item1[itemKeyName]}}</view>
</view>
</slot>
</template>
</view>
</template>
</view>
</template>
</view>
</scroll-view>
</view>
</view>
</template>
<script>
import { addUnit, sleep } from '../../libs/function/index';
export default {
name: 'up-cate-tab',
props: {
mode: {
type: String,
default: 'follow' // follo跟随联动, tab单一显示。
},
height: {
type: String,
default: '100%'
},
tabList: {
type: Array,
default: () => {
return []
}
},
tabKeyName: {
type: String,
default: 'name'
},
itemKeyName: {
type: String,
default: 'name'
},
current: {
type: Number,
default: 0
}
},
watch: {
tabList: {
deep: true,
handler(newVal, oldVal) {
// this.observer();
sleep(30).then(() => {
this.getMenuItemTop();
this.leftMenuStatus(this.innerCurrent);
})
}
},
current(nval) {
this.innerCurrent = nval;
this.leftMenuStatus(this.innerCurrent);
sleep(30).then(() => {
this.swichMenu(this.innerCurrent)
})
},
height() {
// console.log('height change');
this.getMenuItemTop();
this.leftMenuStatus(this.innerCurrent);
}
},
emits: ['update:current'],
data() {
return {
scrollTop: 0, //tab标题的滚动条位置
scrollIntoView: '', // 滚动至哪个元素
oldScrollTop: 0,
innerCurrent: 0, // 预设当前项的值
menuHeight: 0, // 左边菜单的高度
menuItemHeight: 0, // 左边菜单item的高度
itemId: '', // 栏目右边scroll-view用于滚动的id
menuItemPos: [],
rects: [],
arr: [],
scrollRightTop: 0, // 右边栏目scroll-view的滚动条高度
timer: null, // 定时器
}
},
mounted() {
// this.observer();
this.innerCurrent = this.current;
this.leftMenuStatus(this.innerCurrent);
this.getMenuItemTop()
// 设置默认index
sleep(50).then(() => {
this.swichMenu(this.innerCurrent)
})
},
methods: {
addUnit,
// 点击左边的栏目切换
async swichMenu(index) {
if (this.mode == 'follow') {
if(this.arr.length == 0) {
await this.getMenuItemTop();
}
if (this.scrollIntoView != 'item' + index) {
this.scrollIntoView = 'item' + index;
}
}
if (index == this.innerCurrent) return;
this.$nextTick(function(){
this.innerCurrent = index;
this.$emit('update:current', index);
})
},
// 获取一个目标元素的高度
getElRect(elClass, dataVal) {
return new Promise((resolve, reject) => {
const query = uni.createSelectorQuery().in(this);
query.select('.' + elClass).fields({
size: true
}, res => {
// 如果节点尚未生成,res值为null,循环调用执行
if (!res) {
setTimeout(() => {
this.getElRect(elClass);
}, 10);
return;
}
this[dataVal] = res.height;
resolve();
}).exec();
})
},
// 观测元素相交状态
async observer() {
await this.$nextTick();
// 清除之前的观察器
if (this._observerList) {
this._observerList.forEach(observer => {
observer.disconnect();
});
}
this._observerList = [];
this.tabList.map((val, index) => {
let observer = uni.createIntersectionObserver(this);
this._observerList.push(observer);
// 检测相交状态
observer.relativeTo('.u-cate-tab__right-box', {
top: 10
}).observe('#item' + index, (res) => {
if (res.intersectionRatio > 0) {
console.log('res', res);
// 修复:确保正确获取索引
let id = res.id ? res.id.substring(4) : index;
this.leftMenuStatus(parseInt(id));
}
})
})
},
// 设置左边菜单的滚动状态
async leftMenuStatus(index) {
this.innerCurrent = index;
this.$emit('update:current', index);
// 如果为0,意味着尚未初始化
if (this.menuHeight == 0 || this.menuItemHeight == 0) {
await this.getElRect('u-cate-tab__menu-scroll-view', 'menuHeight');
await this.getElRect('u-cate-tab__item', 'menuItemHeight');
}
// console.log(this.menuHeight, this.menuItemHeight)
// 将菜单活动item垂直居中
this.scrollTop = index * this.menuItemHeight + this.menuItemHeight / 2 - this.menuHeight / 2;
},
// 获取右边菜单每个item到顶部的距离
async getMenuItemTop() {
// await this.$nextTick();
// console.log('getMenuItemTop')
return new Promise(resolve => {
let selectorQuery = uni.createSelectorQuery().in(this);
selectorQuery.selectAll('.u-cate-tab__page-item').boundingClientRect((rects) => {
// 如果节点尚未生成,rects值为[](因为用selectAll,所以返回的是数组),循环调用执行
if(!rects.length) {
setTimeout(() => {
this.getMenuItemTop();
}, 100);
return ;
}
// console.log(rects)
this.rects = rects;
this.arr = [];
rects.forEach((rect) => {
// 这里减去rects[0].top,是因为第一项顶部可能不是贴到导航栏(比如有个搜索框的情况)
this.arr.push(rect.top - rects[0].top);
})
// console.log(this.arr)
resolve();
}).exec()
})
},
// 右边菜单滚动
async rightScroll(e) {
if (this.mode !== 'follow') return;
this.oldScrollTop = e.detail.scrollTop;
// console.log(e.detail.scrollTop)
// console.log(JSON.stringify(this.arr))
if(this.arr.length == 0) {
await this.getMenuItemTop();
}
if(this.timer) return ;
if(!this.menuHeight) {
await this.getElRect('u-cate-tab__menu-scroll-view', 'menuHeight');
}
setTimeout(() => { // 节流
this.timer = null;
// scrollHeight为右边菜单垂直中点位置
let scrollHeight = e.detail.scrollTop + 1;
// console.log(e.detail.scrollTop)
for (let i = 0; i < this.arr.length; i++) {
let height1 = this.arr[i];
let height2 = this.arr[i + 1];
// console.log('i', i)
// console.log('height1', height1)
// console.log('height2', height2)
// 如果不存在height2,意味着数据循环已经到了最后一个,设置左边菜单为最后一项即可
if (!height2 || scrollHeight >= height1 && scrollHeight <= height2) {
// console.log('scrollHeight', scrollHeight)
// console.log('height1', height1)
// console.log('height2', height2)
this.leftMenuStatus(i);
return ;
}
}
}, 100)
}
}
}
</script>
<style lang="scss" scoped>
.u-cate-tab {
display: flex;
flex-direction: column;
}
.u-cate-tab__wrap {
flex: 1;
display: flex;
flex-direction: row;
overflow: hidden;
}
.u-search-inner {
background-color: var(--up-bg-color, rgb(234, 234, 234));
border-radius: 100rpx;
display: flex;
align-items: center;
padding: 10rpx 16rpx;
}
.u-search-text {
font-size: 26rpx;
color: $u-tips-color;
margin-left: 10rpx;
}
.u-cate-tab__view {
width: 200rpx;
height: 100%;
}
.u-cate-tab__item {
height: 110rpx;
background: var(--up-bg-color, #f6f6f6);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
color: var(--up-content-color, #444);
font-weight: 400;
line-height: 1;
}
.u-cate-tab__item-active {
position: relative;
color: var(--up-main-color, #000);
font-size: 30rpx;
font-weight: 600;
background: var(--up-card-bg-color, #fff);
}
.u-cate-tab__item-active::before {
content: "";
position: absolute;
border-left: 4px solid $u-primary;
height: 32rpx;
left: 0;
top: 39rpx;
}
.u-cate-tab__view {
height: 100%;
}
.u-cate-tab__right-box {
flex: 1;
background-color: var(--up-page-bg-color, var(--up-bg-color, rgb(250, 250, 250)));
}
.u-cate-tab__page-view {
padding: 16rpx;
}
.u-cate-tab__page-item {
margin-bottom: 30rpx;
background-color: var(--up-card-bg-color, #fff);
border: 1px solid var(--up-border-color, #e5e7eb);
padding: 16rpx;
border-radius: 8rpx;
}
.u-cate-tab__page-item:last-child {
min-height: 100vh;
}
.item-title {
font-size: 26rpx;
color: var(--up-main-color, $u-main-color);
font-weight: bold;
}
.item-menu-name {
font-weight: normal;
font-size: 24rpx;
color: var(--up-main-color, $u-main-color);
}
.item-container {
display: flex;
flex-wrap: wrap;
}
.thumb-box {
width: 33.333333%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
margin-top: 20rpx;
}
.item-menu-image {
width: 120rpx;
height: 120rpx;
}
</style>

View File

@@ -0,0 +1,17 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/cellGroup.js
*/
export default {
// cell-group组件的props
cellGroup: {
title: '',
border: true,
customStyle: {}
}
}

View File

@@ -0,0 +1,16 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 分组标题
title: {
type: String,
default: () => defProps.cellGroup.title
},
// 是否显示外边框
border: {
type: Boolean,
default: () => defProps.cellGroup.border
}
}
})

View File

@@ -0,0 +1,73 @@
<template>
<view :style="[groupStyle, addStyle(customStyle)]" :class="[customClass]" class="u-cell-group">
<view v-if="title" class="u-cell-group__title">
<slot name="title">
<text class="u-cell-group__title__text" :style="{ color: upThemeVar('--up-main-color', '#303133') }">{{ title }}</text>
</slot>
</view>
<view class="u-cell-group__wrapper">
<u-line v-if="border"></u-line>
<slot />
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle } from '../../libs/function/index';
/**
* cellGroup 单元格
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。
* @tutorial https://uview-plus.jiangruyi.com/components/cell.html
*
* @property {String} title 分组标题
* @property {Boolean} border 是否显示外边框 (默认 true )
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击cell列表时触发
* @example <u-cell-group title="设置喜好">
*/
export default {
name: 'u-cell-group',
mixins: [mpMixin, mixin, props],
computed: {
groupStyle() {
const fallbackBg = this.upThemeIsDark ? '#1c1c1e' : '#ffffff'
return {
backgroundColor: this.upThemeVar('--up-card-bg-color', fallbackBg)
}
}
},
methods: {
addStyle
}
}
</script>
<style lang="scss" scoped>
$u-cell-group-title-padding: 16px 16px 8px !default;
$u-cell-group-title-font-size: 15px !default;
$u-cell-group-title-line-height: 16px !default;
$u-cell-group-title-color: $u-main-color !default;
.u-cell-group {
flex: 1;
&__title {
padding: $u-cell-group-title-padding;
&__text {
font-size: $u-cell-group-title-font-size;
line-height: $u-cell-group-title-line-height;
color: $u-cell-group-title-color;
}
}
&__wrapper {
position: relative;
}
}
</style>

View File

@@ -0,0 +1,35 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/cell.js
*/
export default {
// cell组件的props
cell: {
customClass: '',
title: '',
label: '',
value: '',
icon: '',
disabled: false,
border: true,
center: false,
url: '',
linkType: 'navigateTo',
clickable: false,
isLink: false,
required: false,
arrowDirection: '',
iconStyle: {},
rightIconStyle: {},
rightIcon: 'arrow-right',
titleStyle: {},
size: '',
stop: true,
name: ''
}
}

View File

@@ -0,0 +1,112 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 标题
title: {
type: [String, Number],
default: () => defProps.cell.title
},
// 标题下方的描述信息
label: {
type: [String, Number],
default: () => defProps.cell.label
},
// 右侧的内容
value: {
type: [String, Number],
default: () => defProps.cell.value
},
// 左侧图标名称,或者图片链接(本地文件建议使用绝对地址)
icon: {
type: String,
default: () => defProps.cell.icon
},
// 是否禁用cell
disabled: {
type: Boolean,
default: () => defProps.cell.disabled
},
// 是否显示下边框
border: {
type: Boolean,
default: () => defProps.cell.border
},
// 内容是否垂直居中(主要是针对右侧的value部分)
center: {
type: Boolean,
default: () => defProps.cell.center
},
// 点击后跳转的URL地址
url: {
type: String,
default: () => defProps.cell.url
},
// 链接跳转的方式,内部使用的是uView封装的route方法,可能会进行拦截操作
linkType: {
type: String,
default: () => defProps.cell.linkType
},
// 是否开启点击反馈(表现为点击时加上灰色背景)
clickable: {
type: Boolean,
default: () => defProps.cell.clickable
},
// 是否展示右侧箭头并开启点击反馈
isLink: {
type: Boolean,
default: () => defProps.cell.isLink
},
// 是否显示表单状态下的必填星号(此组件可能会内嵌入input组件)
required: {
type: Boolean,
default: () => defProps.cell.required
},
// 右侧的图标箭头
rightIcon: {
type: String,
default: () => defProps.cell.rightIcon
},
// 右侧箭头的方向,可选值为:left,up,down
arrowDirection: {
type: String,
default: () => defProps.cell.arrowDirection
},
// 左侧图标样式
iconStyle: {
type: [Object, String],
default: () => {
return defProps.cell.iconStyle
}
},
// 右侧箭头图标的样式
rightIconStyle: {
type: [Object, String],
default: () => {
return defProps.cell.rightIconStyle
}
},
// 标题的样式
titleStyle: {
type: [Object, String],
default: () => {
return defProps.cell.titleStyle
}
},
// 单位元的大小,可选值为large
size: {
type: String,
default: () => defProps.cell.size
},
// 点击cell是否阻止事件传播
stop: {
type: Boolean,
default: () => defProps.cell.stop
},
// 标识符,cell被点击时返回
name: {
type: [Number, String],
default: () => defProps.cell.name
}
}
})

View File

@@ -0,0 +1,287 @@
<template>
<view class="u-cell" :class="[customClass]" :style="[addStyle(customStyle)]"
:hover-class="(!disabled && (clickable || isLink)) ? 'u-cell--clickable' : ''" :hover-stay-time="250"
@tap="clickHandler">
<view class="u-cell__body" :class="[ center && 'u-cell--center', size === 'large' && 'u-cell__body--large']">
<view class="u-cell__body__content">
<view class="u-cell__left-icon-wrap" v-if="$slots.icon || icon">
<slot name="icon" v-if="$slots.icon">
</slot>
<up-icon v-else :name="icon"
:custom-style="iconStyle"
:size="size === 'large' ? 22 : 18"></up-icon>
</view>
<view class="u-cell__title">
<!-- 将slot与默认内容用if/else分开主要是因为微信小程序不支持slot嵌套传递,这样才能解决collapse组件的slot不失效问题,label暂时未用到。 -->
<slot name="title" v-if="$slots.title || !title">
</slot>
<text v-else class="u-cell__title-text" :style="[cellTitleDynamicStyle, titleTextStyle]"
:class="[required && 'u-cell--required', disabled && 'u-cell--disabled', size === 'large' && 'u-cell__title-text--large']">{{ title }}</text>
<slot name="label">
<text class="u-cell__label" v-if="label"
:style="[cellLabelDynamicStyle]"
:class="[disabled && 'u-cell--disabled', size === 'large' && 'u-cell__label--large']">{{ label }}</text>
</slot>
</view>
</view>
<slot name="value">
<text class="u-cell__value"
:style="[cellValueDynamicStyle]"
:class="[disabled && 'u-cell--disabled', size === 'large' && 'u-cell__value--large']"
v-if="!testEmpty(value)">{{ value }}</text>
</slot>
<view class="u-cell__right-icon-wrap" v-if="$slots['right-icon'] || isLink"
:class="[`u-cell__right-icon-wrap--${arrowDirection}`]">
<up-icon v-if="rightIcon && !$slots['right-icon']" :name="rightIcon"
:custom-style="rightIconStyle" :color="disabled ? cellDisabledColor : 'info'"
:size="size === 'large' ? 18 : 16"></up-icon>
<slot v-else name="right-icon">
</slot>
</view>
<view class="u-cell__right-icon-wrap" v-if="$slots['righticon']"
:class="[`u-cell__right-icon-wrap--${arrowDirection}`]">
<slot name="righticon">
</slot>
</view>
</view>
<u-line v-if="border"></u-line>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* cell 单元格
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。
* @tutorial https://uview-plus.jiangruyi.com/components/cell.html
* @property {String | Number} title 标题
* @property {String | Number} label 标题下方的描述信息
* @property {String | Number} value 右侧的内容
* @property {String} icon 左侧图标名称,或者图片链接(本地文件建议使用绝对地址)
* @property {Boolean} disabled 是否禁用cell
* @property {Boolean} border 是否显示下边框 (默认 true )
* @property {Boolean} center 内容是否垂直居中(主要是针对右侧的value部分) (默认 false )
* @property {String} url 点击后跳转的URL地址
* @property {String} linkType 链接跳转的方式,内部使用的是uView封装的route方法,可能会进行拦截操作 (默认 'navigateTo' )
* @property {Boolean} clickable 是否开启点击反馈(表现为点击时加上灰色背景) (默认 false )
* @property {Boolean} isLink 是否展示右侧箭头并开启点击反馈 (默认 false )
* @property {Boolean} required 是否显示表单状态下的必填星号(此组件可能会内嵌入input组件) (默认 false )
* @property {String} rightIcon 右侧的图标箭头 (默认 'arrow-right')
* @property {String} arrowDirection 右侧箭头的方向,可选值为:left,up,down
* @property {Object | String} rightIconStyle 右侧箭头图标的样式
* @property {Object | String} titleStyle 标题的样式
* @property {Object | String} iconStyle 左侧图标样式
* @property {String} size 单位元的大小,可选值为 large,normal,mini
* @property {Boolean} stop 点击cell是否阻止事件传播 (默认 true )
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} click 点击cell列表时触发
* @example 该组件需要搭配cell-group组件使用,见官方文档示例
*/
export default {
name: 'u-cell',
data() {
return {
}
},
mixins: [mpMixin, mixin, props],
computed: {
titleTextStyle() {
return addStyle(this.titleStyle)
},
cellDisabledColor() {
return this.upThemeVar('--up-disabled-color', '#c8c9cc')
},
cellTitleDynamicStyle() {
return {
color: this.upThemeVar('--up-main-color', '#303133')
}
},
cellLabelDynamicStyle() {
return {
color: this.upThemeVar('--up-tips-color', '#909399')
}
},
cellValueDynamicStyle() {
return {
color: this.upThemeVar('--up-content-color', '#606266')
}
}
},
emits: ['click'],
methods: {
addStyle,
testEmpty: test.empty,
// 点击cell
clickHandler(e) {
if (this.disabled) return
this.$emit('click', {
name: this.name
})
// 如果配置了url(此props参数通过mixin引入)参数,跳转页面
this.openPage()
// 是否阻止事件传播
this.stop && this.preventEvent(e)
},
}
}
</script>
<style lang="scss" scoped>
$u-cell-padding: 13px 15px !default;
$u-cell-font-size: 15px !default;
$u-cell-line-height: 24px !default;
$u-cell-color: $u-main-color !default;
$u-cell-icon-size: 16px !default;
$u-cell-title-font-size: 15px !default;
$u-cell-title-line-height: 22px !default;
$u-cell-title-color: $u-main-color !default;
$u-cell-label-font-size: 12px !default;
$u-cell-label-color: $u-tips-color !default;
$u-cell-label-line-height: 18px !default;
$u-cell-value-font-size: 14px !default;
$u-cell-value-color: $u-content-color !default;
$u-cell-clickable-color: var(--up-hover-bg-color, #f1f1f1) !default;
$u-cell-disabled-color: $u-disabled-color !default;
$u-cell-padding-top-large: 13px !default;
$u-cell-padding-bottom-large: 13px !default;
$u-cell-value-font-size-large: 15px !default;
$u-cell-label-font-size-large: 14px !default;
$u-cell-title-font-size-large: 16px !default;
$u-cell-left-icon-wrap-margin-right: 4px !default;
$u-cell-right-icon-wrap-margin-left: 4px !default;
$u-cell-title-flex:1 !default;
$u-cell-label-margin-top:5px !default;
.u-cell {
&__body {
@include flex();
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
padding: $u-cell-padding;
font-size: $u-cell-font-size;
color: $u-cell-color;
// line-height: $u-cell-line-height;
align-items: center;
&__content {
@include flex(row);
align-items: center;
flex: 1;
}
&--large {
padding-top: $u-cell-padding-top-large;
padding-bottom: $u-cell-padding-bottom-large;
}
}
&__left-icon-wrap,
&__right-icon-wrap {
@include flex();
align-items: center;
// height: $u-cell-line-height;
font-size: $u-cell-icon-size;
}
&__left-icon-wrap {
margin-right: $u-cell-left-icon-wrap-margin-right;
}
&__right-icon-wrap {
margin-left: $u-cell-right-icon-wrap-margin-left;
transition: transform 0.3s;
&--up {
transform: rotate(-90deg);
}
&--down {
transform: rotate(90deg);
}
}
&__title {
flex: $u-cell-title-flex;
display: flex;
flex-direction: column;
&-text {
font-size: $u-cell-title-font-size;
line-height: $u-cell-title-line-height;
color: $u-cell-title-color;
&--large {
font-size: $u-cell-title-font-size-large;
}
}
}
&__label {
margin-top: $u-cell-label-margin-top;
font-size: $u-cell-label-font-size;
color: $u-cell-label-color;
line-height: $u-cell-label-line-height;
&--large {
font-size: $u-cell-label-font-size-large;
}
}
&__value {
text-align: right;
/* #ifndef APP-NVUE */
margin-left: auto;
/* #endif */
font-size: $u-cell-value-font-size;
line-height: $u-cell-line-height;
color: $u-cell-value-color;
&--large {
font-size: $u-cell-value-font-size-large;
}
}
&--required {
/* #ifndef APP-NVUE */
overflow: visible;
/* #endif */
@include flex;
align-items: center;
}
&--required:before {
position: absolute;
/* #ifndef APP-NVUE */
content: '*';
/* #endif */
left: -8px;
margin-top: 4rpx;
font-size: 14px;
color: $u-error;
}
&--clickable {
background-color: $u-cell-clickable-color;
}
&--disabled {
color: $u-cell-disabled-color;
/* #ifndef APP-NVUE */
cursor: not-allowed;
/* #endif */
}
&--center {
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,29 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/checkboxGroup.js
*/
export default {
// checkbox-group组件
checkboxGroup: {
name: '',
value: [],
shape: 'square',
disabled: false,
activeColor: '#2979ff',
inactiveColor: '#c8c9cc',
size: 18,
placement: 'row',
labelSize: 14,
labelColor: '#303133',
labelDisabled: false,
iconColor: '#ffffff',
iconSize: 12,
iconPlacement: 'left',
borderBottom: false
}
}

View File

@@ -0,0 +1,93 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 标识符
name: {
type: String,
default: () => defProps.checkboxGroup.name
},
// #ifdef VUE3
// 绑定的值
modelValue: {
type: Array,
default: () => defProps.checkboxGroup.value
},
// #endif
// #ifdef VUE2
// 绑定的值
value: {
type: Array,
default: () => defProps.checkboxGroup.value
},
// #endif
// 形状,circle-圆形,square-方形
shape: {
type: String,
default: () => defProps.checkboxGroup.shape
},
// 是否禁用全部checkbox
disabled: {
type: Boolean,
default: () => defProps.checkboxGroup.disabled
},
// 选中状态下的颜色,如设置此值,将会覆盖parent的activeColor值
activeColor: {
type: String,
default: () => defProps.checkboxGroup.activeColor
},
// 未选中的颜色
inactiveColor: {
type: String,
default: () => defProps.checkboxGroup.inactiveColor
},
// 整个组件的尺寸,默认px
size: {
type: [String, Number],
default: () => defProps.checkboxGroup.size
},
// 布局方式,row-横向,column-纵向
placement: {
type: String,
default: () => defProps.checkboxGroup.placement
},
// label的字体大小,px单位
labelSize: {
type: [String, Number],
default: () => defProps.checkboxGroup.labelSize
},
// label的字体颜色
labelColor: {
type: [String],
default: () => defProps.checkboxGroup.labelColor
},
// 是否禁止点击文本操作
labelDisabled: {
type: Boolean,
default: () => defProps.checkboxGroup.labelDisabled
},
// 图标颜色
iconColor: {
type: String,
default: () => defProps.checkboxGroup.iconColor
},
// 图标的大小,单位px
iconSize: {
type: [String, Number],
default: () => defProps.checkboxGroup.iconSize
},
// 勾选图标的对齐方式,left-左边,right-右边
iconPlacement: {
type: String,
default: () => defProps.checkboxGroup.iconPlacement
},
// 竖向配列时,是否显示下划线
borderBottom: {
type: Boolean,
default: () => defProps.checkboxGroup.borderBottom
}
}
})

View File

@@ -0,0 +1,136 @@
<template>
<view
class="u-checkbox-group"
:class="bemClass"
>
<slot></slot>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
/**
* checkboxGroup 复选框组
* @description 复选框组件一般用于需要多个选择的场景,该组件功能完整,使用方便
* @tutorial https://uview-plus.jiangruyi.com/components/checkbox.html
* @property {String} name 标识符
* @property {Array} value 绑定的值
* @property {String} shape 形状,circle-圆形,square-方形 (默认 'square' )
* @property {Boolean} disabled 是否禁用全部checkbox (默认 false )
* @property {String} activeColor 选中状态下的颜色,如设置此值,将会覆盖parent的activeColor值 (默认 '#2979ff' )
* @property {String} inactiveColor 未选中的颜色 (默认 '#c8c9cc' )
* @property {String | Number} size 整个组件的尺寸 单位px (默认 18 )
* @property {String} placement 布局方式,row-横向,column-纵向 (默认 'row' )
* @property {String | Number} labelSize label的字体大小,px单位 (默认 14 )
* @property {String} labelColor label的字体颜色 (默认 '#303133' )
* @property {Boolean} labelDisabled 是否禁止点击文本操作 (默认 false )
* @property {String} iconColor 图标颜色 (默认 '#ffffff' )
* @property {String | Number} iconSize 图标的大小,单位px (默认 12 )
* @property {String} iconPlacement 勾选图标的对齐方式,left-左边,right-右边 (默认 'left' )
* @property {Boolean} borderBottom placement为row时,是否显示下边框 (默认 false )
* @event {Function} change 任一个checkbox状态发生变化时触发,回调为一个对象
* @event {Function} input 修改通过v-model绑定的值时触发,回调为一个对象
* @example <u-checkbox-group></u-checkbox-group>
*/
export default {
name: 'u-checkbox-group',
mixins: [mpMixin, mixin,props],
computed: {
// 这里computed的变量,都是子组件u-checkbox需要用到的,由于头条小程序的兼容性差异,子组件无法实时监听父组件参数的变化
// 所以需要手动通知子组件,这里返回一个parentData变量,供watch监听,在其中去通知每一个子组件重新从父组件(u-checkbox-group)
// 拉取父组件新的变化后的参数
parentData() {
return [
// #ifdef VUE2
this.value,
// #endif
// #ifdef VUE3
this.modelValue,
// #endif
this.disabled,
this.inactiveColor,
this.activeColor,
this.size,
this.labelDisabled,
this.shape,
this.iconSize,
this.borderBottom,
this.placement,
];
},
bemClass() {
// this.bem为一个computed变量,在mixin中
return this.bem('checkbox-group', ['placement'])
},
},
watch: {
// 当父组件需要子组件需要共享的参数发生了变化,手动通知子组件
parentData: {
handler() {
if (this.children.length) {
this.children.map((child) => {
// 判断子组件(u-checkbox)如果有init方法的话,就就执行(执行的结果是子组件重新从父组件拉取了最新的值)
typeof child.init === "function" && child.init();
});
}
},
deep: true,
},
},
data() {
return {
}
},
created() {
this.children = []
},
// #ifdef VUE3
emits: ['update:modelValue', 'change'],
// #endif
methods: {
// 将其他的checkbox设置为未选中的状态
unCheckedOther(childInstance) {
const values = []
this.children.map(child => {
// 将被选中的checkbox,放到数组中返回
if (child.isChecked) {
values.push(child.name)
}
})
// 修改通过v-model绑定的值
// #ifdef VUE3
this.$emit("update:modelValue", values);
// #endif
// #ifdef VUE2
this.$emit("input", values);
// #endif
// 放在最后更新,否则change事件传出去的values不会更新
this.$emit('change', values, {
isChecked: childInstance.$data['isChecked'],
name: childInstance.$props['name'] // 当前变动的checkbox的name值
})
},
}
}
</script>
<style lang="scss" scoped>
.u-checkbox-group {
&--row {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-flow: row wrap;
}
&--column {
@include flex(column);
}
}
</style>

View File

@@ -0,0 +1,27 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/checkbox.js
*/
export default {
// checkbox组件
checkbox: {
name: '',
shape: '',
size: '',
checkbox: false,
disabled: '',
activeColor: '',
inactiveColor: '',
iconSize: '',
iconColor: '',
label: '',
labelSize: '',
labelColor: '',
labelDisabled: ''
}
}

View File

@@ -0,0 +1,76 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// checkbox的名称
name: {
type: [String, Number, Boolean],
default: () => defProps.checkbox.name
},
// 形状,square为方形,circle为圆型
shape: {
type: String,
default: () => defProps.checkbox.shape
},
// 整体的大小
size: {
type: [String, Number],
default: () => defProps.checkbox.size
},
// 是否默认选中
checked: {
type: Boolean,
default: () => defProps.checkbox.checked
},
// 是否禁用
disabled: {
type: [String, Boolean],
default: () => defProps.checkbox.disabled
},
// 选中状态下的颜色,如设置此值,将会覆盖parent的activeColor值
activeColor: {
type: String,
default: () => defProps.checkbox.activeColor
},
// 未选中的颜色
inactiveColor: {
type: String,
default: () => defProps.checkbox.inactiveColor
},
// 图标的大小,单位px
iconSize: {
type: [String, Number],
default: () => defProps.checkbox.iconSize
},
// 图标颜色
iconColor: {
type: String,
default: () => defProps.checkbox.iconColor
},
// label提示文字,因为nvue下,直接slot进来的文字,由于特殊的结构,无法修改样式
label: {
type: [String, Number],
default: () => defProps.checkbox.label
},
// label的字体大小,px单位
labelSize: {
type: [String, Number],
default: () => defProps.checkbox.labelSize
},
// label的颜色
labelColor: {
type: String,
default: () => defProps.checkbox.labelColor
},
// 是否禁止点击提示语选中复选框
labelDisabled: {
type: [String, Boolean],
default: () => defProps.checkbox.labelDisabled
},
// 是否独立使用
usedAlone: {
type: [Boolean],
default: () => false
}
}
})

View File

@@ -0,0 +1,389 @@
<template>
<view
class="u-checkbox cursor-pointer"
:style="[checkboxStyle]"
@tap.stop="wrapperClickHandler"
:class="[`u-checkbox-label--${parentData.iconPlacement}`, parentData.borderBottom && parentData.placement === 'column' && 'u-border-bottom']"
>
<view
class="u-checkbox__icon-wrap cursor-pointer"
@tap.stop="iconClickHandler"
:class="iconClasses"
:style="[iconWrapStyle]"
>
<slot name="icon" :elIconSize="elIconSize" :elIconColor="elIconColor">
<up-icon
class="u-checkbox__icon-wrap__icon"
name="checkbox-mark"
:size="elIconSize"
:color="elIconColor"
/>
</slot>
</view>
<view class="u-checkbox__label-wrap cursor-pointer" @tap.stop="labelClickHandler">
<slot name="label" :label="label" :elDisabled="elDisabled">
<text
:style="{
color: elDisabled ? elInactiveColor : elLabelColor,
fontSize: elLabelSize,
lineHeight: elLabelSize
}"
>{{label}}</text>
</slot>
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addStyle, addUnit, deepMerge, formValidate, error } from '../../libs/function/index';
import test from '../../libs/function/test';
/**
* checkbox 复选框
* @description 复选框组件一般用于需要多个选择的场景,该组件功能完整,使用方便
* @tutorial https://uview-plus.jiangruyi.com/components/checkbox.html
* @property {String | Number | Boolean} name checkbox组件的标示符
* @property {String} shape 形状,square为方形,circle为圆型
* @property {String | Number} size 整体的大小
* @property {Boolean} checked 是否默认选中
* @property {String | Boolean} disabled 是否禁用
* @property {String} activeColor 选中状态下的颜色,如设置此值,将会覆盖parent的activeColor值
* @property {String} inactiveColor 未选中的颜色
* @property {String | Number} iconSize 图标的大小,单位px
* @property {String} iconColor 图标颜色
* @property {String | Number} label label提示文字,因为nvue下,直接slot进来的文字,由于特殊的结构,无法修改样式
* @property {String} labelColor label的颜色
* @property {String | Number} labelSize label的字体大小,px单位
* @property {String | Boolean} labelDisabled 是否禁止点击提示语选中复选框
* @property {Object} customStyle 定义需要用到的外部样式
*
* @event {Function} change 任一个checkbox状态发生变化时触发,回调为一个对象
* @example <u-checkbox v-model="checked" :disabled="false">天涯</u-checkbox>
*/
export default {
name: "u-checkbox",
mixins: [mpMixin, mixin, props],
data() {
return {
isChecked: false,
// 父组件的默认值,因为头条小程序不支持在computed中使用this.parent.shape的形式
// 故只能使用如此方法
parentData: {
iconSize: 12,
labelDisabled: null,
disabled: null,
shape: 'square',
activeColor: null,
inactiveColor: null,
size: 18,
// #ifdef VUE2
value: null,
// #endif
// #ifdef VUE3
modelValue: null,
// #endif
iconColor: null,
placement: 'row',
borderBottom: false,
iconPlacement: 'left'
}
}
},
computed: {
// 是否禁用,如果父组件u-radios-group禁用的话,将会忽略子组件的配置
elDisabled() {
return this.disabled !== '' ? this.disabled : this.parentData.disabled !== null ? this.parentData.disabled : false;
},
// 是否禁用label点击
elLabelDisabled() {
return this.labelDisabled !== '' ? this.labelDisabled : this.parentData.labelDisabled !== null ? this.parentData.labelDisabled :
false;
},
// 组件尺寸,对应size的值,默认值为21px
elSize() {
return this.size ? this.size : (this.parentData.size ? this.parentData.size : 21);
},
// 组件的勾选图标的尺寸,默认12px
elIconSize() {
return this.iconSize ? this.iconSize : (this.parentData.iconSize ? this.parentData.iconSize : 12);
},
// 组件选中激活时的颜色
elActiveColor() {
return this.activeColor ? this.activeColor : (this.parentData.activeColor ? this.parentData.activeColor : this.upThemeVar('--up-primary', '#2979ff'));
},
// 组件选未中激活时的颜色
elInactiveColor() {
return this.inactiveColor ? this.inactiveColor : (this.parentData.inactiveColor ? this.parentData.inactiveColor :
this.upThemeVar('--up-border-color', '#c8c9cc'));
},
// label的颜色
elLabelColor() {
return this.labelColor ? this.labelColor : (this.parentData.labelColor ? this.parentData.labelColor : this.upThemeVar('--up-content-color', '#606266'))
},
// 组件的形状
elShape() {
return this.shape ? this.shape : (this.parentData.shape ? this.parentData.shape : 'circle');
},
// label大小
elLabelSize() {
return addUnit(this.labelSize ? this.labelSize : (this.parentData.labelSize ? this.parentData.labelSize :
'15'))
},
elIconColor() {
const iconColor = this.iconColor ? this.iconColor : (this.parentData.iconColor ? this.parentData.iconColor :
this.upThemeVar('--up-card-bg-color', '#ffffff'));
// 图标的颜色
if (this.elDisabled) {
// disabled状态下,已勾选的checkbox图标改为elInactiveColor
return this.isChecked ? this.elInactiveColor : 'transparent'
} else {
return this.isChecked ? iconColor : 'transparent'
}
},
iconClasses() {
let classes = []
// 组件的形状
classes.push('u-checkbox__icon-wrap--' + this.elShape)
if (this.elDisabled) {
classes.push('u-checkbox__icon-wrap--disabled')
}
if (this.isChecked && this.elDisabled) {
classes.push('u-checkbox__icon-wrap--disabled--checked')
}
// 支付宝,头条小程序无法动态绑定一个数组类名,否则解析出来的结果会带有",",而导致失效
// #ifdef MP-ALIPAY || MP-TOUTIAO
classes = classes.join(' ')
// #endif
return classes
},
iconWrapStyle() {
// checkbox的整体样式
const style = {}
style.backgroundColor = this.isChecked && !this.elDisabled ? this.elActiveColor : this.upThemeVar('--up-card-bg-color', '#ffffff')
style.borderColor = this.isChecked && !this.elDisabled ? this.elActiveColor : this.elInactiveColor
style.width = addUnit(this.elSize)
style.height = addUnit(this.elSize)
// 如果是图标在右边的话,移除它的右边距
if (!this.usedAlone) {
if (this.parentData.iconPlacement === 'right') {
style.marginRight = 0
}
}
return style
},
checkboxStyle() {
const style = {}
if (!this.usedAlone) {
if (this.parentData.borderBottom && this.parentData.placement === 'row') {
error('检测到您将borderBottom设置为true,需要同时将up-checkbox-group的placement设置为column才有效')
}
// 当父组件设置了显示下边框并且排列形式为纵向时,给内容和边框之间加上一定间隔
if (this.parentData.borderBottom && this.parentData.placement === 'column') {
style.paddingBottom = '8px'
}
}
return deepMerge(style, addStyle(this.customStyle))
}
},
mounted() {
this.init()
},
emits: ["change", "update:checked"],
methods: {
init() {
if (!this.usedAlone) {
// 支付宝小程序不支持provide/inject,所以使用这个方法获取整个父组件,在created定义,避免循环引用
this.updateParentData()
if (!this.parent) {
error('up-checkbox必须搭配up-checkbox-group组件使用')
}
let value = '';
// #ifdef VUE2
value = this.parentData.value
// #endif
// #ifdef VUE3
value = this.parentData.modelValue
// #endif
// 设置初始化时,是否默认选中的状态,父组件u-checkbox-group的value可能是array,所以额外判断
if (this.checked) {
this.isChecked = true
} else if (!this.usedAlone && test.array(value)) {
// 查找数组是是否存在this.name元素值
this.isChecked = value.some(item => {
return item === this.name
})
}
} else {
if (this.checked) {
this.isChecked = true
}
}
},
updateParentData() {
this.getParentData('u-checkbox-group')
},
// 横向两端排列时,点击组件即可触发选中事件
wrapperClickHandler(e) {
if (!this.usedAlone) {
this.parentData.iconPlacement === 'right' && this.iconClickHandler(e)
} else {
this.iconClickHandler(e)
}
},
// 点击图标
iconClickHandler(e) {
this.preventEvent(e)
// 如果整体被禁用,不允许被点击
if (!this.elDisabled) {
this.setRadioCheckedStatus()
}
},
// 点击label
labelClickHandler(e) {
this.preventEvent(e)
// 如果按钮整体被禁用或者label被禁用,则不允许点击文字修改状态
if (!this.elLabelDisabled && !this.elDisabled) {
this.setRadioCheckedStatus()
}
},
emitEvent() {
this.$emit('change', this.isChecked, {
name: this.name
})
// 双向绑定
if (this.usedAlone) {
this.$emit('update:checked', this.isChecked)
}
// 尝试调用u-form的验证方法,进行一定延迟,否则微信小程序更新可能会不及时
this.$nextTick(() => {
formValidate(this, 'change')
})
},
// 改变组件选中状态
// 这里的改变的依据是,更改本组件的checked值为true,同时通过父组件遍历所有u-checkbox实例
// 将本组件外的其他u-checkbox的checked都设置为false(都被取消选中状态),因而只剩下一个为选中状态
setRadioCheckedStatus() {
// 将本组件标记为与原来相反的状态
this.isChecked = !this.isChecked
this.emitEvent()
if (!this.usedAlone) {
typeof this.parent.unCheckedOther === 'function' && this.parent.unCheckedOther(this)
}
}
},
watch:{
checked(newValue, oldValue){
if (newValue !== this.isChecked) {
this.isChecked = newValue
}
}
}
}
</script>
<style lang="scss" scoped>
$u-checkbox-icon-wrap-margin-right:6px !default;
$u-checkbox-icon-wrap-font-size:6px !default;
$u-checkbox-icon-wrap-border-width:1px !default;
$u-checkbox-icon-wrap-border-color:#c8c9cc !default;
$u-checkbox-icon-wrap-icon-line-height:0 !default;
$u-checkbox-icon-wrap-circle-border-radius:100% !default;
$u-checkbox-icon-wrap-square-border-radius:3px !default;
$u-checkbox-icon-wrap-checked-color:#fff !default;
$u-checkbox-icon-wrap-checked-background-color:red !default;
$u-checkbox-icon-wrap-checked-border-color:#2979ff !default;
$u-checkbox-icon-wrap-disabled-background-color:var(--up-bg-color, #ebedf0) !default;
$u-checkbox-icon-wrap-disabled-checked-color:#c8c9cc !default;
$u-checkbox-label-margin-left:5px !default;
$u-checkbox-label-margin-right:12px !default;
$u-checkbox-label-color:$u-content-color !default;
$u-checkbox-label-font-size:15px !default;
$u-checkbox-label-disabled-color:#c8c9cc !default;
.u-checkbox {
/* #ifndef APP-NVUE */
@include flex(row);
/* #endif */
overflow: hidden;
flex-direction: row;
align-items: center;
margin-bottom: 5px;
margin-top: 5px;
&-label--left {
flex-direction: row
}
&-label--right {
flex-direction: row-reverse;
justify-content: space-between
}
&__icon-wrap {
/* #ifndef APP-NVUE */
box-sizing: border-box;
// nvue下,border-color过渡有问题
transition-property: border-color, background-color, color;
transition-duration: 0.2s;
/* #endif */
color: $u-content-color;
@include flex;
align-items: center;
justify-content: center;
color: transparent;
text-align: center;
margin-right: $u-checkbox-icon-wrap-margin-right;
font-size: $u-checkbox-icon-wrap-font-size;
border-width: $u-checkbox-icon-wrap-border-width;
border-color: $u-checkbox-icon-wrap-border-color;
border-style: solid;
/* #ifdef MP-TOUTIAO */
// 头条小程序兼容性问题,需要设置行高为0,否则图标偏下
&__icon {
line-height: $u-checkbox-icon-wrap-icon-line-height;
}
/* #endif */
&--circle {
border-radius: $u-checkbox-icon-wrap-circle-border-radius;
}
&--square {
border-radius: $u-checkbox-icon-wrap-square-border-radius;
}
&--checked {
color: $u-checkbox-icon-wrap-checked-color;
background-color: $u-checkbox-icon-wrap-checked-background-color;
border-color: $u-checkbox-icon-wrap-checked-border-color;
}
&--disabled {
background-color: $u-checkbox-icon-wrap-disabled-background-color !important;
}
&--disabled--checked {
color: $u-checkbox-icon-wrap-disabled-checked-color !important;
}
}
&__label {
/* #ifndef APP-NVUE */
word-wrap: break-word;
/* #endif */
margin-left: $u-checkbox-label-margin-left;
margin-right: $u-checkbox-label-margin-right;
color: $u-checkbox-label-color;
font-size: $u-checkbox-label-font-size;
&--disabled {
color: $u-checkbox-label-disabled-color;
}
}
}
</style>

View File

@@ -0,0 +1,114 @@
<style scoped lang="scss">
.up-choose {
::v-deep .up-tag {
font-weight: 600;
}
&:last-child {
margin-right: 0;
}
}
.up-choose-wrap {
flex-wrap: wrap;
}
.up-choose-nowrap {
flex-wrap: nowrap;
white-space: nowrap;
}
</style>
<template>
<scroll-view
:scroll-x="wrap === false"
:class="['up-choose', wrap ? 'up-choose-wrap' : 'up-choose-nowrap']">
<template :key="item.id" v-for="(item,index) in options">
<view :style="{width: itemWidth, display: 'inline-block'}">
<slot :item="item" :index="index">
<up-tag :type="index == currentIndex ? 'primary' : 'info'"
size="large" :plain="index == currentIndex ? false : true"
:class="currentIndex === index ? 'active': ''" :height="itemHeight"
:style="{width: itemWidth, padding: itemPadding}"
@click="change(index)">
{{item[labelName]}}
</up-tag>
</slot>
</view>
</template>
</scroll-view>
</template>
<script>
export default {
name: 'up-choose',
props: {
options:{
type: Array,
default: ()=>{
return [];
}
},
modelValue: {
type: [Number,String,Array],
default: false
},
type: {
type: [String],
default: 'radio'
},
itemWidth: {
type: [String],
default: 'auto'
},
itemHeight: {
type: [String],
default: '50px'
},
itemPadding: {
type: [String],
default: '8px'
},
labelName: {
type: String,
default: 'title'
},
valueName: {
type: String,
default: 'value'
},
customClick: {
type: Boolean,
default: false
},
// 是否换行
wrap: {
type: Boolean,
default: true
}
},
data() {
return {
currentIndex: ''
}
},
watch: {
modelValue: {
immediate: true,
handler(newVal) {
this.currentIndex = newVal
}
}
},
emits: ['update:modelValue', 'custom-click'],
methods: {
change(index){
if (this.customClick) {
this.$emit('custom-click', index);
} else {
this.currentIndex = index;
this.$emit('update:modelValue', index);
}
}
}
}
</script>

View File

@@ -0,0 +1,15 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/circleProgress.js
*/
export default {
// circleProgress 组件
circleProgress: {
percentage: 30
}
}

View File

@@ -0,0 +1,10 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
percentage: {
type: [String, Number],
default: () => defProps.circleProgress.percentage
}
}
})

View File

@@ -0,0 +1,200 @@
<template>
<view class="u-circle-progress">
<view class="u-circle-progress__left">
<view
class="u-circle-progress__left__circle"
:style="[leftSyle]"
ref="left-circle"
>
</view>
</view>
<view
class="u-circle-progress__right"
>
<view
class="u-circle-progress__right__circle"
ref="right-circle"
:style="[rightSyle]"
>
</view>
</view>
<view class="u-circle-progress__circle">
</view>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import {sleep } from '../../libs/function/index';
// #ifdef APP-NVUE
const animation = uni.requireNativePlugin('animation')
// #endif
/**
* CircleProgress 圆形进度条 TODO: 待完善
* @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度环。
* @tutorial https://uview-plus.jiangruyi.com/components/circleProgress.html
* @property {String | Number} percentage 圆环进度百分比值,为数值类型,0-100 (默认 30 )
* @example
*/
export default {
name: 'u-circle-progress',
mixins: [mpMixin, mixin, props],
data() {
return {
leftBorderColor: 'rgb(200, 200, 200)',
rightBorderColor: 'rgb(200, 200, 200)',
}
},
computed: {
leftSyle() {
const style = {}
style.borderTopColor = this.leftBorderColor
style.borderRightColor = this.leftBorderColor
return style
},
rightSyle() {
const style = {}
style.borderLeftColor = this.rightBorderColor
style.borderBottomColor = this.rightBorderColor
return style
}
},
mounted() {
sleep().then(() => {
this.rightBorderColor = 'rgb(66, 185, 131)'
// this.init()
})
},
methods: {
init() {
animation.transition(this.$refs['right-circle'].ref, {
styles: {
transform: 'rotate(45deg)',
transformOrigin: 'center center'
},
}, () => {
this.rightBorderColor = 'rgb(66, 185, 131)'
// animation.transition(this.$refs['right-circle'].ref, {
// styles: {
// transform: 'rotate(225deg)',
// transformOrigin: 'center center'
// },
// duration: 3000,
// }, () => {
// animation.transition(this.$refs['left-circle'].ref, {
// styles: {
// transform: 'rotate(45deg)',
// transformOrigin: 'center center'
// },
// }, () => {
// this.leftBorderColor = 'rgb(66, 185, 131)'
// animation.transition(this.$refs['left-circle'].ref, {
// styles: {
// transform: 'rotate(225deg)',
// transformOrigin: 'center center'
// },
// duration: 1500,
// }, () => {
// })
// })
// })
})
}
},
}
</script>
<style lang="scss" scoped>
.u-circle-progress {
@include flex(row);
position: relative;
border-radius: 100px;
height: 100px;
width: 100px;
// transform: rotate(0deg);
// background-color: rgb(66, 185, 131);
background-color: rgb(200, 200, 200);
overflow: hidden;
justify-content: space-between;
&__circle {
border-radius: 100px;
height: 90px;
width: 90px;
transform: translate(-50%, -50%);
background-color: rgb(255, 255, 255);
left: 50px;
top: 50px;
position: absolute;
}
&__left {
position: absolute;
left: 0;
width: 50px;
height: 100px;
overflow: hidden;
box-sizing: border-box;
// background-color: rgb(66, 185, 131);
// background-color: rgb(200, 200, 200);
// transform-origin: left center;
&__circle {
box-sizing: border-box;
// background-color: red;
border-left-color: transparent;
border-bottom-color: transparent;
border-top-left-radius: 50px;
border-top-right-radius: 50px;
border-bottom-right-radius: 50px;
// border-left-color: rgb(66, 185, 131);
// border-bottom-color: rgb(66, 185, 131);
border-top-color: rgb(66, 185, 131);
border-right-color: rgb(66, 185, 131);
border-width: 5px;
width: 100px;
height: 100px;
transform: rotate(225deg);
// border-radius: 100px;
}
}
&__right {
position: absolute;
right: 0;
width: 50px;
height: 100px;
overflow: hidden;
&__circle {
position: absolute;
right: 0;
box-sizing: border-box;
// background-color: red;
border-top-color: transparent;
border-right-color: transparent;
border-top-left-radius: 50px;
border-bottom-left-radius: 50px;
border-bottom-right-radius: 50px;
// border-left-color: rgb(66, 185, 131);
// border-bottom-color: rgb(66, 185, 131);
border-left-color: rgb(200, 200, 200);
border-bottom-color: rgb(200, 200, 200);
border-width: 5px;
width: 100px;
height: 100px;
transform: rotate(45deg);
transform-origin: center center;
// border-radius: 100px;
}
}
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<view class="u-city-locate">
<up-index-list :indexList="indexList">
<template #header>
<view class="u-current-city-wrap">
<view class="u-current-city-title">{{ t("up.cityLocate.locateCity") }}</view>
<view class="u-current-city-item" @tap="location">
<view class="u-location-city">{{locationCity}}</view>
</view>
</view>
</template>
<template :key="index" v-for="(item, index) in cityList">
<!-- #ifdef APP-NVUE -->
<up-index-anchor :text="indexList[index]"></up-index-anchor>
<!-- #endif -->
<up-index-item>
<!-- #ifndef APP-NVUE -->
<up-index-anchor :text="indexList[index]"></up-index-anchor>
<!-- #endif -->
<view class="hot-city-list" v-if="index == 0">
<view class="" v-for="(item1, index1) in item" @tap="selectedCity(item1)">
<view class="hot-city-item">{{ item1[nameKey] }}</view>
</view>
</view>
<view v-else class="item-list" v-for="(item1, index1) in item" :key="index1">
<view class="list__item" @tap="selectedCity(item1)">
<text class="list__item__city-name">{{item1[nameKey]}}</text>
</view>
<up-line></up-line>
</view>
</up-index-item>
</template>
<template #footer>
<view class="u-safe-area-inset--bottom">
<text class="list__footer"></text>
</view>
</template>
</up-index-list>
</view>
</template>
<script>
import { t } from '../../libs/i18n'
export default{
name: 'u-city-locate',
props:{
indexList: {
type: Array,
default: ['🔥']
},
cityList:{
type: Array,
default: () => {
return [
[{
name: '北京',
value: 'beijing'
},
{
name: '上海',
value: 'shanghai'
},
{
name: '广州',
value: 'guangzhou'
},
{
name: '深圳',
value: 'shenzhen'
},
{
name: '杭州',
value: 'hangzhou'
}]
]
}
},
locationType: {
type: String,
default: 'wgs84'
},
currentCity: {
type: String,
default: ''
},
nameKey: {
type: String,
default: 'name'
}
},
computed:{
},
watch:{
currentCity(val) {
this.locationCity = val;
}
},
data(){
return{
locationCity: t("up.cityLocate.locating") + '....'
}
},
emits: ['location-success', 'select-city'],
methods:{
t,
// 获取城市
selectedCity(city){
this.locationCity = city[this.nameKey];
this.$emit('select-city', {
locationCity: this.locationCity
});
},
// 定位操作
location(){
let That = this;
uni.getLocation({
type: this.locationType,
geocode:true,
success(res){
console.log(res);
That.locationCity = res.address && res.address.city;
That.$emit('location-success', {
...res,
locationCity: That.locationCity
});
},
fail(){
That.locationCity = t("up.cityLocate.fail");
}
});
},
},
// 页面挂载后进行异步操作
created(){
},
mounted(){
this.location();
}
}
</script>
<style lang="scss">
.list__item {
padding: 8px 1px;
}
.u-current-city-title {
color: var(--up-tips-color, #909399);
margin-bottom: 5px;
}
.u-location-city,
.list__item__city-name {
color: var(--up-main-color, #303133);
}
.u-current-city-item {
height: 30px;
}
.hot-city-list {
display: flex !important;
flex-direction: row !important;
padding: 12px 0;
.hot-city-item {
padding: 6px 12px;
margin: 5px;
color: var(--up-main-color, #303133);
border: 1px solid var(--up-border-color, #ededed);
background-color: var(--up-card-bg-color, #ffffff);
}
}
</style>

View File

@@ -0,0 +1,29 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/codeInput.js
*/
export default {
// codeInput 组件
codeInput: {
adjustPosition: true,
maxlength: 6,
dot: false,
mode: 'box',
hairline: false,
space: 10,
value: '',
focus: false,
bold: false,
color: '#606266',
fontSize: 18,
size: 35,
disabledKeyboard: false,
borderColor: '#c9cacc',
disabledDot: true
}
}

View File

@@ -0,0 +1,90 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 键盘弹起时,是否自动上推页面
adjustPosition: {
type: Boolean,
default: () => defProps.codeInput.adjustPosition
},
// 最大输入长度
maxlength: {
type: [String, Number],
default: () => defProps.codeInput.maxlength
},
// 是否用圆点填充
dot: {
type: Boolean,
default: () => defProps.codeInput.dot
},
// 显示模式,box-盒子模式,line-底部横线模式
mode: {
type: String,
default: () => defProps.codeInput.mode
},
// 是否细边框
hairline: {
type: Boolean,
default: () => defProps.codeInput.hairline
},
// 字符间的距离
space: {
type: [String, Number],
default: () => defProps.codeInput.space
},
// #ifdef VUE3
// 预置值
modelValue: {
type: [String, Number],
default: () => defProps.codeInput.value
},
// #endif
// #ifdef VUE2
// 预置值
value: {
type: [String, Number],
default: () => defProps.codeInput.value
},
// #endif
// 是否自动获取焦点
focus: {
type: Boolean,
default: () => defProps.codeInput.focus
},
// 字体是否加粗
bold: {
type: Boolean,
default: () => defProps.codeInput.bold
},
// 字体颜色
color: {
type: String,
default: () => defProps.codeInput.color
},
// 字体大小
fontSize: {
type: [String, Number],
default: () => defProps.codeInput.fontSize
},
// 输入框的大小,宽等于高
size: {
type: [String, Number],
default: () => defProps.codeInput.size
},
// 是否隐藏原生键盘,如果想用自定义键盘的话,需设置此参数为true
disabledKeyboard: {
type: Boolean,
default: () => defProps.codeInput.disabledKeyboard
},
// 边框和线条颜色
borderColor: {
type: String,
default: () => defProps.codeInput.borderColor
},
// 是否禁止输入"."符号
disabledDot: {
type: Boolean,
default: () => defProps.codeInput.disabledDot
}
}
})

View File

@@ -0,0 +1,299 @@
<template>
<view class="u-code-input">
<view
class="u-code-input__item"
:style="[itemStyle(index)]"
v-for="(item, index) in codeLength"
:key="index"
>
<view
class="u-code-input__item__dot"
v-if="dot && codeArray.length > index"
></view>
<text
v-else
:style="{
fontSize: addUnit(fontSize),
fontWeight: bold ? 'bold' : 'normal',
color: color
}"
>{{codeArray[index]}}</text>
<view
class="u-code-input__item__line"
v-if="mode === 'line'"
:style="[lineStyle]"
></view>
<!-- #ifndef APP-NVUE -->
<view v-if="isFocus && codeArray.length === index"
:style="{backgroundColor: color}" class="u-code-input__item__cursor"></view>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<view v-if="isFocus && codeArray.length === index"
:style="{backgroundColor: color, opacity: opacity}" class="u-code-input__item__cursor"></view>
<!-- #endif -->
</view>
<input
:disabled="disabledKeyboard"
type="number"
:focus="focus"
:value="inputValue"
:maxlength="maxlength"
:adjustPosition="adjustPosition"
class="u-code-input__input"
@input="inputHandler"
:style="{
height: addUnit(size)
}"
@focus="isFocus = true"
@blur="isFocus = false"
/>
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
import { addUnit, getPx } from '../../libs/function/index';
/**
* CodeInput 验证码输入
* @description 该组件一般用于验证用户短信验证码的场景,也可以结合uview-plus的键盘组件使用
* @tutorial https://uview-plus.jiangruyi.com/components/codeInput.html
* @property {String | Number} maxlength 最大输入长度 (默认 6 )
* @property {Boolean} dot 是否用圆点填充 (默认 false )
* @property {String} mode 显示模式,box-盒子模式,line-底部横线模式 (默认 'box' )
* @property {Boolean} hairline 是否细边框 (默认 false )
* @property {String | Number} space 字符间的距离 (默认 10 )
* @property {String | Number} value 预置值
* @property {Boolean} focus 是否自动获取焦点 (默认 false )
* @property {Boolean} bold 字体和输入横线是否加粗 (默认 false )
* @property {String} color 字体颜色 (默认 '#606266' )
* @property {String | Number} fontSize 字体大小,单位px (默认 18 )
* @property {String | Number} size 输入框的大小,宽等于高 (默认 35 )
* @property {Boolean} disabledKeyboard 是否隐藏原生键盘,如果想用自定义键盘的话,需设置此参数为true (默认 false )
* @property {String} borderColor 边框和线条颜色 (默认 '#c9cacc' )
* @property {Boolean} disabledDot 是否禁止输入"."符号 (默认 true )
*
* @event {Function} change 输入内容发生改变时触发,具体见上方说明 value:当前输入的值
* @event {Function} finish 输入字符个数达maxlength值时触发,见上方说明 value:当前输入的值
* @example <u-code-input v-model="value4" :focus="true"></u-code-input>
*/
export default {
name: 'u-code-input',
mixins: [mpMixin, mixin, props],
data() {
return {
inputValue: '',
isFocus: this.focus,
timer: null,
opacity: 1
}
},
watch: {
// #ifdef VUE2
value: {
immediate: true,
handler(val) {
// 转为字符串,超出部分截掉
this.inputValue = String(val).substring(0, this.maxlength)
}
},
// #endif
// #ifdef VUE3
modelValue: {
immediate: true,
handler(val) {
// 转为字符串,超出部分截掉
this.inputValue = String(val).substring(0, this.maxlength)
}
},
// #endif
isFocus: {
handler(val) {
// #ifdef APP-NVUE
if (val) {
this.timer = setInterval(() => {
this.opacity = Math.abs(this.opacity - 1)
}, 600)
} else {
clearInterval(this.timer)
}
// #endif
}
}
},
created() {
},
beforeUnmount() {
// #ifdef APP-NVUE
clearInterval(this.timer)
// #endif
},
computed: {
// 根据长度,循环输入框的个数,因为头条小程序数值不能用于v-for
codeLength() {
return new Array(Number(this.maxlength))
},
// 循环item的样式
itemStyle() {
return index => {
const style = {
width: addUnit(this.size),
height: addUnit(this.size)
}
// 盒子模式下,需要额外进行处理
if (this.mode === 'box') {
// 设置盒子的边框,如果是细边框,则设置为0.5px宽度
style.border = `${this.hairline ? 0.5 : 1}px solid ${this.borderColor}`
// 如果盒子间距为0的话
if (getPx(this.space) === 0) {
// 给第一和最后一个盒子设置圆角
if (index === 0) {
style.borderTopLeftRadius = '3px'
style.borderBottomLeftRadius = '3px'
}
if (index === this.codeLength.length - 1) {
style.borderTopRightRadius = '3px'
style.borderBottomRightRadius = '3px'
}
// 最后一个盒子的右边框需要保留
if (index !== this.codeLength.length - 1) {
style.borderRight = 'none'
}
}
}
if (index !== this.codeLength.length - 1) {
// 设置验证码字符之间的距离,通过margin-right设置,最后一个字符,无需右边框
style.marginRight = addUnit(this.space)
} else {
// 最后一个盒子的有边框需要保留
style.marginRight = 0
}
return style
}
},
// 将输入的值,转为数组,给item历遍时,根据当前的索引显示数组的元素
codeArray() {
return String(this.inputValue).split('')
},
// 下划线模式下,横线的样式
lineStyle() {
const style = {}
style.height = this.hairline ? '2px' : '4px'
style.width = addUnit(this.size)
// 线条模式下,背景色即为边框颜色
style.backgroundColor = this.borderColor
return style
}
},
emits: ["change", 'finish', "update:modelValue"],
methods: {
addUnit,
// 监听输入框的值发生变化
inputHandler(e) {
const value = e.detail.value
this.inputValue = value
// 是否允许输入“.”符号
if(this.disabledDot) {
this.$nextTick(() => {
this.inputValue = value.replace('.', '')
})
}
// 未达到maxlength之前,发送change事件,达到后发送finish事件
this.$emit('change', value)
// 修改通过v-model双向绑定的值
// #ifdef VUE3
this.$emit("update:modelValue", value);
// #endif
// #ifdef VUE2
this.$emit("input", value);
// #endif
// 达到用户指定输入长度时,发出完成事件
if (String(value).length >= Number(this.maxlength)) {
this.$emit('finish', value)
}
}
}
}
</script>
<style lang="scss" scoped>
$u-code-input-cursor-width: 1px;
$u-code-input-cursor-height: 20px;
$u-code-input-cursor-animation-duration: 1s;
$u-code-input-cursor-animation-name: u-cursor-flicker;
.u-code-input {
@include flex;
position: relative;
overflow: hidden;
&__item {
@include flex;
justify-content: center;
align-items: center;
position: relative;
&__text {
font-size: 15px;
color: $u-content-color;
}
&__dot {
width: 7px;
height: 7px;
border-radius: 100px;
background-color: $u-content-color;
}
&__line {
position: absolute;
bottom: 0;
height: 4px;
border-radius: 100px;
width: 40px;
background-color: $u-content-color;
}
&__cursor {
position: absolute;
/* #ifndef APP-NVUE */
top: 50%;
left: 50%;
opacity: 1;
transform: translate(-50%,-50%);
/* #endif */
width: $u-code-input-cursor-width;
height: $u-code-input-cursor-height;
animation: $u-code-input-cursor-animation-duration u-cursor-flicker infinite;
}
}
&__input {
// 之所以需要input输入框,是因为有它才能唤起键盘
// 这里将它设置为两倍的屏幕宽度,再将左边的一半移出屏幕,为了不让用户看到输入的内容
position: absolute;
left: -750rpx;
width: 1500rpx;
top: 0;
background-color: transparent;
text-align: left;
}
}
/* #ifndef APP-NVUE */
@keyframes u-cursor-flicker {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
/* #endif */
</style>

View File

@@ -0,0 +1,21 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/code.js
*/
import { t } from '../../libs/i18n'
export default {
// code 组件
code: {
seconds: 60,
startText: t("up.code.send"),
changeText: t("up.code.resendAfter"),
endText: t("up.code.resend"),
keepRunning: false,
uniqueKey: ''
}
}

View File

@@ -0,0 +1,36 @@
import { defineMixin } from '../../libs/vue'
import defProps from '../../libs/config/props.js'
export const props = defineMixin({
props: {
// 倒计时总秒数
seconds: {
type: [String, Number],
default: () => defProps.code.seconds
},
// 尚未开始时提示
startText: {
type: String,
default: () => defProps.code.startText
},
// 正在倒计时中的提示
changeText: {
type: String,
default: () => defProps.code.changeText
},
// 倒计时结束时的提示
endText: {
type: String,
default: () => defProps.code.endText
},
// 是否在H5刷新或各端返回再进入时继续倒计时
keepRunning: {
type: Boolean,
default: () => defProps.code.keepRunning
},
// 为了区分多个页面,或者一个页面多个倒计时组件本地存储的继续倒计时变了
uniqueKey: {
type: String,
default: () => defProps.code.uniqueKey
}
}
})

View File

@@ -0,0 +1,131 @@
<template>
<view class="u-code">
<!-- 此组件功能由js完成,无需写html逻辑 -->
</view>
</template>
<script>
import { props } from './props';
import { mpMixin } from '../../libs/mixin/mpMixin';
import { mixin } from '../../libs/mixin/mixin';
/**
* Code 验证码输入框
* @description 考虑到用户实际发送验证码的场景,可能是一个按钮,也可能是一段文字,提示语各有不同,所以本组件 不提供界面显示,只提供提示语,由用户将提示语嵌入到具体的场景
* @tutorial https://uview-plus.jiangruyi.com/components/code.html
* @property {String | Number} seconds 倒计时所需的秒数(默认 60 )
* @property {String} startText 开始前的提示语,见官网说明(默认 '获取验证码' )
* @property {String} changeText 倒计时期间的提示语,必须带有字母"x",见官网说明(默认 'X秒重新获取' )
* @property {String} endText 倒计结束的提示语,见官网说明(默认 '重新获取' )
* @property {Boolean} keepRunning 是否在H5刷新或各端返回再进入时继续倒计时( 默认false )
* @property {String} uniqueKey 为了区分多个页面,或者一个页面多个倒计时组件本地存储的继续倒计时变了
*
* @event {Function} change 倒计时期间,每秒触发一次
* @event {Function} start 开始倒计时触发
* @event {Function} end 结束倒计时触发
* @example <u-code ref="uCode" @change="codeChange" seconds="20"></u-code>
*/
export default {
name: "u-code",
mixins: [mpMixin, mixin,props],
data() {
return {
secNum: this.seconds,
timer: null,
canGetCode: true, // 是否可以执行验证码操作
}
},
mounted() {
this.checkKeepRunning()
},
watch: {
seconds: {
immediate: true,
handler(n) {
this.secNum = n
}
}
},
emits: ["start", "end", "change"],
methods: {
checkKeepRunning() {
// 获取上一次退出页面(H5还包括刷新)时的时间戳,如果没有上次的保存,此值可能为空
let lastTimestamp = Number(uni.getStorageSync(this.uniqueKey + '_$uCountDownTimestamp'))
if(!lastTimestamp) return this.changeEvent(this.startText)
// 当前秒的时间戳
let nowTimestamp = Math.floor((+ new Date()) / 1000)
// 判断当前的时间戳,是否小于上一次的本该按设定结束,却提前结束的时间戳
if(this.keepRunning && lastTimestamp && lastTimestamp > nowTimestamp) {
// 剩余尚未执行完的倒计秒数
this.secNum = lastTimestamp - nowTimestamp
// 清除本地保存的变量
uni.removeStorageSync(this.uniqueKey + '_$uCountDownTimestamp')
// 开始倒计时
this.start()
} else {
// 如果不存在需要继续上一次的倒计时,执行正常的逻辑
this.changeEvent(this.startText)
}
},
// 开始倒计时
start() {
// 防止快速点击获取验证码的按钮而导致内部产生多个定时器导致混乱
if(this.timer) {
clearInterval(this.timer)
this.timer = null
}
this.$emit('start')
this.canGetCode = false
// 这里放这句,是为了一开始时就提示,否则要等setInterval的1秒后才会有提示
this.changeEvent(this.changeText.replace(/x|X/, this.secNum))
this.timer = setInterval(() => {
if (--this.secNum) {
// 用当前倒计时的秒数替换提示字符串中的"x"字母
this.changeEvent(this.changeText.replace(/x|X/, this.secNum))
} else {
clearInterval(this.timer)
this.timer = null
this.changeEvent(this.endText)
this.secNum = this.seconds
this.$emit('end')
this.canGetCode = true
}
}, 1000)
this.setTimeToStorage()
},
// 重置,可以让用户再次获取验证码
reset() {
this.canGetCode = true
clearInterval(this.timer)
this.secNum = this.seconds
this.changeEvent(this.endText)
},
changeEvent(text) {
this.$emit('change', text)
},
// 保存时间戳,为了防止倒计时尚未结束,H5刷新或者各端的右上角返回上一页再进来
setTimeToStorage() {
if(!this.keepRunning || !this.timer) return
// 记录当前的时间戳,为了下次进入页面,如果还在倒计时内的话,继续倒计时
// 倒计时尚未结束,结果大于0;倒计时已经开始,就会小于初始值,如果等于初始值,说明没有开始倒计时,无需处理
if(this.secNum > 0 && this.secNum <= this.seconds) {
// 获取当前时间戳(+ new Date()为特殊写法),除以1000变成秒,再去除小数部分
let nowTimestamp = Math.floor((+ new Date()) / 1000)
// 将本该结束时候的时间戳保存起来 => 当前时间戳 + 剩余的秒数
uni.setStorage({
key: this.uniqueKey + '_$uCountDownTimestamp',
data: nowTimestamp + Number(this.secNum)
})
}
}
},
// 组件销毁的时候,清除定时器,否则定时器会继续存在,系统不会自动清除
beforeUnmount() {
this.setTimeToStorage()
clearTimeout(this.timer)
this.timer = null
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@@ -0,0 +1,19 @@
/*
* @Author : LQ
* @Description :
* @version : 3.0
* @Date : 2021-08-20 16:44:21
* @LastAuthor : jry
* @lastTime : 2025-12-19 08:55:21
* @FilePath : /uview-plus/libs/config/props/col.js
*/
export default {
// col 组件
col: {
span: 12,
offset: 0,
justify: 'start',
align: 'stretch',
textAlign: 'left'
}
}

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