新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化

- Region 表新增热度列,热门省份优先显示
- 区县下拉按地级市分组、已录区县优先、支持全省搜索
- 组选择内置一组至二十组
- 银行卡号独立行+粗体预览,开户行独立行
- 新增农户类型:农户/个体户/合作社/经营集体/公司
- 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS
- 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展
- 同名村弹窗提醒(防选错乡镇)
- 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作
- 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
2026-08-13 16:07:35 +08:00
commit c377421a3e
93 changed files with 12934 additions and 0 deletions
@@ -0,0 +1,39 @@
using System.Security.Claims;
using AgriculturalPlatform.Api.Models;
namespace AgriculturalPlatform.Api.Services;
/// <summary>从 JWT 声明中解析的当前登录用户</summary>
public class CurrentUser
{
public int Id { get; init; }
public string Username { get; init; } = string.Empty;
public string RealName { get; init; } = string.Empty;
public UserRole Role { get; init; }
public int? OrgId { get; init; }
public bool IsSuperAdmin => Role == UserRole.SuperAdmin;
}
public class CurrentUserService(IHttpContextAccessor accessor)
{
public CurrentUser? Get()
{
var principal = accessor.HttpContext?.User;
if (principal is null) return null;
var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (!int.TryParse(idClaim, out var id)) return null;
Enum.TryParse<UserRole>(principal.FindFirstValue(ClaimTypes.Role), out var role);
int? orgId = int.TryParse(principal.FindFirstValue("orgId"), out var o) ? o : null;
return new CurrentUser
{
Id = id,
Username = principal.FindFirstValue(ClaimTypes.Name) ?? "",
RealName = principal.FindFirstValue("realName") ?? "",
Role = role,
OrgId = orgId
};
}
}
@@ -0,0 +1,42 @@
using AgriculturalPlatform.Api.Data;
using AgriculturalPlatform.Api.Models;
using Microsoft.EntityFrameworkCore;
namespace AgriculturalPlatform.Api.Services;
/// <summary>
/// 数据权限:根据当前用户角色计算其可见的组织范围
/// - 系统管理员:全部
/// - 公司管理员:本公司 + 旗下收购站
/// - 收购站员工:本站
/// - 收购个体:本人
/// </summary>
public class DataScopeService(AppDbContext db)
{
/// <summary>返回可见组织 ID 集合;null 表示全部可见</summary>
public async Task<HashSet<int>?> GetVisibleOrgIdsAsync(CurrentUser? user)
{
if (user is null) return new HashSet<int>();
if (user.IsSuperAdmin) return null;
var ids = new HashSet<int>();
switch (user.Role)
{
case UserRole.CompanyAdmin:
if (user.OrgId.HasValue)
{
ids.Add(user.OrgId.Value);
var stationIds = await db.Organizations
.Where(o => o.ParentId == user.OrgId && o.Type == OrgType.Station)
.Select(o => o.Id).ToListAsync();
ids.UnionWith(stationIds);
}
break;
case UserRole.StationStaff:
case UserRole.Individual:
if (user.OrgId.HasValue) ids.Add(user.OrgId.Value);
break;
}
return ids;
}
}
@@ -0,0 +1,79 @@
namespace AgriculturalPlatform.Api.Services;
/// <summary>已保存文件的存储 key 与访问地址</summary>
public record StoredFile(string Key, string Url);
/// <summary>
/// 附件文件存储抽象。
/// 当前默认实现为本地存储(wwwroot/uploads);
/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点),
/// 将 Storage:UseOss 设为 true 即可切换到 OSS。
/// </summary>
public interface IFileStorage
{
/// <summary>保存文件,返回存储 key 与访问 URL</summary>
Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType);
/// <summary>按 key 删除文件(本地/OSS 通用)</summary>
Task DeleteAsync(string key);
}
/// <summary>本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问</summary>
public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
{
private readonly string _root = Path.Combine(
env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads");
public async Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
{
var dateDir = DateTime.Now.ToString("yyyyMM");
var dir = Path.Combine(_root, subDir, dateDir);
Directory.CreateDirectory(dir);
var ext = Path.GetExtension(fileName);
if (string.IsNullOrWhiteSpace(ext)) ext = ".jpg";
if (ext.Length > 10) ext = ".jpg";
var name = $"{Guid.NewGuid():N}{ext}";
var full = Path.Combine(dir, name);
await using var fs = File.Create(full);
await stream.CopyToAsync(fs);
var key = $"/uploads/{subDir}/{dateDir}/{name}";
return new StoredFile(key, key);
}
public Task DeleteAsync(string key)
{
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/")) return Task.CompletedTask;
try
{
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(full)) File.Delete(full);
}
catch
{
// 忽略删除失败
}
return Task.CompletedTask;
}
}
/// <summary>
/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供;
/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。
/// </summary>
public sealed class OssFileStorage(IConfiguration cfg) : IFileStorage
{
public bool IsConfigured =>
!string.IsNullOrWhiteSpace(cfg["Oss:AccessKeyId"])
&& !string.IsNullOrWhiteSpace(cfg["Oss:AccessKeySecret"])
&& !string.IsNullOrWhiteSpace(cfg["Oss:Bucket"]);
public Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
=> throw new InvalidOperationException(
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
public Task DeleteAsync(string key)
=> throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除");
}
@@ -0,0 +1,37 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using AgriculturalPlatform.Api.Models;
using Microsoft.IdentityModel.Tokens;
namespace AgriculturalPlatform.Api.Services;
public class JwtService(IConfiguration config)
{
private readonly string _key = config["Jwt:Key"] ?? "AgriculturalPlatform-Default-Jwt-Secret-Key-2026!";
private readonly string _issuer = config["Jwt:Issuer"] ?? "AgriculturalPlatform";
private readonly string _audience = config["Jwt:Audience"] ?? "AgriculturalPlatform.Client";
public string Generate(User user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.Role, user.Role.ToString()),
new("realName", user.RealName),
new("orgId", user.OrgId?.ToString() ?? "")
};
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _issuer,
audience: _audience,
claims: claims,
expires: DateTime.Now.AddDays(7),
signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
@@ -0,0 +1,47 @@
using System.Collections.Concurrent;
namespace AgriculturalPlatform.Api.Services;
/// <summary>手机传图上传会话(内存存储,10 分钟过期)</summary>
public sealed class MobileUploadSession
{
public string Code { get; init; } = string.Empty;
public DateTime CreatedAt { get; init; } = DateTime.Now;
public StoredFile? Front { get; set; }
public StoredFile? Back { get; set; }
public IdCardOcrResult? FrontOcr { get; set; }
public IdCardOcrResult? BackOcr { get; set; }
public bool Done { get; set; }
}
/// <summary>手机传图上传会话存储(单机内存版,后续可换 Redis</summary>
public sealed class MobileUploadSessionStore
{
private static readonly TimeSpan Expire = TimeSpan.FromMinutes(10);
private readonly ConcurrentDictionary<string, MobileUploadSession> _sessions = new();
public string Create()
{
Cleanup();
var code = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
_sessions[code] = new MobileUploadSession { Code = code };
return code;
}
public MobileUploadSession? Get(string code)
{
if (string.IsNullOrWhiteSpace(code)) return null;
return _sessions.TryGetValue(code.Trim().ToUpperInvariant(), out var s) ? s : null;
}
public void Remove(string code) => _sessions.TryRemove(code, out _);
private void Cleanup()
{
var now = DateTime.Now;
foreach (var kv in _sessions)
{
if (now - kv.Value.CreatedAt > Expire) _sessions.TryRemove(kv.Key, out _);
}
}
}
@@ -0,0 +1,37 @@
using AgriculturalPlatform.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace AgriculturalPlatform.Api.Services;
/// <summary>业务单号生成:前缀 + 日期 + 当日序号(如 CG20260812-0001</summary>
public class NumberGenerator(AppDbContext db)
{
public async Task<string> NextAsync(string prefix, DateOnly? date = null)
{
var day = date ?? DateOnly.FromDateTime(DateTime.Now);
var seq = 1;
try
{
// 从各单据表按当日已存在数量生成序号
var todayStart = day.ToDateTime(TimeOnly.MinValue);
var todayEnd = todayStart.AddDays(1);
var count = prefix switch
{
"CG" => await db.PurchaseOrders.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
"ZF" => await db.PaymentRecords.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
"FP" => await db.Invoices.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
"PK" => await db.Invoices
.Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "")
.Select(x => x.BatchNo).Distinct().CountAsync(),
_ => 0
};
seq = count + 1;
}
catch
{
// 数据库不可用时使用随机序号
}
return $"{prefix}{day:yyyyMMdd}-{seq:D4}";
}
}
@@ -0,0 +1,33 @@
namespace AgriculturalPlatform.Api.Services;
/// <summary>身份证 OCR 识别结果(未识别成功时 Success=false 并给出原因)</summary>
public record IdCardOcrResult(
string Name = "",
string IdCard = "",
string Gender = "",
string Address = "",
string BankName = "",
string BankAccount = "",
bool Success = false,
string Message = "");
/// <summary>
/// OCR 识别服务抽象。当前默认实现为 DisabledOcrService(仅占位);
/// 后续接入阿里云/百度 OCR 时,实现本接口并在 Program.cs 替换注册即可,调用方无需改动。
/// </summary>
public interface IOcrService
{
/// <summary>识别身份证图片,side: front(正面)/ back(反面)</summary>
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg");
}
/// <summary>默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件</summary>
public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService
{
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg")
{
var message = cfg["Ocr:DisabledMessage"]
?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写";
return Task.FromResult(new IdCardOcrResult(Success: false, Message: message));
}
}