feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.env
|
||||
@@ -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>
|
||||
Generated
+3160
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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' } },
|
||||
],
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user