完善生产查询与登录权限配置
@@ -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
|
||||
});
|
||||
};
|
||||
```
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 254 B |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 634 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 788 B |
|
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) ?? ''
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||