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:
@@ -33,6 +33,7 @@ public class FileController : ControllerBase
|
||||
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tag(password 与 tag 可同时设置)</summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置)
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 220_200_960)] // 210MB(默认 128MB,会拒 >128MB 的 multipart 上传)
|
||||
public async Task<IActionResult> Upload([FromForm] int expireHours, [FromForm] string? password,
|
||||
[FromForm] string? tag, IFormFile? file)
|
||||
{
|
||||
|
||||
@@ -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)" }
|
||||
@@ -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 "<html><body>maintenance</body></html>" -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') ==="
|
||||
@@ -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 ==="
|
||||
@@ -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 = @"
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<location path="." inheritInChildApplications="false">
|
||||
<system.webServer>
|
||||
<handlers>
|
||||
<add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified" />
|
||||
</handlers>
|
||||
<aspNetCore processPath="dotnet" arguments=".\WenChuanyi.Api.dll" stdoutLogEnabled="false" stdoutLogFile=".\logs\stdout" hostingModel="inprocess" />
|
||||
</system.webServer>
|
||||
</location>
|
||||
</configuration>
|
||||
"@
|
||||
$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"
|
||||
@@ -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 $_ }
|
||||
@@ -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" }
|
||||
@@ -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>
|
||||
|
||||
@@ -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` 需含 `<security><requestFiltering><requestLimits maxAllowedContentLength="220200960"/></requestFiltering></security>`(~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. 待确认问题清单(已全部确认)
|
||||
|
||||
> ✅ = 已确认(已更新到对应章节)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user