新增农户功能升级:身份证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,182 @@
using AgriculturalPlatform.Api.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace AgriculturalPlatform.Api.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<User> Users => Set<User>();
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Farmer> Farmers => Set<Farmer>();
public DbSet<Region> Regions => Set<Region>();
public DbSet<Product> Products => Set<Product>();
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
public DbSet<PaymentRecord> PaymentRecords => Set<PaymentRecord>();
public DbSet<Invoice> Invoices => Set<Invoice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// 所有枚举属性以字符串形式存储,便于阅读
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType.IsEnum)
{
var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType);
var converter = (ValueConverter)Activator.CreateInstance(converterType)!;
property.SetValueConverter(converter);
}
}
}
// 用户
modelBuilder.Entity<User>()
.Property(u => u.Username).HasMaxLength(50);
modelBuilder.Entity<User>()
.HasIndex(u => u.Username).IsUnique();
modelBuilder.Entity<User>()
.HasOne(u => u.Org).WithMany().HasForeignKey(u => u.OrgId)
.OnDelete(DeleteBehavior.SetNull);
// 组织
modelBuilder.Entity<Organization>()
.Property(o => o.Type).HasMaxLength(32);
modelBuilder.Entity<Organization>()
.Property(o => o.Name).HasMaxLength(200);
modelBuilder.Entity<Organization>()
.HasIndex(o => new { o.Type, o.Name });
modelBuilder.Entity<Organization>()
.HasOne(o => o.Parent).WithMany().HasForeignKey(o => o.ParentId)
.OnDelete(DeleteBehavior.Restrict);
// 农户:身份证唯一
modelBuilder.Entity<Farmer>()
.Property(f => f.IdCard).HasMaxLength(18);
modelBuilder.Entity<Farmer>()
.HasIndex(f => f.IdCard).IsUnique();
modelBuilder.Entity<Farmer>()
.Property(f => f.Phone).HasMaxLength(20);
modelBuilder.Entity<Farmer>()
.HasIndex(f => f.Phone);
modelBuilder.Entity<Farmer>()
.Property(f => f.FarmerType).HasMaxLength(20);
modelBuilder.Entity<Farmer>()
.Property(f => f.IdCardFrontUrl).HasMaxLength(500);
modelBuilder.Entity<Farmer>()
.Property(f => f.IdCardBackUrl).HasMaxLength(500);
modelBuilder.Entity<Farmer>()
.Property(f => f.Province).HasMaxLength(50);
modelBuilder.Entity<Farmer>()
.Property(f => f.County).HasMaxLength(50);
modelBuilder.Entity<Farmer>()
.Property(f => f.Township).HasMaxLength(50);
modelBuilder.Entity<Farmer>()
.Property(f => f.Village).HasMaxLength(100);
modelBuilder.Entity<Farmer>()
.Property(f => f.GroupName).HasMaxLength(50);
modelBuilder.Entity<Farmer>()
.HasIndex(f => new { f.Province, f.County, f.Township });
// 行政区划(AreaCity 全国数据,表由导入脚本/启动建表创建)
modelBuilder.Entity<Region>()
.HasKey(r => r.Id);
modelBuilder.Entity<Region>()
.Property(r => r.Name).HasMaxLength(100);
modelBuilder.Entity<Region>()
.Property(r => r.ExtName).HasMaxLength(100);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Pid);
modelBuilder.Entity<Region>()
.HasIndex(r => r.Deep);
// 品种
modelBuilder.Entity<Product>()
.Property(p => p.Name).HasMaxLength(100);
modelBuilder.Entity<Product>()
.HasIndex(p => p.Name).IsUnique();
// 收购单
modelBuilder.Entity<PurchaseOrder>()
.Property(p => p.OrderNo).HasMaxLength(32);
modelBuilder.Entity<PurchaseOrder>()
.HasIndex(p => p.OrderNo).IsUnique();
modelBuilder.Entity<PurchaseOrder>()
.HasIndex(p => new { p.Status, p.CreatedAt });
modelBuilder.Entity<PurchaseOrder>()
.HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(p => p.Product).WithMany().HasForeignKey(p => p.ProductId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PurchaseOrder>()
.HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId)
.OnDelete(DeleteBehavior.SetNull);
// 支付
modelBuilder.Entity<PaymentRecord>()
.Property(p => p.PayNo).HasMaxLength(32);
modelBuilder.Entity<PaymentRecord>()
.HasIndex(p => p.PayNo).IsUnique();
modelBuilder.Entity<PaymentRecord>()
.HasIndex(p => new { p.Status, p.CreatedAt });
modelBuilder.Entity<PaymentRecord>()
.HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PaymentRecord>()
.HasOne(p => p.PurchaseOrder).WithMany().HasForeignKey(p => p.PurchaseOrderId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<PaymentRecord>()
.HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PaymentRecord>()
.HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId)
.OnDelete(DeleteBehavior.SetNull);
// 发票
modelBuilder.Entity<Invoice>()
.Property(i => i.InvoiceNo).HasMaxLength(32);
modelBuilder.Entity<Invoice>()
.HasIndex(i => i.InvoiceNo).IsUnique();
modelBuilder.Entity<Invoice>()
.Property(i => i.BatchNo).HasMaxLength(32);
modelBuilder.Entity<Invoice>()
.HasIndex(i => i.BatchNo);
modelBuilder.Entity<Invoice>()
.HasIndex(i => new { i.Status, i.IssueDate });
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Farmer).WithMany().HasForeignKey(i => i.FarmerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.PurchaserOrg).WithMany().HasForeignKey(i => i.PurchaserOrgId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.PurchaseOrder).WithMany().HasForeignKey(i => i.PurchaseOrderId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<Invoice>()
.HasOne(i => i.Operator).WithMany().HasForeignKey(i => i.OperatorId)
.OnDelete(DeleteBehavior.SetNull);
// 金额/重量精度
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
if (property.ClrType == typeof(decimal) || property.ClrType == typeof(decimal?))
{
property.SetPrecision(18);
property.SetScale(2);
}
}
}
}
}
@@ -0,0 +1,195 @@
using AgriculturalPlatform.Api.Models;
namespace AgriculturalPlatform.Api.Data;
/// <summary>初始化数据(仅在空库时执行)</summary>
public static class DbSeeder
{
public static void Seed(AppDbContext db)
{
if (db.Users.Any()) return;
var now = DateTime.Now;
// ---------- 组织 ----------
var company = new Organization
{
Type = OrgType.Company,
Name = "绿源农产品收购有限公司",
ContactPerson = "王建国",
Phone = "13800000001",
Address = "河南省郑州市中牟县官渡大道66号",
TaxNo = "91410100MA3X8K7Q2B",
CreatedAt = now
};
var stationEast = new Organization
{
Type = OrgType.Station,
Name = "城东收购站",
ContactPerson = "李强",
Phone = "13800000002",
Address = "中牟县官渡镇城东村",
CreatedAt = now
};
var stationWest = new Organization
{
Type = OrgType.Station,
Name = "城西收购站",
ContactPerson = "赵敏",
Phone = "13800000003",
Address = "中牟县韩寺镇城西村",
CreatedAt = now
};
var individual = new Organization
{
Type = OrgType.Individual,
Name = "张伟(个体收购)",
ContactPerson = "张伟",
Phone = "13800000004",
Address = "中牟县刁家乡",
TaxNo = "410122197501011234",
CreatedAt = now
};
db.Organizations.AddRange(company, stationEast, stationWest, individual);
db.SaveChanges();
stationEast.ParentId = company.Id;
stationWest.ParentId = company.Id;
db.SaveChanges();
// ---------- 用户(默认密码均为 123456 ----------
var users = new List<User>
{
new() { Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "系统管理员", Phone = "13800000000", Role = UserRole.SuperAdmin, CreatedAt = now },
new() { Username = "company", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "王建国", Phone = "13800000001", Role = UserRole.CompanyAdmin, OrgId = company.Id, CreatedAt = now },
new() { Username = "station1", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "李强", Phone = "13800000002", Role = UserRole.StationStaff, OrgId = stationEast.Id, CreatedAt = now },
new() { Username = "station2", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "赵敏", Phone = "13800000003", Role = UserRole.StationStaff, OrgId = stationWest.Id, CreatedAt = now },
new() { Username = "individual", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "张伟", Phone = "13800000004", Role = UserRole.Individual, OrgId = individual.Id, CreatedAt = now }
};
db.Users.AddRange(users);
db.SaveChanges();
// ---------- 品种 ----------
var products = new List<Product>
{
new() { Name = "小麦", Category = "粮食", Unit = "公斤", Spec = "水分≤13%,容重≥750g/L", Price = 2.42m },
new() { Name = "玉米", Category = "粮食", Unit = "公斤", Spec = "水分≤14%,杂质≤1%", Price = 2.18m },
new() { Name = "水稻", Category = "粮食", Unit = "公斤", Spec = "出糙率≥77%", Price = 2.68m },
new() { Name = "花生", Category = "油料", Unit = "公斤", Spec = "果仁饱满,出仁率≥70%", Price = 7.80m },
new() { Name = "大蒜", Category = "蔬菜", Unit = "公斤", Spec = "5cm以上,干度达标", Price = 4.50m },
new() { Name = "西红柿", Category = "蔬菜", Unit = "公斤", Spec = "一级果,单果150g以上", Price = 2.60m },
new() { Name = "苹果", Category = "水果", Unit = "公斤", Spec = "红富士,直径75mm以上", Price = 5.20m },
new() { Name = "棉花", Category = "经济作物", Unit = "公斤", Spec = "衣分≥38%", Price = 15.60m }
};
db.Products.AddRange(products);
db.SaveChanges();
// ---------- 农户 ----------
var farmers = new List<Farmer>
{
new() { Name = "刘老汉", IdCard = "410122197003120011", Gender = "男", Phone = "13911110001", Village = "城东村一组", Address = "城东村一组12号", BankName = "中牟农商银行", BankAccount = "6228480012345678901", CreditScore = 92 },
new() { Name = "陈秀英", IdCard = "410122196806250022", Gender = "女", Phone = "13911110002", Village = "城东村二组", Address = "城东村二组8号", BankName = "中牟农商银行", BankAccount = "6228480012345678902", CreditScore = 88 },
new() { Name = "张铁柱", IdCard = "410122198204170033", Gender = "男", Phone = "13911110003", Village = "城西村一组", Address = "城西村一组21号", BankName = "农业银行", BankAccount = "6228480012345678903", CreditScore = 85 },
new() { Name = "王翠花", IdCard = "410122197512080044", Gender = "女", Phone = "13911110004", Village = "城西村三组", Address = "城西村三组5号", BankName = "邮政储蓄银行", BankAccount = "6228480012345678904", CreditScore = 90 },
new() { Name = "赵大勇", IdCard = "410122199003300055", Gender = "男", Phone = "13911110005", Village = "官渡镇贾庄村", Address = "贾庄村16号", BankName = "中牟农商银行", BankAccount = "6228480012345678905", CreditScore = 82 },
new() { Name = "孙秀兰", IdCard = "410122196511150066", Gender = "女", Phone = "13911110006", Village = "韩寺镇马家村", Address = "马家村9号", BankName = "农业银行", BankAccount = "6228480012345678906", CreditScore = 78 },
new() { Name = "李保田", IdCard = "410122197807210077", Gender = "男", Phone = "13911110007", Village = "刁家乡水沱寨村", Address = "水沱寨村3号", BankName = "邮政储蓄银行", BankAccount = "6228480012345678907", CreditScore = 86 },
new() { Name = "周桂芳", IdCard = "410122198906060088", Gender = "女", Phone = "13911110008", Village = "官渡镇前于村", Address = "前于村28号", BankName = "中牟农商银行", BankAccount = "6228480012345678908", CreditScore = 91 }
};
db.Farmers.AddRange(farmers);
db.SaveChanges();
// ---------- 历史收购单(近30天,用于报表与大屏演示) ----------
var rnd = new Random(20260812);
var purchaserIds = new[] { company.Id, stationEast.Id, stationWest.Id, individual.Id };
var productIds = products.Select(p => p.Id).ToArray();
var farmerIds = farmers.Select(f => f.Id).ToArray();
var userIds = users.Select(u => u.Id).ToArray();
var orders = new List<PurchaseOrder>();
for (int day = 29; day >= 0; day--)
{
int count = rnd.Next(3, 8);
for (int i = 0; i < count; i++)
{
var createAt = now.Date.AddDays(-day).AddHours(rnd.Next(7, 18)).AddMinutes(rnd.Next(0, 59));
var productId = productIds[rnd.Next(productIds.Length)];
var gross = rnd.Next(5000, 32000);
var tare = rnd.Next(3200, 9000);
var net = gross - tare;
if (net <= 0) continue;
var price = products.First(p => p.Id == productId).Price + (decimal)rnd.Next(-10, 10) / 10m;
orders.Add(new PurchaseOrder
{
OrderNo = $"CG{createAt:yyyyMMdd}-{(day * 10 + i):D4}",
FarmerId = farmerIds[rnd.Next(farmerIds.Length)],
ProductId = productId,
PurchaserOrgId = purchaserIds[rnd.Next(purchaserIds.Length)],
Grade = new[] { "一等", "二等", "三等" }[rnd.Next(3)],
Unit = "公斤",
UnitPrice = price,
GrossWeight = gross,
TareWeight = tare,
NetWeight = net,
Amount = Math.Round(net * price, 2),
WeighCount = 2,
Status = PurchaseStatus.Completed,
OperatorId = userIds[rnd.Next(userIds.Length)],
WeighInAt = createAt,
WeighOutAt = createAt.AddMinutes(rnd.Next(15, 50)),
CreatedAt = createAt,
UpdatedAt = createAt
});
}
}
db.PurchaseOrders.AddRange(orders);
db.SaveChanges();
// ---------- 历史付款(对应部分订单) ----------
var payOrders = orders.Where(o => o.Status == PurchaseStatus.Completed)
.OrderBy(o => o.CreatedAt).Take(120).ToList();
var methods = new[] { PayMethod.Wechat, PayMethod.Alipay, PayMethod.BankTransfer, PayMethod.Cash };
var payments = payOrders.Select((o, idx) => new PaymentRecord
{
PayNo = $"ZF{o.CreatedAt:yyyyMMdd}-{idx + 1:D4}",
PurchaseOrderId = o.Id,
FarmerId = o.FarmerId,
Amount = o.Amount,
Method = methods[rnd.Next(methods.Length)],
Status = PayStatus.Success,
TradeNo = $"TRADE{rnd.Next(100000000, 999999999)}",
OperatorId = o.OperatorId,
PaidAt = o.WeighOutAt,
CreatedAt = o.WeighOutAt!.Value,
Notes = $"收购单 {o.OrderNo} 货款结算"
}).ToList();
db.PaymentRecords.AddRange(payments);
db.SaveChanges();
// ---------- 历史发票(对应部分订单,反向开票) ----------
var invoiceOrders = orders.Where(o => o.Status == PurchaseStatus.Completed)
.OrderByDescending(o => o.CreatedAt).Take(80).ToList();
var invoices = invoiceOrders.Select((o, idx) => new Invoice
{
InvoiceNo = $"FP{o.CreatedAt:yyyyMMdd}-{idx + 1:D4}",
InvoiceKind = "农产品收购发票",
FarmerId = o.FarmerId,
PurchaserOrgId = o.PurchaserOrgId,
PurchaseOrderId = o.Id,
ProductId = o.ProductId,
Quantity = o.NetWeight,
Unit = o.Unit,
UnitPrice = o.UnitPrice,
Amount = o.Amount,
TaxRate = 0m,
TaxAmount = 0m,
IssueDate = o.WeighOutAt ?? o.CreatedAt,
Status = InvoiceStatus.Issued,
OperatorId = o.OperatorId,
CreatedAt = o.WeighOutAt ?? o.CreatedAt
}).ToList();
db.Invoices.AddRange(invoices);
db.SaveChanges();
}
}
@@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore;
namespace AgriculturalPlatform.Api.Data;
/// <summary>
/// 兼容已存在的数据库:EnsureCreated 不会为已存在的表补列,
/// 这里通过 information_schema 检测后逐列执行 ALTER TABLE
/// 为 Farmers 表补充四级行政区划等新列(兼容 MySQL 5.7 / 8.0 各版本)。
/// </summary>
public static class SchemaMigrator
{
public static async Task EnsureColumnsAsync(AppDbContext db)
{
var existing = new HashSet<string>(
await db.Database
.SqlQuery<string>($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Farmers'")
.ToListAsync(),
StringComparer.OrdinalIgnoreCase);
(string Name, string Ddl)[] columns =
[
("Province", "Province NVARCHAR(50) NULL"),
("County", "County NVARCHAR(50) NULL"),
("Township", "Township NVARCHAR(50) NULL"),
("GroupName", "GroupName NVARCHAR(50) NULL"),
("FarmerType", "FarmerType NVARCHAR(20) NULL"),
("IdCardFrontUrl", "IdCardFrontUrl NVARCHAR(500) NULL"),
("IdCardBackUrl", "IdCardBackUrl NVARCHAR(500) NULL"),
("AvatarUrl", "AvatarUrl NVARCHAR(500) NULL"),
];
foreach (var col in columns)
{
if (existing.Contains(col.Name)) continue;
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE Farmers ADD COLUMN {col.Ddl}");
}
// 历史数据的新列为 NULL,EF 映射为不可空 string 读取会抛 DBNull 异常,统一刷新为空串(幂等)
await db.Database.ExecuteSqlRawAsync(
"UPDATE Farmers SET Province = COALESCE(Province,''), County = COALESCE(County,''), " +
"Township = COALESCE(Township,''), GroupName = COALESCE(GroupName,''), " +
"FarmerType = COALESCE(FarmerType,'农户'), " +
"IdCardFrontUrl = COALESCE(IdCardFrontUrl,''), IdCardBackUrl = COALESCE(IdCardBackUrl,''), " +
"AvatarUrl = COALESCE(AvatarUrl,'')");
}
/// <summary>
/// 确保 Regions(行政区划)表存在。
/// 表结构与 scripts/areacity/import-regions.mjs 导入脚本一致;
/// 数据由该脚本导入(全国省市区乡镇四级,约 4.2 万条)。
/// </summary>
public static async Task EnsureRegionsTableAsync(AppDbContext db)
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS Regions (
Id INT NOT NULL PRIMARY KEY,
Pid INT NOT NULL,
Deep INT NOT NULL,
Name VARCHAR(100) NOT NULL,
ExtName VARCHAR(100) NOT NULL,
Pinyin VARCHAR(200) NOT NULL DEFAULT '',
PinyinPrefix VARCHAR(10) NOT NULL DEFAULT '',
Hot INT NOT NULL DEFAULT 0,
KEY idx_regions_pid (Pid),
KEY idx_regions_deep (Deep)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
""");
// 老库补充 Hot 热度列(幂等)
var hotExists = await db.Database.SqlQuery<int>(
$"SELECT COUNT(*) AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Regions' AND COLUMN_NAME = 'Hot'")
.FirstOrDefaultAsync();
if (hotExists == 0)
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Regions ADD COLUMN Hot INT NOT NULL DEFAULT 0");
// 常用省份热度值(值越大越靠前,幂等可重复执行)
(string Name, int Hot)[] hotProvinces =
[
("四川省", 20), ("广西壮族自治区", 19), ("陕西省", 18), ("重庆市", 17), ("安徽省", 16),
("云南省", 15), ("贵州省", 14), ("河南省", 13), ("山东省", 12), ("湖北省", 11), ("湖南省", 10),
("甘肃省", 9), ("新疆维吾尔自治区", 8), ("江西省", 7), ("福建省", 6), ("广东省", 5),
("河北省", 4), ("山西省", 3), ("江苏省", 2), ("浙江省", 1),
];
foreach (var p in hotProvinces)
await db.Database.ExecuteSqlRawAsync(
"UPDATE Regions SET Hot = {0} WHERE Deep = 0 AND ExtName = {1}", p.Hot, p.Name);
}
}