diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListOutputDto.cs
index 6938581..6c3574e 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListOutputDto.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListOutputDto.cs
@@ -12,7 +12,10 @@ public class LabelGetListOutputDto
public string ProductCategoryName { get; set; } = string.Empty;
- public string ProductName { get; set; } = string.Empty;
+ ///
+ /// 同一个标签绑定的产品名称,用 “,” 分割
+ ///
+ public string Products { get; set; } = string.Empty;
public string TemplateName { get; set; } = string.Empty;
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelPreviewResolveInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelPreviewResolveInputVo.cs
index bc1128a..64f188e 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelPreviewResolveInputVo.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelPreviewResolveInputVo.cs
@@ -16,9 +16,15 @@ public class LabelPreviewResolveInputVo
public string? ProductId { get; set; }
///
+ /// 业务基准时间(用于 DATE/TIME 元素的渲染计算)
+ /// 不传则默认使用服务器当前时间
+ ///
+ public DateTime? BaseTime { get; set; }
+
+ ///
/// 打印输入(前端传,用于 PRINT_INPUT 元素)
/// key 建议使用模板元素的 InputKey
///
- public Dictionary? PrintInputJson { get; set; }
+ public Dictionary? PrintInputJson { get; set; }
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadInputVo.cs
new file mode 100644
index 0000000..4294c14
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadInputVo.cs
@@ -0,0 +1,17 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+namespace FoodLabeling.Application.Contracts.Dtos.Picture;
+
+public class PictureUploadInputVo
+{
+ [FromForm(Name = "file")]
+ public IFormFile File { get; set; } = default!;
+
+ ///
+ /// 可选子目录(相对路径),例如:category、category/2026-03
+ ///
+ [FromForm(Name = "subDir")]
+ public string? SubDir { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadOutputDto.cs
new file mode 100644
index 0000000..910fa90
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Picture/PictureUploadOutputDto.cs
@@ -0,0 +1,14 @@
+namespace FoodLabeling.Application.Contracts.Dtos.Picture;
+
+public class PictureUploadOutputDto
+{
+ ///
+ /// 可直接保存到业务表的访问 URL(相对路径)
+ ///
+ public string Url { get; set; } = string.Empty;
+
+ public string FileName { get; set; } = string.Empty;
+
+ public long Size { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
index 2f5c40c..16a4335 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
@@ -6,7 +6,7 @@ public class ProductCreateInputVo
public string ProductName { get; set; } = string.Empty;
- public string? CategoryName { get; set; }
+ public string? CategoryId { get; set; }
public string? ProductImageUrl { get; set; }
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListOutputDto.cs
index f172144..cb2b5d7 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListOutputDto.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListOutputDto.cs
@@ -8,6 +8,8 @@ public class ProductGetListOutputDto
public string ProductName { get; set; } = string.Empty;
+ public string? CategoryId { get; set; }
+
public string? CategoryName { get; set; }
public string? ProductImageUrl { get; set; }
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs
index c307077..4308143 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs
@@ -10,6 +10,8 @@ public class ProductGetOutputDto
public string ProductName { get; set; } = string.Empty;
+ public string? CategoryId { get; set; }
+
public string? CategoryName { get; set; }
public string? ProductImageUrl { get; set; }
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs
new file mode 100644
index 0000000..0dd9931
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryCreateInputVo.cs
@@ -0,0 +1,18 @@
+namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+
+///
+/// 产品模块:新增类别入参
+///
+public class ProductCategoryCreateInputVo
+{
+ public string CategoryCode { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = string.Empty;
+
+ public string? CategoryPhotoUrl { get; set; }
+
+ public bool State { get; set; } = true;
+
+ public int OrderNum { get; set; } = 0;
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListInputVo.cs
new file mode 100644
index 0000000..4828537
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListInputVo.cs
@@ -0,0 +1,20 @@
+using Volo.Abp.Application.Dtos;
+
+namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+
+///
+/// 产品模块:类别分页列表入参
+///
+public class ProductCategoryGetListInputVo : PagedAndSortedResultRequestDto
+{
+ ///
+ /// 模糊搜索(CategoryCode/CategoryName)
+ ///
+ public string? Keyword { get; set; }
+
+ ///
+ /// 启用状态过滤
+ ///
+ public bool? State { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs
new file mode 100644
index 0000000..ced7d81
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetListOutputDto.cs
@@ -0,0 +1,22 @@
+namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+
+///
+/// 产品模块:类别列表行
+///
+public class ProductCategoryGetListOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string CategoryCode { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = string.Empty;
+
+ public string? CategoryPhotoUrl { get; set; }
+
+ public bool State { get; set; }
+
+ public int OrderNum { get; set; }
+
+ public DateTime LastEdited { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs
new file mode 100644
index 0000000..3c673a5
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryGetOutputDto.cs
@@ -0,0 +1,20 @@
+namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+
+///
+/// 产品模块:类别详情
+///
+public class ProductCategoryGetOutputDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string CategoryCode { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = string.Empty;
+
+ public string? CategoryPhotoUrl { get; set; }
+
+ public bool State { get; set; }
+
+ public int OrderNum { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryUpdateInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryUpdateInputVo.cs
new file mode 100644
index 0000000..36e6d11
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/ProductCategory/ProductCategoryUpdateInputVo.cs
@@ -0,0 +1,9 @@
+namespace FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+
+///
+/// 产品模块:编辑类别入参
+///
+public class ProductCategoryUpdateInputVo : ProductCategoryCreateInputVo
+{
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelCategoryTreeNodeDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelCategoryTreeNodeDto.cs
new file mode 100644
index 0000000..4e2ce5d
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelCategoryTreeNodeDto.cs
@@ -0,0 +1,17 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// 第一级:标签分类(fl_label_category)
+///
+public class UsAppLabelCategoryTreeNodeDto
+{
+ public string Id { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = string.Empty;
+
+ public string? CategoryPhotoUrl { get; set; }
+
+ public int OrderNum { get; set; }
+
+ public List ProductCategories { get; set; } = new();
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewDto.cs
new file mode 100644
index 0000000..b9206a5
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewDto.cs
@@ -0,0 +1,36 @@
+using FoodLabeling.Application.Contracts.Dtos.Label;
+
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// App 标签预览出参(顶部信息 + 预览模板结构)
+///
+public class UsAppLabelPreviewDto
+{
+ public string LocationId { get; set; } = string.Empty;
+
+ public string LabelCode { get; set; } = string.Empty;
+
+ public string? TemplateCode { get; set; }
+
+ public string? LabelSizeText { get; set; }
+
+ public string? TypeName { get; set; }
+
+ public string? ProductName { get; set; }
+
+ public string? ProductCategoryName { get; set; }
+
+ public string? LabelCategoryName { get; set; }
+
+ ///
+ /// 预览图(base64 png,可空;若为空,客户端可用 Template 自行渲染)
+ ///
+ public string? PreviewImageBase64Png { get; set; }
+
+ ///
+ /// 预览模板结构(与 LabelCanvas/LabelPreviewOnly 结构尽量一致)
+ ///
+ public LabelTemplatePreviewDto Template { get; set; } = new();
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewInputVo.cs
new file mode 100644
index 0000000..97290de
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPreviewInputVo.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// App 标签预览入参
+///
+public class UsAppLabelPreviewInputVo
+{
+ ///
+ /// 门店Id(fl_label.LocationId)
+ ///
+ public string LocationId { get; set; } = string.Empty;
+
+ ///
+ /// 标签编码(fl_label.LabelCode)
+ ///
+ public string LabelCode { get; set; } = string.Empty;
+
+ ///
+ /// 选择用于预览的产品Id(fl_product.Id)
+ /// 不传则默认取该标签绑定的第一个产品
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 业务基准时间(用于 DATE/TIME 等元素的计算)
+ ///
+ public DateTime? BaseTime { get; set; }
+
+ ///
+ /// 打印输入(用于 PRINT_INPUT 元素)
+ ///
+ public Dictionary? PrintInputJson { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
new file mode 100644
index 0000000..6c8bb17
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// App 打印入参
+///
+public class UsAppLabelPrintInputVo
+{
+ ///
+ /// 门店Id(fl_label.LocationId)
+ ///
+ public string LocationId { get; set; } = string.Empty;
+
+ ///
+ /// 标签编码(fl_label.LabelCode)
+ ///
+ public string LabelCode { get; set; } = string.Empty;
+
+ ///
+ /// 选择用于打印的产品Id(fl_product.Id)
+ /// 不传则默认取该标签绑定的第一个产品
+ ///
+ public string? ProductId { get; set; }
+
+ ///
+ /// 打印份数(<=0 则按 1 处理)
+ ///
+ public int PrintQuantity { get; set; } = 1;
+
+ ///
+ /// 业务基准时间(用于 DATE/TIME 等元素的计算)
+ ///
+ public DateTime? BaseTime { get; set; }
+
+ ///
+ /// 打印输入(用于 PRINT_INPUT 元素)
+ ///
+ public Dictionary? PrintInputJson { get; set; }
+
+ ///
+ /// 打印机Id(可选,若业务需要追踪)
+ ///
+ public string? PrinterId { get; set; }
+
+ ///
+ /// 打印机蓝牙 MAC(可选)
+ ///
+ public string? PrinterMac { get; set; }
+
+ ///
+ /// 打印机地址(可选)
+ ///
+ public string? PrinterAddress { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintOutputDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintOutputDto.cs
new file mode 100644
index 0000000..d7c202c
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintOutputDto.cs
@@ -0,0 +1,12 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// App 打印出参
+///
+public class UsAppLabelPrintOutputDto
+{
+ public string TaskId { get; set; } = string.Empty;
+
+ public int PrintQuantity { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelTypeNodeDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelTypeNodeDto.cs
new file mode 100644
index 0000000..0334bc5
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelTypeNodeDto.cs
@@ -0,0 +1,21 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// 第四级:标签种类(对应一条可打印的标签实例)
+///
+public class UsAppLabelTypeNodeDto
+{
+ public string LabelTypeId { get; set; } = string.Empty;
+
+ public string TypeName { get; set; } = string.Empty;
+
+ public int OrderNum { get; set; }
+
+ /// 业务标签编码,预览/打印流程使用
+ public string LabelCode { get; set; } = string.Empty;
+
+ public string? TemplateCode { get; set; }
+
+ /// 模板物理尺寸描述,如 2"x2"
+ public string? LabelSizeText { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs
new file mode 100644
index 0000000..9e31138
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs
@@ -0,0 +1,23 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// 第三级:产品
+///
+public class UsAppLabelingProductNodeDto
+{
+ public string ProductId { get; set; } = string.Empty;
+
+ public string ProductName { get; set; } = string.Empty;
+
+ public string ProductCode { get; set; } = string.Empty;
+
+ public string? ProductImageUrl { get; set; }
+
+ /// 副标题(无独立业务字段时:有编码显示编码,否则「无」)
+ public string Subtitle { get; set; } = string.Empty;
+
+ public int LabelTypeCount { get; set; }
+
+ /// 第四级:该产品在当前标签分类+门店下可选的标签种类
+ public List LabelTypes { get; set; } = new();
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingTreeInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingTreeInputVo.cs
new file mode 100644
index 0000000..081fae8
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingTreeInputVo.cs
@@ -0,0 +1,16 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// App Labeling 四级列表入参
+///
+public class UsAppLabelingTreeInputVo
+{
+ /// 当前门店 Id(location.Id,Guid 字符串)
+ public string LocationId { get; set; } = string.Empty;
+
+ /// 关键词(匹配标签分类/产品分类/产品名/标签类型/标签名称)
+ public string? Keyword { get; set; }
+
+ /// 仅展示某一标签分类(侧边栏选中时传);不传则返回全部分类
+ public string? LabelCategoryId { get; set; }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppProductCategoryNodeDto.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppProductCategoryNodeDto.cs
new file mode 100644
index 0000000..fd93dae
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppProductCategoryNodeDto.cs
@@ -0,0 +1,20 @@
+namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+
+///
+/// 第二级:产品分类(fl_product.CategoryId join fl_product_category)
+///
+public class UsAppProductCategoryNodeDto
+{
+ /// 产品分类Id;当产品未归类或分类不存在时为空
+ public string? CategoryId { get; set; }
+
+ /// 产品分类图片地址;当产品未归类或分类不存在时为空
+ public string? CategoryPhotoUrl { get; set; }
+
+ /// 分类显示名;空为「无」
+ public string Name { get; set; } = string.Empty;
+
+ public int ItemCount { get; set; }
+
+ public List Products { get; set; } = new();
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPictureAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPictureAppService.cs
new file mode 100644
index 0000000..2d265af
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IPictureAppService.cs
@@ -0,0 +1,9 @@
+using FoodLabeling.Application.Contracts.Dtos.Picture;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+public interface IPictureAppService
+{
+ Task UploadCategoryAsync(PictureUploadInputVo input);
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs
new file mode 100644
index 0000000..74f8bee
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IProductCategoryAppService.cs
@@ -0,0 +1,22 @@
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+using Volo.Abp.Application.Services;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+///
+/// 产品模块:类别(Categories)接口
+///
+public interface IProductCategoryAppService : IApplicationService
+{
+ Task> GetListAsync(ProductCategoryGetListInputVo input);
+
+ Task GetAsync(string id);
+
+ Task CreateAsync(ProductCategoryCreateInputVo input);
+
+ Task UpdateAsync(string id, ProductCategoryUpdateInputVo input);
+
+ Task DeleteAsync(string id);
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
new file mode 100644
index 0000000..0006eec
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
@@ -0,0 +1,25 @@
+using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+using Volo.Abp.Application.Services;
+
+namespace FoodLabeling.Application.Contracts.IServices;
+
+///
+/// App Labeling:四级列表(标签分类 → 产品分类 → 产品 → 标签种类)
+///
+public interface IUsAppLabelingAppService : IApplicationService
+{
+ ///
+ /// 获取当前门店下四级嵌套树,供移动端 Labeling 首页使用
+ ///
+ Task> GetLabelingTreeAsync(UsAppLabelingTreeInputVo input);
+
+ ///
+ /// App 打印预览:按标签编码解析模板并返回顶部展示字段 + 预览模板结构
+ ///
+ Task PreviewAsync(UsAppLabelPreviewInputVo input);
+
+ ///
+ /// App 打印:创建打印任务并落库打印明细(fl_label_print_task / fl_label_print_data)
+ ///
+ Task PrintAsync(UsAppLabelPrintInputVo input);
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintDataDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintDataDbEntity.cs
new file mode 100644
index 0000000..415b402
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintDataDbEntity.cs
@@ -0,0 +1,40 @@
+using SqlSugar;
+
+namespace FoodLabeling.Application.Services.DbModels;
+
+///
+/// 标签打印数据明细(对应表:fl_label_print_data)
+///
+[SugarTable("fl_label_print_data")]
+public class FlLabelPrintDataDbEntity
+{
+ [SugarColumn(IsPrimaryKey = true)]
+ public string Id { get; set; } = string.Empty;
+
+ public bool IsDeleted { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public string? CreatorId { get; set; }
+
+ public string? LastModifierId { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+
+ public string ConcurrencyStamp { get; set; } = string.Empty;
+
+ public string TaskId { get; set; } = string.Empty;
+
+ public int? CopyIndex { get; set; }
+
+ ///
+ /// 原始打印输入(json 字段,直接保存为字符串)
+ ///
+ public string? PrintInputJson { get; set; }
+
+ ///
+ /// 解析后的可打印数据(建议保存为 json 字符串)
+ ///
+ public string? RenderDataJson { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintTaskDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintTaskDbEntity.cs
new file mode 100644
index 0000000..8b73e7e
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelPrintTaskDbEntity.cs
@@ -0,0 +1,46 @@
+using SqlSugar;
+
+namespace FoodLabeling.Application.Services.DbModels;
+
+///
+/// 标签打印任务(对应表:fl_label_print_task)
+///
+[SugarTable("fl_label_print_task")]
+public class FlLabelPrintTaskDbEntity
+{
+ [SugarColumn(IsPrimaryKey = true)]
+ public string Id { get; set; } = string.Empty;
+
+ public bool IsDeleted { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public string? CreatorId { get; set; }
+
+ public string? LastModifierId { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+
+ public string ConcurrencyStamp { get; set; } = string.Empty;
+
+ public string? LocationId { get; set; }
+
+ public string? LabelCode { get; set; }
+
+ public string? ProductId { get; set; }
+
+ public string? LabelTypeId { get; set; }
+
+ public string? TemplateCode { get; set; }
+
+ public int PrintQuantity { get; set; }
+
+ public DateTime? BaseTime { get; set; }
+
+ public string? PrinterId { get; set; }
+
+ public string? PrinterMac { get; set; }
+
+ public string? PrinterAddress { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs
new file mode 100644
index 0000000..1f51a75
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductCategoryDbEntity.cs
@@ -0,0 +1,33 @@
+using SqlSugar;
+
+namespace FoodLabeling.Application.Services.DbModels;
+
+[SugarTable("fl_product_category")]
+public class FlProductCategoryDbEntity
+{
+ [SugarColumn(IsPrimaryKey = true)]
+ public string Id { get; set; } = string.Empty;
+
+ public bool IsDeleted { get; set; }
+
+ public DateTime CreationTime { get; set; }
+
+ public string? CreatorId { get; set; }
+
+ public string? LastModifierId { get; set; }
+
+ public DateTime? LastModificationTime { get; set; }
+
+ public string ConcurrencyStamp { get; set; } = string.Empty;
+
+ public string CategoryCode { get; set; } = string.Empty;
+
+ public string CategoryName { get; set; } = string.Empty;
+
+ public string? CategoryPhotoUrl { get; set; }
+
+ public bool State { get; set; }
+
+ public int OrderNum { get; set; }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs
index c4c7d45..f58cd55 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs
@@ -14,7 +14,7 @@ public class FlProductDbEntity
public string ProductName { get; set; } = string.Empty;
- public string? CategoryName { get; set; }
+ public string? CategoryId { get; set; }
public string? ProductImageUrl { get; set; }
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
index 63270c0..178285c 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
@@ -40,55 +40,124 @@ public class LabelAppService : ApplicationService, ILabelAppService
var labelTypeId = input.LabelTypeId?.Trim();
var templateCode = input.TemplateCode?.Trim();
- // 先查 label-product 映射(按产品)
- var query = _dbContext.SqlSugarClient.Queryable(
- (lp, l, p, c, t, tpl) =>
- lp.LabelId == l.Id &&
- lp.ProductId == p.Id &&
- l.LabelCategoryId == c.Id &&
- l.LabelTypeId == t.Id &&
- l.TemplateId == tpl.Id)
- .Where((lp, l, p, c, t, tpl) => l.IsDeleted == false)
- .Where((lp, l, p, c, t, tpl) => !c.IsDeleted)
- .Where((lp, l, p, c, t, tpl) => !t.IsDeleted)
- .Where((lp, l, p, c, t, tpl) => !tpl.IsDeleted)
- .Where((lp, l, p, c, t, tpl) => !p.IsDeleted)
- .WhereIF(!string.IsNullOrWhiteSpace(productId), (lp, l, p, c, t, tpl) => lp.ProductId == productId)
- .WhereIF(!string.IsNullOrWhiteSpace(locationId), (lp, l, p, c, t, tpl) => l.LocationId == locationId)
- .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), (lp, l, p, c, t, tpl) => l.LabelCategoryId == labelCategoryId)
- .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), (lp, l, p, c, t, tpl) => l.LabelTypeId == labelTypeId)
- .WhereIF(!string.IsNullOrWhiteSpace(templateCode), (lp, l, p, c, t, tpl) => tpl.TemplateCode == templateCode)
- .WhereIF(!string.IsNullOrWhiteSpace(keyword),
- (lp, l, p, c, t, tpl) =>
+ // 目标:列表每行是“标签”,同一个标签下的 products 以 “,” 拼接展示
+ // 因此需要按 label 维度分页(避免 label-product join 导致重复行与分页错乱)。
+
+ var labelIdsQuery = _dbContext.SqlSugarClient.Queryable()
+ .Where(l => !l.IsDeleted)
+ .WhereIF(!string.IsNullOrWhiteSpace(locationId), l => l.LocationId == locationId)
+ .WhereIF(!string.IsNullOrWhiteSpace(labelCategoryId), l => l.LabelCategoryId == labelCategoryId)
+ .WhereIF(!string.IsNullOrWhiteSpace(labelTypeId), l => l.LabelTypeId == labelTypeId)
+ .WhereIF(input.State != null, l => l.State == input.State);
+
+ if (!string.IsNullOrWhiteSpace(templateCode))
+ {
+ labelIdsQuery = labelIdsQuery
+ .InnerJoin((l, tpl) => l.TemplateId == tpl.Id)
+ .Where((l, tpl) => !tpl.IsDeleted && tpl.TemplateCode == templateCode)
+ .Select((l, tpl) => l);
+ }
+
+ // 按产品筛选:存在 label-product 关联即可
+ if (!string.IsNullOrWhiteSpace(productId))
+ {
+ labelIdsQuery = labelIdsQuery
+ .InnerJoin((l, lp) => lp.LabelId == l.Id)
+ .Where((l, lp) => lp.ProductId == productId)
+ .Select((l, lp) => l);
+ }
+
+ // 关键字:匹配 labelName/categoryName/typeName/templateName/productName
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ labelIdsQuery = labelIdsQuery
+ .LeftJoin((l, c) => l.LabelCategoryId == c.Id)
+ .LeftJoin((l, c, t) => l.LabelTypeId == t.Id)
+ .LeftJoin((l, c, t, tpl) => l.TemplateId == tpl.Id)
+ .LeftJoin((l, c, t, tpl, lp) => lp.LabelId == l.Id)
+ .LeftJoin((l, c, t, tpl, lp, p) => lp.ProductId == p.Id)
+ .Where((l, c, t, tpl, lp, p) =>
l.LabelName.Contains(keyword!) ||
- p.ProductName.Contains(keyword!) ||
- c.CategoryName.Contains(keyword!) ||
- t.TypeName.Contains(keyword!))
- .WhereIF(input.State != null, (lp, l, p, c, t, tpl) => l.State == input.State)
- .OrderByDescending((lp, l, p, c, t, tpl) => l.LastModificationTime ?? l.CreationTime);
+ (c.CategoryName != null && c.CategoryName.Contains(keyword!)) ||
+ (t.TypeName != null && t.TypeName.Contains(keyword!)) ||
+ (tpl.TemplateName != null && tpl.TemplateName.Contains(keyword!)) ||
+ (p.ProductName != null && p.ProductName.Contains(keyword!)))
+ .Select((l, c, t, tpl, lp, p) => l);
+ }
+ // 排序(优先外部 Sorting,否则按最后编辑倒序)
if (!string.IsNullOrWhiteSpace(input.Sorting))
{
- query = query.OrderBy(input.Sorting);
+ labelIdsQuery = labelIdsQuery.OrderBy(input.Sorting);
+ }
+ else
+ {
+ labelIdsQuery = labelIdsQuery.OrderByDescending(l => l.LastModificationTime ?? l.CreationTime);
}
- var entities = await query.Select((lp, l, p, c, t, tpl) => new
+ var pageLabelIds = await labelIdsQuery
+ .Select(l => l.Id)
+ .Distinct()
+ .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+
+ if (pageLabelIds.Count == 0)
+ {
+ return new PagedResultWithPageDto
{
- LabelCode = l.LabelCode,
- LabelName = l.LabelName,
- LocationId = l.LocationId,
- LocationName = (string?)null, // later fill
+ PageIndex = 1,
+ PageSize = input.MaxResultCount,
+ TotalCount = total,
+ TotalPages = 0,
+ Items = new List()
+ };
+ }
+
+ // 查询标签基础信息(分类/类型/模板)
+ var labelRows = await _dbContext.SqlSugarClient
+ .Queryable(
+ (l, c, t, tpl) => l.LabelCategoryId == c.Id && l.LabelTypeId == t.Id && l.TemplateId == tpl.Id)
+ .Where((l, c, t, tpl) => pageLabelIds.Contains(l.Id))
+ .Where((l, c, t, tpl) => !l.IsDeleted && !c.IsDeleted && !t.IsDeleted && !tpl.IsDeleted)
+ .Select((l, c, t, tpl) => new
+ {
+ l.Id,
+ l.LabelCode,
+ l.LabelName,
+ l.LocationId,
LabelCategoryName = c.CategoryName,
- ProductCategoryName = p.CategoryName,
- ProductName = p.ProductName,
- TemplateName = tpl.TemplateName,
LabelTypeName = t.TypeName,
- State = l.State,
+ TemplateName = tpl.TemplateName,
+ l.State,
LastEdited = l.LastModificationTime ?? l.CreationTime
})
- .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+ .ToListAsync();
+
+ // 按分页顺序输出
+ var labelMap = labelRows.ToDictionary(x => x.Id, x => x);
+ var orderedLabels = pageLabelIds.Where(id => labelMap.ContainsKey(id)).Select(id => labelMap[id]).ToList();
+
+ // 查询 products 并拼接
+ var productRows = await _dbContext.SqlSugarClient
+ .Queryable()
+ .InnerJoin((lp, l) => lp.LabelId == l.Id)
+ .InnerJoin((lp, l, p) => lp.ProductId == p.Id)
+ .LeftJoin((lp, l, p, pc) => p.CategoryId == pc.Id)
+ .Where((lp, l, p, pc) => pageLabelIds.Contains(lp.LabelId))
+ .Where((lp, l, p, pc) => !l.IsDeleted && !p.IsDeleted)
+ .Select((lp, l, p, pc) => new { lp.LabelId, p.ProductName, ProductCategoryName = pc.CategoryName })
+ .ToListAsync();
- var locationIds = entities
+ var productsMap = productRows
+ .GroupBy(x => x.LabelId)
+ .ToDictionary(
+ g => g.Key,
+ g => new
+ {
+ Products = string.Join(",", g.Select(x => x.ProductName ?? string.Empty).Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct()),
+ ProductCategoryName = string.Join(",", g.Select(x => x.ProductCategoryName ?? string.Empty).Select(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct())
+ });
+
+ var locationIds = orderedLabels
.Select(x => x.LocationId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x!.Trim())
@@ -109,21 +178,23 @@ public class LabelAppService : ApplicationService, ILabelAppService
}
}
- var items = entities.Select(x =>
+ var items = orderedLabels.Select(x =>
{
var locationName = string.Empty;
if (!string.IsNullOrWhiteSpace(x.LocationId) && locationMap.TryGetValue(x.LocationId!, out var loc))
{
locationName = loc.LocationName ?? loc.LocationCode;
}
+ var products = productsMap.TryGetValue(x.Id, out var prod) ? prod.Products : string.Empty;
+ var productCategoryNameValue = productsMap.TryGetValue(x.Id, out var prod2) ? prod2.ProductCategoryName : string.Empty;
return new LabelGetListOutputDto
{
Id = x.LabelCode ?? string.Empty,
LabelName = x.LabelName ?? string.Empty,
LocationName = string.IsNullOrWhiteSpace(locationName) ? "无" : locationName,
LabelCategoryName = x.LabelCategoryName ?? string.Empty,
- ProductCategoryName = x.ProductCategoryName ?? string.Empty,
- ProductName = x.ProductName ?? string.Empty,
+ ProductCategoryName = string.IsNullOrWhiteSpace(productCategoryNameValue) ? "无" : productCategoryNameValue,
+ Products = products,
TemplateName = x.TemplateName ?? string.Empty,
LabelTypeName = x.LabelTypeName ?? string.Empty,
State = x.State,
@@ -406,6 +477,8 @@ public class LabelAppService : ApplicationService, ILabelAppService
throw new UserFriendlyException("labelCode不能为空");
}
+ var baseTime = input?.BaseTime ?? DateTime.Now;
+
var label = await _dbContext.SqlSugarClient.Queryable()
.FirstAsync(x => !x.IsDeleted && x.LabelCode == labelCode);
if (label is null)
@@ -572,14 +645,14 @@ public class LabelAppService : ApplicationService, ILabelAppService
case "DATE":
{
var offsetDays = cfg.TryGetValue("offsetDays", out var od) ? TryGetInt(od, 0) : 0;
- var dt = DateTime.Today.AddDays(offsetDays);
+ var dt = baseTime.Date.AddDays(offsetDays);
UpsertConfigValue(cfg, "format", dt.ToString("yyyy-MM-dd"));
cfg.Remove("inputType");
}
break;
case "TIME":
{
- var dt = DateTime.Now;
+ var dt = baseTime;
UpsertConfigValue(cfg, "format", dt.ToString("HH:mm"));
cfg.Remove("inputType");
}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PictureAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PictureAppService.cs
new file mode 100644
index 0000000..379a233
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/PictureAppService.cs
@@ -0,0 +1,114 @@
+using FoodLabeling.Application.Contracts.Dtos.Picture;
+using FoodLabeling.Application.Contracts.IServices;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Hosting;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Guids;
+
+namespace FoodLabeling.Application.Services;
+
+public class PictureAppService : ApplicationService, IPictureAppService
+{
+ private const long MaxSizeBytes = 5 * 1024 * 1024;
+ private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".jpg",
+ ".jpeg",
+ ".png",
+ ".webp",
+ ".gif"
+ };
+
+ private readonly IGuidGenerator _guidGenerator;
+ private readonly IHostEnvironment _hostEnvironment;
+
+ public PictureAppService(IGuidGenerator guidGenerator, IHostEnvironment hostEnvironment)
+ {
+ _guidGenerator = guidGenerator;
+ _hostEnvironment = hostEnvironment;
+ }
+
+ ///
+ /// 上传类别图片(保存到 /www/wwwroot/FoodLabelingManagementUs/picture)
+ ///
+ /// 返回的 Url 可直接保存到 CategoryPhotoUrl。
+ [HttpPost]
+ [Consumes("multipart/form-data")]
+ [Route("/api/app/picture/category/upload")]
+ public async Task UploadCategoryAsync([FromForm] PictureUploadInputVo input)
+ {
+ if (input.File is null || input.File.Length <= 0)
+ {
+ throw new UserFriendlyException("请选择要上传的图片文件");
+ }
+
+ if (input.File.Length > MaxSizeBytes)
+ {
+ throw new UserFriendlyException("图片大小不能超过5MB");
+ }
+
+ var ext = Path.GetExtension(input.File.FileName ?? string.Empty);
+ if (string.IsNullOrWhiteSpace(ext) || !AllowedExtensions.Contains(ext))
+ {
+ throw new UserFriendlyException("仅支持上传 jpg/jpeg/png/webp/gif 格式图片");
+ }
+
+ var subDir = NormalizeSubDir(input.SubDir);
+ var saveRoot = ResolvePictureRoot();
+ var saveDir = string.IsNullOrWhiteSpace(subDir) ? saveRoot : Path.Combine(saveRoot, subDir);
+ Directory.CreateDirectory(saveDir);
+
+ var fileName = $"{DateTime.Now:yyyyMMddHHmmss}_{_guidGenerator.Create():N}{ext.ToLowerInvariant()}";
+ var savePath = Path.Combine(saveDir, fileName);
+
+ await using (var stream = new FileStream(savePath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+ {
+ await input.File.CopyToAsync(stream);
+ }
+
+ var url = BuildPictureUrl(subDir, fileName);
+ return new PictureUploadOutputDto
+ {
+ Url = url,
+ FileName = fileName,
+ Size = input.File.Length
+ };
+ }
+
+ private string ResolvePictureRoot()
+ {
+ var linuxPictureRoot = "/www/wwwroot/FoodLabelingManagementUs/picture";
+ var webRootPicture = Path.Combine(_hostEnvironment.ContentRootPath, "wwwroot", "FoodLabelingManagementUs", "picture");
+ return Directory.Exists(linuxPictureRoot) ? linuxPictureRoot : webRootPicture;
+ }
+
+ private static string NormalizeSubDir(string? subDir)
+ {
+ if (string.IsNullOrWhiteSpace(subDir))
+ {
+ return string.Empty;
+ }
+
+ var s = subDir.Trim().Replace('\\', '/');
+ while (s.StartsWith('/'))
+ {
+ s = s[1..];
+ }
+
+ if (s.Contains("..", StringComparison.Ordinal))
+ {
+ throw new UserFriendlyException("subDir 不能包含 ..");
+ }
+
+ return s;
+ }
+
+ private static string BuildPictureUrl(string? subDir, string fileName)
+ {
+ var s = string.IsNullOrWhiteSpace(subDir) ? string.Empty : $"/{subDir.Trim().Replace('\\', '/').Trim('/')}";
+ return $"/picture{s}/{fileName}";
+ }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
index 17474d5..0bd17df 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
@@ -31,14 +31,22 @@ public class ProductAppService : ApplicationService, IProductAppService
RefAsync total = 0;
var keyword = input.Keyword?.Trim();
- var query = _dbContext.SqlSugarClient.Queryable()
+ var query = _dbContext.SqlSugarClient
+ .Queryable()
.Where(x => !x.IsDeleted)
- .WhereIF(!string.IsNullOrWhiteSpace(keyword), x =>
- x.ProductCode.Contains(keyword!) ||
- x.ProductName.Contains(keyword!) ||
- (x.CategoryName != null && x.CategoryName.Contains(keyword!)))
.WhereIF(input.State != null, x => x.State == input.State);
+ if (!string.IsNullOrWhiteSpace(keyword))
+ {
+ query = query
+ .LeftJoin((p, c) => p.CategoryId == c.Id)
+ .Where((p, c) =>
+ p.ProductCode.Contains(keyword!) ||
+ p.ProductName.Contains(keyword!) ||
+ (c.CategoryName != null && c.CategoryName.Contains(keyword!)))
+ .Select((p, c) => p);
+ }
+
if (!string.IsNullOrWhiteSpace(input.Sorting))
{
query = query.OrderBy(input.Sorting);
@@ -65,15 +73,41 @@ public class ProductAppService : ApplicationService, IProductAppService
countMap = countRows.ToDictionary(x => x.ProductId, x => (long)x.Count);
}
- var items = entities.Select(x => new ProductGetListOutputDto
+ var categoryIds = entities
+ .Select(x => x.CategoryId)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!.Trim())
+ .Distinct()
+ .ToList();
+
+ var categoryMap = new Dictionary();
+ if (categoryIds.Count > 0)
{
- Id = x.Id,
- ProductCode = x.ProductCode,
- ProductName = x.ProductName,
- CategoryName = x.CategoryName,
- ProductImageUrl = x.ProductImageUrl,
- State = x.State,
- NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0
+ var categories = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted && categoryIds.Contains(x.Id))
+ .ToListAsync();
+ categoryMap = categories.ToDictionary(x => x.Id, x => x);
+ }
+
+ var items = entities.Select(x =>
+ {
+ var categoryName = "无";
+ if (!string.IsNullOrWhiteSpace(x.CategoryId) && categoryMap.TryGetValue(x.CategoryId.Trim(), out var c))
+ {
+ categoryName = string.IsNullOrWhiteSpace(c.CategoryName) ? "无" : c.CategoryName.Trim();
+ }
+
+ return new ProductGetListOutputDto
+ {
+ Id = x.Id,
+ ProductCode = x.ProductCode,
+ ProductName = x.ProductName,
+ CategoryId = x.CategoryId,
+ CategoryName = categoryName,
+ ProductImageUrl = x.ProductImageUrl,
+ State = x.State,
+ NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0
+ };
}).ToList();
return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
@@ -95,6 +129,17 @@ public class ProductAppService : ApplicationService, IProductAppService
throw new UserFriendlyException("产品不存在");
}
+ string? categoryName = "无";
+ if (!string.IsNullOrWhiteSpace(entity.CategoryId))
+ {
+ var c = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.Id == entity.CategoryId);
+ if (c is not null && !string.IsNullOrWhiteSpace(c.CategoryName))
+ {
+ categoryName = c.CategoryName.Trim();
+ }
+ }
+
var locationIds = await _dbContext.SqlSugarClient.Queryable()
.Where(x => x.ProductId == productId)
.Select(x => x.LocationId)
@@ -106,7 +151,8 @@ public class ProductAppService : ApplicationService, IProductAppService
Id = entity.Id,
ProductCode = entity.ProductCode,
ProductName = entity.ProductName,
- CategoryName = entity.CategoryName,
+ CategoryId = entity.CategoryId,
+ CategoryName = categoryName,
ProductImageUrl = entity.ProductImageUrl,
State = entity.State,
LocationIds = locationIds
@@ -136,7 +182,7 @@ public class ProductAppService : ApplicationService, IProductAppService
IsDeleted = false,
ProductCode = code,
ProductName = name,
- CategoryName = input.CategoryName?.Trim(),
+ CategoryId = input.CategoryId?.Trim(),
ProductImageUrl = input.ProductImageUrl?.Trim(),
State = input.State
};
@@ -177,7 +223,7 @@ public class ProductAppService : ApplicationService, IProductAppService
entity.ProductCode = code;
entity.ProductName = name;
- entity.CategoryName = input.CategoryName?.Trim();
+ entity.CategoryId = input.CategoryId?.Trim();
entity.ProductImageUrl = input.ProductImageUrl?.Trim();
entity.State = input.State;
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs
new file mode 100644
index 0000000..3a6fdf6
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductCategoryAppService.cs
@@ -0,0 +1,237 @@
+using FoodLabeling.Application.Contracts.Dtos.Common;
+using FoodLabeling.Application.Contracts.Dtos.ProductCategory;
+using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Services.DbModels;
+using SqlSugar;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Guids;
+using Yi.Framework.SqlSugarCore.Abstractions;
+
+namespace FoodLabeling.Application.Services;
+
+///
+/// 产品模块:类别(Categories)服务,对外仅在 food-labeling-us 暴露
+///
+public class ProductCategoryAppService : ApplicationService, IProductCategoryAppService
+{
+ private readonly ISqlSugarDbContext _dbContext;
+ private readonly IGuidGenerator _guidGenerator;
+
+ public ProductCategoryAppService(ISqlSugarDbContext dbContext, IGuidGenerator guidGenerator)
+ {
+ _dbContext = dbContext;
+ _guidGenerator = guidGenerator;
+ }
+
+ ///
+ /// 类别分页列表
+ ///
+ public async Task> GetListAsync(ProductCategoryGetListInputVo input)
+ {
+ RefAsync total = 0;
+ var keyword = input.Keyword?.Trim();
+
+ var query = _dbContext.SqlSugarClient.Queryable()
+ .Where(x => !x.IsDeleted)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword),
+ x => x.CategoryCode.Contains(keyword!) || x.CategoryName.Contains(keyword!))
+ .WhereIF(input.State != null, x => x.State == input.State);
+
+ // Sorting 仅允许白名单字段,避免不同数据库列命名导致 Unknown column
+ // 同时避免将 input.Sorting 原样拼接到 SQL(存在注入风险)
+ if (!string.IsNullOrWhiteSpace(input.Sorting))
+ {
+ var sorting = input.Sorting.Trim();
+ if (sorting.Equals("OrderNum desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending(x => x.OrderNum);
+ }
+ else if (sorting.Equals("OrderNum asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy(x => x.OrderNum);
+ }
+ else if (sorting.Equals("CreationTime desc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderByDescending(x => x.CreationTime);
+ }
+ else if (sorting.Equals("CreationTime asc", StringComparison.OrdinalIgnoreCase))
+ {
+ query = query.OrderBy(x => x.CreationTime);
+ }
+ else
+ {
+ // 不识别的排序统一走默认
+ query = query.OrderByDescending(x => x.OrderNum).OrderByDescending(x => x.CreationTime);
+ }
+ }
+ else
+ {
+ query = query.OrderByDescending(x => x.OrderNum).OrderByDescending(x => x.CreationTime);
+ }
+
+ var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
+
+ var items = entities.Select(x => new ProductCategoryGetListOutputDto
+ {
+ Id = x.Id,
+ CategoryCode = x.CategoryCode,
+ CategoryName = x.CategoryName,
+ CategoryPhotoUrl = x.CategoryPhotoUrl,
+ State = x.State,
+ OrderNum = x.OrderNum,
+ LastEdited = x.LastModificationTime ?? x.CreationTime
+ }).ToList();
+
+ return BuildPagedResult(input.SkipCount, input.MaxResultCount, total, items);
+ }
+
+ ///
+ /// 类别详情
+ ///
+ public async Task GetAsync(string id)
+ {
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => x.Id == id && !x.IsDeleted);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("类别不存在");
+ }
+
+ return MapToGetOutput(entity);
+ }
+
+ ///
+ /// 新增类别
+ ///
+ public async Task CreateAsync(ProductCategoryCreateInputVo input)
+ {
+ var code = input.CategoryCode?.Trim();
+ var name = input.CategoryName?.Trim();
+ if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("类别编码和名称不能为空");
+ }
+
+ var duplicated = await _dbContext.SqlSugarClient.Queryable()
+ .AnyAsync(x => !x.IsDeleted && (x.CategoryCode == code || x.CategoryName == name));
+ if (duplicated)
+ {
+ throw new UserFriendlyException("类别编码或名称已存在");
+ }
+
+ var now = DateTime.Now;
+ var currentUserId = CurrentUser?.Id?.ToString();
+ var entity = new FlProductCategoryDbEntity
+ {
+ Id = _guidGenerator.Create().ToString(),
+ IsDeleted = false,
+ CreationTime = now,
+ CreatorId = currentUserId,
+ LastModifierId = currentUserId,
+ LastModificationTime = now,
+ ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
+ CategoryCode = code,
+ CategoryName = name,
+ CategoryPhotoUrl = input.CategoryPhotoUrl?.Trim(),
+ State = input.State,
+ OrderNum = input.OrderNum
+ };
+
+ await _dbContext.SqlSugarClient.Insertable(entity).ExecuteCommandAsync();
+ return await GetAsync(entity.Id);
+ }
+
+ ///
+ /// 编辑类别
+ ///
+ public async Task UpdateAsync(string id, ProductCategoryUpdateInputVo input)
+ {
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => x.Id == id && !x.IsDeleted);
+ if (entity is null)
+ {
+ throw new UserFriendlyException("类别不存在");
+ }
+
+ var code = input.CategoryCode?.Trim();
+ var name = input.CategoryName?.Trim();
+ if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name))
+ {
+ throw new UserFriendlyException("类别编码和名称不能为空");
+ }
+
+ var duplicated = await _dbContext.SqlSugarClient.Queryable()
+ .AnyAsync(x => !x.IsDeleted && x.Id != id && (x.CategoryCode == code || x.CategoryName == name));
+ if (duplicated)
+ {
+ throw new UserFriendlyException("类别编码或名称已存在");
+ }
+
+ entity.CategoryCode = code;
+ entity.CategoryName = name;
+ entity.CategoryPhotoUrl = input.CategoryPhotoUrl?.Trim();
+ entity.State = input.State;
+ entity.OrderNum = input.OrderNum;
+ entity.LastModificationTime = DateTime.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ return await GetAsync(id);
+ }
+
+ ///
+ /// 删除类别(逻辑删除)
+ ///
+ public async Task DeleteAsync(string id)
+ {
+ var entity = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => x.Id == id && !x.IsDeleted);
+ if (entity is null)
+ {
+ return;
+ }
+
+ // 若被产品引用则不允许删除
+ var usedByProduct = await _dbContext.SqlSugarClient.Queryable()
+ .AnyAsync(x => !x.IsDeleted && x.CategoryId == id);
+ if (usedByProduct)
+ {
+ throw new UserFriendlyException("该类别已被产品引用,无法删除");
+ }
+
+ entity.IsDeleted = true;
+ entity.LastModificationTime = DateTime.Now;
+ entity.LastModifierId = CurrentUser?.Id?.ToString();
+ await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
+ }
+
+ private static ProductCategoryGetOutputDto MapToGetOutput(FlProductCategoryDbEntity x)
+ {
+ return new ProductCategoryGetOutputDto
+ {
+ Id = x.Id,
+ CategoryCode = x.CategoryCode,
+ CategoryName = x.CategoryName,
+ CategoryPhotoUrl = x.CategoryPhotoUrl,
+ State = x.State,
+ OrderNum = x.OrderNum
+ };
+ }
+
+ private static PagedResultWithPageDto BuildPagedResult(int skipCount, int maxResultCount, int total, List items)
+ {
+ var pageSize = maxResultCount <= 0 ? items.Count : maxResultCount;
+ var pageIndex = pageSize <= 0 ? 1 : (skipCount / pageSize) + 1;
+ var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total / (double)pageSize);
+ return new PagedResultWithPageDto
+ {
+ PageIndex = pageIndex,
+ PageSize = pageSize,
+ TotalCount = total,
+ TotalPages = totalPages,
+ Items = items
+ };
+ }
+}
+
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
new file mode 100644
index 0000000..7b0d54d
--- /dev/null
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
@@ -0,0 +1,533 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text.Json;
+using System.Threading.Tasks;
+using FoodLabeling.Application.Contracts.Dtos.Label;
+using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
+using FoodLabeling.Application.Contracts.IServices;
+using FoodLabeling.Application.Services.DbModels;
+using Microsoft.AspNetCore.Authorization;
+using SqlSugar;
+using Volo.Abp;
+using Volo.Abp.Application.Services;
+using Volo.Abp.Guids;
+using Volo.Abp.Uow;
+using Yi.Framework.SqlSugarCore.Abstractions;
+
+namespace FoodLabeling.Application.Services;
+
+///
+/// App Labeling:四级列表(标签分类 → 产品分类 → 产品 → 标签种类)
+///
+public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppService
+{
+ private readonly ISqlSugarDbContext _dbContext;
+ private readonly ILabelAppService _labelAppService;
+ private readonly IGuidGenerator _guidGenerator;
+
+ public UsAppLabelingAppService(ISqlSugarDbContext dbContext, ILabelAppService labelAppService, IGuidGenerator guidGenerator)
+ {
+ _dbContext = dbContext;
+ _labelAppService = labelAppService;
+ _guidGenerator = guidGenerator;
+ }
+
+ ///
+ /// 获取当前门店下四级嵌套数据
+ ///
+ ///
+ /// L1 标签分类 fl_label_category;L2 产品分类 fl_product.CategoryId join fl_product_category;
+ /// L3 产品;L4 与该门店、该标签分类、该产品关联的标签实例(fl_label + fl_label_type)。
+ ///
+ [Authorize]
+ public virtual async Task> GetLabelingTreeAsync(UsAppLabelingTreeInputVo input)
+ {
+ if (string.IsNullOrWhiteSpace(input.LocationId))
+ {
+ throw new UserFriendlyException("门店Id不能为空");
+ }
+
+ var locationId = input.LocationId.Trim();
+ var keyword = input.Keyword?.Trim();
+ var filterCategoryId = input.LabelCategoryId?.Trim();
+
+ var productIds = await _dbContext.SqlSugarClient.Queryable()
+ .Where(x => x.LocationId == locationId)
+ .Select(x => x.ProductId)
+ .ToListAsync();
+
+ if (productIds.Count == 0)
+ {
+ return new List();
+ }
+
+ var query = BuildLabelingJoinQuery(locationId, productIds, filterCategoryId, keyword);
+
+ var raw = await query
+ .Select((lp, l, p, c, t, tpl, pc) => new LabelingTreeRow
+ {
+ LabelCategoryId = c.Id,
+ LabelCategoryName = c.CategoryName,
+ LabelCategoryPhotoUrl = c.CategoryPhotoUrl,
+ LabelCategoryOrderNum = c.OrderNum,
+ ProductCategoryId = p.CategoryId,
+ ProductCategoryName = pc.CategoryName,
+ ProductCategoryPhotoUrl = pc.CategoryPhotoUrl,
+ ProductId = p.Id,
+ ProductName = p.ProductName,
+ ProductCode = p.ProductCode,
+ ProductImageUrl = p.ProductImageUrl,
+ LabelTypeId = t.Id,
+ TypeName = t.TypeName,
+ TypeOrderNum = t.OrderNum,
+ LabelCode = l.LabelCode,
+ TemplateCode = tpl.TemplateCode,
+ TemplateWidth = tpl.Width,
+ TemplateHeight = tpl.Height,
+ TemplateUnit = tpl.Unit
+ })
+ .ToListAsync();
+
+ if (raw.Count == 0)
+ {
+ return new List();
+ }
+
+ var byL1 = raw.GroupBy(x => new
+ {
+ x.LabelCategoryId,
+ x.LabelCategoryName,
+ x.LabelCategoryPhotoUrl,
+ x.LabelCategoryOrderNum
+ }).OrderBy(g => g.Key.LabelCategoryOrderNum).ThenBy(g => g.Key.LabelCategoryName);
+
+ var result = new List();
+ foreach (var g1 in byL1)
+ {
+ var l1 = new UsAppLabelCategoryTreeNodeDto
+ {
+ Id = g1.Key.LabelCategoryId,
+ CategoryName = g1.Key.LabelCategoryName ?? string.Empty,
+ CategoryPhotoUrl = g1.Key.LabelCategoryPhotoUrl,
+ OrderNum = g1.Key.LabelCategoryOrderNum,
+ ProductCategories = new List()
+ };
+
+ var byL2 = g1.GroupBy(x =>
+ {
+ var categoryId = NormalizeNullableId(x.ProductCategoryId);
+ if (categoryId is null)
+ {
+ return new
+ {
+ CategoryId = (string?)null,
+ CategoryName = "无",
+ CategoryPhotoUrl = (string?)null
+ };
+ }
+
+ var categoryName = NormalizeCategoryName(x.ProductCategoryName);
+ var categoryPhotoUrl = NormalizeNullableUrl(x.ProductCategoryPhotoUrl);
+ return new
+ {
+ CategoryId = (string?)categoryId,
+ CategoryName = categoryName,
+ CategoryPhotoUrl = categoryPhotoUrl
+ };
+ })
+ .OrderBy(g => g.Key.CategoryName);
+
+ foreach (var g2 in byL2)
+ {
+ var productsGrouped = g2.GroupBy(x => x.ProductId).OrderBy(pg => pg.First().ProductName);
+ var l2 = new UsAppProductCategoryNodeDto
+ {
+ CategoryId = g2.Key.CategoryId,
+ CategoryPhotoUrl = g2.Key.CategoryPhotoUrl,
+ Name = g2.Key.CategoryName,
+ ItemCount = productsGrouped.Count(),
+ Products = new List()
+ };
+
+ foreach (var g3 in productsGrouped)
+ {
+ var first = g3.First();
+ var typeNodes = g3
+ .GroupBy(r => r.LabelCode)
+ .Select(gr => BuildLabelTypeNode(gr.First()))
+ .OrderBy(t => t.OrderNum)
+ .ThenBy(t => t.TypeName)
+ .ToList();
+
+ var subtitle = string.IsNullOrWhiteSpace(first.ProductCode?.Trim())
+ ? "无"
+ : first.ProductCode!.Trim();
+
+ l2.Products.Add(new UsAppLabelingProductNodeDto
+ {
+ ProductId = first.ProductId,
+ ProductName = first.ProductName ?? string.Empty,
+ ProductCode = first.ProductCode ?? string.Empty,
+ ProductImageUrl = first.ProductImageUrl,
+ Subtitle = subtitle,
+ LabelTypeCount = typeNodes.Count,
+ LabelTypes = typeNodes
+ });
+ }
+
+ l1.ProductCategories.Add(l2);
+ }
+
+ result.Add(l1);
+ }
+
+ return result;
+ }
+
+ ///
+ /// App 打印预览:按标签编码解析模板并返回顶部展示字段 + 预览模板结构
+ ///
+ ///
+ /// 示例请求:
+ /// ```json
+ /// {
+ /// "locationId": "LOC001",
+ /// "labelCode": "LBL0001",
+ /// "productId": "PROD001",
+ /// "baseTime": "2026-03-26T10:30:00",
+ /// "printInputJson": {
+ /// "price": "12.99"
+ /// }
+ /// }
+ /// ```
+ ///
+ /// 预览入参
+ /// 顶部字段 + 预览模板结构
+ /// 成功
+ /// 参数错误/数据不存在
+ /// 服务器错误
+ [Authorize]
+ public virtual async Task PreviewAsync(UsAppLabelPreviewInputVo input)
+ {
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ var locationId = input.LocationId?.Trim();
+ if (string.IsNullOrWhiteSpace(locationId))
+ {
+ throw new UserFriendlyException("门店Id不能为空");
+ }
+
+ var labelCode = input.LabelCode?.Trim();
+ if (string.IsNullOrWhiteSpace(labelCode))
+ {
+ throw new UserFriendlyException("labelCode不能为空");
+ }
+
+ var labelRow = await _dbContext.SqlSugarClient
+ .Queryable(
+ (l, c, t, tpl) => l.LabelCategoryId == c.Id && l.LabelTypeId == t.Id && l.TemplateId == tpl.Id)
+ .Where((l, c, t, tpl) => !l.IsDeleted && l.State)
+ .Where((l, c, t, tpl) => !c.IsDeleted && c.State)
+ .Where((l, c, t, tpl) => !t.IsDeleted && t.State)
+ .Where((l, c, t, tpl) => !tpl.IsDeleted)
+ .Where((l, c, t, tpl) => l.LabelCode == labelCode)
+ .Select((l, c, t, tpl) => new
+ {
+ l.LabelCode,
+ l.LocationId,
+ LabelCategoryName = c.CategoryName,
+ TypeName = t.TypeName,
+ TemplateCode = tpl.TemplateCode,
+ TemplateWidth = tpl.Width,
+ TemplateHeight = tpl.Height,
+ TemplateUnit = tpl.Unit
+ })
+ .FirstAsync();
+
+ if (labelRow is null)
+ {
+ throw new UserFriendlyException("标签不存在或不可用");
+ }
+
+ if (!string.Equals(labelRow.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new UserFriendlyException("该标签不属于当前门店");
+ }
+
+ var template = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
+ {
+ LabelCode = labelCode,
+ ProductId = input.ProductId?.Trim(),
+ BaseTime = input.BaseTime,
+ PrintInputJson = input.PrintInputJson?.ToDictionary(x => x.Key, x => (object?)x.Value)
+ });
+
+ var productName = string.Empty;
+ var productCategoryName = "无";
+ if (!string.IsNullOrWhiteSpace(input.ProductId))
+ {
+ var pid = input.ProductId.Trim();
+ var p = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.State && x.Id == pid);
+ if (p is not null)
+ {
+ productName = p.ProductName ?? string.Empty;
+ if (!string.IsNullOrWhiteSpace(p.CategoryId))
+ {
+ var pc = await _dbContext.SqlSugarClient.Queryable()
+ .FirstAsync(x => !x.IsDeleted && x.State && x.Id == p.CategoryId);
+ productCategoryName = NormalizeCategoryName(pc?.CategoryName);
+ }
+ }
+ }
+
+ return new UsAppLabelPreviewDto
+ {
+ LocationId = locationId,
+ LabelCode = labelCode,
+ TemplateCode = labelRow.TemplateCode,
+ LabelSizeText = FormatLabelSize(labelRow.TemplateWidth, labelRow.TemplateHeight, labelRow.TemplateUnit),
+ TypeName = labelRow.TypeName,
+ ProductName = string.IsNullOrWhiteSpace(productName) ? null : productName,
+ ProductCategoryName = productCategoryName,
+ LabelCategoryName = labelRow.LabelCategoryName,
+ PreviewImageBase64Png = null,
+ Template = template
+ };
+ }
+
+ ///
+ /// App 打印:创建打印任务并落库打印明细(fl_label_print_task / fl_label_print_data)
+ ///
+ /// 打印入参
+ /// 任务Id
+ [Authorize]
+ [UnitOfWork]
+ public virtual async Task PrintAsync(UsAppLabelPrintInputVo input)
+ {
+ if (input is null)
+ {
+ throw new UserFriendlyException("入参不能为空");
+ }
+
+ var locationId = input.LocationId?.Trim();
+ if (string.IsNullOrWhiteSpace(locationId))
+ {
+ throw new UserFriendlyException("门店Id不能为空");
+ }
+
+ var labelCode = input.LabelCode?.Trim();
+ if (string.IsNullOrWhiteSpace(labelCode))
+ {
+ throw new UserFriendlyException("labelCode不能为空");
+ }
+
+ var quantity = input.PrintQuantity <= 0 ? 1 : input.PrintQuantity;
+
+ // 校验 label + location,并补齐一些顶部字段用于任务表落库
+ var labelRow = await _dbContext.SqlSugarClient
+ .Queryable(
+ (l, t, tpl) => l.LabelTypeId == t.Id && l.TemplateId == tpl.Id)
+ .Where((l, t, tpl) => !l.IsDeleted && l.State)
+ .Where((l, t, tpl) => !t.IsDeleted && t.State)
+ .Where((l, t, tpl) => !tpl.IsDeleted)
+ .Where((l, t, tpl) => l.LabelCode == labelCode)
+ .Select((l, t, tpl) => new
+ {
+ l.LocationId,
+ l.LabelTypeId,
+ TemplateCode = tpl.TemplateCode
+ })
+ .FirstAsync();
+
+ if (labelRow is null)
+ {
+ throw new UserFriendlyException("标签不存在或不可用");
+ }
+
+ if (!string.Equals(labelRow.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new UserFriendlyException("该标签不属于当前门店");
+ }
+
+ // 解析模板 elements(与预览一致的渲染数据)
+ var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
+ {
+ LabelCode = labelCode,
+ ProductId = input.ProductId?.Trim(),
+ BaseTime = input.BaseTime,
+ PrintInputJson = input.PrintInputJson
+ });
+
+ var printInputJsonStr = input.PrintInputJson is null
+ ? null
+ : JsonSerializer.Serialize(input.PrintInputJson);
+ var renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate);
+
+ var now = DateTime.Now;
+ var currentUserId = CurrentUser?.Id?.ToString();
+ var taskId = _guidGenerator.Create().ToString();
+
+ var task = new FlLabelPrintTaskDbEntity
+ {
+ Id = taskId,
+ IsDeleted = false,
+ CreationTime = now,
+ CreatorId = currentUserId,
+ ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
+ LocationId = locationId,
+ LabelCode = labelCode,
+ ProductId = input.ProductId?.Trim(),
+ LabelTypeId = labelRow.LabelTypeId,
+ TemplateCode = labelRow.TemplateCode,
+ PrintQuantity = quantity,
+ BaseTime = input.BaseTime,
+ PrinterId = input.PrinterId?.Trim(),
+ PrinterMac = input.PrinterMac?.Trim(),
+ PrinterAddress = input.PrinterAddress?.Trim()
+ };
+
+ await _dbContext.SqlSugarClient.Insertable(task).ExecuteCommandAsync();
+
+ var dataRows = Enumerable.Range(1, quantity).Select(i => new FlLabelPrintDataDbEntity
+ {
+ Id = _guidGenerator.Create().ToString(),
+ IsDeleted = false,
+ CreationTime = now,
+ CreatorId = currentUserId,
+ ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
+ TaskId = taskId,
+ CopyIndex = i,
+ PrintInputJson = printInputJsonStr,
+ RenderDataJson = renderDataJsonStr
+ }).ToList();
+
+ await _dbContext.SqlSugarClient.Insertable(dataRows).ExecuteCommandAsync();
+
+ return new UsAppLabelPrintOutputDto
+ {
+ TaskId = taskId,
+ PrintQuantity = quantity
+ };
+ }
+
+ private ISugarQueryable BuildLabelingJoinQuery(
+ string locationId,
+ List productIds,
+ string? filterCategoryId,
+ string? keyword)
+ {
+ var q = _dbContext.SqlSugarClient
+ .Queryable()
+ .InnerJoin((lp, l) => lp.LabelId == l.Id)
+ .InnerJoin((lp, l, p) => lp.ProductId == p.Id)
+ .InnerJoin((lp, l, p, c) => l.LabelCategoryId == c.Id)
+ .InnerJoin((lp, l, p, c, t) => l.LabelTypeId == t.Id)
+ .InnerJoin((lp, l, p, c, t, tpl) => l.TemplateId == tpl.Id)
+ .LeftJoin((lp, l, p, c, t, tpl, pc) => p.CategoryId == pc.Id)
+ .Where((lp, l, p, c, t, tpl, pc) => productIds.Contains(p.Id))
+ .Where((lp, l, p, c, t, tpl, pc) => l.LocationId == locationId)
+ .Where((lp, l, p, c, t, tpl, pc) => !l.IsDeleted && l.State)
+ .Where((lp, l, p, c, t, tpl, pc) => !p.IsDeleted && p.State)
+ .Where((lp, l, p, c, t, tpl, pc) => !c.IsDeleted && c.State)
+ .Where((lp, l, p, c, t, tpl, pc) => !t.IsDeleted && t.State)
+ .Where((lp, l, p, c, t, tpl, pc) => !tpl.IsDeleted)
+ .WhereIF(!string.IsNullOrWhiteSpace(filterCategoryId), (lp, l, p, c, t, tpl, pc) => l.LabelCategoryId == filterCategoryId)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), (lp, l, p, c, t, tpl, pc) =>
+ (l.LabelName != null && l.LabelName.Contains(keyword!)) ||
+ (p.ProductName != null && p.ProductName.Contains(keyword!)) ||
+ (pc.CategoryName != null && pc.CategoryName.Contains(keyword!)) ||
+ (c.CategoryName != null && c.CategoryName.Contains(keyword!)) ||
+ (t.TypeName != null && t.TypeName.Contains(keyword!)) ||
+ (l.LabelCode != null && l.LabelCode.Contains(keyword!)));
+
+ return q;
+ }
+
+ private sealed class LabelingTreeRow
+ {
+ public string LabelCategoryId { get; set; } = string.Empty;
+
+ public string? LabelCategoryName { get; set; }
+
+ public string? LabelCategoryPhotoUrl { get; set; }
+
+ public int LabelCategoryOrderNum { get; set; }
+
+ public string? ProductCategoryId { get; set; }
+
+ public string? ProductCategoryName { get; set; }
+
+ public string? ProductCategoryPhotoUrl { get; set; }
+
+ public string ProductId { get; set; } = string.Empty;
+
+ public string? ProductName { get; set; }
+
+ public string? ProductCode { get; set; }
+
+ public string? ProductImageUrl { get; set; }
+
+ public string LabelTypeId { get; set; } = string.Empty;
+
+ public string? TypeName { get; set; }
+
+ public int TypeOrderNum { get; set; }
+
+ public string LabelCode { get; set; } = string.Empty;
+
+ public string? TemplateCode { get; set; }
+
+ public decimal TemplateWidth { get; set; }
+
+ public decimal TemplateHeight { get; set; }
+
+ public string TemplateUnit { get; set; } = "inch";
+ }
+
+ private static string NormalizeCategoryName(string? categoryName)
+ {
+ var s = categoryName?.Trim();
+ return string.IsNullOrWhiteSpace(s) ? "无" : s;
+ }
+
+ private static string? NormalizeNullableId(string? id)
+ {
+ var s = id?.Trim();
+ return string.IsNullOrWhiteSpace(s) ? null : s;
+ }
+
+ private static string? NormalizeNullableUrl(string? url)
+ {
+ var s = url?.Trim();
+ return string.IsNullOrWhiteSpace(s) ? null : s;
+ }
+
+ private static UsAppLabelTypeNodeDto BuildLabelTypeNode(LabelingTreeRow r)
+ {
+ return new UsAppLabelTypeNodeDto
+ {
+ LabelTypeId = r.LabelTypeId,
+ TypeName = r.TypeName ?? string.Empty,
+ OrderNum = r.TypeOrderNum,
+ LabelCode = r.LabelCode ?? string.Empty,
+ TemplateCode = r.TemplateCode,
+ LabelSizeText = FormatLabelSize(r.TemplateWidth, r.TemplateHeight, r.TemplateUnit)
+ };
+ }
+
+ private static string? FormatLabelSize(decimal w, decimal h, string unit)
+ {
+ var u = (unit ?? "inch").Trim().ToLowerInvariant();
+ var ws = w.ToString(CultureInfo.InvariantCulture);
+ var hs = h.ToString(CultureInfo.InvariantCulture);
+ return u is "inch" or "in"
+ ? $"{ws}\"x{hs}\""
+ : $"{ws}x{hs}{u}";
+ }
+}
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs
index d8ccbaa..d23327c 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web/YiAbpWebModule.cs
@@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
+using Microsoft.Extensions.FileProviders;
using StackExchange.Redis;
using Volo.Abp.AspNetCore.Auditing;
using Volo.Abp.AspNetCore.Authentication.JwtBearer;
@@ -397,6 +398,19 @@ namespace Yi.Abp.Web
});
app.UseDirectoryBrowser("/api/app/wwwroot");
+ // 类别图片静态资源(物理目录 /www/wwwroot/FoodLabelingManagementUs/picture)
+ // - Linux:通常可直接写入与访问
+ // - Windows:如该目录不存在,则使用项目 wwwroot/FoodLabelingManagementUs/picture 作为落盘目录
+ var linuxPictureRoot = "/www/wwwroot/FoodLabelingManagementUs/picture";
+ var webRootPicture = Path.Combine(env.WebRootPath ?? string.Empty, "FoodLabelingManagementUs", "picture");
+ var pictureRoot = Directory.Exists(linuxPictureRoot) ? linuxPictureRoot : webRootPicture;
+ Directory.CreateDirectory(pictureRoot);
+ app.UseStaticFiles(new StaticFileOptions
+ {
+ FileProvider = new PhysicalFileProvider(pictureRoot),
+ RequestPath = "/picture"
+ });
+
app.Properties.Add("_AbpExceptionHandlingMiddleware_Added", false);
//工作单元
app.UseUnitOfWork();
diff --git a/项目相关文档/产品模块Categories接口对接说明.md b/项目相关文档/产品模块Categories接口对接说明.md
new file mode 100644
index 0000000..37ad481
--- /dev/null
+++ b/项目相关文档/产品模块Categories接口对接说明.md
@@ -0,0 +1,204 @@
+# 产品模块 Categories(类别)接口对接说明(美国版)
+
+## 概述
+
+本模块用于平台端(H5)Products → **Categories** 页签的数据对接。
+
+- **模块**:`food-labeling-us`
+- **接口前缀**:宿主统一前缀为 `/api/app`
+- **分类表**:`fl_product_category`
+- **关联字段**:`fl_product.category_id` → `fl_product_category.id`
+- **图片字段**:`CategoryPhotoUrl`(前端字段:`categoryPhotoUrl`)
+
+> 说明:本文以 Swagger 为准(本地示例:`http://localhost:19001/swagger`,搜索 `ProductCategory`)。
+
+---
+
+## 接口 1:类别分页列表
+
+### HTTP
+
+- **方法**:`GET`
+- **路径**:`/api/app/product-category`
+- **鉴权**:需要登录(Header:`Authorization: Bearer {token}`)
+
+### 入参(Query 参数)
+
+| 参数名 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `skipCount` | number | 是 | 跳过条数(分页) |
+| `maxResultCount` | number | 是 | 每页条数(分页) |
+| `sorting` | string | 否 | 排序字段(如 `OrderNum desc`),不传则按 `OrderNum desc, CreationTime desc` |
+| `keyword` | string | 否 | 模糊搜索(匹配 `CategoryCode/CategoryName`) |
+| `state` | boolean | 否 | 启用状态过滤 |
+
+### 请求示例
+
+```http
+GET /api/app/product-category?skipCount=0&maxResultCount=10&keyword=Prep HTTP/1.1
+Host: localhost:19001
+Authorization: Bearer eyJhbGciOi...
+```
+
+### 出参(PagedResultWithPageDto)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `pageIndex` | number | 当前页(从 1 开始) |
+| `pageSize` | number | 每页条数 |
+| `totalCount` | number | 总数 |
+| `totalPages` | number | 总页数 |
+| `items` | array | 当前页数据 |
+
+`items[]` 字段:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | 主键 |
+| `categoryCode` | string | 类别编码 |
+| `categoryName` | string | 类别名称 |
+| `categoryPhotoUrl` | string \| null | 类别图片 URL(建议用 `/picture/...`) |
+| `state` | boolean | 是否启用 |
+| `orderNum` | number | 排序 |
+| `lastEdited` | string | 最后编辑时间 |
+
+### 响应示例
+
+```json
+{
+ "pageIndex": 1,
+ "pageSize": 10,
+ "totalCount": 1,
+ "totalPages": 1,
+ "items": [
+ {
+ "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
+ "categoryCode": "CAT_PREP",
+ "categoryName": "Prep",
+ "categoryPhotoUrl": "/picture/category/20260325123010_xxx.png",
+ "state": true,
+ "orderNum": 100,
+ "lastEdited": "2026-03-25 12:30:10"
+ }
+ ]
+}
+```
+
+---
+
+## 接口 2:类别详情
+
+### HTTP
+
+- **方法**:`GET`
+- **路径**:`/api/app/product-category/{id}`
+
+### 请求示例
+
+```http
+GET /api/app/product-category/a2696b9e-2277-11f1-b4c6-00163e0c7c4f HTTP/1.1
+Host: localhost:19001
+Authorization: Bearer eyJhbGciOi...
+```
+
+### 响应示例(ProductCategoryGetOutputDto)
+
+```json
+{
+ "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",
+ "categoryCode": "CAT_PREP",
+ "categoryName": "Prep",
+ "categoryPhotoUrl": "/picture/category/20260325123010_xxx.png",
+ "state": true,
+ "orderNum": 100
+}
+```
+
+---
+
+## 接口 3:新增类别
+
+### HTTP
+
+- **方法**:`POST`
+- **路径**:`/api/app/product-category`
+- **Content-Type**:`application/json`
+
+### 入参(Body JSON:ProductCategoryCreateInputVo)
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `categoryCode` | string | 是 | 类别编码(唯一) |
+| `categoryName` | string | 是 | 类别名称(唯一) |
+| `categoryPhotoUrl` | string \| null | 否 | 图片 URL(建议先上传图片拿到 `/picture/...` 再保存) |
+| `state` | boolean | 否 | 是否启用(默认 true) |
+| `orderNum` | number | 否 | 排序(默认 0) |
+
+### 请求示例
+
+```json
+{
+ "categoryCode": "CAT_PREP",
+ "categoryName": "Prep",
+ "categoryPhotoUrl": "/picture/category/20260325123010_xxx.png",
+ "state": true,
+ "orderNum": 100
+}
+```
+
+---
+
+## 接口 4:编辑类别
+
+### HTTP
+
+- **方法**:`PUT`
+- **路径**:`/api/app/product-category/{id}`
+- **Content-Type**:`application/json`
+
+### 请求示例
+
+```json
+{
+ "categoryCode": "CAT_PREP",
+ "categoryName": "Prep",
+ "categoryPhotoUrl": "/picture/category/20260325123010_xxx.png",
+ "state": true,
+ "orderNum": 100
+}
+```
+
+---
+
+## 接口 5:删除类别(逻辑删除)
+
+### HTTP
+
+- **方法**:`DELETE`
+- **路径**:`/api/app/product-category/{id}`
+
+### 约束
+
+- 若该类别已被 `fl_label` 引用(`fl_label.LabelCategoryId = id`),删除会失败并返回友好提示:`该类别已被标签引用,无法删除`。
+
+### 请求示例
+
+```http
+DELETE /api/app/product-category/a2696b9e-2277-11f1-b4c6-00163e0c7c4f HTTP/1.1
+Host: localhost:19001
+Authorization: Bearer eyJhbGciOi...
+```
+
+---
+
+## 配套:类别图片上传接口
+
+类别图片上传接口见文档:
+
+- `项目相关文档/平台端Categories图片上传接口说明.md`
+
+推荐前端流程:
+
+1. 调用上传接口 `POST /api/app/picture/category/upload` 拿到响应 `url`
+2. 新增/编辑类别时把 `categoryPhotoUrl` 设为该 `url`
+
diff --git a/项目相关文档/平台端Categories图片上传接口说明.md b/项目相关文档/平台端Categories图片上传接口说明.md
new file mode 100644
index 0000000..edeb7d7
--- /dev/null
+++ b/项目相关文档/平台端Categories图片上传接口说明.md
@@ -0,0 +1,102 @@
+# 平台端 Categories 图片上传接口说明
+
+## 概述
+
+平台端(H5)Products 模块的 **Categories** 页面,类别图片字段为 **`CategoryPhotoUrl`**(后端已在 `fl_label_category` 与分类 CRUD 接口中贯通)。
+
+图片上传由 `food-labeling-us` 模块提供上传接口,文件会保存到服务器目录,并通过静态资源路径 `/picture/...` 直接访问。
+
+---
+
+## 接口:上传类别图片
+
+### HTTP
+
+- **方法**:`POST`
+- **路径**:`/api/app/picture/category/upload`
+- **Content-Type**:`multipart/form-data`
+- **鉴权**:需要登录(Header:`Authorization: Bearer {token}`)
+
+### 表单参数(multipart/form-data)
+
+| 参数名 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `file` | file | 是 | 图片文件 |
+| `subDir` | string | 否 | 可选子目录(相对路径),例如 `category`、`category/2026-03`;**禁止包含 `..`** |
+
+### 限制
+
+- **大小**:最大 5MB
+- **格式**:仅支持 `jpg/jpeg/png/webp/gif`
+- **文件名策略**:后端自动生成唯一文件名(避免覆盖)
+
+### 请求示例(curl)
+
+Windows(PowerShell/命令行注意路径转义):
+
+```bash
+curl -X POST "http://localhost:19001/api/app/picture/category/upload" ^
+ -H "Authorization: Bearer " ^
+ -F "file=@C:\\tmp\\category.png" ^
+ -F "subDir=category"
+```
+
+### 请求示例(Postman/Apifox)
+
+- 选择 `POST`
+- URL:`http://localhost:19001/api/app/picture/category/upload`
+- Headers:`Authorization: Bearer `
+- Body:`form-data`
+ - Key=`file`,类型选 `File`,选择图片文件
+ - Key=`subDir`,类型 `Text`,填 `category`(可选)
+
+### 响应体(PictureUploadOutputDto)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `url` | string | 图片访问的相对路径,可直接保存到 `CategoryPhotoUrl`(例如:`/picture/category/xxx.png`) |
+| `fileName` | string | 服务器保存的文件名 |
+| `size` | number | 文件大小(字节) |
+
+### 响应示例
+
+```json
+{
+ "url": "/picture/category/20260325123010_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png",
+ "fileName": "20260325123010_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.png",
+ "size": 123456
+}
+```
+
+---
+
+## 文件保存位置与访问方式
+
+### 落盘目录
+
+后端会按环境自动选择落盘目录(不存在会自动创建):
+
+- 优先:`/www/wwwroot/FoodLabelingManagementUs/picture`
+- 否则(Windows 本地开发):`<项目根>/wwwroot/FoodLabelingManagementUs/picture`
+
+### 访问 URL
+
+静态资源映射为:
+
+- `GET /picture/{subDir}/{fileName}`
+
+举例:
+
+- `http://localhost:19001/picture/category/20260325123010_xxx.png`
+
+---
+
+## 如何写入 Categories(CategoryPhotoUrl)
+
+推荐前端流程:
+
+1. 调用本上传接口,拿到返回的 `url`
+2. 再调用分类新增/编辑接口,把 `categoryPhotoUrl` 设置为该 `url`
+
+> 说明:分类 CRUD 已支持 `CategoryPhotoUrl` 字段;你只需要在页面表单里新增该字段即可。
+
diff --git a/项目相关文档/标签模块接口对接说明.md b/项目相关文档/标签模块接口对接说明.md
index 5707218..bbcdd08 100644
--- a/项目相关文档/标签模块接口对接说明.md
+++ b/项目相关文档/标签模块接口对接说明.md
@@ -6,7 +6,7 @@ Swagger 地址:
- `http://localhost:19001/swagger`
说明:
-- 接口最终 URL 以 Swagger 展示为准(可在 Swagger 里搜索 `LabelCategory / LabelType / LabelMultipleOption / LabelTemplate / Label`)。
+- 接口最终 URL 以 Swagger 展示为准(可在 Swagger 里搜索 `LabelCategory / LabelType / LabelMultipleOption / LabelTemplate / Label / UsAppLabeling`)。
- 本模块后端接口以各 AppService 的方法签名自动暴露。
- 返回分页统一包含 `PageIndex / PageSize / TotalCount / TotalPages / Items`。
@@ -22,6 +22,7 @@ Swagger 地址:
- `label-multiple-option`
- `label-template`
- `label`
+ - `us-app-labeling`(App 端 Labeling 四级树)
---
@@ -355,7 +356,8 @@ Swagger 地址:
## 接口 5:Labels(按产品展示多个标签)
说明:
-- 列表接口按 `ProductId` 查询,一个产品会对应多条标签记录。
+- 列表接口以“标签”为维度分页展示(同一个标签会绑定多个产品)。
+- 列表支持按 `ProductId` 过滤:仅返回“绑定了该产品”的标签。
- 标签详情/编辑/删除的 `id` 使用 `fl_label.LabelCode`。
### 5.1 分页列表(按产品)
@@ -379,6 +381,11 @@ Swagger 地址:
}
```
+列表出参要点(`LabelGetListOutputDto`):
+
+- `products`:同一个标签下绑定的产品名称,用 `,` 分割(例如:`Chicken,Sandwich`)
+- 其他字段与之前一致:`labelName/locationName/category/type/template/state/lastEdited...`
+
### 5.2 详情
方法:`GET /api/app/label/{id}`
@@ -582,3 +589,297 @@ Swagger 地址:
入参:
- `id`:门店Id
+---
+
+## 接口 8:App Labeling 四级列表(门店打标页)
+
+**场景**:美国版 UniApp「Labeling」页:左侧 **标签分类(Label Category)** → 主区域按 **产品分类(Product Category)** 折叠分组 → **产品(Product)** 卡片 → 点选后底部弹层展示 **标签种类(Label Type)**。
+
+**实现**:`UsAppLabelingAppService.GetLabelingTreeAsync`,约定式 API 控制器名 **`us-app-labeling`**(与 `UsAppAuth` → `us-app-auth` 同规则)。
+
+### 8.1 获取四级嵌套树
+
+#### HTTP
+
+- **方法**:`GET`
+- **路径**:`/api/app/us-app-labeling/labeling-tree`(若与 Swagger 不一致,**以 Swagger 为准**)
+- **鉴权**:需要登录(`Authorization: Bearer ...`);可使用 App 登录或 Web 账号 Token,需能通过 `[Authorize]`。当前用户可选门店列表见 **`/api/app/us-app-auth/my-locations`**(说明见 `美国版App登录接口说明.md`)。
+
+#### 入参(Query:`UsAppLabelingTreeInputVo`)
+
+| 参数名 | 类型 | 必填 | 说明 |
+|--------|------|------|------|
+| `locationId` | string | 是 | 当前门店 Id(`location.Id`,与 `fl_location_product.LocationId`、`fl_label.LocationId` 一致) |
+| `keyword` | string | 否 | 模糊过滤:标签名、产品名、产品分类、**标签分类**名、标签类型名、`labelCode` 等(实现见服务内 `WhereIF`) |
+| `labelCategoryId` | string | 否 | 侧边栏只展示某一 **标签分类** 时传入;不传则返回当前门店下出现的全部标签分类节点 |
+
+#### 数据范围与表关联(便于联调对照)
+
+- **门店内商品**:`fl_location_product`(先有 `locationId` 下的 `productId` 集合)。
+- **参与连接的表**:`fl_label_product`(标签-产品)、`fl_label`(`LocationId` 须为当前门店且未删除、启用)、`fl_product`、`fl_label_category`、`fl_label_type`、`fl_label_template`。
+- **第二级「产品分类」**:来自 `fl_product.CategoryName`,trim 后为空则归并为显示名 **`无`**。
+- **第四级去重**:同一产品在同一标签分类、同一门店下,多条 `fl_label` 若 **`labelCode` 相同**,只保留一条用于列表(预览/打印仍用返回的 `labelCode` 等业务字段)。
+
+#### 出参(`List`)
+
+若宿主对成功结果有统一包装,业务数组一般在 **`data`** 中;下列为 **解包后的数组项** 结构。
+
+**L1 `UsAppLabelCategoryTreeNodeDto`(标签分类)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `id` | string | `fl_label_category.Id` |
+| `categoryName` | string | 分类名称 |
+| `categoryPhotoUrl` | string \| null | 分类图标/图 |
+| `orderNum` | number | 排序 |
+| `productCategories` | array | 第二级列表(见下表) |
+
+**L2 `UsAppProductCategoryNodeDto`(产品分类)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `categoryId` | string \| null | 产品分类Id;产品未归类或分类不存在时为空 |
+| `categoryPhotoUrl` | string \| null | 产品分类图片地址;产品未归类或分类不存在时为空 |
+| `name` | string | 产品分类显示名;空源数据为 **`无`** |
+| `itemCount` | number | 该分类下 **产品个数**(去重后的产品数) |
+| `products` | array | 第三级产品列表(见下表) |
+
+**L3 `UsAppLabelingProductNodeDto`(产品)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `productId` | string | `fl_product.Id` |
+| `productName` | string | 产品名称 |
+| `productCode` | string | 产品编码 |
+| `productImageUrl` | string \| null | 主图 |
+| `subtitle` | string | 卡片副标题:**有 `productCode` 则显示编码,否则「无」**(与原型「Basic」等独立文案不同,需另行扩展字段时再对齐) |
+| `labelTypeCount` | number | 第四级条数,可用于角标「N Types」 |
+| `labelTypes` | array | 第四级(见下表) |
+
+**L4 `UsAppLabelTypeNodeDto`(标签种类 / 可选项)**
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `labelTypeId` | string | `fl_label_type.Id` |
+| `typeName` | string | 类型名称(如 Defrost) |
+| `orderNum` | number | 排序 |
+| `labelCode` | string | 业务标签编码,后续预览、打印流程使用 |
+| `templateCode` | string \| null | 关联模板编码 |
+| `labelSizeText` | string \| null | 尺寸文案;`inch` 常用格式如 `2"x2"` |
+
+#### 错误与边界
+
+- `locationId` 为空:返回友好错误 **「门店Id不能为空」**。
+- 门店下无关联产品:返回 **空数组** `[]`。
+- 有产品但无任何符合条件的标签关联:返回 **空数组** `[]`。
+
+#### 请求示例
+
+```http
+GET /api/app/us-app-labeling/labeling-tree?locationId=11111111-1111-1111-1111-111111111111&labelCategoryId=a2696b9e-2277-11f1-b4c6-00163e0c7c4f&keyword=Chicken HTTP/1.1
+Host: localhost:19001
+Authorization: Bearer eyJhbGciOi...
+```
+
+**curl**(Token 取自登录响应的 `data.token` 整段,已含 `Bearer ` 前缀时直接放入 Header):
+
+```bash
+curl -X GET "http://localhost:19001/api/app/us-app-labeling/labeling-tree?locationId=11111111-1111-1111-1111-111111111111" \
+ -H "Authorization: "
+```
+
+#### 响应结构示例(解包后)
+
+```json
+[
+ {
+ "id": "cat-prep-id",
+ "categoryName": "Prep",
+ "categoryPhotoUrl": "/picture/...",
+ "orderNum": 1,
+ "productCategories": [
+ {
+ "categoryId": "pc-meat-id",
+ "categoryPhotoUrl": "/picture/product-category/20260325123010_xxx.png",
+ "name": "Meat",
+ "itemCount": 1,
+ "products": [
+ {
+ "productId": "prod-chicken-id",
+ "productName": "Chicken",
+ "productCode": "CHK-001",
+ "productImageUrl": "/picture/...",
+ "subtitle": "CHK-001",
+ "labelTypeCount": 3,
+ "labelTypes": [
+ {
+ "labelTypeId": "lt-defrost",
+ "typeName": "Defrost",
+ "orderNum": 1,
+ "labelCode": "LBL_CHICKEN_DEFROST",
+ "templateCode": "TPL_2X2",
+ "labelSizeText": "2\"x2\""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+]
+```
+
+> 前端 Axios 若项目约定 **GET 使用 `data` 配置对象** 传参,请仍绑定到与上述 Query 同名的字段(`locationId`、`keyword`、`labelCategoryId`),与 URL Query 等价即可。
+
+### 8.2 App 打印预览(elements 渲染结构)
+
+**场景**:用户选择某个 Product + Label Type 进入「Label Preview」页面,需要把模板预览区域渲染出来。
+后端根据 `labelCode` 读取模板(`fl_label_template` + `fl_label_template_element`),并将 AUTO_DB / PRINT_INPUT 的值渲染回每个 element 的 `config`,前端按 `elements` 自行绘制预览。
+
+#### HTTP
+
+- **方法**:`POST`
+- **路径**:`/api/app/us-app-labeling/preview`(若与 Swagger 不一致,**以 Swagger 为准**)
+- **鉴权**:需要登录(`Authorization: Bearer ...`)
+
+#### 入参(Body:`UsAppLabelPreviewInputVo`)
+
+| 参数名(JSON) | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `locationId` | string | 是 | 门店Id(校验 `fl_label.LocationId` 必须一致) |
+| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
+| `productId` | string | 否 | 预览用产品Id;不传则默认取该标签绑定的第一个产品(用于 AUTO_DB 数据填充) |
+| `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算;不传则用服务器当前时间) |
+| `printInputJson` | object | 否 | 打印输入(用于 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 |
+
+#### 出参(`UsAppLabelPreviewDto`)
+
+除顶部信息外,核心是 `template`(供前端画布渲染):
+
+- `template`:`LabelTemplatePreviewDto`
+ - `width` / `height` / `unit`:模板物理尺寸
+ - `elements[]`:元素数组(对齐前端 editor JSON:`id/type/x/y/width/height/rotation/border/zIndex/orderNum/config`)
+
+`elements[].config` 内常用字段(示例):
+
+- 文本类(如 `TEXT_PRODUCT` / `TEXT_STATIC` / `TEXT_PRICE`):`config.text`
+- 条码/二维码(`BARCODE` / `QRCODE`):`config.data`
+- 日期/时间(`DATE` / `TIME`):`config.format`(后端已计算并写回)
+
+#### 数据来源说明
+
+- 模板头:`fl_label_template`
+- 模板元素:`fl_label_template_element`(按 `OrderNum` + `ZIndex` 排序)
+- 标签归属:`fl_label`(校验 `labelCode` 存在且 `LocationId == locationId`)
+
+#### 错误与边界
+
+- `locationId` 为空:友好错误 **「门店Id不能为空」**。
+- `labelCode` 为空:友好错误 **「labelCode不能为空」**。
+- 标签不存在:友好错误 **「标签不存在」**。
+- 模板不存在:友好错误 **「模板不存在」**。
+- 标签不属于当前门店:友好错误 **「该标签不属于当前门店」**。
+- 标签未绑定产品且未传 `productId`:友好错误 **「该标签未绑定产品,无法预览」**。
+
+#### 请求示例
+
+```json
+{
+ "locationId": "11111111-1111-1111-1111-111111111111",
+ "labelCode": "LBL_CHICKEN_DEFROST",
+ "productId": "22222222-2222-2222-2222-222222222222",
+ "baseTime": "2026-03-26T10:30:00",
+ "printInputJson": {
+ "price": "12.99"
+ }
+}
+```
+
+**curl:**
+
+```bash
+curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \
+ -H "Authorization: " \
+ -H "Content-Type: application/json" \
+ -d '{"locationId":"11111111-1111-1111-1111-111111111111","labelCode":"LBL_CHICKEN_DEFROST","productId":"22222222-2222-2222-2222-222222222222","baseTime":"2026-03-26T10:30:00","printInputJson":{"price":"12.99"}}'
+```
+
+---
+
+## 接口 9:App 打印(落库打印任务与明细)
+
+**场景**:移动端预览确认后点击 **Print**。后端负责把“本次打印”写入数据库,方便追溯/统计/重打。
+
+### 9.1 创建打印任务并写入明细
+
+#### HTTP
+
+- **方法**:`POST`
+- **路径**:`/api/app/us-app-labeling/print`(若与 Swagger 不一致,**以 Swagger 为准**)
+- **鉴权**:需要登录(`Authorization: Bearer ...`)
+
+#### 入参(Body:`UsAppLabelPrintInputVo`)
+
+| 参数名(JSON) | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `locationId` | string | 是 | 门店Id(校验 `fl_label.LocationId` 必须一致) |
+| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
+| `productId` | string | 否 | 打印用产品Id;不传则默认取该标签绑定的第一个产品(用于模板解析) |
+| `printQuantity` | number | 否 | 打印份数;`<=0` 按 1 处理 |
+| `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算) |
+| `printInputJson` | object | 否 | 打印输入(用于模板 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 |
+| `printerId` | string | 否 | 打印机Id(可选,用于追踪) |
+| `printerMac` | string | 否 | 打印机蓝牙 MAC(可选) |
+| `printerAddress` | string | 否 | 打印机地址(可选) |
+
+#### 数据落库说明
+
+- **任务表**:`fl_label_print_task`
+ - 插入 1 条任务记录(`locationId / labelCode / productId / labelTypeId / templateCode / printQuantity / baseTime / printer...` 等)。
+- **明细表**:`fl_label_print_data`
+ - 按 `printQuantity` 插入 N 条明细记录(`copyIndex = 1..N`)。
+ - `printInputJson`:保存本次打印的原始输入(JSON 字符串)。
+ - `renderDataJson`:保存本次解析后的模板预览结构(`LabelTemplatePreviewDto`,包含 resolved 后的 `elements[].config`),供追溯/重打使用。
+
+> 模板解析的数据源来自 `fl_label_template` + `fl_label_template_element`,与预览接口一致。
+
+#### 出参(`UsAppLabelPrintOutputDto`)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `taskId` | string | 打印任务Id(用于后续查询/重打/统计) |
+| `printQuantity` | number | 实际写入的份数 |
+
+#### 错误与边界
+
+- `locationId` 为空:友好错误 **「门店Id不能为空」**。
+- `labelCode` 为空:友好错误 **「labelCode不能为空」**。
+- 标签不存在/不可用:友好错误 **「标签不存在或不可用」**。
+- 标签不属于当前门店:友好错误 **「该标签不属于当前门店」**。
+- 标签未绑定产品且未传 `productId`:友好错误 **「该标签未绑定产品,无法预览」**(模板解析阶段抛出)。
+
+#### 请求示例
+
+```json
+{
+ "locationId": "11111111-1111-1111-1111-111111111111",
+ "labelCode": "LBL_CHICKEN_DEFROST",
+ "productId": "22222222-2222-2222-2222-222222222222",
+ "printQuantity": 2,
+ "baseTime": "2026-03-26T10:30:00",
+ "printInputJson": {
+ "price": "12.99"
+ },
+ "printerMac": "AA:BB:CC:DD:EE:FF"
+}
+```
+
+**curl:**
+
+```bash
+curl -X POST "http://localhost:19001/api/app/us-app-labeling/print" \
+ -H "Authorization: " \
+ -H "Content-Type: application/json" \
+ -d '{"locationId":"11111111-1111-1111-1111-111111111111","labelCode":"LBL_CHICKEN_DEFROST","productId":"22222222-2222-2222-2222-222222222222","printQuantity":2,"baseTime":"2026-03-26T10:30:00","printInputJson":{"price":"12.99"}}'
+```
+