init project

This commit is contained in:
Timi
2025-12-19 20:19:28 +08:00
parent 669ca37d28
commit 35e34088b8
25 changed files with 10910 additions and 140 deletions

24
src/Root.vue Normal file
View 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
View 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
View 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

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

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

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