commit c377421a3e568d7b815e6ccfc2df2c825f6f0953 Author: fanhongcai Date: Thu Aug 13 16:07:35 2026 +0800 新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化 - Region 表新增热度列,热门省份优先显示 - 区县下拉按地级市分组、已录区县优先、支持全省搜索 - 组选择内置一组至二十组 - 银行卡号独立行+粗体预览,开户行独立行 - 新增农户类型:农户/个体户/合作社/经营集体/公司 - 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS - 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展 - 同名村弹窗提醒(防选错乡镇) - 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作 - 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7cf689 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# .NET +bin/ +obj/ +*.user +backend/AgriculturalPlatform.Api/appsettings.Development.json + +# Node +node_modules/ +frontend/dist/ +*.local + +# 运行时附件(身份证图片等,本地存储;OSS 上线后由 OSS 承载) +backend/AgriculturalPlatform.Api/wwwroot/uploads/ + +# IDE +.vscode/ +.idea/ +*.suo +*.swp + +# OS +Thumbs.db +.DS_Store + +# 运行日志与进程文件 +*.log +*.err +*.pid + +# 编译检查临时目录 +build-check/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2af79ea --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# 农易富 · 农产品收购交易平台(BS 版) + +基于主干信息 F8 农产品收购业务的产品化网页版(Browser/Server)平台,服务于**收购公司、收购站、收购个体**,向**农户**收购农产品,覆盖"农户管理 → 过磅称重 → 电子支付 → 反向开票 → 统计报表 → 可视化大屏"全业务链路。 + +## 技术栈 + +| 端 | 技术 | +| --- | --- | +| 后端 | C# / .NET 10(ASP.NET Core Web API、EF Core 10、JWT、BCrypt) | +| 数据库 | 在线 MySQL 8(Pomelo 官方分支 `Microting.EntityFrameworkCore.MySql`,支持 EF Core 10) | +| 前端 | Vue 3(最新版)+ Vite + TypeScript + Pinia + Vue Router + Element Plus + ECharts + Axios | +| 适配 | 电脑 PC 与触摸屏(触摸屏过磅页大按钮 + 数字键盘) | + +## 目录结构 + +``` +backend/AgriculturalPlatform.Api/ # 后端 API + ├── Models/ # 实体模型(组织/用户/农户/品种/收购单/支付/发票) + ├── Data/ # DbContext 与种子数据 + ├── Dtos/ # 接口传输对象 + ├── Services/ # JWT、数据权限、单号生成、当前用户 + └── Controllers/ # Auth/Orgs/Users/Farmers/Products/Purchases/Payments/Invoices/Reports/Dashboard +frontend/ # 前端 + └── src/ + ├── api/ # Axios 封装与全部接口 + ├── views/ # 登录、工作台、大屏、各业务模块页面 + ├── layouts/ # 主布局(侧边菜单) + └── stores/ # Pinia 状态 +docker-compose.yml # 可选:本地 MySQL 8 开发环境 +``` + +## 快速开始 + +### 1. 准备数据库(在线 MySQL 或本地) + +- **在线 MySQL**:将下方连接串改为你的在线数据库地址(首次启动会自动建库建表并写入演示数据)。 + +### 2. 后端 + +```bash +cd backend/AgriculturalPlatform.Api +# 修改 appsettings.json 中 ConnectionStrings:Default 为你的 MySQL 连接串 +dotnet restore +dotnet run +``` + +后端默认地址:`http://localhost:5246`(OpenAPI 文档:`http://localhost:5246/openapi/v1.json`)。 + +### 3. 前端 + +```bash +cd frontend +npm install +npm run dev +``` + +前端默认地址:`http://localhost:5173`,已配置 `/api` 代理到后端。 + +### 4. 登录 + +| 账号 | 密码 | 角色 | +| --- | --- | --- | +| admin | 123456 | 系统管理员 | +| company | 123456 | 公司管理员 | +| station1 / station2 | 123456 | 收购站员工 | +| individual | 123456 | 收购个体 | + +## 功能清单 + +- **农户管理**:农户档案(身份证/联系方式/村组/银行账户)、信用评分、冻结/启用、交易统计。 +- **过磅称重**:一次过磅(毛重)→ 二次回皮(皮重)自动计算净重与金额;支持 PC 列表操作与**触摸屏大字键盘**过磅;进行中单据实时展示。 +- **电子支付**:按收购单或批量结算,微信/支付宝/银行转账/现金四种方式,支付确认与退款,农户应收/已付/未付汇总。 +- **反向开票**:收购方向农户开具"农产品收购发票"(自产农产品免税,税率 0%),支持按已结算收购单一键开票、发票作废(红冲)。 +- **统计报表**:收购汇总(按日/月/品种/农户/收购方)、付款统计(方式/状态)、开票统计,支持图表与 CSV 导出。 +- **可视化大屏**:今日/本月 KPI、14 天趋势、品种占比、农户排行、收购方对比、实时过磅,30 秒自动刷新。 +- **组织与用户**:公司 / 收购站 / 收购个体三级组织;四类角色;**数据权限隔离**(公司只看本公司及旗下站点数据)。 + +## 数据库配置说明 + +后端启动时会执行 `EnsureCreated + Seed`:数据库不存在则自动建表,空库自动写入演示数据(组织、账号、品种、农户及近 30 天收购/付款/发票历史)。 + +生产建议改用 EF Core Migration 管理表结构。 + +## 常见问题 + +- **后端启动报数据库连接失败**:检查 `appsettings.json` 的 `ConnectionStrings:Default`,MySQL 需允许远程连接(主机防火墙/云数据库白名单放行)。 +- **前端接口 401**:登录过期,重新登录即可。 +- **想换端口**:后端在 `Properties/launchSettings.json` 修改后,同步修改 `frontend/vite.config.ts` 的代理 target 与后端 `appsettings.json` 的 `FrontendUrl`。 diff --git a/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj new file mode 100644 index 0000000..2913b3b --- /dev/null +++ b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.http b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.http new file mode 100644 index 0000000..a5fda91 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.http @@ -0,0 +1,18 @@ +@host = http://localhost:5246 + +### 登录(获取 Token) +POST {{host}}/api/auth/login +Content-Type: application/json + +{ + "username": "admin", + "password": "123456" +} + +### 大屏概览(请在 Header 中粘贴登录返回的 Token) +GET {{host}}/api/dashboard/overview +Authorization: Bearer + +### 收购单列表 +GET {{host}}/api/purchases?page=1&pageSize=10 +Authorization: Bearer diff --git a/backend/AgriculturalPlatform.Api/Controllers/AdminController.cs b/backend/AgriculturalPlatform.Api/Controllers/AdminController.cs new file mode 100644 index 0000000..b964fd7 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/AdminController.cs @@ -0,0 +1,122 @@ +using AgriculturalPlatform.Api.Data; +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/admin")] +[Authorize] +public class AdminController(AppDbContext db, CurrentUserService currentUser) : ControllerBase +{ + /// + /// 创建当日演示数据:随机生成 20-60 条蚕茧收购单(已完成) + /// 等级为「正茧 / 双宫 / 黄斑 / 血茧」之一,其中 95% 概率为正茧。 + /// + [HttpPost("demo-data")] + public async Task CreateDemoData() + { + var rnd = Random.Shared; + var now = DateTime.Now; + var todayStart = now.Date; + + // 1. 品种:蚕茧(不存在则自动创建) + var cocoon = await db.Products.FirstOrDefaultAsync(p => p.Name == "蚕茧"); + if (cocoon is null) + { + cocoon = new Product + { + Name = "蚕茧", + Category = "经济作物", + Unit = "公斤", + Spec = "鲜茧含水率≤15%", + Price = 30m, + CreatedAt = now + }; + db.Products.Add(cocoon); + await db.SaveChangesAsync(); + } + + // 2. 农户(活跃) + var farmerIds = await db.Farmers + .Where(f => f.Status == FarmerStatus.Active) + .Select(f => f.Id).ToListAsync(); + if (farmerIds.Count == 0) + return BadRequest(new { message = "暂无农户数据,请先在「农户管理」中创建农户" }); + + // 3. 收购组织:优先当前用户所属组织(保证数据可见),否则随机 + var cu = currentUser.Get(); + int orgId; + if (cu?.OrgId is { } oid && await db.Organizations.AnyAsync(o => o.Id == oid)) + orgId = oid; + else + { + var orgIds = await db.Organizations.Select(o => o.Id).ToListAsync(); + if (orgIds.Count == 0) + return BadRequest(new { message = "暂无组织数据,请先在「组织管理」中创建组织" }); + orgId = orgIds[rnd.Next(orgIds.Count)]; + } + + // 4. 随机生成 20-60 条 + var count = rnd.Next(20, 61); + var baseSeq = await db.PurchaseOrders.CountAsync(x => + x.CreatedAt >= todayStart && x.CreatedAt < todayStart.AddDays(1)); + + var grades = new[] { "双宫", "黄斑", "血茧" }; + var orders = new List(); + for (var i = 0; i < count; i++) + { + // 等级:95% 正茧,其余在双宫/黄斑/血茧中随机 + var grade = rnd.Next(100) < 95 ? "正茧" : grades[rnd.Next(grades.Length)]; + + // 单价(元/公斤)按等级定价 + var unitPrice = grade switch + { + "正茧" => rnd.Next(2800, 3601) / 100m, // 28.00 - 36.00 + "双宫" => rnd.Next(1800, 2401) / 100m, // 18.00 - 24.00 + "黄斑" => rnd.Next(1000, 1501) / 100m, // 10.00 - 15.00 + _ => rnd.Next(500, 901) / 100m // 血茧 5.00 - 9.00 + }; + + // 重量:净重 15-150kg,皮重 3-9kg + var net = rnd.Next(1500, 15000) / 100m; + var tare = rnd.Next(300, 900) / 100m; + + // 时间:今日 06:00-22:00 随机进场 + var weighIn = todayStart.AddHours(rnd.Next(6, 22)).AddMinutes(rnd.Next(0, 60)); + var weighOut = weighIn.AddMinutes(rnd.Next(5, 25)); + + orders.Add(new PurchaseOrder + { + OrderNo = $"CG{todayStart:yyyyMMdd}-{baseSeq + i + 1:D4}", + FarmerId = farmerIds[rnd.Next(farmerIds.Count)], + ProductId = cocoon.Id, + PurchaserOrgId = orgId, + Grade = grade, + Unit = "公斤", + UnitPrice = unitPrice, + GrossWeight = net + tare, + TareWeight = tare, + NetWeight = net, + Amount = Math.Round(net * unitPrice, 2), + WeighCount = 2, + Status = PurchaseStatus.Completed, + OperatorId = cu?.Id, + WeighInAt = weighIn, + WeighOutAt = weighOut, + CreatedAt = weighIn, + UpdatedAt = weighOut, + Notes = "演示数据" + }); + } + + db.PurchaseOrders.AddRange(orders); + await db.SaveChangesAsync(); + + return Ok(new { created = orders.Count, from = todayStart, to = now }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs b/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs new file mode 100644 index 0000000..f6cf423 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs @@ -0,0 +1,62 @@ +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/[controller]")] +public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService currentUser) : ControllerBase +{ + /// 登录 + [HttpPost("login")] + [AllowAnonymous] + public async Task> Login(LoginRequest req) + { + var user = await db.Users.Include(u => u.Org) + .FirstOrDefaultAsync(u => u.Username == req.Username); + if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) + return Unauthorized(new { message = "用户名或密码错误" }); + if (!user.IsActive) + return Unauthorized(new { message = "账号已被停用,请联系管理员" }); + + user.LastLoginAt = DateTime.Now; + await db.SaveChangesAsync(); + + var token = jwt.Generate(user); + return Ok(new LoginResult(token, BuildInfo(user))); + } + + /// 当前登录用户信息 + [HttpGet("me")] + [Authorize] + public async Task> Me() + { + var cu = currentUser.Get()!; + var user = await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == cu.Id); + return Ok(BuildInfo(user)); + } + + /// 修改密码 + [HttpPut("password")] + [Authorize] + public async Task ChangePassword(ChangePasswordRequest req) + { + var cu = currentUser.Get()!; + var user = await db.Users.FindAsync(cu.Id); + if (user is null) return NotFound(); + if (!BCrypt.Net.BCrypt.Verify(req.OldPassword, user.PasswordHash)) + return BadRequest(new { message = "原密码不正确" }); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword); + await db.SaveChangesAsync(); + return Ok(new { message = "密码修改成功" }); + } + + private static UserInfoDto BuildInfo(User user) => new( + user.Id, user.Username, user.RealName, user.Phone, user.Role.ToString(), + user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive); +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/DashboardController.cs b/backend/AgriculturalPlatform.Api/Controllers/DashboardController.cs new file mode 100644 index 0000000..8247bc5 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/DashboardController.cs @@ -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> 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; + } + + /// 解析查询区间:[From, ToExclusive)。未传 from 时默认回溯 days 天。 + 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)); + } + + /// 大屏概览 KPI(按日期区间) + [HttpGet("overview")] + public async Task> 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)); + } + + /// 区间收购金额/重量趋势(逐日) + [HttpGet("trend")] + public async Task> 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); + } + + /// 品种收购金额占比 + [HttpGet("product-distribution")] + public async Task> 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); + } + + /// 农户收购金额排行 + [HttpGet("farmer-top")] + public async Task> 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); + } + + /// 收购方(公司/站/个体)对比 + [HttpGet("station-comparison")] + public async Task> 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); + } + + /// 收购单价排行(按品种平均单价) + [HttpGet("price-top")] + public async Task> 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); + } + + /// 农户收入排行(收入 = 区间收购金额,附已付金额) + [HttpGet("income-top")] + public async Task> 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); + } + + /// 实时过磅(进行中的收购单) + [HttpGet("realtime-weighing")] + public async Task> 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 定位实时天气) ---------- + + /// ShowAPI 天气中文描述 -> 图标 + 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; + + /// 天气预报:ShowAPI ip-to-weather(按调用方 IP 定位,返回实时天气) + [HttpGet("weather")] + [AllowAnonymous] + public async Task> 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 }); + } + } + + /// 解析调用方 IP:代理转发头 -> 直连 IP -> 本机公网 IP 兜底(本地开发时用) + private async Task 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; + } + + /// 读取数值型天气字段(容忍"25℃"、"50%"等后缀) + 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; + } + + /// 读取天气字符串字段 + private static string GetWeatherField(JsonElement obj, string prop) + { + if (obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String) + return el.GetString() ?? ""; + return ""; + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs b/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs new file mode 100644 index 0000000..4ef9f6e --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs @@ -0,0 +1,148 @@ +using AgriculturalPlatform.Api.Data; +using AgriculturalPlatform.Api.Dtos; +using AgriculturalPlatform.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Controllers; + +[ApiController] +[Route("api/farmers")] +[Authorize] +public class FarmersController(AppDbContext db) : ControllerBase +{ + /// 农户列表(分页/关键字/地区筛选) + [HttpGet] + public async Task>> List( + string? status, string? province, string? county, string? township, string? village, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var query = db.Farmers.AsQueryable(); + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var fs)) + query = query.Where(f => f.Status == fs); + if (!string.IsNullOrWhiteSpace(province)) + query = query.Where(f => f.Province == province); + if (!string.IsNullOrWhiteSpace(county)) + query = query.Where(f => f.County == county); + if (!string.IsNullOrWhiteSpace(township)) + query = query.Where(f => f.Township == township); + if (!string.IsNullOrWhiteSpace(village)) + query = query.Where(f => f.Village == village); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(f => f.Name.Contains(keyword) + || f.Phone.Contains(keyword) + || f.IdCard.Contains(keyword) + || f.Village.Contains(keyword) + || f.Province.Contains(keyword) + || f.County.Contains(keyword) + || f.Township.Contains(keyword) + || f.GroupName.Contains(keyword)); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(f => f.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + return Ok(new PagedResult(items.ToDtos(), total)); + } + + /// 全部农户(下拉选择用) + [HttpGet("all")] + public async Task All(string? keyword, int limit = 50) + { + var query = db.Farmers.Where(f => f.Status == FarmerStatus.Active).AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(f => f.Name.Contains(keyword) || f.Phone.Contains(keyword) || f.IdCard.Contains(keyword)); + var items = await query.OrderByDescending(f => f.Id).Take(limit) + .Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard }).ToListAsync(); + return Ok(items); + } + + /// 农户详情(含交易统计) + [HttpGet("{id:int}")] + public async Task Get(int id) + { + var farmer = await db.Farmers.FindAsync(id); + if (farmer is null) return NotFound(); + + var totalAmount = await db.PurchaseOrders + .Where(p => p.FarmerId == id && p.Status == PurchaseStatus.Completed) + .SumAsync(p => (decimal?)p.Amount) ?? 0; + var orderCount = await db.PurchaseOrders + .CountAsync(p => p.FarmerId == id && p.Status == PurchaseStatus.Completed); + var paidAmount = await db.PaymentRecords + .Where(p => p.FarmerId == id && p.Status == PayStatus.Success) + .SumAsync(p => (decimal?)p.Amount) ?? 0; + + var dto = farmer.ToDto(); + return Ok(new { farmer = dto, totalAmount, orderCount, paidAmount }); + } + + /// 新增农户 + [HttpPost] + public async Task> Create(FarmerSaveRequest req) + { + if (await db.Farmers.AnyAsync(f => f.IdCard == req.IdCard)) + return BadRequest(new { message = "该身份证号已存在" }); + if (!Enum.TryParse(req.Status, out var status)) status = FarmerStatus.Active; + + var farmer = new Farmer + { + Name = req.Name, IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType, + Phone = req.Phone, + Province = req.Province, County = req.County, Township = req.Township, + Village = req.Village, GroupName = req.GroupName, + Address = req.Address, + BankName = req.BankName, BankAccount = req.BankAccount, + IdCardFrontUrl = req.IdCardFrontUrl, IdCardBackUrl = req.IdCardBackUrl, + AvatarUrl = req.AvatarUrl, + CreditScore = req.CreditScore, Status = status, Notes = req.Notes + }; + db.Farmers.Add(farmer); + await db.SaveChangesAsync(); + return Ok(farmer.ToDto()); + } + + /// 修改农户 + [HttpPut("{id:int}")] + public async Task> Update(int id, FarmerSaveRequest req) + { + var farmer = await db.Farmers.FindAsync(id); + if (farmer is null) return NotFound(); + if (req.IdCard != farmer.IdCard && await db.Farmers.AnyAsync(f => f.IdCard == req.IdCard)) + return BadRequest(new { message = "该身份证号已存在" }); + + farmer.Name = req.Name; farmer.IdCard = req.IdCard; farmer.Gender = req.Gender; + farmer.FarmerType = req.FarmerType; + farmer.Phone = req.Phone; + farmer.Province = req.Province; farmer.County = req.County; + farmer.Township = req.Township; farmer.Village = req.Village; + farmer.GroupName = req.GroupName; + farmer.Address = req.Address; + farmer.BankName = req.BankName; farmer.BankAccount = req.BankAccount; + farmer.IdCardFrontUrl = req.IdCardFrontUrl; farmer.IdCardBackUrl = req.IdCardBackUrl; + farmer.AvatarUrl = req.AvatarUrl; + farmer.CreditScore = req.CreditScore; farmer.Notes = req.Notes; + if (Enum.TryParse(req.Status, out var status)) farmer.Status = status; + farmer.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + return Ok(farmer.ToDto()); + } + + /// 删除农户(存在收购记录时禁止删除,仅冻结) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var farmer = await db.Farmers.FindAsync(id); + if (farmer is null) return NotFound(); + if (await db.PurchaseOrders.AnyAsync(p => p.FarmerId == id)) + { + farmer.Status = FarmerStatus.Frozen; + await db.SaveChangesAsync(); + return Ok(new { message = "该农户已有收购记录,已将其状态置为冻结" }); + } + db.Farmers.Remove(farmer); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/InvoicesController.cs b/backend/AgriculturalPlatform.Api/Controllers/InvoicesController.cs new file mode 100644 index 0000000..df05eef --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/InvoicesController.cs @@ -0,0 +1,349 @@ +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/invoices")] +[Authorize] +public class InvoicesController( + AppDbContext db, + DataScopeService scope, + CurrentUserService currentUser, + NumberGenerator numberGen) : ControllerBase +{ + /// 发票列表 + [HttpGet] + public async Task>> List( + string? status, int? farmerId, int? productId, DateTime? from, DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.Invoices + .Include(i => i.Farmer).Include(i => i.PurchaserOrg) + .Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator) + .AsQueryable(); + + if (visible is not null) + query = query.Where(i => visible.Contains(i.PurchaserOrgId)); + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var st)) + query = query.Where(i => i.Status == st); + if (farmerId.HasValue) query = query.Where(i => i.FarmerId == farmerId); + if (productId.HasValue) query = query.Where(i => i.ProductId == productId); + if (from.HasValue) query = query.Where(i => i.IssueDate >= from); + if (to.HasValue) query = query.Where(i => i.IssueDate < to.Value.Date.AddDays(1)); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(i => i.InvoiceNo.Contains(keyword) + || (i.Farmer != null && i.Farmer.Name.Contains(keyword)) + || (i.Farmer != null && i.Farmer.IdCard.Contains(keyword))); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(i => i.IssueDate) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + return Ok(new PagedResult(items.ToDtos(), total)); + } + + /// + /// 开票状态统计:以「过磅称重有效收购单」为口径。 + /// 待开票 + 开票成功 + 开票失败 + 开票异常 = 过磅称重有效收购单总数。 + /// + [HttpGet("stats")] + public async Task Stats() + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PurchaseOrders + .Where(p => p.Status == PurchaseStatus.Completed) + .AsQueryable(); + if (visible is not null) + query = query.Where(p => visible.Contains(p.PurchaserOrgId)); + + var total = await query.CountAsync(); + var issued = await query.CountAsync(p => + db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued)); + var failed = await query.CountAsync(p => + !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued) + && db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Failed)); + var abnormal = await query.CountAsync(p => + !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued) + && !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Failed) + && db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Abnormal)); + var pending = total - issued - failed - abnormal; + + return Ok(new { total, pending, issued, failed, abnormal }); + } + + /// 开票状态优先级(用于收购单多张发票时取最终状态) + private static int StatusRank(InvoiceStatus s) => s switch + { + InvoiceStatus.Issued => 4, + InvoiceStatus.Failed => 3, + InvoiceStatus.Abnormal => 2, + InvoiceStatus.Pending => 1, + _ => 0 + }; + + /// 收购单维度开票清单(反向开票数据源:过磅称重有效收购单) + /// None/Pending/Issued/Failed/Abnormal,空为全部 + [HttpGet("orders")] + public async Task>> OrderList( + string? status, DateTime? from, DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product).Include(p => p.PurchaserOrg) + .Where(p => p.Status == PurchaseStatus.Completed) + .AsQueryable(); + if (visible is not null) + query = query.Where(p => visible.Contains(p.PurchaserOrgId)); + if (from.HasValue) query = query.Where(p => p.WeighOutAt >= from); + if (to.HasValue) query = query.Where(p => p.WeighOutAt < to.Value.Date.AddDays(1)); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(p => p.OrderNo.Contains(keyword) + || (p.Farmer != null && p.Farmer.Name.Contains(keyword)) + || (p.Farmer != null && p.Farmer.IdCard.Contains(keyword))); + + var all = await query.OrderByDescending(p => p.WeighOutAt).ToListAsync(); + + // 非作废发票按收购单聚合,取优先级最高的那条 + var orderIds = all.Select(p => p.Id).ToArray(); + var invoices = await db.Invoices + .Where(i => i.Status != InvoiceStatus.Reversed && i.PurchaseOrderId != null && orderIds.Contains(i.PurchaseOrderId.Value)) + .ToListAsync(); + var invMap = invoices + .Where(i => i.PurchaseOrderId.HasValue) + .GroupBy(i => i.PurchaseOrderId!.Value) + .ToDictionary(g => g.Key, g => g.OrderByDescending(x => StatusRank(x.Status)).First()); + + var mapped = all.Select(p => + { + invMap.TryGetValue(p.Id, out var inv); + var invStatus = inv is null ? "None" : inv.Status.ToString(); + return new OrderInvoiceDto( + p.Id, p.OrderNo, p.WeighOutAt, + p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.IdCard ?? "", + p.ProductId, p.Product?.Name ?? "", p.Unit, p.NetWeight, p.UnitPrice, p.Amount, + p.PurchaserOrgId, p.PurchaserOrg?.Name ?? "", p.PurchaserOrg?.TaxNo, + inv?.Id, inv?.InvoiceNo, string.IsNullOrEmpty(inv?.BatchNo) ? null : inv.BatchNo, + invStatus, inv?.IssueDate); + }); + + // OPEN = 待开票(未开票 None 或 开票中 Pending) + if (!string.IsNullOrWhiteSpace(status)) + mapped = status == "OPEN" + ? mapped.Where(x => x.InvoiceStatus is "None" or "Pending") + : mapped.Where(x => x.InvoiceStatus == status); + + var list = mapped.ToList(); + var total = list.Count; + var items = list.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + return Ok(new PagedResult(items, total)); + } + + /// 可开票的已完成收购单(按农户) + [HttpGet("available-orders")] + public async Task AvailableOrders(int farmerId) + { + var orders = await db.PurchaseOrders + .Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed) + .Where(p => !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued)) + .Include(p => p.Product) + .Select(p => new + { + p.Id, p.OrderNo, ProductName = p.Product!.Name, + p.ProductId, p.NetWeight, p.Unit, p.UnitPrice, p.Amount, p.WeighOutAt + }) + .ToListAsync(); + return Ok(orders); + } + + /// 开具收购发票(反向开票:收购方向农户开具) + [HttpPost] + public async Task> Create(InvoiceCreateRequest req) + { + if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId)) + return BadRequest(new { message = "农户不存在" }); + if (!await db.Products.AnyAsync(p => p.Id == req.ProductId)) + return BadRequest(new { message = "品种不存在" }); + if (!await db.Organizations.AnyAsync(o => o.Id == req.PurchaserOrgId)) + return BadRequest(new { message = "收购方组织不存在" }); + + if (req.PurchaseOrderId.HasValue) + { + var order = await db.PurchaseOrders.FindAsync(req.PurchaseOrderId); + if (order is null || order.FarmerId != req.FarmerId) + return BadRequest(new { message = "收购单不存在或与农户不匹配" }); + if (await db.Invoices.AnyAsync(i => i.PurchaseOrderId == req.PurchaseOrderId && i.Status == InvoiceStatus.Issued)) + return BadRequest(new { message = "该收购单已开具发票" }); + } + + var cu = currentUser.Get()!; + var invoiceNo = await numberGen.NextAsync("FP"); + var taxAmount = Math.Round(req.Amount * req.TaxRate / 100m, 2); + + var invoice = new Invoice + { + InvoiceNo = invoiceNo, + InvoiceKind = req.InvoiceKind, + FarmerId = req.FarmerId, + PurchaserOrgId = req.PurchaserOrgId, + PurchaseOrderId = req.PurchaseOrderId, + ProductId = req.ProductId, + Quantity = req.Quantity, + Unit = req.Unit, + UnitPrice = req.UnitPrice, + Amount = req.Amount, + TaxRate = req.TaxRate, + TaxAmount = taxAmount, + IssueDate = DateTime.Now, + Status = InvoiceStatus.Pending, + OperatorId = cu.Id + }; + db.Invoices.Add(invoice); + await db.SaveChangesAsync(); + + return Ok((await db.Invoices + .Include(i => i.Farmer).Include(i => i.PurchaserOrg) + .Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator) + .FirstAsync(i => i.Id == invoice.Id)).ToDto()); + } + + /// 开票结果回填(模拟税控平台回调:Issued/Failed/Abnormal) + [HttpPut("{id:int}/result")] + public async Task> UpdateResult(int id, InvoiceResultRequest req) + { + var invoice = await db.Invoices.FindAsync(id); + if (invoice is null) return NotFound(); + if (invoice.Status == InvoiceStatus.Reversed) + return BadRequest(new { message = "已作废发票不可更新状态" }); + if (!Enum.TryParse(req.Status, out var st) || st == InvoiceStatus.Reversed) + return BadRequest(new { message = "开票结果状态不正确" }); + + invoice.Status = st; + invoice.IssueDate = DateTime.Now; + if (!string.IsNullOrWhiteSpace(req.Message)) + invoice.ReverseReason = req.Message; + await db.SaveChangesAsync(); + + return Ok((await db.Invoices + .Include(i => i.Farmer).Include(i => i.PurchaserOrg) + .Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator) + .FirstAsync(i => i.Id == invoice.Id)).ToDto()); + } + + /// 发票作废(红冲) + [HttpPut("{id:int}/reverse")] + public async Task Reverse(int id, InvoiceReverseRequest req) + { + var invoice = await db.Invoices.FindAsync(id); + if (invoice is null) return NotFound(); + if (invoice.Status != InvoiceStatus.Issued) + return BadRequest(new { message = "该发票已作废" }); + + invoice.Status = InvoiceStatus.Reversed; + invoice.ReversedAt = DateTime.Now; + invoice.ReverseReason = req.Reason; + await db.SaveChangesAsync(); + return Ok(new { message = "作废成功" }); + } + + /// 创建开票批次并批量发起开票(基于过磅称重有效收购单,生成待开票 Pending 发票) + [HttpPost("batches")] + public async Task> CreateBatch(CreateBatchRequest req) + { + if (req.OrderIds is null || req.OrderIds.Length == 0) + return BadRequest(new { message = "请选择要开票的收购单" }); + var cu = currentUser.Get()!; + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + + var orderIds = req.OrderIds.Distinct().ToArray(); + var orders = await db.PurchaseOrders + .Where(p => orderIds.Contains(p.Id) && p.Status == PurchaseStatus.Completed) + .ToListAsync(); + if (visible is not null) + orders = orders.Where(p => visible.Contains(p.PurchaserOrgId)).ToList(); + + var invoicedIds = await db.Invoices + .Where(i => i.Status != InvoiceStatus.Reversed && i.PurchaseOrderId != null && orderIds.Contains(i.PurchaseOrderId.Value)) + .Select(i => i.PurchaseOrderId!.Value) + .ToListAsync(); + var validOrders = orders.Where(p => !invoicedIds.Contains(p.Id)).ToList(); + if (validOrders.Count == 0) + return BadRequest(new { message = "所选收购单均已开票,无可开票单据" }); + + var batchNo = await numberGen.NextAsync("PK"); + foreach (var order in validOrders) + { + db.Invoices.Add(new Invoice + { + InvoiceNo = await numberGen.NextAsync("FP"), + BatchNo = batchNo, + InvoiceKind = "销售发票", + FarmerId = order.FarmerId, + PurchaserOrgId = order.PurchaserOrgId, + PurchaseOrderId = order.Id, + ProductId = order.ProductId, + Quantity = order.NetWeight, + Unit = order.Unit, + UnitPrice = order.UnitPrice, + Amount = order.Amount, + TaxRate = 0, + TaxAmount = 0, + IssueDate = DateTime.Now, + Status = InvoiceStatus.Pending, + OperatorId = cu.Id + }); + } + await db.SaveChangesAsync(); + + return Ok(new BatchProgressDto(batchNo, validOrders.Count, validOrders.Count, 0, 0, 0, false)); + } + + /// 开票批次进度(开票中/成功/失败/异常) + [HttpGet("batches/{batchNo}/progress")] + public async Task> BatchProgress(string batchNo) + { + var query = db.Invoices.Where(i => i.BatchNo == batchNo); + var total = await query.CountAsync(); + if (total == 0) return NotFound(); + var pending = await query.CountAsync(i => i.Status == InvoiceStatus.Pending); + var issued = await query.CountAsync(i => i.Status == InvoiceStatus.Issued); + var failed = await query.CountAsync(i => i.Status == InvoiceStatus.Failed); + var abnormal = await query.CountAsync(i => i.Status == InvoiceStatus.Abnormal); + return Ok(new BatchProgressDto(batchNo, total, pending, issued, failed, abnormal, pending == 0)); + } + + /// 模拟税控平台处理:每次推进批次中的部分待开票发票为成功/失败/异常 + [HttpPost("batches/{batchNo}/step")] + public async Task> BatchStep(string batchNo) + { + var pendingInvoices = await db.Invoices + .Where(i => i.BatchNo == batchNo && i.Status == InvoiceStatus.Pending) + .OrderBy(i => i.Id) + .Take(3) + .ToListAsync(); + + var rnd = Random.Shared; + foreach (var inv in pendingInvoices) + { + var r = rnd.Next(100); + inv.Status = r < 70 ? InvoiceStatus.Issued + : r < 85 ? InvoiceStatus.Failed + : InvoiceStatus.Abnormal; + if (inv.Status == InvoiceStatus.Failed) + inv.ReverseReason = "开票平台处理失败(模拟):税控设备连接超时"; + else if (inv.Status == InvoiceStatus.Abnormal) + inv.ReverseReason = "开票平台返回异常(模拟):数据校验未通过"; + inv.IssueDate = DateTime.Now; + } + await db.SaveChangesAsync(); + + return await BatchProgress(batchNo); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/OrgsController.cs b/backend/AgriculturalPlatform.Api/Controllers/OrgsController.cs new file mode 100644 index 0000000..a8a8515 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/OrgsController.cs @@ -0,0 +1,100 @@ +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/orgs")] +[Authorize] +public class OrgsController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase +{ + /// 组织列表(按数据权限过滤) + [HttpGet] + public async Task List(string? type, string? keyword) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.Organizations.Include(o => o.Parent).AsQueryable(); + if (visible is not null) + query = query.Where(o => visible.Contains(o.Id)); + if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse(type, out var orgType)) + query = query.Where(o => o.Type == orgType); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(o => o.Name.Contains(keyword) || o.ContactPerson.Contains(keyword) || o.Phone.Contains(keyword)); + + var items = await query.OrderBy(o => o.Type).ThenBy(o => o.Id).ToListAsync(); + return Ok(items.ToDtos()); + } + + /// 组织详情 + [HttpGet("{id:int}")] + public async Task> Get(int id) + { + var org = await db.Organizations.Include(o => o.Parent).FirstOrDefaultAsync(o => o.Id == id); + if (org is null) return NotFound(); + return Ok(org.ToDto()); + } + + /// 新增组织 + [HttpPost] + public async Task> Create(OrgSaveRequest req) + { + if (!Enum.TryParse(req.Type, out var orgType)) + return BadRequest(new { message = "组织类型不正确" }); + + var org = new Organization + { + Type = orgType, + Name = req.Name, + ParentId = req.ParentId, + ContactPerson = req.ContactPerson, + Phone = req.Phone, + Address = req.Address, + TaxNo = req.TaxNo, + IsActive = req.IsActive + }; + db.Organizations.Add(org); + await db.SaveChangesAsync(); + return Ok((await db.Organizations.Include(o => o.Parent).FirstAsync(o => o.Id == org.Id)).ToDto()); + } + + /// 修改组织 + [HttpPut("{id:int}")] + public async Task> Update(int id, OrgSaveRequest req) + { + var org = await db.Organizations.FindAsync(id); + if (org is null) return NotFound(); + if (Enum.TryParse(req.Type, out var orgType)) org.Type = orgType; + org.Name = req.Name; + org.ParentId = req.ParentId; + org.ContactPerson = req.ContactPerson; + org.Phone = req.Phone; + org.Address = req.Address; + org.TaxNo = req.TaxNo; + org.IsActive = req.IsActive; + await db.SaveChangesAsync(); + return Ok((await db.Organizations.Include(o => o.Parent).FirstAsync(o => o.Id == org.Id)).ToDto()); + } + + /// 删除组织(存在关联时禁止删除) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var org = await db.Organizations.FindAsync(id); + if (org is null) return NotFound(); + if (await db.Organizations.AnyAsync(o => o.ParentId == id)) + return BadRequest(new { message = "该组织下存在下级组织,无法删除" }); + if (await db.PurchaseOrders.AnyAsync(p => p.PurchaserOrgId == id)) + return BadRequest(new { message = "该组织已有收购业务,无法删除" }); + if (await db.Users.AnyAsync(u => u.OrgId == id)) + return BadRequest(new { message = "该组织下存在用户,无法删除" }); + + db.Organizations.Remove(org); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/PaymentsController.cs b/backend/AgriculturalPlatform.Api/Controllers/PaymentsController.cs new file mode 100644 index 0000000..1d068d1 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/PaymentsController.cs @@ -0,0 +1,205 @@ +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/payments")] +[Authorize] +public class PaymentsController( + AppDbContext db, + DataScopeService scope, + CurrentUserService currentUser, + NumberGenerator numberGen) : ControllerBase +{ + /// 付款记录列表 + [HttpGet] + public async Task>> List( + string? status, int? farmerId, int? orgId, DateTime? from, DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PaymentRecords + .Include(p => p.Farmer).Include(p => p.PurchaseOrder) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .AsQueryable(); + + if (visible is not null) + query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var ps)) + query = query.Where(p => p.Status == ps); + if (farmerId.HasValue) query = query.Where(p => p.FarmerId == farmerId); + if (orgId.HasValue) query = query.Where(p => p.PurchaserOrgId == orgId); + if (from.HasValue) query = query.Where(p => p.CreatedAt >= from); + if (to.HasValue) query = query.Where(p => p.CreatedAt < to.Value.Date.AddDays(1)); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(p => p.PayNo.Contains(keyword) + || p.TradeNo.Contains(keyword) + || (p.Farmer != null && p.Farmer.Name.Contains(keyword))); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + return Ok(new PagedResult(items.ToDtos(), total)); + } + + /// 支付状态统计(待支付/成功/失败/异常) + [HttpGet("stats")] + public async Task Stats() + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PaymentRecords.AsQueryable(); + if (visible is not null) + query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); + return Ok(new + { + pending = await query.CountAsync(p => p.Status == PayStatus.Pending), + success = await query.CountAsync(p => p.Status == PayStatus.Success), + failed = await query.CountAsync(p => p.Status == PayStatus.Failed), + abnormal = await query.CountAsync(p => p.Status == PayStatus.Abnormal) + }); + } + + /// 农户应收/已付汇总(结算用) + [HttpGet("farmer-summary")] + public async Task FarmerSummary(int farmerId, int? orgId) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var orderQuery = db.PurchaseOrders + .Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed) + .AsQueryable(); + if (visible is not null) + orderQuery = orderQuery.Where(p => visible.Contains(p.PurchaserOrgId)); + if (orgId.HasValue) + orderQuery = orderQuery.Where(p => p.PurchaserOrgId == orgId); + + var totalAmount = await orderQuery.SumAsync(p => (decimal?)p.Amount) ?? 0; + + var payQuery = db.PaymentRecords + .Where(p => p.FarmerId == farmerId && p.Status == PayStatus.Success) + .AsQueryable(); + if (visible is not null) + payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); + if (orgId.HasValue) + payQuery = payQuery.Where(p => p.PurchaserOrgId == orgId); + + var paidAmount = await payQuery.SumAsync(p => (decimal?)p.Amount) ?? 0; + + // 未结算的收购单(可批量付款) + var unpaidOrders = await db.PurchaseOrders + .Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed) + .Where(p => !db.PaymentRecords.Any(pay => + pay.PurchaseOrderId == p.Id && pay.Status == PayStatus.Success)) + .Include(p => p.Product) + .Select(p => new { p.Id, p.OrderNo, p.Product!.Name, p.NetWeight, p.Amount, p.UnitPrice }) + .ToListAsync(); + + return Ok(new + { + farmerId, + totalAmount, + paidAmount, + unpaidAmount = Math.Round(totalAmount - paidAmount, 2), + unpaidOrders + }); + } + + /// 发起支付(按收购单或对农户批量结算) + [HttpPost] + public async Task> Create(PaymentCreateRequest req) + { + if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId)) + return BadRequest(new { message = "农户不存在" }); + if (!Enum.TryParse(req.Method, out var method)) + return BadRequest(new { message = "支付方式不正确" }); + + // 按收购单付款:校验订单并防止重复支付 + if (req.PurchaseOrderId.HasValue) + { + var order = await db.PurchaseOrders.FindAsync(req.PurchaseOrderId); + if (order is null || order.FarmerId != req.FarmerId) + return BadRequest(new { message = "收购单不存在或与农户不匹配" }); + if (await db.PaymentRecords.AnyAsync(p => + p.PurchaseOrderId == req.PurchaseOrderId && p.Status == PayStatus.Success)) + return BadRequest(new { message = "该收购单已支付成功" }); + } + + var cu = currentUser.Get()!; + int? purchaserOrgId = req.PurchaseOrderId.HasValue + ? (await db.PurchaseOrders.FindAsync(req.PurchaseOrderId))?.PurchaserOrgId + : cu.OrgId; + + var payNo = await numberGen.NextAsync("ZF"); + var payment = new PaymentRecord + { + PayNo = payNo, + PurchaseOrderId = req.PurchaseOrderId, + PurchaserOrgId = purchaserOrgId, + FarmerId = req.FarmerId, + Amount = req.Amount, + Method = method, + Status = PayStatus.Pending, + OperatorId = cu.Id, + Notes = req.Notes + }; + db.PaymentRecords.Add(payment); + await db.SaveChangesAsync(); + + return Ok((await db.PaymentRecords + .Include(p => p.Farmer).Include(p => p.PurchaseOrder) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstAsync(p => p.Id == payment.Id)).ToDto()); + } + + /// 确认支付结果(模拟收单回调,默认支付成功,可选失败/异常) + [HttpPut("{id:int}/confirm")] + public async Task> Confirm(int id, PaymentConfirmRequest req) + { + var payment = await db.PaymentRecords.FindAsync(id); + if (payment is null) return NotFound(); + if (payment.Status != PayStatus.Pending) + return BadRequest(new { message = "当前状态不可确认" }); + + // Status 缺省或非法时按成功处理;成功/失败/异常均可写入 + var result = Enum.TryParse(req.Status, out var st) + && st is PayStatus.Success or PayStatus.Failed or PayStatus.Abnormal + ? st : PayStatus.Success; + + payment.Status = result; + if (result == PayStatus.Success) + { + payment.PaidAt = DateTime.Now; + payment.TradeNo = string.IsNullOrWhiteSpace(req.TradeNo) ? payment.TradeNo : req.TradeNo; + if (string.IsNullOrWhiteSpace(payment.TradeNo)) + payment.TradeNo = $"TRADE{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(100, 999)}"; + } + if (!string.IsNullOrWhiteSpace(req.Message)) + payment.Notes = string.IsNullOrWhiteSpace(payment.Notes) ? req.Message : $"{payment.Notes}\n{req.Message}"; + await db.SaveChangesAsync(); + + return Ok((await db.PaymentRecords + .Include(p => p.Farmer).Include(p => p.PurchaseOrder) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstAsync(p => p.Id == payment.Id)).ToDto()); + } + + /// 退款 + [HttpPut("{id:int}/refund")] + public async Task Refund(int id) + { + var payment = await db.PaymentRecords.FindAsync(id); + if (payment is null) return NotFound(); + if (payment.Status != PayStatus.Success) + return BadRequest(new { message = "仅已支付成功的记录可退款" }); + + payment.Status = PayStatus.Refunded; + await db.SaveChangesAsync(); + return Ok(new { message = "退款成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/ProductsController.cs b/backend/AgriculturalPlatform.Api/Controllers/ProductsController.cs new file mode 100644 index 0000000..00556be --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/ProductsController.cs @@ -0,0 +1,78 @@ +using AgriculturalPlatform.Api.Data; +using AgriculturalPlatform.Api.Dtos; +using AgriculturalPlatform.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Controllers; + +[ApiController] +[Route("api/products")] +[Authorize] +public class ProductsController(AppDbContext db) : ControllerBase +{ + /// 品种列表(可选品类/关键字) + [HttpGet] + public async Task List(string? category, string? keyword) + { + var query = db.Products.AsQueryable(); + if (!string.IsNullOrWhiteSpace(category)) + query = query.Where(p => p.Category == category); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(p => p.Name.Contains(keyword) || p.Category.Contains(keyword)); + var items = await query.OrderBy(p => p.Category).ThenBy(p => p.Name).ToListAsync(); + return Ok(items.ToDtos()); + } + + /// 品类列表(筛选用) + [HttpGet("categories")] + public async Task Categories() + { + var items = await db.Products.Select(p => p.Category).Distinct().OrderBy(c => c).ToListAsync(); + return Ok(items); + } + + [HttpPost] + public async Task> Create(ProductSaveRequest req) + { + if (await db.Products.AnyAsync(p => p.Name == req.Name)) + return BadRequest(new { message = "品种名称已存在" }); + var product = new Product + { + Name = req.Name, Category = req.Category, Unit = req.Unit, + Spec = req.Spec, Price = req.Price + }; + if (Enum.TryParse(req.Status, out var status)) product.Status = status; + db.Products.Add(product); + await db.SaveChangesAsync(); + return Ok(product.ToDto()); + } + + [HttpPut("{id:int}")] + public async Task> Update(int id, ProductSaveRequest req) + { + var product = await db.Products.FindAsync(id); + if (product is null) return NotFound(); + if (req.Name != product.Name && await db.Products.AnyAsync(p => p.Name == req.Name)) + return BadRequest(new { message = "品种名称已存在" }); + + product.Name = req.Name; product.Category = req.Category; product.Unit = req.Unit; + product.Spec = req.Spec; product.Price = req.Price; + if (Enum.TryParse(req.Status, out var status)) product.Status = status; + await db.SaveChangesAsync(); + return Ok(product.ToDto()); + } + + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var product = await db.Products.FindAsync(id); + if (product is null) return NotFound(); + if (await db.PurchaseOrders.AnyAsync(p => p.ProductId == id)) + return BadRequest(new { message = "该品种已有收购记录,无法删除" }); + db.Products.Remove(product); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs b/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs new file mode 100644 index 0000000..ac212cc --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs @@ -0,0 +1,181 @@ +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/purchases")] +[Authorize] +public class PurchasesController( + AppDbContext db, + DataScopeService scope, + CurrentUserService currentUser, + NumberGenerator numberGen) : ControllerBase +{ + /// 收购单列表(分页 + 多条件 + 数据权限) + [HttpGet] + public async Task>> List( + string? status, int? farmerId, int? productId, int? orgId, DateTime? from, DateTime? to, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .AsQueryable(); + + if (visible is not null) + query = query.Where(p => visible.Contains(p.PurchaserOrgId)); + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var ps)) + query = query.Where(p => p.Status == ps); + if (farmerId.HasValue) query = query.Where(p => p.FarmerId == farmerId); + if (productId.HasValue) query = query.Where(p => p.ProductId == productId); + if (orgId.HasValue) query = query.Where(p => p.PurchaserOrgId == orgId); + if (from.HasValue) query = query.Where(p => p.CreatedAt >= from); + if (to.HasValue) query = query.Where(p => p.CreatedAt < to.Value.Date.AddDays(1)); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(p => p.OrderNo.Contains(keyword) + || (p.Farmer != null && p.Farmer.Name.Contains(keyword)) + || (p.Farmer != null && p.Farmer.Phone.Contains(keyword))); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(p => p.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + return Ok(new PagedResult(items.ToDtos(), total)); + } + + /// 进行中的过磅单(触摸屏/大屏实时展示) + [HttpGet("weighing")] + public async Task Weighing() + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Where(p => p.Status == PurchaseStatus.Weighing) + .AsQueryable(); + if (visible is not null) + query = query.Where(p => visible.Contains(p.PurchaserOrgId)); + + var items = await query.OrderByDescending(p => p.WeighInAt).Take(20).ToListAsync(); + return Ok(items.ToDtos()); + } + + /// 收购单详情 + [HttpGet("{id:int}")] + public async Task> Get(int id) + { + var order = await db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstOrDefaultAsync(p => p.Id == id); + if (order is null) return NotFound(); + return Ok(order.ToDto()); + } + + /// 新建收购单(第一次过磅:记录毛重) + [HttpPost] + public async Task> Create(PurchaseCreateRequest req) + { + if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId)) + return BadRequest(new { message = "农户不存在" }); + if (!await db.Products.AnyAsync(p => p.Id == req.ProductId)) + return BadRequest(new { message = "品种不存在" }); + if (!await db.Organizations.AnyAsync(o => o.Id == req.PurchaserOrgId)) + return BadRequest(new { message = "收购方组织不存在" }); + + var cu = currentUser.Get()!; + var orderNo = await numberGen.NextAsync("CG"); + var order = new PurchaseOrder + { + OrderNo = orderNo, + FarmerId = req.FarmerId, + ProductId = req.ProductId, + PurchaserOrgId = req.PurchaserOrgId, + Grade = req.Grade, + Unit = req.Unit, + UnitPrice = req.UnitPrice, + GrossWeight = req.GrossWeight, + WeighCount = 1, + Status = PurchaseStatus.Weighing, + OperatorId = cu.Id, + Notes = req.Notes + }; + db.PurchaseOrders.Add(order); + await db.SaveChangesAsync(); + + return Ok((await db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstAsync(p => p.Id == order.Id)).ToDto()); + } + + /// 回皮完成(第二次过磅:记录皮重,计算净重与金额) + [HttpPut("{id:int}/tare")] + public async Task> CompleteTare(int id, PurchaseTareRequest req) + { + var order = await db.PurchaseOrders.FindAsync(id); + if (order is null) return NotFound(); + if (order.Status != PurchaseStatus.Weighing) + return BadRequest(new { message = "该收购单当前状态不可回皮" }); + if (req.TareWeight >= order.GrossWeight) + return BadRequest(new { message = "皮重不能大于等于毛重" }); + + order.TareWeight = req.TareWeight; + order.NetWeight = Math.Round(order.GrossWeight - req.TareWeight, 2); + order.Amount = Math.Round(order.NetWeight * order.UnitPrice, 2); + order.WeighCount = 2; + order.Status = PurchaseStatus.Completed; + order.WeighOutAt = DateTime.Now; + order.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + + return Ok((await db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstAsync(p => p.Id == order.Id)).ToDto()); + } + + /// 修改单价/等级(完成前可调整) + [HttpPut("{id:int}/price")] + public async Task> UpdatePrice(int id, [FromBody] decimal unitPrice, string? grade = null) + { + var order = await db.PurchaseOrders.FindAsync(id); + if (order is null) return NotFound(); + if (order.Status == PurchaseStatus.Cancelled) + return BadRequest(new { message = "作废单据不可修改" }); + + order.UnitPrice = unitPrice; + if (!string.IsNullOrWhiteSpace(grade)) order.Grade = grade; + order.Amount = Math.Round(order.NetWeight * order.UnitPrice, 2); + order.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + + return Ok((await db.PurchaseOrders + .Include(p => p.Farmer).Include(p => p.Product) + .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .FirstAsync(p => p.Id == order.Id)).ToDto()); + } + + /// 作废收购单 + [HttpPut("{id:int}/cancel")] + public async Task Cancel(int id) + { + var order = await db.PurchaseOrders.FindAsync(id); + if (order is null) return NotFound(); + if (order.Status == PurchaseStatus.Cancelled) + return BadRequest(new { message = "该单据已作废" }); + if (await db.PaymentRecords.AnyAsync(p => p.PurchaseOrderId == id && p.Status == PayStatus.Success)) + return BadRequest(new { message = "该单据已付款,请先退款后再作废" }); + + order.Status = PurchaseStatus.Cancelled; + order.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + return Ok(new { message = "作废成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/RegionsController.cs b/backend/AgriculturalPlatform.Api/Controllers/RegionsController.cs new file mode 100644 index 0000000..376bd6d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/RegionsController.cs @@ -0,0 +1,120 @@ +using AgriculturalPlatform.Api.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Controllers; + +/// +/// 行政区划接口: +/// 省、市、县、乡镇来自 AreaCity 全国公开行政区划数据(Regions 表,深 0~3); +/// 村、组来自农户档案表(Famers 表已录入数据,去重后作为下拉选项)。 +/// +[ApiController] +[Route("api/regions")] +[Authorize] +public class RegionsController(AppDbContext db) : ControllerBase +{ + /// 省(自治区/直辖市)列表,热门省份(热度值高)优先 + [HttpGet("provinces")] + public async Task> Provinces() + { + var items = await db.Regions + .Where(r => r.Deep == 0) + .OrderByDescending(r => r.Hot) + .ThenBy(r => r.Id) + .Select(r => r.ExtName) + .ToListAsync(); + return Ok(items); + } + + /// + /// 按省获取县(区/县级市)列表,附带所属地级市(city)用于前端分组, + /// used=true 表示数据库农户表中已录入过该区县(前端优先展示)。 + /// + [HttpGet("counties")] + public async Task Counties(string? province) + { + if (string.IsNullOrWhiteSpace(province)) return BadRequest(new { message = "请先选择省份" }); + var prov = await db.Regions.FirstOrDefaultAsync(r => r.Deep == 0 && r.ExtName == province); + if (prov == null) return Ok(Array.Empty()); + + var items = await (from c in db.Regions + join city in db.Regions on c.Pid equals city.Id + where city.Pid == prov.Id && c.Deep == 2 + orderby city.Id, c.Id + select new { name = c.ExtName, city = city.ExtName }) + .ToListAsync(); + + var usedNames = await db.Farmers + .Where(f => f.County != null && f.County != "") + .Select(f => f.County) + .Distinct() + .ToListAsync(); + var used = new HashSet(usedNames, StringComparer.OrdinalIgnoreCase); + + return Ok(items.Select(c => new { c.name, c.city, used = used.Contains(c.name) })); + } + + /// 按县获取乡镇(街道)列表(公开数据 + 农户表已录入数据补充) + [HttpGet("townships")] + public async Task> Townships(string? county, string? city) + { + if (string.IsNullOrWhiteSpace(county)) return BadRequest(new { message = "请先选择县(区)" }); + + var countyQuery = db.Regions.Where(r => r.Deep == 2 && r.ExtName == county); + if (!string.IsNullOrWhiteSpace(city)) + { + var cityRow = await db.Regions.FirstOrDefaultAsync(r => r.Deep == 1 && r.ExtName == city); + if (cityRow != null) countyQuery = countyQuery.Where(r => r.Pid == cityRow.Id); + } + var ids = await countyQuery.Select(r => r.Id).ToListAsync(); + + var builtin = ids.Count > 0 + ? await db.Regions.Where(r => r.Deep == 3 && ids.Contains(r.Pid)) + .OrderBy(r => r.Id).Select(r => r.ExtName).ToListAsync() + : []; + + var fromDb = await db.Farmers + .Where(f => f.County == county && f.Township != null && f.Township != "") + .Select(f => f.Township) + .Distinct() + .ToListAsync(); + return Ok(builtin.Union(fromDb).OrderBy(x => x).ToArray()); + } + + /// + /// 村列表(来自农户表,按县/乡镇过滤去重),返回 { name, township } 对象数组。 + /// 不传 township 时返回全县所有村(含所属乡镇),供前端做同名村检测。 + /// + [HttpGet("villages")] + public async Task Villages(string? county, string? township) + { + var q = db.Farmers.Where(f => f.Village != null && f.Village != ""); + if (!string.IsNullOrWhiteSpace(township)) + q = q.Where(f => f.Township == township); + else if (!string.IsNullOrWhiteSpace(county)) + q = q.Where(f => f.County == county); + var items = await q + .Select(f => new { f.Village, f.Township }) + .Distinct() + .OrderBy(x => x.Village) + .ToListAsync(); + return Ok(items.Select(x => new { name = x.Village, township = x.Township ?? "" })); + } + + /// 组列表(来自农户表,按县/乡镇/村过滤去重) + [HttpGet("groups")] + public async Task> Groups(string? county, string? township, string? village) + { + var q = db.Farmers.Where(f => f.GroupName != null && f.GroupName != ""); + if (!string.IsNullOrWhiteSpace(village)) + q = q.Where(f => f.Village == village); + else if (!string.IsNullOrWhiteSpace(township)) + q = q.Where(f => f.Township == township); + else if (!string.IsNullOrWhiteSpace(county)) + q = q.Where(f => f.County == county); + var items = await q.Select(f => f.GroupName).Distinct().OrderBy(x => x).ToListAsync(); + return Ok(items); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/ReportsController.cs b/backend/AgriculturalPlatform.Api/Controllers/ReportsController.cs new file mode 100644 index 0000000..a827125 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/ReportsController.cs @@ -0,0 +1,130 @@ +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/reports")] +[Authorize] +public class ReportsController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase +{ + /// 收购汇总报表(维度:day=按日 / month=按月 / product=按品种 / farmer=按农户 / org=按收购方) + [HttpGet("purchase-summary")] + public async Task> PurchaseSummary( + string dimension = "day", DateTime? from = null, DateTime? to = null) + { + var start = from ?? DateTime.Today.AddDays(-29); + var end = (to ?? DateTime.Today).Date.AddDays(1); + + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PurchaseOrders + .Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < end) + .AsQueryable(); + if (visible is not null) + query = query.Where(p => visible.Contains(p.PurchaserOrgId)); + + var rows = dimension switch + { + "month" => await query + .GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month }) + .Select(g => new + { + Key = $"{g.Key.Year}-{g.Key.Month:D2}", + Amount = g.Sum(p => p.Amount), + Weight = g.Sum(p => p.NetWeight), + Cnt = g.Count() + }) + .OrderBy(g => g.Key).ToListAsync(), + "product" => await query + .GroupBy(p => p.Product!.Name) + .Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() }) + .OrderByDescending(g => g.Amount).ToListAsync(), + "farmer" => await query + .GroupBy(p => p.Farmer!.Name) + .Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() }) + .OrderByDescending(g => g.Amount).Take(20).ToListAsync(), + "org" => await query + .GroupBy(p => p.PurchaserOrg!.Name) + .Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() }) + .OrderByDescending(g => g.Amount).ToListAsync(), + _ => await query + .GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month, p.CreatedAt.Day }) + .Select(g => new + { + Key = $"{g.Key.Year}-{g.Key.Month:D2}-{g.Key.Day:D2}", + Amount = g.Sum(p => p.Amount), + Weight = g.Sum(p => p.NetWeight), + Cnt = g.Count() + }) + .OrderBy(g => g.Key).ToListAsync() + }; + + return Ok(new PurchaseSummaryResult( + dimension, start, end, + rows.Sum(r => r.Amount), rows.Sum(r => r.Weight), rows.Sum(r => r.Cnt), + rows.Select(r => new SummaryRow(r.Key, r.Key, r.Amount, r.Cnt)).ToArray())); + } + + /// 付款统计报表 + [HttpGet("payment-summary")] + public async Task> PaymentSummary(DateTime? from = null, DateTime? to = null) + { + var start = from ?? DateTime.Today.AddDays(-29); + var end = (to ?? DateTime.Today).Date.AddDays(1); + + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.PaymentRecords.AsQueryable(); + if (visible is not null) + query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value)); + + var inRange = query.Where(p => p.CreatedAt >= start && p.CreatedAt < end); + var totalPaid = await inRange.Where(p => p.Status == PayStatus.Success).SumAsync(p => (decimal?)p.Amount) ?? 0; + var totalPending = await inRange.Where(p => p.Status == PayStatus.Pending).SumAsync(p => (decimal?)p.Amount) ?? 0; + var payCount = await inRange.Where(p => p.Status == PayStatus.Success).CountAsync(); + + var byMethod = await inRange.GroupBy(p => p.Method.ToString()) + .Select(g => new { Key = g.Key, Value = g.Sum(p => p.Amount), Cnt = g.Count() }) + .ToListAsync(); + var byStatus = await inRange.GroupBy(p => p.Status.ToString()) + .Select(g => new { Key = g.Key, Value = g.Sum(p => p.Amount), Cnt = g.Count() }) + .ToListAsync(); + + return Ok(new PaymentSummaryResult(start, end, totalPaid, totalPending, payCount, + byMethod.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray(), + byStatus.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray())); + } + + /// 开票统计报表 + [HttpGet("invoice-summary")] + public async Task> InvoiceSummary(DateTime? from = null, DateTime? to = null) + { + var start = from ?? DateTime.Today.AddDays(-29); + var end = (to ?? DateTime.Today).Date.AddDays(1); + + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.Invoices.Where(i => i.IssueDate >= start && i.IssueDate < end).AsQueryable(); + if (visible is not null) + query = query.Where(i => visible.Contains(i.PurchaserOrgId)); + + var issued = query.Where(i => i.Status == InvoiceStatus.Issued); + var totalAmount = await issued.SumAsync(i => (decimal?)i.Amount) ?? 0; + var totalTax = await issued.SumAsync(i => (decimal?)i.TaxAmount) ?? 0; + var issueCount = await issued.CountAsync(); + + var byProduct = await issued.GroupBy(i => i.Product!.Name) + .Select(g => new { Key = g.Key, Value = g.Sum(i => i.Amount), Cnt = g.Count() }) + .OrderByDescending(g => g.Value).ToListAsync(); + var byStatus = await query.GroupBy(i => i.Status.ToString()) + .Select(g => new { Key = g.Key, Value = g.Sum(i => i.Amount), Cnt = g.Count() }) + .ToListAsync(); + + return Ok(new InvoiceSummaryResult(start, end, totalAmount, totalTax, issueCount, + byProduct.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray(), + byStatus.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray())); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs b/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs new file mode 100644 index 0000000..8f48760 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs @@ -0,0 +1,229 @@ +using System.Net.NetworkInformation; +using System.Net.Sockets; +using AgriculturalPlatform.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AgriculturalPlatform.Api.Controllers; + +/// +/// 附件上传(身份证正反面):高拍仪/本地拍照直传 + 手机扫码上传。 +/// 图片保存为附件(本地存储,OSS 配置后自动切换),并返回 OCR 识别结果。 +/// +[ApiController] +[Route("api/uploads")] +public class UploadsController( + IFileStorage storage, + IOcrService ocr, + MobileUploadSessionStore sessions, + IConfiguration cfg) : ControllerBase +{ + /// + /// 高拍仪/本地拍照:上传身份证单面图片(side = front 正面 | back 反面), + /// 返回附件地址(url/key)与 OCR 识别结果。 + /// + [HttpPost("idcard")] + [Authorize] + [RequestSizeLimit(15 * 1024 * 1024)] + public async Task UploadIdCard(IFormFile? file, [FromForm] string? side) + { + if (file is null || file.Length == 0) + return BadRequest(new { message = "请先拍摄或选择身份证图片" }); + + side = side == "back" ? "back" : "front"; + await using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + ms.Position = 0; + + StoredFile saved; + try + { + saved = await storage.SaveAsync("idcard", ms, file.FileName, file.ContentType); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + + var result = await ocr.RecognizeAsync(ms, side, file.FileName); + return Ok(new { url = saved.Url, key = saved.Key, side, ocr = result }); + } + + /// + /// 手机传图:创建上传会话,返回二维码内容(手机访问的 URL)。 + /// 电脑端展示二维码,手机扫码后访问 /mobile-upload.html?code=xxx 上传身份证正反面。 + /// 注意:端口固定取 Server:LanPort(默认 5246),不依赖请求 Host, + /// 因为经 Vite 代理(5173)访问时 Host 端口可能是前端端口,手机无法访问该端口。 + /// + [HttpGet("qr")] + [Authorize] + public ActionResult CreateQr() + { + var code = sessions.Create(); + // 优先取 TCP 连接本端地址(后端监听 0.0.0.0 时即手机可直达的局域网 IP),回环则回退枚举网卡 + var localIp = HttpContext.Connection.LocalIpAddress?.ToString() ?? ""; + var lanIp = localIp.StartsWith("127.") || localIp.StartsWith("::1") || localIp.StartsWith("::ffff:127.") + ? GetLanIp() + : localIp; + var port = cfg["Server:LanPort"] ?? "5246"; + var url = $"http://{lanIp}:{port}/mobile-upload.html?code={code}"; + return Ok(new { code, url, expiresAt = DateTime.Now.AddMinutes(10) }); + } + + /// + /// 农户头像上传:摄像头/本地图片直传,返回附件地址。 + /// 图片保存于 avatar 目录,未来可用于人脸识别收购(届时在识别服务中引用 AvatarUrl)。 + /// + [HttpPost("avatar")] + [Authorize] + [RequestSizeLimit(15 * 1024 * 1024)] + public async Task UploadAvatar(IFormFile? file) + { + if (file is null || file.Length == 0) + return BadRequest(new { message = "请先拍摄或选择头像图片" }); + + await using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + ms.Position = 0; + + StoredFile saved; + try + { + saved = await storage.SaveAsync("avatar", ms, file.FileName, file.ContentType); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + + return Ok(new { url = saved.Url, key = saved.Key }); + } + + /// 手机上传身份证正反面(front/back 两张图,允许只传一张) + [HttpPost("mobile")] + [AllowAnonymous] + [RequestSizeLimit(30 * 1024 * 1024)] + public async Task MobileUpload( + [FromForm] string? code, + IFormFile? front, + IFormFile? back) + { + var session = sessions.Get(code ?? ""); + if (session is null || session.Done) + return BadRequest(new { message = "二维码已失效,请回到电脑端重新生成" }); + + if (front is not null && front.Length > 0) + { + await using var ms = new MemoryStream(); + await front.CopyToAsync(ms); + ms.Position = 0; + try + { + session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType); + session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + if (back is not null && back.Length > 0) + { + await using var ms = new MemoryStream(); + await back.CopyToAsync(ms); + ms.Position = 0; + try + { + session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType); + session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + session.Done = true; + return Ok(new { ok = true, message = "上传成功,请回到电脑端查看" }); + } + + /// 电脑端轮询手机上传结果(done=false 表示手机还未传完) + [HttpGet("mobile/{code}/result")] + [Authorize] + public ActionResult MobileResult(string code) + { + var session = sessions.Get(code); + if (session is null) + return NotFound(new { message = "二维码已失效,请重新生成" }); + if (!session.Done) + return Ok(new { done = false }); + + var ocr = MergeOcr(session.FrontOcr, session.BackOcr); + return Ok(new + { + done = true, + frontUrl = session.Front?.Url, + frontKey = session.Front?.Key, + backUrl = session.Back?.Url, + backKey = session.Back?.Key, + ocr + }); + } + + /// 合并正反面 OCR 结果(正面的姓名/证件号/性别/住址为主) + private static IdCardOcrResult MergeOcr(IdCardOcrResult? front, IdCardOcrResult? back) + { + var any = front is { Success: true } || back is { Success: true }; + var f = front ?? new IdCardOcrResult(); + return new IdCardOcrResult( + f.Name, f.IdCard, f.Gender, f.Address, f.BankName, f.BankAccount, + any, any ? "识别完成" : (f.Message ?? "未配置 OCR 服务")); + } + + /// + /// 获取本机局域网 IP,供手机扫码访问。 + /// 优先选择物理网卡(排除 VMware/VirtualBox/Hyper-V/WSL/VPN 等虚拟网卡), + /// 避免多网卡环境(虚拟机、VPN、Docker)下拿到错误的 IP 导致手机无法访问。 + /// + private static string GetLanIp() + { + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + if (IsVirtualAdapter(ni)) continue; + foreach (var ua in ni.GetIPProperties().UnicastAddresses) + { + if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; + var ip = ua.Address.ToString(); + if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172.")) + return ip; + } + } + + // 兜底:物理网卡找不到时,在虚拟网卡里再找一次 + foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) + { + if (ni.OperationalStatus != OperationalStatus.Up) continue; + foreach (var ua in ni.GetIPProperties().UnicastAddresses) + { + if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue; + var ip = ua.Address.ToString(); + if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172.")) + return ip; + } + } + return "127.0.0.1"; + } + + /// 根据网卡描述判断是否为虚拟/隧道/环回网卡 + private static bool IsVirtualAdapter(NetworkInterface ni) + { + var desc = (ni.Description ?? "").ToLowerInvariant(); + return desc.Contains("virtual") || desc.Contains("vmware") || desc.Contains("virtualbox") + || desc.Contains("hyper-v") || desc.Contains("tunnel") || desc.Contains("loopback") + || desc.Contains("docker") || desc.Contains("wsl") || desc.Contains("tailscale") + || desc.Contains("zerotier") || desc.Contains("vpn") || desc.Contains("tap-") + || desc.Contains("tun-") || desc.Contains("hamachi"); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs b/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs new file mode 100644 index 0000000..7bf6bfc --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs @@ -0,0 +1,109 @@ +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/users")] +[Authorize] +public class UsersController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase +{ + /// 用户列表(分页 + 数据权限) + [HttpGet] + public async Task>> List( + string? role, string? orgId, + [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null) + { + var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get()); + var query = db.Users.Include(u => u.Org).AsQueryable(); + if (visible is not null) + query = query.Where(u => u.OrgId != null && visible.Contains(u.OrgId.Value)); + if (!string.IsNullOrWhiteSpace(role) && Enum.TryParse(role, out var userRole)) + query = query.Where(u => u.Role == userRole); + if (int.TryParse(orgId, out var oid)) + query = query.Where(u => u.OrgId == oid); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(u => u.Username.Contains(keyword) || u.RealName.Contains(keyword) || u.Phone.Contains(keyword)); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(u => u.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .ToListAsync(); + + return Ok(new PagedResult(items.ToDtos(), total)); + } + + /// 新增用户 + [HttpPost] + public async Task> Create(UserSaveRequest req) + { + if (await db.Users.AnyAsync(u => u.Username == req.Username)) + return BadRequest(new { message = "用户名已存在" }); + if (!Enum.TryParse(req.Role, out var role)) + return BadRequest(new { message = "角色不正确" }); + + var user = new User + { + Username = req.Username, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password ?? "123456"), + RealName = req.RealName, + Phone = req.Phone, + Role = role, + OrgId = req.OrgId, + IsActive = req.IsActive + }; + db.Users.Add(user); + await db.SaveChangesAsync(); + return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto()); + } + + /// 修改用户 + [HttpPut("{id:int}")] + public async Task> Update(int id, UserSaveRequest req) + { + var user = await db.Users.FindAsync(id); + if (user is null) return NotFound(); + if (req.Username != user.Username && await db.Users.AnyAsync(u => u.Username == req.Username)) + return BadRequest(new { message = "用户名已存在" }); + + user.Username = req.Username; + user.RealName = req.RealName; + user.Phone = req.Phone; + if (Enum.TryParse(req.Role, out var role)) user.Role = role; + user.OrgId = req.OrgId; + user.IsActive = req.IsActive; + if (!string.IsNullOrWhiteSpace(req.Password)) + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password); + await db.SaveChangesAsync(); + return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto()); + } + + /// 重置密码 + [HttpPut("{id:int}/reset-password")] + public async Task ResetPassword(int id, [FromBody] string? newPassword = null) + { + var user = await db.Users.FindAsync(id); + if (user is null) return NotFound(); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword ?? "123456"); + await db.SaveChangesAsync(); + return Ok(new { message = "密码已重置为 " + (newPassword ?? "123456") }); + } + + /// 删除用户(禁止删除自己) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var cu = currentUser.Get()!; + if (id == cu.Id) return BadRequest(new { message = "不能删除当前登录账号" }); + var user = await db.Users.FindAsync(id); + if (user is null) return NotFound(); + db.Users.Remove(user); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs b/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..b385afe --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs @@ -0,0 +1,182 @@ +using AgriculturalPlatform.Api.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace AgriculturalPlatform.Api.Data; + +public class AppDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet Users => Set(); + public DbSet Organizations => Set(); + public DbSet Farmers => Set(); + public DbSet Regions => Set(); + public DbSet Products => Set(); + public DbSet PurchaseOrders => Set(); + public DbSet PaymentRecords => Set(); + public DbSet Invoices => Set(); + + 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() + .Property(u => u.Username).HasMaxLength(50); + modelBuilder.Entity() + .HasIndex(u => u.Username).IsUnique(); + modelBuilder.Entity() + .HasOne(u => u.Org).WithMany().HasForeignKey(u => u.OrgId) + .OnDelete(DeleteBehavior.SetNull); + + // 组织 + modelBuilder.Entity() + .Property(o => o.Type).HasMaxLength(32); + modelBuilder.Entity() + .Property(o => o.Name).HasMaxLength(200); + modelBuilder.Entity() + .HasIndex(o => new { o.Type, o.Name }); + modelBuilder.Entity() + .HasOne(o => o.Parent).WithMany().HasForeignKey(o => o.ParentId) + .OnDelete(DeleteBehavior.Restrict); + + // 农户:身份证唯一 + modelBuilder.Entity() + .Property(f => f.IdCard).HasMaxLength(18); + modelBuilder.Entity() + .HasIndex(f => f.IdCard).IsUnique(); + modelBuilder.Entity() + .Property(f => f.Phone).HasMaxLength(20); + modelBuilder.Entity() + .HasIndex(f => f.Phone); + modelBuilder.Entity() + .Property(f => f.FarmerType).HasMaxLength(20); + modelBuilder.Entity() + .Property(f => f.IdCardFrontUrl).HasMaxLength(500); + modelBuilder.Entity() + .Property(f => f.IdCardBackUrl).HasMaxLength(500); + modelBuilder.Entity() + .Property(f => f.Province).HasMaxLength(50); + modelBuilder.Entity() + .Property(f => f.County).HasMaxLength(50); + modelBuilder.Entity() + .Property(f => f.Township).HasMaxLength(50); + modelBuilder.Entity() + .Property(f => f.Village).HasMaxLength(100); + modelBuilder.Entity() + .Property(f => f.GroupName).HasMaxLength(50); + modelBuilder.Entity() + .HasIndex(f => new { f.Province, f.County, f.Township }); + + // 行政区划(AreaCity 全国数据,表由导入脚本/启动建表创建) + modelBuilder.Entity() + .HasKey(r => r.Id); + modelBuilder.Entity() + .Property(r => r.Name).HasMaxLength(100); + modelBuilder.Entity() + .Property(r => r.ExtName).HasMaxLength(100); + modelBuilder.Entity() + .HasIndex(r => r.Pid); + modelBuilder.Entity() + .HasIndex(r => r.Deep); + + // 品种 + modelBuilder.Entity() + .Property(p => p.Name).HasMaxLength(100); + modelBuilder.Entity() + .HasIndex(p => p.Name).IsUnique(); + + // 收购单 + modelBuilder.Entity() + .Property(p => p.OrderNo).HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(p => p.OrderNo).IsUnique(); + modelBuilder.Entity() + .HasIndex(p => new { p.Status, p.CreatedAt }); + modelBuilder.Entity() + .HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.Product).WithMany().HasForeignKey(p => p.ProductId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId) + .OnDelete(DeleteBehavior.SetNull); + + // 支付 + modelBuilder.Entity() + .Property(p => p.PayNo).HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(p => p.PayNo).IsUnique(); + modelBuilder.Entity() + .HasIndex(p => new { p.Status, p.CreatedAt }); + modelBuilder.Entity() + .HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.PurchaseOrder).WithMany().HasForeignKey(p => p.PurchaseOrderId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId) + .OnDelete(DeleteBehavior.SetNull); + + // 发票 + modelBuilder.Entity() + .Property(i => i.InvoiceNo).HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(i => i.InvoiceNo).IsUnique(); + modelBuilder.Entity() + .Property(i => i.BatchNo).HasMaxLength(32); + modelBuilder.Entity() + .HasIndex(i => i.BatchNo); + modelBuilder.Entity() + .HasIndex(i => new { i.Status, i.IssueDate }); + modelBuilder.Entity() + .HasOne(i => i.Farmer).WithMany().HasForeignKey(i => i.FarmerId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.PurchaserOrg).WithMany().HasForeignKey(i => i.PurchaserOrgId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId) + .OnDelete(DeleteBehavior.Restrict); + modelBuilder.Entity() + .HasOne(i => i.PurchaseOrder).WithMany().HasForeignKey(i => i.PurchaseOrderId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .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); + } + } + } + } +} diff --git a/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs b/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs new file mode 100644 index 0000000..c5bea5b --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs @@ -0,0 +1,195 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Data; + +/// 初始化数据(仅在空库时执行) +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 + { + 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 + { + 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 + { + 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(); + 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(); + } +} diff --git a/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs b/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs new file mode 100644 index 0000000..02e8038 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs @@ -0,0 +1,87 @@ +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Data; + +/// +/// 兼容已存在的数据库:EnsureCreated 不会为已存在的表补列, +/// 这里通过 information_schema 检测后逐列执行 ALTER TABLE, +/// 为 Farmers 表补充四级行政区划等新列(兼容 MySQL 5.7 / 8.0 各版本)。 +/// +public static class SchemaMigrator +{ + public static async Task EnsureColumnsAsync(AppDbContext db) + { + var existing = new HashSet( + await db.Database + .SqlQuery($"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,'')"); + } + + /// + /// 确保 Regions(行政区划)表存在。 + /// 表结构与 scripts/areacity/import-regions.mjs 导入脚本一致; + /// 数据由该脚本导入(全国省市区乡镇四级,约 4.2 万条)。 + /// + 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( + $"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); + } +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs new file mode 100644 index 0000000..b960a4d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs @@ -0,0 +1,17 @@ +namespace AgriculturalPlatform.Api.Dtos; + +// ---------- 认证 ---------- +public record LoginRequest(string Username, string Password); + +public record LoginResult(string Token, UserInfoDto User); + +public record UserInfoDto( + int Id, string Username, string RealName, string Phone, string Role, + int? OrgId, string? OrgName, string? OrgType, bool IsActive); + +public record ChangePasswordRequest(string OldPassword, string NewPassword); + +// ---------- 通用分页 ---------- +public record PagedResult(IEnumerable Items, int Total); + + diff --git a/backend/AgriculturalPlatform.Api/Dtos/DashboardDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/DashboardDtos.cs new file mode 100644 index 0000000..938f75b --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/DashboardDtos.cs @@ -0,0 +1,35 @@ +namespace AgriculturalPlatform.Api.Dtos; + +/// 大屏概览 KPI(按日期区间统计) +public record OverviewDto( + decimal Amount, int Orders, decimal NetWeight, decimal Paid, + decimal AvgPrice, int FarmerCount, int WeighingCount); + +/// 趋势点 +public record TrendPoint(string Date, decimal Amount, decimal NetWeight); + +/// 占比点 +public record RatioPoint(string Name, decimal Value); + +/// 农户收购金额排行点 +public record FarmerTopPoint(int FarmerId, string Name, decimal Amount, int Count, decimal NetWeight); + +/// 站点对比点 +public record StationPoint(int OrgId, string Name, decimal Amount, int Count, decimal NetWeight); + +/// 收购单价排行点(按品种平均单价) +public record PriceTopPoint(string Name, decimal AvgPrice, decimal NetWeight, int Count); + +/// 农户收入排行点(收入 = 区间收购金额,含已付) +public record IncomeTopPoint(int FarmerId, string Name, decimal Amount, decimal Paid, int Count); + +/// 实时过磅(进行中的收购单) +public record RealtimeWeighing( + int Id, string OrderNo, string FarmerName, string ProductName, + string Status, decimal GrossWeight, DateTime WeighInAt); + +/// 天气信息 +public record WeatherInfo( + string City, string Region, string Text, string Icon, + decimal TempC, decimal FeelsLike, decimal Humidity, string Wind, + decimal TodayMin, decimal TodayMax, DateTime UpdateTime, string Source); diff --git a/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs new file mode 100644 index 0000000..7aa67f6 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs @@ -0,0 +1,30 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record FarmerDto( + int Id, string Name, string IdCard, string Gender, string FarmerType, string Phone, + string Province, string County, string Township, string Village, string GroupName, + string Address, string BankName, string BankAccount, int CreditScore, + string IdCardFrontUrl, string IdCardBackUrl, string AvatarUrl, + string Status, string Notes, DateTime CreatedAt); + +public record FarmerSaveRequest( + string Name, string IdCard, string Gender, string Phone, + string Province, string County, string Township, string Village, string GroupName, + string Address, string BankName, string BankAccount, int CreditScore = 80, + string FarmerType = "农户", + string IdCardFrontUrl = "", string IdCardBackUrl = "", string AvatarUrl = "", + string Status = "Active", string Notes = ""); + +public static class FarmerMappers +{ + public static FarmerDto ToDto(this Farmer f) => new( + f.Id, f.Name, f.IdCard, f.Gender, f.FarmerType, f.Phone, + f.Province, f.County, f.Township, f.Village, f.GroupName, + f.Address, f.BankName, f.BankAccount, f.CreditScore, + f.IdCardFrontUrl, f.IdCardBackUrl, f.AvatarUrl, + f.Status.ToString(), f.Notes, f.CreatedAt); + + public static FarmerDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/InvoiceDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/InvoiceDtos.cs new file mode 100644 index 0000000..b53c50b --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/InvoiceDtos.cs @@ -0,0 +1,50 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record InvoiceDto( + int Id, string InvoiceNo, string? BatchNo, string InvoiceKind, + int FarmerId, string FarmerName, string? FarmerIdCard, + int PurchaserOrgId, string PurchaserOrgName, int? PurchaseOrderId, string? OrderNo, + int ProductId, string ProductName, decimal Quantity, string Unit, decimal UnitPrice, + decimal Amount, decimal TaxRate, decimal TaxAmount, DateTime IssueDate, + string Status, string? OperatorName, DateTime? ReversedAt, string ReverseReason, DateTime CreatedAt); + +/// 开具销售发票:可指定收购单,也可手工录入 +public record InvoiceCreateRequest( + int FarmerId, int PurchaserOrgId, int ProductId, int? PurchaseOrderId, + decimal Quantity, string Unit, decimal UnitPrice, decimal Amount, + decimal TaxRate = 0, string InvoiceKind = "销售发票"); + +/// 发票作废 +public record InvoiceReverseRequest(string Reason); + +/// 开票结果回填:Status 取值 Issued/Failed/Abnormal +public record InvoiceResultRequest(string Status, string? Message = null); + +/// 收购单维度开票状态行(反向开票清单数据源:过磅称重有效收购单) +public record OrderInvoiceDto( + int Id, string OrderNo, DateTime? WeighOutAt, + int FarmerId, string FarmerName, string? FarmerIdCard, + int ProductId, string ProductName, string Unit, decimal NetWeight, decimal UnitPrice, decimal Amount, + int PurchaserOrgId, string PurchaserOrgName, string? PurchaserTaxNo, + int? InvoiceId, string? InvoiceNo, string? BatchNo, string InvoiceStatus, DateTime? IssueDate); + +/// 创建开票批次(批量开票) +public record CreateBatchRequest(string? From, string? To, long[] OrderIds); + +/// 开票批次进度 +public record BatchProgressDto(string BatchNo, int Total, int Pending, int Issued, int Failed, int Abnormal, bool Done); + +public static class InvoiceMappers +{ + public static InvoiceDto ToDto(this Invoice i) => new( + i.Id, i.InvoiceNo, i.BatchNo, i.InvoiceKind, + i.FarmerId, i.Farmer?.Name ?? "", i.Farmer?.IdCard ?? "", + i.PurchaserOrgId, i.PurchaserOrg?.Name ?? "", i.PurchaseOrderId, i.PurchaseOrder?.OrderNo, + i.ProductId, i.Product?.Name ?? "", i.Quantity, i.Unit, i.UnitPrice, + i.Amount, i.TaxRate, i.TaxAmount, i.IssueDate, + i.Status.ToString(), i.Operator?.RealName ?? "", i.ReversedAt, i.ReverseReason, i.CreatedAt); + + public static InvoiceDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/OrgDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/OrgDtos.cs new file mode 100644 index 0000000..3c7af7d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/OrgDtos.cs @@ -0,0 +1,20 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record OrgDto( + int Id, string Type, string Name, int? ParentId, string? ParentName, + string ContactPerson, string Phone, string Address, string TaxNo, bool IsActive, DateTime CreatedAt); + +public record OrgSaveRequest( + string Type, string Name, int? ParentId, string ContactPerson, + string Phone, string Address, string TaxNo, bool IsActive = true); + +public static class OrgMappers +{ + public static OrgDto ToDto(this Organization o) => new( + o.Id, o.Type.ToString(), o.Name, o.ParentId, o.Parent?.Name, + o.ContactPerson, o.Phone, o.Address, o.TaxNo, o.IsActive, o.CreatedAt); + + public static OrgDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/PaymentDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/PaymentDtos.cs new file mode 100644 index 0000000..46c938f --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/PaymentDtos.cs @@ -0,0 +1,27 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record PaymentDto( + int Id, string PayNo, int? PurchaseOrderId, string? OrderNo, + int FarmerId, string FarmerName, decimal Amount, string Method, + string Status, string TradeNo, string? OperatorName, DateTime? PaidAt, + string Notes, DateTime CreatedAt); + +/// 发起支付(按收购单结算,可为空表示对农户批量结算) +public record PaymentCreateRequest( + int? PurchaseOrderId, int FarmerId, decimal Amount, string Method, string Notes = ""); + +/// 确认支付结果:Status 缺省视为成功,可选 Success/Failed/Abnormal +public record PaymentConfirmRequest(string? TradeNo = null, string? Status = null, string? Message = null); + +public static class PaymentMappers +{ + public static PaymentDto ToDto(this PaymentRecord p) => new( + p.Id, p.PayNo, p.PurchaseOrderId, p.PurchaseOrder?.OrderNo, + p.FarmerId, p.Farmer?.Name ?? "", p.Amount, p.Method.ToString(), + p.Status.ToString(), p.TradeNo, p.Operator?.RealName ?? "", + p.PaidAt, p.Notes, p.CreatedAt); + + public static PaymentDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/ProductDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/ProductDtos.cs new file mode 100644 index 0000000..0c2d38f --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/ProductDtos.cs @@ -0,0 +1,19 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record ProductDto( + int Id, string Name, string Category, string Unit, string Spec, + decimal Price, string Status, DateTime CreatedAt); + +public record ProductSaveRequest( + string Name, string Category, string Unit, string Spec, decimal Price, + string Status = "Active"); + +public static class ProductMappers +{ + public static ProductDto ToDto(this Product p) => new( + p.Id, p.Name, p.Category, p.Unit, p.Spec, p.Price, p.Status.ToString(), p.CreatedAt); + + public static ProductDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs new file mode 100644 index 0000000..204435d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs @@ -0,0 +1,32 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record PurchaseDto( + int Id, string OrderNo, int FarmerId, string FarmerName, string? FarmerPhone, + int ProductId, string ProductName, string Category, int PurchaserOrgId, string PurchaserOrgName, + string Grade, string Unit, decimal UnitPrice, decimal GrossWeight, decimal TareWeight, + decimal NetWeight, decimal Amount, int WeighCount, string Status, + string? OperatorName, DateTime WeighInAt, DateTime? WeighOutAt, string Notes, DateTime CreatedAt); + +/// 新建收购单(第一次过磅:称毛重) +public record PurchaseCreateRequest( + int FarmerId, int ProductId, int PurchaserOrgId, string Grade, + decimal GrossWeight, string Unit = "公斤", decimal UnitPrice = 0, string Notes = ""); + +/// 回皮完成(第二次过磅:称皮重,计算净重与金额) +public record PurchaseTareRequest(decimal TareWeight); + +public static class PurchaseMappers +{ + public static PurchaseDto ToDto(this PurchaseOrder p) => new( + p.Id, p.OrderNo, + p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.Phone ?? "", + p.ProductId, p.Product?.Name ?? "", p.Product?.Category ?? "", + p.PurchaserOrgId, p.PurchaserOrg?.Name ?? "", + p.Grade, p.Unit, p.UnitPrice, p.GrossWeight, p.TareWeight, + p.NetWeight, p.Amount, p.WeighCount, p.Status.ToString(), + p.Operator?.RealName ?? "", p.WeighInAt, p.WeighOutAt, p.Notes, p.CreatedAt); + + public static PurchaseDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Dtos/ReportDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/ReportDtos.cs new file mode 100644 index 0000000..9fa86f1 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/ReportDtos.cs @@ -0,0 +1,19 @@ +namespace AgriculturalPlatform.Api.Dtos; + +/// 汇总明细行 +public record SummaryRow(string Key, string Label, decimal Value, int Count); + +/// 收购汇总(按维度:日/月/品种/农户/站点) +public record PurchaseSummaryResult( + string Dimension, DateTime From, DateTime To, + decimal TotalAmount, decimal TotalNetWeight, int TotalCount, SummaryRow[] Rows); + +/// 付款统计 +public record PaymentSummaryResult( + DateTime From, DateTime To, decimal TotalPaid, decimal TotalPending, int PayCount, + SummaryRow[] ByMethod, SummaryRow[] ByStatus); + +/// 开票统计 +public record InvoiceSummaryResult( + DateTime From, DateTime To, decimal TotalAmount, decimal TotalTax, int IssueCount, + SummaryRow[] ByProduct, SummaryRow[] ByStatus); diff --git a/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs new file mode 100644 index 0000000..1bd892e --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs @@ -0,0 +1,20 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +public record UserDto( + int Id, string Username, string RealName, string Phone, string Role, + int? OrgId, string? OrgName, bool IsActive, DateTime? LastLoginAt, DateTime CreatedAt); + +public record UserSaveRequest( + string Username, string RealName, string Phone, string Role, + int? OrgId, bool IsActive = true, string? Password = null); + +public static class UserMappers +{ + public static UserDto ToDto(this User u) => new( + u.Id, u.Username, u.RealName, u.Phone, u.Role.ToString(), + u.OrgId, u.Org?.Name, u.IsActive, u.LastLoginAt, u.CreatedAt); + + public static UserDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); +} diff --git a/backend/AgriculturalPlatform.Api/Models/Enums.cs b/backend/AgriculturalPlatform.Api/Models/Enums.cs new file mode 100644 index 0000000..b891005 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Enums.cs @@ -0,0 +1,69 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 组织类型:收购公司 / 收购站 / 收购个体 +public enum OrgType +{ + Company, // 收购公司 + Station, // 收购站 + Individual // 收购个体 +} + +/// 用户角色 +public enum UserRole +{ + SuperAdmin, // 系统管理员 + CompanyAdmin, // 公司管理员 + StationStaff, // 收购站员工 + Individual // 收购个体 +} + +/// 农户状态 +public enum FarmerStatus +{ + Active, // 正常 + Frozen // 冻结 +} + +/// 收购单状态 +public enum PurchaseStatus +{ + Weighing, // 过磅中(已称毛重,待回皮) + Completed, // 已完成 + Cancelled // 已作废 +} + +/// 支付方式 +public enum PayMethod +{ + Wechat, // 微信 + Alipay, // 支付宝 + BankTransfer, // 银行转账 + Cash // 现金 +} + +/// 支付状态 +public enum PayStatus +{ + Pending, // 待支付 + Success, // 支付成功 + Failed, // 支付失败 + Abnormal, // 支付异常 + Refunded // 已退款 +} + +/// 发票状态 +public enum InvoiceStatus +{ + Pending, // 待开票 + Issued, // 开票成功 + Failed, // 开票失败 + Abnormal, // 开票异常 + Reversed // 已作废 +} + +/// 品种状态 +public enum ProductStatus +{ + Active, // 启用 + Disabled // 停用 +} diff --git a/backend/AgriculturalPlatform.Api/Models/Farmer.cs b/backend/AgriculturalPlatform.Api/Models/Farmer.cs new file mode 100644 index 0000000..e00e362 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Farmer.cs @@ -0,0 +1,62 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 农户档案 +public class Farmer +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// 身份证号(唯一) + public string IdCard { get; set; } = string.Empty; + + public string Gender { get; set; } = string.Empty; + + /// 农户类型:农户/个体户/合作社/经营集体/公司 + public string FarmerType { get; set; } = "农户"; + + public string Phone { get; set; } = string.Empty; + + /// 所在省 + public string Province { get; set; } = string.Empty; + + /// 所在县(区) + public string County { get; set; } = string.Empty; + + /// 所在乡镇(街道) + public string Township { get; set; } = string.Empty; + + /// 所在村/合作社 + public string Village { get; set; } = string.Empty; + + /// 所在组 + public string GroupName { get; set; } = string.Empty; + + public string Address { get; set; } = string.Empty; + + /// 开户银行 + public string BankName { get; set; } = string.Empty; + + /// 银行卡号(付款用) + public string BankAccount { get; set; } = string.Empty; + + /// 身份证正面附件地址(阿里云 OSS / 本地存储 key 或 URL) + public string IdCardFrontUrl { get; set; } = string.Empty; + + /// 身份证反面附件地址(阿里云 OSS / 本地存储 key 或 URL) + public string IdCardBackUrl { get; set; } = string.Empty; + + /// 农户头像附件地址(摄像头/手机采集,未来用于人脸识别收购) + public string AvatarUrl { get; set; } = string.Empty; + + /// 信用评分(0-100) + public int CreditScore { get; set; } = 80; + + public FarmerStatus Status { get; set; } = FarmerStatus.Active; + + public string Notes { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public DateTime? UpdatedAt { get; set; } +} diff --git a/backend/AgriculturalPlatform.Api/Models/Invoice.cs b/backend/AgriculturalPlatform.Api/Models/Invoice.cs new file mode 100644 index 0000000..0c3c0c3 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Invoice.cs @@ -0,0 +1,66 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 农产品反向开票(收购发票:收购方向农户开具) +public class Invoice +{ + public int Id { get; set; } + + /// 发票号码(如 FP20260812-0001) + public string InvoiceNo { get; set; } = string.Empty; + + /// 开票批次号(批量开票时同一批共用) + public string BatchNo { get; set; } = string.Empty; + + /// 票种:农产品收购发票 + public string InvoiceKind { get; set; } = "农产品收购发票"; + + /// 销售方(农户) + public int FarmerId { get; set; } + + public Farmer? Farmer { get; set; } + + /// 收购方(公司/站/个体) + public int PurchaserOrgId { get; set; } + + public Organization? PurchaserOrg { get; set; } + + /// 关联收购单(可空) + public int? PurchaseOrderId { get; set; } + + public PurchaseOrder? PurchaseOrder { get; set; } + + /// 品名(冗余,便于发票展示) + public int ProductId { get; set; } + + public Product? Product { get; set; } + + /// 数量 + public decimal Quantity { get; set; } + + public string Unit { get; set; } = "公斤"; + + public decimal UnitPrice { get; set; } + + /// 价税合计(元) + public decimal Amount { get; set; } + + /// 税率(%),农产品收购发票免税为 0 + public decimal TaxRate { get; set; } = 0m; + + public decimal TaxAmount { get; set; } + + public DateTime IssueDate { get; set; } = DateTime.Now; + + public InvoiceStatus Status { get; set; } = InvoiceStatus.Issued; + + /// 开票人 + public int? OperatorId { get; set; } + + public User? Operator { get; set; } + + public DateTime? ReversedAt { get; set; } + + public string ReverseReason { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/Organization.cs b/backend/AgriculturalPlatform.Api/Models/Organization.cs new file mode 100644 index 0000000..0dded01 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Organization.cs @@ -0,0 +1,30 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 组织(收购公司 / 收购站 / 收购个体) +public class Organization +{ + public int Id { get; set; } + + public OrgType Type { get; set; } + + /// 组织名称 + public string Name { get; set; } = string.Empty; + + /// 上级组织(收购站归属的公司) + public int? ParentId { get; set; } + + public Organization? Parent { get; set; } + + public string ContactPerson { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + + public string Address { get; set; } = string.Empty; + + /// 税号(公司/个体开票用) + public string TaxNo { get; set; } = string.Empty; + + public bool IsActive { get; set; } = true; + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/PaymentRecord.cs b/backend/AgriculturalPlatform.Api/Models/PaymentRecord.cs new file mode 100644 index 0000000..2ebfe0c --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/PaymentRecord.cs @@ -0,0 +1,45 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 电子支付记录(农户货款结算) +public class PaymentRecord +{ + public int Id { get; set; } + + /// 付款单号(如 ZF20260812-0001) + public string PayNo { get; set; } = string.Empty; + + /// 关联收购单(可为空:批量结算) + public int? PurchaseOrderId { get; set; } + + public PurchaseOrder? PurchaseOrder { get; set; } + + /// 收购方组织(批量结算时也记录归属) + public int? PurchaserOrgId { get; set; } + + public Organization? PurchaserOrg { get; set; } + + public int FarmerId { get; set; } + + public Farmer? Farmer { get; set; } + + /// 付款金额(元) + public decimal Amount { get; set; } + + public PayMethod Method { get; set; } = PayMethod.BankTransfer; + + public PayStatus Status { get; set; } = PayStatus.Pending; + + /// 外部交易流水号 + public string TradeNo { get; set; } = string.Empty; + + /// 经办人 + public int? OperatorId { get; set; } + + public User? Operator { get; set; } + + public DateTime? PaidAt { get; set; } + + public string Notes { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/Product.cs b/backend/AgriculturalPlatform.Api/Models/Product.cs new file mode 100644 index 0000000..7080a89 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Product.cs @@ -0,0 +1,26 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 农产品品种 +public class Product +{ + public int Id { get; set; } + + /// 品种名称(如:小麦、玉米) + public string Name { get; set; } = string.Empty; + + /// 品类(粮食/蔬菜/水果/经济作物/畜禽等) + public string Category { get; set; } = string.Empty; + + /// 计量单位(公斤/吨/斤) + public string Unit { get; set; } = "公斤"; + + /// 规格说明 + public string Spec { get; set; } = string.Empty; + + /// 参考单价(元/单位) + public decimal Price { get; set; } + + public ProductStatus Status { get; set; } = ProductStatus.Active; + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs b/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs new file mode 100644 index 0000000..1803367 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs @@ -0,0 +1,67 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 收购单(过磅称重单) +public class PurchaseOrder +{ + public int Id { get; set; } + + /// 收购单号(如 CG20260812-0001) + public string OrderNo { get; set; } = string.Empty; + + /// 农户 + public int FarmerId { get; set; } + + public Farmer? Farmer { get; set; } + + /// 收购品种 + public int ProductId { get; set; } + + public Product? Product { get; set; } + + /// 收购方组织(公司/站/个体) + public int PurchaserOrgId { get; set; } + + public Organization? PurchaserOrg { get; set; } + + /// 等级(一等/二等/三等) + public string Grade { get; set; } = string.Empty; + + public string Unit { get; set; } = "公斤"; + + /// 单价(元/单位) + public decimal UnitPrice { get; set; } + + /// 毛重(kg) + public decimal GrossWeight { get; set; } + + /// 皮重(kg) + public decimal TareWeight { get; set; } + + /// 净重(kg)= 毛重 - 皮重 + public decimal NetWeight { get; set; } + + /// 金额(元)= 净重 × 单价 + public decimal Amount { get; set; } + + /// 过磅次数:1=已称毛重,2=已回皮完成 + public int WeighCount { get; set; } + + public PurchaseStatus Status { get; set; } = PurchaseStatus.Weighing; + + /// 经办人 + public int? OperatorId { get; set; } + + public User? Operator { get; set; } + + /// 第一次过磅时间(进场称毛重) + public DateTime WeighInAt { get; set; } = DateTime.Now; + + /// 回皮时间 + public DateTime? WeighOutAt { get; set; } + + public string Notes { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public DateTime? UpdatedAt { get; set; } +} diff --git a/backend/AgriculturalPlatform.Api/Models/Region.cs b/backend/AgriculturalPlatform.Api/Models/Region.cs new file mode 100644 index 0000000..89c3d13 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Region.cs @@ -0,0 +1,31 @@ +namespace AgriculturalPlatform.Api.Models; + +/// +/// 行政区划(来自 AreaCity 全国省市区乡镇四级公开数据,导入 Regions 表)。 +/// deep: 0=省 1=市(地级/直辖市虚拟层级) 2=县(区/县级市) 3=乡镇(镇/乡/街道) +/// +public class Region +{ + public int Id { get; set; } + + /// 上级 Id(省为 0) + public int Pid { get; set; } + + /// 层级深度 0~3 + public int Deep { get; set; } + + /// 精简名称(如 武汉) + public string Name { get; set; } = string.Empty; + + /// 完整名称(如 武汉市) + public string ExtName { get; set; } = string.Empty; + + /// 完整拼音(如 wu han) + public string Pinyin { get; set; } = string.Empty; + + /// 拼音首字母前缀(如 w) + public string PinyinPrefix { get; set; } = string.Empty; + + /// 热度(值越大排序越靠前,用于常用省份/地区优先展示,默认 0) + public int Hot { get; set; } +} diff --git a/backend/AgriculturalPlatform.Api/Models/User.cs b/backend/AgriculturalPlatform.Api/Models/User.cs new file mode 100644 index 0000000..09f70f8 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/User.cs @@ -0,0 +1,28 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 系统用户 +public class User +{ + public int Id { get; set; } + + public string Username { get; set; } = string.Empty; + + public string PasswordHash { get; set; } = string.Empty; + + public string RealName { get; set; } = string.Empty; + + public string Phone { get; set; } = string.Empty; + + public UserRole Role { get; set; } + + /// 所属组织(公司管理员→公司;站员工→站;个体→个体组织) + public int? OrgId { get; set; } + + public Organization? Org { get; set; } + + public bool IsActive { get; set; } = true; + + public DateTime? LastLoginAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Program.cs b/backend/AgriculturalPlatform.Api/Program.cs new file mode 100644 index 0000000..fcaff72 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Program.cs @@ -0,0 +1,130 @@ +using System.Text; +using System.Text.Json.Serialization; +using AgriculturalPlatform.Api.Data; +using AgriculturalPlatform.Api.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +// ---------- 注册 GBK 等编码(天气 IP 定位接口使用) ---------- +Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + +// ---------- 在线 MySQL 数据库 ---------- +var connectionString = builder.Configuration.GetConnectionString("Default") + ?? throw new InvalidOperationException("未配置数据库连接字符串 ConnectionStrings:Default"); + +var serverVersion = new MySqlServerVersion(new Version( + builder.Configuration.GetValue("MySql:Major", 8), + builder.Configuration.GetValue("MySql:Minor", 0), + builder.Configuration.GetValue("MySql:Build", 36))); + +builder.Services.AddDbContext(options => + options.UseMySql(connectionString, serverVersion, mysql => + { + mysql.EnableRetryOnFailure(3); + mysql.CommandTimeout(30); + })); + +// ---------- JWT 认证 ---------- +var jwtKey = builder.Configuration["Jwt:Key"] ?? "AgriculturalPlatform-Default-Jwt-Secret-Key-2026!"; +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "AgriculturalPlatform", + ValidAudience = builder.Configuration["Jwt:Audience"] ?? "AgriculturalPlatform.Client", + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)) + }; + options.Events = new JwtBearerEvents + { + OnMessageReceived = ctx => + { + // 兼容前端直接携带 token 的查询参数方式 + if (string.IsNullOrEmpty(ctx.Token) + && ctx.Request.Query.TryGetValue("access_token", out var token)) + { + ctx.Token = token; + } + return Task.CompletedTask; + } + }; + }); +builder.Services.AddAuthorization(); + +// ---------- JSON:枚举序列化为字符串、中文不被转义 ---------- +builder.Services.Configure(o => +{ + o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); + o.SerializerOptions.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping; +}); + +// ---------- 业务服务 ---------- +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpContextAccessor(); + +// ---------- 附件存储(本地为准,OSS 配置好后切换) ---------- +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => + sp.GetRequiredService().GetValue("Storage:UseOss", false) + ? sp.GetRequiredService() + : sp.GetRequiredService()); + +// ---------- OCR 识别(默认占位,接入阿里云/百度 OCR 时替换实现) ---------- +builder.Services.AddSingleton(); + +// ---------- 手机传图上传会话 ---------- +builder.Services.AddSingleton(); + +// ---------- 天气服务(调用第三方天气接口) ---------- +builder.Services.AddHttpClient("weather"); + +// ---------- 前端(Vue 开发服务器)跨域 ---------- +var frontendUrl = builder.Configuration["FrontendUrl"] ?? "http://localhost:5173"; +builder.Services.AddCors(o => o.AddPolicy("frontend", p => + p.WithOrigins(frontendUrl.Split(';')).AllowAnyHeader().AllowAnyMethod().AllowCredentials())); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +// ---------- 初始化数据库(自动建库建表 + 种子数据) ---------- +try +{ + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + await SchemaMigrator.EnsureColumnsAsync(db); + await SchemaMigrator.EnsureRegionsTableAsync(db); + DbSeeder.Seed(db); + app.Logger.LogInformation("数据库初始化完成"); +} +catch (Exception ex) +{ + app.Logger.LogError(ex, "数据库初始化失败,请检查 ConnectionStrings:Default 配置"); +} + +app.UseCors("frontend"); +app.UseAuthentication(); +app.UseAuthorization(); + +// 附件与手机上传页面(wwwroot) +app.UseDefaultFiles(); +app.UseStaticFiles(); + +app.MapOpenApi(); +app.MapControllers(); + +app.Run(); diff --git a/backend/AgriculturalPlatform.Api/Properties/launchSettings.json b/backend/AgriculturalPlatform.Api/Properties/launchSettings.json new file mode 100644 index 0000000..09fb82b --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://0.0.0.0:5246", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/CurrentUser.cs b/backend/AgriculturalPlatform.Api/Services/CurrentUser.cs new file mode 100644 index 0000000..df7f88d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/CurrentUser.cs @@ -0,0 +1,39 @@ +using System.Security.Claims; +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Services; + +/// 从 JWT 声明中解析的当前登录用户 +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(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 + }; + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/DataScopeService.cs b/backend/AgriculturalPlatform.Api/Services/DataScopeService.cs new file mode 100644 index 0000000..ff4b0cb --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/DataScopeService.cs @@ -0,0 +1,42 @@ +using AgriculturalPlatform.Api.Data; +using AgriculturalPlatform.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Services; + +/// +/// 数据权限:根据当前用户角色计算其可见的组织范围 +/// - 系统管理员:全部 +/// - 公司管理员:本公司 + 旗下收购站 +/// - 收购站员工:本站 +/// - 收购个体:本人 +/// +public class DataScopeService(AppDbContext db) +{ + /// 返回可见组织 ID 集合;null 表示全部可见 + public async Task?> GetVisibleOrgIdsAsync(CurrentUser? user) + { + if (user is null) return new HashSet(); + if (user.IsSuperAdmin) return null; + + var ids = new HashSet(); + 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; + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/FileStorage.cs b/backend/AgriculturalPlatform.Api/Services/FileStorage.cs new file mode 100644 index 0000000..e76d11f --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/FileStorage.cs @@ -0,0 +1,79 @@ +namespace AgriculturalPlatform.Api.Services; + +/// 已保存文件的存储 key 与访问地址 +public record StoredFile(string Key, string Url); + +/// +/// 附件文件存储抽象。 +/// 当前默认实现为本地存储(wwwroot/uploads); +/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点), +/// 将 Storage:UseOss 设为 true 即可切换到 OSS。 +/// +public interface IFileStorage +{ + /// 保存文件,返回存储 key 与访问 URL + Task SaveAsync(string subDir, Stream stream, string fileName, string contentType); + + /// 按 key 删除文件(本地/OSS 通用) + Task DeleteAsync(string key); +} + +/// 本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问 +public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage +{ + private readonly string _root = Path.Combine( + env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads"); + + public async Task 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; + } +} + +/// +/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供; +/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。 +/// +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 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 尚未配置授权验证,暂不支持删除"); +} diff --git a/backend/AgriculturalPlatform.Api/Services/JwtService.cs b/backend/AgriculturalPlatform.Api/Services/JwtService.cs new file mode 100644 index 0000000..1ca9f9a --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/JwtService.cs @@ -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 + { + 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); + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/MobileUploadSessionStore.cs b/backend/AgriculturalPlatform.Api/Services/MobileUploadSessionStore.cs new file mode 100644 index 0000000..1110f0f --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/MobileUploadSessionStore.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace AgriculturalPlatform.Api.Services; + +/// 手机传图上传会话(内存存储,10 分钟过期) +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; } +} + +/// 手机传图上传会话存储(单机内存版,后续可换 Redis) +public sealed class MobileUploadSessionStore +{ + private static readonly TimeSpan Expire = TimeSpan.FromMinutes(10); + private readonly ConcurrentDictionary _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 _); + } + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs b/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs new file mode 100644 index 0000000..1194d85 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs @@ -0,0 +1,37 @@ +using AgriculturalPlatform.Api.Data; +using Microsoft.EntityFrameworkCore; + +namespace AgriculturalPlatform.Api.Services; + +/// 业务单号生成:前缀 + 日期 + 当日序号(如 CG20260812-0001) +public class NumberGenerator(AppDbContext db) +{ + public async Task 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}"; + } +} diff --git a/backend/AgriculturalPlatform.Api/Services/OcrService.cs b/backend/AgriculturalPlatform.Api/Services/OcrService.cs new file mode 100644 index 0000000..f89a582 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/OcrService.cs @@ -0,0 +1,33 @@ +namespace AgriculturalPlatform.Api.Services; + +/// 身份证 OCR 识别结果(未识别成功时 Success=false 并给出原因) +public record IdCardOcrResult( + string Name = "", + string IdCard = "", + string Gender = "", + string Address = "", + string BankName = "", + string BankAccount = "", + bool Success = false, + string Message = ""); + +/// +/// OCR 识别服务抽象。当前默认实现为 DisabledOcrService(仅占位); +/// 后续接入阿里云/百度 OCR 时,实现本接口并在 Program.cs 替换注册即可,调用方无需改动。 +/// +public interface IOcrService +{ + /// 识别身份证图片,side: front(正面)/ back(反面) + Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg"); +} + +/// 默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件 +public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService +{ + public Task 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)); + } +} diff --git a/backend/AgriculturalPlatform.Api/appsettings.json b/backend/AgriculturalPlatform.Api/appsettings.json new file mode 100644 index 0000000..60d9baa --- /dev/null +++ b/backend/AgriculturalPlatform.Api/appsettings.json @@ -0,0 +1,40 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "server=116.198.221.105;port=3306;database=F8Web;user=f8web;password=r^P%eGC2e2;charset=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True" + }, + "MySql": { + "Major": 8, + "Minor": 0, + "Build": 36 + }, + "Jwt": { + "Key": "AgriculturalPlatform-Jwt-Secret-Key-Change-Me-In-Production-2026", + "Issuer": "AgriculturalPlatform", + "Audience": "AgriculturalPlatform.Client" + }, + "FrontendUrl": "http://localhost:5173", + "Server": { + "LanPort": 5246 + }, + "Storage": { + "UseOss": false + }, + "Oss": { + "AccessKeyId": "", + "AccessKeySecret": "", + "Endpoint": "oss-cn-hangzhou.aliyuncs.com", + "Bucket": "", + "PublicUrl": "" + }, + "Ocr": { + "Provider": "none", + "DisabledMessage": "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写" + } +} diff --git a/backend/AgriculturalPlatform.Api/wwwroot/mobile-upload.html b/backend/AgriculturalPlatform.Api/wwwroot/mobile-upload.html new file mode 100644 index 0000000..274b425 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/wwwroot/mobile-upload.html @@ -0,0 +1,139 @@ + + + + + +身份证上传 + + + +
+

身份证上传

+

请将身份证正面(人像面)、反面(国徽面)清晰拍摄后上传

+ +
+

正面(人像面)

+ + + +
+ +
+

反面(国徽面)

+ + + +
+ + +
+
+ + + + diff --git a/backend/AgriculturalPlatform.slnx b/backend/AgriculturalPlatform.slnx new file mode 100644 index 0000000..c329b08 --- /dev/null +++ b/backend/AgriculturalPlatform.slnx @@ -0,0 +1,3 @@ + + + diff --git a/cleanup.mjs b/cleanup.mjs new file mode 100644 index 0000000..eba367e --- /dev/null +++ b/cleanup.mjs @@ -0,0 +1,12 @@ +const BASE = 'http://localhost:5246' +const login = await fetch(BASE + '/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: '123456' }) +}).then((r) => r.json()) +const h = { Authorization: `Bearer ${login.token}` } + +for (const id of [60, 61]) { + const res = await fetch(`${BASE}/api/farmers/${id}`, { method: 'DELETE', headers: h }) + console.log(`delete ${id} => ${res.status}`) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..43d8b14 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +# 可选:本地开发用 MySQL 8 数据库 +# 启动:docker compose up -d +# 连接:server=localhost;port=3306;database=agricultural_platform;user=root;password=root123456 +services: + mysql: + image: mysql:8.0 + container_name: agri-platform-mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: root123456 + MYSQL_DATABASE: agricultural_platform + TZ: Asia/Shanghai + ports: + - "3306:3306" + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + volumes: + - mysql_data:/var/lib/mysql + +volumes: + mysql_data: diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..d1aa4ae --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,2 @@ +VITE_API_BASE=/api +VITE_APP_TITLE=农易富 · 农产品收购交易平台 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..62cfccc --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + 农易富 · 农产品收购交易平台 + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..826ba85 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2514 @@ +{ + "name": "agricultural-platform-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agricultural-platform-web", + "version": "1.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "@types/qrcode": "^1.5.6", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "echarts": "^5.6.0", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "qrcode": "^1.5.4", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.14.4", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.4.tgz", + "integrity": "sha512-vMKR9tFcLeNrJgFXA3zhUn6YuRKUQW9d0btakBR8U1Iq8MfzkjMOGcgs5de2VgiLldHt69brmuBHpxc3bK4ZgQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.3.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.9", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.9.tgz", + "integrity": "sha512-3c/UfMe0SqyEfcGTyH7mfshHagJ9QTCbppCb0/uGpHZpFug7+If3GeGZN7I0YheKEExemx3xldQPoO7PQSOLQg==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cb4007d --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "agricultural-platform-web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.1", + "@types/qrcode": "^1.5.6", + "axios": "^1.7.9", + "dayjs": "^1.11.13", + "echarts": "^5.6.0", + "element-plus": "^2.9.1", + "pinia": "^2.3.0", + "qrcode": "^1.5.4", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@types/node": "^22.10.5", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.7.3", + "vite": "^6.0.7", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..cd9578a --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,6 @@ + + + diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..6304df8 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,178 @@ +import request from './request' +import type { + AvatarUploadResult, IdCardOcrResult, IdCardUploadResult, MobileQrResult, MobileResult, + Invoice, InvoiceBatchProgress, OrderInvoice, Farmer, FarmerOption, CountyItem, VillageItem, + LoginResult, Org, PageQuery, PagedResult, PaymentRecord, + Product, PurchaseOrder, PurchaseSummaryResult, PaymentSummaryResult, + InvoiceSummaryResult, Overview, TrendPoint, RatioPoint, FarmerTopPoint, + StationPoint, PriceTopPoint, IncomeTopPoint, RealtimeWeighing, + SysUser, UserInfo, WeatherInfo +} from '@/types' + +// ---------- 认证 ---------- +export const login = (data: { username: string; password: string }) => + request.post('/auth/login', data) +export const getMe = () => request.get('/auth/me') +export const changePassword = (data: { oldPassword: string; newPassword: string }) => + request.put('/auth/password', data) + +// ---------- 组织 ---------- +export const getOrgs = (params?: { type?: string; keyword?: string }) => + request.get('/orgs', { params }) +export const saveOrg = (data: Partial & { id?: number }) => + data.id ? request.put(`/orgs/${data.id}`, data) : request.post('/orgs', data) +export const deleteOrg = (id: number) => request.delete(`/orgs/${id}`) + +// ---------- 用户 ---------- +export const getUsers = (params: PageQuery & { role?: string; orgId?: number }) => + request.get>('/users', { params }) +export const saveUser = (data: any) => + data.id ? request.put(`/users/${data.id}`, data) : request.post('/users', data) +export const deleteUser = (id: number) => request.delete(`/users/${id}`) +export const resetPassword = (id: number, newPassword?: string) => + request.put(`/users/${id}/reset-password`, JSON.stringify(newPassword ?? '123456'), { + headers: { 'Content-Type': 'application/json' } + }) + +// ---------- 农户 ---------- +export const getFarmers = (params: PageQuery & { status?: string }) => + request.get>('/farmers', { params }) +export const getFarmerAll = (params?: { keyword?: string; limit?: number }) => + request.get('/farmers/all', { params }) +export const getFarmerDetail = (id: number) => + request.get(`/farmers/${id}`) +export const saveFarmer = (data: Partial & { id?: number }) => + data.id ? request.put(`/farmers/${data.id}`, data) : request.post('/farmers', data) +export const deleteFarmer = (id: number) => request.delete(`/farmers/${id}`) + +// ---------- 行政区划(县/乡镇来自公开数据,村/组来自农户表) ---------- +export const getProvinces = () => request.get('/regions/provinces') +export const getCounties = (province: string) => + request.get('/regions/counties', { params: { province } }) +export const getTownships = (county: string, city?: string) => + request.get('/regions/townships', { params: { county, city } }) +export const getVillages = (county?: string, township?: string) => + request.get('/regions/villages', { params: { county, township } }) +export const getGroups = (county?: string, township?: string, village?: string) => + request.get('/regions/groups', { params: { county, township, village } }) + +// ---------- 身份证附件上传(高拍仪 / 手机传图) ---------- +export const uploadIdCard = (file: File, side: 'front' | 'back') => { + const fd = new FormData() + fd.append('file', file) + fd.append('side', side) + return request.post('/uploads/idcard', fd) +} +/** 创建手机传图二维码会话,返回手机访问 URL */ +export const getUploadQr = () => request.get('/uploads/qr') +/** 轮询手机上传结果 */ +export const getMobileResult = (code: string) => + request.get(`/uploads/mobile/${code}/result`) +/** 农户头像上传(摄像头/本地图片) */ +export const uploadAvatar = (file: File) => { + const fd = new FormData() + fd.append('file', file) + return request.post('/uploads/avatar', fd) +} + +// ---------- 品种 ---------- +export const getProducts = (params?: { category?: string; keyword?: string }) => + request.get('/products', { params }) +export const getCategories = () => request.get('/products/categories') +export const saveProduct = (data: Partial & { id?: number }) => + data.id ? request.put(`/products/${data.id}`, data) : request.post('/products', data) +export const deleteProduct = (id: number) => request.delete(`/products/${id}`) + +// ---------- 收购单(过磅称重) ---------- +export const getPurchases = (params: PageQuery & { + status?: string; farmerId?: number; productId?: number; orgId?: number; from?: string; to?: string +}) => request.get>('/purchases', { params }) +export const getWeighingList = () => request.get('/purchases/weighing') +export const getPurchase = (id: number) => request.get(`/purchases/${id}`) +export const createPurchase = (data: any) => request.post('/purchases', data) +export const completeTare = (id: number, tareWeight: number) => + request.put(`/purchases/${id}/tare`, { tareWeight }) +export const updatePurchasePrice = (id: number, unitPrice: number, grade?: string) => + request.put(`/purchases/${id}/price`, unitPrice, { + params: { grade }, + headers: { 'Content-Type': 'application/json' } + }) +export const cancelPurchase = (id: number) => request.put(`/purchases/${id}/cancel`) + +// ---------- 支付 ---------- +export const getPayments = (params: PageQuery & { + status?: string; farmerId?: number; orgId?: number; from?: string; to?: string +}) => request.get>('/payments', { params }) +export const getPaymentStats = () => + request.get('/payments/stats') +export const getFarmerSummary = (farmerId: number, orgId?: number) => + request.get('/payments/farmer-summary', { params: { farmerId, orgId } }) +export const createPayment = (data: any) => request.post('/payments', data) +export const confirmPayment = (id: number, tradeNo?: string) => + request.put(`/payments/${id}/confirm`, { tradeNo }) +export const updatePaymentResult = (id: number, status: string, message?: string) => + request.put(`/payments/${id}/confirm`, { status, message }) +export const refundPayment = (id: number) => request.put(`/payments/${id}/refund`) + +// ---------- 发票 ---------- +export const getInvoices = (params: PageQuery & { + status?: string; farmerId?: number; productId?: number; from?: string; to?: string +}) => request.get>('/invoices', { params }) +export const getInvoiceStats = () => + request.get('/invoices/stats') +export const getAvailableOrders = (farmerId: number) => + request.get( + '/invoices/available-orders', { params: { farmerId } }) +export const createInvoice = (data: any) => request.post('/invoices', data) +export const reverseInvoice = (id: number, reason: string) => + request.put(`/invoices/${id}/reverse`, { reason }) +export const updateInvoiceResult = (id: number, status: string, message?: string) => + request.put(`/invoices/${id}/result`, { status, message }) +/** 收购单维度开票清单(反向开票数据源:过磅称重有效收购单) */ +export const getInvoiceOrders = (params: PageQuery & { + status?: string; from?: string; to?: string +}) => request.get>('/invoices/orders', { params }) +/** 创建开票批次并批量发起开票 */ +export const createInvoiceBatch = (data: { from?: string; to?: string; orderIds: number[] }) => + request.post('/invoices/batches', data) +/** 批次开票进度 */ +export const getBatchProgress = (batchNo: string) => + request.get(`/invoices/batches/${batchNo}/progress`) +/** 模拟推进批次开票(每步处理部分待开票发票) */ +export const stepBatchProcess = (batchNo: string) => + request.post(`/invoices/batches/${batchNo}/step`) + +// ---------- 报表 ---------- +export const getPurchaseSummary = (params: { dimension: string; from?: string; to?: string }) => + request.get('/reports/purchase-summary', { params }) +export const getPaymentSummary = (params?: { from?: string; to?: string }) => + request.get('/reports/payment-summary', { params }) +export const getInvoiceSummary = (params?: { from?: string; to?: string }) => + request.get('/reports/invoice-summary', { params }) + +// ---------- 大屏 ---------- +export interface DateRange { from?: string; to?: string } +export const getOverview = (params?: DateRange) => + request.get('/dashboard/overview', { params }) +export const getTrend = (params?: DateRange) => + request.get('/dashboard/trend', { params }) +export const getProductDistribution = (params?: DateRange) => + request.get('/dashboard/product-distribution', { params }) +export const getFarmerTop = (params?: DateRange & { limit?: number }) => + request.get('/dashboard/farmer-top', { params }) +export const getStationComparison = (params?: DateRange) => + request.get('/dashboard/station-comparison', { params }) +export const getPriceTop = (params?: DateRange & { limit?: number }) => + request.get('/dashboard/price-top', { params }) +export const getIncomeTop = (params?: DateRange & { limit?: number }) => + request.get('/dashboard/income-top', { params }) +export const getRealtimeWeighing = () => request.get('/dashboard/realtime-weighing') +export const getWeather = (params?: { lat?: number; lng?: number }) => + request.get('/dashboard/weather', { params }) + +// ---------- 演示数据 ---------- +export const createDemoData = () => + request.post('/admin/demo-data') diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..bcaaea6 --- /dev/null +++ b/frontend/src/api/request.ts @@ -0,0 +1,36 @@ +import axios, { type AxiosInstance } from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +const request: AxiosInstance = axios.create({ + baseURL: import.meta.env.VITE_API_BASE || '/api', + timeout: 30000 +}) + +request.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +request.interceptors.response.use( + (response) => response.data, + (error) => { + const status = error.response?.status + const message = error.response?.data?.message || error.message || '请求失败' + + if (status === 401) { + localStorage.removeItem('token') + localStorage.removeItem('user') + ElMessage.error('登录已过期,请重新登录') + router.push('/login') + } else { + ElMessage.error(message) + } + return Promise.reject(error) + } +) + +export default request diff --git a/frontend/src/components/FarmerAvatarUploader.vue b/frontend/src/components/FarmerAvatarUploader.vue new file mode 100644 index 0000000..33a7cc9 --- /dev/null +++ b/frontend/src/components/FarmerAvatarUploader.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/frontend/src/components/InvoicePreview.vue b/frontend/src/components/InvoicePreview.vue new file mode 100644 index 0000000..44117f9 --- /dev/null +++ b/frontend/src/components/InvoicePreview.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts new file mode 100644 index 0000000..dbbc749 --- /dev/null +++ b/frontend/src/env.d.ts @@ -0,0 +1,16 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +interface ImportMetaEnv { + readonly VITE_API_BASE: string + readonly VITE_APP_TITLE: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue new file mode 100644 index 0000000..542a7c8 --- /dev/null +++ b/frontend/src/layouts/MainLayout.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..66dbec0 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,22 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +// 全局注册图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.mount('#app') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..e4f7618 --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,45 @@ +import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' + +const routes: RouteRecordRaw[] = [ + { path: '/login', name: 'Login', component: () => import('@/views/Login.vue'), meta: { public: true } }, + { + path: '/', + component: () => import('@/layouts/MainLayout.vue'), + redirect: '/home', + children: [ + { path: 'home', name: 'Home', component: () => import('@/views/Home.vue'), meta: { title: '工作台' } }, + { path: 'farmers', name: 'Farmers', component: () => import('@/views/farmers/FarmerList.vue'), meta: { title: '农户管理' } }, + { path: 'products', name: 'Products', component: () => import('@/views/products/ProductList.vue'), meta: { title: '品种管理' } }, + { path: 'orgs', name: 'Orgs', component: () => import('@/views/orgs/OrgList.vue'), meta: { title: '组织管理' } }, + { path: 'users', name: 'Users', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理' } }, + { path: 'weighing', name: 'Weighing', component: () => import('@/views/weighing/WeighingList.vue'), meta: { title: '过磅称重' } }, + { path: 'weighing/touch', name: 'WeighingTouch', component: () => import('@/views/weighing/WeighingTouch.vue'), meta: { title: '触摸屏过磅' } }, + { path: 'payments', name: 'Payments', component: () => import('@/views/payments/PaymentList.vue'), meta: { title: '电子支付' } }, + { path: 'invoices', name: 'Invoices', component: () => import('@/views/invoices/InvoiceList.vue'), meta: { title: '反向开票' } }, + { path: 'reports/purchase', name: 'ReportPurchase', component: () => import('@/views/reports/ReportPurchase.vue'), meta: { title: '收购报表' } }, + { path: 'reports/payment', name: 'ReportPayment', component: () => import('@/views/reports/ReportPayment.vue'), meta: { title: '付款报表' } }, + { path: 'reports/invoice', name: 'ReportInvoice', component: () => import('@/views/reports/ReportInvoice.vue'), meta: { title: '开票报表' } }, + { path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } } + ] + }, + { path: '/dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '可视化大屏', public: false } }, + { path: '/:pathMatch(.*)*', redirect: '/home' } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +router.beforeEach((to) => { + const token = localStorage.getItem('token') + if (!to.meta.public && !token) { + return { path: '/login', query: { redirect: to.fullPath } } + } + if (to.path === '/login' && token) { + return '/home' + } + return true +}) + +export default router diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts new file mode 100644 index 0000000..56c6e71 --- /dev/null +++ b/frontend/src/stores/auth.ts @@ -0,0 +1,32 @@ +import { defineStore } from 'pinia' +import type { UserInfo } from '@/types' + +export const useAuthStore = defineStore('auth', { + state: () => ({ + token: localStorage.getItem('token') || '', + user: JSON.parse(localStorage.getItem('user') || 'null') as UserInfo | null + }), + getters: { + isLoggedIn: (state) => !!state.token, + role: (state) => state.user?.role || '', + isSuperAdmin: (state) => state.user?.role === 'SuperAdmin' + }, + actions: { + setLogin(token: string, user: UserInfo) { + this.token = token + this.user = user + localStorage.setItem('token', token) + localStorage.setItem('user', JSON.stringify(user)) + }, + setUser(user: UserInfo) { + this.user = user + localStorage.setItem('user', JSON.stringify(user)) + }, + logout() { + this.token = '' + this.user = null + localStorage.removeItem('token') + localStorage.removeItem('user') + } + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..16d4d7f --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,385 @@ +export interface UserInfo { + id: number + username: string + realName: string + phone: string + role: string + orgId: number | null + orgName: string | null + orgType: string | null + isActive: boolean +} + +export interface LoginResult { + token: string + user: UserInfo +} + +export interface PagedResult { + items: T[] + total: number +} + +export interface PageQuery { + page: number + pageSize: number + keyword?: string +} + +export interface Org { + id: number + type: string + name: string + parentId: number | null + parentName: string | null + contactPerson: string + phone: string + address: string + taxNo: string + isActive: boolean + createdAt: string +} + +export interface SysUser { + id: number + username: string + realName: string + phone: string + role: string + orgId: number | null + orgName: string | null + isActive: boolean + lastLoginAt: string | null + createdAt: string +} + +export interface Farmer { + id: number + name: string + idCard: string + gender: string + /** 农户类型:农户/个体户/合作社/经营集体/公司 */ + farmerType: string + phone: string + province: string + county: string + township: string + village: string + groupName: string + address: string + bankName: string + bankAccount: string + /** 身份证正面附件地址 */ + idCardFrontUrl: string + /** 身份证反面附件地址 */ + idCardBackUrl: string + /** 农户头像附件地址(摄像头/手机采集,未来用于人脸识别收购) */ + avatarUrl: string + creditScore: number + status: string + notes: string + createdAt: string +} + +/** 行政区划:县级(name 为区县名,city 为所属地级市/州,used 表示数据库已录入过) */ +export interface CountyItem { + name: string + city: string + /** 该区县是否已在农户表中录入过(前端优先展示) */ + used?: boolean +} + +/** 村选项:name 为村名,township 为所属乡镇(同名村检测用) */ +export interface VillageItem { + name: string + township: string +} + +/** 头像上传结果 */ +export interface AvatarUploadResult { + url: string + key: string +} + +/** 身份证 OCR 识别结果 */ +export interface IdCardOcrResult { + name: string + idCard: string + gender: string + address: string + bankName: string + bankAccount: string + success: boolean + message: string +} + +/** 身份证上传结果(高拍仪/本地拍照) */ +export interface IdCardUploadResult { + url: string + key: string + side: string + ocr: IdCardOcrResult +} + +/** 手机传图二维码会话 */ +export interface MobileQrResult { + code: string + url: string + expiresAt: string +} + +/** 手机上传结果轮询 */ +export interface MobileResult { + done: boolean + frontUrl?: string + frontKey?: string + backUrl?: string + backKey?: string + ocr?: IdCardOcrResult +} + +/** 农户下拉选项(地区接口/农户 all 接口) */ +export interface FarmerOption { + id: number + name: string + phone: string + province: string + county: string + township: string + village: string + groupName: string + idCard: string +} + +export interface Product { + id: number + name: string + category: string + unit: string + spec: string + price: number + status: string + createdAt: string +} + +export interface PurchaseOrder { + id: number + orderNo: string + farmerId: number + farmerName: string + farmerPhone: string | null + productId: number + productName: string + category: string + purchaserOrgId: number + purchaserOrgName: string + grade: string + unit: string + unitPrice: number + grossWeight: number + tareWeight: number + netWeight: number + amount: number + weighCount: number + status: string + operatorName: string | null + weighInAt: string + weighOutAt: string | null + notes: string + createdAt: string +} + +export interface PaymentRecord { + id: number + payNo: string + purchaseOrderId: number | null + orderNo: string | null + farmerId: number + farmerName: string + amount: number + method: string + status: string + tradeNo: string + operatorName: string | null + paidAt: string | null + notes: string + createdAt: string +} + +export interface Invoice { + id: number + invoiceNo: string + batchNo: string | null + invoiceKind: string + farmerId: number + farmerName: string + farmerIdCard: string | null + purchaserOrgId: number + purchaserOrgName: string + purchaseOrderId: number | null + orderNo: string | null + productId: number + productName: string + quantity: number + unit: string + unitPrice: number + amount: number + taxRate: number + taxAmount: number + issueDate: string + status: string + operatorName: string | null + reversedAt: string | null + reverseReason: string + createdAt: string +} + +/** 收购单维度开票状态行(反向开票清单数据源:过磅称重有效收购单) */ +export interface OrderInvoice { + id: number + orderNo: string + weighOutAt: string | null + farmerId: number + farmerName: string + farmerIdCard: string | null + productId: number + productName: string + unit: string + netWeight: number + unitPrice: number + amount: number + purchaserOrgId: number + purchaserOrgName: string + purchaserTaxNo: string | null + invoiceId: number | null + invoiceNo: string | null + batchNo: string | null + /** None/Pending/Issued/Failed/Abnormal */ + invoiceStatus: string + issueDate: string | null +} + +/** 开票批次进度 */ +export interface InvoiceBatchProgress { + batchNo: string + total: number + pending: number + issued: number + failed: number + abnormal: number + done: boolean +} + +export interface Overview { + amount: number + orders: number + netWeight: number + paid: number + avgPrice: number + farmerCount: number + weighingCount: number +} + +export interface TrendPoint { + date: string + amount: number + netWeight: number +} + +export interface RatioPoint { + name: string + value: number +} + +export interface FarmerTopPoint { + farmerId: number + name: string + amount: number + count: number + netWeight: number +} + +export interface StationPoint { + orgId: number + name: string + amount: number + count: number + netWeight: number +} + +export interface PriceTopPoint { + name: string + avgPrice: number + netWeight: number + count: number +} + +export interface IncomeTopPoint { + farmerId: number + name: string + amount: number + paid: number + count: number +} + +export interface RealtimeWeighing { + id: number + orderNo: string + farmerName: string + productName: string + status: string + grossWeight: number + weighInAt: string +} + +export interface WeatherInfo { + city: string + region: string + text: string + icon: string + tempC: number + feelsLike: number + humidity: number + wind: string + todayMin: number + todayMax: number + updateTime: string + source: string +} + +export interface SummaryRow { + key: string + label: string + value: number + count: number +} + +export interface PurchaseSummaryResult { + dimension: string + from: string + to: string + totalAmount: number + totalNetWeight: number + totalCount: number + rows: SummaryRow[] +} + +export interface PaymentSummaryResult { + from: string + to: string + totalPaid: number + totalPending: number + payCount: number + byMethod: SummaryRow[] + byStatus: SummaryRow[] +} + +export interface InvoiceSummaryResult { + from: string + to: string + totalAmount: number + totalTax: number + issueCount: number + byProduct: SummaryRow[] + byStatus: SummaryRow[] +} diff --git a/frontend/src/utils/format.ts b/frontend/src/utils/format.ts new file mode 100644 index 0000000..eed1ca4 --- /dev/null +++ b/frontend/src/utils/format.ts @@ -0,0 +1,90 @@ +// ---------- 手机号人性化展示 ---------- + +/** 手机号 13812345678 -> 138 1234 5678;非 11 位原样返回 */ +export function formatPhone(phone?: string | null): string { + const p = (phone ?? '').replace(/\s+/g, '') + if (/^1\d{10}$/.test(p)) return `${p.slice(0, 3)} ${p.slice(3, 7)} ${p.slice(7)}` + return p +} + +/** 手机号输入时自动格式化:仅保留数字,边输入边补空格 */ +export function formatPhoneInput(value: string): string { + const digits = value.replace(/\D+/g, '').slice(0, 11) + if (digits.length <= 3) return digits + if (digits.length <= 7) return `${digits.slice(0, 3)} ${digits.slice(3)}` + return `${digits.slice(0, 3)} ${digits.slice(3, 7)} ${digits.slice(7)}` +} + +// ---------- 银行卡号 ---------- + +/** 银行卡号 6228480012345678 -> 6228 4800 1234 5678(4 位一组) */ +export function formatBankCard(cardNo?: string | null): string { + const c = (cardNo ?? '').replace(/\s+/g, '') + return c.replace(/(.{4})/g, '$1 ').trim() +} + +/** 银行卡号输入时自动格式化(仅保留数字) */ +export function formatBankCardInput(value: string): string { + const digits = value.replace(/\D+/g, '').slice(0, 25) + return digits.replace(/(.{4})/g, '$1 ').trim() +} + +// ---------- 常见银行(开户银行下拉选项) ---------- + +export const COMMON_BANKS = [ + '中国农业银行', + '中国工商银行', + '中国建设银行', + '中国银行', + '交通银行', + '中国邮政储蓄银行', + '招商银行', + '中信银行', + '中国光大银行', + '华夏银行', + '中国民生银行', + '广发银行', + '平安银行', + '兴业银行', + '上海浦东发展银行', + '上海银行', + '北京银行', + '四川农村商业银行(农信社)', + '广西农村商业银行(农信社)', + '成都银行', + '桂林银行', + '广西北部湾银行', + '其他银行' +] + +/** 银行卡 BIN 前缀(前 6 位,覆盖常见卡) */ +const BANK_BINS: Array<{ name: string; prefixes: string[] }> = [ + { name: '中国农业银行', prefixes: ['622848', '622846', '622845', '622820', '622821', '622822', '622823', '622824', '622825', '622826', '622827', '622828', '622829', '621671', '621672', '621673', '621674', '621336', '621337', '621338'] }, + { name: '中国工商银行', prefixes: ['622200', '622202', '622203', '622208', '622210', '622211', '622212', '621226', '621227', '621228', '621229', '621281', '621558', '621559', '621721', '621722', '621723'] }, + { name: '中国建设银行', prefixes: ['621700', '621081', '621082', '621083', '621084', '621085', '621086', '621087', '621088', '622700', '622707', '622709', '436742', '436745'] }, + { name: '中国银行', prefixes: ['621660', '621661', '621662', '621663', '621665', '621666', '621667', '621668', '621669', '621670', '456351', '601382', '621756', '621758', '621759', '621760'] }, + { name: '交通银行', prefixes: ['622260', '622262', '622263', '622264', '405512', '601428', '621016', '621017', '621018', '621019', '621020'] }, + { name: '中国邮政储蓄银行', prefixes: ['621095', '621096', '621098', '621797', '621799', '622150', '622151', '622188', '622199', '622832', '621622'] }, + { name: '招商银行', prefixes: ['621485', '621286', '621483', '622575', '622576', '622577', '622578', '622579', '410062', '468203', '518718'] }, + { name: '中信银行', prefixes: ['621767', '621768', '621769', '621770', '622690', '622691', '622692', '622693', '622695', '622696', '968807'] }, + { name: '中国光大银行', prefixes: ['621489', '621491', '621492', '622660', '622661', '622662', '622663', '622664', '622665', '622666', '622667', '622668', '622669'] }, + { name: '华夏银行', prefixes: ['621460', '621461', '621462', '622630', '622631', '622632', '622633', '622634', '622635', '623020', '623021'] }, + { name: '中国民生银行', prefixes: ['621691', '621692', '621693', '622615', '622616', '622617', '622618', '622619', '622620', '622621', '622622', '622623', '415599', '421393'] }, + { name: '广发银行', prefixes: ['621462', '621463', '621464', '622568', '622569', '622570', '622571', '622572', '622573', '622574', '436768'] }, + { name: '平安银行', prefixes: ['621626', '621627', '621628', '622155', '622156', '622157', '622158', '622159', '602907', '412962'] }, + { name: '兴业银行', prefixes: ['621081', '621082', '621083', '621084', '621085', '621086', '621087', '621088', '622908', '622909', '622910', '622911', '622922', '623061'] }, + { name: '上海浦东发展银行', prefixes: ['621792', '621793', '621794', '621795', '621796', '621797', '621798', '621799', '622521', '622522', '622523', '622524', '622525', '622526'] }, + { name: '四川农村商业银行(农信社)', prefixes: ['623058', '621770', '621771', '621772', '621773'] }, + { name: '广西农村商业银行(农信社)', prefixes: ['623133', '623253', '623254', '623255', '621691'] } +] + +/** 根据银行卡号前缀识别所属银行,识别不到返回 null */ +export function detectBank(cardNo?: string | null): string | null { + const c = (cardNo ?? '').replace(/\s+/g, '') + if (c.length < 6) return null + const head = c.slice(0, 6) + for (const bank of BANK_BINS) { + if (bank.prefixes.some((p) => head.startsWith(p) || p.startsWith(head))) return bank.name + } + return null +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..ac653a8 --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,481 @@ + + + + + diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue new file mode 100644 index 0000000..e25aae1 --- /dev/null +++ b/frontend/src/views/Home.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..573c588 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..b81aba7 --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/frontend/src/views/farmers/FarmerList.vue b/frontend/src/views/farmers/FarmerList.vue new file mode 100644 index 0000000..3114443 --- /dev/null +++ b/frontend/src/views/farmers/FarmerList.vue @@ -0,0 +1,741 @@ + + + + + diff --git a/frontend/src/views/invoices/InvoiceList.vue b/frontend/src/views/invoices/InvoiceList.vue new file mode 100644 index 0000000..9a9372f --- /dev/null +++ b/frontend/src/views/invoices/InvoiceList.vue @@ -0,0 +1,814 @@ + + + + + diff --git a/frontend/src/views/orgs/OrgList.vue b/frontend/src/views/orgs/OrgList.vue new file mode 100644 index 0000000..93e1caa --- /dev/null +++ b/frontend/src/views/orgs/OrgList.vue @@ -0,0 +1,142 @@ + + + + + diff --git a/frontend/src/views/payments/PaymentList.vue b/frontend/src/views/payments/PaymentList.vue new file mode 100644 index 0000000..3a7f262 --- /dev/null +++ b/frontend/src/views/payments/PaymentList.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/frontend/src/views/products/ProductList.vue b/frontend/src/views/products/ProductList.vue new file mode 100644 index 0000000..85935f5 --- /dev/null +++ b/frontend/src/views/products/ProductList.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/frontend/src/views/reports/ReportInvoice.vue b/frontend/src/views/reports/ReportInvoice.vue new file mode 100644 index 0000000..ae8ac77 --- /dev/null +++ b/frontend/src/views/reports/ReportInvoice.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/views/reports/ReportPayment.vue b/frontend/src/views/reports/ReportPayment.vue new file mode 100644 index 0000000..335c1b7 --- /dev/null +++ b/frontend/src/views/reports/ReportPayment.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/views/reports/ReportPurchase.vue b/frontend/src/views/reports/ReportPurchase.vue new file mode 100644 index 0000000..1b91373 --- /dev/null +++ b/frontend/src/views/reports/ReportPurchase.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/frontend/src/views/users/UserList.vue b/frontend/src/views/users/UserList.vue new file mode 100644 index 0000000..0701f4b --- /dev/null +++ b/frontend/src/views/users/UserList.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/views/weighing/WeighingList.vue b/frontend/src/views/weighing/WeighingList.vue new file mode 100644 index 0000000..faf680f --- /dev/null +++ b/frontend/src/views/weighing/WeighingList.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/frontend/src/views/weighing/WeighingTouch.vue b/frontend/src/views/weighing/WeighingTouch.vue new file mode 100644 index 0000000..2271cb8 --- /dev/null +++ b/frontend/src/views/weighing/WeighingTouch.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e0488ed --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vite/client", "node"] + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..b0882af --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/api': { + target: 'http://localhost:5246', + changeOrigin: true + }, + '/uploads': { + target: 'http://localhost:5246', + changeOrigin: true + }, + '/mobile-upload.html': { + target: 'http://localhost:5246', + changeOrigin: true + } + } + } +}) diff --git a/p1.json b/p1.json new file mode 100644 index 0000000..9fe7848 --- /dev/null +++ b/p1.json @@ -0,0 +1 @@ +{"items":[{"id":176,"orderNo":"CG20260813-0035","farmerId":6,"farmerName":"孙秀兰","farmerPhone":"13911110006","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":34.77,"grossWeight":139.06,"tareWeight":3.31,"netWeight":135.75,"amount":4720.03,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T21:24:00","weighOutAt":"2026-08-13T21:48:00","notes":"演示数据","createdAt":"2026-08-13T21:24:00"},{"id":163,"orderNo":"CG20260813-0022","farmerId":8,"farmerName":"周桂芳","farmerPhone":"13911110008","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":31.02,"grossWeight":78.79,"tareWeight":8.18,"netWeight":70.61,"amount":2190.32,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T20:52:00","weighOutAt":"2026-08-13T21:01:00","notes":"演示数据","createdAt":"2026-08-13T20:52:00"},{"id":151,"orderNo":"CG20260813-0010","farmerId":1,"farmerName":"刘老汉","farmerPhone":"13911110001","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":33.06,"grossWeight":103.71,"tareWeight":7.99,"netWeight":95.72,"amount":3164.50,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T19:51:00","weighOutAt":"2026-08-13T20:06:00","notes":"演示数据","createdAt":"2026-08-13T19:51:00"},{"id":143,"orderNo":"CG20260813-0002","farmerId":1,"farmerName":"刘老汉","farmerPhone":"13911110001","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":30.70,"grossWeight":63.84,"tareWeight":7.92,"netWeight":55.92,"amount":1716.74,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T19:45:00","weighOutAt":"2026-08-13T19:51:00","notes":"演示数据","createdAt":"2026-08-13T19:45:00"},{"id":156,"orderNo":"CG20260813-0015","farmerId":2,"farmerName":"陈秀英","farmerPhone":"13911110002","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":28.79,"grossWeight":57.22,"tareWeight":3.29,"netWeight":53.93,"amount":1552.64,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T19:33:00","weighOutAt":"2026-08-13T19:44:00","notes":"演示数据","createdAt":"2026-08-13T19:33:00"},{"id":168,"orderNo":"CG20260813-0027","farmerId":6,"farmerName":"孙秀兰","farmerPhone":"13911110006","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":34.20,"grossWeight":123.37,"tareWeight":3.00,"netWeight":120.37,"amount":4116.65,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T19:18:00","weighOutAt":"2026-08-13T19:28:00","notes":"演示数据","createdAt":"2026-08-13T19:18:00"},{"id":160,"orderNo":"CG20260813-0019","farmerId":3,"farmerName":"张铁柱","farmerPhone":"13911110003","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":29.58,"grossWeight":48.11,"tareWeight":7.36,"netWeight":40.75,"amount":1205.38,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T18:25:00","weighOutAt":"2026-08-13T18:45:00","notes":"演示数据","createdAt":"2026-08-13T18:25:00"},{"id":169,"orderNo":"CG20260813-0028","farmerId":8,"farmerName":"周桂芳","farmerPhone":"13911110008","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":30.42,"grossWeight":29.21,"tareWeight":5.60,"netWeight":23.61,"amount":718.22,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T17:48:00","weighOutAt":"2026-08-13T18:09:00","notes":"演示数据","createdAt":"2026-08-13T17:48:00"},{"id":174,"orderNo":"CG20260813-0033","farmerId":5,"farmerName":"赵大勇","farmerPhone":"13911110005","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":33.74,"grossWeight":120.24,"tareWeight":3.92,"netWeight":116.32,"amount":3924.64,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T17:10:00","weighOutAt":"2026-08-13T17:26:00","notes":"演示数据","createdAt":"2026-08-13T17:10:00"},{"id":155,"orderNo":"CG20260813-0014","farmerId":8,"farmerName":"周桂芳","farmerPhone":"13911110008","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":32.66,"grossWeight":58.44,"tareWeight":6.31,"netWeight":52.13,"amount":1702.57,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T17:05:00","weighOutAt":"2026-08-13T17:29:00","notes":"演示数据","createdAt":"2026-08-13T17:05:00"},{"id":165,"orderNo":"CG20260813-0024","farmerId":6,"farmerName":"孙秀兰","farmerPhone":"13911110006","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":31.39,"grossWeight":94.04,"tareWeight":5.97,"netWeight":88.07,"amount":2764.52,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T16:43:00","weighOutAt":"2026-08-13T16:57:00","notes":"演示数据","createdAt":"2026-08-13T16:43:00"},{"id":145,"orderNo":"CG20260813-0004","farmerId":6,"farmerName":"孙秀兰","farmerPhone":"13911110006","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":31.25,"grossWeight":148.52,"tareWeight":3.79,"netWeight":144.73,"amount":4522.81,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T16:12:00","weighOutAt":"2026-08-13T16:23:00","notes":"演示数据","createdAt":"2026-08-13T16:12:00"},{"id":171,"orderNo":"CG20260813-0030","farmerId":8,"farmerName":"周桂芳","farmerPhone":"13911110008","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":34.43,"grossWeight":131.21,"tareWeight":6.60,"netWeight":124.61,"amount":4290.32,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T16:00:00","weighOutAt":"2026-08-13T16:06:00","notes":"演示数据","createdAt":"2026-08-13T16:00:00"},{"id":148,"orderNo":"CG20260813-0007","farmerId":5,"farmerName":"赵大勇","farmerPhone":"13911110005","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":33.32,"grossWeight":153.75,"tareWeight":4.91,"netWeight":148.84,"amount":4959.35,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T15:15:00","weighOutAt":"2026-08-13T15:38:00","notes":"演示数据","createdAt":"2026-08-13T15:15:00"},{"id":159,"orderNo":"CG20260813-0018","farmerId":8,"farmerName":"周桂芳","farmerPhone":"13911110008","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":29.82,"grossWeight":110.61,"tareWeight":8.41,"netWeight":102.20,"amount":3047.60,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T15:01:00","weighOutAt":"2026-08-13T15:13:00","notes":"演示数据","createdAt":"2026-08-13T15:01:00"},{"id":180,"orderNo":"CG20260813-0039","farmerId":1,"farmerName":"刘老汉","farmerPhone":"13911110001","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":33.39,"grossWeight":107.10,"tareWeight":5.31,"netWeight":101.79,"amount":3398.77,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T14:16:00","weighOutAt":"2026-08-13T14:28:00","notes":"演示数据","createdAt":"2026-08-13T14:16:00"},{"id":154,"orderNo":"CG20260813-0013","farmerId":6,"farmerName":"孙秀兰","farmerPhone":"13911110006","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":35.48,"grossWeight":135.11,"tareWeight":3.04,"netWeight":132.07,"amount":4685.84,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T14:12:00","weighOutAt":"2026-08-13T14:27:00","notes":"演示数据","createdAt":"2026-08-13T14:12:00"},{"id":173,"orderNo":"CG20260813-0032","farmerId":2,"farmerName":"陈秀英","farmerPhone":"13911110002","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":31.70,"grossWeight":40.79,"tareWeight":7.44,"netWeight":33.35,"amount":1057.20,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T14:10:00","weighOutAt":"2026-08-13T14:16:00","notes":"演示数据","createdAt":"2026-08-13T14:10:00"},{"id":152,"orderNo":"CG20260813-0011","farmerId":7,"farmerName":"李保田","farmerPhone":"13911110007","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":34.37,"grossWeight":39.97,"tareWeight":4.60,"netWeight":35.37,"amount":1215.67,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T13:42:00","weighOutAt":"2026-08-13T14:03:00","notes":"演示数据","createdAt":"2026-08-13T13:42:00"},{"id":162,"orderNo":"CG20260813-0021","farmerId":2,"farmerName":"陈秀英","farmerPhone":"13911110002","productId":9,"productName":"蚕茧","category":"经济作物","purchaserOrgId":3,"purchaserOrgName":"城西收购站","grade":"正茧","unit":"公斤","unitPrice":30.02,"grossWeight":103.35,"tareWeight":7.06,"netWeight":96.29,"amount":2890.63,"weighCount":2,"status":"Completed","operatorName":"系统管理员","weighInAt":"2026-08-13T13:33:00","weighOutAt":"2026-08-13T13:45:00","notes":"演示数据","createdAt":"2026-08-13T13:33:00"}],"total":181} diff --git a/scripts/areacity/import-regions.mjs b/scripts/areacity/import-regions.mjs new file mode 100644 index 0000000..101e0ff --- /dev/null +++ b/scripts/areacity/import-regions.mjs @@ -0,0 +1,114 @@ +/** + * AreaCity 全国行政区划数据导入脚本 + * ================================== + * 数据源: https://github.com/xiangyuecn/AreaCity-JsSpider-StatsGov + * 下载: https://github.com/xiangyuecn/AreaCity-JsSpider-StatsGov/releases/download/2025.251231.260403/ok_data_level3-4.csv.7z + * (Gitee 备用: https://gitee.com/xiangyuecn/AreaCity-JsSpider-StatsGov/releases) + * 说明: 解压后得到 ok_data_level4.csv(省市区镇四级,UTF-8 带 BOM) + * 列: id, pid, deep, name, pinyin_prefix, pinyin, ext_id, ext_name + * deep: 0=省 1=市(地级/直辖市虚拟) 2=县(区/县级市) 3=乡镇(镇/乡/街道) + * + * 用法: + * 1) 下载并解压 ok_data_level4.csv 到本目录 tmp/ok_data_level4.csv + * 2) npm i mysql2 + * 3) node import-regions.mjs + * 脚本会 DROP 并重建 Regions 表后导入(幂等,可重复执行)。 + */ +import fs from 'fs' +import path from 'path' +import mysql from 'mysql2/promise' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '../..') +const csvPath = path.join(__dirname, 'tmp/ok_data_level4.csv') + +if (!fs.existsSync(csvPath)) { + console.error('未找到 ' + csvPath + ',请先下载并解压数据文件') + process.exit(1) +} + +// ---------- 读取数据库连接串 ---------- +const appCfg = JSON.parse(fs.readFileSync(path.join(root, 'backend/AgriculturalPlatform.Api/appsettings.json'), 'utf8')) +const parseCs = (s) => Object.fromEntries( + s.split(';').filter(Boolean).map(kv => { + const i = kv.indexOf('=') + return [kv.slice(0, i).trim().toLowerCase(), kv.slice(i + 1).trim()] + }) +) +const dbc = parseCs(appCfg.ConnectionStrings.Default) + +// ---------- 解析 CSV ---------- +const raw = fs.readFileSync(csvPath, 'utf8').replace(/^\uFEFF/, '') +const lines = raw.split(/\r?\n/).filter(l => l.trim().length > 0) +lines.shift() // 去掉表头 + +const parseRow = (line) => { + const fields = [] + let cur = '', inQ = false + for (const ch of line) { + if (ch === '"') inQ = !inQ + else if (ch === ',' && !inQ) { fields.push(cur); cur = '' } + else cur += ch + } + fields.push(cur) + return fields +} + +// 常用省份(热度高的排前面,值越大越靠前,供前端优先展示) +const HOT_PROVINCES = [ + '四川省', '广西壮族自治区', '陕西省', '重庆市', '安徽省', + '云南省', '贵州省', '河南省', '山东省', '湖北省', '湖南省', + '甘肃省', '新疆维吾尔自治区', '江西省', '福建省', '广东省', + '河北省', '山西省', '江苏省', '浙江省' +] +const hotOf = (deep, extName) => { + if (deep !== 0) return 0 + const i = HOT_PROVINCES.indexOf(extName) + return i < 0 ? 0 : HOT_PROVINCES.length - i +} + +const rows = [] +for (const line of lines) { + const [id, pid, deep, name, pfx, pinyin, , extName] = parseRow(line) + if (!id) continue + rows.push([Number(id), Number(pid), Number(deep), name, extName, pinyin, pfx, hotOf(Number(deep), extName)]) +} +console.log('解析完成,共 ' + rows.length + ' 条') +const stat = {} +for (const r of rows) stat[r[2]] = (stat[r[2]] || 0) + 1 +console.log('层级分布(deep):', JSON.stringify(stat)) + +// ---------- 建表并导入 ---------- +const conn = await mysql.createConnection({ + host: dbc.server, + port: Number(dbc.port || 3306), + user: dbc.user, + password: dbc.password, + database: dbc.database, + charset: 'utf8mb4' +}) + +await conn.query('DROP TABLE IF EXISTS Regions') +await conn.query(`CREATE TABLE 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`) + +const BATCH = 1000 +for (let i = 0; i < rows.length; i += BATCH) { + const chunk = rows.slice(i, i + BATCH) + await conn.query('INSERT INTO Regions (Id, Pid, Deep, Name, ExtName, Pinyin, PinyinPrefix, Hot) VALUES ?', [chunk]) +} + +const [cnt] = await conn.query('SELECT COUNT(*) AS c FROM Regions') +console.log('导入完成,Regions 表共 ' + cnt[0].c + ' 条记录') +await conn.end() diff --git a/scripts/areacity/package-lock.json b/scripts/areacity/package-lock.json new file mode 100644 index 0000000..0a102ff --- /dev/null +++ b/scripts/areacity/package-lock.json @@ -0,0 +1,148 @@ +{ + "name": "areacity", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "areacity", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "mysql2": "^3.23.3" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.23.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.3.tgz", + "integrity": "sha512-ehp9HEKr4wVJaBOUVxNFa+CNrsCCCZ6363/jbGhb7WpEmSRNIXjHBjFs5K2s2cXn7j/RhDieejsQJ6nfUwD6vQ==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/scripts/areacity/package.json b/scripts/areacity/package.json new file mode 100644 index 0000000..dd10c94 --- /dev/null +++ b/scripts/areacity/package.json @@ -0,0 +1,16 @@ +{ + "name": "areacity", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "mysql2": "^3.23.3" + } +} diff --git a/verify-farmers.mjs b/verify-farmers.mjs new file mode 100644 index 0000000..554aaf3 --- /dev/null +++ b/verify-farmers.mjs @@ -0,0 +1,51 @@ +const BASE = 'http://localhost:5246' +let token = '' +const h = (json = false) => ({ + Authorization: `Bearer ${token}`, + ...(json ? { 'Content-Type': 'application/json' } : {}) +}) + +const api = async (path, opts = {}) => { + const res = await fetch(BASE + path, opts) + if (!res.ok) { + const text = await res.text() + throw new Error(`${res.status} ${path} => ${text.slice(0, 200)}`) + } + return res.json() +} + +token = (await api('/api/auth/login', { + method: 'POST', + headers: h(true), + body: JSON.stringify({ username: 'admin', password: '123456' }) +})).token + +const enc = encodeURIComponent + +console.log('== /farmers/all 新字段(含老数据 NULL 兼容) ==') +const all = await api('/api/farmers/all?limit=6', { headers: h() }) +all.forEach((f) => console.log(`id=${f.id} ${f.name} | province=${f.province || ''} county=${f.county || ''} township=${f.township || ''} village=${f.village} groupName=${f.groupName || ''}`)) + +console.log('== 村列表(锦江区/春熙路街道) ==') +console.log((await api(`/api/regions/villages?county=${enc('锦江区')}&township=${enc('春熙路街道')}`, { headers: h() })).join(', ')) + +console.log('== 组列表(锦江区/春熙路街道/水井坊村) ==') +console.log((await api(`/api/regions/groups?county=${enc('锦江区')}&township=${enc('春熙路街道')}&village=${enc('水井坊村')}`, { headers: h() })).join(', ')) + +console.log('== 乡镇列表(锦江区, 公开数据+农户补充) ==') +console.log((await api(`/api/regions/townships?county=${enc('锦江区')}`, { headers: h() })).join(', ')) + +console.log('== 列表按县筛选(平南县) ==') +const list = await api(`/api/farmers?county=${enc('平南县')}&page=1&pageSize=10`, { headers: h() }) +console.log(`total=${list.total}`) +list.items.forEach((f) => console.log(`${f.name} | ${f.province}/${f.county}/${f.township}/${f.village}/${f.groupName} | ${f.phone} | ${f.bankAccount}`)) + +console.log('== 关键字搜索“水井坊” ==') +const search = await api(`/api/farmers?keyword=${enc('水井坊')}&page=1&pageSize=10`, { headers: h() }) +console.log(`total=${search.total}`) +search.items.forEach((f) => console.log(`${f.name} | ${f.county}/${f.township}/${f.village}/${f.groupName}`)) + +console.log('== 全量列表分页(page=1,pageSize=3,验证NULL兼容) ==') +const p = await api('/api/farmers?page=1&pageSize=3', { headers: h() }) +console.log(`total=${p.total}`) +p.items.forEach((f) => console.log(`id=${f.id} ${f.name} | province=${f.province || ''} | phone=${f.phone}`)) diff --git a/verify-farmers.ps1 b/verify-farmers.ps1 new file mode 100644 index 0000000..54b3d11 --- /dev/null +++ b/verify-farmers.ps1 @@ -0,0 +1,41 @@ +$ErrorActionPreference = 'Stop' +$body = '{"username":"admin","password":"123456"}' +$r = Invoke-RestMethod -Uri 'http://localhost:5246/api/auth/login' -Method Post -ContentType 'application/json' -Body $body +$token = $r.token +$h = @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json' } + +Write-Output '== 创建农户1(四川锦江区) ==' +$f1 = @{ + name = '测试农户一'; idCard = '511302199001011234'; gender = '男'; phone = '138 1234 5678' + province = '四川省'; county = '锦江区'; township = '春熙路街道'; village = '水井坊村'; groupName = '3组' + address = '锦江区某路1号'; bankName = '中国农业银行'; bankAccount = '622848 0408 1234 5678' + creditScore = 85; status = 'Active'; notes = '四级地区+银行+手机号测试' +} | ConvertTo-Json +$c1 = Invoke-RestMethod -Uri 'http://localhost:5246/api/farmers' -Method Post -Headers $h -Body $f1 +$c1 | ConvertTo-Json -Compress + +Write-Output '== 创建农户2(广西平南县) ==' +$f2 = @{ + name = '测试农户二'; idCard = '452122199001012345'; gender = '女'; phone = '13911110002' + province = '广西壮族自治区'; county = '平南县'; township = '大安镇'; village = '稻香村'; groupName = '5组' + address = '平南县大安镇稻香村'; bankName = ''; bankAccount = '' + creditScore = 80; status = 'Active'; notes = '' +} | ConvertTo-Json +$c2 = Invoke-RestMethod -Uri 'http://localhost:5246/api/farmers' -Method Post -Headers $h -Body $f2 +$c2 | ConvertTo-Json -Compress + +Write-Output '== 村列表(按锦江区/春熙路街道) ==' +(Invoke-RestMethod -Uri 'http://localhost:5246/api/regions/villages?county=%E9%94%A6%E6%B1%9F%E5%8C%BA&township=%E6%98%A5%E7%86%99%E8%B7%AF%E8%A1%97%E9%81%93' -Headers $h) -join ', ' +Write-Output '== 组列表(按锦江区/春熙路街道/水井坊村) ==' +(Invoke-RestMethod -Uri 'http://localhost:5246/api/regions/groups?county=%E9%94%A6%E6%B1%9F%E5%8C%BA&township=%E6%98%A5%E7%86%99%E8%B7%AF%E8%A1%97%E9%81%93&village=%E6%B0%B4%E4%BA%95%E5%9D%8A%E6%9D%91' -Headers $h) -join ', ' +Write-Output '== 乡镇列表(锦江区,含农户补充) ==' +(Invoke-RestMethod -Uri 'http://localhost:5246/api/regions/townships?county=%E9%94%A6%E6%B1%9F%E5%8C%BA' -Headers $h) -join ', ' + +Write-Output '== 列表按县筛选(平南县) ==' +$list = Invoke-RestMethod -Uri 'http://localhost:5246/api/farmers?county=%E5%B9%B3%E5%8D%97%E5%8E%BF&page=1&pageSize=10' -Headers $h +"total=$($list.total)" +$list.items | ForEach-Object { "$($_.name) | $($_.province)/$($_.county)/$($_.township)/$($_.village)/$($_.groupName) | $($_.phone) | $($_.bankAccount)" } + +Write-Output '== /farmers/all 新字段 ==' +$all = Invoke-RestMethod -Uri 'http://localhost:5246/api/farmers/all?limit=5' -Headers $h +$all | ForEach-Object { "$($_.name) | province=$($_.province) county=$($_.county) township=$($_.township) village=$($_.village) groupName=$($_.groupName)" } diff --git a/项目说明 b/项目说明 new file mode 100644 index 0000000..ede9d2d --- /dev/null +++ b/项目说明 @@ -0,0 +1,2 @@ +这是基于主干信息F8项目网页版的项目说明 +开发语言:后台C#,前端Vue,数据库MySQL