Skip to content

自定义组件

介绍

在使用 VitePress 构建项目的时候呢,咱自己捣鼓了一些组件。现在把它们拿出来总结总结,顺便共享一下,指不定能帮到哪位小伙伴呢。我用到的所有和这些组件相关的引入,都放在下面的代码里啦。 需要用到下面组件:

yarn add element-plus
yarn add imagesloaded
yarn add crypto-js

.vitepress/theme/index.ts

import DefaultTheme from 'vitepress/theme'
import MenuCustom from './components/MenuCustom.vue'
import PasswordCheck from './components/PasswordCheck.vue'
import ImageCustom from './components/ImageCustom.vue'
import MasonryCustom from './components/MasonryCustom.vue'

// 导入elementplus组件
import "element-plus/dist/index.css";
import elementplus from "element-plus"
// 导入elementplus组件-中文
import zhCn from 'element-plus/es/locale/lang/zh-cn'
// 导入elementplus组件-暗黑模式
import 'element-plus/theme-chalk/dark/css-vars.css'
// 导入elementplus组件-图标组件
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

import './styles/custom.css'

import {
  VPBadge,
  VPButton,
  VPDocAsideSponsors,
  VPHomeFeatures,
  VPHomeHero,
  VPHomeSponsors,
  VPImage,
  VPSponsors,
  VPTeamMembers,
  VPTeamPage,
  VPTeamPageSection,
  VPTeamPageTitle
} from 'vitepress/theme-without-fonts'

export default {
  ...DefaultTheme,
  // extends: DefaultTheme,
  enhanceApp({ app }) {
    // DefaultTheme.enhanceApp(ctx);
    app.component('MenuCustom', MenuCustom);
    app.component('PasswordCheck', PasswordCheck);
    app.component('ImageCustom', ImageCustom);
    app.component('MasonryCustom', MasonryCustom);
    app.component('Badge', VPBadge);
    app.component('Button', VPButton);
    app.component('DocAsideSponsors', VPDocAsideSponsors);
    app.component('HomeFeatures', VPHomeFeatures);
    app.component('HomeHero', VPHomeHero);
    app.component('HomeSponsors', VPHomeSponsors);
    app.component('Image', VPImage);
    app.component('Sponsors', VPSponsors);
    app.component('TeamMembers', VPTeamMembers);
    app.component('TeamPage', VPTeamPage);
    app.component('TeamPageSection', VPTeamPageSection);
    app.component('TeamPageTitle', VPTeamPageTitle);

    app.use(elementplus, {
      locale: zhCn,
    });
    for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
      app.component(key, component)
    }
  }
}

轮播组件

其实就是采用element的走马灯来制作的

vue
<!--
 * @Author: trexwb
 * @Date: 2024-10-18 13:50:54
 * @LastEditors: trexwb
 * @LastEditTime: 2024-10-18 14:16:55
 * @FilePath: /xuanyun.wang/docs/attachment/code/vue/components/ImageCustom.vue
 * @Description: 
 * 一花一世界,一叶一如来
 * Copyright (c) 2024 by 杭州大美, All Rights Reserved. 
-->
<template>
  <div ref="container" class="image__preview" v-loading="loading">
    <el-carousel :interval="5000" arrow="always">
      <el-carousel-item v-for="(item, index) in srcList" :key="index">
        <el-link v-if="item.href" :underline="false" :href="item.href"><el-image style="width: 100%;"
            :src="item.src || item.photo" /></el-link>
        <el-image v-else style="width: 100%;" :src="item.src || item.photo" @click="handleImageClick(index)" />
      </el-carousel-item>
    </el-carousel>
    <el-image-viewer v-if="previewVisible" @close="handleClose" :url-list="allPhotos" :initial-index="currentIndex"></el-image-viewer>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, PropType, nextTick } from 'vue';
import imagesLoaded from 'imagesloaded';

// 定义图片项的接口
interface Item {
  id?: String; // 假设每个 item 都有一个唯一的 id
  title?: String;
  src?: String;
  photo?: Array<any>;
  href?: String;
  content?: String;
}
// 接受一个图片列表作为属性
const props = defineProps({
  srcList: {
    type: Array as PropType<Item[]>,
    required: true
  },
});
// 控制加载状态
const loading = ref(true);
// 控制图片预览的可见状态
const previewVisible = ref(false);
// 当前预览图片的索引
const currentIndex = ref(0);
// 所有图片的列表
const allPhotos = ref<any>([]);
// 容器元素的引用
const container = ref<HTMLElement | null>(null);
// 获取第一张图片的URL
const getUrl = () => {
  return Array.isArray(props.srcList) ? props.srcList[0] : (props.srcList ?? '');
}
// 处理图片点击事件
const handleImageClick = (index: number) => {
  currentIndex.value = index;
  previewVisible.value = true;
}
// 处理预览关闭事件
const handleClose = () => {
  previewVisible.value = false;
}
// 初始化所有图片的列表
allPhotos.value = Array.isArray(props.srcList) ? props.srcList.map(item => item.photo) : [];
// 在 mounted 钩子中初始化 Masonry
onMounted(async () => {
  if (container.value) {
    imagesLoaded(container.value, { background: true }, async () => {
      await nextTick();
      loading.value = false;
    });
  }
});
</script>

<style scoped>
.el-carousel__item h3 {
  color: #475669;
  opacity: 0.75;
  line-height: 300px;
  margin: 0;
  text-align: center;
}

.el-carousel__item:nth-child(2n) {
  background-color: #99a9bf;
}

.el-carousel__item:nth-child(2n + 1) {
  background-color: #d3dce6;
}

.image__error .image-slot {
  font-size: 30px;
}

.image__error .image-slot .el-icon {
  font-size: 30px;
}

.image__error .el-image {
  width: 100%;
  height: 200px;
}
</style>

调方式

vue
<ImageCustom :srcList="demoImages" />

<script setup>
const demoImages = [
  {
    "href": false,
    "src": "http://attach.dmcdn.com/riss/2023/12/26/01_57_49_kvexk488ftyzn4sjft5xrysqjedynz06.jpg!a300",
    "photo": "http://attach.dmcdn.com/riss/2023/12/26/01_57_49_kvexk488ftyzn4sjft5xrysqjedynz06.jpg!a800"
  },
  {
    "href": false,
    "src": "http://attach.dmcdn.com/riss/2024/01/25/09_05_55_iwphzecvoq3l4a0pb3kznxjz29a08djq.jpg!a300",
    "photo": "http://attach.dmcdn.com/riss/2024/01/25/09_05_55_iwphzecvoq3l4a0pb3kznxjz29a08djq.jpg!a800"
  }
]
</script>

瀑布流

目前这个组件还在改进当中,只是将就能用

vue
<!--
 * @Author: trexwb
 * @Date: 2024-10-16 11:28:39
 * @LastEditors: trexwb
 * @LastEditTime: 2024-10-18 14:21:26
 * @FilePath: /xuanyun.wang/docs/attachment/code/vue/components/MasonryCustom.vue
 * @Description: 
 * 一花一世界,一叶一如来
 * Copyright (c) 2024 by 杭州大美, All Rights Reserved. 
-->
<template>
  <div class="album-list" v-loading="loading">
    <div class="card-container" :style="waterfallStyle" ref="container">
      <el-card v-for="(item, index) in dataList" :key="index" :body-style="{ padding: '0px' }" shadow="always"
        class="card-item">
        <template #header>{{ item.title }}</template>
        <el-link v-if="item.href" :underline="false" :href="item.href">
          <el-image style="width: 100%;" :src="item.src || item.photos?.[0]" />
        </el-link>
        <el-image v-else style="width: 100%;" :src="item.src || item.photos?.[0]" :preview-src-list="item.photos ?? []" />
        <template #footer>{{ item.content }}</template>
      </el-card>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, PropType, onMounted, onBeforeUnmount, watch, nextTick, toDisplayString } from 'vue';
import { ElCard, ElLoading } from 'element-plus';
import imagesLoaded from 'imagesloaded';

interface Item {
  id?: String; // 假设每个 item 都有一个唯一的 id
  title?: String;
  src?: String;
  photos?: Array<any>;
  href?: String;
  content?: String;
}

const props = defineProps({
  dataList: {
    type: Array as PropType<Item[]>,
    required: true
  },
});

const waterfallStyle = ref({});
const loading = ref(true);
waterfallStyle.value = {
  'position': 'absolute',
  'visibility': 'hidden'
}

const container = ref<HTMLElement | null>(null);

const arrangeCards = async () => {
  if (!container.value) return;

  const columns: HTMLElement[] = [];
  const items = Array.from(container.value.children);

  // 初始化列
  for (let i = 0; i < 4; i++) {
    const column = document.createElement('div');
    column.classList.add('column'); // 添加 column 类
    container.value!.appendChild(column);
    columns.push(column);
  }

  // 清空现有列
  for (let col of columns) {
    while (col.firstChild) {
      col.removeChild(col.firstChild);
    }
  }

  // 将卡片分配到列中
  let colIndex = 0;
  for (let item of items) {
    let minHeight = Infinity; // 初始化为正无穷大
    let shortestColumn: HTMLElement | null = null;

    // 找到当前最短的列
    for (const [index, col] of columns.entries()) {
      // console.log(`col[${index}]:`, col.offsetHeight, minHeight)
      if (col.offsetHeight < minHeight) {
        minHeight = col.offsetHeight;
        shortestColumn = col;
        colIndex = index;
      }
    }

    if (shortestColumn) {
      // 将卡片添加到最短的列
      shortestColumn.appendChild(item);
      await nextTick(); // 确保 DOM 更新后再继续
      // console.log(`minHeight[${colIndex}]:`, shortestColumn.offsetHeight, minHeight)
    }
  }
  waterfallStyle.value = {
    'display': 'grid',
    'grid-template-columns': 'repeat(4, 1fr)'
  }
  loading.value = false;
};

onMounted(async () => {
  if (container.value) {
    imagesLoaded(container.value, { background: true }, async () => {
      await nextTick();
      setTimeout(() => {
        requestAnimationFrame(() => {
          arrangeCards();
        });
      }, 500);
    });
    // 监听窗口大小变化
    window.addEventListener('resize', arrangeCards);
  }
});

onBeforeUnmount(() => {
  // 清理事件监听器
  window.removeEventListener('resize', arrangeCards);
});
</script>

<style scoped>
.album-list {
  min-height: 300px;
}
.card-container {
  /* display: grid;
  grid-template-columns: repeat(4, 1fr); */
  gap: 5px;
  margin: 5px;
}

.card-container .column {
  width: 100%; /* 考虑间距 */
  box-sizing: border-box;
}

.card-item {
  box-sizing: border-box;
  margin: 10px 2px;
}
</style>

调方式

vue
<MasonryCustom :dataList="demoList" />

<script setup>
const demoList = [
  {
      "id": "1", // 随意写即可,未来可以用来做传参
      "title": "测试title名称",
      "href": false,
      "src": "http://attach.dmcdn.com/riss/2023/12/26/01_57_49_kvexk488ftyzn4sjft5xrysqjedynz06.jpg!a300"
      "photos": [
        "http://attach.dmcdn.com/riss/2023/12/26/01_57_49_kvexk488ftyzn4sjft5xrysqjedynz06.jpg!a800",
        "http://attach.dmcdn.com/riss/2024/01/25/09_05_55_iwphzecvoq3l4a0pb3kznxjz29a08djq.jpg!a800"
      ],
      "content": "2024.07.12"
    },
    {
      "id": "2",
      "title": "测试title名称1",
      "href": false,
      "src": "http://attach.dmcdn.com/riss/2024/01/25/09_05_55_iwphzecvoq3l4a0pb3kznxjz29a08djq.jpg!a300"
      "photos": [
        "http://attach.dmcdn.com/riss/2024/01/25/09_05_55_iwphzecvoq3l4a0pb3kznxjz29a08djq.jpg!a800"
      ],
      "content": "2022.01.02"
    },
    ...
]
</script>

访问密码

目前这个组件还在改进当中,只是将就能用

vue
<!--
 * @Author: trexwb
 * @Date: 2024-06-18 15:45:22
 * @LastEditors: trexwb
 * @LastEditTime: 2024-10-18 14:25:21
 * @FilePath: /xuanyun.wang/docs/attachment/code/vue/components/PasswordCheck.vue
 * @Description: 
 * 一花一世界,一叶一如来
 * Copyright (c) 2024 by 杭州大美, All Rights Reserved. 
-->
<template>
  <el-dialog v-model="showMask" :modal="true" :lock-scroll="true" :show-close="false" :close-on-click-modal="false" :close-on-press-escape="false" modal-class="fullscreen-mask" title="请输入密码" width="500"
    align-center>
    <el-input type="password" v-model="userPassword" placeholder="页面需要输入密码才能访问!虽然你也可以简单绕过!" @keyup.enter="checkPassword" clearable show-password></el-input>
    <button @click="checkPassword" class="login-button">登录</button>
  </el-dialog>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import CryptoJS from 'crypto-js'
import { ElMessage } from 'element-plus'

const publicSecret = '32位随机字符串'; // 用来做公钥
const encryptedPassword = '密码'; // 请自己进行一次md5加密
const userPassword = ref('');
const showMask = ref(true);

const checkPassword = () => {
  if (md5(userPassword.value) === encryptedPassword) {
    // 密码正确时关闭遮罩层或执行其他操作
    setTokenStorage(encryptedPassword, 8 * 60 * 1000 * 1000, 'token');
    ElMessage({
      type: 'success',
      message: '登录成功',
      grouping: true,
      plain: true
    });
    closeMask();
  } else {
    ElMessage({
      type: 'warning',
      message: '密码错误,请重试!',
      grouping: true,
      plain: true
    });
  }
};

const closeMask = () => {
  // 如果点击遮罩层外部,则关闭遮罩层
  showMask.value = false;
};

const md5 = (str: string) => {
  return CryptoJS.MD5(str).toString()
}

// 加密函数
const encrypt = (encryptedData: { password: any; expiry: any; }, key: string, iv: string) => {
  try {
    const encryptedText = JSON.stringify(encryptedData)
    const cipher = CryptoJS.AES.encrypt(encryptedText, key, { iv: CryptoJS.enc.Utf8.parse(iv) })
    return cipher.toString()
  } catch (err) {
    return false
  }
}
// 解密函数
const decrypt = (encryptedText: any, key: string, iv: any) => {
  try {
    const decryptedData = CryptoJS.AES.decrypt(encryptedText, key, { iv: CryptoJS.enc.Utf8.parse(iv) })
    const decryptedText = decryptedData.toString(CryptoJS.enc.Utf8)
    return JSON.parse(decryptedText)
  } catch (err) {
    return false
  }
}

const isJson = (value: string | null) => {
  if (typeof value === 'string') {
    const obj = JSON.parse(value)
    return !!(typeof obj === 'object' && obj)
  }
  return false
}
const getTokenStorage = (key: undefined) => {
  const itemStr = localStorage ? localStorage.getItem(key || 'token') : null
  if (itemStr && isJson(itemStr)) {
    const item = JSON.parse(itemStr);
    const now = new Date().getTime();
    // 检查是否已过期
    if (now > item.expiry) {
      // 过期则删除该条数据
      removeTokenStorage(key || 'token');
      return null;
    }
    return item;
  } else {
    return itemStr
  }
}
function generateRandomString(length: number) {
  var result = '';
  var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  var charactersLength = characters.length;
  for (var i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
}
const setTokenStorage = (value: string, ttlMilliseconds: number, key: string) => {
  // 计算过期时间
  const expiryTime = new Date().getTime() + ttlMilliseconds;
  const iv = generateRandomString(16);
  const token = encrypt({
    password: value,
    expiry: expiryTime
  }, publicSecret, iv)
  return localStorage.setItem(key || 'token', JSON.stringify({
    token: token,
    iv: iv,
    expiry: expiryTime
  }))
}
const removeTokenStorage = (key: any) => {
  return localStorage.removeItem(key || 'token')
}

// 阻止页面滚动
if (typeof window !== 'undefined') {
  const tokenEncrypt = getTokenStorage(undefined);
  if (tokenEncrypt) {
    const tokenDecrypt = decrypt(tokenEncrypt.token, publicSecret, tokenEncrypt.iv);
    if (tokenDecrypt?.password && tokenDecrypt?.password === encryptedPassword) showMask.value = false;
  }
}

// document.body.style.overflow = showMask.value ? 'hidden' : 'auto';

// onMounted(() => {

// });

// // 添加卸载时的清理工作,以防万一组件在消息隐藏前被卸载
// onUnmounted(() => {
//   document.body.style.overflow = 'auto';
// });

// defineExpose({
//   userPassword,
//   checkPassword,
//   closeMask,
//   showMask
// });
</script>

<style scoped>
:root {
  --placeholder-color: #322d2d;
  /* 默认颜色 */
}

.fullscreen-mask {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(255, 255, 255, 0.9);
  /* 半透明黑色背景 */
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 9999;
  /* 确保遮罩层在最上层 */
}

.login-container {
  background-color: #ffffff;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.6);
  /* 添加阴影效果 */
  border-radius: 10px;
  /* 圆角效果 */
  padding: 30px;
  width: 300px;
  /* 登录框宽度自定义 */
}

.login-form {
  text-align: center;
}

.input-field {
  display: block;
  margin: 10px auto;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 5px;
  color: var(--placeholder-color);
  color: #322d2d
}

.login-button {
  display: block;
  margin: 20px auto 0;
  padding: 10px 20px;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.login-button:hover {
  background-color: #0056b3;
}

/* 为了更好的浏览器兼容性,可以添加针对不同浏览器引擎的前缀 */
input::-webkit-input-placeholder {
  color: var(--placeholder-color);
}

input::-moz-placeholder {
  color: var(--placeholder-color);
}

input:-ms-input-placeholder {
  color: var(--placeholder-color);
}
</style>

调方式

vue
<PasswordCheck />

以上言论仅代表个人观点