+
+
@@ -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. 待确认问题清单(已全部确认)
> ✅ = 已确认(已更新到对应章节)