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 差异,未纳入本次提交)
This commit is contained in:
2026-08-24 02:02:57 +08:00
parent 2e3f68472e
commit 5568548001
9 changed files with 389 additions and 28 deletions
+85 -21
View File
@@ -18,6 +18,20 @@
</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"
@@ -48,12 +62,17 @@
<!-- 文件模式 -->
<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"
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>
@@ -287,6 +306,35 @@ const expires = [
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)
@@ -322,10 +370,6 @@ const canUpload = computed(() => {
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])
@@ -524,14 +568,16 @@ async function buildPoster(d: UploadResult): Promise<string> {
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, 37)
ctx.fillText('传', 74, 50) // logo 圆块 24-76 范围的中线
ctx.textAlign = 'left'
ctx.fillText('文传易', 118, 26)
ctx.fillText('文传易', 118, 36) // 与 logo 中线略上对齐(视觉与"免登录"副标题更平衡)
ctx.font = `14px ${FONT}`
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillText('免登录,传文件,真容易', 118, 60)
ctx.fillText('免登录,传文件,真容易', 118, 64)
ctx.textBaseline = 'top' // 恢复默认给后续 sectionTitle 等使用
// 成功提示
ctx.fillStyle = '#00B578'
@@ -542,12 +588,12 @@ async function buildPoster(d: UploadResult): Promise<string> {
// 分区小标题
const sectionTitle = (text: string, y: number) => {
ctx.fillStyle = '#2D6CFF'
roundRect(ctx, 44, y + 2, 4, 15, 2)
roundRect(ctx, 38, y + 2, 4, 15, 2)
ctx.fill()
ctx.fillStyle = '#1F2329'
ctx.textAlign = 'left'
ctx.font = `600 15px ${FONT}`
ctx.fillText(text, 60, y)
ctx.fillText(text, 66, y)
}
let y = 164
@@ -598,8 +644,8 @@ async function buildPoster(d: UploadResult): Promise<string> {
sectionTitle('文件信息', y)
y += 36
// 文件信息卡
const cardH = 20 + 32 + nameLines.length * 24 + 14
// 文件信息卡(精确高度:上下各 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()
@@ -614,14 +660,23 @@ async function buildPoster(d: UploadResult): Promise<string> {
cy += 24
ctx.fillText(nameLines[i], 130, cy)
}
cy += 30
// 大小(单独一行)
cy += 28
ctx.fillStyle = '#8A9099'
ctx.font = `14px ${FONT}`
ctx.fillText('大小 / 有效期', 62, cy)
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(`${formatSize(d.size)} · ${expireText}`, 150, cy)
ctx.fillText(expireText, 130, cy)
// 取件码标签(与大码拉开间距避免重叠)
let codeY = y + cardH + 28
@@ -650,14 +705,15 @@ async function buildPoster(d: UploadResult): Promise<string> {
// 动态画布高度(按实际内容,消除底部大留白)
// 注意:重设 canvas.height 会清空画布内容,必须先备份再画回
// 关键:drawImage 必须显式指定目标尺寸=backup 尺寸,否则浏览器会按新 canvas.height 缩放整张图,导致文字比例全乱
const newH = qrY + qrSize + 14 + 40
if (canvas.height !== newH) {
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)
canvas.getContext('2d')!.drawImage(backup, 0, 0, backup.width, backup.height)
}
return canvas.toDataURL('image/png')
@@ -697,9 +753,14 @@ async function copyText(text: string) {
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}.png`
a.download = `文传易取件码-${result.value.pickCode}(${safeName})-${result.value.id}.png`
a.click()
}
@@ -715,6 +776,9 @@ function formatDateTime(s: string | null) {
return s.replace('T', ' ').slice(0, 16)
}
onMounted(() => window.addEventListener('paste', onPaste))
onMounted(() => {
isWeChat.value = /MicroMessenger/i.test(navigator.userAgent)
window.addEventListener('paste', onPaste)
})
onUnmounted(() => window.removeEventListener('paste', onPaste))
</script>