chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,254 @@
@import '@/uni_modules/lime-style/index.scss';
/* #ifdef uniVersion >= 4.75 */
$use-css-var: true;
/* #endif */
$loading-color: create-var(loading-color, $primary-color);
$loading-size: create-var(loading-size, 20px);
$loading-text-color: create-var(loading-text-color, $text-color-3);
$loading-font-size: create-var(loading-font-size, $font-size);
/* #ifndef APP-ANDROID || APP-HARMONY || APP-IOS || APP-NVUE */
/* #ifndef MP-ALIPAY || MP-WEIXIN */
$loading-duration: var(--l-loading-duration, 2s);
@property --l-loading-start {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-loading-end {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-left {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-loadding-ball-size {
syntax: '<length> | <length-percentage>';
// initial-value: 1%;
inherits: false;
}
/* #endif */
/* #ifdef MP-ALIPAY || MP-WEIXIN */
$loading-duration: var(--l-loading-duration, 1s);
/* #endif */
/* #endif */
.l-loading {
display: flex;
position: relative;
flex-direction: row;
align-items: center;
// align-self: flex-start;
/* #ifndef APP-ANDROID || APP-HARMONY || APP-IOS || APP-NVUE */
color: $loading-color;
&--ball{
display: inline-flex;
align-items: center;
.l-loading {
&__ball {
position: relative;
perspective: calc(var(--l-loadding-ball-size) * 4);
transform-style: preserve-3d;
// border: 1px solid;
&:before{
background-color: $primary-color;
left: 0%;
// mix-blend-mode: darken;
animation-name: l-ball-before;
}
&:after{
right: 0;
background-color: red;
// mix-blend-mode: darken;
animation-name: l-ball-after;
}
&:before,&:after{
top: 0;
content: '';
position: absolute;
// width: 100%;
height: 100%;
aspect-ratio: 1/1;
border-radius: 50%;
animation-iteration-count: infinite;
animation-delay: -100ms;
animation-duration: 900ms;
mix-blend-mode: darken;
animation-play-state: var(--l-play-state, running);
}
}
}
}
&--circular {
.l-loading {
&__circular {
display: inline-block;
position: relative;
animation: l-rotate $loading-duration linear infinite;
animation-play-state: var(--l-play-state, running);
vertical-align: middle;
&:before {
content: '';
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
/* #ifndef MP-ALIPAY */
background: conic-gradient(
transparent 0%,
transparent var(--l-loading-start, 0%), var(--l-loading-color-1, currentColor) var(--l-loading-start, 0%),
var(--l-loading-color-2, currentColor) var(--l-loading-end, 0%), transparent var(--l-loading-end, 0%),
transparent 100%);
/* #endif */
/* #ifdef MP-ALIPAY */
background: conic-gradient(
var(--l-loading-color-1, transparent) 0%,
var(--l-loading-color-2, currentColor) 100%);
/* #endif */
mask: radial-gradient(closest-side, transparent calc(80% - 1px), #fff 80%);
-webkit-mask: radial-gradient(closest-side, transparent calc(80% - 1px), #fff 80%);
animation: l-circular 3s ease-in-out infinite;
animation-play-state: var(--l-play-state, running);
transform: rotate(90deg);
}
}
}
}
&--spinner {
.l-loading {
&__spinner {
position: relative;
box-sizing: border-box;
// width: 100%;
// height: 100%;
// max-width: 100%;
// max-height: 100%;
animation-timing-function: steps(12);
animation: l-rotate 1.5s linear infinite;
animation-play-state: var(--l-play-state, running)
}
&__dot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: rotate(calc(var(--l-loading-dot, 1) * 30deg));
opacity: calc(var(--l-loading-dot, 1) / 12);
&::before {
display: block;
width: 5rpx;
height: 25%;
margin: 0 auto;
background-color: currentColor;
border-radius: 40%;
content: ' ';
}
}
}
}
/* #endif */
/* #ifdef APP-ANDROID || APP-HARMONY || APP-IOS || APP-NVUE */
&__view{
// background-color: aqua;
// background-color: #1677ff;
// transition-duration: 1.5s;
// transition-property: transform;
// transition-timing-function: linear;
}
/* #endif */
&__text {
margin-left: $spacer-xs;
color: $loading-text-color;
font-size: $loading-font-size;
}
&.is-vertical {
flex-direction: column;
.l-loading__text {
margin: $spacer-tn 0 0;
}
}
&__ball,&__circular,&__spinner {
width: $loading-size;
height: $loading-size;
}
}
/* #ifndef APP-ANDROID || APP-HARMONY || APP-IOS || APP-NVUE */
@keyframes l-circular {
0% {
--l-loading-start: 0%;
--l-loading-end: 0%;
}
50% {
--l-loading-start: 0%;
--l-loading-end: 100%;
}
100% {
--l-loading-start: 100%;
--l-loading-end: 100%;
}
}
@keyframes l-rotate {
to {
transform: rotate(1turn)
}
}
@keyframes l-ball-before {
0%{
animation-timing-function: ease-in;
}
25% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2);
transform: translate3d(var(--l-left), 0, var(--l-loadding-ball-size));
}
50% {
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)));
animation-timing-function:ease-in;
transform: translate3d(var(--l-left), 0, 0);
}
75% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2);
transform: translate3d(var(--l-left), 0, calc(var(--l-loadding-ball-size) * -1));
}
}
@keyframes l-ball-after {
0%{
animation-timing-function: ease-in;
}
25% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2 * -1);
transform: translate3d(var(--l-left), 0, calc(var(--l-loadding-ball-size) * -1));
}
50% {
animation-timing-function:ease-in;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) * -1);
transform: translate3d(var(--l-left), 0, 0);
}
75% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2 * -1);
transform: translate3d(var(--l-left), 0, var(--l-loadding-ball-size));
}
}
/* #endif */

View File

@@ -0,0 +1,246 @@
@import '@/uni_modules/lime-style/index.scss';
$loading-color: create-var(loading-color, $primary-color);
$loading-size: create-var(loading-size, 20px);
$loading-text-color: create-var(loading-text-color, $text-color-3);
$loading-font-size: create-var(loading-font-size, $font-size);
/* #ifndef MP-ALIPAY */
$loading-duration: create-var(loading-duration, 2s);
/* #endif */
/* #ifdef MP-ALIPAY */
$loading-duration: create-var(loading-duration, 1s);
/* #endif */
/* #ifndef APP-NVUE */
/* #ifndef MP-ALIPAY */
@property --l-loading-start {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-loading-end {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-left {
syntax: '<length-percentage>';
initial-value: 1%;
inherits: false;
}
@property --l-loadding-ball-size {
syntax: '<length> | <length-percentage>';
// initial-value: 1%;
inherits: false;
}
/* #endif */
:host {
display: inline-flex;
}
/* #endif */
.l-loading {
position: relative;
// color: #c8c9cc;
color: $loading-color;
font-size: 0;
vertical-align: middle;
&--ball{
display: inline-flex;
align-items: center;
.l-loading {
&__ball {
position: relative;
perspective: calc(var(--l-loadding-ball-size) * 4);
transform-style: preserve-3d;
// border: 1px solid;
&:before{
background-color: $primary-color;
left: 0%;
// mix-blend-mode: darken;
animation-name: l-ball-before;
}
&:after{
right: 0;
background-color: red;
// mix-blend-mode: darken;
animation-name: l-ball-after;
}
&:before,&:after{
top: 0;
content: '';
position: absolute;
// width: 100%;
height: 100%;
aspect-ratio: 1/1;
border-radius: 50%;
animation-iteration-count: infinite;
animation-delay: -100ms;
animation-duration: 900ms;
mix-blend-mode: darken;
animation-play-state: var(--l-play-state, running);
}
}
}
}
&--circular {
.l-loading {
&__circular {
display: inline-block;
position: relative;
/* #ifndef APP-NVUE */
animation: l-rotate $loading-duration linear infinite;
vertical-align: middle;
animation-play-state: var(--l-play-state, running);
&:before {
content: '';
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
/* #ifndef MP-ALIPAY || APP-VUE */
background-image: conic-gradient(
transparent 0%,
transparent var(--l-loading-start, 0%), var(--l-loading-color-1, currentColor) var(--l-loading-start, 0%),
var(--l-loading-color-2, currentColor) var(--l-loading-end, 0%), transparent var(--l-loading-end, 0%),
transparent 100%);
/* #endif */
/* #ifdef MP-ALIPAY || APP-VUE */
background-image: conic-gradient(
var(--l-loading-color-1, transparent) 0%,
var(--l-loading-color-2, currentColor) 100%);
/* #endif */
mask: radial-gradient(closest-side, transparent calc(80% - 1px), #fff 80%);
-webkit-mask: radial-gradient(closest-side, transparent calc(80% - 1px), #fff 80%);
animation: l-circular 2.5s ease-in-out infinite;
transform: rotate(90deg);
animation-play-state: var(--l-play-state, running);
}
/* #endif */
}
}
}
&--spinner {
.l-loading {
&__spinner {
position: relative;
box-sizing: border-box;
width: 100%;
height: 100%;
max-width: 100%;
max-height: 100%;
animation-timing-function: steps(12);
animation: l-rotate 0.8s linear infinite;
animation-play-state: var(--l-play-state, running);
}
&__dot {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: rotate(calc(var(--l-loading-dot, 1) * 30deg));
opacity: calc(var(--l-loading-dot, 1) / 12);
&::before {
display: block;
width: 5rpx;
height: 25%;
margin: 0 auto;
background-color: currentColor;
border-radius: 40%;
content: ' ';
}
}
}
}
&__text{
display: inline-block;
margin-left: $spacer-xs;
color: $loading-text-color;
font-size: $loading-font-size;
vertical-align: middle;
}
&.is-vertical {
display: inline-flex;
flex-direction: column;
align-items: center;
.l-loading__text {
margin: $spacer-tn 0 0;
}
}
&__ball,&__circular,&__spinner {
width: $loading-size;
height: $loading-size;
}
}
/* #ifndef APP-NVUE */
@keyframes l-circular {
0% {
--l-loading-start: 0%;
--l-loading-end: 0%;
}
50% {
--l-loading-start: 0%;
--l-loading-end: 100%;
}
100% {
--l-loading-start: 100%;
--l-loading-end: 100%;
}
}
@keyframes l-rotate {
to {
transform: rotate(1turn)
}
}
@keyframes l-ball-before {
0%{
animation-timing-function: ease-in;
}
25% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2);
transform: translate3d(var(--l-left), 0, var(--l-loadding-ball-size));
}
50% {
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)));
animation-timing-function:ease-in;
transform: translate3d(var(--l-left), 0, 0);
}
75% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2);
transform: translate3d(var(--l-left), 0, calc(var(--l-loadding-ball-size) * -1));
}
}
@keyframes l-ball-after {
0%{
animation-timing-function: ease-in;
}
25% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2 * -1);
transform: translate3d(var(--l-left), 0, calc(var(--l-loadding-ball-size) * -1));
}
50% {
animation-timing-function:ease-in;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) * -1);
transform: translate3d(var(--l-left), 0, 0);
}
75% {
animation-timing-function: ease-out;
--l-left: calc((var(--l-loadding-ball-size,100%) * 2.1 - var(--l-loadding-ball-size,100%)) / 2 * -1);
transform: translate3d(var(--l-left), 0, var(--l-loadding-ball-size));
}
}
/* #endif */

View File

@@ -0,0 +1,120 @@
<template>
<view class="l-loading" :class="classes">
<!-- #ifndef APP-ANDROID || APP-IOS || APP-HARMONY -->
<view class="l-loading__ball" v-if="type == 'ball'" :style="[spinnerStyle]"></view>
<view class="l-loading__circular" v-if="type == 'circular'" :style="[spinnerStyle]"></view>
<view class="l-loading__spinner" v-if="type == 'spinner'" :style="[spinnerStyle]">
<view class="l-loading__dot" v-for="item in 12" :key="item" :style="{'--l-loading-dot': item}"></view>
</view>
<!-- #endif -->
<!-- #ifdef APP-ANDROID || APP-IOS || APP-HARMONY -->
<view class="l-loading__view" ref="loadingRef" :style="spinnerStyle"></view>
<!-- #endif -->
<text class="l-loading__text" v-if="$slots['default'] != null || text != null" :style="textStyle">
<slot>{{text}}</slot>
</text>
</view>
</template>
<script lang="uts" setup>
/**
* Loading 加载指示器
* @description 用于表示加载中的过渡状态,支持多种动画类型和布局方式
* <br> 插件类型LLoadingComponentPublicInstance
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-loading
*
* @property {string} color 加载图标颜色(默认:主题色)
* @property {'circular' | 'spinner' | 'failed'} mode 动画实现的模式.只针对APP
* @value raf 延时
* @value animate 基于元素的annimate方法
* @property {'circular' | 'spinner' | 'failed'} type 加载状态类型
* @value circular 环形旋转动画(默认)
* @value spinner 菊花转动画
* @value failed 加载失败提示
* @property {string} text 提示文字内容
* @property {string} textColor 文字颜色默认同color
* @property {string} textSize 文字字号默认14px
* @property {boolean} vertical 是否垂直排列图标和文字
* @property {boolean} animated 是否启用旋转动画failed类型自动禁用
* @property {string} size 图标尺寸(默认:'40px'
*/
import { LoadingProps } from './type'
// #ifdef APP
// import {useLoading} from './useLoading'
const themeVars = inject('limeConfigProviderThemeVars', computed(()=> ({})))
import {useLoading} from '@/uni_modules/lime-loading'
// #endif
const name = 'l-loading'
const props = withDefaults(defineProps<LoadingProps>(), {
size: '40rpx', // 为所有平台设置默认值
type: 'circular',
mode: 'raf',
animated: true,
vertical: false,
})
const classes = computed<Map<string,any>>(():Map<string,any> => {
const cls = new Map<string,any>()
cls.set(name + '--' + props.type, true)
if (props.vertical) {
cls.set('is-vertical', props.vertical)
} else {
cls.set('is-horizontal', !props.vertical)
}
return cls
})
const spinnerStyle = computed<Map<string,any>>(():Map<string,any> => {
const style = new Map<string,any>()
// 只在值不为 undefined 时设置,避免微信小程序报错
if (props.size != null) {
style.set('width', props.size)
style.set('height', props.size)
}
// #ifndef APP
if (props.color != null) {
style.set('color', props.color)
}
style.set('--l-play-state', props.animated ? 'running' : 'paused')
// #endif
return style
})
const textStyle = computed<Map<string,any>>(():Map<string,any> => {
const style = new Map<string,any>()
if (props.textColor != null) {
style.set('color', props.textColor!)
}
if (props.textSize != null) {
style.set('font-size', props.textSize!)
}
return style
})
// #ifdef APP
const loadingRef = ref<UniElement|null>(null)
const loading = useLoading(loadingRef)
loading.type = props.type;
loading.mode = props.mode;
if(props.animated){
loading.play()
} else {
loading.pause()
}
watchEffect(()=>{
if(loadingRef.value == null) return
loading.color = props.color ?? `${themeVars.value['loadingColor'] ?? '#3283ff'}`
if(props.animated){
loading.play()
} else {
loading.pause()
}
})
// #endif
</script>
<style lang="scss">
@import './index-u.scss';
</style>

View File

@@ -0,0 +1,79 @@
<template>
<view class="l-class l-loading" :class="['l-loading--' + type, {'is-vertical': vertical}]" :style="{color: inheritColor ? 'inherit': ''}">
<!-- #ifdef APP-NVUE -->
<loading-indicator class="l-loading__circular" :style="[spinnerStyle]" :animating="true"/>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<view class="l-loading__ball" v-if="type == 'ball'" :style="[spinnerStyle]"></view>
<view class="l-loading__circular" v-if="type == 'circular'" :style="[spinnerStyle]"></view>
<view class="l-loading__spinner" v-if="type == 'spinner'" :style="[spinnerStyle]">
<view class="l-loading__dot" v-for="item in 12" :key="item" :style="{'--l-loading-dot': item}"></view>
</view>
<!-- #endif -->
<text class="l-loading__text" v-if="$slots['default']||text" :style="[textStyle]">
{{text}}<slot></slot>
</text>
</view>
</template>
<script lang="ts">
/**
* Loading 加载指示器
* @description 用于表示加载中的过渡状态,支持多种动画类型和布局方式
* @tutorial https://ext.dcloud.net.cn/plugin?name=lime-loading
*
* @property {string} color 加载图标颜色(默认:主题色)
* @property {'circular' | 'spinner' | 'failed'} type 加载状态类型
* @value circular 环形旋转动画(默认)
* @value spinner 菊花转动画
* @value failed 加载失败提示
* @property {string} text 提示文字内容
* @property {string} textColor 文字颜色默认同color
* @property {string} textSize 文字字号默认14px
* @property {boolean} vertical 是否垂直排列图标和文字
* @property {boolean} animated 是否启用旋转动画failed类型自动禁用
* @property {string} size 图标尺寸(默认:'40px'
*/
import {computed, defineComponent} from '@/uni_modules/lime-shared/vue';
import {addUnit} from '@/uni_modules/lime-shared/addUnit';
import {unitConvert} from '@/uni_modules/lime-shared/unitConvert';
import LoadingProps from './props';
const name = 'l-loading';
export default defineComponent({
// name,
props:LoadingProps,
setup(props) {
const classes = computed(() => {
const {type, vertical} = props
return {
[name + '--' + type]: type,
['is-vertical']: vertical,
}
})
const spinnerStyle = computed(() => {
const size = unitConvert(props.size ?? 0) * (props.type == 'ball' ? 0.6 : 1);
return {
color: props.color,
width: size != 0 && (props.type == 'ball' ? addUnit(size * 2.1) : addUnit(size)),
height: size != 0 && addUnit(size),
'--l-loadding-ball-size': size != 0 && addUnit(size)
}
})
const textStyle = computed(() => {
return {
color: props.textColor,
fontSize: props.textSize,
}
})
return {
classes,
spinnerStyle,
textStyle
}
}
})
</script>
<style lang="scss">
@import './index.scss';
</style>

View File

@@ -0,0 +1,26 @@
// import {PropType} from 'vue'
export default {
color: {
type: String,
// default: '#c9c9c9'
},
type: {
type: String, //as PropType<'circular'|'spinner'>,
default: 'circular'
},
size: {
type: String,
// #ifdef APP-NVUE
default: '40rpx'
// #endif
},
text: String,
textColor: String,
textSize: String,
vertical: Boolean,
inheritColor: Boolean,
animated: {
type: Boolean,
default: true
}
}

View File

@@ -0,0 +1,17 @@
// 完整类型定义
export interface LoadingProps {
color?: string;
type: 'circular' | 'spinner' | 'failed';
// #ifndef APP
size?: string;
// #endif
// #ifdef APP
size: string;
// #endif
text?: string;
textColor?: string;
textSize?: string;
mode: 'raf' | 'animate';
vertical: boolean;
animated: boolean;
}

View File

@@ -0,0 +1,330 @@
// type UseLoadingOtions = {
// type: string,
// color: string,
// el: UniElement
// }
import {tinyColor} from '@/uni_modules/lime-color'
function easeInOutCubic(t : number) : number {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
}
type useLoadingReturnType = {
state : Ref<boolean>
color : Ref<string>
play: () => void
failed: () => void
clear : () => void
destroy : () => void
}
type Point = {
x1: number
y1: number
x2: number
y2: number
}
export function useLoading(
element : Ref<UniElement | null>,
type : 'circular' | 'spinner',
strokeColor : string,
ratio : number,
immediate: boolean = false,
) : useLoadingReturnType {
const state = ref(false)
const color = ref(strokeColor)
let tick = 0 // 0 不绘制 | 1 旋转 | 2 错误
let init = false
let isDestroy = ref(false)
let width = 0
let height = 0
let size = 0
let x = 0
let y = 0
let ctx : DrawableContext | null = null
let timer = -1
let isClear = false;
let drawing = false;
const updateSize = () => {
if (element.value == null) return
const rect = element.value!.getBoundingClientRect();
ctx = element.value!.getDrawableContext()! as DrawableContext
width = rect.width
height = rect.height
size = ratio > 1 ? ratio : Math.floor(Math.min(width, height) * ratio)
x = width / 2
y = height / 2
}
const circular = () => {
if (ctx == null) return
let _ctx = ctx!
let startAngle = 0;
let endAngle = 0;
let startSpeed = 0;
let endSpeed = 0;
let rotate = 0;
// 不使用360的原因是加上rotate后会导致闪烁
const ARC_LENGTH = 359.5
const PI = Math.PI / 180
const SPEED = 0.018
const ROTATE_INTERVAL = 0.09
const center = size / 2
const lineWidth = size / 10;
function draw() {
if(isClear) return
_ctx.reset();
_ctx.beginPath();
_ctx.arc(
x,
y,
center - lineWidth,
startAngle * PI + rotate,
endAngle * PI + rotate);
_ctx.lineWidth = lineWidth;
_ctx.strokeStyle = color.value;
_ctx.stroke();
if (endAngle < ARC_LENGTH && startAngle == 0) {
endSpeed += SPEED
endAngle = Math.min(ARC_LENGTH, easeInOutCubic(endSpeed) * ARC_LENGTH)
} else if (endAngle == ARC_LENGTH && startAngle < ARC_LENGTH) {
startSpeed += SPEED
startAngle = Math.min(ARC_LENGTH, easeInOutCubic(startSpeed) * ARC_LENGTH);
} else if (endAngle >= ARC_LENGTH && startAngle >= ARC_LENGTH) {
endSpeed = 0
startSpeed = 0
startAngle = 0;
endAngle = 0;
}
rotate += ROTATE_INTERVAL;
_ctx.update()
// clearTimeout(timer)
timer = setTimeout(() => draw(), 24)
}
draw()
}
const spinner = () => {
if (ctx == null) return
let _ctx = ctx!
const steps = 12;
let step = 0;
const lineWidth = size / 10;
// 线长度和距离圆心距离
const length = size / 4 - lineWidth;
const offset = size / 4;
function generateColorGradient(hex: string, steps: number):string[]{
const colors:string[] = []
const _color = tinyColor(hex)
for (let i = 1; i <= steps; i++) {
_color.setAlpha(i/steps);
colors.push(_color.toRgbString());
}
return colors
}
let colors = computed(():string[]=> generateColorGradient(color.value, steps))
function draw() {
if(tick == 0) return
_ctx.reset();
for (let i = 0; i < steps; i++) {
const stepAngle = 360 / steps
const angle = stepAngle * i;
const index =(steps + i - (step % steps)) % steps
// 正余弦
const sin = Math.sin(angle / 180 * Math.PI);
const cos = Math.cos(angle / 180 * Math.PI);
// 开始绘制
_ctx.lineWidth = lineWidth;
_ctx.lineCap = 'round';
_ctx.beginPath();
_ctx.moveTo(size / 2 + offset * cos, size / 2 + offset * sin);
_ctx.lineTo(size / 2 + (offset + length) * cos, size / 2 + (offset + length) * sin);
_ctx.strokeStyle = colors.value[index]
_ctx.stroke();
}
step += 1
_ctx.update()
timer = setTimeout(() => draw(), 1000/10)
}
draw()
}
const clear = () => {
clearTimeout(timer)
drawing = false
tick = 0
if(ctx == null) return
// ctx?.reset()
// ctx?.update()
setTimeout(()=>{
ctx!.reset()
ctx!.update()
},1000)
}
const failed = () => {
if(tick == 1) {
drawing = false
}
clearTimeout(timer)
tick = 2
if (ctx == null || drawing) return
let _ctx = ctx!
const _size = size * 0.61
const _sizeX = _size * 0.65
const lineWidth = _size / 6;
const lineLength = Math.ceil(Math.sqrt(Math.pow(_sizeX, 2) * 2))
const startX1 = (width - _sizeX) * 0.5
const startY = (height - _sizeX) * 0.5
const startX2 = startX1 + _sizeX
// 添加圆的参数
const centerX = width / 2;
const centerY = height / 2;
const radius = (_size * Math.sqrt(2)) / 2 + lineWidth / 2;
const totalSteps = 36;
function generateSteps(stepsCount: number):Point[][] {
const halfStepsCount = stepsCount / 2;
const step = lineLength / halfStepsCount //Math.floor(lineLength / 18);
const steps:Point[][] = []
for (let i = 0; i < stepsCount; i++) {
const sub:Point[] = []
const index = i % 18 + 1
if(i < halfStepsCount) {
const x2 = Math.sin(45 * Math.PI / 180) * step * index + startX1
const y2 = Math.cos(45 * Math.PI / 180) * step * index + startY
const start1 = {
x1: startX1,
y1: startY,
x2,
y2,
} as Point
sub.push(start1)
} else {
sub.push(steps[halfStepsCount-1][0])
const x2 = Math.sin((45 - 90) * Math.PI / 180) * step * index + startX2
const y2 = Math.cos((45 - 90) * Math.PI / 180) * step * index + startY
const start2 = {
x1: startX2,
y1: startY,
x2,
y2,
} as Point
sub.push(start2)
}
steps.push(sub)
}
return steps
}
const steps = generateSteps(36);
function draw(){
if(steps.length == 0 || tick == 0) {
clearTimeout(timer)
return
}
const drawStep = steps.shift()!
_ctx.reset()
_ctx.lineWidth = lineWidth;
_ctx.strokeStyle = color.value;
// 绘制逐渐显示的圆
_ctx.beginPath();
_ctx.arc(centerX, centerY, radius, 0, (2 * Math.PI) * (totalSteps - steps.length) / totalSteps);
_ctx.lineWidth = lineWidth;
_ctx.strokeStyle = color.value;
_ctx.stroke();
// 绘制X
_ctx.beginPath();
drawStep.forEach(item => {
_ctx.beginPath();
_ctx.moveTo(item.x1, item.y1)
_ctx.lineTo(item.x2, item.y2)
_ctx.stroke();
})
_ctx.update()
timer = setTimeout(() => draw(), 1000/30)
}
draw()
}
const destroy = () => {
isDestroy.value = true;
clear()
}
const play = () => {
if(tick == 2) {
drawing = false
}
if(drawing) return
tick = 1
if(width == 0 || height == 0) return
if (type == 'circular') {
circular()
} else if (type == 'spinner') {
spinner()
}
drawing = true
}
const _watch = (v:boolean) => {
if(isDestroy.value) return
if (v) {
play()
} else {
failed()
}
}
const stopWatchState = watch(state, _watch)
const ob = new UniResizeObserver((entries: UniResizeObserverEntry[])=>{
if(isDestroy.value) return
entries.forEach(entry => {
if(isDestroy.value) return
const rect = entry.target.getBoundingClientRect();
if(rect.width > 0 && rect.height > 0) {
updateSize();
if(tick == 1) {
play()
state.value = true
} else if(tick == 2) {
failed()
state.value = false
} else if(immediate && !init) {
_watch(state.value)
init = true
}
}
})
})
const stopWatchElement = watch(element, (el:UniElement|null) => {
if(el == null || isDestroy.value) return
ob.observe(el)
})
onUnmounted(()=>{
stopWatchState()
stopWatchElement()
clear()
ob.disconnect()
})
return {
state,
play,
failed,
clear,
color,
destroy
} as useLoadingReturnType
}

View File

@@ -0,0 +1,416 @@
// 引入颜色处理库
import { tinyColor } from '@/uni_modules/lime-color';
/**
* 操作类型
* play: 开始动画
* failed: 显示失败状态
* clear: 清除动画
* destroy: 销毁实例
*/
export type TickType = 'play' | 'failed' | 'clear' | 'destroy' | 'pause'
/**
* 加载动画类型
* circular: 环形加载动画
* spinner: 旋转器加载动画
* failed: 失败状态动画
*/
export type LoadingType = 'circular' | 'spinner' | 'failed';
/**
* 加载组件返回接口
*/
export type UseLoadingReturn = {
ratio : 1;
type : LoadingType;
mode : 'raf' | 'animate'; //
color : string;//Ref<string>;
play : () => void;
failed : () => void;
clear : () => void;
destroy : () => void;
pause : () => void;
}
/**
* 计算圆周上指定角度的点的坐标
* @param centerX 圆心的 X 坐标
* @param centerY 圆心的 Y 坐标
* @param radius 圆的半径
* @param angleDegrees 角度(以度为单位)
* @returns 包含 X 和 Y 坐标的对象
*/
function getPointOnCircle(
centerX : number,
centerY : number,
radius : number,
angleDegrees : number
) : number[] {
// 将角度转换为弧度
const angleRadians = (angleDegrees * Math.PI) / 180;
// 计算点的 X 和 Y 坐标
const x = centerX + radius * Math.cos(angleRadians);
const y = centerY + radius * Math.sin(angleRadians);
return [x, y]
}
export function useLoading(element : Ref<UniElement | null>) : UseLoadingReturn {
const tick = ref<TickType>('pause')
const state = reactive<UseLoadingReturn>({
color: '#000',
type: 'circular',
ratio: 1,
mode: 'raf',
play: () => {
tick.value = 'play'
},
failed: () => {
tick.value = 'failed'
},
clear: () => {
tick.value = 'clear'
},
destroy: () => {
tick.value = 'destroy'
},
pause: () => {
tick.value = 'pause'
}
})
const context = shallowRef<DrawableContext | null>(null);
// let ctx:DrawableContext|null = null
// let rotation = 0
let isPlaying = false
let canvasWidth = ref(0)
let canvasHeight = ref(0)
let canvasSize = ref(0)
let animationFrameId = -1
let animation : UniAnimation | null = null
let drawFrame : (() => void) | null = null
const size = computed(() : number => state.ratio > 1 ? state.ratio : canvasSize.value * state.ratio)
// 绘制圆形加载
const drawCircular = () => {
let startAngle = 0; // 起始角度
let endAngle = 0; // 结束角度
let rotate = 0; // 旋转角度
// const ctx = context.value!
// 动画参数配置
const MIN_ANGLE = 5; // 最小保持角度
const ARC_LENGTH = 359.5 // 最大弧长(避免闭合)
const PI = Math.PI / 180 // 角度转弧度系数
const SPEED = 0.018 / 4 // 动画速度
const ROTATE_INTERVAL = 0.09 / 4 // 旋转增量
const lineWidth = size.value / 10; // 线宽计算
const x = canvasWidth.value / 2 // 中心点X
const y = canvasHeight.value / 2 // 中心点Y
const radius = size.value / 2 - lineWidth // 实际绘制半径
try {
drawFrame = () => {
if (context.value == null || !isPlaying) return
let ctx = context.value!
// console.log('radius', radius, size.value)
ctx.reset();
// 绘制圆弧
ctx.beginPath();
ctx.arc(
x,
y,
radius,
startAngle * PI + rotate,
endAngle * PI + rotate
);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = state.color;
ctx.stroke();
// 角度更新逻辑
if (endAngle < ARC_LENGTH) {
endAngle = Math.min(ARC_LENGTH, endAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
} else if (startAngle < ARC_LENGTH) {
startAngle = Math.min(ARC_LENGTH, startAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
} else {
// 重置时保留最小可见角度
startAngle = 0;
endAngle = MIN_ANGLE;
}
ctx.update()
if (state.mode == 'raf') {
rotate = (rotate + ROTATE_INTERVAL) % 360; // 持续旋转并限制范围
if (isPlaying && drawFrame != null) {
animationFrameId = requestAnimationFrame(drawFrame!)
}
}
}
} catch(err) {
}
}
let lastTime = Date.now();
const drawSpinner = () => {
const steps = 12; // 旋转线条数量
// const size = state.ratio > 1 ? state.ratio : canvasSize.value
const lineWidth = size.value / 10; // 线宽
const x = canvasWidth.value / 2 // 中心坐标
const y = canvasHeight.value / 2
let step = 0; // 当前步数
// #ifdef APP-HARMONY
const length = size.value / 3.4 - lineWidth; // 线长
// #endif
// #ifndef APP-HARMONY
const length = size.value / 3.6 - lineWidth; // 线长
// #endif
const offset = size.value / 4; // 距中心偏移
/** 生成颜色渐变数组 */
function generateColorGradient(hex : string, steps : number) : string[] {
const colors : string[] = []
const _color = tinyColor(hex)
for (let i = 1; i <= steps; i++) {
_color.setAlpha(i / steps);
colors.push(_color.toRgbString());
}
return colors
}
// 计算颜色渐变
let colors = computed(() : string[] => generateColorGradient(state.color, steps))
/** 帧绘制函数 */
drawFrame = () => {
if (context.value == null || !isPlaying) return
const delta = Date.now() - lastTime;
if (delta >= 1000 / 10) {
lastTime = Date.now();
let ctx = context.value!
ctx.reset();
for (let i = 0; i < steps; i++) {
const stepAngle = 360 / steps; // 单步角度
const angle = stepAngle * i; // 当前角度
const index = (steps + i - step) % steps // 颜色索引
// 计算线段坐标
const radian = angle * Math.PI / 180;
const cos = Math.cos(radian);
const sin = Math.sin(radian);
// 绘制线段
ctx.beginPath();
ctx.moveTo(x + offset * cos, y + offset * sin);
ctx.lineTo(x + (offset + length) * cos, y + (offset + length) * sin);
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.strokeStyle = colors.value[index];
ctx.stroke();
}
ctx.update()
if(state.mode == 'raf') {
// step += 1
step = (step + 1) % steps; // 限制step范围
}
}
if (state.mode == 'raf') {
if (isPlaying && drawFrame != null) {
animationFrameId = requestAnimationFrame(drawFrame!)
}
}
}
}
const drwaFailed = () => {
if (context.value == null) return
let ctx = context.value!
// const size = state.ratio > 1 ? state.ratio : canvasSize.value
const innerSize = size.value * 0.8 // 内圈尺寸
const lineWidth = innerSize / 10; // 线宽
const lineLength = (size.value - lineWidth) / 2 // X长度
const centerX = canvasWidth.value / 2;
const centerY = canvasHeight.value / 2;
const radius = (size.value - lineWidth) / 2
const angleRadians1 = 45 * Math.PI / 180
const angleRadians2 = (45 - 90) * Math.PI / 180
ctx.reset()
ctx.lineWidth = lineWidth;
ctx.strokeStyle = state.color;
// 绘制逐渐显示的圆
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = state.color;
ctx.stroke();
const [startX1, startY] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 45)
const [startX2] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 90 + 45)
const x2 = Math.sin(angleRadians1) * lineLength + startX1
const y2 = Math.cos(angleRadians1) * lineLength + startY
ctx.beginPath();
ctx.moveTo(startX1, startY)
ctx.lineTo(x2, y2)
ctx.stroke();
const x3 = Math.sin(angleRadians2) * lineLength + startX2
const y3 = Math.cos(angleRadians2) * lineLength + startY
ctx.beginPath();
ctx.moveTo(startX2, startY)
ctx.lineTo(x3, y3)
ctx.stroke();
ctx.update()
}
let currentType : LoadingType | null = null
const useMode = () => {
if (state.mode != 'raf') {
const keyframes = [{ transform: 'rotate(0)' }, { transform: 'rotate(360)' }]
animation = element.value!.animate(keyframes, {
duration: 80000,
easing: 'linear',
// fill: 'forwards',
iterations: Infinity
})
}
}
const startAnimation = (type : string) => {
if (context.value == null || element.value == null) return
animation?.pause()
if (currentType == type) {
isPlaying = true
animation?.play()
drawFrame?.()
return
}
if (type == 'circular') {
currentType = 'circular'
drawCircular()
useMode()
}
if (type == 'spinner') {
currentType = 'spinner'
drawSpinner()
useMode()
}
isPlaying = true
drawFrame?.()
}
// 监听元素尺寸
const getBoundingClientRect = () => {
requestAnimationFrame(()=> {
element.value?.getBoundingClientRectAsync()?.then(rect => {
if (rect.width == 0 || rect.height == 0) return
context.value = element.value!.getDrawableContext() as DrawableContext;
canvasWidth.value = rect.width;
canvasHeight.value = rect.height;
canvasSize.value = Math.min(rect.width, rect.height);
// startAnimation(state.type)
})
})
}
const resizeObserver : UniResizeObserver = new UniResizeObserver((_entries : UniResizeObserverEntry[]) => {
getBoundingClientRect()
});
watchEffect(() => {
if (element.value == null) return
resizeObserver.observe(element.value!);
// #ifdef APP-IOS
getBoundingClientRect()
// #endif
})
watchEffect(() => {
if (context.value == null) return
if (tick.value == 'play') {
animation?.pause()
isPlaying = false
cancelAnimationFrame(animationFrameId)
startAnimation(state.type)
}
if (tick.value == 'failed') {
cancelAnimationFrame(animationFrameId)
animation?.pause()
animation?.cancel()
drwaFailed()
return
}
if (tick.value == 'clear') {
cancelAnimationFrame(animationFrameId)
animation?.pause()
animation?.cancel()
context.value?.reset();
context.value?.update();
isPlaying = false
return
}
if (tick.value == 'destroy') {
cancelAnimationFrame(animationFrameId)
animation?.pause()
animation?.cancel()
context.value?.reset();
context.value?.update();
context.value = null
animation = null
isPlaying = false
return
}
if (tick.value == 'pause') {
// 首次需要绘制一帧
if(animation == null) {
startAnimation(state.type)
}
cancelAnimationFrame(animationFrameId)
isPlaying = false
animation?.pause()
return
}
})
watchEffect(()=>{
if(state.color == '') return
// #ifdef APP-HARMONY
isPlaying = false
cancelAnimationFrame(animationFrameId)
// #endif
})
return state
}

View File

@@ -0,0 +1,595 @@
// 引入颜色处理库
import { tinyColor } from '@/uni_modules/lime-color';
// ===================== 类型定义 =====================
/**
* 加载动画类型
* circular: 环形加载动画
* spinner: 旋转器加载动画
* failed: 失败状态动画
*/
export type LoadingType = 'circular' | 'spinner' | 'failed';
/**
* 操作类型
* play: 开始动画
* failed: 显示失败状态
* clear: 清除动画
* destroy: 销毁实例
*/
export type TickType = 'play' | 'failed' | 'clear' | 'destroy' | 'pause'
/**
* 加载组件配置选项
* @property type - 初始动画类型
* @property strokeColor - 线条颜色
* @property ratio - 尺寸比例
* @property immediate - 是否立即启动
*/
export type UseLoadingOptions = {
type : LoadingType;
strokeColor : string;
ratio : number;
immediate ?: boolean;
};
/**
* 加载组件返回接口
*/
export type UseLoadingReturn = {
// state : Ref<boolean>;
// setOptions: (options: UseLoadingOptions) => void
ratio : 1;
type : LoadingType;
color : string;//Ref<string>;
play : () => void;
failed : () => void;
clear : () => void;
destroy : () => void;
pause : () => void;
}
/**
* 画布尺寸信息
*/
export type Dimensions = {
width : number;
height : number;
size : number
}
/**
* 线段坐标点
*/
type Point = {
x1 : number
y1 : number
x2 : number
y2 : number
}
/**
* 画布上下文信息
*/
type LoadingCanvasContext = {
ctx : Ref<DrawableContext | null>;
dimensions : Ref<Dimensions>;
updateDimensions : (el : UniElement) => void;
};
/**
* 动画参数配置
*/
type AnimationParams = {
width : number
height : number
center : number[] // 元组类型,明确表示两个数值的坐标
color : string // 使用Ref类型包裹字符串
size : number // 数值类型尺寸
}
// ===================== 动画管理器 =====================
type AnimationFrameHandler = () => boolean;
/**
* 动画管理类
* 封装动画的启动/停止逻辑
*/
export class AnimationManager {
time : number = 1000 / 60 // 默认帧率60fps
private timer : number = -1;// 定时器ID
private isDestroyed : boolean = false; // 销毁状态
private drawFrame : AnimationFrameHandler// 帧绘制函数
private lastTime : number = 0;
private isRunning : boolean = false;
type : LoadingType;
constructor(drawFrame : AnimationFrameHandler, type : LoadingType = 'circular') {
this.type = type
this.drawFrame = drawFrame
}
/** 启动动画循环 */
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTime = Date.now();
let animate : ((task : number) => void) | null = null
animate = (task : number) => {
if (!this.isRunning) return;
if (this.isDestroyed) return;
const delta = Date.now() - this.lastTime;
if (delta >= this.time || delta < 10) {
const shouldContinue : boolean = this.drawFrame();
this.lastTime = Date.now()
if (!shouldContinue) {
this.stop();
return;
}
}
this.timer = requestAnimationFrame(animate!);
};
animate(Date.now());
}
/** 停止动画并清理资源 */
stop() {
cancelAnimationFrame(this.timer)
this.isDestroyed = true;
}
pause() {
this.isRunning = false;
cancelAnimationFrame(this.timer);
}
}
// ===================== 工具函数 =====================
/**
* 缓动函数 - 三次缓入缓出
* @param t 时间系数 (0-1)
* @returns 计算后的进度值
*/
function easeInOutCubic(t : number) : number {
return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1;
}
// ===================== 画布管理 =====================
/**
* 获取画布上下文信息
* @param element 画布元素引用
* @returns 包含画布上下文和尺寸信息的对象
*/
//_element : Ref<UniElement | null>
export function useCanvas() : LoadingCanvasContext {
const ctx = shallowRef<DrawableContext | null>(null);
const dimensions = ref<Dimensions>({
width: 0,
height: 0,
size: 0
});
const updateDimensions = (el : UniElement) => {
// const rect = el.getBoundingClientRect();
// // 鸿蒙尺寸为0时 再次尺寸变化会不渲染
// if(rect.width == 0) return
// if(ctx.value == null) {
// ctx.value = el.getDrawableContext() as DrawableContext;
// }
// dimensions.value.width = rect.width;
// dimensions.value.height = rect.height;
// dimensions.value.size = Math.min(rect.width, rect.height);
el.getBoundingClientRectAsync()?.then(rect => {
if (rect.width == 0 || rect.height == 0) return
ctx.value = el.getDrawableContext() as DrawableContext;
dimensions.value.width = rect.width;
dimensions.value.height = rect.height;
dimensions.value.size = Math.min(rect.width, rect.height);
})
};
return {
ctx,
dimensions,
updateDimensions
} as LoadingCanvasContext
}
// ===================== 动画创建函数 =====================
/**
* 创建环形加载动画
* @param ctx 画布上下文
* @param animationParams 动画参数
* @returns 动画管理器实例
*/
function createCircularAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
const { size, color, width, height } = animationParams
let startAngle = 0; // 起始角度
let endAngle = 0; // 结束角度
let rotate = 0; // 旋转角度
// 动画参数配置
const MIN_ANGLE = 5; // 最小保持角度
const ARC_LENGTH = 359.5 // 最大弧长(避免闭合)
const PI = Math.PI / 180 // 角度转弧度系数
const SPEED = 0.018 // 动画速度
const ROTATE_INTERVAL = 0.09 // 旋转增量
const lineWidth = size / 10; // 线宽计算
const x = width / 2 // 中心点X
const y = height / 2 // 中心点Y
const radius = size / 2 - lineWidth // 实际绘制半径
/** 帧绘制函数 */
const drawFrame = () : boolean => {
ctx.reset();
// 绘制圆弧
ctx.beginPath();
ctx.arc(
x,
y,
radius,
startAngle * PI + rotate,
endAngle * PI + rotate
);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
// 角度更新逻辑
if (endAngle < ARC_LENGTH) {
endAngle = Math.min(ARC_LENGTH, endAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
} else if (startAngle < ARC_LENGTH) {
startAngle = Math.min(ARC_LENGTH, startAngle + (ARC_LENGTH - MIN_ANGLE) * SPEED);
} else {
// 重置时保留最小可见角度
startAngle = 0;
endAngle = MIN_ANGLE;
}
rotate = (rotate + ROTATE_INTERVAL) % 360; // 持续旋转并限制范围
ctx.update()
return true
}
return new AnimationManager(drawFrame)
}
/**
* 创建旋转器动画
* @param ctx 画布上下文
* @param animationParams 动画参数
* @returns 动画管理器实例
*/
function createSpinnerAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
const { size, color, center } = animationParams
const steps = 12; // 旋转线条数量
let step = 0; // 当前步数
const lineWidth = size / 10; // 线宽
// #ifdef APP-HARMONY
const length = size / 3.4 - lineWidth; // 线长
// #endif
// #ifndef APP-HARMONY
const length = size / 3.6 - lineWidth; // 线长
// #endif
const offset = size / 4; // 距中心偏移
const [x, y] = center // 中心坐标
/** 生成颜色渐变数组 */
function generateColorGradient(hex : string, steps : number) : string[] {
const colors : string[] = []
const _color = tinyColor(hex)
for (let i = 1; i <= steps; i++) {
_color.setAlpha(i / steps);
colors.push(_color.toRgbString());
}
return colors
}
// 计算颜色渐变
let colors = computed(() : string[] => generateColorGradient(color, steps))
/** 帧绘制函数 */
const drawFrame = () : boolean => {
ctx.reset();
for (let i = 0; i < steps; i++) {
const stepAngle = 360 / steps; // 单步角度
const angle = stepAngle * i; // 当前角度
const index = (steps + i - step) % steps // 颜色索引
// const index = (i + step) % steps;
// console.log('index', index)
// 计算线段坐标
const radian = angle * Math.PI / 180;
const cos = Math.cos(radian);
const sin = Math.sin(radian);
// 绘制线段
ctx.beginPath();
ctx.moveTo(x + offset * cos, y + offset * sin);
ctx.lineTo(x + (offset + length) * cos, y + (offset + length) * sin);
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
ctx.strokeStyle = colors.value[index];
ctx.stroke();
}
// step += 1
step = (step + 1) % steps; // 限制step范围
ctx.update()
return true
}
return new AnimationManager(drawFrame)
}
/**
* 计算圆周上指定角度的点的坐标
* @param centerX 圆心的 X 坐标
* @param centerY 圆心的 Y 坐标
* @param radius 圆的半径
* @param angleDegrees 角度(以度为单位)
* @returns 包含 X 和 Y 坐标的对象
*/
function getPointOnCircle(
centerX : number,
centerY : number,
radius : number,
angleDegrees : number
) : number[] {
// 将角度转换为弧度
const angleRadians = (angleDegrees * Math.PI) / 180;
// 计算点的 X 和 Y 坐标
const x = centerX + radius * Math.cos(angleRadians);
const y = centerY + radius * Math.sin(angleRadians);
return [x, y]
}
/**
* 创建失败状态动画包含X图标和外围圆圈
* @param ctx 画布上下文
* @param animationParams 动画参数
* @returns 动画管理器实例
*/
function createFailedAnimation(ctx : DrawableContext, animationParams : AnimationParams) : AnimationManager {
const { width, height, size, color } = animationParams
const innerSize = size * 0.8 // 内圈尺寸
const lineWidth = innerSize / 10; // 线宽
const lineLength = (size - lineWidth) / 2 // X长度
const centerX = width / 2;
const centerY = height / 2;
const [startX1, startY] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 45)
const [startX2] = getPointOnCircle(centerX, centerY, lineLength / 2, 180 + 90 + 45)
const angleRadians1 = 45 * Math.PI / 180
const angleRadians2 = (45 - 90) * Math.PI / 180
const radius = (size - lineWidth) / 2
const totalSteps = 36; // 总动画步数
function generateSteps(stepsCount : number) : Point[][] {
const halfStepsCount = stepsCount / 2;
const step = lineLength / halfStepsCount
const steps : Point[][] = []
for (let i = 0; i < stepsCount; i++) {
const sub : Point[] = []
const index = i % 18 + 1
if (i < halfStepsCount) {
const x2 = Math.sin(angleRadians1) * step * index + startX1
const y2 = Math.cos(angleRadians1) * step * index + startY
const start1 = {
x1: startX1,
y1: startY,
x2,
y2,
} as Point
sub.push(start1)
} else {
sub.push(steps[halfStepsCount - 1][0])
const x2 = Math.sin(angleRadians2) * step * index + startX2
const y2 = Math.cos(angleRadians2) * step * index + startY
const start2 = {
x1: startX2,
y1: startY,
x2,
y2,
} as Point
sub.push(start2)
}
steps.push(sub)
}
return steps
}
const steps = generateSteps(totalSteps);
const drawFrame = () : boolean => {
const drawStep = steps.shift()!
ctx.reset()
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
// 绘制逐渐显示的圆
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, (2 * Math.PI) * (totalSteps - steps.length) / totalSteps);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
// 绘制X
ctx.beginPath();
drawStep.forEach(item => {
ctx.beginPath();
ctx.moveTo(item.x1, item.y1)
ctx.lineTo(item.x2, item.y2)
ctx.stroke();
})
ctx.update()
return steps.length != 0
}
return new AnimationManager(drawFrame)
}
// ===================== 主Hook函数 =====================
/**
* 加载动画组合式函数
* @param element 画布元素引用
* @returns 加载控制器实例
*/
export function useLoading(
element : Ref<UniElement | null>,
// options : UseLoadingOptions
) : UseLoadingReturn {
const ticks = ref<TickType[]>([]);
const currentTick = ref<TickType>('clear');
const currentAnimation = shallowRef<AnimationManager | null>(null);
const state = reactive<UseLoadingReturn>({
color: '#000',
type: 'circular',
ratio: 1,
play: () => {
// if (currentTick.value == 'pause' && currentAnimation.value != null) {
// // 从暂停状态恢复时直接启动动画管理器
// currentAnimation.value?.start()
// } else {
// // 首次播放或类型变化时重新初始化
// ticks.value.length = 0
// ticks.value.push('play')
// }
ticks.value.length = 0
ticks.value.push('play')
},
failed: () => {
ticks.value.length = 0
ticks.value.push('failed')
},
clear: () => {
ticks.value.length = 0
ticks.value.push('clear')
},
destroy: () => {
ticks.value.length = 0
ticks.value.push('destroy')
},
pause: () => {
ticks.value.push('pause')
}
})
const { ctx, dimensions, updateDimensions } = useCanvas();
const resizeObserver : UniResizeObserver = new UniResizeObserver((_entries : UniResizeObserverEntry[]) => {
updateDimensions(element.value!)
});
// 计算动画参数
const animationParams = computed(() : AnimationParams => {
return {
width: dimensions.value.width,
height: dimensions.value.height,
center: [dimensions.value.width / 2, dimensions.value.height / 2],
color: state.color,
size: state.ratio > 1 ? state.ratio : dimensions.value.size * state.ratio
} as AnimationParams
})
const startAnimation = (type : LoadingType) => {
currentAnimation.value?.pause();
if (currentAnimation.value?.type == type) {
currentAnimation.value!.start()
return
}
if (type == 'circular') {
currentAnimation.value = createCircularAnimation(ctx.value!, animationParams.value)
currentAnimation.value!.time = 1000 / 30
currentAnimation.value!.start()
return
}
if (type == 'spinner') {
currentAnimation.value = createSpinnerAnimation(ctx.value!, animationParams.value)
currentAnimation.value!.time = 1000 / 10
currentAnimation.value!.start()
return
}
if (type == 'failed') {
currentAnimation.value = createFailedAnimation(ctx.value!, animationParams.value)
currentAnimation.value?.start()
return
}
}
const failed = () => {
startAnimation('failed')
}
const play = () => {
startAnimation(state.type)
}
const clear = () => {
currentAnimation.value?.stop();
ctx.value?.reset();
ctx.value?.update();
}
const destroy = () => {
clear();
resizeObserver.disconnect();
}
watch(animationParams, () => {
if (['clear', 'destroy', 'pause'].includes(currentTick.value)) return
startAnimation(state.type)
})
watchEffect(() => {
if (ctx.value == null) return
const tick = ticks.value.pop()
if (ticks.value.length > 0) { }
if (tick != null) {
currentTick.value = tick
}
if (tick == 'play') {
play()
return
}
if (tick == 'failed') {
failed()
return
}
if (tick == 'clear') {
clear()
return
}
if (tick == 'destroy') {
destroy()
return
}
if (tick == 'pause') {
if (currentAnimation.value == null) {
play()
}
currentAnimation.value?.pause();
}
})
watchEffect(() => {
if (element.value == null) return
resizeObserver.observe(element.value!);
// #ifdef APP-IOS || APP-HARMONY
// APP-HARMONY 在下拉组件中使用居中会影响绘制 故先获取一次
updateDimensions(element.value!)
// #endif
})
onUnmounted(destroy);
return state
}

View File

@@ -0,0 +1,110 @@
{
"id": "lime-loading",
"displayName": "lime-loading 加载",
"version": "0.1.9",
"description": "lime-loading 全端通用加载插件.用于表示页面或操作的加载状态给予用户反馈的同时减缓等待的焦虑感。支持uniapp/uniappx",
"keywords": [
"loading",
"lime-loading",
"加载",
"uvue"
],
"repository": "",
"engines": {
"HBuilderX": "^3.92",
"uni-app": "^4.73",
"uni-app-x": "^4.76"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"darkmode": "√",
"i18n": "x",
"widescreen": "x"
},
"uni_modules": {
"dependencies": [
"lime-shared",
"lime-style",
"lime-color"
],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "√",
"aliyun": "√",
"alipay": "x"
},
"client": {
"uni-app": {
"vue": {
"vue2": "√",
"vue3": "√"
},
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"vue": "√",
"nvue": "-",
"android": {
"extVersion": "",
"minVersion": "21"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√",
"alipay": "-",
"toutiao": "-",
"baidu": "-",
"kuaishou": "-",
"jd": "-",
"harmony": "-",
"qq": "-",
"lark": "-"
},
"quickapp": {
"huawei": "-",
"union": "-"
}
},
"uni-app-x": {
"web": {
"safari": "√",
"chrome": "√"
},
"app": {
"android": {
"extVersion": "",
"minVersion": "21"
},
"ios": "√",
"harmony": "√"
},
"mp": {
"weixin": "√"
}
}
}
}
}
}