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

This commit is contained in:
2026-09-03 19:56:19 +08:00
parent 793f140a45
commit 02c8fb7021
438 changed files with 1595 additions and 573 deletions
@@ -0,0 +1,2 @@
## 1.0.0(2024-10-30)
发布程序
@@ -0,0 +1,227 @@
<template>
<view>
<camera v-if="isPermission" device-position="back" style="width: 100%; height: 100vh;position: fixed;" mode="scanCode"
@scancode="scanProcess" :flash="flash" :initdone="(isInitDone = true)">
<view @tap="back" class="backButton" :style="{top: getStatusBarHeight,height: getCapsuleHeight,width: getCapsuleHeight,}">
<image src="../../static/back.png"></image>
</view>
<view class="cameraUI" v-if="isInitDone">
<image class="scanBox" :src=" isOnlyBarCode === true ? '../../static/BarCodeScan.png' : '../../static/scan.png'" mode="widthFix">
<image class="scanEffect" src="../../static/scan-effect.png" mode="aspectFill"></image>
</image>
<view class="control">
<view :class="['flashLight', { flashOpen: flash === 'on' }]" @tap="swithFlash" hover-class="flashClick">
<image :src="flash === 'off' ? '../../static/flash_close.png' : '../../static/flash_open.png'"></image>
</view>
</view>
</view>
</camera>
</view>
</template>
<script setup>
import { ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
const { isOnlyBarCode, scanResult } = defineProps({
isOnlyBarCode: {
type: Boolean,
default: false,
},
scanResult: {
type: Function,
default: (data) => {
console.log(data);
uni.showToast({
icon: "none",
title: "扫描结果:" + data,
});
},
},
});
const SYSTEM_INFO = uni.getSystemInfoSync();
const getStatusBarHeight = SYSTEM_INFO.statusBarHeight + 5 + "px";
const getCapsuleHeight = uni.getMenuButtonBoundingClientRect().height + "px";
const isPermission = ref(false);
const isShowButtonAni = ref(false);
const isInitDone = ref(false);
const flash = ref("off");
onShow(() => {
handleCameraPermission()
.then(() => {
isPermission.value = true;
})
.catch((err) => {
uni.showModal({
title: "访问失败",
content: "由于您" + err.message +",无法使用本功能,如需使用请重新进入本页面进行授权。",
showCancel: false,
complete: (res) => {
back();
},
});
});
});
const back = () => {
uni.navigateBack();
};
const swithFlash = () => {
flash.value = flash.value === "off" ? "on" : "off";
uni.vibrateShort();
};
const scanProcess = (e) => {
if (isOnlyBarCode === true && e.detail.type !== "barcode") {
uni.showToast({
icon: "error",
title: "请扫描条形码",
});
uni.vibrateShort();
} else {
scanResult(e.detail.result);
uni.vibrateLong();
}
};
const handleCameraPermission = () => {
return new Promise((resolve, reject) => {
const showCameraPermissionModal = () => {
uni.showModal({
title: "提示",
content: "检测到未授权摄像头权限,将无法使用本功能,是否前往设置权限?",
success: (res) => {
if (res.confirm) {
uni.openSetting();
} else {
reject(new Error("未授权摄像头权限"));
}
},
});
};
uni.getSetting({
success: (res) => {
const hasCameraPermission =
res.authSetting.hasOwnProperty("scope.camera");
if (hasCameraPermission) {
if (!res.authSetting["scope.camera"]) {
showCameraPermissionModal();
} else {
resolve();
}
} else {
uni.authorize({
scope: "scope.camera",
success: () => {
resolve();
},
fail: () => {
showCameraPermissionModal();
},
});
}
},
fail: (err) => {
reject(err);
},
});
});
};
</script>
<style lang="less" scoped>
.cameraUI {
width: 750rpx;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
.scanBox {
width: 636rpx;
display: block;
position: relative;
.scanEffect {
position: absolute;
left: 50%;
top: 5%;
width: 595rpx;
height: 86rpx;
transform: translateX(-50%);
z-index: 1;
animation: scan 1.5s ease-in-out infinite;
}
}
.BarCodeScanEffect {
top: 30%;
animation: BarCodeScan 1.5s ease-in-out infinite;
}
@keyframes scan {
0% {
top: 5%;
opacity: 0;
}
20% {
opacity: 1;
}
80% {
opacity: 1;
}
100% {
top: 70%;
opacity: 0;
}
}
.control {
margin-top: 80rpx;
height: 110rpx;
display: flex;
align-items: center;
justify-content: center;
.flashLight {
width: 90rpx;
height: 90rpx;
background: rgba(74, 74, 74, 0.8);
box-shadow: rgba(142, 142, 142, 0.19) 0px 6px 15px 0px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
image {
width: 60%;
height: 60%;
}
}
.flashOpen {
backdrop-filter: blur(6px);
background: rgba(255, 255, 255, 0.5);
}
.flashClick {
transition: all 0.1s ease-in-out 0s;
transform: scale(1.2);
}
}
}
.backButton {
position: absolute;
left: 30rpx;
width: 65rpx;
height: 65rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
image {
width: 75%;
height: 75%;
}
}
</style>
@@ -0,0 +1,85 @@
{
"id": "Zeekin-ScanCode",
"displayName": "小程序扫描扫码解析二维码和条形码,简洁的代码、细节的交互和界面设计,开箱即用",
"version": "1.0.0",
"description": "基于camera实现,细节满满的界面、动画和交互设计,简洁清晰的组件代码,已配置完善的权限校验交互",
"keywords": [
"扫码",
"条形码",
"二维码",
"小程序"
],
"repository": "",
"engines": {
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "摄像头"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "n",
"vue3": "y"
},
"App": {
"app-vue": "n",
"app-nvue": "n",
"app-uvue": "n"
},
"H5-mobile": {
"Safari": "n",
"Android Browser": "n",
"微信浏览器(Android)": "n",
"QQ浏览器(Android)": "n"
},
"H5-pc": {
"Chrome": "n",
"IE": "n",
"Edge": "n",
"Firefox": "n",
"Safari": "n"
},
"小程序": {
"微信": "y",
"阿里": "n",
"百度": "n",
"字节跳动": "y",
"QQ": "n",
"钉钉": "n",
"快手": "y",
"飞书": "y",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}
@@ -0,0 +1,28 @@
### 程序简介
> 由于自带的uni.scancode不支持连续扫码和实现定制化的界面,故设计本程序。
基于camera实现,细节满满的界面、动画和交互设计,简洁清晰的组件代码,已配置完善的权限校验交互。
- 默认支持连续扫码,通过自行改写自定义扫描事件可以实现单次扫码;
- 支持仅扫描条形码,将更改扫描框样式和扫描结果过滤;
- 支持扫码震动反馈、提示轻震反馈,支持闪光灯功能;
- 清晰、简洁、兼容良好的代码和界面,可基于现有逻辑快速二次开发。
### 组件参数说明
参数名 | 类型 | 必填 | 说明
------------- | -------------
isOnlyBarCode | Boolean | 否 | 配置是否仅扫码条形码模式,默认为false
scanResult | Function | 否 | 自定义扫码成功事件,默认为弹出结果提示框
### 使用示例(uni_module)
```html
<Zeekin-ScanCode :scanResult="handleScanResult" :isOnlyBarCode="true" />
```
```javascript
const handleScanResult = (data) => {
uni.showToast({
icon: 'none',
title: '自定义处理扫描结果:' + data
});
};
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,61 @@
# 更新日志
## 1.3.0(2026-08-29)
### 新增
- `scanStyle.cornerStyle` 新增 `rounded` 完整圆角边框和 `rounded-corner` 四角圆角边框。
- 新增 `scanStyle.cornerRadius`,单位 dp,范围 `0 ~ 160`,默认 `20`。
- 两种圆角扫描框启用遮罩时,遮罩缺口和扫描线同步按同一圆角路径裁剪。
### 兼容性
- 保留 `corner` 四角边框与 `full` 完整矩形边框的原有绘制和调用方式。
- 圆角半径会自动限制在扫描框短边的一半以内,适配窄条码扫描框。
## 1.2.0(2026-08-28)
### 新增
- 新增独立的 `scanStyle` 配置,不再将视觉表现与 `scanRegion` 识别区域混合。
- 支持 `showMask`,可控制扫描框外遮罩的显示与隐藏。
- 支持 `showBeam`,可控制扫描线的显示与隐藏。
- 支持 `borderColor`、`maskColor`、`beamColor` 自定义边框、遮罩和扫描线颜色。
- 支持 `beamDuration` 配置扫描线移动周期,原生层限制为 `100 ~ 60000` 毫秒。
- 支持 `cornerStyle`:`corner` 四角边框、`full` 完整矩形边框。
- README 补充扫描区域、视觉样式、扫码页交互、生命周期回调和错误码说明。
- 新增 `9021005 ~ 9021011` 细化错误码,覆盖相机占用/初始化、相册读取与无条码、非法 `formats` 和单次扫码超时。
- 新增可选 `scanTimeout`,单次扫码可设置 `1000 ~ 600000` 毫秒超时;连续扫码不启用超时。
### 兼容性
- 保持原有 `openCodeScan` 调用方式不变。
- 未传 `scanStyle` 时使用默认样式:绿色边框、半透明黑色遮罩、绿色扫描线、四角边框。
- `scanStyle` 仅影响 Android 扫描页绘制,不改变条码识别逻辑。
- 同屏多码定格后的绿色点选标记增加呼吸波纹动画,帮助用户快速定位可点击目标。
## 1.1.0(2026-07-22)
### 新增
- 增加相册识图能力,支持打开扫码页后直接进入相册,也支持扫码页内手动选图。
- 增加 `formats` 码制过滤,支持二维码、一维码、商品码、Data Matrix、PDF417、Aztec 等格式。
- 增加 `vibrate` 成功震动开关。
- 增加 `continuous` 连续扫码和 `showPauseButton` 暂停/继续控制。
- 增加 `torch` 手电筒策略:`off`、`on`、`auto`。
- 增加 `resultMode` 结果处理策略:`immediate`、`confirm`、`pause`。
- 区分 Activity 获取失败、相机权限拒绝、用户取消和无有效结果等错误码。
### 增强
- 支持同屏多个条码定格并点击选择。
- 增加 `smartZoom` 智能变焦和双指手势缩放。
- 拆分 Kotlin 原生实现,改善扫码页、相册识图、结果处理和码制解析的维护性。
## 1.0.0(2026-07-22)
### 新增
- 初始化 Android 扫码 UTS API 插件。
- 基于 CameraX + Google ML Kit 实现实时条码识别。
- 支持通过 `success`、`fail`、`complete` 回调接收扫码结果和流程状态。
@@ -0,0 +1,101 @@
{
"id": "mas-code-scan",
"displayName": "android端扫码功能,uts插件,支持多二维码",
"version": "1.3.0",
"description": "Android 扫码 UTS 插件:全格式、多码点选、相册识图、连续扫、码制过滤、圆角扫描框视觉配置、错误码细分和扫码超时",
"keywords": [
"code-scan",
"扫码",
"scan-code",
"android"
],
"repository": "",
"engines": {
"HBuilderX": "^3.6.8",
"uni-app": "^5.0",
"uni-app-x": "^5.0"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "20871209"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "相机、震动"
},
"npmurl": "",
"darkmode": "x",
"i18n": "x",
"widescreen": "√"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "x",
"aliyun": "x",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "x",
"chrome": "x"
},
"app": {
"vue": "√",
"nvue": "x",
"android": "√",
"ios": "x",
"harmony": "x"
},
"mp": {
"weixin": "x",
"alipay": "x",
"toutiao": "x",
"baidu": "x",
"kuaishou": "x",
"jd": "x",
"harmony": "x",
"qq": "x",
"lark": "x",
"xhs": "x"
},
"quickapp": {
"huawei": "x",
"union": "x"
}
},
"uni-app-x": {
"web": {
"safari": "-",
"chrome": "-"
},
"app": {
"android": "-",
"ios": "-",
"harmony": "-"
},
"mp": {
"weixin": "-"
}
}
}
}
}
}
@@ -0,0 +1,751 @@
# mas-code-scan
Android 端扫码 **UTS API 插件**(非 Vue 组件)。
基于 CameraX + Google ML Kit,支持全格式条码/二维码、多码同屏点选、识别区域过滤、扫描框视觉配置、智能变焦、手势缩放、相册识图、连续扫码、手电筒策略、结果确认和暂停控制。
插件对外只提供 `openCodeScan` 一个 UTS API。扫码页由原生 Android Activity 提供,识别结果通过 `success`、`fail`、`complete` 回调返回。
支持:**Android App(uni-app Vue2/Vue3)**。
暂不支持:iOS / 鸿蒙 / H5 / 小程序 / nvue。
> 依赖原生三方库,**必须使用自定义调试基座或正式云打包**后才能真机调用。
---
## 插件定位与工作流程
`mas-code-scan` 是一个打开原生 Android 扫码 Activity 的 UTS API 插件,不是 Vue 组件,也不是对 `uni.scanCode` 的简单包装。一次扫码流程如下:
1. 业务页面调用 `openCodeScan(options)`。
2. 插件启动全屏竖屏原生扫码页,并根据参数初始化 CameraX、ML Kit 和界面控件。
3. 相机帧交给 ML Kit 识别;如果配置了 `scanRegion`,实时结果还会经过扫描框中心点过滤。
4. 单码按照 `resultMode` 返回;多码进入定格点选;连续模式通过 `success` 多次上报。
5. Activity 结束后通过 `complete` 收尾;失败场景同时通过 `fail` 和 `complete` 通知。
### 能力边界
- 识别引擎:Google ML Kit Barcode Scanning。
- 相机管线:CameraX,使用后置摄像头,预览采用 `FILL_CENTER` 填充显示。
- 图片来源:实时相机帧、系统相册图片。
- 原生页面:全屏、竖屏、无标题栏;关闭、相册、手电筒和连续扫码暂停按钮由插件绘制。
- 生命周期:每次调用创建一个扫码会话;关闭、成功、权限失败、超时或初始化失败后会话结束。
## 目录
1. [功能一览](#功能一览)
2. [安装](#安装)
3. [制作自定义基座(必做)](#制作自定义基座必做)
4. [快速上手](#快速上手)
5. [进阶用法](#进阶用法)
6. [API](#api)
7. [错误码](#错误码)
8. [扫码页交互说明](#扫码页交互说明)
9. [码制 formats 取值](#码制-formats-取值)
10. [常见问题](#常见问题)
11. [注意事项](#注意事项)
12. [目录结构](#目录结构)
13. [版本](#版本)
---
## 功能一览
| 能力 | 说明 |
| --- | --- |
| 全格式识别 | QR / EAN / CODE_128 / PDF417 / AZTEC 等 |
| 码制过滤 | `formats` 只扫指定类型 |
| 扫描区域 | `scanRegion` 配置扫描框尺寸、位置,并过滤框外条码 |
| 扫描框样式 | `scanStyle` 配置遮罩、扫描线、边框颜色、动画周期、边框形态和圆角大小 |
| 单码自动返回 | 识别成功后关闭并回调 |
| 多码点选 | 同屏多个码定格,点选其中一个 |
| 智能变焦 | `smartZoom`:码偏小时自动放大 |
| 手势缩放 | 双指捏合 |
| 手电筒 | 扫码页右下角开关,支持关闭、启动时开启和低照度自动补光 |
| 相册识图 | 页内相册按钮,或 `fromAlbum: true` |
| 连续扫码 | `continuous: true`,多次 `success`,点关闭结束;可显示暂停/继续按钮 |
| 结果处理 | 单次扫码支持立即返回、确认后返回、暂停后选择 |
| 成功震动 | `vibrate`,可关 |
| 错误码区分 | 权限拒绝 `9021002` ≠ 用户取消 `9021003` |
| 原生回收 | 页面结束时停止分析、释放相机、关闭 ML Kit Scanner、关闭手电筒并清理临时图片 |
---
## 安装
将插件拷贝到业务项目:
```text
你的项目/uni_modules/mas-code-scan/
```
UTS 插件需 **import 后调用**,不会 easycom 自动注册组件。
要求:
- HBuilderX ≥ 3.6.8(建议较新版本)
- uni-app Vue2 / Vue3 均可
- **仅 Android**;需制作自定义基座
本仓库演示页:`frontend/scanapp/pages/scan/prototype.vue`,用于在 Android 自定义基座中验证插件能力。页面包含:
- 智能变焦、成功震动、手电筒策略、结果处理模式和连续扫码暂停按钮。
- 扫码页标题、扫描引导语、成功提示语、无结果提示语自定义。
- 连续扫码成功提示停留时长、单次扫码超时配置。
- 二维码正方形框、横向条码框和全屏识别三种识别区域预设;横向条码框支持上/中/下位置、自定义上下微调和三档高度。
- 扫描框遮罩、扫描线、边框颜色、遮罩颜色、扫描线颜色、扫描线周期、四种边框形态和圆角半径配置。
- 二维码、CODE 128、EAN-13 码制筛选,相册识图、单次扫码、连续扫码和本地结果列表。
演示页的“最近结果”仅保存在当前页面内,用于展示回调结果,不会提交业务接口。
---
## 制作自定义基座(必做)
插件依赖:
- `androidx.camera:*` 1.4.1
- `com.google.mlkit:barcode-scanning` 17.3.0
标准基座不含上述库,**不制作自定义基座会调用失败 / 编译不过**。
### 步骤
1. HBuilderX 菜单:**运行** → **运行到手机或模拟器** → **制作自定义调试基座**
2. 选择当前项目,填写 Android 包名、证书(可用公共测试证书)
3. 云打包,等待完成并安装到手机
4. 之后每次运行选择 **「自定义调试基座」**,再打开演示页或业务页调试扫码
正式发版:使用 **云打包 / 本地打包** 生成安装包即可(打包流程会带上 UTS 原生依赖)。
---
## 快速上手
### 1. uni-app(Vue3 `<script setup>`)
```vue
<template>
<view>
<button type="primary" @click="doScan">扫码</button>
<text>{{ tip }}</text>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { openCodeScan } from '@/uni_modules/mas-code-scan'
const tip = ref('')
const doScan = () => {
openCodeScan({
success: (res) => {
tip.value = res.content + ' / ' + res.format
},
fail: (err) => {
tip.value = err.errMsg + '(' + err.errCode + ')'
}
})
}
</script>
```
### 2. uni-app(Options API)
```js
import { openCodeScan } from '@/uni_modules/mas-code-scan'
export default {
methods: {
doScan() {
openCodeScan({
success: (res) => {
console.log(res.content, res.format, res.fromAlbum)
},
fail: (err) => {
console.log(err.errCode, err.errMsg)
},
complete: () => {
console.log('扫码流程结束')
}
})
}
}
}
```
### 3. 最少参数
```ts
import { openCodeScan } from '@/uni_modules/mas-code-scan'
openCodeScan({
success: (res) => console.log(res.content)
})
```
---
## 进阶用法
### 自定义扫码页文案
以下参数只影响原生扫码页显示文字,不影响识别逻辑:
```ts
openCodeScan({
title: '扫描商品码',
hint: '请将商品条码放入框内',
successHint: '识别成功',
emptyHint: '图片中没有商品条码',
success: (res) => console.log(res.content)
})
```
- `title`:顶部标题,默认 `扫一扫`。
- `hint`:扫描框下方引导语,默认 `请将条码放入扫描框内`。
- `successHint`:连续扫码成功提示层标题,也用于部分原生提示,默认 `扫码成功`。
- `emptyHint`:相册图片未识别到条码时的提示,默认 `未识别到有效条码`。
- 空字符串或全空格会回退到默认文案;原生层会去除首尾空格。
### 只扫二维码
```ts
openCodeScan({
formats: ['QR_CODE'],
success: (res) => console.log(res.content)
})
```
### 手电筒和结果处理
```ts
openCodeScan({
torch: 'auto', // 'off' | 'on' | 'auto'
resultMode: 'confirm', // 'immediate' | 'confirm' | 'pause'
success: (res) => console.log(res.content)
})
```
`confirm` 会弹出结果确认框;`pause` 会暂停识别并提供“继续扫描/确认”操作。两种模式下,点击确认才会结束并触发 `success`、`complete`;点击重新扫描或继续扫描会恢复相机识别,不会触发失败回调。
### 设置单次扫码超时
```ts
openCodeScan({
scanTimeout: 15000,
success: (res) => console.log(res.content),
fail: (err) => {
if (err.errCode === 9021011) console.log('15 秒内未识别到条码')
}
})
```
`scanTimeout` 单位为毫秒。只有单次扫码且值在 `1000 ~ 600000` 时启用;不传、传 `0` 或小于 `1000` 时不启用。连续扫码始终不启用单次超时。
### 关闭智能变焦 / 震动
```ts
openCodeScan({
smartZoom: false,
vibrate: false,
success: (res) => console.log(res.content)
})
```
### 配置扫描区域
`scanRegion` 同时决定扫描框的位置和实时识别过滤区域。宽度、高度、中心点均使用 `0 ~ 1` 的比例值;宽高以预览宽度为参考,越界值会由原生层收敛到可显示范围。`scanRegion` 只作用于实时相机,不裁剪或过滤相册图片。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
success: (res) => console.log(res.content)
})
```
- 不传 `scanRegion`:保持全屏识别,同时保留默认扫描框显示。
- `scanRegion: { fullScreen: true }`:整画面识别,不显示扫描框,也不进行区域过滤。
- 配置扫描区域后,条码中心点必须落在扫描框内才会被实时相机识别结果接受。
- 相册识图不受 `scanRegion` 限制。
### 配置扫描框视觉样式
`scanStyle` 只控制扫描页视觉表现,不改变识别区域。颜色支持 `#RRGGBB`、`#AARRGGBB`;遮罩还支持 `rgba(r, g, b, a)`。
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
- `showMask: false` 仅隐藏框外遮罩,不会扩大或改变识别范围。
- `showBeam: false` 隐藏扫描线;扫描线周期 `beamDuration` 单位为毫秒,原生层限制在 `100 ~ 60000`。
- `cornerStyle: 'corner'` 显示直角四角边框,`'full'` 显示完整矩形边框,`'rounded'` 显示完整圆角边框,`'rounded-corner'` 显示四角圆角边框。
- `cornerRadius` 为圆角半径,单位为 dp,范围 `0 ~ 160`,在两种圆角边框样式下均生效;圆角遮罩缺口和扫描线会同步裁剪为相同圆角。
- `rounded` 是四边完整的圆角边框;`rounded-corner` 只绘制四个带圆弧过渡的角,中间不连接四条边。
- 圆角半径最终还会限制在扫描框短边的一半以内,适合二维码框和横向条码框。
- 当未启用 `scanRegion` 时,视觉样式仅作用于默认扫描框;`fullScreen: true` 时不会显示扫描框。
### 打开后直接进相册
```ts
openCodeScan({
fromAlbum: true,
success: (res) => {
console.log(res.content, res.fromAlbum) // fromAlbum === true
}
})
```
扫码页内也可随时点左下角 **相册** 按钮选图识别。
### 连续扫码
页内不关闭,每识别一次触发一次 `success`;用户点左上角关闭(或系统返回)后走 `complete`(若已有命中,**不会**再走 `fail`)。
```ts
const list = []
openCodeScan({
continuous: true,
showPauseButton: true,
vibrate: true,
success: (res) => {
list.push(res.content)
console.log('命中', res.content, res.format)
},
fail: (err) => {
// 一次都没扫到就关闭 → 一般是 9021003 取消
console.log('失败', err.errCode, err.errMsg)
},
complete: (res) => {
if (res && res.closed) {
console.log('结束,共', res.hitCount, '次', list)
}
}
})
```
### 组合:仅二维码 + 连续扫 + 相册入口
```ts
openCodeScan({
formats: ['QR_CODE'],
continuous: true,
smartZoom: true,
vibrate: true,
success: (res) => console.log(res),
complete: (res) => console.log('done', res)
})
```
---
## API
### openCodeScan(options?)
打开全屏扫码页。
#### CodeScanOptions
| 参数 | 类型 | 必填 | 默认 | 说明 |
| --- | --- | :---: | --- | --- |
| `title` | `string` | 否 | `扫一扫` | 原生扫码页顶部标题 |
| `hint` | `string` | 否 | `请将条码放入扫描框内` | 扫描框下方引导语 |
| `successHint` | `string` | 否 | `扫码成功` | 连续扫码成功提示层标题 |
| `emptyHint` | `string` | 否 | `未识别到有效条码` | 图片无条码时提示语 |
| `smartZoom` | `boolean` | 否 | `true` | 条码占画面较小时自动平滑放大;最多尝试 6 次 |
| `formats` | `string[]` | 否 | 全部 | 限定码制,见下方取值表;空/不传 = 全格式 |
| `vibrate` | `boolean` | 否 | `true` | 识别成功后是否短震动约 40ms |
| `scanRegion` | `ScanRegion` | 否 | 未启用 | 实时相机识别区域,控制扫描框尺寸、位置和中心点过滤 |
| `scanStyle` | `CodeScanStyle` | 否 | 见下表 | 扫描框视觉样式,不改变识别区域 |
| `torch` | `'off' \| 'on' \| 'auto'` | 否 | `'off'` | 手电筒策略;`on` 启动后开启,`auto` 低照度连续满足条件后自动开启 |
| `resultMode` | `'immediate' \| 'confirm' \| 'pause'` | 否 | `'immediate'` | 单次扫码结果处理方式;连续扫码固定按立即上报处理 |
| `continuous` | `boolean` | 否 | `false` | 连续扫:页内不关闭,每次命中触发一次 `success` |
| `continuousSuccessDuration` | `number` | 否 | `1000` | 连续扫码成功提示停留时长,单位毫秒,范围 `0 ~ 600000` |
| `showPauseButton` | `boolean` | 否 | `false` | 仅 `continuous: true` 时显示右上角暂停/继续按钮 |
| `fromAlbum` | `boolean` | 否 | `false` | `true` 时打开扫码页后立即拉起系统相册 |
| `scanTimeout` | `number` | 否 | 不启用 | 单次扫码超时,单位毫秒,范围 `1000 ~ 600000` |
| `success` | `(res: CodeScanSuccess) => void` | 否 | - | 成功;连续模式下可多次触发 |
| `fail` | `(err: CodeScanFail) => void` | 否 | - | 失败或取消;连续模式已有命中后关闭时不触发 |
| `complete` | `(res: any) => void` | 否 | - | 会话结束;成功、失败、取消和连续扫码关闭都会触发 |
#### ScanRegion
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `fullScreen` | `boolean` | `false` | 为 `true` 时整画面识别,不显示扫描框,也不做中心点过滤;优先级高于其他区域参数 |
| `width` | `number` | `0.74` | 扫描框宽度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `height` | `number` | `0.74` | 扫描框高度相对预览宽度的比例,原生限制在 `0.1 ~ 1` |
| `centerX` | `number` | `0.5` | 扫描框中心横坐标比例,范围 `0 ~ 1`,越界会收敛 |
| `centerY` | `number` | `0.5` | 扫描框中心纵坐标比例,范围 `0 ~ 1`,越界会收敛 |
区域映射会补偿 `PreviewView` 的 `FILL_CENTER` 裁剪偏移和图像旋转;最终判断的是 ML Kit 包围盒的中心点是否在区域内,而不是要求整个条码完全位于框内。
建议:二维码使用接近正方形的区域,横向条码使用较宽且较矮的区域;如果业务只需要识别、不需要框选提示,可使用 `fullScreen: true`。
#### 组合约束与默认行为
| 组合 | 实际行为 |
| --- | --- |
| 不传 `scanRegion` | 实时相机按全画面识别;使用原生默认扫描框布局 |
| `scanRegion.fullScreen: true` | 全画面识别,不显示扫描框,不过滤框外结果 |
| `fromAlbum: true` | 先打开相册;选图识别失败后,在相机可用时仍可继续相机扫码 |
| `continuous: true` | 页内持续识别,`resultMode` 不参与单码确认;关闭页结束会话 |
| `continuous: true` + `showPauseButton: true` | 右上角显示暂停/继续按钮,暂停期间不处理相机帧 |
| `continuous: true` + `scanTimeout` | 忽略 `scanTimeout`,连续扫码没有会话超时 |
| `continuous: true` + `resultMode` | 连续扫码固定立即上报,每次命中通过 `success` 返回 |
| `formats` 含未知值和有效值 | 忽略未知值,使用有效码制 |
| `formats` 非空且全部未知 | 立即失败并返回 `9021010` |
| 单次识别到多个有定位框的条码 | 定格当前画面,显示绿色点选标记,点击后返回选中条码 |
| 连续扫码短时间重复同一内容 | 约 1.2 秒内去重,避免重复触发 `success` |
### CodeScanStyle
| 参数 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `showMask` | `boolean` | `true` | 是否显示扫描框外遮罩 |
| `showBeam` | `boolean` | `true` | 是否显示扫描线 |
| `borderColor` | `string` | `#19be6b` | 边框颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `maskColor` | `string` | `rgba(0, 0, 0, 0.45)` | 遮罩颜色和透明度,支持 `rgba()` |
| `beamColor` | `string` | `#12e880` | 扫描线颜色,支持 `#RRGGBB`、`#AARRGGBB` |
| `beamDuration` | `number` | `1800` | 扫描线移动周期,单位为毫秒,范围 `100 ~ 60000` |
| `cornerStyle` | `'corner' \| 'full' \| 'rounded' \| 'rounded-corner'` | `'corner'` | `corner` 为直角四角边框,`full` 为完整边框,`rounded` 为完整圆角边框,`rounded-corner` 为四角圆角边框 |
| `cornerRadius` | `number` | `20` | 圆角半径,单位 dp,范围 `0 ~ 160`,两种圆角样式均生效 |
默认值:`showMask: true`、`showBeam: true`、`borderColor: '#19be6b'`、`maskColor: 'rgba(0, 0, 0, 0.45)'`、`beamColor: '#12e880'`、`beamDuration: 1800`、`cornerStyle: 'corner'`、`cornerRadius: 20`。非法颜色会回退到默认值,`beamDuration` 会限制在 `100 ~ 60000` 毫秒,`cornerRadius` 会限制在 `0 ~ 160` dp。
示例:
```ts
openCodeScan({
scanRegion: {
width: 0.96,
height: 0.24,
centerX: 0.5,
centerY: 0.5
},
scanStyle: {
showMask: true,
showBeam: true,
borderColor: '#19be6b',
maskColor: 'rgba(0, 0, 0, 0.45)',
beamColor: '#12e880',
beamDuration: 1800,
cornerStyle: 'rounded',
cornerRadius: 20
},
success: (res) => console.log(res.content)
})
```
`scanRegion` 控制实时识别区域,`scanStyle` 只控制扫描页的视觉表现;两者可以独立配置。不传 `scanRegion` 时实时相机按全画面识别,但仍显示原生默认扫描框;需要隐藏扫描框时请传 `scanRegion: { fullScreen: true }`。
#### 回调返回值与生命周期
单次扫码成功:
```ts
success({ content: '...', format: 'QR_CODE', fromAlbum: false })
complete({ content: '...', format: 'QR_CODE', fromAlbum: false })
```
单次扫码失败或用户取消:
```ts
fail({ errCode: 9021003, errMsg: '用户取消扫码', errSubject: 'mas-code-scan' })
complete({ errCode: 9021003, ... })
```
连续扫码关闭:
```ts
complete({ closed: true, hitCount: 3 })
```
连续扫码如果一次命中都没有就关闭,通常会先触发 `fail(9021003)`,随后触发 `complete({ closed: true, hitCount: 0, failed: true, errCode: 9021003 })`。已有命中后关闭只触发 `complete`,不会触发 `fail`。
| 回调 | 触发时机 | 注意 |
| --- | --- | --- |
| `success` | 单次确认成功;或连续模式每次命中 | 多码点选只有点击某个码后触发 |
| `fail` | Activity 无法启动、权限失败、相机失败、相册失败、超时、用户取消等 | 用户取消错误码为 `9021003` |
| `complete` | 每次会话结束 | 不能只依赖 `success` 清理页面状态 |
#### CodeScanSuccess
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `content` | `string` | 识别到的文本内容 |
| `format` | `string` | 码制名,如 `QR_CODE`、`CODE_128` |
| `fromAlbum` | `boolean` | 是否来自相册识图(可选) |
#### CodeScanFail
继承 `UniError`:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `errCode` | `number` | 见错误码表 |
| `errMsg` | `string` | 错误描述 |
| `errSubject` | `string` | 固定 `"mas-code-scan"` |
回调规则:
- 单次扫码成功:依次调用 `success`、`complete`,成功对象包含 `content`、`format` 和 `fromAlbum`。
- 单次扫码失败或取消:依次调用 `fail`、`complete`。
- 连续扫码:每次命中调用一次 `success`;关闭页面时调用 `complete({ closed: true, hitCount })`。
- 连续扫码在已有命中后关闭:不会再调用 `fail`,只调用 `complete`。
- 相册识图成功:`fromAlbum` 为 `true`;相机实时识别成功时为 `false`。
#### 类型定义(摘录)
```ts
type CodeScanOptions = {
title?: string
hint?: string
successHint?: string
emptyHint?: string
smartZoom?: boolean
formats?: string[]
vibrate?: boolean
torch?: 'off' | 'on' | 'auto'
resultMode?: 'immediate' | 'confirm' | 'pause'
continuous?: boolean
continuousSuccessDuration?: number
showPauseButton?: boolean
fromAlbum?: boolean
scanRegion?: {
fullScreen?: boolean
width?: number
height?: number
centerX?: number
centerY?: number
}
scanStyle?: {
showMask?: boolean
showBeam?: boolean
borderColor?: string
maskColor?: string
beamColor?: string
beamDuration?: number
cornerStyle?: 'corner' | 'full' | 'rounded' | 'rounded-corner'
cornerRadius?: number
}
// 单次扫码超时(毫秒);小于 1000 或不传则不启用,最大 600000;连续扫码不启用超时
scanTimeout?: number
success?: (res: CodeScanSuccess) => void
fail?: (res: CodeScanFail) => void
complete?: (res: any) => void
}
type CodeScanSuccess = {
content: string
format: string
fromAlbum?: boolean
}
```
---
## 错误码
| 错误码 | 说明 | 常见场景 |
| --- | --- | --- |
| `9021001` | 无法获取页面 Activity / 平台不支持 | 非 Android、页面上下文异常 |
| `9021002` | 相机权限被拒绝 | 用户拒绝相机权限(非「仅相册」场景) |
| `9021003` | 用户取消扫码 | 点关闭、系统返回、未扫就退出 |
| `9021004` | 未获取到有效扫码结果 | 成功关页但内容为空(少见) |
| `9021005` | 相机被其他应用占用或当前不可用 | 相机被其他应用、系统功能或设备状态占用 |
| `9021006` | 设备不支持手电筒 | 点击手电筒时提示;不结束当前扫码会话 |
| `9021007` | 相机初始化失败 | CameraX 初始化、绑定预览或分析管线失败 |
| `9021008` | 相册图片无法读取 | 所选 URI 无法读取、复制或解码图片 |
| `9021009` | 图片中未识别到条码 | `fromAlbum: true` 且无可回退相机时,图片中不存在有效码 |
| `9021010` | 指定码制不支持 | `formats` 非空但其中没有插件支持的码制 |
| `9021011` | 扫码超时 | 单次扫码设置了有效 `scanTimeout` 且超时未成功;连续扫码不启用 |
说明:
- `9021001` ~ `9021004` 为已有错误码,含义保持不变;新增错误码从 `9021005` 开始。
- `fail` 与 `complete` 在失败时都会触发(连续扫「已有命中后关闭」除外:只 `complete`)。
- 打开即相册且无相机权限时,仍可用相册;点关闭且无命中 → 一般为 `9021003`。
- 无闪光灯不影响扫码,因此 `9021006` 仅通过扫码页提示展示,不会触发 `fail`。
---
## 扫码页交互说明
| 控件 / 手势 | 说明 |
| --- | --- |
| 左上关闭 | 退出;连续扫时结束会话 |
| 左下相册 | 选图识别;多码时同样可点选 |
| 右下手电筒 | 有闪光灯时可用;可由 `torch` 设置初始策略,`auto` 会在低照度时自动开启 |
| 右上暂停按钮 | 仅连续扫码且 `showPauseButton: true` 时显示,暂停/继续识别 |
| 结果确认框 | `resultMode: 'confirm'` 或 `'pause'` 时,确认返回或继续扫描 |
| 双指捏合 | 手动变焦 |
| 多码绿点 | 同屏多个有效码时定格,显示绿色呼吸波纹,点击选中 |
| 系统返回键 | 等同关闭 |
---
## 码制 formats 取值
传入字符串数组;空/缺省时按全格式。若 `formats` 非空但全部不受支持,调用失败并返回 `9021010`。
| 取值 | 说明 |
| --- | --- |
| `QR_CODE` / `QR` | 二维码 |
| `EAN_13` / `EAN_8` | 商品条码 |
| `CODE_128` / `CODE_39` / `CODE_93` | 一维码 |
| `CODABAR` | Codabar |
| `DATA_MATRIX` | Data Matrix |
| `UPC_A` / `UPC_E` | UPC |
| `ITF` | ITF |
| `PDF417` | PDF417 |
| `AZTEC` | Aztec |
| `ALL` | 全格式(与具体码制混用时按全格式处理) |
补充规则:插件会忽略未知 token;只要数组中存在一个有效 token 就使用有效 token。数组非空但全部无效时返回 `9021010`。`ALL` 与其他有效码制同时传入时最终使用全格式。
示例:
```ts
formats: ['QR_CODE', 'CODE_128', 'EAN_13']
```
---
## 常见问题
### 1. 点击没反应 / 报找不到类?
未使用自定义基座。请按上文制作并选择「自定义调试基座」再运行。
### 2. 提示相机权限被拒绝(9021002)?
到系统设置打开应用相机权限后重试。
### 3. 连续扫只回调一次?
确认传了 `continuous: true`。同一内容约 1.2 秒内会去重,避免狂刷;换个码或稍等再扫。
### 4. 相册选了图但提示未识别?
图中可能没有清晰条码,或被 `formats` 过滤掉。可先不传 `formats` 试全格式;打开即相册且没有可用相机时,会以 `9021009` 返回无条码错误。
### 5. 为什么会提示扫码超时(9021011)?
仅在单次扫码传入 `scanTimeout`(1000 ~ 600000 毫秒)后启用。超时不会影响连续扫码;不需要限制时不传该参数或传 `0`。
### 6. iOS / 鸿蒙能用吗?
当前版本仅 Android。目录未包含 iOS/鸿蒙实现。
### 7. 能否当组件标签用?
不能。这是 **UTS API**,请 `import { openCodeScan } from '@/uni_modules/mas-code-scan'` 后调用。
### 8. 演示页可以直接当业务页面使用吗?
`prototype.vue` 是能力验证页面,结果列表是本地状态,不包含业务接口提交、权限引导和业务字段转换。正式业务建议复制 `openScan` 调用方式,在自己的页面中处理 `success`、`fail` 和 `complete`,并在 `complete` 中恢复 loading、页面状态等资源。
### 9. `scanStyle` 不生效怎么办?
确认调用的是包含当前插件的自定义调试基座或正式打包应用。修改 UTS 原生代码后需要重新制作自定义基座或重新云打包,普通标准基座不会加载插件原生依赖和最新实现。
### 10. 扫描框显示了但框外条码没有识别?
这是 `scanRegion` 的预期行为。扫描框不仅是视觉提示,也会作为实时识别过滤区域;如需整屏识别,请不传 `scanRegion` 或传入 `fullScreen: true`。
### 11. `resultMode` 和 `continuous` 一起传会怎样?
连续扫码以连续模式为准,不显示单次扫码确认框,每次识别成功都会通过 `success` 上报;如需单个结果确认,请不要传 `continuous: true`。
### 12. `fromAlbum` 和相机权限有什么关系?
Android 13 及以上优先使用系统 Photo Picker;Android 12 及以下使用系统图片选择器,可能需要读取相册权限。打开即相册时,即使没有相机权限,也可以先完成相册识图;相册无结果且无法继续使用相机时返回 `9021009`。
### 13. 为什么识别框位置和相机画面看起来不完全一致?
预览采用 `FILL_CENTER`,相机画面会按比例填充并裁剪边缘;插件会在识别过滤时补偿裁剪和旋转,但不同设备的相机传感器比例、状态栏和厂商裁剪策略可能存在少量视觉差异。
---
## 注意事项
1. 仅 Android App-Plus;发布市场包请走正式打包,勿依赖标准基座调试结果。
2. 首次使用实时相机需要相机权限;插件不会主动跳转系统设置,权限被拒绝后由业务决定是否引导用户开启。
3. 震动权限由插件 Manifest 声明;设置 `vibrate: false` 可关闭成功震动。
4. 相册选图走系统选择器;Android 12 及以下可能需要读取相册权限,Android 13 及以上优先使用 Photo Picker。
5. `scanRegion` 只过滤实时相机,且按条码包围盒中心点判断;相册识图始终分析整张图片。
6. 连续扫依赖原生命中队列和 UTS 桥接轮询,业务应在 `complete` 中收尾,不要只在 `success` 中恢复状态。
7. 多码点选依赖 ML Kit 返回定位框;多码中只有一个可解码码时会直接返回,无法定位的结果不能作为可点击目标。
8. 智能变焦和双指缩放共享相机变焦能力;不支持变焦的设备会保持原始比例。
9. 大尺寸相册图片会先缩放到最长边约 1920 像素再识别,以降低内存压力。
10. 文档与演示不保证覆盖所有机型相机差异,建议使用真实目标机型回归。
---
## 目录结构
```text
mas-code-scan/
├── package.json
├── readme.md
├── changelog.md
└── utssdk/
├── interface.uts # 对外类型与 API 声明
├── unierror.uts # 错误实现
└── app-android/
├── index.uts # JS/UTS 桥接
├── config.json # 原生依赖
├── AndroidManifest.xml
├── CodeCaptureActivity.kt
├── MultiCodePickView.kt
├── ScanFrameDecorView.kt
├── YuvFrames.kt
├── SymbologyUtil.kt
├── CodeScanHitBus.kt
└── res/drawable/ # 关闭/相册等资源
```
---
## 版本
| 版本 | 说明 |
| --- | --- |
| 1.3.0 | 新增 `rounded` 完整圆角边框、`rounded-corner` 四角圆角边框和自定义 `cornerRadius`,遮罩与扫描线同步适配圆角 |
| 1.2.0 | 新增 `scanStyle` 扫描框视觉配置,支持遮罩、扫描线、颜色、动画周期、四角/完整边框;完善 `scanRegion` 与回调说明 |
| 1.1.0 | 权限码区分、关闭/相册、码制过滤、震动、连续扫、Kotlin 拆分 |
| 1.0.0 | Android 扫码初版 |
更多变更见 `changelog.md`。
## 相关链接
- [UTS 语法](https://uniapp.dcloud.net.cn/tutorial/syntax-uts.html)
- [UTS API 插件](https://uniapp.dcloud.net.cn/plugin/uts-plugin.html)
- [制作自定义调试基座](https://uniapp.dcloud.net.cn/tutorial/run/run-app.html#customplayground)
@@ -0,0 +1,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="uts.sdk.modules.masCodeScan">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application>
<activity
android:name="uts.sdk.modules.masCodeScan.CodeCaptureActivity"
android:exported="false"
android:screenOrientation="portrait"
android:theme="@android:style/Theme.NoTitleBar.Fullscreen" />
</application>
</manifest>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
package uts.sdk.modules.masCodeScan
import java.util.concurrent.ConcurrentLinkedQueue
/**
* 连续扫码命中总线:Activity 入队,index.uts 轮询出队并触发 success。
*/
object CodeScanHitBus {
private val queue = ConcurrentLinkedQueue<Array<String>>()
@JvmStatic
fun detach() {
queue.clear()
}
@JvmStatic
fun emitHit(content: String, format: String, fromAlbum: Boolean) {
queue.offer(
arrayOf(
content,
format,
if (fromAlbum) "1" else "0"
)
)
}
/** 取出一条命中:[content, format, fromAlbumFlag];无数据返回 null */
@JvmStatic
fun pollHit(): Array<String>? {
return queue.poll()
}
}
@@ -0,0 +1,96 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.view.View
import kotlin.math.min
/**
* 连续扫码成功提示层:覆盖扫描框中部,显示成功状态和本次扫码值。
*/
class ContinuousSuccessOverlayView(
context: Context,
private val successHint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val scaledDensity = resources.displayMetrics.scaledDensity
private val panelPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(218, 48, 48, 48)
style = Paint.Style.FILL
}
private val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * scaledDensity
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * scaledDensity
textAlign = Paint.Align.CENTER
}
private val panelRect = RectF()
private var frameRect = RectF()
private var stillFrame: Bitmap? = null
private var value = ""
fun showSuccess(scanFrame: RectF, content: String, bitmap: Bitmap?) {
stillFrame?.recycle()
stillFrame = bitmap
frameRect = RectF(scanFrame)
value = content
visibility = VISIBLE
invalidate()
}
fun hideSuccess() {
stillFrame?.recycle()
stillFrame = null
value = ""
visibility = GONE
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (value.isBlank() || width <= 0 || height <= 0) return
stillFrame?.takeIf { !it.isRecycled && it.width > 0 && it.height > 0 }?.let { bitmap ->
val bitmapWidth = bitmap.width.toFloat()
val bitmapHeight = bitmap.height.toFloat()
val scale = maxOf(width / bitmapWidth, height / bitmapHeight)
val drawWidth = bitmapWidth * scale
val drawHeight = bitmapHeight * scale
val left = (width - drawWidth) / 2f
val top = (height - drawHeight) / 2f
canvas.drawBitmap(bitmap, null, RectF(left, top, left + drawWidth, top + drawHeight), null)
}
val availableWidth = if (frameRect.isEmpty) width.toFloat() else frameRect.width()
val panelWidth = min(300f * density, maxOf(0f, availableWidth - 24f * density))
val panelHeight = 68f * density
val centerX = if (frameRect.isEmpty) width / 2f else frameRect.centerX()
val centerY = if (frameRect.isEmpty) height / 2f else frameRect.centerY()
panelRect.set(
centerX - panelWidth / 2f,
centerY - panelHeight / 2f,
centerX + panelWidth / 2f,
centerY + panelHeight / 2f
)
panelRect.left = panelRect.left.coerceAtLeast(12f * density)
panelRect.right = panelRect.right.coerceAtMost(width - 12f * density)
panelRect.top = panelRect.top.coerceAtLeast(24f * density)
panelRect.bottom = panelRect.bottom.coerceAtMost(height - 24f * density)
canvas.drawRoundRect(panelRect, 12f * density, 12f * density, panelPaint)
val titleBaseline = panelRect.centerY() - 3f * density
val valueBaseline = panelRect.centerY() + 19f * density
canvas.drawText(successHint, panelRect.centerX(), titleBaseline, titlePaint)
canvas.drawText(value, panelRect.centerX(), valueBaseline, valuePaint)
}
}
@@ -0,0 +1,267 @@
package uts.sdk.modules.masCodeScan
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.os.SystemClock
import android.view.View
import com.google.mlkit.vision.barcode.common.Barcode
import kotlin.math.max
import kotlin.math.sin
data class HitMark(
val rawValue: String,
val format: Int,
val formatName: String,
val screenRect: Rect
) {
val centerX: Float get() = screenRect.exactCenterX()
val centerY: Float get() = screenRect.exactCenterY()
}
/**
* 多码点选覆盖层:定格帧 + 圆点标记。
*/
class MultiCodePickView(context: Context) : View(context) {
private var stillFrame: Bitmap? = null
private var rawHits: List<Barcode> = emptyList()
private var hitMarks: List<HitMark> = emptyList()
private var visualCrop: Rect = Rect()
private var stillW: Int = 0
private var stillH: Int = 0
private var layerW: Int = 0
private var layerH: Int = 0
private var recognizing: Boolean = false
private var recognizingStartedAt: Long = 0L
private val density: Float = resources.displayMetrics.density
private val dotOuterR: Float = 18f * density
private val dotInnerR: Float = 6f * density
private val touchR: Float = 28f * density
private val breatheDurationMs = 1500L
private val haloPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.argb(60, 7, 193, 96)
}
private val shellPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.WHITE
}
private val corePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = Color.rgb(7, 193, 96)
}
private val shadePaint = Paint().apply {
color = Color.argb(40, 0, 0, 0)
}
private val promptBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(190, 0, 0, 0)
}
private val promptTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 16f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
typeface = android.graphics.Typeface.DEFAULT_BOLD
}
private val loadingTrackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.argb(100, 255, 255, 255)
}
private val loadingArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 3f * density
strokeCap = Paint.Cap.ROUND
color = Color.rgb(7, 193, 96)
}
private val loadingTextPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 15f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
layerW = right - left
layerH = bottom - top
if (changed && rawHits.isNotEmpty()) {
rebuildHitMarks()
}
}
fun showRecognizing(bitmap: Bitmap) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = bitmap.width
stillH = bitmap.height
recognizing = true
recognizingStartedAt = SystemClock.elapsedRealtime()
visibility = VISIBLE
invalidate()
}
fun presentStillFrame(bitmap: Bitmap, barcodes: List<Barcode>, visualCropRect: Rect) {
if (stillFrame !== bitmap) {
stillFrame?.recycle()
}
stillFrame = bitmap
rawHits = barcodes
visualCrop = Rect(visualCropRect)
stillW = bitmap.width
stillH = bitmap.height
recognizing = false
rebuildHitMarks()
visibility = VISIBLE
invalidate()
}
private fun rebuildHitMarks() {
if (layerW <= 0 || layerH <= 0 || stillW <= 0 || stillH <= 0) {
hitMarks = emptyList()
return
}
hitMarks = rawHits.mapNotNull { barcode ->
val box = barcode.boundingBox ?: return@mapNotNull null
val adjustedBox = Rect(
box.left - visualCrop.left,
box.top - visualCrop.top,
box.right - visualCrop.left,
box.bottom - visualCrop.top
)
val clippedBox = Rect(adjustedBox)
if (!clippedBox.intersect(0, 0, stillW, stillH)) {
return@mapNotNull null
}
val screenRect = mapBoxToScreen(clippedBox, stillW, stillH, layerW, layerH)
HitMark(
rawValue = barcode.rawValue ?: "",
format = barcode.format,
formatName = SymbologyUtil.nameOf(barcode.format),
screenRect = screenRect
)
}
}
fun clear() {
stillFrame?.recycle()
stillFrame = null
rawHits = emptyList()
hitMarks = emptyList()
visualCrop.setEmpty()
stillW = 0
stillH = 0
recognizing = false
recognizingStartedAt = 0L
visibility = GONE
invalidate()
}
fun pickHitAt(x: Float, y: Float): HitMark? {
return hitMarks.minByOrNull {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy
}?.takeIf {
val dx = x - it.centerX
val dy = y - it.centerY
dx * dx + dy * dy <= touchR * touchR
}
}
private fun mapBoxToScreen(
boundingBox: Rect,
sourceW: Int, sourceH: Int,
viewW: Int, viewH: Int
): Rect {
val scale = max(viewW.toFloat() / sourceW, viewH.toFloat() / sourceH)
val offsetX = (viewW - sourceW * scale) / 2f
val offsetY = (viewH - sourceH * scale) / 2f
return Rect(
(boundingBox.left * scale + offsetX).toInt(),
(boundingBox.top * scale + offsetY).toInt(),
(boundingBox.right * scale + offsetX).toInt(),
(boundingBox.bottom * scale + offsetY).toInt()
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
stillFrame?.let { bmp ->
if (layerW <= 0 || layerH <= 0) return@let
val bmpW = bmp.width.toFloat()
val bmpH = bmp.height.toFloat()
val scale = max(layerW.toFloat() / bmpW, layerH.toFloat() / bmpH)
val drawW = (bmpW * scale).toInt()
val drawH = (bmpH * scale).toInt()
val left = (layerW - drawW) / 2
val top = (layerH - drawH) / 2
val destRect = Rect(left, top, left + drawW, top + drawH)
canvas.drawBitmap(bmp, null, destRect, null)
if (!recognizing) {
canvas.drawRect(destRect, shadePaint)
}
}
if (recognizing) {
drawRecognizing(canvas)
postInvalidateDelayed(16)
return
}
if (hitMarks.size >= 2) {
val prompt = "已识别到多个码,请选择一个"
val horizontalPadding = 18f * density
val verticalPadding = 10f * density
val textBounds = Rect()
promptTextPaint.getTextBounds(prompt, 0, prompt.length, textBounds)
val promptWidth = textBounds.width() + horizontalPadding * 2
val promptHeight = textBounds.height() + verticalPadding * 2
val promptLeft = (layerW - promptWidth) / 2f
// 放在底部操作按钮上方,避免遮挡条码和顶部关闭按钮。
val promptTop = layerH - promptHeight - 100f * density
canvas.drawRoundRect(
promptLeft,
promptTop,
promptLeft + promptWidth,
promptTop + promptHeight,
promptHeight / 2,
promptHeight / 2,
promptBackgroundPaint
)
val baseline = promptTop + verticalPadding - textBounds.top
canvas.drawText(prompt, layerW / 2f, baseline, promptTextPaint)
}
val breatheProgress = (SystemClock.elapsedRealtime() % breatheDurationMs).toFloat() / breatheDurationMs
val breatheWave = ((sin(breatheProgress * Math.PI * 2.0) + 1.0) / 2.0).toFloat()
for (mark in hitMarks) {
haloPaint.alpha = (34 + breatheWave * 70).toInt()
canvas.drawCircle(mark.centerX, mark.centerY, touchR + breatheWave * 8f * density, haloPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotOuterR, shellPaint)
canvas.drawCircle(mark.centerX, mark.centerY, dotInnerR, corePaint)
}
if (hitMarks.isNotEmpty()) {
postInvalidateDelayed(16)
}
}
private fun drawRecognizing(canvas: Canvas) {
val cx = layerW / 2f
val cy = layerH / 2f
val radius = 24f * density
val bounds = android.graphics.RectF(cx - radius, cy - radius, cx + radius, cy + radius)
val elapsed = SystemClock.elapsedRealtime() - recognizingStartedAt
val startAngle = (elapsed % 1000L) * 360f / 1000f - 90f
canvas.drawCircle(cx, cy, radius, loadingTrackPaint)
canvas.drawArc(bounds, startAngle, 110f, false, loadingArcPaint)
canvas.drawText("识别中", cx, cy + radius + 30f * density, loadingTextPaint)
}
}
@@ -0,0 +1,328 @@
package uts.sdk.modules.masCodeScan
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Path
import android.graphics.Shader
import android.view.View
import kotlin.math.max
import kotlin.math.min
/**
* 扫描框视觉样式。颜色支持 #RRGGBB、#AARRGGBB 和 rgba()。
*/
data class ScanStyleConfig(
val showMask: Boolean = true,
val showBeam: Boolean = true,
val borderColor: Int = Color.rgb(25, 190, 107),
val maskColor: Int = Color.argb(115, 0, 0, 0),
val beamColor: Int = Color.rgb(18, 232, 128),
val beamDuration: Long = 1800L,
val cornerStyle: String = "corner",
val cornerRadius: Float = 20f
) {
companion object {
private const val DEFAULT_BORDER_COLOR = "#19be6b"
private const val DEFAULT_MASK_COLOR = "rgba(0, 0, 0, 0.45)"
private const val DEFAULT_BEAM_COLOR = "#12e880"
private const val DEFAULT_DURATION = 1800L
fun safe(
showMask: Boolean,
showBeam: Boolean,
borderColor: String,
maskColor: String,
beamColor: String,
beamDuration: Int,
cornerStyle: String,
cornerRadius: Int
): ScanStyleConfig {
return ScanStyleConfig(
showMask = showMask,
showBeam = showBeam,
borderColor = parseColor(borderColor, DEFAULT_BORDER_COLOR),
maskColor = parseColor(maskColor, DEFAULT_MASK_COLOR),
beamColor = parseColor(beamColor, DEFAULT_BEAM_COLOR),
beamDuration = beamDuration.toLong().coerceIn(100L, 60000L),
cornerStyle = when (cornerStyle) {
"full" -> "full"
"rounded" -> "rounded"
"rounded-corner" -> "rounded-corner"
else -> "corner"
},
cornerRadius = cornerRadius.toFloat().coerceIn(0f, 160f)
)
}
private fun parseColor(value: String, fallback: String): Int {
val input = value.trim()
if (input.startsWith("rgba(") && input.endsWith(")")) {
val parts = input.substring(5, input.length - 1).split(',').map { it.trim() }
if (parts.size == 4) {
val red = parts[0].toIntOrNull()
val green = parts[1].toIntOrNull()
val blue = parts[2].toIntOrNull()
val alpha = parts[3].toFloatOrNull()
if (red != null && green != null && blue != null && alpha != null) {
return Color.argb(
(alpha.coerceIn(0f, 1f) * 255f).toInt(),
red.coerceIn(0, 255),
green.coerceIn(0, 255),
blue.coerceIn(0, 255)
)
}
}
}
return try {
Color.parseColor(input)
} catch (_: IllegalArgumentException) {
Color.parseColor(fallback)
}
}
}
}
/**
* 取景框光条装饰层。
*/
class ScanFrameDecorView(
context: Context,
private val scanRegion: ScanRegionConfig,
private val scanStyle: ScanStyleConfig,
private val hint: String
) : View(context) {
private val density = resources.displayMetrics.density
private val beamH = 4f * density
private val beamInset = 12f * density
private val cornerLength = 24f * density
private val finderRect = RectF()
private val roundedFinderPath = Path()
private var beamProgress = 0f
private var beamAnimator: ValueAnimator? = null
private val beamPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
}
private val regionMaskPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.maskColor
style = Paint.Style.FILL
}
private val regionBorderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = scanStyle.borderColor
style = Paint.Style.STROKE
strokeWidth = 2f * density
strokeCap = Paint.Cap.SQUARE
}
private val beamGlowPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(
(Color.alpha(scanStyle.beamColor) * 0.2f).toInt(),
Color.red(scanStyle.beamColor),
Color.green(scanStyle.beamColor),
Color.blue(scanStyle.beamColor)
)
style = Paint.Style.FILL
maskFilter = BlurMaskFilter(18f * density, BlurMaskFilter.Blur.NORMAL)
}
private val hintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = 14f * resources.displayMetrics.scaledDensity
textAlign = Paint.Align.CENTER
}
init {
setLayerType(LAYER_TYPE_SOFTWARE, null)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
runBeamLoop()
}
override fun onDetachedFromWindow() {
haltBeamLoop()
super.onDetachedFromWindow()
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
layoutFinder(w, h)
updateFinderPath()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width <= 0 || height <= 0) return
if (scanRegion.enabled) {
if (scanStyle.showMask) {
paintRegionMask(canvas)
}
paintRegionBorder(canvas)
}
if (scanStyle.showBeam) {
paintBeam(canvas)
}
if (hint.isNotBlank()) {
val hintBaseline = min(height - 24f * density, finderRect.bottom + 30f * density)
canvas.drawText(hint, finderRect.centerX(), hintBaseline, hintPaint)
}
}
fun haltBeamLoop() {
beamAnimator?.cancel()
beamAnimator = null
}
private fun runBeamLoop() {
if (beamAnimator != null) return
beamAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = scanStyle.beamDuration
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener {
beamProgress = it.animatedValue as Float
invalidate()
}
start()
}
}
private fun layoutFinder(w: Int, h: Int) {
if (scanRegion.enabled) {
finderRect.set(scanRegion.resolveViewRect(w, h))
return
}
// 未配置 scanRegion 时保持旧版默认扫描框,避免影响现有调用方。
val horizontalMargin = 36f * density
val maxFrameWidth = min(w * 0.74f, 320f * density)
val frameWidth = min(maxFrameWidth, w - horizontalMargin * 2f)
val frameHeight = frameWidth
val left = (w - frameWidth) / 2f
val top = (h - frameHeight) / 2f - 44f * density
finderRect.set(left, max(24f * density, top), left + frameWidth, max(24f * density, top) + frameHeight)
}
private fun updateFinderPath() {
val maxRadius = min(finderRect.width(), finderRect.height()) / 2f
val radius = min(scanStyle.cornerRadius * density, maxRadius)
roundedFinderPath.reset()
roundedFinderPath.addRoundRect(finderRect, radius, radius, Path.Direction.CW)
roundedFinderPath.close()
}
private fun paintRegionMask(canvas: Canvas) {
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.drawColor(scanStyle.maskColor)
canvas.drawPath(roundedFinderPath, Paint(Paint.ANTI_ALIAS_FLAG).apply {
xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.CLEAR)
})
return
}
canvas.drawRect(0f, 0f, width.toFloat(), finderRect.top, regionMaskPaint)
canvas.drawRect(0f, finderRect.bottom, width.toFloat(), height.toFloat(), regionMaskPaint)
canvas.drawRect(0f, finderRect.top, finderRect.left, finderRect.bottom, regionMaskPaint)
canvas.drawRect(finderRect.right, finderRect.top, width.toFloat(), finderRect.bottom, regionMaskPaint)
}
private fun paintRegionBorder(canvas: Canvas) {
if (scanStyle.cornerStyle == "full") {
canvas.drawRect(finderRect, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded") {
canvas.drawPath(roundedFinderPath, regionBorderPaint)
return
}
if (scanStyle.cornerStyle == "rounded-corner") {
paintRoundedCorners(canvas)
return
}
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
canvas.drawLine(left, top, left + cornerLength, top, regionBorderPaint)
canvas.drawLine(left, top, left, top + cornerLength, regionBorderPaint)
canvas.drawLine(right - cornerLength, top, right, top, regionBorderPaint)
canvas.drawLine(right, top, right, top + cornerLength, regionBorderPaint)
canvas.drawLine(left, bottom - cornerLength, left, bottom, regionBorderPaint)
canvas.drawLine(left, bottom, left + cornerLength, bottom, regionBorderPaint)
canvas.drawLine(right - cornerLength, bottom, right, bottom, regionBorderPaint)
canvas.drawLine(right, bottom - cornerLength, right, bottom, regionBorderPaint)
}
private fun paintRoundedCorners(canvas: Canvas) {
val radius = min(scanStyle.cornerRadius * density, min(finderRect.width(), finderRect.height()) / 2f)
val left = finderRect.left
val top = finderRect.top
val right = finderRect.right
val bottom = finderRect.bottom
val segmentLength = min(this.cornerLength, radius + this.cornerLength)
val cornerOnlyPath = Path()
val topLeft = RectF(left, top, left + radius * 2f, top + radius * 2f)
val topRight = RectF(right - radius * 2f, top, right, top + radius * 2f)
val bottomLeft = RectF(left, bottom - radius * 2f, left + radius * 2f, bottom)
val bottomRight = RectF(right - radius * 2f, bottom - radius * 2f, right, bottom)
cornerOnlyPath.moveTo(left + segmentLength, top)
cornerOnlyPath.lineTo(left + radius, top)
cornerOnlyPath.arcTo(topLeft, 270f, -90f)
cornerOnlyPath.lineTo(left, top + segmentLength)
cornerOnlyPath.moveTo(right - segmentLength, top)
cornerOnlyPath.lineTo(right - radius, top)
cornerOnlyPath.arcTo(topRight, 270f, 90f)
cornerOnlyPath.lineTo(right, top + segmentLength)
cornerOnlyPath.moveTo(left, bottom - segmentLength)
cornerOnlyPath.lineTo(left, bottom - radius)
cornerOnlyPath.arcTo(bottomLeft, 180f, -90f)
cornerOnlyPath.lineTo(left + segmentLength, bottom)
cornerOnlyPath.moveTo(right, bottom - segmentLength)
cornerOnlyPath.lineTo(right, bottom - radius)
cornerOnlyPath.arcTo(bottomRight, 0f, 90f)
cornerOnlyPath.lineTo(right - segmentLength, bottom)
canvas.drawPath(cornerOnlyPath, regionBorderPaint)
}
private fun paintBeam(canvas: Canvas) {
val availableHeight = finderRect.height() - beamInset * 2f
if (availableHeight <= 0f) return
val y = finderRect.top + beamInset + availableHeight * beamProgress
val lineTop = y - beamH / 2f
val lineBottom = y + beamH / 2f
val lineLeft = finderRect.left + beamInset
val lineRight = finderRect.right - beamInset
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.save()
canvas.clipPath(roundedFinderPath)
}
beamPaint.shader = LinearGradient(
lineLeft, y, lineRight, y,
intArrayOf(
scanStyle.beamColor and 0x00ffffff,
scanStyle.beamColor,
scanStyle.beamColor and 0x00ffffff
),
floatArrayOf(0f, 0.5f, 1f),
Shader.TileMode.CLAMP
)
canvas.drawRect(
lineLeft, lineTop - 10f * density, lineRight, lineBottom + 10f * density, beamGlowPaint
)
canvas.drawRect(lineLeft, lineTop, lineRight, lineBottom, beamPaint)
if (scanStyle.cornerStyle == "rounded" || scanStyle.cornerStyle == "rounded-corner") {
canvas.restore()
}
}
}
@@ -0,0 +1,108 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Rect
import android.graphics.RectF
import kotlin.math.max
import kotlin.math.min
/**
* 相对于实时预览层的有效扫码区域配置。
* 由 Activity 统一校验后同时用于扫描框绘制和条码结果过滤。
*/
data class ScanRegionConfig(
val enabled: Boolean,
val widthRatio: Float = DEFAULT_SIZE_RATIO,
val heightRatio: Float = DEFAULT_SIZE_RATIO,
val centerXRatio: Float = DEFAULT_CENTER_RATIO,
val centerYRatio: Float = DEFAULT_CENTER_RATIO
) {
companion object {
const val DEFAULT_SIZE_RATIO = 0.74f
const val DEFAULT_CENTER_RATIO = 0.5f
private const val MIN_SIZE_RATIO = 0.1f
private const val MAX_SIZE_RATIO = 1f
fun safe(
enabled: Boolean,
widthRatio: Float,
heightRatio: Float,
centerXRatio: Float,
centerYRatio: Float
): ScanRegionConfig {
val safeWidth = widthRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
val safeHeight = heightRatio.sanitize(DEFAULT_SIZE_RATIO).coerceIn(MIN_SIZE_RATIO, MAX_SIZE_RATIO)
return ScanRegionConfig(
enabled = enabled,
widthRatio = safeWidth,
heightRatio = safeHeight,
centerXRatio = centerXRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f),
centerYRatio = centerYRatio.sanitize(DEFAULT_CENTER_RATIO).coerceIn(0f, 1f)
)
}
private fun Float.sanitize(defaultValue: Float): Float {
return if (isFinite()) this else defaultValue
}
}
/** 返回扫描框在预览 View 坐标系中的实际位置,越界部分会收敛到 View 内。 */
fun resolveViewRect(viewWidth: Int, viewHeight: Int): RectF {
if (viewWidth <= 0 || viewHeight <= 0) return RectF()
// width / height 都以预览宽度为基准,并使用同一缩放因子收敛,确保始终保持配置比例。
val desiredWidth = viewWidth * widthRatio
val desiredHeight = viewWidth * heightRatio
val sizeScale = min(1f, min(viewWidth / desiredWidth, viewHeight / desiredHeight))
val frameWidth = desiredWidth * sizeScale
val frameHeight = desiredHeight * sizeScale
val wantedLeft = viewWidth * centerXRatio - frameWidth / 2f
val wantedTop = viewHeight * centerYRatio - frameHeight / 2f
val left = wantedLeft.coerceIn(0f, max(0f, viewWidth - frameWidth))
val top = wantedTop.coerceIn(0f, max(0f, viewHeight - frameHeight))
return RectF(left, top, left + frameWidth, top + frameHeight)
}
}
/**
* 将 PreviewView 的扫描框映射到 ML Kit 返回 boundingBox 所在的旋转后图像坐标。
* PreviewView 使用 FILL_CENTER,因此必须补偿等比填充产生的裁剪偏移。
*/
object ScanRegionMapper {
fun mapViewRectToImage(
viewRect: RectF,
viewWidth: Int,
viewHeight: Int,
imageWidth: Int,
imageHeight: Int,
cropRect: Rect,
rotationDegrees: Int
): Rect? {
if (viewRect.isEmpty || viewWidth <= 0 || viewHeight <= 0 || imageWidth <= 0 || imageHeight <= 0) {
return null
}
val visualCrop = YuvFrames.rectToVisualSpace(cropRect, imageWidth, imageHeight, rotationDegrees)
val cropWidth = visualCrop.width()
val cropHeight = visualCrop.height()
if (cropWidth <= 0 || cropHeight <= 0) return null
val scale = max(viewWidth.toFloat() / cropWidth, viewHeight.toFloat() / cropHeight)
if (!scale.isFinite() || scale <= 0f) return null
val offsetX = (viewWidth - cropWidth * scale) / 2f
val offsetY = (viewHeight - cropHeight * scale) / 2f
val mapped = Rect(
((viewRect.left - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.top - offsetY) / scale).toInt() + visualCrop.top,
((viewRect.right - offsetX) / scale).toInt() + visualCrop.left,
((viewRect.bottom - offsetY) / scale).toInt() + visualCrop.top
)
return mapped.apply {
left = left.coerceIn(visualCrop.left, visualCrop.right)
top = top.coerceIn(visualCrop.top, visualCrop.bottom)
right = right.coerceIn(left, visualCrop.right)
bottom = bottom.coerceIn(top, visualCrop.bottom)
}.takeIf { it.width() > 0 && it.height() > 0 }
}
fun containsBarcodeCenter(region: Rect, barcodeBox: Rect): Boolean {
return region.contains(barcodeBox.centerX(), barcodeBox.centerY())
}
}
@@ -0,0 +1,67 @@
package uts.sdk.modules.masCodeScan
import com.google.mlkit.vision.barcode.common.Barcode
object SymbologyUtil {
fun nameOf(format: Int): String = when (format) {
Barcode.FORMAT_QR_CODE -> "QR_CODE"
Barcode.FORMAT_EAN_13 -> "EAN_13"
Barcode.FORMAT_EAN_8 -> "EAN_8"
Barcode.FORMAT_CODE_128 -> "CODE_128"
Barcode.FORMAT_CODE_39 -> "CODE_39"
Barcode.FORMAT_CODE_93 -> "CODE_93"
Barcode.FORMAT_CODABAR -> "CODABAR"
Barcode.FORMAT_DATA_MATRIX -> "DATA_MATRIX"
Barcode.FORMAT_UPC_A -> "UPC_A"
Barcode.FORMAT_UPC_E -> "UPC_E"
Barcode.FORMAT_ITF -> "ITF"
Barcode.FORMAT_PDF417 -> "PDF417"
Barcode.FORMAT_AZTEC -> "AZTEC"
else -> "UNKNOWN"
}
fun hasSupportedFormat(csv: String?): Boolean {
if (csv.isNullOrBlank()) return true
return csv.split(',').any { tokenToFormat(it.trim().uppercase()) != null }
}
fun parseFormatList(csv: String?): IntArray {
if (csv.isNullOrBlank()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
val mapped = csv.split(',')
.map { it.trim().uppercase() }
.mapNotNull { tokenToFormat(it) }
.distinct()
if (mapped.isEmpty()) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
// ALL 与具体码制混用时以全格式为准,避免 ML Kit 参数异常
if (mapped.contains(Barcode.FORMAT_ALL_FORMATS)) {
return intArrayOf(Barcode.FORMAT_ALL_FORMATS)
}
return mapped.toIntArray()
}
private fun tokenToFormat(token: String): Int? = when (token) {
"QR_CODE", "QR" -> Barcode.FORMAT_QR_CODE
"EAN_13" -> Barcode.FORMAT_EAN_13
"EAN_8" -> Barcode.FORMAT_EAN_8
"CODE_128" -> Barcode.FORMAT_CODE_128
"CODE_39" -> Barcode.FORMAT_CODE_39
"CODE_93" -> Barcode.FORMAT_CODE_93
"CODABAR" -> Barcode.FORMAT_CODABAR
"DATA_MATRIX" -> Barcode.FORMAT_DATA_MATRIX
"UPC_A" -> Barcode.FORMAT_UPC_A
"UPC_E" -> Barcode.FORMAT_UPC_E
"ITF" -> Barcode.FORMAT_ITF
"PDF417" -> Barcode.FORMAT_PDF417
"AZTEC" -> Barcode.FORMAT_AZTEC
"ALL", "FORMAT_ALL_FORMATS" -> Barcode.FORMAT_ALL_FORMATS
else -> null
}
fun isDecodedText(barcode: Barcode): Boolean {
return !barcode.rawValue.isNullOrBlank()
}
}
@@ -0,0 +1,136 @@
package uts.sdk.modules.masCodeScan
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Matrix
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image
import java.io.ByteArrayOutputStream
object YuvFrames {
fun imageToBitmap(
image: Image,
width: Int,
height: Int,
cropRect: Rect,
rotationDegrees: Int
): Bitmap? {
return try {
val planes = image.planes
val yPlane = planes[0]
val uPlane = planes[1]
val vPlane = planes[2]
val yRowStride = yPlane.rowStride
val yPixelStride = yPlane.pixelStride
val uRowStride = uPlane.rowStride
val uPixelStride = uPlane.pixelStride
val vRowStride = vPlane.rowStride
val vPixelStride = vPlane.pixelStride
val nv21Size = width * height * 3 / 2
val nv21 = ByteArray(nv21Size)
var destOffset = 0
if (yPixelStride == 1) {
for (row in 0 until height) {
yPlane.buffer.position(row * yRowStride)
yPlane.buffer.get(nv21, destOffset, width)
destOffset += width
}
} else {
val yBuffer = yPlane.buffer
for (row in 0 until height) {
for (col in 0 until width) {
nv21[destOffset++] = yBuffer.get(row * yRowStride + col * yPixelStride)
}
}
}
var uvOffset = width * height
if (uPixelStride == 1 && vPixelStride == 1) {
for (row in 0 until height / 2) {
vPlane.buffer.position(row * vRowStride)
vPlane.buffer.get(nv21, uvOffset + row * width, width / 2)
}
for (row in 0 until height / 2) {
uPlane.buffer.position(row * uRowStride)
uPlane.buffer.get(nv21, uvOffset + width / 2 + row * width, width / 2)
}
} else {
val uBuffer = uPlane.buffer
val vBuffer = vPlane.buffer
for (row in 0 until height / 2) {
for (col in 0 until width / 2) {
val uvIndex = row * uRowStride + col * uPixelStride
nv21[uvOffset++] = vBuffer.get(uvIndex)
nv21[uvOffset++] = uBuffer.get(uvIndex)
}
}
}
val safeCropRect = Rect(cropRect).apply {
left = left.coerceIn(0, width)
top = top.coerceIn(0, height)
right = right.coerceIn(left, width)
bottom = bottom.coerceIn(top, height)
}
val jpegRect = if (safeCropRect.width() > 0 && safeCropRect.height() > 0) {
safeCropRect
} else {
Rect(0, 0, width, height)
}
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
val baos = ByteArrayOutputStream()
yuvImage.compressToJpeg(jpegRect, 85, baos)
val jpegData = baos.toByteArray()
baos.close()
var bitmap = BitmapFactory.decodeByteArray(jpegData, 0, jpegData.size)
if (bitmap != null && rotationDegrees != 0) {
val matrix = Matrix()
matrix.postRotate(rotationDegrees.toFloat())
val rotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
if (rotated != bitmap) {
bitmap.recycle()
}
bitmap = rotated
}
bitmap
} catch (_: Exception) {
null
}
}
fun rectToVisualSpace(
rect: Rect,
sourceWidth: Int,
sourceHeight: Int,
rotationDegrees: Int
): Rect {
return when (rotationDegrees) {
90 -> Rect(
sourceHeight - rect.bottom,
rect.left,
sourceHeight - rect.top,
rect.right
)
180 -> Rect(
sourceWidth - rect.right,
sourceHeight - rect.bottom,
sourceWidth - rect.left,
sourceHeight - rect.top
)
270 -> Rect(
rect.top,
sourceWidth - rect.right,
rect.bottom,
sourceWidth - rect.left
)
else -> Rect(rect)
}
}
}
@@ -0,0 +1,12 @@
{
"dependencies": [
"androidx.activity:activity:1.8.0",
"androidx.camera:camera-core:1.4.1",
"androidx.camera:camera-camera2:1.4.1",
"androidx.camera:camera-lifecycle:1.4.1",
"androidx.camera:camera-view:1.4.1",
"androidx.appcompat:appcompat:1.7.0",
"com.google.mlkit:barcode-scanning:17.3.0"
],
"minSdkVersion": 21
}
@@ -0,0 +1,356 @@
import Activity from 'android.app.Activity'
import Intent from 'android.content.Intent'
import { OpenCodeScan, CodeScanErrorCode, CodeScanOptions, CodeScanSuccess } from '../interface.uts'
import { CodeScanFailImpl } from '../unierror.uts'
const LAUNCH_REQ: Int = 53011
const EXTRA_PAYLOAD = 'mas_content'
const EXTRA_FORMAT = 'mas_format'
const EXTRA_TITLE = 'mas_title'
const EXTRA_HINT = 'mas_hint'
const EXTRA_SUCCESS_HINT = 'mas_success_hint'
const EXTRA_EMPTY_HINT = 'mas_empty_hint'
const EXTRA_SMART_ZOOM = 'mas_smart_zoom'
const EXTRA_TORCH_MODE = 'mas_torch_mode'
const EXTRA_RESULT_MODE = 'mas_result_mode'
const EXTRA_SHOW_PAUSE_BUTTON = 'mas_show_pause_button'
const EXTRA_VIBRATE = 'mas_vibrate'
const EXTRA_CONTINUOUS = 'mas_continuous'
const EXTRA_FROM_ALBUM = 'mas_from_album'
const EXTRA_FORMATS = 'mas_formats'
const EXTRA_SCAN_REGION_ENABLED = 'mas_scan_region_enabled'
const EXTRA_SCAN_REGION = 'mas_scan_region'
const EXTRA_SCAN_STYLE_SHOW_MASK = 'mas_scan_style_show_mask'
const EXTRA_SCAN_STYLE_SHOW_BEAM = 'mas_scan_style_show_beam'
const EXTRA_SCAN_STYLE_BORDER_COLOR = 'mas_scan_style_border_color'
const EXTRA_SCAN_STYLE_MASK_COLOR = 'mas_scan_style_mask_color'
const EXTRA_SCAN_STYLE_BEAM_COLOR = 'mas_scan_style_beam_color'
const EXTRA_SCAN_STYLE_BEAM_DURATION = 'mas_scan_style_beam_duration'
const EXTRA_SCAN_STYLE_CORNER = 'mas_scan_style_corner'
const EXTRA_SCAN_STYLE_CORNER_RADIUS = 'mas_scan_style_corner_radius'
const EXTRA_SCAN_TIMEOUT = 'mas_scan_timeout'
const EXTRA_CONTINUOUS_SUCCESS_DURATION = 'mas_continuous_success_duration'
const EXTRA_ERR = 'mas_err'
const EXTRA_FROM_ALBUM_RESULT = 'mas_result_from_album'
const EXTRA_HAD_HITS = 'mas_had_hits'
const CAPTURE_ACTIVITY = 'uts.sdk.modules.masCodeScan.CodeCaptureActivity'
let activityResultHook: ((requestCode: Int, resultCode: Int, data?: Intent) => void) | null = null
let activeOptions: CodeScanOptions | null = null
let continuousHitCount: Int = 0
let pollTimerId: number = -1
let sessionContinuous: boolean = false
let continuousSuccess: ((res: CodeScanSuccess) => void) | null = null
@UTSJS.keepAlive
export function openCodeScan(options?: CodeScanOptions | null): void {
const opts = options != null ? options : null
const host = UTSAndroid.getUniActivity()
if (host == null) {
emitFailure(opts, 9021001)
return
}
launchCapture(host, opts)
}
function clearPollTimer() {
if (pollTimerId >= 0) {
clearInterval(pollTimerId)
pollTimerId = -1
}
}
function drainHits() {
const current = activeOptions
const success = continuousSuccess
if (current == null || success == null) return
while (true) {
const hit = CodeScanHitBus.pollHit()
if (hit == null) break
const content = hit[0]
const format = hit[1]
const fromAlbumFlag = hit[2]
if (content == null || format == null || fromAlbumFlag == null) continue
continuousHitCount++
const ok: CodeScanSuccess = {
content: content,
format: format,
fromAlbum: fromAlbumFlag == '1'
}
success(ok)
}
}
function launchCapture(host: Activity, opts: CodeScanOptions | null): void {
if (activityResultHook != null) {
UTSAndroid.offAppActivityResult(activityResultHook)
activityResultHook = null
}
clearPollTimer()
CodeScanHitBus.detach()
activeOptions = opts
continuousHitCount = 0
sessionContinuous = opts != null && opts.continuous == true
continuousSuccess = sessionContinuous && opts != null ? opts.success : null
if (sessionContinuous) {
pollTimerId = setInterval(() => {
drainHits()
}, 120)
}
const onResult = (requestCode: Int, resultCode: Int, data?: Intent) => {
if (requestCode != LAUNCH_REQ) {
return
}
UTSAndroid.offAppActivityResult()
activityResultHook = null
drainHits()
clearPollTimer()
CodeScanHitBus.detach()
const current = activeOptions
const wasContinuous = sessionContinuous
sessionContinuous = false
if (resultCode != Activity.RESULT_OK) {
let errCode: CodeScanErrorCode = 9021003
if (data != null) {
const nativeErr = data.getIntExtra(EXTRA_ERR, 0)
if (nativeErr >= 9021002 && nativeErr <= 9021011) {
errCode = nativeErr as CodeScanErrorCode
}
}
// 连续扫已有命中:关闭只 complete,不 fail
if (wasContinuous && continuousHitCount > 0) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && data != null && data.getBooleanExtra(EXTRA_HAD_HITS, false)) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous && current != null) {
emitFailure(current, errCode)
const complete = current.complete
if (complete != null) {
complete({ closed: true, hitCount: continuousHitCount, failed: true, errCode: errCode })
}
activeOptions = null
continuousSuccess = null
return
}
emitFailure(current, errCode)
activeOptions = null
continuousSuccess = null
return
}
if (wasContinuous) {
if (current != null) {
current.complete?.({ closed: true, hitCount: continuousHitCount })
}
activeOptions = null
continuousSuccess = null
return
}
let payload = ''
let formatName = ''
let fromAlbum = false
if (data != null) {
const rawPayload = data.getStringExtra(EXTRA_PAYLOAD)
const rawFormat = data.getStringExtra(EXTRA_FORMAT)
payload = rawPayload != null ? rawPayload : ''
formatName = rawFormat != null ? rawFormat : ''
fromAlbum = data.getBooleanExtra(EXTRA_FROM_ALBUM_RESULT, false)
}
if (payload == '') {
emitFailure(current, 9021004)
activeOptions = null
continuousSuccess = null
return
}
const ok: CodeScanSuccess = {
content: payload,
format: formatName,
fromAlbum: fromAlbum
}
if (current != null) {
current.success?.(ok)
current.complete?.(ok)
}
activeOptions = null
continuousSuccess = null
}
activityResultHook = onResult
UTSAndroid.onAppActivityResult(onResult)
let pageTitle = '扫一扫'
let scanHint = '请将条码放入扫描框内'
let successHint = '扫码成功'
let emptyHint = '未识别到有效条码'
let useSmartZoom = true
let useTorchMode = 'off'
let useResultMode = 'immediate'
let useShowPauseButton = false
let useVibrate = true
let useContinuous = false
let useFromAlbum = false
let useScanRegion = false
let regionWidth = 0.74
let regionHeight = 0.74
let regionCenterX = 0.5
let regionCenterY = 0.5
let formatsCsv = ''
let styleShowMask = true
let styleShowBeam = true
let styleBorderColor = '#19be6b'
let styleMaskColor = 'rgba(0, 0, 0, 0.45)'
let styleBeamColor = '#12e880'
let styleBeamDuration = 1800
let styleCorner = 'corner'
let styleCornerRadius = 20
let scanTimeout = 0
let continuousSuccessDuration = 1000
if (opts != null) {
if (opts.title != null && opts.title!.trim().length > 0) {
pageTitle = opts.title!.trim()
}
if (opts.hint != null && opts.hint!.trim().length > 0) {
scanHint = opts.hint!.trim()
}
if (opts.successHint != null && opts.successHint!.trim().length > 0) {
successHint = opts.successHint!.trim()
}
if (opts.emptyHint != null && opts.emptyHint!.trim().length > 0) {
emptyHint = opts.emptyHint!.trim()
}
if (opts.smartZoom != null) {
useSmartZoom = opts.smartZoom!
}
if (opts.torch == 'on' || opts.torch == 'auto' || opts.torch == 'off') {
useTorchMode = opts.torch!
}
if (opts.resultMode == 'confirm' || opts.resultMode == 'pause' || opts.resultMode == 'immediate') {
useResultMode = opts.resultMode!
}
if (opts.showPauseButton != null) {
useShowPauseButton = opts.showPauseButton!
}
if (opts.vibrate != null) {
useVibrate = opts.vibrate!
}
if (opts.continuous != null) {
useContinuous = opts.continuous!
}
if (opts.fromAlbum != null) {
useFromAlbum = opts.fromAlbum!
}
if (opts.scanRegion != null) {
useScanRegion = opts.scanRegion!.fullScreen != true
if (opts.scanRegion!.width != null) {
regionWidth = opts.scanRegion!.width!
}
if (opts.scanRegion!.height != null) {
regionHeight = opts.scanRegion!.height!
}
if (opts.scanRegion!.centerX != null) {
regionCenterX = opts.scanRegion!.centerX!
}
if (opts.scanRegion!.centerY != null) {
regionCenterY = opts.scanRegion!.centerY!
}
}
if (opts.scanTimeout != null) {
scanTimeout = Math.floor(opts.scanTimeout!)
}
if (opts.continuousSuccessDuration != null) {
continuousSuccessDuration = Math.max(0, Math.min(600000, Math.floor(opts.continuousSuccessDuration!)))
}
if (opts.scanStyle != null) {
if (opts.scanStyle!.showMask != null) {
styleShowMask = opts.scanStyle!.showMask!
}
if (opts.scanStyle!.showBeam != null) {
styleShowBeam = opts.scanStyle!.showBeam!
}
if (opts.scanStyle!.borderColor != null) {
styleBorderColor = opts.scanStyle!.borderColor!
}
if (opts.scanStyle!.maskColor != null) {
styleMaskColor = opts.scanStyle!.maskColor!
}
if (opts.scanStyle!.beamColor != null) {
styleBeamColor = opts.scanStyle!.beamColor!
}
if (opts.scanStyle!.beamDuration != null) {
styleBeamDuration = opts.scanStyle!.beamDuration!
}
if (opts.scanStyle!.cornerStyle == 'full' || opts.scanStyle!.cornerStyle == 'corner' || opts.scanStyle!.cornerStyle == 'rounded' || opts.scanStyle!.cornerStyle == 'rounded-corner') {
styleCorner = opts.scanStyle!.cornerStyle!
}
if (opts.scanStyle!.cornerRadius != null) {
styleCornerRadius = Math.max(0, Math.min(160, Math.floor(opts.scanStyle!.cornerRadius!)))
}
}
if (opts.formats != null && opts.formats!.length > 0) {
const parts: string[] = []
for (let i = 0; i < opts.formats!.length; i++) {
const item = opts.formats![i]
if (item != null && item.length > 0) {
parts.push(item)
}
}
formatsCsv = parts.join(',')
}
}
const intent = new Intent()
intent.setClassName(host, CAPTURE_ACTIVITY)
intent.putExtra(EXTRA_TITLE, pageTitle)
intent.putExtra(EXTRA_HINT, scanHint)
intent.putExtra(EXTRA_SUCCESS_HINT, successHint)
intent.putExtra(EXTRA_EMPTY_HINT, emptyHint)
intent.putExtra(EXTRA_SMART_ZOOM, useSmartZoom)
intent.putExtra(EXTRA_TORCH_MODE, useTorchMode)
intent.putExtra(EXTRA_RESULT_MODE, useResultMode)
intent.putExtra(EXTRA_SHOW_PAUSE_BUTTON, useShowPauseButton)
intent.putExtra(EXTRA_VIBRATE, useVibrate)
intent.putExtra(EXTRA_CONTINUOUS, useContinuous)
intent.putExtra(EXTRA_FROM_ALBUM, useFromAlbum)
intent.putExtra(EXTRA_FORMATS, formatsCsv)
intent.putExtra(EXTRA_SCAN_REGION_ENABLED, useScanRegion)
intent.putExtra(EXTRA_SCAN_REGION, `${regionWidth},${regionHeight},${regionCenterX},${regionCenterY}`)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_MASK, styleShowMask)
intent.putExtra(EXTRA_SCAN_STYLE_SHOW_BEAM, styleShowBeam)
intent.putExtra(EXTRA_SCAN_STYLE_BORDER_COLOR, styleBorderColor)
intent.putExtra(EXTRA_SCAN_STYLE_MASK_COLOR, styleMaskColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_COLOR, styleBeamColor)
intent.putExtra(EXTRA_SCAN_STYLE_BEAM_DURATION, styleBeamDuration)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER, styleCorner)
intent.putExtra(EXTRA_SCAN_STYLE_CORNER_RADIUS, styleCornerRadius)
intent.putExtra(EXTRA_SCAN_TIMEOUT, scanTimeout)
intent.putExtra(EXTRA_CONTINUOUS_SUCCESS_DURATION, continuousSuccessDuration)
host.startActivityForResult(intent, LAUNCH_REQ)
}
function emitFailure(opts: CodeScanOptions | null, code: CodeScanErrorCode): void {
const err = new CodeScanFailImpl(code)
if (opts != null) {
opts.fail?.(err)
opts.complete?.(err)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

@@ -0,0 +1,90 @@
export type CodeScanSuccess = {
content : string
format : string
fromAlbum ?: boolean
}
export type CodeScanErrorCode = 9021001 | 9021002 | 9021003 | 9021004 | 9021005 | 9021006 | 9021007 | 9021008 | 9021009 | 9021010 | 9021011
export type CodeScanTorchMode = 'off' | 'on' | 'auto'
export type CodeScanResultMode = 'immediate' | 'confirm' | 'pause'
export type CodeScanCornerStyle = 'corner' | 'full' | 'rounded' | 'rounded-corner'
export type CodeScanStyle = {
/** 是否显示扫描框外遮罩,默认 true */
showMask ?: boolean
/** 是否显示扫描线,默认 true */
showBeam ?: boolean
/** 扫描框边框颜色,支持 #RRGGBB / #AARRGGBB */
borderColor ?: string
/** 遮罩颜色,支持 #RRGGBB / #AARRGGBB 或 rgba() */
maskColor ?: string
/** 扫描线颜色,支持 #RRGGBB / #AARRGGBB */
beamColor ?: string
/** 扫描线移动周期,单位毫秒,默认 1800 */
beamDuration ?: number
/** 扫描框样式:corner 四角边框,full 完整边框,rounded 完整圆角边框,rounded-corner 四角圆角边框,默认 corner */
cornerStyle ?: CodeScanCornerStyle
/** 圆角半径,单位 dp,rounded 和 rounded-corner 样式生效,默认 20,范围 0 ~ 160 */
cornerRadius ?: number
}
export interface CodeScanFail extends IUniError {
errCode : CodeScanErrorCode
}
/**
* 实时相机扫描区域。宽度和高度均以预览宽度为参考比例,因此可稳定锁定宽高比。
* 不传时保持原有整画面识别;相册识图不受该配置限制。
*/
export type ScanRegion = {
/** 全屏识别:不绘制扫描框、不进行区域过滤 */
fullScreen ?: boolean
/** 扫描框宽度比例,建议 0.1 ~ 1 */
width ?: number
/** 扫描框高度比例,建议 0.1 ~ 1 */
height ?: number
/** 扫描框中心横坐标比例,0 为最左、1 为最右 */
centerX ?: number
/** 扫描框中心纵坐标比例,0 为顶部、1 为底部 */
centerY ?: number
}
export type CodeScanOptions = {
/** 扫码页标题,默认“扫一扫” */
title ?: string
/** 扫描框下方引导语,默认“请将条码放入扫描框内” */
hint ?: string
/** 识别成功提示语,默认“扫码成功” */
successHint ?: string
/** 未识别到条码时的提示语,默认“未识别到有效条码” */
emptyHint ?: string
/** 条码偏小时自动放大,默认 true */
smartZoom ?: boolean
/** 手电筒策略:off 关闭、on 默认开启、auto 根据环境亮度自动补光,默认 off */
torch ?: CodeScanTorchMode
/** 单次扫码结果策略:immediate 立即返回、confirm 确认后返回、pause 暂停后可继续扫描,默认 immediate */
resultMode ?: CodeScanResultMode
/** 连续扫码页是否显示暂停/继续按钮,默认 false */
showPauseButton ?: boolean
/** 限定码制,如 ['QR_CODE','CODE_128'];空/缺省为全格式 */
formats ?: string[]
/** 识别成功是否震动,默认 true */
vibrate ?: boolean
/** 连续扫码:页内不关闭,多次 success;点关闭结束;可通过 showPauseButton 显示暂停按钮 */
continuous ?: boolean
/** 连续扫码成功提示停留时长,单位毫秒,默认 1000,范围 0 ~ 600000 */
continuousSuccessDuration ?: number
/** 打开后直接进入相册选图 */
fromAlbum ?: boolean
/** 实时相机有效识别区域;fullScreen 为 true 时整画面均可识别 */
scanRegion ?: ScanRegion
/** 扫描框视觉样式 */
scanStyle ?: CodeScanStyle
/** 扫描会话超时毫秒数;不传或小于 1000 时不启用,最大 600000 */
scanTimeout ?: number
success ?: (res : CodeScanSuccess) => void
fail ?: (res : CodeScanFail) => void
complete ?: (res : any) => void
}
export type OpenCodeScan = (options ?: CodeScanOptions | null) => void
@@ -0,0 +1,28 @@
import { CodeScanErrorCode, CodeScanFail } from './interface.uts'
export const CodeScanErrorSubject = 'mas-code-scan'
export const CodeScanErrorText: Map<CodeScanErrorCode, string> = new Map([
[9021001, '当前无法获取页面 Activity'],
[9021002, '相机权限被拒绝'],
[9021003, '用户取消扫码'],
[9021004, '未获取到有效的扫码结果'],
[9021005, '相机被其他应用占用或当前不可用'],
[9021006, '设备不支持手电筒'],
[9021007, '相机初始化失败'],
[9021008, '相册图片无法读取'],
[9021009, '图片中未识别到条码'],
[9021010, '指定码制不支持'],
[9021011, '扫码超时']
])
export class CodeScanFailImpl extends UniError implements CodeScanFail {
override errCode: CodeScanErrorCode
constructor(errCode: CodeScanErrorCode) {
super()
this.errSubject = CodeScanErrorSubject
this.errCode = errCode
this.errMsg = CodeScanErrorText.get(errCode) ?? ''
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 标题 */
text: { type: String, default: '' },
/** 描述 */
subText: { type: String, default: '' },
/** 边距 */
padding: { type: [Number, String] as PropType<number | string>, default: '34rpx 0' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否禁用 */
disabled: { type: Boolean, default: false },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetItemProps = ExtractPropTypes<typeof ActionSheetItemProps>;
@@ -0,0 +1,85 @@
<template>
<view
class="u-action-sheet-item u-line-1"
:class="[!isLast ? 'u-border-bottom' : '', disabled ? 'u-action-sheet-item--disabled' : '', customClass]"
:style="$u.toStyle(itemStyle, customStyle)"
@click="handleClick"
@touchmove.stop.prevent
hover-class="u-hover-class"
:hover-stay-time="150"
>
<slot>
<text>{{ text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="subText">{{ subText }}</text>
</slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ActionSheetItemProps } from './types';
import { $u, useChildren } from '../../libs';
const props = defineProps(ActionSheetItemProps);
const emit = defineEmits(['click']);
const { parent, parentExposed, childIndex, emitToParent } = useChildren('u-action-sheet-item', 'u-action-sheet');
const isLast = computed(() => {
return (childIndex?.value ?? 0) + 1 >= (parent?.value?.getChildren()?.length ?? 0);
});
const itemStyle = computed(() => {
const style: Record<string, string> = {};
style.color = props.color || parentExposed?.value?.props?.color;
style.fontSize = $u.addUnit(props.fontSize || parentExposed?.value?.props?.fontSize);
style.padding = $u.addUnit(props.padding);
if (props.disabled) style.color = 'var(--u-light-color)';
return style;
});
function handleClick() {
if (props.disabled) return;
emit('click', childIndex?.value || 0);
if (!props.asyncClose && !parentExposed?.value?.props?.asyncClose) {
emitToParent('itemClick', childIndex?.value || 0);
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item--disabled {
color: $u-light-color;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,52 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ActionSheetItem, ActionSheetTips } from '../../types/global';
import { getColor } from '../../';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
*/
export const ActionSheetProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 点击遮罩是否可以关闭actionSheet */
maskCloseAble: { type: Boolean, default: true },
/** 按钮的文字数组,可以自定义颜色和字体大小,字体单位为rpx */
list: {
type: Array as unknown as PropType<ActionSheetItem[]>,
default: () => []
},
/** 顶部的提示文字 */
tips: {
type: Object as unknown as PropType<ActionSheetTips>,
default: () => ({ text: '', color: getColor('tipsColor'), fontSize: '26rpx' })
},
/** 底部的取消按钮 */
cancelBtn: { type: Boolean, default: true },
/** 是否开启底部安全区适配,开启的话,会在iPhoneX机型底部添加一定的内边距 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 通过双向绑定控制组件的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 弹出的顶部圆角值 */
borderRadius: { type: [String, Number], default: 0 },
/** 弹出的z-index值 */
zIndex: { type: [String, Number], default: 0 },
/** 取消按钮的文字提示 */
cancelText: { type: String, default: '' },
/** 字体颜色 */
color: { type: String, default: () => getColor('mainColor') },
/** 字体大小 */
fontSize: { type: [String, Number], default: '32rpx' },
/** 是否异步关闭 */
asyncClose: { type: Boolean, default: false }
};
export type ActionSheetProps = ExtractPropTypes<typeof ActionSheetProps>;
@@ -0,0 +1,196 @@
<template>
<u-popup
mode="bottom"
:border-radius="borderRadius"
:popup="false"
v-model="popupValue"
:maskCloseAble="maskCloseAble"
length="auto"
:safeAreaInsetBottom="safeAreaInsetBottom"
@close="popupClose"
:z-index="uZIndex"
:custom-class="customClass"
:custom-style="customStyle"
>
<view class="u-tips u-border-bottom" v-if="tips.text" :style="[tipsStyle]">
{{ tips.text }}
</view>
<block v-if="list && list.length > 0" v-for="(item, index) in list" :key="index">
<view
@touchmove.stop.prevent
@tap="itemClick(index)"
:style="[itemStyle(index)]"
class="u-action-sheet-item u-line-1"
:class="[index < list.length - 1 ? 'u-border-bottom' : '']"
:hover-stay-time="150"
>
<text>{{ item.text }}</text>
<text class="u-action-sheet-item__subtext u-line-1" v-if="item.subText">{{ item.subText }}</text>
</view>
</block>
<template v-else>
<slot></slot>
</template>
<view class="u-gab" v-if="cancelBtn"> </view>
<view
@touchmove.stop.prevent
class="u-action-sheet-cancel u-action-sheet-item"
hover-class="u-hover-class"
:hover-stay-time="150"
v-if="cancelBtn"
@tap="close"
>
{{ getCancelText }}
</view>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-action-sheet',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u, useParent, useLocale } from '../..';
import { ActionSheetProps } from './types';
import uPopup from '../u-popup/u-popup.vue';
/**
* actionSheet 操作菜单
* @description 本组件用于从底部弹出一个操作菜单,供用户选择并返回结果。本组件功能类似于uni的uni.showActionSheetAPI,配置更加灵活,所有平台都表现一致。
* @tutorial https://uviewpro.cn/zh/components/actionSheet.html
* @property {Array<{ text: string; subText?: string; color?: string; fontSize?: string; disabled?: boolean }>} list 按钮的文字数组,见官方文档示例
* @property {{text: string; color?: string; fontSize?: string}} tips 顶部的提示文字,见官方文档示例
* @property {String} cancel-text 取消按钮的提示文字
* @property {Boolean} cancel-btn 是否显示底部的取消按钮(默认true)
* @property {Number String} border-radius 弹出部分顶部左右的圆角值,单位rpx(默认0)
* @property {Boolean} mask-close-able 点击遮罩是否可以关闭(默认true)
* @property {Boolean} safe-area-inset-bottom 是否开启底部安全区适配(默认false)
* @property {Number String} z-index z-index值(默认1075)
* @property {String} cancel-text 取消按钮的提示文字
* @event {Function} click 点击ActionSheet列表项时触发
* @event {Function} close 点击取消按钮时触发
* @example <u-action-sheet :list="list" @click="click" v-model="show"></u-action-sheet>
*/
const props = defineProps(ActionSheetProps);
const emit = defineEmits(['update:modelValue', 'click', 'close']);
const { t } = useLocale();
useParent('u-action-sheet');
const popupValue = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val)
});
// 国际化计算属性
const getCancelText = computed(() => props.cancelText || t('uActionSheet.cancelText'));
// 顶部提示的样式
const tipsStyle = computed(() => {
let style: Record<string, string> = {};
style.color = String(props.tips?.color || $u.color.tipsColor);
style.fontSize = $u.addUnit(String(props.tips?.fontSize || '26rpx'));
return style;
});
// 操作项目的样式
const itemStyle = (index: number) => {
let style: Record<string, string> = {};
style.color = String(props.list[index]?.color || props.color);
style.fontSize = $u.addUnit(String(props.list[index]?.fontSize || props.fontSize));
// 选项被禁用的样式
if (props.list[index]?.disabled) style.color = String($u.color.lightColor || '');
return style;
};
// 计算z-index
const uZIndex = computed(() => {
// 如果用户有传递z-index值,优先使用
return props.zIndex ? props.zIndex : $u.zIndex.popup;
});
/**
* 点击取消按钮
*/
function close() {
// 发送input事件,并不会作用于父组件,而是要设置组件内部通过props传递的value参数
// 这是一个vue发送事件的特殊用法
popupClose();
emit('close');
}
/**
* 弹窗关闭
*/
function popupClose() {
emit('update:modelValue', false);
}
/**
* 点击某一个item
* @param index 选项索引
*/
function itemClick(index: number) {
// disabled的项禁止点击
if (props.list[index]?.disabled) return;
emit('click', index);
if (props.asyncClose) return;
emit('update:modelValue', false);
}
defineExpose({
props,
close,
itemClick
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-tips {
font-size: 26rpx;
text-align: center;
padding: 34rpx 0;
line-height: 1;
color: $u-tips-color;
}
.u-action-sheet-item {
@include vue-flex;
line-height: 1;
justify-content: center;
align-items: center;
font-size: 32rpx;
padding: 34rpx 0;
flex-direction: column;
}
.u-action-sheet-item__subtext {
font-size: 24rpx;
color: $u-tips-color;
margin-top: 20rpx;
}
.u-gab {
height: 12rpx;
background-color: rgb(234, 234, 236);
}
.u-action-sheet-cancel {
color: $u-main-color;
}
</style>
@@ -0,0 +1,49 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
*/
export const AlertTipsProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 显示文字 */
title: { type: String, default: '' },
/** 主题,success/warning/info/error */
type: { type: String as PropType<ThemeType>, default: 'warning' },
/** 辅助性文字 */
description: { type: String, default: '' },
/** 是否可关闭 */
closeAble: { type: Boolean, default: false },
/** 关闭按钮自定义文本 */
closeText: { type: String, default: '' },
/** 是否显示图标 */
showIcon: { type: Boolean, default: false },
/** 文字颜色,如果定义了color值,icon会失效 */
color: { type: String, default: '' },
/** 背景颜色 */
bgColor: { type: String, default: '' },
/** 边框颜色 */
borderColor: { type: String, default: '' },
/** 是否显示 */
show: { type: Boolean, default: true },
/** 左边显示的icon */
icon: { type: String, default: '' },
/** icon的样式 */
iconStyle: { type: Object, default: () => ({}) },
/** 标题的样式 */
titleStyle: { type: Object, default: () => ({}) },
/** 描述文字的样式 */
descStyle: { type: Object, default: () => ({}) }
};
export type AlertTipsProps = ExtractPropTypes<typeof AlertTipsProps>;
@@ -0,0 +1,239 @@
<template>
<view
class="u-alert-tips"
v-if="show"
:class="[
!show ? 'u-close-alert-tips' : '',
type ? 'u-alert-tips--bg--' + type + '-light' : '',
type ? 'u-alert-tips--border--' + type + '-disabled' : '',
customClass
]"
:style="
$u.toStyle(
{
backgroundColor: bgColor,
borderColor: borderColor
},
customStyle
)
"
>
<view class="u-icon-wrap">
<u-icon
v-if="showIcon"
:name="uIconName"
:size="description ? 40 : 32"
:color="uIconType"
:custom-style="iconStyle"
custom-class="u-tips-icon"
></u-icon>
</view>
<view class="u-alert-content" @tap.stop="onClick">
<view class="u-alert-title" :style="uTitleStyle">
{{ title }}
</view>
<view v-if="description" class="u-alert-desc" :style="descStyle">
{{ description }}
</view>
</view>
<view class="u-icon-wrap">
<u-icon
@click="onClose"
v-if="closeAble && !closeText"
hoverClass="u-type-error-hover-color"
name="close"
color="var(--u-tips-color)"
:size="22"
:custom-style="{
top: description ? '18rpx' : '24rpx'
}"
custom-class="u-close-icon"
></u-icon>
</view>
<text
v-if="closeAble && closeText"
class="u-close-text"
:style="{
top: description ? '18rpx' : '24rpx'
}"
>
{{ closeText }}
</text>
</view>
</template>
<script lang="ts">
export default {
name: 'u-alert-tips',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { $u } from '../..';
import { AlertTipsProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* alertTips 警告提示
* @description 警告提示,展现需要关注的信息
* @tutorial https://uviewpro.cn/zh/components/alertTips.html
* @property {String} title 显示的标题文字
* @property {String} description 辅助性文字,颜色比title浅一点,字号也小一点,可选
* @property {String} type 关闭按钮(默认为叉号icon图标)
* @property {String} icon 图标名称
* @property {Object} icon-style 图标的样式,对象形式
* @property {Object} title-style 标题的样式,对象形式
* @property {Object} desc-style 描述的样式,对象形式
* @property {String} close-able 用文字替代关闭图标,close-able为true时有效
* @property {Boolean} show-icon 是否显示左边的辅助图标
* @property {Boolean} show 显示或隐藏组件
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
*/
const props = defineProps(AlertTipsProps);
const emit = defineEmits(['click', 'close']);
/**
* 标题样式,合并加粗和用户自定义样式
*/
const uTitleStyle = computed(() => {
let style: Record<string, any> = {};
// 如果有描述文字的话,标题进行加粗
style.fontWeight = props.description ? 500 : 'normal';
// 将用户传入样式对象和style合并,传入的优先级比style高,同属性会被覆盖
return $u.deepMerge(style, props.titleStyle);
});
/**
* 图标名称,优先使用用户传入,否则根据type主题推定默认图标
*/
const uIconName = computed(() => {
// 如果有设置icon名称就使用,否则根据type主题,推定一个默认的图标
return props.icon ? props.icon : $u.type2icon(props.type as any);
});
/**
* 图标类型,优先使用iconStyle,否则用type
*/
const uIconType = computed(() => {
// 如果有设置图标的样式,优先使用,没有的话,则用type的样式
return Object.keys(props.iconStyle).length ? '' : props.type;
});
/**
* 点击内容
*/
function onClick() {
emit('click');
}
/**
* 点击关闭按钮
*/
function onClose() {
emit('close');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-alert-tips {
@include vue-flex;
align-items: center;
padding: 16rpx 30rpx;
border-radius: 8rpx;
position: relative;
transition: all 0.3s linear;
border: 1px solid var(--u-white-color);
&--bg--primary-light {
background-color: $u-type-primary-light;
}
&--bg--info-light {
background-color: $u-type-info-light;
}
&--bg--success-light {
background-color: $u-type-success-light;
}
&--bg--warning-light {
background-color: $u-type-warning-light;
}
&--bg--error-light {
background-color: $u-type-error-light;
}
&--border--primary-disabled {
border-color: $u-type-primary-disabled;
}
&--border--success-disabled {
border-color: $u-type-success-disabled;
}
&--border--error-disabled {
border-color: $u-type-error-disabled;
}
&--border--warning-disabled {
border-color: $u-type-warning-disabled;
}
&--border--info-disabled {
border-color: $u-type-info-disabled;
}
:deep(.u-tips-icon) {
margin-right: 16rpx;
}
:deep(.u-close-icon) {
position: absolute;
right: 20rpx;
cursor: pointer;
}
}
.u-close-alert-tips {
opacity: 0;
visibility: hidden;
}
.u-alert-title {
font-size: 28rpx;
color: $u-main-color;
}
.u-alert-desc {
font-size: 26rpx;
text-align: left;
color: $u-content-color;
}
.u-close-hover {
color: red;
}
.u-close-text {
font-size: 24rpx;
color: $u-tips-color;
position: absolute;
top: 20rpx;
right: 20rpx;
line-height: 1;
}
</style>
@@ -0,0 +1,23 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { AvatarCropperBoundStyle } from '../../types/global';
export const AvatarCropperProps = {
/** 裁剪矩形框的样式 */
boundStyle: {
type: Object as PropType<AvatarCropperBoundStyle>,
default: (): AvatarCropperBoundStyle => ({
lineWidth: 4,
borderColor: 'rgb(245, 245, 245)',
mask: 'rgba(0, 0, 0, 0.35)'
})
}
// 其他 props 可参考注释,后续如需开放直接补充
// rectWidth: { type: [String, Number], default: 400 },
// rectHeight: { type: [String, Number], default: 400 },
// destWidth: { type: [String, Number], default: 400 },
// destHeight: { type: [String, Number], default: 400 },
// fileType: { type: String, default: 'jpg' },
// quality: { type: [Number, String], default: 1 }
};
export type AvatarCropperProps = ExtractPropTypes<typeof AvatarCropperProps>;
@@ -0,0 +1,299 @@
<template>
<view class="content">
<view class="cropper-wrapper" :style="{ height: cropperOpt.height + 'px' }">
<canvas
class="cropper"
:disable-scroll="true"
@touchstart="touchStart"
@touchmove="touchMove"
@touchend="touchEnd"
:style="{ width: cropperOpt.width, height: cropperOpt.height, backgroundColor: 'rgba(0, 0, 0, 0.8)' }"
canvas-id="cropper"
id="cropper"
></canvas>
<canvas
class="cropper"
:disable-scroll="true"
:style="{
position: 'fixed',
top: `-${cropperOpt.width * cropperOpt.pixelRatio}px`,
left: `-${cropperOpt.height * cropperOpt.pixelRatio}px`,
width: `${cropperOpt.width * cropperOpt.pixelRatio}px`,
height: `${cropperOpt.height * cropperOpt.pixelRatio}`
}"
canvas-id="targetId"
id="targetId"
></canvas>
</view>
<view class="cropper-buttons safe-area-padding" :style="{ height: bottomNavHeight + 'px' }">
<!-- #ifdef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.select') }}</view>
<!-- #endif -->
<!-- #ifndef H5 -->
<view class="upload" @tap="uploadTap">{{ t('avatarCropper.reselect') }}</view>
<!-- #endif -->
<view class="getCropperImage" @tap="() => getCropperImage(false)">{{ t('avatarCropper.confirm') }}</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-avatar-cropper',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
// @ts-nocheck
import { ref, reactive, onMounted } from 'vue';
import { $u, useLocale } from '../..';
// 兼容 UMD/ESM 导入 weCropper.js
import WeCropper from './weCropper';
import { AvatarCropperProps } from './types';
/**
* 裁剪矩形框的样式,其中可包含的属性为lineWidth-边框宽度(单位rpx),color: 边框颜色,
* mask-遮罩颜色,一般设置为一个rgba的透明度,如"rgba(0, 0, 0, 0.35)"
*/
const props = defineProps(AvatarCropperProps);
const { t } = useLocale();
/**
* 组合式API变量声明
* 保留所有说明注释
*/
const bottomNavHeight = ref(50); // 底部导航的高度
const originWidth = ref(200);
const width = ref(0);
const height = ref(0);
const src = ref(''); // 选择的图片路径,用于在点击确定时,判断是否选择了图片
const destWidth = ref(200); // 输出图片宽度,单位px
const rectWidth = ref(200); // 裁剪框宽度,单位px
const fileType = ref('jpg'); // 输出的图片类型,如果'png'类型发现裁剪的图片太大,改成"jpg"即可
// cropperOpt 需响应式,且类型安全
const cropperOpt = reactive({
id: 'cropper',
targetId: 'targetCropper',
pixelRatio: 1,
width: 0,
height: 0,
scale: 2.5,
zoom: 8,
cut: {
x: 0,
y: 0,
width: originWidth.value,
height: originWidth.value
},
boundStyle: {
lineWidth: 0,
mask: '',
color: ''
}
});
let cropper: any = null; // WeCropper 实例,类型 any,建议后续补充类型声明
/**
* 组件挂载时初始化 cropper
* 保留所有说明注释
*/
onMounted(() => {
// 获取系统信息
const rectInfo = uni.getSystemInfoSync();
width.value = rectInfo.windowWidth;
height.value = rectInfo.windowHeight - bottomNavHeight.value;
cropperOpt.width = width.value;
cropperOpt.height = height.value;
cropperOpt.pixelRatio = rectInfo.pixelRatio;
// 初始化裁剪框
cropperOpt.cut = {
x: (width.value - originWidth.value) / 2,
y: (height.value - originWidth.value) / 2,
width: originWidth.value,
height: originWidth.value
};
cropperOpt.boundStyle = {
lineWidth: uni.upx2px(props.boundStyle.lineWidth),
mask: props.boundStyle.mask,
color: props.boundStyle.borderColor
};
// 设置导航栏样式,以免用户在page.json中没有设置为黑色背景
uni.setNavigationBarColor({
frontColor: 'var(--u-white-color)',
backgroundColor: 'var(--u-bg-black)'
});
// 初始化 cropper 实例
cropper = new WeCropper(cropperOpt)
.on('ready', (ctx: any) => {
// wecropper is ready for work!
})
.on('beforeImageLoad', (ctx: any) => {
// before picture loaded, i can do something
})
.on('imageLoad', (ctx: any) => {
// picture loaded
})
.on('beforeDraw', (ctx: any, instance: any) => {
// before canvas draw,i can do something
});
// 选择图片
uni.chooseImage({
count: 1, // 默认9
sizeType: ['compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
});
/**
* 触摸开始事件
* @param e 事件对象
*/
function touchStart(e: any) {
cropper && cropper.touchStart(e);
}
/**
* 触摸移动事件
* @param e 事件对象
*/
function touchMove(e: any) {
cropper && cropper.touchMove(e);
}
/**
* 触摸结束事件
* @param e 事件对象
*/
function touchEnd(e: any) {
cropper && cropper.touchEnd(e);
}
/**
* 获取裁剪后的图片
* @param isPre 是否预览
*/
function getCropperImage(isPre = false) {
if (!src.value) return $u.toast(t('avatarCropper.noSelect'));
const cropper_opt = {
destHeight: Number(destWidth.value), // uni.canvasToTempFilePath要求这些参数为数值
destWidth: Number(destWidth.value),
fileType: fileType.value
};
cropper.getCropperImage(cropper_opt, (path: string, err: any) => {
if (err) {
uni.showModal({
title: t('avatarCropper.modalTitle'),
content: err.message
});
} else {
if (isPre) {
uni.previewImage({
current: '', // 当前显示图片的 http 链接
urls: [path] // 需要预览的图片 http 链接列表
});
} else {
uni.$emit('uAvatarCropper', path);
$u.route({
type: 'back'
});
}
}
});
}
/**
* 重新选择图片
*/
function uploadTap() {
uni.chooseImage({
count: 1, // 默认9
sizeType: ['original', 'compressed'], // 可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], // 可以指定来源是相册还是相机,默认二者都有
success: (res: UniApp.ChooseImageSuccessCallbackResult) => {
src.value = res.tempFilePaths[0];
// 获取裁剪图片资源后,给data添加src属性及其值
cropper.pushOrign(src.value);
}
});
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.content {
background: rgba(255, 255, 255, 1);
}
.cropper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 11;
}
.cropper-buttons {
background-color: var(--u-bg-black);
color: var(--u-divider-color);
}
.cropper-wrapper {
position: relative;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 100%;
background-color: var(--u-bg-black);
}
.cropper-buttons {
width: 100vw;
@include vue-flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
position: fixed;
bottom: 0;
left: 0;
font-size: 28rpx;
}
.cropper-buttons .upload,
.cropper-buttons .getCropperImage {
width: 50%;
text-align: center;
}
.cropper-buttons .upload {
text-align: left;
padding-left: 50rpx;
}
.cropper-buttons .getCropperImage {
text-align: right;
padding-right: 50rpx;
}
</style>
@@ -0,0 +1,62 @@
// @ts-nocheck
/**
* we-cropper 类型声明文件
* 适用于 Vue3 + TypeScript 项目中 weCropper.js 的类型提示
*/
export interface WeCropperOptions {
id?: string;
targetId?: string;
pixelRatio?: number;
width?: number;
height?: number;
scale?: number;
zoom?: number;
cut?: {
x?: number;
y?: number;
width?: number;
height?: number;
};
boundStyle?: {
lineWidth?: number;
borderColor?: string;
mask?: string;
color?: string;
};
[key: string]: any;
}
export type WeCropperEvent = (ctx: any, instance?: any) => void;
export interface WeCropperInstance {
pushOrign(src: string): Promise<void>;
pushOrigin(src: string): Promise<void>;
removeImage(): Promise<void>;
getCropperBase64(done?: (base64: string) => void): void;
getCropperImage(opt: any, fn: (path: string, err: any) => void): void;
update(): void;
touchStart(e: any): void;
touchMove(e: any): void;
touchEnd(e: any): void;
on(event: 'ready' | 'beforeImageLoad' | 'imageLoad' | 'beforeDraw', fn: WeCropperEvent): WeCropperInstance;
// 其它动态方法
[key: string]: any;
}
// WeCropper 类型声明文件
declare class WeCropper {
constructor(params: any);
// 事件方法
on(event: string, callback: (ctx?: any) => void): WeCropper;
// 其他方法
pushOrign(src: string): void;
getCropperImage(callback?: (tempFilePath: string) => void): void;
// 属性
[key: string]: any;
}
export default WeCropper;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ImgMode } from '../../types/global';
// u-avatar 组件 props
export const AvatarProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 头像路径 */
src: { type: String, default: '' },
/** 尺寸,large-大,default-中等,mini-小,如果为数值,则单位为rpx,宽度等于高度 */
size: { type: [String, Number], default: 'default' },
/** 头像模型,square-带圆角方形,circle-圆形 */
mode: { type: String, default: 'circle' },
/** 文字内容 */
text: { type: String, default: '' },
/** 图片的裁剪模型 */
imgMode: { type: String as PropType<ImgMode>, default: 'aspectFill' },
/** 标识符 */
index: { type: [String, Number], default: '' },
/** 右上角性别角标,man-男,woman-女 */
sexIcon: { type: String as PropType<'man' | 'woman'>, default: 'man' },
/** 右下角的等级图标 */
levelIcon: { type: String, default: 'level' },
/** 右下角等级图标背景颜色 */
levelBgColor: { type: String, default: '' },
/** 右上角性别图标的背景颜色 */
sexBgColor: { type: String, default: '' },
/** 是否显示性别图标 */
showSex: { type: Boolean, default: false },
/** 是否显示等级图标 */
showLevel: { type: Boolean, default: false }
};
export type AvatarProps = ExtractPropTypes<typeof AvatarProps>;
@@ -0,0 +1,218 @@
<template>
<view class="u-avatar" :class="customClass" :style="$u.toStyle(wrapStyle, customStyle)" @tap="onClick">
<image
@error="onLoadError"
:style="imgStyle"
class="u-avatar__img"
v-if="!uText && avatar"
:src="avatar"
:mode="imgMode"
></image>
<text class="u-line-1" v-else-if="uText" :style="{ fontSize: '38rpx' }">{{ uText }}</text>
<slot v-else></slot>
<view class="u-avatar__sex" v-if="showSex" :class="['u-avatar__sex--' + sexIcon]" :style="uSexStyle">
<u-icon :name="sexIcon" size="20"></u-icon>
</view>
<view class="u-avatar__level" v-if="showLevel" :style="uLevelStyle">
<u-icon :name="levelIcon" size="20"></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-avatar',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { AvatarProps } from './types';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
const base64Avatar =
'data:image/jpg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAA8AAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjREMEQwRkY0RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjREMEQwRkY1RjgwNDExRUE5OTY2RDgxODY3NkJFODMxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NEQwRDBGRjJGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NEQwRDBGRjNGODA0MTFFQTk5NjZEODE4Njc2QkU4MzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAGBAQEBQQGBQUGCQYFBgkLCAYGCAsMCgoLCgoMEAwMDAwMDBAMDg8QDw4MExMUFBMTHBsbGxwfHx8fHx8fHx8fAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCADIAMgDAREAAhEBAxEB/8QAcQABAQEAAwEBAAAAAAAAAAAAAAUEAQMGAgcBAQAAAAAAAAAAAAAAAAAAAAAQAAIBAwICBgkDBQAAAAAAAAABAhEDBCEFMVFBYXGREiKBscHRMkJSEyOh4XLxYjNDFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A/fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHbHFyZ/Dam+yLA+Z2L0Pjtyj2poD4AAAAAAAAAAAAAAAAAAAAAAAAKWFs9y6lcvvwQeqj8z9wFaziY1n/HbUX9XF97A7QAGXI23EvJ1goyfzR0YEfN269jeZ+a03pNe0DIAAAAAAAAAAAAAAAAAAAACvtO3RcVkXlWutuL9YFYAAAAAOJRjKLjJVi9GmB5/csH/mu1h/in8PU+QGMAAAAAAAAAAAAAAAAAAaMDG/6MmMH8C80+xAelSSVFolwQAAAAAAAHVlWI37ErUulaPk+hgeYnCUJuElSUXRrrQHAAAAAAAAAAAAAAAAABa2Oz4bM7r4zdF2ICmAAAAAAAAAg7zZ8GX41wuJP0rRgYAAAAAAAAAAAAAAAAAD0m2R8ODaXU33tsDSAAAAAAAAAlb9HyWZcnJd9PcBHAAAAAAAAAAAAAAAAAPS7e64Vn+KA0AAAAAAAAAJm+v8Ftf3ewCKAAAAAAAAAAAAAAAAAX9muqeGo9NttP06+0DcAAAAAAAAAjb7dTu2ra+VOT9P8AQCWAAAAAAAAAAAAAAAAAUNmyPt5Ltv4bui/kuAF0AAAAAAADiUlGLlJ0SVW+oDzOXfd/Ind6JPRdS0QHSAAAAAAAAAAAAAAAAAE2nVaNcGB6Lbs6OTao9LsF51z60BrAAAAAABJ3jOVHjW3r/sa9QEgAAAAAAAAAAAAAAAAAAAPu1duWriuW34ZR4MC9hbnZyEoy8l36XwfYBsAAADaSq9EuLAlZ+7xSdrGdW9Hc5dgEdtt1erfFgAAAAAAAAAAAAAAAAADVjbblX6NR8MH80tEBRs7HYivyzlN8lovaBPzduvY0m6eK10TXtAyAarO55lpJK54orolr+4GqO/Xaea1FvqbXvA+Z77kNeW3GPbV+4DJfzcm/pcm3H6Vou5AdAFLC2ed2Pjv1txa8sV8T6wOL+yZEKu1JXFy4MDBOE4ScZxcZLinoB8gAAAAAAAAAAAB242LeyJ+C3GvN9C7QLmJtePYpKS+5c+p8F2IDYAANJqj1T4oCfk7Nj3G5Wn9qXJax7gJ93Z82D8sVNc4v30A6Xg5i42Z+iLfqARwcyT0sz9MWvWBps7LlTf5Grce9/oBTxdtxseklHxT+uWr9AGoAB138ezfj4bsFJdD6V2MCPm7RdtJzs1uW1xXzL3gTgAAAAAAAAADRhYc8q74I6RWs5ckB6GxYtWLat21SK731sDsAAAAAAAAAAAAAAAASt021NO/YjrxuQXT1oCOAAAAAAABzGLlJRSq26JAelwsWONYjbXxcZvmwO8AAAAAAAAAAAAAAAAAAef3TEWPkVivx3NY9T6UBiAAAAAABo2+VmGXblddIJ8eivRUD0oAAAAAAAAAAAAAAAAAAAYt4tKeFKVNYNSXfRgefAAAAAAAAr7VuSSWPedKaW5v1MCsAAAAAAAAAAAAAAAAAAIe6bj96Ts2n+JPzSXzP3ATgAAAAAAAAFbbt1UUrOQ9FpC4/UwK6aaqtU+DAAAAAAAAAAAAAAA4lKMIuUmoxWrb4ARNx3R3q2rLpa4Sl0y/YCcAAAAAAAAAAANmFud7G8r89r6X0dgFvGzLGRGtuWvTF6NAdwAAAAAAAAAAAy5W442PVN+K59EePp5ARMvOv5MvO6QXCC4AZwAAAAAAAAAAAAAcxlKLUotprg1owN+PvORborq+7Hnwl3gUbO74VzRydt8pKn68ANcJwmqwkpLmnUDkAAAAfNy9atqtyagut0AxXt5xIV8Fbj6lRd7Am5G65V6qUvtwfyx94GMAAAAAAAAAAAAAAAAAAAOU2nVOj5gdsc3LiqRvTpyqwOxbnnrhdfpSfrQB7pnv/AGvuS9gHXPMy5/Fem1yq0v0A6W29XqwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAf//Z';
/**
* avatar 头像
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uviewpro.cn/zh/components/avatar.html
* @property {String} bg-color 背景颜色,一般显示文字时用(默认var(--u-bg-white))
* @property {String} src 头像路径,如加载失败,将会显示默认头像
* @property {String Number} size 头像尺寸,可以为指定字符串(large, default, mini),或者数值,单位rpx(默认default)
* @property {String} mode 显示类型,见上方说明(默认circle)
* @property {String} sex-icon 性别图标,man-男,woman-女(默认man)
* @property {String} level-icon 等级图标(默认level)
* @property {String} sex-bg-color 性别图标背景颜色
* @property {String} level-bg-color 等级图标背景颜色
* @property {String} show-sex 是否显示性别图标(默认false)
* @property {String} show-level 是否显示等级图标(默认false)
* @property {String} img-mode 头像图片的裁剪类型,与uni的image组件的mode参数一致,如效果达不到需求,可尝试传widthFix值(默认aspectFill)
* @property {String} index 用户传递的标识符值,如果是列表循环,可穿v-for的index值
* @event {Function} click 头像被点击
* @example <u-avatar :src="src"></u-avatar>
*/
const props = defineProps(AvatarProps);
const emit = defineEmits(['click']);
// 头像的地址,因为如果加载错误,需要赋值为默认图片,props值无法修改,所以需要一个中间值
const avatar = ref(props.src ? props.src : base64Avatar);
const error = ref(false);
// 用户可能会在头像加载失败时,再次修改头像值,所以需要重新赋值
watch(
() => props.src,
n => {
if (!n) {
// 如果传入null或者'',或者undefined,显示默认头像
avatar.value = base64Avatar;
error.value = true;
} else {
avatar.value = n;
error.value = false;
}
}
);
/**
* 头像外层样式
*/
const wrapStyle = computed(() => {
let style: Record<string, string> = {};
style.height =
props.size === 'large'
? '120rpx'
: props.size === 'default'
? '90rpx'
: props.size === 'mini'
? '70rpx'
: props.size + 'rpx';
style.width = style.height;
style.flex = `0 0 ${style.height}`;
style.backgroundColor = props.bgColor;
style.borderRadius = props.mode === 'circle' ? '500px' : '5px';
if (props.text) style.padding = '0 6rpx';
return style;
});
/**
* 图片样式
*/
const imgStyle = computed(() => {
let style: Record<string, string> = {};
style.borderRadius = props.mode === 'circle' ? '500px' : '5px';
return style;
});
/**
* 取字符串的第一个字符
*/
const uText = computed(() => {
return String(props.text)[0];
});
/**
* 性别图标的自定义样式
*/
const uSexStyle = computed(() => {
let style: Record<string, string> = {};
if (props.sexBgColor) style.backgroundColor = props.sexBgColor;
return style;
});
/**
* 等级图标的自定义样式
*/
const uLevelStyle = computed(() => {
let style: Record<string, string> = {};
if (props.levelBgColor) style.backgroundColor = props.levelBgColor;
return style;
});
/**
* 图片加载错误时,显示默认头像
*/
function onLoadError() {
error.value = true;
avatar.value = base64Avatar;
}
/**
* 头像被点击
*/
function onClick() {
emit('click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-avatar {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
justify-content: center;
font-size: 28rpx;
color: $u-content-color;
border-radius: 10px;
position: relative;
&__img {
width: 100%;
height: 100%;
}
&__sex {
position: absolute;
width: 32rpx;
color: var(--u-white-color);
height: 32rpx;
@include vue-flex;
justify-content: center;
align-items: center;
border-radius: 100rpx;
top: 5%;
z-index: 1;
right: -7%;
border: 1px var(--u-white-color) solid;
&--man {
background-color: $u-type-primary;
}
&--woman {
background-color: $u-type-error;
}
&--none {
background-color: $u-type-warning;
}
}
&__level {
position: absolute;
width: 32rpx;
color: var(--u-white-color);
height: 32rpx;
@include vue-flex;
justify-content: center;
align-items: center;
border-radius: 100rpx;
bottom: 5%;
z-index: 1;
right: -7%;
border: 1px var(--u-white-color) solid;
background-color: $u-type-warning;
}
}
</style>
@@ -0,0 +1,44 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape } from '../../types/global';
// u-back-top 组件 props
export const BackTopProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 返回顶部的形状,circle-圆形,square-方形 */
mode: { type: String as PropType<Shape>, default: 'circle' },
/** 自定义图标 */
icon: { type: String, default: 'arrow-upward' },
/** 提示文字 */
tips: { type: String, default: '' },
/** 返回顶部滚动时间 */
duration: { type: [Number, String], default: 100 },
/** 滚动距离 */
scrollTop: { type: [Number, String], default: 0 },
/** 距离顶部多少距离显示,单位rpx */
top: { type: [Number, String], default: 400 },
/** 返回顶部按钮到底部的距离,单位rpx */
bottom: { type: [Number, String], default: 200 },
/** 返回顶部按钮到右边的距离,单位rpx */
right: { type: [Number, String], default: 40 },
/** 层级 */
zIndex: { type: [Number, String], default: '9' },
/** 图标的样式,对象形式 */
iconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({
color: 'var(--u-tips-color)',
fontSize: '38rpx'
})
}
};
export type BackTopProps = ExtractPropTypes<typeof BackTopProps>;
@@ -0,0 +1,141 @@
<template>
<view
@tap="backToTop"
class="u-back-top"
:class="['u-back-top--mode--' + mode, customClass]"
:style="
$u.toStyle(
{
bottom: bottom + 'rpx',
right: right + 'rpx',
borderRadius: mode == 'circle' ? '10000rpx' : '8rpx',
zIndex: uZIndex,
opacity: opacity
},
customStyle
)
"
>
<view class="u-back-top__content" v-if="!slots.default">
<u-icon @click="backToTop" :name="icon" :custom-style="iconStyle"></u-icon>
<view class="u-back-top__content__tips">
{{ tips }}
</view>
</view>
<slot v-else />
</view>
</template>
<script lang="ts">
export default {
name: 'u-back-top',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, useSlots } from 'vue';
import { BackTopProps } from './types';
import { $u } from '../../';
import uIcon from '../u-icon/u-icon.vue';
/**
* back-top 返回顶部按钮
* @description 页面滚动时显示的返回顶部按钮
* @property {String} mode 返回顶部的形状,circle-圆形,square-方形
* @property {String} icon 自定义图标
* @property {String} tips 提示文字
* @property {Number|String} duration 返回顶部滚动时间
* @property {Number|String} scrollTop 滚动距离
* @property {Number|String} top 距离顶部多少距离显示,单位rpx
* @property {Number|String} bottom 返回顶部按钮到底部的距离,单位rpx
* @property {Number|String} right 返回顶部按钮到右边的距离,单位rpx
* @property {Number|String} zIndex 层级
* @property {Object} iconStyle 图标的样式,对象形式
* @property {Object} customStyle 整个组件的样式
*/
const props = defineProps(BackTopProps);
const slots = useSlots();
// 不透明度,为了让组件有一个显示和隐藏的过渡动画
const opacity = ref(0);
// 组件的z-index值,隐藏时设置为-1,就会看不到
const uZIndex = ref(-1);
/**
* 计算是否显示返回顶部按钮
* 由于scrollTop为页面的滚动距离,默认为px单位,这里将用于传入的top(rpx)值
* 转为px用于比较,如果滚动条到顶的距离大于设定的距离,就显示返回顶部的按钮
*/
const showBackTop = computed(() => {
// uni.upx2px 用于将 rpx 转为 px
return (
Number(props.scrollTop) >
(typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.top)) : Number(props.top))
);
// #else
// return Number(props.scrollTop) > Number(props.top)
});
// 监听组件的显示与隐藏状态,修改组件的层级和不透明度
// 让组件有显示和消失的动画效果,如果用v-if控制组件状态,将无设置动画效果
watch(showBackTop, nVal => {
if (nVal) {
uZIndex.value = Number(props.zIndex);
opacity.value = 1;
} else {
uZIndex.value = -1;
opacity.value = 0;
}
});
/**
* 返回顶部方法
*/
function backToTop() {
// uni.pageScrollTo 用于页面滚动到顶部
if (typeof uni !== 'undefined' && uni.pageScrollTo) {
uni.pageScrollTo({
scrollTop: 0,
duration: Number(props.duration)
});
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-back-top {
width: 80rpx;
height: 80rpx;
position: fixed;
z-index: 9;
@include vue-flex;
flex-direction: column;
justify-content: center;
background-color: var(--u-divider-color);
color: $u-content-color;
align-items: center;
transition: opacity 0.4s;
&__content {
@include vue-flex;
flex-direction: column;
align-items: center;
&__tips {
font-size: 24rpx;
transform: scale(0.8);
line-height: 1;
}
}
}
</style>
@@ -0,0 +1,46 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { BadgeSize, ThemeType } from '../../types/global';
/**
* badge 角标类型定义
* @description 供 u-badge 组件 props 使用
*/
export const BadgeProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 使用预设的背景颜色 primary,warning,success,error,info */
type: { type: String as PropType<ThemeType>, default: 'error' },
/** Badge的尺寸,default, mini */
size: { type: String as PropType<BadgeSize>, default: 'default' },
/** 是否是圆点 */
isDot: { type: Boolean, default: false },
/** 显示的数值内容 */
count: { type: [Number, String], default: undefined },
/** 展示封顶的数字值 */
overflowCount: { type: Number, default: 99 },
/** 当数值为 0 时,是否展示 Badge */
showZero: { type: Boolean, default: false },
/** 位置偏移 [number, number] */
offset: { type: Array as unknown as PropType<[number, number]>, default: () => [20, 20] },
/** 是否开启绝对定位,开启了offset才会起作用 */
absolute: { type: Boolean, default: true },
/** 字体大小 */
fontSize: { type: [String, Number], default: '24' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-white-color)' },
/** badge的背景颜色 */
bgColor: { type: String, default: '' },
/** 是否让badge组件的中心点和父组件右上角重合,配置的话,offset将会失效 */
isCenter: { type: Boolean, default: false }
};
export type BadgeProps = ExtractPropTypes<typeof BadgeProps>;
@@ -0,0 +1,153 @@
<template>
<view
v-if="show"
class="u-badge"
:class="[
isDot ? 'u-badge-dot' : '',
size === 'mini' ? 'u-badge-mini' : '',
type ? 'u-badge--bg--' + type : '',
customClass
]"
:style="
$u.toStyle(
{
top: (offset as number[])[0] + 'rpx',
right: (offset as number[])[1] + 'rpx',
fontSize: fontSize + 'rpx',
position: absolute ? 'absolute' : 'static',
color: color,
backgroundColor: bgColor
},
boxStyle,
customStyle
)
"
>
{{ showText }}
</view>
</template>
<script lang="ts">
export default {
name: 'u-badge',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { BadgeProps } from './types';
import { $u } from '../..';
/**
* badge 角标
* @description 本组件一般用于展示头像的地方,如个人中心,或者评论列表页的用户头像展示等场所。
* @tutorial https://uviewpro.cn/zh/components/badge.html
* @property {String|Number} count 展示的数字,大于 overflowCount 时显示为 ${overflowCount}+,为0且show-zero为false时隐藏
* @property {Boolean} is-dot 不展示数字,只有一个小点(默认false)
* @property {Boolean} absolute 组件是否绝对定位,为true时,offset参数才有效(默认true)
* @property {String|Number} overflow-count 展示封顶的数字值(默认99)
* @property {String} type 使用预设的背景颜色(默认error)
* @property {Boolean} show-zero 当数值为 0 时,是否展示 Badge(默认false)
* @property {String} size Badge的尺寸,设为mini会得到小一号的Badge(默认default)
* @property {Array} offset 设置badge的位置偏移,格式为 [x, y],也即设置的为top和right的值,单位rpx。absolute为true时有效(默认[20, 20])
* @property {String} color 字体颜色(默认var(--u-white-color))
* @property {String} bgColor 背景颜色,优先级比type高,如设置,type参数会失效
* @property {Boolean} is-center 组件中心点是否和父组件右上角重合,优先级比offset高,如设置,offset参数会失效(默认false)
* @example <u-badge type="error" count="7"></u-badge>
*/
const props = defineProps(BadgeProps);
/**
* 计算 badge 的定位和变换样式
*/
const boxStyle = computed(() => {
let style: Record<string, any> = {};
if (props.isCenter) {
style.top = 0;
style.right = 0;
// Y轴-50%,意味着badge向上移动了badge自身高度一半,X轴50%,意味着向右移动了自身宽度一半
style.transform = 'translateY(-50%) translateX(50%)';
} else {
style.top = (props.offset as number[])[0] + 'rpx';
style.right = (props.offset as number[])[1] + 'rpx';
style.transform = 'translateY(0) translateX(0)';
}
// 如果尺寸为mini,后接上scale()
if (props.size === 'mini') {
style.transform = style.transform + ' scale(0.8)';
}
return style;
});
/**
* 计算显示的文本内容
*/
const showText = computed(() => {
if (props.isDot) return '';
else {
if (Number(props.count) > props.overflowCount) return `${props.overflowCount}+`;
else return props.count;
}
});
/**
* 是否显示组件
*/
const show = computed(() => {
// 如果count的值为0,并且showZero设置为false,不显示组件
if (Number(props.count) === 0 && props.showZero === false) return false;
else return true;
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-badge {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
justify-content: center;
align-items: center;
line-height: 24rpx;
padding: 4rpx 8rpx;
border-radius: 100rpx;
z-index: 9;
&--bg--primary {
background-color: $u-type-primary;
}
&--bg--error {
background-color: $u-type-error;
}
&--bg--success {
background-color: $u-type-success;
}
&--bg--info {
background-color: $u-type-info;
}
&--bg--warning {
background-color: $u-type-warning;
}
}
.u-badge-dot {
height: 16rpx;
width: 16rpx;
border-radius: 100rpx;
line-height: 1;
}
.u-badge-mini {
transform: scale(0.8);
transform-origin: center center;
}
</style>
@@ -0,0 +1,76 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ButtonFormType, ButtonOpenType, ButtonScope, ButtonSize, ButtonType, Shape } from '../../types/global';
/**
* button 按钮类型定义
* @description 供 u-button 组件 props 使用
*/
export const ButtonProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 按钮文本 */
text: { type: String, default: '' },
/** 是否细边框 */
hairLine: { type: Boolean, default: true },
/** 按钮的预置样式,default,primary,error,warning,success */
type: { type: String as PropType<ButtonType>, default: 'default' },
/** 按钮尺寸,default,large, medium=small,mini */
size: { type: String as PropType<ButtonSize>, default: 'default' },
/** 按钮形状,circle(两边为半圆),square(带圆角) */
shape: { type: String as PropType<Shape>, default: 'square' },
/** 按钮是否镂空 */
plain: { type: Boolean, default: false },
/** 是否禁止状态 */
disabled: { type: Boolean, default: false },
/** 是否加载中 */
loading: { type: Boolean, default: false },
/** 支付宝小程序,当 open-type 为 getAuthorize 时有效 */
scope: { type: String as PropType<ButtonScope>, default: '' },
/** 开放能力,具体请看uniapp稳定关于button组件部分说明 */
openType: { type: String as PropType<ButtonOpenType>, default: '' },
/** 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件 */
formType: { type: String as PropType<ButtonFormType>, default: '' },
/** 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效 */
appParameter: { type: String, default: '' },
/** 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效 */
hoverStopPropagation: { type: Boolean, default: false },
/** 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效 */
lang: { type: String, default: 'en' },
/** 会话来源,open-type="contact"时有效。只微信小程序有效 */
sessionFrom: { type: String, default: '' },
/** 会话内消息卡片标题,open-type="contact"时有效 */
sendMessageTitle: { type: String, default: '' },
/** 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效 */
sendMessagePath: { type: String, default: '' },
/** 会话内消息卡片图片,open-type="contact"时有效 */
sendMessageImg: { type: String, default: '' },
/** 是否显示会话内消息卡片,open-type="contact"时有效 */
showMessageCard: { type: Boolean, default: false },
/** 手指按(触摸)按钮时按钮时的背景颜色 */
hoverBgColor: { type: String, default: '' },
/** 水波纹的背景颜色 */
rippleBgColor: { type: String, default: '' },
/** 是否开启水波纹效果 */
ripple: { type: Boolean, default: false },
/** 按下的类名 */
hoverClass: { type: String, default: '' },
/** 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取 */
dataName: { type: String, default: '' },
/** 节流,一定时间内只能触发一次 */
throttleTime: { type: [String, Number], default: 0 },
/** 按住后多久出现点击态,单位毫秒 */
hoverStartTime: { type: [String, Number], default: 20 },
/** 手指松开后点击态保留时间,单位毫秒 */
hoverStayTime: { type: [String, Number], default: 150 }
};
export type ButtonProps = ExtractPropTypes<typeof ButtonProps>;
@@ -0,0 +1,608 @@
<template>
<!-- prettier-ignore -->
<button
id="u-wave-btn"
class="u-btn u-line-1 u-fix-ios-appearance"
data-eventsync="true"
:class="[
'u-size-' + size,
plain ? 'u-btn--' + type + '--plain' : '',
loading ? 'u-loading' : '',
shape === 'circle' ? 'u-round-circle' : '',
hairLine ? showHairLineBorder : 'u-btn--bold-border',
'u-btn--' + type,
disabled ? `u-btn--${type}--disabled` : '',
customClass
]"
:hover-start-time="Number(hoverStartTime)"
:hover-stay-time="Number(hoverStayTime)"
:disabled="disabled"
:form-type="(formType as any)"
:open-type="disabled || loading ? undefined : openType"
:app-parameter="appParameter"
:hover-stop-propagation="hoverStopPropagation"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:lang="(lang as any)"
:data-name="dataName"
:session-from="sessionFrom"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
@getAuthorize="getAuthorize"
@getuserinfo="getuserinfo"
@contact="contact"
@getphonenumber="getphonenumber"
@error="error"
@launchapp="launchapp"
@opensetting="opensetting"
@chooseavatar="chooseavatar"
@agreeprivacyauthorization="agreeprivacyauthorization"
:style="
$u.toStyle(
{
overflow: ripple ? 'hidden' : 'visible'
},
customStyle
)
"
@tap.stop="click($event)"
:hover-class="getHoverClass"
:loading="loading"
>
<slot>{{ props.text }}</slot>
<view
v-if="ripple"
class="u-wave-ripple"
:class="[waveActive ? 'u-wave-active' : '']"
:style="{
top: rippleTop + 'px',
left: rippleLeft + 'px',
width: fields.targetWidth + 'px',
height: fields.targetWidth + 'px',
'background-color': rippleBgColor || 'rgba(0, 0, 0, 0.15)'
}"
></view>
</button>
</template>
<script lang="ts">
export default {
name: 'u-button',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue';
import { $u } from '../../';
import { ButtonProps } from './types';
/**
* button 按钮
* @description Button 按钮
* @tutorial https://uviewpro.cn/zh/components/button.html
* @property {String} size 按钮的大小
* @property {Boolean} ripple 是否开启点击水波纹效果
* @property {String} ripple-bg-color 水波纹的背景色,ripple为true时有效
* @property {String} type 按钮的样式类型
* @property {Boolean} plain 按钮是否镂空,背景色透明
* @property {Boolean} disabled 是否禁用
* @property {Boolean} hair-line 是否显示按钮的细边框(默认true)
* @property {String} shape 按钮外观形状,见文档说明
* @property {Boolean} loading 按钮名称前是否带 loading 图标(App-nvue 平台,在 ios 上为雪花,Android上为圆圈)
* @property {String} form-type 用于 <form> 组件,点击分别会触发 <form> 组件的 submit/reset 事件
* @property {String} open-type 开放能力
* @property {String} data-name 额外传参参数,用于小程序的data-xxx属性,通过target.dataset.name获取
* @property {String} hover-class 指定按钮按下去的样式类。当 hover-class="none" 时,没有点击态效果(App-nvue 平台暂不支持)
* @property {Number} hover-start-time 按住后多久出现点击态,单位毫秒
* @property {Number} hover-stay-time 手指松开后点击态保留时间,单位毫秒
* @property {Object} custom-style 对按钮的自定义样式,对象形式,见文档说明
* @property {String} app-parameter 打开 APP 时,向 APP 传递的参数,open-type=launchApp时有效
* @property {Boolean} hover-stop-propagation 指定是否阻止本节点的祖先节点出现点击态,微信小程序有效
* @property {String} lang 指定返回用户信息的语言,zh_CN 简体中文,zh_TW 繁体中文,en 英文。只微信小程序有效
* @property {String} session-from 会话来源,open-type="contact"时有效。只微信小程序有效
* @property {String} send-message-title 会话内消息卡片标题,open-type="contact"时有效
* @property {String} send-message-path 会话内消息卡片点击跳转小程序路径,open-type="contact"时有效
* @property {String} send-message-img 会话内消息卡片图片,open-type="contact"时有效
* @property {Boolean} show-message-card 是否显示会话内消息卡片,open-type="contact"时有效
* @property {Number|String} throttle-time 节流,一定时间内只能触发一次,单位毫秒
* @property {String} scope 支付宝小程序,当 open-type 为 getAuthorize 时有效。可选值:'phoneNumber' | 'userInfo'
* @event {Function} click 按钮点击
* @event {Function} getphonenumber open-type="getPhoneNumber"时有效
* @event {Function} getuserinfo 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
* @event {Function} error 当使用开放能力时,发生错误的回调
* @event {Function} opensetting 在打开授权设置页并关闭后回调
* @event {Function} launchapp 打开 APP 成功的回调
* @event {Function} contact 客服消息回调
* @event {Function} chooseavatar 头像选择回调
* @event {Function} agreeprivacyauthorization 用户点击允许授权回调
* @example <u-button>月落</u-button>
*/
const emit = defineEmits([
'click',
'getuserinfo',
'contact',
'getphonenumber',
'error',
'launchapp',
'opensetting',
'chooseavatar',
'agreeprivacyauthorization'
]);
const props = defineProps(ButtonProps);
/**
* 按钮水波纹相关状态
*/
const rippleTop = ref(0); // 水波纹的起点Y坐标到按钮上边界的距离
const rippleLeft = ref(0); // 水波纹起点X坐标到按钮左边界的距离
const fields = ref<Record<string, any>>({}); // 波纹按钮节点信息
const waveActive = ref(false); // 激活水波纹
/**
* 当没有传bgColor变量时,按钮按下去的颜色类名
* @returns {string}
*/
const getHoverClass = computed(() => {
// 如果开启水波纹效果,则不启用hover-class效果
if (props.loading || props.disabled || props.ripple) return '';
// 如果用户传了 hoverClass,优先使用用户的
if (props.hoverClass) return props.hoverClass;
let hoverClass = '';
hoverClass = props.plain ? 'u-' + props.type + '-plain-hover' : 'u-' + props.type + '-hover';
return hoverClass;
});
/**
* 在'primary', 'success', 'error', 'warning'类型下,不显示边框,否则会造成四角有毛刺现象
* @returns {string}
*/
const showHairLineBorder = computed(() => {
if (['primary', 'success', 'error', 'warning'].indexOf(props.type) >= 0 && !props.plain) {
return '';
} else {
return 'u-hairline-border';
}
});
/**
* 按钮点击
* @param e 事件对象
* @emits click
*/
function click(e: any) {
if (Number(props.throttleTime)) {
$u.throttle(() => {
clickAction(e);
}, Number(props.throttleTime));
} else {
clickAction(e);
}
}
function clickAction(e: any) {
// 如果按钮时disabled和loading状态,不触发水波纹效果
if (props.loading === true || props.disabled === true) return;
// 是否开启水波纹效果
if (props.ripple) {
// 每次点击时,移除上一次的类,再次添加,才能触发动画效果
waveActive.value = false;
nextTick(() => {
getWaveQuery(e);
});
}
emit('click', e);
}
/**
* 查询按钮的节点信息
* @param e 事件对象
*/
function getWaveQuery(e: any) {
getElQuery().then((res: any[]) => {
// 查询返回的是一个数组节点
let data = res[0];
// 查询不到节点信息,不操作
if (!data.width || !data.width) return;
// 水波纹的最终形态是一个正方形(通过border-radius让其变为一个圆形),这里要保证正方形的边长等于按钮的最长边
// 最终的方形(变换后的圆形)才能覆盖整个按钮
data.targetWidth = data.height > data.width ? data.height : data.width;
if (!data.targetWidth) return;
fields.value = data;
let touchesX = '',
touchesY = '';
// #ifdef MP-BAIDU
// @ts-ignore
touchesX = e.changedTouches[0].clientX;
touchesY = e.changedTouches[0].clientY;
// #endif
// #ifdef MP-ALIPAY
// @ts-ignore
touchesX = e.detail.clientX;
touchesY = e.detail.clientY;
// #endif
// #ifndef MP-BAIDU || MP-ALIPAY
// @ts-ignore
touchesX = e.touches[0].clientX;
touchesY = e.touches[0].clientY;
// #endif
// 获取触摸点相对于按钮上边和左边的x和y坐标,原理是通过屏幕的触摸点(touchesY),减去按钮的上边界data.top
// 但是由于`transform-origin`默认是center,所以这里再减去半径才是水波纹view应该的位置
// 总的来说,就是把水波纹的矩形(变换后的圆形)的中心点,移动到我们的触摸点位置
rippleTop.value = Number(touchesY) - data.top - data.targetWidth / 2;
rippleLeft.value = Number(touchesX) - data.left - data.targetWidth / 2;
nextTick(() => {
waveActive.value = true;
});
});
}
/**
* 获取节点信息
* @returns {Promise<any[]>}
*/
function getElQuery(): Promise<any[]> {
return new Promise(resolve => {
let queryInfo: any = '';
// 获取元素节点信息,请查看uniapp相关文档
// https://uniapp.dcloud.io/api/ui/nodes-info?id=nodesrefboundingclientrect
// @ts-ignore
queryInfo = uni.createSelectorQuery().in(null);
// #ifdef MP-ALIPAY
// @ts-ignore
queryInfo = uni.createSelectorQuery();
// #endif
queryInfo.select('.u-btn').boundingClientRect();
queryInfo.exec((data: any) => {
resolve(data);
});
});
}
// 下面为对接uniapp官方按钮开放能力事件回调的对接
/**
* open-type="getPhoneNumber"时有效
*/
function getphonenumber(event: any) {
emit('getphonenumber', event);
}
/**
* 用户点击该按钮时,会返回获取到的用户信息,从返回参数的detail中获取到的值同uni.getUserInfo
*/
function getuserinfo(event: any) {
emit('getuserinfo', event);
}
/**
* 当使用开放能力时,发生错误的回调
*/
function error(event: any) {
emit('error', event);
}
/**
* 在打开授权设置页并关闭后回调
*/
function opensetting(event: any) {
emit('opensetting', event);
}
/**
* 打开 APP 成功的回调
*/
function launchapp(event: any) {
emit('launchapp', event);
}
/**
* 支付宝小程序授权
* @param event
*/
function getAuthorize(event: any) {
if (props.scope === 'phoneNumber') {
getphonenumber(event);
} else if (props.scope === 'userInfo') {
getuserinfo(event);
}
}
/**
* 客服消息回调
* @param event
*/
function contact(event: any) {
emit('contact', event);
}
/**
* 头像选择回调
* @param event
*/
function chooseavatar(event: any) {
emit('chooseavatar', event);
}
/**
* 用户点击允许授权回调
* @param event
*/
function agreeprivacyauthorization(event: any) {
emit('agreeprivacyauthorization', event);
}
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-btn::after {
border: none;
}
.u-btn {
position: relative;
border: 0;
//border-radius: 10rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
// 避免边框某些场景可能被“裁剪”,不能设置为hidden
overflow: visible;
line-height: 1;
@include vue-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0 40rpx;
z-index: 1;
box-sizing: border-box;
transition: all 0.15s;
&--bold-border {
border: 1px solid var(--u-white-color);
}
&--default {
color: $u-content-color;
border-color: var(--u-light-color);
background-color: var(--u-bg-white);
}
&--primary {
color: var(--u-white-color);
border-color: $u-type-primary;
background-color: $u-type-primary;
}
&--success {
color: var(--u-white-color);
border-color: $u-type-success;
background-color: $u-type-success;
}
&--error {
color: var(--u-white-color);
border-color: $u-type-error;
background-color: $u-type-error;
}
&--warning {
color: var(--u-white-color);
border-color: $u-type-warning;
background-color: $u-type-warning;
}
&--default--disabled {
color: var(--u-white-color);
border-color: var(--u-border-color);
background-color: var(--u-bg-white);
}
&--primary--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-primary-disabled !important;
background-color: $u-type-primary-disabled !important;
}
&--success--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-success-disabled !important;
background-color: $u-type-success-disabled !important;
}
&--error--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-error-disabled !important;
background-color: $u-type-error-disabled !important;
}
&--warning--disabled {
color: var(--u-white-color) !important;
border-color: $u-type-warning-disabled !important;
background-color: $u-type-warning-disabled !important;
}
&--primary--plain {
color: $u-type-primary !important;
border-color: $u-type-primary-disabled !important;
background-color: $u-type-primary-light !important;
}
&--success--plain {
color: $u-type-success !important;
border-color: $u-type-success-disabled !important;
background-color: $u-type-success-light !important;
}
&--error--plain {
color: $u-type-error !important;
border-color: $u-type-error-disabled !important;
background-color: $u-type-error-light !important;
}
&--warning--plain {
color: $u-type-warning !important;
border-color: $u-type-warning-disabled !important;
background-color: $u-type-warning-light !important;
}
}
.u-hairline-border:after {
content: ' ';
position: absolute;
pointer-events: none;
// 设置为border-box,意味着下面的scale缩小为0.5,实际上缩小的是伪元素的内容(border-box意味着内容不含border)
box-sizing: border-box;
// 中心点作为变形(scale())的原点
-webkit-transform-origin: 0 0;
transform-origin: 0 0;
left: 0;
top: 0;
width: 199.8%;
height: 199.7%;
-webkit-transform: scale(0.5, 0.5);
transform: scale(0.5, 0.5);
border: 1px solid currentColor;
z-index: 1;
}
.u-wave-ripple {
z-index: 0;
position: absolute;
border-radius: 100%;
background-clip: padding-box;
pointer-events: none;
user-select: none;
transform: scale(0);
opacity: 1;
transform-origin: center;
}
.u-wave-ripple.u-wave-active {
opacity: 0;
transform: scale(2);
transition:
opacity 1s linear,
transform 0.4s linear;
}
.u-round-circle {
border-radius: 100rpx;
}
.u-round-circle::after {
border-radius: 100rpx;
}
.u-loading::after {
background-color: hsla(0, 0%, 100%, 0.35);
}
.u-size-large {
font-size: 36rpx;
height: 100rpx;
line-height: 100rpx;
}
.u-size-default {
font-size: 30rpx;
height: 80rpx;
line-height: 80rpx;
}
.u-size-medium {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 26rpx;
height: 70rpx;
line-height: 70rpx;
padding: 0 80rpx;
}
.u-size-small {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 24rpx;
height: 60rpx;
line-height: 60rpx;
padding: 0 30rpx;
}
.u-size-mini {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
width: auto;
font-size: 22rpx;
padding-top: 1px;
height: 50rpx;
line-height: 50rpx;
padding: 0 20rpx;
}
.u-primary-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-primary-dark !important;
}
.u-default-plain-hover {
color: $u-type-primary-dark !important;
background: $u-type-primary-light !important;
}
.u-success-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-success-dark !important;
}
.u-warning-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-warning-dark !important;
}
.u-error-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-error-dark !important;
}
.u-info-plain-hover {
color: var(--u-white-color) !important;
background: $u-type-info-dark !important;
}
.u-default-hover {
color: $u-type-primary-dark !important;
border-color: $u-type-primary-dark !important;
background-color: $u-type-primary-light !important;
}
.u-primary-hover {
background: $u-type-primary-dark !important;
color: var(--u-white-color);
}
.u-success-hover {
background: $u-type-success-dark !important;
color: var(--u-white-color);
}
.u-info-hover {
background: $u-type-info-dark !important;
color: var(--u-white-color);
}
.u-warning-hover {
background: $u-type-warning-dark !important;
color: var(--u-white-color);
}
.u-error-hover {
background: $u-type-error-dark !important;
color: var(--u-white-color);
}
</style>
@@ -0,0 +1,126 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { CalendarChangeDate, CalendarChangeRange, CalendarMode, ThemeType } from '../../types/global';
import { getColor } from '../../';
/**
* calendar 日历类型定义
* @description 供 u-calendar 组件 props 使用
*/
export const CalendarProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否开启底部安全区适配 */
safeAreaInsetBottom: { type: Boolean, default: false },
/** 是否允许通过点击遮罩关闭Picker */
maskCloseAble: { type: Boolean, default: true },
/** 通过双向绑定控制组件的弹出与收起 */
modelValue: { type: Boolean, default: false },
/** 弹出的z-index值 */
zIndex: { type: [String, Number], default: 0 },
/** 是否允许切换年份 */
changeYear: { type: Boolean, default: true },
/** 是否允许切换月份 */
changeMonth: { type: Boolean, default: true },
/** date-单个日期选择,range-开始日期+结束日期选择 */
mode: { type: String as PropType<CalendarMode>, default: 'date' },
/** 可切换的最大年份 */
maxYear: { type: [Number, String], default: 2050 },
/** 可切换的最小年份 */
minYear: { type: [Number, String], default: 1950 },
/** 最小可选日期(不在范围内日期禁用不可选) */
minDate: { type: [Number, String], default: '1950-01-01' },
/** 最大可选日期,默认最大值为今天,之后的日期不可选 */
maxDate: { type: [Number, String], default: '' },
/** 弹窗顶部左右两边的圆角值 */
borderRadius: { type: [String, Number], default: 20 },
/** 月份切换按钮箭头颜色 */
monthArrowColor: { type: String, default: 'var(--u-content-color)' },
/** 年份切换按钮箭头颜色 */
yearArrowColor: { type: String, default: 'var(--u-tips-color)' },
/** 默认日期字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 选中|起始结束日期背景色 */
activeBgColor: { type: String, default: () => getColor('primary') },
/** 选中|起始结束日期字体颜色 */
activeColor: { type: String, default: 'var(--u-white-color)' },
/** 范围内日期背景色 */
rangeBgColor: { type: String, default: 'var(--u-type-primary-light)' },
/** 范围内日期字体颜色 */
rangeColor: { type: String, default: () => getColor('primary') },
/** mode=range时生效,起始日期自定义文案 */
startText: { type: String, default: '' },
/** mode=range时生效,结束日期自定义文案 */
endText: { type: String, default: '' },
/** 按钮样式类型 */
btnType: { type: String as PropType<ThemeType>, default: 'primary' },
/** 当前选中日期带选中效果 */
isActiveCurrent: { type: Boolean, default: true },
/** 初始化时是否默认选中今天,优先级低于 defaultDate/startDate/endDate */
defaultSelectToday: { type: Boolean, default: true },
/** 切换年月是否触发事件 mode=date时生效 */
isChange: { type: Boolean, default: false },
/** 是否显示右上角的关闭图标 */
closeable: { type: Boolean, default: true },
/** 顶部的提示文字 */
toolTip: { type: String, default: '' },
/** 是否显示农历 */
showLunar: { type: Boolean, default: false },
/** 是否在页面中显示 */
isPage: { type: Boolean, default: false },
/** 默认选中的日期,mode=date时生效,格式:2024-01-01 */
defaultDate: { type: String, default: '' },
/** 默认选中的开始日期,mode=range时生效,格式:2024-01-01 */
startDate: { type: String, default: '' },
/** 默认选中的结束日期,mode=range时生效,格式:2024-01-01 */
endDate: { type: String, default: '' },
/** 是否只读,只读模式下禁止点击选择日期 */
readonly: { type: Boolean, default: false },
/** 已打卡日期列表,格式:['2024-01-01', '2024-01-02'] */
checkedDates: { type: Array as PropType<string[]>, default: () => [] },
/** 打卡日期背景色 */
checkedBgColor: { type: String, default: 'var(--u-type-warning)' },
/** 打卡日期字体颜色 */
checkedColor: { type: String, default: 'var(--u-white-color)' },
/** 今日是否已打卡 */
todayChecked: { type: Boolean, default: false },
/** 今日已打卡背景色 */
todayCheckedBgColor: { type: String, default: 'var(--u-type-success)' },
/** 未打卡日期背景色 */
uncheckedBgColor: { type: String, default: 'var(--u-light-color)' },
/** 未打卡日期字体颜色 */
uncheckedColor: { type: String, default: 'var(--u-white-color)' },
/** 是否启用打卡签到模式(未打卡日期显示灰色) */
checkinMode: { type: Boolean, default: false },
/** 节假日列表,格式:['2024-01-01', '2024-01-02'] */
holidays: { type: Array as PropType<string[]>, default: () => [] },
/** 节假日文字颜色 */
holidayColor: { type: String, default: 'var(--u-type-error)' },
/** 加班日列表,格式:['2024-01-06', '2024-01-07'] */
workdays: { type: Array as PropType<string[]>, default: () => [] },
/** 加班日文字颜色 */
workdayColor: { type: String, default: 'var(--u-type-primary)' },
/** 节日配置,格式:{ '2024-04-04': '清明节', '2024-04-01': '愚人节' } */
festivals: { type: Object as PropType<Record<string, string>>, default: () => ({}) },
/** 节日文字颜色 */
festivalColor: { type: String, default: 'var(--u-type-primary)' },
/** 是否显示内置节日(中国传统节日) */
showFestival: { type: Boolean, default: false },
/** 是否启用自定义日期内容插槽(微信小程序需要显式声明) */
useDateSlot: { type: Boolean, default: false }
};
export type CalendarProps = ExtractPropTypes<typeof CalendarProps>;
export type CalendarEmits = {
(e: 'update:modelValue', value: boolean): void;
(e: 'input', value: boolean): void;
(e: 'change', value: CalendarChangeDate | CalendarChangeRange): void;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-car-keyboard 车牌号键盘类型定义
* @description 供 u-car-keyboard 组件 props 使用
*/
export const CarKeyboardProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否打乱键盘按键的顺序 */
random: { type: Boolean, default: false }
};
export type CarKeyboardProps = ExtractPropTypes<typeof CarKeyboardProps>;
@@ -0,0 +1,263 @@
<template>
<view class="u-keyboard" :class="customClass" :style="$u.toStyle(customStyle)" @touchmove.stop.prevent="() => {}">
<view class="u-keyboard-grids">
<view>
<view class="u-keyboard-grids-item" v-for="(group, i) in abc ? EngKeyBoardList : areaList" :key="i">
<view
:hover-stay-time="100"
@tap="carInputClick(i, j)"
hover-class="u-carinput-hover"
class="u-keyboard-grids-btn"
v-for="(item, j) in group"
:key="j"
>
{{ item }}
</view>
</view>
<view
@touchstart="backspaceClick"
@touchend="clearTimer"
:hover-stay-time="100"
class="u-keyboard-back"
hover-class="u-hover-class"
>
<u-icon :size="38" name="backspace" :bold="true"></u-icon>
</view>
<view
:hover-stay-time="100"
class="u-keyboard-change"
hover-class="u-carinput-hover"
@tap="changeCarInputMode"
>
<text class="zh" :class="[!abc ? 'active' : 'inactive']">中</text>
/
<text class="en" :class="[abc ? 'active' : 'inactive']">英</text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-car-keyboard',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { $u } from '../..';
import { CarKeyboardProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* u-car-keyboard 车牌号键盘
* @description 车牌号输入专用键盘,支持省份简称和字母数字切换,支持按键顺序打乱。
* @property {Boolean} random 是否打乱键盘按键的顺序(默认false)
* @event {Function} change 按键被点击
* @event {Function} backspace 退格键被点击
*/
const props = defineProps(CarKeyboardProps);
const emit = defineEmits(['change', 'backspace']);
// 车牌输入时,abc=true为输入车牌号码,abc=false为输入省份中文简称
const abc = ref(false);
let timer: ReturnType<typeof setInterval> | null = null;
const areaList = computed(() => {
let data = [
'京',
'沪',
'粤',
'津',
'冀',
'豫',
'云',
'辽',
'黑',
'湘',
'皖',
'鲁',
'苏',
'浙',
'赣',
'鄂',
'桂',
'甘',
'晋',
'陕',
'蒙',
'吉',
'闽',
'贵',
'渝',
'川',
'青',
'琼',
'宁',
'挂',
'藏',
'港',
'澳',
'新',
'使',
'学'
];
let tmp: string[][] = [];
// 打乱顺序
if (props.random) {
data = $u.randomArray(data);
}
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
});
const EngKeyBoardList = computed(() => {
// prettier-ignore
let data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M'];
let tmp: (string | number)[][] = [];
if (props.random) {
data = $u.randomArray(data);
}
tmp[0] = data.slice(0, 10);
tmp[1] = data.slice(10, 20);
tmp[2] = data.slice(20, 30);
tmp[3] = data.slice(30, 36);
return tmp;
});
/**
* 点击键盘按钮
*/
function carInputClick(i: number, j: number) {
let value = '';
// 不同模式,获取不同数组的值
if (abc.value) value = String(EngKeyBoardList.value[i][j]);
else value = String(areaList.value[i][j]);
emit('change', value);
}
/**
* 修改汽车牌键盘的输入模式,中文|英文
*/
function changeCarInputMode() {
abc.value = !abc.value;
}
/**
* 点击退格键
*/
function backspaceClick() {
emit('backspace');
if (timer) clearInterval(timer);
timer = setInterval(() => {
emit('backspace');
}, 250);
}
function clearTimer() {
if (timer) clearInterval(timer);
timer = null;
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-keyboard-grids {
background: rgb(215, 215, 217);
padding: 24rpx 0;
position: relative;
}
.u-keyboard-grids-item {
@include vue-flex;
align-items: center;
justify-content: center;
}
.u-keyboard-grids-btn {
text-decoration: none;
width: 62rpx;
flex: 0 0 64rpx;
height: 80rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
font-size: 36rpx;
text-align: center;
line-height: 80rpx;
background-color: var(--u-bg-white);
margin: 8rpx 5rpx;
border-radius: 8rpx;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
font-weight: 500;
justify-content: center;
}
.u-carinput-hover {
background-color: rgb(185, 188, 195) !important;
}
.u-keyboard-back {
position: absolute;
width: 96rpx;
right: 22rpx;
bottom: 32rpx;
height: 80rpx;
background-color: rgb(185, 188, 195);
@include vue-flex;
align-items: center;
border-radius: 8rpx;
justify-content: center;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
}
.u-keyboard-change {
font-size: 24rpx;
box-shadow: 0 2rpx 0rpx var(--u-tips-color);
position: absolute;
width: 96rpx;
left: 22rpx;
line-height: 1;
bottom: 32rpx;
height: 80rpx;
background-color: var(--u-bg-white);
@include vue-flex;
align-items: center;
border-radius: 8rpx;
justify-content: center;
}
.u-keyboard-change .inactive.zh {
transform: scale(0.85) translateY(-10rpx);
}
.u-keyboard-change .inactive.en {
transform: scale(0.85) translateY(10rpx);
}
.u-keyboard-change .active {
color: rgb(237, 112, 64);
font-size: 30rpx;
}
.u-keyboard-change .zh {
transform: translateY(-10rpx);
}
.u-keyboard-change .en {
transform: translateY(10rpx);
}
</style>
@@ -0,0 +1,69 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* card 卡片类型定义
* @description 供 u-card 组件 props 使用
*/
export type CardIndex = string | number | Record<string, any>;
export type CardStyle = Record<string, any>;
export const CardProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 与屏幕两侧是否留空隙 */
full: { type: Boolean, default: false },
/** 标题 */
title: { type: String, default: '' },
/** 标题颜色 */
titleColor: { type: String, default: 'var(--u-main-color)' },
/** 标题字体大小,单位rpx */
titleSize: { type: [Number, String], default: '30' },
/** 副标题 */
subTitle: { type: String, default: '' },
/** 副标题颜色 */
subTitleColor: { type: String, default: 'var(--u-tips-color)' },
/** 副标题字体大小,单位rpx */
subTitleSize: { type: [Number, String], default: '26' },
/** 是否显示外部边框,只对full=false时有效(卡片与边框有空隙时) */
border: { type: Boolean, default: false },
/** 用于标识点击了第几个 */
index: { type: [String, Number, Object] as PropType<CardIndex>, default: '' },
/** 用于隔开上下左右的边距,带单位的写法,如:"30rpx 30rpx","20rpx 20rpx 30rpx 30rpx" */
margin: { type: String, default: '30rpx' },
/** card卡片的圆角 */
borderRadius: { type: [Number, String], default: '16' },
/** 头部自定义样式,对象形式 */
headStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 主体自定义样式,对象形式 */
bodyStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 底部自定义样式,对象形式 */
footStyle: { type: Object as PropType<CardStyle>, default: () => ({}) },
/** 头部是否下边框 */
headBorderBottom: { type: Boolean, default: true },
/** 底部是否有上边框 */
footBorderTop: { type: Boolean, default: true },
/** 标题左边的缩略图 */
thumb: { type: String, default: '' },
/** 缩略图宽高,单位rpx */
thumbWidth: { type: [String, Number], default: '60' },
/** 缩略图是否为圆形 */
thumbCircle: { type: Boolean, default: false },
/** 给head,body,foot的内边距 */
padding: { type: [String, Number], default: '30' },
/** 是否显示头部 */
showHead: { type: Boolean, default: true },
/** 是否显示尾部 */
showFoot: { type: Boolean, default: true },
/** 卡片外围阴影,字符串形式 */
boxShadow: { type: String, default: 'none' }
};
export type CardProps = ExtractPropTypes<typeof CardProps>;
@@ -0,0 +1,210 @@
<template>
<view
class="u-card"
@tap.stop="onClick"
:class="[{ 'u-border': border, 'u-card-full': full, 'u-card--border': Number(borderRadius) > 0 }, customClass]"
:style="
$u.toStyle(
{
borderRadius: borderRadius + 'rpx',
margin: margin,
boxShadow: boxShadow
},
customStyle
)
"
>
<view
v-if="showHead"
class="u-card__head"
:style="[{ padding: padding + 'rpx' }, headStyle]"
:class="{
'u-border-bottom': headBorderBottom
}"
@tap="onHeadClick"
>
<view v-if="!slots.head" class="u-flex u-row-between">
<view class="u-card__head--left u-flex u-line-1" v-if="title">
<image
:src="thumb"
class="u-card__head--left__thumb"
mode="aspectFill"
v-if="thumb"
:style="{
height: thumbWidth + 'rpx',
width: thumbWidth + 'rpx',
borderRadius: thumbCircle ? '100rpx' : '6rpx'
}"
></image>
<text
class="u-card__head--left__title u-line-1"
:style="{
fontSize: titleSize + 'rpx',
color: titleColor
}"
>
{{ title }}
</text>
</view>
<view class="u-card__head--right u-line-1" v-if="subTitle">
<text
class="u-card__head__title__text"
:style="{
fontSize: subTitleSize + 'rpx',
color: subTitleColor
}"
>
{{ subTitle }}
</text>
</view>
</view>
<slot name="head" v-else />
</view>
<view @tap="onBodyClick" class="u-card__body" :style="[{ padding: padding + 'rpx' }, bodyStyle]">
<slot />
<slot name="body" />
</view>
<view
v-if="showFoot"
class="u-card__foot"
@tap="onFootClick"
:style="[{ padding: slots.foot ? padding + 'rpx' : 0 }, footStyle]"
:class="{
'u-border-top': footBorderTop
}"
>
<slot name="foot" />
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-card',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { useSlots } from 'vue';
import { CardProps } from './types';
import { $u } from '../..';
/**
* card 卡片
* @description 卡片组件一般用于多个列表条目,且风格统一的场景
* @tutorial https://uviewpro.cn/zh/components/card.html
* @property {Boolean} full 卡片与屏幕两侧是否留空隙(默认false)
* @property {String} title 头部左边的标题
* @property {String} title-color 标题颜色(默认var(--u-main-color))
* @property {String | Number} title-size 标题字体大小,单位rpx(默认30)
* @property {String} sub-title 头部右边的副标题
* @property {String} sub-title-color 副标题颜色(默认var(--u-tips-color))
* @property {String | Number} sub-title-size 副标题字体大小(默认26)
* @property {Boolean} border 是否显示边框(默认true)
* @property {String | Number} index 用于标识点击了第几个卡片
* @property {String} box-shadow 卡片外围阴影,字符串形式(默认none)
* @property {String} margin 卡片与屏幕两边和上下元素的间距,需带单位,如"30rpx 20rpx"(默认30rpx)
* @property {String | Number} border-radius 卡片整体的圆角值,单位rpx(默认16)
* @property {Object} head-style 头部自定义样式,对象形式
* @property {Object} body-style 中部自定义样式,对象形式
* @property {Object} foot-style 底部自定义样式,对象形式
* @property {Boolean} head-border-bottom 是否显示头部的下边框(默认true)
* @property {Boolean} foot-border-top 是否显示底部的上边框(默认true)
* @property {Boolean} show-head 是否显示头部(默认true)
* @property {Boolean} show-foot 是否显示尾部(默认true)
* @property {String} thumb 缩略图路径,如设置将显示在标题的左边,不建议使用相对路径
* @property {String | Number} thumb-width 缩略图的宽度,高等于宽,单位rpx(默认60)
* @property {Boolean} thumb-circle 缩略图是否为圆形(默认false)
* @event {Function} click 整个卡片任意位置被点击时触发
* @event {Function} head-click 卡片头部被点击时触发
* @event {Function} body-click 卡片主体部分被点击时触发
* @event {Function} foot-click 卡片底部部分被点击时触发
* @example <u-card padding="30" title="card"></u-card>
*/
const props = defineProps(CardProps);
const emit = defineEmits(['click', 'head-click', 'body-click', 'foot-click']);
const slots = useSlots();
/**
* 整个卡片任意位置被点击时触发
*/
function onClick() {
emit('click', props.index);
}
/**
* 卡片头部被点击时触发
*/
function onHeadClick() {
emit('head-click', props.index);
}
/**
* 卡片主体部分被点击时触发
*/
function onBodyClick() {
emit('body-click', props.index);
}
/**
* 卡片底部部分被点击时触发
*/
function onFootClick() {
emit('foot-click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-card {
position: relative;
overflow: hidden;
font-size: 28rpx;
background-color: var(--u-bg-white);
box-sizing: border-box;
&-full {
// 如果是与屏幕之间不留空隙,应该设置左右边距为0
margin-left: 0 !important;
margin-right: 0 !important;
width: 100%;
}
&--border:after {
border-radius: 16rpx;
}
&__head {
&--left {
color: $u-main-color;
&__thumb {
margin-right: 16rpx;
}
&__title {
max-width: 400rpx;
}
}
&--right {
color: $u-tips-color;
margin-left: 6rpx;
}
}
&__body {
color: $u-content-color;
}
&__foot {
color: $u-tips-color;
}
}
</style>
@@ -0,0 +1,27 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* cell-group 组件 props 类型定义
* @description 供 u-cell-group 组件 props 使用
*/
export const CellGroupProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 分组标题 */
title: { type: String, default: '' },
/** 是否显示分组list上下边框 */
border: { type: Boolean, default: true },
/** 分组标题的样式,对象形式,注意驼峰属性写法 */
titleStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) }
};
export type CellGroupProps = ExtractPropTypes<typeof CellGroupProps>;
@@ -0,0 +1,60 @@
<template>
<view class="u-cell-box" :class="customClass" :style="$u.toStyle(customStyle)">
<view class="u-cell-title" v-if="title" :style="[titleStyle]">
{{ title }}
</view>
<view class="u-cell-item-box" :class="{ 'u-border-bottom u-border-top': border }">
<slot />
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-cell-group',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { CellGroupProps } from './types';
import { $u } from '../..';
/**
* cellGroup 单元格父组件Group
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-item
* @tutorial https://uviewpro.cn/zh/components/cell.html
* @property {String} title 分组标题
* @property {Boolean} border 是否显示外边框(默认true)
* @property {Object} title-style 分组标题的的样式,对象形式,如{'font-size': '24rpx'} 或 {'fontSize': '24rpx'}
* @example <u-cell-group title="设置喜好">
*/
defineProps(CellGroupProps);
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-cell-box {
width: 100%;
}
.u-cell-title {
padding: 30rpx 32rpx 10rpx 32rpx;
font-size: 30rpx;
text-align: left;
color: $u-tips-color;
}
.u-cell-item-box {
background-color: var(--u-bg-white);
flex-direction: row;
}
</style>
@@ -0,0 +1,64 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { CellItemArrowDirection } from '../../types/global';
/**
* cell-item 组件 props 类型定义
* @description 供 u-cell-item 组件 props 使用
*/
export type CellItemIndex = string | number;
export type CellItemStyle = Record<string, any>;
export const CellItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 左侧图标名称(只能uView内置图标),或者图标src */
icon: { type: String, default: '' },
/** 左侧标题 */
title: { type: [String, Number], default: '' },
/** 右侧内容 */
value: { type: [String, Number], default: '' },
/** 标题下方的描述信息 */
label: { type: [String, Number], default: '' },
/** 是否显示下边框 */
borderBottom: { type: Boolean, default: true },
/** 是否显示上边框 */
borderTop: { type: Boolean, default: false },
/** 是否开启点击反馈,即点击时cell背景为灰色,none为无效果 */
hoverClass: { type: String, default: 'u-cell-hover' },
/** 是否显示右侧箭头 */
arrow: { type: Boolean, default: true },
/** 内容是否垂直居中 */
center: { type: Boolean, default: false },
/** 是否显示左边表示必填的星号 */
required: { type: Boolean, default: false },
/** 标题的宽度,单位rpx */
titleWidth: { type: [Number, String], default: '' },
/** 右侧箭头方向,可选值:right|up|down,默认为right */
arrowDirection: { type: String as PropType<CellItemArrowDirection>, default: 'right' },
/** 控制标题的样式 */
titleStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 右侧显示内容的样式 */
valueStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 描述信息的样式 */
labelStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) },
/** 背景颜色 */
bgColor: { type: String, default: 'transparent' },
/** 用于识别被点击的是第几个cell */
index: { type: [String, Number] as PropType<CellItemIndex>, default: '' },
/** 是否使用label插槽 */
useLabelSlot: { type: Boolean, default: false },
/** 左边图标的大小,单位rpx,只对传入icon字段时有效 */
iconSize: { type: [Number, String], default: 34 },
/** 左边图标的样式,对象形式 */
iconStyle: { type: Object as PropType<CellItemStyle>, default: () => ({}) }
};
export type CellItemProps = ExtractPropTypes<typeof CellItemProps>;
@@ -0,0 +1,221 @@
<template>
<view
@tap="onClick"
class="u-cell"
:class="[
{
'u-border-bottom': borderBottom,
'u-border-top': borderTop,
'u-col-center': center,
'u-cell--required': required
},
customClass
]"
hover-stay-time="150"
:hover-class="hoverClass"
:style="$u.toStyle({ backgroundColor: bgColor }, customStyle)"
>
<view v-if="icon" class="u-cell__left-icon-wrap">
<u-icon :size="iconSize" :name="icon" :custom-style="iconStyle"></u-icon>
</view>
<view class="u-flex" v-else>
<slot name="icon"></slot>
</view>
<view class="u-cell_title" :style="[{ width: titleWidth ? titleWidth + 'rpx' : 'auto' }, titleStyle]">
<template v-if="title !== ''">{{ title }}</template>
<slot name="title" v-else></slot>
<view class="u-cell__label" v-if="label || $slots.label" :style="[labelStyle]">
<template v-if="label !== ''">{{ label }}</template>
<slot name="label" v-else></slot>
</view>
</view>
<view class="u-cell__value" :style="[valueStyle]">
<template v-if="value !== ''">{{ value }}</template>
<slot v-else></slot>
</view>
<view class="u-flex u-cell_right" v-if="$slots['right-icon']">
<slot name="right-icon"></slot>
</view>
<view v-if="arrow" class="u-icon-wrap u-cell__right-icon-wrap">
<u-icon name="arrow-right" :style="[arrowStyle]"></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-cell-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, useSlots } from 'vue';
import { CellItemProps } from './types';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
/**
* cellItem 单元格Item
* @description cell单元格一般用于一组列表的情况,比如个人中心页,设置页等。搭配u-cell-group使用
* @tutorial https://uviewpro.cn/zh/components/cell.html
* @property {String} title 左侧标题
* @property {String} icon 左侧图标名,只支持uView内置图标,见Icon 图标
* @property {Object} icon-style 左边图标的样式,对象形式
* @property {String} value 右侧内容
* @property {String} label 标题下方的描述信息
* @property {Boolean} border-bottom 是否显示cell的下边框(默认true)
* @property {Boolean} border-top 是否显示cell的上边框(默认false)
* @property {Boolean} center 是否使内容垂直居中(默认false)
* @property {String} hover-class 是否开启点击反馈,none为无效果(默认true)
* // @property {Boolean} border-gap border-bottom为true时,Cell列表中间的条目的下边框是否与左边有一个间隔(默认true)
* @property {Boolean} arrow 是否显示右侧箭头(默认true)
* @property {Boolean} required 箭头方向,可选值(默认right)
* @property {Boolean} arrow-direction 是否显示左边表示必填的星号(默认false)
* @property {Object} title-style 标题样式,对象形式
* @property {Object} value-style 右侧内容样式,对象形式
* @property {Object} label-style 标题下方描述信息的样式,对象形式
* @property {String} bg-color 背景颜色(默认transparent)
* @property {String|Number} index 用于在click事件回调中返回,标识当前是第几个Item
* @property {String|Number} title-width 标题的宽度,单位rpx
* @example <u-cell-item icon="integral-fill" title="会员等级" value="新版本"></u-cell-item>
*/
const emit = defineEmits<{ (e: 'click', index: string | number): void }>();
const props = defineProps(CellItemProps);
const $slots = useSlots();
/**
* 箭头样式
*/
const arrowStyle = computed(() => {
let style: Record<string, any> = {};
if (props.arrowDirection === 'up') style.transform = 'rotate(-90deg)';
else if (props.arrowDirection === 'down') style.transform = 'rotate(90deg)';
else style.transform = 'rotate(0deg)';
return style;
});
/**
* 点击事件
*/
function onClick() {
emit('click', props.index);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-cell {
@include vue-flex;
align-items: center;
position: relative;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
width: 100%;
padding: 26rpx 32rpx;
font-size: 28rpx;
line-height: 54rpx;
color: $u-content-color;
background-color: var(--u-bg-white);
text-align: left;
}
.u-cell_title {
font-size: 28rpx;
}
.u-cell__left-icon-wrap {
margin-right: 10rpx;
font-size: 32rpx;
}
.u-cell__right-icon-wrap {
margin-left: 10rpx;
color: var(--u-tips-color);
font-size: 28rpx;
}
.u-cell__left-icon-wrap,
.u-cell__right-icon-wrap {
@include vue-flex;
align-items: center;
height: 48rpx;
}
.u-cell-border:after {
position: absolute;
/* #ifndef APP-NVUE */
box-sizing: border-box;
content: ' ';
pointer-events: none;
border-bottom: 1px solid $u-border-color;
/* #endif */
right: 0;
left: 0;
top: 0;
transform: scaleY(0.5);
}
.u-cell-border {
position: relative;
}
.u-cell__label {
margin-top: 6rpx;
font-size: 26rpx;
line-height: 36rpx;
color: $u-tips-color;
/* #ifndef APP-NVUE */
word-wrap: break-word;
/* #endif */
}
.u-cell__value {
overflow: hidden;
text-align: right;
/* #ifndef APP-NVUE */
vertical-align: middle;
/* #endif */
color: $u-tips-color;
font-size: 26rpx;
}
.u-cell__title,
.u-cell__value {
flex: 1;
}
.u-cell--required {
/* #ifndef APP-NVUE */
overflow: visible;
/* #endif */
@include vue-flex;
align-items: center;
}
.u-cell--required:before {
position: absolute;
/* #ifndef APP-NVUE */
content: '*';
/* #endif */
left: 8px;
margin-top: 4rpx;
font-size: 14px;
color: $u-type-error;
}
.u-cell_right {
line-height: 1;
}
</style>
@@ -0,0 +1,45 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape } from '../../types/global';
import { getColor } from '../../';
/**
* checkbox-group 复选框组类型定义
* @description 供 u-checkbox-group 组件 props 使用
*/
export const CheckboxGroupProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 绑定值,选中的复选框name组成的数组 */
modelValue: { type: Array as PropType<(string | number)[]>, default: undefined },
/** 最多能选中多少个checkbox */
max: { type: Number, default: 999 },
/** 是否禁用所有复选框 */
disabled: { type: Boolean, default: false },
/** 在表单内提交时的标识符 */
name: { type: [Boolean, String], default: '' },
/** 是否禁止点击提示语选中复选框 */
labelDisabled: { type: Boolean, default: false },
/** 形状,square为方形,circle为原型 */
shape: { type: String as PropType<Shape>, default: 'square' },
/** 选中状态下的颜色 */
activeColor: { type: String, default: () => getColor('primary') },
/** 组件的整体大小,单位rpx,默认34rpx */
size: { type: [String, Number], default: '' },
/** 每个checkbox占u-checkbox-group的宽度 */
width: { type: String, default: 'auto' },
/** 是否每个checkbox都换行 */
wrap: { type: Boolean, default: false },
/** 图标的大小,单位rpx,默认20rpx */
iconSize: { type: [String, Number], default: '' }
};
export type CheckboxGroupProps = ExtractPropTypes<typeof CheckboxGroupProps>;
@@ -0,0 +1,172 @@
<template>
<view class="u-checkbox-group u-clearfix" :class="customClass" :style="$u.toStyle(customStyle)">
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-checkbox-group',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, watch, nextTick, onMounted } from 'vue';
import { $u, useParent, useChildren, useDebounce } from '../..';
import { CheckboxGroupProps } from './types';
/**
* checkboxGroup 开关选择器父组件Group
* @description 复选框组件一般用于需要多个选择的场景,该组件功能完整,使用方便
* @tutorial https://uviewpro.cn/zh/components/checkbox.html
* @property {Array} modelValue 绑定值,选中的复选框name组成的数组(支持v-model双向绑定)
* @property {String Number} max 最多能选中多少个checkbox(默认999)
* @property {String Number} size 组件整体的大小,单位rpx(默认40)
* @property {Boolean} disabled 是否禁用所有checkbox(默认false)
* @property {String Number} icon-size 图标大小,单位rpx(默认20)
* @property {Boolean} label-disabled 是否禁止点击文本操作checkbox(默认false)
* @property {String} width 宽度,需带单位
* @property {String} shape 外观形状,shape-方形,circle-圆形(默认circle)
* @property {Boolean} wrap 是否每个checkbox都换行(默认false)
* @property {String} active-color 选中时的颜色,应用到所有子Checkbox组件(默认主题色primary)
* @event {Function} change 任一个checkbox状态发生变化时触发,回调为选中的name数组
* @example <u-checkbox-group v-model="selectedValues">
* <u-checkbox name="apple">苹果</u-checkbox>
* <u-checkbox name="banana">香蕉</u-checkbox>
* </u-checkbox-group>
*/
const props = defineProps(CheckboxGroupProps);
const emit = defineEmits(['update:modelValue', 'change']);
// 使用父组件Hook
const { children, broadcast } = useParent('u-checkbox-group');
const { emitToParent } = useChildren('u-checkbox-group', 'u-form-item');
const { debounce } = useDebounce(1);
/**
* 根据modelValue设置子组件状态
*/
function syncChildrenSelection() {
if (!children || children.length === 0 || !props.modelValue) return;
const modelValueSet = new Set(props.modelValue);
children.forEach((child: any) => {
const childValue = child.getExposed?.()?.value;
const shouldBeChecked = modelValueSet.has(childValue);
const isCurrentlyChecked = child.getExposed?.()?.isChecked.value;
if (shouldBeChecked !== isCurrentlyChecked) {
child.getExposed?.()?.setChecked({ checked: shouldBeChecked });
}
});
}
/**
* 监听modelValue变化,同步子组件状态
*/
watch(
() => props.modelValue,
() => {
syncChildrenSelection();
}
);
/**
* 派发 change 事件和表单校验
*/
function emitEvent() {
debounce(() => {
// 收集所有选中的 name
let values: any[] = [];
children.forEach((child: any) => {
if (child.getExposed?.()?.isChecked.value) {
values.push(child.getExposed?.()?.value);
}
});
emit('change', values);
emit('update:modelValue', values);
setTimeout(() => {
emitToParent('onFormChange', values);
}, 60);
});
}
/**
* 全选/全不选方法
*/
function setAllChecked(checked: boolean) {
if (props.disabled) {
console.warn('u-checkbox-group已禁用,无法操作');
return;
}
broadcast('setChecked', { checked });
}
/**
* 获取选中的值
*/
function getSelectedValues() {
return children
.filter(child => child.getExposed?.()?.isChecked.value)
.map(child => child.getExposed?.()?.name)
.filter(Boolean);
}
/**
* 验证选择是否超过最大数量
*/
function validateSelection() {
const selectedCount = children.filter(child => child.getExposed?.()?.isChecked.value).length;
if (props.max && selectedCount >= props.max) {
$u.toast(`超过最大选择数量: ${props.max}`);
return false;
}
return true;
}
onMounted(() => {
nextTick(() => {
syncChildrenSelection();
});
});
// 使用defineExpose暴露给外部
defineExpose({
// props
props,
// 方法
emitEvent,
setAllChecked,
getSelectedValues,
validateSelection,
syncChildrenSelection,
// 计算属性
selectedCount: computed(() => children.filter(child => child.getExposed?.()?.isChecked.value).length),
isFull: computed(() => {
const selectedCount = children.filter(child => child.getExposed?.()?.isChecked.value).length;
return props.max && selectedCount >= props.max;
}),
isEmpty: computed(() => children.filter(child => child.getExposed?.()?.isChecked.value).length === 0),
// 工具方法
getChildrenCount: () => children.length
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-checkbox-group {
/* #ifndef MP || APP-NVUE */
display: inline-flex;
flex-wrap: wrap;
/* #endif */
}
</style>
@@ -0,0 +1,45 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Shape, SizeType } from '../../types/global';
/**
* checkbox 复选框类型定义
* @description 供 u-checkbox 组件 props 使用
*/
export type CheckboxValue = string | number | boolean;
export const CheckboxProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** checkbox的标签 */
label: { type: String, default: '' },
/** checkbox的值 */
name: { type: [String, Number], default: '' },
/** checkbox的值,不传递时为name的属性 */
value: { type: [String, Number], default: '' },
/** 形状,square为方形,circle为原型 */
shape: { type: String as PropType<Shape>, default: '' },
/** 是否为选中状态 */
modelValue: { type: Boolean, default: false },
/** 是否禁用 */
disabled: { type: [String, Boolean], default: '' },
/** 是否禁止点击提示语选中复选框 */
labelDisabled: { type: [String, Boolean], default: '' },
/** 选中状态下的颜色,如设置此值,将会覆盖checkboxGroup的activeColor值 */
activeColor: { type: String, default: '' },
/** 图标的大小,单位rpx */
iconSize: { type: [String, Number], default: '' },
/** label的字体大小,rpx单位 */
labelSize: { type: [String, Number], default: '' },
/** 组件的整体大小 */
size: { type: [String, Number] as PropType<SizeType | string | number>, default: '' }
};
export type CheckboxProps = ExtractPropTypes<typeof CheckboxProps>;
@@ -0,0 +1,363 @@
<template>
<view class="u-checkbox" :style="$u.toStyle(checkboxStyle, customStyle)" :class="customClass">
<view class="u-checkbox__icon-wrap" @tap="toggle" :class="iconClass" :style="$u.toStyle(iconStyle)">
<u-icon name="checkbox-mark" :size="checkboxIconSize" :color="iconColor" />
</view>
<view
class="u-checkbox__label"
@tap="onClickLabel"
:style="{
fontSize: labelFontSize
}"
>
<slot>
{{ label }}
</slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-checkbox',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { $u, useChildren } from '../..';
import { CheckboxProps } from './types';
import type { SizeType } from '../../types/global';
import uIcon from '../u-icon/u-icon.vue';
/**
* checkbox 复选框
* @description 该组件需要搭配checkboxGroup组件使用,以便用户进行操作时,获得当前复选框组的选中情况。
* @tutorial https://uviewpro.cn/zh/components/checkbox.html
* @property {String Number} icon-size 图标大小,单位rpx(默认20)
* @property {String Number} label-size label字体大小,单位rpx(默认28)
* @property {String Number} name checkbox组件的标示符
* @property {String} shape 形状,见官网说明(默认circle)
* @property {Boolean} disabled 是否禁用
* @property {Boolean} label-disabled 是否禁止点击文本操作checkbox
* @property {String} active-color 选中时的颜色,如设置CheckboxGroup的active-color将失效
* @event {Function} change 某个checkbox状态发生变化时触发,回调为一个对象
* @example <u-checkbox v-model="checked" :disabled="false">天涯</u-checkbox>
*/
const props = defineProps(CheckboxProps);
const emit = defineEmits(['change', 'update:modelValue']);
// checkbox 是否选中,true/false
const checkedValue = ref(props.modelValue);
// 使用子组件Hook
const { parentExposed } = useChildren('u-checkbox', 'u-checkbox-group');
const { parentExposed: formExposed } = useChildren('u-checkbox', 'u-form');
// checkbox 的value值,id
const checkboxValue = computed(() => {
if (props.value !== '') return props.value;
return props.name;
});
watch(
() => props.modelValue,
(newVal: boolean) => {
checkedValue.value = newVal;
}
);
watch(
() => checkedValue.value,
(newVal: boolean) => {
emit('update:modelValue', newVal);
},
{ immediate: true }
);
// 根据 size 定义不同的配置
const sizeConfig = {
small: {
size: 28,
fontSize: 24,
iconSize: 16
},
default: {
size: 34,
fontSize: 28,
iconSize: 20
},
large: {
size: 40,
fontSize: 32,
iconSize: 24
}
};
// 获取实际使用的 size 值(优先级:props.size > u-form.size)
const actualSize = computed(() => {
// 优先使用 props 的 size 属性
if (props.size !== '') {
return String(props.size);
}
// 次优先:使用 u-checkbox-group 的 size 属性
if (parentExposed.value?.props?.size) {
return String(parentExposed.value.props.size);
}
// 最后:使用 u-form 的 size 属性(u-form 的 size 只支持预设值)
if (formExposed.value?.props?.size) {
return String(formExposed.value.props.size);
}
// 默认值
return 'default';
});
// 判断实际使用的 size 是否在预设配置中
const isInSizeConfig = computed(() => actualSize.value in sizeConfig);
// 获取预设 size(用于查找 sizeConfig 配置,如图标大小、高度等)
const presetSize = computed(() => {
return (isInSizeConfig.value ? actualSize.value : 'default') as SizeType;
});
// 获取当前尺寸配置
const currentSizeConfig = computed(() => sizeConfig[presetSize.value]);
// 是否禁用,如果父组件u-checkbox-group禁用的话,将会忽略子组件的配置
const isDisabled = computed(() => {
return props.disabled !== '' ? props.disabled : (parentExposed.value?.props?.disabled ?? false);
});
// 是否禁用label点击
const isLabelDisabled = computed(() => {
return props.labelDisabled !== '' ? props.labelDisabled : (parentExposed.value?.props?.labelDisabled ?? false);
});
// 组件尺寸,对应size的值,默认值为34rpx
const checkboxSize = computed(() => currentSizeConfig.value.size);
// 组件的勾选图标的尺寸,默认20rpx
const checkboxIconSize = computed(() => {
if (props.iconSize) {
return props.iconSize;
}
if (parentExposed.value?.props?.iconSize) {
return parentExposed.value?.props?.iconSize;
}
if (isInSizeConfig.value) {
return currentSizeConfig.value.iconSize;
}
return 20;
});
// label字体大小,默认28rpx
const labelFontSize = computed(() => {
if (isInSizeConfig.value) {
return $u.addUnit(currentSizeConfig.value.fontSize);
}
return $u.addUnit(props.labelSize);
});
// 组件选中激活时的颜色
const elActiveColor = computed(() => {
return props.activeColor ? props.activeColor : (parentExposed.value?.props?.activeColor ?? 'primary');
});
// 组件的形状
const elShape = computed(() => {
return props.shape ? props.shape : (parentExposed.value?.props?.shape ?? 'square');
});
// 图标样式
const iconStyle = computed(() => {
let style: Record<string, string> = {};
if (elActiveColor.value && checkedValue.value && !isDisabled.value) {
style.borderColor = elActiveColor.value;
style.backgroundColor = elActiveColor.value;
}
style.width = $u.addUnit(checkboxSize.value);
style.height = $u.addUnit(checkboxSize.value);
return style;
});
// checkbox内部的勾选图标,如果选中状态,为白色,否则为透明色即可
const iconColor = computed(() => {
return checkedValue.value ? 'var(--u-white-color)' : 'transparent';
});
const iconClass = computed(() => {
let classes: string[] = [];
classes.push('u-checkbox__icon-wrap--' + elShape.value);
if (checkedValue.value == true) classes.push('u-checkbox__icon-wrap--checked');
if (isDisabled.value) classes.push('u-checkbox__icon-wrap--disabled');
if (checkedValue.value && isDisabled.value) classes.push('u-checkbox__icon-wrap--disabled--checked');
return classes.join(' ');
});
const checkboxStyle = computed(() => {
let style: Record<string, string> = {};
if (parentExposed.value?.props?.width) {
style.width = parentExposed.value.props.width;
// #ifdef MP
style.float = 'left';
// #endif
// #ifndef MP
style.flex = `0 0 ${parentExposed.value.props.width}`;
// #endif
}
if (parentExposed.value?.props?.wrap) {
style.width = '100%';
// #ifndef MP
style.flex = '0 0 100%';
// #endif
}
return style;
});
/**
* 点击label
*/
function onClickLabel() {
if (!isLabelDisabled.value && !isDisabled.value) {
setValue();
}
}
/**
* 点击icon
*/
function toggle() {
if (!isDisabled.value) {
setValue();
}
}
/**
* 触发change事件
*/
function emitEvent() {
emit('change', !checkedValue.value);
// 通知父组件状态变化
setTimeout(() => {
if (parentExposed.value?.emitEvent) {
parentExposed.value.emitEvent();
}
}, 80);
}
/**
* 设置通过v-model绑定的组件的值
*/
function setValue() {
// 判断是否超过了可选的最大数量
if (checkedValue.value == true) {
emitEvent();
checkedValue.value = false;
} else {
if (parentExposed?.value?.validateSelection && !parentExposed?.value?.validateSelection()) {
return;
}
emitEvent();
checkedValue.value = true;
}
}
// 设置组件的modelValue值
function setChecked(data: any) {
if (!isDisabled.value) {
const needEmit = checkedValue.value !== data.checked;
checkedValue.value = data.checked;
if (needEmit) {
emitEvent();
}
}
}
defineExpose({
isChecked: computed(() => checkedValue.value),
label: props.label,
name: props.name,
value: checkboxValue.value,
setValue,
emitEvent,
props,
setChecked
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-checkbox {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
overflow: hidden;
user-select: none;
line-height: 1.8;
&__icon-wrap {
color: $u-content-color;
flex: none;
display: -webkit-flex;
@include vue-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 42rpx;
height: 42rpx;
color: transparent;
text-align: center;
transition-property: color, border-color, background-color;
font-size: 20px;
border: 1px solid var(--u-border-color);
transition-duration: 0.2s;
/* #ifdef MP-TOUTIAO */
// 头条小程序兼容性问题,需要设置行高为0,否则图标偏下
line-height: 0;
/* #endif */
&--circle {
border-radius: 100%;
}
&--square {
border-radius: 6rpx;
}
&--checked {
color: var(--u-white-color);
background-color: $u-type-primary;
border-color: $u-type-primary;
}
&--disabled {
background-color: var(--u-bg-gray-light);
border-color: var(--u-border-color);
}
&--disabled--checked {
color: var(--u-bg-gray-light) !important;
}
}
&__label {
word-wrap: break-word;
margin-left: 10rpx;
margin-right: 24rpx;
color: $u-content-color;
font-size: 30rpx;
&--disabled {
color: var(--u-bg-gray-light);
}
}
}
</style>
@@ -0,0 +1,63 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
import { $u } from '../../libs';
/**
* circleProgress 环形进度条 Props
* @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度条。注意:此组件的percent值只能动态增加,不能动态减少。
*/
export const CircleProgressProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 圆环进度百分比值 */
percent: {
type: Number,
default: 0,
validator: (val: number) => val >= 0 && val <= 100
},
/** 底部圆环的颜色(灰色的圆环) */
inactiveColor: {
type: String,
default: () => $u.getColor('dividerColor')
},
/** 圆环激活部分的颜色 */
activeColor: {
type: String,
default: 'var(--u-type-success)'
},
/** 圆环线条的宽度,单位rpx */
borderWidth: {
type: [Number, String] as PropType<number | string>,
default: 14
},
/** 整个圆形的宽度,单位rpx */
width: {
type: [Number, String] as PropType<number | string>,
default: 200
},
/** 整个圆环执行一圈的时间,单位ms */
duration: {
type: [Number, String] as PropType<number | string>,
default: 1500
},
/** 主题类型 */
type: {
type: String as PropType<ThemeType>,
default: ''
},
/** 整个圆环进度区域的背景色 */
bgColor: {
type: String,
default: 'var(--u-bg-white)'
}
};
export type CircleProgressProps = ExtractPropTypes<typeof CircleProgressProps>;
@@ -0,0 +1,299 @@
<template>
<view
class="u-circle-progress"
:class="customClass"
:style="
$u.toStyle(
{
width: widthPx + 'px',
height: widthPx + 'px',
backgroundColor: bgColor
},
customStyle
)
"
>
<!-- 支付宝小程序不支持canvas-id属性,必须用id属性 -->
<!-- #ifdef MP-WEIXIN || MP-TOUTIAO -->
<canvas
class="u-canvas-bg"
type="2d"
:canvas-id="elBgId"
:id="elBgId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<canvas
class="u-canvas"
type="2d"
:canvas-id="elId"
:id="elId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN || MP-TOUTIAO -->
<canvas
class="u-canvas-bg"
:canvas-id="elBgId"
:id="elBgId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<canvas
class="u-canvas"
:canvas-id="elId"
:id="elId"
:style="{
width: widthPx + 'px',
height: widthPx + 'px'
}"
></canvas>
<!-- #endif -->
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-circle-progress',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, getCurrentInstance, onBeforeMount } from 'vue';
import { $u } from '../..';
import { CircleProgressProps } from './types';
// #ifdef MP-WEIXIN || MP-TOUTIAO
import { canvas2d } from '../../libs/util/canvas-2d';
// #endif
/**
* circleProgress 环形进度条
* @description 展示操作或任务的当前进度,比如上传文件,是一个圆形的进度条。注意:此组件的percent值只能动态增加,不能动态减少。
* @tutorial https://uviewpro.cn/zh/components/circleProgress.html
* @property {String Number} percent 圆环进度百分比值,为数值类型,0-100
* @property {String} inactive-color 圆环的底色,默认为灰色(该值无法动态变更)(默认var(--u-divider-color))
* @property {String} active-color 圆环激活部分的颜色(该值无法动态变更)(默认var(--u-type-success))
* @property {String Number} width 整个圆环组件的宽度,高度默认等于宽度值,单位rpx(默认200)
* @property {String Number} border-width 圆环的边框宽度,单位rpx(默认14)
* @property {String Number} duration 整个圆环执行一圈的时间,单位ms(默认呢1500)
* @property {String} type 如设置,active-color值将会失效
* @property {String} bg-color 整个组件背景颜色,默认为白色
* @example <u-circle-progress active-color="var(--u-type-primary)" :percent="80"></u-circle-progress>
*/
const props = defineProps(CircleProgressProps);
let elBgId = $u.guid(); // 非微信端的时候,需用动态的id,否则一个页面多个圆形进度条组件数据会混乱
let elId = $u.guid();
// #ifdef MP-WEIXIN || MP-TOUTIAO
// elBgId = 'uCircleProgressBgId'; // 微信小程序中不能使用$u.guid()形式动态生成id值,否则会报错
// elId = 'uCircleProgressElId';
// #endif
const instance = getCurrentInstance();
const pixelRatio = ref<number>(1); // 像素比
// 存储 MP-WEIXIN 下通过 selectorQuery 获取到的 canvas node,方便在绘制前清空
const canvasNodeMap = new Map<string, any>();
const widthPx = computed(() =>
typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.width)) : Number(props.width)
);
const borderWidthPx = computed(() =>
typeof uni !== 'undefined' && uni.upx2px ? uni.upx2px(Number(props.borderWidth)) : Number(props.borderWidth)
);
const startAngle = -Math.PI / 2; // canvas画圆的起始角度,默认为3点钟方向,定位到12点钟方向
const progressContext = ref<any>(null); // 活动圆的canvas上下文
const newPercent = ref(props.percent); // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
const oldPercent = ref(0); // 当动态修改进度值的时候,保存进度值的变化前后值,用于比较用
/**
* 有type主题时,优先起作用
*/
const circleColor = computed(() => {
if (props.type && ['success', 'error', 'info', 'primary', 'warning'].includes(props.type)) {
return $u.color[props.type as keyof typeof $u.color] as string;
}
return props.activeColor;
});
onBeforeMount(() => {
pixelRatio.value = uni.getSystemInfoSync().pixelRatio;
});
// 监听percent变化,动态绘制进度
watch(
() => props.percent,
(nVal, oVal = 0) => {
let next = nVal > 100 ? 100 : nVal;
let prev = oVal < 0 ? 0 : oVal;
newPercent.value = next;
oldPercent.value = prev;
setTimeout(() => {
// 无论是百分比值增加还是减少,需要操作还是原来的旧的百分比值
// 将此值减少或者新增到新的百分比值
drawCircleByProgress(prev);
}, 50);
}
);
onMounted(() => {
// 赋值,用于加载后第一个画圆使用
newPercent.value = props.percent;
oldPercent.value = 0;
// 在h5端,必须要做一点延时才起作用,this.$nextTick()无效(HX2.4.7)
setTimeout(() => {
drawProgressBg();
drawCircleByProgress(oldPercent.value);
}, 50);
});
/**
* 获取canvas上下文
*/
function getContext(canvasId: any) {
return new Promise<UniApp.CanvasContext>(resolve => {
let ctx = null;
// #ifndef MP-WEIXIN || MP-TOUTIAO
ctx = uni.createCanvasContext(canvasId, instance);
resolve(ctx);
// #endif
// #ifdef MP-WEIXIN || MP-TOUTIAO
uni.createSelectorQuery()
.in(instance?.proxy)
.select(`#${canvasId}`)
.node(res => {
if (res && res.node) {
const canvas = res.node;
ctx = canvas2d(canvas.getContext('2d') as CanvasRenderingContext2D);
canvas.width = widthPx.value * pixelRatio.value;
canvas.height = widthPx.value * pixelRatio.value;
ctx.scale(pixelRatio.value, pixelRatio.value);
// 存储 canvas node,后续绘制时用于清空画布
canvasNodeMap.set(canvasId, canvas);
resolve(ctx);
}
})
.exec();
// #endif
});
}
/**
* 绘制底部灰色圆环
*/
async function drawProgressBg() {
const ctx = await getContext(elBgId);
// #ifdef MP-WEIXIN || MP-TOUTIAO
// 清空背景画布(如果可用)以确保绘制一致性
try {
if (typeof ctx.clearRect === 'function') {
const node = canvasNodeMap.get(elBgId);
const w = node ? node.width : widthPx.value * pixelRatio.value;
const h = node ? node.height : widthPx.value * pixelRatio.value;
ctx.clearRect(0, 0, w, h);
}
} catch (e) {
// ignore
}
// #endif
ctx.setLineWidth(borderWidthPx.value); // 设置圆环宽度
ctx.setStrokeStyle(props.inactiveColor); // 线条颜色
ctx.beginPath(); // 开始描绘路径
const radius = widthPx.value / 2; // 设置一个原点(110,110),半径为100的圆的路径到当前路径
ctx.arc(radius, radius, radius - borderWidthPx.value, 0, 2 * Math.PI, false);
ctx.stroke(); // 对路径进行描绘
ctx.draw();
}
/**
* 绘制进度圆环
* @param progress 当前进度
*/
async function drawCircleByProgress(progress: number) {
// 第一次操作进度环时将上下文保存到了this.data中,直接使用即可
let ctx = progressContext.value;
if (!ctx) {
ctx = await getContext(elId);
progressContext.value = ctx;
}
// #ifdef MP-WEIXIN || MP-TOUTIAO
// 清空进度画布,避免旧的更大进度残留,导致无法降低的视觉错误
try {
if (typeof ctx.clearRect === 'function') {
const node = canvasNodeMap.get(elId);
const w = node ? node.width : widthPx.value * pixelRatio.value;
const h = node ? node.height : widthPx.value * pixelRatio.value;
ctx.clearRect(0, 0, w, h);
}
} catch (e) {
// ignore
}
// #endif
// 表示进度的两端为圆形
ctx.setLineCap('round');
// 设置线条的宽度和颜色
ctx.setLineWidth(borderWidthPx.value);
ctx.setStrokeStyle(circleColor.value);
// 将总过渡时间除以100,得出每修改百分之一进度所需的时间
let time = Math.floor(Number(props.duration) / 100);
// 结束角的计算依据为:将2π分为100份,乘以当前的进度值,得出终止点的弧度值,加起始角,为整个圆从默认的
// 3点钟方向开始画图,转为更好理解的12点钟方向开始作图,这需要起始角和终止角同时加上this.startAngle值
let endAngle = ((2 * Math.PI) / 100) * progress + startAngle;
ctx.beginPath();
// 半径为整个canvas宽度的一半
let radius = widthPx.value / 2;
ctx.arc(radius, radius, radius - borderWidthPx.value, startAngle, endAngle, false);
ctx.stroke();
ctx.draw();
// 如果变更后新值大于旧值,意味着增大了百分比
if (newPercent.value > oldPercent.value) {
// 每次递增百分之一
progress++;
// 如果新增后的值,大于需要设置的值百分比值,停止继续增加
if (progress > newPercent.value) return;
} else {
// 同理于上面
progress--;
if (progress < newPercent.value) return;
}
setTimeout(() => {
// 定时器,每次操作间隔为time值,为了让进度条有动画效果
drawCircleByProgress(progress);
}, time);
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-circle-progress {
position: relative;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
justify-content: center;
}
.u-canvas-bg {
position: absolute;
}
.u-canvas {
position: absolute;
}
</style>
@@ -0,0 +1,30 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-city-select 城市选择器 Props
* @description 用于选择省、市、区三级行政区域,支持回显和自定义初始值。
*/
export const CitySelectProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 控制弹窗显示与隐藏(v-model) */
modelValue: { type: Boolean, default: false },
/** 默认选中的省市区名称数组 */
defaultRegion: { type: Array as PropType<string[]>, default: () => [] },
/** 默认选中的省市区编码数组 */
areaCode: { type: Array as PropType<string[]>, default: () => [] },
/** 是否允许点击遮罩关闭弹窗 */
maskCloseAble: { type: Boolean, default: true },
/** 弹窗层级 */
zIndex: { type: [String, Number] as PropType<string | number>, default: 0 }
};
export type CitySelectProps = ExtractPropTypes<typeof CitySelectProps>;
@@ -0,0 +1,281 @@
<template>
<u-popup
v-model="popupValue"
:class="customClass"
:style="$u.toStyle(customStyle)"
mode="bottom"
:popup="false"
:mask="true"
:closeable="true"
:safe-area-inset-bottom="true"
close-icon-color="var(--u-white-color)"
:z-index="uZIndex"
:maskCloseAble="props.maskCloseAble"
@close="close"
>
<u-tabs
v-if="popupValue"
:list="genTabsList"
:is-scroll="true"
:current="tabsIndex"
@change="tabsChange"
ref="tabs"
></u-tabs>
<view class="area-box">
<view class="u-flex" :class="{ change: isChange }">
<!-- 省 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group>
<u-cell-item
v-for="(item, index) in provincesList"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="provinceChange(index)"
>
<template v-if="isChooseP && province === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
<!-- 市 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group v-if="isChooseP">
<u-cell-item
v-for="(item, index) in citys"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="cityChange(index)"
>
<template v-if="isChooseC && city === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
<!-- 区 -->
<view class="area-item">
<view class="u-padding-10 u-bg-gray" style="height: 100%">
<scroll-view :scroll-y="true" style="height: 100%">
<u-cell-group v-if="isChooseC">
<u-cell-item
v-for="(item, index) in areas"
:title="item.label"
:arrow="false"
:index="index"
:key="index"
@click="areaChange(index)"
>
<template v-if="isChooseA && area === index" #right-icon>
<u-icon size="34" name="checkbox-mark"></u-icon>
</template>
</u-cell-item>
</u-cell-group>
</scroll-view>
</view>
</view>
</view>
</view>
</u-popup>
</template>
<script lang="ts">
export default {
name: 'u-city-select',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import provinces from '../../libs/util/province';
import citysData from '../../libs/util/city';
import areasData from '../../libs/util/area';
import { CitySelectProps } from './types';
import { $u } from '../..';
import uCellGroup from '../u-cell-group/u-cell-group.vue';
import uCellItem from '../u-cell-item/u-cell-item.vue';
import uIcon from '../u-icon/u-icon.vue';
import uPopup from '../u-popup/u-popup.vue';
import uTabs from '../u-tabs/u-tabs.vue';
/**
* u-city-select 城市选择器
* @description 用于选择省、市、区三级行政区域,支持回显和自定义初始值。
* @property {Boolean} modelValue 控制弹窗显示与隐藏(v-model)
* @property {Array} defaultRegion 默认选中的省市区名称数组,如 ['山东省', '青岛市', '崂山区']
* @property {Array} areaCode 默认选中的省市区编码数组,如 ['440000', '440100', '440106']
* @property {Boolean} maskCloseAble 是否允许点击遮罩关闭弹窗(默认 true)
* @property {String|Number} zIndex 弹窗层级(默认 0,自动适配)
* @event update:modelValue v-model 绑定值变化时触发
* @event city-change 选择省市区后触发,返回选中的省市区对象
* @example <u-city-select v-model="show" :defaultRegion="['山东省', '青岛市', '崂山区']" @city-change="onChange"></u-city-select>
*/
const props = defineProps(CitySelectProps);
// emits 定义
const emit = defineEmits(['update:modelValue', 'city-change']);
// 省市区选择相关响应式数据
const cityValue = ref('');
const isChooseP = ref(false); // 是否已选省
const province = ref(0); // 省下标
const provincesList = ref(provinces);
const isChooseC = ref(false); // 是否已选市
const city = ref(0); // 市下标
const citys = ref(citysData[0]);
const isChooseA = ref(false); // 是否已选区
const area = ref(0); // 区下标
const areas = ref(areasData[0][0]);
const tabsIndex = ref(0);
// v-model 双向绑定
const popupValue = computed({
get: () => props.modelValue,
set: val => emit('update:modelValue', val)
});
// 是否切换到区级
const isChange = computed(() => tabsIndex.value > 1);
// 顶部 tab 列表
const genTabsList = computed(() => {
const tabsList = [{ name: '请选择' }];
if (isChooseP.value) {
tabsList[0].name = provincesList.value[province.value].label;
tabsList[1] = { name: '请选择' };
}
if (isChooseC.value) {
tabsList[1].name = citys.value[city.value].label;
tabsList[2] = { name: '请选择' };
}
if (isChooseA.value) {
tabsList[2].name = areas.value[area.value].label;
}
return tabsList;
});
// z-index 计算
const uZIndex = computed(() => (props.zIndex ? props.zIndex : (uni as any).$u?.zIndex?.popup || 1075));
// 初始化选中项
function init() {
if (props.areaCode.length === 3) {
setProvince('', props.areaCode[0]);
setCity('', props.areaCode[1]);
setArea('', props.areaCode[2]);
} else if (props.defaultRegion.length === 3) {
setProvince(props.defaultRegion[0], '');
setCity(props.defaultRegion[1], '');
setArea(props.defaultRegion[2], '');
}
}
// 选中省
function setProvince(label = '', value = '') {
provincesList.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
provinceChange(k);
}
});
}
// 选中市
function setCity(label = '', value = '') {
citys.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
cityChange(k);
}
});
}
// 选中区
function setArea(label = '', value = '') {
areas.value.forEach((v, k) => {
if (value ? v.value == value : v.label == label) {
isChooseA.value = true;
area.value = k;
}
});
}
// 关闭弹窗
function close() {
emit('update:modelValue', false);
}
// tab 切换
function tabsChange(index: number) {
tabsIndex.value = index;
}
// 省点击
function provinceChange(index: number) {
isChooseP.value = true;
isChooseC.value = false;
isChooseA.value = false;
province.value = index;
citys.value = citysData[index];
tabsIndex.value = 1;
}
// 市点击
function cityChange(index: number) {
isChooseC.value = true;
isChooseA.value = false;
city.value = index;
areas.value = areasData[province.value][index];
tabsIndex.value = 2;
}
// 区点击
function areaChange(index: number) {
isChooseA.value = true;
area.value = index;
const result = {
province: provincesList.value[province.value],
city: citys.value[city.value],
area: areas.value[area.value]
};
emit('city-change', result);
close();
}
// 挂载时初始化
onMounted(init);
</script>
<style lang="scss">
.area-box {
width: 100%;
overflow: hidden;
height: 800rpx;
> view {
width: 150%;
transition: transform 0.3s ease-in-out 0s;
transform: translateX(0);
&.change {
transform: translateX(-33.3333333%);
}
}
.area-item {
width: 33.3333333%;
height: 800rpx;
}
}
</style>
@@ -0,0 +1,40 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { AlignType, JustifyType } from '../../types/global';
/**
* u-col 布局单元格 Props
* @description 通过基础的 12 分栏,迅速简便地创建布局(搭配<u-row>使用)
* @property {String|Number} span 栅格占据的列数,总12等分(默认12)
* @property {String|Number} offset 分栏左边偏移,计算方式与span相同(默认0)
* @property {JustifyType} justify 水平排列方式,可选值为start、end、center、around、between(默认start)
* @property {AlignType} align 垂直对齐方式,可选值为top、center、bottom(默认center)
* @property {String} textAlign 文字对齐方式(默认left)
* @property {Boolean} stop 是否阻止事件传播(默认true)
*/
export const ColProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 占父容器宽度的多少等分,总分为12份 */
span: { type: [Number, String] as PropType<number | string>, default: 12 },
/** 指定栅格左侧的间隔数(总12栏) */
offset: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 水平排列方式 */
justify: { type: String as PropType<JustifyType>, default: 'start' },
/** 垂直对齐方式 */
align: { type: String as PropType<AlignType>, default: 'center' },
/** 文字对齐方式 */
textAlign: { type: String, default: 'left' },
/** 是否阻止事件传播 */
stop: { type: Boolean, default: true }
};
export type ColProps = ExtractPropTypes<typeof ColProps>;
@@ -0,0 +1,142 @@
<template>
<view
class="u-col"
:class="['u-col-' + span, customClass]"
:style="$u.toStyle(colStyle, customStyle)"
@tap="onClick"
>
<slot></slot>
</view>
</template>
<script lang="ts">
export default {
name: 'u-col',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ColProps } from './types';
import { $u, useChildren } from '../../';
/**
* col 布局单元格
* @description 通过基础的 12 分栏,迅速简便地创建布局(搭配<u-row>使用)
* @tutorial https://uviewpro.cn/zh/components/layout.html
* @property {String|Number} span 栅格占据的列数,总12等分(默认0)
* @property {String} text-align 文字水平对齐方式(默认left)
* @property {String|Number} offset 分栏左边偏移,计算方式与span相同(默认0)
* @example <u-col span="3"><view class="demo-layout bg-purple"></view></u-col>
*/
const emit = defineEmits<{ (e: 'click'): void }>();
const props = defineProps(ColProps);
const { parentExposed } = useChildren('u-col', 'u-row');
/**
* gutter 给col添加间距,左右边距各占一半,从父组件u-row获取
*/
const gutter = computed(() => {
return parentExposed?.value?.props?.gutter ?? 20;
});
/**
* 计算水平排列方式
* @returns {string}
*/
const uJustify = computed(() => {
if (props.justify === 'end' || props.justify === 'start') return 'flex-' + props.justify;
else if (props.justify === 'around' || props.justify === 'between') return 'space-' + props.justify;
else return props.justify;
});
/**
* 计算垂直对齐方式
* @returns {string}
*/
const uAlignItem = computed(() => {
if (props.align === 'top') return 'flex-start';
if (props.align === 'bottom') return 'flex-end';
else return props.align;
});
/**
* 计算样式对象
*/
const colStyle = computed<any>(() => ({
padding: `0 ${Number(gutter.value) / 2}px`,
marginLeft: `${(100 / 12) * Number(props.offset)}%`,
flex: `0 0 ${(100 / 12) * Number(props.span)}%`,
alignItems: uAlignItem.value,
justifyContent: uJustify.value,
textAlign: props.textAlign
}));
/**
* 点击事件
* @param e 事件对象
*/
function onClick(e: Event) {
emit('click');
}
</script>
<style lang="scss">
@import '../../libs/css/style.components.scss';
.u-col {
/* #ifdef MP-WEIXIN || MP-QQ || MP-TOUTIAO */
float: left;
/* #endif */
}
.u-col-0 {
width: 0;
}
.u-col-1 {
width: calc(100% / 12);
}
.u-col-2 {
width: calc(100% / 12 * 2);
}
.u-col-3 {
width: calc(100% / 12 * 3);
}
.u-col-4 {
width: calc(100% / 12 * 4);
}
.u-col-5 {
width: calc(100% / 12 * 5);
}
.u-col-6 {
width: calc(100% / 12 * 6);
}
.u-col-7 {
width: calc(100% / 12 * 7);
}
.u-col-8 {
width: calc(100% / 12 * 8);
}
.u-col-9 {
width: calc(100% / 12 * 9);
}
.u-col-10 {
width: calc(100% / 12 * 10);
}
.u-col-11 {
width: calc(100% / 12 * 11);
}
.u-col-12 {
width: calc(100% / 12 * 12);
}
</style>
@@ -0,0 +1,35 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { TextAlign } from '../../types/global';
/**
* u-collapse-item 手风琴Item Props
* @description 通过折叠面板收纳内容区域(搭配u-collapse使用)
*/
export const CollapseItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 标题 */
title: { type: String, default: '' },
/** 标题的对齐方式 */
align: { type: String as PropType<TextAlign>, default: 'left' },
/** 是否可以点击收起 */
disabled: { type: Boolean, default: false },
/** collapse显示与否 */
open: { type: Boolean, default: false },
/** 唯一标识符 */
name: { type: [Number, String] as PropType<number | string>, default: '' },
/** 活动样式 */
activeStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 标识当前为第几个 */
index: { type: [String, Number] as PropType<string | number>, default: '' }
};
export type CollapseItemProps = ExtractPropTypes<typeof CollapseItemProps>;
@@ -0,0 +1,291 @@
<template>
<view class="u-collapse-item" :style="$u.toStyle(itemStyle, customStyle)" :class="customClass">
<view
:hover-stay-time="200"
class="u-collapse-head"
@tap.stop="headClick"
:hover-class="hoverClass"
:style="headStyle"
>
<template v-if="!slots['title-all']">
<view v-if="!slots['title']" class="u-collapse-title u-line-1" :style="titleStyle">
{{ title }}
</view>
<slot v-else name="title" />
<view class="u-icon-wrap">
<u-icon v-if="showArrow" :color="arrowColor" :name="isShow ? 'arrow-up' : 'arrow-down'" />
</view>
</template>
<slot v-else name="title-all" />
</view>
<view class="u-collapse-body" :style="{ height: isShow ? height : '0' }">
<view class="u-collapse-content" :id="elId" :style="bodyStyle">
<slot></slot>
</view>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-collapse-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, watch, onMounted, useSlots, getCurrentInstance, nextTick, computed } from 'vue';
import { $u, useChildren } from '../..';
import { CollapseItemProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* collapseItem 手风琴Item
* @description 通过折叠面板收纳内容区域(搭配u-collapse使用)
* @tutorial https://uviewpro.cn/zh/components/collapse.html
* @property {String} title 面板标题
* @property {String Number} index 主要用于事件的回调,标识那个Item被点击
* @property {Boolean} disabled 面板是否可以打开或收起(默认false)
* @property {Boolean} open 设置某个面板的初始状态是否打开(默认false)
* @property {String Number} name 唯一标识符,如不设置,默认用当前collapse-item的索引值
* @property {String} align 标题的对齐方式(默认left)
* @property {Object} active-style 不显示箭头时,可以添加当前选择的collapse-item活动样式,对象形式
* @event {Function} change 某个item被打开或者收起时触发
* @example <u-collapse-item :title="item.head" v-for="(item, index) in itemList" :key="index">{{item.body}}</u-collapse-item>
*/
const props = defineProps(CollapseItemProps);
const emit = defineEmits(['change']);
const slots = useSlots();
const instance = getCurrentInstance();
const isShow = ref(false);
const elId = ref('');
const height = ref('0');
const headStyle = ref<Record<string, any>>({});
const bodyStyle = ref<Record<string, any>>({});
const itemStyle = ref<Record<string, any>>({});
const arrowColor = ref('');
const hoverClass = ref('');
// 使用通信库的子组件Hook
const { childId, parentExposed } = useChildren('u-collapse-item', 'u-collapse');
// 计算属性
const showArrow = computed(() => {
return parentExposed.value?.props ? parentExposed.value.props.arrow : true;
});
const titleStyle = computed(() => {
let style = { textAlign: props.align ? props.align : 'left' };
if (isShow.value && props.activeStyle && !showArrow.value) {
style = $u.deepMerge(style, props.activeStyle);
}
return $u.toStyle(style);
});
// 获取唯一标识符
const itemName = computed(() => {
// 优先级:name > index > childId
if (props.name !== undefined && props.name !== '') {
return props.name;
} else if (props.index !== undefined && props.index !== '') {
return props.index;
} else {
return childId;
}
});
/**
* 设置显示状态
*/
function setShowState(show: boolean) {
if (isShow.value !== show) {
isShow.value = show;
// 如果展开,需要重新计算高度
if (show) {
nextTick(() => {
queryRect();
});
}
// 本地触发change事件
emit('change', {
index: props.index,
name: itemName.value,
show: isShow.value
});
}
}
/**
* 异步获取内容,或者动态修改了内容时,需要重新初始化
*/
function init() {
if (parentExposed.value?.props) {
headStyle.value = parentExposed.value.props.headStyle || {};
bodyStyle.value = parentExposed.value.props.bodyStyle || {};
arrowColor.value = parentExposed.value.props.arrowColor || 'var(--u-tips-color)';
hoverClass.value = parentExposed.value.props.hoverClass || 'u-hover-class';
itemStyle.value = parentExposed.value.props.itemStyle || {};
}
elId.value = $u.guid();
nextTick(() => {
queryRect();
});
}
/**
* 点击collapse head头部
*/
function headClick() {
if (props.disabled) return;
// 通知父组件状态变化
parentExposed?.value?.onChange(itemName.value);
}
/**
* 查询内容高度
*/
function queryRect() {
$u.getRect('#' + elId.value, instance)
.then((res: any) => {
if (res && res.height) {
height.value = res.height + 'px';
}
// #ifdef MP-TOUTIAO
if (isShow.value) {
height.value = 'auto';
}
// #endif
})
.catch((err: any) => {
console.warn('queryRect error:', err);
height.value = 'auto';
});
}
// 单选
function openSingle(data: any) {
// 只有目标项展开,其他都关闭
const shouldShow = data.targetName === itemName.value;
setShowState(shouldShow);
}
// 关闭所有
function closeAll() {
setShowState(false);
}
// 多选
function setMultiple(data: any) {
const shouldShow = data.targetNames.includes(itemName.value);
setShowState(shouldShow);
}
// 切换单个
function toggleSingle(data: any) {
// 只有目标项才切换状态
if (data.targetName === itemName.value) {
setShowState(!isShow.value);
}
}
onMounted(() => {
// 关键修复:根据 open 属性设置初始状态
setShowState(props.open);
// 初始化
init();
});
// 监听 open 属性变化
watch(
() => props.open,
newVal => {
setShowState(newVal);
}
);
// 监听父组件exposed的变化
watch(
parentExposed,
newExposed => {
if (newExposed) {
init();
}
},
{ deep: true, immediate: true }
);
defineExpose({
init,
isShow,
elId,
height,
headStyle,
bodyStyle,
itemStyle,
arrowColor,
hoverClass,
itemName: itemName.value,
queryRect,
setShowState,
openSingle,
closeAll,
setMultiple,
toggleSingle
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-collapse-head {
position: relative;
@include vue-flex;
justify-content: space-between;
align-items: center;
color: $u-main-color;
font-size: 30rpx;
line-height: 1;
padding: 24rpx 0;
text-align: left;
}
.u-collapse-title {
flex: 1;
overflow: hidden;
}
.u-arrow-down-icon {
transition: all 0.3s;
margin-right: 20rpx;
margin-left: 14rpx;
}
.u-arrow-down-icon-active {
transform: rotate(180deg);
transform-origin: center center;
}
.u-collapse-body {
overflow: hidden;
transition: all 0.3s;
}
.u-collapse-content {
overflow: hidden;
font-size: 28rpx;
color: $u-tips-color;
text-align: left;
}
</style>
@@ -0,0 +1,41 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-collapse 手风琴 Props
* @description 通过折叠面板收纳内容区域
* @property {Boolean} accordion 是否手风琴模式(默认true)
* @property {Object} headStyle 标题自定义样式,对象形式
* @property {Object} bodyStyle 主体自定义样式,对象形式
* @property {Object} itemStyle 每一个item的样式,对象形式
* @property {Boolean} arrow 是否显示标题右侧的箭头(默认true)
* @property {String} arrowColor 标题右侧箭头的颜色(默认var(--u-tips-color))
* @property {String} hoverClass 样式类名,按下时有效(默认u-hover-class)
*/
export const CollapseProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 是否手风琴模式 */
accordion: { type: Boolean, default: true },
/** 头部的样式 */
headStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 主体的样式 */
bodyStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 每一个item的样式 */
itemStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 是否显示右侧的箭头 */
arrow: { type: Boolean, default: true },
/** 箭头的颜色 */
arrowColor: { type: String, default: 'var(--u-tips-color)' },
/** 标题部分按压时的样式类,"none"为无效果 */
hoverClass: { type: String, default: 'u-hover-class' }
};
export type CollapseProps = ExtractPropTypes<typeof CollapseProps>;
@@ -0,0 +1,190 @@
<template>
<view class="u-collapse" :style="$u.toStyle(customStyle)" :class="customClass">
<slot />
</view>
</template>
<script lang="ts">
export default {
name: 'u-collapse',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { nextTick, onMounted, ref } from 'vue';
import { CollapseProps } from './types';
import { $u, useParent } from '../../';
/**
* collapse 手风琴
* @description 通过折叠面板收纳内容区域
* @tutorial https://uviewpro.cn/zh/components/collapse.html
* @property {Boolean} accordion 是否手风琴模式(默认true)
* @property {Boolean} arrow 是否显示标题右侧的箭头(默认true)
* @property {String} arrow-color 标题右侧箭头的颜色(默认var(--u-tips-color))
* @property {Object} head-style 标题自定义样式,对象形式
* @property {Object} body-style 主体自定义样式,对象形式
* @property {String} hover-class 样式类名,按下时有效(默认u-hover-class)
* @event {Function} change 当前激活面板展开时触发(如果是手风琴模式,参数activeNames类型为String,否则为Array)
* @example <u-collapse></u-collapse>
*/
const props = defineProps(CollapseProps);
const emit = defineEmits(['change']);
// 使用通信库的父组件Hook
const { children, broadcast, getChildrenExposed } = useParent('u-collapse');
// 当前激活的面板 - 只在手风琴模式下使用
const activeName = ref<string | number>('');
onMounted(() => {
nextTick(() => {
// 初始化:收集所有open为true的项
setTimeout(() => {
initializeActiveName();
}, 100);
});
});
/**
* 初始化activeName - 找到第一个open为true的项
*/
function initializeActiveName() {
if (props.accordion) {
// 手风琴模式下,取第一个open为true的项作为初始激活项
const childrenExposed = getChildrenExposed();
const openChild = childrenExposed.find(child => child.exposed.isShow === true);
if (openChild) {
activeName.value = openChild.exposed.itemName || '';
}
}
}
/**
* collapse item被点击,由collapse item调用父组件方法
*/
function onChange(name: string | number) {
if (props.accordion) {
// 手风琴模式
const childrenExposed = getChildrenExposed();
const targetChild = childrenExposed.find(child => child.exposed.itemName === name);
if (targetChild?.exposed.isShow.value === true) {
// 目标项当前是展开状态,点击后要关闭它
activeName.value = '';
broadcast('closeAll', {});
} else {
// 目标项当前是关闭状态,点击后要展开它并关闭其他
activeName.value = name;
broadcast('openSingle', { targetName: name });
}
} else {
// 非手风琴模式 - 只通知目标项切换状态
broadcast('toggleSingle', { targetName: name });
}
// 收集当前所有展开的项
let currentActiveNames: (string | number)[] = [];
if (props.accordion) {
currentActiveNames = activeName.value === 0 || activeName.value ? [activeName.value] : [];
} else {
// 对于非手风琴模式,我们不知道所有项的状态
currentActiveNames = [];
children.forEach(child => {
if (child.getExposed().isShow.value) {
currentActiveNames.push(child.getExposed().itemName);
}
});
}
currentActiveNames.length > 0 && emit('change', props.accordion ? activeName.value || '' : currentActiveNames);
}
/**
* 设置激活的面板
*/
function setActiveNames(names: string | number | (string | number)[]) {
if (props.accordion) {
// 手风琴模式
const name = Array.isArray(names) ? names[0] : names;
activeName.value = name || '';
if (name) {
broadcast('openSingle', { targetName: name });
} else {
broadcast('closeAll', {});
}
} else {
// 非手风琴模式
const namesArray = Array.isArray(names) ? names : [names];
broadcast('setMultiple', { targetNames: namesArray });
}
}
/**
* 打开所有面板
*/
function openAll() {
if (props.accordion) {
console.warn('手风琴模式下不能打开所有面板');
return;
}
const childrenExposed = getChildrenExposed();
const allNames = childrenExposed.map(child => child.exposed.itemName).filter(Boolean);
broadcast('setMultiple', { targetNames: allNames });
}
/**
* 关闭所有面板
*/
function closeAll() {
broadcast('closeAll', {});
if (props.accordion) {
activeName.value = '';
}
}
/**
* 重新初始化,用于动态内容变化
*/
function init() {
const childrenExposed = getChildrenExposed();
childrenExposed.forEach(child => {
if (child.exposed.init) {
child.exposed.init();
}
});
// 重新初始化activeName
setTimeout(() => {
initializeActiveName();
}, 150);
}
// 使用defineExpose暴露给外部
defineExpose({
// props
props,
// 状态
activeName,
// 方法
onChange,
setActiveNames,
openAll,
closeAll,
init,
// 计算属性
childrenCount: () => children.length
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,58 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { Direction, PlayState, ScrollDirection, ThemeType } from '../../types/global';
/**
* u-column-notice 通告栏 Props
*/
export const ColumnNoticeProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 显示的内容,数组 */
list: { type: Array as PropType<string[]>, default: () => [] },
/** 显示的主题,success|error|primary|info|warning */
type: { type: String as PropType<ThemeType | 'none'>, default: 'warning' },
/** 是否显示左侧的音量图标 */
volumeIcon: { type: Boolean, default: true },
/** 是否显示右侧的右箭头图标 */
moreIcon: { type: Boolean, default: false },
/** 是否显示右侧的关闭图标 */
closeIcon: { type: Boolean, default: false },
/** 是否自动播放 */
autoplay: { type: Boolean, default: true },
/** 文字颜色,各图标也会使用文字颜色 */
color: { type: String, default: '' },
/** 背景颜色 */
bgColor: { type: String, default: '' },
/** 滚动方向,row-水平滚动,column-垂直滚动 */
direction: { type: String as PropType<ScrollDirection>, default: 'row' },
/** 是否显示 */
show: { type: Boolean, default: true },
/** 字体大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/** 滚动一个周期的时间长,单位ms */
duration: { type: [Number, String] as PropType<number | string>, default: 2000 },
/** 音量喇叭的大小 */
volumeSize: { type: [Number, String] as PropType<number | string>, default: 34 },
/** 水平滚动时的滚动速度,即每秒滚动多少rpx,这有利于控制文字无论多少时,都能有一个恒定的速度 */
speed: { type: Number, default: 160 },
/** 水平滚动时,是否采用衔接形式滚动 */
isCircular: { type: Boolean, default: true },
/** 滚动方向,horizontal-水平滚动,vertical-垂直滚动 */
mode: { type: String as PropType<Direction>, default: 'horizontal' },
/** 播放状态,play-播放,paused-暂停 */
playState: { type: String as PropType<PlayState>, default: 'play' },
/** 是否禁止用手滑动切换 */
disableTouch: { type: Boolean, default: true },
/** 通知的边距 */
padding: { type: [Number, String] as PropType<number | string>, default: '18rpx 24rpx' }
};
export type ColumnNoticeProps = ExtractPropTypes<typeof ColumnNoticeProps>;
@@ -0,0 +1,223 @@
<template>
<view
class="u-notice-bar"
:style="$u.toStyle({ background: computeBgColor, padding: props.padding }, customStyle)"
:class="[props.type ? `u-type-${props.type}-light-bg` : '', customClass]"
>
<view class="u-icon-wrap">
<u-icon
class="u-left-icon"
v-if="props.volumeIcon"
name="volume-fill"
:size="props.volumeSize"
:color="computeColor"
></u-icon>
</view>
<swiper
:disable-touch="props.disableTouch"
@change="change"
:autoplay="props.autoplay && props.playState === 'play'"
:vertical="vertical"
circular
:interval="props.duration"
class="u-swiper"
>
<swiper-item v-for="(item, index) in props.list" :key="index" class="u-swiper-item">
<view
class="u-news-item u-line-1"
:style="textStyle"
@tap="click(index)"
:class="['u-type-' + props.type]"
>
{{ item }}
</view>
</swiper-item>
</swiper>
<view class="u-icon-wrap">
<u-icon
@click="getMore"
class="u-right-icon"
v-if="props.moreIcon"
name="arrow-right"
:size="26"
:color="computeColor"
></u-icon>
<u-icon
@click="close"
class="u-right-icon"
v-if="props.closeIcon"
name="close"
:size="24"
:color="computeColor"
></u-icon>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-column-notice',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { ColumnNoticeProps } from './types';
import { $u } from '../../';
import uIcon from '../u-icon/u-icon.vue';
/**
* u-column-notice 通告栏
* @property {Array} list 显示的内容,数组
* @property {String} type 显示的主题,success|error|primary|info|warning
* @property {Boolean} volumeIcon 是否显示左侧的音量图标
* @property {Boolean} moreIcon 是否显示右侧的右箭头图标
* @property {Boolean} closeIcon 是否显示右侧的关闭图标
* @property {Boolean} autoplay 是否自动播放
* @property {String} color 文字颜色,各图标也会使用文字颜色
* @property {String} bgColor 背景颜色
* @property {String} direction 滚动方向,row-水平滚动,column-垂直滚动
* @property {Boolean} show 是否显示
* @property {String|Number} fontSize 字体大小,单位rpx
* @property {String|Number} duration 滚动一个周期的时间长,单位ms
* @property {String|Number} volumeSize 音量喇叭的大小
* @property {Number} speed 水平滚动时的滚动速度,即每秒滚动多少rpx
* @property {Boolean} isCircular 水平滚动时,是否采用衔接形式滚动
* @property {String} mode 滚动方向,horizontal-水平滚动,vertical-垂直滚动
* @property {String} playState 播放状态,play-播放,paused-暂停
* @property {Boolean} disableTouch 是否禁止用手滑动切换
* @property {String|Number} padding 通知的边距
*/
const props = defineProps(ColumnNoticeProps);
const emit = defineEmits<{
(e: 'click', index: number): void;
(e: 'close'): void;
(e: 'getMore'): void;
(e: 'end'): void;
}>();
/**
* 计算字体颜色,如果没有自定义的,就用uview主题颜色
*/
const computeColor = computed(() => {
if (props.color) return props.color;
// 如果是无主题,就默认使用content-color
else if (props.type === 'none') return 'var(--u-content-color)';
else return props.type;
});
/**
* 文字内容的样式
*/
const textStyle = computed(() => {
const style: Record<string, any> = {};
if (props.color) style.color = props.color;
else if (props.type === 'none') style.color = 'var(--u-content-color)';
style.fontSize = props.fontSize + 'rpx';
return style;
});
/**
* 垂直或者水平滚动
*/
const vertical = computed(() => {
return props.mode !== 'horizontal';
});
/**
* 计算背景颜色
*/
const computeBgColor = computed(() => {
if (props.bgColor) return props.bgColor;
else if (props.type === 'none') return 'transparent';
else return '';
});
/**
* 点击通告栏
*/
function click(index: number) {
emit('click', index);
}
/**
* 点击关闭按钮
*/
function close() {
emit('close');
}
/**
* 点击更多箭头按钮
*/
function getMore() {
emit('getMore');
}
/**
* swiper 切换事件
*/
function change(e: { detail: { current: number } }) {
const index = e.detail.current;
if (index === props.list.length - 1) {
emit('end');
}
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-notice-bar {
width: 100%;
@include vue-flex;
align-items: center;
justify-content: center;
flex-wrap: nowrap;
padding: 18rpx 24rpx;
overflow: hidden;
}
.u-swiper {
font-size: 26rpx;
height: 32rpx;
@include vue-flex;
align-items: center;
flex: 1;
margin-left: 12rpx;
}
.u-swiper-item {
@include vue-flex;
align-items: center;
overflow: hidden;
}
.u-news-item {
overflow: hidden;
}
.u-right-icon {
margin-left: 12rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
.u-left-icon {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
</style>
@@ -0,0 +1,53 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { config, configProvider } from '../../libs';
import type { Theme, DarkMode } from '../../types/global';
export const ConfigProviderProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/**
* 主题风格,可选值:
* - 'light': 强制亮色模式(默认)
* - 'dark': 强制暗黑模式
* - 'auto': 自动跟随系统设置
*/
darkMode: {
type: String as PropType<DarkMode>,
default: () => configProvider.getDarkMode()
},
/**
* 当前主题名称(用于多主题切换)
*/
currentTheme: {
type: String,
default: () => configProvider.getCurrentTheme()?.name ?? config.defaultTheme
},
/**
* 自定义主题列表
*/
themes: {
type: Array as PropType<Theme[]>,
default: () => configProvider.getThemes()
},
/**
* 国际化配置:传入 locale 列表(数组)并可指定默认语言 currentLocale
*/
locales: {
type: Array as PropType<any[]>,
default: () => []
},
currentLocale: {
type: String,
default: () => configProvider.getCurrentLocale()?.name ?? ''
}
};
export type ConfigProviderProps = ExtractPropTypes<typeof ConfigProviderProps>;
@@ -0,0 +1,185 @@
<template>
<view :class="['u-config-provider', `u-theme-${darkMode}`, customClass]" :style="mergedThemeStyle">
<slot />
</view>
</template>
<script lang="ts">
/**
* u-config-provider
*
* 说明(简要):
* - 初始化场景(推荐在应用入口执行):使用 `useTheme().initTheme(themes)` 进行一次性初始化与全局设置(例如在 `main.ts` 或 `App.vue` 中)。
* - 组件/页面场景(推荐):使用 `useTheme()` 组合式函数在组件内部读取响应式 `currentTheme`、`themes`、`darkMode` 并通过 `setTheme()` 和 `setDarkMode()` 切换主题/模式。
*
* 该组件的行为:
* - 如果在挂载时传入 `themes`,会调用 `configProvider.initTheme(themes)`
* - 如果传入 `currentTheme`,会优先设置当前主题
* - 如果传入 `darkMode`,会设置当前的暗黑模式状态,同时在 document 上添加 `u-theme-dark` 或 `u-theme-light` 类名
*
* 详尽说明请参考:`docs/config-provider-usage.md`
*/
export default {
name: 'u-config-provider',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script lang="ts" setup>
import { computed, watch, onMounted } from 'vue';
import { ConfigProviderProps } from './types';
import { $u, configProvider } from '../../libs';
import { useTheme } from '../../libs/hooks/useTheme';
import { useLocale } from '../../libs/hooks/useLocale';
const props = defineProps(ConfigProviderProps);
const emit = defineEmits<{
'theme-change': [themeName: string];
'mode-change': [mode: string];
}>();
const { initTheme } = useTheme();
const { initLocales, setLocale, getLocales, getCurrentLocale } = useLocale();
const bootstrapTheme = () => {
// 初始化主题
try {
// 如果已经初始化过主题,不再重复初始化,只更新 props 相关配置
const existingThemes = configProvider.getThemes();
if (existingThemes.length > 0) {
// 已初始化,只在值不同时才更新(避免覆盖用户之前的设置)
if (props.currentTheme && props.currentTheme !== configProvider.getCurrentTheme()?.name) {
configProvider.setTheme(props.currentTheme as string);
}
if (props.darkMode && props.darkMode !== configProvider.getDarkMode()) {
configProvider.setDarkMode(props.darkMode);
}
return;
}
// 未初始化,进行初始化
if (props.themes && props.themes.length) {
configProvider.initTheme(props.themes, props.currentTheme as any);
} else {
// 使用 useTheme 的 initTheme,它会处理默认主题
initTheme(undefined, props.currentTheme as any);
}
} catch (e) {
console.warn('[u-config-provider] init theme failed', e);
}
};
const bootstrapLocale = () => {
// 初始化国际化
try {
const existingLocales = getLocales();
if (existingLocales.length > 0) {
if (props.currentLocale && props.currentLocale !== getCurrentLocale()?.name) {
setLocale(props.currentLocale as string);
}
} else {
if (props.locales && props.locales.length) {
initLocales(props.locales, props.currentLocale as any);
} else {
initLocales(undefined, props.currentLocale as any);
}
}
} catch (e) {
console.warn('[u-config-provider] init locales failed', e);
}
};
// 当传入自定义 themes 时,初始化全局 configProvider(覆盖已有)
onMounted(() => {
bootstrapTheme();
bootstrapLocale();
});
// 监听外部 props 变化(如果上层修改 prop)
watch(
() => props.themes,
val => {
// 如果传入的 themes 来自于 configProvider 自身(常见于模板中使用 useTheme() 直接透传),
// 那么对其做深度监听会在我们内部更新主题对象时触发该回调,进而再次调用 init 导致循环更新。
// 为避免该情况,先做简单保护:当传入对象正是 configProvider.themesRef.value 时不重复初始化。
if (val && val.length && val !== configProvider.themesRef.value) {
configProvider.initTheme(val, props.currentTheme as any);
}
},
{ deep: true }
);
watch(
() => props.currentTheme,
val => {
if (val) {
configProvider.setTheme(val);
}
}
);
watch(
() => props.darkMode,
val => {
if (val && val !== configProvider.getDarkMode()) {
configProvider.setDarkMode(val);
}
}
);
// 监听 locales prop 变化
watch(
() => props.locales,
val => {
if (val && val.length) {
initLocales(val, props.currentLocale as any);
}
},
{ deep: true }
);
watch(
() => props.currentLocale,
val => {
if (val) {
setLocale(val);
}
}
);
// 监听全局主题变更并触发事件
watch(
() => configProvider.currentThemeRef.value,
(val, oldVal) => {
if (val && val.name !== (oldVal as any)?.name) {
emit('theme-change', val.name);
}
},
{ immediate: true }
);
// 监听暗黑模式变更并触发事件
watch(
() => configProvider.darkModeRef.value,
(val, oldVal) => {
if (val && val !== oldVal) {
emit('mode-change', val);
}
},
{ immediate: true }
);
// 计算合并样式(作为局部 fallback),configProvider 已经会把变量注入到 document 上
const mergedThemeStyle = computed(() => {
return $u.toStyle(configProvider.cssVarsRef.value, props.customStyle);
});
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,52 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-count-down 倒计时 Props
* @description 该组件一般使用于某个活动的截止时间上,通过数字的变化,给用户明确的时间感受,提示用户进行某一个行为操作。
*/
export const CountDownProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 倒计时的时间,秒为单位 */
timestamp: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 是否自动开始倒计时 */
autoplay: { type: Boolean, default: true },
/** 分隔符 */
separator: { type: String, default: 'colon' },
/** 分隔符的大小,单位rpx */
separatorSize: { type: [Number, String] as PropType<number | string>, default: 30 },
/** 分隔符颜色 */
separatorColor: { type: String, default: 'var(--u-main-color)' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 字体大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 30 },
/** 背景颜色 */
bgColor: { type: String, default: 'var(--u-bg-white)' },
/** 数字框高度,单位rpx */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 是否显示数字框 */
showBorder: { type: Boolean, default: false },
/** 边框颜色 */
borderColor: { type: String, default: 'var(--u-main-color)' },
/** 是否显示秒 */
showSeconds: { type: Boolean, default: true },
/** 是否显示分钟 */
showMinutes: { type: Boolean, default: true },
/** 是否显示小时 */
showHours: { type: Boolean, default: true },
/** 是否显示“天” */
showDays: { type: Boolean, default: true },
/** 当"天"的部分为0时,不显示 */
hideZeroDay: { type: Boolean, default: false }
};
export type CountDownProps = ExtractPropTypes<typeof CountDownProps>;
@@ -0,0 +1,288 @@
<template>
<view class="u-countdown" :class="customClass" :style="$u.toStyle(customStyle)">
<view
class="u-countdown-item"
:style="$u.toStyle(itemStyle)"
v-if="props.showDays && (props.hideZeroDay || (!props.hideZeroDay && d != '00'))"
>
<view class="u-countdown-time" :style="$u.toStyle(letterStyle)">
{{ d }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showDays && (props.hideZeroDay || (!props.hideZeroDay && d != '00'))"
>
{{ props.separator == 'colon' && props.showHours ? ':' : t('uCountDown.day') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showHours">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ h }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showHours"
>
{{ props.separator == 'colon' && props.showMinutes ? ':' : t('uCountDown.hour') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showMinutes">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ i }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{
fontSize: props.separatorSize + 'rpx',
color: props.separatorColor,
paddingBottom: props.separator == 'colon' ? '4rpx' : 0
}"
v-if="props.showMinutes"
>
{{ props.separator == 'colon' && props.showSeconds ? ':' : t('uCountDown.minute') }}
</view>
<view class="u-countdown-item" :style="$u.toStyle(itemStyle)" v-if="props.showSeconds">
<view class="u-countdown-time" :style="{ fontSize: props.fontSize + 'rpx', color: props.color }">
{{ s }}
</view>
</view>
<view
class="u-countdown-colon"
:style="{ fontSize: props.separatorSize + 'rpx', color: props.separatorColor, paddingBottom: 0 }"
v-if="props.showSeconds && props.separator == 'zh'"
>
{{ t('uCountDown.second') }}
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-count-down',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { CountDownProps } from './types';
import { $u, useLocale } from '../../';
const { t } = useLocale();
/**
* countDown 倒计时
* @description 该组件一般使用于某个活动的截止时间上,通过数字的变化,给用户明确的时间感受,提示用户进行某一个行为操作。
* @tutorial https://uviewpro.cn/zh/components/countDown.html
* @property {String | Number} timestamp 倒计时,单位为秒
* @property {Boolean} autoplay 是否自动开始倒计时,如果为false,需手动调用开始方法。见官网说明(默认true)
* @property {String} separator 分隔符,colon为英文冒号,zh为中文(默认colon)
* @property {String | Number} separator-size 分隔符的字体大小,单位rpx(默认30)
* @property {String} separator-color 分隔符的颜色(默认var(--u-main-color))
* @property {String | Number} font-size 倒计时字体大小,单位rpx(默认30)
* @property {Boolean} show-border 是否显示倒计时数字的边框(默认false)
* @property {Boolean} hide-zero-day 当"天"的部分为0时,隐藏该字段 (默认true)
* @property {String} border-color 数字边框的颜色(默认var(--u-main-color))
* @property {String} bg-color 倒计时数字的背景颜色(默认var(--u-bg-white))
* @property {String} color 倒计时数字的颜色(默认var(--u-main-color))
* @property {String | Number} height 数字高度值(宽度等同此值),设置边框时看情况是否需要设置此值,单位rpx(默认auto)
* @property {Boolean} show-days 是否显示倒计时的"天"部分(默认true)
* @property {Boolean} show-hours 是否显示倒计时的"时"部分(默认true)
* @property {Boolean} show-minutes 是否显示倒计时的"分"部分(默认true)
* @property {Boolean} show-seconds 是否显示倒计时的"秒"部分(默认true)
* @event {Function} end 倒计时结束
* @event {Function} change 每秒触发一次,回调为当前剩余的倒计秒数
* @example <u-count-down ref="uCountDown" :timestamp="86400" :autoplay="false"></u-count-down>
*/
const emit = defineEmits(['end', 'change']);
const props = defineProps(CountDownProps);
const d = ref('00'); // 天
const h = ref('00'); // 小时
const i = ref('00'); // 分钟
const s = ref('00'); // 秒
const timer = ref<number | ReturnType<typeof setInterval> | null>(null); // 定时器
const seconds = ref(0);
/**
* 倒计时item的样式,item为分别的时分秒部分的数字
*/
const itemStyle = computed(() => {
const style: Record<string, string> = {};
if (props.height) {
style.height = props.height + 'rpx';
style.width = props.height + 'rpx';
}
if (props.showBorder) {
style.borderStyle = 'solid';
style.borderColor = props.borderColor;
style.borderWidth = '1px';
}
if (props.bgColor) {
style.backgroundColor = props.bgColor;
}
return style;
});
/**
* 倒计时数字的样式
*/
const letterStyle = computed(() => {
const style: Record<string, string> = {};
if (props.fontSize) style.fontSize = props.fontSize + 'rpx';
if (props.color) style.color = props.color;
return style;
});
// 监听时间戳的变化
watch(
() => props.timestamp,
(newVal, oldVal) => {
// 如果倒计时间发生变化,清除定时器,重新开始倒计时
clearTimer();
start();
}
);
onMounted(() => {
// 如果自动倒计时
if (props.autoplay && props.timestamp) {
start();
}
});
/**
* 倒计时
*/
function start() {
// 避免可能出现的倒计时重叠情况
clearTimer();
if (Number(props.timestamp) <= 0) return;
seconds.value = Number(props.timestamp);
formatTime(seconds.value);
timer.value = setInterval(() => {
seconds.value--;
// 发出change事件
emit('change', seconds.value);
if (seconds.value < 0) {
end();
return;
}
formatTime(seconds.value);
}, 1000);
}
/**
* 格式化时间
*/
function formatTime(sec: number) {
// 小于等于0的话,结束倒计时
if (sec <= 0) end();
let dayNum = Math.floor(sec / (60 * 60 * 24));
let hourNum = Math.floor(sec / (60 * 60)) - dayNum * 24;
// showHour为需要显示的小时
let showHour: string;
// 判断是否显示“天”参数,如果不显示,将天部分的值,加入到小时中
// hour为给后面计算秒和分等用的(基于显示天的前提下计算)
if (props.showDays) {
showHour = hourNum < 10 ? '0' + hourNum : String(hourNum);
} else {
// 如果不显示天数,将“天”部分的时间折算到小时中去
// 如果小于10,在前面补上一个"0"
const hourTotal = Math.floor(sec / (60 * 60));
showHour = hourTotal < 10 ? '0' + hourTotal : String(hourTotal);
}
let minuteNum = Math.floor(sec / 60) - hourNum * 60 - dayNum * 24 * 60;
let secondNum = Math.floor(sec) - dayNum * 24 * 60 * 60 - hourNum * 60 * 60 - minuteNum * 60;
const minute = minuteNum < 10 ? '0' + minuteNum : String(minuteNum);
const second = secondNum < 10 ? '0' + secondNum : String(secondNum);
const day = dayNum < 10 ? '0' + dayNum : String(dayNum);
d.value = day;
h.value = showHour;
i.value = minute;
s.value = second;
}
/**
* 停止倒计时
*/
function end() {
clearTimer();
emit('end', {});
}
/**
* 清除定时器
*/
function clearTimer() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
defineExpose({
start,
end
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-countdown {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
align-items: center;
}
.u-countdown-item {
@include vue-flex;
align-items: center;
justify-content: center;
padding: 2rpx;
border-radius: 6rpx;
white-space: nowrap;
transform: translateZ(0);
}
.u-countdown-time {
margin: 0;
padding: 0;
line-height: 1;
}
.u-countdown-colon {
@include vue-flex;
justify-content: center;
padding: 0 5rpx;
line-height: 1;
align-items: center;
padding-bottom: 4rpx;
}
.u-countdown-scale {
transform: scale(0.9);
transform-origin: center center;
}
</style>
@@ -0,0 +1,42 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-count-to 数字滚动 Props
* @description 该组件一般用于需要滚动数字到某一个值的场景,目标要求是一个递增的值。
*/
export const CountToProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 开始的数值,默认从0增长到某一个数 */
startVal: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 要滚动的目标数值,必须 */
endVal: { type: [Number, String] as PropType<number | string>, default: 0, required: true },
/** 滚动到目标数值的动画持续时间,单位为毫秒(ms) */
duration: { type: [Number, String] as PropType<number | string>, default: 2000 },
/** 设置数值后是否自动开始滚动 */
autoplay: { type: Boolean, default: true },
/** 要显示的小数位数 */
decimals: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 是否在即将到达目标数值的时候,使用缓慢滚动的效果 */
useEasing: { type: Boolean, default: true },
/** 十进制分割符号 */
decimal: { type: [Number, String] as PropType<number | string>, default: '.' },
/** 字体颜色 */
color: { type: String, default: 'var(--u-main-color)' },
/** 字体大小 */
fontSize: { type: [Number, String] as PropType<number | string>, default: 50 },
/** 是否加粗字体 */
bold: { type: Boolean, default: false },
/** 千位分隔符,类似金额的分割(¥23,321.05中的",") */
separator: { type: String, default: '' }
};
export type CountToProps = ExtractPropTypes<typeof CountToProps>;
@@ -0,0 +1,266 @@
<template>
<view
class="u-count-num"
:class="customClass"
:style="
$u.toStyle(
{
fontSize: props.fontSize + 'rpx',
fontWeight: props.bold ? 'bold' : 'normal',
color: props.color
},
customStyle
)
"
>
{{ displayValue }}
</view>
</template>
<script lang="ts">
export default {
name: 'u-count-to',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { CountToProps } from './types';
import { $u } from '../../';
/**
* countTo 数字滚动
* @description 该组件一般用于需要滚动数字到某一个值的场景,目标要求是一个递增的值。
* @tutorial https://uviewpro.cn/zh/components/countTo.html
* @property {String | Number} start-val 开始值
* @property {String | Number} end-val 结束值
* @property {String | Number} duration 滚动过程所需的时间,单位ms(默认2000)
* @property {Boolean} autoplay 是否自动开始滚动(默认true)
* @property {String | Number} decimals 要显示的小数位数,见官网说明(默认0)
* @property {Boolean} use-easing 滚动结束时,是否缓动结尾,见官网说明(默认true)
* @property {String} separator 千位分隔符,见官网说明
* @property {String} color 字体颜色(默认var(--u-main-color))
* @property {String | Number} font-size 字体大小,单位rpx(默认50)
* @property {Boolean} bold 字体是否加粗(默认false)
* @event {Function} end 数值滚动到目标值时触发
* @example <u-count-to ref="uCountTo" :end-val="endVal" :autoplay="autoplay"></u-count-to>
*/
const emit = defineEmits(['end']);
const props = defineProps(CountToProps);
const localStartVal = ref(Number(props.startVal));
const displayValue = ref(formatNumber(props.startVal));
const printVal = ref<number | null>(null);
const paused = ref(false); // 是否暂停
const localDuration = ref(Number(props.duration));
const startTime = ref<number | null>(null); // 开始的时间
const timestamp = ref<number | null>(null); // 时间戳
const remaining = ref<number | null>(null); // 停留的时间
const rAF = ref<number | null>(null);
const lastTime = ref(0); // 上一次的时间
/**
* 是否倒计时
*/
const countDown = computed(() => Number(props.startVal) > Number(props.endVal));
watch(
() => props.startVal,
() => {
if (props.autoplay) start();
}
);
watch(
() => props.endVal,
() => {
if (props.autoplay) start();
}
);
onMounted(() => {
if (props.autoplay) start();
});
/**
* 缓动函数
*/
function easingFn(t: number, b: number, c: number, d: number): number {
return (c * (-Math.pow(2, (-10 * t) / d) + 1) * 1024) / 1023 + b;
}
/**
* requestAnimationFrame polyfill
*/
function requestAnimationFrame(callback: (ts: number) => void): number {
const currTime = new Date().getTime();
// 为了使setTimteout的尽可能的接近每秒60帧的效果
const timeToCall = Math.max(0, 16 - (currTime - lastTime.value));
const id = setTimeout(() => {
callback(currTime + timeToCall);
}, timeToCall);
lastTime.value = currTime + timeToCall;
return id as unknown as number;
}
/**
* 取消动画帧
*/
function cancelAnimationFrame(id: number | null) {
if (id) clearTimeout(id);
}
/**
* 开始滚动数字
*/
function start() {
localStartVal.value = Number(props.startVal);
startTime.value = null;
localDuration.value = Number(props.duration);
paused.value = false;
rAF.value = requestAnimationFrame(count);
}
/**
* 暂停/恢复滚动
* 暂定状态,重新再开始滚动;或者滚动状态下,暂停
*/
function reStart() {
if (paused.value) {
resume();
paused.value = false;
} else {
stop();
paused.value = true;
}
}
/**
* 暂停
*/
function stop() {
cancelAnimationFrame(rAF.value);
}
/**
* 重新开始(暂停的情况下)
*/
function resume() {
startTime.value = null;
localDuration.value = remaining.value || Number(props.duration);
localStartVal.value = printVal.value || Number(props.startVal);
rAF.value = requestAnimationFrame(count);
}
/**
* 重置
*/
function reset() {
startTime.value = null;
cancelAnimationFrame(rAF.value);
displayValue.value = formatNumber(props.startVal);
}
/**
* 数字滚动动画主逻辑
*/
function count(ts: number) {
if (!startTime.value) startTime.value = ts;
timestamp.value = ts;
const progress = ts - (startTime.value || 0);
remaining.value = localDuration.value - progress;
let val: number;
if (props.useEasing) {
if (countDown.value) {
val =
localStartVal.value -
easingFn(progress, 0, localStartVal.value - Number(props.endVal), localDuration.value);
} else {
val = easingFn(
progress,
localStartVal.value,
Number(props.endVal) - localStartVal.value,
localDuration.value
);
}
} else {
if (countDown.value) {
val = localStartVal.value - (localStartVal.value - Number(props.endVal)) * (progress / localDuration.value);
} else {
val = localStartVal.value + (Number(props.endVal) - localStartVal.value) * (progress / localDuration.value);
}
}
if (countDown.value) {
val = val < Number(props.endVal) ? Number(props.endVal) : val;
} else {
val = val > Number(props.endVal) ? Number(props.endVal) : val;
}
printVal.value = val;
displayValue.value = formatNumber(val);
if (progress < localDuration.value) {
rAF.value = requestAnimationFrame(count);
} else {
emit('end');
}
}
/**
* 判断是否数字
*/
function isNumber(val: unknown): boolean {
return !isNaN(parseFloat(String(val)));
}
/**
* 格式化数字
*/
function formatNumber(num: unknown): string {
// 将num转为Number类型,因为其值可能为字符串数值,调用toFixed会报错
let n = Number(num);
n = Number(n.toFixed(Number(props.decimals)));
let str = n + '';
const x = str.split('.');
let x1 = x[0];
const x2 = x.length > 1 ? String(props.decimal) + x[1] : '';
const rgx = /(\d+)(\d{3})/;
if (props.separator && !isNumber(props.separator)) {
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + props.separator + '$2');
}
}
return x1 + x2;
}
// 销毁时清理动画帧
onUnmounted(() => {
cancelAnimationFrame(rAF.value);
});
// 暴露给父组件的函数
defineExpose({
start,
stop,
reStart,
resume,
reset
});
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-count-num {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
text-align: center;
}
</style>
@@ -0,0 +1,41 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { ThemeType } from '../../types/global';
/**
* u-divider 分割线 Props
* @description 区隔内容的分割线,一般用于页面底部"没有更多"的提示。
*/
export const DividerProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 单一边divider横线的宽度(数值),单位rpx。或者百分比 */
halfWidth: { type: [Number, String] as PropType<number | string>, default: 150 },
/** divider横线的颜色,如设置 */
borderColor: { type: String, default: 'var(--u-border-color)' },
/** 主题色,可以是primary|info|success|warning|error之一值 */
type: { type: String as PropType<ThemeType>, default: 'primary' },
/** 文字颜色 */
color: { type: String, default: 'var(--u-tips-color)' },
/** 文字大小,单位rpx */
fontSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/** 整个divider的背景颜色 */
bgColor: { type: String, default: 'var(--u-bg-white)' },
/** 整个divider的高度单位rpx */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 上边距 */
marginTop: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 下边距 */
marginBottom: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 是否使用slot传入内容,如果不用slot传入内容,先的中间就不会有空隙 */
useSlot: { type: Boolean, default: true }
};
export type DividerProps = ExtractPropTypes<typeof DividerProps>;
@@ -0,0 +1,146 @@
<template>
<view
class="u-divider"
:class="customClass"
:style="
$u.toStyle(
{
height: height === 'auto' ? 'auto' : height + 'rpx',
backgroundColor: bgColor,
marginBottom: marginBottom + 'rpx',
marginTop: marginTop + 'rpx'
},
customStyle
)
"
@tap="onClick"
>
<view
class="u-divider-line"
:class="[type ? 'u-divider-line--bordercolor--' + type : '']"
:style="lineStyle"
></view>
<view
v-if="useSlot"
class="u-divider-text"
:style="{
color: color,
fontSize: fontSize + 'rpx'
}"
>
<slot />
</view>
<view
class="u-divider-line"
:class="[type ? 'u-divider-line--bordercolor--' + type : '']"
:style="lineStyle"
></view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-divider',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { DividerProps } from './types';
import { $u } from '../../';
/**
* divider 分割线
* @description 区隔内容的分割线,一般用于页面底部"没有更多"的提示。
* @tutorial https://uviewpro.cn/zh/components/divider.html
* @property {String Number} half-width 文字左或右边线条宽度,数值或百分比,数值时单位为rpx
* @property {String} border-color 线条颜色,优先级高于type(默认var(--u-border-color))
* @property {String} color 文字颜色(默认var(--u-tips-color))
* @property {String Number} fontSize 字体大小,单位rpx(默认26)
* @property {String} bg-color 整个divider的背景颜色(默认呢var(--u-bg-white))
* @property {String Number} height 整个divider的高度,单位rpx(默认40)
* @property {String} type 将线条设置主题色(默认primary)
* @property {Boolean} useSlot 是否使用slot传入内容,如果不传入,中间不会有空隙(默认true)
* @property {String Number} margin-top 与前一个组件的距离,单位rpx(默认0)
* @property {String Number} margin-bottom 与后一个组件的距离,单位rpx(0)
* @event {Function} click divider组件被点击时触发
* @example <u-divider color="var(--u-type-error)">长河落日圆</u-divider>
*/
const props = defineProps(DividerProps);
const emit = defineEmits(['click']);
/**
* divider横线样式
*/
const lineStyle = computed(() => {
let style: Record<string, string> = {};
if (String(props.halfWidth).indexOf('%') !== -1) style.width = String(props.halfWidth);
else style.width = props.halfWidth + 'rpx';
// borderColor优先级高于type值
if (props.borderColor) style.borderColor = props.borderColor;
return style;
});
/**
* divider组件被点击时触发
*/
function onClick() {
emit('click');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-divider {
width: 100%;
position: relative;
text-align: center;
@include vue-flex;
justify-content: center;
align-items: center;
overflow: hidden;
flex-direction: row;
}
.u-divider-line {
border-bottom: 1px solid $u-border-color;
transform: scale(1, 0.5);
transform-origin: center;
&--bordercolor--primary {
border-color: $u-type-primary;
}
&--bordercolor--success {
border-color: $u-type-success;
}
&--bordercolor--error {
border-color: $u-type-primary;
}
&--bordercolor--info {
border-color: $u-type-info;
}
&--bordercolor--warning {
border-color: $u-type-warning;
}
}
.u-divider-text {
white-space: nowrap;
padding: 0 16rpx;
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
}
</style>
@@ -0,0 +1,40 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { OptionType } from '../../types/global';
/**
* u-dropdown-item 下拉菜单项 Props
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
* @property {String|Number|Array} modelValue 双向绑定选项卡选择值
* @property {String|Number} title 菜单项标题
* @property {OptionType[]} options 选项数据,如果传入了默认slot,此参数无效
* @property {Boolean} disabled 是否禁用此选项卡(默认false)
* @property {String|Number} height 弹窗下拉内容的高度(内容超出将会滚动)(默认auto)
* @property {Boolean} show 控制是否显示菜单项(默认true)
*/
export const DropdownItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 当前选中项的value值 */
modelValue: { type: [Number, String, Array] as PropType<number | string | any[]>, default: '' },
/** 菜单项标题 */
title: { type: [String, Number] as PropType<string | number>, default: '' },
/** 选项数据,如果传入了默认slot,此参数无效 */
options: { type: Array as PropType<OptionType[]>, default: () => [] },
/** 是否禁用此菜单项 */
disabled: { type: Boolean, default: false },
/** 下拉弹窗的高度 */
height: { type: [Number, String] as PropType<number | string>, default: 'auto' },
/** 控制是否显示菜单项 */
show: { type: Boolean, default: true }
};
export type DropdownItemProps = ExtractPropTypes<typeof DropdownItemProps>;
@@ -0,0 +1,124 @@
<template>
<view
v-if="show && active"
class="u-dropdown-item"
:class="customClass"
:style="$u.toStyle(customStyle)"
@touchmove.stop.prevent
@tap.stop.prevent
>
<block v-if="!slots.default && !slots.$default">
<scroll-view :scroll-y="true" :style="{ height: $u.addUnit(String(height)) }">
<view class="u-dropdown-item__options">
<u-cell-group>
<u-cell-item
@click="cellClick(item.value)"
:arrow="false"
:title="item.label"
v-for="(item, index) in options"
:key="index"
:title-style="{ color: modelValue == item.value ? activeColor : inactiveColor }"
>
<u-icon
v-if="modelValue == item.value"
name="checkbox-mark"
:color="activeColor"
size="32"
></u-icon>
</u-cell-item>
</u-cell-group>
</view>
</scroll-view>
</block>
<slot v-else />
</view>
</template>
<script lang="ts">
export default {
name: 'u-dropdown-item',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, useSlots, watch } from 'vue';
import { $u, useChildren } from '../..';
import { DropdownItemProps } from './types';
import uCellGroup from '../u-cell-group/u-cell-group.vue';
import uCellItem from '../u-cell-item/u-cell-item.vue';
import uIcon from '../u-icon/u-icon.vue';
/**
* dropdown-item 下拉菜单
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
* @tutorial https://uviewpro.cn/zh/components/dropdown.html
* @property {String | Number} v-model 双向绑定选项卡选择值
* @property {String} title 菜单项标题
* @property {Array[Object]} options 选项数据,如果传入了默认slot,此参数无效
* @property {Boolean} disabled 是否禁用此选项卡(默认false)
* @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
* @property {String | Number} height 弹窗下拉内容的高度(内容超出将会滚动)(默认auto)
* @property {Boolean} show 控制是否显示菜单项(默认true)
* @example <u-dropdown-item title="标题"></u-dropdown-item>
*/
// props 定义
const props = defineProps(DropdownItemProps);
// emits 定义
const emit = defineEmits(['update:modelValue', 'change']);
// 插槽
const slots = useSlots();
const { parentExposed, emitToParent } = useChildren('u-dropdown-item', 'u-dropdown');
// 当前项是否处于展开状态
const active = ref(false);
// 激活时左边文字和右边对勾图标的颜色
const activeColor = computed(() => parentExposed.value?.props?.activeColor || $u.color.primary);
// 未激活时左边文字和右边对勾图标的颜色
const inactiveColor = computed(() => parentExposed.value?.props?.inactiveColor || $u.color.contentColor);
// 监听props变化,通知父组件重新初始化
const propsChange = computed(() => `${props.title}-${props.disabled}-${props.show}`);
// 监听propsChange变化,通知父组件重新init
watch(propsChange, () => {
emitToParent('init');
});
/**
* cell被点击
* @param value 选中值
*/
function cellClick(value: string | number | any) {
// 修改通过v-model绑定的值
emit('update:modelValue', value);
// 通知父组件(u-dropdown)收起菜单
emitToParent('close');
// 发出事件,抛出当前勾选项的value
emit('change', value);
}
function setActive(value: boolean) {
active.value = value;
}
defineExpose({
cellClick,
setActive,
props
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
</style>
@@ -0,0 +1,67 @@
import type { ExtractPropTypes, PropType } from 'vue';
import { getColor } from '../../';
import zIndex from '../../libs/config/zIndex';
/**
* u-dropdown 下拉菜单 Props
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景
*/
export const DropdownProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 菜单标题和选项的激活态颜色 */
activeColor: { type: String, default: () => getColor('primary') },
/** 菜单标题和选项的未激活态颜色 */
inactiveColor: { type: String, default: () => getColor('contentColor') },
/** 点击遮罩是否关闭菜单 */
closeOnClickMask: { type: Boolean, default: true },
/** 点击当前激活项标题是否关闭菜单 */
closeOnClickSelf: { type: Boolean, default: true },
/** 过渡时间 */
duration: { type: [Number, String] as PropType<number | string>, default: 300 },
/** 标题菜单的高度,单位任意,数值默认为rpx单位 */
height: { type: [Number, String] as PropType<number | string>, default: 80 },
/** 是否显示下边框 */
borderBottom: { type: Boolean, default: false },
/** 标题的字体大小 */
titleSize: { type: [Number, String] as PropType<number | string>, default: 28 },
/** 下拉出来的内容部分的圆角值 */
borderRadius: { type: [Number, String] as PropType<number | string>, default: 0 },
/** 菜单右侧的icon图标 */
menuIcon: { type: String, default: 'arrow-down' },
/** 菜单右侧图标的大小 */
menuIconSize: { type: [Number, String] as PropType<number | string>, default: 26 },
/**
* 是否使用fixed定位,开启后下拉菜单固定在顶部,
* 自动适配状态栏和导航栏高度,并生成占位区域防止页面塌陷
*/
fixed: { type: Boolean, default: false },
/**
* fixed定位时,在自动计算的偏移量基础上的额外顶部偏移量(px),用于用户微调位置。
* 实际top = statusBarHeight + navbarHeight + offsetTop
*/
offsetTop: { type: [Number, String] as PropType<number | string>, default: 0 },
/**
* fixed定位时,状态栏和导航栏所占的总高度(px)。
* 默认不设置(空字符串),组件会自动获取系统状态栏高度 + 默认导航栏高度(44px)。
* 设置任意数值(包括0)则使用用户指定值,不再自动计算。
*/
navbarHeight: { type: [Number, String] as PropType<number | string>, default: '' },
/**
* 沉浸式模式,仅在fixed下生效。
* 开启后不生成占位区域,允许页面内容延伸到dropdown下方(类似沉浸式导航栏)
*/
immersive: { type: Boolean, default: false },
/** fixed定位时的z-index值 */
zIndex: { type: [Number, String] as PropType<number | string>, default: zIndex.dropdown }
};
export type DropdownProps = ExtractPropTypes<typeof DropdownProps>;
@@ -0,0 +1,436 @@
<template>
<view class="u-dropdown-wrapper">
<view class="u-dropdown" :style="dropdownRootStyle" :class="[{ 'u-dropdown__fixed': fixed }, customClass]">
<view
class="u-dropdown__menu"
:style="{ height: $u.addUnit(height) }"
:class="{ 'u-border-bottom': borderBottom }"
>
<view
class="u-dropdown__menu__item"
v-for="(item, index) in menuList"
:key="index"
@tap.stop="menuClick(index)"
>
<view class="u-flex">
<text
class="u-dropdown__menu__item__text"
:style="{
color: item.disabled
? 'var(--u-light-color)'
: index === current || highlightIndex == index
? activeColor
: inactiveColor,
fontSize: $u.addUnit(titleSize)
}"
>{{ item.title }}</text
>
<view
class="u-dropdown__menu__item__arrow"
:class="{
'u-dropdown__menu__item__arrow--rotate': index === current
}"
>
<u-icon
:custom-style="{ display: 'flex' }"
:name="menuIcon"
:size="$u.addUnit(menuIconSize)"
:color="
index === current || highlightIndex == index ? activeColor : 'var(--u-light-color)'
"
></u-icon>
</view>
</view>
</view>
</view>
<view
class="u-dropdown__content"
:style="[
contentStyle,
{
transition: `opacity ${Number(duration) / 1000}s linear`,
top: $u.addUnit(height),
height: contentHeight + 'px'
}
]"
@tap="maskClick"
@touchmove.stop.prevent
>
<view @tap.stop.prevent class="u-dropdown__content__popup" :style="[popupStyle]">
<slot></slot>
</view>
<view class="u-dropdown__content__mask"></view>
</view>
</view>
<!-- fixed定位时的占位区域,防止页面内容塌陷 -->
<view
v-if="fixed && !immersive"
class="u-dropdown__placeholder"
:style="{ height: dropdownPlaceholderHeight + 'px' }"
></view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-dropdown',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue';
import { $u, useParent } from '../..';
import { DropdownProps } from './types';
import uIcon from '../u-icon/u-icon.vue';
/**
* dropdown 下拉菜单
* @description 该组件一般用于向下展开菜单,同时可切换多个选项卡的场景。
* 支持fixed定位,自动适配状态栏和导航栏高度,并提供占位区域防止页面塌陷。
* @tutorial https://uviewpro.cn/zh/components/dropdown.html
* @property {String} active-color 标题和选项卡选中的颜色(默认主题色primary)
* @property {String} inactive-color 标题和选项卡未选中的颜色(默认var(--u-content-color))
* @property {Boolean} close-on-click-mask 点击遮罩是否关闭菜单(默认true)
* @property {Boolean} close-on-click-self 点击当前激活项标题是否关闭菜单(默认true)
* @property {String | Number} duration 选项卡展开和收起的过渡时间,单位ms(默认300)
* @property {String | Number} height 标题菜单的高度,单位任意(默认80)
* @property {String | Number} border-radius 菜单展开内容下方的圆角值,单位任意(默认0)
* @property {Boolean} border-bottom 标题菜单是否显示下边框(默认false)
* @property {String | Number} title-size 标题的字体大小,单位任意,数值默认为rpx单位(默认28)
* @property {Boolean} fixed 是否使用fixed定位固定在顶部,自动适配状态栏和导航栏高度(默认false)
* @property {String | Number} offset-top fixed定位时,在自动计算的偏移量基础上的额外顶部偏移量,单位px(默认0)
* @property {String | Number} navbar-height fixed定位时,状态栏+导航栏的总高度(px)。不设置则自动获取,设置任意数值(包括0)则使用指定值
* @property {Boolean} immersive 沉浸式模式,仅在fixed下生效,开启后不生成占位区域(默认false)
* @property {String | Number} z-index fixed定位时的z-index值(默认985)
* @event {Function} open 下拉菜单被打开时触发
* @event {Function} close 下拉菜单被关闭时触发
* @example <u-dropdown :fixed="true"></u-dropdown>
*/
const props = defineProps(DropdownProps);
const emit = defineEmits(['open', 'close']);
const { children } = useParent('u-dropdown');
// 菜单列表
const menuList = ref<any[]>([]);
// 下拉菜单的状态
const active = ref(false);
// 当前激活菜单索引
// 当前是第几个菜单处于激活状态,小程序中此处不能写成false或者"",否则后续将current赋值为0,
// 无能的TX没有使用===而是使用==判断,导致程序认为前后二者没有变化,从而不会触发视图更新
const current = ref<number>(99999);
// 外层内容样式
const contentStyle = ref<any>({ zIndex: -1, opacity: 0 });
// 高亮菜单索引
const highlightIndex = ref<number>(99999);
// 下拉内容高度
const contentHeight = ref<number>(0);
// ==================== fixed定位相关 ====================
// 获取系统状态栏高度
const systemInfo = uni.getSystemInfoSync();
const statusBarHeight = ref(0);
// #ifdef APP-HARMONY || MP-WEIXIN
const windowInfo = uni.getWindowInfo();
statusBarHeight.value = windowInfo.statusBarHeight || 0;
// #endif
// #ifndef APP-HARMONY || MP-WEIXIN
statusBarHeight.value = systemInfo.statusBarHeight || 0;
// #endif
// 默认导航栏高度(与 u-navbar 保持一致)
const defaultNavbarHeight = computed(() => {
// #ifdef APP || H5
return 44;
// #endif
// #ifdef MP
return systemInfo.platform === 'ios' ? 44 : 48;
// #endif
});
/**
* 计算 fixed 定位时的总偏移高度(px)
* - 若用户通过 navbarHeight 指定了任意数值(包括0),则使用用户指定值
* - 否则(空字符串)自动计算:状态栏高度 + 默认导航栏高度
* - 最后加上 offsetTop 作为额外偏移
*/
const dropdownOffsetHeight = computed(() => {
let baseHeight: number;
if (props.navbarHeight !== '' && props.navbarHeight !== undefined && props.navbarHeight !== null) {
// 用户手动指定了 navbar 高度(包括0),直接使用
baseHeight = Number(props.navbarHeight);
} else {
// 自动计算:状态栏高度 + 默认导航栏高度
baseHeight = statusBarHeight.value + defaultNavbarHeight.value;
}
// 加上用户设置的额外 offsetTop 偏移
return baseHeight + Number(props.offsetTop);
});
/**
* 占位区域高度 = dropdown菜单栏自身高度(px)
* 不包含状态栏+导航栏偏移,因为页面上已有 navbar 等元素占据该空间
*/
const dropdownPlaceholderHeight = computed(() => {
return uni.upx2px(Number(props.height));
});
/**
* dropdown 根节点样式
* fixed 时设置 top 和 z-index
*/
const dropdownRootStyle = computed(() => {
const style: Record<string, any> = {};
// 兼容头条小程序
// #ifdef MP-TOUTIAO
style.width = '100vw';
// #endif
// fixed 定位时,计算顶部偏移和 z-index
if (props.fixed) {
style.top = dropdownOffsetHeight.value + 'px';
style.zIndex = props.zIndex ? Number(props.zIndex) : $u.zIndex.dropdown;
}
return style;
});
// 下拉出来部分的样式
const popupStyle = computed<any>(() => {
const style: any = {};
// 进行Y轴位移,展开状态时,恢复原位。收齐状态时,往上位移100%,进行隐藏
style.transform = `translateY(${active.value ? 0 : '-100%'})`;
style['transition-duration'] = Number(props.duration) / 1000 + 's';
style.borderRadius = `0 0 ${$u.addUnit(props.borderRadius)} ${$u.addUnit(props.borderRadius)}`;
return style;
});
// 生命周期
onMounted(() => {
getContentHeight();
});
/**
* 初始化所有子组件
* 当某个子组件内容变化时,触发父组件的init,父组件再让每一个子组件重新初始化一遍
* 以保证数据的正确性
*/
function init() {
menuList.value = [];
children.forEach(child => {
// 过滤不显示的项
const show = child?.getExposed()?.props.show !== false;
if (!show) return;
menuList.value.push({
title: child?.getExposed()?.props.title ?? '',
disabled: child?.getExposed()?.props.disabled ?? false,
childIndex: children.indexOf(child)
});
});
}
/**
* 点击菜单
* @param index 菜单索引
*/
function menuClick(index: number) {
// 判断是否被禁用
if (menuList.value[index]?.disabled) return;
// 如果点击时的索引和当前激活项索引相同,意味着点击了激活项,需要收起下拉菜单
if (index === current.value && props.closeOnClickSelf) {
close();
// 等动画结束后,再移除下拉菜单中的内容,否则直接移除,也就没有下拉菜单收起的效果了
const childIndex = menuList.value[index]?.childIndex; //避免访问到show为false的项
setTimeout(() => {
if (childIndex !== undefined && children[childIndex]) {
children[childIndex]?.getExposed()?.setActive(false);
}
}, Number(props.duration));
return;
}
open(index);
}
/**
* 打开下拉菜单
* @param index 菜单索引
*/
function open(index: number) {
// 嵌套popup使用时可能获取不到正确的高度,重新计算
if (contentHeight.value < 1) getContentHeight();
// 重置高亮索引,否则会造成多个菜单同时高亮
// highlightIndex.value = 9999;
// 展开时,设置下拉内容的样式
contentStyle.value = { zIndex: 11, display: 'block' };
// 标记展开状态以及当前展开项的索引
active.value = true;
current.value = index;
// 历遍所有的子元素,将索引匹配的项标记为激活状态,因为子元素是通过v-if控制切换的
// 之所以不是因display: none,是因为nvue没有display这个属性
const childIndex = menuList.value[index]?.childIndex; //避免访问到show为false的项
children.forEach((child, idx) => {
child?.getExposed()?.setActive(childIndex === idx ? true : false);
});
emit('open', current.value);
}
/**
* 设置下拉菜单处于收起状态
*/
function close() {
emit('close', current.value);
// 设置为收起状态,同时current归位,设置为空字符串
active.value = false;
current.value = 99999;
// 下拉内容的样式进行调整,不透明度设置为0,fixed模式下隐藏display避免遮挡页面元素
if (props.fixed) {
contentStyle.value = { zIndex: -1, opacity: 0, display: 'none' };
} else {
contentStyle.value = { zIndex: -1, opacity: 0 };
}
}
/**
* 点击遮罩
*/
function maskClick() {
// 如果不允许点击遮罩,直接返回
if (!props.closeOnClickMask) return;
close();
}
/**
* 外部手动设置某个菜单高亮
* @param index 菜单索引
*/
function highlight(index?: number) {
highlightIndex.value = index !== undefined ? index : 99999;
}
/**
* 获取下拉菜单内容的高度
* 这里的原理为,因为dropdown组件是相对定位的,它的下拉出来的内容,必须给定一个高度
* 才能让遮罩占满菜单一下,直到屏幕底部的高度
* this.$u.sys()为uView封装的获取设备信息的方法
*/
function getContentHeight() {
const windowHeight = $u.sys().windowHeight;
contentHeight.value = windowHeight;
// $u.getRect('.u-dropdown__menu', instance).then((res: any) => {
// // 这里获取的是dropdown的尺寸,在H5上,uniapp获取尺寸是有bug的(以前提出修复过,后来又出现了此bug,目前hx2.8.11版本)
// // H5端bug表现为元素尺寸的top值为导航栏底部到到元素的上边沿的距离,但是元素的bottom值确是导航栏顶部到元素底部的距离
// // 二者是互相矛盾的,本质原因是H5端导航栏非原生,uni的开发者大意造成
// // 这里取菜单栏的botton值合理的,不能用res.top,否则页面会造成滚动
// contentHeight.value = windowHeight - res.bottom;
// });
}
onMounted(() => {
nextTick(() => {
init();
});
});
// 暴露方法
defineExpose({
props,
init,
close,
open,
highlight,
getContentHeight,
children,
menuList
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-dropdown-wrapper {
width: 100%;
}
.u-dropdown {
flex: 1;
width: 100%;
position: relative;
&__fixed {
position: fixed;
left: 0;
right: 0;
background-color: #fff;
}
&__placeholder {
width: 100%;
}
&__menu {
@include vue-flex;
position: relative;
z-index: 11;
height: 80rpx;
&__item {
flex: 1;
@include vue-flex;
justify-content: center;
align-items: center;
&__text {
font-size: 28rpx;
color: $u-content-color;
}
&__arrow {
margin-left: 6rpx;
transition: transform 0.3s;
align-items: center;
@include vue-flex;
&--rotate {
transform: rotate(180deg);
}
}
}
}
&__content {
position: absolute;
width: 100%;
left: 0px;
bottom: 0;
overflow: hidden;
&__mask {
position: absolute;
z-index: 9;
background: rgba(0, 0, 0, 0.3);
width: 100%;
left: 0;
top: 0;
bottom: 0;
}
&__popup {
position: relative;
z-index: 10;
transition: all 0.3s;
transform: translate3D(0, -100%, 0);
overflow: hidden;
}
}
}
</style>
@@ -0,0 +1,46 @@
import type { ExtractPropTypes, PropType } from 'vue';
/**
* u-empty 组件 Props 类型定义
*/
export const EmptyProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 图标路径 */
src: { type: String, default: '' },
/** 提示文字 */
text: { type: String, default: '' },
/** 文字颜色 */
color: { type: String, default: 'var(--u-light-color)' },
/** 图标的颜色 */
iconColor: { type: String, default: 'var(--u-light-color)' },
/** 图标的大小 */
iconSize: { type: [String, Number] as PropType<string | number>, default: 120 },
/** 文字大小,单位rpx */
fontSize: { type: [String, Number] as PropType<string | number>, default: 26 },
/** 选择预置的图标类型 */
mode: { type: String, default: 'data' },
/** 图标宽度,单位rpx */
imgWidth: { type: [String, Number] as PropType<string | number>, default: 120 },
/** 图标高度,单位rpx */
imgHeight: { type: [String, Number] as PropType<string | number>, default: 'auto' },
/** 是否显示组件 */
show: { type: Boolean, default: true },
/** 组件距离上一个元素之间的距离 */
marginTop: { type: [String, Number] as PropType<string | number>, default: 0 },
/** 图标自定义样式 */
iconStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) }
};
/**
* u-empty 组件 Props 类型
*/
export type EmptyProps = ExtractPropTypes<typeof EmptyProps>;
@@ -0,0 +1,109 @@
<template>
<view
v-if="show"
class="u-empty"
:class="customClass"
:style="$u.toStyle({ marginTop: marginTop + 'rpx' }, customStyle)"
>
<u-icon
:name="src ? src : 'empty-' + mode"
:custom-style="iconStyle"
:label="text ? text : (icons as any)[mode]"
label-pos="bottom"
:label-color="color"
:label-size="fontSize"
:size="iconSize"
:color="iconColor"
margin-top="14"
></u-icon>
<view class="u-slot-wrap">
<slot name="bottom"></slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-empty',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { EmptyProps } from './types';
import { $u, useLocale } from '../../';
import { computed } from 'vue';
import uIcon from '../u-icon/u-icon.vue';
const { t } = useLocale();
/**
* empty 内容为空
* @description 该组件用于需要加载内容,但是加载的第一页数据就为空,提示一个"没有内容"的场景, 我们精心挑选了十几个场景的图标,方便您使用。
* @tutorial https://uviewpro.cn/zh/components/empty.html
* @property {String} color 文字颜色(默认var(--u-light-color))
* @property {String} text 文字提示(默认“无内容”)
* @property {String} src 自定义图标路径,如定义,mode参数会失效
* @property {String Number} font-size 提示文字的大小,单位rpx(默认28)
* @property {String} mode 内置的图标,见官网说明(默认data)
* @property {String Number} img-width 图标的宽度,单位rpx(默认240)
* @property {String} img-height 图标的高度,单位rpx(默认auto)
* @property {String Number} margin-top 组件距离上一个元素之间的距离(默认0)
* @property {Boolean} show 是否显示组件(默认true)
* @event {Function} click 点击组件时触发
* @event {Function} close 点击关闭按钮时触发
* @example <u-empty text="所谓伊人,在水一方" mode="list"></u-empty>
*/
const props = defineProps(EmptyProps);
/**
* 预置图标对应的提示文字
*/
const icons = computed(() => {
return {
car: t('uEmpty.car'),
page: t('uEmpty.page'),
search: t('uEmpty.search'),
address: t('uEmpty.address'),
wifi: t('uEmpty.wifi'),
order: t('uEmpty.order'),
coupon: t('uEmpty.coupon'),
favor: t('uEmpty.favor'),
permission: t('uEmpty.permission'),
history: t('uEmpty.history'),
news: t('uEmpty.news'),
message: t('uEmpty.message'),
list: t('uEmpty.list'),
data: t('uEmpty.data')
};
});
</script>
<style scoped lang="scss">
@import '../../libs/css/style.components.scss';
.u-empty {
@include vue-flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
}
.u-image {
margin-bottom: 20rpx;
}
.u-slot-wrap {
@include vue-flex;
justify-content: center;
align-items: center;
margin-top: 20rpx;
}
</style>
@@ -0,0 +1,83 @@
import type { CSSProperties, ExtractPropTypes, PropType } from 'vue';
import type { FabDirection, FabGap, FabPosition, ThemeType } from '../../types/global';
/**
* fab 悬浮按钮类型定义
* @description 供 u-fab 组件 props 使用
*/
export const FabProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** 按钮的预置样式,primary,info,error,warning,success */
type: { type: String as PropType<ThemeType>, default: 'primary' },
/** 是否禁止状态 */
disabled: { type: Boolean, default: false },
/** 按钮能否可以拖动 */
draggable: { type: Boolean, default: false },
/** 按钮与边缘的间距,单位 px。支持 number 或对象 {top,left,right,bottom},优先级按键存在顺序 */
gap: {
type: Object as PropType<FabGap>,
default: () => ({
top: 16,
left: 16,
right: 16,
bottom: 16
})
},
/** 拖动结束时是否自动吸边(仅当 draggable 为 true 时生效) */
autoStick: { type: Boolean, default: true },
/** 预设定位:控制组件默认停靠位置 */
position: {
type: String as PropType<FabPosition>,
default: 'right-bottom'
},
/** 菜单出现的方向 */
direction: { type: String as PropType<FabDirection>, default: 'top' },
/** 按钮自定义层级 */
zIndex: { type: Number, default: 99 }
};
export type FabProps = ExtractPropTypes<typeof FabProps>;
interface DirectionConfig {
opposite: keyof CSSProperties;
sizeKey: 'width' | 'height';
positionKey: 'left' | 'top';
flexBase: 'row' | 'row-reverse' | 'column' | 'column-reverse';
}
export const directionConfig: Record<FabDirection, DirectionConfig> = {
top: {
opposite: 'bottom',
sizeKey: 'width',
positionKey: 'left',
flexBase: 'column'
},
bottom: {
opposite: 'top',
sizeKey: 'width',
positionKey: 'left',
flexBase: 'column-reverse'
},
left: {
opposite: 'right',
sizeKey: 'height',
positionKey: 'top',
flexBase: 'row'
},
right: {
opposite: 'left',
sizeKey: 'height',
positionKey: 'top',
flexBase: 'row-reverse'
}
};
@@ -0,0 +1,330 @@
<template>
<view
class="u-fab"
:style="$u.toStyle(fabStyle, customStyle)"
:class="[customClass]"
@touchstart="handleTouchstart"
@touchmove.stop.prevent="handleTouchmove"
@touchend="handleTouchend"
>
<view class="u-fab-trigger" id="trigger">
<slot name="trigger">
<u-button
custom-class="u-fab-trigger-btn"
custom-style="width:112rpx;height:112rpx;border-radius:112rpx;"
:type="type"
:disabled="disabled"
:throttle-time="0"
@click="handleBtnClick"
>
<u-icon :name="expansion ? 'close' : 'plus'" size="36rpx"></u-icon>
</u-button>
</slot>
</view>
<view class="u-fab-actions" id="actions" :class="{ 'u-fab-actions__show': expansion }" :style="actionsStyle">
<slot></slot>
</view>
</view>
</template>
<script lang="ts">
export default {
name: 'u-fab',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { directionConfig, FabProps } from './types';
import { computed, getCurrentInstance, onMounted, reactive, ref, useSlots, watch } from 'vue';
import { $u } from '../../';
import uButton from '../u-button/u-button.vue';
import uIcon from '../u-icon/u-icon.vue';
const props = defineProps(FabProps);
const emit = defineEmits(['trigger']);
const slots = useSlots();
const instance = getCurrentInstance();
const sysInfo = $u.sys();
const dragging = ref(true);
const minLeft = ref(0);
const maxLeft = ref(0);
const minTop = ref(0);
const maxTop = ref(0);
const expansion = ref(false);
const direction = ref(props.direction);
const effectiveWindowHeight = ref(sysInfo.windowHeight);
// #ifdef H5
effectiveWindowHeight.value = sysInfo.windowTop + sysInfo.windowHeight;
// #endif
const position = reactive({
top: 0,
left: 0
});
const actions = ref();
const btnInfo = ref({
width: 56,
height: 56
});
const start = reactive({
x: 0,
y: 0
});
// 计算悬浮按钮样式
const fabStyle = computed(() => {
const style: Record<string, any> = {
transition: dragging.value ? 'none' : 'all 0.3s ease',
zIndex: props.zIndex,
left: `${position.left}px`,
top: `${position.top}px`
};
return style;
});
// 动画样式
const actionsStyle = computed(() => {
const config = directionConfig[direction.value];
const base: Record<string, any> = {
[config.opposite]: '100%',
[config.sizeKey]: '100%',
[config.positionKey]: 0,
flexDirection: config.flexBase,
transition: 'all 0.28s cubic-bezier(0.3, 0, 0.2, 1)'
};
// 展开/收缩动画:根据方向设置初始偏移
const offset = 12; // rpx/px 相对轻微偏移,视觉更顺滑
if (expansion.value) {
base.transform = 'translate3d(0,0,0) scale(1)';
base.opacity = 1;
} else {
// 隐藏时做一个方向上的微位移并缩放以优化动画感
if (direction.value === 'top') base.transform = `translate3d(0, ${offset}px, 0) scale(0.96)`;
else if (direction.value === 'bottom') base.transform = `translate3d(0, -${offset}px, 0) scale(0.96)`;
else if (direction.value === 'left') base.transform = `translate3d(${offset}px, 0, 0) scale(0.96)`;
else base.transform = `translate3d(-${offset}px, 0, 0) scale(0.96)`;
base.opacity = 0;
}
return base;
});
watch(
() => [props.position, props.gap],
() => {
initPosition();
}
);
watch(
() => props.direction,
() => {
if (expansion.value) direction.value = calcDirection();
}
);
// helper:支持 gap 为 number 或对象形式
function getGap(side: 'top' | 'left' | 'right' | 'bottom') {
const g = props.gap as any;
if (typeof g === 'number') return g;
if (g && typeof g === 'object' && g[side] !== undefined) return Number(g[side]);
return 0;
}
// 拖动开始事件
function handleTouchstart(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
start.x = touches.clientX;
start.y = touches.clientY;
dragging.value = true;
}
// 拖动移动事件
function handleTouchmove(e: TouchEvent) {
if (props.disabled || !props.draggable) return;
const touches = e.touches[0];
const deltaX = touches.clientX - start.x;
const deltaY = touches.clientY - start.y;
position.left += deltaX;
position.top += deltaY;
start.x = touches.clientX;
start.y = touches.clientY;
// 设置边界,防止拖出边界
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
}
// 拖动结束事件
function handleTouchend() {
if (props.disabled || !props.draggable) return;
dragging.value = false;
// 如果 autoStick 为 false,则释放后不进行自动吸边,仅做边界限制
if (props.autoStick === false) {
position.left = Math.max(minLeft.value, Math.min(maxLeft.value, position.left));
position.top = Math.max(minTop.value, Math.min(maxTop.value, position.top));
return;
}
const middle = sysInfo.windowWidth / 2;
const buttonCenter = position.left + btnInfo.value.width / 2;
// 自动吸边,按钮中心位置大于视口的一半时,自动依附在右边,不然就是左边
position.left =
buttonCenter > middle ? sysInfo.windowWidth - btnInfo.value.width - getGap('right') : getGap('left');
if (expansion.value) direction.value = calcDirection();
}
// 按钮点击事件
function handleBtnClick() {
if (slots?.default) {
expansion.value = !expansion.value;
if (expansion.value) direction.value = calcDirection();
} else {
emit('trigger');
}
}
// 计算方向
function calcDirection() {
if (!actions.value) return props.direction;
let dir = props.direction;
const actionsHeight = actions.value?.height || 0;
const actionsWidth = actions.value?.width || 0;
// 菜单展开时,如果位置不够,则反转方向
if (dir === 'top') {
// 按钮上方剩余空间: 按钮顶部 - 顶部边距
if (position.top - minTop.value < actionsHeight) dir = 'bottom';
} else if (dir === 'bottom') {
// 按钮下方剩余空间: 有效窗口高度 - (按钮顶部 + 按钮高 + 边距)
const bottom = effectiveWindowHeight.value - (position.top + btnInfo.value.height + getGap('bottom'));
if (bottom < actionsHeight) dir = 'top';
} else if (dir === 'left') {
// 按钮左侧剩余空间: 有效窗口宽度 - 边距
if (position.left - getGap('left') < actionsWidth) dir = 'right';
} else if (dir === 'right') {
// 按钮右侧剩余空间: 有效窗口宽度 - (按钮左侧 + 按钮宽 + 边距)
const right = sysInfo.windowWidth - (position.left + btnInfo.value.width + getGap('right'));
if (right < actionsWidth) dir = 'left';
}
return dir;
}
// 初始化位置
function initPosition() {
// 根据 props.position 计算初始 left/top
const pos = props.position || 'right-bottom';
const winW = sysInfo.windowWidth;
const winH = effectiveWindowHeight.value;
switch (pos) {
case 'left-top':
position.left = getGap('left');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'right-top':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'left-bottom':
position.left = getGap('left');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'right-bottom':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
case 'left-center':
position.left = getGap('left');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'right-center':
position.left = winW - btnInfo.value.width - getGap('right');
position.top = Math.round((winH - btnInfo.value.height) / 2);
break;
case 'top-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = getGap('top') + sysInfo.windowTop;
break;
case 'bottom-center':
position.left = Math.round((winW - btnInfo.value.width) / 2);
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
default:
position.left = winW - btnInfo.value.width - getGap('right');
position.top = winH - btnInfo.value.height - getGap('bottom');
break;
}
}
onMounted(async () => {
btnInfo.value = await $u.getRect('#trigger', instance);
actions.value = await $u.getRect('#actions', instance);
initPosition();
minLeft.value = getGap('left');
minTop.value = getGap('top') + sysInfo.windowTop;
maxLeft.value = sysInfo.windowWidth - btnInfo.value.width - getGap('right');
maxTop.value = effectiveWindowHeight.value - btnInfo.value.height - getGap('bottom');
});
defineExpose({
toggle: handleBtnClick
});
</script>
<style scoped lang="scss">
.u-fab {
position: fixed;
.u-fab-trigger {
:deep(.u-fab-trigger-btn) {
width: 112rpx;
height: 112rpx;
border-radius: 112rpx;
&::after {
border-radius: 112rpx;
}
}
}
.u-fab-actions {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
visibility: hidden;
opacity: 0;
transform-origin: center;
will-change: transform, opacity;
transition: all 0.28s cubic-bezier(0.3, 0, 0.2, 1);
&.u-fab-actions__show {
visibility: visible;
opacity: 1;
}
}
}
</style>
@@ -0,0 +1,81 @@
import type { ExtractPropTypes, PropType } from 'vue';
import type { InputAlign, InputConfirmType, InputLabelPosition, InputType } from '../../types/global';
/**
* u-field 组件 Props 类型定义
* @description 表单输入框属性
*/
export const FieldProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** label左边的图标,限uView的图标名称 */
icon: { type: String, default: '' },
/** 输入框右边的图标名称,限uView的图标名称(默认false) */
rightIcon: { type: String, default: '' },
/** 方向属性 */
arrowDirection: { type: String, default: 'right' },
/** 是否必填,左边显示红色"*"号(默认false) */
required: { type: Boolean, default: false },
/** 输入框左边的文字提示 */
label: { type: String, default: '' },
/** 是否密码输入方式(用点替换文字),type为text时有效(默认false) */
password: { type: Boolean, default: false },
/** 是否显示右侧清空内容的图标控件(默认true) */
clearable: { type: Boolean, default: true },
/** label的宽度,单位rpx(默认130) */
labelWidth: { type: [Number, String] as PropType<string | number>, default: 130 },
/** label的文字对齐方式(默认left) */
labelAlign: { type: String as PropType<InputAlign>, default: 'left' },
/** 输入框内容对齐方式(默认left) */
inputAlign: { type: String as PropType<InputAlign>, default: 'left' },
/** 左边通过icon配置的图标的颜色(默认var(--u-content-color)) */
iconColor: { type: String, default: 'var(--u-content-color)' },
/** 是否自动增高输入区域,type为textarea时有效(默认true) */
autoHeight: { type: Boolean, default: true },
/** 显示的错误提示内容,如果为空字符串或者false,则不显示错误信息 */
errorMessage: { type: [String, Boolean] as PropType<string | boolean>, default: '' },
/** 输入框的提示文字 */
placeholder: { type: String, default: '' },
/** placeholder的样式(内联样式,字符串),如"color: var(--u-divider-color)" */
placeholderStyle: { type: String, default: '' },
/** 是否自动获得焦点(默认false) */
focus: { type: Boolean, default: false },
/** 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false) */
fixed: { type: Boolean, default: false },
/** 输入框绑定值 */
modelValue: [Number, String],
/** 输入框类型(text/textarea/password等,默认text) */
type: { type: String as PropType<InputType>, default: 'text' },
/** 是否不可输入(默认false) */
disabled: { type: Boolean, default: false },
/** 是否只读,禁止输入但可点击,样式不变,可触发click事件(默认false) */
readonly: { type: Boolean, default: false },
/** 最大输入长度,设置为 -1 的时候不限制最大长度(默认140) */
maxlength: { type: [Number, String] as PropType<string | number>, default: 140 },
/** 设置键盘右下角按钮的文字,仅在type="text"时生效(默认done) */
confirmType: { type: String as PropType<InputConfirmType>, default: 'done' },
/** label位置(默认left)left-左边,top-上边 */
labelPosition: { type: String as PropType<InputLabelPosition>, default: 'left' },
/** 自定义输入框的样式,对象形式 */
fieldStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 清除图标的大小,单位rpx(默认30) */
clearSize: { type: [Number, String] as PropType<string | number>, default: 30 },
/** 左侧图标样式 */
iconStyle: { type: Object as PropType<Record<string, any>>, default: () => ({}) },
/** 是否显示field的上边框(默认false) */
borderTop: { type: Boolean, default: false },
/** 是否显示field的下边框(默认true) */
borderBottom: { type: Boolean, default: true },
/** 是否自动去除输入内容首尾空格(默认true) */
trim: { type: Boolean, default: true }
};
export type FieldProps = ExtractPropTypes<typeof FieldProps>;
@@ -0,0 +1,398 @@
<template>
<view
class="u-field"
:class="[
{
'u-border-top': props.borderTop,
'u-border-bottom': props.borderBottom,
'u-field--disabled': props.disabled
},
customClass
]"
:style="$u.toStyle(customStyle)"
>
<view
class="u-field-inner"
:class="[props.type === 'textarea' ? 'u-textarea-inner' : '', 'u-label-postion-' + props.labelPosition]"
>
<view
class="u-label"
:class="[props.required ? 'u-required' : '']"
:style="{
justifyContent: justifyContent,
flex: props.labelPosition === 'left' ? `0 0 ${props.labelWidth}rpx` : '1'
}"
>
<view class="u-icon-wrap" v-if="props.icon">
<u-icon
size="32"
:custom-style="props.iconStyle"
:name="props.icon"
:color="props.iconColor"
class="u-icon"
></u-icon>
</view>
<slot name="icon"></slot>
<text class="u-label-text" :class="[$slots.icon || props.icon ? 'u-label-left-gap' : '']">
{{ props.label }}
</text>
</view>
<view class="fild-body">
<view class="u-flex-1 u-flex u-field-input-wrap" :style="[inputWrapStyle]">
<textarea
v-if="props.type === 'textarea'"
class="u-flex-1 u-textarea-class"
:style="$u.toStyle(props.fieldStyle)"
:value="inputValue"
:placeholder="String(props.placeholder)"
:placeholderStyle="props.placeholderStyle"
:disabled="props.disabled"
:readonly="props.readonly"
:maxlength="inputMaxlength"
:focus="props.focus"
:autoHeight="props.autoHeight"
:fixed="props.fixed"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
@confirm="onConfirm"
/>
<!-- prettier-ignore -->
<input
v-else
class="u-flex-1 u-field__input-wrap"
:style="$u.toStyle(props.fieldStyle)"
:type="(props.type as any)"
:value="inputValue"
:password="props.password || props.type === 'password'"
:placeholder="String(props.placeholder)"
:placeholderStyle="props.placeholderStyle"
:disabled="props.disabled"
:readonly="props.readonly"
:maxlength="inputMaxlength"
:focus="props.focus"
:confirmType="props.confirmType"
@focus="onFocus"
@blur="onBlur"
@input="onInput"
@confirm="onConfirm"
/>
<!-- 透明遮罩,在disabled或readonly时显示,用于捕获点击事件(原生input设置disabled会阻止点击冒泡) -->
<view v-if="props.readonly" class="u-field__readonly-overlay" @tap.stop="fieldClick"></view>
</view>
<u-icon
v-if="props.clearable && inputValue !== '' && focused && !props.disabled"
:size="props.clearSize"
name="close-circle-fill"
color="var(--u-light-color)"
class="u-clear-icon"
@click="onClear"
/>
<view class="u-button-wrap"><slot name="right" /></view>
<u-icon
v-if="props.rightIcon"
@click="rightIconClick"
:name="props.rightIcon"
color="var(--u-light-color)"
:style="[rightIconStyle]"
size="26"
class="u-arror-right"
/>
</view>
</view>
<view
v-if="props.errorMessage !== false && props.errorMessage != ''"
class="u-error-message"
:style="{
paddingLeft: props.labelWidth + 'rpx'
}"
>{{ props.errorMessage }}</view
>
</view>
</template>
<script lang="ts">
export default {
name: 'u-field',
options: {
addGlobalClass: true,
// #ifndef MP-TOUTIAO
virtualHost: true,
// #endif
styleIsolation: 'shared'
}
};
</script>
<script setup lang="ts">
import { FieldProps } from './types';
import { ref, computed } from 'vue';
import { $u } from '../..';
import uIcon from '../u-icon/u-icon.vue';
/**
* field 输入框
* @description 借助此组件,可以实现表单的输入, 有"text"和"textarea"类型的,此外,借助uView的picker和actionSheet组件可以快速实现上拉菜单,时间,地区选择等, 为表单解决方案的利器。
* @tutorial https://uviewpro.cn/zh/components/field.html
* @property {String} type 输入框的类型(默认text)
* @property {String} icon label左边的图标,限uView的图标名称
* @property {Object} icon-style 左边图标的样式,对象形式
* @property {Boolean} right-icon 输入框右边的图标名称,限uView的图标名称(默认false)
* @property {Boolean} required 是否必填,左侧显示红色"*"号(默认false)
* @property {String} label 输入框左边的文字提示
* @property {Boolean} password 是否密码输入方式(用点替换文字),type为text时有效(默认false)
* @property {Boolean} clearable 是否显示右侧清空内容的图标控件(输入框有内容,且获得焦点时才显示),点击可清空输入框内容(默认true)
* @property {Number | String} label-width label的宽度,单位rpx(默认130)
* @property {String} label-align label的文字对齐方式(默认left)
* @property {Object} field-style 自定义输入框的样式,对象形式
* @property {Number | String} clear-size 清除图标的大小,单位rpx(默认30)
* @property {String} input-align 输入框内容对齐方式(默认left)
* @property {Boolean} border-bottom 是否显示field的下边框(默认true)
* @property {Boolean} border-top 是否显示field的上边框(默认false)
* @property {String} icon-color 左边通过icon配置的图标的颜色(默认var(--u-content-color))
* @property {Boolean} auto-height 是否自动增高输入区域,type为textarea时有效(默认true)
* @property {String Boolean} error-message 显示的错误提示内容,如果为空字符串或者false,则不显示错误信息
* @property {String} placeholder 输入框的提示文字
* @property {String} placeholder-style placeholder的样式(内联样式,字符串),如"color: var(--u-divider-color)"
* @property {Boolean} focus 是否自动获得焦点(默认false)
* @property {Boolean} fixed 如果type为textarea,且在一个"position:fixed"的区域,需要指明为true(默认false)
* @property {Boolean} disabled 是否不可输入(默认false)
* @property {Number String} maxlength 最大输入长度,设置为 -1 的时候不限制最大长度(默认140)
* @property {String} confirm-type 设置键盘右下角按钮的文字,仅在type="text"时生效(默认done)
* @event {Function} input 输入框内容发生变化时触发
* @event {Function} focus 输入框获得焦点时触发
* @event {Function} blur 输入框失去焦点时触发
* @event {Function} confirm 点击完成按钮时触发
* @event {Function} right-icon-click 通过right-icon生成的图标被点击时触发
* @event {Function} click 输入框被点击或者通过right-icon生成的图标被点击时触发,这样设计是考虑到传递右边的图标,一般都为需要弹出"picker"等操作时的场景,点击倒三角图标,理应发出此事件,见上方说明
* @example <u-field v-model="mobile" label="手机号" required :error-message="errorMessage"></u-field>
*/
const emit = defineEmits(['update:modelValue', 'input', 'focus', 'blur', 'confirm', 'right-icon-click', 'click']);
const props = defineProps(FieldProps);
const focused = ref(false);
const inputValue = computed<string>(() => {
if (props.modelValue === undefined || props.modelValue === null) return '';
return String(props.modelValue);
});
const inputWrapStyle = computed(() => {
const style: Record<string, string> = {};
style.textAlign = props.inputAlign;
// 判断label的位置,如果是left的话,让input左边两边有间隙
if (props.labelPosition === 'left') {
style.margin = '0 8rpx';
} else {
// 如果label是top的,input的左边就没必要有间隙了
style.marginRight = '8rpx';
}
return style;
});
const rightIconStyle = computed(() => {
const style: Record<string, string> = {};
// 方向属性已注释,保留原有逻辑
if (props.arrowDirection === 'top') style.transform = 'rotate(-90deg)';
if (props.arrowDirection === 'bottom') style.transform = 'rotate(90deg)';
else style.transform = 'rotate(0deg)';
return style;
});
const labelStyle = computed(() => {
const style: Record<string, string> = {};
if (props.labelAlign === 'left') style.justifyContent = 'flex-start';
if (props.labelAlign === 'center') style.justifyContent = 'center';
if (props.labelAlign === 'right') style.justifyContent = 'flex-end';
return style;
});
// uni不支持在computed中写style.justifyContent = 'center'的形式,故用此方法
const justifyContent = computed(() => {
if (props.labelAlign === 'left') return 'flex-start';
if (props.labelAlign === 'center') return 'center';
if (props.labelAlign === 'right') return 'flex-end';
return 'flex-start';
});
// 因为uniapp的input组件的maxlength组件必须要数值,这里转为数值,给用户可以传入字符串数值
const inputMaxlength = computed(() => Number(props.maxlength));
// label的位置
const fieldInnerStyle = computed(() => {
const style: Record<string, string> = {};
style.flexDirection = props.labelPosition === 'left' ? 'row' : 'column';
return style;
});
function onInput(event: any) {
// 兼容 uni-app input/textarea 事件参数
let value = event?.detail?.value ?? '';
// 判断是否去除空格
if (props.trim) value = $u.trim(value);
emit('update:modelValue', value);
emit('input', value);
}
function onFocus(event: any) {
focused.value = true;
emit('focus', event);
}
function onBlur(event: any) {
// 最开始使用的是监听图标@touchstart事件,自从hx2.8.4后,此方法在微信小程序出错
// 这里改为监听点击事件,手点击清除图标时,同时也发生了@blur事件,导致图标消失而无法点击,这里做一个延时
setTimeout(() => {
focused.value = false;
}, 100);
emit('blur', event);
}
function onConfirm(e: any) {
emit('confirm', e?.detail?.value ?? '');
}
function onClear() {
emit('update:modelValue', '');
}
function rightIconClick() {
emit('right-icon-click');
emit('click');
}
function fieldClick() {
if (props.disabled) return;
emit('click');
}
</script>
<style lang="scss" scoped>
@import '../../libs/css/style.components.scss';
.u-field {
font-size: 28rpx;
padding: 20rpx 28rpx;
text-align: left;
position: relative;
color: $u-main-color;
}
.u-field-inner {
@include vue-flex;
align-items: center;
}
.u-textarea-inner {
align-items: flex-start;
}
.u-textarea-class {
min-height: 96rpx;
width: auto;
font-size: 28rpx;
}
.fild-body {
@include vue-flex;
flex: 1;
align-items: center;
}
.u-arror-right {
margin-left: 8rpx;
}
.u-label-text {
/* #ifndef APP-NVUE */
display: inline-flex;
/* #endif */
}
.u-label-left-gap {
margin-left: 6rpx;
}
.u-label-postion-top {
flex-direction: column;
align-items: flex-start;
}
.u-label {
width: 130rpx;
flex: 1 1 130rpx;
text-align: left;
position: relative;
@include vue-flex;
align-items: center;
}
.u-required::before {
content: '*';
position: absolute;
left: -16rpx;
font-size: 14px;
color: $u-type-error;
height: 9px;
line-height: 1;
}
.u-field__input-wrap {
position: relative;
overflow: hidden;
font-size: 28rpx;
height: 48rpx;
flex: 1;
width: auto;
}
.u-clear-icon {
@include vue-flex;
align-items: center;
}
.u-error-message {
color: $u-type-error;
font-size: 26rpx;
text-align: left;
}
.placeholder-style {
color: rgb(150, 151, 153);
}
.u-input-class {
font-size: 28rpx;
}
.u-button-wrap {
margin-left: 8rpx;
}
.u-field-input-wrap {
position: relative;
}
.u-field__readonly-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: transparent;
z-index: 1;
}
.u-field--disabled {
background-color: $u-bg-gray-light;
}
.u-field--disabled .u-textarea-class,
.u-field--disabled .u-field__input-wrap {
background-color: transparent;
color: $u-light-color;
-webkit-text-fill-color: $u-light-color;
}
</style>
@@ -0,0 +1,95 @@
import type { PropType, ExtractPropTypes } from 'vue';
import type { InputAlign, InputLabelPosition, SizeType } from '../../types/global';
/**
* form-item 表单item Props
*/
export const FormItemProps = {
/** 自定义根节点样式 */
customStyle: {
type: [String, Object] as PropType<string | Record<string, any>>,
default: () => ({})
},
/** 自定义根节点样式类 */
customClass: {
type: String as unknown as PropType<string>,
default: ''
},
/** input的label提示语 */
label: {
type: String,
default: ''
},
/** 绑定的值 */
prop: {
type: String,
default: ''
},
/** 是否显示表单域的下划线边框 */
borderBottom: {
type: [String, Boolean] as PropType<string | boolean>,
default: ''
},
/** label的位置,left-左边,top-上边 */
labelPosition: {
type: String as PropType<InputLabelPosition>,
default: ''
},
/** label的宽度,单位rpx */
labelWidth: {
type: [String, Number] as PropType<string | number>,
default: ''
},
/** label的样式,对象形式 */
labelStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** label字体的对齐方式 */
labelAlign: {
type: String as PropType<InputAlign>,
default: ''
},
/** 右侧图标 */
rightIcon: {
type: String,
default: ''
},
/** 左侧图标 */
leftIcon: {
type: String,
default: ''
},
/** 左侧图标的自定义前缀 */
leftIconPrefix: {
type: String,
default: 'uicon'
},
/** 右侧图标的自定义前缀 */
rightIconPrefix: {
type: String,
default: 'uicon'
},
/** 左侧图标的样式 */
leftIconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 右侧图标的样式 */
rightIconStyle: {
type: Object as PropType<Record<string, any>>,
default: () => ({})
},
/** 是否显示左边的必填星号,只作显示用,具体校验必填的逻辑,请在rules中配置 */
required: {
type: Boolean,
default: false
},
/** 表单内组件的大小,仅支持预设值 default/small/large */
size: {
type: String as PropType<SizeType | string>,
default: ''
}
};
export type FormItemProps = ExtractPropTypes<typeof FormItemProps>;

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