迁移UI框架至naive-ui,重构组件和样式,添加Gallery和Mask组件
This commit is contained in:
72
src/App.vue
72
src/App.vue
@ -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>
|
||||
@ -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;
|
||||
|
||||
@ -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
281
src/components/Gallery.vue
Normal 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>
|
||||
@ -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 = ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
35
src/components/mask.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -1,5 +0,0 @@
|
||||
import { Message } from 'vue-devui';
|
||||
|
||||
const msg:any = Message;
|
||||
|
||||
export default msg;
|
||||
1
src/icon/loc.svg
Normal file
1
src/icon/loc.svg
Normal 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
1
src/icon/search.svg
Normal 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 |
BIN
src/lib/assets/loadError.png
Normal file
BIN
src/lib/assets/loadError.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 680 B |
BIN
src/lib/assets/loading.gif
Normal file
BIN
src/lib/assets/loading.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 284 KiB |
183
src/lib/components/LazyImg.vue
Normal file
183
src/lib/components/LazyImg.vue
Normal 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>
|
||||
208
src/lib/components/Waterfall.vue
Normal file
208
src/lib/components/Waterfall.vue
Normal 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
3
src/lib/index.ts
Normal 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
4
src/lib/types/images.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "*.gif" {
|
||||
const value: string;
|
||||
export default value;
|
||||
}
|
||||
13
src/lib/types/jsx.d.ts
vendored
Normal file
13
src/lib/types/jsx.d.ts
vendored
Normal 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
77
src/lib/types/lazy.d.ts
vendored
Normal 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
4
src/lib/types/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module "*.png" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
3
src/lib/types/util.d.ts
vendored
Normal file
3
src/lib/types/util.d.ts
vendored
Normal 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
34
src/lib/types/waterfall.d.ts
vendored
Normal 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
3
src/lib/use/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
export { useCalculateCols } from './useCalculateCols'
|
||||
export { useLayout } from './useLayout'
|
||||
46
src/lib/use/useCalculateCols.ts
Normal file
46
src/lib/use/useCalculateCols.ts
Normal 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
118
src/lib/use/useLayout.ts
Normal 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
188
src/lib/utils/Lazy.ts
Normal 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
53
src/lib/utils/dom.ts
Normal 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)
|
||||
}
|
||||
19
src/lib/utils/itemWidth.ts
Normal file
19
src/lib/utils/itemWidth.ts
Normal 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
20
src/lib/utils/loader.ts
Normal 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
156
src/lib/utils/util.ts
Normal 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
|
||||
}
|
||||
18
src/main.ts
18
src/main.ts
@ -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");
|
||||
|
||||
|
||||
@ -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
15
src/util/theme.ts
Normal 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;
|
||||
@ -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
18
src/views/Blog.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
这里是控制台首页
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
//mark import
|
||||
|
||||
//mark data
|
||||
|
||||
//mark method
|
||||
|
||||
//mark 周期、内置函数等
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
</style>
|
||||
Reference in New Issue
Block a user