迁移UI框架至naive-ui,重构组件和样式,添加Gallery和Mask组件

This commit is contained in:
2025-12-28 16:45:58 +08:00
parent 4c097e4c40
commit 96edada2ff
54 changed files with 2482 additions and 2979 deletions

View File

@ -1,71 +1,29 @@
<template>
<div>
<PerfectScrollbar>
<lay-index></lay-index>
</PerfectScrollbar>
</div>
<d-modal class="!w-80" v-model="modal.visible" :title="modal.title">
{{ modal.content }}
<div class="mt-4 w-full flex justify-between">
<!-- <d-button @click="modal.handdleCancel" variant="text"
class="w-[49%] hover:bg-[#8a6684] hover:!text-white">{{modal.cancelText}}</d-button>
<span class="text-[20px]"> | </span>
<d-button @click="modal.handdleSubmit" variant="text" class="w-[49%] hover:bg-[#5c866a] hover:!text-white"
color="primary">{{modal.submitText}}</d-button> -->
<d-button class="w-[48%]" variant="solid" color="secondary" @click="modal.handdleCancel">{{modal.cancelText}}</d-button>
<d-button class="w-[48%]" variant="solid" color="primary" @click="modal.handdleSubmit">{{modal.submitText}}</d-button>
</div>
</d-modal>
<n-config-provider :theme-overrides="themeOverrides">
<n-message-provider>
<n-dialog-provider>
<n-modal-provider>
<n-scrollbar style="max-height: 100vh">
<lay-index></lay-index>
</n-scrollbar>
</n-modal-provider>
</n-dialog-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import layIndex from './Index.vue'
import themeOverrides from '@/util/theme.ts';
import layIndex from './Index.vue';
// 空白项目入口
const route = useRoute()
const logStatus = $store.log.useLogStore()
const modal = reactive<any>({
visible: false,
title: "",
content: "",
handdleCancel: () => { modal.visible = false },
handdleSubmit: () => { },
})
function comemodal(mv: any) {
if (!mv) return
modal.visible = true
modal.title = mv.title
modal.content = mv.content
modal.cancelText = mv.cancelText || "取消"
modal.submitText = mv.submitText || "确定"
if (mv.handdleCancel) modal.handdleCancel = () => {
mv.handdleCancel()
modal.visible = false
}
modal.handdleSubmit = () => {
mv.handdleSubmit()
modal.visible = false
}
}
window.$modal = comemodal
// 全局禁用右键菜单
document.oncontextmenu = function () {
return false;
};
onMounted(() => {
if($cookies.get('userinfo')) logStatus.setIsLogin(true)
if ($cookies.get('userinfo')) logStatus.setIsLogin(true)
})
</script>
<style scoped lang="less">
.ps {
width: 100vw;
height: 100vh;
padding-inline-start: 0;
}
</style>
<style scoped lang="less"></style>

View File

@ -1,38 +1,112 @@
<template>
<d-layout>
<d-header class="dheader-1">
<n-layout>
<n-layout-header class="dheader-1">
<menu-h />
</d-header>
<d-content class="dcontent-1">
</n-layout-header>
<n-layout-content class="dcontent-1">
<router-view />
</d-content>
<d-footer v-show="route.path == '/home'" class="dfooter-1">
</n-layout-content>
<n-layout-footer v-show="route.path == '/home'" class="flex justify-center dfooter-1">
<div class="beian" ref="footer">
<a class="text-primary" href="https://beian.miit.gov.cn" target="_blank">皖ICP备2021017362号-1</a>
<a class="swag text-primary" target="_blank" href="https://www.hxyouzi.com/swag">api文档</a>
</div>
</d-footer>
</d-layout>
</n-layout-footer>
</n-layout>
<n-modal v-model:show="modal.visible" preset="dialog" title="Dialog">
<template #header>
<div>{{ modal.title }}</div>
</template>
<div>
<div class="my-2">{{ modal.content }}</div>
<div v-if="modal.contType == 'input'">
<n-input @change="modal.handdleInputChange($event)" :placeholder="modal.placeholder"></n-input>
</div>
</div>
<template #action>
<n-button @click="modal.handdleCancel">{{ modal.cancelText }}</n-button>
<n-button type="primary" @click="modal.handdleSubmit">{{ modal.submitText }}</n-button>
</template>
</n-modal>
</template>
<script setup lang="ts">
const route = useRoute()
const footer = ref<HTMLElement | null>(null)
window.$msg = useMessage()
const modal: NmodalItem = reactive({
visible: false,
title: '',
content: '',
contType: 'text',
cancelText: '取消',
submitText: '确定',
placeholder: '请输入内容',
handdleCancel: () => { },
handdleSubmit: () => { },
handdleInputChange: (e: any) => { }
})
interface NmodalItem {
visible: boolean
title: string
content: string
contType?: string
placeholder?: string
cancelText?: string
submitText?: string
handdleCancel?: () => void
handdleSubmit?: () => void
handdleInputChange: (e: any) => void
}
//mark method
function comemodal(mv: NmodalItem) {
if (!mv) return
modal.visible = true
modal.title = mv.title
modal.content = mv.content
modal.contType = mv.contType || "text"
modal.placeholder = mv.placeholder || "请输入内容"
modal.cancelText = mv.cancelText || "取消"
modal.submitText = mv.submitText || "确定"
modal.handdleCancel = (): void => {
if (mv.handdleCancel) {
mv.handdleCancel()
}
modal.visible = false
}
modal.handdleSubmit = () => {
if (mv.handdleSubmit) {
mv.handdleSubmit()
}
modal.visible = false
}
modal.handdleInputChange = (e) => {
if (mv.handdleInputChange) {
mv.handdleInputChange(e)
}
}
}
window.$modal = comemodal
onMounted(() => {
// 计算footer的高度
const footerHeight = footer.value?.clientHeight || 0;
console.log('footerHeight', footerHeight);
})
</script>
<style scoped>
<style scoped lang="less">
.dcontent-1 {
background-color: #fbfbfb;
}
.beian {
width: 100%;
padding: 20px 0;
display: flex;
justify-content: center;

View File

@ -1,6 +1,3 @@
@import "./base.less";
@import "@devui-design/icons/icomoon/devui-icon.css";
@import "qweather-icons/font/qweather-icons.css";
@import "@devui-design/icons/icomoon/devui-icon.css";
@import "vue-devui/style.css";
@import "vue3-perfect-scrollbar/style.css";

281
src/components/Gallery.vue Normal file
View File

@ -0,0 +1,281 @@
<template>
<PerfectScrollbar ref="scrollbar" @ps-scroll-y="handleScroll">
<div ref="myCon" class="gallery-page py-5 px-[10%]">
<d-search class="mt-0 mb-8 w-2/3 mx-auto rounded-full" v-model="kw" is-keyup-search :delay="1000"
@search="onSearch"></d-search>
<div class="gallery-container w-full box-border">
<!-- 瀑布流容器 -->
<div ref="waterfallContainer" v-image-preview
class="waterfall-container flex justify-between flex-nowrap w-full overflow-hidden">
<!-- 动态生成的列 -->
<div v-for="(column, index) in columns" :key="index" class="waterfall-column flex flex-col w-[240px]">
<div v-for="item in column" :key="item.id"
class="gallery-item group relative my-[10px] rounded-lg overflow-hidden transition-transform duration-300 box-border hover:-translate-y-1.5">
<div
class="absolute px-2 truncate hidden group-hover:block top-0 text-center w-full bg-[#00000070] text-white">
{{ item.filename }}</div>
<img :src="item.filepath" alt="" class="gallery-image block w-full h-auto object-cover rounded-md">
<div class="px-2 absolute bottom-0 flex justify-between w-full bg-[#00000060]">
<div class="text-white "> <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 上传</div>
<d-popover content="下载" trigger="hover" class="!bg-primary" style="color: #fff">
<icon-download @click="downloadFile(item.filepath)"
class="w-5 h-5 text-white hover-text-primary"></icon-download>
</d-popover>
</div>
</div>
</div>
</div>
<!-- 加载中指示器 -->
<div v-if="loading" class="loading-indicator text-center p-5 text-gray-600">加载中...</div>
<div v-else class="loading-indicator text-center p-5 text-gray-600">已全部加载完成</div>
</div>
</div>
</PerfectScrollbar>
</template>
<script setup lang="ts">
import { throttle } from 'es-toolkit';
definePage({
name: 'gallery',
meta: {
title: '画廊',
}
})
// 画廊页逻辑
const fileList = ref<any[]>([]);
const pn = ref(1);
const ps = ref(40);
const loading = ref(false);
const waterfallContainer = ref<HTMLDivElement | null>(null);
const columns = ref<Array<Array<any>>>([]);
const columnHeights = ref<number[]>([]);
const columnCount = ref(4); // 默认列数
const itemWidth = ref(240); // 图片宽度固定为240px
const kw = ref<string>('');
// const uploadOptions = ref({
// uri: 'https://www.hxyouzi.com/api/files/upload',
// method: 'POST',
// maximumSize: 5 * 1024 * 1024,
// headers: {
// 'Authorization': 'Bearer ' + $cookies.get('token'),
// },
// });
// 计算列数 based on 屏幕宽度
function calculateColumnCount() {
if (!waterfallContainer.value) return;
const containerWidth = waterfallContainer.value.clientWidth;
const newColumnCount = Math.max(1, Math.floor(containerWidth / itemWidth.value));
if (newColumnCount !== columnCount.value) {
columnCount.value = newColumnCount;
resetWaterfall();
}
}
// 重置瀑布流
function resetWaterfall() {
columns.value = Array(columnCount.value).fill(0).map(() => []);
columnHeights.value = Array(columnCount.value).fill(0);
// 重新分配图片
fileList.value.forEach(async (item:any) =>await addToWaterfall(item));
console.log('>>> --> resetWaterfall --> fileList:', fileList.value)
}
// 添加图片到瀑布流
async function addToWaterfall(item: any) {
if (columns.value.length === 0) return;
const { height, width } =await getImageSizeByCheck(item.filepath)
// 找到高度最小的列
let minHeight = Math.min(...columnHeights.value);
let minIndex = columnHeights.value.indexOf(minHeight);
console.log('>>> --> addToWaterfall --> item:', item)
console.log('>>> --> addToWaterfall --> minIndex:', minIndex)
// 添加到该列
columns.value[minIndex].push(item);
// 估算列高 - 实际高度会在图片加载后更新
const estimatedHeight = itemWidth.value * height / width
columnHeights.value[minIndex] += estimatedHeight + 20; // 加上padding和margin
}
function getImageSizeByCheck(url: string): any {
return new Promise(function (resolve, reject) {
let image = new Image();
image.src = url;
let height = 0
let width = 0
// let timer = setTimeout(() => {
image.onload = () => {
if (image.width > 0 && image.height > 0) {
height = image.height
width = image.width
resolve({ height, width })
// clearTimeout(timer)
}
}
// }, 100)
});
}
// 获取文件列表
async function getFileList() {
if (loading.value) return;
loading.value = true;
try {
const res = await $http.file.getFileList({
keyword: kw.value,
page_num: pn.value,
page_size: ps.value,
});
// console.log('>>> --> getFileList --> res:', res);
if (pn.value === 1) {
fileList.value = res.data;
resetWaterfall();
} else {
// 追加新数据
res.data.forEach((item: any) => {
fileList.value.push(item);
addToWaterfall(item);
});
}
} catch (error) {
console.error('获取文件列表失败:', error);
} finally {
loading.value = false;
}
}
// // 获取我的文件列表
// async function getMyList() {
// if (loading.value) return;
// loading.value = true;
// try {
// const res = await $http.file.getMyList({
// page_num: pn.value,
// page_size: ps.value,
// });
// // console.log('>>> --> getFileList --> res:', res);
// if (pn.value === 1) {
// fileList.value = res.data;
// resetWaterfall();
// } else {
// // 追加新数据
// res.data.forEach((item: any) => {
// fileList.value.push(item);
// addToWaterfall(item);
// });
// }
// } catch (error) {
// console.error('获取文件列表失败:', error);
// } finally {
// loading.value = false;
// }
// }
// 监听滚动事件
const handleScroll: any = throttle((e: any) => {
console.log('>>> --> handleScroll --> loading:', e)
if (loading.value) return;
const scrollTop = e.target.scrollTop
console.log('>>> --> handleScroll --> scrollTop:', scrollTop)
const scrollHeight = e.target.scrollHeight
// console.log('>>> --> handleScroll --> scrollHeight:', scrollHeight)
const clientHeight = e.target.offsetHeight;
// 当滚动到距离底部20%时加载更多
// console.log('>>> --> clientHeight --> clientHeight:', clientHeight)
if (scrollTop + clientHeight >= scrollHeight * 0.8) {
console.log('>>> --> handleScroll --> 加载更多')
pn.value++;
getFileList();
}
}, 1000)
function onSearch() {
pn.value = 1;
getFileList();
}
function downloadFile(url: string) {
console.log('>>> --> downloadFile --> url:', url)
// 创建临时a标签
const link = document.createElement('a');
// 设置下载链接
link.href = url;
// 提取文件名
const fileName = url.split('/').pop() || 'downloaded-file';
// 设置下载属性和文件名
link.download = fileName;
// 设置为隐藏元素
link.style.display = 'none';
// 添加到文档
document.body.appendChild(link);
// 触发点击事件
link.click();
// 移除临时元素
document.body.removeChild(link);
}
onMounted(() => {
getFileList();
// 计算初始列数
calculateColumnCount();
// 添加窗口大小变化监听
window.addEventListener('resize', calculateColumnCount);
// 添加滚动监听
window.addEventListener('scroll', handleScroll);
});
onUnmounted(() => {
pn.value = 1;
kw.value = '';
fileList.value = [];
resetWaterfall();
// 移除监听
window.removeEventListener('resize', calculateColumnCount);
window.removeEventListener('scroll', handleScroll);
});
</script>
<style scoped lang="less">
/* 画廊页样式 */
:deep(.devui-tabs__nav) {
display: flex;
justify-content: center;
width: 100% !important;
// padding: 0 10%;
li {
width: 50%;
display: flex;
justify-content: center;
}
li a span {
font-size: 18px !important;
font-weight: 500;
}
}
.ps {
height: calc(100vh - 65px);
width: 100%;
}
:deep(.devui-upload) {
width: 100%;
&>div {
width: 100%;
}
}
</style>

View File

@ -1,44 +1,47 @@
<template>
<d-card class="mt-10 bg-[#ffffff60] rounded-[10px]" shadow="never">
<d-tabs v-model="tid" type="pills">
<d-tab id="tab1" title="登录">
<d-form ref="formLogin" layout="vertical" :data="loginData" :rules="rules">
<d-form-item field="username">
<d-input v-model="loginData.username" placeholder="请输入用户名" />
</d-form-item>
<d-form-item field="password">
<d-input v-model="loginData.password" show-password placeholder="请输入密码" />
</d-form-item>
<d-form-item class="form-operation-wrap">
<d-button class="w-full" variant="solid" @click="login"> </d-button>
</d-form-item>
</d-form>
</d-tab>
<d-tab id="tab2" title="注册">
<d-form ref="formReg" layout="vertical" :data="regData" :rules="rrules">
<d-form-item field="username">
<d-input v-model="regData.username" placeholder="请输入用户名" />
</d-form-item>
<d-form-item field="password">
<d-input v-model="regData.password" show-password placeholder="请输入用密码" />
</d-form-item>
<d-form-item field="nickname">
<d-input v-model="regData.nickname" placeholder="请输入昵称" />
</d-form-item>
<d-form-item class="form-operation-wrap">
<d-button class="w-full" variant="solid" @click="register">注册</d-button>
</d-form-item>
</d-form>
</d-tab>
</d-tabs>
</d-card>
<n-card class="mt-10 rounden-[10px] w-[500px]" shadow="never">
<n-tabs v-model:value="tid" justify-content="space-evenly" animated>
<n-tab-pane name="tab1" tab="登录">
<n-form ref="formLogin" layout="vertical" :data="loginData" :rules="rules">
<n-form-item field="username">
<n-input v-model:value="loginData.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item field="password">
<n-input v-model:value="loginData.password" type="password" show-password-on="click" placeholder="请输入密码" />
</n-form-item>
<n-form-item class="form-operation-wrap">
<n-button class="w-full" type="primary" @click="login"> </n-button>
</n-form-item>
</n-form>
</n-tab-pane>
<n-tab-pane name="tab2" tab="注册">
<n-form ref="formReg" layout="vertical" :data="regData" :rules="rrules">
<n-form-item field="username">
<n-input v-model:value="regData.username" placeholder="请输入用户名" />
</n-form-item>
<n-form-item field="password">
<n-input v-model:value="regData.password" type="password" show-password-on="click" placeholder="请输入用密码" />
</n-form-item>
<n-form-item field="nickname">
<n-input v-model:value="regData.nickname" placeholder="请输入昵称" />
</n-form-item>
<n-form-item class="form-operation-wrap">
<n-button class="w-full" type="primary" @click="register">注册</n-button>
</n-form-item>
</n-form>
</n-tab-pane>
</n-tabs>
</n-card>
</template>
<script setup lang="ts">
const router = useRouter()
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
}>()
const props = defineProps({
setVisible: {
type: Function,
default: () => { },
}
})
// 登录注册逻辑
const tid = ref("tab1");
const formLogin: any = ref(null);
@ -77,8 +80,8 @@ const rrules: any = reactive({
})
function login() {
console.log('>>> --> login --> formLogin.value:', usrLog.isLogin)
formLogin.value.validate(async (is: boolean, b: any) => {
if (!is) {
formLogin.value?.validate(async (is: boolean) => {
if (is) {
$msg.error('信息填写不正确,请检查后再提交')
} else {
const res = await $http.user.login(loginData)
@ -90,15 +93,14 @@ function login() {
$cookies.set('userinfo', res.data.userinfo, '1d')
$msg.success(res.msg)
usrLog.setIsLogin(true)
// router.push({ path: "/" })
emit("update:visible",false)
props.setVisible(false)
}
})
}
function register() {
formReg.value.validate(async (is: boolean, b: any) => {
if (!is) {
formReg.value.validate(async (is: boolean) => {
if (is) {
$msg.error('信息填写不正确,请检查后再提交')
} else {
const res = await $http.user.register(regData)
@ -108,8 +110,10 @@ function register() {
}
$msg.success(res.msg)
tid.value = 'tab1'
formReg.value.resetForm()
loginData.username = regData.username
regData.username = ''
regData.nickname = ''
regData.password = ''
}
})
}

View File

@ -1,14 +1,14 @@
<template>
<div class="pr-8">
<d-card shadow="never" class="mt-4 bg-white">
<n-card embedded class="mt-4 shadow">
<!-- <div class="dt-card mt-10 bg-white"> -->
<template #title>
<template #header>
<div class="flex items-center">
<icon-time class="w-5 mr-2"></icon-time>
时间日期
</div>
</template>
<template #content>
<template #default>
<div class="w-full pr-20 text-center text-[#ec66ab] font-500 text-4xl font-[yj]">{{ t }}</div>
<div class="mt-3 flex justify-between">
<span>今年已过了{{ jq.dayOfYear }}</span>
@ -18,19 +18,20 @@
<img v-else class="absolute top-0 right-4" width="120" src="@/assets/images/onwork.png" alt="">
</template>
<!-- </div> -->
</d-card>
</n-card>
<d-card shadow="never" class="mt-4 bg-white">
<template #title>
<n-card embedded class="mt-4 shadow">
<template #header>
<div class="flex items-center">
<icon-news class="w-5 mr-2"></icon-news>
百度新闻
</div>
</template>
<template #content>
<template #default>
<div class="py-1" v-for="i in bdNews" :key="i.id">
<a class="devui-link flex justify-between truncate" :href="i.url" target="_blank">
<!-- 淡蓝色 -->
<a class="text-[#526ecc] no-underline hover:text-primary hover:underline flex justify-between truncate" :href="i.url" target="_blank">
<span class="flex items-center">{{ i.index }}. {{ i.title }}
<icon-hot v-show="i.index < 4" class="ml-2 w-4 text-[red] inline-block"></icon-hot>
</span>
@ -39,7 +40,7 @@
</div>
<div class="mt-2 justify-between flex items-center">
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
<a class="devui-link text-[#ec66ab] flex items-center" href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
<a class="text-[#526ecc] no-underline text-[#ec66ab] flex items-center" href="https://www.baidu.com/s?ie=utf-8&wd=百度新闻"
target="_blank">
更多
<icon-right class="ml-1 w-4 text-primary inline-block"></icon-right>
@ -47,18 +48,18 @@
<div class="w-2/5 h-px bg-[#ec66ab]"></div>
</div>
</template>
</d-card>
</n-card>
<d-card shadow="never" class="mt-4 bg-white">
<template #title>
<n-card embedded class="mt-4 shadow">
<template #header>
<div class="flex items-center">
<icon-date class="w-5 mr-2"></icon-date>
农历节气
<div class="ml-12 text-[#ec66ab] font-500">{{ jq.yearTips }} {{ jq.lunarCalendar }}</div>
</div>
</template>
<template #content>
<template #default>
<div class="truncate text-sm mx-[5%]">
{{ jq.suit }}
</div>
@ -70,7 +71,7 @@
</div>
<div class="mt-2 text-center">{{ jq.solarTerms }}</div>
</template>
</d-card>
</n-card>
</div>
</template>
@ -132,8 +133,14 @@ onUnmounted(() => {
src: url('@/assets/font/LCDML.woff2');
}
:deep(.n-card > .n-card__content, .n-card > .n-card__footer){
padding: 8px 25px !important;
}
// .dt-card {
// background-image: url('@/assets/images/中秋节中国风边框34.png');
// background-size: 100% 100%;
// padding: 20px 40px;
// }</style>
// }
</style>

35
src/components/mask.vue Normal file
View File

@ -0,0 +1,35 @@
<template>
<Teleport to="body">
<div v-if="visible"
class="fixed top-0 left-0 z-[1000] w-[100vw] h-[100vh] flex items-center justify-center bg-[rgba(0,0,0,0.5)]">
<slot></slot>
<div @click.prevent="handdleClose" class="absolute w-8 h-8 top-4 right-4 rounded-full bg-white flex items-center justify-center text-[#ec66ab] cursor-pointer">X</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
//mark import
//mark data
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
setVisible: {
type: Function,
default: () => { },
}
});
//mark method
function handdleClose() {
props.setVisible(false)
}
//mark 周期、内置函数等
</script>
<style scoped lang="less"></style>

View File

@ -5,36 +5,15 @@
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
</div>
<!-- 主导航菜单 -->
<d-menu mode="horizontal" router class="ml-5 h-14 text-[16px] " :default-select-keys="[key]">
<d-menu-item key="home">
<d-icon :component="homeSvg" class="w-5 mr-1"></d-icon>
首页
</d-menu-item>
<d-menu-item key="gallery">
<d-icon :component="picSvg" class="w-5 mr-1"></d-icon>
画廊
</d-menu-item>
<d-menu-item key="article">
<d-icon :component="artiSvg" class="w-5 mr-1 "></d-icon>
文章
</d-menu-item>
<d-menu-item key="widget">
<d-icon :component="settingSvg" class="w-5 mr-1 "></d-icon>
工具
</d-menu-item>
<d-menu-item key="appshare">
<d-icon :component="downSvg" class="w-5 mr-1 "></d-icon>
软件分享
</d-menu-item>
<d-menu-item key="plink">
<d-icon :component="linkSvg" class="w-5 mr-1 "></d-icon>
友链
</d-menu-item>
</d-menu>
<div>
<n-menu :icon-size="12" v-model:value="activeKey" class="" mode="horizontal" :options="menuOptions" />
</div>
<!-- 用户区域 -->
<div class="!text-[#ec66ab] flex items-center" @click="gotoHf">
<span class="flex items-center location-info truncate">
<d-icon color="#ec66ab" class="mr-1" name="location-new"></d-icon>
<n-icon class="mr-1">
<icon-loc class="w-[26px] h-[26px]"></icon-loc>
</n-icon>
{{ locationInfo }}
</span>
<span class="mx-3 text-gray-300">|</span>
@ -44,47 +23,35 @@
</div>
<div class="flex items-center mr-8">
<d-dropdown class="cursor-pointer w-[100px]" v-if="userinfo" trigger="hover">
<n-dropdown class="cursor-pointer w-[100px]" v-if="userinfo" :options="oprOp" @select="handleSelect"
trigger="hover">
<div class="flex items-center">
<d-avatar :img-src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" />
<n-avatar round :src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
</div>
<template #menu>
<ul class="list-menu">
<!-- hover为淡粉色 -->
<li class="w-full p-2 text-center hover:text-primary hover:bg-[#f5f0f0] cursor-pointer" @click="logout">
登出
</li>
<li class="w-full p-2 text-center hover:text-primary hover:bg-[#f5f0f0] cursor-pointer" @click="">
控制台
</li>
<li class="w-full p-2 text-center hover:text-primary hover:bg-[#f5f0f0] cursor-pointer" @click="">
设置
</li>
</ul>
</template>
</d-dropdown>
<div v-else class="flex items-center">
<d-avatar class="cursor-pointer" @click="toLogin"></d-avatar>
<div class="cursor-pointer ml-2 text-gray text-sm" @click="toLogin">登录</div>
</n-dropdown>
<div v-else class="flex items-center" @click="toLogin">
<n-avatar round class="cursor-pointer"></n-avatar>
<div class="cursor-pointer ml-2 text-gray text-sm" >登录</div>
</div>
</div>
<!-- 登录弹窗 -->
<d-modal class="!w-120" v-model="visible">
<login-modal v-model:visible="visible"></login-modal>
</d-modal>
<masked :visible="visible" :setVisible="setVisible">
<loginModal :setVisible="setVisible" />
</masked>
</div>
<div class="flex justify-between bg-white lg:hidden">
<div class="pl-2 items-centerflex" slot="brand" @click="">
<img :src="logo" alt="柚子的网站" class="h-9 align-middle" />
</div>
<div class="flex items-center mr-2">
<div v-if="userinfo" class="flex items-center">
<d-avatar :img-src="userinfo.ava_url" class="cursor-pointer" alt="用户的头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
</div>
<div v-if="userinfo" class="flex items-center">
<n-avatar :src="userinfo.ava_url" round class="cursor-pointer" alt="用户的头" />
<div class="cursor-pointer ml-2 text-gray text-sm">{{ userinfo.nickname }}</div>
</div>
<div v-else class="flex items-center">
<d-avatar class="cursor-pointer" @click="toLogin"></d-avatar>
<n-avatar round class="cursor-pointer" @click="toLogin"></n-avatar>
<div class="cursor-pointer ml-2 text-gray text-sm" @click="toLogin">登录</div>
</div>
</div>
@ -101,16 +68,16 @@ import linkSvg from '@/icon/menu/link.svg';
import picSvg from '@/icon/menu/pic.svg';
import settingSvg from '@/icon/menu/setting.svg';
import loginModal from '@/components/Login.vue'
import masked from '@/components/mask.vue'
import { onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute, useRouter, RouterLink } from 'vue-router';
const activeKey = ref('home')
const route = useRoute();
const visible = ref(false);
const router = useRouter();
const key = ref("home");
const locationInfo = ref("获取位置中...");
const latitude = ref<number | null>(null);
const longitude = ref<number | null>(null);
@ -122,7 +89,69 @@ const userinfo: any = ref(null)
const nav: any = useTemplateRef('nav')
const navx = $store.nav.useNavStore()
const usrLog = $store.log.useLogStore()
console.log('>>> --> route:', route)
const menuOptions = ref([
{
label: () => h(RouterLink, { to: '/home', class: 'flex items-center justify-center' }, { default: () => '首页' }),
key: "home",
icon: () => h(homeSvg)
},
{
label: () => h(RouterLink, { to: '/gallery', class: 'flex items-center justify-center' }, { default: () => '画廊管理' }),
key: "gallery",
icon: () => h(picSvg)
},
{
label: () => h(RouterLink, { to: '/blog', class: 'flex items-center justify-center' }, { default: () => '文章管理' }),
key: "blog",
icon: () => h(artiSvg)
},
{
label: () => h(RouterLink, { to: '/widget', class: 'flex items-center justify-center' }, { default: () => '工具管理' }),
key: "widget",
icon: () => h(downSvg)
},
{
label: () => h(RouterLink, { to: '/apps', class: 'flex items-center justify-center' }, { default: () => '软件管理' }),
key: "apps",
icon: () => h(settingSvg)
},
{
label: () => h(RouterLink, { to: '/plink', class: 'flex items-center justify-center' }, { default: () => '友链管理' }),
key: "plink",
icon: () => h(linkSvg)
},
]);
const oprOp = ref([
{
key: 'logout',
label: '退出登录',
},
{
key: 'console',
label: '控制台',
},
{
key: 'setting',
label: '设置',
}
])
function setVisible(v: any) {
visible.value = v
}
function handleSelect(key: string) {
console.log('>>> --> handleSelect --> key:', key)
if (key == 'logout') {
logout()
return
}
if (key == 'console') {
router.push('/console')
return
}
}
// 获取地理位置
const getLocation = () => {
if (!navigator.geolocation) {
@ -198,7 +227,7 @@ async function handdleJw(jw: string) {
}
watch(() => route.name, (newVal) => {
key.value = newVal as string
activeKey.value = newVal as string
})
function goHome() {
@ -220,6 +249,9 @@ function logout() {
usrLog.setIsLogin(false)
userinfo.value = null;
}
function gotoConsole() {
window.open("https://www.hxyouzi.com/console/home", "_BLACK")
}
function gotoHf() {
console.log('>>> --> gotoHf --> fxlink:', fxlink)
@ -230,7 +262,10 @@ onMounted(() => {
userinfo.value = $cookies.get('userinfo');
console.log('>>>>>>>>>>', userinfo.value);
key.value = route.name as string;
setTimeout(() => {
console.log('>>>>>>>>>>', route)
activeKey.value = route.name as string;
}, 20);
console.log('>>> --> route.name:', route.name)
getLocation(); // 组件挂载时获取位置
@ -242,7 +277,7 @@ onMounted(() => {
});
onBeforeUpdate(() => {
userinfo.value = $cookies.get('userinfo');
key.value = route.name as string;
activeKey.value = route.name as string;
const h: number = nav.value.clientHeight
// console.log('******>>> --> nav.value:', nav.value)
// console.log('()()()()()>>> --> h:', h)
@ -257,32 +292,10 @@ onBeforeUpdate(() => {
margin-bottom: 1px;
}
:deep(.devui-menu-horizontal .devui-menu-item:hover span .icon) {
color: var(--devui-brand, #5e7ce0) !important;
fill: var(--devui-brand, #5e7ce0) !important;
:deep(.n-menu-item-content__icon) {
width: 16px !important;
height: 16px !important;
}
:deep(.devui-menu-item-select span svg) {
color: var(--devui-brand, #5e7ce0) !important;
fill: var(--devui-brand, #5e7ce0) !important;
line-height: 100% !important;
}
:deep(.devui-menu-item-select span) {
color: var(--devui-brand, #5e7ce0) !important;
}
:deep(.devui-menu-horizontal) {
padding: 14px 20px 6px;
}
:deep(.devui-menu-item span) {
display: flex;
align-items: center;
.devui-icon__container {
display: flex;
align-items: center;
}
}
</style>

View File

@ -1,5 +0,0 @@
import { Message } from 'vue-devui';
const msg:any = Message;
export default msg;

1
src/icon/loc.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1766884807799" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10346" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M774.826667 365.226667c0 117.76-194.56 392.533333-245.76 464.213333-6.826667 8.533333-18.773333 8.533333-25.6 0C450.56 757.76 256 482.986667 256 365.226667 256 230.4 372.053333 119.466667 515.413333 119.466667s259.413333 110.933333 259.413334 245.76z" fill="#ec66ab" p-id="10347"></path><path d="M208.213333 841.386667a307.2 76.8 0 1 0 614.4 0 307.2 76.8 0 1 0-614.4 0Z" fill="#ec66ab" opacity=".5" p-id="10348"></path></svg>

After

Width:  |  Height:  |  Size: 759 B

1
src/icon/search.svg Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1766900628434" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11570" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M0 512a512 512 0 1 0 1024 0 512 512 0 1 0-1024 0Z" fill="#F2ECFF" p-id="11571"></path><path d="M801.723733 695.330133l-273.066666-238.933333a51.2 51.2 0 1 0-67.447467 77.073067l273.066667 238.933333a51.2 51.2 0 0 0 67.447466-77.073067z" fill="#DBBBFF" p-id="11572"></path><path d="M238.933333 494.933333a256 256 0 1 0 512 0 256 256 0 1 0-512 0Z" fill="#BC86F9" p-id="11573"></path><path d="M494.933333 315.733333q74.24 0 126.702934 52.497067 52.497067 52.462933 52.497066 126.702933 0 2.525867-0.477866 4.983467-0.512 2.491733-1.467734 4.8128-0.955733 2.321067-2.389333 4.437333-1.365333 2.082133-3.1744 3.857067-1.774933 1.809067-3.857067 3.208533-2.116267 1.365333-4.437333 2.3552-2.321067 0.955733-4.778667 1.467734-2.491733 0.477867-5.0176 0.477866t-4.983466-0.477866q-2.491733-0.512-4.8128-1.467734-2.321067-0.955733-4.437334-2.389333-2.082133-1.365333-3.857066-3.1744-1.809067-1.774933-3.208534-3.857067-1.365333-2.116267-2.3552-4.437333-0.955733-2.321067-1.467733-4.778667-0.477867-2.491733-0.477867-5.0176 0-53.009067-37.4784-90.5216-37.512533-37.4784-90.5216-37.4784-2.525867 0-4.983466-0.477866-2.491733-0.512-4.8128-1.467734-2.321067-0.955733-4.437334-2.389333-2.082133-1.365333-3.857066-3.1744-1.809067-1.774933-3.208534-3.857067-1.365333-2.116267-2.3552-4.437333-0.955733-2.321067-1.467733-4.778667-0.477867-2.491733-0.477867-5.0176t0.477867-4.983466q0.512-2.491733 1.467733-4.8128 0.955733-2.321067 2.389334-4.437334 1.365333-2.082133 3.1744-3.857066 1.774933-1.809067 3.857066-3.208534 2.116267-1.365333 4.437334-2.3552 2.321067-0.955733 4.778666-1.467733 2.491733-0.477867 5.0176-0.477867z" fill="#FFFFFF" p-id="11574"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 B

BIN
src/lib/assets/loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

View File

@ -0,0 +1,183 @@
<template>
<div class="lazy__box">
<div class="lazy__resource">
<div class="image-container">
<el-image
ref="lazyRef"
class="lazy__img"
:src="url"
:preview-src-list="previewSrcList.length > 0 ? previewSrcList : []"
fit="contain"
:preview-teleported="true"
:initial-index="0"
:hide-on-click-modal="hideOnClickModal"
@load="handleLoad"
@error="handleError"
>
<template #placeholder>
<img :src="loading" alt="loading" />
</template>
<template #error>
<img :src="errorImg" alt="error" />
</template>
</el-image>
<div
class="overlay"
v-if="previewSrcList.length > 0 && previewIcon"
@click="hanldeShowPreview"
>
<div class="preview-icon">
<img :src="previewIcon" alt="preview" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ElImage } from "element-plus";
import type { PropType } from "vue";
import { inject, onMounted, ref, toRefs } from "vue";
import loadError from "../assets/loadError.png";
import loadingImg from "../assets/loading.gif";
import type { Nullable } from "../types/util";
const props = defineProps({
previewIcon: {
type: String,
default: "",
},
url: {
type: String,
default: "",
},
loading: {
type: String,
default: loadingImg,
},
errorImg: {
type: String,
default: loadError,
},
previewSrcList: {
type: Array as PropType<string[]>,
default: () => [],
},
hideOnClickModal: {
// 是否可以通过点击遮罩层关闭预览
type: Boolean,
default: false,
},
});
const { url, loading, errorImg, previewSrcList } = toRefs(props);
const imgLoaded = inject("imgLoaded") as () => void;
const lazyRef = ref<Nullable<InstanceType<typeof ElImage>>>(null);
const handleLoad = () => {
imgLoaded();
};
const handleError = () => {
// 可以在这里添加错误处理逻辑
};
// 显示预览
const hanldeShowPreview = () => {
if (lazyRef.value) {
lazyRef.value.showPreview();
}
};
onMounted(() => {
if (lazyRef.value) {
imgLoaded();
}
});
</script>
<style scoped>
.lazy__box {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.lazy__resource {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.lazy__img {
width: 100%;
height: auto;
display: block;
}
:deep(.el-image) {
width: 100%;
display: block;
}
:deep(.el-image__inner) {
width: 100%;
height: auto;
object-fit: cover;
}
.lazy__img img[alt="loading"],
.lazy__img img[alt="error"] {
width: 48px;
height: 48px;
padding: 1em;
margin: 0 auto;
display: block;
}
.image-container {
position: relative;
width: 100%;
cursor: pointer;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.image-container:hover .overlay {
opacity: 1;
}
.preview-icon {
color: white;
width: 32px;
height: 32px;
}
:deep(.el-image__placeholder),
:deep(.el-image__error) {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
:deep(.el-image__placeholder) img,
:deep(.el-image__error) img {
width: 48px;
height: 48px;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,208 @@
<template>
<div
ref="waterfallWrapper"
class="waterfall-list"
:style="{ height: `${wrapperHeight}px` }"
>
<div
v-for="(item, index) in list"
:key="getKey(item, index)"
class="waterfall-item"
>
<div class="waterfall-card">
<slot
name="item"
:item="item"
:index="index"
:url="getRenderURL(item)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PropType } from "vue";
import { provide, ref, watch } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { useCalculateCols, useLayout } from "../use";
import Lazy from "../utils/Lazy";
import { getValue } from "../utils/util";
import type { ViewCard } from "../types/waterfall";
const props = defineProps({
list: {
type: Array as PropType<any[]>,
default: () => [],
},
rowKey: {
type: String,
default: "id",
},
imgSelector: {
type: String,
default: "src",
},
width: {
type: Number,
default: 200,
},
columns: {
type: Number,
default: 3,
},
gutter: {
type: Number,
default: 10,
},
hasAroundGutter: {
type: Boolean,
default: true,
},
animationPrefix: {
type: String,
default: "animate__animated",
},
animationEffect: {
type: String,
default: "fadeIn",
},
animationDuration: {
type: Number,
default: 1000,
},
animationDelay: {
type: Number,
default: 300,
},
backgroundColor: {
type: String,
default: "#fff",
},
lazyload: {
type: Boolean,
default: true,
},
loadProps: {
type: Object,
default: () => {},
},
crossOrigin: {
type: Boolean,
default: true,
},
delay: {
type: Number,
default: 300,
},
});
const lazy = new Lazy(props.lazyload, props.loadProps, props.crossOrigin);
provide("lazy", lazy);
// 容器块信息
const { waterfallWrapper, wrapperWidth, colWidth, cols, offsetX } =
useCalculateCols(props);
// 容器高度,块定位
const { wrapperHeight, layoutHandle } = useLayout(
props,
colWidth,
cols,
offsetX,
waterfallWrapper
);
// 1s内最多执行一次排版减少性能开销
const renderer = useDebounceFn(() => {
layoutHandle();
// console.log("强制更新排版");
}, props.delay);
// 列表发生变化直接触发排版
watch(
() => [wrapperWidth, colWidth, props.list],
() => {
renderer();
},
{ deep: true }
);
// 尺寸宽度变化防抖触发
const sizeChangeTime = ref(0);
provide("sizeChangeTime", sizeChangeTime);
// 图片加载完成
provide("imgLoaded", renderer);
// 根据选择器获取图片地址
const getRenderURL = (item: ViewCard): string => {
return getValue(item, props.imgSelector)[0];
};
// 获取唯一值
const getKey = (item: ViewCard, index: number): string => {
return item[props.rowKey] || index;
};
const clearAndReload = () => {
const originalList = [...props.list];
props.list.length = 0;
setTimeout(() => {
props.list.push(...originalList);
renderer();
}, 0);
};
defineExpose({
waterfallWrapper,
wrapperHeight,
getRenderURL,
getKey,
list: props.list,
backgroundColor: props.backgroundColor,
renderer,
clearAndReload,
});
</script>
<style scoped>
.waterfall-list {
width: 100%;
position: relative;
overflow: hidden;
background-color: v-bind(backgroundColor);
}
.waterfall-item {
position: absolute;
left: 0;
top: 0;
/* transition: .3s; */
/* 初始位置设置到屏幕以外,避免懒加载失败 */
transform: translate3d(0, 3000px, 0);
visibility: hidden;
}
/* 初始的入场效果 */
@-webkit-keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.fadeIn {
-webkit-animation-name: fadeIn;
animation-name: fadeIn;
}
</style>

3
src/lib/index.ts Normal file
View File

@ -0,0 +1,3 @@
import Waterfall from './components/Waterfall.vue'
import LazyImg from './components/LazyImg.vue'
export { Waterfall, LazyImg }

4
src/lib/types/images.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.gif" {
const value: string;
export default value;
}

13
src/lib/types/jsx.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import { VNode } from "vue";
declare global {
namespace JSX {
interface Element extends VNode {}
interface ElementClass {
$props: {};
}
interface IntrinsicElements {
[elem: string]: any;
}
}
}

77
src/lib/types/lazy.d.ts vendored Normal file
View File

@ -0,0 +1,77 @@
export interface LazyOptions {
error?: string;
loading?: string;
observerOptions?: IntersectionObserverInit;
log?: boolean;
}
export interface ValueFormatterObject {
src: string;
error?: string;
loading?: string;
}
export default class Lazy {
lazyActive: boolean;
options: LazyOptions;
_images = new WeakMap();
constructor(flag = true, options: LazyOptions);
config(options = {}): void;
// mount
mount(
el: HTMLImageElement,
binding: string | ValueFormatterObject,
callback: () => void
): void;
// unmount
unmount(el: HTMLElement): void;
resize(el: HTMLElement, callback: () => void): void;
/**
* 设置img的src
* @param {*} el - img
* @param {*} src - 原图
* @param {*} error - 错误图片
* @param {*} callback - 完成的回调函数,通知组件刷新布局
* @returns
*/
_setImageSrc(
el: HTMLImageElement,
src: string,
callback: () => void,
error?: string
): void;
_isOpenLazy(): boolean;
/**
* 添加img和对应的observer到weakMap中
* 开启监听
* 当出现在可视区域后取消监听
* @param {*} el - img
* @param {*} src - 图片
* @param {*} error - 错误图片
* @param {*} callback - 完成的回调函数,通知组件刷新布局
*/
_initIntersectionObserver(
el: HTMLImageElement,
src: string,
callback: () => void,
error?: string
): void;
// 格式化参数
_valueFormatter(value: ValueFormatterObject | string): ValueFormatterObject;
// 日志
_log(callback: () => void): void;
// 在map中获取对应img的observer事件
_realObserver(el: HTMLElement): IntersectionObserver | undefined;
}

4
src/lib/types/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module "*.png" {
const src: string;
export default src;
}

3
src/lib/types/util.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
export type Nullable<T> = T | null
export type CssStyleObject = Partial<CSSStyleDeclaration> & Record<string, string | null>

34
src/lib/types/waterfall.d.ts vendored Normal file
View File

@ -0,0 +1,34 @@
export interface ViewCard {
src?: any;
id?: string;
name?: string;
star?: boolean;
backgroundColor?: string;
[key: string]: any;
}
export interface WaterfallProps {
columns: number;
width: number;
animationDuration: number;
animationDelay: number;
animationEffect: string;
hasAroundGutter: boolean;
gutter: number;
list: ViewCard[];
animationPrefix: string;
backgroundColor: string;
lazyload: boolean;
loadProps: Record<string, any>;
crossOrigin: boolean;
delay: number;
rowKey: string;
imgSelector: string;
}
export interface ItemWidthProps {
wrapperWidth: number;
gutter: number;
hasAroundGutter: boolean;
columns: number;
}

3
src/lib/use/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { useCalculateCols } from './useCalculateCols'
export { useLayout } from './useLayout'

View File

@ -0,0 +1,46 @@
import { computed, ref } from "vue";
import { useResizeObserver } from "@vueuse/core";
import { getItemWidth } from "../utils/itemWidth";
import type { WaterfallProps } from "../types/waterfall";
import type { Nullable } from "../types/util";
export function useCalculateCols(props: WaterfallProps) {
const wrapperWidth = ref<number>(0);
const waterfallWrapper = ref<Nullable<HTMLElement>>(null);
useResizeObserver(waterfallWrapper, (entries) => {
const entry = entries[0];
const { width } = entry.contentRect;
if (width === 0) return;
wrapperWidth.value = width;
});
// 列实际宽度
const colWidth = computed(() => {
return getItemWidth({
wrapperWidth: wrapperWidth.value,
gutter: props.gutter,
hasAroundGutter: props.hasAroundGutter,
columns: props.columns,
});
});
// 列
const cols = computed(() => props.columns);
// 偏移
const offsetX = computed(() => {
const offset = props.hasAroundGutter ? props.gutter : -props.gutter;
const contextWidth =
cols.value * (colWidth.value + props.gutter) + offset;
return (wrapperWidth.value - contextWidth) / 2;
});
return {
waterfallWrapper,
wrapperWidth,
colWidth,
cols,
offsetX,
};
}

118
src/lib/use/useLayout.ts Normal file
View File

@ -0,0 +1,118 @@
import type { Ref } from "vue";
import { ref } from "vue";
import { addClass, hasClass, prefixStyle } from "../utils/dom";
import type { WaterfallProps } from "../types/waterfall";
import type { CssStyleObject, Nullable } from "../types/util";
const transform = prefixStyle("transform");
const duration = prefixStyle("animation-duration");
const delay = prefixStyle("animation-delay");
const transition = prefixStyle("transition");
const fillMode = prefixStyle("animation-fill-mode");
export function useLayout(
props: WaterfallProps,
colWidth: Ref<number>,
cols: Ref<number>,
offsetX: Ref<number>,
waterfallWrapper: Ref<Nullable<HTMLElement>>
) {
const posY = ref<number[]>([]);
const wrapperHeight = ref(0);
// 获取对应y下标的x的值
const getX = (index: number): number => {
const count = props.hasAroundGutter ? index + 1 : index;
return props.gutter * count + colWidth.value * index + offsetX.value;
};
// 初始y
const initY = (): void => {
posY.value = new Array(cols.value).fill(
props.hasAroundGutter ? props.gutter : 0
);
};
// 添加入场动画
const animation = addAnimation(props);
// 排版
const layoutHandle = async () => {
// 初始化y集合
initY();
// 构造列表
const items: HTMLElement[] = [];
if (waterfallWrapper && waterfallWrapper.value) {
waterfallWrapper.value.childNodes.forEach((el: any) => {
if (el!.className === "waterfall-item") items.push(el);
});
}
// 获取节点
if (items.length === 0) return false;
// 遍历节点
for (let i = 0; i < items.length; i++) {
const curItem = items[i] as HTMLElement;
// 最小的y值
const minY = Math.min.apply(null, posY.value);
// 最小y的下标
const minYIndex = posY.value.indexOf(minY);
// 当前下标对应的x
const curX = getX(minYIndex);
// 设置x,y,width
const style = curItem.style as CssStyleObject;
// 设置偏移
if (transform) style[transform] = `translate3d(${curX}px,${minY}px, 0)`;
style.width = `${colWidth.value}px`;
// 更新当前index的y值
const { height } = curItem.getBoundingClientRect();
posY.value[minYIndex] += height + props.gutter;
// 添加入场动画
animation(curItem, () => {
// 添加动画时间
if (transition) style[transition] = "transform .3s";
});
}
wrapperHeight.value = Math.max.apply(null, posY.value);
};
return {
wrapperHeight,
layoutHandle,
};
}
// 动画
function addAnimation(props: WaterfallProps) {
return (item: HTMLElement, callback?: () => void) => {
const content = item!.firstChild as HTMLElement;
if (content && !hasClass(content, props.animationPrefix)) {
const durationSec = `${props.animationDuration / 1000}s`;
const delaySec = `${props.animationDelay / 1000}s`;
const style = content.style as CssStyleObject;
style.visibility = "visible";
if (duration) style[duration] = durationSec;
if (delay) style[delay] = delaySec;
if (fillMode) style[fillMode] = "both";
addClass(content, props.animationPrefix);
addClass(content, props.animationEffect);
// 确保动画完成后item可见
setTimeout(() => {
const itemStyle = item.style as CssStyleObject;
itemStyle.visibility = "visible";
if (callback) callback();
}, props.animationDuration + props.animationDelay);
}
};
}

188
src/lib/utils/Lazy.ts Normal file
View File

@ -0,0 +1,188 @@
import type { LazyOptions, ValueFormatterObject } from '../types/lazy'
import type { CssStyleObject } from '../types/util'
import { loadImage } from './loader'
import { assign, hasIntersectionObserver, isObject } from './util'
enum LifecycleEnum {
LOADING = 'loading',
LOADED = 'loaded',
ERROR = 'error',
}
const DEFAULT_OBSERVER_OPTIONS = {
rootMargin: '0px',
threshold: 0,
}
const DEFAULT_LOADING = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
const DEFAULT_ERROR = ''
export default class Lazy {
lazyActive = true // 是否开启懒加载
crossOrigin = true // 开启跨域
options: LazyOptions = {
loading: DEFAULT_LOADING,
error: DEFAULT_ERROR,
observerOptions: DEFAULT_OBSERVER_OPTIONS,
log: true,
}
_images = new WeakMap()
constructor(flag = true, options: LazyOptions, crossOrigin = true) {
this.lazyActive = flag
this.crossOrigin = crossOrigin
this.config(options)
}
config(options = {}) {
assign(this.options, options)
}
// mount
mount(el: HTMLImageElement, binding: string | ValueFormatterObject, callback: () => void): void {
const { src, loading, error } = this._valueFormatter(binding)
el.setAttribute('lazy', LifecycleEnum.LOADING)
el.setAttribute('src', loading || DEFAULT_LOADING)
if (!this.lazyActive) {
this._setImageSrc(el, src, callback, error)
}
else {
if (!hasIntersectionObserver) {
this._setImageSrc(el, src, callback, error)
this._log(() => {
throw new Error('Not support IntersectionObserver!')
})
}
this._initIntersectionObserver(el, src, callback, error)
}
}
// resize
resize(el: HTMLImageElement, callback: () => void) {
const lazy = el.getAttribute('lazy')
const src = el.getAttribute('src')
if (lazy && lazy === LifecycleEnum.LOADED && src) {
loadImage(src, this.crossOrigin).then((image) => {
const { width, height } = image
const curHeight = (el.width / width) * height
el.height = curHeight
const style = el.style as CssStyleObject
style.height = `${curHeight}px`
callback()
})
}
}
// unmount
unmount(el: HTMLElement): void {
const imgItem = this._realObserver(el)
imgItem && imgItem.unobserve(el)
this._images.delete(el)
}
/**
* 设置img的src
* @param {*} el - img
* @param {*} src - 原图
* @param {*} error - 错误图片
* @param {*} callback - 完成的回调函数,通知组件刷新布局
* @returns
*/
_setImageSrc(el: HTMLImageElement, src: string, callback: () => void, error?: string): void {
if (!src)
return
const preSrc = el.getAttribute('src')
if (preSrc === src)
return
loadImage(src, this.crossOrigin)
.then((image) => {
// 修改容器
const { width, height } = image
const ratio = height / width
const lazyBox = el.parentNode!.parentNode as HTMLElement
lazyBox.style.paddingBottom = `${ratio * 100}%`
// 设置图片
el.setAttribute('lazy', LifecycleEnum.LOADED)
el.removeAttribute('src')
el.setAttribute('src', src)
callback()
})
.catch(() => {
const imgItem = this._realObserver(el)
imgItem && imgItem.disconnect()
if (error) {
el.setAttribute('lazy', LifecycleEnum.ERROR)
el.setAttribute('src', error)
}
this._log(() => {
throw new Error(`Image failed to load!And failed src was: ${src} `)
})
callback()
})
}
_isOpenLazy(): boolean {
return hasIntersectionObserver && this.lazyActive
}
/**
* 添加img和对应的observer到weakMap中
* 开启监听
* 当出现在可视区域后取消监听
* @param {*} el - img
* @param {*} src - 图片
* @param {*} error - 错误图片
* @param {*} callback - 完成的回调函数,通知组件刷新布局
*/
_initIntersectionObserver(el: HTMLImageElement, src: string, callback: () => void, error?: string): void {
const observerOptions = this.options.observerOptions
this._images.set(
el,
new IntersectionObserver((entries) => {
Array.prototype.forEach.call(entries, (entry) => {
if (entry.isIntersecting) {
const imgItem = this._realObserver(el)
imgItem && imgItem.unobserve(entry.target)
this._setImageSrc(el, src, callback, error)
}
})
}, observerOptions),
)
const imgItem = this._realObserver(el)
imgItem && imgItem.observe(el)
}
// 格式化参数
_valueFormatter(value: ValueFormatterObject | string): ValueFormatterObject {
let src = value as string
let loading = this.options.loading
let error = this.options.error
if (isObject(value)) {
src = (value as ValueFormatterObject).src
loading = (value as ValueFormatterObject).loading || this.options.loading
error = (value as ValueFormatterObject).error || this.options.error
}
return {
src,
loading,
error,
}
}
// 日志
_log(callback: () => void): void {
if (this.options.log)
callback()
}
// 在map中获取对应img的observer事件
_realObserver(el: HTMLElement): IntersectionObserver | undefined {
return this._images.get(el)
}
}

53
src/lib/utils/dom.ts Normal file
View File

@ -0,0 +1,53 @@
import type { CssStyleObject } from '../types/util'
export function hasClass(el: HTMLElement, className: string) {
const reg = new RegExp(`(^|\\s)${className}(\\s|$)`)
return reg.test(el.className)
}
export function addClass(el: HTMLElement, className: string) {
if (hasClass(el, className))
return
const newClass = el.className.split(/\s+/)
newClass.push(className)
el.className = newClass.join(' ')
}
export function removeClass(el: HTMLElement, className: string) {
if (hasClass(el, className)) {
const newClass = el.className.split(/\s+/).filter(name => name !== className)
el.className = newClass.join(' ')
}
}
const elementStyle = document.createElement('div').style as CssStyleObject
const vendor = (() => {
const transformNames: Record<string, string> = {
webkit: 'webkitTransform',
Moz: 'MozTransform',
O: 'OTransform',
ms: 'msTransform',
standard: 'transform',
}
for (const key in transformNames) {
const val = transformNames[key]
if (elementStyle[val] !== undefined)
return key
}
return false
})()
export function prefixStyle(style: string) {
if (vendor === false)
return false
if (vendor === 'standard')
return style
return vendor + style.charAt(0).toUpperCase() + style.substr(1)
}

View File

@ -0,0 +1,19 @@
import type { ItemWidthProps } from "../types/waterfall";
/**
* @description: 获取当前窗口尺寸下格子的宽度
* @param {ItemWidthProps} param1
* @return {*}
*/
export const getItemWidth = ({
wrapperWidth,
gutter,
hasAroundGutter,
columns,
}: ItemWidthProps) => {
if (hasAroundGutter) {
return (wrapperWidth - gutter) / columns - gutter;
} else {
return (wrapperWidth - (columns - 1) * gutter) / columns;
}
};

20
src/lib/utils/loader.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* load images
* @param {Array[String]} images - 图片链接数组
*/
export function loadImage(url: string, crossOrigin: Boolean): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => {
resolve(image)
}
image.onerror = () => {
reject(new Error('Image load error'))
}
if (crossOrigin)
image.crossOrigin = 'Anonymous' // 支持跨域图片
image.src = url
})
}

156
src/lib/utils/util.ts Normal file
View File

@ -0,0 +1,156 @@
export const inBrowser = typeof window !== 'undefined' && window !== null
export const hasIntersectionObserver = checkIntersectionObserver()
const isEnumerable = Object.prototype.propertyIsEnumerable
const getSymbols = Object.getOwnPropertySymbols
/**
* 取值
* @param {Object | Array} form
* @param {...any} selectors
* @returns
*/
export function getValue(form: any, ...selectors: string[]) {
const res = selectors.map((s) => {
return s
.replace(/\[(\w+)\]/g, '.$1')
.split('.')
.reduce((prev, cur) => {
return prev && prev[cur]
}, form)
})
return res
}
/**
* 防抖
* @param {*} fn
* @param {*} delay
* @returns
*/
export function debounce(fn: (args?: any) => void, delay: number) {
let timer: any
return function(this: any, ...args: any) {
timer && clearTimeout(timer)
timer = null
timer = setTimeout(() => {
fn.apply(this, args)
}, delay)
}
}
/**
* 判断是否支持IntersectionObserver
* @returns {boolean}
*/
export function checkIntersectionObserver(): boolean {
if (
inBrowser
&& 'IntersectionObserver' in window
&& 'IntersectionObserverEntry' in window
&& 'intersectionRatio' in window.IntersectionObserverEntry.prototype
) {
// Minimal polyfill for Edge 15's lack of `isIntersecting`
// See: https://github.com/w3c/IntersectionObserver/issues/211
if (!('isIntersecting' in window.IntersectionObserverEntry.prototype)) {
Object.defineProperty(window.IntersectionObserverEntry.prototype, 'isIntersecting', {
get() {
return this.intersectionRatio > 0
},
})
}
return true
}
return false
}
/**
* is object
*
* @param {*} val
* @returns {boolean}
*/
export function isObject(val: any): boolean {
return typeof val === 'function' || toString.call(val) === '[object Object]'
}
/**
* is primitive
*
* @param {*} val
* @returns {boolean}
*/
export function isPrimitive(val: any): boolean {
return typeof val === 'object' ? val === null : typeof val !== 'function'
}
/**
* check private key
*
* @export
* @param {*} key
* @returns {boolean}
*/
export function isValidKey(key: any): boolean {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
}
/**
* Assign the enumerable es6 Symbol properties from one
* or more objects to the first object passed on the arguments.
* Can be used as a supplement to other extend, assign or
* merge methods as a polyfill for the Symbols part of
* the es6 Object.assign method.
* https://github.com/jonschlinkert/assign-symbols
*
* @param {*} target
* @param {Array} args
* @returns
*/
function assignSymbols(target: any, ...args: any[]) {
if (!isObject(target))
throw new TypeError('expected the first argument to be an object')
if (args.length === 0 || typeof Symbol !== 'function' || typeof getSymbols !== 'function')
return target
for (const arg of args) {
const names = getSymbols(arg)
for (const key of names) {
if (isEnumerable.call(arg, key))
target[key] = arg[key]
}
}
return target
}
/**
* Deeply assign the values of all enumerable-own-properties and symbols
* from one or more source objects to a target object. Returns the target object.
* https://github.com/jonschlinkert/assign-deep
*
* @param {*} target
* @param {Array} args
* @returns
*/
export function assign(target: any, ...args: any[]): void {
let i = 0
if (isPrimitive(target)) target = args[i++]
if (!target) target = {}
for (; i < args.length; i++) {
if (isObject(args[i])) {
for (const key of Object.keys(args[i])) {
if (isValidKey(key)) {
if (isObject(target[key]) && isObject(args[i][key]))
assign(target[key], args[i][key])
else
target[key] = args[i][key]
}
}
assignSymbols(target, args[i])
}
}
return target
}

View File

@ -3,23 +3,16 @@ import { useConfig } from "@/config";
import icon from "@/icon/index.ts";
import { createPinia } from "pinia";
import "virtual:uno.css";
import { createApp, vaporInteropPlugin } from "vue";
import 'vue-devui/tag/style.css';
import App from "./App.vue";
import router from "./router";
// 自定义主题配置 - 设置主色和二级色
import { ThemeServiceInit, infinityTheme, sweetTheme } from "devui-theme";
// 自定义主题配置 - 设置主色和二级色\
import "vfonts/FiraCode.css";
import Tag from 'vue-devui/tag';
import { PerfectScrollbarPlugin } from "vue3-perfect-scrollbar";
// import vue3videoPlay from "vue3-video-play";
// import "vue3-video-play/dist/style.css";
//main.js
// ThemeServiceInit({ customTheme }, "customTheme");
const themeService = ThemeServiceInit({ infinityTheme }, "infinityTheme");
themeService?.applyTheme(sweetTheme);
const app = createApp(App);
app.use(vaporInteropPlugin)
app.use(Tag)
app.use(createPinia());
app.use(router);
@ -30,4 +23,3 @@ for (const key in icon) {
}
app.use(PerfectScrollbarPlugin);
app.mount("#app");

View File

@ -1,4 +1,3 @@
import router from "@/router";
import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from "axios";
import axios from "axios";
import { useCookies } from "vue3-cookies";
@ -45,16 +44,16 @@ request.interceptors.response.use(
console.log("Response error", error);
if (error.response?.status === 401) {
// window.$msg.warning("无效的token");
window.$msg.warning("无效的token");
cookies.remove("token");
cookies.remove("userinfo");
window.$modal({
title: "无效的token",
content: "token已失效需要登录请登录 =>",
handdleSubmit: () => {
router.replace("/login");
},
});
// window.$modal({
// title: "无效的token",
// content: "token已失效需要登录请登录 =>",
// handdleSubmit: () => {
// router.replace("/login");
// },
// });
// router.replace("/login");
return "Unauthorized";

15
src/util/theme.ts Normal file
View File

@ -0,0 +1,15 @@
/**
* js 文件下使用这个做类型提示
* @type import('naive-ui').GlobalThemeOverrides
*/
const themeOverrides = {
common: {
primaryColor: '#ec66ab',
primaryColorHover: '#ec66ab',
primaryColorPressed: '#ec66ab',
primaryColorSuppl: '#ec66ab',
},
// ...
}
export default themeOverrides;

View File

@ -1,30 +0,0 @@
<template>
<div class="article-page">
<div id="vditor"></div>
</div>
</template>
<script setup lang="ts">
import Vditor from 'vditor';
import 'vditor/dist/index.css';
import { onMounted, ref } from 'vue';
definePage({
name: 'article',
meta: {
title: '文章',
}
})
// 文章页逻辑
const vditor:any = ref(null);
onMounted(() => {
vditor.value = new Vditor('vditor', {
height: '100vh',
width: '100vw'
});
});
</script>
<style scoped>
/* 文章页样式 */
</style>

18
src/views/Blog.vue Normal file
View File

@ -0,0 +1,18 @@
<template>
<div class="article-page">
<h1>文章页</h1>
</div>
</template>
<script setup lang="ts">
definePage({
name: 'blog',
meta: {
title: '文章',
}
})
</script>
<style scoped>
/* 文章页样式 */
</style>

View File

@ -1,126 +1,75 @@
<template>
<PerfectScrollbar ref="scrollbar" @ps-scroll-y="handleScroll">
<!-- <PerfectScrollbar ref="scrollbar" @ps-scroll-y="handleScroll"> -->
<div ref="myCon" class="gallery-page py-5 px-[10%]">
<d-search class="mt-0 mb-8 w-2/3 mx-auto rounded-full" v-model="kw" is-keyup-search :delay="1000"
@search="onSearch"></d-search>
<div class="gallery-container w-full box-border">
<!-- 瀑布流容器 -->
<div ref="waterfallContainer" v-image-preview
class="waterfall-container flex justify-between flex-nowrap w-full overflow-hidden">
<!-- 动态生成的列 -->
<div v-for="(column, index) in columns" :key="index" class="waterfall-column flex flex-col w-[240px]">
<div v-for="item in column" :key="item.id"
class="gallery-item group relative my-[10px] rounded-lg overflow-hidden transition-transform duration-300 box-border hover:-translate-y-1.5">
<div
class="absolute px-2 truncate hidden group-hover:block top-0 text-center w-full bg-[#00000070] text-white">
{{ item.filename }}</div>
<img :src="item.filepath" alt="" class="gallery-image block w-full h-auto object-cover rounded-md">
<div class="px-2 absolute bottom-0 flex justify-between w-full bg-[#00000060]">
<div class="text-white "> <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 上传</div>
<d-popover content="下载" trigger="hover" class="!bg-primary" style="color: #fff">
<icon-download @click="downloadFile(item.filepath)"
class="w-5 h-5 text-white hover-text-primary"></icon-download>
</d-popover>
</div>
<!-- <d-search class="mt-0 mb-8 w-2/3 mx-auto rounded-full" v-model="kw" is-keyup-search :delay="1000"
@search="onSearch"></d-search> -->
<n-input class="mb-8 !w-[90%] ml-[5%]" size="large" v-model:value="kw" @keyup.enter="onSearch" placeholder="请输入">
<template #suffix>
<n-icon size="large">
<icon-search />
</n-icon>
</template>
</n-input>
<!-- <div v-infinite-scroll="loadMore"> -->
<Waterfall ref="waterfall" :list="fileList" :width="cwidth" :gutter="gutter" :columns="column" img-selector="url"
animation-effect="fadeIn" :animation-duration="1000" :animation-delay="300" backgroundColor="transparent"> >
<template #item="{ item }">
<div
class="card rounded-md overflow-hidden group shadow-md transition-transform duration-300 box-border hover:-translate-y-1.5">
<!-- <div class="image-wrapper"> -->
<LazyImg class="rounded-md overflow-hidden" :url="item.filepath" />
<!-- </div> -->
<div
class="hidden truncate group-hover:block absolute rounded-md z-10 truncate top-0 text-center w-full bg-[#00000070] text-white">
{{ item.filename }}
</div>
<div
class="absolute rounded-md flex z-10 truncate bottom-0 w-full bg-[#00000070] text-white justify-between items-center">
<div> <span class="text-[#f1d9db] font-600">{{ item.nickname }}</span> 分享</div>
<icon-download class="cursor-pointer w-4 h-4" @click="downloadFile(item.filepath)" />
</div>
</div>
</div>
<!-- 加载中指示器 -->
<div v-if="loading" class="loading-indicator text-center p-5 text-gray-600">加载中...</div>
<div v-else class="loading-indicator text-center p-5 text-gray-600">已全部加载完成</div>
</div>
</template>
</Waterfall>
</div>
</PerfectScrollbar>
<!-- </div> -->
<!-- </PerfectScrollbar> -->
</template>
<script setup lang="ts">
import { throttle } from 'es-toolkit';
import { onMounted, onUnmounted, ref } from 'vue';
import { LazyImg, Waterfall } from '../lib';
definePage({
name: 'gallery',
meta: {
title: '画廊',
}
})
declare module "vue" {
interface HTMLAttributes {
"v-infinite-scroll"?: () => void;
}
}
const waterfall = ref<any>(null);
// 画廊页逻辑
const fileList = ref<any[]>([]);
const pn = ref(1);
const ps = ref(40);
const loading = ref(false);
const waterfallContainer = ref<HTMLDivElement | null>(null);
const columns = ref<Array<Array<any>>>([]);
const columnHeights = ref<number[]>([]);
const columnCount = ref(4); // 默认列数
const itemWidth = ref(240); // 图片宽度固定为240px
const kw = ref<string>('');
const cwidth = ref<number>(220);
const column = ref<number>(5);
const gutter = ref<number>(20);
// const uploadOptions = ref({
// uri: 'https://www.hxyouzi.com/api/files/upload',
// method: 'POST',
// maximumSize: 5 * 1024 * 1024,
// headers: {
// 'Authorization': 'Bearer ' + $cookies.get('token'),
// },
// });
// 计算列数 based on 屏幕宽度
function calculateColumnCount() {
if (!waterfallContainer.value) return;
const containerWidth = waterfallContainer.value.clientWidth;
const newColumnCount = Math.max(1, Math.floor(containerWidth / itemWidth.value));
if (newColumnCount !== columnCount.value) {
columnCount.value = newColumnCount;
resetWaterfall();
}
}
// 重置瀑布流
function resetWaterfall() {
columns.value = Array(columnCount.value).fill(0).map(() => []);
columnHeights.value = Array(columnCount.value).fill(0);
// 重新分配图片
fileList.value.forEach(async item =>await addToWaterfall(item));
console.log('>>> --> resetWaterfall --> fileList:', fileList.value)
}
// 添加图片到瀑布流
async function addToWaterfall(item: any) {
if (columns.value.length === 0) return;
const { height, width } =await getImageSizeByCheck(item.filepath)
// 找到高度最小的列
let minHeight = Math.min(...columnHeights.value);
let minIndex = columnHeights.value.indexOf(minHeight);
console.log('>>> --> addToWaterfall --> item:', item)
console.log('>>> --> addToWaterfall --> minIndex:', minIndex)
// 添加到该列
columns.value[minIndex].push(item);
// 估算列高 - 实际高度会在图片加载后更新
const estimatedHeight = itemWidth.value * height / width
columnHeights.value[minIndex] += estimatedHeight + 20; // 加上padding和margin
}
function getImageSizeByCheck(url: string): any {
return new Promise(function (resolve, reject) {
let image = new Image();
image.src = url;
let height = 0
let width = 0
// let timer = setTimeout(() => {
image.onload = () => {
if (image.width > 0 && image.height > 0) {
height = image.height
width = image.width
resolve({ height, width })
// clearTimeout(timer)
}
}
// }, 100)
});
// 计算列数
function calculateColumns() {
const totalWidth = window.innerWidth * 0.8; // 画廊宽度为视口宽度的80%
const col = Math.floor(totalWidth / (cwidth.value + gutter.value));
column.value = col > 0 ? col : 1;
waterfall.value?.renderer()
}
// 获取文件列表
@ -137,55 +86,27 @@ async function getFileList() {
if (pn.value === 1) {
fileList.value = res.data;
resetWaterfall();
} else {
// 追加新数据
res.data.forEach((item: any) => {
fileList.value.push(item);
addToWaterfall(item);
});
}
} catch (error) {
console.error('获取文件列表失败:', error);
} finally {
loading.value = false;
waterfall.value?.renderer();
}
}
// // 获取我的文件列表
// async function getMyList() {
// if (loading.value) return;
// loading.value = true;
// try {
// const res = await $http.file.getMyList({
// page_num: pn.value,
// page_size: ps.value,
// });
// // console.log('>>> --> getFileList --> res:', res);
// if (pn.value === 1) {
// fileList.value = res.data;
// resetWaterfall();
// } else {
// // 追加新数据
// res.data.forEach((item: any) => {
// fileList.value.push(item);
// addToWaterfall(item);
// });
// }
// } catch (error) {
// console.error('获取文件列表失败:', error);
// } finally {
// loading.value = false;
// }
// }
// 监听滚动事件
const handleScroll: any = throttle((e: any) => {
console.log('>>> --> handleScroll --> loading:', e)
// console.log('>>> --> handleScroll --> loading:', e)
if (loading.value) return;
const scrollTop = e.target.scrollTop
console.log('>>> --> handleScroll --> scrollTop:', scrollTop)
// console.log('>>> --> handleScroll --> scrollTop:', scrollTop)
const scrollHeight = e.target.scrollHeight
// console.log('>>> --> handleScroll --> scrollHeight:', scrollHeight)
const clientHeight = e.target.offsetHeight;
@ -224,12 +145,10 @@ function downloadFile(url: string) {
}
onMounted(() => {
calculateColumns()
getFileList();
// 计算初始列数
calculateColumnCount();
// 添加窗口大小变化监听
window.addEventListener('resize', calculateColumnCount);
// 添加滚动监听
window.addEventListener('resize', calculateColumns);
window.addEventListener('scroll', handleScroll);
});
@ -237,10 +156,7 @@ onUnmounted(() => {
pn.value = 1;
kw.value = '';
fileList.value = [];
resetWaterfall();
// 移除监听
window.removeEventListener('resize', calculateColumnCount);
window.removeEventListener('scroll', handleScroll);
});
</script>

View File

@ -1,97 +1,99 @@
<template>
<div class="home-page" :style="contentStyle">
<d-layout>
<d-content class="main-content">
<div class="home-page" :content-style="contentStyle">
<n-layout has-sider>
<n-layout-content class="main-content">
<div class="pt-8 px-12 relative hidden lg:block">
<d-input class="devui-input-demo__mt" size="lg" v-model="searchWord" @blur="cancelSbox" placeholder="请输入">
<template #prepend>
<d-select class="w-48" size="lg" v-model="broswer" :options="options" @click="cancelSbox"></d-select>
</template>
<template #append>
<d-icon name="search" style="font-size: inherit;" @click="search" />
</template>
</d-input>
<div v-if="searchBox" class="absolute left-34 mt-2 z-10 bg-white text-sm text-gray-500 max-h-40 rounded-md shadow-md px-4 py-2 max-w-80">
<n-input-group class="shadow ">
<n-select class="w-24" size="large" v-model:value="broswer" :options="options"
@click="cancelSbox"></n-select>
<n-input class="flex-1" size="large" v-model:value="searchWord" @blur="cancelSbox" placeholder="请输入">
<template #suffix>
<n-icon size="large">
<icon-search />
</n-icon>
</template>
</n-input>
</n-input-group>
<div v-if="searchBox"
class="absolute left-34 mt-2 z-10 bg-white text-sm text-gray-500 rounded-md shadow-md px-4 py-2 max-w-80">
<div class="flex p-2 pr-20 truncate rounded-md items-center hover:text-primary cursor-pointer"
:class="selecedIdx === idx ? 'text-white bg-primary' : ''" v-for="(i, idx) in searchItems"
:key="idx" @click="goExtra(i.menu_link)" @keyup.enter ="goExtra(i.menu_link)"
><span v-if="idx">导航</span> {{i.menu_name }}</div>
:class="selecedIdx === idx ? 'text-white bg-primary' : ''" v-for="(i, idx) in searchItems" :key="idx"
@click="goExtra(i.menu_link)" @keyup.enter="goExtra(i.menu_link)"><span v-if="idx">导航</span>
{{ i.menu_name }}</div>
</div>
</div>
<!-- 标签组 -->
<!-- <PerfectScrollbar class="w-full overflow-x-auto"> -->
<div v-if="navlist.length" class="flex gap-6 px-2 mt-6 mb-3 flex-wrap lg:px-12">
<d-tag class="cursor-pointer truncate" hideBeyondTags v-for="tag in tagList" :checked="tag.checked"
:color="tag.color" @click="handdleTagClick(tag)">{{ tag.name }}</d-tag>
</div>
<!-- </PerfectScrollbar> -->
<!-- 图片网格展示区域 -->
<PerfectScrollbar class="" :style="navStyle">
<n-scrollbar class="w-full overflow-x-auto" :style="navStyle">
<div v-if="navlist.length" class="flex gap-6 px-2 mt-6 mb-3 flex-wrap lg:px-12">
<d-tag class="cursor-pointer truncate" hideBeyondTags v-for="tag in tagList" :checked="tag.checked"
:color="tag.color" @click="handdleTagClick(tag)">{{ tag.name }}</d-tag>
</div>
<!-- </PerfectScrollbar> -->
<!-- 图片网格展示区域 -->
<div ref="navcards"
class="navcard grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-6 gap-5 pt-3 pb-6 px-2 lg:px-12">
<d-card class="bg-[#ffffff80] h-24" v-for="(item, index) in navlist" :key="index"
@click="goExtra(item.menu_link)" @contextmenu.prevent="handdleContextMenu($event, item)">
<template #content>
<div class="mt-1 w-full flex flex-col items-center cursor-pointer hover:!text-primary">
<div :style="{ background: item.color }"
class="w-8 h-8 rounded-full text-white flex items-center justify-center" v-if="item.icon_error">
{{ item.first }}</div>
<img class="grid-image w-8 h-8 rounded-full" v-else :src="item.menu_icon"
@error="imgErr(index as number)" />
<div class="mt-2 w-full text-center text-lg truncate">{{ item.menu_name || "" }}</div>
<em class="absolute rounded-md top-0 left-0 px-2 text-white text-center text-sm"
:style="{ background: getItemColor(item) }">{{ item.tag }}</em>
</div>
</template>
</d-card>
<d-card class="bg-[#ffffff80] h-25">
<n-card embedded
class="bg-[#ffffff80] h-24 shadow-md transition-transform duration-300 box-border hover:-translate-y-1.5"
v-for="(item, index) in navlist" :key="index" @click="goExtra(item.menu_link)"
@contextmenu.prevent="handdleContextMenu($event, item)">
<div class="mt-1 w-full flex flex-col items-center cursor-pointer hover:!text-primary">
<div :style="{ background: item.color }"
class="w-8 h-8 rounded-full text-white flex items-center justify-center" v-if="item.icon_error">
{{ item.first }}</div>
<img class="grid-image w-8 h-8 rounded-full" v-else :src="item.menu_icon"
@error="imgErr(index as number)" />
<div class="mt-2 w-full text-center text-lg truncate">{{ item.menu_name || "" }}</div>
<em class="absolute rounded-md top-0 left-0 px-2 text-white text-center text-sm"
:style="{ background: getItemColor(item) }">{{ item.tag }}</em>
</div>
</n-card>
<n-card embedded
class="bg-[#ffffff80] h-24 shadow-md transition-transform duration-300 box-border hover:-translate-y-1.5">
<div @click="addNav" class="w-full h-full flex flex-col items-center justify-center cursor-pointer">
<div :style="{ background: getRandomDarkColor() }"
class="w-12 h-12 rounded-full text-2xl text-white flex items-center justify-center">
class="w-8 h-8 rounded-full text-2xl text-white flex items-center justify-center">
+
</div>
<div class="mt-2 w-full text-center text-lg truncate text-primary">新增导航</div>
</div>
</d-card>
</n-card>
</div>
</PerfectScrollbar>
</d-content>
<d-aside class="daside hidden w-120 lg:block">
</n-scrollbar>
</n-layout-content>
<n-layout-sider width="30rem">
<homeSide></homeSide>
</d-aside>
</d-layout>
</n-layout-sider>
</n-layout>
<!-- 新增导航弹窗 -->
<d-modal class="!w-120" v-model="visible" title="新增导航">
<d-form ref="formNav" layout="vertical" :data="navData">
<d-form-item class="h-8" field="username">
<d-input @blur="getIcon" v-model="navData.menu_link" placeholder="请输入单行链接(必填)" />
</d-form-item>
<d-form-item class="h-8" field="password">
<d-input v-model="navData.menu_name" placeholder="请输入导航名称(必填)" />
</d-form-item>
<d-form-item class="h-8" field="tag">
<d-input v-model="navData.tag" placeholder="请自定义一个标签(必填,只取前四字)" />
</d-form-item>
<d-form-item class="h-8 form-operation-wrap">
<div class="flex">
<d-input v-model="navData.menu_icon" placeholder="请输入图标链接" />
<img class="ml-5" v-if="navData.menu_icon" width="30" height="30" :src="navData.menu_icon" alt="">
<div v-else class="ml-5 w-[30px] h-[30px]"></div>
</div>
</d-form-item>
</d-form>
<!-- <div class="mt-14 w-full flex justify-between">
<d-button @click="navCancel" variant="text" class="w-[49%] hover:bg-[#8a6684] hover:!text-white">取消</d-button>
<span class="text-[20px]"> | </span>
<d-button @click="navSubmit" variant="text" class="w-[49%] hover:bg-[#5c866a] hover:!text-white"
color="primary">确定</d-button>
</div> -->
<div class="mt-14 flex justify-between">
<d-button class="w-[48%]" variant="solid" color="secondary" @click="navCancel">取消</d-button>
<d-button class="w-[48%]" variant="solid" color="primary" @click="navSubmit">确定</d-button>
</div>
</d-modal>
<maskX :visible="visible" :setVisible="navCancel">
<n-form class="w-[500px] bg-white rounded-md p-8 shadow-md" ref="formNav" layout="vertical" :data="navData">
<div class="text-center text-lg">新增导航</div>
<n-form-item class="h-8" path="menu_link">
<n-input @blur="getIcon" v-model:value="navData.menu_link" placeholder="请输入单行链接(必填)" />
</n-form-item>
<n-form-item class="h-8 mt-4" path="menu_name">
<n-input v-model:value="navData.menu_name" placeholder="请输入导航名称(必填)" />
</n-form-item>
<n-form-item class="h-8 mt-4" path="tag">
<n-input v-model:value="navData.tag" placeholder="请自定义一个标签(必填,只取前四字)" />
</n-form-item>
<n-form-item class="h-8 mt-4 form-operation-wrap">
<!-- <div class="flex"> -->
<n-input v-model:value="navData.menu_icon" placeholder="请输入图标链接" />
<img class="ml-5" v-if="navData.menu_icon" width="30" height="30" :src="navData.menu_icon" alt="">
<div v-else class="ml-5 w-[30px] h-[30px]"></div>
<!-- </div> -->
</n-form-item>
<div class="mt-14 flex justify-between">
<n-button class="w-[48%]" secondary variant="solid" @click="navCancel">取消</n-button>
<n-button class="w-[48%]" type="primary" variant="solid" @click="navSubmit">确定</n-button>
</div>
</n-form>
</maskX>
<!-- 音乐插件 -->
<aplayer></aplayer>
@ -116,25 +118,29 @@
</contextMenu>
<!-- 编辑弹窗 -->
<d-modal class="!w-120" v-model="editModal" title="导航修改">
<div class="mb-2">
<span class="text-primary" v-if="currentClickedItem === 1">导航名称{{ currentItem.menu_name }}</span>
<span class="text-primary" v-if="currentClickedItem === 2">导航链接{{ currentItem.menu_link }}</span>
<span class="text-primary" v-if="currentClickedItem === 3">导航标签{{ currentItem.tag }}</span>
<maskX :visible="editModal" :setVisible="navCancel">
<div class="w-[500px] bg-white p-8 rounded-md">
<div class="text-center text-lg mb-8">修改导航内容</div>
<div class="mb-4">
<span class="text-primary" v-if="currentClickedItem === 1">导航名称{{ currentItem?.menu_name }}</span>
<span class="text-primary" v-if="currentClickedItem === 2">导航链接{{ currentItem?.menu_link }}</span>
<span class="text-primary" v-if="currentClickedItem === 3">导航标签{{ currentItem?.tag }}</span>
</div>
<n-input v-model:value="editInput" placeholder="请输入修改内容"></n-input>
<div class="mt-8 flex justify-between">
<n-button class="w-[48%]" secondary @click="handdleItemCancel">取消</n-button>
<n-button class="w-[48%]" type="primary" @click="handdleItemSubmit">确定</n-button>
</div>
</div>
<d-input v-model="editInput" placeholder="请输入修改内容"></d-input>
<div class="mt-8 flex justify-between">
<d-button class="w-[48%]" variant="solid" color="secondary" @click="handdleItemCancel">取消</d-button>
<d-button class="w-[48%]" variant="solid" color="primary" @click="handdleItemSubmit">确定</d-button>
</div>
</d-modal>
</maskX>
</div>
</template>
<script setup lang="ts">
import contextMenu from '@/components/contextMenu.vue';
import maskX from '@/components/mask.vue';
import { deepclone, getDictValue } from '@/util/index.ts';
definePage({
name: 'home',
@ -144,24 +150,48 @@ definePage({
})
// 定义接口类型
interface NavItem {
nid?: number
menu_link: string
menu_name: string
tag: string
menu_icon: string
icon_error?: boolean
first?: string
color?: string
}
interface MenuItem {
menu_name: string
menu_link: string
tag: string
}
interface TagItem {
name: string
color: string
checked: boolean
}
// 右键菜单
const menuShow = ref(false)
const MenuOptions: any = reactive({});
const currentItem = ref<any>(null);
const menuS = '!m-0 py-3 px-6 text-sm text-gray-700 w-full flex items-center justify-center hover:bg-[#f5f0f0] hover:text-primary';
const currentClickedItem = ref<number>(0);
const editModal = ref<boolean>(false);
const editInput = ref<string>('');
const MenuOptions = reactive({ x: 0, y: 0 })
const currentItem = ref<NavItem | null>(null)
const menuS = '!m-0 py-3 px-6 text-sm text-gray-700 w-full flex items-center justify-center hover:bg-[#f5f0f0] hover:text-primary'
const currentClickedItem = ref<number>(0)
const editModal = ref<boolean>(false)
const editInput = ref<string>('')
// 新增导航弹窗
const visible: any = ref<boolean>(false)
const navData: any = reactive({
const visible = ref<boolean>(false)
const navData = reactive<NavItem>({
menu_link: '',
menu_name: '',
tag: '',
menu_icon: ''
})
const formNav: any = ref(null)
const navcards: any = ref(null)
const formNav = ref<any>(null)
const navcards = ref<any>(null)
// 首页逻辑
const nav: any = $store.nav.useNavStore()
@ -174,22 +204,22 @@ const selecedIdx = ref(0)
const searchBox = ref(false)
const options = ref([
{
name: '必应',
label: '必应',
value: 'bing',
url: 'https://cn.bing.com/search?q='
},
{
name: '百度',
label: '百度',
value: 'baidu',
url: 'https://www.baidu.com/s?wd='
},
{
name: '谷歌',
label: '谷歌',
value: 'google',
url: 'https://www.google.com/search?q='
},
{
name: '翻译',
label: '翻译',
value: 'trans',
url: 'https://translate.volcengine.com?text='
},
@ -197,9 +227,9 @@ const options = ref([
])
const navlist: any = ref([])
let cloneNavlist: any = []
const tagList: any = ref([
const navlist = ref<NavItem[]>([])
let cloneNavlist: NavItem[] = []
const tagList = ref<TagItem[]>([
{
name: '全部',
color: '',
@ -209,11 +239,11 @@ const tagList: any = ref([
const usrLog = $store.log.useLogStore()
let timer: any = null;
let timer: any = null
// 输入搜索内容时监听searchWord在navlist中模糊搜索
watch(searchWord, () => {
handdleInput()
handleInput()
})
function cancelSbox() {
@ -224,7 +254,7 @@ function cancelSbox() {
}, 200)
}
function handdleInput() {
function handleInput() {
if (!searchWord.value) {
searchItems.value = []
searchBox.value = false
@ -234,24 +264,23 @@ function handdleInput() {
searchBox.value = true
selecedIdx.value = 0
const keyword = searchWord.value.toLowerCase()
searchItems.value = navlist.value.filter((item: any) =>
searchItems.value = navlist.value.filter((item) =>
item.menu_name.toLowerCase().includes(keyword) ||
item.menu_link.toLowerCase().includes(keyword) ||
item.tag.toLowerCase().includes(keyword)
)
// 在searchItems第一个位置插入一条原本搜索
searchItems.value.unshift({
menu_name: `${getDictValue(options.value, "value", broswer.value, "name")}中搜索"${searchWord.value}"`,
menu_name: `${getDictValue(options.value, "value", broswer.value, "label")}中搜索"${searchWord.value}"`,
menu_link: getDictValue(options.value, "value", broswer.value, "url") + searchWord.value,
tag: ''
})
}
function handdleKeyup(e: any) {
function handleKeyup(e: KeyboardEvent) {
if (!searchBox.value) return
// console.log('>>> --> haddleDown --> idx:', e.keyCode)
// 向下箭头
if (e.keyCode == 40) {
if (e.keyCode === 40) {
if (selecedIdx.value < searchItems.value.length - 1) {
selecedIdx.value += 1
} else {
@ -259,7 +288,7 @@ function handdleKeyup(e: any) {
}
}
// 向上箭头
else if (e.keyCode == 38) {
else if (e.keyCode === 38) {
if (selecedIdx.value > 0) {
selecedIdx.value -= 1
} else {
@ -267,7 +296,7 @@ function handdleKeyup(e: any) {
}
}
// 回车键
else if (e.keyCode == 13) {
else if (e.keyCode === 13) {
if (searchItems.value.length > 0) {
const selectedItem = searchItems.value[selecedIdx.value]
window.open(selectedItem.menu_link, "_BLANK")
@ -281,19 +310,23 @@ function handdleKeyup(e: any) {
// 2秒后自动隐藏菜单
const hideMenu = () => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
menuShow.value = false
}, 2000)
}
const removeTimer = () => {
clearTimeout(timer)
if (timer) {
clearTimeout(timer)
timer = null
}
}
function handdleMenuItem(type: number) {
function handleMenuItem(type: number) {
menuShow.value = false
currentClickedItem.value = type
if (type == 4) {
if (type === 4) {
// 删除导航
if (!currentItem.value) return
$modal({
@ -302,9 +335,8 @@ function handdleMenuItem(type: number) {
cancelText: '取消',
submitText: '删除',
handdleSubmit: async () => {
const res = await $http.nav.deleteNav(currentItem.value.nid)
console.log('>>> --> handdleMenuItem --> res:', res)
if (res.code == 200) {
const res = await $http.nav.deleteNav(currentItem.value?.nid)
if (res.code === 200) {
$msg.success('删除成功')
getNavList()
}
@ -315,27 +347,26 @@ function handdleMenuItem(type: number) {
editModal.value = true
}
function handdleItemCancel() {
function handleItemCancel() {
editModal.value = false
editInput.value = ''
}
async function handdleItemSubmit() {
async function handleItemSubmit() {
if (!editInput.value) {
$msg.error('请输入修改内容')
return
}
let updateData: any = {}
if (currentClickedItem.value == 1) {
let updateData: Partial<NavItem> = {}
if (currentClickedItem.value === 1) {
updateData.menu_name = editInput.value
} else if (currentClickedItem.value == 2) {
} else if (currentClickedItem.value === 2) {
updateData.menu_link = editInput.value
} else if (currentClickedItem.value == 3) {
} else if (currentClickedItem.value === 3) {
updateData.tag = editInput.value.slice(0, 4)
}
const res = await $http.nav.editNav(currentItem.value.nid, updateData)
console.log('>>> --> handdleItemSubmit --> res:', res)
if (res.code == 200) {
const res = await $http.nav.editNav(currentItem.value!.nid!, updateData)
if (res.code === 200) {
$msg.success('修改成功')
editModal.value = false
editInput.value = ''
@ -350,23 +381,30 @@ async function getNavList() {
return
}
const res = await $http.nav.getNavList()
res.data?.forEach((i: any) => {
i.icon_error = false
i.first = i.menu_name.at(0)
i.color = getRandomDarkColor()
});
navlist.value = res.data
if (!res.data) return
console.log("&&&&&&&&&&&&&&", navlist.value);
// 合并两个循环,提高性能
res.data.forEach((i: any) => {
if (!tagList.value.find((t: any) => t.name == i.tag))
tagList.value.push({ name: i.tag.substring(0, 4), color: getRandomDarkColor(30, 128), checked: false })
})
i.icon_error = false
i.first = i.menu_name?.at(0) || ''
i.color = getRandomDarkColor()
// 同时处理标签列表
if (!tagList.value.find((t) => t.name === i.tag)) {
tagList.value.push({
name: i.tag.substring(0, 4),
color: getRandomDarkColor(30, 128),
checked: false
})
}
});
navlist.value = res.data
cloneNavlist = deepclone(res.data)
}
function getItemColor(item: any) {
const tag = tagList.value.find((t: any) => t.name == item.tag)
function getItemColor(item: NavItem) {
const tag = tagList.value.find((t) => t.name === item.tag)
return tag ? tag.color : getRandomDarkColor(30, 128)
}
@ -453,6 +491,8 @@ function addNav() {
}
async function getIcon() {
console.log(11111);
if (!navData.menu_link) return
const res = await $http.mix.getIcon({
url: navData.menu_link
@ -463,9 +503,15 @@ async function getIcon() {
}
}
function resetNav() {
navData.menu_link = ''
navData.menu_name = ''
navData.tag = ''
navData.menu_icon = ''
}
function navCancel() {
visible.value = false
formNav.value.resetFields()
resetNav()
}
async function navSubmit() {
@ -481,14 +527,10 @@ async function navSubmit() {
if (res.code == 200) {
$msg.success('添加成功')
visible.value = false
formNav.value.resetFields()
getNavList()
}
// 清空navData
navData.menu_link = ''
navData.menu_name = ''
navData.tag = ''
navData.menu_icon = ''
resetNav()
}
// 处理右键菜单
function handdleContextMenu(event: MouseEvent, item: any) {
@ -503,7 +545,7 @@ function handdleContextMenu(event: MouseEvent, item: any) {
// 监听store的登录状态
watch(() => usrLog.isLogin, (newVal) => {
watch(() => usrLog.isLogin, (newVal: any) => {
console.log('********** --> watch --> newVal:', newVal)
if (newVal) {
getNavList()
@ -511,17 +553,97 @@ watch(() => usrLog.isLogin, (newVal) => {
navlist.value = []
}
})
function handdleKeyup(e: any) {
if (!searchBox.value) return
// console.log('>>> --> haddleDown --> idx:', e.keyCode)
// 向下箭头
if (e.keyCode == 40) {
if (selecedIdx.value < searchItems.value.length - 1) {
selecedIdx.value += 1
} else {
selecedIdx.value = 0
}
}
// 向上箭头
else if (e.keyCode == 38) {
if (selecedIdx.value > 0) {
selecedIdx.value -= 1
} else {
selecedIdx.value = searchItems.value.length - 1
}
}
// 回车键
else if (e.keyCode == 13) {
if (searchItems.value.length > 0) {
const selectedItem = searchItems.value[selecedIdx.value]
window.open(selectedItem.menu_link, "_BLANK")
searchBox.value = false
selecedIdx.value = 0
}
}
}
function handdleItemCancel() {
editModal.value = false
editInput.value = ''
}
async function handdleItemSubmit() {
if (!editInput.value) {
$msg.error('请输入修改内容')
return
}
let updateData: any = {}
if (currentClickedItem.value == 1) {
updateData.menu_name = editInput.value
} else if (currentClickedItem.value == 2) {
updateData.menu_link = editInput.value
} else if (currentClickedItem.value == 3) {
updateData.tag = editInput.value.slice(0, 4)
}
const res = await $http.nav.editNav(currentItem.value?.nid, updateData)
console.log('>>> --> handdleItemSubmit --> res:', res)
if (res.code == 200) {
$msg.success('修改成功')
editModal.value = false
editInput.value = ''
getNavList()
}
}
function handdleMenuItem(type: number) {
menuShow.value = false
currentClickedItem.value = type
if (type == 4) {
// 删除导航
if (!currentItem.value) return
$modal({
title: '删除导航',
content: `确定要删除【${currentItem.value.menu_name}】吗?删除后不可恢复哦~`,
cancelText: '取消',
submitText: '删除',
handdleSubmit: async () => {
const res = await $http.nav.deleteNav(currentItem.value?.nid)
console.log('>>> --> handdleMenuItem --> res:', res)
if (res.code == 200) {
$msg.success('删除成功')
getNavList()
}
}
})
return
}
editModal.value = true
}
const navHeight: any = ref(0)
onMounted(() => {
console.log("&&&&&&&&&&&&&&", nav.navH);
contentStyle.value = {
height: `${window.innerHeight - nav.navH}px)`
}
console.log('>>> --> onMounted --> navcards.value.getBoundingClientRect().y:', navcards.value.getBoundingClientRect().y)
navHeight.value = window.innerHeight - navcards.value.getBoundingClientRect().y
navStyle.value = {
// height: `calc(100vh - ${navcards.value.getBoundingClientRect().y}px - ${nav.navH}px)`,
height: `${window.innerHeight - navcards.value.getBoundingClientRect().y - 20}px`
height: `${window.innerHeight - navcards.value.getBoundingClientRect().y}px`
}
tagList.value = [
{
@ -536,7 +658,7 @@ onMounted(() => {
height: `calc(100vh - ${nav.navH}px)`
}
navStyle.value = {
height: `${window.innerHeight - navcards.value.getBoundingClientRect().top - 20}px`
height: `${navHeight.value}px`
}
});
window.addEventListener('keyup', (e: Event) => {

View File

@ -1,22 +1,6 @@
<template>
<div class="widget-page">
<h1>工具页</h1>
<d-card class="w-1/3 bg-[#ffffff60] rounded-[10px]" shadow="hover">
<template #title>
<div class="flex items-center">
<icon-widget class="w-5 mr-2"></icon-widget>
视频工具
</div>
</template>
<template #content>
<div class="p-4">
<vue3VideoPlay class="w-full" title="钢铁侠" src="https://cdn.jsdelivr.net/gh/xdlumia/files/video-play/IronMan.mp4" />
</div>
</template>
</d-card>
</div>
</template>

View File

@ -1,21 +0,0 @@
<template>
<div>
这里是控制台首页
</div>
</template>
<script setup>
//mark import
//mark data
//mark method
//mark 周期、内置函数等
</script>
<style scoped lang="less">
</style>