Skip to content

卡片返转计时 + 粒子时钟

翻转卡片展示

  • 0
    0
  • 1
    1
  • 2
    2
  • 3
    3
  • 4
    4
  • 5
    5
  • 6
    6
  • 7
    7
  • 8
    8
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
翻转卡片组件 代码 点击展开

模板 和 逻辑

vue
<template>
  <ul :class="['flip-card', { dark: isDark }]">
    <li
      class="item"
      v-for="(item, key) in totalList"
      :class="{ active: active === item, before: item === before }"
      :key="item"
    >
      <div class="up">
        <div class="shadow"></div>
        <div class="number">{{ item }}</div>
      </div>
      <div class="down">
        <div class="shadow"></div>
        <div class="number">{{ item }}</div>
      </div>
    </li>
  </ul>
</template>

<script setup lang="ts">
import { computed } from "vue";
import { useData } from "vitepress";
const { isDark } = useData();
const props = withDefaults(
  defineProps<{
    total?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
    current?: number;
    countdown?: boolean;
  }>(),
  {
    total: 9,
    current: 0,
    countdown: false,
  }
);
const active = computed(() => {
  if (props.countdown) {
    return props.current < 0 ? 0 : props.current;
  } else {
    return props.current > props.total ? props.total : props.current;
  }
});
const before = computed(() => {
  if (props.countdown) {
    return active.value < props.total ? active.value + 1 : 0;
  } else {
    return active.value ? active.value - 1 : props.total;
  }
});
const totalList = computed(() => {
  const list = Array.from({ length: props.total + 1 }, (_, i) => i);
  return props.countdown ? list.reverse() : list;
});
</script>

<style lang="scss" scoped>
.vp-doc li + li {
  margin: 0;
}
@import "./flipCard.scss";
</style>

样式 和 动画

scss
@keyframes turn-down {
  0% {
    transform: rotateX(90deg);
  }
  100% {
    transform: rotateX(0deg);
  }
}

@keyframes turn-up {
  0% {
    transform: rotateX(0deg);
  }
  100% {
    transform: rotateX(-90deg);
  }
}

@keyframes zIndex {
  0% {
    z-index: 2;
  }
  5% {
    z-index: 4;
  }
  100% {
    z-index: 4;
  }
}

@keyframes show {
  0% {
    opacity: 0;
  }
  100% {
    opacity: 1;
  }
}

@keyframes hide {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

.flip-card {
  --flip-card-width: 60px;
  --flip-card-height: 90px;
  --flip-card-radius: 6px;
  --flip-card-line-height: 1px;
  --flip-card-animation-time: 0.5s;
  --flip-card-number-color: #333;
  --flip-card-number-background-color: #ddd;
  --flip-card-box-shadow-color: rgba(0, 0, 0, 0.6);
  --flip-card-line-color: rgba(200, 200, 200, 0.4);
  --flip-card-flip-shadow-color: rgba(50, 50, 50, 0.1);
  --flip-card-flip-shadow-color2: rgba(50, 50, 50, 1);
  &.dark {
    --flip-card-number-color: #ccc;
    --flip-card-number-background-color: #333;
    --flip-card-box-shadow-color: rgba(0, 0, 0, 0.7);
    --flip-card-line-color: rgba(0, 0, 0, 0.4);
    --flip-card-flip-shadow-color: rgba(0, 0, 0, 0.1);
    --flip-card-flip-shadow-color2: rgba(0, 0, 0, 1);
  }
  position: relative;
  margin: 5px;
  width: var(--flip-card-width);
  height: var(--flip-card-height);
  font-size: 80px;
  font-weight: bold;
  line-height: calc(var(--flip-card-height) - var(--flip-card-line-height));
  border-radius: var(--flip-card-radius);
  box-shadow: 0 1px 10px var(--flip-card-box-shadow-color);
  &:after {
    content: "";
    position: absolute;
    width: 100%;
    height: var(--flip-card-line-height);
    top: 50%;
    left: 0;
    background-color: var(--flip-card-line-color);
    z-index: 5;
  }

  .item {
    list-style: none;
    z-index: 1;
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    perspective: 200px;
    &.active,
    &:first-child {
      z-index: 2;
    }
    .up,
    .down {
      z-index: 1;
      position: absolute;
      left: 0;
      width: 100%;
      height: 50%;
      overflow: hidden;
    }
    .up {
      transform-origin: 50% 100%;
      top: 0;
    }
    .down {
      transform-origin: 50% 0%;
      bottom: 0;
      transition: opacity 0.3s;
      border-radius: 0 0 var(--flip-card-radius) var(--flip-card-radius);
    }
    .number {
      position: absolute;
      left: 0;
      z-index: 1;
      width: 100%;
      height: 200%;
      color: var(--flip-card-number-color);
      text-shadow: 0 1px 2px var(--flip-card-number-color);
      text-align: center;
      background-color: var(--flip-card-number-background-color);
      border-radius: var(--flip-card-radius);
    }
    .up .number {
      top: 0;
    }
    .down .number {
      bottom: 0;
    }
    &.before {
      z-index: 3;
    }
    &.active {
      animation: zIndex calc(1s - var(--flip-card-animation-time))
        var(--flip-card-animation-time) linear both;
      z-index: 2;
    }
    &.before .up {
      z-index: 2;
      animation: turn-up var(--flip-card-animation-time) linear both;
    }
    &.active .down {
      z-index: 2;
      animation: turn-down calc(1s - var(--flip-card-animation-time))
        var(--flip-card-animation-time) linear both;
    }
  }
  .shadow {
    position: absolute;
    width: 100%;
    height: 100%;
    z-index: 2;
  }
  .before .up .shadow {
    background: linear-gradient(
      var(--flip-card-flip-shadow-color) 0%,
      var(--flip-card-flip-shadow-color2) 100%
    );
    animation: show var(--flip-card-animation-time) linear both;
  }
  .active .up .shadow {
    background: linear-gradient(
      var(--flip-card-flip-shadow-color) 0%,
      var(--flip-card-flip-shadow-color2) 100%
    );
    animation: hide var(--flip-card-animation-time) 0.3s linear both;
  }
  .before .down .shadow {
    background: linear-gradient(
      var(--flip-card-flip-shadow-color2) 0%,
      var(--flip-card-flip-shadow-color) 100%
    );
    animation: show var(--flip-card-animation-time) linear both;
  }
  .active .down .shadow {
    background: linear-gradient(
      var(--flip-card-flip-shadow-color2) 0%,
      var(--flip-card-flip-shadow-color) 100%
    );
    animation: hide var(--flip-card-animation-time) 0.3s linear both;
  }
}
翻转卡片调用 代码 点击展开
vue
<template>
  <div class="cardTest">
    <el-button @click="countChange">加一下</el-button>
    <flipCard :total="8" :current="refCount"></flipCard>
    <flipCard :total="8" :current="refCount2" countdown></flipCard>
    <el-button @click="countChange2">减一下</el-button>
  </div>
</template>

<script setup lang="ts">
import flipCard from "./flipCard.vue";
import { ref } from "vue";
const refCount = ref(0);
const refCount2 = ref(8);
const countChange = () => {
  if (refCount.value === 8) {
    refCount.value = 0;
  } else {
    refCount.value++;
  }
};
const countChange2 = () => {
  if (refCount2.value === 0) {
    refCount2.value = 8;
  } else {
    refCount2.value--;
  }
};
</script>

<style lang="scss" scoped>
.cardTest {
  display: flex;
  align-items: center;
}
</style>

翻转卡片 视频讲解

小长假倒计时

距离下一个小长假 还有
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
  • 2
    2
  • 1
    1
  • 0
    0
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
:
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
:
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
  • 9
    9
  • 8
    8
  • 7
    7
  • 6
    6
  • 5
    5
  • 4
    4
  • 3
    3
  • 2
    2
  • 1
    1
  • 0
    0
小长假倒计时 代码 点击展开
vue
<template>
  <div class="festivalCountdown">
    <div class="title cor-tip" v-if="nowFestivalText">
      现在正在 <TText type="danger">{{ nowFestivalText }}</TText> 小长假中
    </div>
    <div class="title cor-tip">
      距离下一个小长假 <TText type="danger">{{ festivalText }}</TText> 还有
    </div>
    <div class="show-clock" v-loading="loading">
      <div class="day">
        <flipCard :total="9" :current="diffDayList[0]" countdown></flipCard>
        <flipCard :total="9" :current="diffDayList[1]" countdown></flipCard>
        <flipCard :total="9" :current="diffDayList[2]" countdown></flipCard>
      </div>
      <div class="time-text">天</div>
      <div class="clock">
        <flipCard :total="2" :current="diffTimeList[0]" countdown></flipCard>
        <flipCard :total="9" :current="diffTimeList[1]" countdown></flipCard>
        <div class="time-text">:</div>
        <flipCard :total="5" :current="diffTimeList[2]" countdown></flipCard>
        <flipCard :total="9" :current="diffTimeList[3]" countdown></flipCard>
        <div class="time-text">:</div>
        <flipCard :total="5" :current="diffTimeList[4]" countdown></flipCard>
        <flipCard :total="9" :current="diffTimeList[5]" countdown></flipCard>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import flipCard from "./flipCard.vue";
import { getFestival } from "docs/api/mxnzp.ts";
import { diffDate } from "docs/utils/index.ts";
import { ref, onMounted, onUnmounted } from "vue";
const festivalText = ref("");
const nowFestivalText = ref("");
const loading = ref(false);
const diffDayList = ref([0, 0, 0]);
const diffTimeList = ref([0, 0, 0, 0, 0, 0]);
let onLineTime = 0;
const getRecentlyFestival = () => {
  loading.value = true;
  const nowDate = new Date();
  getFestival(nowDate.getFullYear())
    .then((data: any) => {
      const festivalList = data
        .reduce((prev: any, month: any) => {
          return [...prev, ...month.days];
        }, [])
        .filter((item: any) => {
          return diffDate(nowDate, new Date(item.date)).days <= 0;
        });
      let endDate;
      if (festivalList.length === 0) {
        endDate = new Date(`${nowDate.getFullYear() + 1}-01-01`);
        festivalText.value = "元旦";
      } else {
        const firstFestival = festivalList[0];
        if (diffDate(nowDate, new Date(firstFestival.date)).days === 0) {
          const nowDes = firstFestival.typeDes;
          nowFestivalText.value = nowDes;
          for (let index = 1; index < festivalList.length; index++) {
            const element = festivalList[index];
            if (element.typeDes !== nowDes) {
              endDate = new Date(element.date);
              festivalText.value = element.typeDes;
              break;
            }
          }
          if (!endDate) {
            endDate = new Date(`${nowDate.getFullYear() + 1}-01-01`);
            festivalText.value = "元旦";
          }
        } else {
          endDate = new Date(firstFestival.date);
          festivalText.value = firstFestival.typeDes;
        }
      }
      const diffDay = Math.abs(diffDate(nowDate, endDate).days) - 1;
      const diffDayStr = diffDay.toString();
      let diffDayStrLength = diffDayStr.length;
      for (let len = 3; len--;) {
        diffDayList.value[len] = +diffDayStr[--diffDayStrLength] || 0;
      }
    })
    .catch(() => {
      onLineTime++;
      if (onLineTime < 4) {
        setTimeout(getRecentlyFestival, 1000);
      }
    })
    .finally(() => {
      loading.value = false;
    });
};
const formatTime = (time: number) => {
  return time
    .toString()
    .padStart(2, "0")
    .split("")
    .map((x) => +x);
};

const getTimeToEndDay = () => {
  const now = new Date();
  const endDay =
    new Date(
      now.getFullYear(),
      now.getMonth(),
      now.getDate(),
      23,
      59,
      59
    ).getTime() + 1000;
  const diffDay = endDay - now.getTime();
  const S = formatTime(Math.floor((diffDay / 1000) % 60));
  const M = formatTime(Math.floor((diffDay / 1000 / 60) % 60));
  const H = formatTime(Math.floor(diffDay / 1000 / 60 / 60));
  diffTimeList.value = [...H, ...M, ...S];
};

let clock: NodeJS.Timeout;
onMounted(() => {
  getRecentlyFestival();
  clearInterval(clock);
  clock = setInterval(() => {
    getTimeToEndDay();
  }, 1000);
});
onUnmounted(() => {
  clearInterval(clock);
});
</script>

<style lang="scss" scoped>
.festivalCountdown {

  .show-clock,
  .day,
  .clock {
    display: flex;
    align-items: center;
    font-size: 32px;
  }
}

// 媒体查询
@media (max-width: 768px) {
  .festivalCountdown {

    .show-clock,
    .day,
    .clock {
      flex-wrap: wrap;
      font-size: 24px;
    }
  }
}
</style>

粒子时钟

粒子时钟 代码 点击展开
vue
<template>
  <div class="content">
    <canvas class="canvas" ref="canvasRef"></canvas>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { getRandomNum } from "docs/utils/index.ts";
const canvasRef = ref<HTMLCanvasElement>();
// 设置设备像素比为1 兼容高清屏
const devicePixelRatio = 1;
// const devicePixelRatio = window.devicePixelRatio || 1;

onMounted(() => {
  if (!canvasRef.value) {
    return;
  }
  // 创建字体
  const getText = (): string => {
    return new Date().toTimeString().substring(0, 8);
  };
  const canvas: HTMLCanvasElement = canvasRef.value;
  const ctx = canvas.getContext("2d", {
    // 频繁访问像素点优化配置
    willReadFrequently: true,
  });
  // 初始化画布大小
  const initCanvasSize = () => {
    canvas.width = 650 * devicePixelRatio;
    canvas.height = 650 * devicePixelRatio;
  };
  // 粒子
  class Particle {
    x: number;
    y: number;
    size: number;
    duration: number;
    constructor() {
      // 半径
      const r = Math.min(canvas.width, canvas.height) / 2 - 10;
      // 圆心坐标
      const cx = canvas.width / 2;
      const cy = canvas.height / 2;
      // 随机角度
      const rad = (getRandomNum(0, 360) * Math.PI) / 180;
      // 粒子位置
      this.x = cx + r * Math.cos(rad);
      this.y = cy + r * Math.sin(rad);
      // 粒子大小
      this.size = getRandomNum(2 * devicePixelRatio, 7 * devicePixelRatio);
      // 位移时间
      this.duration = 500;
    }
    // 画粒子
    draw() {
      if (ctx) {
        ctx.beginPath();
        ctx.fillStyle = "rgba(200, 200, 200, .3)";
        ctx.arc(this.x, this.y, this.size, 0, 2 * Math.PI);
        ctx.fill();
      }
    }
    // 移动粒子
    moveTo(tx: number, ty: number) {
      const sx = this.x;
      const sy = this.y;
      const xSpeed = (tx - sx) / this.duration;
      const ySpeed = (ty - sy) / this.duration;
      const startTime = Date.now();
      // 缓动效果
      const _move = () => {
        const t = Date.now() - startTime;
        this.x = sx + xSpeed * t;
        this.y = sy + ySpeed * t;
        if (t > this.duration) {
          this.x = tx;
          this.y = ty;
          return;
        }
        requestAnimationFrame(_move);
      };
      _move();
    }
  }
  // 创建粒子数组
  const particles: Particle[] = [];
  let text: string = "";
  // 清空画布
  const clear = () => {
    ctx?.clearRect(0, 0, canvas.width, canvas.height);
  };
  // 获取粒子坐标数组
  const getPoints = (): number[][] | undefined => {
    if (ctx) {
      const { width, height, data } = ctx.getImageData(
        0,
        0,
        canvas.width,
        canvas.height
      );
      const points = [];
      const gap = 4;
      for (let i = 0; i < width; i += gap) {
        for (let j = 0; j < height; j += gap) {
          const index = (i + j * width) * 4;
          const r = data[index];
          const g = data[index + 1];
          const b = data[index + 2];
          const a = data[index + 3];
          if (r === 0 && g === 0 && b === 0 && a === 255) {
            points.push([i, j]);
          }
        }
      }
      return points;
    }
  };
  // 更新
  const update = () => {
    const newText = getText();
    if (text !== newText) {
      clear();
      text = newText;
      // 画文本
      if (ctx) {
        ctx.fillStyle = "#000";
        ctx.textBaseline = "middle";
        ctx.font = `${120 * devicePixelRatio}px DingTalkJinBuTi,sans-serif`;
        ctx.fillText(
          text,
          (canvas.width - ctx.measureText(text).width) / 2,
          canvas.height / 2
        );
        const points = getPoints();
        if (!points?.length) return;
        clear();
        for (let i = 0; i < points?.length; i++) {
          let p = particles[i];
          if (!p) {
            p = new Particle();
            particles.push(p);
          }
          const [x, y] = points[i];
          p.moveTo(x, y);
        }
        if (points?.length < particles.length) {
          particles.splice(points.length);
        }
      }
    }
  };
  // 绘制
  const draw = () => {
    clear();
    update();
    particles.forEach((p) => {
      p.draw();
    });
    requestAnimationFrame(draw);
  };
  if (!particles.length) {
    initCanvasSize();
    draw();
    console.log("启动");
  }
});

// 创建一个类
</script>

<style lang="scss" scoped>
.content {
  background: url("/assets/demo/bg-1.jpg") no-repeat;
  background-size: cover;

  .canvas {
    width: 100%;
  }
}
</style>

粒子时钟 视频讲解