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

5
.editorconfig Normal file
View File

@ -0,0 +1,5 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true
insert_final_newline = true

2
.env.development Normal file
View File

@ -0,0 +1,2 @@
# 接口
VITE_API=https://api.imyeyu.dev

15
.eslintignore Normal file
View File

@ -0,0 +1,15 @@
*.sh
node_modules
*.md
*.woff
*.ttf
.vscode
.idea
dist
/public
/docs
.husky
.local
.eslintrc.cjs
dist
pnpm-lock.yaml

82
.eslintrc.cjs Normal file
View File

@ -0,0 +1,82 @@
module.exports = {
"env": {
"browser": true,
"es2021": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:vue/vue3-essential",
"./.eslintrc-auto-import.json"
],
"overrides": [
{
"env": {
"node": true
},
"files": [
".eslintrc.{js}"
],
"parserOptions": {
"sourceType": "script"
}
}
],
"parserOptions": {
"ecmaVersion": "latest",
"parser": "@typescript-eslint/parser",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint",
"vue"
],
"rules": { // 注释是解释使用该设置的效果,而不是设置属性本身
"no-console": process.env.NODE_ENV === "production" ? "warn" : "off",
"no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off",
// 其他
"camelcase": 2, // 变量驼峰式命名
"quotes": ["error", "double"], // 强制双引字符串
"eqeqeq": ["error", "always"], // 强制全等比较
"semi": ["error", "always"], // 强制语句分号结束
"max-len": [
"error",
{
"code": 180
}
],
// 逗号
"comma-style": [2, "last"], // 逗号出现在行末 [first, last]
"comma-dangle": [2, "never"], // 数组或对象不可带最后一个逗号 [never, always, always-multiline]
// 空格
"no-trailing-spaces": "error", // 禁止行末存在空格
"comma-spacing": [2, { "before": false, "after": true }], // 逗号后需要空格
"semi-spacing": ["error", { "before": false, "after": true }], // 分号后需要空格
"computed-property-spacing": [2, "never"], // 以方括号取对象属性时,[ 后面和 ] 前面需要空格, [never, always]
"space-before-function-paren": ["error", { // 函数括号前空格
"anonymous": "always", // 针对匿名函数表达式,比如 function () {}
"named": "never", // 针对命名函数表达式,比如 function foo() {}
"asyncArrow": "always" // 针对异步的箭头函数表达式,比如 async () => {}
}],
// 缩进
"no-mixed-spaces-and-tabs": "off", // 允许混合缩进
"no-tabs": ["error", { allowIndentationTabs: true }], // 使用 Tab 缩进
"indent": ["error", "tab", { // Tab 缩进相关
SwitchCase: 1 // Switch Case 缩进一级
}],
// 框架
"@typescript-eslint/ban-types": "off", // TS 允许空对象
"@typescript-eslint/no-empty-function": "off", // TS 允许空函数
"@typescript-eslint/no-explicit-any": "off", // TS 允许 any 类型
"@typescript-eslint/explicit-module-boundary-types": "off", // TS 允许显式模块边界类型?
"vue/no-multiple-template-root": "off", // Vue3 支持多个根节点
"@typescript-eslint/no-this-alias": "off", // 允许 this 变量本地化
"vue/no-v-model-argument": "off", // 允许 v-model 支持参数
"vue/multi-word-component-names": "off", // 允许单个词语的组件名
"@typescript-eslint/no-unused-expressions": [ // 允许逻辑与(&&)、逻辑或(||)短路
"error",
{ "allowShortCircuit": true }
]
}
};

158
.gitignore vendored
View File

@ -1,149 +1,31 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from?
test/
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
node_modules
dist
dist-ssr
*.local
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
/.env.production
/.eslintrc-auto-import.json
/.claude
/CLAUDE.md
/components.d.ts
/src/auto-imports.d.ts

View File

@ -1,3 +1,5 @@
# temp-file-web
# Vue 3 + TypeScript + Vite
文件中转站
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>文件中转站</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "temp-file-web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.24",
"less": "^4.3.0",
"pinia": "^3.0.4",
"timi-web": "link:..\\timi-web",
"timi-tdesign-pc": "link:..\\timi-tdesign-pc",
"tdesign-vue-next": "1.17.7"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/compiler-sfc": "^3.5.13",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.4.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-vue": "^9.32.0",
"less": "^4.3.0",
"prettier": "^3.5.2",
"unplugin-auto-import": "^19.2.0",
"unplugin-vue-components": "^28.5.0",
"vite-plugin-dts": "^4.5.3",
"vite-plugin-vue-setup-extend": "^0.4.0"
}
}

9702
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/vite.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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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
}

28
tsconfig.app.json Normal file
View File

@ -0,0 +1,28 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": [
"vite/client"
],
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
}
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"node_modules/tdesign-vue-next/global.d.ts"
]
}

11
tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
]
}

30
tsconfig.node.json Normal file
View File

@ -0,0 +1,30 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": [
"ES2023"
],
"module": "ESNext",
"types": [
"node"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}

87
vite.config.ts Normal file
View File

@ -0,0 +1,87 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import dts from "vite-plugin-dts";
import { resolve } from "path";
import VueSetupExtend from "vite-plugin-vue-setup-extend";
import AutoImport from "unplugin-auto-import/vite";
import Components from "unplugin-vue-components/vite";
import { TDesignResolver } from "unplugin-vue-components/resolvers";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
dts({
include: "./src"
}),
VueSetupExtend(),
AutoImport({
imports: [
"vue",
"vue-router",
"pinia",
{
"axios": [
["default", "axios"]
]
}
],
dts: "src/auto-imports.d.ts",
eslintrc: {
enabled: true,
globalsPropValue: true
},
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
}),
Components({
dirs: [
"src/components",
"src/layout",
"src/views/components"
],
resolvers: [
TDesignResolver({
library: "vue-next"
})
]
})
],
css: {
preprocessorOptions: {
less: {
math: "always",
lessOptions: {
modifyVars: {
"@btn-border-radius": "0"
},
javascriptEnabled: true
}
}
}
},
define: {
"process.env": {}
},
resolve: {
alias: {
"@": resolve(__dirname, "src")
},
extensions: [".js", ".json", ".ts"]
},
build: {
sourcemap: false,
minify: "terser",
terserOptions: {
compress: {
// eslint-disable-next-line camelcase
drop_console: true,
// eslint-disable-next-line camelcase
drop_debugger: true
}
}
}
});