diff --git a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs index 1e88d38..d7a7fd1 100644 --- a/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs +++ b/泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs @@ -112,7 +112,7 @@ public static class ReportsPrintLogExpiryHelper return false; } - if (!TryResolveElementDateTime(dateElement, reference, out var datePart)) + if (!TryResolveElementDateTimeValue(dateElement, reference, out var datePart)) { return false; } @@ -124,7 +124,7 @@ public static class ReportsPrintLogExpiryHelper } if (TryFindBestElement(elements, isDateElement: false, out var timeElement) && - TryResolveElementDateTime(timeElement, reference, out var timePart)) + TryResolveElementDateTimeValue(timeElement, reference, out var timePart)) { expiry = new DateTime( datePart.Year, @@ -177,16 +177,69 @@ public static class ReportsPrintLogExpiryHelper return found; } - private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) + private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) => + TryResolveElementDateTimeValue(el, reference, out result); + + /// + /// 从模板元素直接解析到期 ,避免 DATE 预设格式(如 MM/DD/YY)剥掉锚点时分。 + /// + private static bool TryResolveElementDateTimeValue(JsonElement el, DateTime reference, out DateTime result) + { + result = default; + if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg)) + { + return false; + } + + var rawText = GetStringProperty(cfg, "text", "Text").Trim(); + var formatText = GetStringProperty(cfg, "format", "Format").Trim(); + var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); + + if (TryResolveOffsetToDateTime(reference, rawText, out result) || + TryResolveOffsetToDateTime(reference, formatText, out result)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(rawText) && IsLikelyResolvedDateTimeLiteral(rawText)) + { + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result); + } + + if (type is "DATE" or "TIME" or "DURATION") + { + var off = ReadOffsetFromElementConfig(el, cfg, type); + if (off.HasValue) + { + result = ApplyOffsetToDate(reference, off.Value.amount, off.Value.unit); + return true; + } + } + + if (!string.IsNullOrWhiteSpace(rawText)) + { + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result); + } + + return false; + } + + private static bool TryResolveOffsetToDateTime(DateTime reference, string raw, out DateTime result) { result = default; - var display = ResolveElementDisplayText(el, reference); - if (string.IsNullOrWhiteSpace(display)) + if (string.IsNullOrWhiteSpace(raw) || !TryParseOffsetStored(raw, out var offset)) { return false; } - return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(display.Trim(), reference, out result); + var amount = NormalizeOffsetAmount(offset.value); + if (amount is null) + { + return false; + } + + result = ApplyOffsetToDate(reference, amount.Value, offset.unit); + return true; } private static string? ResolveElementDisplayText(JsonElement el, DateTime reference) @@ -221,7 +274,7 @@ public static class ReportsPrintLogExpiryHelper return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), - "DATE" => FormatDateByElementPreset(el, cfg, resolved), + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved), _ => $"{off.Value.amount} {off.Value.unit}".Trim() }; } @@ -257,9 +310,9 @@ public static class ReportsPrintLogExpiryHelper return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), - "DATE" => FormatDateByElementPreset(el, cfg, resolved), + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved), "DURATION" => $"{amount.Value} {offset.unit}".Trim(), - _ => FormatDateByElementPreset(el, cfg, resolved) + _ => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved) }; } diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs index 1e88d38..d7a7fd1 100644 --- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs +++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs @@ -112,7 +112,7 @@ public static class ReportsPrintLogExpiryHelper return false; } - if (!TryResolveElementDateTime(dateElement, reference, out var datePart)) + if (!TryResolveElementDateTimeValue(dateElement, reference, out var datePart)) { return false; } @@ -124,7 +124,7 @@ public static class ReportsPrintLogExpiryHelper } if (TryFindBestElement(elements, isDateElement: false, out var timeElement) && - TryResolveElementDateTime(timeElement, reference, out var timePart)) + TryResolveElementDateTimeValue(timeElement, reference, out var timePart)) { expiry = new DateTime( datePart.Year, @@ -177,16 +177,69 @@ public static class ReportsPrintLogExpiryHelper return found; } - private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) + private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) => + TryResolveElementDateTimeValue(el, reference, out result); + + /// + /// 从模板元素直接解析到期 ,避免 DATE 预设格式(如 MM/DD/YY)剥掉锚点时分。 + /// + private static bool TryResolveElementDateTimeValue(JsonElement el, DateTime reference, out DateTime result) + { + result = default; + if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg)) + { + return false; + } + + var rawText = GetStringProperty(cfg, "text", "Text").Trim(); + var formatText = GetStringProperty(cfg, "format", "Format").Trim(); + var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant(); + + if (TryResolveOffsetToDateTime(reference, rawText, out result) || + TryResolveOffsetToDateTime(reference, formatText, out result)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(rawText) && IsLikelyResolvedDateTimeLiteral(rawText)) + { + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result); + } + + if (type is "DATE" or "TIME" or "DURATION") + { + var off = ReadOffsetFromElementConfig(el, cfg, type); + if (off.HasValue) + { + result = ApplyOffsetToDate(reference, off.Value.amount, off.Value.unit); + return true; + } + } + + if (!string.IsNullOrWhiteSpace(rawText)) + { + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result); + } + + return false; + } + + private static bool TryResolveOffsetToDateTime(DateTime reference, string raw, out DateTime result) { result = default; - var display = ResolveElementDisplayText(el, reference); - if (string.IsNullOrWhiteSpace(display)) + if (string.IsNullOrWhiteSpace(raw) || !TryParseOffsetStored(raw, out var offset)) { return false; } - return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(display.Trim(), reference, out result); + var amount = NormalizeOffsetAmount(offset.value); + if (amount is null) + { + return false; + } + + result = ApplyOffsetToDate(reference, amount.Value, offset.unit); + return true; } private static string? ResolveElementDisplayText(JsonElement el, DateTime reference) @@ -221,7 +274,7 @@ public static class ReportsPrintLogExpiryHelper return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), - "DATE" => FormatDateByElementPreset(el, cfg, resolved), + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved), _ => $"{off.Value.amount} {off.Value.unit}".Trim() }; } @@ -257,9 +310,9 @@ public static class ReportsPrintLogExpiryHelper return type switch { "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture), - "DATE" => FormatDateByElementPreset(el, cfg, resolved), + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved), "DURATION" => $"{amount.Value} {offset.unit}".Trim(), - _ => FormatDateByElementPreset(el, cfg, resolved) + _ => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved) }; } diff --git a/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx b/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx index 342bf85..dd6dcf7 100755 --- a/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx +++ b/美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx @@ -100,13 +100,57 @@ function applyOffsetAnchor(base: Date, amount: number, unit: string): Date { return next; } -/** Expiration 列:统一 MM/DD/YY HH:mm;兼容后端返回的 offset JSON 与 partial date */ +function resolveExpiryAnchor(printedAtRaw?: string): Date { + const anchorRaw = (printedAtRaw ?? "").trim(); + if (anchorRaw && anchorRaw !== BACKEND_EMPTY_DISPLAY) { + const partial = anchorRaw.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/); + if (partial) { + const month = Number(partial[1]); + const day = Number(partial[2]); + const yearToken = partial[3]; + const year = yearToken + ? yearToken.length === 2 + ? 2000 + Number(yearToken) + : Number(yearToken) + : new Date().getFullYear(); + const dt = new Date(year, month - 1, day, 0, 0, 0, 0); + if (!Number.isNaN(dt.getTime())) return dt; + } + const parsed = new Date(anchorRaw); + if (!Number.isNaN(parsed.getTime())) return parsed; + } + return new Date(); +} + +function formatExpiryDateOnly(d: Date): string { + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + const yy = String(d.getFullYear()).slice(-2); + return `${mm}/${dd}/${yy}`; +} + +/** + * Expiration 列:有真实到期时分则 MM/DD/YY HH:mm;纯日期不伪造 00:00。 + * 兼容后端已算好的日期+时分、纯日期、offset JSON。 + */ function formatExpiryDateText(raw: string, printedAtRaw?: string): string { const s = (raw ?? "").trim(); if (!s || s === BACKEND_EMPTY_DISPLAY) return "None"; - const anchorRaw = (printedAtRaw ?? "").trim(); - const anchor = anchorRaw ? new Date(anchorRaw) : new Date(); + // 后端已输出 MM/dd/yy HH:mm:直接规范化透传 + const backendDateTime = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}:\d{2})$/); + if (backendDateTime) { + const mm = String(Number(backendDateTime[1])).padStart(2, "0"); + const dd = String(Number(backendDateTime[2])).padStart(2, "0"); + const yyRaw = backendDateTime[3]; + const yy = yyRaw.length === 2 ? yyRaw : String(Number(yyRaw)).slice(-2); + const [hhRaw, minRaw] = backendDateTime[4].split(":"); + const hh = String(Number(hhRaw)).padStart(2, "0"); + const min = String(minRaw).padStart(2, "0"); + return `${mm}/${dd}/${yy} ${hh}:${min}`; + } + + const anchor = resolveExpiryAnchor(printedAtRaw); const anchorOk = !Number.isNaN(anchor.getTime()); const offset = tryParseOffsetPayload(s); @@ -114,13 +158,18 @@ function formatExpiryDateText(raw: string, printedAtRaw?: string): string { return formatPrintedAt(applyOffsetAnchor(anchor, offset.amount, offset.unit).toISOString()); } - const parsed = new Date(s); - if (anchorOk && !Number.isNaN(parsed.getTime()) && /[/\-T]/.test(s)) { - return formatPrintedAt(parsed.toISOString()); + // 纯日期 MM/DD/YY:原样日期展示(不补 00:00) + const dateOnly = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/); + if (dateOnly) { + const mm = String(Number(dateOnly[1])).padStart(2, "0"); + const dd = String(Number(dateOnly[2])).padStart(2, "0"); + const yyRaw = dateOnly[3]; + const yy = yyRaw.length === 2 ? yyRaw : String(Number(yyRaw)).slice(-2); + return `${mm}/${dd}/${yy}`; } const partial = s.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?(?:\s+(\d{1,2}:\d{2}))?$/); - if (partial && anchorOk) { + if (partial) { const month = Number(partial[1]); const day = Number(partial[2]); const yearToken = partial[3]; @@ -134,8 +183,14 @@ function formatExpiryDateText(raw: string, printedAtRaw?: string): string { const [hh, mm] = partial[4].split(":").map((x) => Number(x)); if (Number.isFinite(hh)) dt.setHours(hh); if (Number.isFinite(mm)) dt.setMinutes(mm); + return formatPrintedAt(dt.toISOString()); } - return formatPrintedAt(dt.toISOString()); + return formatExpiryDateOnly(dt); + } + + const parsed = new Date(s); + if (!Number.isNaN(parsed.getTime()) && /[T ]\d{1,2}:\d{2}/.test(s)) { + return formatPrintedAt(parsed.toISOString()); } return s; diff --git a/项目相关文档/2026-07-22代码优化.md b/项目相关文档/2026-07-22代码优化.md new file mode 100644 index 0000000..295798e --- /dev/null +++ b/项目相关文档/2026-07-22代码优化.md @@ -0,0 +1,335 @@ +# 2026-07-22 代码优化 + +本文档说明 **2026-07-22** 后端改动。重点为 **泰额版** 租户业务库菜单表(`menu`)的增删改查接口。 + +> 与 **2026-07-21** 文档中的「平台 SaaS 菜单权限分配」(主库 `fl_th_tenant_menu_permission` / `menuPermissionKeys`)不同: +> 本文接口操作的是**当前租户业务库**的 `menu` 表(RBAC 菜单资源),不是平台静态权限目录。 + +--- + +## 一、变更概述 + +| 项 | 说明 | +|----|------| +| 范围 | 泰额版 `Yi.Abp.Net8` / `FoodLabeling.Th.*` | +| 实现 | `ThRbacMenuAppService`(Swagger 分组:食品标签-泰额版接口) | +| 数据表 | 租户业务库 `menu`(如 Default 租户对应 `antis-foodlabeling-us`) | +| 路由前缀 | `/api/app/th-rbac-menu/*` | +| Id 生成 | 新建菜单 Id 使用 `YitIdHelper.NextId().ToString()`(雪花字符串) | +| 组树方式 | `MenuDbEntity` + 字符串 `ParentId` 自行组树;**禁止** `MenuAggregateRoot(Guid)` + `TreeHelper` | +| 与旧接口关系 | 美国版模块仍保留 `/api/app/rbac-menu/*`;泰额前端建议改调 `th-rbac-menu` | + +--- + +## 二、接口一览 + +鉴权:全部需登录(`Authorization: Bearer {token}`)。 +Token 建议通过 `POST /api/app/th-web-auth/login` 获取(JWT 含 `TenantId`,操作当前租户业务库)。 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/app/th-rbac-menu/list` | 分页列表 | +| GET | `/api/app/th-rbac-menu/{id}` | 单条详情 | +| POST | `/api/app/th-rbac-menu` | 新增 | +| PUT | `/api/app/th-rbac-menu/{id}` | 修改 | +| DELETE | `/api/app/th-rbac-menu` | 批量逻辑删除 | +| GET | `/api/app/th-rbac-menu/tree` | 全量菜单树(不分页) | + +> **路由说明**:ABP RootPath 为 `api/app`;自定义路由必须带 `th-rbac-menu/` 前缀,完整路径才是 `/api/app/th-rbac-menu/...`。 + +--- + +## 三、分页列表 + +### 3.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-rbac-menu/list` | +| 说明 | 查询当前租户业务库未删除菜单,支持筛选与分页 | + +### 3.2 入参(Query) + +| 字段 | 说明 | +|------|------| +| `skipCount` | 跳过条数 | +| `maxResultCount` | 每页条数 | +| `menuName` | 菜单名称模糊匹配(可选) | +| `state` | 启用状态(可选) | +| `menuSource` | 菜单来源枚举(可选) | +| `menuType` | 菜单类型枚举(可选) | + +### 3.3 出参示例 + +```json +{ + "totalCount": 2, + "items": [ + { + "id": "1234567890123456789", + "parentId": "0", + "menuName": "Settings", + "routerName": "settings", + "router": "/settings", + "permissionCode": "system:settings", + "menuType": 1, + "menuSource": 2, + "orderNum": 10, + "state": true + } + ] +} +``` + +排序:`orderNum` **降序**。 + +--- + +## 四、单条详情 + +### 4.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-rbac-menu/{id}` | +| 说明 | 按主键查询未删除菜单;不存在返回业务友好错误 | + +出参字段与列表 `items` 项一致(`ThRbacMenuGetListOutputDto`)。 + +--- + +## 五、新增菜单 + +### 5.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `POST /api/app/th-rbac-menu` | +| Content-Type | `application/json` | + +### 5.2 入参 + +```json +{ + "menuName": "Settings", + "parentId": "0", + "menuType": 1, + "menuSource": 2, + "permissionCode": "system:settings", + "router": "/settings", + "routerName": "settings", + "component": "views/settings/index", + "menuIcon": "Setting", + "orderNum": 10, + "state": true, + "isShow": true +} +``` + +| 字段 | 必填 | 说明 | +|------|------|------| +| `menuName` | 是 | 菜单名称 | +| `parentId` | 否 | 父级 Id;空或根用 `"0"`(兼容全 0 Guid) | +| `menuType` | 是 | `MenuTypeEnum`:0=Catalogue,1=Menu,2=Component | +| `menuSource` | 是 | `MenuSourceEnum`:0=Ruoyi,1=Pure,2=Vben5 | +| `permissionCode` | 否 | 权限码 | +| `router` / `routerName` / `component` / `menuIcon` | 否 | 路由与展示 | +| `orderNum` | 否 | 排序号 | +| `state` | 否 | 启用状态,默认 `true` | +| `isShow` | 否 | 是否显示,默认 `true` | + +### 5.3 处理规则 + +1. 校验 `menuName` 非空 +2. 规范化 `parentId`;非根时校验父菜单存在且未删除 +3. Id = `YitIdHelper.NextId().ToString()` +4. 插入当前租户业务库 `menu`,返回新建详情 + +--- + +## 六、修改菜单 + +### 6.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `PUT /api/app/th-rbac-menu/{id}` | +| Content-Type | `application/json` | + +入参字段与新增相同(`ThRbacMenuUpdateInputVo` 继承 `ThRbacMenuCreateInputVo`)。 + +### 6.2 处理规则 + +1. 菜单必须存在且未删除 +2. `menuName` 必填 +3. 禁止 `parentId` 等于自身 +4. 禁止将菜单移动到其子节点下(一级校验) +5. 更新后返回最新详情 + +--- + +## 七、批量删除 + +### 7.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `DELETE /api/app/th-rbac-menu` | +| Content-Type | `application/json` | +| 说明 | **逻辑删除**(`IsDeleted = true`),非物理删 | + +### 7.2 入参 + +```json +["1234567890123456789", "9876543210987654321"] +``` + +空列表或全空白 Id 时直接返回(无操作)。 + +--- + +## 八、菜单树 + +### 8.1 接口 + +| 项 | 值 | +|----|------| +| 方法 / 路径 | `GET /api/app/th-rbac-menu/tree` | +| 说明 | 返回当前租户全部未删除菜单,按字符串 `ParentId` 组树 | + +### 8.2 出参要点 + +- 根节点:`parentId` 为 `"0"`(或全 0 Guid) +- 子节点:`children` 数组嵌套 +- 同级排序:`orderNum` **降序** +- 树节点字段比列表更全(含 `menuIcon`、`isShow`、`component`、`remark`、审计字段等) + +### 8.3 出参示例(节选) + +```json +[ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "parentId": "0", + "menuName": "System", + "menuType": 0, + "menuSource": 2, + "orderNum": 100, + "state": true, + "isShow": true, + "children": [ + { + "id": "1234567890123456789", + "parentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "menuName": "Settings", + "menuType": 1, + "orderNum": 10, + "children": [] + } + ] + } +] +``` + +--- + +## 九、数据表与枚举 + +### 9.1 表:`menu`(租户业务库) + +| 字段 | 类型要点 | 说明 | +|------|----------|------| +| `Id` | varchar | 主键;历史多为 UUID 字符串,新建为雪花字符串 | +| `ParentId` | varchar | 父级;根为 `"0"` | +| `MenuName` | varchar | 菜单名称 | +| `MenuType` | int | 见 `MenuTypeEnum` | +| `MenuSource` | int | 见 `MenuSourceEnum` | +| `IsDeleted` | tinyint/bool | 逻辑删除 | +| 其他 | — | `Router`、`Component`、`PermissionCode`、`OrderNum`、`State`、`IsShow` 等 | + +平台主库 `antis-foodlabeling-host` **无**此业务 `menu` 表。 + +### 9.2 枚举 + +| 枚举 | 值 | +|------|-----| +| `MenuTypeEnum` | 0=Catalogue(目录),1=Menu(菜单),2=Component(组件/按钮) | +| `MenuSourceEnum` | 0=Ruoyi,1=Pure,2=Vben5 | + +--- + +## 十、代码文件 + +| 类型 | 路径 | +|------|------| +| 服务 | `FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs` | +| 接口 | `FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs` | +| DTO | `FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenu*.cs` | +| 实体映射 | 复用 `FoodLabeling.Application.Services.DbModels.MenuDbEntity`(字符串 Id) | + +--- + +## 十一、curl 示例 + +```bash +# 1) 登录拿 Token(按环境改 host / 账号 / 租户名) +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"tenantName\":\"Default\",\"userName\":\"admin\",\"password\":\"123456\"}" + +# 2) 菜单树 +curl -X GET "http://127.0.0.1:19002/api/app/th-rbac-menu/tree" \ + -H "Authorization: Bearer " + +# 3) 分页列表 +curl -X GET "http://127.0.0.1:19002/api/app/th-rbac-menu/list?skipCount=0&maxResultCount=20" \ + -H "Authorization: Bearer " + +# 4) 新增 +curl -X POST "http://127.0.0.1:19002/api/app/th-rbac-menu" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d "{\"menuName\":\"Settings\",\"parentId\":\"0\",\"menuType\":1,\"menuSource\":2,\"router\":\"/settings\",\"orderNum\":10,\"state\":true,\"isShow\":true}" + +# 5) 修改 +curl -X PUT "http://127.0.0.1:19002/api/app/th-rbac-menu/" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d "{\"menuName\":\"Settings\",\"parentId\":\"0\",\"menuType\":1,\"menuSource\":2,\"orderNum\":20,\"state\":true,\"isShow\":true}" + +# 6) 批量删除 +curl -X DELETE "http://127.0.0.1:19002/api/app/th-rbac-menu" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d "[\"\",\"\"]" +``` + +新增后可用查库核对: + +```sql +SELECT Id, ParentId, MenuName, MenuType, MenuSource, IsDeleted, OrderNum +FROM menu +WHERE Id = '<新Id>'; +``` + +--- + +## 十二、前端对接注意 + +1. 路由前缀使用 `/api/app/th-rbac-menu/*`,不要与旧 `/rbac-menu` 混淆。 +2. 字段 camelCase:`menuName`、`parentId`、`menuType`、`menuSource`、`orderNum` 等。 +3. 树管理页用 `/tree`;表格/筛选页用 `/list` 分页。 +4. 根节点 `parentId` 传 `"0"`。 +5. 必须在正确租户上下文下调用(登录 JWT 含 `TenantId`);写入的是该租户业务库,不是 host。 +6. 部署后需**重启**后端进程,新路由才会注册生效。 + +--- + +## 十三、与 2026-07-21「菜单权限」的区分 + +| 维度 | 2026-07-21 平台菜单权限 | 2026-07-22 业务菜单 CRUD(本文) | +|------|-------------------------|----------------------------------| +| 库 | 平台主库 `antis-foodlabeling-host` | 租户业务库(如 `antis-foodlabeling-us`) | +| 表 | `fl_th_tenant_menu_permission` | `menu` | +| 语义 | SaaS 公司可开通的功能 Key | RBAC 菜单资源(路由/组件/权限码) | +| 典型接口 | `th-multi-tenancy/company-menus` 等 | `th-rbac-menu/*` |