init project
This commit is contained in:
24
src/Root.vue
Normal file
24
src/Root.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<root-layout class="root diselect" author="夜雨" icp="粤ICP备2025368555号-1" domain="imyeyu.com" text>
|
||||
<home />
|
||||
<t-back-top class="to-top" size="small">
|
||||
<icon name="arrow_1_n" :scale="2" />
|
||||
</t-back-top>
|
||||
</root-layout>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import Home from "@/views/Home.vue";
|
||||
import { RootLayout } from "../../timi-tdesign-pc";
|
||||
import { Icon } from "../../timi-web";
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.root {
|
||||
|
||||
.to-top {
|
||||
padding: 0;
|
||||
box-shadow: var(--tui-shadow);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
62
src/api/TempFileAPI.ts
Normal file
62
src/api/TempFileAPI.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { axios } from "timi-web";
|
||||
import type { TempFileResponse } from "@/type/TempFile.ts";
|
||||
|
||||
/** 上传进度事件 */
|
||||
interface UploadProgressEvent {
|
||||
loaded: number;
|
||||
total?: number;
|
||||
lengthComputable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传临时文件
|
||||
*
|
||||
* @param files 文件列表
|
||||
* @param onProgress 上传进度回调
|
||||
* @param ttl 文件缓存时长(毫秒)
|
||||
* @returns 临时文件信息列表
|
||||
*/
|
||||
async function upload(files: File[], onProgress?: (progressEvent: UploadProgressEvent) => void, ttl?: number): Promise<TempFileResponse[]> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append("file", file);
|
||||
});
|
||||
return axios.post("/temp/file/upload", formData, {
|
||||
params: { ttl },
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data"
|
||||
},
|
||||
onUploadProgress: onProgress
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取临时文件下载地址
|
||||
*
|
||||
* @param fileId 临时文件 ID
|
||||
* @returns 下载地址
|
||||
*/
|
||||
function getDownloadUrl(fileId: string): string {
|
||||
return `${axios.defaults.baseURL}/temp/file/download/${fileId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载临时文件
|
||||
*
|
||||
* @param fileId 临时文件 ID
|
||||
*/
|
||||
function download(fileId: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = getDownloadUrl(fileId);
|
||||
link.download = "";
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export default {
|
||||
upload,
|
||||
getDownloadUrl,
|
||||
download
|
||||
};
|
||||
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
61
src/components/FileDownload.vue
Normal file
61
src/components/FileDownload.vue
Normal file
@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="file-download">
|
||||
<h4>文件下载</h4>
|
||||
<div class="input-group">
|
||||
<t-input
|
||||
class="input"
|
||||
v-model="downloadFileId"
|
||||
placeholder="请输入临时文件 ID"
|
||||
clearable
|
||||
@keyup.enter="download"
|
||||
/>
|
||||
<t-button theme="primary" @click="download">
|
||||
<div class="download">
|
||||
<icon class="icon" name="download" fill="#FFF" />
|
||||
<span>下载文件</span>
|
||||
</div>
|
||||
</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MessagePlugin } from "tdesign-vue-next";
|
||||
import TempFileAPI from "@/api/TempFileAPI.ts";
|
||||
import { Icon } from "timi-web";
|
||||
|
||||
const downloadFileId = ref("");
|
||||
function download() {
|
||||
const fileId = downloadFileId.value.trim();
|
||||
if (!fileId) {
|
||||
MessagePlugin.warning("请输入文件 ID");
|
||||
return;
|
||||
}
|
||||
TempFileAPI.download(fileId);
|
||||
MessagePlugin.success("开始下载");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-download {
|
||||
padding: 0 1rem;
|
||||
|
||||
.input-group {
|
||||
gap: .625rem;
|
||||
display: flex;
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.download {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.icon {
|
||||
margin: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
154
src/components/FileHistoryList.vue
Normal file
154
src/components/FileHistoryList.vue
Normal file
@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="file-history-list">
|
||||
<h4 class="gray">已上传文件</h4>
|
||||
<div v-if="fileHistory.length === 0" class="empty gray">暂无上传记录</div>
|
||||
<div v-else class="list">
|
||||
<div
|
||||
v-for="file in fileHistory"
|
||||
:key="file.id"
|
||||
class="item"
|
||||
>
|
||||
<div class="header">
|
||||
<div class="name-expiry">
|
||||
<div class="name bold selectable clip-text" v-text="file.name"></div>
|
||||
<span class="size gray" v-text="IOSize.format(file.size)"></span>
|
||||
</div>
|
||||
<div class="id gray">
|
||||
<span>ID: </span>
|
||||
<span class="selectable" v-text="file.id"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bottom">
|
||||
<div class="expiry gray" v-text="getTimeRemaining(file.expiryTime)"></div>
|
||||
<div class="actions">
|
||||
<t-button size="small" theme="default" variant="outline" @click="copyFileId(file.id)">复制 ID</t-button>
|
||||
<t-button class="download" size="small" theme="primary" @click="download(file.id)">下载</t-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MessagePlugin } from "tdesign-vue-next";
|
||||
import TempFileAPI from "@/api/TempFileAPI.ts";
|
||||
import { useFileHistoryStore } from "@/store/fileHistory";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { IOSize, Time } from "../../../timi-web";
|
||||
|
||||
const fileHistoryStore = useFileHistoryStore();
|
||||
const { fileHistory } = storeToRefs(fileHistoryStore);
|
||||
|
||||
/** 下载文件 */
|
||||
function download(fileId: string) {
|
||||
TempFileAPI.download(fileId);
|
||||
MessagePlugin.success("开始下载");
|
||||
}
|
||||
|
||||
/** 复制文件 ID */
|
||||
async function copyFileId(fileId: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(fileId);
|
||||
await MessagePlugin.success("已复制到剪贴板");
|
||||
} catch (error) {
|
||||
await MessagePlugin.error("复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取剩余时间 */
|
||||
function getTimeRemaining(expiryTime: string): string {
|
||||
const now = Time.now();
|
||||
const expiry = new Date(expiryTime).getTime();
|
||||
const diff = expiry - now;
|
||||
if (diff <= 0) {
|
||||
return "已过期";
|
||||
}
|
||||
const days = Math.floor(diff / Time.D);
|
||||
const hours = Math.floor((diff % Time.D) / Time.H);
|
||||
const minutes = Math.floor((diff % Time.H) / Time.M);
|
||||
|
||||
if (0 < days) {
|
||||
return `剩余 ${days} 天 ${hours} 小时`;
|
||||
}
|
||||
if (0 < hours) {
|
||||
return `剩余 ${hours} 小时 ${minutes} 分钟`;
|
||||
}
|
||||
return `剩余 ${minutes} 分钟`;
|
||||
}
|
||||
|
||||
// 初始化时加载历史记录
|
||||
onMounted(fileHistoryStore.loadHistory);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-history-list {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
.empty {
|
||||
font-size: .85rem;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
|
||||
.item {
|
||||
border: .1rem solid #E2E8F0;
|
||||
display: flex;
|
||||
padding: .5rem 1rem;
|
||||
background: #f7fafc;
|
||||
margin-bottom: .5rem;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--tui-shadow);
|
||||
background: #EDF2F7;
|
||||
border-color: #CBD5E0;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
.name-expiry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
color: #2d3748;
|
||||
margin-bottom: .375rem;
|
||||
}
|
||||
|
||||
.size {
|
||||
font-size: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.id {
|
||||
font-size: .85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
display: flex;
|
||||
margin-top: .5rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.expiry {
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 1rem;
|
||||
display: flex;
|
||||
|
||||
.download {
|
||||
width: 5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
335
src/components/FileUpload.vue
Normal file
335
src/components/FileUpload.vue
Normal file
@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="file-upload">
|
||||
<div class="header">
|
||||
<h4 class="title">文件上传</h4>
|
||||
<t-button
|
||||
v-if="0 < uploadList.length"
|
||||
theme="default"
|
||||
variant="outline"
|
||||
@click="uploadList = []"
|
||||
>
|
||||
<template #icon>
|
||||
<icon name="trash" />
|
||||
</template>
|
||||
<span class="word-space">清空列表</span>
|
||||
</t-button>
|
||||
</div>
|
||||
<div
|
||||
class="upload-area cur-pointer"
|
||||
:class="{ dragover: isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="drop"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="*/*"
|
||||
@change="fileSelect"
|
||||
/>
|
||||
<icon class="icon" name="upload" :scale="3" />
|
||||
<p>点击选择文件或拖拽文件到此处</p>
|
||||
<span class="hint">支持多文件上传</span>
|
||||
<div class="ttl-selector" @click.stop>
|
||||
<span class="label">缓存时长:</span>
|
||||
<t-radio-group v-model="ttl" variant="primary-filled" size="small">
|
||||
<t-radio-button :value="Time.H * 3">3 小时</t-radio-button>
|
||||
<t-radio-button :value="Time.H * 6">6 小时</t-radio-button>
|
||||
<t-radio-button :value="Time.D">1 天</t-radio-button>
|
||||
<t-radio-button :value="Time.D * 3">3 天</t-radio-button>
|
||||
</t-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="0 < uploadList.length" class="upload-list">
|
||||
<div
|
||||
v-for="item in uploadList"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
:class="item.status"
|
||||
>
|
||||
<div class="progress-status">
|
||||
<t-progress
|
||||
class="progress"
|
||||
:percentage="item.progress"
|
||||
:status="item.status === Status.SUCCESS ? 'success' : item.status === Status.ERROR ? 'warning' : 'active'"
|
||||
theme="plump"
|
||||
/>
|
||||
<span class="status" :class="item.status">
|
||||
<template v-if="item.status === Status.UPLOADING">上传中...</template>
|
||||
<template v-else-if="item.status === Status.SUCCESS">上传成功</template>
|
||||
<template v-else v-text="item.message || '上传失败'"></template>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.status === Status.UPLOADING" class="info">
|
||||
<span class="size">
|
||||
<span v-text="IOSize.format(item.loaded)"></span>
|
||||
<span v-text="' / '"></span>
|
||||
<span v-text="IOSize.format(item.totalSize)"></span>
|
||||
</span>
|
||||
<span v-text="`${IOSize.format(item.speed)} / s`"></span>
|
||||
</div>
|
||||
<div class="files selectable">
|
||||
<p class="name clip-text" v-for="(file, index) in item.files" :key="index" v-text="file.name"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { MessagePlugin } from "tdesign-vue-next";
|
||||
import TempFileAPI from "@/api/TempFileAPI.ts";
|
||||
import { useFileHistoryStore } from "@/store/fileHistory";
|
||||
import { type Item, Status } from "@/type/TempFile.ts";
|
||||
import { Icon, IOSize, Time } from "timi-web";
|
||||
|
||||
const fileHistoryStore = useFileHistoryStore();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const uploadList = ref<Item[]>([]);
|
||||
const ttl = ref(Time.H * 6); // 默认 6 小时
|
||||
|
||||
/** 文件选择 */
|
||||
function triggerFileInput() {
|
||||
fileInput.value?.click();
|
||||
}
|
||||
|
||||
/** 文件选择 */
|
||||
function fileSelect(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = Array.from(target.files || []);
|
||||
if (files.length > 0) {
|
||||
upload(files);
|
||||
}
|
||||
target.value = "";
|
||||
}
|
||||
|
||||
/** 文件拖放 */
|
||||
function drop(e: DragEvent) {
|
||||
isDragging.value = false;
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (files.length > 0) {
|
||||
upload(files);
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传文件 */
|
||||
async function upload(files: File[]) {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
// 创建上传任务
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const task: Item = {
|
||||
files,
|
||||
id: `${Time.now()}_${Math.random()}`,
|
||||
progress: 0,
|
||||
speed: 0,
|
||||
loaded: 0,
|
||||
totalSize,
|
||||
status: Status.UPLOADING
|
||||
};
|
||||
// 添加到上传列表开头
|
||||
uploadList.value.unshift(task);
|
||||
let startTime = Time.now();
|
||||
let uploadedSize = 0;
|
||||
try {
|
||||
// 上传文件
|
||||
const responses = await TempFileAPI.upload(files, (progressEvent) => {
|
||||
if (progressEvent.lengthComputable && progressEvent.total) {
|
||||
const totalPercent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
||||
|
||||
// 计算速度
|
||||
const currentTime = Time.now();
|
||||
const timeElapsed = (currentTime - startTime) / 1000;
|
||||
const bytesUploaded = progressEvent.loaded - uploadedSize;
|
||||
const speed = 0 < timeElapsed ? bytesUploaded / timeElapsed : 0;
|
||||
|
||||
// 更新任务进度
|
||||
const uploadTask = uploadList.value.find(u => u.id === task.id);
|
||||
if (uploadTask) {
|
||||
uploadTask.progress = totalPercent;
|
||||
uploadTask.speed = speed;
|
||||
uploadTask.loaded = progressEvent.loaded;
|
||||
}
|
||||
uploadedSize = progressEvent.loaded;
|
||||
startTime = currentTime;
|
||||
}
|
||||
}, ttl.value);
|
||||
// 更新成功状态
|
||||
const uploadTask = uploadList.value.find(u => u.id === task.id);
|
||||
if (uploadTask) {
|
||||
uploadTask.status = Status.SUCCESS;
|
||||
uploadTask.progress = 100;
|
||||
uploadTask.loaded = totalSize;
|
||||
uploadTask.fileIds = responses.map(r => r.id);
|
||||
}
|
||||
// 保存所有文件到历史记录
|
||||
responses.forEach((response, index) => {
|
||||
if (index < files.length) {
|
||||
fileHistoryStore.addFile({
|
||||
id: response.id,
|
||||
name: files[index]!.name,
|
||||
size: files[index]!.size,
|
||||
uploadTime: new Date().toISOString(),
|
||||
expiryTime: response.expireAt
|
||||
});
|
||||
}
|
||||
});
|
||||
await MessagePlugin.success(`成功上传 ${responses.length} 个文件`);
|
||||
} catch (error: any) {
|
||||
// 更新失败状态
|
||||
const uploadTask = uploadList.value.find(u => u.id === task.id);
|
||||
if (uploadTask) {
|
||||
uploadTask.status = Status.ERROR;
|
||||
uploadTask.message = error.message || "上传失败";
|
||||
}
|
||||
await MessagePlugin.error(`上传失败: ${error.message || "未知错误"}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-upload {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: .125rem dashed #CBD5E0;
|
||||
padding: 1rem 2.5rem;
|
||||
text-align: center;
|
||||
transition: all .3s ease;
|
||||
background: #F7FAFC;
|
||||
|
||||
&:hover {
|
||||
background: #EDF2F7;
|
||||
border-color: #667EEA;
|
||||
}
|
||||
|
||||
&.dragover {
|
||||
transform: scale(1.02);
|
||||
background: #E6FFFA;
|
||||
border-color: #667EEA;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #2d3748;
|
||||
font-size: 1rem;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #718096;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
||||
.ttl-selector {
|
||||
display: flex;
|
||||
margin-top: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.label {
|
||||
color: #4a5568;
|
||||
font-size: .875rem;
|
||||
margin-right: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-list {
|
||||
margin-top: 1.25rem;
|
||||
|
||||
.item {
|
||||
border: .0625rem solid #e2e8f0;
|
||||
padding: .9375rem;
|
||||
transition: all 300ms ease;
|
||||
background: #F7FAFC;
|
||||
margin-bottom: .625rem;
|
||||
|
||||
&.success {
|
||||
background: #F0FFF4;
|
||||
border-color: #48BB78;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-color: #F56565;
|
||||
background: #FFF5F5;
|
||||
}
|
||||
|
||||
.progress-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.progress {
|
||||
flex: 1;
|
||||
margin-right: 1rem;
|
||||
|
||||
:deep(.t-progress__inner),
|
||||
:deep(.t-progress__bar) {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: .875rem;
|
||||
font-weight: 500;
|
||||
|
||||
&.UPLOADING {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
&.SUCCESS {
|
||||
color: #48BB78;
|
||||
}
|
||||
|
||||
&.ERROR {
|
||||
color: #f56565;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info {
|
||||
color: #718096;
|
||||
display: flex;
|
||||
font-size: .8125rem;
|
||||
margin-top: .5rem;
|
||||
justify-content: space-between;
|
||||
|
||||
.size {
|
||||
color: #718096;
|
||||
font-size: .875rem;
|
||||
margin-right: .625rem;
|
||||
}
|
||||
}
|
||||
|
||||
.files {
|
||||
margin-top: .5rem;
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: .85rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
29
src/main.ts
Normal file
29
src/main.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import { axios, Network, VDraggable, VPopup } from "timi-web";
|
||||
|
||||
import "timi-tdesign-pc/style.css";
|
||||
import "timi-web/style.css";
|
||||
|
||||
import Root from "@/Root.vue";
|
||||
|
||||
console.log(`
|
||||
______ __ _ _
|
||||
/ __\\ \\ \\ \\ \\
|
||||
/ . . \\ ' \\ \\ \\ \\
|
||||
( ) imyeyu.com ) ) ) )
|
||||
'\\ ___ /' / / / /
|
||||
====='===='=====================/_/_/_/
|
||||
`);
|
||||
|
||||
// ---------- 网络 ----------
|
||||
axios.defaults.baseURL = import.meta.env.VITE_API;
|
||||
axios.interceptors.request.use(Network.userTokenInterceptors);
|
||||
// ---------- Vue ----------
|
||||
const pinia = createPinia();
|
||||
const app = createApp(Root);
|
||||
app.use(pinia);
|
||||
app.directive("draggable", VDraggable as any);
|
||||
app.directive("popup", VPopup as any);
|
||||
app.mount("#root");
|
||||
66
src/store/fileHistory.ts
Normal file
66
src/store/fileHistory.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { defineStore } from "pinia";
|
||||
import type { History } from "@/type/TempFile.ts";
|
||||
import { Storage } from "../../../timi-web";
|
||||
|
||||
const FILE_EXPIRY_HOURS = 6;
|
||||
const HISTORY_STORAGE_KEY = "uploadHistory";
|
||||
const MAX_HISTORY_COUNT = 50;
|
||||
|
||||
export const useFileHistoryStore = defineStore("fileHistory", () => {
|
||||
|
||||
// 状态
|
||||
const fileHistory = ref<History[]>([]);
|
||||
|
||||
/** 添加文件到历史记录 */
|
||||
function addFile(file: History) {
|
||||
fileHistory.value.unshift(file);
|
||||
// 限制数量
|
||||
if (MAX_HISTORY_COUNT < fileHistory.value.length) {
|
||||
fileHistory.value = fileHistory.value.slice(0, MAX_HISTORY_COUNT);
|
||||
}
|
||||
// 保存到 localStorage
|
||||
saveToLocalStorage();
|
||||
}
|
||||
|
||||
/** 加载历史记录 */
|
||||
function loadHistory() {
|
||||
if (Storage.has(HISTORY_STORAGE_KEY)) {
|
||||
try {
|
||||
fileHistory.value = Storage.getJSON(HISTORY_STORAGE_KEY);
|
||||
} catch (error) {
|
||||
console.error("加载历史记录失败:", error);
|
||||
fileHistory.value = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理过期文件 */
|
||||
function cleanExpiredFiles() {
|
||||
const now = new Date();
|
||||
const originalLength = fileHistory.value.length;
|
||||
|
||||
fileHistory.value = fileHistory.value.filter(file => {
|
||||
if (!file.expiryTime) {
|
||||
const uploadTime = file.uploadTime ? new Date(file.uploadTime) : now;
|
||||
file.expiryTime = new Date(uploadTime.getTime() + FILE_EXPIRY_HOURS * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
return new Date(file.expiryTime) > now;
|
||||
});
|
||||
|
||||
if (fileHistory.value.length !== originalLength) {
|
||||
saveToLocalStorage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存到 localStorage */
|
||||
function saveToLocalStorage() {
|
||||
Storage.setJSON(HISTORY_STORAGE_KEY, fileHistory.value);
|
||||
}
|
||||
|
||||
return {
|
||||
fileHistory,
|
||||
addFile,
|
||||
loadHistory,
|
||||
cleanExpiredFiles
|
||||
};
|
||||
});
|
||||
34
src/type/TempFile.ts
Normal file
34
src/type/TempFile.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/** 临时文件响应 */
|
||||
export type TempFileResponse = {
|
||||
id: string;
|
||||
expireAt: string;
|
||||
}
|
||||
|
||||
/** 上传状态 */
|
||||
export const Status = {
|
||||
UPLOADING: "UPLOADING",
|
||||
SUCCESS: "SUCCESS",
|
||||
ERROR: "ERROR"
|
||||
} as const;
|
||||
|
||||
/** 上传任务项 */
|
||||
export type Item = {
|
||||
files: File[];
|
||||
id: string;
|
||||
progress: number;
|
||||
speed: number;
|
||||
loaded: number;
|
||||
totalSize: number;
|
||||
status: typeof Status[keyof typeof Status];
|
||||
message?: string;
|
||||
fileIds?: string[];
|
||||
}
|
||||
|
||||
/** 文件历史记录 */
|
||||
export type History = {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
uploadTime: string;
|
||||
expiryTime: string;
|
||||
}
|
||||
87
src/views/Home.vue
Normal file
87
src/views/Home.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<h1 class="title">文件中转站</h1>
|
||||
<t-alert theme="info" class="tips">
|
||||
<template #message>
|
||||
<h3 class="title">使用须知</h3>
|
||||
<ul class="list">
|
||||
<li v-text="'已上传文件仅自己可见,分享 ID 可直接下载'"></li>
|
||||
<li>
|
||||
<span>每个 IP 限制缓存</span>
|
||||
<strong class="word-space pink">10 GB</strong>
|
||||
<span>容量</span>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</t-alert>
|
||||
<div class="content">
|
||||
<div class="panel left">
|
||||
<file-upload />
|
||||
</div>
|
||||
<div class="panel right">
|
||||
<file-download />
|
||||
<file-history-list />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import FileUpload from "@/components/FileUpload.vue";
|
||||
import FileDownload from "@/components/FileDownload.vue";
|
||||
import FileHistoryList from "@/components/FileHistoryList.vue";
|
||||
import { useFileHistoryStore } from "@/store/fileHistory.ts";
|
||||
import { Time } from "../../../timi-web";
|
||||
|
||||
const fileHistoryStore = useFileHistoryStore();
|
||||
onMounted(() => setInterval(fileHistoryStore.cleanExpiredFiles, Time.S));
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.home {
|
||||
width: 100%;
|
||||
|
||||
> .title {
|
||||
text-align: center;
|
||||
margin-bottom: 1.875rem;
|
||||
}
|
||||
|
||||
.tips {
|
||||
margin: 0 2rem 1.25rem 2rem;
|
||||
|
||||
.title {
|
||||
margin: 0 0 .5rem 0;
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
border-top: var(--tui-border);
|
||||
border-bottom: var(--tui-border);
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
|
||||
&:first-child {
|
||||
border-right: var(--tui-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 45rem) {
|
||||
.home .content {
|
||||
flex-direction: column;
|
||||
|
||||
.panel:first-child {
|
||||
border-right: none;
|
||||
border-bottom: var(--tui-border);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
src/vite-env.d.ts
vendored
Normal file
13
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
Reference in New Issue
Block a user