新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化
- Region 表新增热度列,热门省份优先显示 - 区县下拉按地级市分组、已录区县优先、支持全省搜索 - 组选择内置一组至二十组 - 银行卡号独立行+粗体预览,开户行独立行 - 新增农户类型:农户/个体户/合作社/经营集体/公司 - 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS - 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展 - 同名村弹窗提醒(防选错乡镇) - 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作 - 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
[Authorize]
|
||||
public class DashboardController(
|
||||
AppDbContext db, DataScopeService scope, CurrentUserService currentUser,
|
||||
IHttpClientFactory httpFactory) : ControllerBase
|
||||
{
|
||||
private async Task<IQueryable<PurchaseOrder>> ScopedOrdersAsync()
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>解析查询区间:[From, ToExclusive)。未传 from 时默认回溯 days 天。</summary>
|
||||
private static (DateTime From, DateTime ToExclusive) ResolveRange(DateTime? from, DateTime? to, int days = 30)
|
||||
{
|
||||
var end = to?.Date ?? DateTime.Today;
|
||||
var start = from?.Date ?? end.AddDays(-(days - 1));
|
||||
return (start, end.AddDays(1));
|
||||
}
|
||||
|
||||
/// <summary>大屏概览 KPI(按日期区间)</summary>
|
||||
[HttpGet("overview")]
|
||||
public async Task<ActionResult<OverviewDto>> Overview(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var completed = orders.Where(p => p.Status == PurchaseStatus.Completed
|
||||
&& p.CreatedAt >= start && p.CreatedAt < endEx);
|
||||
|
||||
var amount = await completed.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var count = await orders.CountAsync(p => p.CreatedAt >= start && p.CreatedAt < endEx);
|
||||
var netWeight = await completed.SumAsync(p => (decimal?)p.NetWeight) ?? 0;
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var payQuery = db.PaymentRecords.Where(p => p.Status == PayStatus.Success).AsQueryable();
|
||||
if (visible is not null)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
var paid = await payQuery
|
||||
.Where(p => p.PaidAt >= start && p.PaidAt < endEx)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var avgPrice = netWeight > 0 ? Math.Round(amount / netWeight, 2) : 0;
|
||||
var farmerCount = await completed.Select(p => p.FarmerId).Distinct().CountAsync();
|
||||
var weighingCount = await orders.CountAsync(p => p.Status == PurchaseStatus.Weighing);
|
||||
|
||||
return Ok(new OverviewDto(amount, count, netWeight, paid, avgPrice, farmerCount, weighingCount));
|
||||
}
|
||||
|
||||
/// <summary>区间收购金额/重量趋势(逐日)</summary>
|
||||
[HttpGet("trend")]
|
||||
public async Task<ActionResult<TrendPoint[]>> Trend(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 14);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month, p.CreatedAt.Day })
|
||||
.Select(g => new { Key = new DateTime(g.Key.Year, g.Key.Month, g.Key.Day), Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight) })
|
||||
.ToListAsync();
|
||||
var map = rows.ToDictionary(r => r.Key, r => r);
|
||||
|
||||
var days = (int)(endEx - start).TotalDays;
|
||||
var result = Enumerable.Range(0, days).Select(i =>
|
||||
{
|
||||
var date = start.AddDays(i);
|
||||
map.TryGetValue(date, out var row);
|
||||
return new TrendPoint(date.ToString("MM-dd"), row?.Amount ?? 0, row?.Weight ?? 0);
|
||||
}).ToArray();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>品种收购金额占比</summary>
|
||||
[HttpGet("product-distribution")]
|
||||
public async Task<ActionResult<RatioPoint[]>> ProductDistribution(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.Product!.Name, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.Name)
|
||||
.Select(g => new RatioPoint(g.Key, g.Sum(p => p.Amount)))
|
||||
.OrderByDescending(r => r.Value)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>农户收购金额排行</summary>
|
||||
[HttpGet("farmer-top")]
|
||||
public async Task<ActionResult<FarmerTopPoint[]>> FarmerTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => new { p.FarmerId, p.Name })
|
||||
.Select(g => new FarmerTopPoint(
|
||||
g.Key.FarmerId, g.Key.Name,
|
||||
g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight)))
|
||||
.OrderByDescending(r => r.Amount).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>收购方(公司/站/个体)对比</summary>
|
||||
[HttpGet("station-comparison")]
|
||||
public async Task<ActionResult<StationPoint[]>> StationComparison(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.PurchaserOrgId, Name = p.PurchaserOrg!.Name, p.Amount, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => new { p.PurchaserOrgId, p.Name })
|
||||
.Select(g => new StationPoint(
|
||||
g.Key.PurchaserOrgId, g.Key.Name,
|
||||
g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight)))
|
||||
.OrderByDescending(r => r.Amount)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>收购单价排行(按品种平均单价)</summary>
|
||||
[HttpGet("price-top")]
|
||||
public async Task<ActionResult<PriceTopPoint[]>> PriceTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { Name = p.Product!.Name, p.UnitPrice, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.Name)
|
||||
.Select(g => new PriceTopPoint(
|
||||
g.Key, Math.Round(g.Average(p => p.UnitPrice), 2),
|
||||
g.Sum(p => p.NetWeight), g.Count()))
|
||||
.OrderByDescending(r => r.AvgPrice).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>农户收入排行(收入 = 区间收购金额,附已付金额)</summary>
|
||||
[HttpGet("income-top")]
|
||||
public async Task<ActionResult<IncomeTopPoint[]>> IncomeTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var farmerRows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.FarmerId)
|
||||
.Select(g => new { FarmerId = g.Key, Name = g.First().Name, Amount = g.Sum(p => p.Amount), Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var payQuery = db.PaymentRecords
|
||||
.Where(p => p.Status == PayStatus.Success && p.PaidAt >= start && p.PaidAt < endEx).AsQueryable();
|
||||
if (visible is not null)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
var paidMap = (await payQuery
|
||||
.Select(p => new { p.FarmerId, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.FarmerId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));
|
||||
|
||||
var result = farmerRows
|
||||
.Select(r => new IncomeTopPoint(r.FarmerId, r.Name, r.Amount, paidMap.GetValueOrDefault(r.FarmerId), r.Count))
|
||||
.OrderByDescending(r => r.Amount).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>实时过磅(进行中的收购单)</summary>
|
||||
[HttpGet("realtime-weighing")]
|
||||
public async Task<ActionResult<RealtimeWeighing[]>> RealtimeWeighing()
|
||||
{
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Weighing)
|
||||
.OrderByDescending(p => p.WeighInAt).Take(20)
|
||||
.Select(p => new RealtimeWeighing(
|
||||
p.Id, p.OrderNo, p.Farmer!.Name, p.Product!.Name,
|
||||
p.Status.ToString(), p.GrossWeight, p.WeighInAt))
|
||||
.ToListAsync();
|
||||
return Ok(rows.ToArray());
|
||||
}
|
||||
|
||||
// ---------- 天气(ShowAPI ip-to-weather:IP 定位实时天气) ----------
|
||||
|
||||
/// <summary>ShowAPI 天气中文描述 -> 图标</summary>
|
||||
private static string IconFor(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "🌡️";
|
||||
if (text.Contains("晴")) return "☀️";
|
||||
if (text.Contains("雷") || text.Contains("电")) return "⛈️";
|
||||
if (text.Contains("雨")) return "🌧️";
|
||||
if (text.Contains("雪")) return "🌨️";
|
||||
if (text.Contains("多云") || text.Contains("少云") || text.Contains("转")) return "⛅";
|
||||
if (text.Contains("阴")) return "☁️";
|
||||
if (text.Contains("雾") || text.Contains("霾")) return "🌫️";
|
||||
if (text.Contains("风")) return "🌪️";
|
||||
if (text.Contains("霜")) return "🥶";
|
||||
return "🌡️";
|
||||
}
|
||||
|
||||
private static (DateTime Time, WeatherInfo? Data) _weatherCache;
|
||||
|
||||
/// <summary>天气预报:ShowAPI ip-to-weather(按调用方 IP 定位,返回实时天气)</summary>
|
||||
[HttpGet("weather")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<WeatherInfo>> Weather()
|
||||
{
|
||||
if (_weatherCache.Data is not null && DateTime.UtcNow - _weatherCache.Time < TimeSpan.FromMinutes(30))
|
||||
return Ok(_weatherCache.Data);
|
||||
|
||||
var client = httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
try
|
||||
{
|
||||
var ip = await ResolveClientIpAsync(client);
|
||||
|
||||
// ShowAPI ip-to-weather:必须携带 ip 参数,Authorization 头带 AppCode
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get,
|
||||
$"https://ali-weather.showapi.com/ip-to-weather?ip={Uri.EscapeDataString(ip)}");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "APPCODE e3bdae1a73d2415492e7fdc024f07d08");
|
||||
using var resp = await client.SendAsync(request);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("showapi_res_code", out var rc) && rc.GetInt32() != 0)
|
||||
return StatusCode(502, new
|
||||
{
|
||||
message = "天气服务暂不可用",
|
||||
detail = root.TryGetProperty("showapi_res_error", out var re) ? re.GetString() : "未知错误"
|
||||
});
|
||||
|
||||
var body = root.GetProperty("showapi_res_body");
|
||||
var cityInfo = body.GetProperty("cityInfo");
|
||||
var now = body.GetProperty("now");
|
||||
var today = body.TryGetProperty("f1", out var f1) ? f1 : default;
|
||||
|
||||
var city = cityInfo.TryGetProperty("c3", out var c) ? c.GetString() ?? "" : "";
|
||||
var region = cityInfo.TryGetProperty("c7", out var p) ? p.GetString() ?? "" : "";
|
||||
|
||||
var text = now.TryGetProperty("weather", out var w) ? w.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(text)) text = "未知";
|
||||
|
||||
var temp = ParseWeatherNumber(now, "temperature");
|
||||
var feels = ParseWeatherNumber(now, "feels_like");
|
||||
var humidity = ParseWeatherNumber(now, "sd");
|
||||
var wind = $"{GetWeatherField(now, "wind_direction")} {GetWeatherField(now, "wind_power")}".Trim();
|
||||
|
||||
var min = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "night_air_temperature") : temp;
|
||||
var max = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "day_air_temperature") : temp;
|
||||
|
||||
var result = new WeatherInfo(
|
||||
city, region, text, IconFor(text),
|
||||
temp, feels, humidity, wind,
|
||||
min, max, DateTime.Now, "ShowAPI");
|
||||
|
||||
_weatherCache = (DateTime.UtcNow, result);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(502, new { message = "天气服务暂不可用", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>解析调用方 IP:代理转发头 -> 直连 IP -> 本机公网 IP 兜底(本地开发时用)</summary>
|
||||
private async Task<string> ResolveClientIpAsync(HttpClient client)
|
||||
{
|
||||
var fwd = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(fwd))
|
||||
{
|
||||
var ip = fwd.Split(',')[0].Trim();
|
||||
if (!IsPrivateIp(ip)) return ip;
|
||||
}
|
||||
var remote = HttpContext.Connection.RemoteIpAddress?.MapToIPv4().ToString() ?? "";
|
||||
if (!IsPrivateIp(remote)) return remote;
|
||||
|
||||
try { return (await client.GetStringAsync("http://ip.3322.net")).Trim(); }
|
||||
catch { return remote.Length > 0 ? remote : "0.0.0.0"; }
|
||||
}
|
||||
|
||||
private static bool IsPrivateIp(string ip)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ip) || ip == "::1") return true;
|
||||
if (!IPAddress.TryParse(ip, out var addr)) return true;
|
||||
var bytes = addr.GetAddressBytes();
|
||||
if (bytes.Length != 4) return true; // 仅处理 IPv4
|
||||
return bytes[0] == 10
|
||||
|| bytes[0] == 127
|
||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| bytes[0] == 0;
|
||||
}
|
||||
|
||||
/// <summary>读取数值型天气字段(容忍"25℃"、"50%"等后缀)</summary>
|
||||
private static decimal ParseWeatherNumber(JsonElement obj, string prop)
|
||||
{
|
||||
if (!obj.TryGetProperty(prop, out var el)) return 0;
|
||||
var raw = el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
|
||||
if (string.IsNullOrEmpty(raw)) return 0;
|
||||
var num = new string(raw.TakeWhile(ch => char.IsDigit(ch) || ch == '-' || ch == '.').ToArray());
|
||||
return decimal.TryParse(num, out var v) ? v : 0;
|
||||
}
|
||||
|
||||
/// <summary>读取天气字符串字段</summary>
|
||||
private static string GetWeatherField(JsonElement obj, string prop)
|
||||
{
|
||||
if (obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String)
|
||||
return el.GetString() ?? "";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user