mirror of
https://github.com/jiangrui1994/CloudSaver.git
synced 2026-01-10 15:18:46 +08:00
Initial commit for open-source version
This commit is contained in:
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=""
|
||||
VITE_API_BASE_URL_PROXY="http://127.0.0.1:8009"
|
||||
9
frontend/auto-imports.d.ts
vendored
Normal file
9
frontend/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
33
frontend/components.d.ts
vendored
Normal file
33
frontend/components.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElBacktop: typeof import('element-plus/es')['ElBacktop']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElLink: typeof import('element-plus/es')['ElLink']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
FolderSelect: typeof import('./src/components/FolderSelect.vue')['default']
|
||||
ResourceList: typeof import('./src/components/ResourceList.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SearchBar: typeof import('./src/components/SearchBar.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CloudSaver</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2479
frontend/package-lock.json
generated
Normal file
2479
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "cloud-saver-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"axios": "^1.6.7",
|
||||
"element-plus": "^2.6.1",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.25",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.2",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0",
|
||||
"vite": "^5.1.5",
|
||||
"vue-tsc": "^2.0.6"
|
||||
}
|
||||
}
|
||||
11
frontend/src/App.vue
Normal file
11
frontend/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
23
frontend/src/api/cloud115.ts
Normal file
23
frontend/src/api/cloud115.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ShareInfoResponse, Folder, Save115FileParams } from "@/types";
|
||||
|
||||
export const cloud115Api = {
|
||||
async getShareInfo(shareCode: string, receiveCode = ""): Promise<ShareInfoResponse> {
|
||||
const { data } = await request.get("/api/cloud115/share-info", {
|
||||
params: { shareCode, receiveCode },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||
const { data } = await request.get("/api/cloud115/folders", {
|
||||
params: { parentCid },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async saveFile(params: Save115FileParams) {
|
||||
const { data } = await request.post("/api/cloud115/save", params);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
23
frontend/src/api/quark.ts
Normal file
23
frontend/src/api/quark.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import request from "@/utils/request";
|
||||
import type { ShareInfoResponse, Folder, SaveQuarkFileParams } from "@/types";
|
||||
|
||||
export const quarkApi = {
|
||||
async getShareInfo(pwdId: string, passcode = ""): Promise<ShareInfoResponse> {
|
||||
const { data } = await request.get("/api/quark/share-info", {
|
||||
params: { pwdId, passcode },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async getFolderList(parentCid = "0"): Promise<{ data: Folder[] }> {
|
||||
const { data } = await request.get("/api/quark/folders", {
|
||||
params: { parentCid },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
async saveFile(params: SaveQuarkFileParams) {
|
||||
const { data } = await request.post("/api/quark/save", params);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
10
frontend/src/api/resource.ts
Normal file
10
frontend/src/api/resource.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import request from "@/utils/request";
|
||||
import type { Resource } from "@/types/index";
|
||||
|
||||
export const resourceApi = {
|
||||
search(keyword: string, backupPlan: boolean, channelId?: string, lastMessageId?: string) {
|
||||
return request.get<Resource[]>(`/api/${backupPlan ? "rssSearch" : "search"}`, {
|
||||
params: { keyword, channelId, lastMessageId },
|
||||
});
|
||||
},
|
||||
};
|
||||
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>
|
||||
15
frontend/src/env.d.ts
vendored
Normal file
15
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
19
frontend/src/main.ts
Normal file
19
frontend/src/main.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import ElementPlus from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from "./App.vue";
|
||||
import router from "./router/index";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
app.use(ElementPlus);
|
||||
|
||||
app.mount("#app");
|
||||
18
frontend/src/router/index.ts
Normal file
18
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
import HomeView from "@/views/HomeView.vue";
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
component: HomeView,
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router;
|
||||
209
frontend/src/stores/resource.ts
Normal file
209
frontend/src/stores/resource.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { cloud115Api } from "@/api/cloud115";
|
||||
import { resourceApi } from "@/api/resource";
|
||||
import { quarkApi } from "@/api/quark";
|
||||
import type { Resource, ShareInfoResponse, Save115FileParams, SaveQuarkFileParams } from "@/types";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
// 定义云盘驱动配置类型
|
||||
interface CloudDriveConfig {
|
||||
name: string;
|
||||
type: string;
|
||||
regex: RegExp;
|
||||
api: {
|
||||
getShareInfo: (parsedCode: any) => Promise<ShareInfoResponse>;
|
||||
saveFile: (params: Record<string, any>) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
parseShareCode: (match: RegExpMatchArray) => Record<string, string>;
|
||||
getSaveParams: (shareInfo: ShareInfoResponse, folderId: string) => Record<string, any>;
|
||||
}
|
||||
|
||||
// 云盘类型配置
|
||||
export const CLOUD_DRIVES: CloudDriveConfig[] = [
|
||||
{
|
||||
name: "115网盘",
|
||||
type: "pan115",
|
||||
regex: /(?:115|anxia)\.com\/s\/([^?]+)(?:\?password=([^#]+))?/,
|
||||
api: {
|
||||
getShareInfo: (parsedCode: { shareCode: string; receiveCode: string }) =>
|
||||
cloud115Api.getShareInfo(parsedCode.shareCode, parsedCode.receiveCode),
|
||||
saveFile: (params) => cloud115Api.saveFile(params as Save115FileParams),
|
||||
},
|
||||
parseShareCode: (match) => ({
|
||||
shareCode: match[1],
|
||||
receiveCode: match[2] || "",
|
||||
}),
|
||||
getSaveParams: (shareInfo, folderId) => ({
|
||||
shareCode: shareInfo.data.shareCode,
|
||||
receiveCode: shareInfo.data.receiveCode,
|
||||
fileId: shareInfo.data.list[0].fileId,
|
||||
folderId,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "夸克网盘",
|
||||
type: "quark",
|
||||
regex: /pan\.quark\.cn\/s\/([a-zA-Z0-9]+)/,
|
||||
api: {
|
||||
getShareInfo: (parsedCode: { pwdId: string }) => quarkApi.getShareInfo(parsedCode.pwdId),
|
||||
saveFile: (params) => quarkApi.saveFile(params as SaveQuarkFileParams),
|
||||
},
|
||||
parseShareCode: (match) => ({ pwdId: match[1] }),
|
||||
getSaveParams: (shareInfo, folderId) => ({
|
||||
fid_list: shareInfo.data.list.map((item) => item.fileId || ""),
|
||||
fid_token_list: shareInfo.data.list.map((item) => item.fileIdToken || ""),
|
||||
to_pdir_fid: folderId,
|
||||
pwd_id: shareInfo.data.pwdId || "",
|
||||
stoken: shareInfo.data.stoken || "",
|
||||
pdir_fid: "0",
|
||||
scene: "link",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const useResourceStore = defineStore("resource", {
|
||||
state: () => ({
|
||||
resources: [] as Resource[],
|
||||
selectedResources: [] as Resource[],
|
||||
loading: false,
|
||||
lastKeyWord: "",
|
||||
backupPlan: false,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
// 搜索资源
|
||||
async searchResources(
|
||||
keyword?: string,
|
||||
backupPlan = false,
|
||||
isLoadMore = false,
|
||||
channelId?: string,
|
||||
lastMessageId?: string
|
||||
): Promise<void> {
|
||||
this.loading = true;
|
||||
if (!isLoadMore) this.resources = [];
|
||||
try {
|
||||
if (isLoadMore) {
|
||||
if (!lastMessageId) {
|
||||
ElMessage.error("当次搜索源不支持加载更多");
|
||||
return;
|
||||
}
|
||||
keyword = this.lastKeyWord;
|
||||
backupPlan = this.backupPlan;
|
||||
}
|
||||
const { data } = await resourceApi.search(
|
||||
keyword || "",
|
||||
backupPlan,
|
||||
channelId,
|
||||
lastMessageId
|
||||
);
|
||||
this.lastKeyWord = keyword || "";
|
||||
if (isLoadMore) {
|
||||
this.resources.push(
|
||||
...data.filter(
|
||||
(item) => !this.selectedResources.some((selectedItem) => selectedItem.id === item.id)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
this.resources = data;
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError("搜索失败,请重试", error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 转存资源
|
||||
async saveResource(resource: Resource, folderId: string): Promise<void> {
|
||||
try {
|
||||
const savePromises: Promise<void>[] = [];
|
||||
CLOUD_DRIVES.forEach((drive) => {
|
||||
if (resource.cloudLinks.some((link) => drive.regex.test(link))) {
|
||||
savePromises.push(this.saveResourceToDrive(resource, folderId, drive));
|
||||
}
|
||||
});
|
||||
await Promise.all(savePromises);
|
||||
} catch (error) {
|
||||
this.handleError("转存失败,请重试", error);
|
||||
}
|
||||
},
|
||||
|
||||
// 保存资源到网盘
|
||||
async saveResourceToDrive(
|
||||
resource: Resource,
|
||||
folderId: string,
|
||||
drive: CloudDriveConfig
|
||||
): Promise<void> {
|
||||
const link = resource.cloudLinks.find((link) => drive.regex.test(link));
|
||||
if (!link) return;
|
||||
|
||||
const match = link.match(drive.regex);
|
||||
if (!match) throw new Error("链接解析失败");
|
||||
|
||||
const parsedCode = drive.parseShareCode(match);
|
||||
try {
|
||||
let shareInfo = await drive.api.getShareInfo(parsedCode);
|
||||
if (shareInfo?.data) {
|
||||
shareInfo = {
|
||||
...shareInfo,
|
||||
data: {
|
||||
...shareInfo.data,
|
||||
...parsedCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
const params = drive.getSaveParams(shareInfo, folderId);
|
||||
const result = await drive.api.saveFile(params);
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success(`${drive.name} 转存成功`);
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(error instanceof Error ? error.message : `${drive.name} 转存失败`);
|
||||
}
|
||||
},
|
||||
|
||||
// 解析云盘链接
|
||||
async parsingCloudLink(url: string): Promise<void> {
|
||||
this.loading = true;
|
||||
this.resources = [];
|
||||
try {
|
||||
const matchedDrive = CLOUD_DRIVES.find((drive) => drive.regex.test(url));
|
||||
if (!matchedDrive) throw new Error("不支持的网盘链接");
|
||||
|
||||
const match = url.match(matchedDrive.regex);
|
||||
if (!match) throw new Error("链接解析失败");
|
||||
|
||||
const parsedCode = matchedDrive.parseShareCode(match);
|
||||
const shareInfo = await matchedDrive.api.getShareInfo(parsedCode);
|
||||
|
||||
if (shareInfo?.data?.list?.length) {
|
||||
this.resources = [
|
||||
{
|
||||
id: "1",
|
||||
title: shareInfo.data.list.map((item) => item.fileName).join(", "),
|
||||
cloudLinks: [url],
|
||||
cloudType: matchedDrive.type,
|
||||
channel: matchedDrive.name,
|
||||
pubDate: "",
|
||||
},
|
||||
];
|
||||
} else {
|
||||
throw new Error("解析失败,请检查链接是否正确");
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleError("解析失败,请重试", error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// 统一错误处理
|
||||
handleError(message: string, error: unknown): void {
|
||||
console.error(message, error);
|
||||
ElMessage.error(error instanceof Error ? error.message : message);
|
||||
},
|
||||
},
|
||||
});
|
||||
63
frontend/src/types/index.ts
Normal file
63
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface Resource {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
channelId?: string;
|
||||
cloudLinks: string[];
|
||||
pubDate: string;
|
||||
cloudType: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileIdToken?: string;
|
||||
}
|
||||
|
||||
export interface ShareInfoResponse {
|
||||
data: {
|
||||
list: ShareInfo[];
|
||||
pwdId?: string;
|
||||
stoken?: string;
|
||||
shareCode?: string;
|
||||
receiveCode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
cid: string;
|
||||
name: string;
|
||||
path?: Folder[];
|
||||
}
|
||||
|
||||
export interface SaveFileParams {
|
||||
shareCode: string;
|
||||
receiveCode: string;
|
||||
fileId: string;
|
||||
folderId: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Save115FileParams {
|
||||
shareCode: string;
|
||||
receiveCode: string;
|
||||
fileId: string;
|
||||
folderId: string;
|
||||
}
|
||||
|
||||
export interface SaveQuarkFileParams {
|
||||
fid_list: string[];
|
||||
fid_token_list: string[];
|
||||
to_pdir_fid: string;
|
||||
pwd_id: string;
|
||||
stoken: string;
|
||||
pdir_fid: string;
|
||||
scene: string;
|
||||
}
|
||||
28
frontend/src/utils/request.ts
Normal file
28
frontend/src/utils/request.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL as string,
|
||||
timeout: 60000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
const res = response.data;
|
||||
if (!res.success) {
|
||||
ElMessage.error(res.error || "请求失败");
|
||||
return Promise.reject(new Error(res.error || "请求失败"));
|
||||
}
|
||||
return res;
|
||||
},
|
||||
(error) => {
|
||||
ElMessage.error(error.message || "网络错误");
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request;
|
||||
32
frontend/src/views/HomeView.vue
Normal file
32
frontend/src/views/HomeView.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<search-bar />
|
||||
<resource-list />
|
||||
<el-backtop :bottom="100">
|
||||
<div
|
||||
style="
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
box-shadow: var(--el-box-shadow-lighter);
|
||||
text-align: center;
|
||||
line-height: 40px;
|
||||
color: #1989fa;
|
||||
"
|
||||
>
|
||||
UP
|
||||
</div>
|
||||
</el-backtop>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SearchBar from "@/components/SearchBar.vue";
|
||||
import ResourceList from "@/components/ResourceList.vue";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 20px;
|
||||
}
|
||||
</style>
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
55
frontend/vite.config.ts
Normal file
55
frontend/vite.config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import AutoImport from "unplugin-auto-import/vite";
|
||||
import Components from "unplugin-vue-components/vite";
|
||||
import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
|
||||
|
||||
export default defineConfig({
|
||||
base: "/",
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: 8008,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_API_BASE_URL_PROXY || "http://127.0.0.1:8009",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("error", (err, _req, _res) => {
|
||||
console.log("proxy error", err);
|
||||
});
|
||||
proxy.on("proxyReq", (proxyReq, req, _res) => {
|
||||
console.log("Sending Request:", req.method, req.url);
|
||||
});
|
||||
proxy.on("proxyRes", (proxyRes, req, _res) => {
|
||||
console.log("Received Response:", proxyRes.statusCode, req.url);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
assetsDir: "assets",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: fileURLToPath(new URL("./index.html", import.meta.url)),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user