Files
wenchuanyi/frontend/src/views/UploadView.vue
T
fanhongcai 5568548001 docs: 更新需求文档至v0.5并归档开发进度;修复大文件上传与二维码海报
- 需求文档升级到 v0.5:补充二维码海报保存文件名默认格式、大文件上传三层限制(IIS+ASP.NET+前端)部署约束、新增第11章开发进度状态归档(已上线功能/已修复缺陷/已知限制/后续优化)
- 后端 FileController.Upload 增加 [RequestFormLimits(MultipartBodyLengthLimit=220_200_960)],修复>128MB multipart 上传被拒
- 前端 UploadView 二维码海报:修正 drawImage 缩放错位、顶部logo/标题垂直居中、分区标题间距、大小与有效期分行显示;保存文件名改为 文传易取件码-取件码(原文件名)-流水号.png
- 新增部署脚本:apply_backend.ps1 / force_dll.ps1(app_offline 解锁DLL)/ ftp_fe.ps1 / ftp_diag.ps1 / ftp_apply.ps1 / _diag/check_dll.ps1

(部署手册 IIS部署与FTP发布.md 仅含编码/BOM 差异,未纳入本次提交)
2026-08-24 02:02:57 +08:00

785 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="isWeChat && showWeChatTip"
class="mb-4 px-4 py-3 flex items-start gap-2 text-[13px] rounded-2xl border-2 border-[#FFD591] bg-[#FFF7E8]"
>
<div class="flex-1 text-[#8A6213] leading-relaxed">
微信内上传与下载会受限无法从聊天选择文件下载需跳转外部浏览器)。建议点击右上角「···」→「在浏览器打开」。
<button class="text-[#2D6CFF] font-medium underline underline-offset-2" @click="copySiteLink">复制链接</button>
</div>
<button class="text-[#C0A06B] shrink-0" @click="showWeChatTip = false">
<t-icon name="close" :size="16" />
</button>
</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">
<div
v-if="!selectedFile"
class="relative py-7 flex flex-col items-center justify-center gap-2.5 cursor-pointer text-center"
>
<!-- 原生 file input 透明铺满上传区不显示实体按钮用户点击上传区时直接点到 input由系统弹出选择器 -->
<input
ref="fileInput"
type="file"
class="absolute inset-0 z-10 w-full h-full opacity-0 cursor-pointer"
@change="onFileChange"
/>
<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 isWeChat = ref(false)
const showWeChatTip = ref(true)
// 微信内置浏览器上传/下载受限,提供复制链接引导用户在外部浏览器打开
function copySiteLink() {
const url = location.href
const ok = () => MessagePlugin.success('链接已复制,请到浏览器中粘贴打开')
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(url).then(ok).catch(() => fallbackCopy(url))
} else {
fallbackCopy(url)
}
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea')
ta.value = text
ta.style.position = 'fixed'
ta.style.opacity = '0'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
try {
document.execCommand('copy')
MessagePlugin.success('链接已复制,请到浏览器中粘贴打开')
} catch {
MessagePlugin.error('复制失败,请长按地址栏手动复制')
}
document.body.removeChild(ta)
}
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 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.textBaseline = 'middle'
ctx.textAlign = 'center'
ctx.font = `bold 26px ${FONT}`
ctx.fillText('传', 74, 50) // logo 圆块 24-76 范围的中线
ctx.textAlign = 'left'
ctx.fillText('文传易', 118, 36) // 与 logo 中线略上对齐(视觉与"免登录"副标题更平衡)
ctx.font = `14px ${FONT}`
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillText('免登录,传文件,真容易', 118, 64)
ctx.textBaseline = 'top' // 恢复默认给后续 sectionTitle 等使用
// 成功提示
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, 38, y + 2, 4, 15, 2)
ctx.fill()
ctx.fillStyle = '#1F2329'
ctx.textAlign = 'left'
ctx.font = `600 15px ${FONT}`
ctx.fillText(text, 66, 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
// 文件信息卡(精确高度:上下各 16 padding + 文件名 N*24 + 大小行 24 + 有效期行 24)
const cardH = 16 + nameLines.length * 24 + 24 + 24 + 16
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 += 28
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('大小', 62, cy)
ctx.fillStyle = '#1F2329'
ctx.font = `15px ${FONT}`
ctx.fillText(formatSize(d.size), 130, cy)
// 有效期(单独一行)
cy += 24
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(expireText, 130, 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 会清空画布内容,必须先备份再画回
// 关键:drawImage 必须显式指定目标尺寸=backup 尺寸,否则浏览器会按新 canvas.height 缩放整张图,导致文字比例全乱
const newH = qrY + qrSize + 14 + 40
if (Math.abs(canvas.height - newH) > 1) {
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, backup.width, backup.height)
}
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)
// 保存文件名:文传易取件码-取件码(原文件名)-流水号.png(清洗 Windows 非法字符)
const safeName = (result.value.originalName || '')
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
.replace(/\s+/g, ' ')
.trim() || '未命名'
const a = document.createElement('a')
a.href = posterDataUrl.value
a.download = `文传易取件码-${result.value.pickCode}(${safeName})-${result.value.id}.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(() => {
isWeChat.value = /MicroMessenger/i.test(navigator.userAgent)
window.addEventListener('paste', onPaste)
})
onUnmounted(() => window.removeEventListener('paste', onPaste))
</script>