Files
2026-09-24 16:25:22 +08:00

569 lines
15 KiB
Plaintext

<script lang="ts" setup>
import { getCurrentInstance, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from "vue"
import { getUid } from "../../core/util/xCoreUtil.uts"
import { getDefaultColor } from "../../core/util/xCoreColorUtil.uts"
import { checkIsCssUnit, getUnit } from "../../core/util/xCoreUtil.uts"
import { xAnimate } from "../../core/util/xAnimate.uts"
import { xConfig } from "../../config/xConfig.uts"
import { XANIMATE_OPIONS } from "../../interface.uts"
type dDrect = { x : number, y : number, width : number, height : number, space : number }
/**
* @name 评分 xRate
* @description 评分组件,只读和禁用等属性,它也是支持手势左右滑动设置分值。
* @page /pages/index/rate
* @category 表单组件
* @constant 平台兼容
* | Harmony | H5 | andriod | IOS | 小程序 | UTS | UNIAPP-X SDK | version |
| --- | --- | --- | --- | --- | --- | --- | --- |
| ☑ | ☑ | ☑️ | ☑️ | ☑️ | ☑️ | 4.76+ | 1.1.18 |
*/
defineOptions({name:"xRate"})
const proxy = getCurrentInstance()?.proxy??null;
// 定义 emits
const emits = defineEmits([
/**
* 评分变换时触发
* @param {number} score - 评分
*/
'change',
'update:modelValue'
])
defineSlots<{
default(props: { score: number }): any
}>()
// 定义 props 类型
type xRatePropsType = {
/**
* 当前分值,等同v-model
*/
modelValue: number,
/**
* 最大评分数量
*/
count: number,
/**
* 选中的颜色,默认空值取全局值
*/
color: string,
/**
* 未选中的颜色
*/
unColor: string,
/**
* 未选中的暗黑背景颜色
* 空时取InputDark表单颜色
*/
darkUnColor: string,
/**
* 尺寸
*/
size: string,
/**
* 间隙
*/
space: string,
/**
* 选中的图标
*/
icon: string,
/**
* 未选中的图标
*/
unicon: string,
/**
* 是否只读状态
*/
readonly: boolean,
/**
* 是否禁用状态
*/
disabled: boolean,
/**
* 是否显示右侧评分值
*/
showScore: boolean,
/**
* 右侧文本分值文本的字号
*/
fontSize: string,
/**
* 是否开启半星
* 开启半星后,自定的unicon和icon失效。
*/
half: boolean
}
const props = withDefaults(defineProps<xRatePropsType>(), {
modelValue: 0,
count: 5,
color: "",
unColor: "#cacaca",
darkUnColor: "#8b8b8b",
size: "21",
space: "4",
icon: "star-fill",
unicon: "star-line",
readonly: false,
disabled: false,
showScore: false,
fontSize: "14",
half: false
})
// 响应式数据
const score = ref<number>(0)
const xani = ref<xAnimate | null>(null)
const xRateWrap = ref<UniElement | null>(null)
const clickStartIndex = ref<number>(-1)
//0未选中,1左,2右
const clickStartStatus = ref<number>(0)
const isWEBPC = ref<boolean>(false)
// #ifdef WEB
const xRateWebId = ref<string>("xrateUid-" + Math.random().toString(16).substring(2))
// #endif
const _x = ref<number>(0)
const _y = ref<number>(0)
const _isMove = ref<boolean>(false)
const _parentBounds = ref<dDrect>({ x: 0, y: 0, width: 0, height: 0, space: 0 })
const _isWEBmoveSelecteIndex = ref<number>(-1)
// 计算属性
const _disabled = computed((): boolean => props.disabled)
const _readonly = computed((): boolean => props.readonly)
const _count = computed((): number => props.count)
const _size = computed((): string => props.size)
const _sizeUnUnit = computed((): number => {
let fontSize = checkIsCssUnit(props.size, xConfig.unit);
let sizeNumber = parseInt(fontSize)
const unit = getUnit(fontSize);
if (isNaN(sizeNumber)) {
sizeNumber = 21
}
if (unit == 'rpx') {
sizeNumber = uni.rpx2px(sizeNumber)
}
return sizeNumber;
})
const _fontSize = computed((): string => {
let fontSize = checkIsCssUnit(props.fontSize, xConfig.unit);
if (xConfig.fontScale == 1) return fontSize;
let sizeNumber = parseInt(fontSize)
if (isNaN(sizeNumber)) {
sizeNumber = 14
}
return (sizeNumber * xConfig.fontScale).toString() + getUnit(fontSize)
})
const _space = computed((): string => checkIsCssUnit(props.space, xConfig.unit))
const _color = computed((): string => {
if (props.color == "") return getDefaultColor(xConfig.color)
return getDefaultColor(props.color);
})
const _unColor = computed((): string => {
if (xConfig.dark == 'dark') {
if (props.darkUnColor == '') {
return getDefaultColor(xConfig.inputDarkColor);
} else {
return getDefaultColor(props.darkUnColor);
}
}
return getDefaultColor(props.unColor);
})
const _showScore = computed((): boolean => props.showScore)
const setUpdate = (index: number): void => {
/**
* 同步当前分值,等同v-model
* @param {number} score 评分
*/
emits('update:modelValue', score.value);
/**
* 评分变换时触发
* @param {number} score 评分
*/
emits('change', score.value);
let el = proxy!.$refs['rate-' + index.toString()];
let eles = el as UniElement[]
if (xani.value != null) {
xani.value!.stop();
}
if (eles.length > 0) {
xani.value = new xAnimate(eles[0], { duration: 250, isDescPlay: true } as XANIMATE_OPIONS)
xani.value!.attr('scale', '1', '1.2', false)
xani.value!.attr('scale', '1.2', '1', false)
.play()
}
}
const isPcMouse = (): boolean => {
let ispc = false;
// #ifdef WEB
ispc = !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// #endif
return ispc;
}
const setDefaultHalfStatus = (): void => {
if (score.value == 0) return;
clickStartIndex.value = score.value
let fillCount = Math.ceil(score.value);
clickStartStatus.value = score.value == (fillCount - 0.5) ? 1 : 2;
}
const isSelected = (index: number): boolean => {
if (props.half) {
return score.value >= index - 0.5
}
return score.value >= index
}
const isSelectedHalf = (index: number): string => {
if (score.value > index) {
return 'star-fill'
}
if (score.value < index - 0.5) {
return 'star-line'
}
if (clickStartStatus.value == 1) {
return 'star-half-fill'
}
if (clickStartStatus.value == 2) {
return 'star-fill'
}
return 'star-line'
}
const onClickHalf = (index: number, type: number): void => {
if (_readonly.value || _disabled.value) return;
score.value = type == 1 ? index - 0.5 : index;
clickStartStatus.value = type
setUpdate(index)
}
const onClick = (index: number): void => {
if (_readonly.value || _disabled.value) return;
score.value = index;
setUpdate(index)
clickStartIndex.value = index;
}
// #ifdef WEB
const _msStart = async (evt: MouseEvent): Promise<void> => {
if (!isWEBPC.value) return;
let ele = xRateWrap.value as UniElement | null;
if (ele == null) return;
// @ts-ignore
window.xRateId = xRateWebId.value
_isMove.value = true;
// @ts-ignore
_x.value = evt.clientX
// @ts-ignore
_y.value = evt.clientY
let bounds = await ele.getBoundingClientRectAsync()
if (bounds != null) {
_parentBounds.value = {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
space: (bounds.width - _count.value * _sizeUnUnit.value) / (_count.value - 1)
} as dDrect
}
}
const _msMove = (evt: MouseEvent): void => {
// @ts-ignore
if (!isWEBPC.value || !_isMove.value || window.xRateId != xRateWebId.value) return;
if (_readonly.value || _disabled.value) return;
// @ts-ignore
let diffx = evt.clientX - _parentBounds.value.x
// @ts-ignore
let diffy = evt.clientY - _parentBounds.value.y
// 计算当前鼠标位置对应的星星索引
// 每个星星的宽度 = 星星尺寸 + 间距
const starWidth = _sizeUnUnit.value + _parentBounds.value.space;
// 计算当前鼠标在第几个星星上
let currentIndex = Math.ceil(diffx / starWidth);
// 边界处理
if (currentIndex < 1) currentIndex = 1;
if (currentIndex > _count.value) currentIndex = _count.value;
// 如果开启半星,需要判断是左半部分还是右半部分
if (props.half) {
// 计算鼠标在当前星星内的位置
const posInStar = diffx - (currentIndex - 1) * starWidth;
// 判断是左半部分还是右半部分
const isLeft = posInStar <= (_sizeUnUnit.value / 2);
// 设置评分和状态
score.value = isLeft ? currentIndex - 0.5 : currentIndex;
clickStartStatus.value = isLeft ? 1 : 2;
} else {
// 不开启半星,直接设置整星评分
score.value = currentIndex;
}
// 更新索引
clickStartIndex.value = currentIndex;
_isWEBmoveSelecteIndex.value = currentIndex;
// 触发更新事件
emits('update:modelValue', score.value);
}
const _msEnd = (evt: MouseEvent): void => {
if (!isWEBPC.value) return;
_isMove.value = false;
// @ts-ignore
window.xRateId = '';
// 如果有评分变化,触发change事件
if (_isWEBmoveSelecteIndex.value > 0 && !_readonly.value && !_disabled.value) {
emits('change', score.value);
// 添加动画效果
let el = proxy!.$refs['rate-' + _isWEBmoveSelecteIndex.value.toString()];
let eles = el as UniElement[]
if (xani.value != null) {
xani.value!.stop();
}
if (eles.length > 0) {
xani.value = new xAnimate(eles[0], { duration: 250, isDescPlay: true } as XANIMATE_OPIONS)
xani.value!.attr('scale', '1', '1.2', false)
xani.value!.attr('scale', '1.2', '1', false)
.play()
}
}
// 重置移动选择索引
_isWEBmoveSelecteIndex.value = -1;
}
// #endif
// 移动端触摸事件
const _msMStart = (evt: UniTouchEvent): void => {
if (_readonly.value || _disabled.value || isWEBPC.value) return;
let ele = xRateWrap.value;
if (ele == null) return;
_isMove.value = true;
// 获取触摸点坐标
if (evt.touches.length > 0) {
_x.value = evt.touches[0].clientX;
_y.value = evt.touches[0].clientY;
}
// 异步获取组件位置信息
ele.getBoundingClientRectAsync()!.then(bounds => {
if (bounds != null) {
_parentBounds.value = {
x: bounds.left,
y: bounds.top,
width: bounds.width,
height: bounds.height,
space: (bounds.width - _count.value * _sizeUnUnit.value) / (_count.value - 1)
} as dDrect
}
});
}
const _msMMove = (evt: UniTouchEvent): void => {
if (!_isMove.value || _readonly.value || _disabled.value || isWEBPC.value) return;
// 获取触摸点坐标
if (evt.touches.length > 0) {
let diffx = evt.touches[0].clientX - _parentBounds.value.x;
// 计算当前触摸位置对应的星星索引
const starWidth = _sizeUnUnit.value + _parentBounds.value.space;
// 计算当前触摸在第几个星星上
let currentIndex = Math.ceil(diffx / starWidth);
// 边界处理
if (currentIndex < 1) currentIndex = 1;
if (currentIndex > _count.value) currentIndex = _count.value;
// 如果开启半星,需要判断是左半部分还是右半部分
if (props.half) {
// 计算触摸在当前星星内的位置
const posInStar = diffx - (currentIndex - 1) * starWidth;
// 判断是左半部分还是右半部分
const isLeft = posInStar <= (_sizeUnUnit.value / 2);
// 设置评分和状态
score.value = isLeft ? currentIndex - 0.5 : currentIndex;
clickStartStatus.value = isLeft ? 1 : 2;
} else {
// 不开启半星,直接设置整星评分
score.value = currentIndex;
}
// 更新索引
clickStartIndex.value = currentIndex;
_isWEBmoveSelecteIndex.value = currentIndex;
// 触发更新事件
emits('update:modelValue', score.value);
}
}
const _msMEnd = (evt: UniTouchEvent): void => {
_isMove.value = false;
// 如果有评分变化,触发change事件
if (_isWEBmoveSelecteIndex.value > 0 && !_readonly.value && !_disabled.value) {
emits('change', score.value);
// 添加动画效果
let el = proxy!.$refs['rate-' + _isWEBmoveSelecteIndex.value.toString()];
let eles = el as UniElement[]
if (xani.value != null) {
xani.value!.stop();
}
if (eles.length > 0) {
xani.value = new xAnimate(eles[0], { duration: 250, isDescPlay: true } as XANIMATE_OPIONS)
xani.value!.attr('scale', '1', '1.2', false)
xani.value!.attr('scale', '1.2', '1', false)
.play()
}
}
// 重置移动选择索引
_isWEBmoveSelecteIndex.value = -1;
}
// 监听器
watch((): number => props.modelValue, (newvalue: number): void => {
if (newvalue == score.value) return;
score.value = props.modelValue;
if (props.half) {
setDefaultHalfStatus()
}
})
// 生命周期
onMounted((): void => {
score.value = props.modelValue;
if (props.half) {
setDefaultHalfStatus()
}
})
onBeforeUnmount((): void => {
if (xani.value != null) {
xani.value!.stop();
xani.value = null;
}
})
// 初始化
isWEBPC.value = isPcMouse()
</script>
<template>
<view class="xRate" :style="{opacity:_disabled?0.7:1}">
<view ref="xRateWrap" class="xRateWrap"
<!-- #ifdef WEB -->
@mouseenter="_msStart"
@mousemove="_msMove"
@mouseleave="_msEnd"
<!-- #endif -->
@touchstart="_msMStart"
@touchmove="_msMMove"
@touchend="_msMEnd"
>
<template v-if="!props.half">
<view :ref="'rate-'+index" v-for="index in _count" :key="index">
<x-icon @click="onClick(index)" :style="{marginRight:index==_count?'0rpx' : _space}"
:font-size="_size" :color="isSelected(index)?_color:_unColor"
:dark-color="isSelected(index)?_color:_unColor" :name="isSelected(index)?props.icon:props.unicon"></x-icon>
</view>
</template>
<!-- 开启半星 -->
<template v-if="props.half">
<view :ref="'rate-'+index" v-for="index in _count" :key="index" style='position:relative'>
<x-icon :style="{marginRight:index==_count?'0rpx' : _space,marginLeft:index==0?'0rpx' : _space}"
:font-size="_size" :color="isSelected(index)?_color:_unColor"
:dark-color="isSelected(index)?_color:_unColor" :name="isSelectedHalf(index)"></x-icon>
<view @click="onClickHalf(index,1)" class="xRateLeft"></view>
<view @click="onClickHalf(index,2)" class="xRateRight"></view>
</view>
</template>
</view>
<!--
@slot 文本分值的右侧插槽
@prop {number} score - 当前分值
-->
<slot name="score" :score="score">
<text class="xRateText" v-if="_showScore" :style="{color:_color,fontSize:_fontSize}">
{{score.toFixed(1)}}
</text>
</slot>
</view>
</template>
<style scoped>
.xRateText {
margin-left: 10px;
}
.xRateLeft {
position: absolute;
width: 50%;
height: 100%;
top: 0px;
left: 0px
}
.xRateRight {
position: absolute;
width: 50%;
height: 100%;
top: 0px;
right: 0px
}
.xRate {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
.xRateWrap {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
}
</style>