feat: 文传易匿名临时文件传输站初始版本
- 后端 .NET 8 + FreeSql + 阿里云 OSS,支持共享/私密/标签三种文件模式 - 前端 Vue3 + Vite + TDesign,上传/取件/管理三页 - 取件码/二维码/海报合成(含保存二维码为图片) - deploy/ 部署文档与一键 FTP 发布脚本 - appsettings.json 含真实凭据,已 gitignore 不入库
This commit is contained in:
+26
@@ -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/
|
||||
@@ -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 + tag(password 与 tag 可同时设置)</summary>
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(220_200_960)] // 210MB(Kestrel 同配置)
|
||||
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>文本内容读取(≤2MB,UTF-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>公开上传-共享:expiresHours(0=永久,缺省 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!;
|
||||
}
|
||||
@@ -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>上传者 IP(IPv4/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; }
|
||||
}
|
||||
@@ -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>校验标签:仅英文+数字;纯数字须 >8 位;含英文须 >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>获取对象(支持 Range;end 为 -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": "*"
|
||||
}
|
||||
@@ -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 Module(ANCM)直接处理。
|
||||
- 站点根目录布局见上;后端发布产物全部文件与前端 `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 凭据、数据库密码与文件内容明文。
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.env
|
||||
@@ -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>
|
||||
Generated
+3160
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
Vendored
+7
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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' } },
|
||||
],
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
@@ -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.MySql(CodeFirst 自动建表) |
|
||||
| 数据库 | 远程 MySQL 8.x(`116.198.221.125:3306`,库 `wenchuanyi`,用户 `wenchuanyi`) |
|
||||
| 文件存储 | 阿里云 OSS(`Aliyun.OSS` SDK,Endpoint `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 Bundle;FTP 发布(`ftp://116.198.221.125`,用户 `wenchuanyi`) |
|
||||
|
||||
### 6.2 系统架构
|
||||
```
|
||||
Vue3 SPA ──REST/JSON──> ASP.NET Core Web API ──FreeSql──> 远程 MySQL(files 表)
|
||||
│
|
||||
├── 阿里云 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`。✅
|
||||
Reference in New Issue
Block a user