refactor:pc views

This commit is contained in:
jiangrui
2025-03-05 18:20:54 +08:00
parent 7bcec7e3b4
commit 1f3a83b84d
25 changed files with 2949 additions and 1117 deletions

View File

@@ -1,34 +1,38 @@
<template>
<div class="aside-menu">
<div class="logo">
<img :src="logo" class="logo-img" />
<div class="logo-text">Cloud Saver</div>
<div class="pc-aside">
<!-- Logo 区域 -->
<div class="pc-aside__logo">
<img :src="logo" alt="Cloud Saver Logo" class="logo__image" />
<h1 class="logo__title">Cloud Saver</h1>
</div>
<!-- 菜单区域 -->
<el-menu
:default-active="currentMenu?.index || '1'"
:default-openeds="currentMenuOpen"
class="el-menu-vertical"
@open="handleOpen"
@close="handleClose"
class="pc-aside__menu"
>
<template v-for="menu in menuList">
<el-sub-menu v-if="menu.children" :key="menu.index" :index="menu.index">
<template v-for="menu in menuList" :key="menu.index">
<!-- 子菜单 -->
<el-sub-menu v-if="menu.children" :index="menu.index">
<template #title>
<el-icon><component :is="menu.icon" /></el-icon>
<span>{{ menu.title }}</span>
</template>
<el-menu-item
v-for="child in menu.children"
:key="child.index"
:index="child.index"
@click="handleMenuClick(child)"
>
{{ child.title }}
<span>{{ child.title }}</span>
</el-menu-item>
</el-sub-menu>
<!-- 普通菜单项 -->
<el-menu-item
v-else
:key="menu.router"
:index="menu.index"
:disabled="menu.disabled"
@click="handleMenuClick(menu)"
@@ -38,54 +42,86 @@
</el-menu-item>
</template>
</el-menu>
<!-- GitHub 链接 -->
<div class="pc-aside__footer">
<a
href="https://github.com/jiangrui1994/CloudSaver"
target="_blank"
rel="noopener noreferrer"
class="github-link"
>
<svg
height="20"
aria-hidden="true"
viewBox="0 0 24 24"
version="1.1"
width="20"
class="github-icon"
>
<path
fill="currentColor"
d="M12.5.75C6.146.75 1 5.896 1 12.25c0 5.089 3.292 9.387 7.863 10.91.575.101.79-.244.79-.546 0-.273-.014-1.178-.014-2.142-2.889.532-3.636-.704-3.866-1.35-.13-.331-.69-1.352-1.18-1.625-.402-.216-.977-.748-.014-.762.906-.014 1.553.834 1.769 1.179 1.035 1.74 2.688 1.25 3.349.948.1-.747.402-1.25.733-1.538-2.559-.287-5.232-1.279-5.232-5.678 0-1.25.445-2.285 1.178-3.09-.115-.288-.517-1.467.115-3.048 0 0 .963-.302 3.163 1.179.92-.259 1.897-.388 2.875-.388.977 0 1.955.13 2.875.388 2.2-1.495 3.162-1.179 3.162-1.179.633 1.581.23 2.76.115 3.048.733.805 1.179 1.825 1.179 3.09 0 4.413-2.688 5.39-5.247 5.678.417.36.776 1.05.776 2.128 0 1.538-.014 2.774-.014 3.162 0 .302.216.662.79.547C20.709 21.637 24 17.324 24 12.25 24 5.896 18.854.75 12.5.75Z"
/>
</svg>
<span>GitHub</span>
<span class="version">v{{ pkg.version }}</span>
</a>
</div>
</div>
</template>
<script lang="ts" setup>
import { Search, Film, Setting } from "@element-plus/icons-vue";
import logo from "@/assets/images/logo.png";
import { useRouter, useRoute } from "vue-router";
<script setup lang="ts">
import { computed } from "vue";
const router = useRouter();
const route = useRoute();
import { useRouter, useRoute } from "vue-router";
import { Search, Film, Setting, Link } from "@element-plus/icons-vue";
import logo from "@/assets/images/logo.png";
import pkg from "../../package.json";
// 类型定义
interface MenuItem {
index: string;
title: string;
icon?: typeof Search | typeof Film | typeof Setting;
icon?: typeof Search | typeof Film | typeof Setting | typeof Link;
router?: string;
children?: MenuItem[];
disabled?: boolean;
}
// 路由相关
const router = useRouter();
const route = useRoute();
// 菜单配置
const menuList: MenuItem[] = [
{
index: "2",
index: "1",
title: "资源搜索",
icon: Search,
router: "/",
router: "/resource",
},
{
index: "1",
index: "2",
title: "豆瓣榜单",
icon: Film,
children: [
{
index: "1-1",
index: "2-1",
title: "热门电影",
router: "/douban?type=movie",
},
{
index: "1-2",
index: "2-2",
title: "热门电视剧",
router: "/douban?type=tv",
},
{
index: "1-3",
index: "2-3",
title: "最新电影",
router: "/douban?type=movie&tag=最新",
},
{
index: "1-4",
index: "2-4",
title: "热门综艺",
router: "/douban?type=tv&tag=综艺",
},
@@ -100,50 +136,171 @@ const menuList: MenuItem[] = [
},
];
// 计算当前激活的菜单
const currentMenu = computed(() => {
return menuList
.reduce((pre: MenuItem[], cur: MenuItem) => {
if (!cur.children) {
pre.push(cur);
} else {
pre.push(...cur.children);
}
return pre;
}, [])
.find((x) => x.router === decodeURIComponent(route.fullPath));
const flatMenus = menuList.reduce<MenuItem[]>((acc, menu) => {
if (!menu.children) {
acc.push(menu);
} else {
acc.push(...menu.children);
}
return acc;
}, []);
return flatMenus.find((menu) => menu.router === decodeURIComponent(route.fullPath));
});
// 计算当前展开的子菜单
const currentMenuOpen = computed(() => {
if (currentMenu.value && currentMenu.value.index.length > 1) {
if (currentMenu.value?.index.includes("-")) {
return [currentMenu.value.index.split("-")[0]];
} else {
return [];
}
return [];
});
const handleOpen = (_key: string, _keyPath: string[]): void => {};
const handleClose = (_key: string, _keyPath: string[]): void => {};
const handleMenuClick = (menu: MenuItem): void => {
// 菜单点击处理
const handleMenuClick = (menu: MenuItem) => {
if (menu.router) {
router.push(menu.router);
}
};
</script>
<style lang="scss" scoped>
.el-menu-vertical {
width: 100%;
height: 100vh;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 0;
.logo-img {
width: 30px;
height: 30px;
margin-right: 15px;
@import "@/styles/common.scss";
.pc-aside {
height: 100%;
background: var(--theme-card-bg);
border-right: 1px solid rgba(0, 0, 0, 0.1);
// Logo 区域
&__logo {
@include flex-center;
padding: 24px 16px;
gap: 12px;
.logo__image {
width: 32px;
height: 32px;
object-fit: contain;
}
.logo__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--theme-text-primary);
@include text-overflow;
}
}
.logo-text {
font-size: 20px;
// 菜单区域
&__menu {
border-right: none;
background: transparent;
:deep(.el-menu-item) {
height: 48px;
line-height: 48px;
color: var(--theme-text-regular);
&.is-active {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.1);
}
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
}
}
:deep(.el-sub-menu) {
.el-sub-menu__title {
color: var(--theme-text-regular);
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
}
}
}
:deep(.el-icon) {
font-size: 18px;
margin-right: 12px;
color: inherit;
}
}
// GitHub 链接区域
&__footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: var(--theme-card-bg);
.github-link {
@include flex-center;
gap: 8px;
padding: 12px;
color: var(--theme-text-regular);
text-decoration: none;
border-radius: var(--theme-radius);
transition: var(--theme-transition);
.github-icon {
font-size: 20px;
transition: var(--theme-transition);
}
.version {
font-size: 12px;
opacity: 0.7;
margin-left: 4px;
}
&:hover {
color: var(--theme-primary);
background: rgba(0, 102, 204, 0.05);
transform: translateY(-1px);
.github-icon {
transform: scale(1.1);
}
.version {
opacity: 1;
}
}
}
}
}
// 自定义滚动条
.pc-aside__menu {
height: calc(100vh - 80px - 69px); // 减去 logo 高度和 footer 高度
overflow-y: auto;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background: rgba(0, 0, 0, 0.3);
}
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
</style>

View File

@@ -1,26 +1,46 @@
<template>
<div class="folder-select">
<div class="folder-select-header">
当前位置<el-icon style="margin: 0 5px"><Folder /></el-icon
>{{ selectedFolder?.path?.map((x: Folder) => x.name).join("/") }}
<div class="folder-header">
<div class="folder-path">
<el-icon><FolderOpened /></el-icon>
<template v-if="folderPath.length">
<span
v-for="(folder, index) in folderPath"
:key="folder.cid"
class="path-item"
@click="handlePathClick(index)"
>
<span class="folder-name">{{ folder.name }}</span>
<el-icon v-if="index < folderPath.length - 1"><ArrowRight /></el-icon>
</span>
</template>
<span v-else class="root-path" @click="handlePathClick(-1)">根目录</span>
</div>
</div>
<el-tree
ref="treeRef"
:data="folders"
:props="defaultProps"
node-key="cid"
:load="loadNode"
lazy
highlight-current
@node-click="handleNodeClick"
>
<template #default="{ node }">
<span class="folder-node">
<div class="folder-list">
<div v-if="!folders.length" class="empty-folder">
<el-empty description="暂无文件夹" />
</div>
<div
v-for="folder in folders"
:key="folder.cid"
class="folder-item"
:class="{ 'is-selected': folder.cid === selectedFolder?.cid }"
@click="handleFolderClick(folder)"
>
<div class="folder-info">
<el-icon><Folder /></el-icon>
{{ node.label }}
</span>
</template>
</el-tree>
<span class="folder-name">{{ folder.name }}</span>
</div>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div>
</div>
<div v-if="loading" class="loading-overlay">
<el-icon class="loading-icon"><Loading /></el-icon>
<span>加载中...</span>
</div>
</div>
</template>
@@ -28,12 +48,9 @@
import { ref, defineProps } from "vue";
import { cloud115Api } from "@/api/cloud115";
import { quarkApi } from "@/api/quark";
import type { TreeInstance } from "element-plus";
import type { Folder } from "@/types";
import { type RequestResult } from "@/types/response";
import { useResourceStore } from "@/stores/resource";
import type { Folder as FolderType } from "@/types";
import { Folder, FolderOpened, ArrowRight, Loading } from "@element-plus/icons-vue";
const resourceStore = useResourceStore();
import { ElMessage } from "element-plus";
const props = defineProps({
@@ -43,96 +60,218 @@ const props = defineProps({
},
});
const treeRef = ref<TreeInstance>();
const folders = ref<Folder[]>([]);
const selectedFolder = ref<Folder | null>(null);
const loading = ref(false);
const folders = ref<FolderType[]>([]);
const selectedFolder = ref<FolderType | null>(null);
const folderPath = ref<FolderType[]>([{ name: "根目录", cid: "0" }]);
const emit = defineEmits<{
(e: "select", folderId: string): void;
(e: "close"): void;
}>();
const defaultProps = {
label: "name",
children: "children",
isLeaf: "leaf",
};
const cloudTypeApiMap = {
pan115: cloud115Api,
quark: quarkApi,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const loadNode = async (node: any, resolve: (list: Folder[]) => void) => {
const getList = async (cid: string = "0") => {
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
loading.value = true;
try {
let res: RequestResult<Folder[]> = { code: 0, data: [] as Folder[], message: "" };
resourceStore.setLoadTree(true);
if (node.level === 0) {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList();
}
} else {
if (api.getFolderList) {
// 使用类型保护检查方法是否存在
res = await api.getFolderList(node.data.cid);
}
}
const res = await api.getFolderList?.(cid);
if (res?.code === 0) {
resolve(res.data.length ? res.data : []);
folders.value = res.data || [];
} else {
throw new Error(res.message);
throw new Error(res?.message);
}
resourceStore.setLoadTree(false);
} catch (error) {
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
// 关闭模态框
ElMessage.error(error instanceof Error ? error.message : "获取目录失败");
emit("close");
resourceStore.setLoadTree(false);
resolve([]);
} finally {
loading.value = false;
}
};
const handleNodeClick = (data: Folder) => {
selectedFolder.value = {
...data,
path: data.path ? [...data.path, data] : [data],
};
emit("select", data.cid);
const handleFolderClick = async (folder: FolderType) => {
selectedFolder.value = folder;
folderPath.value = [...folderPath.value, folder];
emit("select", folder.cid);
await getList(folder.cid);
};
const handlePathClick = async (index: number) => {
if (index < 0) {
// 点击根目录
folderPath.value = [{ name: "根目录", cid: "0" }];
selectedFolder.value = null;
await getList("0");
} else {
// 点击路径中的某个文件夹
const targetFolder = folderPath.value[index];
folderPath.value = folderPath.value.slice(0, index + 1);
selectedFolder.value = targetFolder;
await getList(targetFolder.cid);
emit("select", targetFolder.cid);
}
};
// 初始化加载
getList();
</script>
<style scoped>
<style lang="scss" scoped>
@import "@/styles/common.scss";
.folder-select {
position: relative;
min-height: 300px;
max-height: 500px;
display: flex;
flex-direction: column;
padding: 4px;
.folder-header {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: var(--theme-radius);
.folder-path {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-regular);
font-size: 14px;
overflow-x: auto;
&::-webkit-scrollbar {
height: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.el-icon {
flex-shrink: 0;
font-size: 16px;
color: var(--theme-primary);
}
.path-item {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
cursor: pointer;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
.folder-name {
color: var(--theme-primary);
}
}
.folder-name {
color: var(--theme-text-primary);
}
}
.root-path {
color: var(--theme-text-secondary);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
}
}
}
}
}
.folder-list {
flex: 1;
overflow-y: auto;
padding: 4px;
.folder-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-radius: var(--theme-radius);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
background: var(--el-fill-color-light);
}
&.is-selected {
background: var(--el-color-primary-light-9);
color: var(--theme-primary);
.el-icon {
color: var(--theme-primary);
}
}
.folder-info {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
.el-icon {
font-size: 16px;
color: var(--theme-text-regular);
}
.folder-name {
color: var(--theme-text-primary);
}
}
.arrow-icon {
font-size: 16px;
color: var(--theme-text-secondary);
}
}
}
.folder-node {
display: flex;
align-items: center;
.empty-folder {
padding: 32px 0;
}
.loading-overlay {
@include flex-center;
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
gap: 8px;
}
.folder-path {
color: #999;
font-size: 12px;
margin-left: 8px;
}
:deep(.el-tree-node__content) {
height: 32px;
}
.folder-select-header {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
font-size: 14px;
padding: 5px 10px;
border: 1px solid #e5e6e8;
border-radius: 8px;
color: var(--theme-text-regular);
.loading-icon {
font-size: 20px;
animation: rotating 2s linear infinite;
}
}
@keyframes rotating {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,61 +1,155 @@
<template>
<div class="resource-card-list">
<div v-for="group in store.resources" :key="group.id" class="resource-list">
<div class="group-header">
<el-link :href="`https://t.me/s/${group.id}`" target="_blank" :underline="false">
<el-image :src="group.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
{{ group.channelInfo.name }}</el-link
>
<el-icon class="header-icon" @click="group.displayList = !group.displayList"
><ArrowDown
/></el-icon>
</div>
<div v-show="group.displayList" class="card-item-list">
<div v-for="resource in group.list" :key="resource.messageId" class="card-item-content">
<el-card class="card-item">
<el-image
class="card-item-image"
:src="`/tele-images/?url=${encodeURIComponent(resource.image as string)}`"
fit="cover"
lazy
:alt="resource.title"
hide-on-click-modal
:preview-src-list="[
`${location.origin}/tele-images/?url=${encodeURIComponent(resource.image as string)}`,
]"
/>
<el-link :href="resource.cloudLinks[0]" target="_blank" :underline="false"
><div class="item-name">{{ resource.title }}</div></el-link
>
<div class="item-description" v-html="resource.content"></div>
<div v-if="resource.tags && resource.tags.length" class="tags-list">
<span>标签</span>
<div class="resource-card">
<!-- 详情弹窗 -->
<el-dialog
v-model="showDetail"
:title="currentResource?.title"
width="700px"
class="resource-detail-dialog"
destroy-on-close
>
<div v-if="currentResource" class="detail-content">
<div class="detail-cover">
<el-image
class="cover-image"
:src="`/tele-images/?url=${encodeURIComponent(currentResource.image as string)}`"
fit="cover"
/>
<el-tag
class="cloud-type"
:type="store.tagColor[currentResource.cloudType as keyof TagColor]"
effect="dark"
round
>
{{ currentResource.cloudType }}
</el-tag>
</div>
<div class="detail-info">
<h3 class="detail-title">
<el-link :href="currentResource.cloudLinks[0]" target="_blank" :underline="false">
{{ currentResource.title }}
</el-link>
</h3>
<div class="detail-description" v-html="currentResource.content" />
<div v-if="currentResource.tags?.length" class="detail-tags">
<span class="tags-label">标签</span>
<div class="tags-list">
<el-tag
v-for="item in resource.tags"
:key="item"
class="resource_tag"
@click="searchMovieforTag(item)"
v-for="tag in currentResource.tags"
:key="tag"
class="tag-item"
@click="searchMovieforTag(tag)"
>
{{ item }}
{{ tag }}
</el-tag>
</div>
<template #footer>
<div class="item-footer">
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button type="primary" @click="currentResource && handleSave(currentResource)"
>转存</el-button
>
</div>
</template>
</el-dialog>
<div v-for="group in store.resources" :key="group.id" class="resource-group">
<div class="group-header">
<el-link
class="group-title"
:href="`https://t.me/s/${group.id}`"
target="_blank"
:underline="false"
>
<el-image :src="group.channelInfo.channelLogo" class="channel-logo" fit="cover" lazy />
<span>{{ group.channelInfo.name }}</span>
<span class="item-count">({{ group.list.length }})</span>
</el-link>
<el-tooltip effect="dark" :content="group.displayList ? '收起' : '展开'" placement="top">
<el-button class="toggle-btn" type="text" @click="group.displayList = !group.displayList">
<el-icon :class="{ 'is-active': group.displayList }">
<ArrowDown />
</el-icon>
</el-button>
</el-tooltip>
</div>
<div v-show="group.displayList" class="group-content">
<div class="card-grid">
<el-card
v-for="resource in group.list"
:key="resource.messageId"
class="resource-card-item"
:body-style="{ padding: '0' }"
>
<div class="card-wrapper">
<div class="card-cover">
<el-image
class="cover-image"
:src="`/tele-images/?url=${encodeURIComponent(resource.image as string)}`"
fit="cover"
lazy
:alt="resource.title"
@click="showResourceDetail(resource)"
/>
<el-tag
class="cloud-type"
:type="store.tagColor[resource.cloudType as keyof TagColor]"
effect="dark"
round
size="small"
>
{{ resource.cloudType }}
</el-tag>
<el-button @click="handleSave(resource)">转存</el-button>
</div>
</template>
<div class="card-body">
<el-link
class="card-title"
:href="resource.cloudLinks[0]"
target="_blank"
:underline="false"
>
{{ resource.title }}
</el-link>
<div
class="card-content"
@click="showResourceDetail(resource)"
v-html="resource.content"
/>
<div v-if="resource.tags?.length" class="card-tags">
<span class="tags-label">标签</span>
<div class="tags-list">
<el-tag
v-for="tag in resource.tags"
:key="tag"
class="tag-item"
@click="searchMovieforTag(tag)"
>
{{ tag }}
</el-tag>
</div>
</div>
<div class="card-footer">
<el-button type="primary" @click="handleSave(resource)">转存</el-button>
</div>
</div>
</div>
</el-card>
</div>
</div>
<div v-show="group.displayList" class="load-more">
<el-button @click="handleLoadMore(group.id)"> 加载更多 </el-button>
<div class="load-more">
<el-button :loading="group.loading" @click="handleLoadMore(group.id)">
<el-icon><Plus /></el-icon>
加载更多
</el-button>
</div>
</div>
</div>
</div>
@@ -63,19 +157,28 @@
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { computed } from "vue";
import { ref } from "vue";
import type { ResourceItem, TagColor } from "@/types";
import { ArrowDown, Plus } from "@element-plus/icons-vue";
const store = useResourceStore();
const location = computed(() => window.location);
const showDetail = ref(false);
const currentResource = ref<ResourceItem | null>(null);
const emit = defineEmits(["save", "loadMore", "searchMovieforTag"]);
const handleSave = (resource: ResourceItem) => {
if (showDetail.value) {
showDetail.value = false;
}
emit("save", resource);
};
const showResourceDetail = (resource: ResourceItem) => {
currentResource.value = resource;
showDetail.value = true;
};
const searchMovieforTag = (tag: string) => {
emit("searchMovieforTag", tag);
};
@@ -85,105 +188,391 @@ const handleLoadMore = (channelId: string) => {
};
</script>
<style scoped>
.resource-list {
margin-bottom: 30px;
padding: 20px;
border-radius: 15px;
background-color: var(--theme-other_background);
&:last-child {
margin-bottom: 0;
}
}
.card-item-list {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
grid-row-gap: 30px;
justify-content: space-between;
margin-top: 20px;
/* grid-column-gap: auto-fill; */
/* flex-wrap: wrap; */
}
.card-item-content {
/* height: 520px; */
}
.channel-logo {
height: 40px;
width: 40px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
margin-right: 10px;
}
.load-more {
margin-top: 40px;
width: 100%;
text-align: center;
}
.card-item {
max-width: 480px;
<style lang="scss" scoped>
@import "@/styles/common.scss";
.resource-card {
position: relative;
height: 100%;
border-radius: 20px;
}
.card-item-image {
border-radius: 20px;
width: 100%;
height: 220px;
}
.item-name,
.item-description {
max-width: 100%;
margin: 15px 0;
-webkit-box-orient: vertical;
display: -webkit-box;
-webkit-line-clamp: 2;
overflow: hidden;
white-space: all;
}
.item-description {
-webkit-line-clamp: 4;
margin-top: 0;
height: 100px;
}
.item-name {
height: 58px;
font-size: 18px;
}
.tags-list {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
height: 58px;
overflow: hidden;
}
.resource_tag {
cursor: pointer;
margin-right: 10px;
margin-bottom: 5px;
}
.group-header {
height: 50px;
line-height: 50px;
text-align: left;
padding: 0 15px;
font-size: 20px;
display: flex;
align-items: center;
justify-content: space-between;
/* text-align: center; */
.el-link {
font-size: 22px;
// 资源组
.resource-group {
background: var(--theme-card-bg);
backdrop-filter: var(--theme-blur);
-webkit-backdrop-filter: var(--theme-blur);
margin-bottom: 24px;
border-radius: var(--theme-radius);
border: 1px solid rgba(0, 0, 0, 0.08);
transition: var(--theme-transition);
&:last-child {
margin-bottom: 100px;
}
}
.header-icon {
cursor: pointer;
width: 50px;
height: 50px;
// 组标题
.group-header {
@include flex-center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
.group-title {
@include flex-center;
gap: 12px;
font-size: 16px;
color: var(--theme-text-primary);
transition: var(--theme-transition);
.channel-logo {
width: 32px;
height: 32px;
border-radius: 50%;
overflow: hidden;
box-shadow: var(--theme-shadow-sm);
}
.item-count {
font-size: 13px;
color: var(--theme-text-secondary);
}
&:hover {
color: var(--theme-primary);
transform: translateY(-1px);
}
}
.toggle-btn {
width: 32px;
height: 32px;
padding: 0;
color: var(--theme-text-regular);
transition: var(--theme-transition);
.el-icon {
font-size: 16px;
transition: transform 0.3s ease;
&.is-active {
transform: rotate(180deg);
}
}
&:hover {
color: var(--theme-primary);
transform: translateY(-1px);
}
}
}
// 组内容
.group-content {
padding: 20px;
}
// 卡片网格
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 24px;
grid-auto-rows: min-content;
}
// 资源卡片
.resource-card-item {
border-radius: var(--theme-radius);
transition: var(--theme-transition);
overflow: hidden;
max-width: 460px;
margin: 0 auto;
width: 100%;
height: fit-content;
&:hover {
transform: translateY(-2px);
box-shadow: var(--theme-shadow);
}
.card-wrapper {
display: flex;
gap: 20px;
padding: 16px;
height: 100%;
}
.card-cover {
position: relative;
width: 120px;
height: 180px;
flex-shrink: 0;
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: var(--theme-radius);
cursor: pointer;
transition: opacity 0.3s ease;
&:hover {
opacity: 0.85;
}
}
.cloud-type {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
}
}
.card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12px;
.card-title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 16px;
line-height: 1.5;
color: var(--theme-text-primary);
word-break: break-word;
height: 3em;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
}
}
.card-content {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-regular);
cursor: pointer;
transition: color 0.3s ease;
&:hover {
color: var(--theme-text-primary);
}
}
.card-tags {
margin-top: auto;
max-height: 88px;
overflow: hidden;
.tags-label {
font-size: 13px;
color: var(--theme-text-secondary);
margin-right: 8px;
display: block;
margin-bottom: 8px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 72px;
overflow: hidden;
.tag-item {
cursor: pointer;
transition: var(--theme-transition);
margin: 0;
height: 24px;
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
transform: translateY(-1px);
}
}
}
}
}
.card-footer {
@include flex-center;
justify-content: flex-end;
margin-top: 8px;
.el-button {
padding: 6px 16px;
font-size: 14px;
height: 32px;
min-width: 80px;
&:hover {
transform: translateY(-1px);
box-shadow: var(--theme-shadow-sm);
}
}
}
}
// 加载更多
.load-more {
@include flex-center;
position: relative;
padding: 32px 0 8px;
margin-top: 16px;
&::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
var(--el-border-color-lighter) 20%,
var(--el-border-color-lighter) 80%,
transparent
);
}
.el-button {
min-width: 160px;
height: 40px;
border-radius: 20px;
font-size: 14px;
color: var(--theme-text-regular);
background: var(--theme-card-bg);
border: 1px solid var(--el-border-color-lighter);
transition: var(--theme-transition);
position: relative;
overflow: hidden;
&::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transform: translateX(-100%);
transition: transform 0.6s ease;
}
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
background: var(--el-color-primary-light-9);
&::after {
transform: translateX(100%);
}
}
&.is-loading {
color: var(--theme-text-secondary);
&::after {
display: none;
}
}
.el-icon {
margin-right: 6px;
font-size: 16px;
}
}
}
// 详情弹窗样式
.resource-detail-dialog {
:deep(.el-dialog__body) {
padding: 20px;
}
.detail-content {
display: flex;
gap: 24px;
}
.detail-cover {
position: relative;
width: 200px;
flex-shrink: 0;
.cover-image {
width: 100%;
height: 300px;
border-radius: var(--theme-radius);
overflow: hidden;
}
.cloud-type {
position: absolute;
top: 8px;
left: 8px;
z-index: 1;
}
}
.detail-info {
flex: 1;
min-width: 0;
.detail-title {
font-size: 18px;
margin: 0 0 16px;
line-height: 1.5;
color: var(--theme-text-primary);
}
.detail-description {
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-regular);
margin-bottom: 20px;
}
.detail-tags {
.tags-label {
font-size: 13px;
color: var(--theme-text-secondary);
margin-right: 8px;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
.tag-item {
cursor: pointer;
transition: var(--theme-transition);
&:hover {
color: var(--theme-primary);
border-color: var(--theme-primary);
transform: translateY(-1px);
}
}
}
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
}
}
}
.item-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

View File

@@ -1,74 +1,178 @@
<template>
<div class="folder-select">
<el-tree
ref="treeRef"
:data="resourceStore.shareInfo.list"
:props="defaultProps"
:default-checked-keys="resourceStore.shareInfo.list?.map((x) => x.fileId) || []"
node-key="fileId"
show-checkbox
highlight-current
@check-change="handleCheckChange"
>
<template #default="{ node }">
<span class="folder-node">
<el-icon><Folder /></el-icon>
{{ node.data.fileName }}
<span v-if="node.data.fileSize" style="font-weight: bold"
>({{ formattedFileSize(node.data.fileSize) }})</span
>
</span>
</template>
</el-tree>
<div class="resource-select">
<div class="select-header">
<div class="select-info">
<el-icon><Document /></el-icon>
<span>已选择 {{ selectedCount }} 个文件</span>
<span v-if="totalSize" class="total-size">({{ formattedFileSize(totalSize) }})</span>
</div>
<div class="header-actions">
<el-button type="text" @click="handleSelectAll(!hasSelectedAll)">
{{ hasSelectedAll ? "取消全选" : "全选" }}
</el-button>
</div>
</div>
<div class="file-list">
<div
v-for="file in resourceStore.shareInfo.list"
:key="file.fileId"
class="file-item"
:class="{ 'is-checked': isChecked(file.fileId) }"
@click="toggleSelect(file)"
>
<el-checkbox :model-value="isChecked(file.fileId)" @click.stop>
<div class="file-info">
<el-icon><Document /></el-icon>
<span class="file-name">{{ file.fileName }}</span>
<span v-if="file.fileSize" class="file-size">
{{ formattedFileSize(file.fileSize) }}
</span>
</div>
</el-checkbox>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useResourceStore } from "@/stores/resource";
import { formattedFileSize } from "@/utils/index";
import { computed } from "vue";
import type { ShareInfo } from "@/types";
import { Document } from "@element-plus/icons-vue";
const resourceStore = useResourceStore();
const defaultProps = {
isLeaf: "leaf",
const selectedCount = computed(
() => resourceStore.resourceSelect.filter((x) => x.isChecked).length
);
const totalSize = computed(() =>
resourceStore.resourceSelect
.filter((x) => x.isChecked)
.reduce((sum, item) => sum + (item.fileSize || 0), 0)
);
const totalFiles = computed(() => resourceStore.shareInfo.list.length);
const hasSelectedAll = computed(() => selectedCount.value === totalFiles.value);
const isChecked = (fileId: string) => {
return resourceStore.resourceSelect.find((x) => x.fileId === fileId)?.isChecked;
};
const handleCheckChange = (data: ShareInfo) => {
const toggleSelect = (file: ShareInfo) => {
let resourceSelect = [...resourceStore.resourceSelect];
resourceSelect.forEach((x) => {
if (x.fileId === data.fileId) x.isChecked = !x.isChecked;
});
const item = resourceSelect.find((x) => x.fileId === file.fileId);
if (item) {
item.isChecked = !item.isChecked;
resourceStore.setSelectedResource(resourceSelect);
}
};
const handleSelectAll = (checked: boolean) => {
const resourceSelect = resourceStore.shareInfo.list.map((file) => ({
fileId: file.fileId,
fileName: file.fileName,
fileSize: file.fileSize,
isChecked: checked,
}));
resourceStore.setSelectedResource(resourceSelect);
};
</script>
<style scoped>
.folder-select {
min-height: 300px;
<style lang="scss" scoped>
@import "@/styles/responsive.scss";
.resource-select {
min-height: 200px;
max-height: 500px;
overflow-y: auto;
}
.folder-node {
display: flex;
align-items: center;
gap: 8px;
}
flex-direction: column;
gap: 16px;
.folder-path {
color: #999;
font-size: 12px;
margin-left: 8px;
}
.folder-select-header {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 10px;
font-size: 14px;
padding: 5px 10px;
border: 1px solid #e5e6e8;
border-radius: 8px;
.select-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: var(--theme-radius);
.select-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-regular);
font-size: 14px;
.el-icon {
font-size: 16px;
}
.total-size {
color: var(--theme-text-secondary);
}
}
.header-actions {
display: flex;
gap: 16px;
}
.el-button {
font-size: 13px;
padding: 4px 8px;
&:not(:disabled):hover {
color: var(--theme-primary);
}
}
}
.file-list {
flex: 1;
overflow-y: auto;
padding: 4px;
.file-item {
padding: 12px 16px;
border-radius: var(--theme-radius);
cursor: pointer;
transition: var(--theme-transition);
&:hover {
background: var(--el-fill-color-light);
}
&.is-checked {
background: var(--el-color-primary-light-9);
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--theme-text-primary);
font-size: 14px;
.el-icon {
font-size: 16px;
color: var(--theme-text-regular);
}
.file-name {
flex: 1;
@include text-ellipsis;
}
.file-size {
color: var(--theme-text-secondary);
font-size: 13px;
}
}
}
}
}
</style>

View File

@@ -61,14 +61,6 @@
<span v-else></span>
</template>
</el-table-column>
<!-- <el-table-column label="地址">
<template #default="{ row }">
<el-link :href="row.cloudLinks[0]" target="_blank">
{{ row.cloudLinks[0] }}
</el-link>
</template>
</el-table-column> -->
<el-table-column label="云盘类型" width="120">
<template #default="{ row }">
<el-tag :type="store.tagColor[row.cloudType as keyof TagColor]" effect="dark" round>
@@ -195,4 +187,10 @@ const searchMovieforTag = (tag: string) => {
justify-content: center;
padding: 16px 0;
}
.resource-table {
position: relative;
height: auto;
overflow: visible;
}
</style>

View File

@@ -1,130 +1,199 @@
<template>
<div class="search-bar">
<div class="search-bar__input">
<input
<div class="pc-search">
<!-- 搜索区域 -->
<div class="pc-search__input">
<el-input
v-model="keyword"
type="text"
placeholder="请输入搜索关键词或输入链接直接解析"
class="input-with-select"
clearable
@keyup.enter="handleSearch"
/>
<el-icon class="search_icon" size="28px" @click="handleSearch"><Search /></el-icon class="search_icon">
</div>
<div class="logout" alt="退出登录" @click="logout">
<el-tooltip
class="box-item"
effect="dark"
content="退出登录"
placement="bottom"
>
<el-icon><SwitchButton class="logout-icon" /></el-icon>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
<template #suffix>
<el-icon v-if="keyword" class="search-icon" @click="handleSearch">
<ArrowRight />
</el-icon>
</template>
</el-input>
</div>
<!-- 用户操作区 -->
<div class="pc-search__actions">
<el-tooltip effect="dark" content="退出登录" placement="bottom">
<el-button class="logout-btn" type="text" @click="handleLogout">
<el-icon><SwitchButton /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref,watch } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useRoute,useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
const resourcStore = useResourceStore();
const keyword = ref("");
const routeKeyword = computed(() => route.query.keyword as string);
const logout = () => {
localStorage.removeItem("token");
router.push({ path: "/login" });
};
const handleSearch = async () => {
// 如果搜索内容是一个https的链接则尝试解析链接
if (keyword.value.startsWith("http")) {
resourcStore.parsingCloudLink(keyword.value);
return;
}
if (!keyword.value.trim()) {
return;
}
await resourcStore.searchResources(keyword.value);
if(route.path !== '/'){
router.push({ path: '/' });
}
};
import { ref, computed, watch } from "vue";
import { useResourceStore } from "@/stores/resource";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Search, ArrowRight, SwitchButton } from "@element-plus/icons-vue";
import { STORAGE_KEYS } from "@/constants/storage";
watch(() => routeKeyword.value, () => {
if(routeKeyword.value){
keyword.value = routeKeyword.value;
// 路由相关
const route = useRoute();
const router = useRouter();
const resourcStore = useResourceStore();
// 响应式数据
const keyword = ref("");
const routeKeyword = computed(() => route.query.keyword as string);
// 退出登录
const handleLogout = () => {
localStorage.removeItem(STORAGE_KEYS.TOKEN);
router.push("/login");
ElMessage.success("已退出登录");
};
// 搜索处理
const handleSearch = async () => {
const searchText = keyword.value.trim();
if (!searchText) {
ElMessage.warning("请输入搜索内容");
return;
}
// 链接解析处理
if (searchText.startsWith("http")) {
await resourcStore.parsingCloudLink(searchText);
return;
}
// 关键词搜索
await resourcStore.searchResources(searchText);
if (route.path !== "/resource") {
router.push("/resource");
}
};
// 监听路由参数变化
watch(
() => routeKeyword.value,
(newKeyword) => {
if (newKeyword) {
keyword.value = newKeyword;
handleSearch();
} else {
keyword.value = ''
keyword.value = "";
}
});
}
);
</script>
<style scoped lang="scss">
.search-bar {
display: flex;
align-items: center;
justify-content: space-between;
.logout{
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 28px;
margin-left: 15px;
cursor: pointer;
}
.search-bar__input {
width: 100%;
margin: 15px auto;
position: relative;
background-color: var(--theme-other_background);
box-shadow: 0 4px 10px rgba(225, 225, 225, 0.3);
height: 40px;
border-radius: 40px;
display: flex;
align-items: center;
}
.input-with-select {
width: 100%;
height: 100%;
background: none;
padding-left: 24px;
box-sizing: border-box;
border-radius: 40px;
line-height: 100%;
border: unset;
font-size: 18px;
}
.search_icon {
width: 64px;
height: 64px;
cursor: pointer;
}
.search-bar_tips{
width: 100%;
text-align: center;
margin: 20px auto;
<style lang="scss" scoped>
@import "@/styles/common.scss";
.pc-search {
@include flex-center;
justify-content: space-between;
gap: 16px;
width: 100%;
// 搜索输入区域
&__input {
flex: 1;
min-width: 0; // 防止溢出
:deep(.el-input) {
--el-input-height: 44px;
.el-input__wrapper {
@include glass-effect;
padding: 0 16px;
border-radius: var(--theme-radius);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
0 2px 4px rgba(0, 0, 0, 0.05),
0 1px 2px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(0, 0, 0, 0.08);
transition: var(--theme-transition);
background: rgba(255, 255, 255, 0.9);
&:hover {
border-color: var(--theme-primary);
box-shadow:
inset 0 0 0 1px var(--theme-primary),
0 4px 8px rgba(0, 0, 0, 0.1);
}
&.is-focus {
border-color: var(--theme-primary);
box-shadow:
inset 0 0 0 1px var(--theme-primary),
0 4px 8px rgba(0, 0, 0, 0.1),
0 0 0 3px rgba(0, 102, 204, 0.1);
background: #fff;
}
}
.el-input__inner {
font-size: 15px;
color: var(--theme-text-primary);
height: 42px;
line-height: 42px;
&::placeholder {
color: var(--theme-text-secondary);
}
}
.el-input__prefix-inner {
.el-icon {
font-size: 18px;
color: var(--theme-text-secondary);
margin-right: 8px;
}
}
.search-icon {
font-size: 18px;
cursor: pointer;
color: var(--theme-primary);
transition: var(--theme-transition);
margin-left: 8px;
&:hover {
transform: scale(1.1);
}
}
}
}
.search-new {
margin-top: 20px;
display: flex;
align-items: center;
justify-content: flex-start;
}
.switch-source {
margin-left: 20px;
}
.switch-source .label {
margin-left: 5px;
}
.display-style {
margin-left: 20px;
// 操作区域
&__actions {
.logout-btn {
@include glass-effect;
width: 44px;
height: 44px;
padding: 0;
border-radius: var(--theme-radius);
transition: var(--theme-transition);
.el-icon {
font-size: 20px;
color: var(--theme-text-regular);
transition: var(--theme-transition);
}
&:hover {
background: var(--theme-primary);
transform: translateY(-2px);
box-shadow: var(--theme-shadow-sm);
.el-icon {
color: #fff;
}
}
}
}
}
</style>