mirror of
https://github.com/jiangrui1994/CloudSaver.git
synced 2026-01-12 08:08:46 +08:00
Initial commit for open-source version
This commit is contained in:
134
frontend/src/components/FolderSelect.vue
Normal file
134
frontend/src/components/FolderSelect.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="folders"
|
||||
:props="defaultProps"
|
||||
node-key="cid"
|
||||
:load="loadNode"
|
||||
lazy
|
||||
@node-click="handleNodeClick"
|
||||
highlight-current
|
||||
>
|
||||
<template #default="{ node }">
|
||||
<span class="folder-node">
|
||||
<el-icon><Folder /></el-icon>
|
||||
{{ node.label }}
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { ElMessage } from "element-plus";
|
||||
|
||||
const props = defineProps({
|
||||
cloudType: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const treeRef = ref<TreeInstance>();
|
||||
const folders = ref<Folder[]>([]);
|
||||
const selectedFolder = ref<Folder | null>(null);
|
||||
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,
|
||||
};
|
||||
|
||||
const loadNode = async (node: any, resolve: (data: Folder[]) => void) => {
|
||||
const api = cloudTypeApiMap[props.cloudType as keyof typeof cloudTypeApiMap];
|
||||
try {
|
||||
let res: {
|
||||
data: Folder[];
|
||||
error?: string;
|
||||
} = { data: [] };
|
||||
if (node.level === 0) {
|
||||
if (api.getFolderList) {
|
||||
// 使用类型保护检查方法是否存在
|
||||
res = await api.getFolderList();
|
||||
}
|
||||
} else {
|
||||
if (api.getFolderList) {
|
||||
// 使用类型保护检查方法是否存在
|
||||
res = await api.getFolderList(node.data.cid);
|
||||
}
|
||||
}
|
||||
if (res.data?.length > 0) {
|
||||
resolve(res.data);
|
||||
} else {
|
||||
resolve([]);
|
||||
throw new Error(res.error);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? `${error.message}` : "获取目录失败");
|
||||
// 关闭模态框
|
||||
emit("close");
|
||||
resolve([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNodeClick = (data: Folder) => {
|
||||
selectedFolder.value = {
|
||||
...data,
|
||||
path: data.path ? [...data.path, data] : [data],
|
||||
};
|
||||
emit("select", data.cid);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.folder-select {
|
||||
min-height: 300px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
221
frontend/src/components/ResourceList.vue
Normal file
221
frontend/src/components/ResourceList.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="resource-list">
|
||||
<el-table
|
||||
v-loading="store.loading"
|
||||
:data="groupedResources"
|
||||
style="width: 100%"
|
||||
row-key="id"
|
||||
:default-expand-all="true"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default="props">
|
||||
<el-table :data="props.row.items" style="width: 100%">
|
||||
<el-table-column label="图片" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.image"
|
||||
:src="row.image"
|
||||
:preview-src-list="[row.image]"
|
||||
:zoom-rate="1.2"
|
||||
:max-scale="7"
|
||||
:min-scale="0.2"
|
||||
:initial-index="4"
|
||||
preview-teleported
|
||||
:z-index="999"
|
||||
fit="cover"
|
||||
width="60"
|
||||
height="90"
|
||||
/>
|
||||
<el-icon v-else size="20"><Close /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="标题" width="180" />
|
||||
<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="tagColor[row.cloudType as keyof typeof tagColor]"
|
||||
effect="dark"
|
||||
round
|
||||
>
|
||||
{{ row.cloudType }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button @click="handleSave(row)">转存</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="props.row.hasMore" class="load-more">
|
||||
<el-button :loading="props.row.loading" @click="handleLoadMore(props.row.channel)">
|
||||
加载更多
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" prop="channel">
|
||||
<template #default="{ row }">
|
||||
<div class="group-header">
|
||||
<span>{{ row.channel }}</span>
|
||||
<span class="item-count">({{ row.items.length }})</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="folderDialogVisible" title="选择保存目录" v-if="currentResource">
|
||||
<template #header="{ titleId }">
|
||||
<div class="my-header">
|
||||
<div :id="titleId">
|
||||
<el-tag
|
||||
:type="tagColor[currentResource.cloudType as keyof typeof tagColor]"
|
||||
effect="dark"
|
||||
round
|
||||
>
|
||||
{{ currentResource.cloudType }}
|
||||
</el-tag>
|
||||
选择保存目录
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<folder-select
|
||||
v-if="folderDialogVisible"
|
||||
@select="handleFolderSelect"
|
||||
@close="folderDialogVisible = false"
|
||||
:cloudType="currentResource.cloudType"
|
||||
/>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="folderDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveBtnClick">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
import FolderSelect from "./FolderSelect.vue";
|
||||
import type { Resource } from "@/types";
|
||||
|
||||
const tagColor = {
|
||||
baiduPan: "primary",
|
||||
weiyun: "info",
|
||||
aliyun: "warning",
|
||||
pan115: "danger",
|
||||
quark: "success",
|
||||
};
|
||||
|
||||
const store = useResourceStore();
|
||||
const folderDialogVisible = ref(false);
|
||||
const currentResource = ref<Resource | null>(null);
|
||||
const currentFolderId = ref<string | null>(null);
|
||||
|
||||
// 按来源分组的数据
|
||||
const groupedResources = computed(() => {
|
||||
const groups = store.resources.reduce(
|
||||
(acc, curr) => {
|
||||
const channel = curr.channel;
|
||||
const channelId = curr.channelId;
|
||||
if (!acc[channel]) {
|
||||
acc[channel] = {
|
||||
channel,
|
||||
items: [],
|
||||
hasMore: true,
|
||||
loading: false, // 添加 loading 状态
|
||||
id: channelId || "", // 用于row-key
|
||||
};
|
||||
}
|
||||
acc[channel].items.push(curr);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{ channel: string; items: Resource[]; id: string; hasMore: boolean; loading: boolean }
|
||||
>
|
||||
);
|
||||
|
||||
return Object.values(groups);
|
||||
});
|
||||
|
||||
const handleSave = (resource: Resource) => {
|
||||
currentResource.value = resource;
|
||||
folderDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleFolderSelect = async (folderId: string) => {
|
||||
if (!currentResource.value) return;
|
||||
currentFolderId.value = folderId;
|
||||
};
|
||||
|
||||
const handleSaveBtnClick = async () => {
|
||||
if (!currentResource.value || !currentFolderId.value) return;
|
||||
folderDialogVisible.value = false;
|
||||
await store.saveResource(currentResource.value, currentFolderId.value);
|
||||
};
|
||||
|
||||
// 添加加载更多处理函数
|
||||
const handleLoadMore = async (channel: string) => {
|
||||
const group = groupedResources.value.find((g) => g.channel === channel);
|
||||
if (!group || group.loading) return;
|
||||
|
||||
group.loading = true;
|
||||
try {
|
||||
const lastMessageId = group.items[group.items.length - 1].messageId;
|
||||
store.searchResources("", false, true, group.id, lastMessageId);
|
||||
} finally {
|
||||
group.loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resource-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
color: #909399;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-column) {
|
||||
.cell {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-table__expanded-cell) {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__expand-icon) {
|
||||
height: 23px;
|
||||
line-height: 23px;
|
||||
}
|
||||
.load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
82
frontend/src/components/SearchBar.vue
Normal file
82
frontend/src/components/SearchBar.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="请输入搜索关键词与输入链接直接解析"
|
||||
class="input-with-select"
|
||||
@keyup.enter="handleSearch"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="success" @click="handleSearch">{{ searchBtnText }}</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-alert
|
||||
title="可直接输入链接进行资源解析,也可进行资源搜索!"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<div class="search-new">
|
||||
<el-button type="primary" @click="handleSearchNew">最新资源</el-button>
|
||||
<div class="switch-source">
|
||||
<el-switch v-model="backupPlan" /><span class="label">使用rsshub(较慢)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { effect, ref } from "vue";
|
||||
import { useResourceStore } from "@/stores/resource";
|
||||
|
||||
const keyword = ref("");
|
||||
const backupPlan = ref(false);
|
||||
const store = useResourceStore();
|
||||
const searchBtnText = ref("搜索");
|
||||
|
||||
effect(() => {
|
||||
// 监听搜索关键词的变化,如果存在,则自动触发搜索
|
||||
if (keyword.value && keyword.value.startsWith("http")) {
|
||||
searchBtnText.value = "解析";
|
||||
} else {
|
||||
searchBtnText.value = "搜索";
|
||||
}
|
||||
});
|
||||
|
||||
const handleSearch = async () => {
|
||||
// 如果搜索内容是一个https的链接,则尝试解析链接
|
||||
if (keyword.value.startsWith("http")) {
|
||||
store.parsingCloudLink(keyword.value);
|
||||
return;
|
||||
}
|
||||
if (!keyword.value.trim()) {
|
||||
return;
|
||||
}
|
||||
await store.searchResources(keyword.value, backupPlan.value);
|
||||
};
|
||||
|
||||
const handleSearchNew = async () => {
|
||||
keyword.value = "";
|
||||
await store.searchResources("", backupPlan.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-bar {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user