Initial commit for open-source version

This commit is contained in:
jiangrui
2024-12-17 11:30:59 +08:00
commit 42c07ed34c
57 changed files with 10559 additions and 0 deletions

2
frontend/.env Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View 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
View File

@@ -0,0 +1,11 @@
<template>
<el-config-provider>
<router-view />
</el-config-provider>
</template>
<style>
#app {
height: 100vh;
}
</style>

View 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
View 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;
},
};

View 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 },
});
},
};

View 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>

View 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>

View 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
View 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
View 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");

View 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;

View 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);
},
},
});

View 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;
}

View 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;

View 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
View 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" }]
}

View 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
View 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)),
},
},
},
});