小程序初始提交

This commit is contained in:
jdc
2025-11-13 10:36:23 +08:00
parent f26b4f9a2f
commit 5db3b180eb
447 changed files with 83351 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
<template>
<view class="cl-canvas">
<canvas
ref="canvasRef"
:id="canvasId"
:style="{ width: width + 'px', height: height + 'px' }"
></canvas>
</view>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Canvas, useCanvas } from "../../hooks";
import { canvasToPng } from "@/cool";
import { t } from "@/locale";
import { useUi } from "@/uni_modules/cool-ui";
const props = defineProps({
canvasId: {
type: String,
default: ""
},
height: {
type: Number,
default: 300
},
width: {
type: Number,
default: 300
}
});
const emit = defineEmits<{
(e: "load", canvas: Canvas): void;
}>();
const ui = useUi();
// 画布操作实例
const canvas = useCanvas(props.canvasId);
// 画布组件
const canvasRef = ref<UniElement | null>(null);
// 加载画布
function load() {
canvas.create().then(() => {
canvas.height(props.height);
canvas.width(props.width);
emit("load", canvas);
});
}
// 创建图片
async function createImage() {
return canvasToPng(canvasRef.value!);
}
// 预览图片
async function previewImage() {
const url = await createImage();
uni.previewImage({
urls: [url]
});
}
// 保存图片
async function saveImage() {
const url = await createImage();
uni.saveImageToPhotosAlbum({
filePath: url,
success: () => {
ui.showToast({
message: t("保存图片成功"),
type: "success"
});
},
fail: (err) => {
console.error("[cl-canvas]", err);
ui.showToast({
message: t("保存图片失败"),
type: "error"
});
}
});
}
onMounted(() => {
load();
});
defineExpose({
createImage,
saveImage,
previewImage
});
</script>

View File

@@ -0,0 +1,813 @@
import { getDevicePixelRatio, isEmpty } from "@/cool";
import { getCurrentInstance } from "vue";
import type {
CropImageResult,
DivRenderOptions,
ImageRenderOptions,
TextRenderOptions,
TransformOptions
} from "../types";
/**
* Canvas 绘图类,封装了常用的绘图操作
*/
export class Canvas {
// uni-app CanvasContext 对象
context: CanvasContext | null = null;
// 2D 渲染上下文
ctx: CanvasRenderingContext2D | null = null;
// 组件作用域(用于小程序等环境)
scope: ComponentPublicInstance | null = null;
// 画布ID
canvasId: string | null = null;
// 渲染队列,存储所有待渲染的异步操作
renderQuene: (() => Promise<void>)[] = [];
// 图片渲染队列,存储所有待处理的图片参数
imageQueue: ImageRenderOptions[] = [];
/**
* 构造函数
* @param canvasId 画布ID
*/
constructor(canvasId: string) {
const { proxy } = getCurrentInstance()!;
// 当前页面作用域
this.scope = proxy;
// 画布ID
this.canvasId = canvasId;
}
/**
* 创建画布上下文
* @returns Promise<void>
*/
async create(): Promise<void> {
const dpr = getDevicePixelRatio(); // 获取设备像素比
return new Promise((resolve) => {
uni.createCanvasContextAsync({
id: this.canvasId!,
component: this.scope,
success: (context: CanvasContext) => {
this.context = context;
this.ctx = context.getContext("2d")!;
this.ctx.scale(dpr, dpr); // 按照 dpr 缩放,保证高清
resolve();
}
});
});
}
/**
* 设置画布高度
* @param value 高度
* @returns Canvas
*/
height(value: number): Canvas {
this.ctx!.canvas.height = value;
return this;
}
/**
* 设置画布宽度
* @param value 宽度
* @returns Canvas
*/
width(value: number): Canvas {
this.ctx!.canvas.width = value;
return this;
}
/**
* 添加块(矩形/圆角矩形)渲染到队列
* @param options DivRenderOptions
* @returns Canvas
*/
div(options: DivRenderOptions): Canvas {
const render = async () => {
this.divRender(options);
};
this.renderQuene.push(render);
return this;
}
/**
* 添加文本渲染到队列
* @param options TextRenderOptions
* @returns Canvas
*/
text(options: TextRenderOptions): Canvas {
const render = async () => {
this.textRender(options);
};
this.renderQuene.push(render);
return this;
}
/**
* 添加图片渲染到队列
* @param options ImageRenderOptions
* @returns Canvas
*/
image(options: ImageRenderOptions): Canvas {
const render = async () => {
await this.imageRender(options);
};
this.imageQueue.push(options);
this.renderQuene.push(render);
return this;
}
/**
* 执行绘制流程(预加载图片后依次渲染队列)
*/
async draw(): Promise<void> {
// 如果有图片,先预加载
if (!isEmpty(this.imageQueue)) {
await this.preloadImage();
}
this.render();
}
/**
* 下载图片获取本地路径兼容APP等平台
* @param item ImageRenderOptions
* @returns Promise<void>
*/
downloadImage(item: ImageRenderOptions): Promise<void> {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: item.url,
success: (res) => {
// #ifdef APP
item.url = res.path; // APP端需用本地路径
// #endif
resolve();
},
fail: (err) => {
console.error(err);
reject(err);
}
});
});
}
/**
* 预加载所有图片,确保图片可用
*/
async preloadImage(): Promise<void> {
await Promise.all(
this.imageQueue.map((e) => {
return this.downloadImage(e);
})
);
}
/**
* 设置背景颜色
* @param color 颜色字符串
*/
private setBackground(color: string) {
this.ctx!.fillStyle = color;
}
/**
* 设置边框(支持圆角)
* @param options DivRenderOptions
*/
private setBorder(options: DivRenderOptions) {
const { x, y, width: w = 0, height: h = 0, borderWidth, borderColor, radius: r } = options;
if (borderWidth == null || borderColor == null) return;
this.ctx!.lineWidth = borderWidth;
this.ctx!.strokeStyle = borderColor;
// 偏移距离,保证边框居中
let p = borderWidth / 2;
// 是否有圆角
if (r != null) {
this.drawRadius(x - p, y - p, w + 2 * p, h + 2 * p, r + p);
this.ctx!.stroke();
} else {
this.ctx!.strokeRect(x - p, y - p, w + 2 * p, h + 2 * p);
}
}
/**
* 设置变换(缩放、旋转、平移)
* @param options TransformOptions
*/
private setTransform(options: TransformOptions) {
const ctx = this.ctx!;
// 平移
if (options.translateX != null || options.translateY != null) {
ctx.translate(options.translateX ?? 0, options.translateY ?? 0);
}
// 旋转(角度转弧度)
if (options.rotate != null) {
ctx.rotate((options.rotate * Math.PI) / 180);
}
// 缩放
if (options.scale != null) {
// 统一缩放
ctx.scale(options.scale, options.scale);
} else if (options.scaleX != null || options.scaleY != null) {
// 分别缩放
ctx.scale(options.scaleX ?? 1, options.scaleY ?? 1);
}
}
/**
* 绘制带圆角的路径
* @param x 左上角x
* @param y 左上角y
* @param w 宽度
* @param h 高度
* @param r 圆角半径
*/
private drawRadius(x: number, y: number, w: number, h: number, r: number) {
// 圆角半径不能超过宽高一半
const maxRadius = Math.min(w / 2, h / 2);
const radius = Math.min(r, maxRadius);
this.ctx!.beginPath();
// 从左上角圆弧的结束点开始
this.ctx!.moveTo(x + radius, y);
// 顶边
this.ctx!.lineTo(x + w - radius, y);
// 右上角圆弧
this.ctx!.arc(x + w - radius, y + radius, radius, -Math.PI / 2, 0);
// 右边
this.ctx!.lineTo(x + w, y + h - radius);
// 右下角圆弧
this.ctx!.arc(x + w - radius, y + h - radius, radius, 0, Math.PI / 2);
// 底边
this.ctx!.lineTo(x + radius, y + h);
// 左下角圆弧
this.ctx!.arc(x + radius, y + h - radius, radius, Math.PI / 2, Math.PI);
// 左边
this.ctx!.lineTo(x, y + radius);
// 左上角圆弧
this.ctx!.arc(x + radius, y + radius, radius, Math.PI, -Math.PI / 2);
this.ctx!.closePath();
}
/**
* 裁剪图片,支持多种裁剪模式
* @param mode 裁剪模式
* @param canvasWidth 目标区域宽度
* @param canvasHeight 目标区域高度
* @param imageWidth 原图宽度
* @param imageHeight 原图高度
* @param drawX 绘制起点X
* @param drawY 绘制起点Y
* @returns CropImageResult
*/
private cropImage(
mode:
| "scaleToFill"
| "aspectFit"
| "aspectFill"
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight",
canvasWidth: number,
canvasHeight: number,
imageWidth: number,
imageHeight: number,
drawX: number,
drawY: number
): CropImageResult {
// sx, sy, sw, sh: 原图裁剪区域
// dx, dy, dw, dh: 画布绘制区域
let sx = 0,
sy = 0,
sw = imageWidth,
sh = imageHeight;
let dx = drawX,
dy = drawY,
dw = canvasWidth,
dh = canvasHeight;
// 计算宽高比
const imageRatio = imageWidth / imageHeight;
const canvasRatio = canvasWidth / canvasHeight;
switch (mode) {
case "scaleToFill":
// 拉伸填充整个区域,可能变形
break;
case "aspectFit":
// 保持比例完整显示,可能有留白
if (imageRatio > canvasRatio) {
// 图片更宽,以宽度为准
dw = canvasWidth;
dh = canvasWidth / imageRatio;
dx = drawX;
dy = drawY + (canvasHeight - dh) / 2;
} else {
// 图片更高,以高度为准
dw = canvasHeight * imageRatio;
dh = canvasHeight;
dx = drawX + (canvasWidth - dw) / 2;
dy = drawY;
}
break;
case "aspectFill":
// 保持比例填充,可能裁剪
if (imageRatio > canvasRatio) {
// 图片更宽,裁剪左右
const scaledWidth = imageHeight * canvasRatio;
sx = (imageWidth - scaledWidth) / 2;
sw = scaledWidth;
} else {
// 图片更高,裁剪上下
const scaledHeight = imageWidth / canvasRatio;
sy = (imageHeight - scaledHeight) / 2;
sh = scaledHeight;
}
break;
case "center":
// 居中显示,不缩放,超出裁剪
sx = Math.max(0, (imageWidth - canvasWidth) / 2);
sy = Math.max(0, (imageHeight - canvasHeight) / 2);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
dw = sw;
dh = sh;
break;
case "top":
// 顶部对齐,水平居中
sx = Math.max(0, (imageWidth - canvasWidth) / 2);
sy = 0;
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
dy = drawY;
dw = sw;
dh = sh;
break;
case "bottom":
// 底部对齐,水平居中
sx = Math.max(0, (imageWidth - canvasWidth) / 2);
sy = Math.max(0, imageHeight - canvasHeight);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, (canvasWidth - imageWidth) / 2);
dy = drawY + Math.max(0, canvasHeight - imageHeight);
dw = sw;
dh = sh;
break;
case "left":
// 左对齐,垂直居中
sx = 0;
sy = Math.max(0, (imageHeight - canvasHeight) / 2);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX;
dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
dw = sw;
dh = sh;
break;
case "right":
// 右对齐,垂直居中
sx = Math.max(0, imageWidth - canvasWidth);
sy = Math.max(0, (imageHeight - canvasHeight) / 2);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, canvasWidth - imageWidth);
dy = drawY + Math.max(0, (canvasHeight - imageHeight) / 2);
dw = sw;
dh = sh;
break;
case "topLeft":
// 左上角对齐
sx = 0;
sy = 0;
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX;
dy = drawY;
dw = sw;
dh = sh;
break;
case "topRight":
// 右上角对齐
sx = Math.max(0, imageWidth - canvasWidth);
sy = 0;
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, canvasWidth - imageWidth);
dy = drawY;
dw = sw;
dh = sh;
break;
case "bottomLeft":
// 左下角对齐
sx = 0;
sy = Math.max(0, imageHeight - canvasHeight);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX;
dy = drawY + Math.max(0, canvasHeight - imageHeight);
dw = sw;
dh = sh;
break;
case "bottomRight":
// 右下角对齐
sx = Math.max(0, imageWidth - canvasWidth);
sy = Math.max(0, imageHeight - canvasHeight);
sw = Math.min(imageWidth, canvasWidth);
sh = Math.min(imageHeight, canvasHeight);
dx = drawX + Math.max(0, canvasWidth - imageWidth);
dy = drawY + Math.max(0, canvasHeight - imageHeight);
dw = sw;
dh = sh;
break;
}
return {
// 源图片裁剪区域
sx,
sy,
sw,
sh,
// 目标绘制区域
dx,
dy,
dw,
dh
} as CropImageResult;
}
/**
* 获取文本每行内容(自动换行、支持省略号)
* @param options TextRenderOptions
* @returns string[] 每行内容
*/
private getTextRows({
content,
fontSize = 14,
width = 100,
lineClamp = 1,
overflow,
letterSpace = 0,
fontFamily = "sans-serif",
fontWeight = "normal"
}: TextRenderOptions) {
// 临时设置字体以便准确测量
this.ctx!.save();
this.ctx!.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
let arr: string[] = [""];
let currentLineWidth = 0;
for (let i = 0; i < content.length; i++) {
const char = content.charAt(i);
const charWidth = this.ctx!.measureText(char).width;
// 计算当前字符加上字符间距后的总宽度
const needSpace = arr[arr.length - 1].length > 0 && letterSpace > 0;
const totalWidth = charWidth + (needSpace ? letterSpace : 0);
if (currentLineWidth + totalWidth > width) {
// 换行:新行的第一个字符不需要字符间距
currentLineWidth = charWidth;
arr.push(char);
} else {
// 最后一行且设置超出省略号
if (overflow == "ellipsis" && arr.length == lineClamp) {
const ellipsisWidth = this.ctx!.measureText("...").width;
const ellipsisSpaceWidth = needSpace ? letterSpace : 0;
if (
currentLineWidth + totalWidth + ellipsisSpaceWidth + ellipsisWidth >
width
) {
arr[arr.length - 1] += "...";
break;
}
}
currentLineWidth += totalWidth;
arr[arr.length - 1] += char;
}
}
this.ctx!.restore();
return arr;
}
/**
* 渲染块(矩形/圆角矩形)
* @param options DivRenderOptions
*/
private divRender(options: DivRenderOptions) {
const {
x,
y,
width = 0,
height = 0,
radius,
backgroundColor = "#fff",
opacity = 1,
scale,
scaleX,
scaleY,
rotate,
translateX,
translateY
} = options;
this.ctx!.save();
// 设置透明度
this.ctx!.globalAlpha = opacity;
// 设置背景色
this.setBackground(backgroundColor);
// 设置边框
this.setBorder(options);
// 设置变换
this.setTransform({
scale,
scaleX,
scaleY,
rotate,
translateX,
translateY
});
// 判断是否有圆角
if (radius != null) {
// 绘制圆角路径
this.drawRadius(x, y, width, height, radius);
// 填充
this.ctx!.fill();
} else {
// 普通矩形
this.ctx!.fillRect(x, y, width, height);
}
this.ctx!.restore();
}
/**
* 渲染文本
* @param options TextRenderOptions
*/
private textRender(options: TextRenderOptions) {
let {
fontSize = 14,
textAlign,
width,
color = "#000000",
x,
y,
letterSpace,
lineHeight,
fontFamily = "sans-serif",
fontWeight = "normal",
opacity = 1,
scale,
scaleX,
scaleY,
rotate,
translateX,
translateY
} = options;
// 如果行高为空则设置为字体大小的1.4倍
if (lineHeight == null) {
lineHeight = fontSize * 1.4;
}
this.ctx!.save();
// 应用变换
this.setTransform({
scale,
scaleX,
scaleY,
rotate,
translateX,
translateY
});
// 设置字体样式
this.ctx!.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
// 设置透明度
this.ctx!.globalAlpha = opacity;
// 设置字体颜色
this.ctx!.fillStyle = color;
// 获取每行文本内容
const rows = this.getTextRows(options);
// 左偏移量
let offsetLeft = 0;
// 字体对齐无字符间距时使用Canvas的textAlign
if (textAlign != null && width != null && (letterSpace == null || letterSpace <= 0)) {
this.ctx!.textAlign = textAlign;
switch (textAlign) {
case "left":
break;
case "center":
offsetLeft = width / 2;
break;
case "right":
offsetLeft = width;
break;
}
} else {
// 有字符间距时,使用左对齐,手动控制位置
this.ctx!.textAlign = "left";
}
// 计算行间距
const lineGap = lineHeight - fontSize;
// 逐行渲染
for (let i = 0; i < rows.length; i++) {
const currentRow = rows[i];
const yPos = (i + 1) * fontSize + y + lineGap * i;
if (letterSpace != null && letterSpace > 0) {
// 逐字符计算宽度,确保字符间距准确
let lineWidth = 0;
for (let j = 0; j < currentRow.length; j++) {
lineWidth += this.ctx!.measureText(currentRow.charAt(j)).width;
if (j < currentRow.length - 1) {
lineWidth += letterSpace;
}
}
// 计算起始位置(考虑 textAlign
let startX = x;
if (textAlign == "center" && width != null) {
startX = x + (width - lineWidth) / 2;
} else if (textAlign == "right" && width != null) {
startX = x + width - lineWidth;
}
// 逐字符渲染
let charX = startX;
for (let j = 0; j < currentRow.length; j++) {
const char = currentRow.charAt(j);
this.ctx!.fillText(char, charX, yPos);
// 移动到下一个字符位置
charX += this.ctx!.measureText(char).width;
if (j < currentRow.length - 1) {
charX += letterSpace;
}
}
} else {
// 普通渲染(无字符间距)
this.ctx!.fillText(currentRow, x + offsetLeft, yPos);
}
}
this.ctx!.restore();
}
/**
* 渲染图片
* @param options ImageRenderOptions
*/
private async imageRender(options: ImageRenderOptions): Promise<void> {
return new Promise((resolve) => {
this.ctx!.save();
// 设置透明度
this.ctx!.globalAlpha = options.opacity ?? 1;
// 应用变换
this.setTransform({
scale: options.scale,
scaleX: options.scaleX,
scaleY: options.scaleY,
rotate: options.rotate,
translateX: options.translateX,
translateY: options.translateY
});
// 如果有圆角,先绘制路径并裁剪
if (options.radius != null) {
this.drawRadius(
options.x,
options.y,
options.width,
options.height,
options.radius
);
this.ctx!.clip();
}
const temp = this.imageQueue[0];
let img: Image;
// 微信小程序/鸿蒙环境创建图片
// #ifdef MP-WEIXIN || APP-HARMONY
img = this.context!.createImage();
// #endif
// 其他环境创建图片
// #ifndef MP-WEIXIN || APP-HARMONY
img = new Image();
// #endif
img.src = temp.url;
img.onload = () => {
if (options.mode != null) {
let h: number;
let w: number;
// #ifdef H5
h = img["height"];
w = img["width"];
// #endif
// #ifndef H5
h = img.height;
w = img.width;
// #endif
// 按模式裁剪并绘制
const { sx, sy, sw, sh, dx, dy, dw, dh } = this.cropImage(
options.mode,
temp.width, // 目标绘制区域宽度
temp.height, // 目标绘制区域高度
w, // 原图片宽度
h, // 原图片高度
temp.x, // 绘制X坐标
temp.y // 绘制Y坐标
);
// 使用 drawImage 的完整参数形式进行精确裁剪和绘制
this.ctx!.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh);
} else {
// 不指定模式时,直接绘制整个图片
this.ctx!.drawImage(img, temp.x, temp.y, temp.width, temp.height);
}
this.ctx!.restore();
this.imageQueue.shift(); // 移除已渲染图片
resolve();
};
});
}
/**
* 依次执行渲染队列中的所有操作
*/
async render() {
for (let i = 0; i < this.renderQuene.length; i++) {
const r = this.renderQuene[i];
await r();
}
}
}
/**
* useCanvas 钩子函数,返回 Canvas 实例
* @param canvasId 画布ID
* @returns Canvas
*/
export const useCanvas = (canvasId: string) => {
return new Canvas(canvasId);
};

View File

@@ -0,0 +1 @@
export * from "./hooks";

View File

@@ -0,0 +1,5 @@
declare type ClCanvasComponentPublicInstance = {
saveImage: () => void;
previewImage: () => void;
createImage: () => Promise<string>;
};

View File

@@ -0,0 +1,95 @@
// 文本渲染参数
export type TextRenderOptions = {
x: number;
y: number;
height?: number;
width?: number;
content: string;
color?: string;
fontSize?: number;
fontFamily?: string;
fontWeight?: "normal" | "bold" | "bolder" | "lighter" | number;
textAlign?: "left" | "right" | "center";
overflow?: "ellipsis";
lineClamp?: number;
letterSpace?: number;
lineHeight?: number;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 图片渲染参数
export type ImageRenderOptions = {
x: number;
y: number;
height: number;
width: number;
url: string;
mode?:
| "scaleToFill"
| "aspectFit"
| "aspectFill"
| "center"
| "top"
| "bottom"
| "left"
| "right"
| "topLeft"
| "topRight"
| "bottomLeft"
| "bottomRight";
radius?: number;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 变换参数
export type TransformOptions = {
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 块渲染参数
export type DivRenderOptions = {
x: number;
y: number;
height?: number;
width?: number;
radius?: number;
backgroundColor?: string;
borderWidth?: number;
borderColor?: string;
opacity?: number;
scale?: number;
scaleX?: number;
scaleY?: number;
rotate?: number;
translateX?: number;
translateY?: number;
};
// 裁剪图片参数
export type CropImageResult = {
sx: number;
sy: number;
sw: number;
sh: number;
dx: number;
dy: number;
dw: number;
dh: number;
};

View File

@@ -0,0 +1,82 @@
{
"id": "cool-open-web",
"displayName": "cool-open-web",
"version": "1.0.1",
"description": "cool-open-web",
"keywords": [
"cool-open-web"
],
"repository": "",
"engines": {
"HBuilderX": "^3.9.0"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "",
"data": "",
"permissions": ""
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u",
"alipay": "u"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "u",
"app-ios": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
# cool-open-web
### 兼容性
| IOS | Andriod | WEB | 小程序 |
| ---- | ------- | ---- | ------ |
| 支持 | 支持 | 支持 | 支持 |
### 开发文档
调用:
```ts
import { openWeb } from "@/uni_modules/cool-open-web";
openWeb("https://cool-js.com/");
```

View File

@@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@@ -0,0 +1,93 @@
// 导入Android相关类
import Intent from "android.content.Intent"; // Android意图类用于启动活动
import Uri from "android.net.Uri"; // Android URI类用于解析URL
import Activity from "android.app.Activity"; // Android活动基类
import Context from "android.content.Context"; // Android上下文类
/**
* 打开网页URL函数
*
* @param url 要打开的网页地址必须是有效的URL格式
* @returns 返回操作结果true表示成功false表示失败
*
* 功能说明:
* 1. 验证URL格式的合法性
* 2. 创建Intent意图指定ACTION_VIEW动作
* 3. 获取当前Activity实例
* 4. 启动系统默认浏览器打开链接
* 5. 提供错误处理和异常捕获
*
* 注意事项:
* - URL必须包含协议头如http://或https://
* - 需要确保设备已安装浏览器应用
* - 在某些情况下可能需要网络权限
*/
export function openWeb(url: string): boolean {
try {
// 1. 参数验证检查URL是否为空或无效
if (url.trim() == "") {
console.error("[cool-openurl] URL不能为空");
return false;
}
// 2. URL格式验证确保包含协议头
let trimmedUrl = url.trim();
if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://")) {
console.error("[cool-openurl] URL必须包含协议头http://或https://");
return false;
}
// 3. 解析URL将字符串转换为Uri对象
let uri: Uri | null;
try {
uri = Uri.parse(trimmedUrl);
} catch (e: any) {
console.error("[cool-openurl] URL格式无效" + trimmedUrl, e);
return false;
}
// 4. 验证URI是否解析成功
if (uri == null) {
console.error("[cool-openurl] URI解析失败" + trimmedUrl);
return false;
}
// 5. 创建Intent意图
// ACTION_VIEW表示查看指定数据的通用动作系统会选择合适的应用来处理
let intent = new Intent(Intent.ACTION_VIEW, uri);
// 6. 设置Intent标志
// FLAG_ACTIVITY_NEW_TASK在新的任务栈中启动活动确保浏览器在独立的任务中运行
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 7. 获取当前Activity实例
let activity = UTSAndroid.getUniActivity();
if (activity == null) {
console.error("[cool-openurl] 无法获取当前Activity实例");
return false;
}
// 8. 类型安全将获取的对象转换为Activity类型
let currentActivity = activity as Activity;
// 9. 验证系统中是否有能够处理该Intent的应用
let packageManager = currentActivity.getPackageManager();
let resolveInfos = packageManager.queryIntentActivities(intent, 0);
if (resolveInfos.size == 0) {
console.error("[cool-openurl] 系统中没有可以打开URL的应用");
return false;
}
// 10. 启动Intent打开URL
currentActivity.startActivity(intent);
// 11. 记录成功日志
console.log("[cool-openurl] 成功打开URL" + trimmedUrl);
return true;
} catch (error: any) {
// 12. 异常处理:捕获并记录所有可能的异常
console.error("[cool-openurl] 打开URL时发生异常", error);
return false;
}
}

View File

@@ -0,0 +1,36 @@
import { OpenWebNative } from "./openWeb.ets";
/**
* 在鸿蒙系统中打开指定的网页URL
* @param url 要打开的网页地址支持http、https等协议
* @returns 返回操作结果true表示成功false表示失败
*/
export function openWeb(url: string): boolean {
// 参数验证检查URL是否为空或无效
if (url == null || url.trim() == "") {
console.error("openWeb: URL参数不能为空");
return false;
}
try {
let trimmedUrl = url.trim();
// 基本URL格式验证
if (!trimmedUrl.includes(".") || trimmedUrl.length < 4) {
console.error("openWeb: 无效的URL格式 -", trimmedUrl);
return false;
}
// 如果URL不包含协议默认添加https://
if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.startsWith("//")) {
trimmedUrl = "https://" + trimmedUrl;
}
// 调用鸿蒙原生实现
return OpenWebNative.openUrl(trimmedUrl);
} catch (e) {
// 捕获可能的异常
console.error("openWeb: 打开URL时发生错误 -", e);
return false;
}
}

View File

@@ -0,0 +1,45 @@
import { Want, common } from '@kit.AbilityKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 原生打开网页控制类
* 用于在鸿蒙系统中打开网页URL
*/
export class OpenWebNative {
/**
* 打开指定的网页URL
* @param url 要打开的网页地址
* @returns 返回操作结果true表示成功false表示失败
*/
static openUrl(url: string): boolean {
try {
// 获取应用上下文
const context = getContext() as common.UIAbilityContext;
// 构建Want对象用于启动浏览器
const want: Want = {
action: 'ohos.want.action.viewData', // 查看数据的标准动作
entities: ['entity.system.browsable'], // 可浏览实体
uri: url // 目标URL
};
// 启动浏览器应用
context.startAbility(want)
.then(() => {
console.info(`成功打开URL: ${url}`);
})
.catch((error: BusinessError) => {
console.error(`打开URL失败: 错误码 ${error.code}, 错误信息 ${error.message}`);
});
return true;
} catch (err) {
// 捕获意外错误
const error: BusinessError = err as BusinessError;
console.error(
`发生意外错误: 错误码 ${error.code}, 错误信息 ${error.message}`
);
return false;
}
}
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,3 @@
{
"deploymentTarget": "12"
}

View File

@@ -0,0 +1,57 @@
import { URL } from "Foundation";
import { UIApplication } from "UIKit";
/**
* 在iOS设备上打开指定的网页URL
* @param url 要打开的网页地址支持http、https、tel、mailto等协议
* @returns 返回操作结果true表示成功false表示失败
*/
export function openWeb(url: string): boolean {
// 参数验证检查URL是否为空或无效
if (url == null || url.trim() == "") {
console.error("openWeb: URL参数不能为空");
return false;
}
try {
// 创建URL对象用于验证URL格式的有效性
let href = new URL((string = url.trim()));
// 检查URL对象是否创建成功
if (href == null) {
console.error("openWeb: 无效的URL格式 -", url);
return false;
}
// 检查系统版本iOS 16.0及以上版本使用新的API
if (UTSiOS.available("iOS 16.0, *")) {
// iOS 16.0+ 使用 open(_:options:completionHandler:) 方法
// 先检查系统是否支持打开该URL
if (UIApplication.shared.canOpenURL(href!)) {
// 使用新API打开URL传入空的options和completionHandler
UIApplication.shared.open(href!, (options = new Map()), (completionHandler = nil));
console.log("openWeb: 成功使用新API打开URL -", url);
return true;
} else {
console.warn("openWeb: 系统不支持打开该URL协议 -", url);
return false;
}
} else {
// iOS 16.0以下版本使用已弃用但仍可用的 openURL 方法
// 先检查系统是否支持打开该URL
if (UIApplication.shared.canOpenURL(href!)) {
// 使用传统API打开URL
UIApplication.shared.openURL(href!);
console.log("openWeb: 成功使用传统API打开URL -", url);
return true;
} else {
console.warn("openWeb: 系统不支持打开该URL协议 -", url);
return false;
}
}
} catch (e) {
// 捕获可能的异常如URL格式错误等
console.error("openWeb: 打开URL时发生错误 -", e);
return false;
}
}

View File

@@ -0,0 +1,49 @@
/**
* 在微信小程序中打开指定的网页URL
* 使用微信小程序的wx.openUrl API
* @param url 要打开的网页地址必须是https协议
* @returns 返回操作结果true表示成功false表示失败
*/
export function openWeb(url: string): boolean {
// 参数验证检查URL是否为空或无效
if (url == null || url.trim() == "") {
console.error("openWeb: URL参数不能为空");
return false;
}
try {
let trimmedUrl = url.trim();
// 微信小程序要求必须是https协议
if (!trimmedUrl.startsWith("https://")) {
console.error("openWeb: 微信小程序只支持https协议的URL -", trimmedUrl);
return false;
}
// 基本URL格式验证
if (!trimmedUrl.includes(".") || trimmedUrl.length < 12) { // https:// 最少8个字符 + 域名最少4个字符
console.error("openWeb: 无效的URL格式 -", trimmedUrl);
return false;
}
// 使用微信小程序的API打开URL
wx.openUrl({
url: trimmedUrl,
success: (res: any) => {
console.log("openWeb: 成功打开URL -", trimmedUrl);
},
fail: (err: any) => {
console.error("openWeb: 打开URL失败 -", err);
},
complete: (res: any) => {
console.log("openWeb: 操作完成 -", res);
}
});
return true;
} catch (e) {
// 捕获可能的异常
console.error("openWeb: 打开URL时发生错误 -", e);
return false;
}
}

View File

@@ -0,0 +1,37 @@
/**
* 在Web浏览器中打开指定的网页URL
* @param url 要打开的网页地址支持http、https等协议
* @returns 返回操作结果true表示成功false表示失败
*/
export function openWeb(url: string): boolean {
// 参数验证检查URL是否为空或无效
if (url == null || url.trim() == "") {
console.error("openWeb: URL参数不能为空");
return false;
}
try {
let trimmedUrl = url.trim();
// 基本URL格式验证
if (!trimmedUrl.includes(".") || trimmedUrl.length < 4) {
console.error("openWeb: 无效的URL格式 -", trimmedUrl);
return false;
}
// 如果URL不包含协议默认添加https://
if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.startsWith("//")) {
trimmedUrl = "https://" + trimmedUrl;
}
// 在当前窗口中打开URL
location.href = trimmedUrl;
console.log("openWeb: 成功打开URL -", trimmedUrl);
return true;
} catch (e) {
// 捕获可能的异常
console.error("openWeb: 打开URL时发生错误 -", e);
return false;
}
}

View File

@@ -0,0 +1,10 @@
declare module "@/uni_modules/cool-share" {
export function shareWithSystem(options: {
type: "text" | "image" | "file" | "link" | "video" | "audio";
title?: string;
summary?: string;
url?: string;
success?: () => void;
fail?: (error: string) => void;
}): void;
}

View File

@@ -0,0 +1,84 @@
{
"id": "cool-share",
"displayName": "cool-share",
"version": "1.0.0",
"description": "cool-share",
"keywords": [
"cool-share",
"share",
"系统分享"
],
"repository": "",
"engines": {
"HBuilderX": "^4.75"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "",
"data": "",
"permissions": ""
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u",
"alipay": "u"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "u",
"app-ios": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="uts.sdk.modules.coolShare">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application>
<meta-data android:name="ScopedStorage" android:value="true" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true"
tools:replace="android:authorities">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,3 @@
{
"minSdkVersion": "21"
}

View File

@@ -0,0 +1,459 @@
import Intent from "android.content.Intent";
import Uri from "android.net.Uri";
import Context from "android.content.Context";
import File from "java.io.File";
import FileProvider from "androidx.core.content.FileProvider";
import { ShareWithSystemOptions } from "../interface.uts";
/**
* 分享类型枚举
*/
const ShareType = {
TEXT: "text", // 纯文本分享
IMAGE: "image", // 图片分享
VIDEO: "video", // 视频分享
AUDIO: "audio", // 音频分享
FILE: "file", // 文件分享
LINK: "link" // 链接分享
};
/**
* MIME 类型映射
*/
const MimeTypes = {
IMAGE: "image/*",
VIDEO: "video/*",
AUDIO: "audio/*",
TEXT: "text/plain",
PDF: "application/pdf",
WORD: "application/msword",
EXCEL: "application/vnd.ms-excel",
PPT: "application/vnd.ms-powerpoint",
ZIP: "application/zip",
DEFAULT: "*/*"
};
/**
* 判断是否为网络 URL
* @param url 地址
* @returns 是否为网络 URL
*/
function isNetworkUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
/**
* 根据文件路径获取 File 对象
* 按优先级尝试多种路径解析方式
* @param filePath 文件路径
* @returns File 对象或 null
*/
function getFileFromPath(filePath: string): File | null {
// 1. 尝试直接路径
let file = new File(filePath);
if (file.exists()) {
return file;
}
// 2. 尝试资源路径
file = new File(UTSAndroid.getResourcePath(filePath));
if (file.exists()) {
return file;
}
// 3. 尝试绝对路径转换
file = new File(UTSAndroid.convert2AbsFullPath(filePath));
if (file.exists()) {
return file;
}
return null;
}
/**
* 根据文件扩展名获取 MIME 类型
* @param filePath 文件路径
* @param defaultType 默认类型
* @returns MIME 类型字符串
*/
function getMimeTypeByPath(filePath: string, defaultType: string): string {
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
if (ext == "") {
return defaultType;
}
// 常见文件类型映射
const mimeMap = {
// 文档类型
pdf: MimeTypes["PDF"],
doc: MimeTypes["WORD"],
docx: MimeTypes["WORD"],
xls: MimeTypes["EXCEL"],
xlsx: MimeTypes["EXCEL"],
ppt: MimeTypes["PPT"],
pptx: MimeTypes["PPT"],
// 压缩包类型
zip: MimeTypes["ZIP"],
rar: "application/x-rar-compressed",
"7z": "application/x-7z-compressed",
tar: "application/x-tar",
gz: "application/gzip"
};
return (mimeMap[ext] as string) ?? defaultType;
}
/**
* 下载网络文件到本地缓存
* @param url 网络地址
* @param success 成功回调,返回本地文件路径
* @param fail 失败回调
*/
function downloadNetworkFile(
url: string,
success: (localPath: string) => void,
fail: (error: string) => void
): void {
uni.downloadFile({
url: url,
success: (res) => {
if (res.statusCode == 200) {
success(res.tempFilePath);
} else {
fail("下载失败,状态码: " + res.statusCode);
}
},
fail: (err) => {
fail("下载失败: " + (err.errMsg ?? "未知错误"));
}
});
}
/**
* 创建文件 Uri
* @param filePath 文件路径(支持本地路径和网络 URL
* @param success 成功回调
* @param fail 失败回调
*/
function createFileUriAsync(
filePath: string,
success: (uri: Uri) => void,
fail: (error: string) => void
): void {
// 创建文件Uri支持网络和本地文件。网络文件先下载到本地缓存再获取Uri。
const handleFileToUri = (localPath: string) => {
const file = getFileFromPath(localPath);
if (file == null) {
fail(`文件不存在: ${localPath}`);
return;
}
const context = UTSAndroid.getAppContext();
if (context == null) {
fail("无法获取App Context");
return;
}
const authority = context.getPackageName() + ".fileprovider";
const uri = FileProvider.getUriForFile(context, authority, file);
success(uri);
};
if (isNetworkUrl(filePath)) {
// 网络路径需先下载,下载完成后处理
downloadNetworkFile(filePath, handleFileToUri, fail);
} else {
// 本地文件直接处理
handleFileToUri(filePath);
}
}
/**
* 创建图片分享 Intent异步
* @param url 图片路径(支持本地路径和网络 URL
* @param title 分享标题
* @param success 成功回调
* @param fail 失败回调
*/
function createImageShareIntent(
url: string,
title: string,
success: (intent: Intent) => void,
fail: (error: string) => void
): void {
if (url == "") {
fail("图片路径不能为空");
return;
}
createFileUriAsync(
url,
(uri: Uri) => {
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(MimeTypes["IMAGE"] as string);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.putExtra(Intent.EXTRA_TITLE, title);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
success(intent);
},
(error: string) => {
fail(error);
}
);
}
/**
* 创建视频分享 Intent异步
* @param url 视频路径(支持本地路径和网络 URL
* @param title 分享标题
* @param success 成功回调
* @param fail 失败回调
*/
function createVideoShareIntent(
url: string,
title: string,
success: (intent: Intent) => void,
fail: (error: string) => void
): void {
if (url == "") {
fail("视频路径不能为空");
return;
}
createFileUriAsync(
url,
(uri: Uri) => {
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(MimeTypes["VIDEO"] as string);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.putExtra(Intent.EXTRA_TITLE, title);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
success(intent);
},
(error: string) => {
fail(error);
}
);
}
/**
* 创建音频分享 Intent异步
* @param url 音频路径(支持本地路径和网络 URL
* @param title 分享标题
* @param success 成功回调
* @param fail 失败回调
*/
function createAudioShareIntent(
url: string,
title: string,
success: (intent: Intent) => void,
fail: (error: string) => void
): void {
if (url == "") {
fail("音频路径不能为空");
return;
}
createFileUriAsync(
url,
(uri: Uri) => {
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(MimeTypes["AUDIO"] as string);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.putExtra(Intent.EXTRA_TITLE, title);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
success(intent);
},
(error: string) => {
fail(error);
}
);
}
/**
* 创建文件分享 Intent异步
* @param filePath 文件路径(支持本地路径和网络 URL
* @param title 分享标题
* @param success 成功回调
* @param fail 失败回调
*/
function createFileShareIntent(
filePath: string,
title: string,
success: (intent: Intent) => void,
fail: (error: string) => void
): void {
if (filePath == "") {
fail("文件路径不能为空");
return;
}
createFileUriAsync(
filePath,
(uri: Uri) => {
// 根据文件扩展名确定 MIME 类型
const mimeType = getMimeTypeByPath(filePath, MimeTypes["DEFAULT"] as string);
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_STREAM, uri);
intent.putExtra(Intent.EXTRA_TITLE, title);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
success(intent);
},
(error: string) => {
fail(error);
}
);
}
/**
* 创建链接分享 Intent
* @param url 链接地址
* @param title 分享标题
* @param summary 分享描述
* @param success 成功回调
* @param fail 失败回调
*/
function createLinkShareIntent(
url: string,
title: string,
summary: string,
success: (intent: Intent) => void,
fail: (error: string) => void
): void {
if (url == "") {
fail("链接地址不能为空");
return;
}
// 组合分享内容:标题 + 描述 + 链接
let content = "";
if (title != "") {
content = title;
}
if (summary != "") {
content = content == "" ? summary : content + "\n" + summary;
}
if (url != "") {
content = content == "" ? url : content + "\n" + url;
}
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(MimeTypes["TEXT"] as string);
intent.putExtra(Intent.EXTRA_TEXT, content);
success(intent);
}
/**
* 创建文本分享 Intent
* @param title 分享标题
* @param summary 分享描述
* @param url 附加链接(可选)
* @param success 成功回调
*/
function createTextShareIntent(
title: string,
summary: string,
url: string,
success: (intent: Intent) => void
): void {
// 组合分享内容
let content = "";
if (title != "") {
content = title;
}
if (summary != "") {
content = content == "" ? summary : content + "\n" + summary;
}
if (url != "") {
content = content == "" ? url : content + "\n" + url;
}
// 如果内容为空,使用默认文本
if (content == "") {
content = "分享内容";
}
const intent = new Intent(Intent.ACTION_SEND);
intent.setType(MimeTypes["TEXT"] as string);
intent.putExtra(Intent.EXTRA_TEXT, content);
success(intent);
}
/**
* 启动分享 Activity
* @param intent 分享 Intent
* @param title 选择器标题
* @param success 成功回调
* @param fail 失败回调
*/
function startShareActivity(
intent: Intent,
title: string,
success: () => void,
fail: (error: string) => void
): void {
const chooserTitle = title != "" ? title : "选择分享方式";
const chooser = Intent.createChooser(intent, chooserTitle);
try {
UTSAndroid.getUniActivity()!.startActivity(chooser);
success();
} catch (e: Exception) {
const errorMsg = e.message ?? "分享失败";
fail(errorMsg);
}
}
/**
* 系统分享功能
* @param options 分享参数
* @param options.type 分享类型: text(文本) | image(图片) | video(视频) | audio(音频) | file(文件) | link(链接)
* @param options.title 分享标题
* @param options.summary 分享描述/内容
* @param options.url 资源路径(图片/视频/音频/文件路径或链接地址,支持本地路径和网络 URL
* @param options.success 成功回调
* @param options.fail 失败回调
*/
export function shareWithSystem(options: ShareWithSystemOptions): void {
const type = options.type;
const title = options.title ?? "";
const summary = options.summary ?? "";
const url = options.url ?? "";
// 成功和失败回调
const onSuccess = (intent: Intent) => {
startShareActivity(
intent,
title,
() => {
options.success?.();
},
(error: string) => {
options.fail?.(error);
}
);
};
const onFail = (error: string) => {
options.fail?.(error);
};
// 根据分享类型创建对应的 Intent
if (type == ShareType["IMAGE"]) {
createImageShareIntent(url, title, onSuccess, onFail);
} else if (type == ShareType["VIDEO"]) {
createVideoShareIntent(url, title, onSuccess, onFail);
} else if (type == ShareType["AUDIO"]) {
createAudioShareIntent(url, title, onSuccess, onFail);
} else if (type == ShareType["FILE"]) {
createFileShareIntent(url, title, onSuccess, onFail);
} else if (type == ShareType["LINK"]) {
createLinkShareIntent(url, title, summary, onSuccess, onFail);
} else {
// 默认为文本分享
createTextShareIntent(title, summary, url, onSuccess);
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 外部存储路径 -->
<external-path name="external_files" path="." />
<!-- 外部缓存路径 -->
<external-cache-path name="external_cache" path="." />
<!-- 应用缓存路径(用于 uni.downloadFile 下载的临时文件) -->
<cache-path name="cache" path="." />
<!-- 应用内部文件路径 -->
<files-path name="files" path="." />
<!-- 根路径(谨慎使用) -->
<root-path name="root" path="." />
</paths>

View File

@@ -0,0 +1,13 @@
import { ShareWithSystemOptions } from "../interface.uts";
import { share } from "./share.ets";
export function shareWithSystem(options: ShareWithSystemOptions) {
share(
options.type,
options.title ?? "",
options.summary ?? "",
options.url ?? "",
options.success ?? (() => {}),
options.fail ?? (() => {})
);
}

View File

@@ -0,0 +1,265 @@
import { UTSHarmony } from '@dcloudio/uni-app-x-runtime';
import { systemShare } from '@kit.ShareKit';
import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { fileUri } from '@kit.CoreFileKit';
import { BusinessError } from '@kit.BasicServicesKit';
/**
* 分享类型枚举
*/
enum ShareType {
TEXT = "text", // 纯文本分享
IMAGE = "image", // 图片分享
VIDEO = "video", // 视频分享
AUDIO = "audio", // 音频分享
FILE = "file", // 文件分享
LINK = "link" // 链接分享
}
/**
* 根据文件路径获取统一数据类型标识符
* @param filePath 文件路径
* @param defaultType 默认数据类型
* @returns 统一数据类型标识符
*/
function getUtdTypeByPath(filePath: string, defaultType: string): string {
const ext = filePath?.split('.')?.pop()?.toLowerCase() ?? '';
if (ext === '') {
return defaultType;
}
return utd.getUniformDataTypeByFilenameExtension('.' + ext, defaultType);
}
/**
* 创建图片分享数据
* @param url 图片路径(支持本地路径和网络 URL
* @param title 分享标题
* @param summary 分享描述
* @returns 分享数据对象
*/
function createImageShareData(url: string, title: string, summary: string): systemShare.SharedData | null {
if (url === '') {
return null;
}
const filePath = UTSHarmony.getResourcePath(url);
const utdTypeId = getUtdTypeByPath(filePath, utd.UniformDataType.IMAGE);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(filePath),
title: title,
description: summary,
});
}
/**
* 创建视频分享数据
* @param url 视频路径(支持本地路径和网络 URL
* @param title 分享标题
* @param summary 分享描述
* @returns 分享数据对象
*/
function createVideoShareData(url: string, title: string, summary: string): systemShare.SharedData | null {
if (url === '') {
return null;
}
const filePath = UTSHarmony.getResourcePath(url);
const utdTypeId = getUtdTypeByPath(filePath, utd.UniformDataType.VIDEO);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(filePath),
title: title,
description: summary,
});
}
/**
* 创建音频分享数据
* @param url 音频路径(支持本地路径和网络 URL
* @param title 分享标题
* @param summary 分享描述
* @returns 分享数据对象
*/
function createAudioShareData(url: string, title: string, summary: string): systemShare.SharedData | null {
if (url === '') {
return null;
}
const filePath = UTSHarmony.getResourcePath(url);
const utdTypeId = getUtdTypeByPath(filePath, utd.UniformDataType.AUDIO);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(filePath),
title: title,
description: summary,
});
}
/**
* 创建文件分享数据
* @param filePath 文件路径(支持本地路径和网络 URL
* @param title 分享标题
* @param summary 分享描述
* @returns 分享数据对象
*/
function createFileShareData(filePath: string, title: string, summary: string): systemShare.SharedData | null {
if (filePath === '') {
return null;
}
const resourcePath = UTSHarmony.getResourcePath(filePath);
const ext = resourcePath?.split('.')?.pop()?.toLowerCase() ?? '';
// 根据文件扩展名确定数据类型
let utdType = utd.UniformDataType.FILE;
let utdTypeId = '';
// 支持常见的文件类型
switch (ext) {
case 'zip':
case 'rar':
case '7z':
case 'tar':
case 'gz':
utdType = utd.UniformDataType.ARCHIVE;
break;
case 'pdf':
utdType = utd.UniformDataType.PDF;
break;
case 'doc':
case 'docx':
utdType = utd.UniformDataType.WORD_DOC;
break;
case 'xls':
case 'xlsx':
utdType = utd.UniformDataType.EXCEL;
break;
case 'ppt':
case 'pptx':
utdType = utd.UniformDataType.PPT;
break;
default:
utdType = utd.UniformDataType.FILE;
break;
}
utdTypeId = utd.getUniformDataTypeByFilenameExtension('.' + ext, utdType);
return new systemShare.SharedData({
utd: utdTypeId,
uri: fileUri.getUriFromPath(resourcePath),
title: title,
description: summary,
});
}
/**
* 创建链接分享数据
* @param url 链接地址
* @param title 分享标题
* @param summary 分享描述
* @returns 分享数据对象
*/
function createLinkShareData(url: string, title: string, summary: string): systemShare.SharedData {
return new systemShare.SharedData({
utd: utd.UniformDataType.HYPERLINK,
title: title,
content: url,
description: summary
});
}
/**
* 创建文本分享数据
* @param title 分享标题
* @param summary 分享内容
* @returns 分享数据对象
*/
function createTextShareData(title: string, summary: string): systemShare.SharedData {
return new systemShare.SharedData({
utd: utd.UniformDataType.TEXT,
title: title,
content: summary
});
}
/**
* 系统分享功能
* @param options 分享参数
* @param options.type 分享类型: text(文本) | image(图片) | video(视频) | audio(音频) | file(文件) | link(链接)
* @param options.title 分享标题
* @param options.summary 分享描述/内容
* @param options.url 资源路径(图片/视频/音频/文件路径或链接地址,支持本地路径和网络 URL
* @param options.success 成功回调
* @param options.fail 失败回调
*/
export function share(type: string, title: string, summary: string, url: string, success: () => void, fail: (error: string) => void): void {
// 获取UI上下文
const uiContext: UIContext = UTSHarmony.getCurrentWindow()?.getUIContext();
const context: common.UIAbilityContext = uiContext.getHostContext() as common.UIAbilityContext;
// 根据分享类型创建分享数据
let shareData: systemShare.SharedData | null = null;
let errorMsg = '';
switch (type) {
case ShareType.IMAGE:
shareData = createImageShareData(url, title, summary);
errorMsg = '图片路径不能为空';
break;
case ShareType.VIDEO:
shareData = createVideoShareData(url, title, summary);
errorMsg = '视频路径不能为空';
break;
case ShareType.AUDIO:
shareData = createAudioShareData(url, title, summary);
errorMsg = '音频路径不能为空';
break;
case ShareType.FILE:
shareData = createFileShareData(url, title, summary);
errorMsg = '文件路径不能为空';
break;
case ShareType.LINK:
shareData = createLinkShareData(url, title, summary);
break;
default:
// 默认为文本分享
shareData = createTextShareData(title, summary);
errorMsg = '分享内容不能为空';
break;
}
// 验证分享数据
if (shareData === null) {
fail(errorMsg);
return;
}
// 创建分享控制器
const controller: systemShare.ShareController = new systemShare.ShareController(shareData);
// 显示分享面板,配置分享选项
controller.show(context, {
selectionMode: systemShare.SelectionMode.SINGLE, // 单选模式
previewMode: systemShare.SharePreviewMode.DEFAULT, // 默认预览模式
})
.then(() => {
// 分享成功
success();
})
.catch((error: BusinessError) => {
// 分享失败,返回错误信息
const errorMessage = error?.message ?? '分享失败';
fail(errorMessage);
});
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>

View File

@@ -0,0 +1,3 @@
{
"deploymentTarget": "12"
}

View File

@@ -0,0 +1,287 @@
import { UIActivityViewController, UIImage } from "UIKit";
import { URL, Data } from "Foundation";
import { ShareWithSystemOptions } from "../interface.uts";
/**
* 分享类型枚举
*/
const ShareType = {
TEXT: "text", // 纯文本分享
IMAGE: "image", // 图片分享
VIDEO: "video", // 视频分享
AUDIO: "audio", // 音频分享
FILE: "file", // 文件分享
LINK: "link" // 链接分享
};
/**
* 判断是否为网络 URL
* @param url 地址
* @returns 是否为网络 URL
*/
function isNetworkUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
/**
* 下载网络文件到本地缓存
* @param url 网络地址
* @param success 成功回调,返回本地文件路径
* @param fail 失败回调
*/
function downloadNetworkFile(
url: string,
success: (localPath: string) => void,
fail: (error: string) => void
): void {
uni.downloadFile({
url: url,
success: (res) => {
if (res.statusCode == 200) {
success(res.tempFilePath);
} else {
fail(`下载失败,状态码: ${res.statusCode}`);
}
},
fail: (err) => {
fail(`下载失败: ${err.errMsg ?? "未知错误"}`);
}
});
}
/**
* 处理文件路径并加载数据
* @param filePath 文件路径
* @returns Data 对象或 null
*/
function loadFileData(filePath: string): Data | null {
const absolutePath = UTSiOS.convert2AbsFullPath(filePath);
const fileURL = new URL((fileURLWithPath = absolutePath));
const data = UTSiOS.try(new Data((contentsOf = fileURL)), "?");
return data;
}
/**
* 创建文本分享内容
* @param title 标题
* @param summary 描述
* @param url 链接
* @returns 分享内容数组
*/
function createTextShareItems(title: string, summary: string, url: string): any[] {
// 组合分享内容
let content = "";
if (title != "") {
content = title;
}
if (summary != "") {
content = content == "" ? summary : content + "\n" + summary;
}
if (url != "") {
content = content == "" ? url : content + "\n" + url;
}
// 如果内容为空,使用默认文本
if (content == "") {
content = "分享内容";
}
return [content];
}
/**
* 创建图片分享内容
* @param url 图片路径
* @param success 成功回调
* @param fail 失败回调
*/
function createImageShareItems(
url: string,
success: (items: any[]) => void,
fail: (error: string) => void
): void {
if (url == "") {
fail("图片路径不能为空");
return;
}
const handleImagePath = (localPath: string) => {
const absolutePath = UTSiOS.convert2AbsFullPath(localPath);
const image = new UIImage((contentsOfFile = absolutePath));
if (image == null) {
fail("图片加载失败");
return;
}
success([image]);
};
if (isNetworkUrl(url)) {
// 网络图片先下载
downloadNetworkFile(url, handleImagePath, fail);
} else {
// 本地图片直接处理
handleImagePath(url);
}
}
/**
* 创建文件分享内容(视频、音频、文件)
* @param filePath 文件路径
* @param success 成功回调
* @param fail 失败回调
*/
function createFileShareItems(
filePath: string,
success: (items: any[]) => void,
fail: (error: string) => void
): void {
if (filePath == "") {
fail("文件路径不能为空");
return;
}
const handleFilePath = (localPath: string) => {
const data = loadFileData(localPath);
if (data == null) {
fail("文件加载失败");
return;
}
const absolutePath = UTSiOS.convert2AbsFullPath(localPath);
const fileURL = new URL.init((fileURLWithPath = absolutePath));
success([data, fileURL]);
};
if (isNetworkUrl(filePath)) {
// 网络文件先下载
downloadNetworkFile(filePath, handleFilePath, fail);
} else {
// 本地文件直接处理
handleFilePath(filePath);
}
}
/**
* 启动系统分享界面
* @param items 分享内容数组
* @param success 成功回调
* @param fail 失败回调
*/
function presentShareController(
items: any[],
success: () => void,
fail: (error: string) => void
): void {
DispatchQueue.main.async(
(execute = (): void => {
const activityViewController = new UIActivityViewController(
(activityItems = items),
(applicationActivities = nil)
);
const currentVC = UTSiOS.getCurrentViewController();
if (currentVC == null) {
fail("无法获取当前视图控制器");
return;
}
currentVC.present(
activityViewController,
(animated = true),
(completion = () => {
success();
})
);
})
);
}
/**
* 系统分享功能
* @param options 分享参数
* @param options.type 分享类型: text(文本) | image(图片) | video(视频) | audio(音频) | file(文件) | link(链接)
* @param options.title 分享标题
* @param options.summary 分享描述/内容
* @param options.url 资源路径(图片/视频/音频/文件路径或链接地址,支持本地路径和网络 URL
* @param options.success 成功回调
* @param options.fail 失败回调
*/
export function shareWithSystem(options: ShareWithSystemOptions): void {
const type = options.type;
const title = options.title ?? "";
const summary = options.summary ?? "";
const url = options.url ?? "";
// 根据分享类型创建对应的内容
if (type == (ShareType["TEXT"] as string) || type == (ShareType["LINK"] as string)) {
// 文本或链接分享
const items = createTextShareItems(title, summary, url);
presentShareController(
items,
() => {
options.success?.();
},
(error: string) => {
options.fail?.(error);
}
);
} else if (type == (ShareType["IMAGE"] as string)) {
// 图片分享
createImageShareItems(
url,
(items: any[]) => {
presentShareController(
items,
() => {
options.success?.();
},
(error: string) => {
options.fail?.(error);
}
);
},
(error: string) => {
options.fail?.(error);
}
);
} else if (
type == (ShareType["VIDEO"] as string) ||
type == (ShareType["AUDIO"] as string) ||
type == (ShareType["FILE"] as string)
) {
// 视频、音频、文件分享
createFileShareItems(
url,
(items: any[]) => {
presentShareController(
items,
() => {
options.success?.();
},
(error: string) => {
options.fail?.(error);
}
);
},
(error: string) => {
options.fail?.(error);
}
);
} else {
// 未知类型,默认使用文本分享
const items = createTextShareItems(title, summary, url);
presentShareController(
items,
() => {
options.success?.();
},
(error: string) => {
options.fail?.(error);
}
);
}
}

View File

@@ -0,0 +1,29 @@
export type ShareWithSystemOptions = {
/**
* 分享类型:
* text(文本) | image(图片) | video(视频) | audio(音频) | file(文件) | link(链接)
*/
type: string;
/**
* 分享标题
*/
title?: string;
/**
* 分享描述或内容
*/
summary?: string;
/**
* 分享资源路径:
* 如果是图片/视频/音频/文件填写资源路径或网络URL
* 如果是link填写链接地址
*/
url?: string;
/**
* 分享成功回调
*/
success?: () => void;
/**
* 分享失败回调,返回错误信息
*/
fail?: (error: string) => void;
};

View File

@@ -0,0 +1,233 @@
<template>
<!-- App 平台:使用原生视图渲染 SVG性能最佳 -->
<!-- #ifdef APP-ANDROID || APP-IOS -->
<!-- @vue-ignore -->
<native-view @init="onInit"></native-view>
<!-- #endif -->
<!-- 小程序平台:使用 image 标签显示 SVG -->
<!-- #ifdef MP || APP-HARMONY -->
<!-- @vue-ignore -->
<image class="cl-svg" :src="svgSrc"></image>
<!-- #endif -->
<!-- Web 平台:使用 object 标签以支持 SVG 交互和样式控制 -->
<!-- #ifdef WEB -->
<object :id="svgId" :data="svgSrc" type="image/svg+xml" class="cl-svg"></object>
<!-- #endif -->
</template>
<script lang="ts" setup>
import { computed, watch, onMounted } from "vue";
import { getColor, isDark } from "@/cool";
// #ifdef APP-ANDROID || APP-IOS
// @ts-ignore
import { CoolSvg } from "@/uni_modules/cool-svg";
// #endif
// 组件属性定义
const props = defineProps({
/**
* SVG 数据源
* 支持格式:
* - 文件路径:'/static/icon.svg'
* - base64 数据:'data:image/svg+xml;base64,PHN2Zw...'
* - 标签 SVG'<svg>...</svg>'
*/
src: {
type: String,
default: ""
},
/**
* SVG 填充颜色
* 支持格式:#hex、rgb()、rgba()、颜色名称
* 会自动替换 SVG 中 path 元素的 fill 属性
*/
color: {
type: String,
default: ""
}
});
// 颜色值
const color = computed(() => {
if (props.color == "none") {
return "";
}
if (props.color != "") {
if (props.color == "primary") {
return getColor("primary-500");
}
return props.color;
} else {
return isDark.value ? "white" : "black";
}
});
/**
* 将 SVG 字符串转换为数据 URL
* @param svgString 原始 SVG 字符串
* @returns 转换后的数据 URL
*/
function svgToDataUrl(svgString: string): string {
let encodedSvg: string;
// #ifdef APP-ANDROID || APP-IOS
// App 平台:简单的空格替换即可,无需完整 URL 编码
encodedSvg = svgString.replace(/\+/g, "%20");
// #endif
// #ifndef APP-ANDROID || APP-IOS
// 非 App 平台:使用标准 URL 编码
encodedSvg = encodeURIComponent(svgString)!.replace(/\+/g, "%20");
// #endif
// 确保返回完整的数据 URL 格式
return encodedSvg.startsWith("data:image/svg+xml,")
? encodedSvg
: `data:image/svg+xml,${encodedSvg}`;
}
/**
* 计算最终的 SVG 数据源
* 自动判断数据类型并进行相应处理
*/
const svgSrc = computed((): string => {
let val = props.src;
if (val == "") {
return "";
}
// 处理颜色
if (color.value != "") {
if (val.includes("fill")) {
val = val.replace(/(<path\b[^>]*\bfill=")[^"]*("[^>]*>)/g, `$1${color.value}$2`);
} else {
val = val.replace(/<svg /g, `<svg fill="${color.value}" `);
}
}
// 判断是否为 标签 SVG以 <svg 开头)
if (val.startsWith("<svg")) {
return svgToDataUrl(val);
}
// 其他情况直接返回原始数据源文件路径、base64 等)
return val;
});
/**
* 生成符合 RFC4122 标准的 UUID v4
* 用于 Web 平台创建唯一的元素 ID
* @returns UUID 字符串
*/
function generateUuid(): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".split("");
const uuid: string[] = [];
// 生成 36 位字符
for (let i = 0; i < 36; i++) {
const randomIndex = Math.floor(Math.random() * 16);
const char = chars[i == 19 ? (randomIndex & 0x3) | 0x8 : randomIndex];
uuid.push(char);
}
// 设置 RFC4122 标准要求的固定位
uuid[8] = "-"; // 第一个连字符
uuid[13] = "-"; // 第二个连字符
uuid[18] = "-"; // 第三个连字符
uuid[23] = "-"; // 第四个连字符
uuid[14] = "4"; // 版本号 v4
return uuid.join("");
}
// Web 平台使用的唯一元素 ID
const svgId = `cool-svg-${generateUuid()}`;
// #ifdef APP-ANDROID || APP-IOS
// App 平台 SVG 渲染器实例
let svgRenderer: CoolSvg | null = null;
// 重新加载
function reload() {
if (svgRenderer != null) {
svgRenderer!.load(svgSrc.value, color.value);
}
}
/**
* App 平台原生视图初始化回调
* @param e 原生视图初始化事件
*/
function onInit(e: UniNativeViewInitEvent) {
svgRenderer = new CoolSvg(e.detail.element);
reload();
}
/**
* 监听 SVG 数据源变化,重新渲染
*/
watch(svgSrc, (newSrc: string) => {
if (svgRenderer != null && newSrc != "") {
reload();
}
});
// #endif
/**
* 设置颜色
*/
function setColor() {
if (color.value == "") {
return;
}
// #ifdef WEB
const element = document.getElementById(svgId) as HTMLObjectElement;
if (element != null) {
const set = () => {
const svgDoc = element.getSVGDocument();
if (svgDoc != null) {
// 查找所有 path 元素并应用颜色
const paths = svgDoc.querySelectorAll("path");
paths?.forEach((path) => {
path.setAttribute("fill", color.value);
});
}
};
if (element.getSVGDocument() != null) {
set();
} else {
element.addEventListener("load", set);
}
}
// #endif
// #ifdef APP-ANDROID || APP-IOS
if (svgRenderer != null && svgSrc.value != "") {
reload();
}
// #endif
}
/**
* 监听颜色变化,重新渲染
*/
watch(
computed(() => [props.color, isDark.value]),
() => {
setColor();
}
);
onMounted(() => {
setColor();
});
</script>

View File

@@ -0,0 +1,7 @@
export {};
declare module "vue" {
export interface GlobalComponents {
"cl-svg": (typeof import("./components/cl-svg/cl-svg.uvue"))["default"];
}
}

View File

@@ -0,0 +1,82 @@
{
"id": "cool-svg",
"displayName": "cool-svg",
"version": "1.0.0",
"description": "cool-svg",
"keywords": [
"cool-svg"
],
"repository": "",
"engines": {
"HBuilderX": "^4.75"
},
"dcloudext": {
"type": "uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "",
"data": "",
"permissions": ""
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u",
"alipay": "u"
},
"client": {
"Vue": {
"vue2": "u",
"vue3": "u"
},
"App": {
"app-android": "u",
"app-ios": "u"
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,4 @@
{
"minSdkVersion": "21",
"dependencies": ["com.caverock:androidsvg:1.4"]
}

View File

@@ -0,0 +1,132 @@
import PictureDrawable from "android.graphics.drawable.PictureDrawable";
import ImageView from "android.widget.ImageView";
import File from "java.io.File";
import FileInputStream from "java.io.FileInputStream";
import Color from "android.graphics.Color";
import RenderOptions from "com.caverock.androidsvg.RenderOptions";
import Base64 from "android.util.Base64";
import Charset from "java.nio.charset.Charset";
import StandardCharsets from "java.nio.charset.StandardCharsets";
/**
* CoolSvg Android 平台 SVG 渲染器
* 支持多种 SVG 数据格式:
* - base64 编码的数据 URL
* - URL 编码的数据 URL
* - 本地文件路径
* - Android 资源文件
*/
export class CoolSvg {
/** 原生视图元素 */
$element: UniNativeViewElement;
/** Android ImageView 实例 */
imageView: ImageView | null = null;
/**
* 构造函数
* @param element uni-app x 原生视图元素
*/
constructor(element: UniNativeViewElement) {
this.$element = element;
this.imageView = new ImageView(UTSAndroid.getAppContext()!);
this.$element.bindAndroidView(this.imageView!);
}
/**
* 加载并渲染 SVG
* @param src SVG 数据源,支持以下格式:
* - data:image/svg+xml;base64,<base64数据>
* - data:image/svg+xml,<URL编码的SVG>
* - 本地文件路径
* @param color 填充颜色,用于替换 SVG 中 path 元素的 fill 属性
*/
load(src: string, color: string) {
// 空字符串检查
if (src == "") {
return;
}
try {
if (src.startsWith("data:image/svg")) {
// 处理数据 URL 格式的 SVG
this.loadFromDataUrl(src, color);
} else {
// 处理本地文件或资源文件
this.loadFromFile(src, color);
}
} catch (e) {
// 打印异常信息用于调试
e.printStackTrace();
}
}
/**
* 从数据 URL 加载 SVG
* @param dataUrl 数据 URL 字符串
* @param color 填充颜色
*/
private loadFromDataUrl(dataUrl: string, color: string) {
let svgString: string;
if (dataUrl.startsWith("data:image/svg+xml;base64,")) {
// 处理 base64 编码的 SVG
const base64Prefix = "data:image/svg+xml;base64,";
const base64Data = dataUrl.substring(base64Prefix.length);
const decodedBytes = Base64.decode(base64Data, Base64.DEFAULT);
svgString = String(decodedBytes, StandardCharsets.UTF_8);
} else {
// 处理 URL 编码的 SVG
const urlPrefix = "data:image/svg+xml,";
const encodedSvg = dataUrl.substring(urlPrefix.length);
svgString = decodeURIComponent(encodedSvg) ?? '';
}
const svg = com.caverock.androidsvg.SVG.getFromString(svgString);
this.render(svg, color);
}
/**
* 从文件加载 SVG
* @param src 文件路径
* @param color 填充颜色
*/
private loadFromFile(src: string, color: string) {
// uni-app x 正式打包会将资源文件放在 Android asset 中
const path = UTSAndroid.getResourcePath(src);
if (path.startsWith("/android_asset")) {
// 从 Android 资源文件中加载
const assetPath = path.substring(15); // 移除 "/android_asset" 前缀
const svg = com.caverock.androidsvg.SVG.getFromAsset(
UTSAndroid.getAppContext()!.getAssets(),
assetPath
);
this.render(svg, color);
} else {
// 从本地文件系统加载
const file = new File(path);
if (file.exists()) {
const svg = com.caverock.androidsvg.SVG.getFromInputStream(
new FileInputStream(file)
);
this.render(svg, color);
}
}
}
/**
* 渲染 SVG 到 ImageView
* @param svg AndroidSVG 对象
* @param color 填充颜色,应用到所有 path 元素
*/
private render(svg: com.caverock.androidsvg.SVG, color: string) {
// 创建渲染选项,设置 CSS 样式来改变 SVG 的填充颜色
const options = RenderOptions.create().css(`path { fill: ${color}; }`);
// 将 SVG 渲染为 Picture然后转换为 Drawable
const drawable = new PictureDrawable(svg.renderToPicture(options));
// 设置到 ImageView 中显示
this.imageView?.setImageDrawable(drawable);
}
}

View File

@@ -0,0 +1,9 @@
{
"deploymentTarget": "9",
"dependencies-pods": [
{
"name": "SVGKit",
"version": "2.1.0"
}
]
}

View File

@@ -0,0 +1,68 @@
import { UIView } from "UIKit";
import { SVGKFastImageView, SVGKImage } from "SVGKit";
/**
* CoolSvg iOS 平台 SVG 渲染器
* - 本地文件路径
*/
export class CoolSvg {
/** uni-app x 原生视图元素 */
$element: UniNativeViewElement;
/** iOS SVGKFastImageView 实例 */
imageView: SVGKFastImageView | null = null;
/**
* 构造函数
* @param element uni-app x 原生视图元素
*/
constructor(element: UniNativeViewElement) {
this.$element = element;
// 创建占位符 SVG 图像
let placeholderImage = SVGKImage((contentsOf = URL((fileURLWithPath = ""))));
// 初始化 SVG 图像视图
this.imageView = new SVGKFastImageView((svgkImage = placeholderImage));
// 将视图绑定到 uni-app x 元素
this.$element.bindIOSView(this.imageView!);
}
/**
* 加载并渲染 SVG
* @param src SVG 数据源
* @param color 填充颜色,用于替换 SVG 中 path 元素的 fill 属性
*/
load(src: string, color: string) {
// 空字符串检查
if (src == "") {
return;
}
try {
// 从资源路径加载 SVG 图像
let svgImage = SVGKImage(
(contentsOf = URL((fileURLWithPath = UTSiOS.getResourcePath(src))))
);
// 加载失败检查
if (svgImage == null) {
return;
}
// 设置 SVG 图像到视图
this.imageView!.image = svgImage;
// 应用颜色覆盖
if (color != "") {
// 创建颜色覆盖层视图
let colorView = new UIView();
colorView.frame = this.imageView!.bounds;
colorView.backgroundColor = UTSiOS.colorWithString(color);
// 设置合成滤镜为 sourceAtop实现颜色覆盖效果
colorView.layer.compositingFilter = "sourceAtop";
this.imageView!.addSubview(colorView);
}
} catch (e) {
// 静默处理异常,避免影响应用运行
}
}
}

View File

@@ -0,0 +1,179 @@
<template>
<cl-popup
v-model="visible"
:show-close="false"
:swipe-close-threshold="50"
:pt="{
inner: {
className: parseClass([[isDark, '!bg-surface-700', '!bg-surface-100']])
}
}"
:mask-closable="config.maskClosable"
:title="config.title"
>
<view class="cl-action-sheet" :class="[pt.className]">
<slot name="prepend"></slot>
<view class="cl-action-sheet__description" v-if="config.description != ''">
<cl-text
:pt="{
className: 'text-surface-400 text-md text-center'
}"
>{{ config.description }}</cl-text
>
</view>
<view class="cl-action-sheet__list" :class="[pt.list?.className]">
<view
class="cl-action-sheet__item"
:class="[`${isDark ? '!bg-surface-800' : 'bg-white'}`, pt.item?.className]"
v-for="(item, index) in config.list"
:key="index"
:hover-class="`${isDark ? '!bg-surface-900' : '!bg-surface-50'}`"
:hover-stay-time="250"
@tap="onItemTap(item)"
>
<slot name="item" :item="item">
<cl-icon
:name="item.icon"
:pt="{
className: 'mr-2'
}"
:color="item.color"
v-if="item.icon != null"
></cl-icon>
<cl-text :color="item.color">{{ item.label }}</cl-text>
</slot>
</view>
</view>
<slot name="append"></slot>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from "vue";
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps } from "../../types";
import { t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
defineOptions({
name: "cl-action-sheet"
});
defineSlots<{
prepend(): any;
append(): any;
item(props: { item: ClActionSheetItem }): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
}
});
// 透传样式类型定义
type PassThrough = {
className?: string; // 根元素类名
item?: PassThroughProps; // 列表项样式
list?: PassThroughProps; // 列表样式
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 控制弹窗显示状态
const visible = ref(false);
// 操作表配置数据
const config = reactive<ClActionSheetOptions>({
title: "", // 标题
list: [] // 操作列表
});
/**
* 关闭操作表
* 设置visible为false隐藏弹窗
*/
function close() {
visible.value = false;
}
/**
* 打开操作表
* @param options 操作表配置选项
*/
function open(options: ClActionSheetOptions) {
// 显示弹窗
visible.value = true;
// 更新标题
config.title = options.title;
// 更新描述
config.description = options.description ?? "";
// 更新操作列表
config.list = [...options.list] as ClActionSheetItem[];
// 取消按钮文本
config.cancelText = options.cancelText ?? t("取消");
// 是否显示取消按钮
config.showCancel = options.showCancel ?? true;
// 是否可以点击遮罩关闭
config.maskClosable = options.maskClosable ?? true;
// 如果需要显示取消按钮,添加到列表末尾
if (config.showCancel!) {
config.list.push({
label: config.cancelText!,
callback() {
close();
}
} as ClActionSheetItem);
}
}
/**
* 点击列表项事件处理
* @param item 被点击的操作项
*/
function onItemTap(item: ClActionSheetItem) {
// 如果存在回调函数则执行
if (item.callback != null) {
item.callback!();
}
}
// 暴露组件方法供外部调用
defineExpose({
open,
close
});
</script>
<style scoped lang="scss">
.cl-action-sheet {
&__description {
@apply flex flex-row items-center justify-center;
margin-bottom: 30rpx;
}
&__list {
padding: 0 20rpx;
}
&__item {
@apply flex flex-row items-center justify-center rounded-lg;
padding: 20rpx;
margin-bottom: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,12 @@
import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps } from "../../types";
export type ClActionSheetPassThrough = {
className?: string;
item?: PassThroughProps;
list?: PassThroughProps;
};
export type ClActionSheetProps = {
className?: string;
pt?: ClActionSheetPassThrough;
};

View File

@@ -0,0 +1,67 @@
<template>
<cl-image
:src="src"
:height="size"
:width="size"
:pt="{
className: parseClass([
'cl-avatar',
{
'!rounded-full': rounded
},
pt.className
])
}"
>
<template #loading>
<cl-icon
:name="pt.icon?.name ?? 'user-smile-fill'"
:size="pt.icon?.size ?? 40"
:pt="{
className: parseClass([
[isDark, '!text-surface-50', '!text-surface-400'],
pt.icon?.className
])
}"
></cl-icon>
</template>
<slot></slot>
</cl-image>
</template>
<script setup lang="ts">
import { isDark, parseClass, parsePt } from "@/cool";
import { computed } from "vue";
import type { ClIconProps } from "../cl-icon/props";
defineOptions({
name: "cl-avatar"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
src: {
type: String,
default: ""
},
size: {
type: [String, Number],
default: 80
},
rounded: {
type: Boolean,
default: false
}
});
type PassThrough = {
className?: string;
icon?: ClIconProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
</script>

View File

@@ -0,0 +1,14 @@
import type { ClIconProps } from "../cl-icon/props";
export type ClAvatarPassThrough = {
className?: string;
icon?: ClIconProps;
};
export type ClAvatarProps = {
className?: string;
pt?: ClAvatarPassThrough;
src?: string;
size?: any;
rounded?: boolean;
};

View File

@@ -0,0 +1,112 @@
<template>
<view class="cl-back-top-wrapper" :style="{ bottom }" @tap="toTop">
<view
class="cl-back-top"
:class="{
'is-show': visible
}"
>
<cl-icon name="skip-up-line" color="white" size="25px"></cl-icon>
</view>
</view>
</template>
<script setup lang="ts">
import { getTabBarHeight, hasCustomTabBar, scroller } from "@/cool";
import { computed, onMounted, onUnmounted, ref, watch, type PropType } from "vue";
import { usePage } from "../../hooks";
import { clFooterOffset } from "../cl-footer/offset";
defineOptions({
name: "cl-back-top"
});
const props = defineProps({
top: {
type: Number as PropType<number | null>,
default: null
}
});
const emit = defineEmits(["backTop"]);
const { screenHeight } = uni.getWindowInfo();
// cl-page 上下文
const { scrollToTop, onScroll, offScroll } = usePage();
// 是否显示回到顶部按钮
const visible = ref(false);
// 底部距离
const bottom = computed(() => {
let h = 20;
if (hasCustomTabBar()) {
h += getTabBarHeight();
} else {
h += clFooterOffset.get();
}
return h + "px";
});
// 是否页面滚动
const isPage = computed(() => props.top == null);
// 控制是否显示
function onVisible(top: number) {
visible.value = top > screenHeight - 100;
}
// 回到顶部
function toTop() {
if (isPage.value) {
scrollToTop();
}
emit("backTop");
}
onMounted(() => {
if (isPage.value) {
// 监听页面滚动
onScroll(onVisible);
} else {
// 监听参数变化
watch(
computed(() => props.top!),
(top: number) => {
onVisible(top);
},
{
immediate: true
}
);
}
});
onUnmounted(() => {
if (isPage.value) {
offScroll(onVisible);
}
});
</script>
<style lang="scss" scoped>
.cl-back-top {
@apply flex flex-row items-center justify-center bg-primary-500 rounded-full duration-300;
width: 40px;
height: 40px;
transition-property: transform;
transform: translateX(160rpx);
&.is-show {
transform: translateX(-20px);
}
&-wrapper {
@apply fixed right-0 z-50 overflow-visible;
}
}
</style>

View File

@@ -0,0 +1,4 @@
export type ClBackTopProps = {
className?: string;
top?: number | any;
};

View File

@@ -0,0 +1,109 @@
<template>
<view
class="cl-badge"
:class="[
{
'bg-primary-500': type == 'primary',
'bg-green-500': type == 'success',
'bg-yellow-500': type == 'warn',
'bg-red-500': type == 'error',
'bg-surface-500': type == 'info',
'cl-badge--dot': dot,
'cl-badge--position': position
},
pt.className
]"
:style="badgeStyle"
>
<cl-text
:pt="{
className: parseClass(['cl-badge__text text-white text-xs', pt.text?.className])
}"
v-if="!dot"
>
{{ value }}
</cl-text>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { PropType } from "vue";
import type { PassThroughProps, Type } from "../../types";
import { parseClass, parsePt } from "@/cool";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-badge"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
type: {
type: String as PropType<Type>,
default: "error"
},
dot: {
type: Boolean,
default: false
},
value: {
type: [Number, String],
default: 0
},
position: {
type: Boolean,
default: false
}
});
const { getRpx } = useSize();
type PassThrough = {
className?: string;
text?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
const badgeStyle = computed(() => {
const style = {};
if (props.dot) {
style["height"] = getRpx(10);
style["width"] = getRpx(10);
style["minWidth"] = getRpx(10);
style["padding"] = 0;
} else {
style["height"] = getRpx(30);
style["minWidth"] = getRpx(30);
style["padding"] = `0 ${getRpx(6)}`;
}
if (props.position) {
style["transform"] = "translate(50%, -50%)";
if (props.dot) {
style["transform"] = `translate(-${getRpx(5)}, ${getRpx(5)})`;
}
}
return style;
});
</script>
<style lang="scss" scoped>
.cl-badge {
@apply flex flex-row items-center justify-center;
@apply rounded-full;
&--position {
@apply absolute z-10 right-0 top-0;
}
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps, Type } from "../../types";
export type ClBadgePassThrough = {
className?: string;
text?: PassThroughProps;
};
export type ClBadgeProps = {
className?: string;
pt?: ClBadgePassThrough;
type?: Type;
dot?: boolean;
value?: any;
position?: boolean;
};

View File

@@ -0,0 +1,485 @@
<template>
<view
class="cl-banner"
:class="[pt.className]"
:style="{
height: parseRpx(height)
}"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<view
class="cl-banner__list"
:style="{
transform: `translateX(${slideOffset}px)`,
transitionDuration: isAnimating ? '0.3s' : '0s'
}"
>
<view
class="cl-banner__item"
v-for="(item, index) in list"
:key="index"
:class="[
pt.item?.className,
`${item.isActive ? `${pt.itemActive?.className}` : ''}`
]"
:style="{
width: `${getSlideWidth(index)}px`
}"
@tap="handleSlideClick(index)"
>
<slot :item="item" :index="index">
<image
:src="item.url"
:mode="imageMode"
class="cl-banner__item-image"
:class="[pt.image?.className]"
></image>
</slot>
</view>
</view>
<view class="cl-banner__dots" :class="[pt.dots?.className]" v-if="showDots">
<view
class="cl-banner__dots-item"
v-for="(item, index) in list"
:key="index"
:class="[
{
'is-active': item.isActive
},
pt.dot?.className,
`${item.isActive ? `${pt.dotActive?.className}` : ''}`
]"
></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, onMounted, watch, getCurrentInstance, type PropType } from "vue";
import { parsePt, parseRpx } from "@/cool";
import type { PassThroughProps } from "../../types";
type Item = {
url: string;
isActive: boolean;
};
defineOptions({
name: "cl-banner"
});
defineSlots<{
item(props: { item: Item; index: number }): any;
}>();
const props = defineProps({
// 透传属性
pt: {
type: Object,
default: () => ({})
},
// 轮播项列表
list: {
type: Array as PropType<string[]>,
default: () => []
},
// 上一个轮播项的左边距
previousMargin: {
type: Number,
default: 0
},
// 下一个轮播项的右边距
nextMargin: {
type: Number,
default: 0
},
// 是否自动轮播
autoplay: {
type: Boolean,
default: true
},
// 自动轮播间隔时间(ms)
interval: {
type: Number,
default: 5000
},
// 是否显示指示器
showDots: {
type: Boolean,
default: true
},
// 是否禁用触摸
disableTouch: {
type: Boolean,
default: false
},
// 高度
height: {
type: [Number, String],
default: 300
},
// 图片模式
imageMode: {
type: String,
default: "aspectFill"
}
});
const emit = defineEmits(["change", "item-tap"]);
const { proxy } = getCurrentInstance()!;
// 透传属性类型定义
type PassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 当前激活的轮播项索引 */
const activeIndex = ref(0);
/** 轮播项列表 */
const list = computed<Item[]>(() => {
return props.list.map((e, i) => {
return {
url: e,
isActive: i == activeIndex.value
} as Item;
});
});
/** 轮播容器的水平偏移量(px) */
const slideOffset = ref(0);
/** 是否正在执行动画过渡 */
const isAnimating = ref(false);
/** 轮播容器的总宽度(px) */
const bannerWidth = ref(0);
/** 单个轮播项的宽度(px) - 用于缓存计算结果 */
const slideWidth = ref(0);
/** 触摸开始时的X坐标 */
const touchStartPoint = ref(0);
/** 触摸开始时的时间戳 */
const touchStartTimestamp = ref(0);
/** 触摸开始时的初始偏移量 */
const initialOffset = ref(0);
/** 是否正在触摸中 */
const isTouching = ref(false);
/** 位置更新防抖定时器 */
let positionUpdateTimer: number = 0;
/**
* 更新轮播容器的位置
* 根据当前激活索引计算并设置容器的偏移量
*/
function updateSlidePosition() {
if (bannerWidth.value == 0) return;
// 防抖处理,避免频繁更新
if (positionUpdateTimer != 0) {
clearTimeout(positionUpdateTimer);
}
// @ts-ignore
positionUpdateTimer = setTimeout(() => {
// 计算累积偏移量,考虑每个位置的动态边距
let totalOffset = 0;
// 遍历当前索引之前的所有项,累加它们的宽度
for (let i = 0; i < activeIndex.value; i++) {
const itemPreviousMargin = i == 0 ? 0 : props.previousMargin;
const itemNextMargin = i == props.list.length - 1 ? 0 : props.nextMargin;
const itemWidthAtIndex = bannerWidth.value - itemPreviousMargin - itemNextMargin;
totalOffset += itemWidthAtIndex;
}
// 当前项的左边距
const currentPreviousMargin = activeIndex.value == 0 ? 0 : props.previousMargin;
// 设置最终的偏移量:负方向移动累积宽度,然后加上当前项的左边距
slideOffset.value = -totalOffset + currentPreviousMargin;
positionUpdateTimer = 0;
}, 10);
}
/**
* 获取指定索引轮播项的宽度
* @param index 轮播项索引
* @returns 轮播项宽度(px)
*/
function getSlideWidth(index: number): number {
// 动态计算每个项的宽度,考虑边距
const itemPreviousMargin = index == 0 ? 0 : props.previousMargin;
const itemNextMargin = index == props.list.length - 1 ? 0 : props.nextMargin;
return bannerWidth.value - itemPreviousMargin - itemNextMargin;
}
/**
* 计算并缓存轮播项宽度
* 使用固定的基础宽度计算,避免动态变化导致的性能问题
*/
function calculateSlideWidth() {
const baseWidth = bannerWidth.value - props.previousMargin - props.nextMargin;
slideWidth.value = baseWidth;
}
/**
* 测量轮播容器的尺寸信息
* 获取容器宽度并初始化相关计算
*/
function getRect() {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-banner")
.boundingClientRect((node) => {
bannerWidth.value = (node as NodeInfo).width ?? 0;
// 重新计算宽度和位置
calculateSlideWidth();
updateSlidePosition();
})
.exec();
}
/** 自动轮播定时器 */
let autoplayTimer: number = 0;
/**
* 清除自动轮播定时器
*/
function clearAutoplay() {
if (autoplayTimer != 0) {
clearInterval(autoplayTimer);
autoplayTimer = 0;
}
}
/**
* 启动自动轮播
*/
function startAutoplay() {
if (props.list.length <= 1) return;
if (props.autoplay) {
clearAutoplay();
// 只有在非触摸状态下才启动自动轮播
if (!isTouching.value) {
isAnimating.value = true;
// @ts-ignore
autoplayTimer = setInterval(() => {
// 再次检查是否在触摸中,避免触摸时自动切换
if (!isTouching.value) {
if (activeIndex.value >= props.list.length - 1) {
activeIndex.value = 0;
} else {
activeIndex.value++;
}
}
}, props.interval);
}
}
}
// 触摸起始Y坐标
let touchStartY = 0;
// 横向滑动参数
let touchHorizontal = 0;
/**
* 处理触摸开始事件
* 记录触摸起始状态,准备手势识别
* @param e 触摸事件对象
*/
function onTouchStart(e: TouchEvent) {
// 如果禁用触摸,则不进行任何操作
if (props.disableTouch) return;
// 单项或空列表不支持滑动
if (props.list.length <= 1) return;
// 设置触摸状态
isTouching.value = true;
// 清除自动轮播
clearAutoplay();
// 禁用动画,开始手势跟踪
isAnimating.value = false;
touchStartPoint.value = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchHorizontal = 0;
touchStartTimestamp.value = Date.now();
initialOffset.value = slideOffset.value;
}
/**
* 处理触摸移动事件
* 实时更新容器位置,实现跟手效果
* @param e 触摸事件对象
*/
function onTouchMove(e: TouchEvent) {
if (props.list.length <= 1 || props.disableTouch || !isTouching.value) return;
const x = touchStartPoint.value - e.touches[0].clientX;
if (touchHorizontal == 0) {
// 只在horizontal=0时判断一次
const y = touchStartY - e.touches[0].clientY;
if (Math.abs(x) > Math.abs(y)) {
// 如果x轴移动距离大于y轴移动距离则表明是横向移动手势
touchHorizontal = 1;
}
if (touchHorizontal == 1) {
// 如果是横向移动手势,则阻止默认行为(防止页面滚动)
e.preventDefault();
}
}
// 横向移动时才处理
if (touchHorizontal != 1) {
return;
}
// 计算手指移动距离,实时更新偏移量
const deltaX = e.touches[0].clientX - touchStartPoint.value;
slideOffset.value = initialOffset.value + deltaX;
}
/**
* 处理触摸结束事件
* 根据滑动距离和速度判断是否切换轮播项
*/
function onTouchEnd() {
if (props.list.length <= 1 || !isTouching.value) return;
touchStartY = 0;
touchHorizontal = 0;
// 重置触摸状态
isTouching.value = false;
// 恢复动画效果
isAnimating.value = true;
// 计算滑动距离、时间和速度
const deltaX = slideOffset.value - initialOffset.value;
const deltaTime = Date.now() - touchStartTimestamp.value;
const velocity = deltaTime > 0 ? Math.abs(deltaX) / deltaTime : 0; // px/ms
let newIndex = activeIndex.value;
// 使用当前项的实际宽度进行滑动判断
const currentSlideWidth = getSlideWidth(activeIndex.value);
// 判断是否需要切换滑动距离超过30%或速度够快
if (Math.abs(deltaX) > currentSlideWidth * 0.3 || velocity > 0.3) {
// 向右滑动且不是第一项 -> 上一项
if (deltaX > 0 && activeIndex.value > 0) {
newIndex = activeIndex.value - 1;
}
// 向左滑动且不是最后一项 -> 下一项
else if (deltaX < 0 && activeIndex.value < props.list.length - 1) {
newIndex = activeIndex.value + 1;
}
}
// 更新索引 - 如果索引没有变化,需要手动恢复位置
if (newIndex == activeIndex.value) {
// 索引未变化,恢复到正确位置
updateSlidePosition();
} else {
// 索引变化watch会自动调用updateSlidePosition
activeIndex.value = newIndex;
}
// 恢复自动轮播
setTimeout(() => {
startAutoplay();
}, 300);
}
/**
* 处理轮播项点击事件
* @param index 被点击的轮播项索引
*/
function handleSlideClick(index: number) {
emit("item-tap", index);
}
/** 监听激活索引变化 */
watch(activeIndex, (val: number) => {
updateSlidePosition();
emit("change", val);
});
onMounted(() => {
getRect();
startAutoplay();
});
// 将触摸事件暴露给父组件支持控制其它view将做touch代理
defineExpose({
onTouchStart,
onTouchMove,
onTouchEnd
});
</script>
<style lang="scss" scoped>
.cl-banner {
@apply relative z-10 rounded-xl;
&__list {
@apply flex flex-row h-full w-full overflow-visible;
// HBuilderX 4.8.2 bug临时处理
width: 100000px;
transition-property: transform;
}
&__item {
@apply relative duration-200;
transition-property: transform;
&-image {
@apply w-full h-full rounded-xl;
}
}
&__dots {
@apply flex flex-row items-center justify-center;
@apply absolute bottom-3 left-0 w-full;
&-item {
@apply w-2 h-2 rounded-full mx-1 border border-solid border-surface-500;
background-color: rgba(255, 255, 255, 0.3);
transition-property: width, background-color;
transition-duration: 0.3s;
&.is-active {
@apply bg-white w-5;
}
}
}
}
</style>

View File

@@ -0,0 +1,25 @@
import type { PassThroughProps } from "../../types";
export type ClBannerPassThrough = {
className?: string;
item?: PassThroughProps;
itemActive?: PassThroughProps;
image?: PassThroughProps;
dots?: PassThroughProps;
dot?: PassThroughProps;
dotActive?: PassThroughProps;
};
export type ClBannerProps = {
className?: string;
pt?: ClBannerPassThrough;
list?: string[];
previousMargin?: number;
nextMargin?: number;
autoplay?: boolean;
interval?: number;
showDots?: boolean;
disableTouch?: boolean;
height?: any;
imageMode?: string;
};

View File

@@ -0,0 +1,673 @@
<template>
<view
class="cl-button"
:class="[
`cl-button--${size}`,
`cl-button--${type} `,
{
'cl-button--loading': loading,
'cl-button--disabled': disabled,
'cl-button--text': text,
'cl-button--border': border,
'cl-button--rounded': rounded,
'cl-button--icon': isIcon,
'cl-button--hover': isHover,
'is-dark': isDark
},
isHover ? hoverClass : '',
pt.className
]"
:key="cache.key"
:style="buttonStyle"
@tap.stop="onTap"
>
<button
class="cl-button__clicker"
:disabled="isDisabled"
:hover-class="hoverClass"
:hover-stop-propagation="hoverStopPropagation"
:hover-start-time="hoverStartTime"
:hover-stay-time="hoverStayTime"
:form-type="formType"
:open-type="openType"
:lang="lang"
:session-from="sessionFrom"
:send-message-title="sendMessageTitle"
:send-message-path="sendMessagePath"
:send-message-img="sendMessageImg"
:show-message-card="showMessageCard"
:app-parameter="appParameter"
:group-id="groupId"
:guild-id="guildId"
:public-id="publicId"
:phone-number-no-quota-toast="phoneNumberNoQuotaToast"
:createliveactivity="createliveactivity"
@getuserinfo="onGetUserInfo"
@contact="onContact"
@getphonenumber="onGetPhoneNumber"
@error="onError"
@opensetting="onOpenSetting"
@launchapp="onLaunchApp"
@chooseavatar="onChooseAvatar"
@chooseaddress="onChooseAddress"
@chooseinvoicetitle="onChooseInvoiceTitle"
@addgroupapp="onAddGroupApp"
@subscribe="onSubscribe"
@login="onLogin"
@getrealtimephonenumber="onGetRealtimePhoneNumber"
@agreeprivacyauthorization="onAgreePrivacyAuthorization"
@touchstart="onTouchStart"
@touchend="onTouchEnd"
@touchcancel="onTouchCancel"
></button>
<cl-loading
:color="loadingIcon.color"
:size="loadingIcon.size"
:pt="{
className: parseClass(['mr-[10rpx]', pt.loading?.className])
}"
v-if="loading && !disabled"
></cl-loading>
<cl-icon
:name="icon"
:color="leftIcon.color"
:size="leftIcon.size"
:pt="{
className: parseClass([
{
'mr-[8rpx]': !isIcon
},
pt.icon?.className
])
}"
v-if="icon"
></cl-icon>
<template v-if="!isIcon">
<cl-text
:color="textColor"
:pt="{
className: parseClass([
'cl-button__label',
{
'text-sm': size == 'small'
},
pt.label?.className
])
}"
>
<slot></slot>
</cl-text>
<slot name="content"></slot>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, ref, useSlots, type PropType } from "vue";
import { get, isDark, parseClass, parsePt, useCache } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import type { ClButtonType, PassThroughProps, Size } from "../../types";
import type { ClLoadingProps } from "../cl-loading/props";
defineOptions({
name: "cl-button"
});
// 组件属性定义
const props = defineProps({
// 样式穿透
pt: {
type: Object,
default: () => ({})
},
// 按钮类型
type: {
type: String as PropType<ClButtonType>,
default: "primary"
},
// 字体、图标颜色
color: {
type: String,
default: ""
},
// 图标
icon: {
type: String,
default: ""
},
// 文本按钮
text: {
type: Boolean,
default: false
},
// 圆角按钮
rounded: {
type: Boolean,
default: false
},
// 边框按钮
border: {
type: Boolean,
default: false
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 禁用状态
disabled: {
type: Boolean,
default: false
},
// 按钮尺寸
size: {
type: String as PropType<Size>,
default: "normal"
},
// 按钮点击态样式类
hoverClass: {
type: String,
default: ""
},
// 是否阻止点击态冒泡
hoverStopPropagation: {
type: Boolean,
default: false
},
// 按住后多久出现点击态
hoverStartTime: {
type: Number,
default: 20
},
// 手指松开后点击态保留时间
hoverStayTime: {
type: Number,
default: 70
},
// 表单提交类型
formType: {
type: String as PropType<"submit" | "reset">,
default: ""
},
// 开放能力类型
openType: {
type: String as PropType<
| "agreePrivacyAuthorization"
| "feedback"
| "share"
| "getUserInfo"
| "contact"
| "getPhoneNumber"
| "launchApp"
| "openSetting"
| "chooseAvatar"
| "getAuthorize"
| "lifestyle"
| "contactShare"
| "openGroupProfile"
| "openGuildProfile"
| "openPublicProfile"
| "shareMessageToFriend"
| "addFriend"
| "addColorSign"
| "addGroupApp"
| "addToFavorites"
| "chooseAddress"
| "chooseInvoiceTitle"
| "login"
| "subscribe"
| "favorite"
| "watchLater"
| "openProfile"
| "liveActivity"
| "getRealtimePhoneNumber"
>,
default: ""
},
// 语言
lang: {
type: String as PropType<"en" | "zh_CN" | "zh_TW">,
default: "zh_CN"
},
// 会话来源
sessionFrom: {
type: String,
default: ""
},
// 会话标题
sendMessageTitle: {
type: String,
default: ""
},
// 会话路径
sendMessagePath: {
type: String,
default: ""
},
// 会话图片
sendMessageImg: {
type: String,
default: ""
},
// 显示会话卡片
showMessageCard: {
type: Boolean,
default: false
},
// 打开 APP 时,向 APP 传递的参数
appParameter: {
type: String,
default: ""
},
// 群ID
groupId: {
type: String,
default: ""
},
// 公会ID
guildId: {
type: String,
default: ""
},
// 公众号ID
publicId: {
type: String,
default: ""
},
// 手机号获取失败时是否弹出错误提示
phoneNumberNoQuotaToast: {
type: Boolean,
default: false
},
// 是否创建直播活动
createliveactivity: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits([
"click",
"tap",
"getuserinfo",
"contact",
"getphonenumber",
"error",
"opensetting",
"launchapp",
"chooseavatar",
"chooseaddress",
"chooseinvoicetitle",
"addgroupapp",
"subscribe",
"login",
"getrealtimephonenumber",
"agreeprivacyauthorization"
]);
const slots = useSlots();
const { cache } = useCache(() => [
props.type,
props.text,
props.disabled,
props.loading,
props.color
]);
// 样式穿透类型
type PassThrough = {
className?: string;
label?: PassThroughProps;
icon?: ClIconProps;
loading?: ClLoadingProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 是否是图标按钮
const isIcon = computed(() => get(slots, "default") == null && get(slots, "content") == null);
// 文本颜色
const textColor = computed(() => {
if (props.color != "") {
return props.color;
}
let color = "light";
if (props.text) {
color = props.type;
if (props.disabled) {
color = "disabled";
}
}
if (props.type == "light") {
if (!isDark.value) {
color = "dark";
}
}
return color;
});
// 图标信息
const leftIcon = computed<ClIconProps>(() => {
let color = textColor.value;
let size: number | string;
switch (props.size) {
case "small":
size = 26;
break;
default:
size = 32;
break;
}
const ptIcon = pt.value.icon;
if (ptIcon != null) {
color = ptIcon.color ?? color;
size = ptIcon.size ?? size;
}
return {
size,
color
};
});
// 加载图标信息
const loadingIcon = computed<ClLoadingProps>(() => {
let color = textColor.value;
let size: number | string;
switch (props.size) {
case "small":
size = 22;
break;
default:
size = 24;
break;
}
const ptIcon = pt.value.loading;
if (ptIcon != null) {
color = ptIcon.color ?? color;
size = ptIcon.size ?? size;
}
return {
size,
color
};
});
// 按钮样式
const buttonStyle = computed(() => {
const style = {};
if (props.color != "") {
style["border-color"] = props.color;
}
return style;
});
// 是否禁用状态
const isDisabled = computed(() => props.disabled || props.loading);
// 点击事件处理
function onTap(e: UniPointerEvent) {
if (isDisabled.value) return;
emit("click", e);
emit("tap", e);
}
// 获取用户信息事件处理
function onGetUserInfo(e: UniEvent) {
emit("getuserinfo", e);
}
// 客服消息事件处理
function onContact(e: UniEvent) {
emit("contact", e);
}
// 获取手机号事件处理
function onGetPhoneNumber(e: UniEvent) {
emit("getphonenumber", e);
}
// 错误事件处理
function onError(e: UniEvent) {
emit("error", e);
}
// 打开设置事件处理
function onOpenSetting(e: UniEvent) {
emit("opensetting", e);
}
// 打开APP事件处理
function onLaunchApp(e: UniEvent) {
emit("launchapp", e);
}
// 选择头像事件处理
function onChooseAvatar(e: UniEvent) {
emit("chooseavatar", e);
}
// 选择收货地址事件处理
function onChooseAddress(e: UniEvent) {
emit("chooseaddress", e);
}
// 选择发票抬头事件处理
function onChooseInvoiceTitle(e: UniEvent) {
emit("chooseinvoicetitle", e);
}
// 添加群应用事件处理
function onAddGroupApp(e: UniEvent) {
emit("addgroupapp", e);
}
// 订阅消息事件处理
function onSubscribe(e: UniEvent) {
emit("subscribe", e);
}
// 登录事件处理
function onLogin(e: UniEvent) {
emit("login", e);
}
// 获取实时手机号事件处理
function onGetRealtimePhoneNumber(e: UniEvent) {
emit("getrealtimephonenumber", e);
}
// 同意隐私授权事件处理
function onAgreePrivacyAuthorization(e: UniEvent) {
emit("agreeprivacyauthorization", e);
}
// 点击态状态
const isHover = ref(false);
// 触摸开始事件处理
function onTouchStart() {
if (!isDisabled.value) {
isHover.value = true;
}
}
// 触摸结束事件处理
function onTouchEnd() {
isHover.value = false;
}
// 触摸取消事件处理
function onTouchCancel() {
isHover.value = false;
}
</script>
<style lang="scss" scoped>
@mixin button-type($color) {
@apply bg-#{$color}-500;
&.cl-button--hover {
@apply bg-#{$color}-600;
}
&.cl-button--text {
background-color: transparent;
&.cl-button--hover {
@apply bg-transparent opacity-50;
}
}
&.cl-button--border {
@apply border-#{$color}-500;
}
}
.cl-button {
@apply flex flex-row items-center justify-center relative box-border;
@apply border border-transparent border-solid;
overflow: visible;
transition-duration: 0.3s;
transition-property: background-color, border-color, opacity;
&__clicker {
@apply absolute p-0 m-0;
@apply w-full h-full;
@apply opacity-0;
@apply z-10;
}
&--small {
padding: 6rpx 14rpx;
border-radius: 12rpx;
&.cl-button--icon {
padding: 10rpx;
}
}
&--normal {
padding: 10rpx 28rpx;
border-radius: 16rpx;
&.cl-button--icon {
padding: 14rpx;
}
}
&--large {
padding: 14rpx 32rpx;
border-radius: 20rpx;
&.cl-button--icon {
padding: 18rpx;
}
}
&--rounded {
@apply rounded-full;
}
&--primary {
@include button-type("primary");
}
&--warn {
@include button-type("yellow");
}
&--error {
@include button-type("red");
}
&--info {
@include button-type("surface");
}
&--success {
@include button-type("green");
}
&--light {
@apply border-surface-700;
&.cl-button--hover {
@apply bg-surface-100;
}
&.is-dark {
&.cl-button--hover {
@apply bg-surface-700;
}
}
}
&--dark {
@apply bg-surface-700;
&.cl-button--hover {
@apply bg-surface-800;
}
}
&--disabled {
@apply bg-surface-300;
&.cl-button--border {
@apply border-surface-300;
}
}
&--loading {
opacity: 0.6;
}
&.is-dark {
&.cl-button--disabled {
@apply bg-surface-400;
&.cl-button--border {
@apply border-surface-500;
}
}
&.cl-button--text {
@apply bg-transparent;
}
&.cl-button--light {
@apply border-surface-500;
}
}
}
.cl-button {
& + .cl-button {
@apply ml-2;
}
}
</style>

View File

@@ -0,0 +1,42 @@
import type { ClIconProps } from "../cl-icon/props";
import type { ClButtonType, PassThroughProps, Size } from "../../types";
import type { ClLoadingProps } from "../cl-loading/props";
export type ClButtonPassThrough = {
className?: string;
label?: PassThroughProps;
icon?: ClIconProps;
loading?: ClLoadingProps;
};
export type ClButtonProps = {
className?: string;
pt?: ClButtonPassThrough;
type?: ClButtonType;
color?: string;
icon?: string;
text?: boolean;
rounded?: boolean;
border?: boolean;
loading?: boolean;
disabled?: boolean;
size?: Size;
hoverClass?: string;
hoverStopPropagation?: boolean;
hoverStartTime?: number;
hoverStayTime?: number;
formType?: "submit" | "reset";
openType?: "agreePrivacyAuthorization" | "feedback" | "share" | "getUserInfo" | "contact" | "getPhoneNumber" | "launchApp" | "openSetting" | "chooseAvatar" | "getAuthorize" | "lifestyle" | "contactShare" | "openGroupProfile" | "openGuildProfile" | "openPublicProfile" | "shareMessageToFriend" | "addFriend" | "addColorSign" | "addGroupApp" | "addToFavorites" | "chooseAddress" | "chooseInvoiceTitle" | "login" | "subscribe" | "favorite" | "watchLater" | "openProfile" | "liveActivity" | "getRealtimePhoneNumber";
lang?: "en" | "zh_CN" | "zh_TW";
sessionFrom?: string;
sendMessageTitle?: string;
sendMessagePath?: string;
sendMessageImg?: string;
showMessageCard?: boolean;
appParameter?: string;
groupId?: string;
guildId?: string;
publicId?: string;
phoneNumberNoQuotaToast?: boolean;
createliveactivity?: boolean;
};

View File

@@ -0,0 +1,310 @@
<template>
<cl-select-trigger
v-if="showTrigger"
:pt="ptTrigger"
:placeholder="placeholder"
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@open="open()"
@clear="clear"
></cl-select-trigger>
<cl-popup ref="popupRef" v-model="visible" :title="title" :pt="ptPopup">
<view class="cl-select-popup" @touchmove.stop>
<slot name="prepend"></slot>
<view class="cl-select-popup__picker">
<cl-calendar
v-model="value"
v-model:date="date"
:mode="mode"
:date-config="dateConfig"
:start="start"
:end="end"
@change="onCalendarChange"
></cl-calendar>
</view>
<slot name="append"></slot>
<view class="cl-select-popup__op">
<cl-button
v-if="showCancel"
size="large"
text
border
type="light"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
@tap="close"
>{{ cancelText }}</cl-button
>
<cl-button
v-if="showConfirm"
size="large"
:pt="{
className: 'flex-1 !rounded-xl h-[80rpx]'
}"
@tap="confirm"
>{{ confirmText }}</cl-button
>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, computed, type PropType } from "vue";
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import { isEmpty, parsePt, parseToObject } from "@/cool";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import { t } from "@/locale";
import { useUi } from "../../hooks";
defineOptions({
name: "cl-calendar-select"
});
defineSlots<{
prepend(): any;
append(): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 选择器的值
modelValue: {
type: String as PropType<string | null>,
default: null
},
// 多个日期
date: {
type: Array as PropType<string[]>,
default: () => []
},
// 日期选择模式
mode: {
type: String as PropType<ClCalendarMode>,
default: "single"
},
// 日期配置
dateConfig: {
type: Array as PropType<ClCalendarDateConfig[]>,
default: () => []
},
// 开始日期,可选日期的开始
start: {
type: String
},
// 结束日期,可选日期的结束
end: {
type: String
},
// 选择器标题
title: {
type: String,
default: () => t("请选择")
},
// 选择器占位符
placeholder: {
type: String,
default: () => t("请选择")
},
// 是否显示选择器触发器
showTrigger: {
type: Boolean,
default: true
},
// 是否禁用选择器
disabled: {
type: Boolean,
default: false
},
// 分隔符
splitor: {
type: String,
default: "、"
},
// 范围分隔符
rangeSplitor: {
type: String,
default: () => t(" 至 ")
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示确认按钮
showConfirm: {
type: Boolean,
default: true
},
// 取消按钮文本
cancelText: {
type: String,
default: () => t("取消")
},
// 是否显示取消按钮
showCancel: {
type: Boolean,
default: true
}
});
// 定义事件
const emit = defineEmits(["update:modelValue", "update:date", "change", "select"]);
// UI实例
const ui = useUi();
// 弹出层引用
const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
// 透传样式类型定义
type PassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 解析触发器透传样式配置
const ptTrigger = computed(() => parseToObject(pt.value.trigger));
// 解析弹窗透传样式配置
const ptPopup = computed(() => parseToObject(pt.value.popup));
// 当前选中的值
const value = ref<string | null>(null);
// 当前选中的日期
const date = ref<string[]>([]);
// 显示文本
const text = computed(() => {
switch (props.mode) {
case "single":
return props.modelValue ?? "";
case "multiple":
return props.date.join(props.splitor);
case "range":
return props.date.join(props.rangeSplitor);
default:
return "";
}
});
// 选择器显示状态
const visible = ref(false);
// 选择回调函数
let callback: ((value: string | string[]) => void) | null = null;
// 打开选择器
function open(cb: ((value: string | string[]) => void) | null = null) {
visible.value = true;
// 单选日期
value.value = props.modelValue;
// 多个日期
date.value = props.date;
// 回调
callback = cb;
}
// 关闭选择器
function close() {
visible.value = false;
}
// 清空选择器
function clear() {
value.value = null;
date.value = [] as string[];
emit("update:modelValue", value.value);
emit("update:date", date.value);
}
// 确认选择
function confirm() {
if (props.mode == "single") {
if (value.value == null) {
ui.showToast({
message: t("请选择日期")
});
return;
}
emit("update:modelValue", value.value);
emit("change", value.value);
// 触发回调
if (callback != null) {
callback!(value.value);
}
} else {
if (isEmpty(date.value)) {
ui.showToast({
message: t("请选择日期")
});
return;
}
if (date.value.length != 2) {
ui.showToast({
message: t("请选择日期范围")
});
return;
}
emit("update:date", date.value);
emit("change", date.value);
// 触发回调
if (callback != null) {
callback!(date.value);
}
}
// 关闭选择器
close();
}
// 日历变化
function onCalendarChange(date: string[]) {
emit("select", date);
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-select {
&-popup {
&__picker {
@apply p-3 pt-0;
}
&__op {
@apply flex flex-row items-center justify-center;
padding: 24rpx;
}
}
}
</style>

View File

@@ -0,0 +1,29 @@
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
export type ClCalendarSelectPassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
export type ClCalendarSelectProps = {
className?: string;
pt?: ClCalendarSelectPassThrough;
modelValue?: string | any;
date?: string[];
mode?: ClCalendarMode;
dateConfig?: ClCalendarDateConfig[];
start?: string;
end?: string;
title?: string;
placeholder?: string;
showTrigger?: boolean;
disabled?: boolean;
splitor?: string;
rangeSplitor?: string;
confirmText?: string;
showConfirm?: boolean;
cancelText?: string;
showCancel?: boolean;
};

View File

@@ -0,0 +1,926 @@
<template>
<view class="cl-calendar" :class="[pt.className]">
<!-- 年月选择器弹窗 -->
<calendar-picker
:year="currentYear"
:month="currentMonth"
:ref="refs.set('picker')"
@change="onYearMonthChange"
></calendar-picker>
<!-- 头部导航栏 -->
<view class="cl-calendar__header" v-if="showHeader">
<!-- 上一月按钮 -->
<view
class="cl-calendar__header-prev"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoPrevMonth"
>
<cl-icon name="arrow-left-s-line"></cl-icon>
</view>
<!-- 当前年月显示区域 -->
<view class="cl-calendar__header-date" @tap="refs.open('picker')">
<slot name="current-date">
<cl-text :pt="{ className: 'text-lg' }">{{
$t(`{year}年{month}月`, { year: currentYear, month: currentMonth })
}}</cl-text>
</slot>
</view>
<!-- 下一月按钮 -->
<view
class="cl-calendar__header-next"
:class="{ 'is-dark': isDark }"
@tap.stop="gotoNextMonth"
>
<cl-icon name="arrow-right-s-line"></cl-icon>
</view>
</view>
<!-- 星期标题行 -->
<view class="cl-calendar__weeks" :style="{ gap: `${cellGap}px` }" v-if="showWeeks">
<view class="cl-calendar__weeks-item" v-for="weekName in weekLabels" :key="weekName">
<cl-text>{{ weekName }}</cl-text>
</view>
</view>
<!-- 日期网格容器 -->
<view
class="cl-calendar__view"
ref="viewRef"
:style="{ height: `${viewHeight}px`, gap: `${cellGap}px` }"
@tap="onTap"
>
<!-- #ifndef APP -->
<view
class="cl-calendar__view-row"
:style="{ gap: `${cellGap}px` }"
v-for="(weekRow, rowIndex) in dateMatrix"
:key="rowIndex"
>
<view
class="cl-calendar__view-cell"
v-for="(dateCell, cellIndex) in weekRow"
:key="cellIndex"
:class="{
'is-selected': dateCell.isSelected,
'is-range': dateCell.isRange,
'is-hide': dateCell.isHide,
'is-disabled': dateCell.isDisabled,
'is-today': dateCell.isToday,
'is-other-month': !dateCell.isCurrentMonth
}"
:style="{
height: cellHeight + 'px',
backgroundColor: getCellBgColor(dateCell)
}"
@click.stop="selectDateCell(dateCell)"
>
<!-- 顶部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute top-[2px]'
}"
>{{ dateCell.topText }}</cl-text
>
<!-- 主日期数字 -->
<cl-text
:color="getCellTextColor(dateCell)"
:size="`${fontSize}px`"
:pt="{
className: 'font-bold'
}"
>{{ dateCell.date }}</cl-text
>
<!-- 底部文本 -->
<cl-text
:size="20"
:color="getCellTextColor(dateCell)"
:pt="{
className: 'absolute bottom-[2px]'
}"
>{{ dateCell.bottomText }}</cl-text
>
</view>
</view>
<!-- #endif -->
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { ctx, dayUts, first, isDark, isHarmony, parsePt, useRefs } from "@/cool";
import CalendarPicker from "./picker.uvue";
import { $t, t } from "@/locale";
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-calendar"
});
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 当前选中的日期值(单选模式)
modelValue: {
type: String as PropType<string | null>,
default: null
},
// 选中的日期数组(多选/范围模式)
date: {
type: Array as PropType<string[]>,
default: () => []
},
// 日期选择模式:单选/多选/范围选择
mode: {
type: String as PropType<ClCalendarMode>,
default: "single"
},
// 日期配置
dateConfig: {
type: Array as PropType<ClCalendarDateConfig[]>,
default: () => []
},
// 开始日期,可选日期的开始
start: {
type: String
},
// 结束日期,可选日期的结束
end: {
type: String
},
// 设置年份
year: {
type: Number
},
// 设置月份
month: {
type: Number
},
// 是否显示其他月份的日期
showOtherMonth: {
type: Boolean,
default: true
},
// 是否显示头部导航栏
showHeader: {
type: Boolean,
default: true
},
// 是否显示星期
showWeeks: {
type: Boolean,
default: true
},
// 单元格高度
cellHeight: {
type: Number,
default: 66
},
// 单元格间距
cellGap: {
type: Number,
default: 0
},
// 主色
color: {
type: String,
default: ""
},
// 当前月份日期颜色
textColor: {
type: String,
default: ""
},
// 其他月份日期颜色
textOtherMonthColor: {
type: String,
default: ""
},
// 禁用日期颜色
textDisabledColor: {
type: String,
default: ""
},
// 今天日期颜色
textTodayColor: {
type: String,
default: "#ff6b6b"
},
// 选中日期颜色
textSelectedColor: {
type: String,
default: "#ffffff"
},
// 选中日期背景颜色
bgSelectedColor: {
type: String,
default: ""
},
// 范围选择背景颜色
bgRangeColor: {
type: String,
default: ""
}
});
// 事件发射器定义
const emit = defineEmits(["update:modelValue", "update:date", "change"]);
// 日期单元格数据结构
type DateCell = {
date: string; // 显示的日期数字
isCurrentMonth: boolean; // 是否属于当前显示月份
isToday: boolean; // 是否为今天
isSelected: boolean; // 是否被选中
isRange: boolean; // 是否在选择范围内
fullDate: string; // 完整日期格式 YYYY-MM-DD
isDisabled: boolean; // 是否被禁用
isHide: boolean; // 是否隐藏显示
topText: string; // 顶部文案
bottomText: string; // 底部文案
color: string; // 颜色
};
// 透传样式属性类型
type PassThrough = {
className?: string;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 字体大小
const { getPxValue } = useSize();
// 主色
const color = computed(() => {
if (props.color != "") {
return props.color;
}
return ctx.color["primary-500"] as string;
});
// 单元格高度
const cellHeight = computed(() => props.cellHeight);
// 单元格间距
const cellGap = computed(() => props.cellGap);
// 字体大小
const fontSize = computed(() => {
// #ifdef APP
return getPxValue("14px");
// #endif
// #ifndef APP
return 14;
// #endif
});
// 当前月份日期颜色
const textColor = computed(() => {
if (props.textColor != "") {
return props.textColor;
}
return isDark.value ? "white" : (ctx.color["surface-700"] as string);
});
// 其他月份日期颜色
const textOtherMonthColor = computed(() => {
if (props.textOtherMonthColor != "") {
return props.textOtherMonthColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 禁用日期颜色
const textDisabledColor = computed(() => {
if (props.textDisabledColor != "") {
return props.textDisabledColor;
}
return isDark.value
? (ctx.color["surface-500"] as string)
: (ctx.color["surface-300"] as string);
});
// 今天日期颜色
const textTodayColor = computed(() => props.textTodayColor);
// 选中日期颜色
const textSelectedColor = computed(() => props.textSelectedColor);
// 选中日期背景颜色
const bgSelectedColor = computed(() => {
if (props.bgSelectedColor != "") {
return props.bgSelectedColor;
}
return color.value;
});
// 范围选择背景颜色
const bgRangeColor = computed(() => {
if (props.bgRangeColor != "") {
return props.bgRangeColor;
}
return isHarmony() ? (ctx.color["primary-50"] as string) : color.value + "11";
});
// 组件引用管理器
const refs = useRefs();
// 日历视图DOM元素引用
const viewRef = ref<UniElement | null>(null);
// 当前显示的年份
const currentYear = ref(0);
// 当前显示的月份
const currentMonth = ref(0);
// 视图高度
const viewHeight = computed(() => {
return cellHeight.value * 6;
});
// 单元格宽度
const cellWidth = ref(0);
// 星期标签数组
const weekLabels = computed(() => {
return [t("周日"), t("周一"), t("周二"), t("周三"), t("周四"), t("周五"), t("周六")];
});
// 日历日期矩阵数据6行7列
const dateMatrix = ref<DateCell[][]>([]);
// 当前选中的日期列表
const selectedDates = ref<string[]>([]);
/**
* 获取日历视图元素的位置信息
*/
async function getViewRect(): Promise<DOMRect | null> {
return viewRef.value!.getBoundingClientRectAsync();
}
/**
* 判断指定日期是否被选中
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateSelected(dateStr: string): boolean {
if (props.mode == "single") {
// 单选模式:检查是否为唯一选中日期
return selectedDates.value[0] == dateStr;
} else {
// 多选/范围模式:检查是否在选中列表中
return selectedDates.value.includes(dateStr);
}
}
/**
* 判断指定日期是否被禁用
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateDisabled(dateStr: string): boolean {
// 大于开始日期
if (props.start != null) {
if (dayUts(dateStr).isBefore(dayUts(props.start))) {
return true;
}
}
// 小于结束日期
if (props.end != null) {
if (dayUts(dateStr).isAfter(dayUts(props.end))) {
return true;
}
}
return props.dateConfig.some((config) => config.date == dateStr && config.disabled == true);
}
/**
* 判断指定日期是否在选择范围内(不包括端点)
* @param dateStr 日期字符串 YYYY-MM-DD
*/
function isDateInRange(dateStr: string): boolean {
// 仅范围选择模式且已选择两个端点时才有范围
if (props.mode != "range" || selectedDates.value.length != 2) {
return false;
}
const [startDate, endDate] = selectedDates.value;
const currentDate = dayUts(dateStr);
return currentDate.isAfter(startDate) && currentDate.isBefore(endDate);
}
/**
* 获取单元格字体颜色
* @param dateCell 日期单元格数据
* @returns 字体颜色
*/
function getCellTextColor(dateCell: DateCell): string {
// 选中的日期文字颜色
if (dateCell.isSelected) {
return textSelectedColor.value;
}
if (dateCell.color != "") {
return dateCell.color;
}
// 范围选择日期颜色
if (dateCell.isRange) {
return color.value;
}
// 禁用的日期颜色
if (dateCell.isDisabled) {
return textDisabledColor.value;
}
// 今天日期颜色
if (dateCell.isToday) {
return textTodayColor.value;
}
// 当前月份日期颜色
if (dateCell.isCurrentMonth) {
return textColor.value;
}
// 其他月份日期颜色
return textOtherMonthColor.value;
}
/**
* 获取单元格背景颜色
* @param dateCell 日期单元格数据
* @returns 背景颜色
*/
function getCellBgColor(dateCell: DateCell): string {
if (dateCell.isSelected) {
return bgSelectedColor.value;
}
if (dateCell.isRange) {
return bgRangeColor.value;
}
return "transparent";
}
/**
* 计算并生成日历矩阵数据
* 生成6行7列共42个日期包含上月末尾和下月开头的日期
*/
function calculateDateMatrix() {
const weekRows: DateCell[][] = [];
const todayStr = dayUts().format("YYYY-MM-DD"); // 今天的日期字符串
// 获取当前月第一天
const monthFirstDay = dayUts(`${currentYear.value}-${currentMonth.value}-01`);
const firstDayWeekIndex = monthFirstDay.getDay(); // 第一天是星期几 (0=周日, 6=周六)
// 计算日历显示的起始日期(可能是上个月的日期)
const calendarStartDate = monthFirstDay.subtract(firstDayWeekIndex, "day");
// 生成6周的日期数据6行 × 7列 = 42天
let iterateDate = calendarStartDate;
for (let weekIndex = 0; weekIndex < 6; weekIndex++) {
const weekDates: DateCell[] = [];
for (let dayIndex = 0; dayIndex < 7; dayIndex++) {
const fullDateStr = iterateDate.format("YYYY-MM-DD");
const nativeDate = iterateDate.toDate();
const dayNumber = nativeDate.getDate();
// 判断是否属于当前显示月份
const belongsToCurrentMonth =
nativeDate.getMonth() + 1 == currentMonth.value &&
nativeDate.getFullYear() == currentYear.value;
// 日期配置
const dateConfig = props.dateConfig.find((config) => config.date == fullDateStr);
// 构建日期单元格数据
const dateCell = {
date: `${dayNumber}`,
isCurrentMonth: belongsToCurrentMonth,
isToday: fullDateStr == todayStr,
isSelected: isDateSelected(fullDateStr),
isRange: isDateInRange(fullDateStr),
fullDate: fullDateStr,
isDisabled: isDateDisabled(fullDateStr),
isHide: false,
topText: dateConfig?.topText ?? "",
bottomText: dateConfig?.bottomText ?? "",
color: dateConfig?.color ?? ""
} as DateCell;
// 根据配置决定是否隐藏相邻月份的日期
if (!props.showOtherMonth && !belongsToCurrentMonth) {
dateCell.isHide = true;
}
weekDates.push(dateCell);
iterateDate = iterateDate.add(1, "day"); // 移动到下一天
}
weekRows.push(weekDates);
}
dateMatrix.value = weekRows;
}
/**
* 使用Canvas绘制日历仅APP端
* Web端使用DOM渲染APP端使用Canvas提升性能
*/
async function renderCalendar() {
// #ifdef APP
await nextTick(); // 等待DOM更新完成
const ctx = viewRef.value!.getDrawableContext();
if (ctx == null) return;
ctx!.reset(); // 清空画布
/**
* 绘制单个日期单元格
* @param dateCell 日期单元格数据
* @param colIndex 列索引 (0-6)
* @param rowIndex 行索引 (0-5)
*/
function drawSingleCell(dateCell: DateCell, colIndex: number, rowIndex: number) {
// 计算单元格位置
const cellX = colIndex * cellWidth.value;
const cellY = rowIndex * cellHeight.value;
const centerX = cellX + cellWidth.value / 2;
const centerY = cellY + cellHeight.value / 2;
// 绘制背景(选中状态或范围状态)
if (dateCell.isSelected || dateCell.isRange) {
const padding = cellGap.value; // 使用间距作为内边距
const bgX = cellX + padding;
const bgY = cellY + padding;
const bgWidth = cellWidth.value - padding * 2;
const bgHeight = cellHeight.value - padding * 2;
// 设置背景颜色
if (dateCell.isSelected) {
ctx!.fillStyle = bgSelectedColor.value;
}
if (dateCell.isRange) {
ctx!.fillStyle = bgRangeColor.value;
}
ctx!.fillRect(bgX, bgY, bgWidth, bgHeight); // 绘制背景矩形
}
// 获取单元格文字颜色
const cellTextColor = getCellTextColor(dateCell);
ctx!.textAlign = "center";
// 绘制顶部文本
if (dateCell.topText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const topY = cellY + 16; // 距离顶部
ctx!.fillText(dateCell.topText, centerX, topY);
}
// 绘制主日期数字
ctx!.font = `${fontSize.value}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const textOffsetY = (fontSize.value / 2) * 0.7;
ctx!.fillText(dateCell.date.toString(), centerX, centerY + textOffsetY);
// 绘制底部文本
if (dateCell.bottomText != "") {
ctx!.font = `${Math.floor(fontSize.value * 0.75)}px sans-serif`;
ctx!.fillStyle = cellTextColor;
const bottomY = cellY + cellHeight.value - 8; // 距离底部
ctx!.fillText(dateCell.bottomText, centerX, bottomY);
}
}
// 获取容器尺寸信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算单元格宽度总宽度除以7列
const cellSize = viewRect.width / 7;
// 更新渲染配置
cellWidth.value = cellSize;
// 遍历日期矩阵进行绘制
for (let rowIndex = 0; rowIndex < dateMatrix.value.length; rowIndex++) {
const weekRow = dateMatrix.value[rowIndex];
for (let colIndex = 0; colIndex < weekRow.length; colIndex++) {
const dateCell = weekRow[colIndex];
if (!dateCell.isHide) {
drawSingleCell(dateCell, colIndex, rowIndex);
}
}
}
ctx!.update(); // 更新画布显示
// #endif
}
/**
* 处理日期单元格选择逻辑
* @param dateCell 被点击的日期单元格
*/
function selectDateCell(dateCell: DateCell) {
// 隐藏或禁用的日期不可选择
if (dateCell.isHide || dateCell.isDisabled) {
return;
}
if (props.mode == "single") {
// 单选模式:直接替换选中日期
selectedDates.value = [dateCell.fullDate];
emit("update:modelValue", dateCell.fullDate);
} else if (props.mode == "multiple") {
// 多选模式:切换选中状态
const existingIndex = selectedDates.value.indexOf(dateCell.fullDate);
if (existingIndex >= 0) {
// 已选中则移除
selectedDates.value.splice(existingIndex, 1);
} else {
// 未选中则添加
selectedDates.value.push(dateCell.fullDate);
}
} else {
// 范围选择模式
if (selectedDates.value.length == 0) {
// 第一次点击:设置起始日期
selectedDates.value = [dateCell.fullDate];
} else if (selectedDates.value.length == 1) {
// 第二次点击:设置结束日期
const startDate = dayUts(selectedDates.value[0]);
const endDate = dayUts(dateCell.fullDate);
if (endDate.isBefore(startDate)) {
// 结束日期早于开始日期时自动交换
selectedDates.value = [dateCell.fullDate, selectedDates.value[0]];
} else {
selectedDates.value = [selectedDates.value[0], dateCell.fullDate];
}
} else {
// 已有范围时重新开始选择
selectedDates.value = [dateCell.fullDate];
}
}
// 发射更新事件
emit("update:date", [...selectedDates.value]);
emit("change", selectedDates.value);
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理年月选择器的变化事件
* @param yearMonthArray [年份, 月份] 数组
*/
function onYearMonthChange(yearMonthArray: number[]) {
currentYear.value = yearMonthArray[0];
currentMonth.value = yearMonthArray[1];
// 重新计算日历数据并重绘
calculateDateMatrix();
renderCalendar();
}
/**
* 处理点击事件APP端点击检测
*/
async function onTap(e: UniPointerEvent) {
// 获取容器位置信息
const viewRect = await getViewRect();
if (viewRect == null) {
return;
}
// 计算触摸点相对于容器的坐标
const relativeX = e.clientX - viewRect.left;
const relativeY = e.clientY - viewRect.top;
// 根据坐标计算对应的行列索引
const columnIndex = Math.floor(relativeX / cellWidth.value);
const rowIndex = Math.floor(relativeY / cellHeight.value);
// 边界检查:确保索引在有效范围内
if (
rowIndex < 0 ||
rowIndex >= dateMatrix.value.length ||
columnIndex < 0 ||
columnIndex >= 7
) {
return;
}
const targetDateCell = dateMatrix.value[rowIndex][columnIndex];
selectDateCell(targetDateCell);
}
/**
* 切换到上一个月
*/
function gotoPrevMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.subtract(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 切换到下一个月
*/
function gotoNextMonth() {
const [newYear, newMonth] = dayUts(`${currentYear.value}-${currentMonth.value}-01`)
.add(1, "month")
.toArray();
currentYear.value = newYear;
currentMonth.value = newMonth;
// 重新计算并渲染日历
calculateDateMatrix();
renderCalendar();
}
/**
* 解析选中日期
*/
function parseDate(flag: boolean | null = null) {
// 根据选择模式初始化选中日期
if (props.mode == "single") {
selectedDates.value = props.modelValue != null ? [props.modelValue] : [];
} else {
selectedDates.value = [...props.date];
}
// 获取初始显示日期(优先使用选中日期,否则使用当前日期)
let [year, month] = dayUts(first(selectedDates.value)).toArray();
if (flag == true) {
year = props.year ?? year;
month = props.month ?? month;
}
currentYear.value = year;
currentMonth.value = month;
// 计算初始日历数据
calculateDateMatrix();
// 渲染日历视图
renderCalendar();
}
// 组件挂载时的初始化逻辑
onMounted(() => {
// 解析日期
parseDate(true);
// 监听单选模式的值变化
watch(
computed(() => props.modelValue ?? ""),
(newValue: string) => {
selectedDates.value = [newValue];
parseDate();
}
);
// 监听多选/范围模式的值变化
watch(
computed(() => props.date),
(newDateArray: string[]) => {
selectedDates.value = [...newDateArray];
parseDate();
}
);
// 重新渲染
watch(
computed(() => [props.dateConfig, props.showOtherMonth]),
() => {
calculateDateMatrix();
renderCalendar();
},
{
deep: true
}
);
});
</script>
<style lang="scss" scoped>
/* 日历组件主容器 */
.cl-calendar {
@apply relative;
/* 头部导航栏样式 */
&__header {
@apply flex flex-row items-center justify-between p-3 w-full;
/* 上一月/下一月按钮样式 */
&-prev,
&-next {
@apply flex flex-row items-center justify-center rounded-full bg-surface-100;
width: 60rpx;
height: 60rpx;
/* 暗色模式适配 */
&.is-dark {
@apply bg-surface-700;
}
}
/* 当前年月显示区域 */
&-date {
@apply flex flex-row items-center justify-center;
}
}
/* 星期标题行样式 */
&__weeks {
@apply flex flex-row;
/* 单个星期标题样式 */
&-item {
@apply flex flex-row items-center justify-center flex-1;
height: 80rpx;
}
}
/* 日期网格容器样式 */
&__view {
@apply w-full;
// #ifndef APP
/* 日期行样式 */
&-row {
@apply flex flex-row;
}
/* 日期单元格样式 */
&-cell {
@apply flex-1 flex flex-col items-center justify-center relative;
height: 80rpx;
/* 隐藏状态(相邻月份日期) */
&.is-hide {
opacity: 0;
}
/* 禁用状态 */
&.is-disabled {
@apply opacity-50;
}
}
// #endif
}
}
</style>

View File

@@ -0,0 +1,80 @@
import { appendLocale } from "@/locale";
setTimeout(() => {
appendLocale("zh-cn", [
["周日", "日"],
["周一", "一"],
["周二", "二"],
["周三", "三"],
["周四", "四"],
["周五", "五"],
["周六", "六"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("zh-tw", [
["周日", "週日"],
["周一", "週一"],
["周二", "週二"],
["周三", "週三"],
["周四", "週四"],
["周五", "週五"],
["周六", "週六"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("en", [
["周日", "Sun"],
["周一", "Mon"],
["周二", "Tue"],
["周三", "Wed"],
["周四", "Thu"],
["周五", "Fri"],
["周六", "Sat"],
["{year}年{month}月", "{month}/{year}"]
]);
appendLocale("ja", [
["周日", "日曜"],
["周一", "月曜"],
["周二", "火曜"],
["周三", "水曜"],
["周四", "木曜"],
["周五", "金曜"],
["周六", "土曜"],
["{year}年{month}月", "{year}年{month}月"]
]);
appendLocale("ko", [
["周日", "일"],
["周一", "월"],
["周二", "화"],
["周三", "수"],
["周四", "목"],
["周五", "금"],
["周六", "토"],
["{year}年{month}月", "{year}년 {month}월"]
]);
appendLocale("fr", [
["周日", "Dim"],
["周一", "Lun"],
["周二", "Mar"],
["周三", "Mer"],
["周四", "Jeu"],
["周五", "Ven"],
["周六", "Sam"],
["{year}年{month}月", "{month}/{year}"]
]);
appendLocale("es", [
["周日", "Dom"],
["周一", "Lun"],
["周二", "Mar"],
["周三", "Mié"],
["周四", "Jue"],
["周五", "Vie"],
["周六", "Sáb"],
["{year}年{month}月", "{month}/{year}"]
]);
}, 0);

View File

@@ -0,0 +1,273 @@
<template>
<view
class="cl-calendar-picker"
:class="{
'is-dark': isDark
}"
v-if="visible"
>
<view class="cl-calendar-picker__header">
<view
class="cl-calendar-picker__prev"
:class="{
'is-dark': isDark
}"
@tap="prev"
>
<cl-icon name="arrow-left-double-line"></cl-icon>
</view>
<view class="cl-calendar-picker__date" @tap="toMode('year')">
<cl-text
:pt="{
className: 'text-lg'
}"
>
{{ title }}
</cl-text>
</view>
<view
class="cl-calendar-picker__next"
:class="{
'is-dark': isDark
}"
@tap="next"
>
<cl-icon name="arrow-right-double-line"></cl-icon>
</view>
</view>
<view class="cl-calendar-picker__list">
<view
class="cl-calendar-picker__item"
v-for="item in list"
:key="item.value"
@tap="select(item.value)"
>
<cl-text
:pt="{
className: parseClass([[item.value == value, 'text-primary-500']])
}"
>{{ item.label }}</cl-text
>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { first, isDark, last, parseClass } from "@/cool";
import { t } from "@/locale";
import { computed, ref } from "vue";
defineOptions({
name: "cl-calendar-picker"
});
// 定义日历选择器的条目类型
type Item = {
label: string; // 显示的标签,如"1月"、"2024"
value: number; // 对应的数值如1、2024
};
// 定义组件接收的属性年份和月份均为数字类型默认值为0
const props = defineProps({
year: {
type: Number,
default: 0
},
month: {
type: Number,
default: 0
}
});
// 定义组件可触发的事件,这里只定义了"change"事件
const emit = defineEmits(["change"]);
// 当前选择的模式,"year"表示选择年份,"month"表示选择月份,默认是"month"
const mode = ref<"year" | "month">("month");
// 当前选中的年份
const year = ref(0);
// 当前选中的月份
const month = ref(0);
// 当前年份选择面板的起始年份如2020-2029则startYear为2020
const startYear = ref(0);
// 当前选中的值,若为月份模式则为月份,否则为年份
const value = computed(() => {
return mode.value == "month" ? month.value : year.value;
});
// 计算可供选择的列表:
// - 若为月份模式返回1-12月
// - 若为年份模式返回以startYear为起点的连续10年
const list = computed(() => {
if (mode.value == "month") {
return [
{ label: t("1月"), value: 1 },
{ label: t("2月"), value: 2 },
{ label: t("3月"), value: 3 },
{ label: t("4月"), value: 4 },
{ label: t("5月"), value: 5 },
{ label: t("6月"), value: 6 },
{ label: t("7月"), value: 7 },
{ label: t("8月"), value: 8 },
{ label: t("9月"), value: 9 },
{ label: t("10月"), value: 10 },
{ label: t("11月"), value: 11 },
{ label: t("12月"), value: 12 }
] as Item[];
} else {
const years: Item[] = [];
// 生成10个连续年份
for (let i = 0; i < 10; i++) {
years.push({
label: `${startYear.value + i}`,
value: startYear.value + i
});
}
return years;
}
});
// 计算标题内容:
// - 月份模式下显示“xxxx年”
// - 年份模式下显示“起始年 - 结束年”
const title = computed(() => {
return mode.value == "month"
? `${year.value}`
: `${first(list.value)?.label} - ${last(list.value)?.label}`;
});
// 控制选择器弹窗的显示与隐藏
const visible = ref(false);
/**
* 打开选择器,并初始化年份、月份、起始年份
*/
function open() {
visible.value = true;
// 初始化当前年份和月份为传入的props
year.value = props.year;
month.value = props.month;
// 计算当前年份所在的十年区间的起始年份
startYear.value = Math.floor(year.value / 10) * 10;
}
/**
* 关闭选择器
*/
function close() {
visible.value = false;
}
/**
* 切换选择模式(年份/月份)
* @param val "year" 或 "month"
*/
function toMode(val: "year" | "month") {
mode.value = val;
}
/**
* 选择某个值(年份或月份)
* @param val 选中的值
*/
function select(val: number) {
if (mode.value == "month") {
// 选择月份后关闭弹窗并触发change事件
month.value = val;
close();
emit("change", [year.value, month.value]);
} else {
// 选择年份后,切换到月份选择模式
year.value = val;
toMode("month");
}
}
/**
* 切换到上一个区间
* - 月份模式下年份减1
* - 年份模式下起始年份减10
*/
function prev() {
if (mode.value == "month") {
year.value -= 1;
} else {
startYear.value -= 10;
}
}
/**
* 切换到下一个区间
* - 月份模式下年份加1
* - 年份模式下起始年份加10
*/
function next() {
if (mode.value == "month") {
year.value += 1;
} else {
startYear.value += 10;
}
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-calendar-picker {
@apply flex flex-col absolute left-0 top-0 w-full h-full bg-white z-10;
&__header {
@apply flex flex-row items-center justify-between w-full p-3;
}
&__prev,
&__next {
@apply flex flex-row items-center justify-center rounded-full bg-surface-100;
width: 60rpx;
height: 60rpx;
&.is-dark {
@apply bg-surface-700;
}
}
&__date {
@apply flex flex-row items-center justify-center;
}
&__list {
@apply flex flex-row flex-wrap;
}
&__item {
@apply flex flex-row items-center justify-center;
height: 100rpx;
width: 25%;
&-bg {
@apply px-4 py-2;
&.is-active {
@apply bg-primary-500 rounded-xl;
}
}
}
&.is-dark {
@apply bg-surface-800 rounded-2xl;
}
}
</style>

View File

@@ -0,0 +1,31 @@
import type { ClCalendarDateConfig, ClCalendarMode } from "../../types";
export type ClCalendarPassThrough = {
className?: string;
};
export type ClCalendarProps = {
className?: string;
pt?: ClCalendarPassThrough;
modelValue?: string | any;
date?: string[];
mode?: ClCalendarMode;
dateConfig?: ClCalendarDateConfig[];
start?: string;
end?: string;
year?: number;
month?: number;
showOtherMonth?: boolean;
showHeader?: boolean;
showWeeks?: boolean;
cellHeight?: number;
cellGap?: number;
color?: string;
textColor?: string;
textOtherMonthColor?: string;
textDisabledColor?: string;
textTodayColor?: string;
textSelectedColor?: string;
bgSelectedColor?: string;
bgRangeColor?: string;
};

View File

@@ -0,0 +1,532 @@
<template>
<cl-select-trigger
v-if="showTrigger"
:pt="ptTrigger"
:placeholder="placeholder"
:disabled="disabled"
:focus="popupRef?.isOpen"
:text="text"
@open="open"
@clear="clear"
></cl-select-trigger>
<cl-popup ref="popupRef" v-model="visible" :title="title" :pt="ptPopup" @closed="onClosed">
<view class="cl-select-popup" @touchmove.stop>
<view class="cl-select-popup__labels">
<cl-tag
v-for="(item, index) in labels"
:key="index"
:type="index != current ? 'info' : 'primary'"
plain
@tap="onLabelTap(index)"
>
{{ item }}
</cl-tag>
</view>
<view
class="cl-select-popup__list"
:style="{
height: parseRpx(height)
}"
>
<swiper
v-if="isMp() ? popupRef?.isOpen : true"
class="h-full bg-transparent"
:current="current"
:disable-touch="disableTouch"
@change="onSwiperChange"
>
<swiper-item
v-for="(data, index) in list"
:key="index"
class="h-full bg-transparent"
>
<cl-list-view
:data="data"
:item-height="45"
:virtual="!isMp()"
@item-tap="onItemTap"
>
<template #item="{ data, item }">
<view
class="flex flex-row items-center justify-between w-full px-[20rpx]"
:class="{
'bg-primary-50': onItemActive(index, data),
'bg-surface-800': isDark && onItemActive(index, data)
}"
:style="{
height: item.height + 'px'
}"
>
<cl-text
:pt="{
className: parseClass({
'text-primary-500': onItemActive(index, data)
})
}"
>{{ data[labelKey] }}</cl-text
>
</view>
</template>
</cl-list-view>
</swiper-item>
</swiper>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, computed, type PropType, nextTick } from "vue";
import {
isDark,
isEmpty,
isMp,
isNull,
parseClass,
parsePt,
parseRpx,
parseToObject
} from "@/cool";
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import { t } from "@/locale";
import type { ClListViewItem } from "../../types";
defineOptions({
name: "cl-cascader"
});
/**
* 组件属性定义
* 定义级联选择器组件的所有可配置属性
*/
const props = defineProps({
/**
* 透传样式配置
* 用于自定义组件各部分的样式,支持嵌套配置
* 可配置trigger(触发器)、popup(弹窗)等部分的样式
*/
pt: {
type: Object,
default: () => ({})
},
/**
* 选择器的值 - v-model绑定
* 数组形式,按层级顺序存储选中的值
* 例如:["province", "city", "district"] 表示选中了省市区三级
*/
modelValue: {
type: Array as PropType<string[]>,
default: () => []
},
/**
* 选择器弹窗标题
* 显示在弹窗顶部的标题文字
*/
title: {
type: String,
default: () => t("请选择")
},
/**
* 选择器占位符文本
* 当没有选中任何值时显示的提示文字
*/
placeholder: {
type: String,
default: () => t("请选择")
},
/**
* 选项数据源,支持树形结构
* 每个选项需包含 labelKey 和 valueKey 指定的字段
* 如果有子级,需包含 children 字段
*/
options: {
type: Array as PropType<ClListViewItem[]>,
default: () => []
},
/**
* 是否显示选择器触发器
* 设为 false 时可以通过编程方式控制弹窗显示
*/
showTrigger: {
type: Boolean,
default: true
},
/**
* 是否禁用选择器
* 禁用状态下无法点击触发器打开弹窗
*/
disabled: {
type: Boolean,
default: false
},
/**
* 标签显示字段的键名
* 指定从数据项的哪个字段读取显示文字
*/
labelKey: {
type: String,
default: "label"
},
/**
* 值字段的键名
* 指定从数据项的哪个字段读取实际值
*/
valueKey: {
type: String,
default: "label"
},
/**
* 文本分隔符
* 用于连接多级标签的文本
*/
textSeparator: {
type: String,
default: " - "
},
/**
* 列表高度
*/
height: {
type: [String, Number],
default: 800
}
});
/**
* 定义组件事件
* 向父组件发射的事件列表
*/
const emit = defineEmits(["update:modelValue", "change"]);
/**
* 弹出层组件的引用
* 用于调用弹出层的方法,如打开、关闭等
*/
const popupRef = ref<ClPopupComponentPublicInstance | null>(null);
/**
* 透传样式类型定义
* 定义可以透传给子组件的样式配置结构
*/
type PassThrough = {
trigger?: ClSelectTriggerPassThrough; // 触发器样式配置
popup?: ClPopupPassThrough; // 弹窗样式配置
};
/**
* 解析透传样式配置
* 将传入的样式配置按照指定类型进行解析和处理
*/
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 解析触发器透传样式配置
const ptTrigger = computed(() => parseToObject(pt.value.trigger));
// 解析弹窗透传样式配置
const ptPopup = computed(() => parseToObject(pt.value.popup));
/**
* 当前显示的级联层级索引
* 用于控制 swiper 组件显示哪一级的选项列表
*/
const current = ref(0);
/**
* 是否还有下一级可选
* 当选中项没有子级时设为 false表示选择完成
*/
const isNext = ref(true);
/**
* 当前临时选中的值数组
* 存储用户在弹窗中正在选择的值,确认后才会更新到 modelValue
*/
const value = ref<any[]>([]);
/**
* 级联选择的数据列表
* 根据当前选中的值生成多级选项数据数组
* 返回二维数组,第一维是级别,第二维是该级别的选项
*
* 计算逻辑:
* 1. 如果没有选中任何值,返回根级选项
* 2. 根据已选中的值,逐级查找对应的子级选项
* 3. 最终返回所有级别的选项数据
*/
const list = computed<ClListViewItem[][]>(() => {
let data = props.options;
// 如果没有选中任何值,直接返回根级选项
if (isEmpty(value.value)) {
return [data];
}
// 根据选中的值逐级构建选项数据
const arr = value.value.map((v) => {
// 在当前级别中查找选中的项
const item = data.find((e) => e[props.valueKey] == v);
if (item == null) {
return [];
}
// 如果找到的项有子级更新data为子级数据
if (!isNull(item.children)) {
data = item.children ?? [];
}
return data as ClListViewItem[];
});
// 返回根级选项 + 各级子选项
return [props.options, ...arr];
});
/**
* 扁平化的选项数据
* 将树形结构的选项数据转换为一维数组
* 用于根据值快速查找对应的选项信息
*/
const flatOptions = computed(() => {
const data = props.options;
const arr = [] as ClListViewItem[];
/**
* 深度遍历树形数据,将所有节点添加到扁平数组中
* @param list 当前层级的选项列表
*/
function deep(list: ClListViewItem[]) {
list.forEach((e) => {
// 将当前项添加到扁平数组
arr.push(e);
// 如果有子级,递归处理
if (e.children != null) {
deep(e.children!);
}
});
}
// 开始深度遍历
deep(data);
return arr;
});
/**
* 当前选中项的标签数组
* 根据选中的值获取对应的显示标签
* 用于在弹窗顶部显示选择路径
*/
const labels = computed(() => {
const arr = value.value.map((v, i) => {
// 在对应级别的选项中查找匹配的项,返回其标签
return list.value[i].find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
});
if (isNext.value && !isEmpty(flatOptions.value)) {
arr.push(t("请选择"));
}
return arr;
});
/**
* 触发器显示的文本
* 将选中的值转换为对应的标签,用 " - " 连接
* 例如:北京 - 朝阳区 - 三里屯街道
*/
const text = computed(() => {
return props.modelValue
.map((v) => {
// 在扁平化数据中查找对应的选项,获取其标签
return flatOptions.value.find((e) => e[props.valueKey] == v)?.[props.labelKey] ?? "";
})
.join(props.textSeparator);
});
/**
* 选择器弹窗显示状态
* 控制弹窗的打开和关闭
*/
const visible = ref(false);
/**
* 打开选择器弹窗
* 检查禁用状态,如果未禁用则显示弹窗
*/
function open() {
visible.value = true;
}
/**
* 关闭选择器弹窗
* 直接设置弹窗为隐藏状态
*/
function close() {
visible.value = false;
}
/**
* 重置选择器
*/
function reset() {
// 重置当前级别索引
current.value = 0;
// 清空临时选中的值
value.value = [];
// 重置下一级状态
isNext.value = true;
}
/**
* 弹窗关闭完成后的回调
* 重置所有临时状态,为下次打开做准备
*/
function onClosed() {
reset();
}
/**
* 清空选择器的值
* 重置所有状态并触发相关事件
*/
function clear() {
reset();
// 触发值更新事件
emit("update:modelValue", value.value);
emit("change", value.value);
}
/**
* 是否禁用触摸
*/
const disableTouch = ref(false);
/**
* 处理选项点击事件
* 根据点击的选项更新选中状态,如果是叶子节点则完成选择并关闭弹窗
*
* @param item 被点击的选项数据
*/
function onItemTap(item: ClListViewItem) {
if (disableTouch.value == true) {
return;
}
// 处理选项点击事件的主逻辑,防止重复点击,确保级联选择流程正确
disableTouch.value = true;
// 设置新的定时器
setTimeout(() => {
disableTouch.value = false;
}, 300);
// 如果选项没有值,直接返回
if (item[props.valueKey] == null) {
return;
}
// 在当前级别的数据中查找对应的完整选项信息
const data = list.value[current.value].find((e) => e[props.valueKey] == item[props.valueKey]);
// 截取当前级别之前的值,清除后续级别的选择
value.value = value.value.slice(0, current.value);
// 添加当前选中的值
value.value.push(item[props.valueKey]!);
if (data != null) {
// 判断是否为叶子节点(没有子级或子级为空)
if (data.children == null || isEmpty(data.children!)) {
// 关闭弹窗
close();
// 设置下一级状态为不可选
isNext.value = false;
// 选择完成
emit("update:modelValue", value.value);
emit("change", value.value);
} else {
// 还有下一级,继续选择
isNext.value = true;
nextTick(() => {
current.value += 1; // 切换到下一级
});
}
}
}
/**
* 判断选项是否为当前激活状态
* 用于高亮显示当前选中的选项
*
* @param index 当前级别索引
* @param item 选项数据
* @returns 是否为激活状态
*/
function onItemActive(index: number, item: ClListViewItem) {
// 如果没有选中任何值,则没有激活项
if (isEmpty(value.value)) {
return false;
}
// 如果索引超出选中值的长度,说明该级别没有选中项
if (index >= value.value.length) {
return false;
}
// 判断当前级别的选中值是否与该选项的值相匹配
return value.value[index] == item[props.valueKey];
}
/**
* 处理标签点击事件
* 点击标签可以快速跳转到对应的级别
*
* @param index 要跳转到的级别索引
*/
function onLabelTap(index: number) {
current.value = index;
}
/**
* 处理 swiper 组件的切换事件
* 当用户滑动切换级别时同步更新当前级别索引
*
* @param e swiper 切换事件对象
*/
function onSwiperChange(e: UniSwiperChangeEvent) {
current.value = e.detail.current;
}
defineExpose({
open,
close,
reset,
clear
});
</script>
<style lang="scss" scoped>
.cl-select {
&-popup {
&__labels {
@apply flex flex-row mb-3;
padding: 0 20rpx;
}
&__list {
@apply relative;
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
import type { ClSelectTriggerPassThrough } from "../cl-select-trigger/props";
import type { ClPopupPassThrough } from "../cl-popup/props";
import type { ClListViewItem } from "../../types";
export type ClCascaderPassThrough = {
trigger?: ClSelectTriggerPassThrough;
popup?: ClPopupPassThrough;
};
export type ClCascaderProps = {
className?: string;
pt?: ClCascaderPassThrough;
modelValue?: string[];
title?: string;
placeholder?: string;
options?: ClListViewItem[];
showTrigger?: boolean;
disabled?: boolean;
labelKey?: string;
valueKey?: string;
textSeparator?: string;
height?: any;
};

View File

@@ -0,0 +1,177 @@
<template>
<view
class="cl-checkbox"
:class="[
{
'cl-checkbox--disabled': isDisabled,
'cl-checkbox--checked': isChecked
},
pt.className
]"
@tap="onTap"
>
<cl-icon
v-if="showIcon"
:name="iconName"
:size="pt.icon?.size ?? 40"
:pt="{
className: parseClass([
'cl-checkbox__icon mr-1',
{
'text-primary-500': isChecked
},
pt.icon?.className
])
}"
></cl-icon>
<cl-text
:pt="{
className: parseClass([
'cl-checkbox__label',
{
'text-primary-500': isChecked
},
pt.label?.className
])
}"
v-if="showLabel"
>
<slot>{{ label }}</slot>
</cl-text>
</view>
</template>
<script lang="ts" setup>
import { computed, useSlots, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import { get, parseClass, parsePt, pull } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-checkbox"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 绑定值 - 当前选中的值
modelValue: {
type: [Array, Boolean] as PropType<any[] | boolean>,
default: () => []
},
// 标签文本
label: {
type: String,
default: ""
},
// 选项值 - 该单选框对应的值
value: {
type: null
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 选中时的图标
activeIcon: {
type: String,
default: "checkbox-line"
},
// 未选中时的图标
inactiveIcon: {
type: String,
default: "checkbox-blank-line"
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "change"]);
const slots = useSlots();
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => props.disabled || disabled.value);
// 透传样式类型定义
type PassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 是否为选中状态
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value!);
}
if (typeof props.modelValue == "boolean") {
return props.modelValue;
}
return false;
});
// 是否显示标签
const showLabel = computed(() => props.label != "" || get(slots, "default") != null);
// 图标名称
const iconName = computed(() => {
// 选中状态
if (isChecked.value) {
return props.activeIcon;
}
// 默认状态
return props.inactiveIcon;
});
/**
* 点击事件处理函数
* 在非禁用状态下切换选中状态
*/
function onTap() {
if (!isDisabled.value) {
let val = props.modelValue;
if (Array.isArray(val)) {
if (isChecked.value) {
val = pull(val, props.value!);
} else {
val.push(props.value!);
}
} else {
val = !val;
}
emit("update:modelValue", val);
emit("change", val);
}
}
</script>
<style lang="scss" scoped>
.cl-checkbox {
@apply flex flex-row items-center;
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClCheckboxPassThrough = {
className?: string;
icon?: ClIconProps;
label?: PassThroughProps;
};
export type ClCheckboxProps = {
className?: string;
pt?: ClCheckboxPassThrough;
modelValue?: any[] | boolean;
label?: string;
value?: any;
disabled?: boolean;
activeIcon?: string;
inactiveIcon?: string;
showIcon?: boolean;
};

View File

@@ -0,0 +1,106 @@
<template>
<view
class="cl-col"
:class="[
`cl-col-${span}`,
`cl-col-offset-${offset}`,
`cl-col-push-${push}`,
`cl-col-pull-${pull}`,
pt.className
]"
:style="{
paddingLeft: padding,
paddingRight: padding
}"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { parsePt, parseRpx, useParent } from "@/cool";
defineOptions({
name: "cl-col"
});
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 栅格占据的列数
span: {
type: Number,
default: 24
},
// 栅格左侧的间隔格数
offset: {
type: Number,
default: 0
},
// 栅格向右移动格数
push: {
type: Number,
default: 0
},
// 栅格向左移动格数
pull: {
type: Number,
default: 0
}
});
// 获取父组件实例
const parent = useParent<ClRowComponentPublicInstance>("cl-row");
// 透传类型定义
type PassThrough = {
// 自定义类名
className?: string;
};
// 解析透传配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 计算列的padding,用于实现栅格间隔
const padding = computed(() => (parent == null ? "0" : parseRpx(parent.gutter / 2)));
</script>
<style lang="scss" scoped>
@use "sass:math";
.cl-col {
@apply w-full overflow-visible;
}
@for $i from 1 through 24 {
.cl-col-push-#{$i},
.cl-col-pull-#{$i} {
position: relative;
}
}
@for $i from 1 through 24 {
$w: math.div(100%, 24);
.cl-col-#{$i} {
width: $w * $i;
}
.cl-col-offset-#{$i} {
margin-left: $w * $i;
}
.cl-col-pull-#{$i} {
right: $w * $i;
}
.cl-col-push-#{$i} {
left: $w * $i;
}
}
</style>

View File

@@ -0,0 +1,12 @@
export type ClColPassThrough = {
className?: string;
};
export type ClColProps = {
className?: string;
pt?: ClColPassThrough;
span?: number;
offset?: number;
push?: number;
pull?: number;
};

View File

@@ -0,0 +1,112 @@
<template>
<view class="cl-collapse" :style="{ height: `${height}px` }">
<view class="cl-collapse__content" :class="[pt.className]">
<slot></slot>
</view>
</view>
</template>
<script setup lang="ts">
import { getCurrentInstance, ref, computed, watch } from "vue";
import { parsePt } from "@/cool";
defineOptions({
name: "cl-collapse"
});
// 定义组件属性
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 折叠状态值
modelValue: {
type: Boolean,
default: false
}
});
// 获取组件实例
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 折叠展开状态
const isOpened = ref(false);
// 内容高度
const height = ref(0);
/**
* 显示折叠内容
*/
function show() {
isOpened.value = true;
// 获取内容区域高度
uni.createSelectorQuery()
.in(proxy)
.select(".cl-collapse__content")
.boundingClientRect((node) => {
height.value = (node as NodeInfo).height ?? 0;
})
.exec();
}
/**
* 隐藏折叠内容
*/
function hide() {
isOpened.value = false;
height.value = 0;
}
/**
* 切换折叠状态
*/
function toggle() {
if (isOpened.value) {
hide();
} else {
show();
}
}
// 监听折叠状态变化
watch(
computed(() => props.modelValue),
(val: boolean) => {
if (val) {
show();
} else {
hide();
}
}
);
defineExpose({
show,
hide,
toggle
});
</script>
<style lang="scss" scoped>
.cl-collapse {
@apply relative;
transition-property: height;
transition-duration: 0.2s;
&__content {
@apply absolute top-0 left-0 w-full pt-3;
}
}
</style>

View File

@@ -0,0 +1,9 @@
export type ClCollapsePassThrough = {
className?: string;
};
export type ClCollapseProps = {
className?: string;
pt?: ClCollapsePassThrough;
modelValue?: boolean;
};

View File

@@ -0,0 +1,194 @@
<template>
<cl-popup
v-model="visible"
:pt="{
className: '!rounded-[60rpx]'
}"
size="70%"
:show-close="false"
:show-header="false"
:mask-closable="false"
direction="center"
@mask-close="onAction('close')"
@closed="onClosed"
>
<view class="cl-confirm">
<cl-text
:pt="{
className: parseClass(['cl-confirm__title text-lg text-center font-bold mb-2'])
}"
>{{ config.title }}</cl-text
>
<cl-text
:pt="{
className: parseClass(['cl-confirm__message text-md text-center mb-8'])
}"
>{{ config.message }}</cl-text
>
<view class="cl-confirm__actions">
<cl-button
v-if="config.showCancel"
size="large"
text
rounded
border
type="info"
:pt="{
className: 'flex-1 h-[80rpx]'
}"
@tap="onAction('cancel')"
>{{ config.cancelText }}</cl-button
>
<cl-button
v-if="config.showConfirm"
size="large"
rounded
:loading="loading"
:pt="{
className: 'flex-1 h-[80rpx]'
}"
@tap="onAction('confirm')"
>{{ config.confirmText }}</cl-button
>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { ref, reactive } from "vue";
import type { ClConfirmAction, ClConfirmOptions } from "../../types";
import { t } from "@/locale";
import { parseClass } from "@/cool";
// 控制弹窗显示/隐藏
const visible = ref(false);
// 控制弹窗是否关闭
const closed = ref(true);
// 确认弹窗配置项,包含标题、内容、按钮文本等
const config = reactive<ClConfirmOptions>({
title: "",
message: ""
});
// 控制确认按钮loading状态
const loading = ref(false);
// 显示loading
function showLoading() {
loading.value = true;
}
// 隐藏loading
function hideLoading() {
loading.value = false;
}
// 关闭弹窗
function close() {
visible.value = false;
}
/**
* 打开确认弹窗,并设置相关配置
* @param options ClConfirmOptions 配置项
*/
let timer: number = 0;
function open(options: ClConfirmOptions) {
const next = () => {
// 清除之前的定时器
clearTimeout(timer);
// 设置弹窗状态为打开
closed.value = false;
// 显示弹窗
visible.value = true;
// 设置弹窗标题
config.title = options.title;
// 设置弹窗内容
config.message = options.message;
// 是否显示取消按钮,默认显示
config.showCancel = options.showCancel ?? true;
// 是否显示确认按钮,默认显示
config.showConfirm = options.showConfirm ?? true;
// 取消按钮文本,默认"取消"
config.cancelText = options.cancelText ?? t("取消");
// 确认按钮文本,默认"确定"
config.confirmText = options.confirmText ?? t("确定");
// 显示时长默认0不自动关闭
config.duration = options.duration ?? 0;
// 回调函数
config.callback = options.callback;
// 关闭前钩子
config.beforeClose = options.beforeClose;
// 如果设置了显示时长且不为0则启动自动关闭定时器
if (config.duration != 0) {
// 设置定时器,在指定时长后自动关闭弹窗
// @ts-ignore
timer = setTimeout(() => {
// 调用关闭方法
close();
}, config.duration!);
}
};
if (closed.value) {
next();
} else {
setTimeout(() => {
next();
}, 360);
}
}
// 弹窗关闭后重置loading状态
function onClosed() {
hideLoading();
closed.value = true;
}
/**
* 处理用户操作(确认、取消、关闭)
* @param action ClConfirmAction 操作类型
*/
function onAction(action: ClConfirmAction) {
// 如果没有beforeClose钩子直接关闭并回调
if (config.beforeClose == null) {
visible.value = false;
if (config.callback != null) {
config.callback!(action);
}
} else {
// 有beforeClose钩子时传递操作方法
config.beforeClose!(action, {
close,
showLoading,
hideLoading
});
}
}
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-confirm {
@apply p-4;
&__actions {
@apply flex flex-row items-center justify-center;
}
}
</style>

View File

@@ -0,0 +1,324 @@
<template>
<view class="cl-countdown" :class="[pt.className]">
<view
class="cl-countdown__item"
:class="[`${item.isSplitor ? pt.splitor?.className : pt.text?.className}`]"
v-for="(item, index) in list"
:key="index"
>
<slot name="item" :item="item">
<cl-text>{{ item.value }}</cl-text>
</slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { ref, watch, nextTick, computed, type PropType, onMounted, onUnmounted } from "vue";
import type { PassThroughProps } from "../../types";
import { dayUts, get, has, isEmpty, parsePt } from "@/cool";
type Item = {
value: string;
isSplitor: boolean;
};
defineOptions({
name: "cl-countdown"
});
defineSlots<{
item(props: { item: Item }): any;
}>();
const props = defineProps({
// 样式穿透配置
pt: {
type: Object,
default: () => ({})
},
// 格式化模板,支持 {d}天{h}:{m}:{s} 格式
format: {
type: String,
default: "{h}:{m}:{s}"
},
// 是否隐藏为0的单位
hideZero: {
type: Boolean,
default: false
},
// 倒计时天数
day: {
type: Number,
default: 0
},
// 倒计时小时数
hour: {
type: Number,
default: 0
},
// 倒计时分钟数
minute: {
type: Number,
default: 0
},
// 倒计时秒数
second: {
type: Number,
default: 0
},
// 结束时间可以是Date对象或日期字符串
datetime: {
type: [Date, String] as PropType<Date | string>,
default: null
},
// 是否自动开始倒计时
auto: {
type: Boolean,
default: true
}
});
/**
* 组件事件定义
*/
const emit = defineEmits(["stop", "done", "change"]);
/**
* 样式穿透类型定义
*/
type PassThrough = {
className?: string;
text?: PassThroughProps;
splitor?: PassThroughProps;
};
// 解析样式穿透配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 定时器ID用于清除定时器
let timer: number = 0;
// 当前剩余秒数
const seconds = ref(0);
// 倒计时运行状态
const isRunning = ref(false);
// 显示列表
const list = ref<Item[]>([]);
/**
* 倒计时选项类型定义
*/
type Options = {
day?: number;
hour?: number;
minute?: number;
second?: number;
datetime?: Date | string;
};
/**
* 将时间单位转换为总秒数
* @param options 时间选项,支持天、时、分、秒或具体日期时间
* @returns 总秒数
*/
function toSeconds({ day, hour, minute, second, datetime }: Options) {
if (datetime != null) {
// 如果提供了具体日期时间,计算与当前时间的差值
const diff = dayUts(datetime).diff(dayUts());
return Math.max(0, Math.floor(diff / 1000));
} else {
// 否则将各个时间单位转换为秒数
return Math.max(
0,
(day ?? 0) * 86400 + (hour ?? 0) * 3600 + (minute ?? 0) * 60 + (second ?? 0)
);
}
}
/**
* 执行倒计时逻辑
* 计算剩余时间并格式化显示
*/
function countDown() {
// 计算天、时、分、秒,使用更简洁的计算方式
const totalSeconds = Math.floor(seconds.value);
const day = Math.floor(totalSeconds / 86400); // 86400 = 24 * 60 * 60
const hour = Math.floor((totalSeconds % 86400) / 3600); // 3600 = 60 * 60
const minute = Math.floor((totalSeconds % 3600) / 60);
const second = totalSeconds % 60;
// 格式化时间对象,用于模板替换
const t = {
d: day.toString(),
h: hour.toString().padStart(2, "0"),
m: minute.toString().padStart(2, "0"),
s: second.toString().padStart(2, "0")
};
// 控制是否隐藏零值初始为true表示隐藏
let isHide = true;
// 记录开始隐藏的位置索引,-1表示不隐藏
let start = -1;
// 根据格式模板生成显示列表
list.value = (props.format.split(/[{,}]/) as string[])
.map((e, i) => {
const value = has(t, e) ? (get(t, e) as string) : e;
const isSplitor = /^\D+$/.test(value);
if (props.hideZero) {
if (isHide && !isSplitor) {
if (value == "00" || value == "0" || isEmpty(value)) {
start = i;
isHide = true;
} else {
isHide = false;
}
}
}
return {
value,
isSplitor
} as Item;
})
.filter((e, i) => {
return !isEmpty(e.value) && (start == -1 ? true : start < i);
})
.filter((e, i) => {
if (i == 0 && e.isSplitor) {
return false;
}
return true;
});
// 触发change事件
emit("change", list.value);
}
/**
* 清除定时器并重置状态
*/
function clear() {
clearTimeout(timer);
timer = 0;
isRunning.value = false;
}
/**
* 停止倒计时
*/
function stop() {
clear();
emit("stop");
}
/**
* 倒计时结束处理
*/
function done() {
clear();
emit("done");
}
/**
* 继续倒计时
* 启动定时器循环执行倒计时逻辑
*/
function next() {
// 如果时间已到或正在运行,直接返回
if (seconds.value <= 0 || isRunning.value) {
return;
}
isRunning.value = true;
/**
* 倒计时循环函数
* 每秒执行一次,直到时间结束
*/
function loop() {
countDown();
if (seconds.value <= 0) {
done();
return;
} else {
seconds.value--;
// @ts-ignore
timer = setTimeout(() => loop(), 1000);
}
}
loop();
}
/**
* 开始倒计时
* @param options 可选的倒计时参数不传则使用props中的值
*/
function start(options: Options | null = null) {
nextTick(() => {
// 计算初始秒数
seconds.value = toSeconds({
day: options?.day ?? props.day,
hour: options?.hour ?? props.hour,
minute: options?.minute ?? props.minute,
second: options?.second ?? props.second,
datetime: options?.datetime ?? props.datetime
});
// 开始倒计时
if (props.auto) {
next();
} else {
countDown();
}
});
}
// 组件销毁前停止倒计时
onUnmounted(() => stop());
// 组件挂载前开始倒计时
onMounted(() => {
start();
// 监听时间单位变化,重新开始倒计时
watch(
computed(() => [props.day, props.hour, props.minute, props.second] as number[]),
() => {
start();
}
);
// 监听结束时间变化,重新开始倒计时
watch(
computed(() => props.datetime),
() => {
start();
}
);
});
defineExpose({
start,
stop,
done,
next,
isRunning
});
</script>
<style lang="scss" scoped>
.cl-countdown {
@apply flex flex-row items-center;
&__item {
@apply flex flex-row justify-center items-center;
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
export type ClCountdownPassThrough = {
className?: string;
text?: PassThroughProps;
splitor?: PassThroughProps;
};
export type ClCountdownProps = {
className?: string;
pt?: ClCountdownPassThrough;
format?: string;
hideZero?: boolean;
day?: number;
hour?: number;
minute?: number;
second?: number;
datetime?: Date | string;
auto?: boolean;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
import type { PassThroughProps } from "../../types";
export type ClCropperPassThrough = {
className?: string;
image?: PassThroughProps;
op?: PassThroughProps;
opItem?: PassThroughProps;
mask?: PassThroughProps;
cropBox?: PassThroughProps;
};
export type ClCropperProps = {
className?: string;
pt?: ClCropperPassThrough;
cropWidth?: number;
cropHeight?: number;
maxScale?: number;
resizable?: boolean;
};

View File

@@ -0,0 +1,697 @@
<template>
<view
class="cl-draggable"
:class="[
{
'cl-draggable--columns': props.columns > 1
},
pt.className
]"
>
<!-- @vue-ignore -->
<view
v-for="(item, index) in list"
:key="getItemKey(item, index)"
class="cl-draggable__item"
:class="[
{
'cl-draggable__item--disabled': disabled,
'cl-draggable__item--dragging': dragging && dragIndex == index,
'cl-draggable__item--animating': dragging && dragIndex != index
}
]"
:style="getItemStyle(index)"
@touchstart="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'touch');
}
"
@longpress="
(event: UniTouchEvent) => {
onTouchStart(event, index, 'longpress');
}
"
@touchmove="onTouchMove"
@touchend="onTouchEnd"
>
<slot
name="item"
:item="item"
:index="index"
:dragging="dragging"
:dragIndex="dragIndex"
:insertIndex="insertIndex"
>
</slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, ref, getCurrentInstance, type PropType, watch } from "vue";
import { isNull, parsePt, uuid } from "@/cool";
import type { PassThroughProps } from "../../types";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-draggable"
});
defineSlots<{
item(props: {
item: UTSJSONObject;
index: number;
dragging: boolean;
dragIndex: number;
insertIndex: number;
}): any;
}>();
// 项目位置信息类型定义
type ItemPosition = {
top: number;
left: number;
width: number;
height: number;
};
// 位移偏移量类型定义
type TranslateOffset = {
x: number;
y: number;
};
const props = defineProps({
/** PassThrough 样式配置 */
pt: {
type: Object,
default: () => ({})
},
/** 数据数组,支持双向绑定 */
modelValue: {
type: Array as PropType<UTSJSONObject[]>,
default: () => []
},
/** 是否禁用拖拽功能 */
disabled: {
type: Boolean,
default: false
},
/** 列数1为单列纵向布局>1为多列网格布局 */
columns: {
type: Number,
default: 1
},
// 是否需要长按触发
longPress: {
type: Boolean,
default: true
}
});
const emit = defineEmits(["update:modelValue", "change", "start", "end"]);
const { proxy } = getCurrentInstance()!;
// 透传样式类型定义
type PassThrough = {
className?: string;
ghost?: PassThroughProps;
};
/** PassThrough 样式解析 */
const pt = computed(() => parsePt<PassThrough>(props.pt));
/** 数据列表 */
const list = ref<UTSJSONObject[]>([]);
/** 是否正在拖拽 */
const dragging = ref(false);
/** 当前拖拽元素的原始索引 */
const dragIndex = ref(-1);
/** 预期插入的目标索引 */
const insertIndex = ref(-1);
/** 触摸开始时的Y坐标 */
const startY = ref(0);
/** 触摸开始时的X坐标 */
const startX = ref(0);
/** Y轴偏移量 */
const offsetY = ref(0);
/** X轴偏移量 */
const offsetX = ref(0);
/** 当前拖拽的数据项 */
const dragItem = ref<UTSJSONObject>({});
/** 所有项目的位置信息缓存 */
const itemPositions = ref<ItemPosition[]>([]);
/** 是否处于放下动画状态 */
const dropping = ref(false);
/** 动态计算的项目高度 */
const itemHeight = ref(0);
/** 动态计算的项目宽度 */
const itemWidth = ref(0);
/** 是否已开始排序模拟(防止误触) */
const sortingStarted = ref(false);
/**
* 重置所有拖拽相关的状态
* 在拖拽结束后调用,确保组件回到初始状态
*/
function reset() {
dragging.value = false; // 拖拽状态
dropping.value = false; // 放下动画状态
dragIndex.value = -1; // 拖拽元素索引
insertIndex.value = -1; // 插入位置索引
offsetX.value = 0; // X轴偏移
offsetY.value = 0; // Y轴偏移
dragItem.value = {}; // 拖拽的数据项
itemPositions.value = []; // 位置信息缓存
itemHeight.value = 0; // 动态计算的高度
itemWidth.value = 0; // 动态计算的宽度
sortingStarted.value = false; // 排序模拟状态
}
/**
* 计算网格布局中元素的位移偏移
* @param index 当前元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function calculateGridOffset(index: number, dragIdx: number, insertIdx: number): TranslateOffset {
const cols = props.columns;
// 计算当前元素在网格中的行列位置
const currentRow = Math.floor(index / cols);
const currentCol = index % cols;
// 计算元素在拖拽后的新位置索引
let newIndex = index;
if (dragIdx < insertIdx) {
// 向后拖拽dragIdx+1 到 insertIdx 之间的元素需要向前移动一位
if (index > dragIdx && index <= insertIdx) {
newIndex = index - 1;
}
} else if (dragIdx > insertIdx) {
// 向前拖拽insertIdx 到 dragIdx-1 之间的元素需要向后移动一位
if (index >= insertIdx && index < dragIdx) {
newIndex = index + 1;
}
}
// 计算新位置的行列坐标
const newRow = Math.floor(newIndex / cols);
const newCol = newIndex % cols;
// 使用动态计算的网格尺寸
const cellWidth = itemWidth.value;
const cellHeight = itemHeight.value;
// 计算实际的像素位移
const offsetX = (newCol - currentCol) * cellWidth;
const offsetY = (newRow - currentRow) * cellHeight;
return { x: offsetX, y: offsetY };
}
/**
* 计算网格布局的插入位置
* @param dragCenterX 拖拽元素中心点X坐标
* @param dragCenterY 拖拽元素中心点Y坐标
* @returns 最佳插入位置索引
*/
function calculateGridInsertIndex(dragCenterX: number, dragCenterY: number): number {
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 使用欧几里得距离找到最近的网格位置(包括原位置)
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const centerX = position.left + position.width / 2;
const centerY = position.top + position.height / 2;
// 使用欧几里得距离公式
const distance = Math.sqrt(
Math.pow(dragCenterX - centerX, 2) + Math.pow(dragCenterY - centerY, 2)
);
// 更新最近的位置
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算单列布局的插入位置
* @param clientY Y坐标
* @returns 最佳插入位置索引
*/
function calculateSingleColumnInsertIndex(clientY: number): number {
let closestIndex = dragIndex.value;
let minDistance = Infinity;
// 遍历所有元素,找到距离最近的元素中心
for (let i = 0; i < itemPositions.value.length; i++) {
const position = itemPositions.value[i];
// 计算到元素中心点的距离
const itemCenter = position.top + position.height / 2;
const distance = Math.abs(clientY - itemCenter);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
return closestIndex;
}
/**
* 计算拖拽元素的最佳插入位置
* @param clientPosition 在主轴上的坐标仅用于单列布局的Y轴坐标
* @returns 最佳插入位置的索引
*/
function calculateInsertIndex(clientPosition: number): number {
// 如果没有位置信息,保持原位置
if (itemPositions.value.length == 0) {
return dragIndex.value;
}
// 根据布局类型选择计算方式
if (props.columns > 1) {
// 多列网格布局计算拖拽元素的中心点坐标使用2D坐标计算最近位置
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
return calculateGridInsertIndex(dragCenterX, dragCenterY);
} else {
// 单列布局基于Y轴距离计算最近的元素中心
return calculateSingleColumnInsertIndex(clientPosition);
}
}
/**
* 计算单列布局的位移偏移
* @param index 元素索引
* @param dragIdx 拖拽元素索引
* @param insertIdx 插入位置索引
* @returns 位移偏移对象
*/
function calculateSingleColumnOffset(
index: number,
dragIdx: number,
insertIdx: number
): TranslateOffset {
if (dragIdx < insertIdx) {
// 向下拖拽dragIdx+1 到 insertIdx 之间的元素向上移动
if (index > dragIdx && index <= insertIdx) {
return { x: 0, y: -itemHeight.value };
}
} else if (dragIdx > insertIdx) {
// 向上拖拽insertIdx 到 dragIdx-1 之间的元素向下移动
if (index >= insertIdx && index < dragIdx) {
return { x: 0, y: itemHeight.value };
}
}
return { x: 0, y: 0 };
}
/**
* 计算非拖拽元素的位移偏移量
* @param index 元素索引
* @returns 包含 x 和 y 坐标偏移的对象
*/
function getItemTranslateOffset(index: number): TranslateOffset {
// 只在满足所有条件时才计算位移:拖拽中、非放下状态、已开始排序
if (!dragging.value || dropping.value || !sortingStarted.value) {
return { x: 0, y: 0 };
}
const dragIdx = dragIndex.value;
const insertIdx = insertIndex.value;
// 跳过正在拖拽的元素(拖拽元素由位置控制)
if (index == dragIdx) {
return { x: 0, y: 0 };
}
// 没有位置变化时不需要位移(拖回原位置)
if (dragIdx == insertIdx) {
return { x: 0, y: 0 };
}
// 根据布局类型计算位移
if (props.columns > 1) {
// 多列网格布局使用2D位移计算
return calculateGridOffset(index, dragIdx, insertIdx);
} else {
// 单列布局:使用简单的纵向位移
return calculateSingleColumnOffset(index, dragIdx, insertIdx);
}
}
/**
* 计算项目的完整样式对象
* @param index 项目索引
* @returns 样式对象
*/
function getItemStyle(index: number) {
const style = {};
const isCurrent = dragIndex.value == index;
// 多列布局时设置等宽分布
if (props.columns > 1) {
const widthPercent = 100 / props.columns;
style["flex-basis"] = `${widthPercent}%`;
style["width"] = `${widthPercent}%`;
style["box-sizing"] = "border-box";
}
// 放下动画期间,只保留基础样式
if (dropping.value) {
return style;
}
// 拖拽状态下的样式处理
if (dragging.value) {
if (isCurrent) {
// 拖拽元素:跟随移动
style["transform"] = `translate(${offsetX.value}px, ${offsetY.value}px)`;
style["z-index"] = "100";
} else {
// 其他元素:显示排序预览位移
const translateOffset = getItemTranslateOffset(index);
style["transform"] = `translate(${translateOffset.x}px, ${translateOffset.y}px)`;
}
}
return style;
}
/**
* 获取所有项目的位置信息
*/
async function getItemPosition(): Promise<void> {
return new Promise((resolve) => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-draggable")
.boundingClientRect()
.exec((res) => {
const box = res[0] as NodeInfo;
itemWidth.value = (box.width ?? 0) / props.columns;
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-draggable__item")
.boundingClientRect()
.exec((res) => {
const rects = res[0] as NodeInfo[];
const positions: ItemPosition[] = [];
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
if (i == 0) {
itemHeight.value = rect.height ?? 0;
}
positions.push({
top: rect.top ?? 0,
left: rect.left ?? 0,
width: itemWidth.value,
height: itemHeight.value
});
}
itemPositions.value = positions;
resolve();
});
});
});
}
/**
* 获取项目是否禁用
* @param index 项目索引
* @returns 是否禁用
*/
function getItemDisabled(index: number): boolean {
return !isNull(list.value[index]["disabled"]) && (list.value[index]["disabled"] as boolean);
}
/**
* 检查拖拽元素的中心点是否移动到其他元素区域
*/
function checkMovedToOtherElement(): boolean {
// 如果没有位置信息,默认未移出
if (itemPositions.value.length == 0) return false;
const dragIdx = dragIndex.value;
const dragPosition = itemPositions.value[dragIdx];
// 计算拖拽元素当前的中心点位置(考虑拖拽偏移)
const dragCenterX = dragPosition.left + dragPosition.width / 2 + offsetX.value;
const dragCenterY = dragPosition.top + dragPosition.height / 2 + offsetY.value;
// 根据布局类型采用不同的判断策略
if (props.columns > 1) {
// 多列网格布局:检查中心点是否与其他元素区域重叠
for (let i = 0; i < itemPositions.value.length; i++) {
if (i == dragIdx) continue;
const otherPosition = itemPositions.value[i];
const isOverlapping =
dragCenterX >= otherPosition.left &&
dragCenterX <= otherPosition.left + otherPosition.width &&
dragCenterY >= otherPosition.top &&
dragCenterY <= otherPosition.top + otherPosition.height;
if (isOverlapping) {
return true;
}
}
} else {
// 检查是否向上移动超过上一个元素的中线
if (dragIdx > 0) {
const prevPosition = itemPositions.value[dragIdx - 1];
const prevCenterY = prevPosition.top + prevPosition.height / 2;
if (dragCenterY <= prevCenterY) {
return true;
}
}
// 检查是否向下移动超过下一个元素的中线
if (dragIdx < itemPositions.value.length - 1) {
const nextPosition = itemPositions.value[dragIdx + 1];
const nextCenterY = nextPosition.top + nextPosition.height / 2;
if (dragCenterY >= nextCenterY) {
return true;
}
}
}
return false;
}
/**
* 触摸开始事件处理
* @param event 触摸事件对象
* @param index 触摸的项目索引
*/
async function onTouchStart(event: UniTouchEvent, index: number, type: string) {
// 如果是长按触发,但未开启长按功能,则直接返回
if (type == "longpress" && !props.longPress) return;
// 如果是普通触摸触发,但已开启长按功能,则直接返回
if (type == "touch" && props.longPress) return;
// 检查是否禁用或索引无效
if (props.disabled) return;
if (getItemDisabled(index)) return;
if (index < 0 || index >= list.value.length) return;
// 获取触摸点
const touch = event.touches[0];
// 初始化拖拽状态
dragging.value = true;
// 初始化拖拽索引
dragIndex.value = index;
insertIndex.value = index; // 初始插入位置为原位置
startX.value = touch.clientX;
startY.value = touch.clientY;
offsetX.value = 0;
offsetY.value = 0;
// 初始化拖拽数据项
dragItem.value = list.value[index];
// 先获取所有项目的位置信息,为后续计算做准备
await getItemPosition();
// 触发开始事件
emit("start", index);
// 震动
vibrate(1);
// 阻止事件冒泡
event.stopPropagation();
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸移动事件处理
* @param event 触摸事件对象
*/
function onTouchMove(event: TouchEvent): void {
if (!dragging.value) return;
const touch = event.touches[0];
// 更新拖拽偏移量
offsetX.value = touch.clientX - startX.value;
offsetY.value = touch.clientY - startY.value;
// 智能启动排序模拟:只有移出原元素区域才开始
if (!sortingStarted.value) {
if (checkMovedToOtherElement()) {
sortingStarted.value = true;
}
}
// 只有开始排序模拟后才计算插入位置
if (sortingStarted.value) {
// 计算拖拽元素当前的中心点坐标
const dragPos = itemPositions.value[dragIndex.value];
const dragCenterX = dragPos.left + dragPos.width / 2 + offsetX.value;
const dragCenterY = dragPos.top + dragPos.height / 2 + offsetY.value;
// 根据布局类型选择坐标轴网格布局使用X坐标单列布局使用Y坐标
const dragCenter = props.columns > 1 ? dragCenterX : dragCenterY;
// 计算最佳插入位置
const newIndex = calculateInsertIndex(dragCenter);
if (newIndex != insertIndex.value) {
insertIndex.value = newIndex;
}
}
// 阻止默认行为
event.preventDefault();
}
/**
* 触摸结束事件处理
*/
function onTouchEnd(): void {
if (!dragging.value) return;
// 旧索引
const oldIndex = dragIndex.value;
// 新索引
const newIndex = insertIndex.value;
// 如果位置发生变化,立即更新数组
if (oldIndex != newIndex && newIndex >= 0) {
const newList = [...list.value];
const item = newList.splice(oldIndex, 1)[0];
newList.splice(newIndex, 0, item);
list.value = newList;
// 触发变化事件
emit("update:modelValue", list.value);
emit("change", list.value);
}
// 开始放下动画
dropping.value = true;
dragging.value = false;
// 重置所有状态
reset();
// 等待放下动画完成后重置所有状态
emit("end", newIndex >= 0 ? newIndex : oldIndex);
}
/**
* 根据平台选择合适的key
* @param item 数据项
* @param index 索引
* @returns 合适的key
*/
function getItemKey(item: UTSJSONObject, index: number): string {
// #ifdef MP
// 小程序环境使用 index 作为 key避免数据错乱
return `${index}`;
// #endif
// #ifndef MP
// 其他平台使用 uid提供更好的性能
return item["uid"] as string;
// #endif
}
watch(
computed(() => props.modelValue),
(val: UTSJSONObject[]) => {
list.value = val.map((e) => {
return {
uid: e["uid"] ?? uuid(),
...e
};
});
},
{
immediate: true
}
);
</script>
<style lang="scss" scoped>
.cl-draggable {
@apply flex-col relative overflow-visible;
&--columns {
@apply flex-row flex-wrap;
}
&__item {
@apply relative z-10;
// #ifdef APP-IOS
@apply transition-none opacity-100;
// #endif
&--dragging {
@apply opacity-80 z-20;
}
&--disabled {
@apply opacity-60;
}
&--animating {
@apply duration-200;
transition-property: transform;
}
}
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps } from "../../types";
export type ClDraggablePassThrough = {
className?: string;
ghost?: PassThroughProps;
};
export type ClDraggableProps = {
className?: string;
pt?: ClDraggablePassThrough;
modelValue?: UTSJSONObject[];
disabled?: boolean;
columns?: number;
longPress?: boolean;
};

View File

@@ -0,0 +1,102 @@
<template>
<view
class="cl-empty"
:class="[
{
'cl-empty--fixed': fixed
},
pt.className
]"
>
<image
class="cl-empty__icon"
:src="`/static/empty/${icon}.png`"
:style="{
height: parseRpx(iconSize)
}"
mode="aspectFit"
v-if="showIcon"
></image>
<cl-text
:pt="{
className: parseClass([
'cl-empty__text text-sm text-surface-400',
{
'text-surface-100': isDark
}
])
}"
v-if="text"
>{{ text }}</cl-text
>
</view>
</template>
<script lang="ts" setup>
import { t } from "@/locale";
import { isDark, parseClass, parsePt, parseRpx } from "@/cool";
import { computed } from "vue";
// 组件属性定义
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 空状态文本
text: {
type: String,
default: () => t("暂无数据")
},
// 空状态图标名称
icon: {
type: String,
default: "comm"
},
// 图标尺寸
iconSize: {
type: [Number, String],
default: 120
},
// 是否显示图标
showIcon: {
type: Boolean,
default: true
},
// 是否固定定位
fixed: {
type: Boolean,
default: true
}
});
// 透传样式类型定义
type PassThrough = {
className?: string; // 根元素类名
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
</script>
<style lang="scss" scoped>
.cl-empty {
@apply flex flex-col items-center justify-center w-full h-full;
pointer-events: none;
&--fixed {
@apply fixed top-0 left-0;
z-index: -1;
// #ifdef H5
z-index: 0;
// #endif
}
&__icon {
margin-bottom: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<view class="cl-filter-bar">
<slot></slot>
</view>
</template>
<script lang="ts" setup>
defineOptions({
name: "cl-filter-bar"
});
</script>
<style lang="scss" scoped>
.cl-filter-bar {
@apply flex flex-row;
}
</style>

View File

@@ -0,0 +1,3 @@
export type ClFilterBarProps = {
className?: string;
};

View File

@@ -0,0 +1,169 @@
<template>
<view class="cl-filter-item" :class="[pt.className]" @tap="onTap">
<slot>
<cl-text
:pt="{
className: parseClass([
[isActive, 'text-primary-500'],
'text-center',
pt.label?.className
])
}"
>{{ text }}</cl-text
>
<!-- 排序 -->
<cl-icon
v-if="type == 'sort' && sort != 'none'"
:name="`sort-${sort}`"
:pt="{
className: 'ml-1'
}"
></cl-icon>
<!-- 下拉框 -->
<cl-icon
v-if="type == 'select'"
name="arrow-down-s-line"
:pt="{
className: 'ml-1'
}"
></cl-icon>
</slot>
</view>
<cl-select
v-model="selectValue"
ref="selectRef"
:show-trigger="false"
:options="options"
></cl-select>
</template>
<script lang="ts" setup>
import { parsePt, parseClass } from "@/cool";
import { computed, onMounted, ref, watch, type PropType } from "vue";
import type { PassThroughProps, ClFilterItemType, ClSelectOption } from "../../types";
defineOptions({
name: "cl-filter-item"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
label: {
type: String,
default: ""
},
value: {
type: [String, Number, Boolean, Array] as PropType<any>,
required: true
},
type: {
type: String as PropType<ClFilterItemType>,
default: "switch"
},
options: {
type: Array as PropType<ClSelectOption[]>,
default: () => []
}
});
const emit = defineEmits(["change"]);
// 透传样式类型
type PassThrough = {
className?: string;
label?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// select组件的ref引用用于调用select的方法
const selectRef = ref<ClSelectComponentPublicInstance | null>(null);
// switch类型的激活状态
const isActive = ref(false);
// sort类型的排序状态可为"asc"、"desc"、"none"
const sort = ref("none");
// select类型的当前选中值
const selectValue = ref<any | null>(null);
// 根据类型动态计算显示文本
const text = computed(() => {
// 如果是select类型显示选中项的label
if (props.type == "select") {
return props.options.find((e) => e.value == selectValue.value)?.label ?? "";
} else {
// 其他类型直接显示label
return props.label;
}
});
// 点击事件,根据不同类型处理
function onTap() {
// 排序类型,切换排序状态
if (props.type == "sort") {
if (sort.value == "asc") {
sort.value = "desc";
} else if (sort.value == "desc") {
sort.value = "none";
} else {
sort.value = "asc";
}
emit("change", sort.value);
}
// 开关类型,切换激活状态
if (props.type == "switch") {
isActive.value = !isActive.value;
emit("change", isActive.value);
}
// 选择类型打开select组件
if (props.type == "select") {
// 打开select弹窗选择后回调
selectRef.value!.open((val) => {
emit("change", val);
});
}
}
// 组件挂载时监听props.value变化并同步到本地状态
onMounted(() => {
watch(
computed(() => props.value!),
(val: any) => {
switch (props.type) {
case "select":
// select类型同步选中值
selectValue.value = val as any;
break;
case "switch":
// switch类型同步激活状态
isActive.value = val as boolean;
break;
case "sort":
// sort类型同步排序状态
sort.value = val as string;
break;
}
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-filter-item {
@apply flex flex-row flex-1 justify-center items-center h-[72rpx];
}
</style>

View File

@@ -0,0 +1,15 @@
import type { PassThroughProps, ClFilterItemType, ClSelectOption } from "../../types";
export type ClFilterItemPassThrough = {
className?: string;
label?: PassThroughProps;
};
export type ClFilterItemProps = {
className?: string;
pt?: ClFilterItemPassThrough;
label?: string;
value: any;
type?: ClFilterItemType;
options?: ClSelectOption[];
};

View File

@@ -0,0 +1,287 @@
<template>
<view
class="cl-float-view"
:class="{
'no-dragging': !position.isDragging
}"
:style="viewStyle"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
@touchcancel="onTouchEnd"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { getSafeAreaHeight, getTabBarHeight, hasCustomTabBar, router } from "@/cool";
import { computed, reactive } from "vue";
import { clFooterOffset } from "../cl-footer/offset";
defineOptions({
name: "cl-float-view"
});
const props = defineProps({
// 图层
zIndex: {
type: Number,
default: 500
},
// 尺寸
size: {
type: Number,
default: 40
},
// 左边距
left: {
type: Number,
default: 10
},
// 底部距离
bottom: {
type: Number,
default: 10
},
// 距离边缘的间距
gap: {
type: Number,
default: 10
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 不吸附边缘
noSnapping: {
type: Boolean,
default: false
}
});
// 获取设备屏幕信息
const { screenWidth, statusBarHeight, screenHeight } = uni.getWindowInfo();
/**
* 悬浮按钮位置状态类型定义
*/
type Position = {
x: number; // 水平位置(左边距)
y: number; // 垂直位置(相对底部的距离)
isDragging: boolean; // 是否正在拖拽中
};
/**
* 悬浮按钮位置状态管理
* 控制按钮在屏幕上的位置和拖拽状态
*/
const position = reactive<Position>({
x: props.left, // 初始左边距10px
y: props.bottom, // 初始距离底部10px
isDragging: false // 初始状态为非拖拽
});
/**
* 拖拽操作状态类型定义
*/
type DragState = {
startX: number; // 拖拽开始时的X坐标
startY: number; // 拖拽开始时的Y坐标
};
/**
* 拖拽操作状态管理
* 记录拖拽过程中的关键信息
*/
const dragState = reactive<DragState>({
startX: 0,
startY: 0
});
/**
* 动态位置样式计算
* 根据当前位置和拖拽状态计算组件的CSS样式
*/
const viewStyle = computed(() => {
const style = {};
// 额外的底部偏移
let bottomOffset = 0;
// 标签页需要额外减去标签栏高度和安全区域
if (hasCustomTabBar()) {
bottomOffset += getTabBarHeight();
} else {
// 获取其他组件注入的底部偏移
bottomOffset += clFooterOffset.get();
}
// 设置水平位置
style["left"] = `${position.x}px`;
// 设置垂直位置(从底部计算)
style["bottom"] = `${bottomOffset + position.y}px`;
// 设置z-index
style["z-index"] = props.zIndex;
// 设置尺寸
style["width"] = `${props.size}px`;
// 设置高度
style["height"] = `${props.size}px`;
return style;
});
/**
* 计算垂直方向的边界限制
* @returns 返回最大Y坐标值距离底部的最大距离
*/
function calculateMaxY(): number {
let maxY = screenHeight - props.size;
// 根据导航栏状态调整顶部边界
if (router.isCustomNavbarPage()) {
// 自定义导航栏页面,只需减去状态栏高度
maxY -= statusBarHeight;
} else {
// 默认导航栏页面,减去导航栏高度(44px)和状态栏高度
maxY -= 44 + statusBarHeight;
}
// 标签页需要额外减去标签栏高度和安全区域
if (router.isTabPage()) {
maxY -= getTabBarHeight();
}
return maxY;
}
// 计算垂直边界
const maxY = calculateMaxY();
/**
* 触摸开始事件处理
* 初始化拖拽状态,记录起始位置和时间
*/
function onTouchStart(e: TouchEvent) {
// 如果禁用,直接返回
if (props.disabled) return;
// 确保有触摸点存在
if (e.touches.length > 0) {
const touch = e.touches[0];
// 记录拖拽开始的位置
dragState.startX = touch.clientX;
dragState.startY = touch.clientY;
// 标记为拖拽状态,关闭过渡动画
position.isDragging = true;
}
}
/**
* 触摸移动事件处理
* 实时更新按钮位置,实现拖拽效果
*/
function onTouchMove(e: TouchEvent) {
// 如果不在拖拽状态或没有触摸点,直接返回
if (!position.isDragging || e.touches.length == 0) return;
// 阻止默认的滚动行为
e.preventDefault();
const touch = e.touches[0];
// 计算相对于起始位置的偏移量
const deltaX = touch.clientX - dragState.startX;
const deltaY = dragState.startY - touch.clientY; // Y轴方向相反屏幕坐标系向下为正我们的bottom向上为正
// 计算新的位置
let newX = position.x + deltaX;
let newY = position.y + deltaY;
// 水平方向边界限制:确保按钮不超出屏幕左右边界
newX = Math.max(0, Math.min(screenWidth - props.size, newX));
// 垂直方向边界限制
let minY = 0;
// 非标签页时,底部需要考虑安全区域
if (!router.isTabPage()) {
minY += getSafeAreaHeight("bottom");
}
// 确保按钮不超出屏幕上下边界
newY = Math.max(minY, Math.min(maxY, newY));
// 更新按钮位置
position.x = newX;
position.y = newY;
// 更新拖拽起始点,为下次移动计算做准备
dragState.startX = touch.clientX;
dragState.startY = touch.clientY;
}
/**
* 执行边缘吸附逻辑
* 拖拽结束后自动将按钮吸附到屏幕边缘
*/
function performEdgeSnapping() {
const edgeThreshold = 60; // 吸附触发阈值(像素)
const edgePadding = props.gap; // 距离边缘的间距
// 判断按钮当前更靠近左边还是右边
const centerX = screenWidth / 2;
const isLeftSide = position.x < centerX;
// 水平方向吸附逻辑
if (position.x < edgeThreshold) {
// 距离左边缘很近,吸附到左边
position.x = edgePadding;
} else if (position.x > screenWidth - props.size - edgeThreshold) {
// 距离右边缘很近,吸附到右边
position.x = screenWidth - props.size - edgePadding;
} else if (isLeftSide) {
// 在左半屏且不在边缘阈值内,吸附到左边
position.x = edgePadding;
} else {
// 在右半屏且不在边缘阈值内,吸附到右边
position.x = screenWidth - props.size - edgePadding;
}
// 垂直方向边界修正
const verticalPadding = props.gap;
if (position.y > maxY - verticalPadding) {
position.y = maxY - verticalPadding;
}
if (position.y < verticalPadding) {
position.y = verticalPadding;
}
}
/**
* 触摸结束事件处理
* 结束拖拽状态并执行边缘吸附
*/
function onTouchEnd() {
// 如果不在拖拽状态,直接返回
if (!position.isDragging) return;
// 结束拖拽状态,恢复过渡动画
position.isDragging = false;
// 执行边缘吸附逻辑
if (!props.noSnapping) {
performEdgeSnapping();
}
}
</script>
<style lang="scss" scoped>
.cl-float-view {
@apply fixed transition-none;
&.no-dragging {
@apply duration-300;
transition-property: left, bottom;
}
}
</style>

View File

@@ -0,0 +1,10 @@
export type ClFloatViewProps = {
className?: string;
zIndex?: number;
size?: number;
left?: number;
bottom?: number;
gap?: number;
disabled?: boolean;
noSnapping?: boolean;
};

View File

@@ -0,0 +1,124 @@
<template>
<view class="cl-footer-placeholder" :style="{ height: height + 'px' }" v-if="visible"> </view>
<view class="cl-footer-wrapper" :class="[pt.wrapper?.className]">
<view
class="cl-footer"
:class="[
{
'is-dark': isDark
},
pt.className
]"
v-if="visible"
>
<view class="cl-footer__content" :class="[pt.content?.className]">
<slot></slot>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { getSafeAreaHeight, isDark, isHarmony, parsePt } from "@/cool";
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from "vue";
import type { PassThroughProps } from "../../types";
import { clFooterOffset } from "./offset";
defineOptions({
name: "cl-footer"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
// 最小高度,小于该高度时,不显示
minHeight: {
type: Number,
default: 30
},
// 监听值,触发更新
vt: {
type: Number,
default: 0
}
});
const { proxy } = getCurrentInstance()!;
type PassThrough = {
className?: string;
content?: PassThroughProps;
wrapper?: PassThroughProps;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 内容高度
const height = ref(0);
// 是否显示
const visible = ref(true);
// 获取内容高度
function getHeight() {
nextTick(() => {
setTimeout(
() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-footer")
.boundingClientRect((res) => {
// 获取内容高度
const h = Math.floor((res as NodeInfo).height ?? 0);
// 设置高度
height.value = h;
// 如果内容高度大于最小高度,则显示
visible.value = h > props.minHeight + getSafeAreaHeight("bottom");
// 隔离高度
clFooterOffset.set(visible.value ? h : 0);
})
.exec();
},
isHarmony() ? 50 : 0
);
});
}
onMounted(() => {
watch(
computed(() => props.vt),
() => {
visible.value = true;
getHeight();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-footer {
@apply bg-white overflow-visible;
padding-bottom: env(safe-area-inset-bottom);
&.is-dark {
@apply bg-surface-900;
}
&__content {
@apply px-3 py-3 overflow-visible;
}
&-wrapper {
@apply fixed bottom-0 left-0 w-full overflow-visible;
}
}
</style>

View File

@@ -0,0 +1,16 @@
import { router } from "@/cool";
import { reactive } from "vue";
export class ClFooterOffset {
private data = reactive({});
set(value: number): void {
this.data[router.path()] = value;
}
get(): number {
return (this.data[router.path()] as number | null) ?? 0;
}
}
export const clFooterOffset = new ClFooterOffset();

View File

@@ -0,0 +1,14 @@
import type { PassThroughProps } from "../../types";
export type ClFooterPassThrough = {
className?: string;
content?: PassThroughProps;
wrapper?: PassThroughProps;
};
export type ClFooterProps = {
className?: string;
pt?: ClFooterPassThrough;
minHeight?: number;
vt?: number;
};

View File

@@ -0,0 +1,269 @@
<template>
<view
class="cl-form-item"
:class="[
{
'cl-form-item--error': isError
},
pt.className
]"
:id="`cl-form-item-${prop}`"
>
<view class="cl-form-item__inner" :class="[`is-${labelPosition}`, pt.inner?.className]">
<view
class="cl-form-item__label"
:class="[`is-${labelPosition}`, pt.label?.className]"
:style="{
width: labelPosition != 'top' ? labelWidth : 'auto'
}"
v-if="label != ''"
>
<cl-text>{{ label }}</cl-text>
<cl-text
color="error"
:pt="{
className: 'ml-1'
}"
v-if="showAsterisk"
>
*
</cl-text>
</view>
<view class="cl-form-item__content" :class="[pt.content?.className]">
<slot></slot>
</view>
</view>
<view class="cl-form-item__error" v-if="isError && showMessage">
<slot name="error" :error="errorText">
<cl-text
color="error"
:pt="{
className: parseClass(['mt-2 text-sm', pt.error?.className])
}"
>
{{ errorText }}
</cl-text>
</slot>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, watch, type PropType } from "vue";
import { isEqual, parseClass, parsePt } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, PassThroughProps } from "../../types";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-form-item"
});
defineSlots<{
error(props: { error: string }): any;
}>();
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 字段标签
label: {
type: String,
default: ""
},
// 字段名称
prop: {
type: String,
default: ""
},
// 字段验证规则
rules: {
type: Array as PropType<ClFormRule[]>,
default: () => []
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: null
},
// 标签宽度
labelWidth: {
type: String as PropType<string | null>,
default: null
},
// 是否显示必填星号
showAsterisk: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否显示错误信息
showMessage: {
type: Boolean as PropType<boolean | null>,
default: null
},
// 是否必填
required: {
type: Boolean,
default: false
}
});
// cl-form 上下文
const { formRef, getError, getValue, validateField, addField, removeField, setRule } = useForm();
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
error?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 当前错误信息
const errorText = computed<string>(() => {
return getError(props.prop);
});
// 是否有错误
const isError = computed<boolean>(() => {
return errorText.value != "";
});
// 当前标签位置
const labelPosition = computed<ClFormLabelPosition>(() => {
return props.labelPosition ?? formRef.value?.labelPosition ?? "left";
});
// 标签宽度
const labelWidth = computed<string>(() => {
return props.labelWidth ?? formRef.value?.labelWidth ?? "120rpx";
});
// 是否显示必填星号
const showAsterisk = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showAsterisk ?? formRef.value?.showAsterisk ?? true;
});
// 是否显示错误信息
const showMessage = computed<boolean>(() => {
if (!props.required) {
return false;
}
return props.showMessage ?? formRef.value?.showMessage ?? true;
});
watch(
computed(() => props.required),
(val: boolean) => {
if (val) {
addField(props.prop, props.rules);
} else {
removeField(props.prop);
}
},
{
immediate: true
}
);
onMounted(() => {
// 监听字段值变化
watch(
computed(() => {
const value = getValue(props.prop);
if (value == null) {
return "";
}
return value;
}),
(a: any, b: any) => {
if (props.required) {
if (!isEqual(a, b)) {
validateField(props.prop);
}
}
},
{
deep: true
}
);
// 监听规则变化
watch(
computed(() => props.rules),
(val: ClFormRule[]) => {
setRule(props.prop, val);
},
{
deep: true
}
);
});
onUnmounted(() => {
removeField(props.prop);
});
defineExpose({
prop: props.prop
});
</script>
<style lang="scss" scoped>
.cl-form-item {
@apply w-full mb-6;
&__inner {
@apply w-full;
&.is-top {
@apply flex flex-col;
}
&.is-left {
@apply flex flex-row;
}
&.is-right {
@apply flex flex-row;
}
}
&__label {
@apply flex flex-row items-center;
&.is-top {
@apply w-full mb-2;
}
&.is-left {
@apply mr-3;
}
&.is-right {
@apply mr-3 justify-end;
}
}
&__content {
@apply relative flex-1 w-full;
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { ClFormLabelPosition, ClFormRule, PassThroughProps } from "../../types";
export type ClFormItemPassThrough = {
className?: string;
inner?: PassThroughProps;
label?: PassThroughProps;
content?: PassThroughProps;
error?: PassThroughProps;
};
export type ClFormItemProps = {
className?: string;
pt?: ClFormItemPassThrough;
label?: string;
prop?: string;
rules?: ClFormRule[];
labelPosition?: ClFormLabelPosition;
labelWidth?: string | any;
showAsterisk?: boolean | any;
showMessage?: boolean | any;
required?: boolean;
};

View File

@@ -0,0 +1,455 @@
<template>
<view
class="cl-form"
:class="[
`cl-form--label-${labelPosition}`,
{
'cl-form--disabled': disabled
},
pt.className
]"
>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, ref, watch, type PropType } from "vue";
import { get, isEmpty, isNull, isString, parsePt, parseToObject } from "@/cool";
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
import { $t, t } from "@/locale";
import { usePage } from "../../hooks";
defineOptions({
name: "cl-form"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 表单数据模型
modelValue: {
type: Object as PropType<any>,
default: () => ({})
},
// 表单规则
rules: {
type: Object as PropType<Map<string, ClFormRule[]>>,
default: () => new Map<string, ClFormRule[]>()
},
// 标签位置
labelPosition: {
type: String as PropType<ClFormLabelPosition>,
default: "top"
},
// 标签宽度
labelWidth: {
type: String,
default: "140rpx"
},
// 是否显示必填星号
showAsterisk: {
type: Boolean,
default: true
},
// 是否显示错误信息
showMessage: {
type: Boolean,
default: true
},
// 是否禁用整个表单
disabled: {
type: Boolean,
default: false
},
// 滚动到第一个错误位置
scrollToError: {
type: Boolean,
default: true
}
});
const { proxy } = getCurrentInstance()!;
// cl-page 上下文
const page = usePage();
// 透传样式类型
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 表单数据
const data = ref({} as UTSJSONObject);
// 表单字段错误信息
const errors = ref(new Map<string, string>());
// 表单字段集合
const fields = ref(new Set<string>([]));
// 标签位置
const labelPosition = computed(() => props.labelPosition);
// 标签宽度
const labelWidth = computed(() => props.labelWidth);
// 是否显示必填星号
const showAsterisk = computed(() => props.showAsterisk);
// 是否显示错误信息
const showMessage = computed(() => props.showMessage);
// 是否禁用整个表单
const disabled = computed(() => props.disabled);
// 错误信息锁定
const errorLock = ref(false);
// 设置字段错误信息
function setError(prop: string, error: string) {
if (errorLock.value) {
return;
}
if (prop != "") {
errors.value.set(prop, error);
}
}
// 移除字段错误信息
function removeError(prop: string) {
if (prop != "") {
errors.value.delete(prop);
}
}
// 获取字段错误信息
function getError(prop: string): string {
if (prop != "") {
return errors.value.get(prop) ?? "";
}
return "";
}
// 获得错误信息,并滚动到第一个错误位置
async function getErrors(): Promise<ClFormValidateError[]> {
return new Promise((resolve) => {
// 错误信息
const errs = [] as ClFormValidateError[];
// 错误信息位置
const tops = new Map<string, number>();
// 完成回调,将错误信息添加到数组中
function done() {
tops.forEach((top, prop) => {
errs.push({
field: prop,
message: getError(prop)
});
});
// 滚动到第一个错误位置
if (props.scrollToError && errs.length > 0) {
page.scrollTo((tops.get(errs[0].field) ?? 0) + page.getScrollTop());
}
resolve(errs);
}
// 如果错误信息为空,直接返回
if (errors.value.size == 0) {
done();
return;
}
nextTick(() => {
let component = proxy;
// #ifdef MP
let num = 0; // 记录已处理的表单项数量
// 并查找其错误节点的位置
const deep = (el: any, index: number) => {
// 遍历当前节点的所有子节点
el?.$children.map((e: any) => {
// 限制递归深度,防止死循环
if (index < 5) {
// 判断是否为 cl-form-item 组件且 prop 存在
if (e.prop != null && e.$options.name == "cl-form-item") {
// 如果该字段已注册到 fields 中,则计数加一
if (fields.value.has(e.prop)) {
num += 1;
}
// 查询该 cl-form-item 下是否有错误节点,并获取其位置信息
uni.createSelectorQuery()
.in(e)
.select(".cl-form-item--error")
.boundingClientRect((res) => {
// 如果未获取到节点信息,直接返回
if (res == null) {
return;
}
// 记录该字段的错误节点 top 值
tops.set(e.prop, (res as NodeInfo).top!);
// 如果已处理的表单项数量达到总数,执行 done 回调
if (num >= fields.value.size) {
done();
}
})
.exec();
}
// 递归查找子节点
deep(e, index + 1);
}
});
};
deep(component, 0);
// #endif
// #ifndef MP
uni.createSelectorQuery()
.in(component)
.selectAll(".cl-form-item--error")
.boundingClientRect((res) => {
(res as NodeInfo[]).map((e) => {
tops.set((e.id ?? "").replace("cl-form-item-", ""), e.top ?? 0);
});
done();
})
.exec();
// #endif
});
});
}
// 清除所有错误信息
function clearErrors() {
errors.value.clear();
}
// 获取字段值
function getValue(prop: string): any | null {
if (prop != "") {
return get(data.value, prop, null);
}
return null;
}
// 获取字段规则
function getRule(prop: string): ClFormRule[] {
return props.rules.get(prop) ?? ([] as ClFormRule[]);
}
// 设置字段规则
function setRule(prop: string, rules: ClFormRule[]) {
if (prop != "" && !isEmpty(rules)) {
props.rules.set(prop, rules);
}
}
// 移除字段规则
function removeRule(prop: string) {
if (prop != "") {
props.rules.delete(prop);
}
}
// 注册表单字段
function addField(prop: string, rules: ClFormRule[]) {
if (prop != "") {
fields.value.add(prop);
setRule(prop, rules);
}
}
// 注销表单字段
function removeField(prop: string) {
if (prop != "") {
fields.value.delete(prop);
removeRule(prop);
removeError(prop);
}
}
// 验证单个规则
function validateRule(value: any | null, rule: ClFormRule): null | string {
// 必填验证
if (rule.required == true) {
if (
value == null ||
(value == "" && isString(value)) ||
(Array.isArray(value) && value.length == 0)
) {
return rule.message ?? t("此字段为必填项");
}
}
// 如果值为空且不是必填,直接通过
if ((value == null || (value == "" && isString(value))) && rule.required != true) {
return null;
}
// 最小长度验证
if (rule.min != null) {
if (typeof value == "number") {
if ((value as number) < rule.min) {
return rule.message ?? $t(`最小值为{min}`, { min: rule.min });
}
} else {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len < rule.min) {
return rule.message ?? $t(`最少需要{min}个字符`, { min: rule.min });
}
}
}
// 最大长度验证
if (rule.max != null) {
if (typeof value == "number") {
if (value > rule.max) {
return rule.message ?? $t(`最大值为{max}`, { max: rule.max });
}
} else {
const len = Array.isArray(value) ? value.length : `${value}`.length;
if (len > rule.max) {
return rule.message ?? $t(`最多允许{max}个字符`, { max: rule.max });
}
}
}
// 正则验证
if (rule.pattern != null) {
if (!rule.pattern.test(`${value}`)) {
return rule.message ?? t("格式不正确");
}
}
// 自定义验证
if (rule.validator != null) {
const result = rule.validator(value);
if (result != true) {
return typeof result == "string" ? result : (rule.message ?? t("验证失败"));
}
}
return null;
}
// 清除所有验证
function clearValidate() {
errorLock.value = true;
nextTick(() => {
clearErrors();
errorLock.value = false;
});
}
// 验证单个字段
function validateField(prop: string): string | null {
let error = null as string | null;
if (prop != "") {
const value = getValue(prop);
const rules = getRule(prop);
if (!isEmpty(rules)) {
// 逐个验证规则
rules.find((rule) => {
const msg = validateRule(value, rule);
if (msg != null) {
error = msg;
return true;
}
return false;
});
}
// 移除错误信息
removeError(prop);
}
if (error != null) {
setError(prop, error!);
}
return error;
}
// 验证整个表单
async function validate(callback: (valid: boolean, errors: ClFormValidateError[]) => void) {
// 验证所有字段
fields.value.forEach((prop) => {
validateField(prop);
});
// 获取所有错误信息,并滚动到第一个错误位置
const errs = await getErrors();
// 回调
callback(errs.length == 0, errs);
}
watch(
computed(() => parseToObject(props.modelValue)),
(val: UTSJSONObject) => {
data.value = val;
},
{
immediate: true,
deep: true
}
);
defineExpose({
labelPosition,
labelWidth,
showAsterisk,
showMessage,
disabled,
data,
errors,
fields,
addField,
removeField,
getValue,
setError,
getError,
getErrors,
removeError,
clearErrors,
getRule,
setRule,
removeRule,
validateRule,
clearValidate,
validateField,
validate
});
</script>
<style lang="scss" scoped>
.cl-form {
@apply w-full;
}
</style>

View File

@@ -0,0 +1,18 @@
import type { ClFormLabelPosition, ClFormRule, ClFormValidateError } from "../../types";
export type ClFormPassThrough = {
className?: string;
};
export type ClFormProps = {
className?: string;
pt?: ClFormPassThrough;
modelValue?: any;
rules?: Map<string, ClFormRule[]>;
labelPosition?: ClFormLabelPosition;
labelWidth?: string;
showAsterisk?: boolean;
showMessage?: boolean;
disabled?: boolean;
scrollToError?: boolean;
};

View File

@@ -0,0 +1,162 @@
<template>
<text class="cl-icon" :class="[ptClassName]" :style="iconStyle" :key="cache.key">
{{ icon.text }}
</text>
</template>
<script setup lang="ts">
import { computed, type PropType } from "vue";
import {
forInObject,
get,
has,
parsePt,
useCache,
isDark,
ctx,
hasTextColor,
isNull
} from "@/cool";
import { icons } from "@/icons";
import { useSize } from "../../hooks";
defineOptions({
name: "cl-icon"
});
// 定义组件属性
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 图标名称
name: {
type: String,
default: ""
},
// 图标大小
size: {
type: [String, Number] as PropType<string | number>,
default: 32
},
// 图标高度
height: {
type: [String, Number] as PropType<string | number>,
default: null
},
// 图标宽度
width: {
type: [String, Number] as PropType<string | number>,
default: null
},
// 图标颜色
color: {
type: String,
default: ""
}
});
// 透传样式类型定义
type PassThrough = {
className?: string;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 缓存
const { cache } = useCache(() => [props.color]);
// 字号
const { getRpx, ptClassName } = useSize(() => pt.value.className ?? "");
// 图标类型定义
type Icon = {
font: string; // 字体名称
text: string; // 图标文本
};
// 图标信息
const icon = computed<Icon>(() => {
let font = "";
let text = "";
try {
let code = "";
// 遍历字体库查找对应图标
forInObject(icons, (value, key) => {
if (has(value, props.name)) {
font = key;
code = get(value, props.name) as string;
}
});
text = String.fromCharCode(parseInt(code, 16));
} catch (e) {
console.error(`图标 ${props.name} 不存在`, e);
}
return {
font,
text
};
});
// 图标颜色
const color = computed(() => {
if (props.color != "" && !isNull(props.color)) {
switch (props.color) {
case "primary":
return ctx.color["primary-500"] as string;
case "success":
return "#22c55e";
case "warn":
return "#eab308";
case "error":
return "#ef4444";
case "info":
return ctx.color["surface-500"] as string;
case "dark":
return ctx.color["surface-700"] as string;
case "light":
return ctx.color["surface-50"] as string;
case "disabled":
return ctx.color["surface-300"] as string;
default:
return props.color;
}
}
return isDark.value ? "white" : (ctx.color["surface-700"] as string);
});
// 图标样式
const iconStyle = computed(() => {
const style = {};
// 判断是不是有颜色样式
if (!hasTextColor(ptClassName.value)) {
style["color"] = color.value;
}
// 设置字体
if (icon.value.font != "") {
style["fontFamily"] = icon.value.font;
}
// 设置字体大小
style["fontSize"] = getRpx(props.size!);
// 设置高度
style["height"] = getRpx(props.height ?? props.size!);
style["lineHeight"] = getRpx(props.size!);
// 设置宽度
style["width"] = getRpx(props.width ?? props.size!);
return style;
});
</script>

View File

@@ -0,0 +1,13 @@
export type ClIconPassThrough = {
className?: string;
};
export type ClIconProps = {
className?: string;
pt?: ClIconPassThrough;
name?: string;
size?: string | number;
height?: string | number;
width?: string | number;
color?: string;
};

View File

@@ -0,0 +1,221 @@
<template>
<view
class="cl-image"
:class="[pt.className]"
:style="{
width: parseRpx(width!),
height: parseRpx(height!)
}"
>
<view
class="cl-image__error"
:class="[
{
'is-dark': isDark
},
pt.error?.className
]"
v-if="isError"
>
<slot name="error">
<cl-icon
:name="pt.error?.name ?? 'close-line'"
:size="pt.error?.size ?? 40"
:pt="{
className: parseClass(['!text-surface-400', pt.error?.className])
}"
></cl-icon>
</slot>
</view>
<view
class="cl-image__loading"
:class="[
{
'is-dark': isDark
},
pt.loading?.className
]"
v-else-if="isLoading && showLoading"
>
<slot name="loading">
<cl-loading :loading="true"></cl-loading>
</slot>
</view>
<image
class="cl-image__inner"
:class="[pt.inner?.className]"
:src="src"
:mode="mode"
:lazy-load="lazyLoad"
:webp="webp"
:show-menu-by-longpress="showMenuByLongpress"
@load="onLoad"
@error="onError"
@tap="onTap"
/>
<slot></slot>
</view>
</template>
<script setup lang="ts">
import { computed, ref, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import { isDark, isEmpty, parseClass, parsePt, parseRpx } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
defineOptions({
name: "cl-image"
});
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 图片源
src: {
type: String,
default: ""
},
// 图片裁剪、缩放的模式
mode: {
type: String as PropType<
| "scaleToFill"
| "aspectFit"
| "aspectFill"
| "widthFix"
| "heightFix"
| "top"
| "bottom"
| "center"
| "left"
| "right"
| "top left"
| "top right"
| "bottom left"
| "bottom right"
>,
default: "aspectFill"
},
// 是否显示边框
border: {
type: Boolean,
default: false
},
// 是否预览
preview: {
type: Boolean,
default: false
},
// 预览图片列表
previewList: {
type: Array as PropType<string[]>,
default: () => []
},
// 图片高度
height: {
type: [String, Number] as PropType<string | number>,
default: 120
},
// 图片宽度
width: {
type: [String, Number] as PropType<string | number>,
default: 120
},
// 是否显示加载状态
showLoading: {
type: Boolean,
default: true
},
// 是否懒加载
lazyLoad: {
type: Boolean,
default: false
},
// 图片显示动画效果
fadeShow: {
type: Boolean,
default: false
},
// 是否解码webp格式
webp: {
type: Boolean,
default: false
},
// 是否长按显示菜单
showMenuByLongpress: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits(["load", "error"]);
// 透传样式类型
type PassThrough = {
className?: string;
inner?: PassThroughProps;
error?: ClIconProps;
loading?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 加载状态
const isLoading = ref(true);
// 加载失败状态
const isError = ref(false);
// 图片加载成功
function onLoad(e: UniEvent) {
isLoading.value = false;
isError.value = false;
emit("load", e);
}
// 图片加载失败
function onError(e: UniEvent) {
isLoading.value = false;
isError.value = true;
emit("error", e);
}
// 图片点击
function onTap() {
if (props.preview) {
const urls = isEmpty(props.previewList) ? [props.src] : props.previewList;
uni.previewImage({
urls,
current: props.src
});
}
}
</script>
<style lang="scss" scoped>
.cl-image {
@apply relative flex flex-row items-center justify-center;
&__inner {
@apply w-full h-full rounded-xl;
}
&__loading,
&__error {
@apply absolute h-full w-full bg-surface-200 rounded-xl;
@apply flex flex-col items-center justify-center;
&.is-dark {
@apply bg-surface-700;
}
}
}
</style>

View File

@@ -0,0 +1,26 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClImagePassThrough = {
className?: string;
inner?: PassThroughProps;
error?: ClIconProps;
loading?: PassThroughProps;
};
export type ClImageProps = {
className?: string;
pt?: ClImagePassThrough;
src?: string;
mode?: "scaleToFill" | "aspectFit" | "aspectFill" | "widthFix" | "heightFix" | "top" | "bottom" | "center" | "left" | "right" | "top left" | "top right" | "bottom left" | "bottom right";
border?: boolean;
preview?: boolean;
previewList?: string[];
height?: string | number;
width?: string | number;
showLoading?: boolean;
lazyLoad?: boolean;
fadeShow?: boolean;
webp?: boolean;
showMenuByLongpress?: boolean;
};

View File

@@ -0,0 +1,317 @@
<template>
<view class="cl-index-bar" :class="[pt.className]">
<view
class="cl-index-bar__list"
@touchstart="onTouchStart"
@touchmove.stop.prevent="onTouchMove"
@touchend="onTouchEnd"
>
<view class="cl-index-bar__item" v-for="(item, index) in list" :key="index">
<view
class="cl-index-bar__item-inner"
:class="{
'is-active': activeIndex == index
}"
>
<text
class="cl-index-bar__item-text"
:class="{
'is-active': activeIndex == index || isDark
}"
>{{ item }}</text
>
</view>
</view>
</view>
</view>
<view class="cl-index-bar__alert" v-show="showAlert">
<view class="cl-index-bar__alert-icon dark:!bg-surface-800">
<view class="cl-index-bar__alert-arrow dark:!bg-surface-800"></view>
<text class="cl-index-bar__alert-text dark:!text-white">{{ alertText }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch, type PropType } from "vue";
import { isDark, isEmpty, parsePt } from "@/cool";
defineOptions({
name: "cl-index-bar"
});
const props = defineProps({
pt: {
type: Object,
default: () => ({})
},
modelValue: {
type: Number,
default: 0
},
list: {
type: Array as PropType<string[]>,
default: () => []
}
});
const emit = defineEmits(["update:modelValue", "change"]);
const { proxy } = getCurrentInstance()!;
type PassThrough = {
className?: string;
};
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 存储索引条整体的位置信息
const barRect = ref({
height: 0,
width: 0,
left: 0,
top: 0
} as NodeInfo);
// 存储所有索引项的位置信息数组
const itemsRect = ref<NodeInfo[]>([]);
// 是否正在触摸
const isTouching = ref(false);
// 是否显示提示弹窗
const showAlert = ref(false);
// 当前提示弹窗显示的文本
const alertText = ref("");
// 当前触摸过程中的临时索引
const activeIndex = ref(-1);
/**
* 获取索引条及其所有子项的位置信息
* 用于后续触摸时判断手指所在的索引项
*/
function getRect() {
nextTick(() => {
setTimeout(() => {
uni.createSelectorQuery()
.in(proxy)
.select(".cl-index-bar")
.boundingClientRect()
.exec((bar) => {
if (isEmpty(bar)) {
return;
}
// 获取索引条整体的位置信息
barRect.value = bar[0] as NodeInfo;
// 获取所有索引项的位置信息
uni.createSelectorQuery()
.in(proxy)
.selectAll(".cl-index-bar__item")
.boundingClientRect()
.exec((items) => {
if (isEmpty(items)) {
getRect();
return;
}
itemsRect.value = items[0] as NodeInfo[];
});
});
}, 350);
});
}
/**
* 根据触摸点的Y坐标计算出最接近的索引项下标
* @param clientY 触摸点的Y坐标相对于屏幕
* @returns 最接近的索引项下标
*/
function getIndex(clientY: number): number {
if (itemsRect.value.length == 0) {
// 没有索引项时默认返回0
return 0;
}
// 初始化最接近的索引和最小距离
let closestIndex = 0;
let minDistance = Number.MAX_VALUE;
// 遍历所有索引项,找到距离触摸点最近的项
for (let i = 0; i < itemsRect.value.length; i++) {
const item = itemsRect.value[i];
// 计算每个item的中心点Y坐标
const itemCenterY = (item.top ?? 0) + (item.height ?? 0) / 2;
// 计算触摸点到中心点的距离
const distance = Math.abs(clientY - itemCenterY);
// 更新最小距离和索引
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
}
}
// 边界处理,防止越界
if (closestIndex < 0) {
closestIndex = 0;
} else if (closestIndex >= props.list.length) {
closestIndex = props.list.length - 1;
}
return closestIndex;
}
/**
* 更新触摸过程中的显示状态
* @param index 新的索引
*/
function updateActive(index: number) {
// 更新当前触摸索引
activeIndex.value = index;
// 更新弹窗提示文本
alertText.value = props.list[index];
}
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
function onTouchStart(e: TouchEvent) {
// 标记为正在触摸
isTouching.value = true;
// 显示提示弹窗
showAlert.value = true;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸移动事件处理
* @param e 触摸事件对象
*/
function onTouchMove(e: TouchEvent) {
// 未处于触摸状态时不处理
if (!isTouching.value) return;
// 获取第一个触摸点
const touch = e.touches[0];
// 计算对应的索引
const index = getIndex(touch.clientY);
// 更新显示状态
updateActive(index);
}
/**
* 触摸结束事件处理
* 结束后延迟隐藏提示弹窗,并确认最终选中的索引
*/
function onTouchEnd() {
isTouching.value = false; // 标记为未触摸
// 更新值
if (props.modelValue != activeIndex.value) {
emit("update:modelValue", activeIndex.value);
emit("change", activeIndex.value);
}
// 延迟500ms后隐藏提示弹窗提升用户体验
setTimeout(() => {
showAlert.value = false;
}, 500);
}
watch(
computed(() => props.modelValue),
(val: number) => {
activeIndex.value = val;
},
{
immediate: true
}
);
onMounted(() => {
watch(
computed(() => props.list),
() => {
getRect();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-index-bar {
@apply flex flex-col items-center justify-center;
@apply absolute bottom-0 right-0 h-full;
z-index: 110;
&__item {
@apply flex flex-col items-center justify-center;
width: 50rpx;
height: 34rpx;
&-inner {
@apply rounded-full flex flex-row items-center justify-center;
width: 30rpx;
height: 30rpx;
&.is-active {
@apply bg-primary-500;
}
}
&-text {
@apply text-xs text-surface-500;
&.is-active {
@apply text-white;
}
}
}
}
.cl-index-bar__alert {
@apply absolute bottom-0 right-8 h-full flex flex-col items-center justify-center;
width: 120rpx;
z-index: 110;
&-icon {
@apply rounded-full flex flex-row items-center justify-center;
@apply bg-surface-300;
height: 80rpx;
width: 80rpx;
overflow: visible;
}
&-arrow {
@apply bg-surface-300 absolute;
right: -8rpx;
height: 40rpx;
width: 40rpx;
transform: rotate(45deg);
}
&-text {
@apply text-white text-2xl;
}
}
</style>

View File

@@ -0,0 +1,10 @@
export type ClIndexBarPassThrough = {
className?: string;
};
export type ClIndexBarProps = {
className?: string;
pt?: ClIndexBarPassThrough;
modelValue?: number;
list?: string[];
};

View File

@@ -0,0 +1,333 @@
<template>
<view
class="cl-input-number"
:class="[
{
'cl-input-number--disabled': isDisabled
},
pt.className
]"
>
<view
class="cl-input-number__minus"
:class="[
{
'is-disabled': !isMinus
},
pt.op?.className,
pt.op?.minus?.className
]"
hover-class="!bg-surface-200"
:hover-stay-time="250"
:style="{
height: parseRpx(size!),
width: parseRpx(size!)
}"
@touchstart="onMinus"
@touchend="longPress.stop"
@touchcancel="longPress.stop"
>
<cl-icon
name="subtract-line"
:size="pt.op?.icon?.size ?? 36"
:color="pt.op?.icon?.color ?? 'info'"
:pt="{
className: pt.op?.icon?.className
}"
></cl-icon>
</view>
<view class="cl-input-number__value">
<cl-input
:model-value="`${value}`"
:type="inputType"
:disabled="isDisabled"
:clearable="false"
:readonly="inputable == false"
:placeholder="placeholder"
:hold-keyboard="false"
:pt="{
className: `!h-full w-[120rpx] ${pt.value?.className}`,
inner: {
className: `text-center ${pt.value?.input?.className}`
}
}"
@blur="onBlur"
></cl-input>
</view>
<view
class="cl-input-number__plus"
:class="[
{
'is-disabled': !isPlus
},
pt.op?.className,
pt.op?.plus?.className
]"
hover-class="!bg-primary-600"
:hover-stay-time="250"
:style="{
height: parseRpx(size!),
width: parseRpx(size!)
}"
@touchstart="onPlus"
@touchend="longPress.stop"
@touchcancel="longPress.stop"
>
<cl-icon
name="add-line"
:size="pt.op?.icon?.size ?? 36"
:color="pt.op?.icon?.color ?? 'white'"
:pt="{
className: pt.op?.icon?.className
}"
></cl-icon>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref, watch, type PropType } from "vue";
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
import { useLongPress, parsePt, parseRpx } from "@/cool";
import { useForm } from "../../hooks";
defineOptions({
name: "cl-input-number"
});
// 定义组件属性
const props = defineProps({
modelValue: {
type: Number,
default: 0
},
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// 占位符 - 输入框为空时显示的提示文本
placeholder: {
type: String,
default: ""
},
// 步进值 - 点击加减按钮时改变的数值
step: {
type: Number,
default: 1
},
// 最大值 - 允许输入的最大数值
max: {
type: Number,
default: 100
},
// 最小值 - 允许输入的最小数值
min: {
type: Number,
default: 0
},
// 输入框类型 - digit表示带小数点的数字键盘,number表示纯数字键盘
inputType: {
type: String as PropType<"digit" | "number">,
default: "number"
},
// 是否可输入 - 控制是否允许手动输入数值
inputable: {
type: Boolean,
default: true
},
// 是否禁用 - 禁用后无法输入和点击加减按钮
disabled: {
type: Boolean,
default: false
},
// 组件大小 - 控制加减按钮的尺寸,支持数字或字符串形式
size: {
type: [Number, String] as PropType<number | string>,
default: 50
}
});
// 定义组件事件
const emit = defineEmits(["update:modelValue", "change"]);
// 长按操作
const longPress = useLongPress();
// cl-form 上下文
const { disabled } = useForm();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 数值样式
type ValuePassThrough = {
className?: string;
input?: PassThroughProps;
};
// 操作按钮样式
type OpPassThrough = {
className?: string;
minus?: PassThroughProps;
plus?: PassThroughProps;
icon?: ClIconProps;
};
// 定义透传样式类型
type PassThrough = {
className?: string;
value?: ValuePassThrough;
op?: OpPassThrough;
};
// 解析透传样式配置
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 绑定值
const value = ref(props.modelValue);
// 是否可以继续增加数值
const isPlus = computed(() => !isDisabled.value && value.value < props.max);
// 是否可以继续减少数值
const isMinus = computed(() => !isDisabled.value && value.value > props.min);
/**
* 更新数值并触发事件
* 确保数值在最大值和最小值范围内
*/
function update() {
nextTick(() => {
let val = value.value;
// 处理小于最小值的情况
if (val < props.min) {
val = props.min;
}
// 处理大于最大值的情况
if (val > props.max) {
val = props.max;
}
// 处理最小值大于最大值的异常情况
if (props.min > props.max) {
val = props.max;
}
// 小数点后两位
if (props.inputType == "digit") {
val = parseFloat(val.toFixed(2));
}
// 更新值,确保值是数字
value.value = val;
// 如果值发生变化,则触发事件
if (val != props.modelValue) {
emit("update:modelValue", val);
emit("change", val);
}
});
}
/**
* 点击加号按钮处理函数 (支持长按)
* 在非禁用状态下增加step值
*/
function onPlus() {
if (isDisabled.value || !isPlus.value) return;
longPress.start(() => {
if (isPlus.value) {
const val = props.max - value.value;
value.value += val > props.step ? props.step : val;
update();
}
});
}
/**
* 点击减号按钮处理函数 (支持长按)
* 在非禁用状态下减少step值
*/
function onMinus() {
if (isDisabled.value || !isMinus.value) return;
longPress.start(() => {
if (isMinus.value) {
const val = value.value - props.min;
value.value -= val > props.step ? props.step : val;
update();
}
});
}
/**
* 输入框失去焦点处理函数
* @param val 输入的字符串值
*/
function onBlur(e: UniInputBlurEvent) {
if (e.detail.value == "") {
value.value = 0;
} else {
value.value = parseFloat(e.detail.value);
}
update();
}
// 监听绑定值变化
watch(
computed(() => props.modelValue),
(val: number) => {
value.value = val;
update();
},
{
immediate: true
}
);
// 监听最大值变化,确保当前值不超过新的最大值
watch(
computed(() => props.max),
update
);
// 监听最小值变化,确保当前值不小于新的最小值
watch(
computed(() => props.min),
update
);
</script>
<style lang="scss" scoped>
.cl-input-number {
@apply flex flex-row items-center;
&__plus,
&__minus {
@apply flex items-center justify-center rounded-md bg-surface-100;
&.is-disabled {
@apply opacity-50;
}
}
&__plus {
@apply bg-primary-500;
}
&__value {
@apply flex flex-row items-center justify-center h-full;
margin: 0 12rpx;
}
}
</style>

View File

@@ -0,0 +1,34 @@
import type { PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClInputNumberValuePassThrough = {
className?: string;
input?: PassThroughProps;
};
export type ClInputNumberOpPassThrough = {
className?: string;
minus?: PassThroughProps;
plus?: PassThroughProps;
icon?: ClIconProps;
};
export type ClInputNumberPassThrough = {
className?: string;
value?: ClInputNumberValuePassThrough;
op?: ClInputNumberOpPassThrough;
};
export type ClInputNumberProps = {
className?: string;
modelValue?: number;
pt?: ClInputNumberPassThrough;
placeholder?: string;
step?: number;
max?: number;
min?: number;
inputType?: "digit" | "number";
inputable?: boolean;
disabled?: boolean;
size?: number | string;
};

View File

@@ -0,0 +1,280 @@
<template>
<view
class="cl-input-otp"
:class="[
{
'cl-input-otp--disabled': disabled
},
pt.className
]"
>
<view class="cl-input-otp__inner" @tap="onCursor()">
<cl-input
v-model="value"
ref="inputRef"
:type="inputType"
:pt="{
className: '!h-full'
}"
:disabled="disabled"
:autofocus="autofocus"
:maxlength="length"
:hold-keyboard="false"
:clearable="false"
@change="onChange"
></cl-input>
</view>
<view class="cl-input-otp__list" :class="[pt.list?.className]">
<view
v-for="(item, index) in list"
:key="index"
class="cl-input-otp__item"
:class="[
{
'is-disabled': disabled,
'is-dark': isDark,
'is-active': value.length >= index && isFocus
},
pt.item?.className
]"
>
<cl-text
:color="value.length >= index && isFocus ? 'primary' : ''"
:pt="{
className: pt.value?.className
}"
>{{ item }}</cl-text
>
<view
ref="cursorRef"
class="cl-input-otp__cursor"
:class="[pt.cursor?.className]"
v-if="value.length == index && isFocus && item == ''"
></view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch, type PropType, type Ref } from "vue";
import type { ClInputType, PassThroughProps } from "../../types";
import { createAnimation, isDark, isEmpty, last, parsePt } from "@/cool";
defineOptions({
name: "cl-input-otp"
});
// 定义组件属性
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 绑定值
modelValue: {
type: String,
default: ""
},
// 是否自动聚焦
autofocus: {
type: Boolean,
default: false
},
// 验证码位数
length: {
type: Number,
default: 4
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 输入框类型
inputType: {
type: String as PropType<ClInputType>,
default: "number"
}
});
/**
* 事件定义
* update:modelValue - 更新绑定值
* done - 输入完成
*/
const emit = defineEmits(["update:modelValue", "done"]);
/**
* 透传样式类型定义
*/
type PassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
cursor?: PassThroughProps;
value?: PassThroughProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 输入框引用
const inputRef = ref<ClInputComponentPublicInstance | null>(null);
// 光标引用
const cursorRef = ref<UniElement[]>([]);
// 输入值
const value = ref(props.modelValue);
/**
* 是否聚焦状态
*/
const isFocus = computed<boolean>(() => {
if (props.disabled) {
return false;
}
if (inputRef.value == null) {
return false;
}
return (inputRef.value as ClInputComponentPublicInstance).isFocus;
});
/**
* 验证码数组
* 根据长度生成空数组,每个位置填充对应的输入值
*/
const list = computed<string[]>(() => {
const arr = [] as string[];
for (let i = 0; i < props.length; i++) {
arr.push(value.value.charAt(i));
}
return arr;
});
/**
* 光标动画
*/
async function onCursor() {
await nextTick();
if (isEmpty(cursorRef.value)) {
return;
}
// 开始动画
// #ifdef APP
createAnimation(last(cursorRef.value), {
duration: 600,
loop: -1,
alternate: true
})
.opacity("0", "1")
.play();
// #endif
}
/**
* 输入事件处理
* @param val 输入值
*/
function onChange(val: string) {
// 更新绑定值
emit("update:modelValue", val);
// 输入完成时触发done事件
if (val.length == props.length) {
uni.hideKeyboard();
emit("done", val);
}
// 更新光标动画
onCursor();
}
onMounted(() => {
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
onCursor();
},
{
immediate: true
}
);
});
</script>
<style lang="scss" scoped>
.cl-input-otp {
@apply relative;
&__inner {
@apply absolute top-0 h-full z-10;
opacity: 0;
// 小程序隐藏 placeholder
left: -100%;
width: 200%;
}
&__list {
@apply flex flex-row relative;
margin: 0 -10rpx;
}
&__item {
@apply flex flex-row items-center justify-center duration-100;
@apply border border-solid border-surface-200 rounded-lg bg-white;
height: 80rpx;
width: 80rpx;
margin: 0 10rpx;
&.is-disabled {
@apply bg-surface-100 opacity-70;
}
&.is-dark {
@apply bg-surface-800 border-surface-600;
&.is-disabled {
@apply bg-surface-700;
}
}
&.is-active {
@apply border-primary-500;
}
}
&__cursor {
@apply absolute bg-primary-500;
width: 2rpx;
height: 24rpx;
// #ifndef APP
animation: blink 1s infinite;
@keyframes blink {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
// #endif
}
&--disabled {
@apply opacity-50;
}
}
</style>

View File

@@ -0,0 +1,19 @@
import type { ClInputType, PassThroughProps } from "../../types";
export type ClInputOtpPassThrough = {
className?: string;
list?: PassThroughProps;
item?: PassThroughProps;
cursor?: PassThroughProps;
value?: PassThroughProps;
};
export type ClInputOtpProps = {
className?: string;
pt?: ClInputOtpPassThrough;
modelValue?: string;
autofocus?: boolean;
length?: number;
disabled?: boolean;
inputType?: ClInputType;
};

View File

@@ -0,0 +1,411 @@
<template>
<view
class="cl-input"
:class="[
pt.className,
{
'is-dark': isDark,
'cl-input--border': border,
'cl-input--focus': isFocus,
'cl-input--disabled': isDisabled,
'cl-input--error': isError
}
]"
@tap="onTap"
>
<slot name="prepend"></slot>
<view class="cl-input__icon !pl-0 pr-[12rpx]" v-if="prefixIcon">
<cl-icon
:name="prefixIcon"
:size="pt.prefixIcon?.size ?? 32"
:pt="{ className: parseClass([pt.prefixIcon?.className]) }"
></cl-icon>
</view>
<input
class="cl-input__inner"
:class="[
{
'is-disabled': isDisabled,
'is-dark': isDark
},
ptClassName
]"
:style="inputStyle"
:value="value"
:disabled="readonly ?? isDisabled"
:type="type"
:password="isPassword"
:focus="isFocus"
:placeholder="placeholder"
:placeholder-class="`text-surface-400 ${placeholderClass}`"
:maxlength="maxlength"
:cursor-spacing="cursorSpacing"
:confirm-type="confirmType"
:confirm-hold="confirmHold"
:adjust-position="adjustPosition"
:hold-keyboard="holdKeyboard"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@confirm="onConfirm"
@keyboardheightchange="onKeyboardheightchange"
/>
<view class="cl-input__icon" v-if="suffixIcon">
<cl-icon
:name="suffixIcon"
:size="pt.suffixIcon?.size ?? 32"
:pt="{ className: parseClass([pt.prefixIcon?.className]) }"
></cl-icon>
</view>
<view class="cl-input__icon" @tap="clear" v-if="showClear">
<cl-icon
name="close-circle-fill"
:size="32"
:pt="{ className: '!text-surface-400' }"
></cl-icon>
</view>
<view class="cl-input__icon" @tap="showPassword" v-if="password">
<cl-icon
:name="isPassword ? 'eye-line' : 'eye-off-line'"
:size="32"
:pt="{ className: '!text-surface-300' }"
></cl-icon>
</view>
<slot name="append"></slot>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch, type PropType } from "vue";
import type { ClInputType, PassThroughProps } from "../../types";
import { isDark, parseClass, parsePt } from "@/cool";
import type { ClIconProps } from "../cl-icon/props";
import { t } from "@/locale";
import { useForm, useFormItem, useSize } from "../../hooks";
defineOptions({
name: "cl-input"
});
// 组件属性定义
const props = defineProps({
// 透传样式
pt: {
type: Object,
default: () => ({})
},
// 绑定值
modelValue: {
type: String,
default: ""
},
// 输入框类型
type: {
type: String as PropType<ClInputType>,
default: "text"
},
// 前缀图标
prefixIcon: {
type: String,
default: ""
},
// 后缀图标
suffixIcon: {
type: String,
default: ""
},
// 是否密码框
password: {
type: Boolean,
default: false
},
// 是否自动聚焦
autofocus: {
type: Boolean,
default: false
},
// 是否禁用
disabled: {
type: Boolean,
default: false
},
// 是否只读
readonly: {
type: Boolean,
default: null
},
// 占位符
placeholder: {
type: String,
default: () => t("请输入")
},
// 占位符样式类
placeholderClass: {
type: String,
default: ""
},
// 是否显示边框
border: {
type: Boolean,
default: true
},
// 是否可清除
clearable: {
type: Boolean,
default: false
},
// 光标与键盘的距离
cursorSpacing: {
type: Number,
default: 5
},
// 点击键盘确认按钮时是否保持键盘不收起
confirmHold: {
type: Boolean,
default: false
},
// 设置键盘右下角按钮的文字
confirmType: {
type: String as PropType<"done" | "go" | "next" | "search" | "send">,
default: "done"
},
// 键盘弹起时,是否自动上推页面
adjustPosition: {
type: Boolean,
default: true
},
// 最大输入长度
maxlength: {
type: Number,
default: 140
},
// 是否保持键盘不收起
holdKeyboard: {
type: Boolean,
default: false
}
});
// 事件定义
const emit = defineEmits([
"update:modelValue",
"input",
"change",
"focus",
"blur",
"confirm",
"clear",
"keyboardheightchange"
]);
// cl-form 上下文
const { disabled } = useForm();
// cl-form-item 上下文
const { isError } = useFormItem();
// 是否禁用
const isDisabled = computed(() => {
return disabled.value || props.disabled;
});
// 透传样式类型定义
type PassThrough = {
className?: string;
inner?: PassThroughProps;
prefixIcon?: ClIconProps;
suffixIcon?: ClIconProps;
};
// 解析透传样式
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 字号
const { ptClassName, getSize } = useSize(() => pt.value.inner?.className ?? "");
// 输入框样式
const inputStyle = computed(() => {
const style = {};
// 字号
const fontSize = getSize(null);
if (fontSize != null) {
style["fontSize"] = fontSize;
}
return style;
});
// 绑定值
const value = ref<string>("");
// 是否聚焦
const isFocus = ref<boolean>(props.autofocus);
// 是否显示清除按钮
const showClear = computed(() => {
return isFocus.value && props.clearable && value.value != "";
});
// 是否显示密码
const isPassword = ref(props.password);
// 切换密码显示状态
function showPassword() {
isPassword.value = !isPassword.value;
}
// 获取焦点事件
function onFocus(e: UniInputFocusEvent) {
isFocus.value = true;
emit("focus", e);
}
// 失去焦点事件
function onBlur(e: UniInputBlurEvent) {
emit("blur", e);
setTimeout(() => {
isFocus.value = false;
}, 0);
}
// 输入事件
function onInput(e: UniInputEvent) {
const v1 = e.detail.value;
const v2 = value.value;
value.value = v1;
emit("update:modelValue", v1);
emit("input", e);
if (v1 != v2) {
emit("change", v1);
}
}
// 点击确认按钮事件
function onConfirm(e: UniInputConfirmEvent) {
emit("confirm", e);
}
// 键盘高度变化事件
function onKeyboardheightchange(e: UniInputKeyboardHeightChangeEvent) {
emit("keyboardheightchange", e);
}
// 点击事件
function onTap() {
if (isDisabled.value) {
return;
}
isFocus.value = true;
}
// 聚焦方法
function focus() {
setTimeout(() => {
isFocus.value = false;
nextTick(() => {
isFocus.value = true;
});
}, 0);
}
// 清除方法
function clear() {
value.value = "";
emit("update:modelValue", "");
emit("change", "");
emit("clear");
// #ifdef H5
focus();
// #endif
}
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
},
{
immediate: true
}
);
defineExpose({
isFocus,
focus,
clear
});
</script>
<style lang="scss" scoped>
.cl-input {
@apply flex flex-row items-center bg-white duration-200;
@apply rounded-lg;
height: 66rpx;
padding: 0 20rpx;
transition-property: background-color, border-color;
&__inner {
@apply h-full text-surface-700;
flex: 1;
font-size: 28rpx;
&.is-dark {
@apply text-white;
}
}
&__icon {
@apply flex items-center justify-center h-full;
padding-left: 20rpx;
}
&--border {
@apply border border-solid border-surface-200;
}
&--disabled {
@apply bg-surface-100 opacity-70;
}
&--focus {
&.cl-input--border {
@apply border-primary-500;
}
}
&--error {
@apply border-red-500;
}
&.is-dark {
@apply bg-surface-800;
&.cl-input--border {
@apply border-surface-600;
&.cl-input--focus {
@apply border-primary-500;
}
}
&.cl-input--disabled {
@apply bg-surface-700;
}
}
}
</style>

View File

@@ -0,0 +1,32 @@
import type { ClInputType, PassThroughProps } from "../../types";
import type { ClIconProps } from "../cl-icon/props";
export type ClInputPassThrough = {
className?: string;
inner?: PassThroughProps;
prefixIcon?: ClIconProps;
suffixIcon?: ClIconProps;
};
export type ClInputProps = {
className?: string;
pt?: ClInputPassThrough;
modelValue?: string;
type?: ClInputType;
prefixIcon?: string;
suffixIcon?: string;
password?: boolean;
autofocus?: boolean;
disabled?: boolean;
readonly?: boolean;
placeholder?: string;
placeholderClass?: string;
border?: boolean;
clearable?: boolean;
cursorSpacing?: number;
confirmHold?: boolean;
confirmType?: "done" | "go" | "next" | "search" | "send";
adjustPosition?: boolean;
maxlength?: number;
holdKeyboard?: boolean;
};

View File

@@ -0,0 +1,354 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-car" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-car__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ valueText }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-car__list">
<view
class="cl-keyboard-car__rows"
v-for="(row, rowIndex) in list"
:key="rowIndex"
:class="[`is-mode-${mode}`]"
>
<view
v-for="(item, index) in row"
:key="item"
class="cl-keyboard-car__item"
:class="[
`is-keycode-${item}`,
{
'is-dark': isDark,
'is-empty': item == '',
'is-fill': rowIndex == 0 && mode == 'plate'
},
pt.item?.className
]"
:style="{
marginRight: index == row.length - 1 ? '0' : '10rpx'
}"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-icon
v-else-if="item == 'confirm'"
name="check-line"
:size="36"
color="white"
></cl-icon>
<cl-text v-else>{{ item }}</cl-text>
</slot>
</view>
</view>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch } from "vue";
import { $t, t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-car"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 弹窗标题
title: {
type: String,
default: () => t("车牌键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最大输入长度
maxlength: {
type: Number,
default: 8
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 键盘模式province: 省份简称区plate: 字母数字及特殊类型区
const mode = ref<"province" | "plate">("province");
// 输入框显示值
const valueText = computed(() => {
if (value.value.length > 2) {
return value.value.substring(0, 2) + " · " + value.value.substring(2);
}
return value.value;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
// 车牌键盘的省份简称区
const province: string[][] = [
["京", "沪", "粤", "津", "冀", "豫", "云", "辽", "黑", "湘"],
["皖", "鲁", "新", "苏", "浙", "赣", "鄂", "桂", "甘"],
["晋", "蒙", "陕", "吉", "闽", "贵", "渝", "川"],
["青", "藏", "琼", "宁"]
];
// 车牌键盘的字母数字及特殊类型区
const plate: string[][] = [
["学", "警", "港", "澳", "领", "使", "电", "挂"],
["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", "M"],
["Z", "X", "C", "V", "B", "N", "M", "delete", "confirm"]
];
// 默认返回省份区,后续可根据输入位数切换键盘区
return mode.value == "province" ? province : plate;
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
vibrate(1);
// 确认按钮逻辑
if (key == "confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 校验车牌号
if (value.value.length < 7) {
ui.showToast({
message: t("车牌号格式不正确")
});
return;
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= props.maxlength) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: props.maxlength
})
});
return;
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
// 根据输入位数切换键盘模式
mode.value = val.length < 1 ? "province" : "plate";
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-car {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative;
}
&__rows {
@apply flex flex-row items-center;
&.is-mode-province {
@apply justify-center;
}
&.is-mode-plate {
@apply justify-between;
}
}
&__item {
@apply flex items-center justify-center rounded-lg bg-white;
height: 80rpx;
width: 62rpx;
margin-top: 10rpx;
&.is-dark {
@apply bg-surface-800;
}
&.is-fill {
flex: 1;
}
&.is-keycode-delete {
@apply bg-surface-200;
flex: 1;
&.is-dark {
@apply bg-surface-600;
}
}
&.is-keycode-confirm {
@apply bg-primary-500;
flex: 1;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardCarPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardCarProps = {
className?: string;
pt?: ClKeyboardCarPassThrough;
modelValue?: string;
title?: string;
placeholder?: string;
maxlength?: number;
showValue?: boolean;
inputImmediate?: boolean;
};

View File

@@ -0,0 +1,431 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-number" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-number__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ value }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-number__list">
<cl-row :gutter="10">
<cl-col :span="18">
<cl-row :gutter="10">
<cl-col :span="8" v-for="item in list" :key="item">
<view
class="cl-keyboard-number__item"
:class="[
`is-keycode-${item}`,
{
'is-dark': isDark,
'is-empty': item == ''
},
pt.item?.className
]"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<view
v-else-if="item == 'confirm'"
class="cl-keyboard-number__item-confirm"
>
<cl-text
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
</view>
<view v-else-if="item == '_confirm'"></view>
<cl-text
v-else
:pt="{
className: 'text-lg'
}"
>{{ item }}</cl-text
>
</slot>
</view>
</cl-col>
</cl-row>
</cl-col>
<cl-col :span="6">
<view class="cl-keyboard-number__op">
<view
v-for="item in opList"
:key="item"
class="cl-keyboard-number__item"
:class="[
{
'is-dark': isDark
},
`is-keycode-${item}`
]"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-text
v-if="item == 'confirm'"
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
</view>
</view>
</cl-col>
</cl-row>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch, type PropType } from "vue";
import { $t, t } from "@/locale";
import { isAppIOS, isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-number"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 键盘类型支持number、digit、idcard
type: {
type: String as PropType<"number" | "digit" | "idcard">,
default: "digit"
},
// 弹窗标题
title: {
type: String,
default: () => t("数字键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最大输入长度
maxlength: {
type: Number,
default: 10
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 最大输入长度
const maxlength = computed(() => {
if (props.type == "idcard") {
return 18;
}
return props.maxlength;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
const arr = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "00", "0", ""];
// 数字键盘显示为小数点 "."
if (props.type == "digit") {
arr[11] = ".";
}
// 身份证键盘显示为 "X"
if (props.type == "idcard") {
arr[11] = "X";
}
return arr;
});
// 操作按钮列表
const opList = computed(() => {
return ["delete", "confirm"];
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
try {
vibrate(1);
} catch (error) {}
// 确认按钮逻辑
if (key == "confirm" || key == "_confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 如果最后一位是小数点,去掉
if (value.value.endsWith(".")) {
value.value = value.value.slice(0, -1);
}
// 身份证号码正则校验支持15位和18位18位末尾可为X/x
if (props.type == "idcard") {
if (
!/^(^[1-9]\d{5}(18|19|20)?\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}(\d|X|x)?$)$/.test(
value.value
)
) {
ui.showToast({
message: t("身份证号码格式不正确")
});
return;
}
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= maxlength.value) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: maxlength.value
})
});
return;
}
// 处理小数点输入,已存在则不再添加
if (key == ".") {
if (value.value.includes(".")) {
return;
}
if (value.value == "") {
value.value = "0.";
return;
}
}
// 处理00键首位不能输入00只能输入0
if (key == "00") {
if (value.value.length + 2 > maxlength.value) {
value.value += "0";
return;
}
if (value.value == "") {
value.value = "0";
return;
}
}
if (key == "00" || key == "0") {
if (value.value == "" || value.value == "0") {
value.value = "0";
return;
}
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-number {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative overflow-visible;
}
&__op {
@apply flex flex-col h-full;
}
&__item {
@apply flex items-center justify-center rounded-xl bg-white overflow-visible;
height: 100rpx;
margin-top: 10rpx;
&.is-dark {
@apply bg-surface-800;
}
&.is-keycode-delete {
@apply bg-surface-200;
&.is-dark {
@apply bg-surface-800;
}
}
&.is-keycode-confirm {
@apply flex flex-col items-center justify-center;
@apply bg-primary-500 rounded-xl flex-1;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,22 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardNumberPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardNumberProps = {
className?: string;
pt?: ClKeyboardNumberPassThrough;
modelValue?: string;
type?: "number" | "digit" | "idcard";
title?: string;
placeholder?: string;
maxlength?: number;
confirmText?: string;
showValue?: boolean;
inputImmediate?: boolean;
};

View File

@@ -0,0 +1,450 @@
<template>
<cl-popup
:title="title"
:swipe-close-threshold="100"
:pt="{
inner: {
className: parseClass([
[isDark, '!bg-surface-700', '!bg-surface-100'],
pt.popup?.className
])
},
mask: {
className: '!bg-transparent'
}
}"
v-model="visible"
>
<view class="cl-keyboard-password" :class="[pt.className]">
<slot name="value" :value="value">
<view
v-if="showValue"
class="cl-keyboard-password__value"
:class="[pt.value?.className]"
>
<cl-text
v-if="value != ''"
:pt="{
className: 'text-2xl'
}"
>{{ valueText }}</cl-text
>
<cl-text
v-else
:pt="{
className: 'text-md text-surface-400'
}"
>{{ placeholder }}</cl-text
>
</view>
</slot>
<view class="cl-keyboard-password__list">
<view
class="cl-keyboard-password__rows"
v-for="(row, rowIndex) in list"
:key="rowIndex"
:class="[`is-mode-${mode}`]"
>
<view
v-for="(item, index) in row"
:key="item"
class="cl-keyboard-password__item"
:class="[
`is-keycode-${item}`,
{
'is-empty': item == '',
'is-dark': isDark
},
pt.item?.className
]"
:style="{
marginRight: index == row.length - 1 ? '0' : '10rpx'
}"
hover-class="opacity-50"
:hover-stay-time="250"
@touchstart.stop="onCommand(item)"
>
<slot name="item" :item="item">
<cl-icon
v-if="item == 'delete'"
name="delete-back-2-line"
:size="36"
></cl-icon>
<cl-text
v-else-if="item == 'confirm'"
color="white"
:pt="{
className: 'text-lg'
}"
>{{ confirmText }}</cl-text
>
<cl-text
v-else-if="item == 'letter'"
:pt="{
className: 'text-lg'
}"
>ABC</cl-text
>
<cl-text
v-else-if="item == 'number'"
:pt="{
className: 'text-lg'
}"
>123</cl-text
>
<template v-else-if="item == 'caps'">
<cl-icon name="upload-line" :size="36"></cl-icon>
<cl-badge
dot
position
type="info"
:pt="{
className: '!right-1 !top-1'
}"
v-if="mode == 'letterUpper'"
></cl-badge>
</template>
<cl-text
v-else
:pt="{
className: 'text-lg'
}"
>{{ item }}</cl-text
>
</slot>
</view>
</view>
</view>
</view>
</cl-popup>
</template>
<script setup lang="ts">
import { useUi } from "../../hooks";
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
import { ref, computed, watch } from "vue";
import { $t, t } from "@/locale";
import { isDark, parseClass, parsePt } from "@/cool";
import { vibrate } from "@/uni_modules/cool-vibrate";
defineOptions({
name: "cl-keyboard-password"
});
defineSlots<{
value(props: { value: string }): any;
item(props: { item: string }): any;
}>();
const props = defineProps({
// 透传样式配置
pt: {
type: Object,
default: () => ({})
},
// v-model绑定的值
modelValue: {
type: String,
default: ""
},
// 弹窗标题
title: {
type: String,
default: () => t("密码键盘")
},
// 输入框占位符
placeholder: {
type: String,
default: () => t("安全键盘,请放心输入")
},
// 最小输入长度
minlength: {
type: Number,
default: 6
},
// 最大输入长度
maxlength: {
type: Number,
default: 20
},
// 确认按钮文本
confirmText: {
type: String,
default: () => t("确定")
},
// 是否显示输入值
showValue: {
type: Boolean,
default: true
},
// 是否输入即绑定
inputImmediate: {
type: Boolean,
default: false
},
// 是否加密
encrypt: {
type: Boolean,
default: false
}
});
// 定义事件发射器支持v-model和change事件
const emit = defineEmits(["update:modelValue", "change"]);
// 样式穿透类型
type PassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
// 样式穿透计算
const pt = computed(() => parsePt<PassThrough>(props.pt));
// 获取UI相关的工具方法
const ui = useUi();
// 控制弹窗显示/隐藏
const visible = ref(false);
// 输入框当前值,双向绑定
const value = ref(props.modelValue);
// 键盘模式letter: 字母区number: 数字区
const mode = ref<"letter" | "letterUpper" | "number">("letter");
// 输入框显示值
const valueText = computed(() => {
if (props.encrypt) {
return "*".repeat(value.value.length);
}
return value.value;
});
// 数字键盘的按键列表包含数字、删除、00和小数点
const list = computed(() => {
// 字母键盘的字母区
const letter: string[][] = [
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
["a", "s", "d", "f", "g", "h", "j", "k", "l", "m"],
["caps", "z", "x", "c", "v", "b", "n", "m", "delete"],
["number", "space", "confirm"]
];
// 大写字母键盘的字母区
const letterUpper: string[][] = [
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
["A", "S", "D", "F", "G", "H", "J", "K", "L", "M"],
["caps", "Z", "X", "C", "V", "B", "N", "M", "delete"],
["number", "space", "confirm"]
];
// 数字键盘的数字区
const number: string[][] = [
["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
["#", "/", ":", ";", "(", ")", "^", "*", "+"],
["-", "=", "|", "~", "$", "&", ".", ",", "delete"],
["letter", "%", "?", "!", "{", "}", "confirm"]
];
switch (mode.value) {
case "letter":
return letter;
case "letterUpper":
return letterUpper;
case "number":
return number;
default:
return letter;
}
});
// 打开键盘弹窗
function open() {
visible.value = true;
}
// 关闭键盘弹窗
function close() {
visible.value = false;
}
// 处理键盘按键点击事件
function onCommand(key: string) {
// 震动
vibrate(1);
// 大写字母键盘
if (key == "caps") {
mode.value = mode.value == "letter" ? "letterUpper" : "letter";
return;
}
// 字母键盘
if (key == "letter") {
mode.value = "letter";
return;
}
// 数字键盘
if (key == "number") {
mode.value = "number";
return;
}
// 确认按钮逻辑
if (key == "confirm") {
if (value.value == "") {
ui.showToast({
message: t("请输入内容")
});
return;
}
// 校验密码长度
if (value.value.length < props.minlength || value.value.length > props.maxlength) {
ui.showToast({
message: $t("请输入{minlength}到{maxlength}位密码", {
minlength: props.minlength,
maxlength: props.maxlength
})
});
return;
}
// 触发v-model和change事件
emit("update:modelValue", value.value);
emit("change", value.value);
// 关闭弹窗
close();
return;
}
// 删除键,去掉最后一位
if (key == "delete") {
value.value = value.value.slice(0, -1);
return;
}
// 超过最大输入长度,提示并返回
if (value.value.length >= props.maxlength) {
ui.showToast({
message: $t("最多输入{maxlength}位", {
maxlength: props.maxlength
})
});
return;
}
// 其他按键直接拼接到value
value.value += key;
}
watch(value, (val: string) => {
// 如果输入即绑定,则立即更新绑定值
if (props.inputImmediate) {
emit("update:modelValue", val);
emit("change", val);
}
});
// 监听外部v-model的变化保持内部value同步
watch(
computed(() => props.modelValue),
(val: string) => {
value.value = val;
}
);
defineExpose({
open,
close
});
</script>
<style lang="scss" scoped>
.cl-keyboard-password {
padding: 0 20rpx 20rpx 20rpx;
&__value {
@apply flex flex-row items-center justify-center;
height: 80rpx;
margin-bottom: 20rpx;
}
&__list {
@apply relative;
}
&__rows {
@apply flex flex-row items-center;
&.is-mode-province {
@apply justify-center;
}
&.is-mode-plate {
@apply justify-between;
}
}
&__item {
@apply flex items-center justify-center rounded-lg relative bg-white;
height: 80rpx;
width: 62rpx;
margin-top: 10rpx;
flex: 1;
&.is-dark {
@apply bg-surface-800;
}
&.is-keycode-number,
&.is-keycode-letter {
width: 150rpx;
flex: none;
}
&.is-keycode-caps,
&.is-keycode-delete {
width: 80rpx;
flex: none;
}
&.is-keycode-letter,
&.is-keycode-number,
&.is-keycode-caps,
&.is-keycode-delete {
@apply bg-surface-200;
&.is-dark {
@apply bg-surface-600;
}
}
&.is-keycode-confirm {
@apply bg-primary-500;
width: 150rpx !important;
flex: none;
}
&.is-empty {
background-color: transparent !important;
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
import type { PassThroughProps } from "../../types";
import type { ClPopupProps } from "../cl-popup/props";
export type ClKeyboardPasswordPassThrough = {
className?: string;
item?: PassThroughProps;
value?: PassThroughProps;
popup?: ClPopupProps;
};
export type ClKeyboardPasswordProps = {
className?: string;
pt?: ClKeyboardPasswordPassThrough;
modelValue?: string;
title?: string;
placeholder?: string;
minlength?: number;
maxlength?: number;
confirmText?: string;
showValue?: boolean;
inputImmediate?: boolean;
encrypt?: boolean;
};

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