小程序初始提交
This commit is contained in:
@@ -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>
|
||||
813
cool-unix/uni_modules/cool-canvas/hooks/index.ts
Normal file
813
cool-unix/uni_modules/cool-canvas/hooks/index.ts
Normal 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);
|
||||
};
|
||||
1
cool-unix/uni_modules/cool-canvas/index.ts
Normal file
1
cool-unix/uni_modules/cool-canvas/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./hooks";
|
||||
5
cool-unix/uni_modules/cool-canvas/types/component.d.ts
vendored
Normal file
5
cool-unix/uni_modules/cool-canvas/types/component.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare type ClCanvasComponentPublicInstance = {
|
||||
saveImage: () => void;
|
||||
previewImage: () => void;
|
||||
createImage: () => Promise<string>;
|
||||
};
|
||||
95
cool-unix/uni_modules/cool-canvas/types/index.ts
Normal file
95
cool-unix/uni_modules/cool-canvas/types/index.ts
Normal 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;
|
||||
};
|
||||
82
cool-unix/uni_modules/cool-open-web/package.json
Normal file
82
cool-unix/uni_modules/cool-open-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
cool-unix/uni_modules/cool-open-web/readme.md
Normal file
17
cool-unix/uni_modules/cool-open-web/readme.md
Normal 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/");
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"minSdkVersion": "21"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deploymentTarget": "12"
|
||||
}
|
||||
57
cool-unix/uni_modules/cool-open-web/utssdk/app-ios/index.uts
Normal file
57
cool-unix/uni_modules/cool-open-web/utssdk/app-ios/index.uts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
37
cool-unix/uni_modules/cool-open-web/utssdk/web/index.uts
Normal file
37
cool-unix/uni_modules/cool-open-web/utssdk/web/index.uts
Normal 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;
|
||||
}
|
||||
}
|
||||
10
cool-unix/uni_modules/cool-share/index.d.ts
vendored
Normal file
10
cool-unix/uni_modules/cool-share/index.d.ts
vendored
Normal 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;
|
||||
}
|
||||
84
cool-unix/uni_modules/cool-share/package.json
Normal file
84
cool-unix/uni_modules/cool-share/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"minSdkVersion": "21"
|
||||
}
|
||||
459
cool-unix/uni_modules/cool-share/utssdk/app-android/index.uts
Normal file
459
cool-unix/uni_modules/cool-share/utssdk/app-android/index.uts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 ?? (() => {})
|
||||
);
|
||||
}
|
||||
265
cool-unix/uni_modules/cool-share/utssdk/app-harmony/share.ets
Normal file
265
cool-unix/uni_modules/cool-share/utssdk/app-harmony/share.ets
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"deploymentTarget": "12"
|
||||
}
|
||||
287
cool-unix/uni_modules/cool-share/utssdk/app-ios/index.uts
Normal file
287
cool-unix/uni_modules/cool-share/utssdk/app-ios/index.uts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
29
cool-unix/uni_modules/cool-share/utssdk/interface.uts
Normal file
29
cool-unix/uni_modules/cool-share/utssdk/interface.uts
Normal 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;
|
||||
};
|
||||
233
cool-unix/uni_modules/cool-svg/components/cl-svg/cl-svg.uvue
Normal file
233
cool-unix/uni_modules/cool-svg/components/cl-svg/cl-svg.uvue
Normal 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>
|
||||
7
cool-unix/uni_modules/cool-svg/index.d.ts
vendored
Normal file
7
cool-unix/uni_modules/cool-svg/index.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
export {};
|
||||
|
||||
declare module "vue" {
|
||||
export interface GlobalComponents {
|
||||
"cl-svg": (typeof import("./components/cl-svg/cl-svg.uvue"))["default"];
|
||||
}
|
||||
}
|
||||
82
cool-unix/uni_modules/cool-svg/package.json
Normal file
82
cool-unix/uni_modules/cool-svg/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"minSdkVersion": "21",
|
||||
"dependencies": ["com.caverock:androidsvg:1.4"]
|
||||
}
|
||||
132
cool-unix/uni_modules/cool-svg/utssdk/app-android/index.uts
Normal file
132
cool-unix/uni_modules/cool-svg/utssdk/app-android/index.uts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"deploymentTarget": "9",
|
||||
"dependencies-pods": [
|
||||
{
|
||||
"name": "SVGKit",
|
||||
"version": "2.1.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
68
cool-unix/uni_modules/cool-svg/utssdk/app-ios/index.uts
Normal file
68
cool-unix/uni_modules/cool-svg/utssdk/app-ios/index.uts
Normal 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) {
|
||||
// 静默处理异常,避免影响应用运行
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
14
cool-unix/uni_modules/cool-ui/components/cl-avatar/props.ts
Normal file
14
cool-unix/uni_modules/cool-ui/components/cl-avatar/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
export type ClBackTopProps = {
|
||||
className?: string;
|
||||
top?: number | any;
|
||||
};
|
||||
109
cool-unix/uni_modules/cool-ui/components/cl-badge/cl-badge.uvue
Normal file
109
cool-unix/uni_modules/cool-ui/components/cl-badge/cl-badge.uvue
Normal 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>
|
||||
15
cool-unix/uni_modules/cool-ui/components/cl-badge/props.ts
Normal file
15
cool-unix/uni_modules/cool-ui/components/cl-badge/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
25
cool-unix/uni_modules/cool-ui/components/cl-banner/props.ts
Normal file
25
cool-unix/uni_modules/cool-ui/components/cl-banner/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
42
cool-unix/uni_modules/cool-ui/components/cl-button/props.ts
Normal file
42
cool-unix/uni_modules/cool-ui/components/cl-button/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
273
cool-unix/uni_modules/cool-ui/components/cl-calendar/picker.uvue
Normal file
273
cool-unix/uni_modules/cool-ui/components/cl-calendar/picker.uvue
Normal 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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
106
cool-unix/uni_modules/cool-ui/components/cl-col/cl-col.uvue
Normal file
106
cool-unix/uni_modules/cool-ui/components/cl-col/cl-col.uvue
Normal 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>
|
||||
12
cool-unix/uni_modules/cool-ui/components/cl-col/props.ts
Normal file
12
cool-unix/uni_modules/cool-ui/components/cl-col/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ClCollapsePassThrough = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ClCollapseProps = {
|
||||
className?: string;
|
||||
pt?: ClCollapsePassThrough;
|
||||
modelValue?: boolean;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
1352
cool-unix/uni_modules/cool-ui/components/cl-cropper/cl-cropper.uvue
Normal file
1352
cool-unix/uni_modules/cool-ui/components/cl-cropper/cl-cropper.uvue
Normal file
File diff suppressed because it is too large
Load Diff
19
cool-unix/uni_modules/cool-ui/components/cl-cropper/props.ts
Normal file
19
cool-unix/uni_modules/cool-ui/components/cl-cropper/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
102
cool-unix/uni_modules/cool-ui/components/cl-empty/cl-empty.uvue
Normal file
102
cool-unix/uni_modules/cool-ui/components/cl-empty/cl-empty.uvue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export type ClFilterBarProps = {
|
||||
className?: string;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ClFloatViewProps = {
|
||||
className?: string;
|
||||
zIndex?: number;
|
||||
size?: number;
|
||||
left?: number;
|
||||
bottom?: number;
|
||||
gap?: number;
|
||||
disabled?: boolean;
|
||||
noSnapping?: boolean;
|
||||
};
|
||||
@@ -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>
|
||||
16
cool-unix/uni_modules/cool-ui/components/cl-footer/offset.ts
Normal file
16
cool-unix/uni_modules/cool-ui/components/cl-footer/offset.ts
Normal 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();
|
||||
14
cool-unix/uni_modules/cool-ui/components/cl-footer/props.ts
Normal file
14
cool-unix/uni_modules/cool-ui/components/cl-footer/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
455
cool-unix/uni_modules/cool-ui/components/cl-form/cl-form.uvue
Normal file
455
cool-unix/uni_modules/cool-ui/components/cl-form/cl-form.uvue
Normal 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>
|
||||
18
cool-unix/uni_modules/cool-ui/components/cl-form/props.ts
Normal file
18
cool-unix/uni_modules/cool-ui/components/cl-form/props.ts
Normal 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;
|
||||
};
|
||||
162
cool-unix/uni_modules/cool-ui/components/cl-icon/cl-icon.uvue
Normal file
162
cool-unix/uni_modules/cool-ui/components/cl-icon/cl-icon.uvue
Normal 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>
|
||||
13
cool-unix/uni_modules/cool-ui/components/cl-icon/props.ts
Normal file
13
cool-unix/uni_modules/cool-ui/components/cl-icon/props.ts
Normal 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;
|
||||
};
|
||||
221
cool-unix/uni_modules/cool-ui/components/cl-image/cl-image.uvue
Normal file
221
cool-unix/uni_modules/cool-ui/components/cl-image/cl-image.uvue
Normal 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>
|
||||
26
cool-unix/uni_modules/cool-ui/components/cl-image/props.ts
Normal file
26
cool-unix/uni_modules/cool-ui/components/cl-image/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ClIndexBarPassThrough = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export type ClIndexBarProps = {
|
||||
className?: string;
|
||||
pt?: ClIndexBarPassThrough;
|
||||
modelValue?: number;
|
||||
list?: string[];
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
411
cool-unix/uni_modules/cool-ui/components/cl-input/cl-input.uvue
Normal file
411
cool-unix/uni_modules/cool-ui/components/cl-input/cl-input.uvue
Normal 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>
|
||||
32
cool-unix/uni_modules/cool-ui/components/cl-input/props.ts
Normal file
32
cool-unix/uni_modules/cool-ui/components/cl-input/props.ts
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user