diff --git a/backend/WenChuanyi.Api/Controllers/FileController.cs b/backend/WenChuanyi.Api/Controllers/FileController.cs index 45b215e..7f7c000 100644 --- a/backend/WenChuanyi.Api/Controllers/FileController.cs +++ b/backend/WenChuanyi.Api/Controllers/FileController.cs @@ -33,6 +33,7 @@ public class FileController : ControllerBase /// multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置) [HttpPost("upload")] [RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置) + [RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)] // 210MB(默认 128MB,会拒 >128MB 的 multipart 上传) public async Task Upload([FromForm] int expireHours, [FromForm] string? password, [FromForm] string? tag, IFormFile? file) { diff --git a/deploy/_diag/check_dll.ps1 b/deploy/_diag/check_dll.ps1 new file mode 100644 index 0000000..81fc9c0 --- /dev/null +++ b/deploy/_diag/check_dll.ps1 @@ -0,0 +1,24 @@ +# Download remote DLL + search for the new attribute metadata string +$ErrorActionPreference = "Continue" +$h="116.198.221.125"; $p=21; $u="wenchuanyi"; $pw="r7P^f*v7rFts" +$Out="D:\CodeBuddy\Pros\WenChuanyi\deploy\_diag" +function NewR($path,$m){ $r=[System.Net.FtpWebRequest]::Create("ftp://${h}:${p}/"+$path.TrimStart('/')); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($u,$pw); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=60000; return $r } +# download remote dll +try{ + $r=NewR "/wwwroot/WenChuanyi.Api.dll" ([System.Net.WebRequestMethods+Ftp]::DownloadFile) + $resp=$r.GetResponse(); $fs=[IO.File]::Create("$Out\server-WenChuanyi.Api.dll") + $s=$resp.GetResponseStream(); $b=New-Object byte[] 65536 + while(($n=$s.Read($b,0,65536)) -gt 0){ $fs.Write($b,0,$n) } + $fs.Close(); $s.Close(); $resp.Close() + $remote=Get-Item "$Out\server-WenChuanyi.Api.dll" + $local=Get-Item "D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish\WenChuanyi.Api.dll" + Write-Host "remote dll len=$($remote.Length) mtime=$($remote.LastWriteTime)" + Write-Host "local dll len=$($local.Length) mtime=$($local.LastWriteTime)" + $bytes=[IO.File]::ReadAllBytes($remote.FullName) + $ascii=[Text.Encoding]::ASCII.GetString($bytes) + Write-Host "remote has 'RequestFormLimits': $($ascii.Contains('RequestFormLimitsAttribute'))" + $lb=[IO.File]::ReadAllBytes($local.FullName) + $lascii=[Text.Encoding]::ASCII.GetString($lb) + Write-Host "local has 'RequestFormLimits': $($lascii.Contains('RequestFormLimitsAttribute'))" + Write-Host "dll identical: $((Compare-Object $bytes $lb).Count -eq 0)" +}catch{ Write-Host "ERR $($_.Exception.Message)" } diff --git a/deploy/apply_backend.ps1 b/deploy/apply_backend.ps1 new file mode 100644 index 0000000..676a23c --- /dev/null +++ b/deploy/apply_backend.ps1 @@ -0,0 +1,78 @@ +# Apply new backend build via app_offline.htm (unlock inprocess DLLs) + full sync +$ErrorActionPreference = "Continue" +$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts" +$Root="D:\CodeBuddy\Pros\WenChuanyi" +$PublishDir = "$Root\backend\WenChuanyi.Api\bin\Release\net8.0\publish" +$DistDir = "$Root\frontend\dist" +$Stage = Join-Path $env:TEMP "wcy_publish_staging" +$RemoteRoot="/wwwroot" +$Log = "$Root\deploy\apply.log" +$fail=$false +Set-Content -Path $Log -Value "=== apply start $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" -Encoding UTF8 +function L([string]$m){ Add-Content -Path $Log -Value $m -Encoding UTF8 } +function New-FtpReq([string]$path,[string]$method,[int]$timeoutMs=90000){ + $uri="ftp://${FtpHost}:${FtpPort}/"+$path.TrimStart('/') + $r=[System.Net.FtpWebRequest]::Create($uri) + $r.Method=$method; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass) + $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$timeoutMs + return $r +} +function Get-FtpSize([string]$p){ + for($i=0;$i -lt 3;$i++){ + try{ $r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000 + $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s }catch{ Start-Sleep -Seconds 2 } + } + return -1 +} +function UpFile([string]$lp,[string]$rp){ + $localSize=(Get-Item $lp).Length + $remoteSize=Get-FtpSize $rp + if($remoteSize -eq $localSize){ L "SKIP $rp"; return } + L "UPLOAD $rp (remote=$remoteSize local=$localSize)" + for($i=1;$i -le 4;$i++){ + try{ + $r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000 + $b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length + $s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() } + $resp=$r.GetResponse(); try{ $resp.Close() }catch{} + Start-Sleep -Seconds 1 + $after=Get-FtpSize $rp + if($after -eq $localSize){ L "OK $rp"; return } + L "VERIFYFAIL $rp (got $after)" + }catch{ L "RETRY($i) $rp : $($_.Exception.Message)" } + Start-Sleep -Seconds 4 + } + L "FAIL $rp"; $script:fail=$true +} +function DelF([string]$rp){ + try{ $r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::DeleteFile) + $resp=$r.GetResponse(); try{$resp.Close()}catch{}; L "DEL $rp" }catch{ L "DEL skip $rp" } + Start-Sleep -Milliseconds 800 +} +function UpDir([string]$ld,[string]$rd){ + foreach($d in Get-ChildItem $ld -Directory){ + $c="$rd/$($d.Name)" + $mr=New-FtpReq $c ([System.Net.WebRequestMethods+Ftp]::MakeDirectory) 30000 + try{ $null=$mr.GetResponse() }catch{} + UpDir $d.FullName $c + } + foreach($f in Get-ChildItem $ld -File){ UpFile $f.FullName "$rd/$($f.Name)" } +} +# 1. stage +if(Test-Path $Stage){ Remove-Item $Stage -Recurse -Force } +New-Item -ItemType Directory -Path $Stage | Out-Null +Copy-Item "$DistDir\*" $Stage -Recurse -Force +Copy-Item "$PublishDir\*" $Stage -Recurse -Force +L "stage files: $((Get-ChildItem $Stage -File -Recurse).Count)" +# 2. app_offline to stop app & unlock DLLs +Set-Content -Path "$Stage\app_offline.htm" -Value "maintenance" -Encoding UTF8 +UpFile "$Stage\app_offline.htm" "$RemoteRoot/app_offline.htm" +L "wait 6s for app shutdown..." +Start-Sleep -Seconds 6 +# 3. full sync +UpDir $Stage $RemoteRoot +# 4. remove app_offline -> app auto-restart +DelF "$RemoteRoot/app_offline.htm" +Remove-Item $Stage -Recurse -Force +if($fail){ L "RESULT=PARTIAL_FAIL" } else { L "RESULT=OK" } +L "=== apply end $(Get-Date -Format 'HH:mm:ss') ===" diff --git a/deploy/force_dll.ps1 b/deploy/force_dll.ps1 new file mode 100644 index 0000000..7257c43 --- /dev/null +++ b/deploy/force_dll.ps1 @@ -0,0 +1,24 @@ +# Force-overwrite WenChuanyi.Api.dll via app_offline (dll size identical -> size-check skipped it) +$ErrorActionPreference = "Continue" +$h="116.198.221.125"; $p=21; $u="wenchuanyi"; $pw="r7P^f*v7rFts" +$LocalDll="D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish\WenChuanyi.Api.dll" +$Log="D:\CodeBuddy\Pros\WenChuanyi\deploy\force_dll.log" +Set-Content $Log "=== force dll start $(Get-Date -Format 'HH:mm:ss') ===" -Encoding UTF8 +function L([string]$m){ Add-Content $Log $m -Encoding UTF8; Write-Host $m } +function NewR([string]$path,[string]$m,[int]$t=90000){ $r=[System.Net.FtpWebRequest]::Create("ftp://${h}:${p}/"+$path.TrimStart('/')); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($u,$pw); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$t; return $r } +function UpRaw([string]$lp,[string]$rp){ + $b=[IO.File]::ReadAllBytes($lp); $r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000 + $r.ContentLength=$b.Length; $s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() } + $resp=$r.GetResponse(); try{ $resp.Close() }catch{}; L "UP $rp len=$($b.Length)" +} +# 1. stop app +Set-Content "$env:TEMP\app_offline.htm" "maintenance" -Encoding UTF8 +try{ UpRaw "$env:TEMP\app_offline.htm" "/wwwroot/app_offline.htm" }catch{ L "offline up err: $($_.Exception.Message)" } +L "wait 7s..."; Start-Sleep -Seconds 7 +# 2. force dll +try{ UpRaw $LocalDll "/wwwroot/WenChuanyi.Api.dll"; L "DLL FORCE-UP OK" }catch{ L "DLL FAIL: $($_.Exception.Message)" } +Start-Sleep -Seconds 1 +# 3. remove app_offline -> restart +try{ $r=NewR "/wwwroot/app_offline.htm" ([System.Net.WebRequestMethods+Ftp]::DeleteFile); $resp=$r.GetResponse(); try{$resp.Close()}catch{}; L "app_offline removed (restarting)" }catch{ L "offline del err: $($_.Exception.Message)" } +Remove-Item "$env:TEMP\app_offline.htm" -ErrorAction SilentlyContinue +L "=== force dll end ===" diff --git a/deploy/ftp_apply.ps1 b/deploy/ftp_apply.ps1 new file mode 100644 index 0000000..3e7c8bc --- /dev/null +++ b/deploy/ftp_apply.ps1 @@ -0,0 +1,55 @@ +# Apply: new appsettings.json (localhost) + restore web.config (stdout off, triggers restart) + nested assets +$ErrorActionPreference = "Continue" +$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts" +$Local = "D:\CodeBuddy\Pros\WenChuanyi\backend\WenChuanyi.Api\bin\Release\net8.0\publish" +$Dist = "D:\CodeBuddy\Pros\WenChuanyi\frontend\dist" +function NewR($p,$m,$to=90000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r } +function Mk($p){ + try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::MakeDirectory); $resp=$r.GetResponse(); try{$resp.Close()}catch{} }catch{} + Start-Sleep -Milliseconds 300 +} +function GetSizeF($p){ + try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000; $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s } + catch{ return -1 } +} +function UpF($lp,$rp){ + $want=(Get-Item $lp).Length + for($i=1;$i -le 4;$i++){ + try{ + $r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000 + $b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length + $s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() } + $resp=$r.GetResponse(); try{ $resp.Close() }catch{} + Start-Sleep -Milliseconds 300 + $after=GetSizeF $rp + if($after -eq $want){ Write-Host "UP OK $rp"; return $true } + Write-Host "SIZE MISMATCH $rp got=$after want=$want" + }catch{ Write-Host "RETRY($i) $rp : $($_.Exception.Message)" } + Start-Sleep -Seconds 3 + } + Write-Host "FAIL $rp"; return $false +} +# 1. appsettings.json (localhost db) +UpF "$Local\appsettings.json" "/wwwroot/appsettings.json" +# 2. web.config: stdout off (production) - also triggers app pool restart +$wc = @" + + + + + + + + + + + +"@ +$tmp = Join-Path $env:TEMP "wcy_web_prod.config" +[IO.File]::WriteAllText($tmp, $wc, [Text.Encoding]::UTF8) +UpF $tmp "/wwwroot/web.config" +# 3. nested wwwroot assets (frontend static files served by ASP.NET Core WebRoot) +Mk "/wwwroot/wwwroot/assets" +UpF "$Dist\assets\index-Bwz67ZeI.js" "/wwwroot/wwwroot/assets/index-Bwz67ZeI.js" +UpF "$Dist\assets\index-c9wa7v4K.css" "/wwwroot/wwwroot/assets/index-c9wa7v4K.css" +Write-Host "APPLY DONE" diff --git a/deploy/ftp_diag.ps1 b/deploy/ftp_diag.ps1 new file mode 100644 index 0000000..b32ccda --- /dev/null +++ b/deploy/ftp_diag.ps1 @@ -0,0 +1,29 @@ +# Download web.config + list logs dir for upload-failure diagnosis +$ErrorActionPreference = "Continue" +$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts" +$Out = "D:\CodeBuddy\Pros\WenChuanyi\deploy\_diag" +New-Item -ItemType Directory -Force -Path $Out | Out-Null +function NewR($p,$m,$to=60000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r } +function Down($rp,$lp){ + try{ + $r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::DownloadFile) + $resp=$r.GetResponse(); $fs=[IO.File]::Create($lp) + $s=$resp.GetResponseStream(); $b=New-Object byte[] 65536 + while(($n=$s.Read($b,0,65536)) -gt 0){ $fs.Write($b,0,$n) } + $fs.Close(); $s.Close(); $resp.Close() + Write-Host "DOWN OK $rp -> $lp" + }catch{ Write-Host "DOWN FAIL $rp : $($_.Exception.Message)" } +} +function ListF($p){ + try{ + $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::ListDirectoryDetails) + $resp=$r.GetResponse(); $sr=New-Object IO.StreamReader($resp.GetResponseStream(),[Text.Encoding]::UTF8) + $txt=$sr.ReadToEnd(); $sr.Close(); $resp.Close() + return $txt + }catch{ return "ERR $($_.Exception.Message)" } +} +Down "/wwwroot/web.config" "$Out\server-web.config" +Write-Host "=== logs dir ===" +(ListF "/wwwroot/logs/") | ForEach-Object { Write-Host $_ } +Write-Host "=== wwwroot dir ===" +(ListF "/wwwroot/") | ForEach-Object { Write-Host $_ } diff --git a/deploy/ftp_fe.ps1 b/deploy/ftp_fe.ps1 new file mode 100644 index 0000000..612e8da --- /dev/null +++ b/deploy/ftp_fe.ps1 @@ -0,0 +1,41 @@ +# Upload frontend build (nested wwwroot) - overwrite index.html, upload new assets, delete old ones +$ErrorActionPreference = "Continue" +$FtpHost="116.198.221.125"; $FtpPort=21; $FtpUser="wenchuanyi"; $FtpPass="r7P^f*v7rFts" +$Dist = "D:\CodeBuddy\Pros\WenChuanyi\frontend\dist" +function NewR($p,$m,$to=90000){ $u="ftp://${FtpHost}:${FtpPort}/"+$p.TrimStart('/'); $r=[System.Net.FtpWebRequest]::Create($u); $r.Method=$m; $r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass); $r.UsePassive=$true; $r.UseBinary=$true; $r.KeepAlive=$false; $r.Timeout=$to; $r } +function GetSizeF($p){ + try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::GetFileSize) 30000; $resp=$r.GetResponse(); $s=$resp.ContentLength; $resp.Close(); return $s } + catch{ return -1 } +} +function DelF($p){ + try{ $r=NewR $p ([System.Net.WebRequestMethods+Ftp]::DeleteFile); $resp=$r.GetResponse(); try{$resp.Close()}catch{}; Write-Host "DEL $p" } + catch{ Write-Host "DEL skip $p (not exist or locked)" } + Start-Sleep -Milliseconds 500 +} +function UpF($lp,$rp){ + $want=(Get-Item $lp).Length + DelF $rp + Start-Sleep -Seconds 1 + for($i=1;$i -le 4;$i++){ + try{ + $r=NewR $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile) 300000 + $b=[IO.File]::ReadAllBytes($lp); $r.ContentLength=$b.Length + $s=$r.GetRequestStream(); try{ $s.Write($b,0,$b.Length) } finally { $s.Close() } + $resp=$r.GetResponse(); try{ $resp.Close() }catch{} + Start-Sleep -Milliseconds 500 + $after=GetSizeF $rp + if($after -eq $want){ Write-Host "UP OK $rp ($after bytes)"; return $true } + Write-Host "SIZE MISMATCH $rp got=$after want=$want" + }catch{ Write-Host "RETRY($i) $rp : $($_.Exception.Message)" } + Start-Sleep -Seconds 3 + } + Write-Host "FAIL $rp"; return $false +} +# upload new dist +$ok=$true +$ok=(UpF "$Dist\index.html" "/wwwroot/wwwroot/index.html") -and $ok +$ok=(UpF "$Dist\assets\index-DmgEpwV6.js" "/wwwroot/wwwroot/assets/index-DmgEpwV6.js") -and $ok +$ok=(UpF "$Dist\assets\index-BjMMI5BR.css" "/wwwroot/wwwroot/assets/index-BjMMI5BR.css") -and $ok +# remove stale old-hash assets +DelF "/wwwroot/wwwroot/assets/index-C2wmSVTP.js" +if($ok){ Write-Host "FE DEPLOY OK" } else { Write-Host "FE DEPLOY PARTIAL" } diff --git a/frontend/src/views/UploadView.vue b/frontend/src/views/UploadView.vue index cf06741..0cccfcd 100644 --- a/frontend/src/views/UploadView.vue +++ b/frontend/src/views/UploadView.vue @@ -18,6 +18,20 @@ + +
+
+ 微信内上传与下载会受限(无法从聊天选择文件、下载需跳转外部浏览器)。建议点击右上角「···」→「在浏览器打开」。 + +
+ +
+
-
+ +
@@ -287,6 +306,35 @@ const expires = [ const textMode = ref(false) const fileInput = ref(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(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 { 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 { // 分区小标题 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 { 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 { 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 { // 动态画布高度(按实际内容,消除底部大留白) // 注意:重设 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)) diff --git a/需求文档.md b/需求文档.md index 0060d62..bb9a1ee 100644 --- a/需求文档.md +++ b/需求文档.md @@ -1,8 +1,9 @@ # 文传易 · 需求文档 -> **文档版本**:v0.4(交付版) +> **文档版本**:v0.5 > **创建日期**:2026-08-23 -> **文档状态**:已交付(前后端已实现并通过本地冒烟测试;部署见 `deploy/IIS部署与FTP发布.md`) +> **更新日期**:2026-08-24 +> **文档状态**:已上线(前后端已实现、本地冒烟测试通过、已部署至正式站 `https://wenchuanyi.bbitcn.net`;累计修复:大文件上传 135MB 失败、二维码海报排版、保存文件名带原文件名) > **技术栈**:.NET 8 + FreeSql + MySQL(后端)| Vue 3 + Vite + TypeScript + TDesign(前端) > **正式站**:https://wenchuanyi.bbitcn.net(中文名「文传易」) @@ -114,6 +115,7 @@ - [P0] 通过取件码位数识别文件类型:**8 位 = 共享文件,6 位 = 私密文件**;取件码全局不重复 - [P0] 上传成功返回:取件凭证(取件码或标签)+ 管理码(8 位字母数字)+ 文件信息 - [P0] **二维码分享**:上传成功后前端用 `qrcode` 库本地生成二维码(内容为取件页链接 `{BaseUrl}/#/pickup?code=xxx`,仅含取件码、不含敏感信息),与取件码同卡片展示,可下载/长按转发 +- [P0] **二维码海报保存**:点击"保存二维码"时前端将成功卡片合成为一张 PNG 海报(含品牌条、下载方式、文件信息卡、取件码、二维码);**保存文件名默认格式**为 `文传易取件码-取件码(原文件名)-流水号.png`(原文件名清洗 Windows 非法字符 `\/:*?"<>|`,空名兜底「未命名」) - [P0] 取件码 / 管理码一键复制 - [P1] 上传进度百分比展示 - [P2] 上传失败一键重试 @@ -151,6 +153,10 @@ ### 5.1 性能 - 单文件大小上限:**200MB**(前端上传前拦截 + 服务端双重校验,Kestrel 请求上限 210MB 留余量) +- 大文件上传依赖三道限制**一致放开**,否则请求在到达应用前被拦(日志无记录): + 1. **前端**:`axios` 配置 `maxContentLength` / `maxBodyLength` 放开(上传前另有 200MB 拦截提示); + 2. **IIS 请求过滤**:站点 `web.config` 需含 ``(~210MB;IIS 默认 30MB); + 3. **ASP.NET Core multipart**:`FileController.Upload` 需同时有 `[RequestSizeLimit(220_200_960)]` 与 `[RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)]`(multipart 默认 128MB,缺 `RequestFormLimits` 会拒 >128MB 上传)。 - 上传 / 下载全程流式 I/O,内存占用恒定,不整体读入内存 - 下载附带 `Content-Length` + `Accept-Ranges`,支持断点续传(`Range` 透传,OSS 原生支持 206) - 表查询走唯一索引(PickCode / AdminCode)与普通索引(Tag);标签列表按 CreatedAt 倒序,limit 100 防大列表 @@ -272,7 +278,7 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程 | 文本文件超 2MB / PDF/图片超 30MB | 不提供在线预览,仅提供下载(提示"文件过大,请下载后查看") | | 音视频非白名单格式 | 不提供在线播放,提示下载查看 | | 文件已过期 | 提示"文件已过期",并触发懒清理 | -| 文件超过大小上限(200MB) | 前端上传前拦截 + 服务端双重校验 | +| 文件超过大小上限(200MB) | 前端上传前拦截 + 服务端双重校验;若超过 IIS(30MB 默认)或 ASP.NET multipart(128MB 默认)限制,请求在到达应用前被拒(500.30/404.13/413,日志无记录),需按 §5.1 放开三层限制 | | 上传中断 / 网络错误 | 前端提示并可重试 | | OSS 写入/读取失败 | 记录日志,返回明确错误码 | | 管理码错误 | 提示"管理码无效" | @@ -302,12 +308,51 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程 | 阶段 | 内容 | 状态 | | --- | --- | --- | | M1 需求确认 | 本需求文档定稿、待确认问题全部拍板 | ✅ 已完成 | -| M2 开发 | 后端 API + 前端三页面 + 前后端联调 | 未开始 | -| M3 交付 | 本地测试通过、IIS+FTP 部署说明与脚本齐全 | 未开始 | +| M2 开发 | 后端 API + 前端三页面 + 前后端联调 | ✅ 已完成 | +| M3 交付 | 本地测试通过、IIS+FTP 部署说明与脚本齐全 | ✅ 已完成 | --- -## 11. 未来扩展(可选,本期不实现) +## 11. 开发进度状态(归档) + +> 更新于:2026-08-24。以下为已上线功能与已修复缺陷的归档记录,便于后续迭代回溯。 + +### 11.1 已上线功能 +- 完整匿名文件传输闭环:上传(拖拽 / Ctrl+V / 点击 / 粘贴文字生成 txt)→ 取件码/标签 → 取件下载。 +- 三种文件模式:共享(8 位码)/ 私密(6 位码 + 密码)/ 标签(一对多、含标签强制永久)。 +- 二维码分享 + 一键复制取件码/管理码;二维码海报保存(默认文件名 `文传易取件码-取件码(原文件名)-流水号.png`)。 +- 在线预览:文本(≤2MB)/ PDF / 图片(≤30MB)/ 音视频(白名单格式,流式)。 +- 发送者凭管理码查看列表、删除文件;管理列表展示上传 IP。 +- 过期文件懒清理 + 后台定时任务(每 30 分钟)。 +- 开放 API(public / price / tag)供外部系统直接生成取件链接。 +- 已部署至正式站 `https://wenchuanyi.bbitcn.net`(IIS + .NET 8 + 阿里云 OSS 中转)。 + +### 11.2 已修复缺陷(2026-08-23 ~ 08-24) +| 日期 | 问题 | 根因 | 修复 | +| --- | --- | --- | --- | +| 08-23 | 135MB zip 上传失败 | 三层大小限制未全放开:①前端 axios 未放开;②IIS `web.config` 缺 `maxAllowedContentLength`(默认 30MB);③`FileController.Upload` 缺 `[RequestFormLimits]`(multipart 默认 128MB) | 前端放开 maxBodyLength;web.config 加 `maxAllowedContentLength=220200960`;Upload 加 `[RequestFormLimits(MultipartBodyLengthLimit=220_200_960)]` | +| 08-23 | 服务器 DLL 更新后仍跑旧版 | in-process 下 DLL 被 IIS 锁定,FTP 覆盖被 550;且**大小巧合相同(60416 字节)导致同步脚本按大小校验跳过上传** | 采用 `app_offline.htm` 方案:先放该文件触发 ANCM 优雅停止解锁 DLL → 覆盖 → 删除文件自动重启。脚本见 `deploy/apply_backend.ps1` / `deploy/force_dll.ps1` | +| 08-24 | 保存二维码海报"格式、文字错位"(底部大留白、二维码悬空) | 动态调高画布时 `drawImage(backup,0,0)` 未指定目标尺寸,浏览器按新高度缩放整张图 | `drawImage(backup,0,0,backup.width,backup.height)` 显式指定目标尺寸,仅裁剪底部空白 | +| 08-24 | 海报顶部 logo / 标题文字偏上 | canvas `textBaseline` 默认 `alphabetic`,文字顶贴品牌条顶 | 品牌条文字加 `textBaseline='middle'` 并按 logo 块中线定位 | +| 08-24 | 海报「下载方式/文件信息」小竖条贴住标题首字 | 竖条与文字间距仅 12px | 竖条 x 40→38、文字 x 62→66,间距扩至 24 | +| 08-24 | 海报「大小 / 有效期」挤在一行 | 单卡片内合并绘制 | 拆为「大小」一行 +「有效期」一行,卡片高度公式同步更新 | +| 08-24 | 保存二维码文件名不带原文件名 | 原文件名格式为 `文传易取件凭证_取件码.png` | 改为 `文传易取件码-取件码(原文件名)-流水号.png`,原文件名清洗非法字符 | + +### 11.3 已知限制 / 注意事项 +- **DLL 部署**:每次后端变更后必须核对服务器 DLL 是否真的更新(内容校验/时间戳),**不能只看大小**——大小可能巧合相同而漏传。 +- **大文件配置**:IIS 与 ASP.NET 两道限制务必同步放开,否则 >128MB 上传会在应用外被拒且无日志。 +- **微信内置浏览器**:支持扫码取件/下载;华为鸿蒙微信内置浏览器中上传文件 **可能无法直接读取微信聊天文件、且点击上传不弹系统选择器**(华为自带浏览器正常)。可引导用户改用系统浏览器或华为浏览器上传。 +- **二维码海报**为前端 canvas 合成,依赖浏览器字体渲染;个别机型字体度量差异可能导致细微间距偏差。 + +### 11.4 待办 / 后续可优化 +- 微信内置浏览器上传取件(聊天文件读取 + 系统选择器弹窗)的兼容性进一步增强。 +- 多文件上传(下载打包 zip)。 +- 下载次数限制(本期仅统计)。 +- 网盘容量与配额管理、界面中英文切换、前端 STS 直传 OSS。 + +--- + +## 12. 未来扩展(可选,本期不实现) - 多文件上传(下载打包 zip) - 下载次数限制(本期仅统计不限制) - 网盘容量与配额管理 @@ -316,7 +361,7 @@ Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程 --- -## 12. 待确认问题清单(已全部确认) +## 13. 待确认问题清单(已全部确认) > ✅ = 已确认(已更新到对应章节)