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