feat: 文传易匿名临时文件传输站初始版本

- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式
- 前端 Vue3 + Vite + TDesign,上传/取件/管理三页
- 取件码/二维码/海报合成(含保存二维码为图片)
- deploy/ 部署文档与一键 FTP 发布脚本
- appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
2026-08-24 00:03:28 +08:00
commit 1bfc72f9cb
40 changed files with 6849 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# ============ 构建产物 ============
node_modules/
frontend/dist/
backend/WenChuanyi.Api/bin/
backend/WenChuanyi.Api/obj/
# ============ 敏感配置(含真实凭据,不入库) ============
backend/WenChuanyi.Api/appsettings.json
backend/WenChuanyi.Api/appsettings.Development.json
# ============ IDE / 工具 / 缓存 ============
.vscode/
.codebuddy/
.playwright-cli/
snapshot_tmp.txt
# ============ 临时 ============
*.log
*.tmp
ftp_upload.log
ftp_upload.err
site_index.html
backend/WenChuanyi.Api/run.err
backend/WenChuanyi.Api/run.out.log
frontend/vite.config.ts.timestamp-*.mjs
frontend/dist/
+5
View File
@@ -0,0 +1,5 @@
bin/
obj/
appsettings.json
*.user
.vs/
@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Mvc;
using WenChuanyi.Api.Dtos;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
namespace WenChuanyi.Api.Controllers;
/// <summary>发送者管理:凭管理码查看文件列表 / 删除文件</summary>
[ApiController]
[Route("api/admin")]
public class AdminController : ControllerBase
{
private readonly IFreeSql _fsql;
private readonly OssStorageService _oss;
private readonly ILogger<AdminController> _logger;
public AdminController(IFreeSql fsql, OssStorageService oss, ILogger<AdminController> logger)
{
_fsql = fsql;
_oss = oss;
_logger = logger;
}
/// <summary>管理码查询文件列表(含上传 IP</summary>
[HttpGet("files/{adminCode}")]
public async Task<IActionResult> List(string adminCode)
{
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
{
return BadRequest(new { message = "管理码无效" });
}
var list = await _fsql.Select<FileItem>()
.Where(f => f.AdminCode == adminCode)
.OrderByDescending(f => f.CreatedAt)
.ToListAsync();
return Ok(list.Select(ToDto));
}
/// <summary>删除指定文件(body 传 id 或取件码,校验管理码)</summary>
[HttpDelete("files/{adminCode}")]
public async Task<IActionResult> Delete(string adminCode, [FromBody] DeleteRequest? req)
{
if (string.IsNullOrEmpty(adminCode) || adminCode.Length != 8)
{
return BadRequest(new { message = "管理码无效" });
}
if (req == null || (req.Id == null && string.IsNullOrEmpty(req.PickCode)))
{
return BadRequest(new { message = "请提供要删除的文件 Id 或取件码" });
}
FileItem? item;
if (req.Id != null)
{
item = await _fsql.Select<FileItem>()
.Where(f => f.Id == req.Id.Value && f.AdminCode == adminCode).FirstAsync();
}
else
{
item = await _fsql.Select<FileItem>()
.Where(f => f.PickCode == req.PickCode && f.AdminCode == adminCode).FirstAsync();
}
if (item == null)
{
return NotFound(new { message = "文件不存在或管理码不匹配" });
}
var deleted = await _fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync() > 0;
if (deleted)
{
_oss.TryDeleteObject(item.ObjectKey);
}
return Ok(new { message = "已删除" });
}
private static FileInfoDto ToDto(FileItem f) => new()
{
Id = f.Id,
PickCode = f.PickCode,
FileType = f.FileType,
HasPassword = f.FileType == "private",
OriginalName = f.OriginalName,
Size = f.Size,
MimeType = f.MimeType,
DownloadCount = f.DownloadCount,
UploadIp = f.UploadIp,
IsPermanent = f.IsPermanent,
ExpiresAt = f.ExpiresAt,
CreatedAt = f.CreatedAt,
ExpireText = FileRules.ExpireText(f)
};
}
@@ -0,0 +1,392 @@
using System.Text;
using Aliyun.OSS;
using Microsoft.AspNetCore.Mvc;
using WenChuanyi.Api.Dtos;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
namespace WenChuanyi.Api.Controllers;
/// <summary>文件上传 / 查询 / 标签列表 / 预览 / 下载</summary>
[ApiController]
[Route("api/files")]
public class FileController : ControllerBase
{
private readonly IFreeSql _fsql;
private readonly CodeGeneratorService _codeGen;
private readonly OssStorageService _oss;
private readonly string _baseUrl;
private readonly ILogger<FileController> _logger;
public FileController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
IConfiguration cfg, ILogger<FileController> logger)
{
_fsql = fsql;
_codeGen = codeGen;
_oss = oss;
_baseUrl = cfg["App:BaseUrl"] ?? "";
_logger = logger;
}
// ---------- 上传 ----------
/// <summary>multipart 上传:file + expireHours(24/168/0) + password + tagpassword 与 tag 可同时设置)</summary>
[HttpPost("upload")]
[RequestSizeLimit(220_200_960)] // 210MBKestrel 同配置)
public async Task<IActionResult> Upload([FromForm] int expireHours, [FromForm] string? password,
[FromForm] string? tag, IFormFile? file)
{
if (file == null || file.Length == 0)
{
return BadRequest(new { message = "请选择要上传的文件" });
}
if (file.Length > FileRules.MaxFileBytes)
{
return BadRequest(new { message = "文件超过 200MB 限制,无法上传" });
}
var pwdErr = FileRules.ValidatePassword(password);
if (pwdErr != null) return BadRequest(new { message = pwdErr });
tag = string.IsNullOrWhiteSpace(tag) ? null : tag.Trim();
var tagErr = FileRules.ValidateTag(tag);
if (tagErr != null) return BadRequest(new { message = tagErr });
var hasPassword = !string.IsNullOrEmpty(password);
var hasTag = !string.IsNullOrEmpty(tag);
// 有效期:仅 24/168/0 三档;含标签强制永久
var effectiveExpire = hasTag ? 0 : (expireHours is 24 or 168 or 0 ? expireHours : 24);
var isPermanent = hasTag || effectiveExpire == 0;
DateTime? expiresAt = isPermanent ? null : DateTime.Now.AddHours(effectiveExpire);
var fileType = FileRules.ResolveFileType(hasPassword, hasTag);
var originalName = FileRules.SanitizeFileName(file.FileName);
var objectKey = _oss.BuildObjectKey(originalName);
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
var contentType = string.IsNullOrEmpty(file.ContentType) ? "application/octet-stream" : file.ContentType;
try
{
await using var stream = file.OpenReadStream();
await _oss.UploadAsync(objectKey, stream, contentType);
}
catch (Exception ex)
{
_logger.LogError(ex, "OSS 上传失败:{Name}", file.FileName);
return StatusCode(500, new { message = "文件上传失败,请稍后重试" });
}
var item = new FileItem
{
PickCode = await _codeGen.GeneratePickCodeAsync(FileRules.PickCodeLength(fileType)),
AdminCode = await _codeGen.GenerateAdminCodeAsync(),
FileType = fileType,
Password = hasPassword ? password : null,
Tag = hasTag ? tag : null,
OriginalName = originalName,
ObjectKey = objectKey,
Size = file.Length,
MimeType = contentType,
DownloadCount = 0,
UploadIp = ip,
IsPermanent = isPermanent,
ExpiresAt = expiresAt,
CreatedAt = DateTime.Now
};
await _fsql.Insert(item).ExecuteAffrowsAsync();
return Ok(new UploadResultDto
{
Id = item.Id,
PickCode = item.PickCode,
AdminCode = item.AdminCode,
FileType = fileType,
OriginalName = item.OriginalName,
Size = item.Size,
IsPermanent = isPermanent,
ExpiresAt = expiresAt,
PickUrl = BuildPickUrl(item.PickCode)
});
}
// ---------- 查询 ----------
/// <summary>按取件码查询(私密需带 password;过期返回 410</summary>
[HttpGet("{code}")]
public async Task<IActionResult> Query(string code, [FromQuery] string? password)
{
if (!IsPickCode(code))
{
return NotFound(new { message = "取件码/标签无效" });
}
var (item, error, status) = await FindAndValidateAsync(code, password);
if (item == null)
{
return StatusCode(status, new { message = error });
}
return Ok(ToDto(item));
}
/// <summary>按标签查询文件列表(CreatedAt 倒序,limit 100</summary>
[HttpGet("by-tag/{tag}")]
public async Task<IActionResult> ByTag(string tag)
{
if (string.IsNullOrEmpty(tag) || !tag.All(char.IsLetterOrDigit))
{
return BadRequest(new { message = "标签无效" });
}
var list = await _fsql.Select<FileItem>()
.Where(f => f.Tag == tag)
.OrderByDescending(f => f.CreatedAt)
.Limit(100)
.ToListAsync();
return Ok(list.Select(ToDto));
}
// ---------- 下载 ----------
/// <summary>流式下载:计数 +1(仅统计),还原原始文件名,支持 Range 断点续传(私密需密码)</summary>
[HttpGet("{code}/download")]
public async Task<IActionResult> Download(string code, [FromQuery] string? password)
{
var (item, error, status) = await FindAndValidateAsync(code, password);
if (item == null)
{
return StatusCode(status, new { message = error });
}
await _fsql.Update<FileItem>()
.Set(f => f.DownloadCount + 1)
.Where(f => f.Id == item.Id)
.ExecuteAffrowsAsync();
await ServeStreamAsync(item, inline: false);
return new EmptyResult();
}
// ---------- 预览 ----------
/// <summary>在线预览:PDF/图片(≤30MB)/ 音视频白名单(不限大小)inline 流;不计次数</summary>
[HttpGet("{code}/preview")]
public async Task<IActionResult> Preview(string code, [FromQuery] string? password)
{
var (item, error, status) = await FindAndValidateAsync(code, password);
if (item == null)
{
return StatusCode(status, new { message = error });
}
var ext = Path.GetExtension(item.OriginalName).ToLowerInvariant();
var mime = item.MimeType ?? "";
if (FileRules.IsText(ext))
{
return await ServeTextContentAsync(item);
}
if (FileRules.IsPdfOrImage(mime))
{
if (item.Size > FileRules.MaxPdfImagePreview)
{
return BadRequest(new { message = "文件过大,请下载后查看" });
}
await ServeStreamAsync(item, inline: true);
return new EmptyResult();
}
if (FileRules.IsVideoWhitelist(ext) || FileRules.IsAudioWhitelist(ext))
{
await ServeStreamAsync(item, inline: true);
return new EmptyResult();
}
return BadRequest(new { message = "该格式不支持在线预览,请下载查看" });
}
/// <summary>文本类内容(≤2MB),供在线阅读渲染(私密需密码)</summary>
[HttpGet("{code}/content")]
public async Task<IActionResult> GetContent(string code, [FromQuery] string? password)
{
var (item, error, status) = await FindAndValidateAsync(code, password);
if (item == null)
{
return StatusCode(status, new { message = error });
}
if (!FileRules.IsText(Path.GetExtension(item.OriginalName).ToLowerInvariant()))
{
return BadRequest(new { message = "该类型不支持文本预览" });
}
return await ServeTextContentAsync(item);
}
// ---------- 私有辅助 ----------
private static bool IsPickCode(string code)
=> code.Length is 6 or 8 && code.All(char.IsDigit);
/// <summary>校验取件码 + 过期(懒检查)+ 私密密码;错误时返回 (null, message, status)</summary>
private async Task<(FileItem? Item, string? Error, int Status)> FindAndValidateAsync(string code, string? password)
{
if (!IsPickCode(code))
{
return (null, "取件码/标签无效", StatusCodes.Status404NotFound);
}
var item = await _fsql.Select<FileItem>().Where(f => f.PickCode == code).FirstAsync();
if (item == null)
{
return (null, "取件码/标签无效", StatusCodes.Status404NotFound);
}
if (!item.IsPermanent && item.ExpiresAt != null && item.ExpiresAt < DateTime.Now)
{
return (null, "文件已过期", StatusCodes.Status410Gone);
}
if (item.FileType == "private")
{
if (string.IsNullOrEmpty(password) || password != item.Password)
{
return (null, "密码错误", StatusCodes.Status401Unauthorized);
}
}
return (item, null, StatusCodes.Status200OK);
}
private FileInfoDto ToDto(FileItem f) => new()
{
Id = f.Id,
PickCode = f.PickCode,
FileType = f.FileType,
HasPassword = f.FileType == "private",
OriginalName = f.OriginalName,
Size = f.Size,
MimeType = f.MimeType,
DownloadCount = f.DownloadCount,
IsPermanent = f.IsPermanent,
ExpiresAt = f.ExpiresAt,
CreatedAt = f.CreatedAt,
ExpireText = FileRules.ExpireText(f)
};
private string BuildPickUrl(string pickCode)
=> $"{_baseUrl}/#/pickup?code={pickCode}";
/// <summary>OSS 流式输出到响应(inline/attachment + Range 206 透传)</summary>
private async Task ServeStreamAsync(FileItem item, bool inline)
{
var rangeHeader = Request.Headers.Range.ToString();
long? start = null, end = null;
if (!string.IsNullOrEmpty(rangeHeader) && TryParseRange(rangeHeader, item.Size, out var rs, out var re))
{
start = rs;
end = re;
}
OssObject obj;
try
{
obj = _oss.GetObject(item.ObjectKey, start, end);
}
catch (Exception ex)
{
_logger.LogError(ex, "OSS 读取失败:{Key}", item.ObjectKey);
Response.StatusCode = StatusCodes.Status500InternalServerError;
await Response.WriteAsync("文件读取失败,请稍后重试");
return;
}
Response.StatusCode = StatusCodes.Status200OK;
Response.ContentType = item.MimeType ?? "application/octet-stream";
Response.Headers["Accept-Ranges"] = "bytes";
Response.Headers["Content-Disposition"] = inline
? "inline"
: $"attachment; filename*=UTF-8''{Uri.EscapeDataString(item.OriginalName)}";
if (start.HasValue)
{
Response.StatusCode = StatusCodes.Status206PartialContent;
var actualEnd = end < 0 || end == null ? item.Size - 1 : end.Value;
Response.Headers["Content-Range"] = $"bytes {start}-{actualEnd}/{item.Size}";
}
Response.Headers["Content-Length"] = obj.ContentLength.ToString();
await obj.Content.CopyToAsync(Response.Body);
}
/// <summary>文本内容读取(≤2MBUTF-8 优先,GBK 兜底)</summary>
private async Task<IActionResult> ServeTextContentAsync(FileItem item)
{
if (item.Size > FileRules.MaxTextPreview)
{
return BadRequest(new { message = "文件过大,请下载后查看" });
}
OssObject obj;
try
{
obj = _oss.GetObject(item.ObjectKey);
}
catch (Exception ex)
{
_logger.LogError(ex, "OSS 读取失败:{Key}", item.ObjectKey);
return StatusCode(500, new { message = "文件读取失败,请稍后重试" });
}
using var ms = new MemoryStream();
await obj.Content.CopyToAsync(ms);
var bytes = ms.ToArray();
var text = DecodeText(bytes);
return Content(text, "text/plain; charset=utf-8");
}
private static string DecodeText(byte[] bytes)
{
if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
{
return Encoding.UTF8.GetString(bytes, 3, bytes.Length - 3);
}
if (bytes.Length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE)
{
return Encoding.Unicode.GetString(bytes, 2, bytes.Length - 2);
}
return IsValidUtf8(bytes) ? Encoding.UTF8.GetString(bytes) : Encoding.GetEncoding("GBK").GetString(bytes);
}
private static bool IsValidUtf8(byte[] bytes)
{
try
{
var decoder = new UTF8Encoding(false, true).GetDecoder();
var charCount = decoder.GetCharCount(bytes, 0, bytes.Length, flush: true);
return charCount >= 0;
}
catch (DecoderFallbackException)
{
return false;
}
}
private static bool TryParseRange(string rangeHeader, long total, out long start, out long end)
{
start = 0;
end = -1; // -1 表示到文件末尾
if (string.IsNullOrEmpty(rangeHeader) || !rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
{
return false;
}
var spec = rangeHeader["bytes=".Length..];
var idx = spec.IndexOf('-');
if (idx < 0) return false;
var startPart = spec[..idx];
var endPart = spec[(idx + 1)..];
if (startPart.Length > 0 && long.TryParse(startPart, out start))
{
if (start >= total) return false;
if (endPart.Length > 0 && long.TryParse(endPart, out var e))
{
end = Math.Min(e, total - 1);
if (start > end) return false;
}
// "bytes=start-"end 保持 -1(到末尾)
}
else if (endPart.Length > 0 && long.TryParse(endPart, out var suffix))
{
// "bytes=-N":末尾 N 字节
start = Math.Max(0, total - suffix);
end = -1;
}
else
{
return false;
}
return true;
}
}
@@ -0,0 +1,156 @@
using Microsoft.AspNetCore.Mvc;
using WenChuanyi.Api.Dtos;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
namespace WenChuanyi.Api.Controllers;
/// <summary>开放 API:按服务端文件路径上传(共享/私密/标签)+ 下载链接查询</summary>
[ApiController]
[Route("api/open")]
public class OpenApiController : ControllerBase
{
private readonly IFreeSql _fsql;
private readonly CodeGeneratorService _codeGen;
private readonly OssStorageService _oss;
private readonly string _baseUrl;
private readonly string _uploadRoot;
private readonly ILogger<OpenApiController> _logger;
public OpenApiController(IFreeSql fsql, CodeGeneratorService codeGen, OssStorageService oss,
IConfiguration cfg, ILogger<OpenApiController> logger)
{
_fsql = fsql;
_codeGen = codeGen;
_oss = oss;
_baseUrl = cfg["App:BaseUrl"] ?? "";
_uploadRoot = Path.GetFullPath(cfg["OpenApi:UploadRoot"] ?? throw new InvalidOperationException("缺少配置 OpenApi:UploadRoot"));
_logger = logger;
}
/// <summary>公开上传-共享:expiresHours0=永久,缺省 24);pwd/tag 可同时提供(不互斥)</summary>
[HttpPost("upload-public")]
public Task<IActionResult> UploadPublic([FromBody] OpenUploadRequest req)
=> UploadFromPathAsync(req.FilePath, req.ExpiresHours, req.Pwd, req.Tag);
/// <summary>公开上传-私密:必填密码,可同时带标签</summary>
[HttpPost("upload-price")]
public Task<IActionResult> UploadPrice([FromBody] OpenUploadPriceRequest req)
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
/// <summary>公开上传-标签:必填标签(永久保存),可同时带密码</summary>
[HttpPost("upload-tag")]
public Task<IActionResult> UploadTag([FromBody] OpenUploadTagRequest req)
=> UploadFromPathAsync(req.FilePath, null, req.Pwd, req.Tag);
/// <summary>公开下载链接查询:共享/标签返回直接下载 URL,私密跳取件页手动输密码</summary>
[HttpGet("download/{pickCode}")]
public async Task<IActionResult> Download(string pickCode)
{
if (pickCode.Length is not (6 or 8) || !pickCode.All(char.IsDigit))
{
return NotFound(new { message = "取件码无效" });
}
var item = await _fsql.Select<FileItem>().Where(f => f.PickCode == pickCode).FirstAsync();
if (item == null)
{
return NotFound(new { message = "取件码无效" });
}
if (!item.IsPermanent && item.ExpiresAt != null && item.ExpiresAt < DateTime.Now)
{
return StatusCode(410, new { message = "文件已过期" });
}
var needPassword = item.FileType == "private";
return Ok(new OpenDownloadResult
{
PickUrl = BuildPickUrl(pickCode),
DownloadUrl = needPassword ? BuildPickUrl(pickCode) : BuildDownloadUrl(pickCode),
NeedPassword = needPassword
});
}
// ---------- 私有 ----------
private async Task<IActionResult> UploadFromPathAsync(string filePath, long? expiresHours, string? pwd, string? tag)
{
var fullPath = ResolveSafePath(filePath);
if (fullPath == null)
{
return BadRequest(new { message = "文件路径不在白名单目录内" });
}
if (!System.IO.File.Exists(fullPath))
{
return NotFound(new { message = "文件不存在" });
}
var fi = new FileInfo(fullPath);
if (fi.Length > FileRules.MaxFileBytes)
{
return BadRequest(new { message = "文件超过 200MB 限制" });
}
var pwdErr = FileRules.ValidatePassword(pwd);
if (pwdErr != null) return BadRequest(new { message = pwdErr });
tag = string.IsNullOrWhiteSpace(tag) ? null : tag.Trim();
var tagErr = FileRules.ValidateTag(tag);
if (tagErr != null) return BadRequest(new { message = tagErr });
var hasPassword = !string.IsNullOrEmpty(pwd);
var hasTag = !string.IsNullOrEmpty(tag);
var isPermanent = hasTag || expiresHours == 0;
DateTime? expiresAt = isPermanent ? null : DateTime.Now.AddHours(expiresHours ?? 24);
var fileType = FileRules.ResolveFileType(hasPassword, hasTag);
var objectKey = _oss.BuildObjectKey(fi.Name);
var ip = HttpContext.Connection.RemoteIpAddress?.ToString();
try
{
await using var stream = System.IO.File.OpenRead(fullPath);
await _oss.UploadAsync(objectKey, stream, "application/octet-stream");
}
catch (Exception ex)
{
_logger.LogError(ex, "公开上传 OSS 失败:{Path}", fullPath);
return StatusCode(500, new { message = "文件上传失败" });
}
var item = new FileItem
{
PickCode = await _codeGen.GeneratePickCodeAsync(FileRules.PickCodeLength(fileType)),
AdminCode = await _codeGen.GenerateAdminCodeAsync(),
FileType = fileType,
Password = hasPassword ? pwd : null,
Tag = hasTag ? tag : null,
OriginalName = fi.Name,
ObjectKey = objectKey,
Size = fi.Length,
MimeType = "application/octet-stream",
DownloadCount = 0,
UploadIp = ip,
IsPermanent = isPermanent,
ExpiresAt = expiresAt,
CreatedAt = DateTime.Now
};
await _fsql.Insert(item).ExecuteAffrowsAsync();
return Ok(new OpenResult
{
PickCode = item.PickCode,
PickUrl = BuildPickUrl(item.PickCode),
DownloadUrl = fileType == "private" ? BuildPickUrl(item.PickCode) : BuildDownloadUrl(item.PickCode)
});
}
/// <summary>路径规范化 + 白名单前缀校验,防路径穿越</summary>
private string? ResolveSafePath(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath)) return null;
var full = Path.GetFullPath(filePath);
if (!full.StartsWith(_uploadRoot, StringComparison.OrdinalIgnoreCase)) return null;
return full;
}
private string BuildPickUrl(string pickCode) => $"{_baseUrl}/#/pickup?code={pickCode}";
private string BuildDownloadUrl(string pickCode) => $"{_baseUrl}/api/files/{pickCode}/download";
}
@@ -0,0 +1,22 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>文件信息 / 管理列表 / 标签列表 DTO(不暴露密码明文)</summary>
public class FileInfoDto
{
public long Id { get; set; }
public string PickCode { get; set; } = null!;
public string FileType { get; set; } = null!;
public bool HasPassword { get; set; }
public string OriginalName { get; set; } = null!;
public long Size { get; set; }
public string? MimeType { get; set; }
public int DownloadCount { get; set; }
/// <summary>上传者 IP(安全审计):仅管理列表填充,取件/标签/公开接口不暴露</summary>
public string? UploadIp { get; set; }
public bool IsPermanent { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>剩余有效期文本(永久/已过期/N天N小时)</summary>
public string ExpireText { get; set; } = "永久";
}
@@ -0,0 +1,49 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>公开上传-共享文件:有效期小时数(0=永久,缺省 24),pwd/tag 可同时提供(不互斥)</summary>
public class OpenUploadRequest
{
public string FilePath { get; set; } = null!;
public long ExpiresHours { get; set; } = 24;
public string? Pwd { get; set; }
public string? Tag { get; set; }
}
/// <summary>公开上传-私密文件:必须有密码,可同时带标签</summary>
public class OpenUploadPriceRequest
{
public string FilePath { get; set; } = null!;
public string Pwd { get; set; } = null!;
public string? Tag { get; set; }
}
/// <summary>公开上传-标签文件:必须带标签(永久保存),可同时带密码</summary>
public class OpenUploadTagRequest
{
public string FilePath { get; set; } = null!;
public string Tag { get; set; } = null!;
public string? Pwd { get; set; }
}
/// <summary>公开上传响应</summary>
public class OpenResult
{
public string PickCode { get; set; } = null!;
public string PickUrl { get; set; } = null!;
public string DownloadUrl { get; set; } = null!;
}
/// <summary>公开下载链接查询响应</summary>
public class OpenDownloadResult
{
public string DownloadUrl { get; set; } = null!;
public string PickUrl { get; set; } = null!;
public bool NeedPassword { get; set; }
}
/// <summary>管理删除请求体:按 Id 或取件码删除</summary>
public class DeleteRequest
{
public long? Id { get; set; }
public string? PickCode { get; set; }
}
@@ -0,0 +1,17 @@
namespace WenChuanyi.Api.Dtos;
/// <summary>上传响应:取件凭证 + 管理码 + 文件信息</summary>
public class UploadResultDto
{
public long Id { get; set; }
public string PickCode { get; set; } = null!;
public string AdminCode { get; set; } = null!;
public string FileType { get; set; } = null!;
public string OriginalName { get; set; } = null!;
public long Size { get; set; }
public bool IsPermanent { get; set; }
public DateTime? ExpiresAt { get; set; }
/// <summary>取件页链接(二维码内容)</summary>
public string PickUrl { get; set; } = null!;
}
+63
View File
@@ -0,0 +1,63 @@
using FreeSql.DataAnnotations;
namespace WenChuanyi.Api.Models;
/// <summary>文件记录(files 表,FreeSql CodeFirst 自动建表)</summary>
[Table(Name = "files")]
[Index("uk_pickcode", nameof(PickCode), true)]
[Index("uk_admincode", nameof(AdminCode), true)]
[Index("idx_tag", nameof(Tag))]
public class FileItem
{
[Column(IsPrimary = true, IsIdentity = true)]
public long Id { get; set; }
/// <summary>取件码(共享 8 位 / 私密 6 位),唯一索引</summary>
[Column(StringLength = 8)]
public string PickCode { get; set; } = null!;
/// <summary>管理码 8 位字母数字,唯一索引</summary>
[Column(StringLength = 8)]
public string AdminCode { get; set; } = null!;
/// <summary>standard(无密码无标签)/ private(有密码)/ tagged(仅标签)</summary>
[Column(StringLength = 10)]
public string FileType { get; set; } = null!;
/// <summary>私密文件 4-12 位密码(可同时设置标签,不互斥)</summary>
[Column(StringLength = 12)]
public string? Password { get; set; }
/// <summary>标签(仅英文+数字),普通索引(非唯一,一对多),可与密码同时设置</summary>
[Column(StringLength = 32)]
public string? Tag { get; set; }
/// <summary>原始文件名(下载展示)</summary>
[Column(StringLength = 255)]
public string OriginalName { get; set; } = null!;
/// <summary>OSS 对象键:文传易2026/{yyyyMM}/{Guid}{ext}</summary>
[Column(StringLength = 255)]
public string ObjectKey { get; set; } = null!;
/// <summary>字节数</summary>
public long Size { get; set; }
[Column(StringLength = 100)]
public string? MimeType { get; set; }
/// <summary>下载次数(仅统计、不限制)</summary>
public int DownloadCount { get; set; }
/// <summary>上传者 IPIPv4/IPv6,安全审计,仅管理列表展示)</summary>
[Column(StringLength = 45)]
public string? UploadIp { get; set; }
/// <summary>含标签(无论是否设密码)强制 true(永久保存,不自动过期)</summary>
public bool IsPermanent { get; set; }
/// <summary>过期时间(IsPermanent=true 时为 null</summary>
public DateTime? ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; }
}
+49
View File
@@ -0,0 +1,49 @@
using System.Text;
using FreeSql;
using WenChuanyi.Api.Models;
using WenChuanyi.Api.Services;
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// CORS:开发期前后端分离宽松策略;生产同目录同源不受影响
builder.Services.AddCors(o => o.AddPolicy("any", p => p.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
// FreeSql + CodeFirst 自动建表
var fsql = new FreeSqlBuilder()
.UseConnectionString(DataType.MySql, builder.Configuration.GetConnectionString("MySql")!)
.UseAutoSyncStructure(true)
.UseNoneCommandParameter(true)
.Build();
try
{
fsql.CodeFirst.SyncStructure<FileItem>();
}
catch (Exception ex)
{
Console.WriteLine("[文传易] 数据库初始化失败:" + ex.Message);
Console.WriteLine("[文传易] 请检查 appsettings.json 的 ConnectionStrings:MySql");
Console.WriteLine("[文传易] (服务器 116.198.221.125:3306,账号 wenchuanyi 需具备建表权限)");
throw;
}
builder.Services.AddSingleton<IFreeSql>(fsql);
builder.Services.AddSingleton<CodeGeneratorService>();
builder.Services.AddSingleton<OssStorageService>();
builder.Services.AddHostedService<ExpiredFileCleanerService>();
var app = builder.Build();
app.UseCors("any");
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapControllers();
app.MapFallbackToFile("index.html");
app.Run();
@@ -0,0 +1,31 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:6294",
"sslPort": 0
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "weatherforecast",
"applicationUrl": "http://localhost:5039",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "weatherforecast",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,57 @@
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>取件码/管理码生成与查重(冲突自动重生成)</summary>
public class CodeGeneratorService
{
// 排除易混淆 0/O/1/I/l
private const string AdminChars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
private readonly IFreeSql _fsql;
public CodeGeneratorService(IFreeSql fsql) => _fsql = fsql;
/// <summary>生成 length 位纯数字取件码(全局唯一)</summary>
public async Task<string> GeneratePickCodeAsync(int length)
{
for (var i = 0; i < 30; i++)
{
var code = GenerateDigits(length);
var exists = await _fsql.Select<FileItem>().Where(f => f.PickCode == code).AnyAsync();
if (!exists)
{
return code;
}
}
throw new InvalidOperationException("取件码生成冲突次数过多,请重试");
}
/// <summary>生成 8 位字母数字管理码(全局唯一)</summary>
public async Task<string> GenerateAdminCodeAsync()
{
for (var i = 0; i < 30; i++)
{
var code = new string(Enumerable.Range(0, 8)
.Select(_ => AdminChars[Random.Shared.Next(AdminChars.Length)]).ToArray());
var exists = await _fsql.Select<FileItem>().Where(f => f.AdminCode == code).AnyAsync();
if (!exists)
{
return code;
}
}
throw new InvalidOperationException("管理码生成冲突次数过多,请重试");
}
private static string GenerateDigits(int length)
{
var first = Random.Shared.Next(1, 10); // 首位非 0
var sb = new System.Text.StringBuilder();
sb.Append(first);
for (var i = 1; i < length; i++)
{
sb.Append(Random.Shared.Next(0, 10));
}
return sb.ToString();
}
}
@@ -0,0 +1,67 @@
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>过期文件定时清理:每 N 分钟扫描 IsPermanent=false 且已过期的记录,先删记录再删 OSS 对象</summary>
public class ExpiredFileCleanerService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ExpiredFileCleanerService> _logger;
private readonly TimeSpan _interval;
public ExpiredFileCleanerService(
IServiceScopeFactory scopeFactory,
IConfiguration cfg,
ILogger<ExpiredFileCleanerService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
var minutes = cfg.GetValue<int>("Cleaner:IntervalMinutes", 30);
_interval = TimeSpan.FromMinutes(Math.Max(1, minutes));
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("过期文件清理任务启动,间隔 {Minutes} 分钟", _interval.TotalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CleanExpiredAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "过期文件清理任务异常");
}
await Task.Delay(_interval, stoppingToken);
}
}
private async Task CleanExpiredAsync(CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var fsql = scope.ServiceProvider.GetRequiredService<IFreeSql>();
var oss = scope.ServiceProvider.GetRequiredService<OssStorageService>();
var expired = await fsql.Select<FileItem>()
.Where(f => !f.IsPermanent && f.ExpiresAt != null && f.ExpiresAt < DateTime.Now)
.ToListAsync(ct);
if (expired.Count == 0)
{
return;
}
_logger.LogInformation("发现 {Count} 个过期文件,开始清理", expired.Count);
foreach (var item in expired)
{
// 先删数据库记录
var deleted = await fsql.Delete<FileItem>().Where(f => f.Id == item.Id).ExecuteAffrowsAsync(ct) > 0;
// 再删 OSS 对象(失败仅记日志,不阻塞任务)
if (deleted)
{
oss.TryDeleteObject(item.ObjectKey);
}
}
}
}
@@ -0,0 +1,108 @@
using System.Text.RegularExpressions;
using WenChuanyi.Api.Models;
namespace WenChuanyi.Api.Services;
/// <summary>业务规则共享静态工具:密码/标签校验、模式判定、预览分类</summary>
public static class FileRules
{
public const long MaxFileBytes = 200L * 1024 * 1024; // 200MB
public const long MaxTextPreview = 2L * 1024 * 1024; // 文本预览 2MB
public const long MaxPdfImagePreview = 30L * 1024 * 1024; // PDF/图片预览 30MB
/// <summary>清洗文件名:去除 Windows 非法字符/控制字符、首尾空格与点、保留名与超长限制,防止存储路径注入</summary>
public static string SanitizeFileName(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "未命名";
var dot = name.LastIndexOf('.');
var ext = dot >= 0 ? name[(dot + 1)..] : "";
var baseName = dot >= 0 ? name[..dot] : name;
var cleanBase = new string(baseName
.Where(c => c >= 32 && c != 127 && c != '<' && c != '>' && c != ':' && c != '"'
&& c != '/' && c != '\\' && c != '|' && c != '?' && c != '*')
.ToArray()).Trim().TrimEnd('.', ' ');
var result = string.IsNullOrEmpty(cleanBase) ? "未命名" : cleanBase;
// Windows 保留设备名:CON PRN AUX NUL COM1-9 LPT1-9(含扩展名也禁止)
var upperBase = result.Split('.')[0].ToUpperInvariant();
if (upperBase is "CON" or "PRN" or "AUX" or "NUL"
|| Regex.IsMatch(upperBase, @"^COM[1-9]$") || Regex.IsMatch(upperBase, @"^LPT[1-9]$"))
{
result = "_" + result;
}
// 长度限制(保留扩展名,主体最长 180 字符)
if (result.Length > 180) result = result[..180];
// 扩展名仅保留字母/数字/点,最长 10 字符
var extRaw = new string(ext.Where(c => char.IsLetterOrDigit(c) || c == '.').ToArray()).TrimStart('.');
var cleanExt = extRaw.Length > 0 ? "." + (extRaw.Length > 10 ? extRaw[..10] : extRaw) : "";
return result + cleanExt;
}
/// <summary>校验密码(4-12 位),null 表示通过</summary>
public static string? ValidatePassword(string? pwd)
{
if (string.IsNullOrEmpty(pwd)) return null;
if (pwd.Length < 4 || pwd.Length > 12)
{
return "密码长度须为 4-12 位";
}
return null;
}
/// <summary>校验标签:仅英文+数字;纯数字须 &gt;8 位;含英文须 &gt;4 位;null 表示通过</summary>
public static string? ValidateTag(string? tag)
{
if (string.IsNullOrEmpty(tag)) return null;
if (!Regex.IsMatch(tag, "^[A-Za-z0-9]+$"))
{
return "标签仅允许英文与数字,不能包含符号或空格";
}
if (Regex.IsMatch(tag, "^[0-9]+$"))
{
if (tag.Length <= 8)
{
return "纯数字标签须超过 8 位(建议使用 11 位手机号码)";
}
}
else if (tag.Length <= 4)
{
return "含英文字母的标签须超过 4 位";
}
return null;
}
/// <summary>模式判定:有密码→private;仅标签→tagged;否则 standard</summary>
public static string ResolveFileType(bool hasPassword, bool hasTag)
=> hasPassword ? "private" : hasTag ? "tagged" : "standard";
/// <summary>取件码位数:私密 6 位,其余 8 位</summary>
public static int PickCodeLength(string fileType) => fileType == "private" ? 6 : 8;
/// <summary>剩余有效期文本</summary>
public static string ExpireText(FileItem f)
{
if (f.IsPermanent || f.ExpiresAt == null) return "永久";
var remain = f.ExpiresAt.Value - DateTime.Now;
if (remain <= TimeSpan.Zero) return "已过期";
if (remain.TotalDays >= 1) return $"{(int)remain.TotalDays} 天";
if (remain.TotalHours >= 1) return $"{(int)remain.TotalHours} 小时";
return $"{(int)Math.Max(1, remain.TotalMinutes)} 分钟";
}
public static bool IsText(string ext)
=> ext is ".txt" or ".md" or ".xml" or ".json" or ".csv" or ".log" or ".ini" or ".conf"
or ".srt" or ".html" or ".htm" or ".css" or ".js" or ".ts" or ".cs" or ".java" or ".sql"
or ".yml" or ".yaml" or ".sh" or ".bat" or ".ps1" or ".py" or ".go" or ".c" or ".h" or ".php";
public static bool IsPdfOrImage(string mime)
=> mime is "application/pdf"
or "image/png" or "image/jpeg" or "image/gif" or "image/webp" or "image/bmp" or "image/svg+xml";
public static bool IsVideoWhitelist(string ext) => ext is ".mp4" or ".webm";
public static bool IsAudioWhitelist(string ext) => ext is ".mp3" or ".wav" or ".m4a" or ".aac" or ".ogg";
}
@@ -0,0 +1,68 @@
using Aliyun.OSS;
namespace WenChuanyi.Api.Services;
/// <summary>OSS 存储服务:对象键构造、流式上传/下载/删除(后端中转,前端不接触凭据)</summary>
public class OssStorageService
{
private readonly OssClient _client;
private readonly string _bucket;
private readonly string _baseKey;
private readonly ILogger<OssStorageService> _logger;
public OssStorageService(IConfiguration cfg, ILogger<OssStorageService> logger)
{
var endpoint = cfg["Oss:Endpoint"] ?? throw new InvalidOperationException("缺少配置 Oss:Endpoint");
var ak = cfg["Oss:AccessKeyId"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeyId");
var sk = cfg["Oss:AccessKeySecret"] ?? throw new InvalidOperationException("缺少配置 Oss:AccessKeySecret");
_bucket = cfg["Oss:Bucket"] ?? throw new InvalidOperationException("缺少配置 Oss:Bucket");
_baseKey = cfg["Oss:BaseKey"] ?? "文传易2026";
_client = new OssClient(endpoint, ak, sk);
_logger = logger;
}
/// <summary>构造对象键:文传易2026/{yyyyMM}/{Guid}{ext}(按上传月份建子目录)</summary>
public string BuildObjectKey(string originalName)
{
var month = DateTime.Now.ToString("yyyyMM");
var ext = Path.GetExtension(originalName).ToLowerInvariant();
return $"{_baseKey}/{month}/{Guid.NewGuid():N}{ext}";
}
/// <summary>流式上传(不落本地、不整体读内存)。ContentType 会去除参数部分(如 ";charset=utf-8"),
/// 因为 OSS SDK 对带参数的 Content-Type 签名会与服务端校验不一致,导致 403 签名不匹配。</summary>
public async Task UploadAsync(string objectKey, Stream stream, string contentType)
{
var safeContentType = (contentType ?? "application/octet-stream").Split(';')[0].Trim();
if (string.IsNullOrEmpty(safeContentType)) safeContentType = "application/octet-stream";
var req = new PutObjectRequest(_bucket, objectKey, stream);
req.Metadata = new ObjectMetadata { ContentType = safeContentType };
await Task.Run(() => _client.PutObject(req)).ConfigureAwait(false);
}
/// <summary>获取对象(支持 Rangeend 为 -1 表示到文件末尾)</summary>
public OssObject GetObject(string objectKey, long? start = null, long? end = null)
{
var req = new GetObjectRequest(_bucket, objectKey);
if (start.HasValue || end.HasValue)
{
req.SetRange(start ?? 0, end ?? -1);
}
return _client.GetObject(req);
}
/// <summary>删除对象(失败不抛出,由调用方记录日志)</summary>
public bool TryDeleteObject(string objectKey)
{
try
{
_client.DeleteObject(_bucket, objectKey);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "OSS 对象删除失败:{ObjectKey}", objectKey);
return false;
}
}
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
</ItemGroup>
</Project>
@@ -0,0 +1,6 @@
@WenChuanyi.Api_HostAddress = http://localhost:5039
GET {{WenChuanyi.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,39 @@
{
"App": {
"BaseUrl": "https://wenchuanyi.bbitcn.net"
},
"ConnectionStrings": {
"MySql": "Data Source=116.198.221.125;Port=3306;User ID=wenchuanyi;Password=YOUR_DB_PASSWORD;Initial Catalog=wenchuanyi;Charset=utf8mb4;SslMode=None;Allow User Variables=true;Treat Tiny As Boolean=true"
},
"Oss": {
"Endpoint": "oss-cn-chengdu.aliyuncs.com",
"STSEndpoint": "sts.cn-chengdu.aliyuncs.com",
"Bucket": "bbit-f8-web",
"AccessKeyId": "YOUR_ACCESS_KEY_ID",
"AccessKeySecret": "YOUR_ACCESS_KEY_SECRET",
"BaseKey": "文传易2026"
},
"OpenApi": {
"UploadRoot": "D:\\WenChuanyi\\UploadRoot"
},
"Cleaner": {
"IntervalMinutes": 30
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:5280"
}
},
"Limits": {
"MaxRequestBodySize": 220200960
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
+163
View File
@@ -0,0 +1,163 @@
# 文传易 · IIS 部署与 FTP 发布手册
> 目标环境:Windows Server + IIS 10 + .NET 8 Hosting Bundle
> 站点地址:`https://wenchuanyi.bbitcn.net`
> 发布方式:FTP`ftp://116.198.221.125`,默认端口 21,用户 `wenchuanyi`
> **当前状态(2026-08-23)**:本地全流程已通过冒烟测试——
> 上传(共享/私密/标签,含 200MB 拦截)→ 二维码与取件码 → 取件查询/下载(计数)→ 标签列表 → 管理列表(含上传 IP)/删除,均已验证;数据库表已建、OSS 真实凭据已写入 `appsettings.json`(该文件不入库)。服务器上首次发布后,建议用手机微信扫码访问正式站再做一轮真机验证(含微信聊天记录选文件)。
---
## 1. 部署架构
```
浏览器
│ https://wenchuanyi.bbitcn.net
IIS 站点(物理路径 = 站点根目录)
├── index.html / assets/* ← 前端 dist 构建产物
├── web.config ← 由 dotnet publish 自动生成(ANCM InProcess
├── WenChuanyi.Api.dll ← 后端发布输出
└── appsettings.json ← 数据库连接串 / OSS 凭据 / 上传限制
```
- 前端静态资源与后端发布输出**放在同一 IIS 站点物理路径**(默认文件夹或 `wwwroot`)。
- 后端 `UseStaticFiles` 提供前端静态文件,`MapFallbackToFile("index.html")` 兜底 SPA 路由;
`/api``/api/open` 由 ASP.NET Core ModuleANCM)直接处理。
- 站点根目录布局见上;后端发布产物全部文件与前端 `dist/` 内容合并进同一目录。
---
## 2. 服务器一次性准备(首次部署前)
1. **安装 .NET 8 Hosting Bundle**
- 下载:https://dotnet.microsoft.com/download/dotnet/8.0(选择 **Hosting Bundle**
- 安装后 IIS 中会出现 **ASP.NET Core Module v2**
2. **创建 IIS 站点**
- IIS → 右键「网站」→ 添加网站;
- 站点名称:`wenchuanyi`
- 物理路径:`D:\wenchuanyi`(或任意磁盘,站点根目录);
- 端口 80(HTTP)→ 后续绑定 443 + 证书,域名 `wenchuanyi.bbitcn.net`
- 应用程序池:.NET CLR 版本选「**无托管代码**」(InProcess 托管由 ANCM 处理)。
3. **HTTPS 证书**:为 `wenchuanyi.bbitcn.net` 绑定 SSL 证书(企业已有证书或申请免费证书)。
4. **防火墙**:放行 80 / 443(及测试期 5280)。
> 排障:若站点 502.5 / 500.30,临时开启 web.config 中的 `stdoutLogEnabled="true"` 查看 `stdoutLog` 输出。
---
## 3. 构建发布包(开发机执行)
### 3.1 后端
```powershell
cd backend/WenChuanyi.Api
dotnet publish -c Release
```
- 输出目录:`bin/Release/net8.0/publish/`
- Framework-dependent 模式,web.config 自动生成(`hostingModel="inprocess"`)。
- 发布前确认 `appsettings.json` 已配置真实凭据(见第 4 节)。
### 3.2 前端
```powershell
cd frontend
npm install # 首次
npm run build
```
- 输出目录:`frontend/dist/`index.html + assets/*)。
- 构建目标 ES2018,兼容微信 X5 内核。
---
## 4. 敏感配置(appsettings.json
`backend/WenChuanyi.Api/appsettings.json` 中需填写真实值,**该文件已在 .gitignore 中,不会入库**
| 配置节 | 键 | 说明 |
| --- | --- | --- |
| `ConnectionStrings:MySql` | Password | 数据库密码(`r7P^f*v7rFts` |
| `Oss` | AccessKeyId | 阿里云 OSS AK |
| `Oss` | AccessKeySecret | 阿里云 OSS SK |
| `OpenApi` | UploadRoot | 公开 API 可读取的服务端目录白名单 |
| `App` | BaseUrl | 站点地址,用于生成取件页链接(默认 `https://wenchuanyi.bbitcn.net` |
- 连接串密码含 `^``*`,不含 `;` / `=`,无需特殊转义,JSON 原样写入即可。
- 模板参考:`appsettings.example.json`(占位符 `YOUR_DB_PASSWORD` / `YOUR_ACCESS_KEY_ID` / `YOUR_ACCESS_KEY_SECRET`)。
---
## 5. FTP 发布
### 5.1 手动发布
用任意 FTP 客户端(FileZilla / WinSCP 等,**被动模式**)登录:
- 主机:`116.198.221.125`
- 端口:`21`
- 用户:`wenchuanyi`
- 密码:同数据库密码
上传清单(目标 = IIS 站点根目录,如 `D:\wenchuanyi`):
```
前端 dist/ 全部文件 → 站点根目录
后端 bin/Release/net8.0/publish/* → 站点根目录(覆盖)
```
> 注意:web.config、appsettings.json 等必须位于站点根目录,勿放入子文件夹。
### 5.2 一键脚本发布
已提供 `deploy/publish.ps1`,一条命令完成「后端 publish + 前端 build + FTP 上传」:
```powershell
powershell -ExecutionPolicy Bypass -File deploy/publish.ps1
```
### 5.3 发布后生效
- FTP 上传完成后,**回收应用程序池**使新版本生效:
- IIS 管理器 → 应用程序池 → `wenchuanyi` → 右键「回收」;
- 或命令行:`C:\Windows\System32\inetsrv\appcmd recycle apppool /apppool.name:wenchuanyi`
- 浏览器验证 `https://wenchuanyi.bbitcn.net`
1. 首页正常显示;
2. `/#/pickup``/#/admin` 路由可访问(hash 路由不受 IIS 影响);
3. 上传一个测试文件 → 得到取件码 → 取件页下载成功 → 管理码列表可见。
---
## 6. 验证清单
| 项 | 验证方式 | 预期 |
| --- | --- | --- |
| 首页静态资源 | 访问 `/` | 页面正常渲染,无 404 |
| SPA 路由 | 访问 `/#/pickup` | 取件页正常(hash 路由不受 IIS 影响) |
| API | `GET /api/open/download/000000` | 返回 JSON(凭证不存在提示),非 404 |
| 上传下载 | 上传 → 取件 → 下载 | 全流程通过,文件名还原 |
| 在线预览 | 文本/PDF/图片/音视频 | 预览正常 |
| 清理任务 | 查看日志 | 每 30 分钟扫描,无异常报错 |
---
## 7. 常见问题
| 现象 | 原因与处理 |
| --- | --- |
| 502.5 / 500.30 | Hosting Bundle 未装或版本不匹配;或 appsettings.json 语法错误。检查事件查看器与 stdoutLog。 |
| 403.14 | 站点根目录无默认文档且 ANCM 未接管——确认 web.config 在站点根目录。 |
| 404 静态资源 | 前端产物未上传到站点根目录,或未启用 `UseStaticFiles`。 |
| API 连接 MySQL 失败 | 服务器 3306 未放行 / 白名单未加服务器出口 IP。 |
| 上传 OSS 失败 | OSS AK/SK 未填或权限不足;确认 Bucket `bbit-f8-web` 有 Put/Get/Delete 权限。 |
| 修改不生效 | 上传后未回收应用程序池。 |
---
## 8. 安全提示
- `appsettings.json` 含数据库密码与 OSS 凭据,**不要上传到代码仓库 / 不要外传**(已在 .gitignore)。
- FTP 凭据仅运维持有,建议定期轮换。
- 日志不会打印 OSS 凭据、数据库密码与文件内容明文。
+20
View File
@@ -0,0 +1,20 @@
# 强制覆盖单个文件 (UTF-8 BOM)
param([string]$LocalPath, [string]$RemotePath)
$FtpHost="116.198.221.125"
$FtpPort=21
$FtpUser="wenchuanyi"
$FtpPass="r7P^f*v7rFts"
$r=[System.Net.FtpWebRequest]::Create("ftp://${FtpHost}:${FtpPort}/"+$RemotePath.TrimStart('/'))
$r.Method=[System.Net.WebRequestMethods+Ftp]::UploadFile
$r.Credentials=New-Object System.Net.NetworkCredential($FtpUser,$FtpPass)
$r.UsePassive=$true
$r.UseBinary=$true
$r.KeepAlive=$false
$r.Timeout=120000
$b=[System.IO.File]::ReadAllBytes($LocalPath)
$r.ContentLength=$b.Length
$s=$r.GetRequestStream()
try{ $s.Write($b,0,$b.Length) }finally{ $s.Close() }
$resp=$r.GetResponse()
try{ $resp.Close() }catch{}
Write-Host "FORCED_UPLOAD_OK $RemotePath"
+59
View File
@@ -0,0 +1,59 @@
# FTP 上传脚本 (UTF-8 BOM) - 健壮版
$FtpHost="116.198.221.125"
$FtpPort=21
$FtpUser="wenchuanyi"
$FtpPass="r7P^f*v7rFts"
$RemoteRoot="/"
$Stage = Join-Path $env:TEMP "wcy_publish_staging"
function New-FtpReq([string]$path,[string]$method){
$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=120000
return $r
}
function MkDir([string]$p){
$r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::MakeDirectory)
try{ $null=$r.GetResponse() }catch{}
}
function Exist([string]$p){
$r=New-FtpReq $p ([System.Net.WebRequestMethods+Ftp]::GetDateTimestamp)
try{ $null=$r.GetResponse(); return $true }catch{ return $false }
}
function UpFile([string]$lp,[string]$rp){
$r=New-FtpReq $rp ([System.Net.WebRequestMethods+Ftp]::UploadFile)
$b=[System.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{}
}
function UpDir([string]$ld,[string]$rd){
foreach($d in Get-ChildItem $ld -Directory){
$c="$rd/$($d.Name)"
if(-not (Exist $c)){ MkDir $c }
UpDir $d.FullName $c
}
foreach($f in Get-ChildItem $ld -File){
$rf="$rd/$($f.Name)"
try{
if(-not (Exist $rf)){ UpFile $f.FullName $rf; Write-Host "UP $rf" }
else { Write-Host "SKIP(exists) $rf" }
}catch{
Write-Host "RETRY $rf"
try{ UpFile $f.FullName $rf; Write-Host "OK $rf" }catch{ Write-Host "FAIL $rf : $_" }
}
}
}
if(-not (Test-Path $Stage)){ Write-Host "STAGING_MISSING"; exit 1 }
if(-not (Exist $RemoteRoot)){ MkDir $RemoteRoot }
UpDir $Stage $RemoteRoot
Write-Host "FTP_UPLOAD_DONE"
Remove-Item $Stage -Recurse -Force
+119
View File
@@ -0,0 +1,119 @@
# ============================================================
# 文传易 一键发布脚本
# 1. dotnet publish 后端 (Release, net8.0)
# 2. npm run build 前端 (Vite, dist)
# 3. FTP 上传合并产物至 IIS 站点根目录
#
# 用法:
# powershell -ExecutionPolicy Bypass -File deploy/publish.ps1
#
# 前置: 本机已安装 .NET 8 SDK / Node.js >=18; FTP 凭据见下方变量
# ============================================================
param(
[string]$FtpHost = "116.198.221.125",
[int] $FtpPort = 21,
[string]$FtpUser = "wenchuanyi",
[string]$FtpPass = "r7P^f*v7rFts",
[string]$RemoteRoot = "/", # FTP 远端目标目录 (IIS 站点根目录)
[switch]$SkipBuild # 跳过构建, 仅上传已有产物
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $PSScriptRoot
$BackendDir = Join-Path $Root "backend\WenChuanyi.Api"
$FrontendDir = Join-Path $Root "frontend"
$PublishDir = Join-Path $BackendDir "bin\Release\net8.0\publish"
$DistDir = Join-Path $FrontendDir "dist"
$StagingDir = Join-Path $env:TEMP "wcy_publish_staging"
# ---------------- 1. 构建 ----------------
if (-not $SkipBuild) {
Write-Host "==> [1/3] 构建后端..." -ForegroundColor Cyan
Push-Location $BackendDir
try { dotnet publish -c Release --nologo -v q }
finally { Pop-Location }
Write-Host "==> [2/3] 构建前端..." -ForegroundColor Cyan
if (-not (Test-Path (Join-Path $FrontendDir "node_modules"))) {
Write-Host " 首次构建, 安装 npm 依赖..." -ForegroundColor Yellow
Push-Location $FrontendDir
try { npm install --no-audit --no-fund }
finally { Pop-Location }
}
Push-Location $FrontendDir
try { npm run build }
finally { Pop-Location }
} else {
Write-Host "==> [1-2/3] 跳过构建 (SkipBuild)..." -ForegroundColor Yellow
}
if (-not (Test-Path $PublishDir)) { throw "后端发布目录不存在: $PublishDir (请先 dotnet publish)" }
if (-not (Test-Path $DistDir)) { throw "前端产物目录不存在: $DistDir (请先 npm run build)" }
# ---------------- 2. 合并产物到临时目录 ----------------
Write-Host "==> [3/3] 合并产物并 FTP 上传..." -ForegroundColor Cyan
if (Test-Path $StagingDir) { Remove-Item $StagingDir -Recurse -Force }
New-Item -ItemType Directory -Path $StagingDir -Force | Out-Null
Copy-Item "$DistDir\*" $StagingDir -Recurse -Force
Copy-Item "$PublishDir\*" $StagingDir -Recurse -Force
Write-Host " 产物合计: $((Get-ChildItem $StagingDir -File -Recurse | Measure-Object).Count) 个文件" -ForegroundColor Gray
# ---------------- FTP 工具函数 ----------------
function New-FtpRequest([string]$path, [string]$method) {
$uri = "ftp://${FtpHost}:${FtpPort}/" + $path.TrimStart('/')
$req = [System.Net.FtpWebRequest]::Create($uri)
$req.Method = $method
$req.Credentials = New-Object System.Net.NetworkCredential($FtpUser, $FtpPass)
$req.UsePassive = $true
$req.UseBinary = $true
$req.KeepAlive = $false
return $req
}
function New-FtpDirectory([string]$remotePath) {
$req = New-FtpRequest $remotePath ([System.Net.WebRequestMethods+Ftp]::MakeDirectory)
try { $null = $req.GetResponse() }
catch { } # 目录已存在时报错, 忽略
}
function Send-FtpFile([string]$localPath, [string]$remotePath) {
$req = New-FtpRequest $remotePath ([System.Net.WebRequestMethods+Ftp]::UploadFile)
$bytes = [System.IO.File]::ReadAllBytes($localPath)
$req.ContentLength = $bytes.Length
$stream = $req.GetRequestStream()
try {
$stream.Write($bytes, 0, $bytes.Length)
} finally {
$stream.Close()
}
$resp = $req.GetResponse()
try { $resp.Close() } catch { }
}
function Send-FtpDirectory([string]$localDir, [string]$remoteDir) {
foreach ($dir in Get-ChildItem $localDir -Directory) {
$child = "$remoteDir/$($dir.Name)"
New-FtpDirectory $child
Send-FtpDirectory $dir.FullName $child
}
foreach ($file in Get-ChildItem $localDir -File) {
$remoteFile = "$remoteDir/$($file.Name)"
Send-FtpFile $file.FullName $remoteFile
Write-Host " UP $remoteFile" -ForegroundColor Gray
}
}
# ---------------- 上传 ----------------
New-FtpDirectory $RemoteRoot
Send-FtpDirectory $StagingDir $RemoteRoot
Remove-Item $StagingDir -Recurse -Force
Write-Host ""
Write-Host "============================================================" -ForegroundColor Green
Write-Host " 发布完成! 请手动回收 IIS 应用程序池使新版本生效:" -ForegroundColor Green
Write-Host " IIS 管理器 -> 应用程序池 -> wenchuanyi -> 右键回收" -ForegroundColor Green
Write-Host " 或: appcmd recycle apppool /apppool.name:wenchuanyi" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.local
.env
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#2D6CFF" />
<title>文传易 - 免登录,传文件,真容易</title>
<link
rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%232D6CFF'/%3E%3Cstop offset='1' stop-color='%2300B4FF'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Ctext x='16' y='22' font-size='16' font-family='sans-serif' font-weight='bold' fill='white' text-anchor='middle'%3E%E4%BC%A0%3C/text%3E%3C/svg%3E"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3160
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "wenchuanyi-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"qrcode": "^1.5.4",
"tdesign-vue-next": "^1.10.5",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/qrcode": "^1.5.5",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="min-h-screen flex flex-col bg-[#F4F7FC]">
<!-- 桌面顶栏 -->
<header
class="hidden md:flex sticky top-0 z-50 items-center justify-between px-8 h-16 bg-white/80 backdrop-blur-md shadow-sm"
>
<div class="flex items-center gap-3 select-none" @click="$router.push('/')">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] grid place-items-center text-white font-bold text-lg shadow-card">
</div>
<div>
<div class="text-lg font-semibold leading-tight">文传易</div>
<div class="text-xs text-[#646A73] leading-tight">免登录传文件真容易</div>
</div>
</div>
<nav class="flex items-center gap-2">
<button
v-for="r in routes"
:key="r.path"
class="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
:class="$route.path === r.path ? 'bg-[#EAF1FF] text-[#2D6CFF]' : 'text-[#646A73] hover:bg-[#F0F3F8]'"
@click="$router.push(r.path)"
>
{{ r.meta.title }}
</button>
<button
class="ml-1 px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white shadow-card hover:shadow-lift transition-all flex items-center gap-1.5"
@click="openMobileQr"
>
<t-icon name="qrcode" :size="17" /> 手机上传
</button>
</nav>
</header>
<!-- 手机上传二维码弹窗电脑端 -->
<t-dialog v-model:visible="showMobileQr" header="手机上传" width="340px" :confirm-btn="null" :cancel-btn="null" :close-on-overlay-click="true">
<div class="text-center py-1">
<div v-if="mobileQrDataUrl" class="inline-block bg-white rounded-2xl p-3 border border-[#F0F3F8] shadow-card">
<img :src="mobileQrDataUrl" alt="手机上传二维码" class="w-52 h-52" />
</div>
<div v-else class="w-52 h-52 mx-auto grid place-items-center"><t-loading :size="32" /></div>
<div class="text-sm font-medium text-[#1F2329] mt-3">扫一扫手机浏览器打开文传易</div>
<div class="text-xs text-[#8A9099] mt-1 break-all">{{ mobileSiteUrl }}</div>
<div class="text-xs text-[#00B578] mt-1">手机端可直接上传文件无需登录</div>
</div>
</t-dialog>
<!-- 主内容 -->
<main class="flex-1 w-full max-w-2xl mx-auto px-4 pb-28 md:pb-12 pt-6">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</main>
<!-- 移动端底部 Tab -->
<nav
class="md:hidden fixed bottom-0 inset-x-0 z-50 bg-white/95 backdrop-blur-md border-t border-gray-100 grid grid-cols-3"
style="padding-bottom: env(safe-area-inset-bottom)"
>
<button
v-for="r in routes"
:key="r.path"
class="flex flex-col items-center gap-1 py-3 text-[11px] transition-colors"
:class="$route.path === r.path ? 'text-[#2D6CFF]' : 'text-[#8A9099]'"
@click="$router.push(r.path)"
>
<t-icon :name="r.meta.icon" :size="24" />
<span class="font-medium">{{ r.meta.title }}</span>
</button>
</nav>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { router } from './router'
useRouter()
const routes = router.options.routes
const showMobileQr = ref(false)
const mobileQrDataUrl = ref('')
// 手机版入口:当前站点首页(部署后自动为正式域名)
const mobileSiteUrl = window.location.origin + '/#/'
async function openMobileQr() {
showMobileQr.value = true
if (!mobileQrDataUrl.value) {
mobileQrDataUrl.value = await QRCode.toDataURL(mobileSiteUrl, {
width: 400,
margin: 1,
color: { dark: '#1F2329', light: '#FFFFFF' },
})
}
}
</script>
+75
View File
@@ -0,0 +1,75 @@
import axios from 'axios'
export interface FileInfo {
id: number
pickCode: string
fileType: 'standard' | 'private' | 'tagged'
hasPassword: boolean
originalName: string
size: number
mimeType: string | null
downloadCount: number
uploadIp: string | null
isPermanent: boolean
expiresAt: string | null
createdAt: string
expireText: string
}
export interface UploadResult {
id: number
pickCode: string
adminCode: string
fileType: string
originalName: string
size: number
isPermanent: boolean
expiresAt: string | null
pickUrl: string
}
const api = axios.create({
baseURL: '/api',
timeout: 5 * 60 * 1000,
})
// ---------- 上传 ----------
export function uploadFile(data: FormData, onProgress?: (p: number) => void) {
return api.post<UploadResult>('/files/upload', data, {
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100))
},
})
}
// ---------- 取件 ----------
export function queryFile(code: string, password?: string) {
return api.get<FileInfo>(`/files/${code}`, { params: password ? { password } : {} })
}
export function listByTag(tag: string) {
return api.get<FileInfo[]>(`/files/by-tag/${encodeURIComponent(tag)}`)
}
export function downloadUrl(code: string, password?: string) {
return `/api/files/${code}/download${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
export function previewUrl(code: string, password?: string) {
return `/api/files/${code}/preview${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
export function contentUrl(code: string, password?: string) {
return `/api/files/${code}/content${password ? `?password=${encodeURIComponent(password)}` : ''}`
}
// ---------- 管理 ----------
export function adminList(adminCode: string) {
return api.get<FileInfo[]>(`/admin/files/${adminCode}`)
}
export function adminDelete(adminCode: string, id?: number, pickCode?: string) {
return api.delete(`/admin/files/${adminCode}`, { data: { id, pickCode } })
}
export default api
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}
+64
View File
@@ -0,0 +1,64 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2d6cff;
--color-primary-light: #00b4ff;
--color-bg: #f4f7fc;
}
* {
-webkit-tap-highlight-color: transparent;
}
html,
body {
@apply m-0 p-0;
font-family: 'PingFang SC', 'Microsoft YaHei', system-ui, -apple-system, sans-serif;
background: var(--color-bg);
color: #1f2329;
overscroll-behavior-y: none;
}
#app {
min-height: 100vh;
}
/* 页面淡入 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 通用卡片 */
.wcy-card {
@apply bg-white rounded-2xl shadow-card;
}
/* 渐变文字 */
.text-gradient {
background: linear-gradient(120deg, #2d6cff 0%, #00b4ff 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 等宽数字(凭证) */
.code-font {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
letter-spacing: 0.08em;
}
/* 悬浮上浮微动效 */
.hover-lift {
@apply transition-all duration-200;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(45, 108, 255, 0.16);
}
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
import './index.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(TDesign).use(router).mount('#app')
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import UploadView from '@/views/UploadView.vue'
import PickupView from '@/views/PickupView.vue'
import AdminView from '@/views/AdminView.vue'
export const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: UploadView, meta: { title: '上传', icon: 'cloud-upload' } },
{ path: '/pickup', component: PickupView, meta: { title: '取件', icon: 'link' } },
{ path: '/admin', component: AdminView, meta: { title: '管理', icon: 'setting' } },
],
})
+145
View File
@@ -0,0 +1,145 @@
<template>
<div>
<!-- 管理码输入 -->
<div class="wcy-card p-5 md:p-6">
<div class="text-lg font-semibold text-[#1F2329]">文件管理</div>
<div class="text-xs text-[#8A9099] mt-0.5">输入管理码查看该管理码下的全部文件</div>
<div class="flex flex-col md:flex-row gap-2 mt-4">
<input
v-model="adminCode"
placeholder="请输入 8 位管理码"
class="w-full md:flex-1 text-center code-font text-lg py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
maxlength="8"
@keyup.enter="load"
/>
<button
class="w-full md:w-auto px-6 py-2.5 rounded-xl text-white text-sm font-medium bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] shadow-card hover:shadow-lift disabled:opacity-60 transition-all"
:disabled="adminCode.length !== 8 || loading"
@click="load"
>
查询
</button>
</div>
<div v-if="error" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ error }}</div>
</div>
<!-- 文件列表 -->
<div v-if="loaded" class="mt-5">
<!-- 桌面表格 -->
<div class="hidden md:block wcy-card overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-xs text-[#8A9099] bg-[#F7F9FC]">
<th class="px-4 py-3 font-medium">文件名</th>
<th class="px-4 py-3 font-medium">大小</th>
<th class="px-4 py-3 font-medium">上传时间</th>
<th class="px-4 py-3 font-medium">过期时间</th>
<th class="px-4 py-3 font-medium">下载次数</th>
<th class="px-4 py-3 font-medium">上传 IP</th>
<th class="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="f in files" :key="f.id" class="border-t border-[#F0F3F8] hover:bg-[#FAFCFF]">
<td class="px-4 py-3">
<div class="font-medium text-[#1F2329] max-w-[220px] truncate">{{ f.originalName }}</div>
</td>
<td class="px-4 py-3 text-[#646A73]">{{ formatSize(f.size) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ formatDateTime(f.createdAt) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ f.isPermanent ? '永久' : formatDateTime(f.expiresAt) }}</td>
<td class="px-4 py-3 text-[#646A73]">{{ f.downloadCount }}</td>
<td class="px-4 py-3 text-[#646A73] code-font text-xs">{{ f.uploadIp || '-' }}</td>
<td class="px-4 py-3">
<button class="px-3 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] hover:bg-[#FFE4E2]" @click="askDelete(f)">
删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片 -->
<div class="md:hidden space-y-3">
<div v-for="f in files" :key="f.id" class="wcy-card p-4 hover-lift">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-sm font-medium truncate text-[#1F2329]">{{ f.originalName }}</div>
<div class="text-xs text-[#8A9099] mt-1">大小 {{ formatSize(f.size) }} · 下载 {{ f.downloadCount }} </div>
<div class="text-xs text-[#8A9099] mt-0.5">上传 {{ formatDateTime(f.createdAt) }}</div>
<div class="text-xs text-[#8A9099] mt-0.5">
过期 {{ f.isPermanent ? '永久' : formatDateTime(f.expiresAt) }}
<span class="ml-2">IP {{ f.uploadIp || '-' }}</span>
</div>
</div>
<button class="px-3 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] shrink-0" @click="askDelete(f)">
删除
</button>
</div>
</div>
</div>
<div v-if="!files.length" class="wcy-card p-10 text-center text-sm text-[#8A9099]">暂无文件</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { DialogPlugin, MessagePlugin } from 'tdesign-vue-next'
import { adminDelete, adminList, type FileInfo } from '@/api'
const adminCode = ref('')
const files = ref<FileInfo[]>([])
const loading = ref(false)
const loaded = ref(false)
const error = ref('')
async function load() {
if (adminCode.value.length !== 8) return
loading.value = true
error.value = ''
try {
const res = await adminList(adminCode.value.trim())
files.value = res.data
loaded.value = true
} catch (e: any) {
error.value = e?.response?.data?.message || '查询失败,请检查管理码'
} finally {
loading.value = false
}
}
function askDelete(f: FileInfo) {
const dialog = DialogPlugin.confirm({
header: '删除文件',
body: `确定删除「${f.originalName}」吗?删除后取件凭证立即失效,不可恢复。`,
theme: 'danger',
confirmBtn: { content: '删除', theme: 'danger' },
cancelBtn: '取消',
onConfirm: async () => {
try {
await adminDelete(adminCode.value.trim(), f.id)
MessagePlugin.success('已删除')
files.value = files.value.filter((x) => x.id !== f.id)
dialog.destroy()
} catch (e: any) {
MessagePlugin.error(e?.response?.data?.message || '删除失败')
}
},
onClose: () => dialog.destroy(),
})
}
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)
}
</script>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div>
<!-- 品牌区标题 + slogan -->
<div 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>
<!-- 取件输入卡片 -->
<div class="wcy-card p-5 md:p-6">
<div class="text-center">
<div class="text-lg font-semibold text-[#1F2329]">取件</div>
<div class="mt-3 grid gap-2.5 text-left">
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="lock-on" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入 6 位私密文件取件码</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="usergroup" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入 8 位共享文件取件码</span>
</div>
<div class="flex items-center gap-2.5">
<span class="w-7 h-7 rounded-lg bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF] text-white grid place-items-center shrink-0">
<t-icon name="label" :size="15" />
</span>
<span class="text-sm font-medium text-[#1F2329]">输入标签查询下载</span>
</div>
</div>
</div>
<input
ref="codeInput"
v-model="code"
inputmode="text"
autocomplete="off"
placeholder="请输入取件码或标签"
class="mt-4 w-full text-center code-font text-2xl py-3.5 rounded-xl bg-[#EFF4FF] border-2 border-[#2D6CFF]/40 shadow-[0_0_0_4px_rgba(45,108,255,0.12)] focus:outline-none focus:border-[#2D6CFF] focus:shadow-[0_0_0_4px_rgba(45,108,255,0.22)] transition-all"
@input="onInput"
@keyup.enter="doQuery"
/>
<!-- 7 位提示 -->
<div v-if="tip" class="mt-3 text-center text-sm text-[#FF8800]">{{ tip }}</div>
<!-- 6 位私密密码框 -->
<div v-if="passwordNeeded" class="mt-4">
<div class="text-sm text-[#646A73] mb-2">该文件设置了下载密码请输入密码</div>
<div class="flex gap-2">
<input
v-model="password"
type="password"
inputmode="text"
placeholder="输入下载密码"
class="flex-1 text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
@keyup.enter="doQuery"
/>
<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 disabled:opacity-60"
:disabled="!password"
@click="doQuery"
>
查询
</button>
</div>
</div>
<div v-if="mode === 'tag'" class="mt-3 text-xs text-[#646A73] flex items-center justify-center gap-1">
<t-icon name="label" /> 按标签{{ code }}查询
</div>
</div>
<!-- 加载 -->
<div v-if="loading" class="mt-6 flex justify-center py-10">
<t-loading :size="36" />
</div>
<!-- 单文件结果 -->
<div v-else-if="fileResult" class="mt-5">
<FileCard :file="fileResult" @preview="openPreview(fileResult)" @download="doDownload(fileResult)" />
</div>
<!-- 标签文件列表 -->
<div v-else-if="tagList" class="mt-5 space-y-3">
<div class="text-sm font-medium text-[#1F2329]">该标签下的文件{{ tagList.length }}</div>
<template v-if="tagList.length">
<div v-for="f in tagList" :key="f.id">
<FileCard :file="f" @preview="openPreview(f)" @download="doDownload(f)" @remove="askDelete(f)" />
</div>
</template>
<div v-else class="wcy-card p-10 text-center text-sm text-[#8A9099]">该标签下暂无文件</div>
</div>
<!-- 密码弹窗私密文件预览/下载 -->
<div v-if="pwDialog" class="fixed inset-0 z-50 bg-black/40 grid place-items-center p-6" @click.self="pwDialog = null">
<div class="w-full max-w-xs bg-white rounded-2xl p-5 shadow-lift">
<div class="text-center font-medium text-[#1F2329]">需要下载密码</div>
<div class="text-center text-xs text-[#8A9099] mt-1">{{ pwTarget?.originalName }}</div>
<input
v-model="pwInput"
type="password"
placeholder="输入该文件密码"
class="mt-4 w-full text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#2D6CFF]/50"
@keyup.enter="confirmPassword"
/>
<div v-if="pwError" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ pwError }}</div>
<div class="flex gap-3 mt-4">
<button class="flex-1 py-2 rounded-xl text-sm text-[#646A73] bg-[#F0F3F8]" @click="pwDialog = null">取消</button>
<button class="flex-1 py-2 rounded-xl text-sm text-white bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]" @click="confirmPassword">确定</button>
</div>
</div>
</div>
<!-- 管理码删除弹窗 -->
<div v-if="delDialog" class="fixed inset-0 z-50 bg-black/40 grid place-items-center p-6" @click.self="delDialog = null">
<div class="w-full max-w-xs bg-white rounded-2xl p-5 shadow-lift">
<div class="text-center font-medium text-[#1F2329]">删除文件需管理码确认</div>
<div class="text-center text-xs text-[#8A9099] mt-1">{{ delTarget?.originalName }}</div>
<input
v-model="delInput"
placeholder="输入该文件的管理码"
class="mt-4 w-full text-center py-2.5 rounded-xl bg-[#F4F7FC] focus:outline-none focus:ring-2 focus:ring-[#FF4D4F]/50"
@keyup.enter="confirmDelete"
/>
<div v-if="delError" class="mt-2 text-center text-xs text-[#FF4D4F]">{{ delError }}</div>
<div class="flex gap-3 mt-4">
<button class="flex-1 py-2 rounded-xl text-sm text-[#646A73] bg-[#F0F3F8]" @click="delDialog = null">取消</button>
<button class="flex-1 py-2 rounded-xl text-sm text-white bg-[#FF4D4F]" @click="confirmDelete">删除</button>
</div>
</div>
</div>
<!-- 预览层 -->
<div v-if="preview" class="fixed inset-0 z-50 bg-black/90 flex flex-col" @click.self="preview = null">
<div class="flex items-center justify-between px-4 py-3 text-white">
<div class="text-sm truncate flex-1">{{ preview.file.originalName }}</div>
<button class="w-9 h-9 grid place-items-center rounded-full hover:bg-white/10" @click="preview = null">
<t-icon name="close" :size="22" />
</button>
</div>
<div class="flex-1 overflow-auto flex items-start justify-center p-4">
<template v-if="preview.kind === 'text'">
<pre class="text-white/90 text-sm whitespace-pre-wrap w-full">{{ previewText }}</pre>
</template>
<template v-else-if="preview.kind === 'pdf'">
<iframe :src="preview.src" class="w-full h-full rounded-xl bg-white" />
</template>
<template v-else-if="preview.kind === 'image'">
<img :src="preview.src" class="max-w-full max-h-full object-contain" />
</template>
<template v-else-if="preview.kind === 'video'">
<video :src="preview.src" controls autoplay class="max-w-full max-h-full" />
</template>
<template v-else-if="preview.kind === 'audio'">
<div class="w-full mt-10 text-center">
<div class="text-white/80 mb-4">{{ preview.file.originalName }}</div>
<audio :src="preview.src" controls autoplay class="w-full" />
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, h, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { MessagePlugin } from 'tdesign-vue-next'
import { listByTag, previewUrl, contentUrl, downloadUrl, queryFile, adminDelete, type FileInfo } from '@/api'
const route = useRoute()
const router = useRouter()
const code = ref('')
const codeInput = ref<HTMLInputElement>()
const password = ref('')
const tip = ref('')
const passwordNeeded = ref(false)
const mode = ref<'none' | 'code' | 'tag'>('none')
const loading = ref(false)
const fileResult = ref<FileInfo | null>(null)
const tagList = ref<FileInfo[] | null>(null)
const pwDialog = ref(false)
const pwTarget = ref<FileInfo | null>(null)
const pwInput = ref('')
const pwError = ref('')
const delDialog = ref(false)
const delTarget = ref<FileInfo | null>(null)
const delInput = ref('')
const delError = ref('')
const preview = ref<{ file: FileInfo; kind: string; src?: string } | null>(null)
const previewText = ref('')
let debounceTimer: number | undefined
onMounted(() => {
const c = route.query.code as string | undefined
if (c) {
code.value = c
classify()
if (c.length === 8) doQuery()
}
// 默认聚焦取件码输入框
codeInput.value?.focus()
})
onUnmounted(() => {
if (debounceTimer) window.clearTimeout(debounceTimer)
})
function onInput() {
classify()
if (mode.value === 'tag' && code.value.length > 0) {
if (debounceTimer) window.clearTimeout(debounceTimer)
debounceTimer = window.setTimeout(doQuery, 500)
}
}
function classify() {
const v = code.value.trim()
if (!v) {
mode.value = 'none'
passwordNeeded.value = false
tip.value = ''
fileResult.value = null
tagList.value = null
return
}
if (/^\d+$/.test(v) && v.length <= 8) {
mode.value = 'code'
tagList.value = null
fileResult.value = null
if (v.length === 6) {
passwordNeeded.value = true
tip.value = ''
} else if (v.length === 7) {
passwordNeeded.value = false
tip.value = '取件码为 6 位或 8 位,请继续输入完整取件码'
} else if (v.length === 8) {
passwordNeeded.value = false
tip.value = ''
doQuery()
} else {
passwordNeeded.value = false
tip.value = ''
}
} else {
mode.value = 'tag'
passwordNeeded.value = false
tip.value = ''
fileResult.value = null
}
}
async function doQuery() {
const v = code.value.trim()
if (!v) return
loading.value = true
try {
if (mode.value === 'tag') {
const res = await listByTag(v)
tagList.value = res.data
fileResult.value = null
} else {
if (v.length === 6) {
if (!password.value) {
MessagePlugin.warning('请输入下载密码')
return
}
const res = await queryFile(v, password.value)
fileResult.value = res.data
tagList.value = null
} else if (v.length === 8) {
const res = await queryFile(v)
fileResult.value = res.data
tagList.value = null
} else {
MessagePlugin.warning('请输入完整的 6 位或 8 位取件码')
}
}
} catch (e: any) {
const status = e?.response?.status
const msg = e?.response?.data?.message
if (status === 401) MessagePlugin.error(msg || '密码错误')
else if (status === 410) MessagePlugin.error(msg || '文件已过期')
else MessagePlugin.error(msg || '未找到该凭证对应的文件')
} finally {
loading.value = false
}
}
// ---------- 密码弹窗 ----------
let pendingAction: 'preview' | 'download' = 'preview'
const pwCache: Record<number, string> = {}
function openPreview(f: FileInfo) {
if (f.hasPassword) {
if (pwCache[f.id]) {
showPreview(f, pwCache[f.id])
return
}
pendingAction = 'preview'
pwTarget.value = f
pwInput.value = ''
pwError.value = ''
pwDialog.value = true
return
}
showPreview(f)
}
function doDownload(f: FileInfo) {
if (f.hasPassword) {
if (pwCache[f.id]) {
location.href = downloadUrl(f.pickCode, pwCache[f.id])
return
}
pendingAction = 'download'
pwTarget.value = f
pwInput.value = ''
pwError.value = ''
pwDialog.value = true
return
}
location.href = downloadUrl(f.pickCode)
}
async function confirmPassword() {
if (!pwTarget.value) return
if (!pwInput.value) {
pwError.value = '请输入密码'
return
}
const f = pwTarget.value
try {
// 先验证密码是否正确
await queryFile(f.pickCode, pwInput.value)
pwCache[f.id] = pwInput.value
const action = pendingAction
const pwd = pwInput.value
pwDialog.value = false
pwTarget.value = null
if (action === 'preview') showPreview(f, pwd)
else location.href = downloadUrl(f.pickCode, pwd)
} catch (e: any) {
pwError.value = e?.response?.data?.message || '密码错误'
}
}
// ---------- 删除 ----------
function askDelete(f: FileInfo) {
delTarget.value = f
delInput.value = ''
delError.value = ''
delDialog.value = true
}
async function confirmDelete() {
if (!delTarget.value) return
if (!delInput.value) {
delError.value = '请输入管理码'
return
}
try {
await adminDelete(delInput.value.trim(), delTarget.value.id)
MessagePlugin.success('已删除')
delDialog.value = false
// 刷新标签列表
if (tagList.value) {
tagList.value = tagList.value.filter((x) => x.id !== delTarget.value!.id)
}
} catch (e: any) {
delError.value = e?.response?.data?.message || '删除失败,管理码可能不正确'
}
}
// ---------- 预览 ----------
function showPreview(f: FileInfo, password?: string) {
const mime = f.mimeType || ''
const ext = (f.originalName.split('.').pop() || '').toLowerCase()
const textExts = ['txt', 'md', 'xml', 'json', 'csv', 'log', 'ini', 'conf', 'srt', 'html', 'htm', 'css', 'js', 'ts', 'cs', 'sql', 'yml', 'yaml', 'sh', 'py', 'java', 'go', 'php', 'bat', 'ps1']
if (mime.startsWith('text/') || textExts.includes(ext)) {
preview.value = { file: f, kind: 'text' }
fetch(contentUrl(f.pickCode, password))
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('加载失败'))))
.then((t) => (previewText.value = t))
.catch(() => MessagePlugin.error('预览失败'))
} else if (mime === 'application/pdf') {
preview.value = { file: f, kind: 'pdf', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('image/')) {
preview.value = { file: f, kind: 'image', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('video/')) {
preview.value = { file: f, kind: 'video', src: previewUrl(f.pickCode, password) }
} else if (mime.startsWith('audio/')) {
preview.value = { file: f, kind: 'audio', src: previewUrl(f.pickCode, password) }
} else {
MessagePlugin.warning('该格式不支持在线预览,请下载查看')
}
}
// ---------- 文件卡片子组件 ----------
const FileCard = (props: { file: FileInfo }, { emit }: any) =>
h('div', { class: 'wcy-card p-4 flex flex-wrap items-center gap-x-3 gap-y-2.5 hover-lift' }, [
h('div', { class: 'w-12 h-12 rounded-xl bg-[#EAF1FF] grid place-items-center text-[#2D6CFF] shrink-0' },
h('t-icon', { name: 'file', size: 24 })),
h('div', { class: 'flex-1 min-w-0' }, [
h('div', { class: 'text-sm font-medium break-all text-[#1F2329]' }, props.file.originalName),
h('div', { class: 'text-xs text-[#8A9099] mt-0.5 flex items-center gap-2 flex-wrap' }, [
props.file.hasPassword ? h('span', { class: 'text-[#FF8800]' }, '🔒 私密') : null,
h('span', formatSize(props.file.size)),
h('span', props.file.expireText),
]),
]),
h('div', { class: 'flex gap-1.5 shrink-0 w-full md:w-auto pl-[60px] md:pl-0' }, [
h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-[#2D6CFF] bg-[#EAF1FF] hover:bg-[#DFEAFF]',
onClick: () => emit('preview'),
}, '预览'),
h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-white bg-gradient-to-br from-[#2D6CFF] to-[#00B4FF]',
onClick: () => emit('download'),
}, '下载'),
props.file.fileType === 'tagged' || props.file.hasPassword
? h('button', {
class: 'px-2.5 py-1.5 rounded-lg text-xs text-[#FF4D4F] bg-[#FFF1F0] hover:bg-[#FFE4E2]',
onClick: () => emit('remove'),
}, '删除')
: null,
]),
])
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'
}
</script>
+720
View File
@@ -0,0 +1,720 @@
<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="!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">
<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"
>
<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 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 openPicker() {
fileInput.value?.click()
}
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.textAlign = 'center'
ctx.font = `bold 26px ${FONT}`
ctx.fillText('传', 74, 37)
ctx.textAlign = 'left'
ctx.fillText('文传易', 118, 26)
ctx.font = `14px ${FONT}`
ctx.fillStyle = 'rgba(255,255,255,0.9)'
ctx.fillText('免登录,传文件,真容易', 118, 60)
// 成功提示
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, 44, y + 2, 4, 15, 2)
ctx.fill()
ctx.fillStyle = '#1F2329'
ctx.textAlign = 'left'
ctx.font = `600 15px ${FONT}`
ctx.fillText(text, 60, 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
// 文件信息卡
const cardH = 20 + 32 + nameLines.length * 24 + 14
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 += 30
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)
// 取件码标签(与大码拉开间距避免重叠)
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 会清空画布内容,必须先备份再画回
const newH = qrY + qrSize + 14 + 40
if (canvas.height !== newH) {
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)
}
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)
const a = document.createElement('a')
a.href = posterDataUrl.value
a.download = `文传易取件凭证_${result.value.pickCode}.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(() => window.addEventListener('paste', onPaste))
onUnmounted(() => window.removeEventListener('paste', onPaste))
</script>
+36
View File
@@ -0,0 +1,36 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#2D6CFF',
light: '#00B4FF',
dark: '#1E5EFF',
},
ink: {
DEFAULT: '#1F2329',
secondary: '#646A73',
},
success: '#00B578',
warning: '#FF8800',
danger: '#FF4D4F',
},
fontFamily: {
sans: [
'PingFang SC',
'Microsoft YaHei',
'system-ui',
'-apple-system',
'sans-serif',
],
},
boxShadow: {
card: '0 6px 24px rgba(45, 108, 255, 0.08)',
lift: '0 12px 32px rgba(45, 108, 255, 0.16)',
},
},
},
plugins: [],
}
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2018",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2018", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: true, // 允许局域网(手机)访问
port: 5173,
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:5280',
changeOrigin: true,
},
},
},
build: {
target: 'es2018', // 兼容微信 X5 内核
outDir: 'dist',
},
})
+341
View File
@@ -0,0 +1,341 @@
# 文传易 · 需求文档
> **文档版本**v0.4(交付版)
> **创建日期**2026-08-23
> **文档状态**:已交付(前后端已实现并通过本地冒烟测试;部署见 `deploy/IIS部署与FTP发布.md`
> **技术栈**.NET 8 + FreeSql + MySQL(后端)| Vue 3 + Vite + TypeScript + TDesign(前端)
> **正式站**https://wenchuanyi.bbitcn.net(中文名「文传易」)
---
## 1. 项目概述
### 1.1 项目背景
「文传易」是一个匿名临时文件传输网站(类似文叔叔 / 奶牛快传 / tmp.link):
发送者无需注册登录即可上传文件,系统生成**取件凭证**;将凭证发给收件人后,收件人凭凭证即可下载文件。
适用于"想传文件给对方、但不想注册 / 登录 / 加好友"的场景。
### 1.2 项目目标
- 实现"上传 → 取件凭证 → 凭码下载"的完整闭环,全流程匿名、中文界面。
- 单文件传输,操作尽量简单:3 步完成(加入文件 → 上传 → 发码)。
- **二维码 + 取件码两种分享方式**:上传成功后生成二维码(内容为取件页链接,扫码自动进入下载页并填入取件码)——二维码发给新用户、取件码发给熟悉用户。
- 支持大段文字粘贴生成 txt 传递(**文件名 = 文字前 12 位 + 日期时间**);常见格式(文本/PDF/图片/音视频)支持在线预览与下载。
### 1.3 术语定义
| 术语 | 定义 |
| --- | --- |
| 发送者 | 上传文件的一方,匿名,不登录 |
| 收件人 | 凭取件凭证下载文件的一方,匿名,不登录 |
| 共享文件 | 不设密码/标签的文件类型,取件码为 **8 位纯数字** |
| 私密文件 | 设置 4-12 位密码的文件类型(**可同时设置标签**),取件码为 **6 位纯数字**,下载需校验密码 |
| 标签文件 | 设置标签的文件类型(**可同时设置密码**);**一个标签可关联多个文件**,输入标签后展示该标签下的文件列表;含标签的文件永久保存 |
| 取件码 | 上传成功后生成的下载凭证:共享文件 **8 位纯数字**、私密文件 **6 位纯数字**,输入时按位数实时识别(8 位 = 共享,6 位 = 私密) |
| 标签 | 标签文件的取件凭证,**仅允许英文(A-Za-z)与数字、不含任何符号**;纯数字须 >8 位(推荐 11 位手机号)、含英文字母须 >4 位;同一标签可关联多个文件,不要求唯一 |
| 管理码 | 发送者管理已上传文件的凭证,8 位字母数字;**不提供找回**,仅展示一次 |
| 有效期 | 文件可被下载的时间范围,**固定三档**:24 小时 / 7 天 / 永久 |
| 取件页链接 | 前端取件页地址,格式 `{BaseUrl}/#/pickup?code=xxx`,用于二维码/分享链接,扫码自动填入取件码 |
| 二维码 | 上传成功后前端本地生成的分享码,内容为取件页链接(仅含取件码、不含敏感信息),可下载/长按转发 |
---
## 2. 用户与角色
### 2.1 角色
| 角色 | 说明 | 是否登录 |
| --- | --- | --- |
| 发送者 | 上传文件、复制取件凭证/管理码、凭管理码管理 | 否(匿名) |
| 收件人 | 凭取件码/标签查询并下载文件 | 否(匿名) |
| 运维人员 | 部署 IIS、FTP 发布、配置数据库连接串与 OSS 凭据 | 系统后台 |
### 2.2 核心使用场景
1. **快速传文件**:同事/朋友间临时传文件,发送者上传后把**二维码**(新用户)或**取件码**(熟悉用户)通过微信/短信发给对方,对方扫码/输码下载。
2. **大段文字传递**:需要把一段很长的文字(如代码、会议纪要、账号信息)传给对方,直接粘贴文字生成 txt(文件名 = 文字前 12 位 + 日期时间)上传,对方在线阅读或下载。
3. **标签批量投递**:给同一对象传多个文件(如以手机号 `13800138000` 为标签),收件人输入该标签即可看到全部文件,逐个查看/下载。
4. **发送者管理**:想查看自己传过哪些文件、被下载几次(**仅统计、不限制**),或删除不再需要的文件,凭管理码操作。
---
## 3. 核心业务流程
### 3.1 发件流程(上传)
1. 发送者进入首页 → 通过任意方式加入文件:
- **拖拽**文件到上传区;
- **复制文件后 Ctrl+V 粘贴**上传;
- **点击选择**(打开文件夹直接选中文件);
- **粘贴一段文字** → 自动生成 .txt 文件上传,**文件名 = 文字前 12 位 + 日期时间**(`{文字前12位}_{yyyyMMdd_HHmmss}.txt`;剔除 `\/:*?"<>|` 等 Windows 非法文件名字符,剔除后为空用「文本」兜底),不提供自定义命名;
2. 选择有效期:**固定三档 24 小时 / 7 天 / 永久**(无自定义时长);
3. 按需设置**密码(4-12 位)**与**标签(仅英文+数字)**,**两者可同时设置、不互斥**;均不设置则默认为共享文件:
- **共享文件**:不设密码与标签 → **8 位数字取件码**
- **私密文件**:设置密码(可同时设置标签)→ **6 位数字取件码**(下载需密码);
- **标签文件**:设置标签(可同时设置密码)→ **标签即取件凭证**,同一标签可关联多个文件;
- **含标签的文件有效期强制「永久」**(无论是否同时设置密码);
4. 点击上传 → 系统保存文件并生成取件凭证 + 管理码:
- 无密码无标签 → **8 位数字取件码**(共享文件);
- 设密码(可有标签)→ **6 位数字取件码**(私密文件,下载需密码);
- 仅设标签 → **标签即取件凭证**(标签文件,可关联多个文件,永久保存);
5. 页面展示凭证卡片:**二维码**(取件页链接,扫码直达下载页并自动填码,可下载/长按转发)+ 大号取件凭证 + 一键复制;下方可收起的管理码 + 复制;并提示"二维码发给新用户、取件码发给熟悉用户"。
### 3.2 取件流程(下载)
1. 收件人进入取件页 → 输入取件凭证(取件码或标签),或直接扫码(URL `?code=` 参数自动填入并查询);
2. 系统识别类型并校验:8 位纯数字 → 共享文件;6 位纯数字 → 私密文件(需输入密码);**满 7 位纯数字 → 提示"取件码为 6 位或 8 位,请继续输入完整取件码",不自动查询、等用户继续输入或修正**;非纯数字 → 按**标签**查询文件列表;
3. 校验通过后展示文件信息(名称、大小、剩余有效期)→ 【在线预览】【下载】;标签查询展示该标签下文件列表(按上传时间倒序),逐项【在线预览】【下载】【删除】(列表项为私密文件时,下载/预览需输入该文件密码)。
### 3.3 管理流程(发送者)
1. 输入管理码 → 查看该管理码下的文件列表(文件名、大小、过期时间、下载次数、**上传 IP**);
2. 可删除指定文件(删除后取件凭证立即失效);标签文件也可在取件页标签列表凭管理码删除。
---
## 4. 功能需求
> 优先级:P0 = 必须|P1 = 应当|P2 = 可选
### 4.1 上传功能
- [P0] 文件格式**不限制**;对可执行/脚本类文件(如 .exe/.bat/.sh/.dll)上传时展示"可执行文件风险提示"
- [P0] 多种方式加入文件:
- 拖拽文件到上传区(拖入高亮);
- 复制文件后 **Ctrl+V 粘贴**上传;
- 点击打开文件夹选择**单个**文件;
- **粘贴一段文字** → 自动生成 .txt 文件上传,**文件名 = 文字前 12 位 + 日期时间**(`{文字前12位}_{yyyyMMdd_HHmmss}.txt`;剔除 `\/:*?"<>|` 等 Windows 非法文件名字符,剔除后为空用「文本」兜底),**不提供自定义命名**
- [P0] 上传前展示文件名、大小;超限文件前端拦截并提示
- [P0] 有效期选择:**固定三档**——默认 **24 小时**,可选 **7 天 / 永久**(无自定义时长);**设置标签(无论是否同时设置密码)时强制「永久」**(隐藏有效期选择器)
- [P0] 文件模式(**密码与标签可同时设置、不互斥**,上传时按设置自动识别):
| 模式 | 触发条件 | 取件凭证 | 下载要求 |
| --- | --- | --- | --- |
| 共享文件 | 不设密码与标签 | **8 位数字取件码**(全局唯一) | 输入取件码即可下载 |
| 私密文件 | 设置 **4-12 位密码**(可同时设置标签) | **6 位数字取件码**(全局唯一) | 取件码 + 密码校验 |
| 标签文件 | 设置**标签**(仅英文+数字,可同时设置密码) | **标签即取件凭证**,同一标签可关联多个文件 | 输入标签 → 展示该标签下的文件列表(带密码项需密码) |
> **同时设置密码与标签**:按私密文件识别(6 位取件码,下载需密码),且因含标签强制永久保存(`IsPermanent=true`)。
- [P0] **密码与标签不互斥、可同时设置**:密码与标签为两个独立输入项,互不影响;同时设置时按私密文件(6 位取件码)识别,且因含标签有效期强制永久
- [P0] **标签规则**:仅允许英文(A-Za-z)与数字,不含任何符号(中划线/下划线/空格/汉字等均不允许);**纯数字须 >8 位**(≥9,推荐 11 位手机号,输入时提示"建议使用手机号码");**含英文字母须 >4 位**(≥5);前端实时校验并给出中文提示,后端同规则兜底(400 明确报错)
- [P0] 通过取件码位数识别文件类型:**8 位 = 共享文件,6 位 = 私密文件**;取件码全局不重复
- [P0] 上传成功返回:取件凭证(取件码或标签)+ 管理码(8 位字母数字)+ 文件信息
- [P0] **二维码分享**:上传成功后前端用 `qrcode` 库本地生成二维码(内容为取件页链接 `{BaseUrl}/#/pickup?code=xxx`,仅含取件码、不含敏感信息),与取件码同卡片展示,可下载/长按转发
- [P0] 取件码 / 管理码一键复制
- [P1] 上传进度百分比展示
- [P2] 上传失败一键重试
### 4.2 取件下载功能(输入时实时识别)
- [P0] 取件凭证输入框**实时联动识别**(输入内容为纯数字时):
- 输入满 **6 位** → 识别为**私密文件**,显示【密码输入框】;密码输入满 **4 位**后显示【下载】按钮;
- 继续输入到 **7 位** → 显示提示"**取件码为 6 位或 8 位,请继续输入完整取件码**",不自动查询,等用户继续输入或修正;
- 输入满 **8 位** → 识别为**共享文件**,显示【下载】按钮;单击后加载文件信息并启动下载;
- [P0] 非纯数字输入(英文 / 数字混合)→ 按**标签**查询:输入标签后点击查询/回车,加载**该标签下的文件列表**(按上传时间倒序),逐项展示文件名/大小/上传时间,可对任一项【在线预览】【下载】【删除】(**列表项为私密文件(带密码)时,下载/预览需输入该文件密码**);无文件时提示"该标签下暂无文件"
- [P0] 私密文件下载必须校验密码,密码错误提示"密码错误"
- [P0] 凭码下载还原原始文件名(含中文/特殊字符);**下载次数仅统计、不限制**(`download_count` 只累加展示,不拦截下载)
- [P0] 已过期提示"文件已过期";凭证不存在提示"取件码/标签无效"
- [P0] **在线预览**(取件结果提供【在线预览】【下载】双操作,预览不计下载次数):
- 文本类(txt / md / xml / json 等)→ 在线直接阅读文字 + 下载,**上限 2MB**(超限仅提供下载);
- PDF → 在线阅读 + 下载;
- 图片类(jpg / png / gif / webp 等)→ 在线查看 + 下载,**上限 30MB**(超限提示"文件过大,请下载后查看");
- 音视频 → 在线播放 + 下载,**不设大小上限**(流式播放);**格式白名单**:视频 mp4(H.264/AAC,兼容性最佳)/ webm,音频 mp3 / wav / m4a / aac / ogg;非白名单格式前端提示下载查看
- [P1] 展示剩余有效期、文件大小
- [P1] 展示下载次数(仅统计)
### 4.3 发送者管理功能
- [P0] 凭管理码查看自己的文件列表(文件名/大小/过期时间/下载次数/**上传 IP**)
- [P0] 删除文件(需二次确认);标签文件删除在取件页标签列表凭管理码触发
- [P1] 展示下载次数(**仅统计不限制**)/ 剩余有效期 / 上传时间
### 4.4 过期与清理
- [P0] 过期文件自动清理(**OSS 对象 + 数据库记录**,先删记录再删 OSS 对象),取件凭证失效**即时生效**(查询/下载时懒检查)
- [P1] 后台定时任务兜底清理(每 30 分钟,仅扫描 `IsPermanent = false` 且已过期的记录;**含标签的文件永久保存、不参与自动过期**)
- [P0] **含标签的文件永久保存**:设置标签(无论是否同时设置密码)上传时有效期强制「永久」(前端隐藏有效期选择器,后端落库 `IsPermanent=true``ExpiresAt=null`),由上传者凭管理码主动删除
---
## 5. 非功能需求
### 5.1 性能
- 单文件大小上限:**200MB**(前端上传前拦截 + 服务端双重校验,Kestrel 请求上限 210MB 留余量)
- 上传 / 下载全程流式 I/O,内存占用恒定,不整体读入内存
- 下载附带 `Content-Length` + `Accept-Ranges`,支持断点续传(`Range` 透传,OSS 原生支持 206
- 表查询走唯一索引(PickCode / AdminCode)与普通索引(Tag);标签列表按 CreatedAt 倒序,limit 100 防大列表
### 5.2 安全
- 匿名性:全流程无需注册登录,无手机号/邮箱绑定
- **OSS 后端中转模式**OSS AK/SK 仅存服务端 `appsettings.json`,前端始终只与后端 API 交互,不接触 OSS 凭据;对象键 `文传易2026/{yyyyMM}/{Guid}{ext}`,GUID 文件名杜绝重名与路径问题,原始文件名仅存数据库
- 取件码 / 管理码均全局唯一,取件码冲突自动重生成;**标签不唯一(一对多)**,同一标签可关联多个文件
- 管理码丢失**不提供找回**(只展示一次,UI 明确提示)
- 开放 API 的 `filePath` 入参必须位于配置白名单目录 `OpenApi:UploadRoot` 下(`Path.GetFullPath` 规范化后校验前缀,防路径穿越)
- 上传内容默认不做敏感/病毒扫描(如需可后续扩展)
- 文件格式不限制,但对可执行/脚本类文件(.exe/.bat/.sh/.dll 等)在上传与下载时展示"可执行文件风险提示",提醒谨慎运行
- 日志不打印 OSS 凭据与数据库密码明文
### 5.3 可用性
- 中文界面,核心操作 ≤3 步
- 所有错误均有友好提示(无效码 / 过期 / 文件不存在 / 网络错误 / 标签格式错误)
- **移动端优先**:桌面 + 手机浏览器均可用,手机端完整支持上传 / 取件 / 管理全流程
- **微信扫码可用**:正式站 `https://wenchuanyi.bbitcn.net` 有公网地址,手机微信扫码打开即可使用;**部署公网前先在本地跑通全流程测试**(浏览器自动化冒烟 + 局域网手机访问验证)
- **磁盘无需提醒**:本系统不实现磁盘空间监控与提醒功能
### 5.4 兼容性
- 浏览器:Chrome / Edge / Safari / **微信内置浏览器**(最新两个大版本)
- 微信内置浏览器中点击选择文件,可支持选择**微信聊天记录中的文件**(标准 `<input type="file">` 原生能力,真机验证)
- 前端构建目标保持 ES2018,兼容微信 X5 内核
- 不支持 IE
---
## 6. 技术方案
### 6.1 技术栈(已确认)
| 层 | 选型 |
| --- | --- |
| 后端 | ASP.NET Core Web API.NET 8 LTS,支持 IIS 托管)+ FreeSql.Provider.MySqlCodeFirst 自动建表) |
| 数据库 | 远程 MySQL 8.x`116.198.221.125:3306`,库 `wenchuanyi`,用户 `wenchuanyi` |
| 文件存储 | 阿里云 OSS`Aliyun.OSS` SDKEndpoint `oss-cn-chengdu.aliyuncs.com`Bucket `bbit-f8-web`STSEndpoint 预留备用) |
| 前端 | Vue 3 + Vite + TypeScript + TDesign + Vue Router + Axios + Tailwind CSS + qrcode |
| 部署 | Windows Server + IIS 10 + .NET 8 Hosting BundleFTP 发布(`ftp://116.198.221.125`,用户 `wenchuanyi` |
### 6.2 系统架构
```
Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程 MySQLfiles 表)
├── 阿里云 OSS bbit-f8-web(对象键 文传易2026/{yyyyMM}/{Guid}{ext}
└── BackgroundService 定时清理过期文件(先删记录再删 OSS 对象)
```
- Controller`FileController`(上传 / 查询 / 标签列表 / 预览 / 下载)、`AdminController`(管理列表 / 删除)、`OpenApiController`(公开接口)
- Service`CodeGeneratorService`(取件码/管理码生成)、`OssStorageService`(OSS 对象键构造、流式上传/下载/删除)
- 后台任务:`ExpiredFileCleanerService`(每 30 分钟,仅扫 `IsPermanent = false` 的过期文件)
- 生产部署:前端构建产物与后端发布输出**同目录放置于 IIS 站点物理路径**(默认文件夹或 wwwroot);后端 `UseStaticFiles` + `MapFallbackToFile("index.html")` 实现 SPA 路由与 `/api` 共存
### 6.3 数据表设计(files 表,FreeSql CodeFirst 自动建表)
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| Id | bigint 自增 | 主键 |
| PickCode | varchar(8) 唯一索引 | 取件码(共享 8 位 / 私密 6 位) |
| AdminCode | varchar(8) 唯一索引 | 管理码(8 位字母数字) |
| FileType | varchar(16) | 文件类型:standard(共享)/ private(私密)/ tagged(标签) |
| Password | varchar(12) 可空 | 私密文件密码(4-12 位);可与标签同时设置(不互斥) |
| Tag | varchar(64) 可空 普通索引(非唯一) | 标签(仅英文+数字),非空即为标签文件;可与密码同时设置(不互斥);同一标签可关联多个文件 |
| OriginalName | varchar(255) | 原始文件名(下载展示用) |
| ObjectKey | varchar(255) | OSS 对象键:文传易2026/{yyyyMM}/{Guid}{ext} |
| Size | bigint | 文件大小(字节) |
| MimeType | varchar(100) 可空 | Content-Type |
| DownloadCount | int 默认 0 | 下载次数(仅统计、不限制) |
| UploadIp | varchar(45) | 上传者 IP 地址(IPv4/IPv6,安全审计,仅管理列表可见,取件页不展示) |
| IsPermanent | bool | 是否永久保存(含标签的文件强制 true) |
| ExpiresAt | datetime 可空 | 过期时间(IsPermanent=true 时为 null |
| CreatedAt | datetime | 上传时间 |
### 6.4 接口清单
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| POST | `/api/files/upload` | multipart 上传(file + expire + password + tag);**password 与 tag 可同时设置(不互斥)**、tag 格式校验(仅英文+数字、纯数字>8位/英文>4位)→ 返回取件凭证 + 管理码 + 文件信息(**自动记录上传者 IP**) |
| GET | `/api/files/{code}` | 查询文件信息(code 为取件码;私密文件需带密码校验;过期返回 410) |
| GET | `/api/files/by-tag/{tag}` | 按标签查询文件列表(按上传时间倒序;含标签均永久保存,仅返回未被删除记录;列表项带密码时下载/预览需密码) |
| GET | `/api/files/{code}/download` | 流式下载,下载计数 +1(仅统计不限制),还原原始文件名,支持 Range 断点续传(私密文件需密码) |
| GET | `/api/files/{code}/preview` | 在线预览:PDF/图片(≤30MB/音视频(白名单 mp4/webm/mp3/wav/m4a/aac/ogg,不限大小)以 inline 流返回(私密文件需密码),不计下载次数 |
| GET | `/api/files/{code}/content` | 文本类文件(txt/md/xml/json 等,≤2MB)内容,供在线阅读渲染(私密文件需密码) |
| GET | `/api/admin/files/{adminCode}` | 管理码查询文件列表(含上传 IP) |
| DELETE | `/api/admin/files/{adminCode}` | 删除指定文件(body 传文件 id/取件码,校验管理码);取件页标签列表删除复用此接口 |
| POST | `/api/open/upload-public` | 公开:body `{ filePath, expiresHours }`(long 有效期小时数,0=永久,缺省 24)→ `{ pickCode, pickUrl, downloadUrl }` |
| POST | `/api/open/upload-price` | 公开:body `{ filePath, pwd }``{ pickCode, pickUrl, downloadUrl }`(下载需密码) |
| POST | `/api/open/upload-tag` | 公开:body `{ filePath, tag }``{ pickCode, pickUrl, downloadUrl }`(标签文件永久保存) |
| GET | `/api/open/download/{pickCode}` | 公开:返回 `{ downloadUrl, pickUrl, needPassword }`;共享/标签为直接下载 URL,私密跳取件页手动输密码 |
> 开放 API 说明:入参 `filePath` 为服务端文件路径,必须位于配置白名单目录 `OpenApi:UploadRoot` 下(`Path.GetFullPath` 规范化后校验前缀,防路径穿越);响应含识别码、`pickUrl`(前端取件页链接,带 `?code=` 参数,点击进入下载页)、`downloadUrl`。
---
## 7. 页面与交互设计
### 7.1 页面清单
| 页面 | 路由 | 内容 |
| --- | --- | --- |
| 上传页(首页) | `/` | 品牌区(Logo + 标语「文传易,免登录,传文件,真容易」+ 三步使用提示) + 上传区(拖拽 / Ctrl+V 粘贴文件 / 点击选择 / 粘贴文字生成「文字前12位+日期时间」txt)+ 有效期固定三档(24小时/7天/永久,含标签强制永久)+ 密码(4-12 位)与标签(实时校验)**两个独立输入项、可同时设置** + 结果卡片(**二维码** + 大号取件凭证 + 复制 + 可收起管理码) |
| 取件页 | `/pickup` | 取件凭证输入框(实时识别:6 位→密码框、7 位→提示继续输入、8 位→下载按钮;非纯数字→按标签查询;URL `?code=` 自动填充)+ 单文件结果卡片或标签文件列表 + 预览层(文本/PDF/图片/音视频) |
| 管理页 | `/admin` | 管理码输入框 + 文件列表表格(名称/大小/上传时间/过期时间/下载次数/**上传 IP**)+ 每行删除按钮(二次确认) |
### 7.2 视觉风格
- 现代简约 + 清爽科技感:蓝青渐变主色(`#2D6CFF``#00B4FF`),浅色底 + 大圆角白色卡片 + 柔和阴影
- 导航:桌面顶部固定导航栏,移动端**底部固定 Tab**(上传 / 取件 / 管理)
- 微动效:拖拽高亮、hover 上浮、复制成功 Toast、页面淡入、凭证识别平滑过渡
---
## 8. 异常与边界情况
| 场景 | 处理 |
| --- | --- |
| 取件凭证不存在 | 提示"取件码/标签无效" |
| 标签查询无结果 | 提示"该标签下暂无文件" |
| 输入到 7 位数字 | 提示"取件码为 6 位或 8 位,请继续输入完整取件码",不自动查询 |
| 标签含符号 / 汉字 | 前端实时提示"仅支持英文+数字",后端返回 400 |
| 标签为纯 6/8 位数字 | 已被"纯数字须 >8 位"规则规避(纯数字 ≥9 位,不会与 6/8 位取件码冲突) |
| 标签列表项为私密文件(带密码) | 下载/预览前需输入该文件密码,密码错误提示"密码错误" |
| 私密文件密码错误 | 提示"密码错误",不泄露文件信息 |
| 可执行/脚本类文件 | 上传与下载时展示"可执行文件风险提示" |
| 文本文件超 2MB / PDF/图片超 30MB | 不提供在线预览,仅提供下载(提示"文件过大,请下载后查看") |
| 音视频非白名单格式 | 不提供在线播放,提示下载查看 |
| 文件已过期 | 提示"文件已过期",并触发懒清理 |
| 文件超过大小上限(200MB) | 前端上传前拦截 + 服务端双重校验 |
| 上传中断 / 网络错误 | 前端提示并可重试 |
| OSS 写入/读取失败 | 记录日志,返回明确错误码 |
| 管理码错误 | 提示"管理码无效" |
| 下载时 OSS 对象已被清理 / 缺失 | 提示"文件已不存在" |
| 同一取件码被并发下载 | 下载计数原子自增,不丢失 |
---
## 9. 验收标准
1. 端到端流程:加入文件 → 上传 → 获得取件凭证 → 新会话输入凭证 → 下载成功,文件名与内容正确。
2. 三种文件模式端到端均可用:**共享文件**(输入 8 位取件码后显示下载按钮)、**私密文件**(输入 6 位后出现密码框,密码 4 位后显示下载按钮,密码错误无法下载)、**标签文件**(输入标签 → 展示该标签下文件列表 → 逐项预览/下载;同一标签可关联多个文件);**密码与标签可同时设置**(同时设置时按私密 6 位码识别,且强制永久保存)。
3. 取件码位数实时识别正确(6 位 → 私密,7 位 → 提示继续输入,8 位 → 共享);取件码全局不重复。
4. **二维码分享**:上传成功生成二维码,扫码打开取件页并自动填入取件码、直接查询到文件。
5. 在线预览:文本(≤2MB)/PDF/图片(≤30MB)可在线阅读查看、音视频(白名单格式)可在线播放,均可下载;超限/非白名单仅下载。
6. 多种上传方式可用:拖拽、Ctrl+V 粘贴文件、点击选择、粘贴文字生成「文字前 12 位 + 日期时间」txt。
7. 有效期固定三档(24小时/7天/永久);含标签的文件永久保存不自动过期;过期文件到期后无法下载且被自动清理(懒检查 + 定时任务)。
8. 管理码可查列表、可删除;删除后取件凭证立即失效;下载次数仅统计不限制;**上传自动记录上传者 IP(IPv4/IPv6,仅管理列表可见)**。
9. **标签规则**:仅英文+数字;纯数字 >8 位(推荐手机号)、含字母 >4 位;**密码与标签可同时设置(不互斥)**,前端提示 + 后端校验兜底。
10. 超过大小上限(200MB)的文件被正确拦截。
11. 手机浏览器与微信内置浏览器均可用;手机微信扫码可完成上传/取件,可选择微信聊天记录中的文件。
12. **部署**:本地全流程测试通过后,经 FTP 发布至 IIS 站点(`https://wenchuanyi.bbitcn.net`)访问正常。
13. 前后端代码可一键本地启动,数据库自动建表。
---
## 10. 里程碑与交付
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| M1 需求确认 | 本需求文档定稿、待确认问题全部拍板 | ✅ 已完成 |
| M2 开发 | 后端 API + 前端三页面 + 前后端联调 | 未开始 |
| M3 交付 | 本地测试通过、IIS+FTP 部署说明与脚本齐全 | 未开始 |
---
## 11. 未来扩展(可选,本期不实现)
- 多文件上传(下载打包 zip
- 下载次数限制(本期仅统计不限制)
- 网盘容量与配额管理
- 界面中英文切换
- 前端 STS 直传 OSS(需提供 RAM RoleArn
---
## 12. 待确认问题清单(已全部确认)
> ✅ = 已确认(已更新到对应章节)
1. **单文件大小上限**:200MB(前后端双重校验,Kestrel 请求上限留余量)。✅
2. **文件类型**:格式不限;可执行/脚本类文件(.exe/.bat/.sh/.dll)展示风险提示。✅
3. **标签为"一对多"**:同一标签可关联多个文件,标签不要求唯一。✅
4. **标签文件取件方式**:以标签为取件凭证,输入标签 → 展示该标签下文件列表 → 逐项预览/下载。✅
5. **有效期档位**:固定三档 24 小时 / 7 天 / 永久,无自定义时长。✅
6. **下载次数**:不限次,仅统计。✅
7. **部署与微信扫码**:有公网;正式站 `https://wenchuanyi.bbitcn.net`;服务端支持 IIS 部署,FTP 发包(`ftp://116.198.221.125`,默认端口 21,用户 `wenchuanyi`),文件放 IIS 站点默认文件夹或 `wwwroot`**部署公网前先在本地测试**。✅
8. **磁盘策略**:无总容量上限、无单管理码文件数限制。✅
9. **运维告警**:磁盘空间无需提醒(不做磁盘监控/提醒功能)。✅
10. **密码与标签是否同时设置**:**不互斥、可同时设置**(同时设置时按私密文件识别 6 位取件码,且因含标签强制永久保存)。✅
11. **标签规则**:仅英文(A-Za-z)+ 数字、不含任何符号;纯数字须 >8 位(推荐 11 位手机号);含英文字母须 >4 位;大小写敏感(按原文存储匹配)。✅
12. **粘贴文字生成 txt 文件名**:**文字前 12 位 + 日期时间**(`{前12位}_{yyyyMMdd_HHmmss}.txt`,剔除 Windows 非法文件名字符,剔除后为空用「文本」兜底),不提供自定义。✅
13. **在线预览上限**:文本 2MB、PDF/图片 30MB、音视频不限大小(流式);超限仅下载。✅
14. **音视频预览格式**:仅浏览器原生播放格式——视频 mp4/webm,音频 mp3/wav/m4a/aac/ogg;不支持转码,非白名单提示下载。✅
15. **7 位数字输入的最终处理**:输入满 7 位时提示"取件码为 6 位或 8 位,请继续输入完整取件码",不自动查询,等用户继续输入或修正。✅
16. **品牌 Slogan**:**「文传易,免登录,传文件,真容易」**(上传页品牌区展示:Logo + 标语 + 三步使用提示)。✅
17. **私密文件密码长度****4-12 位**(≥4 且 ≤12,原 4-6 位扩展);前端校验 4-12 位,后端同规则兜底。✅
18. **标签为纯 6/8 位数字的冲突**:通过"纯数字标签须 >8 位(≥9)"规则规避,天然不与 6/8 位取件码冲突。✅
19. **记录上传者 IP**:上传(含开放 API)自动记录客户端 IP(IPv4/IPv6`RemoteIpAddress`varchar(45)),仅管理列表展示,取件页/公开接口不暴露;如后续引入反向代理需启用 `ForwardedHeaders`。✅