Files
F8WebAI/backend/AgriculturalPlatform.Api/Controllers/DashboardController.cs
T
fanhongcai c377421a3e 新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化
- Region 表新增热度列,热门省份优先显示
- 区县下拉按地级市分组、已录区县优先、支持全省搜索
- 组选择内置一组至二十组
- 银行卡号独立行+粗体预览,开户行独立行
- 新增农户类型:农户/个体户/合作社/经营集体/公司
- 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS
- 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展
- 同名村弹窗提醒(防选错乡镇)
- 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作
- 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
2026-08-13 16:07:35 +08:00

341 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-weatherIP 定位实时天气) ----------
/// <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 "";
}
}