feat: 文传易匿名临时文件传输站初始版本

- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式
- 前端 Vue3 + Vite + TDesign,上传/取件/管理三页
- 取件码/二维码/海报合成(含保存二维码为图片)
- deploy/ 部署文档与一键 FTP 发布脚本
- appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
2026-08-24 00:03:28 +08:00
commit 1bfc72f9cb
40 changed files with 6849 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.local
.env
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#2D6CFF" />
<title>文传易 - 免登录,传文件,真容易</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232D6CFF'/%3E%3Cstop offset='1' stop-color='%2300B4FF'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Ctext x='16' y='22' font-size='16' font-family='sans-serif' font-weight='bold' fill='white' text-anchor='middle'%3E%E4%BC%A0%3C/text%3E%3C/svg%3E"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3160
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "wenchuanyi-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"qrcode": "^1.5.4",
"tdesign-vue-next": "^1.10.5",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="min-h-screen flex flex-col bg-[#F4F7FC]">
<!-- 桌面顶栏 -->
<header
class="hidden md:flex sticky top-0 z-50 items-center justify-between px-8 h-16 bg-white/80 backdrop-blur-md shadow-sm"
>
<div class="flex items-center gap-3 select-none" @click="$router.push('/')">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] grid place-items-center text-white font-bold text-lg shadow-card">
</div>
<div>
<div class="text-lg font-semibold leading-tight">文传易</div>
<div class="text-xs text-[#646A73] leading-tight">免登录传文件真容易</div>
</div>
</div>
<nav class="flex items-center gap-2">
<button
v-for="r in routes"
:key="r.path"
class="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
:class="$route.path === r.path ? 'bg-[#EAF1FF] text-[#2D6CFF]' : 'text-[#646A73] hover:bg-[#F0F3F8]'"
@click="$router.push(r.path)"
>
{{ r.meta.title }}
</button>
<button
class="ml-1 px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white shadow-card hover:shadow-lift transition-all flex items-center gap-1.5"
@click="openMobileQr"
>
<t-icon name="qrcode" :size="17" /> 手机上传
</button>
</nav>
</header>
<!-- 手机上传二维码弹窗电脑端 -->
<t-dialog v-model:visible="showMobileQr" header="手机上传" width="340px" :confirm-btn="null" :cancel-btn="null" :close-on-overlay-click="true">
<div class="text-center py-1">
<div v-if="mobileQrDataUrl" class="inline-block bg-white rounded-2xl p-3 border border-[#F0F3F8] shadow-card">
<img :src="mobileQrDataUrl" alt="手机上传二维码" class="w-52 h-52" />
</div>
<div v-else class="w-52 h-52 mx-auto grid place-items-center"><t-loading :size="32" /></div>
<div class="text-sm font-medium text-[#1F2329] mt-3">扫一扫手机浏览器打开文传易</div>
<div class="text-xs text-[#8A9099] mt-1 break-all">{{ mobileSiteUrl }}</div>
<div class="text-xs text-[#00B578] mt-1">手机端可直接上传文件无需登录</div>
</div>
</t-dialog>
<!-- 主内容 -->
<main class="flex-1 w-full max-w-2xl mx-auto px-4 pb-28 md:pb-12 pt-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- 移动端底部 Tab -->
<nav
class="md:hidden fixed bottom-0 inset-x-0 z-50 bg-white/95 backdrop-blur-md border-t border-gray-100 grid grid-cols-3"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<button
v-for="r in routes"
:key="r.path"
class="flex flex-col items-center gap-1 py-3 text-[11px] transition-colors"
:class="$route.path === r.path ? 'text-[#2D6CFF]' : 'text-[#8A9099]'"
@click="$router.push(r.path)"
>
<t-icon :name="r.meta.icon" :size="24" />
<span class="font-medium">{{ r.meta.title }}</span>
</button>
</nav>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { router } from './router'
useRouter()
const routes = router.options.routes
const showMobileQr = ref(false)
const mobileQrDataUrl = ref('')
// 手机版入口:当前站点首页(部署后自动为正式域名)
const mobileSiteUrl = window.location.origin + '/#/'
async function openMobileQr() {
showMobileQr.value = true
if (!mobileQrDataUrl.value) {
mobileQrDataUrl.value = await QRCode.toDataURL(mobileSiteUrl, {
width: 400,
margin: 1,
color: { dark: '#1F2329', light: '#FFFFFF' },
})
}
}
</script>
+75
View File
@@ -0,0 +1,75 @@
import axios from 'axios'
export interface FileInfo {
id: number
pickCode: string
fileType: 'standard' | 'private' | 'tagged'
hasPassword: boolean
originalName: string
size: number
mimeType: string | null
downloadCount: number
uploadIp: string | null
isPermanent: boolean
expiresAt: string | null
createdAt: string
expireText: string
}
export interface UploadResult {
id: number
pickCode: string
adminCode: string
fileType: string
originalName: string
size: number
isPermanent: boolean
expiresAt: string | null
pickUrl: string
}
const api = axios.create({
baseURL: '/api',
timeout: 5 * 60 * 1000,
})
// ---------- 上传 ----------
export function uploadFile(data: FormData, onProgress?: (p: number) => void) {
return api.post<UploadResult>('/files/upload', data, {
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
}
// ---------- 取件 ----------
export function queryFile(code: string, password?: string) {
return api.get<FileInfo>(`/files/${code}`, { params: password ? { password } : {} })
}
export function listByTag(tag: string) {
return api.get<FileInfo[]>(`/files/by-tag/${encodeURIComponent(tag)}`)
}
export function downloadUrl(code: string, password?: string) {
return `/api/files/${code}/download${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
export function previewUrl(code: string, password?: string) {
return `/api/files/${code}/preview${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
export function contentUrl(code: string, password?: string) {
return `/api/files/${code}/content${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
// ---------- 管理 ----------
export function adminList(adminCode: string) {
return api.get<FileInfo[]>(`/admin/files/${adminCode}`)
}
export function adminDelete(adminCode: string, id?: number, pickCode?: string) {
return api.delete(`/admin/files/${adminCode}`, { data: { id, pickCode } })
}
export default api
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+64
View File
@@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2d6cff;
--color-primary-light: #00b4ff;
--color-bg: #f4f7fc;
}
* {
-webkit-tap-highlight-color: transparent;
}
html,
body {
@apply m-0 p-0;
font-family: 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: #1f2329;
overscroll-behavior-y: none;
}
#app {
min-height: 100vh;
}
/* 页面淡入 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 通用卡片 */
.wcy-card {
@apply bg-white rounded-2xl shadow-card;
}
/* 渐变文字 */
.text-gradient {
background: linear-gradient(120deg, #2d6cff 0%, #00b4ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 等宽数字(凭证) */
.code-font {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
letter-spacing: 0.08em;
}
/* 悬浮上浮微动效 */
.hover-lift {
@apply transition-all duration-200;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(45, 108, 255, 0.16);
}
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
import './index.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(TDesign).use(router).mount('#app')
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import UploadView from '@/views/UploadView.vue'
import PickupView from '@/views/PickupView.vue'
import AdminView from '@/views/AdminView.vue'
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: UploadView, meta: { title: '上传', icon: 'cloud-upload' } },
{ path: '/pickup', component: PickupView, meta: { title: '取件', icon: 'link' } },
{ path: '/admin', component: AdminView, meta: { title: '管理', icon: 'setting' } },
],
})
+145
View File
@@ -0,0 +1,145 @@
<template>
<div>
<!-- 管理码输入 -->
<div class="wcy-card p-5 md:p-6">
<div class="text-lg font-semibold text-[#1F2329]">文件管理</div>
<div class="text-xs text-[#8A9099] mt-0.5">输入管理码查看该管理码下的全部文件</div>
<div class="flex flex-col md:flex-row gap-2 mt-4">
<input
v-model="adminCode"
placeholder="请输入 8 位管理码"
class="w-full md:flex-1 text-center code-font text-lg py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
maxlength="8"
@keyup.enter="load"
/>
<button
class="w-full md:w-auto px-6 py-2.5 rounded-xl text-white text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] shadow-card hover:shadow-lift disabled:opacity-60 transition-all"
:disabled="adminCode.length !== 8 || loading"
@click="load"
>
查询
</button>
</div>
<div v-if="error" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ error }}</div>
</div>
<!-- 文件列表 -->
<div v-if="loaded" class="mt-5">
<!-- 桌面表格 -->
<div class="hidden md:block wcy-card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-[#8A9099] bg-[#F7F9FC]">
<th class="px-4 py-3 font-medium">文件名</th>
<th class="px-4 py-3 font-medium">大小</th>
<th class="px-4 py-3 font-medium">上传时间</th>
<th class="px-4 py-3 font-medium">过期时间</th>
<th class="px-4 py-3 font-medium">下载次数</th>
<th class="px-4 py-3 font-medium">上传 IP</th>
<th class="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in files" :key="f.id" class="border-t border-[#F0F3F8] hover:bg-[#FAFCFF]">
<td class="px-4 py-3">
<div class="font-medium text-[#1F2329] max-w-[220px] truncate">{{ f.originalName }}</div>
</td>
<td class="px-4 py-3 text-[#646A73]">{{ formatSize(f.size) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ formatDateTime(f.createdAt) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ f.isPermanent ? '永久' : formatDateTime(f.expiresAt) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ f.downloadCount }}</td>
<td class="px-4 py-3 text-[#646A73] code-font text-xs">{{ f.uploadIp || '-' }}</td>
<td class="px-4 py-3">
<button class="px-3 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] hover:bg-[#FFE4E2]" @click="askDelete(f)">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片 -->
<div class="md:hidden space-y-3">
<div v-for="f in files" :key="f.id" class="wcy-card p-4 hover-lift">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-sm font-medium truncate text-[#1F2329]">{{ f.originalName }}</div>
<div class="text-xs text-[#8A9099] mt-1">大小 {{ formatSize(f.size) }} · 下载 {{ f.downloadCount }} </div>
<div class="text-xs text-[#8A9099] mt-0.5">上传 {{ formatDateTime(f.createdAt) }}</div>
<div class="text-xs text-[#8A9099] mt-0.5">
过期 {{ f.isPermanent ? '永久' : formatDateTime(f.expiresAt) }}
<span class="ml-2">IP {{ f.uploadIp || '-' }}</span>
</div>
</div>
<button class="px-3 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] shrink-0" @click="askDelete(f)">
删除
</button>
</div>
</div>
</div>
<div v-if="!files.length" class="wcy-card p-10 text-center text-sm text-[#8A9099]">暂无文件</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import { adminDelete, adminList, type FileInfo } from '@/api'
const adminCode = ref('')
const files = ref<FileInfo[]>([])
const loading = ref(false)
const loaded = ref(false)
const error = ref('')
async function load() {
if (adminCode.value.length !== 8) return
loading.value = true
error.value = ''
try {
const res = await adminList(adminCode.value.trim())
files.value = res.data
loaded.value = true
} catch (e: any) {
error.value = e?.response?.data?.message || '查询失败,请检查管理码'
} finally {
loading.value = false
}
}
function askDelete(f: FileInfo) {
const dialog = DialogPlugin.confirm({
header: '删除文件',
body: `确定删除「${f.originalName}」吗?删除后取件凭证立即失效,不可恢复。`,
theme: 'danger',
confirmBtn: { content: '删除', theme: 'danger' },
cancelBtn: '取消',
onConfirm: async () => {
try {
await adminDelete(adminCode.value.trim(), f.id)
MessagePlugin.success('已删除')
files.value = files.value.filter((x) => x.id !== f.id)
dialog.destroy()
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '删除失败')
}
},
onClose: () => dialog.destroy(),
})
}
function formatSize(size: number) {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB'
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
function formatDateTime(s: string | null) {
if (!s) return '—'
return s.replace('T', ' ').slice(0, 16)
}
</script>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div>
<!-- 品牌区标题 + slogan -->
<div class="text-center pt-1 pb-4 select-none">
<div
class="w-11 h-11 mx-auto rounded-xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] grid place-items-center text-white text-lg font-bold shadow-lift"
>
</div>
<h1 class="mt-1.5 text-[22px] font-semibold text-gradient">文传易</h1>
<p class="mt-0.5 text-[13px] text-[#646A73]">免登录传文件真容易</p>
</div>
<!-- 取件输入卡片 -->
<div class="wcy-card p-5 md:p-6">
<div class="text-center">
<div class="text-lg font-semibold text-[#1F2329]">取件</div>
<div class="mt-3 grid gap-2.5 text-left">
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="lock-on" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入 6 位私密文件取件码</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="usergroup" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入 8 位共享文件取件码</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="label" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入标签查询下载</span>
</div>
</div>
</div>
<input
ref="codeInput"
v-model="code"
inputmode="text"
autocomplete="off"
placeholder="请输入取件码或标签"
class="mt-4 w-full text-center code-font text-2xl py-3.5 rounded-xl bg-[#EFF4FF] border-2 border-[#2D6CFF]/40 shadow-[0_0_0_4px_rgba(45,108,255,0.12)] focus:outline-none focus:border-[#2D6CFF] focus:shadow-[0_0_0_4px_rgba(45,108,255,0.22)] transition-all"
@input="onInput"
@keyup.enter="doQuery"
/>
<!-- 7 位提示 -->
<div v-if="tip" class="mt-3 text-center text-sm text-[#FF8800]">{{ tip }}</div>
<!-- 6 位私密密码框 -->
<div v-if="passwordNeeded" class="mt-4">
<div class="text-sm text-[#646A73] mb-2">该文件设置了下载密码请输入密码</div>
<div class="flex gap-2">
<input
v-model="password"
type="password"
inputmode="text"
placeholder="输入下载密码"
class="flex-1 text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
@keyup.enter="doQuery"
/>
<button
class="px-5 py-2.5 rounded-xl text-white text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] shadow-card disabled:opacity-60"
:disabled="!password"
@click="doQuery"
>
查询
</button>
</div>
</div>
<div v-if="mode === 'tag'" class="mt-3 text-xs text-[#646A73] flex items-center justify-center gap-1">
<t-icon name="label" /> 按标签{{ code }}查询
</div>
</div>
<!-- 加载 -->
<div v-if="loading" class="mt-6 flex justify-center py-10">
<t-loading :size="36" />
</div>
<!-- 单文件结果 -->
<div v-else-if="fileResult" class="mt-5">
<FileCard :file="fileResult" @preview="openPreview(fileResult)" @download="doDownload(fileResult)" />
</div>
<!-- 标签文件列表 -->
<div v-else-if="tagList" class="mt-5 space-y-3">
<div class="text-sm font-medium text-[#1F2329]">该标签下的文件{{ tagList.length }}</div>
<template v-if="tagList.length">
<div v-for="f in tagList" :key="f.id">
<FileCard :file="f" @preview="openPreview(f)" @download="doDownload(f)" @remove="askDelete(f)" />
</div>
</template>
<div v-else class="wcy-card p-10 text-center text-sm text-[#8A9099]">该标签下暂无文件</div>
</div>
<!-- 密码弹窗私密文件预览/下载 -->
<div v-if="pwDialog" class="fixed inset-0 z-50 bg-black/40 grid place-items-center p-6" @click.self="pwDialog = null">
<div class="w-full max-w-xs bg-white rounded-2xl p-5 shadow-lift">
<div class="text-center font-medium text-[#1F2329]">需要下载密码</div>
<div class="text-center text-xs text-[#8A9099] mt-1">{{ pwTarget?.originalName }}</div>
<input
v-model="pwInput"
type="password"
placeholder="输入该文件密码"
class="mt-4 w-full text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
@keyup.enter="confirmPassword"
/>
<div v-if="pwError" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ pwError }}</div>
<div class="flex gap-3 mt-4">
<button class="flex-1 py-2 rounded-xl text-sm text-[#646A73] bg-[#F0F3F8]" @click="pwDialog = null">取消</button>
<button class="flex-1 py-2 rounded-xl text-sm text-white bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]" @click="confirmPassword">确定</button>
</div>
</div>
</div>
<!-- 管理码删除弹窗 -->
<div v-if="delDialog" class="fixed inset-0 z-50 bg-black/40 grid place-items-center p-6" @click.self="delDialog = null">
<div class="w-full max-w-xs bg-white rounded-2xl p-5 shadow-lift">
<div class="text-center font-medium text-[#1F2329]">删除文件需管理码确认</div>
<div class="text-center text-xs text-[#8A9099] mt-1">{{ delTarget?.originalName }}</div>
<input
v-model="delInput"
placeholder="输入该文件的管理码"
class="mt-4 w-full text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#FF4D4F]/50"
@keyup.enter="confirmDelete"
/>
<div v-if="delError" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ delError }}</div>
<div class="flex gap-3 mt-4">
<button class="flex-1 py-2 rounded-xl text-sm text-[#646A73] bg-[#F0F3F8]" @click="delDialog = null">取消</button>
<button class="flex-1 py-2 rounded-xl text-sm text-white bg-[#FF4D4F]" @click="confirmDelete">删除</button>
</div>
</div>
</div>
<!-- 预览层 -->
<div v-if="preview" class="fixed inset-0 z-50 bg-black/90 flex flex-col" @click.self="preview = null">
<div class="flex items-center justify-between px-4 py-3 text-white">
<div class="text-sm truncate flex-1">{{ preview.file.originalName }}</div>
<button class="w-9 h-9 grid place-items-center rounded-full hover:bg-white/10" @click="preview = null">
<t-icon name="close" :size="22" />
</button>
</div>
<div class="flex-1 overflow-auto flex items-start justify-center p-4">
<template v-if="preview.kind === 'text'">
<pre class="text-white/90 text-sm whitespace-pre-wrap w-full">{{ previewText }}</pre>
</template>
<template v-else-if="preview.kind === 'pdf'">
<iframe :src="preview.src" class="w-full h-full rounded-xl bg-white" />
</template>
<template v-else-if="preview.kind === 'image'">
<img :src="preview.src" class="max-w-full max-h-full object-contain" />
</template>
<template v-else-if="preview.kind === 'video'">
<video :src="preview.src" controls autoplay class="max-w-full max-h-full" />
</template>
<template v-else-if="preview.kind === 'audio'">
<div class="w-full mt-10 text-center">
<div class="text-white/80 mb-4">{{ preview.file.originalName }}</div>
<audio :src="preview.src" controls autoplay class="w-full" />
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { MessagePlugin } from 'tdesign-vue-next'
import { listByTag, previewUrl, contentUrl, downloadUrl, queryFile, adminDelete, type FileInfo } from '@/api'
const route = useRoute()
const router = useRouter()
const code = ref('')
const codeInput = ref<HTMLInputElement>()
const password = ref('')
const tip = ref('')
const passwordNeeded = ref(false)
const mode = ref<'none' | 'code' | 'tag'>('none')
const loading = ref(false)
const fileResult = ref<FileInfo | null>(null)
const tagList = ref<FileInfo[] | null>(null)
const pwDialog = ref(false)
const pwTarget = ref<FileInfo | null>(null)
const pwInput = ref('')
const pwError = ref('')
const delDialog = ref(false)
const delTarget = ref<FileInfo | null>(null)
const delInput = ref('')
const delError = ref('')
const preview = ref<{ file: FileInfo; kind: string; src?: string } | null>(null)
const previewText = ref('')
let debounceTimer: number | undefined
onMounted(() => {
const c = route.query.code as string | undefined
if (c) {
code.value = c
classify()
if (c.length === 8) doQuery()
}
// 默认聚焦取件码输入框
codeInput.value?.focus()
})
onUnmounted(() => {
if (debounceTimer) window.clearTimeout(debounceTimer)
})
function onInput() {
classify()
if (mode.value === 'tag' && code.value.length > 0) {
if (debounceTimer) window.clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(doQuery, 500)
}
}
function classify() {
const v = code.value.trim()
if (!v) {
mode.value = 'none'
passwordNeeded.value = false
tip.value = ''
fileResult.value = null
tagList.value = null
return
}
if (/^\d+$/.test(v) && v.length <= 8) {
mode.value = 'code'
tagList.value = null
fileResult.value = null
if (v.length === 6) {
passwordNeeded.value = true
tip.value = ''
} else if (v.length === 7) {
passwordNeeded.value = false
tip.value = '取件码为 6 位或 8 位,请继续输入完整取件码'
} else if (v.length === 8) {
passwordNeeded.value = false
tip.value = ''
doQuery()
} else {
passwordNeeded.value = false
tip.value = ''
}
} else {
mode.value = 'tag'
passwordNeeded.value = false
tip.value = ''
fileResult.value = null
}
}
async function doQuery() {
const v = code.value.trim()
if (!v) return
loading.value = true
try {
if (mode.value === 'tag') {
const res = await listByTag(v)
tagList.value = res.data
fileResult.value = null
} else {
if (v.length === 6) {
if (!password.value) {
MessagePlugin.warning('请输入下载密码')
return
}
const res = await queryFile(v, password.value)
fileResult.value = res.data
tagList.value = null
} else if (v.length === 8) {
const res = await queryFile(v)
fileResult.value = res.data
tagList.value = null
} else {
MessagePlugin.warning('请输入完整的 6 位或 8 位取件码')
}
}
} catch (e: any) {
const status = e?.response?.status
const msg = e?.response?.data?.message
if (status === 401) MessagePlugin.error(msg || '密码错误')
else if (status === 410) MessagePlugin.error(msg || '文件已过期')
else MessagePlugin.error(msg || '未找到该凭证对应的文件')
} finally {
loading.value = false
}
}
// ---------- 密码弹窗 ----------
let pendingAction: 'preview' | 'download' = 'preview'
const pwCache: Record<number, string> = {}
function openPreview(f: FileInfo) {
if (f.hasPassword) {
if (pwCache[f.id]) {
showPreview(f, pwCache[f.id])
return
}
pendingAction = 'preview'
pwTarget.value = f
pwInput.value = ''
pwError.value = ''
pwDialog.value = true
return
}
showPreview(f)
}
function doDownload(f: FileInfo) {
if (f.hasPassword) {
if (pwCache[f.id]) {
location.href = downloadUrl(f.pickCode, pwCache[f.id])
return
}
pendingAction = 'download'
pwTarget.value = f
pwInput.value = ''
pwError.value = ''
pwDialog.value = true
return
}
location.href = downloadUrl(f.pickCode)
}
async function confirmPassword() {
if (!pwTarget.value) return
if (!pwInput.value) {
pwError.value = '请输入密码'
return
}
const f = pwTarget.value
try {
// 先验证密码是否正确
await queryFile(f.pickCode, pwInput.value)
pwCache[f.id] = pwInput.value
const action = pendingAction
const pwd = pwInput.value
pwDialog.value = false
pwTarget.value = null
if (action === 'preview') showPreview(f, pwd)
else location.href = downloadUrl(f.pickCode, pwd)
} catch (e: any) {
pwError.value = e?.response?.data?.message || '密码错误'
}
}
// ---------- 删除 ----------
function askDelete(f: FileInfo) {
delTarget.value = f
delInput.value = ''
delError.value = ''
delDialog.value = true
}
async function confirmDelete() {
if (!delTarget.value) return
if (!delInput.value) {
delError.value = '请输入管理码'
return
}
try {
await adminDelete(delInput.value.trim(), delTarget.value.id)
MessagePlugin.success('已删除')
delDialog.value = false
// 刷新标签列表
if (tagList.value) {
tagList.value = tagList.value.filter((x) => x.id !== delTarget.value!.id)
}
} catch (e: any) {
delError.value = e?.response?.data?.message || '删除失败,管理码可能不正确'
}
}
// ---------- 预览 ----------
function showPreview(f: FileInfo, password?: string) {
const mime = f.mimeType || ''
const ext = (f.originalName.split('.').pop() || '').toLowerCase()
const textExts = ['txt', 'md', 'xml', 'json', 'csv', 'log', 'ini', 'conf', 'srt', 'html', 'htm', 'css', 'js', 'ts', 'cs', 'sql', 'yml', 'yaml', 'sh', 'py', 'java', 'go', 'php', 'bat', 'ps1']
if (mime.startsWith('text/') || textExts.includes(ext)) {
preview.value = { file: f, kind: 'text' }
fetch(contentUrl(f.pickCode, password))
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('加载失败'))))
.then((t) => (previewText.value = t))
.catch(() => MessagePlugin.error('预览失败'))
} else if (mime === 'application/pdf') {
preview.value = { file: f, kind: 'pdf', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('image/')) {
preview.value = { file: f, kind: 'image', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('video/')) {
preview.value = { file: f, kind: 'video', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('audio/')) {
preview.value = { file: f, kind: 'audio', src: previewUrl(f.pickCode, password) }
} else {
MessagePlugin.warning('该格式不支持在线预览,请下载查看')
}
}
// ---------- 文件卡片子组件 ----------
const FileCard = (props: { file: FileInfo }, { emit }: any) =>
h('div', { class: 'wcy-card p-4 flex flex-wrap items-center gap-x-3 gap-y-2.5 hover-lift' }, [
h('div', { class: 'w-12 h-12 rounded-xl bg-[#EAF1FF] grid place-items-center text-[#2D6CFF] shrink-0' },
h('t-icon', { name: 'file', size: 24 })),
h('div', { class: 'flex-1 min-w-0' }, [
h('div', { class: 'text-sm font-medium break-all text-[#1F2329]' }, props.file.originalName),
h('div', { class: 'text-xs text-[#8A9099] mt-0.5 flex items-center gap-2 flex-wrap' }, [
props.file.hasPassword ? h('span', { class: 'text-[#FF8800]' }, '🔒 私密') : null,
h('span', formatSize(props.file.size)),
h('span', props.file.expireText),
]),
]),
h('div', { class: 'flex gap-1.5 shrink-0 w-full md:w-auto pl-[60px] md:pl-0' }, [
h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-[#2D6CFF] bg-[#EAF1FF] hover:bg-[#DFEAFF]',
onClick: () => emit('preview'),
}, '预览'),
h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-white bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]',
onClick: () => emit('download'),
}, '下载'),
props.file.fileType === 'tagged' || props.file.hasPassword
? h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] hover:bg-[#FFE4E2]',
onClick: () => emit('remove'),
}, '删除')
: null,
]),
])
function formatSize(size: number) {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB'
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
</script>
+720
View File
@@ -0,0 +1,720 @@
<template>
<div>
<!-- 品牌区上传成功后由结果卡片内品牌块替代 -->
<div v-if="!result" class="text-center pt-1 pb-4 select-none">
<div
class="w-11 h-11 mx-auto rounded-xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] grid place-items-center text-white text-lg font-bold shadow-lift"
>
</div>
<h1 class="mt-1.5 text-[22px] font-semibold text-gradient">文传易</h1>
<p class="mt-0.5 text-[13px] text-[#646A73]">免登录传文件真容易</p>
<div class="mt-2.5 flex items-center justify-center gap-2 text-xs text-[#646A73]">
<span class="px-2.5 py-1 rounded-full bg-white shadow-card font-medium">1 上传文件</span>
<span class="text-[#C0C6CF]"></span>
<span class="px-2.5 py-1 rounded-full bg-white shadow-card font-medium">2 获得取件码</span>
<span class="text-[#C0C6CF]"></span>
<span class="px-2.5 py-1 rounded-full bg-white shadow-card font-medium">3 发给对方取件</span>
</div>
</div>
<!-- 上传大卡片 -->
<div
v-if="!result"
class="wcy-card p-5 md:p-6 transition-all duration-300"
:class="isDragOver ? '!border-[#2D6CFF] bg-[#F0F6FF] scale-[1.01]' : ''"
style="border: 2px dashed #d6e0f2"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onDrop"
>
<!-- 模式切换 -->
<div class="grid grid-cols-2 gap-1 p-1 rounded-xl bg-[#F0F3F8] mb-4">
<button
class="py-2 rounded-lg text-sm font-medium transition-all"
:class="!textMode ? 'bg-white text-[#2D6CFF] shadow-sm' : 'text-[#646A73]'"
@click="textMode = false"
>
上传文件
</button>
<button
class="py-2 rounded-lg text-sm font-medium transition-all"
:class="textMode ? 'bg-white text-[#2D6CFF] shadow-sm' : 'text-[#646A73]'"
@click="textMode = true"
>
粘贴文字
</button>
</div>
<!-- 文件模式 -->
<div v-if="!textMode">
<input ref="fileInput" type="file" class="hidden" @change="onFileChange" />
<div
v-if="!selectedFile"
class="py-7 flex flex-col items-center justify-center gap-2.5 cursor-pointer text-center"
@click="openPicker"
>
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]/80 grid place-items-center text-white shadow-card">
<t-icon name="cloud-upload" :size="26" />
</div>
<div>
<div class="font-medium text-[#1F2329]">点击选择文件 / 拖拽至此 / Ctrl+V 粘贴</div>
<div class="text-xs text-[#8A9099] mt-1">单个文件不超过 200MB · 微信内可从聊天记录选择</div>
</div>
</div>
<div
v-else
class="flex items-center gap-3 p-4 rounded-xl bg-[#F4F7FC]"
>
<div class="w-11 h-11 rounded-xl bg-[#EAF1FF] grid place-items-center text-[#2D6CFF] shrink-0">
<t-icon name="file" :size="22" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ selectedFile.name }}</div>
<div class="text-xs text-[#8A9099] mt-0.5">{{ formatSize(selectedFile.size) }}</div>
</div>
<t-button theme="default" variant="text" @click="selectedFile = null">
<t-icon name="close" />
</t-button>
</div>
</div>
<!-- 文字模式 -->
<div v-else>
<t-textarea
v-model="pastedText"
placeholder="在此粘贴要发送的文字(支持多行换行),自动生成 txt 文件发送"
:autosize="{ minRows: 4, maxRows: 8 }"
maxlength="5000"
/>
<div v-if="pastedText.trim()" class="mt-2 text-xs text-[#8A9099]">
将保存为 <span class="code-font text-[#2D6CFF]">{{ buildTextFileName(pastedText) }}</span>
</div>
</div>
<!-- 有效期标签模式下隐藏 -->
<template v-if="!tag">
<div class="mt-5 text-sm font-medium text-[#1F2329]">有效期</div>
<div class="mt-2 grid grid-cols-3 gap-2">
<button
v-for="opt in expires"
:key="opt.value"
class="py-2.5 rounded-xl text-sm font-medium transition-all"
:class="expireHours === opt.value
? 'bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white shadow-card'
: 'bg-[#F0F3F8] text-[#646A73] hover:bg-[#E6ECF5]'"
@click="expireHours = opt.value"
>
{{ opt.label }}
</button>
</div>
</template>
<div v-else class="mt-5 flex items-center gap-2 text-sm text-[#00B578]">
<t-icon name="check-circle" /> 标签文件永久保存有效期固定为永久
</div>
<!-- 密码与标签两个独立输入区可同时设置 -->
<div class="mt-5 space-y-3">
<div class="rounded-2xl border-2 border-[#2D6CFF]/30 bg-[#F0F6FF] p-4">
<div class="flex items-center gap-1.5 text-[15px] font-semibold text-[#1F2329]">
<span class="w-6 h-6 rounded-lg bg-[#2D6CFF]/15 grid place-items-center text-[#2D6CFF]"><t-icon name="lock-on" :size="14" /></span>
设置下载密码
<span class="ml-auto text-xs text-[#8A9099] font-normal">选填 · 4-12 </span>
</div>
<t-input
v-model="password"
type="password"
placeholder="设置后对方需输入密码才能取件"
maxlength="12"
clearable
class="mt-3 !bg-white !rounded-xl shadow-sm"
@update:model-value="validatePassword"
/>
<div v-if="passwordError" class="mt-1.5 text-xs text-[#FF4D4F]">{{ passwordError }}</div>
</div>
<div class="rounded-2xl border-2 border-[#00B4FF]/30 bg-[#F0FAFF] p-4">
<div class="flex items-center gap-1.5 text-[15px] font-semibold text-[#1F2329]">
<span class="w-6 h-6 rounded-lg bg-[#00B4FF]/15 grid place-items-center text-[#00B4FF]"><t-icon name="label" :size="14" /></span>
设置文件标签
<span class="ml-auto text-xs text-[#8A9099] font-normal">选填</span>
</div>
<t-input
v-model="tag"
placeholder="仅限输入英文或者数字,数字需超过8位,纯英文必须超过4位数"
maxlength="32"
clearable
class="mt-3 !bg-white !rounded-xl shadow-sm"
@update:model-value="onTagInput"
/>
<div v-if="tagError" class="mt-1.5 text-xs text-[#FF4D4F]">{{ tagError }}</div>
<div v-else-if="tag && /^[0-9]+$/.test(tag)" class="mt-1.5 text-xs text-[#FF8800]">纯数字标签建议使用 11 位手机号码</div>
<div v-else-if="tag" class="mt-1.5 text-xs text-[#00B578]">标签可重复使用关联多个文件</div>
</div>
</div>
<!-- 上传按钮 + 进度 -->
<div class="mt-5">
<button
class="w-full py-3.5 rounded-xl text-white font-semibold text-[15px] transition-all bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] shadow-card hover:shadow-lift hover:-translate-y-0.5 disabled:opacity-60 disabled:hover:translate-y-0"
:disabled="uploading || !canUpload"
@click="submit"
>
{{ uploading ? `上传中 ${progress}%` : '上传文件' }}
</button>
<div v-if="uploading" class="mt-2 h-1.5 rounded-full bg-[#EAF1FF] overflow-hidden">
<div class="h-full rounded-full bg-gradient-to-r from-[#2D6CFF] to-[#00B4FF] transition-all" :style="{ width: progress + '%' }" />
</div>
</div>
</div>
<!-- 上传成功结果卡片 -->
<div v-else class="wcy-card p-6 text-center fade-enter-active" style="animation: fadeIn 0.3s ease">
<!-- 品牌块截图时品牌可见 -->
<div class="flex items-center justify-center gap-2.5">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] grid place-items-center text-white text-lg font-bold shadow-card">
</div>
<div class="text-left">
<div class="text-xl font-bold text-gradient leading-none">文传易</div>
<div class="text-xs text-[#646A73] mt-1.5 leading-none">免登录传文件真容易</div>
</div>
</div>
<div class="mt-4 text-[15px] font-semibold text-[#00B578] flex items-center justify-center gap-1">
<t-icon name="check-circle" :size="18" /> 上传成功凭证已发出
</div>
<!-- 下载提示手机端 / 电脑端分行排版 -->
<div class="mt-4 rounded-2xl bg-[#F4F7FC] p-4 text-left">
<div class="flex items-start gap-2.5">
<span class="mt-0.5 w-7 h-7 rounded-lg bg-[#EAF1FF] text-[#2D6CFF] grid place-items-center shrink-0">
<t-icon name="mobile" :size="15" />
</span>
<div class="min-w-0">
<div class="text-[13px] font-medium text-[#1F2329]">手机端</div>
<div class="text-xs text-[#646A73] mt-0.5 leading-relaxed">直接识别下方二维码下载文件</div>
</div>
</div>
<div class="flex items-start gap-2.5 mt-3">
<span class="mt-0.5 w-7 h-7 rounded-lg bg-[#EAF1FF] text-[#2D6CFF] grid place-items-center shrink-0">
<t-icon name="desktop" :size="15" />
</span>
<div class="min-w-0">
<div class="text-[13px] font-medium text-[#1F2329]">电脑端</div>
<div class="text-xs text-[#646A73] mt-0.5 leading-relaxed">
登录文传易网站
<span class="text-[#2D6CFF] font-medium break-all">{{ siteOrigin }}</span>
输入取件码下载
</div>
</div>
</div>
</div>
<!-- 文件信息文件名 / 大小 / 有效期 -->
<div class="mt-3 rounded-2xl bg-[#F4F7FC] p-3.5 text-left text-[13px]">
<div class="flex gap-2">
<span class="text-[#8A9099] shrink-0 w-16">文件名</span>
<span class="text-[#1F2329] break-all min-w-0 flex-1">{{ result.originalName }}</span>
</div>
<div class="flex gap-2 mt-1.5">
<span class="text-[#8A9099] shrink-0 w-16">大小</span>
<span class="text-[#1F2329]">{{ formatSize(result.size) }}</span>
</div>
<div class="flex gap-2 mt-1.5">
<span class="text-[#8A9099] shrink-0 w-16">有效期</span>
<span class="text-[#1F2329]">{{ result.isPermanent ? '永久有效' : `${formatDateTime(result.expiresAt)}` }}</span>
</div>
</div>
<!-- 二维码直接展示保存按钮点击后才生成区域图片界面不预嵌入图片 -->
<div class="mt-5">
<img
v-if="qrDataUrl"
:src="qrDataUrl"
alt="取件二维码"
class="w-52 h-52 md:w-56 md:h-56 mx-auto rounded-2xl border border-[#F0F3F8]"
/>
</div>
<div class="code-font text-[30px] font-bold text-[#1F2329] mt-4 tracking-[0.1em]">{{ formatPickCode(result.pickCode) }}</div>
<div class="flex gap-3 justify-center mt-5">
<button
class="px-5 py-2.5 rounded-xl text-white text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] shadow-card hover:shadow-lift transition-all"
@click="copyPickCode"
>
复制取件凭证
</button>
<button
class="px-5 py-2.5 rounded-xl text-[#2D6CFF] text-sm font-medium bg-[#EAF1FF] hover:bg-[#DFEAFF] transition-all"
@click="downloadQr"
>
保存二维码
</button>
</div>
<!-- 管理码可收起 -->
<div class="mt-5 border-t border-[#F0F3F8] pt-3 text-left">
<button class="w-full flex items-center justify-between text-sm text-[#646A73]" @click="showAdmin = !showAdmin">
<span>管理码凭此删除文件</span>
<t-icon :name="showAdmin ? 'chevron-up' : 'chevron-down'" />
</button>
<div v-show="showAdmin" class="mt-2 flex items-center gap-2">
<div class="flex-1 code-font text-lg bg-[#F4F7FC] rounded-xl px-3 py-2 text-[#1F2329]">{{ result.adminCode }}</div>
<button class="px-3 py-2 rounded-lg text-sm text-[#2D6CFF] bg-[#EAF1FF]" @click="copyText(result.adminCode)">复制</button>
</div>
<div class="mt-2 text-xs text-[#8A9099]">管理码仅展示一次不提供找回请妥善保存</div>
</div>
<button class="mt-5 text-sm text-[#2D6CFF] underline underline-offset-4" @click="reset">
再传一个
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { MessagePlugin } from 'tdesign-vue-next'
import QRCode from 'qrcode'
import { uploadFile, type UploadResult } from '@/api'
const expires = [
{ label: '24 小时', value: 24 },
{ label: '7 天', value: 168 },
{ label: '永久', value: 0 },
]
const textMode = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const selectedFile = ref<File | null>(null)
const pastedText = ref('')
const expireHours = ref(24)
const password = ref('')
const passwordError = ref('')
const tag = ref('')
const tagError = ref('')
const isDragOver = ref(false)
const uploading = ref(false)
const progress = ref(0)
const result = ref<UploadResult | null>(null)
const posterDataUrl = ref('')
const qrDataUrl = ref('')
const showAdmin = ref(false)
// 站点地址(与取件二维码保持一致,随部署环境自动变化)
const siteOrigin = computed(() => {
if (!result.value) return ''
try {
return new URL(result.value.pickUrl).origin
} catch {
return ''
}
})
// 8 位取件码按 4+4 格式化展示(6 位码保持原样)
function formatPickCode(code: string) {
return code.length === 8 ? `${code.slice(0, 4)} ${code.slice(4)}` : code
}
const canUpload = computed(() => {
if (textMode.value) return !!pastedText.value.trim() && !passwordError.value && !tagError.value
return !!selectedFile.value && !passwordError.value && !tagError.value
})
function openPicker() {
fileInput.value?.click()
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files?.length) handleFile(input.files[0])
input.value = ''
}
function onDrop(e: DragEvent) {
isDragOver.value = false
if (e.dataTransfer?.files.length) handleFile(e.dataTransfer.files[0])
}
function handleFile(f: File) {
if (f.size > 200 * 1024 * 1024) {
MessagePlugin.error('文件超过 200MB 限制,无法上传')
return
}
const cleaned = sanitizeFileName(f.name)
if (cleaned !== f.name) {
selectedFile.value = new File([f], cleaned, { type: f.type })
MessagePlugin.warning(`文件名含不合法字符,已自动调整为:${cleaned}`)
} else {
selectedFile.value = f
}
}
// 文件名合法性清洗:过滤 Windows 非法字符 / 控制字符、首尾空格与点、保留设备名、超长截断
function sanitizeFileName(name: string) {
const dot = name.lastIndexOf('.')
const base = dot >= 0 ? name.slice(0, dot) : name
const ext = dot >= 0 ? name.slice(dot) : ''
const cleanBase = base.replace(/[<>:"/\\|?*\u0000-\u001f\u007f]/g, '').trim().replace(/[. ]+$/g, '')
let result = cleanBase || '未命名'
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(result)) result = `_${result}`
if (Array.from(result).length > 180) result = Array.from(result).slice(0, 180).join('')
const cleanExt = Array.from(ext.replace(/[^a-zA-Z0-9.]/g, '')).join('').replace(/^\.+/, '')
return result + (cleanExt ? `.${cleanExt.slice(0, 10)}` : '')
}
function onPaste(e: ClipboardEvent) {
const files = e.clipboardData?.files
if (files && files.length) {
handleFile(files[0])
}
}
function validatePassword() {
const v = password.value
if (v && (v.length < 4 || v.length > 12)) {
passwordError.value = '密码长度须为 4-12 位'
} else {
passwordError.value = ''
}
}
function onTagInput() {
const v = tag.value.trim()
if (!/^[A-Za-z0-9]+$/.test(v)) {
tagError.value = '标签仅允许英文与数字,不能包含符号或空格'
} else if (/^[0-9]+$/.test(v)) {
tagError.value = v.length > 8 ? '' : '纯数字标签须超过 8 位(建议使用 11 位手机号码)'
} else {
tagError.value = v.length > 4 ? '' : '含英文字母的标签须超过 4 位'
}
if (!v) tagError.value = ''
}
function buildTextFileName(text: string) {
const illegal = /[<>:"/\\|?*\u0000-\u001f]/g
const head = Array.from(text.trim().slice(0, 12)).join('').replace(illegal, '')
const name = head || '文字'
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.txt`
}
async function submit() {
if (uploading.value) return
let file: File
if (textMode.value) {
// 注意:Content-Type 不能带 ;charset= 参数,OSS SDK 对其签名会与服务端校验不一致导致上传失败
file = new File([pastedText.value], buildTextFileName(pastedText.value), { type: 'text/plain' })
} else {
if (!selectedFile.value) return
file = selectedFile.value
}
if (passwordError.value || tagError.value) return
const fd = new FormData()
fd.append('file', file)
fd.append('expireHours', String(tag.value.trim() ? 0 : expireHours.value))
if (password.value) fd.append('password', password.value)
if (tag.value.trim()) fd.append('tag', tag.value.trim())
uploading.value = true
progress.value = 0
try {
const res = await uploadFile(fd, (p) => (progress.value = p))
result.value = res.data
qrDataUrl.value = await QRCode.toDataURL(res.data.pickUrl, {
width: 480,
margin: 1,
color: { dark: '#1F2329', light: '#FFFFFF' },
})
MessagePlugin.success('上传成功')
} catch (e: any) {
const msg = e?.response?.data?.message || '上传失败,请稍后重试'
MessagePlugin.error(msg)
} finally {
uploading.value = false
}
}
function reset() {
result.value = null
posterDataUrl.value = ''
qrDataUrl.value = ''
selectedFile.value = null
pastedText.value = ''
password.value = ''
tag.value = ''
expireHours.value = 24
showAdmin.value = false
}
// ===== 取件凭证海报(二维码 + 使用帮助 + 文件信息 合成图) =====
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath()
ctx.moveTo(x + r, y)
ctx.arcTo(x + w, y, x + w, y + h, r)
ctx.arcTo(x + w, y + h, x, y + h, r)
ctx.arcTo(x, y + h, x, y, r)
ctx.arcTo(x, y, x + w, y, r)
ctx.closePath()
}
async function buildPoster(d: UploadResult): Promise<string> {
const FONT = '"Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif'
const qr = await QRCode.toDataURL(d.pickUrl, {
width: 720,
margin: 1,
color: { dark: '#1F2329', light: '#FFFFFF' },
})
const qrImg = await loadImage(qr)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
ctx.textBaseline = 'top'
ctx.textAlign = 'left'
// 自动换行(按像素宽度测量)
const wrap = (text: string, maxW: number): string[] => {
const lines: string[] = []
let cur = ''
for (const ch of Array.from(text)) {
const t = cur + ch
if (ctx.measureText(t).width > maxW && cur) {
lines.push(cur)
cur = ch
} else {
cur = t
}
}
if (cur) lines.push(cur)
return lines
}
const W = 600
ctx.font = `15px ${FONT}`
const siteLines = wrap(siteOrigin.value || d.pickUrl, 470)
const nameLines = wrap(d.originalName, 440)
const H = 950 + (siteLines.length - 1) * 24 + (nameLines.length - 1) * 24
canvas.width = W
canvas.height = H
// 背景
ctx.fillStyle = '#FFFFFF'
ctx.fillRect(0, 0, W, H)
// 顶部品牌条
const g = ctx.createLinearGradient(0, 0, W, 0)
g.addColorStop(0, '#2D6CFF')
g.addColorStop(1, '#00B4FF')
ctx.fillStyle = g
roundRect(ctx, 0, 0, W, 100, 24)
ctx.fill()
ctx.fillStyle = 'rgba(255,255,255,0.18)'
roundRect(ctx, 48, 24, 52, 52, 14)
ctx.fill()
ctx.fillStyle = '#FFFFFF'
ctx.textAlign = 'center'
ctx.font = `bold 26px ${FONT}`
ctx.fillText('传', 74, 37)
ctx.textAlign = 'left'
ctx.fillText('文传易', 118, 26)
ctx.font = `14px ${FONT}`
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillText('免登录,传文件,真容易', 118, 60)
// 成功提示
ctx.fillStyle = '#00B578'
ctx.textAlign = 'center'
ctx.font = `600 18px ${FONT}`
ctx.fillText('✓ 上传成功,凭证已发出', W / 2, 120)
// 分区小标题
const sectionTitle = (text: string, y: number) => {
ctx.fillStyle = '#2D6CFF'
roundRect(ctx, 44, y + 2, 4, 15, 2)
ctx.fill()
ctx.fillStyle = '#1F2329'
ctx.textAlign = 'left'
ctx.font = `600 15px ${FONT}`
ctx.fillText(text, 60, y)
}
let y = 164
sectionTitle('下载方式', y)
y += 36
// 设备说明行(图标 + 标题 + 说明文字)
const deviceRow = (type: 'phone' | 'desktop', title: string, lines: string[]) => {
const x = 44
ctx.fillStyle = '#EAF1FF'
roundRect(ctx, x, y, 30, 30, 9)
ctx.fill()
ctx.fillStyle = '#2D6CFF'
if (type === 'phone') {
roundRect(ctx, x + 9, y + 5, 12, 20, 3)
ctx.fill()
ctx.fillStyle = '#EAF1FF'
roundRect(ctx, x + 12, y + 17, 6, 4, 1.5)
ctx.fill()
} else {
roundRect(ctx, x + 5, y + 5, 20, 14, 3)
ctx.fill()
ctx.fillStyle = '#EAF1FF'
roundRect(ctx, x + 9, y + 8, 12, 8, 1.5)
ctx.fill()
ctx.fillStyle = '#2D6CFF'
ctx.fillRect(x + 8, y + 19, 14, 2.5)
ctx.fillRect(x + 4, y + 21.5, 22, 3.5)
}
const tx = x + 42
ctx.fillStyle = '#1F2329'
ctx.font = `600 15px ${FONT}`
ctx.fillText(title, tx, y + 1)
ctx.fillStyle = '#646A73'
ctx.font = `14px ${FONT}`
let ly = y + 28
for (const line of lines) {
ctx.fillText(line, tx, ly)
ly += 24
}
y = ly + 8
}
deviceRow('phone', '手机端', ['直接识别下方二维码下载文件'])
deviceRow('desktop', '电脑端', ['登录文传易网站', ...siteLines])
y += 8
sectionTitle('文件信息', y)
y += 36
// 文件信息卡
const cardH = 20 + 32 + nameLines.length * 24 + 14
ctx.fillStyle = '#F4F8FF'
roundRect(ctx, 44, y, W - 88, cardH, 16)
ctx.fill()
let cy = y + 18
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('文件名', 62, cy)
ctx.fillStyle = '#1F2329'
ctx.font = `15px ${FONT}`
ctx.fillText(nameLines[0], 130, cy)
for (let i = 1; i < nameLines.length; i++) {
cy += 24
ctx.fillText(nameLines[i], 130, cy)
}
cy += 30
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('大小 / 有效期', 62, cy)
ctx.fillStyle = '#1F2329'
ctx.font = `15px ${FONT}`
const expireText = d.isPermanent ? '永久有效' : `${formatDateTime(d.expiresAt)}`
ctx.fillText(`${formatSize(d.size)} · ${expireText}`, 150, cy)
// 取件码标签(与大码拉开间距避免重叠)
let codeY = y + cardH + 28
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.textAlign = 'center'
ctx.fillText('取 件 码', W / 2, codeY)
// 取件码大字(多留一行间距,避免与上方标签重叠)
codeY += 40
ctx.fillStyle = '#1F2329'
ctx.font = `bold 38px ${FONT}`
ctx.fillText(formatPickCode(d.pickCode), W / 2, codeY)
// 二维码
const qrSize = 280
const qrX = (W - qrSize) / 2
const qrY = codeY + 56
ctx.fillStyle = '#FFFFFF'
ctx.strokeStyle = '#E6EBF5'
ctx.lineWidth = 1
roundRect(ctx, qrX - 14, qrY - 14, qrSize + 28, qrSize + 28, 16)
ctx.fill()
ctx.stroke()
ctx.drawImage(qrImg, qrX, qrY, qrSize, qrSize)
// 动态画布高度(按实际内容,消除底部大留白)
// 注意:重设 canvas.height 会清空画布内容,必须先备份再画回
const newH = qrY + qrSize + 14 + 40
if (canvas.height !== newH) {
const backup = document.createElement('canvas')
backup.width = canvas.width
backup.height = canvas.height
backup.getContext('2d')!.drawImage(canvas, 0, 0)
canvas.height = newH
canvas.getContext('2d')!.drawImage(backup, 0, 0)
}
return canvas.toDataURL('image/png')
}
// 复制完整取件凭证文案(提示文字 + 网站地址 + 取件码)
function copyPickCode() {
const d = result.value
if (!d) return
copyText(
[
'【文传易】免登录,传文件,真容易',
`文件名:${d.originalName}`,
`取件码:${d.pickCode}`,
`手机端:识别二维码直接下载;电脑端:登录文传易网站 ${siteOrigin.value} 输入取件码下载。`,
].join('\n')
)
}
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
} catch {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
ta.remove()
}
MessagePlugin.success('已复制')
}
// 点击“保存二维码”时,才把成功卡片区域合成一张图片保存(界面不预嵌入图片)
async function downloadQr() {
if (!result.value) return
posterDataUrl.value = await buildPoster(result.value)
const a = document.createElement('a')
a.href = posterDataUrl.value
a.download = `文传易取件凭证_${result.value.pickCode}.png`
a.click()
}
function formatSize(size: number) {
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
if (size < 1024 * 1024 * 1024) return (size / 1024 / 1024).toFixed(1) + ' MB'
return (size / 1024 / 1024 / 1024).toFixed(2) + ' GB'
}
function formatDateTime(s: string | null) {
if (!s) return ''
return s.replace('T', ' ').slice(0, 16)
}
onMounted(() => window.addEventListener('paste', onPaste))
onUnmounted(() => window.removeEventListener('paste', onPaste))
</script>
+36
View File
@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2D6CFF',
light: '#00B4FF',
dark: '#1E5EFF',
},
ink: {
DEFAULT: '#1F2329',
secondary: '#646A73',
},
success: '#00B578',
warning: '#FF8800',
danger: '#FF4D4F',
},
fontFamily: {
sans: [
'PingFang SC',
'Microsoft YaHei',
'system-ui',
'-apple-system',
'sans-serif',
],
},
boxShadow: {
card: '0 6px 24px rgba(45, 108, 255, 0.08)',
lift: '0 12px 32px rgba(45, 108, 255, 0.16)',
},
},
},
plugins: [],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2018",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2018", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: true, // 允许局域网(手机)访问
port: 5173,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:5280',
changeOrigin: true,
},
},
},
build: {
target: 'es2018', // 兼容微信 X5 内核
outDir: 'dist',
},
})