From 08257ae41201b8258654184bcb013363391a817a Mon Sep 17 00:00:00 2001
From: 李曜臣
Date: Thu, 23 Jul 2026 14:23:17 +0800
Subject: [PATCH] Expiration列下面标签的到期需要精确到标签的到期时间
---
泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/ReportsPrintLogExpiryHelper.cs | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
美国版/Food Labeling Management Platform/src/components/reports/ReportsView.tsx | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
项目相关文档/2026-07-22代码优化.md | 335 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 522 insertions(+), 26 deletions(-)
create mode 100644 项目相关文档/2026-07-22代码优化.md
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/*` |
--
libgit2 0.21.4