Commit 08257ae41201b8258654184bcb013363391a817a

Authored by 李曜臣
1 parent e41dfa09

Expiration列下面标签的到期需要精确到标签的到期时间

泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs
... ... @@ -112,7 +112,7 @@ public static class ReportsPrintLogExpiryHelper
112 112 return false;
113 113 }
114 114  
115   - if (!TryResolveElementDateTime(dateElement, reference, out var datePart))
  115 + if (!TryResolveElementDateTimeValue(dateElement, reference, out var datePart))
116 116 {
117 117 return false;
118 118 }
... ... @@ -124,7 +124,7 @@ public static class ReportsPrintLogExpiryHelper
124 124 }
125 125  
126 126 if (TryFindBestElement(elements, isDateElement: false, out var timeElement) &&
127   - TryResolveElementDateTime(timeElement, reference, out var timePart))
  127 + TryResolveElementDateTimeValue(timeElement, reference, out var timePart))
128 128 {
129 129 expiry = new DateTime(
130 130 datePart.Year,
... ... @@ -177,16 +177,69 @@ public static class ReportsPrintLogExpiryHelper
177 177 return found;
178 178 }
179 179  
180   - private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result)
  180 + private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) =>
  181 + TryResolveElementDateTimeValue(el, reference, out result);
  182 +
  183 + /// <summary>
  184 + /// 从模板元素直接解析到期 <see cref="DateTime"/>,避免 DATE 预设格式(如 MM/DD/YY)剥掉锚点时分。
  185 + /// </summary>
  186 + private static bool TryResolveElementDateTimeValue(JsonElement el, DateTime reference, out DateTime result)
  187 + {
  188 + result = default;
  189 + if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg))
  190 + {
  191 + return false;
  192 + }
  193 +
  194 + var rawText = GetStringProperty(cfg, "text", "Text").Trim();
  195 + var formatText = GetStringProperty(cfg, "format", "Format").Trim();
  196 + var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant();
  197 +
  198 + if (TryResolveOffsetToDateTime(reference, rawText, out result) ||
  199 + TryResolveOffsetToDateTime(reference, formatText, out result))
  200 + {
  201 + return true;
  202 + }
  203 +
  204 + if (!string.IsNullOrWhiteSpace(rawText) && IsLikelyResolvedDateTimeLiteral(rawText))
  205 + {
  206 + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result);
  207 + }
  208 +
  209 + if (type is "DATE" or "TIME" or "DURATION")
  210 + {
  211 + var off = ReadOffsetFromElementConfig(el, cfg, type);
  212 + if (off.HasValue)
  213 + {
  214 + result = ApplyOffsetToDate(reference, off.Value.amount, off.Value.unit);
  215 + return true;
  216 + }
  217 + }
  218 +
  219 + if (!string.IsNullOrWhiteSpace(rawText))
  220 + {
  221 + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result);
  222 + }
  223 +
  224 + return false;
  225 + }
  226 +
  227 + private static bool TryResolveOffsetToDateTime(DateTime reference, string raw, out DateTime result)
181 228 {
182 229 result = default;
183   - var display = ResolveElementDisplayText(el, reference);
184   - if (string.IsNullOrWhiteSpace(display))
  230 + if (string.IsNullOrWhiteSpace(raw) || !TryParseOffsetStored(raw, out var offset))
185 231 {
186 232 return false;
187 233 }
188 234  
189   - return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(display.Trim(), reference, out result);
  235 + var amount = NormalizeOffsetAmount(offset.value);
  236 + if (amount is null)
  237 + {
  238 + return false;
  239 + }
  240 +
  241 + result = ApplyOffsetToDate(reference, amount.Value, offset.unit);
  242 + return true;
190 243 }
191 244  
192 245 private static string? ResolveElementDisplayText(JsonElement el, DateTime reference)
... ... @@ -221,7 +274,7 @@ public static class ReportsPrintLogExpiryHelper
221 274 return type switch
222 275 {
223 276 "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture),
224   - "DATE" => FormatDateByElementPreset(el, cfg, resolved),
  277 + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved),
225 278 _ => $"{off.Value.amount} {off.Value.unit}".Trim()
226 279 };
227 280 }
... ... @@ -257,9 +310,9 @@ public static class ReportsPrintLogExpiryHelper
257 310 return type switch
258 311 {
259 312 "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture),
260   - "DATE" => FormatDateByElementPreset(el, cfg, resolved),
  313 + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved),
261 314 "DURATION" => $"{amount.Value} {offset.unit}".Trim(),
262   - _ => FormatDateByElementPreset(el, cfg, resolved)
  315 + _ => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved)
263 316 };
264 317 }
265 318  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs
... ... @@ -112,7 +112,7 @@ public static class ReportsPrintLogExpiryHelper
112 112 return false;
113 113 }
114 114  
115   - if (!TryResolveElementDateTime(dateElement, reference, out var datePart))
  115 + if (!TryResolveElementDateTimeValue(dateElement, reference, out var datePart))
116 116 {
117 117 return false;
118 118 }
... ... @@ -124,7 +124,7 @@ public static class ReportsPrintLogExpiryHelper
124 124 }
125 125  
126 126 if (TryFindBestElement(elements, isDateElement: false, out var timeElement) &&
127   - TryResolveElementDateTime(timeElement, reference, out var timePart))
  127 + TryResolveElementDateTimeValue(timeElement, reference, out var timePart))
128 128 {
129 129 expiry = new DateTime(
130 130 datePart.Year,
... ... @@ -177,16 +177,69 @@ public static class ReportsPrintLogExpiryHelper
177 177 return found;
178 178 }
179 179  
180   - private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result)
  180 + private static bool TryResolveElementDateTime(JsonElement el, DateTime reference, out DateTime result) =>
  181 + TryResolveElementDateTimeValue(el, reference, out result);
  182 +
  183 + /// <summary>
  184 + /// 从模板元素直接解析到期 <see cref="DateTime"/>,避免 DATE 预设格式(如 MM/DD/YY)剥掉锚点时分。
  185 + /// </summary>
  186 + private static bool TryResolveElementDateTimeValue(JsonElement el, DateTime reference, out DateTime result)
  187 + {
  188 + result = default;
  189 + if (!TryGetProperty(el, "config", out var cfg) && !TryGetProperty(el, "Config", out cfg))
  190 + {
  191 + return false;
  192 + }
  193 +
  194 + var rawText = GetStringProperty(cfg, "text", "Text").Trim();
  195 + var formatText = GetStringProperty(cfg, "format", "Format").Trim();
  196 + var type = GetStringProperty(el, "type", "Type").Trim().ToUpperInvariant();
  197 +
  198 + if (TryResolveOffsetToDateTime(reference, rawText, out result) ||
  199 + TryResolveOffsetToDateTime(reference, formatText, out result))
  200 + {
  201 + return true;
  202 + }
  203 +
  204 + if (!string.IsNullOrWhiteSpace(rawText) && IsLikelyResolvedDateTimeLiteral(rawText))
  205 + {
  206 + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result);
  207 + }
  208 +
  209 + if (type is "DATE" or "TIME" or "DURATION")
  210 + {
  211 + var off = ReadOffsetFromElementConfig(el, cfg, type);
  212 + if (off.HasValue)
  213 + {
  214 + result = ApplyOffsetToDate(reference, off.Value.amount, off.Value.unit);
  215 + return true;
  216 + }
  217 + }
  218 +
  219 + if (!string.IsNullOrWhiteSpace(rawText))
  220 + {
  221 + return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(rawText.Trim(), reference, out result);
  222 + }
  223 +
  224 + return false;
  225 + }
  226 +
  227 + private static bool TryResolveOffsetToDateTime(DateTime reference, string raw, out DateTime result)
181 228 {
182 229 result = default;
183   - var display = ResolveElementDisplayText(el, reference);
184   - if (string.IsNullOrWhiteSpace(display))
  230 + if (string.IsNullOrWhiteSpace(raw) || !TryParseOffsetStored(raw, out var offset))
185 231 {
186 232 return false;
187 233 }
188 234  
189   - return ReportsDateTimeDisplayHelper.TryParseExpiryDateTime(display.Trim(), reference, out result);
  235 + var amount = NormalizeOffsetAmount(offset.value);
  236 + if (amount is null)
  237 + {
  238 + return false;
  239 + }
  240 +
  241 + result = ApplyOffsetToDate(reference, amount.Value, offset.unit);
  242 + return true;
190 243 }
191 244  
192 245 private static string? ResolveElementDisplayText(JsonElement el, DateTime reference)
... ... @@ -221,7 +274,7 @@ public static class ReportsPrintLogExpiryHelper
221 274 return type switch
222 275 {
223 276 "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture),
224   - "DATE" => FormatDateByElementPreset(el, cfg, resolved),
  277 + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved),
225 278 _ => $"{off.Value.amount} {off.Value.unit}".Trim()
226 279 };
227 280 }
... ... @@ -257,9 +310,9 @@ public static class ReportsPrintLogExpiryHelper
257 310 return type switch
258 311 {
259 312 "TIME" => resolved.ToString("HH:mm", CultureInfo.InvariantCulture),
260   - "DATE" => FormatDateByElementPreset(el, cfg, resolved),
  313 + "DATE" => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved),
261 314 "DURATION" => $"{amount.Value} {offset.unit}".Trim(),
262   - _ => FormatDateByElementPreset(el, cfg, resolved)
  315 + _ => ReportsDateTimeDisplayHelper.FormatExpiryDateTime(resolved)
263 316 };
264 317 }
265 318  
... ...
美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx
... ... @@ -100,13 +100,57 @@ function applyOffsetAnchor(base: Date, amount: number, unit: string): Date {
100 100 return next;
101 101 }
102 102  
103   -/** Expiration 列:统一 MM/DD/YY HH:mm;兼容后端返回的 offset JSON 与 partial date */
  103 +function resolveExpiryAnchor(printedAtRaw?: string): Date {
  104 + const anchorRaw = (printedAtRaw ?? "").trim();
  105 + if (anchorRaw && anchorRaw !== BACKEND_EMPTY_DISPLAY) {
  106 + const partial = anchorRaw.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
  107 + if (partial) {
  108 + const month = Number(partial[1]);
  109 + const day = Number(partial[2]);
  110 + const yearToken = partial[3];
  111 + const year = yearToken
  112 + ? yearToken.length === 2
  113 + ? 2000 + Number(yearToken)
  114 + : Number(yearToken)
  115 + : new Date().getFullYear();
  116 + const dt = new Date(year, month - 1, day, 0, 0, 0, 0);
  117 + if (!Number.isNaN(dt.getTime())) return dt;
  118 + }
  119 + const parsed = new Date(anchorRaw);
  120 + if (!Number.isNaN(parsed.getTime())) return parsed;
  121 + }
  122 + return new Date();
  123 +}
  124 +
  125 +function formatExpiryDateOnly(d: Date): string {
  126 + const mm = String(d.getMonth() + 1).padStart(2, "0");
  127 + const dd = String(d.getDate()).padStart(2, "0");
  128 + const yy = String(d.getFullYear()).slice(-2);
  129 + return `${mm}/${dd}/${yy}`;
  130 +}
  131 +
  132 +/**
  133 + * Expiration 列:有真实到期时分则 MM/DD/YY HH:mm;纯日期不伪造 00:00。
  134 + * 兼容后端已算好的日期+时分、纯日期、offset JSON。
  135 + */
104 136 function formatExpiryDateText(raw: string, printedAtRaw?: string): string {
105 137 const s = (raw ?? "").trim();
106 138 if (!s || s === BACKEND_EMPTY_DISPLAY) return "None";
107 139  
108   - const anchorRaw = (printedAtRaw ?? "").trim();
109   - const anchor = anchorRaw ? new Date(anchorRaw) : new Date();
  140 + // 后端已输出 MM/dd/yy HH:mm:直接规范化透传
  141 + const backendDateTime = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})\s+(\d{1,2}:\d{2})$/);
  142 + if (backendDateTime) {
  143 + const mm = String(Number(backendDateTime[1])).padStart(2, "0");
  144 + const dd = String(Number(backendDateTime[2])).padStart(2, "0");
  145 + const yyRaw = backendDateTime[3];
  146 + const yy = yyRaw.length === 2 ? yyRaw : String(Number(yyRaw)).slice(-2);
  147 + const [hhRaw, minRaw] = backendDateTime[4].split(":");
  148 + const hh = String(Number(hhRaw)).padStart(2, "0");
  149 + const min = String(minRaw).padStart(2, "0");
  150 + return `${mm}/${dd}/${yy} ${hh}:${min}`;
  151 + }
  152 +
  153 + const anchor = resolveExpiryAnchor(printedAtRaw);
110 154 const anchorOk = !Number.isNaN(anchor.getTime());
111 155  
112 156 const offset = tryParseOffsetPayload(s);
... ... @@ -114,13 +158,18 @@ function formatExpiryDateText(raw: string, printedAtRaw?: string): string {
114 158 return formatPrintedAt(applyOffsetAnchor(anchor, offset.amount, offset.unit).toISOString());
115 159 }
116 160  
117   - const parsed = new Date(s);
118   - if (anchorOk && !Number.isNaN(parsed.getTime()) && /[/\-T]/.test(s)) {
119   - return formatPrintedAt(parsed.toISOString());
  161 + // 纯日期 MM/DD/YY:原样日期展示(不补 00:00)
  162 + const dateOnly = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/);
  163 + if (dateOnly) {
  164 + const mm = String(Number(dateOnly[1])).padStart(2, "0");
  165 + const dd = String(Number(dateOnly[2])).padStart(2, "0");
  166 + const yyRaw = dateOnly[3];
  167 + const yy = yyRaw.length === 2 ? yyRaw : String(Number(yyRaw)).slice(-2);
  168 + return `${mm}/${dd}/${yy}`;
120 169 }
121 170  
122 171 const partial = s.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?(?:\s+(\d{1,2}:\d{2}))?$/);
123   - if (partial && anchorOk) {
  172 + if (partial) {
124 173 const month = Number(partial[1]);
125 174 const day = Number(partial[2]);
126 175 const yearToken = partial[3];
... ... @@ -134,8 +183,14 @@ function formatExpiryDateText(raw: string, printedAtRaw?: string): string {
134 183 const [hh, mm] = partial[4].split(":").map((x) => Number(x));
135 184 if (Number.isFinite(hh)) dt.setHours(hh);
136 185 if (Number.isFinite(mm)) dt.setMinutes(mm);
  186 + return formatPrintedAt(dt.toISOString());
137 187 }
138   - return formatPrintedAt(dt.toISOString());
  188 + return formatExpiryDateOnly(dt);
  189 + }
  190 +
  191 + const parsed = new Date(s);
  192 + if (!Number.isNaN(parsed.getTime()) && /[T ]\d{1,2}:\d{2}/.test(s)) {
  193 + return formatPrintedAt(parsed.toISOString());
139 194 }
140 195  
141 196 return s;
... ...
项目相关文档/2026-07-22代码优化.md 0 → 100644
  1 +# 2026-07-22 代码优化
  2 +
  3 +本文档说明 **2026-07-22** 后端改动。重点为 **泰额版** 租户业务库菜单表(`menu`)的增删改查接口。
  4 +
  5 +> 与 **2026-07-21** 文档中的「平台 SaaS 菜单权限分配」(主库 `fl_th_tenant_menu_permission` / `menuPermissionKeys`)不同:
  6 +> 本文接口操作的是**当前租户业务库**的 `menu` 表(RBAC 菜单资源),不是平台静态权限目录。
  7 +
  8 +---
  9 +
  10 +## 一、变更概述
  11 +
  12 +| 项 | 说明 |
  13 +|----|------|
  14 +| 范围 | 泰额版 `Yi.Abp.Net8` / `FoodLabeling.Th.*` |
  15 +| 实现 | `ThRbacMenuAppService`(Swagger 分组:食品标签-泰额版接口) |
  16 +| 数据表 | 租户业务库 `menu`(如 Default 租户对应 `antis-foodlabeling-us`) |
  17 +| 路由前缀 | `/api/app/th-rbac-menu/*` |
  18 +| Id 生成 | 新建菜单 Id 使用 `YitIdHelper.NextId().ToString()`(雪花字符串) |
  19 +| 组树方式 | `MenuDbEntity` + 字符串 `ParentId` 自行组树;**禁止** `MenuAggregateRoot(Guid)` + `TreeHelper` |
  20 +| 与旧接口关系 | 美国版模块仍保留 `/api/app/rbac-menu/*`;泰额前端建议改调 `th-rbac-menu` |
  21 +
  22 +---
  23 +
  24 +## 二、接口一览
  25 +
  26 +鉴权:全部需登录(`Authorization: Bearer {token}`)。
  27 +Token 建议通过 `POST /api/app/th-web-auth/login` 获取(JWT 含 `TenantId`,操作当前租户业务库)。
  28 +
  29 +| 方法 | 路径 | 说明 |
  30 +|------|------|------|
  31 +| GET | `/api/app/th-rbac-menu/list` | 分页列表 |
  32 +| GET | `/api/app/th-rbac-menu/{id}` | 单条详情 |
  33 +| POST | `/api/app/th-rbac-menu` | 新增 |
  34 +| PUT | `/api/app/th-rbac-menu/{id}` | 修改 |
  35 +| DELETE | `/api/app/th-rbac-menu` | 批量逻辑删除 |
  36 +| GET | `/api/app/th-rbac-menu/tree` | 全量菜单树(不分页) |
  37 +
  38 +> **路由说明**:ABP RootPath 为 `api/app`;自定义路由必须带 `th-rbac-menu/` 前缀,完整路径才是 `/api/app/th-rbac-menu/...`。
  39 +
  40 +---
  41 +
  42 +## 三、分页列表
  43 +
  44 +### 3.1 接口
  45 +
  46 +| 项 | 值 |
  47 +|----|------|
  48 +| 方法 / 路径 | `GET /api/app/th-rbac-menu/list` |
  49 +| 说明 | 查询当前租户业务库未删除菜单,支持筛选与分页 |
  50 +
  51 +### 3.2 入参(Query)
  52 +
  53 +| 字段 | 说明 |
  54 +|------|------|
  55 +| `skipCount` | 跳过条数 |
  56 +| `maxResultCount` | 每页条数 |
  57 +| `menuName` | 菜单名称模糊匹配(可选) |
  58 +| `state` | 启用状态(可选) |
  59 +| `menuSource` | 菜单来源枚举(可选) |
  60 +| `menuType` | 菜单类型枚举(可选) |
  61 +
  62 +### 3.3 出参示例
  63 +
  64 +```json
  65 +{
  66 + "totalCount": 2,
  67 + "items": [
  68 + {
  69 + "id": "1234567890123456789",
  70 + "parentId": "0",
  71 + "menuName": "Settings",
  72 + "routerName": "settings",
  73 + "router": "/settings",
  74 + "permissionCode": "system:settings",
  75 + "menuType": 1,
  76 + "menuSource": 2,
  77 + "orderNum": 10,
  78 + "state": true
  79 + }
  80 + ]
  81 +}
  82 +```
  83 +
  84 +排序:`orderNum` **降序**。
  85 +
  86 +---
  87 +
  88 +## 四、单条详情
  89 +
  90 +### 4.1 接口
  91 +
  92 +| 项 | 值 |
  93 +|----|------|
  94 +| 方法 / 路径 | `GET /api/app/th-rbac-menu/{id}` |
  95 +| 说明 | 按主键查询未删除菜单;不存在返回业务友好错误 |
  96 +
  97 +出参字段与列表 `items` 项一致(`ThRbacMenuGetListOutputDto`)。
  98 +
  99 +---
  100 +
  101 +## 五、新增菜单
  102 +
  103 +### 5.1 接口
  104 +
  105 +| 项 | 值 |
  106 +|----|------|
  107 +| 方法 / 路径 | `POST /api/app/th-rbac-menu` |
  108 +| Content-Type | `application/json` |
  109 +
  110 +### 5.2 入参
  111 +
  112 +```json
  113 +{
  114 + "menuName": "Settings",
  115 + "parentId": "0",
  116 + "menuType": 1,
  117 + "menuSource": 2,
  118 + "permissionCode": "system:settings",
  119 + "router": "/settings",
  120 + "routerName": "settings",
  121 + "component": "views/settings/index",
  122 + "menuIcon": "Setting",
  123 + "orderNum": 10,
  124 + "state": true,
  125 + "isShow": true
  126 +}
  127 +```
  128 +
  129 +| 字段 | 必填 | 说明 |
  130 +|------|------|------|
  131 +| `menuName` | 是 | 菜单名称 |
  132 +| `parentId` | 否 | 父级 Id;空或根用 `"0"`(兼容全 0 Guid) |
  133 +| `menuType` | 是 | `MenuTypeEnum`:0=Catalogue,1=Menu,2=Component |
  134 +| `menuSource` | 是 | `MenuSourceEnum`:0=Ruoyi,1=Pure,2=Vben5 |
  135 +| `permissionCode` | 否 | 权限码 |
  136 +| `router` / `routerName` / `component` / `menuIcon` | 否 | 路由与展示 |
  137 +| `orderNum` | 否 | 排序号 |
  138 +| `state` | 否 | 启用状态,默认 `true` |
  139 +| `isShow` | 否 | 是否显示,默认 `true` |
  140 +
  141 +### 5.3 处理规则
  142 +
  143 +1. 校验 `menuName` 非空
  144 +2. 规范化 `parentId`;非根时校验父菜单存在且未删除
  145 +3. Id = `YitIdHelper.NextId().ToString()`
  146 +4. 插入当前租户业务库 `menu`,返回新建详情
  147 +
  148 +---
  149 +
  150 +## 六、修改菜单
  151 +
  152 +### 6.1 接口
  153 +
  154 +| 项 | 值 |
  155 +|----|------|
  156 +| 方法 / 路径 | `PUT /api/app/th-rbac-menu/{id}` |
  157 +| Content-Type | `application/json` |
  158 +
  159 +入参字段与新增相同(`ThRbacMenuUpdateInputVo` 继承 `ThRbacMenuCreateInputVo`)。
  160 +
  161 +### 6.2 处理规则
  162 +
  163 +1. 菜单必须存在且未删除
  164 +2. `menuName` 必填
  165 +3. 禁止 `parentId` 等于自身
  166 +4. 禁止将菜单移动到其子节点下(一级校验)
  167 +5. 更新后返回最新详情
  168 +
  169 +---
  170 +
  171 +## 七、批量删除
  172 +
  173 +### 7.1 接口
  174 +
  175 +| 项 | 值 |
  176 +|----|------|
  177 +| 方法 / 路径 | `DELETE /api/app/th-rbac-menu` |
  178 +| Content-Type | `application/json` |
  179 +| 说明 | **逻辑删除**(`IsDeleted = true`),非物理删 |
  180 +
  181 +### 7.2 入参
  182 +
  183 +```json
  184 +["1234567890123456789", "9876543210987654321"]
  185 +```
  186 +
  187 +空列表或全空白 Id 时直接返回(无操作)。
  188 +
  189 +---
  190 +
  191 +## 八、菜单树
  192 +
  193 +### 8.1 接口
  194 +
  195 +| 项 | 值 |
  196 +|----|------|
  197 +| 方法 / 路径 | `GET /api/app/th-rbac-menu/tree` |
  198 +| 说明 | 返回当前租户全部未删除菜单,按字符串 `ParentId` 组树 |
  199 +
  200 +### 8.2 出参要点
  201 +
  202 +- 根节点:`parentId` 为 `"0"`(或全 0 Guid)
  203 +- 子节点:`children` 数组嵌套
  204 +- 同级排序:`orderNum` **降序**
  205 +- 树节点字段比列表更全(含 `menuIcon`、`isShow`、`component`、`remark`、审计字段等)
  206 +
  207 +### 8.3 出参示例(节选)
  208 +
  209 +```json
  210 +[
  211 + {
  212 + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  213 + "parentId": "0",
  214 + "menuName": "System",
  215 + "menuType": 0,
  216 + "menuSource": 2,
  217 + "orderNum": 100,
  218 + "state": true,
  219 + "isShow": true,
  220 + "children": [
  221 + {
  222 + "id": "1234567890123456789",
  223 + "parentId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  224 + "menuName": "Settings",
  225 + "menuType": 1,
  226 + "orderNum": 10,
  227 + "children": []
  228 + }
  229 + ]
  230 + }
  231 +]
  232 +```
  233 +
  234 +---
  235 +
  236 +## 九、数据表与枚举
  237 +
  238 +### 9.1 表:`menu`(租户业务库)
  239 +
  240 +| 字段 | 类型要点 | 说明 |
  241 +|------|----------|------|
  242 +| `Id` | varchar | 主键;历史多为 UUID 字符串,新建为雪花字符串 |
  243 +| `ParentId` | varchar | 父级;根为 `"0"` |
  244 +| `MenuName` | varchar | 菜单名称 |
  245 +| `MenuType` | int | 见 `MenuTypeEnum` |
  246 +| `MenuSource` | int | 见 `MenuSourceEnum` |
  247 +| `IsDeleted` | tinyint/bool | 逻辑删除 |
  248 +| 其他 | — | `Router`、`Component`、`PermissionCode`、`OrderNum`、`State`、`IsShow` 等 |
  249 +
  250 +平台主库 `antis-foodlabeling-host` **无**此业务 `menu` 表。
  251 +
  252 +### 9.2 枚举
  253 +
  254 +| 枚举 | 值 |
  255 +|------|-----|
  256 +| `MenuTypeEnum` | 0=Catalogue(目录),1=Menu(菜单),2=Component(组件/按钮) |
  257 +| `MenuSourceEnum` | 0=Ruoyi,1=Pure,2=Vben5 |
  258 +
  259 +---
  260 +
  261 +## 十、代码文件
  262 +
  263 +| 类型 | 路径 |
  264 +|------|------|
  265 +| 服务 | `FoodLabeling.Th.Application/Services/ThRbacMenuAppService.cs` |
  266 +| 接口 | `FoodLabeling.Th.Application.Contracts/IServices/IThRbacMenuAppService.cs` |
  267 +| DTO | `FoodLabeling.Th.Application.Contracts/Dtos/RbacMenu/ThRbacMenu*.cs` |
  268 +| 实体映射 | 复用 `FoodLabeling.Application.Services.DbModels.MenuDbEntity`(字符串 Id) |
  269 +
  270 +---
  271 +
  272 +## 十一、curl 示例
  273 +
  274 +```bash
  275 +# 1) 登录拿 Token(按环境改 host / 账号 / 租户名)
  276 +curl -X POST "http://127.0.0.1:19002/api/app/th-web-auth/login" \
  277 + -H "Content-Type: application/json" \
  278 + -d "{\"tenantName\":\"Default\",\"userName\":\"admin\",\"password\":\"123456\"}"
  279 +
  280 +# 2) 菜单树
  281 +curl -X GET "http://127.0.0.1:19002/api/app/th-rbac-menu/tree" \
  282 + -H "Authorization: Bearer <token>"
  283 +
  284 +# 3) 分页列表
  285 +curl -X GET "http://127.0.0.1:19002/api/app/th-rbac-menu/list?skipCount=0&maxResultCount=20" \
  286 + -H "Authorization: Bearer <token>"
  287 +
  288 +# 4) 新增
  289 +curl -X POST "http://127.0.0.1:19002/api/app/th-rbac-menu" \
  290 + -H "Authorization: Bearer <token>" \
  291 + -H "Content-Type: application/json" \
  292 + -d "{\"menuName\":\"Settings\",\"parentId\":\"0\",\"menuType\":1,\"menuSource\":2,\"router\":\"/settings\",\"orderNum\":10,\"state\":true,\"isShow\":true}"
  293 +
  294 +# 5) 修改
  295 +curl -X PUT "http://127.0.0.1:19002/api/app/th-rbac-menu/<id>" \
  296 + -H "Authorization: Bearer <token>" \
  297 + -H "Content-Type: application/json" \
  298 + -d "{\"menuName\":\"Settings\",\"parentId\":\"0\",\"menuType\":1,\"menuSource\":2,\"orderNum\":20,\"state\":true,\"isShow\":true}"
  299 +
  300 +# 6) 批量删除
  301 +curl -X DELETE "http://127.0.0.1:19002/api/app/th-rbac-menu" \
  302 + -H "Authorization: Bearer <token>" \
  303 + -H "Content-Type: application/json" \
  304 + -d "[\"<id1>\",\"<id2>\"]"
  305 +```
  306 +
  307 +新增后可用查库核对:
  308 +
  309 +```sql
  310 +SELECT Id, ParentId, MenuName, MenuType, MenuSource, IsDeleted, OrderNum
  311 +FROM menu
  312 +WHERE Id = '<新Id>';
  313 +```
  314 +
  315 +---
  316 +
  317 +## 十二、前端对接注意
  318 +
  319 +1. 路由前缀使用 `/api/app/th-rbac-menu/*`,不要与旧 `/rbac-menu` 混淆。
  320 +2. 字段 camelCase:`menuName`、`parentId`、`menuType`、`menuSource`、`orderNum` 等。
  321 +3. 树管理页用 `/tree`;表格/筛选页用 `/list` 分页。
  322 +4. 根节点 `parentId` 传 `"0"`。
  323 +5. 必须在正确租户上下文下调用(登录 JWT 含 `TenantId`);写入的是该租户业务库,不是 host。
  324 +6. 部署后需**重启**后端进程,新路由才会注册生效。
  325 +
  326 +---
  327 +
  328 +## 十三、与 2026-07-21「菜单权限」的区分
  329 +
  330 +| 维度 | 2026-07-21 平台菜单权限 | 2026-07-22 业务菜单 CRUD(本文) |
  331 +|------|-------------------------|----------------------------------|
  332 +| 库 | 平台主库 `antis-foodlabeling-host` | 租户业务库(如 `antis-foodlabeling-us`) |
  333 +| 表 | `fl_th_tenant_menu_permission` | `menu` |
  334 +| 语义 | SaaS 公司可开通的功能 Key | RBAC 菜单资源(路由/组件/权限码) |
  335 +| 典型接口 | `th-multi-tenancy/company-menus` 等 | `th-rbac-menu/*` |
... ...