Commit 08257ae41201b8258654184bcb013363391a817a
1 parent
e41dfa09
Expiration列下面标签的到期需要精确到标签的到期时间
Showing
4 changed files
with
522 additions
and
26 deletions
泰额版/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/*` | | ... | ... |