From 8099ebae0a7a973af8c3c9f176c5c98b837656a6 Mon Sep 17 00:00:00 2001 From: fanhongcai Date: Fri, 14 Aug 2026 01:12:33 +0800 Subject: [PATCH] Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules --- .gitignore | 8 + .../AgriculturalPlatform.Api.csproj | 1 + .../Controllers/AboutController.cs | 22 + .../Controllers/AnnouncementsController.cs | 139 +++ .../Controllers/AuthController.cs | 4 +- .../Controllers/DictsController.cs | 148 +++ .../Controllers/FarmersController.cs | 14 +- .../Controllers/FilesController.cs | 35 + .../Controllers/MenusController.cs | 112 +++ .../Controllers/PurchasesController.cs | 164 ++++ .../Controllers/RolesController.cs | 106 +++ .../Controllers/UploadsController.cs | 24 +- .../Controllers/UsersController.cs | 8 +- .../Data/AppDbContext.cs | 80 ++ .../AgriculturalPlatform.Api/Data/DbSeeder.cs | 160 +++- .../Data/SchemaMigrator.cs | 363 +++++++- .../AgriculturalPlatform.Api/Dtos/AuthDtos.cs | 2 +- .../Dtos/FarmerDtos.cs | 6 +- .../Dtos/PurchaseDtos.cs | 36 +- .../Dtos/SystemDtos.cs | 34 + .../AgriculturalPlatform.Api/Dtos/UserDtos.cs | 6 +- .../Models/Announcement.cs | 42 + .../AgriculturalPlatform.Api/Models/Farmer.cs | 3 + .../Models/PurchaseOrder.cs | 21 +- .../Models/PurchaseTransfer.cs | 33 + .../Models/PurchaseWeigh.cs | 40 + .../Models/SysDict.cs | 47 + .../Models/SysMenu.cs | 36 + .../Models/SysRole.cs | 27 + .../AgriculturalPlatform.Api/Models/User.cs | 6 + backend/AgriculturalPlatform.Api/Program.cs | 12 +- .../Services/AliyunOcrService.cs | 192 ++++ .../Services/FileStorage.cs | 127 ++- .../Services/JwtService.cs | 1 + .../Services/NumberGenerator.cs | 1 + .../Services/OcrService.cs | 9 +- .../AgriculturalPlatform.Api/appsettings.json | 16 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/api/index.ts | 62 +- frontend/src/components/HeaderCalendar.vue | 301 ++++++ frontend/src/components/HeaderMessage.vue | 198 ++++ frontend/src/components/HeaderWeather.vue | 136 +++ frontend/src/components/ReceiptPrint.vue | 152 +++ frontend/src/composables/useWeather.ts | 149 +++ frontend/src/directives/permission.ts | 16 + frontend/src/layouts/MainLayout.vue | 145 ++- frontend/src/main.ts | 3 + frontend/src/router/index.ts | 32 +- frontend/src/stores/auth.ts | 22 +- frontend/src/styles/theme.css | 85 ++ frontend/src/types/index.ts | 148 ++- frontend/src/types/lunar-javascript.d.ts | 26 + frontend/src/views/Dashboard.vue | 200 +++- frontend/src/views/Home.vue | 453 +++++++-- frontend/src/views/Login.vue | 20 +- frontend/src/views/about/About.vue | 47 + frontend/src/views/farmers/FarmerList.vue | 107 ++- frontend/src/views/invoices/InvoiceList.vue | 16 +- .../src/views/notify/AnnouncementList.vue | 167 ++++ frontend/src/views/orgs/OrgList.vue | 4 +- frontend/src/views/payments/PaymentList.vue | 8 +- frontend/src/views/products/ProductList.vue | 4 +- frontend/src/views/reports/ReportInvoice.vue | 6 +- frontend/src/views/reports/ReportPayment.vue | 4 +- frontend/src/views/reports/ReportPurchase.vue | 10 +- frontend/src/views/system/DictList.vue | 237 +++++ frontend/src/views/system/MenuList.vue | 135 +++ frontend/src/views/system/RoleList.vue | 140 +++ frontend/src/views/users/UserList.vue | 4 +- frontend/src/views/weighing/WeighingList.vue | 868 +++++++++++++++--- frontend/src/views/weighing/WeighingTouch.vue | 50 +- scripts/list-images.ps1 | 5 + scripts/start-backend.ps1 | 10 + scripts/test-ocr.ps1 | 21 + 75 files changed, 5611 insertions(+), 473 deletions(-) create mode 100644 backend/AgriculturalPlatform.Api/Controllers/AboutController.cs create mode 100644 backend/AgriculturalPlatform.Api/Controllers/AnnouncementsController.cs create mode 100644 backend/AgriculturalPlatform.Api/Controllers/DictsController.cs create mode 100644 backend/AgriculturalPlatform.Api/Controllers/FilesController.cs create mode 100644 backend/AgriculturalPlatform.Api/Controllers/MenusController.cs create mode 100644 backend/AgriculturalPlatform.Api/Controllers/RolesController.cs create mode 100644 backend/AgriculturalPlatform.Api/Dtos/SystemDtos.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/Announcement.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/PurchaseTransfer.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/PurchaseWeigh.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/SysDict.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/SysMenu.cs create mode 100644 backend/AgriculturalPlatform.Api/Models/SysRole.cs create mode 100644 backend/AgriculturalPlatform.Api/Services/AliyunOcrService.cs create mode 100644 frontend/src/components/HeaderCalendar.vue create mode 100644 frontend/src/components/HeaderMessage.vue create mode 100644 frontend/src/components/HeaderWeather.vue create mode 100644 frontend/src/components/ReceiptPrint.vue create mode 100644 frontend/src/composables/useWeather.ts create mode 100644 frontend/src/directives/permission.ts create mode 100644 frontend/src/styles/theme.css create mode 100644 frontend/src/types/lunar-javascript.d.ts create mode 100644 frontend/src/views/about/About.vue create mode 100644 frontend/src/views/notify/AnnouncementList.vue create mode 100644 frontend/src/views/system/DictList.vue create mode 100644 frontend/src/views/system/MenuList.vue create mode 100644 frontend/src/views/system/RoleList.vue create mode 100644 scripts/list-images.ps1 create mode 100644 scripts/start-backend.ps1 create mode 100644 scripts/test-ocr.ps1 diff --git a/.gitignore b/.gitignore index e7cf689..71e30a4 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ Thumbs.db # 编译检查临时目录 build-check/ + +# 浏览器测试快照(playwright-cli) +.playwright-cli/ + +# AI 生成图片与测试图片 +generated-images/ +test.png +test-avatar.png diff --git a/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj index 2913b3b..120f22c 100644 --- a/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj +++ b/backend/AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/backend/AgriculturalPlatform.Api/Controllers/AboutController.cs b/backend/AgriculturalPlatform.Api/Controllers/AboutController.cs new file mode 100644 index 0000000..6e8d14d --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/AboutController.cs @@ -0,0 +1,22 @@ +using AgriculturalPlatform.Api.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AgriculturalPlatform.Api.Controllers; + +/// 关于信息 +[ApiController] +[Route("api/about")] +public class AboutController : ControllerBase +{ + [HttpGet] + [AllowAnonymous] + public ActionResult Get() => Ok(new AboutDto( + AppName: "农易富农产品收购交易平台", + Version: "2.0.0", + Copyright: "Copyright © 2026 bbitcn.com 版权所有", + Company: "百博信息技术有限公司", + License: "企业版授权 · 单组织部署许可", + Description: "面向农产品收购企业的数字化管理平台,覆盖农户档案、过磅称重、收购结算、票据打印、通知公告等功能。", + BuiltAt: new DateTime(2026, 8, 13))); +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/AnnouncementsController.cs b/backend/AgriculturalPlatform.Api/Controllers/AnnouncementsController.cs new file mode 100644 index 0000000..eff007f --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/AnnouncementsController.cs @@ -0,0 +1,139 @@ +using System.Security.Claims; +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/announcements")] +[Authorize] +public class AnnouncementsController(AppDbContext db) : ControllerBase +{ + private int UserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "0"); + + /// 我的公告列表(含已读状态、未读数) + [HttpGet("my")] + public async Task My([FromQuery] int page = 1, [FromQuery] int pageSize = 10) + { + var uid = UserId; + var query = db.Announcements.AsNoTracking(); + // 范围过滤:空范围=全员可见;含本人组织 + var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync(); + var queryFiltered = query.Where(a => a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString()))); + + var total = await queryFiltered.CountAsync(); + var items = await queryFiltered.OrderByDescending(a => a.IsPinned).ThenByDescending(a => a.PublishedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(a => new + { + a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned, + PublisherName = a.Publisher != null ? a.Publisher.RealName : "", + a.PublishedAt, a.CreatedAt, + IsRead = db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid) + }) + .ToListAsync(); + + var unread = await queryFiltered + .CountAsync(a => !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)); + return Ok(new { items, total, unread }); + } + + /// 未读公告数(顶栏徽标) + [HttpGet("unread-count")] + public async Task UnreadCount() + { + var uid = UserId; + var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync(); + var count = await db.Announcements.CountAsync(a => + (a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString()))) + && !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)); + return Ok(new { count }); + } + + /// 公告详情(标记已读) + [HttpGet("{id:int}")] + public async Task Get(int id) + { + var uid = UserId; + var a = await db.Announcements.Include(x => x.Publisher).FirstOrDefaultAsync(x => x.Id == id); + if (a is null) return NotFound(); + + // 标记已读 + if (!await db.AnnouncementReads.AnyAsync(r => r.AnnouncementId == id && r.UserId == uid)) + { + db.AnnouncementReads.Add(new AnnouncementRead { AnnouncementId = id, UserId = uid, ReadAt = DateTime.Now }); + await db.SaveChangesAsync(); + } + var dto = new AnnouncementDto(a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned, + a.Publisher?.RealName ?? "", a.PublishedAt, a.CreatedAt, true); + return Ok(dto); + } + + /// 全部公告(管理端) + [HttpGet] + public async Task>> List(string? keyword, string? type, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var uid = UserId; + var query = db.Announcements.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(a => a.Title.Contains(keyword) || a.Content.Contains(keyword)); + if (!string.IsNullOrWhiteSpace(type)) + query = query.Where(a => a.Type == type); + + var total = await query.CountAsync(); + var items = await query.OrderByDescending(a => a.PublishedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(a => new AnnouncementDto( + a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned, + a.Publisher != null ? a.Publisher.RealName : "", + a.PublishedAt, a.CreatedAt, + db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid))) + .ToListAsync(); + return Ok(new PagedResult(items, total)); + } + + /// 发布公告 + [HttpPost] + public async Task Create(AnnouncementSaveRequest req) + { + if (string.IsNullOrWhiteSpace(req.Title)) + return BadRequest(new { message = "标题不能为空" }); + var a = new Announcement + { + Title = req.Title, Content = req.Content ?? "", Type = req.Type, + ScopeOrgIds = req.ScopeOrgIds ?? "", IsPinned = req.IsPinned, + PublisherId = UserId, PublishedAt = DateTime.Now, CreatedAt = DateTime.Now + }; + db.Announcements.Add(a); + await db.SaveChangesAsync(); + return Ok(new { message = "发布成功", id = a.Id }); + } + + /// 修改公告 + [HttpPut("{id:int}")] + public async Task Update(int id, AnnouncementSaveRequest req) + { + var a = await db.Announcements.FindAsync(id); + if (a is null) return NotFound(); + a.Title = req.Title; a.Content = req.Content ?? ""; a.Type = req.Type; + a.ScopeOrgIds = req.ScopeOrgIds ?? ""; a.IsPinned = req.IsPinned; + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功" }); + } + + /// 删除公告 + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var a = await db.Announcements.FindAsync(id); + if (a is null) return NotFound(); + db.Announcements.Remove(a); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs b/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs index f6cf423..24d5f3c 100644 --- a/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs +++ b/backend/AgriculturalPlatform.Api/Controllers/AuthController.cs @@ -37,7 +37,7 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService public async Task> Me() { var cu = currentUser.Get()!; - var user = await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == cu.Id); + var user = await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == cu.Id); return Ok(BuildInfo(user)); } @@ -58,5 +58,5 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService 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); + user.RoleId, user.SysRole?.Name, user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive); } diff --git a/backend/AgriculturalPlatform.Api/Controllers/DictsController.cs b/backend/AgriculturalPlatform.Api/Controllers/DictsController.cs new file mode 100644 index 0000000..d8c4a0e --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/DictsController.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/dicts")] +[Authorize] +public class DictsController(AppDbContext db) : ControllerBase +{ + /// 字典类型列表 + [HttpGet] + public async Task>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var query = db.SysDicts.AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(d => d.Name.Contains(keyword) || d.Code.Contains(keyword)); + + var total = await query.CountAsync(); + var items = await query.OrderBy(d => d.Sort).ThenByDescending(d => d.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(d => new DictDto(d.Id, d.Name, d.Code, d.Remark, d.IsSystem, d.Sort, + d.Items.Count)) + .ToListAsync(); + return Ok(new PagedResult(items, total)); + } + + /// 字典全部(含项,供下拉缓存使用) + [HttpGet("all")] + public async Task All() + { + var items = await db.SysDicts.AsNoTracking() + .Include(d => d.Items.Where(i => i.Enabled)) + .OrderBy(d => d.Sort) + .Select(d => new { d.Code, Items = d.Items.OrderBy(i => i.Sort) + .Select(i => new { i.Label, i.Value, i.Ext, i.IsDefault }) }) + .ToListAsync(); + return Ok(items); + } + + /// 字典详情(含字典项) + [HttpGet("{id:int}")] + public async Task> Get(int id) + { + var dict = await db.SysDicts.Include(d => d.Items).FirstOrDefaultAsync(d => d.Id == id); + if (dict is null) return NotFound(); + + var dto = new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, dict.Items.Count); + var items = dict.Items.OrderBy(i => i.Sort) + .Select(i => new DictItemDto(i.Id, i.DictId, i.Label, i.Value, i.Ext, i.IsDefault, i.Enabled, i.Sort)) + .ToList(); + return Ok(new DictDetailDto(dto, items)); + } + + /// 新增字典类型 + [HttpPost] + public async Task> Create(DictSaveRequest req) + { + if (string.IsNullOrWhiteSpace(req.Code)) + return BadRequest(new { message = "字典编码不能为空" }); + if (await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim())) + return BadRequest(new { message = "字典编码已存在" }); + + var dict = new SysDict + { + Name = req.Name, Code = req.Code.Trim(), Remark = req.Remark, Sort = req.Sort, CreatedAt = DateTime.Now + }; + db.SysDicts.Add(dict); + await db.SaveChangesAsync(); + return Ok(new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, 0)); + } + + /// 修改字典类型 + [HttpPut("{id:int}")] + public async Task Update(int id, DictSaveRequest req) + { + var dict = await db.SysDicts.FindAsync(id); + if (dict is null) return NotFound(); + if (dict.Code != req.Code.Trim() && await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim())) + return BadRequest(new { message = "字典编码已存在" }); + + dict.Name = req.Name; dict.Code = req.Code.Trim(); dict.Remark = req.Remark; dict.Sort = req.Sort; + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功" }); + } + + /// 删除字典类型(系统内置不可删除) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var dict = await db.SysDicts.FindAsync(id); + if (dict is null) return NotFound(); + if (dict.IsSystem) return BadRequest(new { message = "系统内置字典不可删除" }); + db.SysDicts.Remove(dict); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } + + // ---------- 字典项 ---------- + + /// 新增字典项 + [HttpPost("{dictId:int}/items")] + public async Task> CreateItem(int dictId, DictItemSaveRequest req) + { + var dict = await db.SysDicts.FindAsync(dictId); + if (dict is null) return NotFound(); + if (await db.SysDictItems.AnyAsync(i => i.DictId == dictId && i.Value == req.Value)) + return BadRequest(new { message = "字典项值已存在" }); + + var item = new SysDictItem + { + DictId = dictId, Label = req.Label, Value = req.Value, Ext = req.Ext, + IsDefault = req.IsDefault, Enabled = req.Enabled, Sort = req.Sort + }; + db.SysDictItems.Add(item); + await db.SaveChangesAsync(); + return Ok(new DictItemDto(item.Id, item.DictId, item.Label, item.Value, item.Ext, item.IsDefault, item.Enabled, item.Sort)); + } + + /// 修改字典项 + [HttpPut("items/{id:int}")] + public async Task UpdateItem(int id, DictItemSaveRequest req) + { + var item = await db.SysDictItems.FindAsync(id); + if (item is null) return NotFound(); + + item.Label = req.Label; item.Value = req.Value; item.Ext = req.Ext; + item.IsDefault = req.IsDefault; item.Enabled = req.Enabled; item.Sort = req.Sort; + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功" }); + } + + /// 删除字典项 + [HttpDelete("items/{id:int}")] + public async Task DeleteItem(int id) + { + var item = await db.SysDictItems.FindAsync(id); + if (item is null) return NotFound(); + db.SysDictItems.Remove(item); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs b/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs index 4ef9f6e..fc89588 100644 --- a/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs +++ b/backend/AgriculturalPlatform.Api/Controllers/FarmersController.cs @@ -31,6 +31,7 @@ public class FarmersController(AppDbContext db) : ControllerBase query = query.Where(f => f.Village == village); if (!string.IsNullOrWhiteSpace(keyword)) query = query.Where(f => f.Name.Contains(keyword) + || f.PinyinInitials.Contains(keyword.ToUpperInvariant()) || f.Phone.Contains(keyword) || f.IdCard.Contains(keyword) || f.Village.Contains(keyword) @@ -52,9 +53,12 @@ public class FarmersController(AppDbContext db) : ControllerBase { 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)); + query = query.Where(f => f.Name.Contains(keyword) + || f.PinyinInitials.Contains(keyword.ToUpperInvariant()) + || 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(); + .Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard, f.PinyinInitials }).ToListAsync(); return Ok(items); } @@ -88,7 +92,8 @@ public class FarmersController(AppDbContext db) : ControllerBase var farmer = new Farmer { - Name = req.Name, IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType, + Name = req.Name, PinyinInitials = req.PinyinInitials ?? string.Empty, + 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, @@ -112,7 +117,8 @@ public class FarmersController(AppDbContext db) : ControllerBase 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.Name = req.Name; farmer.PinyinInitials = req.PinyinInitials ?? string.Empty; + farmer.IdCard = req.IdCard; farmer.Gender = req.Gender; farmer.FarmerType = req.FarmerType; farmer.Phone = req.Phone; farmer.Province = req.Province; farmer.County = req.County; diff --git a/backend/AgriculturalPlatform.Api/Controllers/FilesController.cs b/backend/AgriculturalPlatform.Api/Controllers/FilesController.cs new file mode 100644 index 0000000..28b1633 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/FilesController.cs @@ -0,0 +1,35 @@ +using AgriculturalPlatform.Api.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AgriculturalPlatform.Api.Controllers; + +/// +/// 附件读取代理:OSS 模式(私有桶)下前端通过 /api/files/{key} 访问图片, +/// 后端实时从 OSS 拉取并回传,避免将 Bucket 设为公共读而泄露身份证等敏感信息。 +/// 本地存储模式不经过此接口(前端直接走静态文件 /uploads)。 +/// +[ApiController] +[Route("api/files")] +[AllowAnonymous] +public class FilesController(IFileStorage storage) : ControllerBase +{ + /// + /// 读取附件。key 形如 uploads/avatar/202608/xxx.png(必须限定 uploads/ 前缀,防止越权读取)。 + /// 通过 FileStreamResult 边读边传,减少大图内存占用。 + /// + [HttpGet("{**key}")] + [ResponseCache(Duration = 300)] // 5 分钟缓存,减少重复拉取 + public async Task Get(string key) + { + if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("uploads/")) + return BadRequest(new { message = "无效的文件路径" }); + + var file = await storage.OpenReadAsync(key); + if (file is null) + return NotFound(new { message = "文件不存在或已被删除" }); + + var (stream, contentType) = file.Value; + return File(stream, contentType, enableRangeProcessing: true); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/MenusController.cs b/backend/AgriculturalPlatform.Api/Controllers/MenusController.cs new file mode 100644 index 0000000..cfdd58e --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/MenusController.cs @@ -0,0 +1,112 @@ +using System.Security.Claims; +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/menus")] +[Authorize] +public class MenusController(AppDbContext db) : ControllerBase +{ + /// 菜单树(全部) + [HttpGet] + public async Task Tree() + { + var menus = await db.SysMenus.AsNoTracking().OrderBy(m => m.Sort).ThenBy(m => m.Id).ToListAsync(); + return Ok(BuildTree(menus, null)); + } + + /// 当前登录用户的菜单与权限 + [HttpGet("my")] + public async Task My() + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId is null) return Unauthorized(); + + // 用户角色 -> 菜单 + var user = await db.Users.Include(u => u.SysRole).FirstOrDefaultAsync(u => u.Id == int.Parse(userId)); + if (user is null) return Unauthorized(); + + int[] menuIds; + if (user.SysRole?.Code == "super_admin" || user.Role == UserRole.SuperAdmin) + { + menuIds = await db.SysMenus.Select(m => m.Id).ToArrayAsync(); + } + else if (user.RoleId is int rid) + { + menuIds = await db.SysRoleMenus.Where(rm => rm.RoleId == rid).Select(rm => rm.MenuId).ToArrayAsync(); + } + else + { + menuIds = Array.Empty(); + } + + var menus = await db.SysMenus.AsNoTracking() + .Where(m => menuIds.Contains(m.Id)) + .OrderBy(m => m.Sort).ThenBy(m => m.Id) + .ToListAsync(); + var tree = BuildTree(menus, null); + var permissions = menus.Where(m => !string.IsNullOrWhiteSpace(m.Permission)).Select(m => m.Permission).Distinct().ToArray(); + return Ok(new { menus = tree, permissions }); + } + + private static List BuildTree(List all, int? parentId) + { + return all.Where(m => m.ParentId == parentId) + .Select(m => new MenuDto( + m.Id, m.ParentId, m.Name, m.Path, m.Component, m.Icon, m.Type, + m.Permission, m.Visible, m.Sort, BuildTree(all, m.Id))) + .ToList(); + } + + /// 新增菜单 + [HttpPost] + public async Task Create(MenuSaveRequest req) + { + var menu = new SysMenu + { + ParentId = req.ParentId, Name = req.Name, Path = req.Path, Component = req.Component, + Icon = req.Icon, Type = req.Type, Permission = req.Permission, Visible = req.Visible, + Sort = req.Sort, CreatedAt = DateTime.Now + }; + db.SysMenus.Add(menu); + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功", id = menu.Id }); + } + + /// 修改菜单 + [HttpPut("{id:int}")] + public async Task Update(int id, MenuSaveRequest req) + { + var menu = await db.SysMenus.FindAsync(id); + if (menu is null) return NotFound(); + if (req.ParentId == id) return BadRequest(new { message = "父级不能选择自身" }); + + menu.ParentId = req.ParentId; menu.Name = req.Name; menu.Path = req.Path; + menu.Component = req.Component; menu.Icon = req.Icon; menu.Type = req.Type; + menu.Permission = req.Permission; menu.Visible = req.Visible; menu.Sort = req.Sort; + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功" }); + } + + /// 删除菜单(有子菜单或已被角色引用时禁止删除) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + if (await db.SysMenus.AnyAsync(m => m.ParentId == id)) + return BadRequest(new { message = "请先删除子菜单" }); + if (await db.SysRoleMenus.AnyAsync(rm => rm.MenuId == id)) + return BadRequest(new { message = "该菜单已被角色引用,请先解除关联" }); + var menu = await db.SysMenus.FindAsync(id); + if (menu is null) return NotFound(); + db.SysMenus.Remove(menu); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs b/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs index ac212cc..ab2bec1 100644 --- a/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs +++ b/backend/AgriculturalPlatform.Api/Controllers/PurchasesController.cs @@ -27,6 +27,7 @@ public class PurchasesController( var query = db.PurchaseOrders .Include(p => p.Farmer).Include(p => p.Product) .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .Include(p => p.Weighs) .AsQueryable(); if (visible is not null) @@ -73,11 +74,42 @@ public class PurchasesController( var order = await db.PurchaseOrders .Include(p => p.Farmer).Include(p => p.Product) .Include(p => p.PurchaserOrg).Include(p => p.Operator) + .Include(p => p.Weighs) .FirstOrDefaultAsync(p => p.Id == id); if (order is null) return NotFound(); return Ok(order.ToDto()); } + /// 收购单统计(单数、重量合计、均价、金额合计) + [HttpGet("summary")] + public async Task> Summary( + int? farmerId, int? productId, int? orgId, DateTime? from, DateTime? to) + { + 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)); + 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)); + + var orderCount = await query.CountAsync(); + var totalNet = await query.SumAsync(p => (decimal?)p.NetWeight) ?? 0; + var totalAmount = await query.SumAsync(p => (decimal?)p.Amount) ?? 0; + var farmerCount = await query.Select(p => p.FarmerId).Distinct().CountAsync(); + var todayStart = DateTime.Today; + var todayOrderCount = await db.PurchaseOrders + .CountAsync(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= todayStart); + var avgPrice = totalNet > 0 ? Math.Round(totalAmount / totalNet, 2) : 0; + + return Ok(new PurchaseSummaryDto(orderCount, Math.Round(totalNet, 2), avgPrice, + Math.Round(totalAmount, 2), farmerCount, todayOrderCount)); + } + /// 新建收购单(第一次过磅:记录毛重) [HttpPost] public async Task> Create(PurchaseCreateRequest req) @@ -162,6 +194,138 @@ public class PurchasesController( .FirstAsync(p => p.Id == order.Id)).ToDto()); } + /// 多磅收购单:一次性保存(一单多磅、多等级),完成后直接打印 + [HttpPost("multi")] + public async Task> CreateMulti(PurchaseMultiCreateRequest req) + { + if (req.Weighs is null || req.Weighs.Count == 0) + return BadRequest(new { message = "请至少录入一磅数据" }); + 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, + Unit = string.IsNullOrWhiteSpace(req.Unit) ? "公斤" : req.Unit, + WeighCount = req.Weighs.Count, + Status = PurchaseStatus.Completed, + OperatorId = cu.Id, + Notes = req.Notes ?? "", + WeighInAt = DateTime.Now, + WeighOutAt = DateTime.Now, + CreatedAt = DateTime.Now, + UpdatedAt = DateTime.Now + }; + + var sortNo = 1; + foreach (var w in req.Weighs) + { + var net = w.NetWeight ?? Math.Round(w.GrossWeight - w.TareWeight, 2); + var amount = Math.Round(net * w.UnitPrice, 2); + order.Weighs.Add(new PurchaseWeigh + { + SortNo = sortNo++, + Grade = w.Grade, + PriceType = w.PriceType, + UnitPrice = w.UnitPrice, + GrossWeight = w.GrossWeight, + TareWeight = w.TareWeight, + NetWeight = net, + BoxCount = w.BoxCount, + Amount = amount, + CreatedAt = DateTime.Now + }); + order.GrossWeight += w.GrossWeight; + order.TareWeight += w.TareWeight; + order.NetWeight += net; + order.Amount += amount; + order.BoxCount += w.BoxCount; + } + order.NetWeight = Math.Round(order.NetWeight, 2); + order.Amount = Math.Round(order.Amount, 2); + + // 主字段取第一磅 + var first = order.Weighs.First(); + order.Grade = first.Grade; + order.UnitPrice = first.UnitPrice; + + 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) + .Include(p => p.Weighs) + .FirstAsync(p => p.Id == order.Id)).ToDto()); + } + + /// 补打票据(记录补打日志到 Notes) + [HttpPost("{id:int}/reprint")] + public async Task Reprint(int id) + { + var order = await db.PurchaseOrders.FindAsync(id); + if (order is null) return NotFound(); + var cu = currentUser.Get()!; + var log = $"补打票据 {DateTime.Now:yyyy-MM-dd HH:mm} by {cu.RealName}"; + order.Notes = string.IsNullOrWhiteSpace(order.Notes) ? log : order.Notes + ";" + log; + order.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + return Ok(new { message = "已记录补打" }); + } + + /// 票据过户(生成过户单号并记录) + [HttpPost("{id:int}/transfer")] + public async Task Transfer(int id, PurchaseTransferRequest req) + { + var order = await db.PurchaseOrders.Include(p => p.Farmer).FirstOrDefaultAsync(p => p.Id == id); + if (order is null) return NotFound(); + if (string.IsNullOrWhiteSpace(req.ToFarmer)) + return BadRequest(new { message = "请填写过户到农户" }); + if (order.Status != PurchaseStatus.Completed) + return BadRequest(new { message = "仅已完成单据可过户" }); + + var cu = currentUser.Get()!; + var transfer = new PurchaseTransfer + { + PurchaseOrderId = order.Id, + TransferNo = await numberGen.NextAsync("GH"), + FromFarmer = order.Farmer?.Name ?? "", + ToFarmer = req.ToFarmer, + ToIdCard = req.ToIdCard ?? "", + Reason = req.Reason ?? "", + OperatorId = cu.Id, + CreatedAt = DateTime.Now + }; + db.PurchaseTransfers.Add(transfer); + order.UpdatedAt = DateTime.Now; + await db.SaveChangesAsync(); + return Ok(new { message = "过户成功", transferNo = transfer.TransferNo }); + } + + /// 收购单过户记录 + [HttpGet("{id:int}/transfers")] + public async Task Transfers(int id) + { + var items = await db.PurchaseTransfers.AsNoTracking() + .Include(t => t.Operator) + .Where(t => t.PurchaseOrderId == id) + .OrderByDescending(t => t.CreatedAt) + .Select(t => new PurchaseTransferDto( + t.Id, t.TransferNo, t.FromFarmer, t.ToFarmer, t.ToIdCard, t.Reason, + t.Operator != null ? t.Operator.RealName : "", t.CreatedAt)) + .ToListAsync(); + return Ok(items); + } + /// 作废收购单 [HttpPut("{id:int}/cancel")] public async Task Cancel(int id) diff --git a/backend/AgriculturalPlatform.Api/Controllers/RolesController.cs b/backend/AgriculturalPlatform.Api/Controllers/RolesController.cs new file mode 100644 index 0000000..c716064 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Controllers/RolesController.cs @@ -0,0 +1,106 @@ +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; + +/// 角色管理(RBAC) +[ApiController] +[Route("api/roles")] +[Authorize] +public class RolesController(AppDbContext db) : ControllerBase +{ + /// 角色列表 + [HttpGet] + public async Task>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20) + { + var query = db.SysRoles.AsQueryable(); + if (!string.IsNullOrWhiteSpace(keyword)) + query = query.Where(r => r.Name.Contains(keyword) || r.Code.Contains(keyword)); + + var total = await query.CountAsync(); + var items = await query.OrderBy(r => r.Id) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(r => new RoleDto( + r.Id, r.Name, r.Code, r.Description, r.IsSystem, + db.SysRoleMenus.Where(rm => rm.RoleId == r.Id).Select(rm => rm.MenuId).ToArray())) + .ToListAsync(); + return Ok(new PagedResult(items, total)); + } + + /// 全部角色(下拉选择用) + [HttpGet("all")] + public async Task All() + { + var items = await db.SysRoles.AsNoTracking().OrderBy(r => r.Id) + .Select(r => new { r.Id, r.Name, r.Code }).ToListAsync(); + return Ok(items); + } + + /// 新增角色 + [HttpPost] + public async Task Create(RoleSaveRequest req) + { + if (string.IsNullOrWhiteSpace(req.Code)) + return BadRequest(new { message = "角色编码不能为空" }); + if (await db.SysRoles.AnyAsync(r => r.Code == req.Code.Trim())) + return BadRequest(new { message = "角色编码已存在" }); + + var role = new SysRole + { + Name = req.Name, Code = req.Code.Trim(), Description = req.Description, CreatedAt = DateTime.Now + }; + db.SysRoles.Add(role); + await db.SaveChangesAsync(); + + if (req.MenuIds.Length > 0) + { + db.SysRoleMenus.AddRange(req.MenuIds.Distinct().Select(mid => new SysRoleMenu { RoleId = role.Id, MenuId = mid })); + await db.SaveChangesAsync(); + } + return Ok(new { message = "保存成功", id = role.Id }); + } + + /// 修改角色 + [HttpPut("{id:int}")] + public async Task Update(int id, RoleSaveRequest req) + { + var role = await db.SysRoles.FindAsync(id); + if (role is null) return NotFound(); + + role.Name = req.Name; role.Description = req.Description; + // 系统内置角色编码不可改 + if (!role.IsSystem) role.Code = req.Code.Trim(); + + var old = await db.SysRoleMenus.Where(rm => rm.RoleId == id).Select(rm => rm.MenuId).ToListAsync(); + var newMenuIds = req.MenuIds.Distinct().ToArray(); + var toRemove = old.Where(m => !newMenuIds.Contains(m)).ToList(); + var toAdd = newMenuIds.Where(m => !old.Contains(m)).ToList(); + if (toRemove.Count > 0) + db.SysRoleMenus.RemoveRange(toRemove.Select(m => new SysRoleMenu { RoleId = id, MenuId = m })); + if (toAdd.Count > 0) + db.SysRoleMenus.AddRange(toAdd.Select(m => new SysRoleMenu { RoleId = id, MenuId = m })); + + await db.SaveChangesAsync(); + return Ok(new { message = "保存成功" }); + } + + /// 删除角色(系统内置或有用户引用时禁止删除) + [HttpDelete("{id:int}")] + public async Task Delete(int id) + { + var role = await db.SysRoles.FindAsync(id); + if (role is null) return NotFound(); + if (role.IsSystem) return BadRequest(new { message = "系统内置角色不可删除" }); + if (await db.Users.AnyAsync(u => u.RoleId == id)) + return BadRequest(new { message = "该角色已被用户使用,请先解除关联" }); + + db.SysRoleMenus.RemoveRange(db.SysRoleMenus.Where(rm => rm.RoleId == id)); + db.SysRoles.Remove(role); + await db.SaveChangesAsync(); + return Ok(new { message = "删除成功" }); + } +} diff --git a/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs b/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs index 8f48760..1cf5d0f 100644 --- a/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs +++ b/backend/AgriculturalPlatform.Api/Controllers/UploadsController.cs @@ -45,7 +45,10 @@ public class UploadsController( return BadRequest(new { message = ex.Message }); } - var result = await ocr.RecognizeAsync(ms, side, file.FileName); + // 反面仅存档至 OSS,不调用 OCR(OCR 只识别正面) + IdCardOcrResult? result = null; + if (side == "front") + result = await ocr.RecognizeAsync(ms, side, file.FileName, saved.Key); return Ok(new { url = saved.Url, key = saved.Key, side, ocr = result }); } @@ -120,7 +123,7 @@ public class UploadsController( try { session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType); - session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName); + session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName, session.Front.Key); } catch (InvalidOperationException ex) { @@ -135,8 +138,9 @@ public class UploadsController( ms.Position = 0; try { + // 反面仅存档,不调用 OCR session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType); - session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName); + session.BackOcr = null; } catch (InvalidOperationException ex) { @@ -159,7 +163,7 @@ public class UploadsController( if (!session.Done) return Ok(new { done = false }); - var ocr = MergeOcr(session.FrontOcr, session.BackOcr); + // 反面不再识别,仅返回正面 OCR 结果(可能为 null) return Ok(new { done = true, @@ -167,20 +171,10 @@ public class UploadsController( frontKey = session.Front?.Key, backUrl = session.Back?.Url, backKey = session.Back?.Key, - ocr + ocr = session.FrontOcr }); } - /// 合并正反面 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 等虚拟网卡), diff --git a/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs b/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs index 7bf6bfc..c878108 100644 --- a/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs +++ b/backend/AgriculturalPlatform.Api/Controllers/UsersController.cs @@ -20,7 +20,7 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse [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(); + var query = db.Users.Include(u => u.Org).Include(u => u.SysRole).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)) @@ -54,12 +54,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse RealName = req.RealName, Phone = req.Phone, Role = role, + RoleId = req.RoleId, 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()); + return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto()); } /// 修改用户 @@ -75,12 +76,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse user.RealName = req.RealName; user.Phone = req.Phone; if (Enum.TryParse(req.Role, out var role)) user.Role = role; + user.RoleId = req.RoleId; 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()); + return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto()); } /// 重置密码 diff --git a/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs b/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs index b385afe..14ee7f0 100644 --- a/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs +++ b/backend/AgriculturalPlatform.Api/Data/AppDbContext.cs @@ -12,9 +12,22 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet Regions => Set(); public DbSet Products => Set(); public DbSet PurchaseOrders => Set(); + public DbSet PurchaseWeighs => Set(); + public DbSet PurchaseTransfers => Set(); public DbSet PaymentRecords => Set(); public DbSet Invoices => Set(); + // 系统管理(RBAC) + public DbSet SysDicts => Set(); + public DbSet SysDictItems => Set(); + public DbSet SysMenus => Set(); + public DbSet SysRoles => Set(); + public DbSet SysRoleMenus => Set(); + + // 通知公告 + public DbSet Announcements => Set(); + public DbSet AnnouncementReads => Set(); + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -41,6 +54,9 @@ public class AppDbContext(DbContextOptions options) : DbContext(op modelBuilder.Entity() .HasOne(u => u.Org).WithMany().HasForeignKey(u => u.OrgId) .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .HasOne(u => u.SysRole).WithMany().HasForeignKey(u => u.RoleId) + .OnDelete(DeleteBehavior.SetNull); // 组织 modelBuilder.Entity() @@ -119,6 +135,70 @@ public class AppDbContext(DbContextOptions options) : DbContext(op .HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId) .OnDelete(DeleteBehavior.SetNull); + // 收购单-磅次 + modelBuilder.Entity() + .HasOne(w => w.PurchaseOrder).WithMany(p => p.Weighs) + .HasForeignKey(w => w.PurchaseOrderId) + .OnDelete(DeleteBehavior.Cascade); + + // 票据过户 + modelBuilder.Entity() + .HasOne(t => t.PurchaseOrder).WithMany(p => p.Transfers) + .HasForeignKey(t => t.PurchaseOrderId) + .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasOne(t => t.Operator).WithMany().HasForeignKey(t => t.OperatorId) + .OnDelete(DeleteBehavior.SetNull); + + // 数据字典 + modelBuilder.Entity() + .Property(d => d.Code).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(d => d.Code).IsUnique(); + modelBuilder.Entity() + .Property(d => d.Name).HasMaxLength(100); + modelBuilder.Entity() + .Property(i => i.Label).HasMaxLength(100); + modelBuilder.Entity() + .Property(i => i.Value).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(i => new { i.DictId, i.Value }).IsUnique(); + modelBuilder.Entity() + .HasMany(d => d.Items).WithOne().HasForeignKey(i => i.DictId) + .OnDelete(DeleteBehavior.Cascade); + + // 菜单(自引用父子关系显式指定外键,避免 EF 推断额外列) + modelBuilder.Entity() + .Property(m => m.Name).HasMaxLength(100); + modelBuilder.Entity() + .Property(m => m.Path).HasMaxLength(200); + modelBuilder.Entity() + .Property(m => m.Permission).HasMaxLength(100); + modelBuilder.Entity() + .HasIndex(m => m.Permission); + modelBuilder.Entity() + .HasMany(m => m.Children).WithOne().HasForeignKey(m => m.ParentId) + .OnDelete(DeleteBehavior.Restrict); + + // 角色 + modelBuilder.Entity() + .Property(r => r.Name).HasMaxLength(100); + modelBuilder.Entity() + .HasIndex(r => r.Code).IsUnique(); + modelBuilder.Entity() + .HasKey(rm => new { rm.RoleId, rm.MenuId }); + + // 公告 + modelBuilder.Entity() + .Property(a => a.Title).HasMaxLength(200); + modelBuilder.Entity() + .HasIndex(a => new { a.Type, a.PublishedAt }); + modelBuilder.Entity() + .HasOne(a => a.Publisher).WithMany().HasForeignKey(a => a.PublisherId) + .OnDelete(DeleteBehavior.SetNull); + modelBuilder.Entity() + .HasIndex(r => new { r.AnnouncementId, r.UserId }).IsUnique(); + // 支付 modelBuilder.Entity() .Property(p => p.PayNo).HasMaxLength(32); diff --git a/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs b/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs index c5bea5b..0f097b9 100644 --- a/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs +++ b/backend/AgriculturalPlatform.Api/Data/DbSeeder.cs @@ -57,14 +57,87 @@ public static class DbSeeder stationWest.ParentId = company.Id; db.SaveChanges(); + // ---------- 角色(RBAC) ---------- + var roleAdmin = new SysRole { Name = "超级管理员", Code = "super_admin", Description = "系统内置:拥有全部权限", IsSystem = true, CreatedAt = now }; + var roleCompany = new SysRole { Name = "公司管理员", Code = "company_admin", Description = "管理本公司及下属收购站业务", IsSystem = true, CreatedAt = now }; + var roleStation = new SysRole { Name = "收购站员工", Code = "station_staff", Description = "负责过磅称重、收购单录入", IsSystem = true, CreatedAt = now }; + var roleIndividual = new SysRole { Name = "收购个体", Code = "individual", Description = "个体收购户", IsSystem = true, CreatedAt = now }; + db.SysRoles.AddRange(roleAdmin, roleCompany, roleStation, roleIndividual); + db.SaveChanges(); + + // ---------- 菜单/权限(RBAC 资源,Path 与前端路由一致) ---------- + var menus = new List + { + // 首页 + new() { Name = "首页", Path = "/home", Component = "views/Home.vue", Icon = "HomeFilled", Type = "menu", Permission = "home:view", Sort = 1, CreatedAt = now }, + // 收购业务 + new() { Name = "收购业务", Path = "/weighing", Icon = "Van", Type = "directory", Sort = 2, CreatedAt = now }, + new() { Name = "过磅称重", Path = "/weighing", Component = "views/weighing/WeighingList.vue", Icon = "Odometer", Type = "menu", Permission = "weighing:list", Sort = 1, CreatedAt = now }, + new() { Name = "触摸屏过磅", Path = "/weighing/touch", Component = "views/weighing/WeighingTouch.vue", Icon = "Iphone", Type = "menu", Permission = "weighing:touch", Sort = 2, CreatedAt = now }, + new() { Name = "电子支付", Path = "/payments", Component = "views/payments/PaymentList.vue", Icon = "Wallet", Type = "menu", Permission = "payment:list", Sort = 3, CreatedAt = now }, + new() { Name = "反向开票", Path = "/invoices", Component = "views/invoices/InvoiceList.vue", Icon = "Tickets", Type = "menu", Permission = "invoice:list", Sort = 4, CreatedAt = now }, + // 基础资料 + new() { Name = "基础资料", Path = "/base", Icon = "FolderOpened", Type = "directory", Sort = 3, CreatedAt = now }, + new() { Name = "农户管理", Path = "/farmers", Component = "views/farmers/FarmerList.vue", Icon = "User", Type = "menu", Permission = "farmer:list", Sort = 1, CreatedAt = now }, + new() { Name = "品种管理", Path = "/products", Component = "views/products/ProductList.vue", Icon = "Goods", Type = "menu", Permission = "product:list", Sort = 2, CreatedAt = now }, + // 统计报表 + new() { Name = "统计报表", Path = "/reports", Icon = "DataAnalysis", Type = "directory", Sort = 4, CreatedAt = now }, + new() { Name = "收购报表", Path = "/reports/purchase", Component = "views/reports/ReportPurchase.vue", Icon = "TrendCharts", Type = "menu", Permission = "report:purchase", Sort = 1, CreatedAt = now }, + new() { Name = "付款报表", Path = "/reports/payment", Component = "views/reports/ReportPayment.vue", Icon = "Money", Type = "menu", Permission = "report:payment", Sort = 2, CreatedAt = now }, + new() { Name = "开票报表", Path = "/reports/invoice", Component = "views/reports/ReportInvoice.vue", Icon = "Document", Type = "menu", Permission = "report:invoice", Sort = 3, CreatedAt = now }, + // 系统管理 + new() { Name = "系统管理", Path = "/system", Icon = "Setting", Type = "directory", Sort = 5, CreatedAt = now }, + new() { Name = "组织管理", Path = "/orgs", Component = "views/orgs/OrgList.vue", Icon = "OfficeBuilding", Type = "menu", Permission = "system:org:list", Sort = 1, CreatedAt = now }, + new() { Name = "用户管理", Path = "/users", Component = "views/users/UserList.vue", Icon = "Avatar", Type = "menu", Permission = "system:user:list", Sort = 2, CreatedAt = now }, + new() { Name = "角色权限", Path = "/system/roles", Component = "views/system/RoleList.vue", Icon = "UserFilled", Type = "menu", Permission = "system:role:list", Sort = 3, CreatedAt = now }, + new() { Name = "菜单管理", Path = "/system/menus", Component = "views/system/MenuList.vue", Icon = "Menu", Type = "menu", Permission = "system:menu:list", Sort = 4, CreatedAt = now }, + new() { Name = "数据字典", Path = "/system/dicts", Component = "views/system/DictList.vue", Icon = "Notebook", Type = "menu", Permission = "system:dict:list", Sort = 5, CreatedAt = now }, + // 通知公告 + new() { Name = "通知公告", Path = "/notify", Icon = "Bell", Type = "directory", Sort = 6, CreatedAt = now }, + new() { Name = "公告管理", Path = "/notify/announcements", Component = "views/notify/AnnouncementList.vue", Icon = "Bell", Type = "menu", Permission = "announcement:list", Sort = 1, CreatedAt = now }, + // 关于 + new() { Name = "关于系统", Path = "/about", Component = "views/about/About.vue", Icon = "InfoFilled", Type = "menu", Permission = "about:view", Sort = 7, CreatedAt = now }, + }; + db.SysMenus.AddRange(menus); + db.SaveChanges(); + + // 目录菜单的父级关系(按路径匹配;目录路径与子菜单路径相同时以 Type 区分) + SetChild(menus, "/weighing", "/weighing"); + SetChild(menus, "/weighing/touch", "/weighing"); + SetChild(menus, "/payments", "/weighing"); + SetChild(menus, "/invoices", "/weighing"); + SetChild(menus, "/farmers", "/base"); + SetChild(menus, "/products", "/base"); + SetChild(menus, "/reports/purchase", "/reports"); + SetChild(menus, "/reports/payment", "/reports"); + SetChild(menus, "/reports/invoice", "/reports"); + SetChild(menus, "/orgs", "/system"); + SetChild(menus, "/users", "/system"); + SetChild(menus, "/system/roles", "/system"); + SetChild(menus, "/system/menus", "/system"); + SetChild(menus, "/system/dicts", "/system"); + SetChild(menus, "/notify/announcements", "/notify"); + db.SaveChanges(); + + // 给超级管理员分配全部菜单;收购站员工分配常用菜单 + var allMenuIds = menus.Select(m => m.Id).ToArray(); + db.SysRoleMenus.AddRange(allMenuIds.Select(mid => new SysRoleMenu { RoleId = roleAdmin.Id, MenuId = mid })); + var stationMenuIds = menus.Where(m => m.Type == "menu" && m.Permission is "home:view" or "weighing:list" or "weighing:touch" or "farmer:list" or "about:view").Select(m => m.Id); + db.SysRoleMenus.AddRange(stationMenuIds.Select(mid => new SysRoleMenu { RoleId = roleStation.Id, MenuId = mid })); + + // 公司管理员:除系统管理外的全部业务菜单 + var companyMenuIds = menus.Where(m => m.Type == "menu" && !m.Permission.StartsWith("system:")).Select(m => m.Id); + db.SysRoleMenus.AddRange(companyMenuIds.Select(mid => new SysRoleMenu { RoleId = roleCompany.Id, MenuId = mid })); + 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 } + new() { Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "系统管理员", Phone = "13800000000", Role = UserRole.SuperAdmin, RoleId = roleAdmin.Id, CreatedAt = now }, + new() { Username = "company", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "王建国", Phone = "13800000001", Role = UserRole.CompanyAdmin, RoleId = roleCompany.Id, OrgId = company.Id, CreatedAt = now }, + new() { Username = "station1", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "李强", Phone = "13800000002", Role = UserRole.StationStaff, RoleId = roleStation.Id, OrgId = stationEast.Id, CreatedAt = now }, + new() { Username = "station2", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "赵敏", Phone = "13800000003", Role = UserRole.StationStaff, RoleId = roleStation.Id, OrgId = stationWest.Id, CreatedAt = now }, + new() { Username = "individual", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "张伟", Phone = "13800000004", Role = UserRole.Individual, RoleId = roleIndividual.Id, OrgId = individual.Id, CreatedAt = now } }; db.Users.AddRange(users); db.SaveChanges(); @@ -146,6 +219,75 @@ public static class DbSeeder db.PurchaseOrders.AddRange(orders); db.SaveChanges(); + // ---------- 数据字典(收购等级 / 价格类型 / 票据格式 / 公告类型) ---------- + var dicts = new List + { + new() { Name = "收购等级", Code = "purchase_grade", Remark = "收购单磅次等级", IsSystem = true, Sort = 1, CreatedAt = now, + Items = + { + new() { Label = "一等", Value = "一等", IsDefault = true, Sort = 1 }, + new() { Label = "二等", Value = "二等", Sort = 2 }, + new() { Label = "三等", Value = "三等", Sort = 3 }, + new() { Label = "级外", Value = "级外", Sort = 4 }, + } + }, + new() { Name = "价格类型", Code = "price_type", Remark = "过磅计价方式", IsSystem = true, Sort = 2, CreatedAt = now, + Items = + { + new() { Label = "固定价格", Value = "Fixed", Ext = "按产品默认价", IsDefault = true, Sort = 1 }, + new() { Label = "现场议价", Value = "Manual", Ext = "与农户协商价", Sort = 2 }, + new() { Label = "价格区间", Value = "Range", Ext = "区间内浮动价", Sort = 3 }, + } + }, + new() { Name = "票据格式", Code = "receipt_format", Remark = "小票/票据打印格式", IsSystem = true, Sort = 3, CreatedAt = now, + Items = + { + new() { Label = "58mm 热敏小票", Value = "thermal_58", Ext = "58mm", IsDefault = true, Sort = 1 }, + new() { Label = "80mm 热敏小票", Value = "thermal_80", Ext = "80mm", Sort = 2 }, + new() { Label = "三等分针式票据", Value = "dotmatrix_3part", Ext = "241mm×139.7mm/联", Sort = 3 }, + new() { Label = "A5 激光打印", Value = "laser_a5", Ext = "A5", Sort = 4 }, + } + }, + new() { Name = "公告类型", Code = "announcement_type", Remark = "通知公告类型", IsSystem = true, Sort = 4, CreatedAt = now, + Items = + { + new() { Label = "通知", Value = "notice", Sort = 1 }, + new() { Label = "公告", Value = "announce", Sort = 2 }, + new() { Label = "价格行情", Value = "price", Sort = 3 }, + new() { Label = "政策法规", Value = "policy", Sort = 4 }, + } + }, + new() { Name = "系统图标", Code = "system_logo", Remark = "主标题系统图标,可选大部分农产品图标", IsSystem = true, Sort = 5, CreatedAt = now, + Items = + { + new() { Label = "苹果", Value = "Apple", IsDefault = true, Sort = 1 }, + new() { Label = "葡萄", Value = "Grape", Sort = 2 }, + new() { Label = "西瓜", Value = "Watermelon", Sort = 3 }, + new() { Label = "樱桃", Value = "Cherry", Sort = 4 }, + new() { Label = "梨", Value = "Pear", Sort = 5 }, + new() { Label = "桃", Value = "Peach", Sort = 6 }, + new() { Label = "橙子", Value = "Orange", Sort = 7 }, + new() { Label = "农作物", Value = "Food", Sort = 8 }, + new() { Label = "时蔬", Value = "Dish", Sort = 9 }, + new() { Label = "牛奶", Value = "Milk", Sort = 10 }, + new() { Label = "冰淇淋", Value = "IceCream", Sort = 11 }, + new() { Label = "生鲜礼盒", Value = "TakeawayBox", Sort = 12 }, + } + }, + }; + db.SysDicts.AddRange(dicts); + db.SaveChanges(); + + // ---------- 通知公告(演示数据) ---------- + var announcements = new List + { + new() { Title = "欢迎使用农易富农产品收购交易平台", Content = "系统已完成升级,新增数据字典、角色权限、通知公告等功能。默认账号 admin / 123456。", Type = "announce", IsPinned = true, PublisherId = users[0].Id, PublishedAt = now, CreatedAt = now }, + new() { Title = "关于2026年夏季收购价格调整的通知", Content = "自8月15日起,一等品收购价调整为每公斤3.6元,请各收购站及时更新价格配置。", Type = "price", PublisherId = users[0].Id, PublishedAt = now.AddHours(-3), CreatedAt = now.AddHours(-3) }, + new() { Title = "电子秤串口连接使用说明", Content = "请在收购单新建界面点击电子秤区域连接串口,支持标准ASCII输出协议(如 ST,GS,+00123.45kg)。无串口设备时可使用手动输入模式。", Type = "notice", PublisherId = users[0].Id, PublishedAt = now.AddDays(-1), CreatedAt = now.AddDays(-1) }, + }; + db.Announcements.AddRange(announcements); + db.SaveChanges(); + // ---------- 历史付款(对应部分订单) ---------- var payOrders = orders.Where(o => o.Status == PurchaseStatus.Completed) .OrderBy(o => o.CreatedAt).Take(120).ToList(); @@ -192,4 +334,12 @@ public static class DbSeeder db.Invoices.AddRange(invoices); db.SaveChanges(); } + + private static void SetChild(List menus, string childPath, string parentPath) + { + var parent = menus.FirstOrDefault(m => m.Type == "directory" && m.Path == parentPath); + if (parent is null) return; + foreach (var child in menus.Where(m => m.Type != "directory" && m.Path == childPath && m.ParentId == null)) + child.ParentId = parent.Id; + } } diff --git a/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs b/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs index 02e8038..7587a03 100644 --- a/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs +++ b/backend/AgriculturalPlatform.Api/Data/SchemaMigrator.cs @@ -1,3 +1,4 @@ +using AgriculturalPlatform.Api.Models; using Microsoft.EntityFrameworkCore; namespace AgriculturalPlatform.Api.Data; @@ -27,6 +28,7 @@ public static class SchemaMigrator ("IdCardFrontUrl", "IdCardFrontUrl NVARCHAR(500) NULL"), ("IdCardBackUrl", "IdCardBackUrl NVARCHAR(500) NULL"), ("AvatarUrl", "AvatarUrl NVARCHAR(500) NULL"), + ("PinyinInitials", "PinyinInitials VARCHAR(50) NULL"), ]; foreach (var col in columns) @@ -41,7 +43,366 @@ public static class SchemaMigrator "Township = COALESCE(Township,''), GroupName = COALESCE(GroupName,''), " + "FarmerType = COALESCE(FarmerType,'农户'), " + "IdCardFrontUrl = COALESCE(IdCardFrontUrl,''), IdCardBackUrl = COALESCE(IdCardBackUrl,''), " + - "AvatarUrl = COALESCE(AvatarUrl,'')"); + "AvatarUrl = COALESCE(AvatarUrl,''), PinyinInitials = COALESCE(PinyinInitials,'')"); + + // Users 表补充 RoleId 列(RBAC 角色外键) + var userCols = new HashSet( + await db.Database + .SqlQuery($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Users'") + .ToListAsync(), + StringComparer.OrdinalIgnoreCase); + if (!userCols.Contains("RoleId")) + await db.Database.ExecuteSqlRawAsync("ALTER TABLE Users ADD COLUMN RoleId INT NULL"); + + // PurchaseOrders 表补充 BoxCount 列(容器/框数合计) + var poCols = new HashSet( + await db.Database + .SqlQuery($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'PurchaseOrders'") + .ToListAsync(), + StringComparer.OrdinalIgnoreCase); + if (!poCols.Contains("BoxCount")) + await db.Database.ExecuteSqlRawAsync("ALTER TABLE PurchaseOrders ADD COLUMN BoxCount INT NOT NULL DEFAULT 0"); + } + + /// + /// 确保 RBAC/数据字典/多磅/公告等新增表存在(兼容旧库 EnsureCreated 不会补建新表)。 + /// + public static async Task EnsureSystemTablesAsync(AppDbContext db) + { + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS SysDicts ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(100) NOT NULL, + Code VARCHAR(64) NOT NULL, + Remark VARCHAR(500) NULL, + IsSystem TINYINT(1) NOT NULL DEFAULT 0, + Sort INT NOT NULL DEFAULT 0, + CreatedAt DATETIME(6) NOT NULL, + UNIQUE KEY uk_dicts_code (Code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS SysDictItems ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + DictId INT NOT NULL, + Label VARCHAR(100) NOT NULL, + Value VARCHAR(64) NOT NULL, + Ext VARCHAR(255) NULL, + IsDefault TINYINT(1) NOT NULL DEFAULT 0, + Enabled TINYINT(1) NOT NULL DEFAULT 1, + Sort INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_dictitems (DictId, Value), + KEY idx_dictitems_dict (DictId) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS SysMenus ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + ParentId INT NULL, + Name VARCHAR(100) NOT NULL, + Path VARCHAR(200) NOT NULL DEFAULT '', + Component VARCHAR(200) NOT NULL DEFAULT '', + Icon VARCHAR(50) NOT NULL DEFAULT '', + Type VARCHAR(20) NOT NULL DEFAULT 'menu', + Permission VARCHAR(100) NOT NULL DEFAULT '', + Visible TINYINT(1) NOT NULL DEFAULT 1, + Sort INT NOT NULL DEFAULT 0, + CreatedAt DATETIME(6) NOT NULL, + KEY idx_menus_permission (Permission) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS SysRoles ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Name VARCHAR(100) NOT NULL, + Code VARCHAR(100) NOT NULL, + Description VARCHAR(500) NULL, + IsSystem TINYINT(1) NOT NULL DEFAULT 0, + CreatedAt DATETIME(6) NOT NULL, + UNIQUE KEY uk_roles_code (Code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS SysRoleMenus ( + RoleId INT NOT NULL, + MenuId INT NOT NULL, + PRIMARY KEY (RoleId, MenuId) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS PurchaseWeighs ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + PurchaseOrderId INT NOT NULL, + SortNo INT NOT NULL DEFAULT 1, + Grade VARCHAR(20) NOT NULL DEFAULT '', + PriceType VARCHAR(20) NOT NULL DEFAULT 'Manual', + UnitPrice DECIMAL(18,2) NOT NULL DEFAULT 0, + GrossWeight DECIMAL(18,2) NOT NULL DEFAULT 0, + TareWeight DECIMAL(18,2) NOT NULL DEFAULT 0, + NetWeight DECIMAL(18,2) NOT NULL DEFAULT 0, + BoxCount INT NOT NULL DEFAULT 0, + Amount DECIMAL(18,2) NOT NULL DEFAULT 0, + CreatedAt DATETIME(6) NOT NULL, + KEY idx_weighs_order (PurchaseOrderId) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS PurchaseTransfers ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + PurchaseOrderId INT NOT NULL, + TransferNo VARCHAR(32) NOT NULL, + FromFarmer VARCHAR(100) NOT NULL DEFAULT '', + ToFarmer VARCHAR(100) NOT NULL DEFAULT '', + ToIdCard VARCHAR(20) NOT NULL DEFAULT '', + Reason VARCHAR(500) NOT NULL DEFAULT '', + OperatorId INT NULL, + CreatedAt DATETIME(6) NOT NULL, + KEY idx_transfers_order (PurchaseOrderId) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS Announcements ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + Title VARCHAR(200) NOT NULL, + Content LONGTEXT NOT NULL, + Type VARCHAR(20) NOT NULL DEFAULT 'notice', + ScopeOrgIds VARCHAR(500) NOT NULL DEFAULT '', + IsPinned TINYINT(1) NOT NULL DEFAULT 0, + PublisherId INT NULL, + PublishedAt DATETIME(6) NOT NULL, + CreatedAt DATETIME(6) NOT NULL, + KEY idx_announcements_type (Type, PublishedAt) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + await db.Database.ExecuteSqlRawAsync(""" + CREATE TABLE IF NOT EXISTS AnnouncementReads ( + Id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + AnnouncementId INT NOT NULL, + UserId INT NOT NULL, + ReadAt DATETIME(6) NOT NULL, + UNIQUE KEY uk_ann_read (AnnouncementId, UserId) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + """); + + // 幂等系统数据种子(兼容旧库:DbSeeder 仅在空库执行,老库需要补齐角色/菜单/字典/公告) + await EnsureSystemSeedAsync(db); + } + + /// + /// 为已有数据的旧库补齐 RBAC/数据字典/公告等系统种子数据(幂等,按表是否为空判断)。 + /// + public static async Task EnsureSystemSeedAsync(AppDbContext db) + { + // 全新库由 DbSeeder 负责完整种子,这里仅补齐旧库(已有用户数据)缺失的系统数据 + if (!await db.Users.AnyAsync()) return; + + var now = DateTime.Now; + + // ---------- 角色 ---------- + var roles = new List(); + if (!await db.SysRoles.AnyAsync()) + { + roles = + [ + new() { Name = "超级管理员", Code = "super_admin", Description = "系统内置:拥有全部权限", IsSystem = true, CreatedAt = now }, + new() { Name = "公司管理员", Code = "company_admin", Description = "管理本公司及下属收购站业务", IsSystem = true, CreatedAt = now }, + new() { Name = "收购站员工", Code = "station_staff", Description = "负责过磅称重、收购单录入", IsSystem = true, CreatedAt = now }, + new() { Name = "收购个体", Code = "individual", Description = "个体收购户", IsSystem = true, CreatedAt = now }, + ]; + db.SysRoles.AddRange(roles); + await db.SaveChangesAsync(); + } + else + { + roles = await db.SysRoles.ToListAsync(); + } + + // ---------- 菜单 / 权限 ---------- + if (!await db.SysMenus.AnyAsync()) + { + var menus = new List + { + new() { Name = "首页", Path = "/home", Component = "views/Home.vue", Icon = "HomeFilled", Type = "menu", Permission = "home:view", Sort = 1, CreatedAt = now }, + new() { Name = "收购业务", Path = "/weighing", Icon = "Van", Type = "directory", Sort = 2, CreatedAt = now }, + new() { Name = "过磅称重", Path = "/weighing", Component = "views/weighing/WeighingList.vue", Icon = "Odometer", Type = "menu", Permission = "weighing:list", Sort = 1, CreatedAt = now }, + new() { Name = "触摸屏过磅", Path = "/weighing/touch", Component = "views/weighing/WeighingTouch.vue", Icon = "Iphone", Type = "menu", Permission = "weighing:touch", Sort = 2, CreatedAt = now }, + new() { Name = "电子支付", Path = "/payments", Component = "views/payments/PaymentList.vue", Icon = "Wallet", Type = "menu", Permission = "payment:list", Sort = 3, CreatedAt = now }, + new() { Name = "反向开票", Path = "/invoices", Component = "views/invoices/InvoiceList.vue", Icon = "Tickets", Type = "menu", Permission = "invoice:list", Sort = 4, CreatedAt = now }, + new() { Name = "基础资料", Path = "/base", Icon = "FolderOpened", Type = "directory", Sort = 3, CreatedAt = now }, + new() { Name = "农户管理", Path = "/farmers", Component = "views/farmers/FarmerList.vue", Icon = "User", Type = "menu", Permission = "farmer:list", Sort = 1, CreatedAt = now }, + new() { Name = "品种管理", Path = "/products", Component = "views/products/ProductList.vue", Icon = "Goods", Type = "menu", Permission = "product:list", Sort = 2, CreatedAt = now }, + new() { Name = "统计报表", Path = "/reports", Icon = "DataAnalysis", Type = "directory", Sort = 4, CreatedAt = now }, + new() { Name = "收购报表", Path = "/reports/purchase", Component = "views/reports/ReportPurchase.vue", Icon = "TrendCharts", Type = "menu", Permission = "report:purchase", Sort = 1, CreatedAt = now }, + new() { Name = "付款报表", Path = "/reports/payment", Component = "views/reports/ReportPayment.vue", Icon = "Money", Type = "menu", Permission = "report:payment", Sort = 2, CreatedAt = now }, + new() { Name = "开票报表", Path = "/reports/invoice", Component = "views/reports/ReportInvoice.vue", Icon = "Document", Type = "menu", Permission = "report:invoice", Sort = 3, CreatedAt = now }, + new() { Name = "系统管理", Path = "/system", Icon = "Setting", Type = "directory", Sort = 5, CreatedAt = now }, + new() { Name = "组织管理", Path = "/orgs", Component = "views/orgs/OrgList.vue", Icon = "OfficeBuilding", Type = "menu", Permission = "system:org:list", Sort = 1, CreatedAt = now }, + new() { Name = "用户管理", Path = "/users", Component = "views/users/UserList.vue", Icon = "Avatar", Type = "menu", Permission = "system:user:list", Sort = 2, CreatedAt = now }, + new() { Name = "角色权限", Path = "/system/roles", Component = "views/system/RoleList.vue", Icon = "UserFilled", Type = "menu", Permission = "system:role:list", Sort = 3, CreatedAt = now }, + new() { Name = "菜单管理", Path = "/system/menus", Component = "views/system/MenuList.vue", Icon = "Menu", Type = "menu", Permission = "system:menu:list", Sort = 4, CreatedAt = now }, + new() { Name = "数据字典", Path = "/system/dicts", Component = "views/system/DictList.vue", Icon = "Notebook", Type = "menu", Permission = "system:dict:list", Sort = 5, CreatedAt = now }, + new() { Name = "通知公告", Path = "/notify", Icon = "Bell", Type = "directory", Sort = 6, CreatedAt = now }, + new() { Name = "公告管理", Path = "/notify/announcements", Component = "views/notify/AnnouncementList.vue", Icon = "Bell", Type = "menu", Permission = "announcement:list", Sort = 1, CreatedAt = now }, + new() { Name = "关于系统", Path = "/about", Component = "views/about/About.vue", Icon = "InfoFilled", Type = "menu", Permission = "about:view", Sort = 7, CreatedAt = now }, + }; + db.SysMenus.AddRange(menus); + await db.SaveChangesAsync(); + + SetChild(menus, "/weighing", "/weighing"); + SetChild(menus, "/weighing/touch", "/weighing"); + SetChild(menus, "/payments", "/weighing"); + SetChild(menus, "/invoices", "/weighing"); + SetChild(menus, "/farmers", "/base"); + SetChild(menus, "/products", "/base"); + SetChild(menus, "/reports/purchase", "/reports"); + SetChild(menus, "/reports/payment", "/reports"); + SetChild(menus, "/reports/invoice", "/reports"); + SetChild(menus, "/orgs", "/system"); + SetChild(menus, "/users", "/system"); + SetChild(menus, "/system/roles", "/system"); + SetChild(menus, "/system/menus", "/system"); + SetChild(menus, "/system/dicts", "/system"); + SetChild(menus, "/notify/announcements", "/notify"); + await db.SaveChangesAsync(); + + // 角色-菜单分配:超级管理员全部;公司管理员除系统管理外全部;收购站员工常用菜单 + var allMenuIds = menus.Select(m => m.Id).ToArray(); + var admin = roles.First(r => r.Code == "super_admin"); + var company = roles.First(r => r.Code == "company_admin"); + var station = roles.First(r => r.Code == "station_staff"); + db.SysRoleMenus.AddRange(allMenuIds.Select(mid => new SysRoleMenu { RoleId = admin.Id, MenuId = mid })); + db.SysRoleMenus.AddRange(menus.Where(m => m.Type == "menu" && !m.Permission.StartsWith("system:")) + .Select(m => new SysRoleMenu { RoleId = company.Id, MenuId = m.Id })); + db.SysRoleMenus.AddRange(menus.Where(m => m.Type == "menu" && m.Permission is "home:view" or "weighing:list" or "weighing:touch" or "farmer:list" or "about:view") + .Select(m => new SysRoleMenu { RoleId = station.Id, MenuId = m.Id })); + await db.SaveChangesAsync(); + } + + // ---------- 菜单层级修复:过磅称重/触摸屏过磅 归入 收购业务 目录(幂等,兼容已有数据的库) ---------- + var weighingDir = await db.SysMenus.FirstOrDefaultAsync(m => m.Type == "directory" && m.Path == "/weighing"); + if (weighingDir != null) + { + var moveTargets = await db.SysMenus + .Where(m => m.Type == "menu" && (m.Path == "/weighing" || m.Path == "/weighing/touch")) + .ToListAsync(); + var menuChanged = false; + foreach (var m in moveTargets) + { + if (m.ParentId != weighingDir.Id) + { + m.ParentId = weighingDir.Id; + menuChanged = true; + } + } + if (menuChanged) await db.SaveChangesAsync(); + } + + // ---------- 已有用户按用户名绑定角色 ---------- + var roleByUser = new Dictionary + { + ["admin"] = "super_admin", + ["company"] = "company_admin", + ["station1"] = "station_staff", + ["station2"] = "station_staff", + ["individual"] = "individual", + }; + var users = await db.Users.Where(u => u.RoleId == null).ToListAsync(); + foreach (var u in users) + { + if (roleByUser.TryGetValue(u.Username, out var code)) + { + var role = roles.FirstOrDefault(r => r.Code == code); + if (role != null) u.RoleId = role.Id; + } + } + if (users.Count > 0) await db.SaveChangesAsync(); + + // ---------- 数据字典 ---------- + if (!await db.SysDicts.AnyAsync()) + { + var dicts = new List + { + new() { Name = "收购等级", Code = "purchase_grade", Remark = "收购单磅次等级", IsSystem = true, Sort = 1, CreatedAt = now, + Items = + { + new() { Label = "一等", Value = "一等", IsDefault = true, Sort = 1 }, + new() { Label = "二等", Value = "二等", Sort = 2 }, + new() { Label = "三等", Value = "三等", Sort = 3 }, + new() { Label = "级外", Value = "级外", Sort = 4 }, + } }, + new() { Name = "价格类型", Code = "price_type", Remark = "过磅计价方式", IsSystem = true, Sort = 2, CreatedAt = now, + Items = + { + new() { Label = "固定价格", Value = "Fixed", Ext = "按产品默认价", IsDefault = true, Sort = 1 }, + new() { Label = "现场议价", Value = "Manual", Ext = "与农户协商价", Sort = 2 }, + new() { Label = "价格区间", Value = "Range", Ext = "区间内浮动价", Sort = 3 }, + } }, + new() { Name = "票据格式", Code = "receipt_format", Remark = "小票/票据打印格式", IsSystem = true, Sort = 3, CreatedAt = now, + Items = + { + new() { Label = "58mm 热敏小票", Value = "thermal_58", Ext = "58mm", IsDefault = true, Sort = 1 }, + new() { Label = "80mm 热敏小票", Value = "thermal_80", Ext = "80mm", Sort = 2 }, + new() { Label = "三等分针式票据", Value = "dotmatrix_3part", Ext = "241mm×139.7mm/联", Sort = 3 }, + new() { Label = "A5 激光打印", Value = "laser_a5", Ext = "A5", Sort = 4 }, + } }, + new() { Name = "公告类型", Code = "announcement_type", Remark = "通知公告类型", IsSystem = true, Sort = 4, CreatedAt = now, + Items = + { + new() { Label = "通知", Value = "notice", Sort = 1 }, + new() { Label = "公告", Value = "announce", Sort = 2 }, + new() { Label = "价格行情", Value = "price", Sort = 3 }, + new() { Label = "政策法规", Value = "policy", Sort = 4 }, + } }, + }; + db.SysDicts.AddRange(dicts); + await db.SaveChangesAsync(); + } + + // ---------- 系统图标字典(幂等补齐,兼容已有数据的库) ---------- + if (!await db.SysDicts.AnyAsync(d => d.Code == "system_logo")) + { + db.SysDicts.Add(new SysDict + { + Name = "系统图标", Code = "system_logo", Remark = "主标题系统图标,可选大部分农产品图标", IsSystem = true, Sort = 5, CreatedAt = now, + Items = + { + new() { Label = "苹果", Value = "Apple", IsDefault = true, Sort = 1 }, + new() { Label = "葡萄", Value = "Grape", Sort = 2 }, + new() { Label = "西瓜", Value = "Watermelon", Sort = 3 }, + new() { Label = "樱桃", Value = "Cherry", Sort = 4 }, + new() { Label = "梨", Value = "Pear", Sort = 5 }, + new() { Label = "桃", Value = "Peach", Sort = 6 }, + new() { Label = "橙子", Value = "Orange", Sort = 7 }, + new() { Label = "农作物", Value = "Food", Sort = 8 }, + new() { Label = "时蔬", Value = "Dish", Sort = 9 }, + new() { Label = "牛奶", Value = "Milk", Sort = 10 }, + new() { Label = "冰淇淋", Value = "IceCream", Sort = 11 }, + new() { Label = "生鲜礼盒", Value = "TakeawayBox", Sort = 12 }, + } + }); + await db.SaveChangesAsync(); + } + + // ---------- 通知公告(演示数据) ---------- + if (!await db.Announcements.AnyAsync()) + { + var publisher = await db.Users.FirstOrDefaultAsync(u => u.Username == "admin"); + var adminRole = roles.FirstOrDefault(r => r.Code == "super_admin"); + db.Announcements.AddRange( + new Announcement { Title = "欢迎使用农易富农产品收购交易平台", Content = "系统已完成升级,新增数据字典、角色权限、通知公告等功能。默认账号 admin / 123456。", Type = "announce", IsPinned = true, PublisherId = publisher?.Id ?? (adminRole != null ? db.Users.FirstOrDefault()?.Id : null), PublishedAt = now, CreatedAt = now }, + new Announcement { Title = "关于2026年夏季收购价格调整的通知", Content = "自8月15日起,一等品收购价调整为每公斤3.6元,请各收购站及时更新价格配置。", Type = "price", PublisherId = publisher?.Id, PublishedAt = now.AddHours(-3), CreatedAt = now.AddHours(-3) }, + new Announcement { Title = "电子秤串口连接使用说明", Content = "请在收购单新建界面点击电子秤区域连接串口,支持标准ASCII输出协议(如 ST,GS,+00123.45kg)。无串口设备时可使用手动输入模式。", Type = "notice", PublisherId = publisher?.Id, PublishedAt = now.AddDays(-1), CreatedAt = now.AddDays(-1) } + ); + await db.SaveChangesAsync(); + } + } + + private static void SetChild(List menus, string childPath, string parentPath) + { + var parent = menus.FirstOrDefault(m => m.Type == "directory" && m.Path == parentPath); + if (parent is null) return; + foreach (var child in menus.Where(m => m.Type != "directory" && m.Path == childPath && m.ParentId == null)) + child.ParentId = parent.Id; } /// diff --git a/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs index b960a4d..aef49f2 100644 --- a/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs +++ b/backend/AgriculturalPlatform.Api/Dtos/AuthDtos.cs @@ -7,7 +7,7 @@ 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); + int? RoleId, string? RoleName, int? OrgId, string? OrgName, string? OrgType, bool IsActive); public record ChangePasswordRequest(string OldPassword, string NewPassword); diff --git a/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs index 7aa67f6..dd5227b 100644 --- a/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs +++ b/backend/AgriculturalPlatform.Api/Dtos/FarmerDtos.cs @@ -3,7 +3,7 @@ using AgriculturalPlatform.Api.Models; namespace AgriculturalPlatform.Api.Dtos; public record FarmerDto( - int Id, string Name, string IdCard, string Gender, string FarmerType, string Phone, + int Id, string Name, string PinyinInitials, 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, @@ -15,12 +15,12 @@ public record FarmerSaveRequest( string Address, string BankName, string BankAccount, int CreditScore = 80, string FarmerType = "农户", string IdCardFrontUrl = "", string IdCardBackUrl = "", string AvatarUrl = "", - string Status = "Active", string Notes = ""); + string Status = "Active", string Notes = "", string PinyinInitials = ""); 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.Id, f.Name, f.PinyinInitials, 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, diff --git a/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs index 204435d..19479fe 100644 --- a/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs +++ b/backend/AgriculturalPlatform.Api/Dtos/PurchaseDtos.cs @@ -2,23 +2,53 @@ using AgriculturalPlatform.Api.Models; namespace AgriculturalPlatform.Api.Dtos; +public record PurchaseWeighDto( + int Id, int SortNo, string Grade, string PriceType, decimal UnitPrice, + decimal GrossWeight, decimal TareWeight, decimal NetWeight, int BoxCount, decimal Amount, DateTime CreatedAt); + +public record PurchaseTransferDto( + int Id, string TransferNo, string FromFarmer, string ToFarmer, string ToIdCard, + string Reason, string? OperatorName, DateTime CreatedAt); + 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); + string? OperatorName, DateTime WeighInAt, DateTime? WeighOutAt, string Notes, DateTime CreatedAt, + List Weighs, int BoxCount); /// 新建收购单(第一次过磅:称毛重) public record PurchaseCreateRequest( int FarmerId, int ProductId, int PurchaserOrgId, string Grade, decimal GrossWeight, string Unit = "公斤", decimal UnitPrice = 0, string Notes = ""); +/// 多磅收购单保存请求(一单多磅、多等级) +public record PurchaseMultiCreateRequest( + int FarmerId, int ProductId, int PurchaserOrgId, string Unit, + string? Notes, List Weighs); + +public record PurchaseWeighSaveRequest( + string Grade, string PriceType, decimal UnitPrice, decimal GrossWeight, + decimal TareWeight, int BoxCount, decimal? NetWeight); + /// 回皮完成(第二次过磅:称皮重,计算净重与金额) public record PurchaseTareRequest(decimal TareWeight); +/// 票据过户请求 +public record PurchaseTransferRequest(string ToFarmer, string ToIdCard, string Reason); + +/// 收购单统计(多维汇总) +public record PurchaseSummaryDto( + int OrderCount, decimal TotalNetWeight, decimal AvgPrice, decimal TotalAmount, + int FarmerCount, int TodayOrderCount); + public static class PurchaseMappers { + public static PurchaseWeighDto ToDto(this PurchaseWeigh w) => new( + w.Id, w.SortNo, w.Grade, w.PriceType, w.UnitPrice, + w.GrossWeight, w.TareWeight, w.NetWeight, w.BoxCount, w.Amount, w.CreatedAt); + public static PurchaseDto ToDto(this PurchaseOrder p) => new( p.Id, p.OrderNo, p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.Phone ?? "", @@ -26,7 +56,9 @@ public static class PurchaseMappers 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); + p.Operator?.RealName ?? "", p.WeighInAt, p.WeighOutAt, p.Notes, p.CreatedAt, + p.Weighs?.OrderBy(w => w.SortNo).Select(w => w.ToDto()).ToList() ?? new List(), + p.BoxCount); public static PurchaseDto[] ToDtos(this IEnumerable items) => items.Select(ToDto).ToArray(); } diff --git a/backend/AgriculturalPlatform.Api/Dtos/SystemDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/SystemDtos.cs new file mode 100644 index 0000000..3c3d4eb --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Dtos/SystemDtos.cs @@ -0,0 +1,34 @@ +using AgriculturalPlatform.Api.Models; + +namespace AgriculturalPlatform.Api.Dtos; + +// ---------- 数据字典 ---------- +public record DictDto(int Id, string Name, string Code, string Remark, bool IsSystem, int Sort, int ItemCount); +public record DictSaveRequest(string Name, string Code, string Remark, int Sort); + +public record DictItemDto(int Id, int DictId, string Label, string Value, string Ext, bool IsDefault, bool Enabled, int Sort); +public record DictItemSaveRequest(string Label, string Value, string Ext, bool IsDefault, bool Enabled, int Sort); +public record DictDetailDto(DictDto Dict, List Items); + +// ---------- 菜单/权限 ---------- +public record MenuDto( + int Id, int? ParentId, string Name, string Path, string Component, string Icon, + string Type, string Permission, bool Visible, int Sort, List Children); +public record MenuSaveRequest( + int? ParentId, string Name, string Path, string Component, string Icon, + string Type, string Permission, bool Visible, int Sort); + +// ---------- 角色 ---------- +public record RoleDto(int Id, string Name, string Code, string Description, bool IsSystem, int[] MenuIds); +public record RoleSaveRequest(string Name, string Code, string Description, int[] MenuIds); + +// ---------- 通知公告 ---------- +public record AnnouncementDto( + int Id, string Title, string Content, string Type, string ScopeOrgIds, bool IsPinned, + string PublisherName, DateTime PublishedAt, DateTime CreatedAt, bool IsRead); +public record AnnouncementSaveRequest(string Title, string Content, string Type, string ScopeOrgIds, bool IsPinned); + +// ---------- 关于 ---------- +public record AboutDto( + string AppName, string Version, string Copyright, string Company, string License, + string Description, DateTime BuiltAt); diff --git a/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs b/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs index 1bd892e..4858da5 100644 --- a/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs +++ b/backend/AgriculturalPlatform.Api/Dtos/UserDtos.cs @@ -4,17 +4,17 @@ 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); + int? RoleId, string? RoleName, 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); + int? OrgId, int? RoleId = null, 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); + u.RoleId, u.SysRole?.Name, 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/Announcement.cs b/backend/AgriculturalPlatform.Api/Models/Announcement.cs new file mode 100644 index 0000000..e14c643 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/Announcement.cs @@ -0,0 +1,42 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 通知公告 +public class Announcement +{ + public int Id { get; set; } + + public string Title { get; set; } = string.Empty; + + /// 内容(富文本/纯文本) + public string Content { get; set; } = string.Empty; + + /// 类型(数据字典 announcement_type:notice=通知 announce=公告) + public string Type { get; set; } = "notice"; + + /// 发布范围组织 Id(逗号分隔;空=全部可见) + public string ScopeOrgIds { get; set; } = string.Empty; + + /// 是否置顶 + public bool IsPinned { get; set; } + + /// 发布人 + public int? PublisherId { get; set; } + + public User? Publisher { get; set; } + + public DateTime PublishedAt { get; set; } = DateTime.Now; + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +/// 公告已读记录 +public class AnnouncementRead +{ + public int Id { get; set; } + + public int AnnouncementId { get; set; } + + public int UserId { get; set; } + + public DateTime ReadAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/Farmer.cs b/backend/AgriculturalPlatform.Api/Models/Farmer.cs index e00e362..a37d8f8 100644 --- a/backend/AgriculturalPlatform.Api/Models/Farmer.cs +++ b/backend/AgriculturalPlatform.Api/Models/Farmer.cs @@ -7,6 +7,9 @@ public class Farmer public string Name { get; set; } = string.Empty; + /// 姓名拼音首字母(如:张三 → ZS,用于首字母检索) + public string PinyinInitials { get; set; } = string.Empty; + /// 身份证号(唯一) public string IdCard { get; set; } = string.Empty; diff --git a/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs b/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs index 1803367..e64b104 100644 --- a/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs +++ b/backend/AgriculturalPlatform.Api/Models/PurchaseOrder.cs @@ -23,26 +23,35 @@ public class PurchaseOrder public Organization? PurchaserOrg { get; set; } - /// 等级(一等/二等/三等) + /// 等级(主等级,多等级时为首磅等级;完整多等级见 Weighs) public string Grade { get; set; } = string.Empty; public string Unit { get; set; } = "公斤"; - /// 单价(元/单位) + /// 单价(元/单位,多磅时为首磅单价) public decimal UnitPrice { get; set; } - /// 毛重(kg) + /// 毛重(kg,合计) public decimal GrossWeight { get; set; } - /// 皮重(kg) + /// 皮重(kg,合计) public decimal TareWeight { get; set; } - /// 净重(kg)= 毛重 - 皮重 + /// 净重(kg,合计)= 毛重 - 皮重 public decimal NetWeight { get; set; } - /// 金额(元)= 净重 × 单价 + /// 金额(元,合计)= Σ 净重 × 单价 public decimal Amount { get; set; } + /// 容器/框数合计 + public int BoxCount { get; set; } + + /// 磅次(多磅单每磅记录) + public ICollection Weighs { get; set; } = new List(); + + /// 过户记录 + public ICollection Transfers { get; set; } = new List(); + /// 过磅次数:1=已称毛重,2=已回皮完成 public int WeighCount { get; set; } diff --git a/backend/AgriculturalPlatform.Api/Models/PurchaseTransfer.cs b/backend/AgriculturalPlatform.Api/Models/PurchaseTransfer.cs new file mode 100644 index 0000000..d39ef91 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/PurchaseTransfer.cs @@ -0,0 +1,33 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 收购票据过户记录 +public class PurchaseTransfer +{ + public int Id { get; set; } + + public int PurchaseOrderId { get; set; } + + public PurchaseOrder? PurchaseOrder { get; set; } + + /// 过户单号(如 GH20260812-0001) + public string TransferNo { get; set; } = string.Empty; + + /// 原农户(快照) + public string FromFarmer { get; set; } = string.Empty; + + /// 过户到农户(名称快照) + public string ToFarmer { get; set; } = string.Empty; + + /// 过户到农户身份证 + public string ToIdCard { get; set; } = string.Empty; + + /// 过户原因 + public string Reason { get; set; } = string.Empty; + + /// 经办人 + public int? OperatorId { get; set; } + + public User? Operator { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/PurchaseWeigh.cs b/backend/AgriculturalPlatform.Api/Models/PurchaseWeigh.cs new file mode 100644 index 0000000..1293548 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/PurchaseWeigh.cs @@ -0,0 +1,40 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 收购单-单磅记录(一张收购单支持多磅、多等级) +public class PurchaseWeigh +{ + public int Id { get; set; } + + public int PurchaseOrderId { get; set; } + + public PurchaseOrder? PurchaseOrder { get; set; } + + /// 磅次序号(第几磅) + public int SortNo { get; set; } + + /// 等级(数据字典 purchase_grade) + public string Grade { get; set; } = string.Empty; + + /// 价格类型:Fixed=固定价 Manual=现场议价 Range=价格区间(数据字典 price_type) + public string PriceType { get; set; } = "Manual"; + + /// 单价(元/公斤) + public decimal UnitPrice { get; set; } + + /// 毛重(kg) + public decimal GrossWeight { get; set; } + + /// 皮重(kg) + public decimal TareWeight { get; set; } + + /// 净重(kg)= 毛重 - 皮重 + public decimal NetWeight { get; set; } + + /// 容器/框数 + public int BoxCount { get; set; } + + /// 金额(元)= 净重 × 单价 + public decimal Amount { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} diff --git a/backend/AgriculturalPlatform.Api/Models/SysDict.cs b/backend/AgriculturalPlatform.Api/Models/SysDict.cs new file mode 100644 index 0000000..4a95153 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/SysDict.cs @@ -0,0 +1,47 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 数据字典类型 +public class SysDict +{ + public int Id { get; set; } + + /// 字典名称(如:收购等级) + public string Name { get; set; } = string.Empty; + + /// 字典编码(如:purchase_grade / price_type / receipt_format / announcement_type) + public string Code { get; set; } = string.Empty; + + public string Remark { get; set; } = string.Empty; + + /// 系统内置字典不可删除 + public bool IsSystem { get; set; } + + public int Sort { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public ICollection Items { get; set; } = new List(); +} + +/// 数据字典项 +public class SysDictItem +{ + public int Id { get; set; } + + public int DictId { get; set; } + + /// 显示名称(如:一等) + public string Label { get; set; } = string.Empty; + + /// 值(如:1) + public string Value { get; set; } = string.Empty; + + /// 扩展属性(如等级对应基准价、票据格式的纸张描述等) + public string Ext { get; set; } = string.Empty; + + public bool IsDefault { get; set; } + + public bool Enabled { get; set; } = true; + + public int Sort { get; set; } +} diff --git a/backend/AgriculturalPlatform.Api/Models/SysMenu.cs b/backend/AgriculturalPlatform.Api/Models/SysMenu.cs new file mode 100644 index 0000000..95c7cc1 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/SysMenu.cs @@ -0,0 +1,36 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 菜单/权限表(目录、菜单、按钮) +public class SysMenu +{ + public int Id { get; set; } + + public int? ParentId { get; set; } + + /// 菜单名称 + public string Name { get; set; } = string.Empty; + + /// 路由路径(前端) + public string Path { get; set; } = string.Empty; + + /// 前端组件路径 + public string Component { get; set; } = string.Empty; + + /// 图标 + public string Icon { get; set; } = string.Empty; + + /// 类型:directory=目录 menu=菜单 button=按钮 + public string Type { get; set; } = "menu"; + + /// 权限标识(如 purchase:create、purchase:reprint) + public string Permission { get; set; } = string.Empty; + + /// 是否可见(不可见的仅作权限标识) + public bool Visible { get; set; } = true; + + public int Sort { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; + + public ICollection Children { get; set; } = new List(); +} diff --git a/backend/AgriculturalPlatform.Api/Models/SysRole.cs b/backend/AgriculturalPlatform.Api/Models/SysRole.cs new file mode 100644 index 0000000..851e97e --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Models/SysRole.cs @@ -0,0 +1,27 @@ +namespace AgriculturalPlatform.Api.Models; + +/// 角色 +public class SysRole +{ + public int Id { get; set; } + + public string Name { get; set; } = string.Empty; + + /// 角色编码(如 super_admin / company_admin / station_staff / viewer) + public string Code { get; set; } = string.Empty; + + public string Description { get; set; } = string.Empty; + + /// 系统内置角色不可删除 + public bool IsSystem { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.Now; +} + +/// 角色-菜单关联 +public class SysRoleMenu +{ + public int RoleId { get; set; } + + public int MenuId { get; set; } +} diff --git a/backend/AgriculturalPlatform.Api/Models/User.cs b/backend/AgriculturalPlatform.Api/Models/User.cs index 09f70f8..ed54944 100644 --- a/backend/AgriculturalPlatform.Api/Models/User.cs +++ b/backend/AgriculturalPlatform.Api/Models/User.cs @@ -13,8 +13,14 @@ public class User public string Phone { get; set; } = string.Empty; + /// 兼容旧版角色枚举(新体系以 RoleId 为准) public UserRole Role { get; set; } + /// 关联角色(RBAC 权限体系) + public int? RoleId { get; set; } + + public SysRole? SysRole { get; set; } + /// 所属组织(公司管理员→公司;站员工→站;个体→个体组织) public int? OrgId { get; set; } diff --git a/backend/AgriculturalPlatform.Api/Program.cs b/backend/AgriculturalPlatform.Api/Program.cs index fcaff72..2d41178 100644 --- a/backend/AgriculturalPlatform.Api/Program.cs +++ b/backend/AgriculturalPlatform.Api/Program.cs @@ -81,8 +81,15 @@ builder.Services.AddSingleton(sp => ? sp.GetRequiredService() : sp.GetRequiredService()); -// ---------- OCR 识别(默认占位,接入阿里云/百度 OCR 时替换实现) ---------- -builder.Services.AddSingleton(); +// ---------- OCR 识别(阿里云市场身份证识别:Ocr:Provider=aliyun 时启用,否则占位提示) ---------- +builder.Services.AddHttpClient("ocr", c => c.Timeout = TimeSpan.FromSeconds(30)); +builder.Services.AddSingleton(sp => +{ + var cfg = sp.GetRequiredService(); + return string.Equals(cfg["Ocr:Provider"], "aliyun", StringComparison.OrdinalIgnoreCase) + ? new AliyunOcrService(cfg, sp.GetRequiredService(), sp.GetRequiredService()) + : new DisabledOcrService(cfg); +}); // ---------- 手机传图上传会话 ---------- builder.Services.AddSingleton(); @@ -107,6 +114,7 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); await SchemaMigrator.EnsureColumnsAsync(db); + await SchemaMigrator.EnsureSystemTablesAsync(db); await SchemaMigrator.EnsureRegionsTableAsync(db); DbSeeder.Seed(db); app.Logger.LogInformation("数据库初始化完成"); diff --git a/backend/AgriculturalPlatform.Api/Services/AliyunOcrService.cs b/backend/AgriculturalPlatform.Api/Services/AliyunOcrService.cs new file mode 100644 index 0000000..7fb8db9 --- /dev/null +++ b/backend/AgriculturalPlatform.Api/Services/AliyunOcrService.cs @@ -0,0 +1,192 @@ +using System.Text.Json; + +namespace AgriculturalPlatform.Api.Services; + +/// +/// 阿里云市场身份证 OCR 识别服务。 +/// 接口:POST https://swcardpack.market.alicloudapi.com/ocr/idcard +/// 认证:请求头 Authorization: APPCODE <AppCode>(无需 AppKey/AppSecret) +/// 传输:优先 image_url(OSS 私有桶 10 分钟签名 URL,实测兼容性最好); +/// 本地存储或签名失败时回退 image_base64。 +/// 返回:result 容器(兼容 data),成功码 0000/1000/0/200 等; +/// 正面字段 name/number/sex/address,反面 authority/valid_date,字段名做多别名兼容。 +/// 配置:appsettings.json 的 Ocr 节点(Provider=aliyun、AppCode、Url、MaxBytes)。 +/// +public sealed class AliyunOcrService(IConfiguration cfg, IHttpClientFactory httpFactory, IFileStorage storage) : IOcrService +{ + public async Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null) + { + var appCode = cfg["Ocr:AppCode"]; + var url = cfg["Ocr:Url"] ?? "https://swcardpack.market.alicloudapi.com/ocr/idcard"; + if (string.IsNullOrWhiteSpace(appCode)) + return new IdCardOcrResult(Success: false, + Message: "未配置阿里云 OCR AppCode(appsettings.json 的 Ocr:AppCode)"); + + var client = httpFactory.CreateClient("ocr"); + + // 方式一:image_url(OSS 签名 URL),可绕过接口对 base64 的兼容问题 + if (!string.IsNullOrWhiteSpace(storageKey)) + { + var presigned = storage.GeneratePresignedUrl(storageKey); + if (!string.IsNullOrWhiteSpace(presigned)) + { + using var req = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new FormUrlEncodedContent(new Dictionary { ["image_url"] = presigned }) + }; + req.Headers.TryAddWithoutValidation("Authorization", $"APPCODE {appCode}"); + try + { + using var resp = await client.SendAsync(req); + var body = await resp.Content.ReadAsStringAsync(); + if (resp.IsSuccessStatusCode) return ParseResult(body, side); + if (!TryParseResult(body, out var r)) // 业务层报错(8888 等)也透传给用户 + return r; + } + catch (Exception ex) + { + return new IdCardOcrResult(Success: false, Message: $"OCR 调用异常(image_url):{ex.Message}"); + } + } + } + + // 方式二:image_base64 回退 + if (image.CanSeek) image.Position = 0; + await using var buffer = new MemoryStream(); + await image.CopyToAsync(buffer); + + var maxBytes = cfg.GetValue("Ocr:MaxBytes", 4L * 1024 * 1024); + if (buffer.Length > maxBytes) + return new IdCardOcrResult(Success: false, Message: "图片过大(超过 4MB),请压缩后重试"); + + var base64 = Convert.ToBase64String(buffer.ToArray()); + using var req2 = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new FormUrlEncodedContent(new Dictionary { ["image_base64"] = base64 }) + }; + req2.Headers.TryAddWithoutValidation("Authorization", $"APPCODE {appCode}"); + + try + { + using var resp2 = await client.SendAsync(req2); + var body2 = await resp2.Content.ReadAsStringAsync(); + if (!resp2.IsSuccessStatusCode) + return new IdCardOcrResult(Success: false, + Message: $"OCR 接口返回 HTTP {(int)resp2.StatusCode}:{Truncate(body2, 200)}"); + return ParseResult(body2, side); + } + catch (Exception ex) + { + return new IdCardOcrResult(Success: false, Message: $"OCR 调用异常:{ex.Message}"); + } + } + + /// 尝试解析接口返回;无法解析出 JSON 时返回 false + private static bool TryParseResult(string json, out IdCardOcrResult result) + { + try + { + using var doc = JsonDocument.Parse(json); + result = ParseDoc(doc.RootElement, ""); + return true; + } + catch (JsonException) + { + result = new IdCardOcrResult(Success: false, Message: "OCR 返回数据无法解析"); + return false; + } + } + + /// 解析接口返回 JSON(兼容 result/data 容器、多字段别名) + private static IdCardOcrResult ParseResult(string json, string side) + { + try + { + using var doc = JsonDocument.Parse(json); + return ParseDoc(doc.RootElement, side); + } + catch (JsonException) + { + return new IdCardOcrResult(Success: false, Message: "OCR 返回数据无法解析"); + } + } + + private static IdCardOcrResult ParseDoc(JsonElement root, string side) + { + var data = root; + if (root.ValueKind == JsonValueKind.Object) + { + if (root.TryGetProperty("result", out var r) && r.ValueKind == JsonValueKind.Object) + data = r; + else if (root.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object) + data = d; + } + + // 接口级失败判定(success=false 或 result_code/code 非成功码) + var ok = GetBool(root, "success") ?? GetBool(root, "ok") ?? true; + var code = GetStr(root, "result_code") ?? GetStr(root, "code") ?? GetStr(root, "status") ?? ""; + var msg = GetStr(root, "result_msg") ?? GetStr(root, "msg") + ?? GetStr(root, "message") ?? GetStr(root, "errmsg") ?? ""; + if (!ok && !IsSuccessCode(code)) + return new IdCardOcrResult(Success: false, + Message: string.IsNullOrWhiteSpace(msg) ? $"OCR 识别失败(code={code})" : $"{msg}(code={code})"); + + var name = GetFirst(data, "name", "realName", "trueName", "姓名"); + var idCard = GetFirst(data, "number", "id", "idCard", "idNumber", "cardNo", "num", "身份证号"); + var gender = NormalizeGender(GetFirst(data, "sex", "gender", "性别")); + var address = GetFirst(data, "address", "addr", "住址"); + var authority = GetFirst(data, "authority", "issuingAuthority", "issueOrg", "signOrg", "签发机关"); + var validDate = GetFirst(data, "valid_date", "validDate", "validity", "expiry", "有效期"); + + // 正面看姓名/证件号,反面看签发机关/有效期;任一命中即视为识别成功 + var success = side == "back" + ? !string.IsNullOrWhiteSpace(authority) || !string.IsNullOrWhiteSpace(validDate) + : !string.IsNullOrWhiteSpace(name) || !string.IsNullOrWhiteSpace(idCard); + + return new IdCardOcrResult( + name, idCard, gender, address, "", "", + success, success ? "识别完成" : "未识别到有效信息,请确保图片清晰、证件完整"); + } + + private static bool IsSuccessCode(string code) => + code is "0000" or "0" or "200" or "1000" or "000000" or "1"; + + private static bool? GetBool(JsonElement e, string key) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(key, out var v) + ? v.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(v.GetString(), out var b) => b, + _ => null + } + : null; + + private static string? GetStr(JsonElement e, string key) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(key, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + + private static string GetFirst(JsonElement e, params string[] keys) + { + if (e.ValueKind != JsonValueKind.Object) return ""; + foreach (var k in keys) + if (e.TryGetProperty(k, out var v) && v.ValueKind is JsonValueKind.String) + { + var s = v.GetString()?.Trim(); + if (!string.IsNullOrWhiteSpace(s)) return s!; + } + return ""; + } + + private static string NormalizeGender(string? v) => v switch + { + null or "" => "", + "1" or "M" or "male" or "Male" or "男" => "男", + "2" or "F" or "female" or "Female" or "女" => "女", + _ => v + }; + + private static string Truncate(string s, int max) => + s.Length <= max ? s : s[..max] + "..."; +} diff --git a/backend/AgriculturalPlatform.Api/Services/FileStorage.cs b/backend/AgriculturalPlatform.Api/Services/FileStorage.cs index e76d11f..654a57d 100644 --- a/backend/AgriculturalPlatform.Api/Services/FileStorage.cs +++ b/backend/AgriculturalPlatform.Api/Services/FileStorage.cs @@ -1,3 +1,6 @@ +using System.Net; +using Aliyun.OSS; + namespace AgriculturalPlatform.Api.Services; /// 已保存文件的存储 key 与访问地址 @@ -16,6 +19,12 @@ public interface IFileStorage /// 按 key 删除文件(本地/OSS 通用) Task DeleteAsync(string key); + + /// 打开文件流供读取(OSS 模式下用于私有桶代理访问),不存在返回 null + Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key); + + /// 生成临时访问 URL(仅 OSS 模式支持),本地存储返回 null + string? GeneratePresignedUrl(string key); } /// 本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问 @@ -57,23 +66,123 @@ public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage } return Task.CompletedTask; } + + public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key) + { + if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/")) + return Task.FromResult<(Stream, string)?>(null); + var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(full)) return Task.FromResult<(Stream, string)?>(null); + var contentType = _contentTypes[Path.GetExtension(full)] ?? "application/octet-stream"; + return Task.FromResult<(Stream, string)?>((File.OpenRead(full), contentType)); + } + + /// 本地存储无公开 URL,返回 null(OCR 会回退 base64 方式) + public string? GeneratePresignedUrl(string key) => null; + + private static readonly Dictionary _contentTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png", + [".gif"] = "image/gif", [".webp"] = "image/webp", [".bmp"] = "image/bmp", + }; } /// -/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供; -/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。 +/// 阿里云 OSS 存储。配置见 appsettings.json 的 Oss 节点(AccessKeyId / AccessKeySecret / 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"]); + private readonly OssClient? _client = CreateClient(cfg); + private readonly string? _bucket = cfg["Oss:Bucket"]; + /// 配置完整(AccessKeyId / AccessKeySecret / Endpoint / Bucket 均非空)时才能创建客户端 + private static OssClient? CreateClient(IConfiguration cfg) + { + var id = cfg["Oss:AccessKeyId"]; + var secret = cfg["Oss:AccessKeySecret"]; + var endpoint = cfg["Oss:Endpoint"]; + var bucket = cfg["Oss:Bucket"]; + if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(secret) + || string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(bucket)) + return null; + return new OssClient(endpoint, id, secret); + } + + public bool IsConfigured => _client != null; + + /// + /// 上传到 OSS,返回 StoredFile。 + /// Key 使用 "uploads/{subDir}/{yyyyMM}/{guid}{ext}"(OSS 对象键不带前导斜杠)。 + /// URL 返回后端代理相对路径 /api/files/{key},由 FilesController 实时从私有桶拉流返回, + /// 保证 Bucket 可保持私有(身份证等敏感信息不暴露公网)。 + /// public Task SaveAsync(string subDir, Stream stream, string fileName, string contentType) - => throw new InvalidOperationException( - "阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务"); + { + if (_client is null || string.IsNullOrWhiteSpace(_bucket)) + throw new InvalidOperationException( + "阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务"); + var dateDir = DateTime.Now.ToString("yyyyMM"); + var ext = Path.GetExtension(fileName); + if (string.IsNullOrWhiteSpace(ext) || ext.Length > 10) ext = ".jpg"; + var key = $"uploads/{subDir}/{dateDir}/{Guid.NewGuid():N}{ext}"; + + var metadata = new ObjectMetadata { ContentType = contentType }; + _client.PutObject(_bucket, key, stream, metadata); + + return Task.FromResult(new StoredFile(key, $"/api/files/{key}")); + } + + /// 按 key 删除 OSS 对象(兼容本地存储带前导斜杠的 key 格式) public Task DeleteAsync(string key) - => throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除"); + { + if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.CompletedTask; + if (string.IsNullOrWhiteSpace(key)) return Task.CompletedTask; + try + { + var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key; + if (objectKey.StartsWith("uploads/")) + _client.DeleteObject(_bucket, objectKey); + } + catch + { + // 忽略删除失败 + } + return Task.CompletedTask; + } + + /// 从私有桶拉取对象流(供代理接口使用),不存在返回 null + public Task<(Stream Stream, string ContentType)?> OpenReadAsync(string key) + { + if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return Task.FromResult<(Stream, string)?>(null); + var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key; + if (!objectKey.StartsWith("uploads/")) return Task.FromResult<(Stream, string)?>(null); + try + { + var obj = _client.GetObject(_bucket, objectKey); + var contentType = obj.Metadata.ContentType ?? "application/octet-stream"; + return Task.FromResult<(Stream, string)?>((obj.Content, contentType)); + } + catch (Exception ex) when (ex is Aliyun.OSS.Common.OssException or WebException or System.Net.Http.HttpRequestException) + { + return Task.FromResult<(Stream, string)?>(null); + } + } + + /// 生成 10 分钟有效的私有桶临时访问 URL(供 OCR 等第三方服务拉取图片),失败返回 null + public string? GeneratePresignedUrl(string key) + { + if (_client is null || string.IsNullOrWhiteSpace(_bucket)) return null; + var objectKey = key.StartsWith('/') ? key.TrimStart('/') : key; + if (!objectKey.StartsWith("uploads/")) return null; + try + { + return _client.GeneratePresignedUri(_bucket, objectKey, DateTime.Now.AddMinutes(10)).ToString(); + } + catch + { + return null; + } + } } diff --git a/backend/AgriculturalPlatform.Api/Services/JwtService.cs b/backend/AgriculturalPlatform.Api/Services/JwtService.cs index 1ca9f9a..df2f8b5 100644 --- a/backend/AgriculturalPlatform.Api/Services/JwtService.cs +++ b/backend/AgriculturalPlatform.Api/Services/JwtService.cs @@ -17,6 +17,7 @@ public class JwtService(IConfiguration config) var claims = new List { new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new("uid", user.Id.ToString()), new(ClaimTypes.Name, user.Username), new(ClaimTypes.Role, user.Role.ToString()), new("realName", user.RealName), diff --git a/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs b/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs index 1194d85..35d1366 100644 --- a/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs +++ b/backend/AgriculturalPlatform.Api/Services/NumberGenerator.cs @@ -23,6 +23,7 @@ public class NumberGenerator(AppDbContext db) "PK" => await db.Invoices .Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "") .Select(x => x.BatchNo).Distinct().CountAsync(), + "GH" => await db.PurchaseTransfers.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd), _ => 0 }; seq = count + 1; diff --git a/backend/AgriculturalPlatform.Api/Services/OcrService.cs b/backend/AgriculturalPlatform.Api/Services/OcrService.cs index f89a582..81e2ad6 100644 --- a/backend/AgriculturalPlatform.Api/Services/OcrService.cs +++ b/backend/AgriculturalPlatform.Api/Services/OcrService.cs @@ -17,14 +17,17 @@ public record IdCardOcrResult( /// public interface IOcrService { - /// 识别身份证图片,side: front(正面)/ back(反面) - Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg"); + /// + /// 识别身份证图片,side: front(正面)/ back(反面)。 + /// storageKey 为已保存文件的存储 key(OSS 模式下可据此生成临时访问 URL 传给识别服务)。 + /// + Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null); } /// 默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件 public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService { - public Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg") + public Task RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg", string? storageKey = null) { var message = cfg["Ocr:DisabledMessage"] ?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写"; diff --git a/backend/AgriculturalPlatform.Api/appsettings.json b/backend/AgriculturalPlatform.Api/appsettings.json index 60d9baa..d661fd6 100644 --- a/backend/AgriculturalPlatform.Api/appsettings.json +++ b/backend/AgriculturalPlatform.Api/appsettings.json @@ -24,17 +24,21 @@ "LanPort": 5246 }, "Storage": { - "UseOss": false + "UseOss": true }, "Oss": { - "AccessKeyId": "", - "AccessKeySecret": "", - "Endpoint": "oss-cn-hangzhou.aliyuncs.com", - "Bucket": "", + "AccessKeyId": "LTAI5tSnK8HCPruXm3ZCRZDQ", + "AccessKeySecret": "lHcL75fV809SJYZ3IJtMWlPM7qYUXK", + "Endpoint": "oss-cn-chengdu.aliyuncs.com", + "StsEndpoint": "sts.cn-chengdu.aliyuncs.com", + "Bucket": "bbit-f8-web", "PublicUrl": "" }, "Ocr": { - "Provider": "none", + "Provider": "aliyun", + "Url": "https://swcardpack.market.alicloudapi.com/ocr/idcard", + "AppCode": "e3bdae1a73d2415492e7fdc024f07d08", + "MaxBytes": 4194304, "DisabledMessage": "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写" } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 826ba85..5d807d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "dayjs": "^1.11.13", "echarts": "^5.6.0", "element-plus": "^2.9.1", + "lunar-javascript": "^1.7.7", "pinia": "^2.3.0", "qrcode": "^1.5.4", "vue": "^3.5.13", @@ -1874,6 +1875,12 @@ "lodash-es": "*" } }, + "node_modules/lunar-javascript": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/lunar-javascript/-/lunar-javascript-1.7.7.tgz", + "integrity": "sha512-u/KYiwPIBo/0bT+WWfU7qO1d+aqeB90Tuy4ErXenr2Gam0QcWeezUvtiOIyXR7HbVnW2I1DKfU0NBvzMZhbVQw==", + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index cb4007d..b241a9a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "dayjs": "^1.11.13", "echarts": "^5.6.0", "element-plus": "^2.9.1", + "lunar-javascript": "^1.7.7", "pinia": "^2.3.0", "qrcode": "^1.5.4", "vue": "^3.5.13", diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 6304df8..3fa5b3e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -6,7 +6,8 @@ import type { Product, PurchaseOrder, PurchaseSummaryResult, PaymentSummaryResult, InvoiceSummaryResult, Overview, TrendPoint, RatioPoint, FarmerTopPoint, StationPoint, PriceTopPoint, IncomeTopPoint, RealtimeWeighing, - SysUser, UserInfo, WeatherInfo + SysUser, UserInfo, WeatherInfo, PurchaseSummary, PurchaseTransfer, MyAnnouncements, + Announcement, Dict, DictDetail, DictCodeItem, DictItemDto, SysMenu, SysRole, MyPermissions, AboutInfo } from '@/types' // ---------- 认证 ---------- @@ -90,6 +91,19 @@ export const getPurchases = (params: PageQuery & { 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 createMultiPurchase = (data: { + farmerId: number; productId: number; purchaserOrgId: number; unit: string; notes?: string + weighs: { grade: string; priceType: string; unitPrice: number; grossWeight: number; tareWeight: number; boxCount: number; netWeight?: number }[] +}) => request.post('/purchases/multi', data) +export const getPurchaseSummary = (params?: { + farmerId?: number; productId?: number; orgId?: number; from?: string; to?: string +}) => request.get('/purchases/summary', { params }) +export const reprintPurchase = (id: number) => + request.post(`/purchases/${id}/reprint`) +export const transferPurchase = (id: number, data: { toFarmer: string; toIdCard: string; reason: string }) => + request.post(`/purchases/${id}/transfer`, data) +export const getPurchaseTransfers = (id: number) => + request.get(`/purchases/${id}/transfers`) export const completeTare = (id: number, tareWeight: number) => request.put(`/purchases/${id}/tare`, { tareWeight }) export const updatePurchasePrice = (id: number, unitPrice: number, grade?: string) => @@ -99,6 +113,50 @@ export const updatePurchasePrice = (id: number, unitPrice: number, grade?: strin }) export const cancelPurchase = (id: number) => request.put(`/purchases/${id}/cancel`) +// ---------- 系统管理:数据字典 ---------- +export const getDicts = (params: PageQuery & { keyword?: string }) => + request.get>('/dicts', { params }) +export const getDictDetail = (id: number) => request.get(`/dicts/${id}`) +export const getDictAll = () => + request.get('/dicts/all') +export const saveDict = (data: Partial & { id?: number }) => + data.id ? request.put(`/dicts/${data.id}`, data) : request.post('/dicts', data) +export const deleteDict = (id: number) => request.delete(`/dicts/${id}`) +export const saveDictItem = (dictId: number, data: Partial & { id?: number }) => + data.id + ? request.put(`/dicts/items/${data.id}`, data) + : request.post(`/dicts/${dictId}/items`, data) +export const deleteDictItem = (id: number) => request.delete(`/dicts/items/${id}`) + +// ---------- 系统管理:菜单 / 角色 / 权限 ---------- +export const getMenus = () => request.get('/menus') +export const getMyPermissions = () => request.get('/menus/my') +export const saveMenu = (data: Partial & { id?: number }) => + data.id ? request.put(`/menus/${data.id}`, data) : request.post('/menus', data) +export const deleteMenu = (id: number) => request.delete(`/menus/${id}`) +export const getRoles = (params?: PageQuery & { keyword?: string }) => + request.get>('/roles', { params }) +export const getRolesAll = () => request.get('/roles/all') +export const saveRole = (data: Partial & { id?: number }) => + data.id ? request.put(`/roles/${data.id}`, data) : request.post('/roles', data) +export const deleteRole = (id: number) => request.delete(`/roles/${id}`) + +// ---------- 通知公告 ---------- +export const getMyAnnouncements = (params?: PageQuery) => + request.get('/announcements/my', { params }) +export const getAnnouncementUnread = () => + request.get('/announcements/unread-count') +export const getAnnouncement = (id: number) => + request.get(`/announcements/${id}`) +export const getAnnouncements = (params: PageQuery & { keyword?: string; type?: string }) => + request.get>('/announcements', { params }) +export const saveAnnouncement = (data: Partial & { id?: number }) => + data.id ? request.put(`/announcements/${data.id}`, data) : request.post('/announcements', data) +export const deleteAnnouncement = (id: number) => request.delete(`/announcements/${id}`) + +// ---------- 关于 ---------- +export const getAbout = () => request.get('/about') + // ---------- 支付 ---------- export const getPayments = (params: PageQuery & { status?: string; farmerId?: number; orgId?: number; from?: string; to?: string @@ -146,7 +204,7 @@ export const stepBatchProcess = (batchNo: string) => request.post(`/invoices/batches/${batchNo}/step`) // ---------- 报表 ---------- -export const getPurchaseSummary = (params: { dimension: string; from?: string; to?: string }) => +export const getPurchaseSummaryReport = (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 }) diff --git a/frontend/src/components/HeaderCalendar.vue b/frontend/src/components/HeaderCalendar.vue new file mode 100644 index 0000000..458acc9 --- /dev/null +++ b/frontend/src/components/HeaderCalendar.vue @@ -0,0 +1,301 @@ + + + + + diff --git a/frontend/src/components/HeaderMessage.vue b/frontend/src/components/HeaderMessage.vue new file mode 100644 index 0000000..dc0701f --- /dev/null +++ b/frontend/src/components/HeaderMessage.vue @@ -0,0 +1,198 @@ + + + + + + + diff --git a/frontend/src/components/HeaderWeather.vue b/frontend/src/components/HeaderWeather.vue new file mode 100644 index 0000000..7244ee6 --- /dev/null +++ b/frontend/src/components/HeaderWeather.vue @@ -0,0 +1,136 @@ + + + + + + + diff --git a/frontend/src/components/ReceiptPrint.vue b/frontend/src/components/ReceiptPrint.vue new file mode 100644 index 0000000..2a9d11d --- /dev/null +++ b/frontend/src/components/ReceiptPrint.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/frontend/src/composables/useWeather.ts b/frontend/src/composables/useWeather.ts new file mode 100644 index 0000000..4e9fe4e --- /dev/null +++ b/frontend/src/composables/useWeather.ts @@ -0,0 +1,149 @@ +import { ref, computed, markRaw, reactive } from 'vue' +import type { Component } from 'vue' +import { Sunny, PartlyCloudy, Cloudy, MostlyCloudy, Umbrella, Lightning } from '@element-plus/icons-vue' + +/** Open-Meteo weather_code -> 中文描述 + 图标(与顶部 banner 一致的唯一数据源) */ +export const WEATHER: Record = { + 0: { text: '晴', icon: Sunny }, + 1: { text: '大致晴朗', icon: Sunny }, + 2: { text: '多云', icon: PartlyCloudy }, + 3: { text: '阴', icon: Cloudy }, + 45: { text: '雾', icon: MostlyCloudy }, + 48: { text: '雾凇', icon: MostlyCloudy }, + 51: { text: '毛毛雨', icon: Umbrella }, + 53: { text: '毛毛雨', icon: Umbrella }, + 55: { text: '毛毛雨', icon: Umbrella }, + 56: { text: '冻毛毛雨', icon: Umbrella }, + 57: { text: '冻毛毛雨', icon: Umbrella }, + 61: { text: '小雨', icon: Umbrella }, + 63: { text: '中雨', icon: Umbrella }, + 65: { text: '大雨', icon: Umbrella }, + 66: { text: '冻雨', icon: Umbrella }, + 67: { text: '冻雨', icon: Umbrella }, + 71: { text: '小雪', icon: Cloudy }, + 73: { text: '中雪', icon: Cloudy }, + 75: { text: '大雪', icon: Cloudy }, + 77: { text: '雪粒', icon: Cloudy }, + 80: { text: '阵雨', icon: Umbrella }, + 81: { text: '强阵雨', icon: Umbrella }, + 82: { text: '暴雨', icon: Lightning }, + 85: { text: '阵雪', icon: Cloudy }, + 86: { text: '强阵雪', icon: Cloudy }, + 95: { text: '雷阵雨', icon: Lightning }, + 96: { text: '雷雨伴冰雹', icon: Lightning }, + 99: { text: '雷雨伴冰雹', icon: Lightning }, +} + +export interface WeatherForecast { + date: string + week: string + min: number + max: number + icon: Component + text: string +} + +export interface WeatherState { + loaded: boolean + city: string + region: string + current: { temperature: number; feels: number; text: string; icon: Component } + daily: WeatherForecast[] + todayMin: number + todayMax: number + wind: string + refresh: () => Promise +} + +async function fetchJson(url: string, timeout = 6000) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), timeout) + try { + const res = await fetch(url, { signal: ctrl.signal }) + if (!res.ok) throw new Error(String(res.status)) + return await res.json() + } finally { + clearTimeout(timer) + } +} + +const DEFAULT_LOC = { lat: 30.67, lon: 104.07, city: '成都', region: '四川省' } + +async function locate() { + try { + const d = await fetchJson('http://ip-api.com/json/?lang=zh-CN') + if (d && d.status === 'success' && d.lat != null && d.lon != null && d.city) { + return { lat: d.lat, lon: d.lon, city: d.city, region: d.regionName || '' } + } + } catch { + /* 使用默认 */ + } + return DEFAULT_LOC +} + +function createWeatherState(): WeatherState { + const loaded = ref(false) + const city = ref('') + const region = ref('') + const current = ref({ temperature: 0, feels: 0, text: '未知', icon: markRaw(Sunny) as Component }) + const daily = ref([]) + const wind = ref('') + const todayMin = computed(() => daily.value[0]?.min ?? 0) + const todayMax = computed(() => daily.value[0]?.max ?? 0) + + async function refresh() { + const loc = await locate() + city.value = loc.city + region.value = loc.region + try { + const data = await fetchJson( + `https://api.open-meteo.com/v1/forecast?latitude=${loc.lat}&longitude=${loc.lon}` + + `¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m` + + `&daily=weather_code,temperature_2m_max,temperature_2m_min` + + `&timezone=auto&forecast_days=5` + ) + const cur = data.current + const m = WEATHER[cur.weather_code] || { text: '未知', icon: Cloudy } + current.value = { + temperature: Math.round(cur.temperature_2m), + feels: Math.round(cur.apparent_temperature), + text: m.text, + icon: markRaw(m.icon), + } + const dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'] + const dir = dirs[Math.round((cur.wind_direction_10m ?? 0) / 45) % 8] + const kmh = Math.round(cur.wind_speed_10m ?? 0) + wind.value = kmh > 0 ? `${dir}风 ${kmh}km/h` : '' + daily.value = data.daily.time.map((date: string, i: number) => { + const [y, mo, dd] = date.split('-').map(Number) + const week = '日一二三四五六'[new Date(y, mo - 1, dd).getDay()] + const dm = WEATHER[data.daily.weather_code[i]] || { text: '未知', icon: Cloudy } + return { + date, + week: `周${week}`, + min: Math.round(data.daily.temperature_2m_min[i]), + max: Math.round(data.daily.temperature_2m_max[i]), + icon: markRaw(dm.icon), + text: dm.text, + } + }) + loaded.value = true + } catch { + // 天气服务不可用,保持占位状态 + } + } + + // 用 reactive 包装,确保使用方访问属性时自动解包 ref + return reactive({ loaded, city, region, current, daily, todayMin, todayMax, wind, refresh }) +} + +/** 全局共享天气状态:顶部 banner / 首页 / 可视化大屏 使用同一份数据 */ +let shared: WeatherState | null = null + +export function useWeather(): WeatherState { + if (!shared) { + shared = createWeatherState() + shared.refresh() + } + return shared +} diff --git a/frontend/src/directives/permission.ts b/frontend/src/directives/permission.ts new file mode 100644 index 0000000..4becfaa --- /dev/null +++ b/frontend/src/directives/permission.ts @@ -0,0 +1,16 @@ +import type { Directive, DirectiveBinding } from 'vue' +import { useAuthStore } from '@/stores/auth' + +/** + * 功能级权限指令:v-permission="'purchase:create'" + * 无权限时移除元素。 + */ +export const permission: Directive = { + mounted(el: HTMLElement, binding: DirectiveBinding) { + const auth = useAuthStore() + const perms = Array.isArray(binding.value) ? binding.value : [binding.value] + if (perms.length === 0) return + const ok = perms.some((p) => auth.hasPermission(p)) + if (!ok) el.remove() + } +} diff --git a/frontend/src/layouts/MainLayout.vue b/frontend/src/layouts/MainLayout.vue index 542a7c8..a702817 100644 --- a/frontend/src/layouts/MainLayout.vue +++ b/frontend/src/layouts/MainLayout.vue @@ -2,52 +2,35 @@ - - 工作台 - - - - - 过磅称重 - 触摸屏过磅 - 电子支付 - 反向开票 - - - - - 农户管理 - 品种管理 - 组织管理 - 用户管理 - - - - - 收购报表 - 付款报表 - 开票报表 - + @@ -55,9 +38,15 @@
{{ route.meta.title || '农易富' }}
- -  可视化大屏 - + + + + + +
+ +
+