Commit 923d50c0ca7766029554147e4bd5a93d476c5a26

Authored by 杨鑫
1 parent 1aa1dca9

更新bug

Showing 51 changed files with 3431 additions and 436 deletions
5-18接口优化.md 0 → 100644
  1 +# 5-18 接口优化
  2 +
  3 +本文档说明 **2026-05-18** 对美国版接口修复与约定,包括:
  4 +
  5 +1. **`/api/app/rbac-role`**:`accessPermissions` 读写(见下文 rbac-role 章节)。
  6 +2. **`/api/app/team-member`**:成员 **平台端无法登录**(已修复);列表支持 **Company / Region / Location** 筛选(见 [team-member-list](#team-member-列表-companyregionlocation-筛选))。
  7 +3. **`/api/app/label-type`**:列表 **No. of Labels / Region / Location**;新增/编辑 **Region·Location 多选**(见 [label-type](#label-type-标签类型))。
  8 +4. **`/api/app/label-multiple-option`**:列表 **Region / Location** 筛选与出参;新增/编辑 **Region·Location 多选**(见 [label-multiple-option](#label-multiple-option-多选项))。
  9 +5. **`/api/app/reports/print-log-list`**:Print Log **Label ID** 当日门店序号 + **Expiry Date** 从打印快照解析(见 [reports-print-log](#reports-print-log-打印日志))。
  10 +6. **`/api/app/reports/label-report`**:按 Token **门店绑定**统计(管理员全量;见 [reports-label-report](#reports-label-report-标签报表))。
  11 +7. **`/api/app/us-app-auth/location-detail/{locationId}`**:出参增加 **经营时间** `operatingHours`(见 [us-app-auth-location-detail](#us-app-auth-location-detail-门店详情))。
  12 +8. **App `POST /api/app/us-app-labeling/get-print-log-list`**、**`get-label-report`**:管理员 / Partner 角色可查看**当前门店全部**打印记录与统计(见 [us-app-print-log](#us-app-print-log-app-打印日志与报表))。
  13 +9. **`GET /api/app/us-app-labeling/labeling-tree`**:修复产品分类 `SPECIFIED` 未配门店关联导致全店空树(见 [us-app-labeling-tree](#us-app-labeling-tree-四级列表))。
  14 +10. **`GET /api/app/location`**:门店列表按 Token **Region** 数据范围过滤(见 [location-list](#location-门店列表))。
  15 +11. **`GET /api/app/reports/template-print-stat-list`**:按模板统计打印标签数量(见 [reports-template-print-stat](#reports-template-print-stat-模板打印统计))。
  16 +
  17 +---
  18 +
  19 +## reports-print-log 打印日志
  20 +
  21 +**应用服务**:`ReportsAppService`
  22 +**接口**:`GET /api/app/reports/print-log-list`(及同源导出 `export-print-log-pdf` / `export-print-log-excel`)
  23 +**辅助类**:`ReportsPrintLogDailyLabelIdHelper`、`ReportsPrintLogExpiryHelper`
  24 +
  25 +### Label ID 变更说明
  26 +
  27 +| 项 | 说明 |
  28 +|----|------|
  29 +| **Label ID 含义** | 由 **`fl_label.LabelCode`**(如 `079`)改为 **门店当日打印序号** |
  30 +| **展示格式** | `{yyyyMMdd}-{n}`,例如 `20260513-1`、`20260513-2` |
  31 +| **排序规则** | 按 **`LocationId`(门店)** + **自然日**(`PrintedAt`,无则 `CreationTime`)分组;组内按打印时间 **升序**,同秒按任务 `Id` 升序,`n` 从 **1** 递增 |
  32 +| **跨页一致** | 分页列表与导出使用同一套序号(按门店全日任务计算,非仅当前页内排序) |
  33 +| **API 字段名** | 仍为 **`labelCode`**(兼容前端列绑定),内容为当日序号,**不是**标签主数据编码 |
  34 +
  35 +### 列表出参(节选)
  36 +
  37 +| 字段 | 说明 |
  38 +|------|------|
  39 +| taskId | 打印任务 Id(`fl_label_print_task.Id`,重打用) |
  40 +| **labelCode** | **Label ID 列**:`20260515-1` 这种当日门店序号 |
  41 +| productName / categoryName / templateText | 不变 |
  42 +| printedAt | 打印时间 |
  43 +| locationText / locationId | 门店 |
  44 +| **expiryDateText** | **Expiry Date 列**:从 `PrintInputJson` 解析的保质期展示文案;无则「无」 |
  45 +
  46 +**示例**
  47 +
  48 +```json
  49 +{
  50 + "taskId": "task-guid-001",
  51 + "labelCode": "20260515-3",
  52 + "productName": "Tuna & Bacon Sub",
  53 + "printedAt": "2026-05-15T15:00:40",
  54 + "locationText": "UNCC store (LOC001)",
  55 + "expiryDateText": "05/17"
  56 +}
  57 +```
  58 +
  59 +### Expiry Date(到期时间)变更说明
  60 +
  61 +| 项 | 说明 |
  62 +|----|------|
  63 +| **数据来源** | `fl_label_print_task.PrintInputJson`(App 打印接口落库,多为**整份模板快照** JSON,含 `elements[]`) |
  64 +| **解析方式** | `ReportsPrintLogExpiryHelper.ExtractExpiryText`:先读根级 `expiryDate` / `expiry` / `expirationDate` 等;若无,在 `elements` 中匹配 **duration date / expiry** 类元素,取 `config.text`(或 `config.format`) |
  65 +| **匹配规则** | 与 Web `isDateTimeDataEntryField` 对齐:`elementName` / `inputKey` 含 `durationdate`、`expirydate` 等;`typeAdd` 含 `duration date`;排除 `currentdate`、`currenttime`、`prepped` 等制备日期字段 |
  66 +| **展示格式** | **与标签出纸一致**(如 `05/17`、`2026-05-17`),不做二次换算 |
  67 +| **适用范围** | 分页列表、`export-print-log-pdf`、`export-print-log-excel` 共用同一解析逻辑 |
  68 +
  69 +**库内样例(节选)**
  70 +
  71 +`PrintInputJson.elements` 中 `elementName: "durationdate1"` → `config.text: "05/17"`,接口应返回 `expiryDateText: "05/17"`(此前仅查根级字段,列恒为「无」)。
  72 +
  73 +### Label ID 计算逻辑(与代码一致)
  74 +
  75 +同一门店、同一天内:
  76 +
  77 +1. 查询 `fl_label_print_task` 中 `LocationId` 相同且 `DATE(COALESCE(PrintedAt, CreationTime))` 相同的全部任务;
  78 +2. 按打印时间升序编号 `1, 2, 3…`;
  79 +3. 格式化为 `yyyyMMdd-n`。
  80 +
  81 +> **注意**:序号统计范围为该门店**当日全部打印任务**(不限于当前列表筛选关键字),以保证同一天内序号全局唯一、连续。列表/导出的日期、门店筛选只决定**哪些行展示**,不改变已展示行的序号。
  82 +
  83 +### 库内核对 SQL
  84 +
  85 +```sql
  86 +SELECT
  87 + t.Id AS task_id,
  88 + t.LocationId,
  89 + DATE(COALESCE(t.PrintedAt, t.CreationTime)) AS print_day,
  90 + ROW_NUMBER() OVER (
  91 + PARTITION BY t.LocationId, DATE(COALESCE(t.PrintedAt, t.CreationTime))
  92 + ORDER BY COALESCE(t.PrintedAt, t.CreationTime), t.Id
  93 + ) AS daily_seq,
  94 + CONCAT(
  95 + DATE_FORMAT(COALESCE(t.PrintedAt, t.CreationTime), '%Y%m%d'),
  96 + '-',
  97 + ROW_NUMBER() OVER (
  98 + PARTITION BY t.LocationId, DATE(COALESCE(t.PrintedAt, t.CreationTime))
  99 + ORDER BY COALESCE(t.PrintedAt, t.CreationTime), t.Id
  100 + )
  101 + ) AS label_id_display
  102 +FROM fl_label_print_task t
  103 +WHERE t.LocationId = :locationId
  104 + AND COALESCE(t.PrintedAt, t.CreationTime) >= :dayStart
  105 + AND COALESCE(t.PrintedAt, t.CreationTime) < :dayEnd
  106 +ORDER BY print_day, daily_seq;
  107 +```
  108 +
  109 +### 联调注意
  110 +
  111 +| 现象 | 可能原因 |
  112 +|------|----------|
  113 +| 仍显示 `079` 等 | 后端未部署;或前端绑错字段(应绑 `labelCode`) |
  114 +| 同一天序号不连续 | 存在无 `LocationId` 的任务,显示「无」 |
  115 +| 与 App 端打印记录不一致 | App 接口为另一套(`us-app-labeling` 打印历史),本规则仅 **Reports Print Log** |
  116 +
  117 +---
  118 +
  119 +## location 门店列表
  120 +
  121 +**应用服务**:`LocationAppService`
  122 +**接口**:`GET /api/app/location`(分页;同源 Excel 导出 `export-locations-excel` 使用相同筛选)
  123 +**辅助类**:`LocationRegionScopeHelper`、`ReportsRoleHelper`
  124 +
  125 +### 数据范围(按 Token)
  126 +
  127 +| 角色 | 可见门店 |
  128 +|------|----------|
  129 +| **管理员** | **全部**未删除门店(`ReportsRoleHelper.IsAdminRole`) |
  130 +| **非管理员** | 仅 **`userlocation` 绑定门店所属 Region** 下的全部门店 |
  131 +
  132 +**Region 判定**:与 `fl_group` / `group` 列表一致——取绑定门店的 `location.Partner`(公司名称)+ `location.GroupName`(Region 名称)去重后,列表返回 **同一 Partner + GroupName** 的所有 `location` 行(不限于本人绑定的那几家店)。
  133 +
  134 +未绑定门店、或绑定门店缺少 `Partner`/`GroupName` 时,列表为空。
  135 +
  136 +### 请求示例
  137 +
  138 +```http
  139 +GET /api/app/location?SkipCount=1&MaxResultCount=10
  140 +Authorization: Bearer {token}
  141 +```
  142 +
  143 +可选 Query:`Keyword`、`Partner`、`GroupName`、`State`、`Sorting`(在数据范围之上再收窄)。
  144 +
  145 +### 与 group 列表的关系
  146 +
  147 +| 接口 | 非管理员范围 |
  148 +|------|----------------|
  149 +| `GET /api/app/group` | 可见的 **fl_group** 记录 |
  150 +| `GET /api/app/location` | 上述 Region 对应的 **location** 门店 |
  151 +
  152 +### 库内核对(非管理员)
  153 +
  154 +```sql
  155 +-- 当前用户绑定门店所属的 Region(Partner + GroupName)
  156 +SELECT DISTINCT loc.Partner, loc.GroupName
  157 +FROM userlocation ul
  158 +INNER JOIN location loc ON ul.LocationId = loc.Id AND loc.IsDeleted = 0
  159 +WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId
  160 + AND loc.Partner IS NOT NULL AND loc.Partner != ''
  161 + AND loc.GroupName IS NOT NULL AND loc.GroupName != '';
  162 +
  163 +-- 列表应返回的同 Region 全部门店
  164 +SELECT loc.*
  165 +FROM location loc
  166 +WHERE loc.IsDeleted = 0
  167 + AND (loc.Partner, loc.GroupName) IN (
  168 + SELECT DISTINCT l2.Partner, l2.GroupName
  169 + FROM userlocation ul
  170 + INNER JOIN location l2 ON ul.LocationId = l2.Id AND l2.IsDeleted = 0
  171 + WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId
  172 + );
  173 +```
  174 +
  175 +### 联调注意
  176 +
  177 +| 现象 | 处理 |
  178 +|------|------|
  179 +| 非管理员列表为空 | 检查 `userlocation` 是否有绑定;门店 `Partner`、`GroupName` 是否已填 |
  180 +| 只能看到部分 Region | 正常:仅能看到绑定门店所在 Region;换绑门店可扩大范围 |
  181 +| 入参 `GroupName` 越权筛选 | 仅能筛 **已有权限范围内** 的数据,不会扩大范围 |
  182 +
  183 +---
  184 +
  185 +## us-app-labeling-tree 四级列表
  186 +
  187 +**应用服务**:`UsAppLabelingAppService.GetLabelingTreeAsync`
  188 +**接口**:`GET /api/app/us-app-labeling/labeling-tree?locationId={guid}`
  189 +
  190 +### 问题与修复(2026-05-18)
  191 +
  192 +| 现象 | 原因 | 处理 |
  193 +|------|------|------|
  194 +| App 各门店 Labeling 页 **No products found** | 产品已写入 `fl_location_product`,但 `fl_product_category.AvailabilityType=SPECIFIED` 且 **未**在 `fl_product_category_location` 配置当前门店时,旧逻辑在 Join 条件中整行过滤 | **已修复**:四级树的产品范围仅由 **`fl_location_product` + `fl_label.LocationId`** 决定;产品分类、标签分类仅校验未删除且启用,不再用 SPECIFIED 子查询拦截 |
  195 +| 某门店仍为空 | 该门店 **无** `fl_label`(`LocationId` 匹配且 `State=1`) | 属数据:需在 Web **Labels** 为该门店创建/复制标签,或把已有标签的 `LocationId` 指到该门店 |
  196 +
  197 +### 数据范围(与代码一致)
  198 +
  199 +1. **门店产品**:`fl_location_product.LocationId = locationId`
  200 +2. **门店标签**:`fl_label.LocationId = locationId` 且未删除、启用
  201 +3. **关联**:`fl_label_product` 连接标签与产品
  202 +4. **登录**:须绑定该门店(`userlocation`),否则返回业务错误
  203 +
  204 +### 请求示例
  205 +
  206 +```http
  207 +GET /api/app/us-app-labeling/labeling-tree?locationId=3a212211-3b01-d66f-a804-125c0cee3bf0
  208 +Authorization: Bearer {token}
  209 +```
  210 +
  211 +### 出参
  212 +
  213 +`UsAppLabelCategoryTreeNodeDto[]`(L1 标签分类 → L2 产品分类 → L3 产品+模板卡片 → L4 标签类型)。
  214 +
  215 +### 库内核对 SQL
  216 +
  217 +```sql
  218 +-- 门店是否有可展示产品(绑定产品数)
  219 +SELECT COUNT(*) FROM fl_location_product WHERE LocationId = :locationId;
  220 +
  221 +-- 门店是否有可用标签(决定树是否非空)
  222 +SELECT COUNT(*) FROM fl_label
  223 +WHERE LocationId = :locationId AND IsDeleted = 0 AND State = 1;
  224 +
  225 +-- 修复后应能查到的树行数(与接口一致)
  226 +SELECT COUNT(*) AS tree_rows
  227 +FROM fl_label_product lp
  228 +INNER JOIN fl_label l ON lp.LabelId = l.Id
  229 +INNER JOIN fl_product p ON lp.ProductId = p.Id
  230 +INNER JOIN fl_label_category c ON l.LabelCategoryId = c.Id
  231 +INNER JOIN fl_label_type t ON l.LabelTypeId = t.Id
  232 +INNER JOIN fl_label_template tpl ON l.TemplateId = tpl.Id
  233 +LEFT JOIN fl_product_category pc ON p.CategoryId = pc.Id
  234 +WHERE l.LocationId = :locationId
  235 + AND p.Id IN (SELECT ProductId FROM fl_location_product WHERE LocationId = :locationId)
  236 + AND l.IsDeleted = 0 AND l.State = 1
  237 + AND p.IsDeleted = 0 AND p.State = 1
  238 + AND c.IsDeleted = 0 AND c.State = 1
  239 + AND t.IsDeleted = 0 AND t.State = 1
  240 + AND tpl.IsDeleted = 0
  241 + AND (pc.Id IS NULL OR (pc.IsDeleted = 0 AND pc.State = 1));
  242 +```
  243 +
  244 +---
  245 +
  246 +## us-app-print-log App 打印日志与报表
  247 +
  248 +**应用服务**:`UsAppLabelingAppService`
  249 +**辅助类**:`UsAppPrintLogScopeHelper`、`ReportsRoleHelper`
  250 +
  251 +### 涉及接口
  252 +
  253 +| 接口 | 说明 |
  254 +|------|------|
  255 +| `POST /api/app/us-app-labeling/get-print-log-list` | 打印日志分页 |
  256 +| `POST /api/app/us-app-labeling/get-label-report` | 当前门店 Label Report 统计(出参与 Web `reports/label-report` 同结构) |
  257 +| `POST /api/app/us-app-labeling/reprint` | 重打;权限与日志查看一致 |
  258 +
  259 +### 查看权限(按 Token + 当前门店)
  260 +
  261 +须已登录,且 `userlocation` **绑定** `locationId`。
  262 +
  263 +| 角色 | 打印日志 / Report 数据范围 |
  264 +|------|---------------------------|
  265 +| **管理员** | 当前门店 **全部** 用户的打印任务(`ReportsRoleHelper.IsAdminRole`:用户名为 `admin`、角色码 `admin`、权限 `*:*:*` 等) |
  266 +| **Partner** | 同上;判定为 `UserRole` → `Role` 的 **`RoleCode` 或 `RoleName` 含 `partner`**(忽略大小写,如 Partner Admin) |
  267 +| **其它**(如 Staff、Store Manager) | 仅 **`CreatedBy == 当前用户 Id`** |
  268 +
  269 +### get-print-log-list
  270 +
  271 +**请求示例**
  272 +
  273 +```http
  274 +POST /api/app/us-app-labeling/get-print-log-list
  275 +Authorization: Bearer {token}
  276 +Content-Type: application/json
  277 +
  278 +{
  279 + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8",
  280 + "skipCount": 1,
  281 + "maxResultCount": 20
  282 +}
  283 +```
  284 +
  285 +**出参要点**
  286 +
  287 +| 字段 | 说明 |
  288 +|------|------|
  289 +| `items[].operatorName` | 实际打印人姓名(全店可见时为对应 `CreatedBy` 用户,非固定当前登录人) |
  290 +| 其它 | 与既有 `PrintLogItemDto` 一致 |
  291 +
  292 +### get-label-report
  293 +
  294 +**请求示例**
  295 +
  296 +```json
  297 +{
  298 + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8",
  299 + "startDate": "2026-04-07",
  300 + "endDate": "2026-05-18",
  301 + "keyword": ""
  302 +}
  303 +```
  304 +
  305 +**出参**:`ReportsLabelReportOutputDto`(`summary`、`labelsByCategory`、`printVolumeTrend`、`mostUsedProducts`)。
  306 +
  307 +### 与 Web Reports 的差异
  308 +
  309 +| 模块 | 范围 |
  310 +|------|------|
  311 +| **App** `get-print-log-list` / `get-label-report` | 单门店 + 上表角色规则 |
  312 +| **Web** `reports/print-log-list` | 非管理员仍仅本人;见 reports-print-log 章节 |
  313 +| **Web** `reports/label-report` | 非管理员为绑定门店全量;见 reports-label-report 章节 |
  314 +
  315 +### 联调注意
  316 +
  317 +| 现象 | 处理 |
  318 +|------|------|
  319 +| Partner 仍只看本人 | 确认角色 `RoleCode`/`RoleName` 含 `partner`,或是否为管理员 |
  320 +| 列表为空 | 确认 `locationId` 与绑定门店一致;Staff 仅能看到自己打印的记录 |
  321 +
  322 +---
  323 +
  324 +## us-app-auth-location-detail 门店详情
  325 +
  326 +**应用服务**:`UsAppAuthAppService`
  327 +**接口**:`GET /api/app/us-app-auth/location-detail/{locationId}`
  328 +**鉴权**:Bearer Token;仅可查当前用户在 **`userlocation`** 中绑定的门店。
  329 +
  330 +### 变更说明
  331 +
  332 +| 项 | 说明 |
  333 +|----|------|
  334 +| **新增出参** | **`operatingHours`**(经营时间) |
  335 +| **数据来源** | `location.OperatingHours`(`varchar(512)` 自由文本) |
  336 +| **空值展示** | 未维护或空白时返回 **`无`**(与 `locationName`、`storePhone` 等字段一致) |
  337 +| **维护入口** | Web 管理端 `POST/PUT /api/app/location` 的 `operatingHours` 字段 |
  338 +
  339 +### 数据库(若列不存在须先执行)
  340 +
  341 +脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_location_add_operating_hours_column.sql`
  342 +
  343 +```sql
  344 +ALTER TABLE `location`
  345 + ADD COLUMN `OperatingHours` varchar(512) DEFAULT NULL COMMENT '经营时间(自由文本)' AFTER `Longitude`;
  346 +```
  347 +
  348 +### 请求示例
  349 +
  350 +```http
  351 +GET /api/app/us-app-auth/location-detail/3a21220f-db37-3e32-7390-d55f64cd62a8
  352 +Authorization: Bearer {token}
  353 +```
  354 +
  355 +### 响应体(UsAppLocationDetailOutputDto)
  356 +
  357 +| 字段(JSON) | 类型 | 说明 |
  358 +|--------------|------|------|
  359 +| `locationId` | string | 门店主键 Guid |
  360 +| `locationName` | string | 门店名称 |
  361 +| `fullAddress` | string | 街道/城市/州/邮编拼接 |
  362 +| `storePhone` | string | `location.Phone` |
  363 +| **`operatingHours`** | string | **经营时间**;示例:`Mon–Fri 9:00 AM – 6:00 PM`;空为 `无` |
  364 +| `managerName` | string | 本店绑定用户中角色含 `manager` 者姓名 |
  365 +| `managerPhone` | string | 同上用户电话 |
  366 +
  367 +### 响应示例
  368 +
  369 +```json
  370 +{
  371 + "locationId": "3a21220f-db37-3e32-7390-d55f64cd62a8",
  372 + "locationName": "Central Park Store",
  373 + "fullAddress": "123 Main St, New York, NY 10001",
  374 + "storePhone": "(212) 555-0100",
  375 + "operatingHours": "Mon–Fri 9:00 AM – 6:00 PM",
  376 + "managerName": "Jane Doe",
  377 + "managerPhone": "+1 (555) 123-4567"
  378 +}
  379 +```
  380 +
  381 +### 联调注意
  382 +
  383 +| 现象 | 处理 |
  384 +|------|------|
  385 +| `operatingHours` 恒为 `无` | 1)确认已执行 DDL;2)在 Web 门店编辑保存 `operatingHours`;3)重启 API 使实体映射生效 |
  386 +| 403 / 业务异常 | 当前 Token 用户未在 `userlocation` 绑定该 `locationId` |
  387 +
  388 +---
  389 +
  390 +## reports-template-print-stat 模板打印统计
  391 +
  392 +**应用服务**:`ReportsAppService`
  393 +**接口**:`GET /api/app/reports/template-print-stat-list`
  394 +**辅助类**:`ReportsLocationScopeHelper`、`ReportsRoleHelper`
  395 +
  396 +### 功能
  397 +
  398 +按 **`fl_label_template`** 汇总 **`fl_label_print_task`** 行数,返回 **模板名称 + 打印标签数量** 分页列表(默认按 `printedCount` 降序)。
  399 +
  400 +### 数据范围(与 label-report 一致)
  401 +
  402 +| 角色 | 统计范围 |
  403 +|------|----------|
  404 +| **管理员** | 全部门店打印任务(可按 Company/Region/Location 入参收窄) |
  405 +| **非管理员** | 仅 **`userlocation` 绑定门店**内全部打印任务(**不按** `CreatedBy` 过滤) |
  406 +
  407 +### 入参
  408 +
  409 +| 参数 | 说明 |
  410 +|------|------|
  411 +| `SkipCount` / `MaxResultCount` | 分页(`SkipCount` 为 **1-based 页码**,第一页传 `1`) |
  412 +| `StartDate` / `EndDate` | 统计区间(含起止日;未传默认近 30 天至今天) |
  413 +| `PartnerId` | Company(`fl_partner.Id`) |
  414 +| `GroupId` | Region(`fl_group.Id`) |
  415 +| `LocationId` | 门店(`location.Id`) |
  416 +| `Keyword` | **模板名称**模糊匹配(`fl_label_template.TemplateName`) |
  417 +| `Sorting` | 可选 `PrintedCount asc`;默认 **`PrintedCount desc`** |
  418 +
  419 +### 请求示例
  420 +
  421 +```http
  422 +GET /api/app/reports/template-print-stat-list?SkipCount=1&MaxResultCount=20&StartDate=2026-04-07&EndDate=2026-05-18
  423 +Authorization: Bearer {token}
  424 +```
  425 +
  426 +### 出参(`items[]`)
  427 +
  428 +| 字段 | 类型 | 说明 |
  429 +|------|------|------|
  430 +| `templateId` | string? | `fl_label_template.Id` |
  431 +| `templateName` | string | 模板名称;缺失时 **「无」** |
  432 +| `printedCount` | int | 该模板下打印任务条数 |
  433 +
  434 +**分页包装**:`pageIndex`、`pageSize`、`totalCount`、`totalPages`、`items`(与其它列表一致)。
  435 +
  436 +### 响应示例
  437 +
  438 +```json
  439 +{
  440 + "pageIndex": 1,
  441 + "pageSize": 20,
  442 + "totalCount": 3,
  443 + "totalPages": 1,
  444 + "items": [
  445 + { "templateId": "tpl-001", "templateName": "2x3 Price Label", "printedCount": 128 },
  446 + { "templateId": "tpl-002", "templateName": "Deli Scale Label", "printedCount": 45 }
  447 + ]
  448 +}
  449 +```
  450 +
  451 +### 库内核对
  452 +
  453 +```sql
  454 +SELECT t.TemplateId,
  455 + tpl.TemplateName,
  456 + COUNT(*) AS printed_count
  457 +FROM fl_label_print_task t
  458 +LEFT JOIN fl_label_template tpl ON t.TemplateId = tpl.Id
  459 +INNER JOIN location loc ON t.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0
  460 +WHERE COALESCE(t.PrintedAt, t.CreationTime) >= :start
  461 + AND COALESCE(t.PrintedAt, t.CreationTime) < :endExcl
  462 + AND t.LocationId IN (:allowedLocationIds) -- 非管理员:userlocation 绑定门店
  463 +GROUP BY t.TemplateId, tpl.TemplateName
  464 +ORDER BY printed_count DESC;
  465 +```
  466 +
  467 +### 联调注意
  468 +
  469 +| 现象 | 处理 |
  470 +|------|------|
  471 +| 列表为空 | 检查日期区间、门店绑定、该区间是否有打印任务 |
  472 +| 与 **print-log-list** 数量不一致 | print-log 非管理员仅本人任务;本接口与 **label-report** 同范围 |
  473 +| 模板已删除仍有统计 | 任务仍保留 `TemplateId`;名称来自 Left Join,无名称时显示「无」 |
  474 +
  475 +---
  476 +
  477 +## reports-label-report 标签报表
  478 +
  479 +**应用服务**:`ReportsAppService`
  480 +**接口**:`GET /api/app/reports/label-report`(及同源 `export-label-report-pdf`)
  481 +**辅助类**:`ReportsLocationScopeHelper`、`ReportsRoleHelper`
  482 +
  483 +### 数据范围(按 Token)
  484 +
  485 +| 角色 | 统计范围 | 说明 |
  486 +|------|----------|------|
  487 +| **管理员** | 全部门店打印任务 | 识别方式与 Print Log 一致:`admin` 角色 / 用户名为 `admin` / 权限 `*:*:*` |
  488 +| **非管理员** | 仅 **`userlocation` 绑定门店** | `UserLocation.UserId = 当前用户 Id` 的 `LocationId`;统计该门店下**全部**打印任务,**不按** `CreatedBy` 过滤 |
  489 +
  490 +### 入参筛选(与 Print Log 一致)
  491 +
  492 +| 参数 | 说明 |
  493 +|------|------|
  494 +| `StartDate` / `EndDate` | 统计区间(含起日、含止日;未传时默认近 30 天至今天) |
  495 +| `PartnerId` | Company(`fl_partner.Id`) |
  496 +| `GroupId` | Region(`fl_group.Id`) |
  497 +| `LocationId` | 门店(`location.Id`) |
  498 +| `Keyword` | 产品名 / 标签分类 / 产品分类模糊匹配 |
  499 +
  500 +**筛选叠加规则**
  501 +
  502 +- **管理员**:未传 Company/Region/Location → 不限制门店;传入后与对应门店集合取交集。
  503 +- **非管理员**:始终在绑定门店集合内统计;若再传 `PartnerId` / `GroupId` / `LocationId`,与绑定门店 **取交集**(传了未绑定门店 → 空数据)。
  504 +- **无绑定门店**:返回空统计(各指标为 0 / 空列表)。
  505 +
  506 +### 示例
  507 +
  508 +```http
  509 +GET /api/app/reports/label-report?StartDate=2026-04-07&EndDate=2026-05-18
  510 +Authorization: Bearer {token}
  511 +```
  512 +
  513 +### 出参结构(不变)
  514 +
  515 +| 块 | 字段 |
  516 +|----|------|
  517 +| `summary` | `totalLabelsPrinted`、`totalLabelsPrintedPrevPeriod`、`mostPrintedCategoryName`、`topProductName`、`avgDailyPrints` 等 |
  518 +| `labelsByCategory` | 按标签分类汇总 |
  519 +| `printVolumeTrend` | 近 7 日(在查询区间内)每日打印量 |
  520 +| `mostUsedProducts` | Top 20 产品 |
  521 +
  522 +### 与 Print Log 的差异
  523 +
  524 +| 模块 | 非管理员范围 |
  525 +|------|----------------|
  526 +| **label-report** | 绑定门店内**所有**打印记录 |
  527 +| **print-log-list** | 仍仅 **`CreatedBy = 当前用户`**(见 `报表Reports接口对接说明.md`) |
  528 +
  529 +### 库内核对(非管理员)
  530 +
  531 +```sql
  532 +-- 当前用户绑定的门店
  533 +SELECT ul.LocationId
  534 +FROM userlocation ul
  535 +WHERE ul.IsDeleted = 0 AND ul.UserId = :currentUserId;
  536 +
  537 +-- 绑定门店在区间内的打印量(应与接口 summary.totalLabelsPrinted 一致)
  538 +SELECT COUNT(*) AS cnt
  539 +FROM fl_label_print_task t
  540 +WHERE t.LocationId IN (:boundLocationIds)
  541 + AND COALESCE(t.PrintedAt, t.CreationTime) >= :start
  542 + AND COALESCE(t.PrintedAt, t.CreationTime) < :endExclusive;
  543 +```
  544 +
  545 +---
  546 +
  547 +## label-multiple-option 多选项
  548 +
  549 +**应用服务**:`LabelMultipleOptionAppService`
  550 +
  551 +**命名约定**:UI **Region** = 入参 **`regionIds` / `groupIds`**、列表筛选 **`groupId`**(均为 `fl_group.Id`);UI **Location** = 入参 **`locationIds`**、列表筛选 **`locationId`**(`location.Id`)。存储表 **`fl_label_multiple_option_location`**(与 label-type / label-category 一致)。
  552 +
  553 +### 数据库变更(列表筛选前须执行)
  554 +
  555 +脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_multiple_option_scope.sql`
  556 +
  557 +```sql
  558 +ALTER TABLE `fl_label_multiple_option`
  559 + ADD COLUMN `AvailabilityType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '门店可用范围:ALL/SPECIFIED' AFTER `State`;
  560 +
  561 +CREATE TABLE IF NOT EXISTS `fl_label_multiple_option_location` (
  562 + `Id` varchar(36) NOT NULL,
  563 + `MultipleOptionId` varchar(36) NOT NULL,
  564 + `LocationId` varchar(36) NOT NULL,
  565 + `CreationTime` datetime NOT NULL,
  566 + `CreatorId` varchar(36) DEFAULT NULL,
  567 + PRIMARY KEY (`Id`),
  568 + KEY `idx_fl_lmol_option` (`MultipleOptionId`),
  569 + KEY `idx_fl_lmol_location` (`LocationId`)
  570 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签多选项适用门店';
  571 +```
  572 +
  573 +> 执行 DDL 后,历史数据默认 **`AvailabilityType = ALL`**,任意 Region/门店筛选下仍可见。
  574 +
  575 +### 新增 `POST /api/app/label-multiple-option`、编辑 `PUT /api/app/label-multiple-option/{id}`
  576 +
  577 +| 字段 | 类型 | 必填 | 说明 |
  578 +|------|------|------|------|
  579 +| optionCode | string | 是 | 多选项编码 |
  580 +| optionName | string | 是 | 多选项名称 |
  581 +| optionValuesJson | string | 否 | 选项值 JSON 字符串 |
  582 +| state | bool | 否 | 默认 `true` |
  583 +| orderNum | int | 否 | 排序 |
  584 +| availabilityType | string | 否 | `ALL` / `SPECIFIED`;传 region/location 数组时自动 **`SPECIFIED`** |
  585 +| **regionIds** | string[] | 否 | **Region 多选**(`fl_group.Id`) |
  586 +| **groupIds** | string[] | 否 | 与 `regionIds` 合并去重 |
  587 +| **locationIds** | string[] | 否 | **Location 多选**(`location.Id`) |
  588 +
  589 +**合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds` **取并集** → 写入 `fl_label_multiple_option_location`;`SPECIFIED` 时至少 **1** 个有效门店。
  590 +
  591 +**请求示例**
  592 +
  593 +```json
  594 +POST /api/app/label-multiple-option
  595 +{
  596 + "optionCode": "OPT_ALLERGENS",
  597 + "optionName": "Allergens",
  598 + "optionValuesJson": "[\"Peanuts\",\"Dairy\",\"Gluten\"]",
  599 + "state": true,
  600 + "orderNum": 1,
  601 + "availabilityType": "SPECIFIED",
  602 + "regionIds": ["fl_group_id_east"],
  603 + "locationIds": ["11111111-1111-1111-1111-111111111111"]
  604 +}
  605 +```
  606 +
  607 +**详情 `GET /api/app/label-multiple-option/{id}` 出参(范围字段)**
  608 +
  609 +| 字段 | 说明 |
  610 +|------|------|
  611 +| availabilityType | `ALL` / `SPECIFIED` |
  612 +| regionIds | Region Id(`SPECIFIED` 时由门店反推) |
  613 +| groupIds | 与 `regionIds` 相同 |
  614 +| locationIds | 已绑定门店 Id |
  615 +
  616 +**编辑**:Body 与新增相同;会 **先删后插** 重建 `fl_label_multiple_option_location`。
  617 +
  618 +---
  619 +
  620 +### 列表 `GET /api/app/label-multiple-option`
  621 +
  622 +示例:`?SkipCount=1&MaxResultCount=10`
  623 +
  624 +### 列表 Query 筛选
  625 +
  626 +| 字段 | 类型 | 必填 | 说明 |
  627 +|------|------|------|------|
  628 +| skipCount | int | 是 | 跳过条数(第 1 页常为 `1`) |
  629 +| maxResultCount | int | 是 | 每页条数 |
  630 +| sorting | string | 否 | 排序 |
  631 +| keyword | string | 否 | 匹配 `optionCode`、`optionName` |
  632 +| state | bool | 否 | 启用状态 |
  633 +| **groupId** | string | 否 | **Region** 筛选(`fl_group.Id`) |
  634 +| **locationId** | string | 否 | **Location** 筛选;**优先于** `groupId` |
  635 +
  636 +**筛选语义**(与 label-category / label-type 一致):
  637 +
  638 +- 未传 `groupId`、`locationId`:返回全部未删除多选项。
  639 +- 传入筛选:返回 **`availabilityType = ALL`** 或 **`fl_label_multiple_option_location` 命中该门店** 的记录。
  640 +
  641 +**请求示例**
  642 +
  643 +```bash
  644 +GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10
  645 +Authorization: {token}
  646 +```
  647 +
  648 +```bash
  649 +GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10&groupId=你的fl_group主键
  650 +Authorization: {token}
  651 +```
  652 +
  653 +```bash
  654 +GET /api/app/label-multiple-option?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111
  655 +Authorization: {token}
  656 +```
  657 +
  658 +### 列表出参 `items[]`
  659 +
  660 +| 字段 | 类型 | 说明 |
  661 +|------|------|------|
  662 +| id | string | 多选项主键 |
  663 +| optionCode / optionName | string | 编码、名称 |
  664 +| optionValuesJson | string | 选项值 JSON |
  665 +| state / orderNum | bool / int | 状态、排序 |
  666 +| availabilityType | string | `ALL` / `SPECIFIED` |
  667 +| **region** | string | 列表列 **Region**:`ALL` → `All Regions`;`SPECIFIED` → 绑定门店 `GroupName` 拼接 |
  668 +| **location** | string | 列表列 **Location**:`ALL` → `All Locations`;`SPECIFIED` → 门店名拼接 |
  669 +| regionIds | string[] | Region Id(`SPECIFIED` 时由门店反推) |
  670 +| locationIds | string[] | 门店 Id |
  671 +| lastEdited | datetime | 最近编辑时间 |
  672 +
  673 +**响应示例**
  674 +
  675 +```json
  676 +{
  677 + "id": "opt_allergens_001",
  678 + "optionCode": "OPT_ALLERGENS",
  679 + "optionName": "Allergens",
  680 + "optionValuesJson": "[\"Peanuts\",\"Dairy\"]",
  681 + "state": true,
  682 + "availabilityType": "SPECIFIED",
  683 + "orderNum": 1,
  684 + "region": "East Region",
  685 + "location": "UNCC store",
  686 + "regionIds": ["fl_group_id_east"],
  687 + "locationIds": ["11111111-1111-1111-1111-111111111111"],
  688 + "lastEdited": "2026-05-18T12:00:00"
  689 +}
  690 +```
  691 +
  692 +### 库内核对 SQL
  693 +
  694 +```sql
  695 +SELECT
  696 + o.Id,
  697 + o.OptionName,
  698 + o.AvailabilityType,
  699 + ol.LocationId,
  700 + loc.GroupName AS region_name,
  701 + COALESCE(NULLIF(TRIM(loc.LocationName), ''), loc.LocationCode) AS location_name
  702 +FROM fl_label_multiple_option o
  703 +LEFT JOIN fl_label_multiple_option_location ol ON ol.MultipleOptionId = o.Id
  704 +LEFT JOIN location loc ON loc.Id = ol.LocationId AND loc.IsDeleted = 0
  705 +WHERE o.IsDeleted = 0
  706 +ORDER BY o.OrderNum DESC;
  707 +```
  708 +
  709 +### 联调注意
  710 +
  711 +| 现象 | 可能原因 |
  712 +|------|----------|
  713 +| region / location 为 `All Regions` / `All Locations` | `availabilityType = ALL`(历史数据默认) |
  714 +| region / location 为「无」 | `SPECIFIED` 但未写入 `fl_label_multiple_option_location` |
  715 +| 筛 Region 后列表为空 | 非 `ALL` 且关联表未覆盖该门店 |
  716 +| 接口报错列不存在 | 未执行 `fl_label_multiple_option_scope.sql` |
  717 +| 保存报「至少需要匹配到一个有效门店」 | Region 下无门店且 `locationIds` 为空 |
  718 +
  719 +---
  720 +
  721 +## label-type 标签类型
  722 +
  723 +**应用服务**:`LabelTypeAppService`
  724 +**命名约定**:UI **Region** = **`regionIds` / `groupIds`**(`fl_group.Id`);UI **Location** = **`locationIds`**(`location.Id`)。存储表 **`fl_label_type_location`**(与 label-category 一致)。
  725 +
  726 +### 数据库变更(新增/编辑 Region·Location 前须执行)
  727 +
  728 +脚本:`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_type_scope.sql`
  729 +
  730 +```sql
  731 +ALTER TABLE `fl_label_type`
  732 + ADD COLUMN `AvailabilityType` varchar(20) NOT NULL DEFAULT 'ALL' COMMENT '门店可用范围:ALL/SPECIFIED' AFTER `State`;
  733 +
  734 +CREATE TABLE IF NOT EXISTS `fl_label_type_location` (
  735 + `Id` varchar(36) NOT NULL,
  736 + `LabelTypeId` varchar(36) NOT NULL,
  737 + `LocationId` varchar(36) NOT NULL,
  738 + `CreationTime` datetime NOT NULL,
  739 + `CreatorId` varchar(36) DEFAULT NULL,
  740 + PRIMARY KEY (`Id`),
  741 + KEY `idx_fl_ltl_type` (`LabelTypeId`),
  742 + KEY `idx_fl_ltl_location` (`LocationId`)
  743 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签类型适用门店';
  744 +```
  745 +
  746 +| 表/字段 | 说明 |
  747 +|---------|------|
  748 +| `fl_label_type.AvailabilityType` | `ALL` = 全部门店;`SPECIFIED` = 仅关联表内门店 |
  749 +| `fl_label_type_location` | `LabelTypeId` + `LocationId` 多选落库 |
  750 +
  751 +---
  752 +
  753 +### 新增 `POST /api/app/label-type`、编辑 `PUT /api/app/label-type/{id}`
  754 +
  755 +| 字段 | 类型 | 必填 | 说明 |
  756 +|------|------|------|------|
  757 +| typeCode | string | 是 | 类型编码 |
  758 +| typeName | string | 是 | 类型名称 |
  759 +| state | bool | 否 | 默认 `true` |
  760 +| orderNum | int | 否 | 排序 |
  761 +| availabilityType | string | 否 | `ALL` / `SPECIFIED`;传了 region/location 数组时自动 **`SPECIFIED`** |
  762 +| **regionIds** | string[] | 否 | **Region 多选**(`fl_group.Id`);推荐字段名 |
  763 +| **groupIds** | string[] | 否 | 与 `regionIds` 合并去重(兼容) |
  764 +| **locationIds** | string[] | 否 | **Location 多选**(`location.Id`) |
  765 +
  766 +**合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds` **取并集** → 写入 `fl_label_type_location`;`SPECIFIED` 时至少 **1** 个有效门店,否则报错:`指定适用区域或门店时,至少需要匹配到一个有效门店`。
  767 +
  768 +**请求示例**
  769 +
  770 +```json
  771 +POST /api/app/label-type
  772 +{
  773 + "typeCode": "PRICE",
  774 + "typeName": "Price Label",
  775 + "state": true,
  776 + "orderNum": 10,
  777 + "availabilityType": "SPECIFIED",
  778 + "regionIds": ["fl_group_id_east"],
  779 + "locationIds": ["11111111-1111-1111-1111-111111111111"]
  780 +}
  781 +```
  782 +
  783 +**详情 `GET /api/app/label-type/{id}` 出参(范围字段)**
  784 +
  785 +| 字段 | 说明 |
  786 +|------|------|
  787 +| availabilityType | `ALL` / `SPECIFIED` |
  788 +| regionIds | Region Id 数组(`SPECIFIED` 时由门店反推) |
  789 +| groupIds | 与 `regionIds` 相同 |
  790 +| locationIds | 已绑定门店 Id |
  791 +
  792 +**编辑**:Body 字段与新增相同;会 **先删后插** 重建 `fl_label_type_location`。
  793 +
  794 +---
  795 +
  796 +### 列表 `GET /api/app/label-type`
  797 +
  798 +示例:`?SkipCount=1&MaxResultCount=10`
  799 +
  800 +### 列表 UI 列与 API 字段对照
  801 +
  802 +| 列表列(UI) | API 字段(camelCase) | 类型 | 说明 |
  803 +|--------------|----------------------|------|------|
  804 +| **No. of Labels** | **noOfLabels** | long | 该 Label Type 下 **未删除** 标签条数(`fl_label.LabelTypeId = fl_label_type.Id` 且 `IsDeleted = 0`) |
  805 +| **Region** | **region** | string | `ALL` → `All Regions`;`SPECIFIED` → 绑定门店 `GroupName` 拼接;未配置为 **`无`** |
  806 +| **Location** | **location** | string | `ALL` → `All Locations`;`SPECIFIED` → 门店名拼接 |
  807 +| (辅助) | regionIds | string[] | Region 主键(`fl_group.Id`,由门店反推) |
  808 +| (辅助) | locationIds | string[] | 门店主键(`location.Id`) |
  809 +| Last Edited | lastEdited | datetime | 类型与下属标签最近编辑时间的较大值 |
  810 +
  811 +### No. of Labels 是否需要库字段?
  812 +
  813 +**不需要** `NoOfLabels` 物理列,由 `fl_label` 实时 `COUNT`。
  814 +
  815 +| 表 | 作用 |
  816 +|----|------|
  817 +| `fl_label_type` | 类型主数据 + **`AvailabilityType`** |
  818 +| `fl_label_type_location` | 新增/编辑时 Region·Location 多选落库 |
  819 +| `fl_label` | 标签实例;**`noOfLabels`** 统计来源 |
  820 +| `location` | 门店;Region 展示用 **`GroupName`** |
  821 +
  822 +**统计逻辑(与代码一致)**:
  823 +
  824 +```sql
  825 +-- 某类型下标签数(No. of Labels)
  826 +SELECT COUNT(*) AS no_of_labels
  827 +FROM fl_label
  828 +WHERE IsDeleted = 0
  829 + AND LabelTypeId = :labelTypeId;
  830 +```
  831 +
  832 +列表接口在分页查出类型后,对当前页 `Id` 批量 `GROUP BY LabelTypeId` 计数,避免 N+1;**标签增删改后无需改类型表**,下次列表即反映最新数量。
  833 +
  834 +### 变更说明
  835 +
  836 +| 项 | 说明 |
  837 +|----|------|
  838 +| **列表** `GET /api/app/label-type` | 出参增加 **`noOfLabels`**、**`region`**、**`location`**、**`regionIds`**、**`locationIds`**、**`lastEdited`** |
  839 +| **列表筛选** | Query 可选 **`groupId`**(Region)、**`locationId`**(Location);`locationId` 优先于 `groupId` |
  840 +| **统计一致性** | 传 Region/门店筛选时,**`noOfLabels`**、**`region`/`location`** 仅统计筛选范围内标签 |
  841 +| **筛选语义** | 传 `groupId`/`locationId` 时:返回 **`availabilityType=ALL`** 或 **关联门店命中** 的类型(与 label-category 一致) |
  842 +| **列表 region/location** | 来自 **配置**(`AvailabilityType` + `fl_label_type_location`),非仅标签反推 |
  843 +
  844 +**命名约定**:UI **Region** = Query **`groupId`**(`fl_group.Id`);UI **Location** = Query **`locationId`**(`location.Id`)。
  845 +
  846 +### 列表 Query
  847 +
  848 +| 字段 | 类型 | 必填 | 说明 |
  849 +|------|------|------|------|
  850 +| skipCount | int | 是 | 跳过条数(项目约定:第 1 页常为 `1`) |
  851 +| maxResultCount | int | 是 | 每页条数 |
  852 +| sorting | string | 否 | 排序,如 `orderNum desc` |
  853 +| keyword | string | 否 | 匹配 `typeCode`、`typeName` |
  854 +| state | bool | 否 | 启用状态 |
  855 +| **groupId** | string | 否 | **Region** 筛选(`fl_group.Id`) |
  856 +| **locationId** | string | 否 | **Location** 筛选(`location.Id`);**优先于** `groupId` |
  857 +
  858 +**请求示例(默认列表,含三列出参)**
  859 +
  860 +```bash
  861 +GET /api/app/label-type?SkipCount=1&MaxResultCount=10
  862 +Authorization: {token}
  863 +```
  864 +
  865 +**按 Region / Location 筛选**
  866 +
  867 +```bash
  868 +GET /api/app/label-type?SkipCount=1&MaxResultCount=10&groupId=你的fl_group主键
  869 +Authorization: {token}
  870 +```
  871 +
  872 +```bash
  873 +GET /api/app/label-type?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111
  874 +Authorization: {token}
  875 +```
  876 +
  877 +### 列表出参 `items[]`(节选)
  878 +
  879 +| 字段 | 类型 | 说明 |
  880 +|------|------|------|
  881 +| id | string | 类型主键(`fl_label_type.Id`) |
  882 +| typeCode / typeName | string | 编码、名称 |
  883 +| state / orderNum | bool / int | 状态、排序 |
  884 +| availabilityType | string | `ALL` / `SPECIFIED` |
  885 +| **noOfLabels** | long | 列表列 **No. of Labels** |
  886 +| **region** | string | 列表列 **Region** |
  887 +| **location** | string | 列表列 **Location** |
  888 +| regionIds | string[] | Region Id(`fl_group.Id`) |
  889 +| locationIds | string[] | 门店 Id |
  890 +| lastEdited | datetime | 最近编辑时间 |
  891 +
  892 +**响应 `items[]` 示例**
  893 +
  894 +```json
  895 +{
  896 + "id": "type_price_001",
  897 + "typeCode": "PRICE",
  898 + "typeName": "Price Label",
  899 + "state": true,
  900 + "availabilityType": "SPECIFIED",
  901 + "orderNum": 10,
  902 + "region": "East Region",
  903 + "location": "UNCC store, Central Park Store",
  904 + "regionIds": ["fl_group_id_east"],
  905 + "locationIds": ["11111111-1111-1111-1111-111111111111"],
  906 + "noOfLabels": 8,
  907 + "lastEdited": "2026-05-18T10:00:00"
  908 +}
  909 +```
  910 +
  911 +### 库内核对 SQL(只读,无需执行 DDL)
  912 +
  913 +**1)确认 `fl_label_type` 无 NoOfLabels 列(不应加列)**
  914 +
  915 +```sql
  916 +SELECT COLUMN_NAME
  917 +FROM information_schema.COLUMNS
  918 +WHERE TABLE_SCHEMA = DATABASE()
  919 + AND TABLE_NAME = 'fl_label_type'
  920 +ORDER BY ORDINAL_POSITION;
  921 +```
  922 +
  923 +**2)按类型统计标签数(与接口 `noOfLabels` 一致)**
  924 +
  925 +```sql
  926 +SELECT
  927 + t.Id,
  928 + t.TypeCode,
  929 + t.TypeName,
  930 + COUNT(l.Id) AS no_of_labels
  931 +FROM fl_label_type t
  932 +LEFT JOIN fl_label l
  933 + ON l.LabelTypeId = t.Id AND l.IsDeleted = 0
  934 +WHERE t.IsDeleted = 0
  935 +GROUP BY t.Id, t.TypeCode, t.TypeName
  936 +ORDER BY t.OrderNum DESC, t.CreationTime DESC;
  937 +```
  938 +
  939 +**3)某类型已配置的 Region / Location(与列表 region/location 一致)**
  940 +
  941 +```sql
  942 +SELECT
  943 + t.Id,
  944 + t.TypeName,
  945 + t.AvailabilityType,
  946 + tl.LocationId,
  947 + loc.GroupName AS region_name,
  948 + COALESCE(NULLIF(TRIM(loc.LocationName), ''), loc.LocationCode) AS location_name
  949 +FROM fl_label_type t
  950 +LEFT JOIN fl_label_type_location tl ON tl.LabelTypeId = t.Id
  951 +LEFT JOIN location loc ON loc.Id = tl.LocationId AND loc.IsDeleted = 0
  952 +WHERE t.IsDeleted = 0
  953 + AND t.Id = :labelTypeId;
  954 +```
  955 +
  956 +### 联调注意(label-type)
  957 +
  958 +| 现象 | 可能原因 |
  959 +|------|----------|
  960 +| **noOfLabels 为 0** | 该类型下尚无标签,或标签均已逻辑删除 |
  961 +| region / location 为「无」 | `SPECIFIED` 但未绑定门店,或门店无 `GroupName`/名称 |
  962 +| 保存报「至少需要匹配到一个有效门店」 | Region 下无门店且 `locationIds` 为空 |
  963 +| 筛 Region 后列表变空 | 非 `ALL` 且 `fl_label_type_location` 未覆盖该门店 |
  964 +| 未执行 DDL | 先跑 `fl_label_type_scope.sql` |
  965 +| noOfLabels 与标签列表不一致 | 对比时 label 列表需传与 label-type 相同的 `groupId`/`locationId` |
  966 +| 前端列空白 | 绑定 **`noOfLabels`**、**`region`**、**`location`**(勿用 `creationTime` 代替 `lastEdited`) |
  967 +
  968 +> 与 **label-category**(见 `5-17接口优化.md`)区别:分类有 `availabilityType=ALL`;**标签类型**无 ALL,三列均由 **`fl_label`** 实时汇总。
  969 +
  970 +> **不提供** `ALTER TABLE fl_label_type ADD NoOfLabels ...`:冗余字段易与 `fl_label` 不一致,且每次标签变更都要维护计数。
  971 +
  972 +---
  973 +
  974 +## team-member 列表 Company·Region·Location 筛选
  975 +
  976 +**应用服务**:`TeamMemberAppService`
  977 +**接口**:`GET /api/app/team-member`(分页;PDF 导出 `export-team-members-pdf` 使用相同 Query 筛选)
  978 +**辅助类**:`LocationScopeBindingHelper.ResolveFilteredLocationIdsForListAsync`
  979 +
  980 +### Query 参数(在原有 Keyword / RoleId / State 基础上)
  981 +
  982 +| UI | Query 参数 | 说明 |
  983 +|----|------------|------|
  984 +| Company | `partnerId` | `fl_partner.Id` |
  985 +| Region | `groupId` | `fl_group.Id` |
  986 +| Location | `locationId` | `location.Id`(Guid 字符串) |
  987 +
  988 +**筛选优先级**(与 product 列表一致):传 `locationId` 时仅按该门店;否则传 `groupId` 按 Region 下全部门店;否则传 `partnerId` 按 Company 下全部门店;均未传则不按组织范围过滤。
  989 +
  990 +**命中规则**:返回在 **`userlocation`** 中至少绑定一家「落在上述门店集合内」的成员;列表行内 `assignedLocations` 在传入范围参数时仅展示该范围内的绑定门店。
  991 +
  992 +### 请求示例
  993 +
  994 +```http
  995 +GET /api/app/team-member?SkipCount=1&MaxResultCount=10&partnerId={fl_partner.Id}
  996 +Authorization: Bearer {token}
  997 +
  998 +GET /api/app/team-member?SkipCount=1&MaxResultCount=10&groupId={fl_group.Id}
  999 +
  1000 +GET /api/app/team-member?SkipCount=1&MaxResultCount=10&locationId={location.Id}
  1001 +```
  1002 +
  1003 +可与 `Keyword`、`RoleId`、`State`、`Sorting` 组合使用。
  1004 +
  1005 +### 库内核对
  1006 +
  1007 +```sql
  1008 +-- 某 Region 下应出现在列表中的成员(userId 去重)
  1009 +SELECT DISTINCT ul.UserId
  1010 +FROM userlocation ul
  1011 +INNER JOIN location loc ON ul.LocationId = CAST(loc.Id AS CHAR) AND loc.IsDeleted = 0
  1012 +WHERE ul.IsDeleted = 0
  1013 + AND loc.Partner = :partnerName
  1014 + AND loc.GroupName = :groupName;
  1015 +```
  1016 +
  1017 +### 联调注意
  1018 +
  1019 +| 现象 | 处理 |
  1020 +|------|------|
  1021 +| 传了 `groupId` 仍无数据 | 确认成员 `userlocation` 是否绑定该区域下门店 |
  1022 +| `partnerId` / `groupId` 无效 | 返回空列表(非 404) |
  1023 +| 与 5-17 新增字段关系 | 列表 **筛选** 用 Query;新增/编辑 Body 仍用 `partnerId` / `regionIds` / `locationIds`(见 `5-17接口优化.md`) |
  1024 +
  1025 +---
  1026 +
  1027 +## team-member 新增用户无法平台登录
  1028 +
  1029 +### 问题现象
  1030 +
  1031 +通过 **`POST /api/app/team-member`** 新增用户(如 `email=123@qq.com`、`userName=1234`)后,在平台登录页用 **邮箱 + 创建时密码** 登录失败(`Sign-in failed: incorrect email or password`)。
  1032 +
  1033 +### 根因(两处)
  1034 +
  1035 +**1)新增时双重 `BuildPassword`(已修复)**
  1036 +
  1037 +- `new UserAggregateRoot(userName, password, …)` 构造函数内已 `BuildPassword()` 一次;
  1038 +- 若再调用 `user.BuildPassword()`,会对 **已哈希值再哈希**,明文登录必失败。
  1039 +
  1040 +**2)改密时 SqlSugar `IsOwnsOne` 只更新了 Salt、未更新 Password(本次主因)**
  1041 +
  1042 +- `PUT /api/app/team-member/{id}` 传入新明文 → `BuildPassword()` 会生成 **新 Salt + 新 Password 哈希**;
  1043 +- `UpdateAsync` 整表更新时 **`Password` 列可能未写入**,库中仍保留旧哈希(例如与 admin 相同的 `ANg9hGZC…`),但 **Salt 已是新值**;
  1044 +- 校验 `SHA2Encode(明文, Salt)` 与库中 `Password` 永远对不上(查库可见 `123@qq.com` 与 `admin` 的 `Password` 相同、`Salt` 不同)。
  1045 +
  1046 +### 代码修复
  1047 +
  1048 +- 新增:与 `UserDataSeed` 一致,`EncryPassword = new EncryPasswordValueObject(明文)` 后 **只调用一次** `BuildPassword()`。
  1049 +- 改密 / 重置密码:改密后调用 `UserPasswordHelper.EnsurePasswordColumnsPersistedAsync`,**显式 SET `Password` 与 `Salt`**(`Yi.Framework.Rbac.Domain/Helpers/UserPasswordHelper.cs`)。
  1050 +- 登录校验统一走 `UserPasswordHelper.VerifyPlainPassword` / `UserAggregateRoot.JudgePassword`。
  1051 +
  1052 +### 平台登录约定
  1053 +
  1054 +| 项目 | 说明 |
  1055 +|------|------|
  1056 +| 接口 | `POST /api/app/account/login`(`AccountService.PostLoginAsync`) |
  1057 +| 入参 | `userName` 填 **邮箱**(平台 UI 的 Email 框);`password` 为明文 |
  1058 +| 匹配 | `User.Email`(忽略大小写)优先;否则 `User.UserName` 与邮箱相同也可 |
  1059 +| 状态 | `State = true` 且未删除才可登录 |
  1060 +
  1061 +**登录示例**
  1062 +
  1063 +```json
  1064 +POST /api/app/account/login
  1065 +{
  1066 + "userName": "123@qq.com",
  1067 + "password": "创建时设置的明文密码"
  1068 +}
  1069 +```
  1070 +
  1071 +### 已受影响账号的处理
  1072 +
  1073 +部署修复后,**已受影响账号**(如 `123@qq.com`,库内 `Password`/`Salt` 不一致)必须 **再保存一次密码**:
  1074 +
  1075 +```http
  1076 +PUT /api/app/team-member/{id}
  1077 +Content-Type: application/json
  1078 +
  1079 +{
  1080 + "fullName": "显示名",
  1081 + "userName": "1234",
  1082 + "email": "123@qq.com",
  1083 + "password": "新的明文密码",
  1084 + "phone": 1234567890,
  1085 + "roleId": "角色Guid",
  1086 + "locationIds": ["门店Id"],
  1087 + "state": true
  1088 +}
  1089 +```
  1090 +
  1091 +保存后用 **邮箱 + 新密码** 登录。
  1092 +
  1093 +**库内核对**
  1094 +
  1095 +```sql
  1096 +SELECT Id, UserName, Email, State, Password, Salt
  1097 +FROM User
  1098 +WHERE Email = '123@qq.com' AND IsDeleted = 0;
  1099 +-- 正常:同一用户的 Password 须与 SHA512(salt+明文) 一致;勿出现与 admin 相同 Password、不同 Salt。
  1100 +```
  1101 +
  1102 +### 新增成员入参提醒
  1103 +
  1104 +| 字段 | 说明 |
  1105 +|------|------|
  1106 +| userName | 登录名(可与邮箱不同);平台登录仍建议用 **email** 字段 |
  1107 +| email | 平台登录主键(须为合法邮箱格式) |
  1108 +| password | 明文,至少 6 位(`UserManager` 校验) |
  1109 +| roleId | 须绑定角色,否则登录后可能无菜单 |
  1110 +| locationIds / partnerId / groupIds | 门店范围(见 5-17 team-member 章节) |
  1111 +
  1112 +---
  1113 +
  1114 +## rbac-role accessPermissions
  1115 +
  1116 +以下为 **`/api/app/rbac-role`** 相关说明(原 5-18 内容)。
  1117 +
  1118 +---
  1119 +
  1120 +## 问题与根因
  1121 +
  1122 +| 现象 | 根因 |
  1123 +|------|------|
  1124 +| `POST`/`PUT` 传了 `accessPermissions` 后仍无菜单绑定 | 库表 **`Menu.PermissionCode` 全为空**,按 Code 解析不到 `MenuId`;或入参 `menuIds: []` 抢先清空绑定 |
  1125 +| `GET /api/app/rbac-role/{id}` 的 `accessPermissions` 为 `""` | 无 **`RoleMenu`** 记录,或已绑定菜单的 **`PermissionCode` 为空** |
  1126 +| `menuIds` 详情为空(历史问题) | 曾错误查询 `rolemenu` 字符串表;已改为查询 **`RoleMenu`**(Guid) |
  1127 +
  1128 +**数据链路(正确)**
  1129 +
  1130 +```text
  1131 +accessPermissions(入参/出参字符串)
  1132 + ↔ Menu.PermissionCode(权限码,可为空时按 Router 推导 menu.xxx)
  1133 + ↔ RoleMenu(RoleId + MenuId)
  1134 + ↔ Role
  1135 +```
  1136 +
  1137 +---
  1138 +
  1139 +## 变更摘要
  1140 +
  1141 +| 项 | 说明 |
  1142 +|----|------|
  1143 +| **绑定写入** | `accessPermissions` 解析为 `MenuId` 后 **覆盖写入 `RoleMenu`**;解析不到任何菜单时 **返回明确错误** |
  1144 +| **出参汇总** | `GET` 列表/详情从 **`RoleMenu` + `Menu.PermissionCode`** 汇总;`PermissionCode` 为空时按 **`Router`** 推导(与回填 SQL 一致) |
  1145 +| **详情 menuIds** | 从 **`RoleMenu`** 读取,不再查错误的 `rolemenu` 映射 |
  1146 +| **RoleMenu 主键** | 插入时生成 **`Id`**(`RoleMenu` 表必填) |
  1147 +| **数据库脚本** | `scripts/menu_backfill_permission_code.sql` 批量回填 `PermissionCode` |
  1148 +
  1149 +---
  1150 +
  1151 +## 公共约定
  1152 +
  1153 +- **宿主**:`Yi.Abp.Web`;路由前缀 `api/app`。
  1154 +- **应用服务**:`RbacRoleAppService`(模块 `food-labeling-us`)。
  1155 +- **鉴权**:`Authorization: {token}`。
  1156 +
  1157 +---
  1158 +
  1159 +## accessPermissions 规则
  1160 +
  1161 +### 出参(只读)
  1162 +
  1163 +- 查询 **`RoleMenu`** 中该角色绑定的菜单,取 **`Menu.PermissionCode`**(非空优先)。
  1164 +- 若 `PermissionCode` 为空,按 **`Router`** 推导:`/labels` → `menu.labels`。
  1165 +- 去重后按字母序用 **`, `** 拼接;无绑定为 `""`。
  1166 +
  1167 +### 入参(写入)
  1168 +
  1169 +| 场景 | menuIds | accessPermissions | 行为 |
  1170 +|------|---------|-------------------|------|
  1171 +| 新增/编辑 | 非空数组 | 任意 | **以 menuIds 为准**(覆盖 `RoleMenu`) |
  1172 +| 新增/编辑 | 不传 / null | 非空字符串 | 按 PermissionCode 解析菜单并 **覆盖绑定** |
  1173 +| 新增/编辑 | 不传 / null | `""` | **清空** `RoleMenu` |
  1174 +| 新增/编辑 | `[]` | 不传 | **清空** `RoleMenu` |
  1175 +| 编辑 | 不传 | 不传 / null | **不修改** 已有菜单绑定 |
  1176 +
  1177 +- 解析 **忽略大小写**;支持英文逗号、分号分隔。
  1178 +- 若传了非空 `accessPermissions` 但 **0 条菜单匹配**,接口返回业务错误(提示检查 `PermissionCode` 或执行回填脚本)。
  1179 +
  1180 +---
  1181 +
  1182 +## 1 角色详情
  1183 +
  1184 +| 项目 | 说明 |
  1185 +|------|------|
  1186 +| HTTP | `GET` |
  1187 +| 路径 | `/api/app/rbac-role/{id}` |
  1188 +| 示例 | `/api/app/rbac-role/3a1f077b-3665-63f2-5fea-0fd7e7044b88` |
  1189 +
  1190 +**响应 `data` 字段(节选)**
  1191 +
  1192 +| 字段 | 说明 |
  1193 +|------|------|
  1194 +| menuIds | 已绑定菜单 Guid 字符串数组(来自 `RoleMenu`) |
  1195 +| accessPermissions | 已绑定菜单权限码汇总,如 `menu.account-management, menu.labels` |
  1196 +
  1197 +---
  1198 +
  1199 +## 2 新增角色
  1200 +
  1201 +| 项目 | 说明 |
  1202 +|------|------|
  1203 +| HTTP | `POST` |
  1204 +| 路径 | `/api/app/rbac-role` |
  1205 +
  1206 +**请求示例(accessPermissions)**
  1207 +
  1208 +```json
  1209 +{
  1210 + "roleName": "Partner Admin",
  1211 + "roleCode": "admin",
  1212 + "state": true,
  1213 + "accessPermissions": "menu.labels, menu.label-categories, menu.account-management"
  1214 +}
  1215 +```
  1216 +
  1217 +> 须先保证 `Menu` 表存在对应 **`PermissionCode`**(或执行回填脚本后使用 `menu.{router-segment}` 形式)。
  1218 +
  1219 +---
  1220 +
  1221 +## 3 编辑角色
  1222 +
  1223 +| 项目 | 说明 |
  1224 +|------|------|
  1225 +| HTTP | `PUT` |
  1226 +| 路径 | `/api/app/rbac-role/{id}` |
  1227 +| Body | 与新增相同(`RbacRoleUpdateInputVo`) |
  1228 +
  1229 +---
  1230 +
  1231 +## 数据库准备(必做)
  1232 +
  1233 +执行(按环境选择库):
  1234 +
  1235 +`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/menu_backfill_permission_code.sql`
  1236 +
  1237 +回填后示例:
  1238 +
  1239 +| MenuName | Router | PermissionCode |
  1240 +|----------|--------|----------------|
  1241 +| Labels | /labels | menu.labels |
  1242 +| Account Management | /account-management | menu.account-management |
  1243 +
  1244 +**验证 SQL**
  1245 +
  1246 +```sql
  1247 +SELECT r.RoleName, m.MenuName, m.PermissionCode
  1248 +FROM Role r
  1249 +INNER JOIN RoleMenu rm ON rm.RoleId = r.Id
  1250 +INNER JOIN Menu m ON m.Id = rm.MenuId AND m.IsDeleted = 0
  1251 +WHERE r.Id = '3a1f077b-3665-63f2-5fea-0fd7e7044b88';
  1252 +```
  1253 +
  1254 +---
  1255 +
  1256 +## 关联接口
  1257 +
  1258 +| 接口 | 说明 |
  1259 +|------|------|
  1260 +| `POST /api/app/rbac-role-menu/set` | 仅维护 `RoleMenu`(`menuIds` 覆盖式),不直接写 `accessPermissions` 列 |
  1261 +| `GET /api/app/rbac-role-menu/menu-ids/{roleId}` | 查询已绑定菜单 Id |
  1262 +
  1263 +保存菜单权限后,再调 **`GET /api/app/rbac-role/{id}`** 应能看到非空 **`accessPermissions`**(在 `PermissionCode` 已配置前提下)。
  1264 +
  1265 +---
  1266 +
  1267 +## 联调注意
  1268 +
  1269 +| 现象 | 处理 |
  1270 +|------|------|
  1271 +| accessPermissions 仍为空 | 1)查 `RoleMenu` 是否有记录;2)查 `Menu.PermissionCode` 是否已回填 |
  1272 +| 保存报未匹配到菜单 | `accessPermissions` 与库中 Code 不一致;先执行回填脚本或改用 `menuIds` |
  1273 +| 同时传 `menuIds: []` 与 accessPermissions | **menuIds 优先**,空数组会清空绑定,accessPermissions 被忽略 |
  1274 +
  1275 +---
  1276 +
  1277 +## 修订记录
  1278 +
  1279 +| 日期 | 说明 |
  1280 +|------|------|
  1281 +| 2026-05-18 | 修复 rbac-role accessPermissions 读写;RoleMenu 绑定;Menu.PermissionCode 回填脚本 |
  1282 +| 2026-05-18 | 修复 team-member 登录:去掉双重 BuildPassword;改密显式落库 Password+Salt;已建账号需 PUT 重置密码 |
  1283 +| 2026-05-18 | label-type 列表 noOfLabels/region/location;新增编辑 regionIds+locationIds 多选;DDL `fl_label_type_scope.sql` |
  1284 +| 2026-05-18 | label-multiple-option 列表筛选与出参;新增/编辑 regionIds+locationIds 多选;DDL `fl_label_multiple_option_scope.sql` |
  1285 +| 2026-05-18 | reports print-log-list:Label ID 改为门店当日序号 yyyyMMdd-n(labelCode 字段) |
  1286 +| 2026-05-18 | reports print-log-list:Expiry Date 从 PrintInputJson 模板快照 elements 解析(expiryDateText) |
  1287 +| 2026-05-18 | reports label-report:管理员全量;非管理员按 userlocation 绑定门店统计(不按 CreatedBy) |
  1288 +| 2026-05-18 | us-app-auth location-detail:出参 operatingHours 读 location.OperatingHours |
  1289 +| 2026-05-18 | us-app get-print-log-list / get-label-report:管理员与 Partner 可看当前门店全部打印;reprint 同权 |
  1290 +| 2026-05-18 | us-app labeling-tree:产品已绑门店时不再因产品分类 SPECIFIED 无 location 关联而空树 |
  1291 +| 2026-05-18 | location 列表:管理员全部门店;非管理员仅绑定门店所属 Region(Partner+GroupName) |
  1292 +| 2026-05-18 | team-member 列表:Query 增加 partnerId / groupId / locationId(Company·Region·Location)筛选 |
  1293 +| 2026-05-18 | reports template-print-stat-list:按模板汇总打印标签数量(templateName + printedCount) |
... ...
泰额版/Food Labeling Management Platform/src/components/ui/select.tsx
... ... @@ -41,7 +41,7 @@ function SelectTrigger({
41 41 data-slot="select-trigger"
42 42 data-size={size}
43 43 className={cn(
44   - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  44 + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 overflow-hidden rounded-md border bg-input-background px-3 py-2 text-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1 *:data-[slot=select-value]:truncate [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
45 45 className,
46 46 )}
47 47 {...props}
... ...
美国版/Food Labeling Management App UniApp/src/App.vue
1 1 <script setup lang="ts">
2 2 import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
  3 +import { initOfflineSqlite } from "./utils/sqliteSync";
  4 +import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager";
  5 +import { isLoggedIn } from "./utils/authSession";
  6 +
  7 +let networkSyncInFlight = false;
3 8  
4 9 onLaunch(() => {
5   - // 静态页面模式,不做登录校验
  10 + void initOfflineSqlite();
  11 + uni.onNetworkStatusChange((res) => {
  12 + if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return;
  13 + networkSyncInFlight = true;
  14 + void syncNowAndRefreshCaches()
  15 + .catch(() => {})
  16 + .finally(() => {
  17 + networkSyncInFlight = false;
  18 + });
  19 + });
6 20 });
7 21  
8 22 onShow(() => {
... ...
美国版/Food Labeling Management App UniApp/src/locales/en.ts
... ... @@ -39,6 +39,8 @@ export default {
39 39 signingIn: 'Signing In...',
40 40 loginSuccess: 'Login successful',
41 41 loginFailed: 'Login failed',
  42 + offlineLoginFailed: 'Offline sign-in failed. Sign in online and tap Sync first.',
  43 + offlineLoginSuccess: 'Signed in offline',
42 44 fillRequired: 'Please enter email and password',
43 45 noStoresBound: 'No stores are linked to this account',
44 46 refreshStoresFail: 'Could not refresh store list',
... ...
美国版/Food Labeling Management App UniApp/src/locales/zh.ts
... ... @@ -39,6 +39,8 @@ export default {
39 39 signingIn: '登录中...',
40 40 loginSuccess: '登录成功',
41 41 loginFailed: '登录失败',
  42 + offlineLoginFailed: '离线登录失败,请先联网登录并点击同步后再断网使用',
  43 + offlineLoginSuccess: '已离线登录',
42 44 fillRequired: '请输入邮箱和密码',
43 45 noStoresBound: '当前账号未绑定门店',
44 46 refreshStoresFail: '门店列表刷新失败',
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
... ... @@ -107,31 +107,39 @@
107 107 >
108 108 <view class="cat-header" @click="toggleCategory(productCategoryRowKey(pCat, pIdx))">
109 109 <view class="cat-header-left">
110   - <view class="cat-header-thumb" :style="productCategoryThumbStyle(pCat)">
  110 + <view
  111 + class="cat-icon cat-header-thumb"
  112 + :class="
  113 + productCategoryVisual(pCat).mode === 'image'
  114 + ? 'cat-icon--photo'
  115 + : 'cat-icon--fallback'
  116 + "
  117 + :style="productCategoryThumbStyle(pCat)"
  118 + >
111 119 <image
112 120 v-if="productCategoryVisual(pCat).mode === 'image'"
113 121 :src="resolveMediaUrlForApp(productCategoryVisual(pCat).imageUrl) || ''"
114   - class="cat-header-img"
  122 + class="cat-photo"
115 123 mode="aspectFill"
116 124 />
117   - <view
  125 + <text
118 126 v-else-if="productCategoryVisual(pCat).mode === 'colorText'"
119   - class="cat-header-text cat-header-text--fill"
  127 + class="cat-icon-text cat-icon-text--on-color"
120 128 :style="{ color: productCategoryVisual(pCat).textColor || '#ffffff' }"
121 129 >
122   - <text class="cat-header-text-inner">{{ productCategoryVisual(pCat).text }}</text>
123   - </view>
  130 + {{ productCategoryVisual(pCat).text }}
  131 + </text>
124 132 <view
125 133 v-else-if="productCategoryVisual(pCat).mode === 'color'"
126 134 class="cat-header-color-fill"
127 135 :style="{ backgroundColor: productCategoryVisual(pCat).bg }"
128 136 />
129   - <view
  137 + <text
130 138 v-else-if="productCategoryVisual(pCat).mode === 'text'"
131   - class="cat-header-text"
  139 + class="cat-icon-text"
132 140 >
133   - <text class="cat-header-text-inner">{{ productCategoryVisual(pCat).text }}</text>
134   - </view>
  141 + {{ productCategoryVisual(pCat).text }}
  142 + </text>
135 143 <view v-else class="cat-header-fallback" :class="colorClassForName(pCat.name)">
136 144 <AppIcon name="food" size="sm" color="white" />
137 145 </view>
... ... @@ -163,15 +171,47 @@
163 171 class="food-card"
164 172 @click="handleProductClick(product, pCat.name)"
165 173 >
166   - <view class="food-img-wrap">
  174 + <view
  175 + class="food-img-wrap"
  176 + :class="
  177 + productVisual(product).mode === 'image'
  178 + ? 'food-img-wrap--photo'
  179 + : 'food-img-wrap--fallback'
  180 + "
  181 + :style="productThumbWrapStyle(product)"
  182 + >
167 183 <image
168   - v-if="productPhotoSrc(product)"
169   - :src="productPhotoSrc(product)"
  184 + v-if="productVisual(product).mode === 'image'"
  185 + :src="resolveMediaUrlForApp(productVisual(product).imageUrl) || ''"
170 186 class="food-img"
171 187 mode="aspectFill"
172 188 />
173   - <view v-else class="food-thumb-placeholder">
174   - <AppIcon name="layers" size="md" color="gray" />
  189 + <text
  190 + v-else-if="productVisual(product).mode === 'colorText'"
  191 + class="food-thumb-text food-thumb-text--on-color"
  192 + :style="{ color: productVisual(product).textColor || '#ffffff' }"
  193 + >
  194 + {{ productVisual(product).text }}
  195 + </text>
  196 + <text
  197 + v-else-if="productVisual(product).mode === 'text'"
  198 + class="food-thumb-text"
  199 + >
  200 + {{ productVisual(product).text }}
  201 + </text>
  202 + <view
  203 + v-else-if="productVisual(product).mode === 'color'"
  204 + class="food-thumb-color-fill"
  205 + :style="{ backgroundColor: productVisual(product).bg }"
  206 + />
  207 + <view
  208 + v-else
  209 + class="food-thumb-fallback"
  210 + :class="colorClassForName(product.productName)"
  211 + >
  212 + <text class="food-thumb-text food-thumb-text--on-color">
  213 + {{ productThumbFallbackText(product) }}
  214 + </text>
175 215 </view>
176 216 <view class="size-badge">
177 217 <text class="size-badge-text">{{ primaryLabelSizeText(product) }}</text>
... ... @@ -404,8 +444,40 @@ function colorClassForName(name: string): string {
404 444 return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length]
405 445 }
406 446  
407   -function productPhotoSrc(p: UsAppLabelingProductNodeDto): string {
408   - return resolveMediaUrlForApp(p.productImageUrl)
  447 +function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender {
  448 + const v = resolveCategoryButtonVisualFromDto({
  449 + buttonStyleJson: p.buttonStyleJson,
  450 + buttonAppearance: p.buttonAppearance,
  451 + displayText: p.displayText,
  452 + buttonBgColor: p.buttonBgColor,
  453 + buttonImageUrl: p.buttonImageUrl,
  454 + buttonTextColor: p.buttonTextColor,
  455 + categoryPhotoUrl: p.categoryPhotoUrl,
  456 + categoryName: p.productName,
  457 + name: p.productName,
  458 + })
  459 + if (v.mode !== 'none') return v
  460 + const legacyImg = (p.productImageUrl ?? '').trim()
  461 + if (legacyImg) return { mode: 'image', imageUrl: legacyImg }
  462 + return { mode: 'none' }
  463 +}
  464 +
  465 +function productThumbWrapStyle(p: UsAppLabelingProductNodeDto): Record<string, string> {
  466 + const v = productVisual(p)
  467 + if (v.mode === 'colorText' || v.mode === 'color') {
  468 + return { backgroundColor: v.bg }
  469 + }
  470 + return {}
  471 +}
  472 +
  473 +function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string {
  474 + const name = (p.productName ?? '').trim()
  475 + if (!name) return '?'
  476 + const parts = name.split(/\s+/).filter(Boolean)
  477 + if (parts.length >= 2) {
  478 + return (parts[0].slice(0, 1) + parts[1].slice(0, 1)).toUpperCase()
  479 + }
  480 + return name.slice(0, 4)
409 481 }
410 482  
411 483 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
... ... @@ -779,22 +851,13 @@ const goBluetoothPage = () =&gt; {
779 851 min-width: 0;
780 852 }
781 853  
  854 +/* 与侧栏 .cat-icon 同尺寸,仅去掉侧栏下边距 */
782 855 .cat-header-thumb {
783   - width: 56rpx;
784   - height: 56rpx;
785   - border-radius: 14rpx;
786   - overflow: hidden;
  856 + width: 64rpx;
  857 + height: 64rpx;
  858 + margin-bottom: 0;
787 859 flex-shrink: 0;
788 860 background: #f3f4f6;
789   - display: flex;
790   - align-items: center;
791   - justify-content: center;
792   -}
793   -
794   -.cat-header-img {
795   - width: 100%;
796   - height: 100%;
797   - display: block;
798 861 }
799 862  
800 863 .cat-header-color-fill {
... ... @@ -802,36 +865,6 @@ const goBluetoothPage = () =&gt; {
802 865 height: 100%;
803 866 }
804 867  
805   -.cat-header-text {
806   - width: 100%;
807   - height: 100%;
808   - display: flex;
809   - align-items: center;
810   - justify-content: center;
811   - padding: 6rpx;
812   -}
813   -
814   -.cat-header-text-inner {
815   - max-width: 100%;
816   - font-size: 22rpx;
817   - font-weight: 700;
818   - color: #111827;
819   - overflow: hidden;
820   - text-overflow: ellipsis;
821   - white-space: nowrap;
822   -}
823   -
824   -.cat-header-text--fill .cat-header-text-inner {
825   - color: inherit;
826   - white-space: normal;
827   - text-align: center;
828   - line-height: 1.15;
829   - display: -webkit-box;
830   - -webkit-line-clamp: 2;
831   - -webkit-box-orient: vertical;
832   - overflow: hidden;
833   -}
834   -
835 868 .cat-header-fallback {
836 869 width: 100%;
837 870 height: 100%;
... ... @@ -909,6 +942,12 @@ const goBluetoothPage = () =&gt; {
909 942 overflow: hidden;
910 943 }
911 944  
  945 +.food-img-wrap--fallback {
  946 + display: flex;
  947 + align-items: center;
  948 + justify-content: center;
  949 +}
  950 +
912 951 .food-img {
913 952 position: absolute;
914 953 left: 0;
... ... @@ -917,7 +956,39 @@ const goBluetoothPage = () =&gt; {
917 956 height: 100%;
918 957 }
919 958  
920   -.food-thumb-placeholder {
  959 +.food-thumb-text {
  960 + position: absolute;
  961 + left: 0;
  962 + top: 0;
  963 + width: 100%;
  964 + height: 100%;
  965 + display: flex;
  966 + align-items: center;
  967 + justify-content: center;
  968 + padding: 12rpx;
  969 + box-sizing: border-box;
  970 + font-size: 32rpx;
  971 + font-weight: 700;
  972 + color: #111827;
  973 + line-height: 1.15;
  974 + text-align: center;
  975 + word-break: break-word;
  976 +}
  977 +
  978 +.food-thumb-text--on-color {
  979 + font-size: 30rpx;
  980 + color: #ffffff;
  981 +}
  982 +
  983 +.food-thumb-color-fill {
  984 + position: absolute;
  985 + left: 0;
  986 + top: 0;
  987 + width: 100%;
  988 + height: 100%;
  989 +}
  990 +
  991 +.food-thumb-fallback {
921 992 position: absolute;
922 993 left: 0;
923 994 top: 0;
... ... @@ -926,7 +997,24 @@ const goBluetoothPage = () =&gt; {
926 997 display: flex;
927 998 align-items: center;
928 999 justify-content: center;
929   - background: linear-gradient(165deg, #f3f4f6 0%, #e5e7eb 45%, #d1d5db 100%);
  1000 + padding: 12rpx;
  1001 + box-sizing: border-box;
  1002 +}
  1003 +
  1004 +.food-thumb-fallback.bg-red {
  1005 + background: #ef4444;
  1006 +}
  1007 +.food-thumb-fallback.bg-blue {
  1008 + background: #3b82f6;
  1009 +}
  1010 +.food-thumb-fallback.bg-green {
  1011 + background: #22c55e;
  1012 +}
  1013 +.food-thumb-fallback.bg-orange {
  1014 + background: #f97316;
  1015 +}
  1016 +.food-thumb-fallback.bg-purple {
  1017 + background: #a855f7;
930 1018 }
931 1019  
932 1020 .size-badge {
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -285,6 +285,8 @@ import {
285 285 } from '../../services/usAppLabeling'
286 286 import {
287 287 applyTemplateProductDefaultValuesToTemplate,
  288 + applyProductCodeValueToTemplateScanElements,
  289 + extractProductCodeValueFromPreviewPayload,
288 290 extractTemplateProductDefaultValuesFromPreviewPayload,
289 291 normalizeLabelTemplateFromPreviewApi,
290 292 overlayProductNameOnPreviewTemplate,
... ... @@ -1343,10 +1345,17 @@ async function loadPreview() {
1343 1345 templateSize.value = labelSizeTextFromApi.trim()
1344 1346 }
1345 1347 const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw)
1346   - const tmplWithDefaults =
  1348 + let tmplWithDefaults =
1347 1349 Object.keys(productDefaults).length > 0
1348 1350 ? applyTemplateProductDefaultValuesToTemplate(tmplRaw, productDefaults)
1349 1351 : tmplRaw
  1352 + const productCodeValue = extractProductCodeValueFromPreviewPayload(raw)
  1353 + if (productCodeValue) {
  1354 + tmplWithDefaults = applyProductCodeValueToTemplateScanElements(
  1355 + tmplWithDefaults,
  1356 + productCodeValue,
  1357 + )
  1358 + }
1350 1359 /** 画布像素仅按接口 template 的 width / height / unit 换算(与 renderLabelPreviewCanvas.toCanvasPx 一致),不用 labelSizeText 覆盖以免单位被误判 */
1351 1360 const base = overlayProductNameOnPreviewTemplate(tmplWithDefaults, displayProductName.value)
1352 1361 basePreviewTemplate.value = base
... ...
美国版/Food Labeling Management App UniApp/src/pages/login/login.vue
... ... @@ -143,6 +143,12 @@ import {
143 143 isLoggedIn,
144 144 } from '../../utils/authSession'
145 145 import { performInitialOfflineSync } from '../../utils/offlineSyncManager'
  146 +import {
  147 + findOfflineAuthAccount,
  148 + initOfflineSqlite,
  149 + isNetworkOnline,
  150 + saveOfflineAuthAccount,
  151 +} from '../../utils/sqliteSync'
146 152 import AppIcon from '../../components/AppIcon.vue'
147 153  
148 154 const { t } = useI18n()
... ... @@ -325,6 +331,30 @@ const handleLogin = async () =&gt; {
325 331 }
326 332 isLoading.value = true
327 333 try {
  334 + await initOfflineSqlite()
  335 + const online = await isNetworkOnline()
  336 +
  337 + if (!online) {
  338 + const offline = await findOfflineAuthAccount(em, pw)
  339 + if (!offline?.token) {
  340 + uni.showToast({ title: t('login.offlineLoginFailed'), icon: 'none', duration: 2800 })
  341 + return
  342 + }
  343 + saveRememberPreference(rememberMe.value, em, pw)
  344 + saveAuthSession({
  345 + token: offline.token,
  346 + refreshToken: offline.refreshToken || '',
  347 + locations: offline.locations ?? [],
  348 + displayName: offline.displayName || displayNameFromEmail(em),
  349 + email: em,
  350 + })
  351 + uni.showToast({ title: t('login.offlineLoginSuccess'), icon: 'success' })
  352 + setTimeout(() => {
  353 + uni.redirectTo({ url: '/pages/store-select/store-select' })
  354 + }, 400)
  355 + return
  356 + }
  357 +
328 358 const res = await usAppLogin({ email: em, password: pw })
329 359 if (!res.token) {
330 360 uni.showToast({ title: t('login.loginFailed'), icon: 'none' })
... ... @@ -338,6 +368,14 @@ const handleLogin = async () =&gt; {
338 368 displayName: displayNameFromEmail(em),
339 369 email: em,
340 370 })
  371 + await saveOfflineAuthAccount({
  372 + email: em,
  373 + password: pw,
  374 + token: res.token,
  375 + refreshToken: res.refreshToken || '',
  376 + locations: res.locations ?? [],
  377 + displayName: displayNameFromEmail(em),
  378 + })
341 379 try {
342 380 await performInitialOfflineSync()
343 381 } catch {
... ...
美国版/Food Labeling Management App UniApp/src/services/locationSupport.ts
... ... @@ -3,6 +3,7 @@
3 3 * App 只读;需登录。
4 4 */
5 5 import { usAppApiRequest } from '../utils/usAppApiRequest'
  6 +import { fetchWithOfflineCache } from '../utils/sqliteSync'
6 7  
7 8 export type LocationSupportGetOutputDto = {
8 9 id?: string | null
... ... @@ -10,20 +11,28 @@ export type LocationSupportGetOutputDto = {
10 11 supportEmail?: string | null
11 12 }
12 13  
13   -export async function fetchGlobalSupportContact(): Promise<LocationSupportGetOutputDto | null> {
14   - const raw = await usAppApiRequest<LocationSupportGetOutputDto | null>({
15   - path: '/api/app/location-support/support',
16   - method: 'GET',
17   - auth: true,
18   - })
  14 +function normalizeSupportContact(raw: unknown): LocationSupportGetOutputDto | null {
19 15 if (raw === null || raw === undefined) return null
20 16 if (typeof raw !== 'object' || Array.isArray(raw)) return null
21   - const phone = String(raw.supportPhone ?? '').trim()
22   - const email = String(raw.supportEmail ?? '').trim()
  17 + const o = raw as LocationSupportGetOutputDto
  18 + const phone = String(o.supportPhone ?? '').trim()
  19 + const email = String(o.supportEmail ?? '').trim()
23 20 if (!phone && !email) return null
24 21 return {
25   - id: raw.id != null ? String(raw.id) : null,
  22 + id: o.id != null ? String(o.id) : null,
26 23 supportPhone: phone || null,
27 24 supportEmail: email || null,
28 25 }
29 26 }
  27 +
  28 +/** 在线缓存至 SQLite,离线可读 */
  29 +export async function fetchGlobalSupportContact(): Promise<LocationSupportGetOutputDto | null> {
  30 + return fetchWithOfflineCache('support', 'global-contact', async () => {
  31 + const raw = await usAppApiRequest<LocationSupportGetOutputDto | null>({
  32 + path: '/api/app/location-support/support',
  33 + method: 'GET',
  34 + auth: true,
  35 + })
  36 + return normalizeSupportContact(raw)
  37 + })
  38 +}
... ...
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
... ... @@ -114,16 +114,61 @@ export async function fetchUsAppLabelingTree(input: {
114 114 })
115 115 }
116 116  
117   -/** 接口 8.2 */
  117 +export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string {
  118 + const loc = String(body.locationId || '').trim()
  119 + const code = String(body.labelCode || '').trim()
  120 + const pid = String(body.productId || '').trim()
  121 + return `preview:${loc}:${code}:${pid}`
  122 +}
  123 +
  124 +/** 接口 8.2(在线写入 SQLite,离线读缓存) */
118 125 export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Promise<unknown> {
119   - return usAppApiRequest<unknown>({
120   - path: '/api/app/us-app-labeling/preview',
121   - method: 'POST',
122   - auth: true,
123   - data: body,
  126 + const key = buildLabelPreviewCacheKey(body)
  127 + return fetchWithOfflineCache('labeling', key, async () => {
  128 + return usAppApiRequest<unknown>({
  129 + path: '/api/app/us-app-labeling/preview',
  130 + method: 'POST',
  131 + auth: true,
  132 + data: body,
  133 + })
124 134 })
125 135 }
126 136  
  137 +const PREVIEW_PREFETCH_MAX_PER_LOCATION = 80
  138 +
  139 +/** 同步时预拉标签预览,供断网进入预览页 */
  140 +export async function prefetchUsAppLabelPreviewsForLocation(
  141 + locationId: string,
  142 + tree: UsAppLabelCategoryTreeNodeDto[]
  143 +): Promise<void> {
  144 + const loc = String(locationId || '').trim()
  145 + if (!loc) return
  146 + const jobs: UsAppLabelPreviewInputVo[] = []
  147 + outer: for (const cat of tree) {
  148 + for (const pc of cat.productCategories ?? []) {
  149 + for (const prod of pc.products ?? []) {
  150 + for (const lt of prod.labelTypes ?? []) {
  151 + const labelCode = String(lt.labelCode || '').trim()
  152 + if (!labelCode) continue
  153 + jobs.push({
  154 + locationId: loc,
  155 + labelCode,
  156 + productId: String(prod.productId || '').trim() || undefined,
  157 + })
  158 + if (jobs.length >= PREVIEW_PREFETCH_MAX_PER_LOCATION) break outer
  159 + }
  160 + }
  161 + }
  162 + }
  163 + for (const body of jobs) {
  164 + try {
  165 + await postUsAppLabelPreview(body)
  166 + } catch {
  167 + // 单条失败不阻断整店同步
  168 + }
  169 + }
  170 +}
  171 +
127 172 /**
128 173 * 接口 9.1 原始请求。
129 174 * 注意:仅供业务落库场景调用;打印机设置页「测试打印」、蓝牙页 Test Print 等 **不得** 使用(避免脏数据)。
... ...
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
... ... @@ -14,9 +14,19 @@ export interface UsAppLabelingProductNodeDto {
14 14 productName: string
15 15 productCode: string
16 16 productImageUrl: string | null
  17 + displayText?: string | null
  18 + categoryPhotoUrl?: string | null
  19 + buttonAppearance?: 'TEXT' | 'COLOR' | 'IMAGE' | string | string[] | null
  20 + buttonBgColor?: string | null
  21 + buttonImageUrl?: string | null
  22 + buttonTextColor?: string | null
  23 + buttonStyleJson?: string | null
17 24 subtitle: string
18 25 labelTypeCount: number
19 26 labelTypes: UsAppLabelTypeNodeDto[]
  27 + templateId?: string
  28 + templateCode?: string | null
  29 + templateLabelSizeText?: string | null
20 30 }
21 31  
22 32 export interface UsAppProductCategoryNodeDto {
... ...
美国版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts 0 → 100644
  1 +/** 与 Web 端 `barcodeFormat.ts` 选项顺序与取值一致 */
  2 +
  3 +export const BARCODE_FORMAT_OPTIONS = [
  4 + { label: 'Codabar', value: 'CODABAR' },
  5 + { label: 'Code39', value: 'CODE39' },
  6 + { label: 'Code128', value: 'CODE128' },
  7 + { label: 'EAN-2', value: 'EAN2' },
  8 + { label: 'EAN-5', value: 'EAN5' },
  9 + { label: 'EAN-8', value: 'EAN8' },
  10 + { label: 'EAN-13', value: 'EAN13' },
  11 + { label: 'ITF-14', value: 'ITF14' },
  12 + { label: 'MSI', value: 'MSI' },
  13 + { label: 'MSI10', value: 'MSI10' },
  14 + { label: 'MSI11', value: 'MSI11' },
  15 + { label: 'MSI1010', value: 'MSI1010' },
  16 + { label: 'MSI1110', value: 'MSI1110' },
  17 + { label: 'Pharmacode', value: 'PHARMACODE' },
  18 + { label: 'UPC(A)', value: 'UPCA' },
  19 +] as const
  20 +
  21 +export type BarcodeFormatValue = (typeof BARCODE_FORMAT_OPTIONS)[number]['value']
  22 +
  23 +export const DEFAULT_BARCODE_FORMAT: BarcodeFormatValue = BARCODE_FORMAT_OPTIONS[0].value
  24 +
  25 +export function normalizeBarcodeType (raw: unknown): BarcodeFormatValue {
  26 + const key = String(raw ?? '')
  27 + .trim()
  28 + .toUpperCase()
  29 + .replace(/[^A-Z0-9]/g, '')
  30 + const allowed = new Set(BARCODE_FORMAT_OPTIONS.map((o) => o.value))
  31 + if (allowed.has(key as BarcodeFormatValue)) return key as BarcodeFormatValue
  32 + if (key === 'UPC' || key === 'UPCA') return 'UPCA'
  33 + if (key === 'ITF') return 'ITF14'
  34 + return DEFAULT_BARCODE_FORMAT
  35 +}
  36 +
  37 +export function toTscBarcodeSymbology (barcodeType: unknown): string {
  38 + const key = normalizeBarcodeType(barcodeType)
  39 + const map: Record<BarcodeFormatValue, string> = {
  40 + CODABAR: 'CODA',
  41 + CODE39: '39',
  42 + CODE128: '128',
  43 + EAN2: '128',
  44 + EAN5: '128',
  45 + EAN8: 'EAN8',
  46 + EAN13: 'EAN13',
  47 + ITF14: 'ITF14',
  48 + MSI: 'MSI',
  49 + MSI10: 'MSI',
  50 + MSI11: 'MSI',
  51 + MSI1010: 'MSI',
  52 + MSI1110: 'MSI',
  53 + PHARMACODE: '128',
  54 + UPCA: 'UPCA',
  55 + }
  56 + return map[key] ?? 'CODA'
  57 +}
  58 +
  59 +export function toEscBarcodeTypeCode (barcodeType: unknown): number {
  60 + const key = normalizeBarcodeType(barcodeType)
  61 + const map: Record<BarcodeFormatValue, number> = {
  62 + CODABAR: 71,
  63 + CODE39: 69,
  64 + CODE128: 73,
  65 + EAN2: 73,
  66 + EAN5: 73,
  67 + EAN8: 68,
  68 + EAN13: 67,
  69 + ITF14: 70,
  70 + MSI: 73,
  71 + MSI10: 73,
  72 + MSI11: 73,
  73 + MSI1010: 73,
  74 + MSI1110: 73,
  75 + PHARMACODE: 73,
  76 + UPCA: 65,
  77 + }
  78 + return map[key] ?? 71
  79 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
... ... @@ -234,6 +234,79 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u
234 234 )
235 235 }
236 236  
  237 +function isTemplateSectionScanElement(el: SystemTemplateElementBase): boolean {
  238 + const cfg = el.config || {}
  239 + const typeAdd = String(
  240 + (el as { typeAdd?: string }).typeAdd ??
  241 + cfg.typeAdd ??
  242 + cfg.TypeAdd ??
  243 + el.type ??
  244 + '',
  245 + )
  246 + .trim()
  247 + .toLowerCase()
  248 + if (!typeAdd.startsWith('template_')) return false
  249 + const t = String(el.type || '').toUpperCase()
  250 + return t === 'BARCODE' || t === 'QRCODE'
  251 +}
  252 +
  253 +/** 预览/打印接口响应中的产品 codeValue(兼容 PascalCase / data 嵌套) */
  254 +export function extractProductCodeValueFromPreviewPayload(payload: unknown): string {
  255 + const readLayer = (layer: Record<string, unknown> | null | undefined): string => {
  256 + if (!layer) return ''
  257 + const cv =
  258 + layer.codeValue ??
  259 + layer.CodeValue ??
  260 + layer.productCodeValue ??
  261 + layer.ProductCodeValue
  262 + if (cv != null && String(cv).trim()) return String(cv).trim()
  263 + const prod = layer.product ?? layer.Product
  264 + if (prod != null && typeof prod === 'object' && !Array.isArray(prod)) {
  265 + const p = prod as Record<string, unknown>
  266 + const nested = p.codeValue ?? p.CodeValue
  267 + if (nested != null && String(nested).trim()) return String(nested).trim()
  268 + }
  269 + return ''
  270 + }
  271 + if (payload == null || typeof payload !== 'object') return ''
  272 + const r = payload as Record<string, unknown>
  273 + const nested = r.data ?? r.Data
  274 + const inner =
  275 + nested != null && typeof nested === 'object' && !Array.isArray(nested)
  276 + ? (nested as Record<string, unknown>)
  277 + : null
  278 + return (
  279 + readLayer(inner) ||
  280 + readLayer(r) ||
  281 + ''
  282 + )
  283 +}
  284 +
  285 +/** 将产品 codeValue 写入 Template 分组下的 BARCODE / QRCODE(覆盖模板内静态 data) */
  286 +export function applyProductCodeValueToTemplateScanElements(
  287 + template: SystemLabelTemplate,
  288 + codeValue: string | null | undefined,
  289 +): SystemLabelTemplate {
  290 + const cv = String(codeValue ?? '').trim()
  291 + if (!cv) return template
  292 + const elements = (template.elements || []).map((el) => {
  293 + if (!isTemplateSectionScanElement(el)) return el
  294 + const cfg = { ...(el.config || {}) } as Record<string, unknown>
  295 + cfg.data = cv
  296 + cfg.Data = cv
  297 + if (String(el.type || '').toUpperCase() === 'BARCODE') {
  298 + cfg.barcodeData = cv
  299 + cfg.BarcodeData = cv
  300 + cfg.value = cv
  301 + cfg.Value = cv
  302 + cfg.content = cv
  303 + cfg.Content = cv
  304 + }
  305 + return { ...el, config: cfg }
  306 + })
  307 + return { ...template, elements }
  308 +}
  309 +
237 310 /**
238 311 * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。
239 312 */
... ...
美国版/Food Labeling Management App UniApp/src/utils/offlineSyncManager.ts
1 1 import { fetchLabelCategoryPage } from '../services/labelCategory'
2 2 import { fetchProductCategoryPage } from '../services/productCategory'
3 3 import { usAppFetchLocationDetail, usAppFetchMyLocations, usAppFetchMyProfile } from '../services/usAppAuth'
4   -import { fetchUsAppLabelingTree, fetchUsAppPrintLogList } from '../services/usAppLabeling'
5   -import { setBoundLocations } from './authSession'
  4 +import {
  5 + fetchUsAppLabelingTree,
  6 + fetchUsAppPrintLogList,
  7 + prefetchUsAppLabelPreviewsForLocation,
  8 +} from '../services/usAppLabeling'
  9 +import { fetchGlobalSupportContact } from '../services/locationSupport'
  10 +import {
  11 + getAccessToken,
  12 + getBoundLocations,
  13 + isLoggedIn,
  14 + setBoundLocations,
  15 +} from './authSession'
6 16 import { getCurrentStoreId } from './stores'
7 17 import {
8 18 clearFailedOfflineMutations,
... ... @@ -15,6 +25,7 @@ import {
15 25 markOfflineMutationDone,
16 26 markOfflineMutationFailed,
17 27 removeOfflineMutationById,
  28 + refreshOfflineAuthAccountSession,
18 29 } from './sqliteSync'
19 30 import type { MutationRow } from './sqliteSync'
20 31 import { usAppApiRequest } from './usAppApiRequest'
... ... @@ -93,16 +104,34 @@ export async function performInitialOfflineSync(): Promise&lt;void&gt; {
93 104 )
94 105 for (const locId of targetLocationIds) {
95 106 // 每个门店一组缓存,确保离线切店后仍有可读数据
96   - await Promise.allSettled([
  107 + const [, treeResult] = await Promise.allSettled([
97 108 usAppFetchLocationDetail(locId),
98 109 fetchUsAppLabelingTree({ locationId: locId }),
99 110 fetchUsAppPrintLogList({ locationId: locId, skipCount: 1, maxResultCount: 20 }),
100 111 ])
  112 + if (treeResult.status === 'fulfilled' && Array.isArray(treeResult.value)) {
  113 + await prefetchUsAppLabelPreviewsForLocation(locId, treeResult.value)
  114 + }
101 115 }
102 116 await Promise.allSettled([
103 117 fetchLabelCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }),
104 118 fetchProductCategoryPage({ skipCount: 1, maxResultCount: 100, state: true }),
  119 + fetchGlobalSupportContact(),
105 120 ])
  121 +
  122 + if (isLoggedIn()) {
  123 + const email = String(uni.getStorageSync('user_email') || '').trim()
  124 + if (email) {
  125 + await refreshOfflineAuthAccountSession({
  126 + email,
  127 + token: getAccessToken(),
  128 + refreshToken: String(uni.getStorageSync('refresh_token') || ''),
  129 + locations: getBoundLocations(),
  130 + displayName: String(uni.getStorageSync('userName') || email),
  131 + })
  132 + }
  133 + }
  134 +
106 135 setLastSyncAt(Date.now())
107 136 }
108 137  
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts
... ... @@ -7,6 +7,7 @@ import type {
7 7 } from '../types/printer'
8 8 import { resolveEscTemplate } from '../templateRenderer'
9 9 import { createTestPrintTemplate } from '../templates/testPrintTemplate'
  10 +import { toEscBarcodeTypeCode } from '../../barcodeFormat'
10 11  
11 12 function normalizePrinterText (str: string): string {
12 13 return String(str || '')
... ... @@ -99,19 +100,7 @@ function clamp (value: number, min: number, max: number): number {
99 100 }
100 101  
101 102 function normalizeEscBarcodeType (value?: string): number {
102   - const key = String(value || 'CODE128').trim().toUpperCase()
103   - const map: Record<string, number> = {
104   - UPCA: 65,
105   - UPCE: 66,
106   - EAN13: 67,
107   - EAN8: 68,
108   - CODE39: 69,
109   - ITF: 70,
110   - CODABAR: 71,
111   - CODE93: 72,
112   - CODE128: 73,
113   - }
114   - return map[key] || 73
  103 + return toEscBarcodeTypeCode(value)
115 104 }
116 105  
117 106 function normalizeEscQrLevel (value?: string): number {
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
  1 +import { normalizeBarcodeType } from '../barcodeFormat'
1 2 import { storedValueLooksLikeImagePath } from '../resolveMediaUrl'
2 3 import {
3 4 createImageBitmapPatch,
... ... @@ -333,6 +334,25 @@ function sanitizeTextForTscBuiltinFont (text: string): string {
333 334 }
334 335  
335 336 /** 估算 TSC 项底部点坐标,用于避免 SIZE 高度略小于内容时裁掉最后一行(常见于底部价格) */
  337 +function pushElementBorderBoxIfNeeded (
  338 + items: TscTemplateItem[],
  339 + element: SystemTemplateElementBase,
  340 + dpi: number
  341 +) {
  342 + const type = String(element.type || '').toUpperCase()
  343 + if (type === 'BLANK') return
  344 + const border = String(element.border || '').toLowerCase()
  345 + if (border !== 'line' && border !== 'dotted') return
  346 + items.push({
  347 + type: 'box',
  348 + x: pxToDots(element.x, dpi),
  349 + y: pxToDots(element.y, dpi),
  350 + width: Math.max(1, pxToDots(element.width, dpi)),
  351 + height: Math.max(1, pxToDots(element.height, dpi)),
  352 + lineWidth: border === 'dotted' ? 1 : 2,
  353 + })
  354 +}
  355 +
336 356 function estimateTscItemBottomDots (item: TscTemplateItem): number {
337 357 switch (item.type) {
338 358 case 'bitmap':
... ... @@ -375,6 +395,7 @@ function buildTscTemplate (
375 395 sortElements(template.elements).forEach((element) => {
376 396 const config = element.config || {}
377 397 const type = String(element.type || '').toUpperCase()
  398 + pushElementBorderBoxIfNeeded(items, element, dpi)
378 399  
379 400 const renderAsTextBlock =
380 401 type.startsWith('TEXT_') ||
... ... @@ -467,15 +488,19 @@ function buildTscTemplate (
467 488 if (type === 'BARCODE') {
468 489 const value = resolveElementDataValue(element, data)
469 490 if (!value) return
  491 + const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], ''))
  492 + const rotation = resolveRotation(
  493 + element.rotation || getConfigString(config, ['orientation'], 'horizontal')
  494 + )
470 495 items.push({
471 496 type: 'barcode',
472 497 x: pxToDots(element.x, dpi),
473 498 y: pxToDots(element.y, dpi),
474 499 value,
475   - symbology: getConfigString(config, ['barcodeType'], 'CODE128'),
  500 + symbology,
476 501 height: Math.max(20, pxToDots(element.height, dpi)),
477 502 readable: config.showText !== false,
478   - rotation: resolveRotation(getConfigString(config, ['orientation'], element.rotation || 'horizontal')),
  503 + rotation,
479 504 narrow: clamp(element.width / Math.max(40, value.length * 6), 1, 4),
480 505 wide: clamp(element.width / Math.max(24, value.length * 3), 2, 6),
481 506 })
... ... @@ -593,7 +618,7 @@ function buildEscTemplate (
593 618 type: 'barcode',
594 619 value,
595 620 align,
596   - symbology: getConfigString(config, ['barcodeType'], 'CODE128'),
  621 + symbology: normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')),
597 622 height: clamp(element.height * 2, 48, 180),
598 623 width: clamp(element.width / Math.max(48, value.length * 4), 2, 6),
599 624 showText: config.showText !== false,
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
... ... @@ -4,6 +4,7 @@
4 4 * 若需中文等非 ASCII,可考虑运行时动态加载 tsc.js(仅 APP 端)。
5 5 */
6 6 import type { MonochromeImageData, PrintImageOptions, StructuredTscTemplate } from './types/printer'
  7 +import { toTscBarcodeSymbology } from '../barcodeFormat'
7 8  
8 9 function normalizePrinterText (str: string): string {
9 10 return String(str || '')
... ... @@ -81,19 +82,7 @@ function escapeTscString (s: string): string {
81 82 }
82 83  
83 84 function normalizeTscBarcodeType (value?: string): string {
84   - const key = String(value || 'CODE128').trim().toUpperCase()
85   - const map: Record<string, string> = {
86   - CODE128: '128',
87   - CODE39: '39',
88   - EAN13: 'EAN13',
89   - EAN8: 'EAN8',
90   - UPCA: 'UPCA',
91   - UPCE: 'UPCE',
92   - CODABAR: 'CODA',
93   - ITF14: 'ITF14',
94   - ITF: 'ITF',
95   - }
96   - return map[key] || '128'
  85 + return toTscBarcodeSymbology(value)
97 86 }
98 87  
99 88 /**
... ...
美国版/Food Labeling Management App UniApp/src/utils/sqliteSync.ts
... ... @@ -15,9 +15,21 @@ export type MutationRow = {
15 15 const DB_NAME = 'us_app_offline.db'
16 16 const CACHE_TABLE = 'offline_cache'
17 17 const MUTATION_TABLE = 'offline_mutation_queue'
  18 +const AUTH_TABLE = 'offline_auth_account'
18 19  
19 20 const FALLBACK_CACHE_KEY = '__offline_cache_json__'
20 21 const FALLBACK_MUTATION_KEY = '__offline_mutation_queue_json__'
  22 +const FALLBACK_AUTH_KEY = '__offline_auth_accounts_json__'
  23 +
  24 +export type OfflineAuthAccountRow = {
  25 + email: string
  26 + password: string
  27 + token: string
  28 + refreshToken: string
  29 + locations: import('../types/usAppBound').UsAppBoundLocationDto[]
  30 + displayName: string
  31 + updatedAt: number
  32 +}
21 33  
22 34 let initialized = false
23 35  
... ... @@ -151,6 +163,17 @@ export async function initOfflineSqlite(): Promise&lt;void&gt; {
151 163 last_error TEXT
152 164 )`
153 165 )
  166 + await sqliteExecute(
  167 + `CREATE TABLE IF NOT EXISTS ${AUTH_TABLE} (
  168 + email TEXT PRIMARY KEY,
  169 + password TEXT NOT NULL,
  170 + token TEXT NOT NULL,
  171 + refresh_token TEXT,
  172 + locations_json TEXT NOT NULL,
  173 + display_name TEXT,
  174 + updated_at INTEGER NOT NULL
  175 + )`
  176 + )
154 177 initialized = true
155 178 }
156 179  
... ... @@ -171,6 +194,19 @@ export async function setOfflineCache(module: string, name: string, payload: Jso
171 194 await sqliteExecute(sql)
172 195 }
173 196  
  197 +export async function hasOfflineCache(module: string, name: string): Promise<boolean> {
  198 + const key = cachePk(module, name)
  199 + if (!isAppSqliteAvailable()) {
  200 + const map = getStorageMap<{ payloadJson: string }>(FALLBACK_CACHE_KEY)
  201 + return Object.prototype.hasOwnProperty.call(map, key)
  202 + }
  203 + await initOfflineSqlite()
  204 + const rows = await sqliteSelect(
  205 + `SELECT 1 FROM ${CACHE_TABLE} WHERE cache_key='${esc(key)}' LIMIT 1`
  206 + )
  207 + return rows.length > 0
  208 +}
  209 +
174 210 export async function getOfflineCache<T = unknown>(module: string, name: string): Promise<T | null> {
175 211 const key = cachePk(module, name)
176 212 if (!isAppSqliteAvailable()) {
... ... @@ -360,8 +396,145 @@ export async function fetchWithOfflineCache&lt;T&gt;(
360 396 await setOfflineCache(module, name, data)
361 397 return data
362 398 }
363   - const cached = await getOfflineCache<T>(module, name)
364   - if (cached != null) return cached
  399 + if (await hasOfflineCache(module, name)) {
  400 + return (await getOfflineCache<T>(module, name)) as T
  401 + }
365 402 throw new Error('No offline cache available')
366 403 }
367 404  
  405 +function getFallbackAuthAccounts(): OfflineAuthAccountRow[] {
  406 + try {
  407 + const raw = String(uni.getStorageSync(FALLBACK_AUTH_KEY) || '').trim()
  408 + if (!raw) return []
  409 + const parsed = JSON.parse(raw) as unknown
  410 + return Array.isArray(parsed) ? (parsed as OfflineAuthAccountRow[]) : []
  411 + } catch {
  412 + return []
  413 + }
  414 +}
  415 +
  416 +function setFallbackAuthAccounts(rows: OfflineAuthAccountRow[]): void {
  417 + uni.setStorageSync(FALLBACK_AUTH_KEY, JSON.stringify(rows))
  418 +}
  419 +
  420 +function authRowFromDb(r: Record<string, unknown>): OfflineAuthAccountRow {
  421 + let locations: OfflineAuthAccountRow['locations'] = []
  422 + try {
  423 + locations = JSON.parse(String(r.locations_json || '[]')) as OfflineAuthAccountRow['locations']
  424 + } catch {
  425 + locations = []
  426 + }
  427 + return {
  428 + email: String(r.email || ''),
  429 + password: String(r.password || ''),
  430 + token: String(r.token || ''),
  431 + refreshToken: String(r.refresh_token || ''),
  432 + locations,
  433 + displayName: String(r.display_name || ''),
  434 + updatedAt: Number(r.updated_at || 0),
  435 + }
  436 +}
  437 +
  438 +/** 在线登录成功后保存,供断网时用同一账号密码登录 */
  439 +export async function saveOfflineAuthAccount(input: {
  440 + email: string
  441 + password: string
  442 + token: string
  443 + refreshToken: string
  444 + locations: OfflineAuthAccountRow['locations']
  445 + displayName: string
  446 +}): Promise<void> {
  447 + const email = input.email.trim().toLowerCase()
  448 + if (!email || !input.password || !input.token) return
  449 + const row: OfflineAuthAccountRow = {
  450 + email,
  451 + password: input.password,
  452 + token: input.token,
  453 + refreshToken: input.refreshToken || '',
  454 + locations: input.locations ?? [],
  455 + displayName: input.displayName || email,
  456 + updatedAt: Date.now(),
  457 + }
  458 + if (!isAppSqliteAvailable()) {
  459 + const list = getFallbackAuthAccounts().filter((x) => x.email !== email)
  460 + list.push(row)
  461 + setFallbackAuthAccounts(list)
  462 + return
  463 + }
  464 + await initOfflineSqlite()
  465 + const locationsJson = esc(JSON.stringify(row.locations))
  466 + await sqliteExecute(
  467 + `INSERT OR REPLACE INTO ${AUTH_TABLE}
  468 + (email, password, token, refresh_token, locations_json, display_name, updated_at)
  469 + VALUES (
  470 + '${esc(email)}',
  471 + '${esc(row.password)}',
  472 + '${esc(row.token)}',
  473 + '${esc(row.refreshToken)}',
  474 + '${locationsJson}',
  475 + '${esc(row.displayName)}',
  476 + ${row.updatedAt}
  477 + )`
  478 + )
  479 +}
  480 +
  481 +/** 断网登录:匹配 SQLite 中已同步过的账号 */
  482 +export async function findOfflineAuthAccount(
  483 + email: string,
  484 + password: string
  485 +): Promise<OfflineAuthAccountRow | null> {
  486 + const em = email.trim().toLowerCase()
  487 + const pw = password
  488 + if (!em || !pw) return null
  489 + if (!isAppSqliteAvailable()) {
  490 + return getFallbackAuthAccounts().find((x) => x.email === em && x.password === pw) ?? null
  491 + }
  492 + await initOfflineSqlite()
  493 + const rows = await sqliteSelect(
  494 + `SELECT email, password, token, refresh_token, locations_json, display_name, updated_at
  495 + FROM ${AUTH_TABLE}
  496 + WHERE email='${esc(em)}' AND password='${esc(pw)}'
  497 + LIMIT 1`
  498 + )
  499 + if (!rows.length) return null
  500 + return authRowFromDb(rows[0] as Record<string, unknown>)
  501 +}
  502 +
  503 +/** 同步后刷新已存离线账号的 token / 门店,无需再次输入密码 */
  504 +export async function refreshOfflineAuthAccountSession(input: {
  505 + email: string
  506 + token: string
  507 + refreshToken: string
  508 + locations: OfflineAuthAccountRow['locations']
  509 + displayName: string
  510 +}): Promise<void> {
  511 + const email = input.email.trim().toLowerCase()
  512 + if (!email || !input.token) return
  513 + if (!isAppSqliteAvailable()) {
  514 + const list = getFallbackAuthAccounts()
  515 + const idx = list.findIndex((x) => x.email === email)
  516 + if (idx < 0) return
  517 + list[idx] = {
  518 + ...list[idx],
  519 + token: input.token,
  520 + refreshToken: input.refreshToken || '',
  521 + locations: input.locations ?? [],
  522 + displayName: input.displayName || list[idx].displayName,
  523 + updatedAt: Date.now(),
  524 + }
  525 + setFallbackAuthAccounts(list)
  526 + return
  527 + }
  528 + await initOfflineSqlite()
  529 + const locationsJson = esc(JSON.stringify(input.locations ?? []))
  530 + await sqliteExecute(
  531 + `UPDATE ${AUTH_TABLE}
  532 + SET token='${esc(input.token)}',
  533 + refresh_token='${esc(input.refreshToken || '')}',
  534 + locations_json='${locationsJson}',
  535 + display_name='${esc(input.displayName || email)}',
  536 + updated_at=${Date.now()}
  537 + WHERE email='${esc(email)}'`
  538 + )
  539 +}
  540 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductCreateInputVo.cs
... ... @@ -15,6 +15,14 @@ public class ProductCreateInputVo
15 15  
16 16 public string? ProductImageUrl { get; set; }
17 17  
  18 + public string? DisplayText { get; set; }
  19 +
  20 + public string? CategoryPhotoUrl { get; set; }
  21 +
  22 + public string? ButtonAppearance { get; set; }
  23 +
  24 + public string? CodeValue { get; set; }
  25 +
18 26 public bool State { get; set; } = true;
19 27  
20 28 /// <summary>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetListOutputDto.cs
... ... @@ -14,6 +14,14 @@ public class ProductGetListOutputDto
14 14  
15 15 public string? ProductImageUrl { get; set; }
16 16  
  17 + public string? DisplayText { get; set; }
  18 +
  19 + public string? CategoryPhotoUrl { get; set; }
  20 +
  21 + public string ButtonAppearance { get; set; } = "TEXT";
  22 +
  23 + public string? CodeValue { get; set; }
  24 +
17 25 public bool State { get; set; }
18 26  
19 27 /// <summary>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Product/ProductGetOutputDto.cs
... ... @@ -16,6 +16,14 @@ public class ProductGetOutputDto
16 16  
17 17 public string? ProductImageUrl { get; set; }
18 18  
  19 + public string? DisplayText { get; set; }
  20 +
  21 + public string? CategoryPhotoUrl { get; set; }
  22 +
  23 + public string ButtonAppearance { get; set; } = "TEXT";
  24 +
  25 + public string? CodeValue { get; set; }
  26 +
19 27 public bool State { get; set; }
20 28  
21 29 /// <summary>适用 Company Id(<c>fl_partner.Id</c>,由关联门店反推;多公司时取第一个)</summary>
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelingProductNodeDto.cs
... ... @@ -22,6 +22,13 @@ public class UsAppLabelingProductNodeDto
22 22  
23 23 public string? ProductImageUrl { get; set; }
24 24  
  25 + /// <summary>按钮展示配置(与平台端产品 Button Appearance、fl_product 扩展字段对齐)</summary>
  26 + public string? DisplayText { get; set; }
  27 +
  28 + public string? CategoryPhotoUrl { get; set; }
  29 +
  30 + public string? ButtonAppearance { get; set; }
  31 +
25 32 /// <summary>副标题(无独立业务字段时:有编码显示编码,否则「无」)</summary>
26 33 public string Subtitle { get; set; } = string.Empty;
27 34  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlProductDbEntity.cs
... ... @@ -18,6 +18,18 @@ public class FlProductDbEntity
18 18  
19 19 public string? ProductImageUrl { get; set; }
20 20  
  21 + /// <summary>按钮展示文案(为空则默认使用 ProductName)</summary>
  22 + public string? DisplayText { get; set; }
  23 +
  24 + /// <summary>与 fl_product_category 一致:TEXT/COLOR/IMAGE 展示值 JSON 数组(与 ButtonAppearance 配合)</summary>
  25 + public string? CategoryPhotoUrl { get; set; }
  26 +
  27 + /// <summary>按钮外观 JSON,如 ["TEXT","COLOR"] 或 ["IMAGE"]</summary>
  28 + public string ButtonAppearance { get; set; } = "TEXT";
  29 +
  30 + /// <summary>条码/编码值(打印模板条形码、二维码数据源)</summary>
  31 + public string? CodeValue { get; set; }
  32 +
21 33 public bool State { get; set; }
22 34 }
23 35  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/ProductAppService.cs
... ... @@ -100,6 +100,10 @@ public class ProductAppService : ApplicationService, IProductAppService
100 100 CategoryId = x.CategoryId,
101 101 CategoryName = categoryName,
102 102 ProductImageUrl = x.ProductImageUrl,
  103 + DisplayText = x.DisplayText,
  104 + CategoryPhotoUrl = x.CategoryPhotoUrl,
  105 + ButtonAppearance = x.ButtonAppearance,
  106 + CodeValue = x.CodeValue,
103 107 State = x.State,
104 108 NoOfLabels = countMap.TryGetValue(x.Id, out var count) ? count : 0
105 109 };
... ... @@ -153,6 +157,10 @@ public class ProductAppService : ApplicationService, IProductAppService
153 157 CategoryId = entity.CategoryId,
154 158 CategoryName = categoryName,
155 159 ProductImageUrl = entity.ProductImageUrl,
  160 + DisplayText = entity.DisplayText,
  161 + CategoryPhotoUrl = entity.CategoryPhotoUrl,
  162 + ButtonAppearance = entity.ButtonAppearance,
  163 + CodeValue = entity.CodeValue,
156 164 State = entity.State,
157 165 PartnerId = partnerIds.Count > 0 ? partnerIds[0] : null,
158 166 PartnerIds = partnerIds,
... ... @@ -185,6 +193,8 @@ public class ProductAppService : ApplicationService, IProductAppService
185 193 }
186 194 }
187 195  
  196 + var displayText = input.DisplayText?.Trim();
  197 + var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
188 198 var entity = new FlProductDbEntity
189 199 {
190 200 Id = _guidGenerator.Create().ToString(),
... ... @@ -193,6 +203,10 @@ public class ProductAppService : ApplicationService, IProductAppService
193 203 ProductName = name,
194 204 CategoryId = input.CategoryId?.Trim(),
195 205 ProductImageUrl = input.ProductImageUrl?.Trim(),
  206 + DisplayText = string.IsNullOrWhiteSpace(displayText) ? null : displayText,
  207 + CategoryPhotoUrl = CategoryAppearanceStorageHelper.NormalizeCategoryPhotoUrlForStorage(input.CategoryPhotoUrl),
  208 + ButtonAppearance = appearance,
  209 + CodeValue = string.IsNullOrWhiteSpace(input.CodeValue?.Trim()) ? null : input.CodeValue!.Trim(),
196 210 State = input.State
197 211 };
198 212  
... ... @@ -246,10 +260,16 @@ public class ProductAppService : ApplicationService, IProductAppService
246 260 }
247 261 }
248 262  
  263 + var displayText = input.DisplayText?.Trim();
  264 + var appearance = CategoryAppearanceStorageHelper.NormalizeButtonAppearanceForStorage(input.ButtonAppearance);
249 265 entity.ProductCode = code;
250 266 entity.ProductName = name;
251 267 entity.CategoryId = input.CategoryId?.Trim();
252 268 entity.ProductImageUrl = input.ProductImageUrl?.Trim();
  269 + entity.DisplayText = string.IsNullOrWhiteSpace(displayText) ? null : displayText;
  270 + entity.CategoryPhotoUrl = CategoryAppearanceStorageHelper.NormalizeCategoryPhotoUrlForStorage(input.CategoryPhotoUrl);
  271 + entity.ButtonAppearance = appearance;
  272 + entity.CodeValue = string.IsNullOrWhiteSpace(input.CodeValue?.Trim()) ? null : input.CodeValue!.Trim();
253 273 entity.State = input.State;
254 274  
255 275 await _dbContext.SqlSugarClient.Updateable(entity).ExecuteCommandAsync();
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -99,6 +99,9 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
99 99 ProductName = p.ProductName,
100 100 ProductCode = p.ProductCode,
101 101 ProductImageUrl = p.ProductImageUrl,
  102 + ProductDisplayText = p.DisplayText,
  103 + ProductCategoryPhotoUrl = p.CategoryPhotoUrl,
  104 + ProductButtonAppearance = p.ButtonAppearance,
102 105 LabelTypeId = t.Id,
103 106 TypeName = t.TypeName,
104 107 TypeOrderNum = t.OrderNum,
... ... @@ -217,6 +220,9 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
217 220 first.TemplateHeight,
218 221 first.TemplateUnit);
219 222  
  223 + var productAppearance = string.IsNullOrWhiteSpace(first.ProductButtonAppearance)
  224 + ? "TEXT"
  225 + : first.ProductButtonAppearance.Trim();
220 226 l2.Products.Add(new UsAppLabelingProductNodeDto
221 227 {
222 228 ProductId = first.ProductId,
... ... @@ -226,6 +232,9 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
226 232 ProductName = first.ProductName ?? string.Empty,
227 233 ProductCode = first.ProductCode ?? string.Empty,
228 234 ProductImageUrl = first.ProductImageUrl,
  235 + DisplayText = first.ProductDisplayText,
  236 + CategoryPhotoUrl = first.ProductCategoryPhotoUrl,
  237 + ButtonAppearance = productAppearance,
229 238 Subtitle = subtitle,
230 239 LabelTypeCount = typeNodes.Count,
231 240 LabelTypes = typeNodes
... ... @@ -960,6 +969,12 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
960 969  
961 970 public string? ProductImageUrl { get; set; }
962 971  
  972 + public string? ProductDisplayText { get; set; }
  973 +
  974 + public string? ProductCategoryPhotoUrl { get; set; }
  975 +
  976 + public string? ProductButtonAppearance { get; set; }
  977 +
963 978 public string LabelTypeId { get; set; } = string.Empty;
964 979  
965 980 public string? TypeName { get; set; }
... ...
美国版/Food Labeling Management Platform/src/components/dashboard/Dashboard.tsx
... ... @@ -29,7 +29,9 @@ import {
29 29 YAxis,
30 30 } from 'recharts';
31 31 import { getDashboardOverview } from '../../services/dashboardService';
  32 +import { getTemplatePrintStatList } from '../../services/reportsService';
32 33 import type { DashboardMetricCardDto, DashboardOverviewDto, DashboardRecentLabelItemDto } from '../../types/dashboardOverview';
  34 +import type { TemplatePrintStatListItem } from '../../types/reports';
33 35 import { useAuth } from '../auth/AuthProvider';
34 36 import { displayNameFromUser } from '../../lib/currentUserDisplay';
35 37 import { formatRelativeSince } from '../../lib/relativeSince';
... ... @@ -48,6 +50,19 @@ function isRecentLabelExpired(item: DashboardRecentLabelItemDto): boolean {
48 50 return (item.status || '').toLowerCase() === 'expired';
49 51 }
50 52  
  53 +/** 与 reports template-print-stat 默认区间一致:近 30 天(含今天) */
  54 +function last30DaysIsoRange(): { startDate: string; endDate: string } {
  55 + const end = new Date();
  56 + const start = new Date();
  57 + start.setHours(12, 0, 0, 0);
  58 + end.setHours(12, 0, 0, 0);
  59 + start.setDate(start.getDate() - 29);
  60 + return {
  61 + startDate: start.toISOString().slice(0, 10),
  62 + endDate: end.toISOString().slice(0, 10),
  63 + };
  64 +}
  65 +
51 66 function formatHeaderDate(iso: string | null | undefined): string {
52 67 const d = iso ? new Date(iso) : new Date();
53 68 if (!Number.isFinite(d.getTime())) return new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
... ... @@ -125,18 +140,37 @@ export function Dashboard({ onViewReports, onViewAllRecentLabels }: DashboardPro
125 140 const welcomeName = auth.roleDisplay ?? displayNameFromUser(auth.user);
126 141 const [overview, setOverview] = React.useState<DashboardOverviewDto | null>(null);
127 142 const [loading, setLoading] = React.useState(true);
  143 + const [templateStats, setTemplateStats] = React.useState<TemplatePrintStatListItem[]>([]);
  144 + const [templateStatsLoading, setTemplateStatsLoading] = React.useState(true);
128 145  
129 146 const load = React.useCallback(async () => {
130 147 setLoading(true);
  148 + setTemplateStatsLoading(true);
  149 + const { startDate, endDate } = last30DaysIsoRange();
131 150 try {
132   - const data = await getDashboardOverview();
  151 + const [data, statRes] = await Promise.all([
  152 + getDashboardOverview(),
  153 + getTemplatePrintStatList({
  154 + skipCount: 1,
  155 + maxResultCount: 12,
  156 + startDate,
  157 + endDate,
  158 + sorting: 'PrintedCount desc',
  159 + }).catch((e) => {
  160 + console.error(e);
  161 + return { items: [], totalCount: 0 };
  162 + }),
  163 + ]);
133 164 setOverview(data);
  165 + setTemplateStats(statRes.items ?? []);
134 166 } catch (e) {
135 167 console.error(e);
136 168 toast.error(e instanceof Error ? e.message : 'Failed to load dashboard');
137 169 setOverview(null);
  170 + setTemplateStats([]);
138 171 } finally {
139 172 setLoading(false);
  173 + setTemplateStatsLoading(false);
140 174 }
141 175 }, []);
142 176  
... ... @@ -453,6 +487,52 @@ export function Dashboard({ onViewReports, onViewAllRecentLabels }: DashboardPro
453 487 )}
454 488 </CardContent>
455 489 </Card>
  490 +
  491 + <Card className="shadow-sm border-gray-200">
  492 + <CardHeader>
  493 + <CardTitle className="text-base font-bold text-gray-800 flex items-center gap-2">
  494 + <FileText className="w-5 h-5 text-gray-500" />
  495 + Template Print Statistics
  496 + </CardTitle>
  497 + <CardDescription>By template — labels printed in the last 30 days</CardDescription>
  498 + </CardHeader>
  499 + <CardContent>
  500 + {loading || templateStatsLoading ? (
  501 + <div className="space-y-3">
  502 + {Array.from({ length: 6 }).map((_, i) => (
  503 + <div key={i} className="flex items-center justify-between gap-3">
  504 + <Skeleton className="h-4 flex-1 max-w-[200px]" />
  505 + <Skeleton className="h-4 w-12 shrink-0" />
  506 + </div>
  507 + ))}
  508 + </div>
  509 + ) : templateStats.length === 0 ? (
  510 + <div className="py-10 text-center text-sm text-gray-500">
  511 + No template print data in the last 30 days.
  512 + </div>
  513 + ) : (
  514 + <div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
  515 + {templateStats.map((row, i) => {
  516 + const name = (row.templateName ?? '').trim() || 'None';
  517 + const key = row.templateId ? `${row.templateId}-${i}` : `row-${i}-${name}`;
  518 + return (
  519 + <div
  520 + key={key}
  521 + className="flex items-center justify-between gap-3 py-2.5 px-3 rounded-lg border border-gray-100 bg-gray-50 hover:bg-gray-100 transition-colors"
  522 + >
  523 + <p className="text-sm font-medium text-gray-900 truncate min-w-0 flex-1" title={name}>
  524 + {name}
  525 + </p>
  526 + <span className="text-sm font-semibold text-gray-900 tabular-nums shrink-0">
  527 + {row.printedCount.toLocaleString()}
  528 + </span>
  529 + </div>
  530 + );
  531 + })}
  532 + </div>
  533 + )}
  534 + </CardContent>
  535 + </Card>
456 536 </div>
457 537 </div>
458 538 </div>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
... ... @@ -66,6 +66,15 @@ import { getLocations } from &quot;../../services/locationService&quot;;
66 66 import { getGroups } from "../../services/groupService";
67 67 import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
68 68 import type { LabelCategoryDto, LabelCategoryCreateInput, LabelCategoryUpdateInput } from "../../types/labelCategory";
  69 +import {
  70 + FORM_DIALOG_CONTENT_CLASS,
  71 + FORM_DIALOG_CONTENT_STYLE,
  72 + FORM_DIALOG_FOOTER_CLASS,
  73 + FORM_DIALOG_HEADER_CLASS,
  74 + FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS,
  75 + FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX,
  76 + FORM_DIALOG_SCROLL_BODY_CLASS,
  77 +} from "../../lib/formDialogLayout";
69 78 import type { LocationDto } from "../../types/location";
70 79 import type { GroupListItem } from "../../types/group";
71 80 import { SearchableMultiSelect } from "../ui/searchable-multi-select";
... ... @@ -933,15 +942,15 @@ function CreateLabelCategoryDialog({
933 942  
934 943 return (
935 944 <Dialog open={open} onOpenChange={onOpenChange}>
936   - <DialogContent className="flex h-[min(85vh,720px)] max-h-[90vh] w-[min(50%,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
937   - <DialogHeader className="shrink-0 space-y-2 px-6 pt-6 pr-14 pb-2 text-center sm:text-left">
  945 + <DialogContent className={FORM_DIALOG_CONTENT_CLASS} style={FORM_DIALOG_CONTENT_STYLE}>
  946 + <DialogHeader className={FORM_DIALOG_HEADER_CLASS}>
938 947 <DialogTitle>Add New Label Category</DialogTitle>
939 948 <DialogDescription>
940 949 Enter the details for the new label category.
941 950 </DialogDescription>
942 951 </DialogHeader>
943 952  
944   - <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-6 pb-4">
  953 + <div className={FORM_DIALOG_SCROLL_BODY_CLASS}>
945 954 <div className="grid gap-4 py-2">
946 955 <div className="grid grid-cols-2 gap-4">
947 956 <div className="space-y-2">
... ... @@ -1125,7 +1134,9 @@ function CreateLabelCategoryDialog({
1125 1134 onChange={(url) => setForm((p) => ({ ...p, categoryPhotoUrl: url || null }))}
1126 1135 uploadSubDir="category"
1127 1136 oneImageOnly
1128   - hint="One image only. Replace or clear to change. JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
  1137 + boxClassName={FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS}
  1138 + boxSizePx={FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX}
  1139 + hint="JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
1129 1140 />
1130 1141 </div>
1131 1142 ) : null}
... ... @@ -1147,7 +1158,7 @@ function CreateLabelCategoryDialog({
1147 1158 </div>
1148 1159 </div>
1149 1160  
1150   - <DialogFooter className="shrink-0 gap-2 border-t border-gray-100 bg-background px-6 py-4 sm:flex-row sm:justify-end">
  1161 + <DialogFooter className={FORM_DIALOG_FOOTER_CLASS}>
1151 1162 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
1152 1163 Cancel
1153 1164 </Button>
... ... @@ -1446,15 +1457,15 @@ function EditLabelCategoryDialog({
1446 1457  
1447 1458 return (
1448 1459 <Dialog open={open} onOpenChange={onOpenChange}>
1449   - <DialogContent className="flex h-[min(85vh,720px)] max-h-[90vh] w-[min(50%,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
1450   - <DialogHeader className="shrink-0 space-y-2 px-6 pt-6 pr-14 pb-2 text-center sm:text-left">
  1460 + <DialogContent className={FORM_DIALOG_CONTENT_CLASS} style={FORM_DIALOG_CONTENT_STYLE}>
  1461 + <DialogHeader className={FORM_DIALOG_HEADER_CLASS}>
1451 1462 <DialogTitle>Edit Label Category</DialogTitle>
1452 1463 <DialogDescription>
1453 1464 Update the label category details.
1454 1465 </DialogDescription>
1455 1466 </DialogHeader>
1456 1467  
1457   - <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-6 pb-4">
  1468 + <div className={FORM_DIALOG_SCROLL_BODY_CLASS}>
1458 1469 <div className="grid gap-4 py-2">
1459 1470 <div className="grid grid-cols-2 gap-4">
1460 1471 <div className="space-y-2">
... ... @@ -1638,7 +1649,9 @@ function EditLabelCategoryDialog({
1638 1649 onChange={(url) => setForm((p) => ({ ...p, categoryPhotoUrl: url || null }))}
1639 1650 uploadSubDir="category"
1640 1651 oneImageOnly
1641   - hint="One image only. Replace or clear to change. JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
  1652 + boxClassName={FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS}
  1653 + boxSizePx={FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX}
  1654 + hint="JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
1642 1655 />
1643 1656 </div>
1644 1657 ) : null}
... ... @@ -1660,7 +1673,7 @@ function EditLabelCategoryDialog({
1660 1673 </div>
1661 1674 </div>
1662 1675  
1663   - <DialogFooter className="shrink-0 gap-2 border-t border-gray-100 bg-background px-6 py-4 sm:flex-row sm:justify-end">
  1676 + <DialogFooter className={FORM_DIALOG_FOOTER_CLASS}>
1664 1677 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
1665 1678 Cancel
1666 1679 </Button>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
... ... @@ -49,6 +49,7 @@ import {
49 49 } from '../../lib/nutritionManualEntry';
50 50 import type { ProductDto } from '../../types/product';
51 51 import type { LabelTypeDto } from '../../types/labelType';
  52 +import { buildTemplateBarcodeQrDefaultsFromCodeValue } from '../../lib/productCodeValueTemplate';
52 53  
53 54 export type TemplateDataEntryRow = {
54 55 id: string;
... ... @@ -303,6 +304,29 @@ export function LabelTemplateDataEntryView({
303 304 );
304 305 }, []);
305 306  
  307 + const applyProductCodeValueToRow = useCallback(
  308 + (rowId: string, productId: string) => {
  309 + const pid = productId.trim();
  310 + const product = products.find((p) => p.id === pid);
  311 + const cv = (product?.codeValue ?? '').trim();
  312 + const scanDefaults = buildTemplateBarcodeQrDefaultsFromCodeValue(
  313 + (templateDto?.elements ?? []) as LabelElement[],
  314 + cv,
  315 + );
  316 + setRows((prev) =>
  317 + prev.map((r) => {
  318 + if (r.id !== rowId) return r;
  319 + return {
  320 + ...r,
  321 + productId: pid,
  322 + fieldValues: { ...r.fieldValues, ...scanDefaults },
  323 + };
  324 + }),
  325 + );
  326 + },
  327 + [products, templateDto],
  328 + );
  329 +
306 330 const setFieldValue = useCallback(
307 331 (rowId: string, elementId: string, value: string) => {
308 332 setRows((prev) =>
... ... @@ -488,7 +512,7 @@ export function LabelTemplateDataEntryView({
488 512 <TableCell className="align-top py-2">
489 513 <SearchableSelect
490 514 value={row.productId}
491   - onValueChange={(v) => updateRow(row.id, { productId: v })}
  515 + onValueChange={(v) => applyProductCodeValueToRow(row.id, v)}
492 516 options={productOptions}
493 517 placeholder="Select product"
494 518 searchPlaceholder="Search product…"
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
... ... @@ -18,41 +18,53 @@ import {
18 18 SelectTrigger,
19 19 SelectValue,
20 20 } from '../../ui/select';
  21 +import { normalizeBarcodeType, toJsBarcodeFormat } from '../../../lib/barcodeFormat';
21 22  
22   -/** 真实条形码渲染(JsBarcode),支持水平/竖排 */
  23 +/** 真实条形码渲染(JsBarcode),支持水平/竖排与制式 */
23 24 function BarcodeBlock({
24 25 data,
25 26 width,
26 27 height,
27 28 showText,
28 29 orientation = 'horizontal',
  30 + barcodeType,
  31 + fontSize = 14,
  32 + textAlign = 'center',
29 33 }: {
30 34 data: string;
31 35 width: number;
32 36 height: number;
33 37 showText?: boolean;
34 38 orientation?: 'horizontal' | 'vertical';
  39 + barcodeType?: unknown;
  40 + fontSize?: number;
  41 + textAlign?: 'left' | 'center' | 'right' | string;
35 42 }) {
36 43 const svgRef = useRef<SVGSVGElement>(null);
37 44 const isVertical = orientation === 'vertical';
38   - const barHeight = Math.max(20, (isVertical ? width : height) - (showText ? 14 : 4));
  45 + const labelReserve = showText !== false ? Math.max(12, Math.round(fontSize) + 4) : 4;
  46 + const barHeight = Math.max(20, (isVertical ? width : height) - labelReserve);
  47 + const jsFormat = toJsBarcodeFormat(barcodeType);
  48 + const align =
  49 + textAlign === 'right' ? 'flex-end' : textAlign === 'center' ? 'center' : 'flex-start';
39 50 useEffect(() => {
40 51 if (svgRef.current && data) {
41 52 try {
42 53 JsBarcode(svgRef.current, data, {
43   - format: 'CODE128',
  54 + format: jsFormat,
44 55 width: 1,
45 56 height: barHeight,
46 57 displayValue: showText !== false,
47 58 margin: 2,
48 59 fontOptions: '',
49   - fontSize: 10,
  60 + fontSize: Math.max(8, Math.round(fontSize)),
  61 + textAlign: textAlign === 'right' ? 'right' : textAlign === 'center' ? 'center' : 'left',
50 62 });
51 63 } catch {
52 64 // invalid data, ignore
53 65 }
54 66 }
55   - }, [data, barHeight, showText]);
  67 + }, [data, barHeight, showText, jsFormat, fontSize, textAlign]);
56 68 const svg = <svg ref={svgRef} className="w-full h-full min-h-0" style={{ maxHeight: isVertical ? width : height }} />;
57 69 if (isVertical) {
58 70 return (
... ... @@ -68,12 +80,16 @@ function BarcodeBlock({
68 80 justifyContent: 'center',
69 81 }}
70 82 >
71   - {svg}
  83 + <div className="w-full min-h-0 flex flex-col" style={{ alignItems: align }}>{svg}</div>
72 84 </div>
73 85 </div>
74 86 );
75 87 }
76   - return svg;
  88 + return (
  89 + <div className="w-full h-full flex flex-col justify-center" style={{ alignItems: align }}>
  90 + <div className="w-full min-h-0 flex flex-col" style={{ alignItems: align }}>{svg}</div>
  91 + </div>
  92 + );
77 93 }
78 94  
79 95 /** 画布网格步长(px),控件吸附到该步长 */
... ... @@ -606,13 +622,19 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
606 622 );
607 623 }
608 624  
609   - // 条码(支持水平/竖排
  625 + // 条码(支持水平/竖排、制式、字号与对齐
610 626 if (type === 'BARCODE') {
611 627 const data = (cfg?.data as string) ?? '123456789';
612 628 const showText = (cfg?.showText as boolean) !== false;
613   - const orientation = ((cfg?.orientation as string) === 'vertical' ? 'vertical' : 'horizontal') as 'horizontal' | 'vertical';
  629 + const orientation = (
  630 + el.rotation === 'vertical' || (cfg?.orientation as string) === 'vertical'
  631 + ? 'vertical'
  632 + : 'horizontal'
  633 + ) as 'horizontal' | 'vertical';
  634 + const textAlign = (cfg?.textAlign as string) ?? 'center';
  635 + const fontSize = (cfg?.fontSize as number) ?? 14;
614 636 return (
615   - <div className="flex flex-col items-center justify-center w-full h-full overflow-hidden p-0.5">
  637 + <div className="flex flex-col w-full h-full overflow-hidden p-0.5">
616 638 <div className="flex-1 w-full min-h-0 flex items-center justify-center">
617 639 <BarcodeBlock
618 640 data={data}
... ... @@ -620,6 +642,9 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
620 642 height={el.height}
621 643 showText={showText}
622 644 orientation={orientation}
  645 + barcodeType={normalizeBarcodeType(cfg?.barcodeType)}
  646 + fontSize={fontSize}
  647 + textAlign={textAlign}
623 648 />
624 649 </div>
625 650 </div>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
... ... @@ -29,6 +29,11 @@ import type { LabelMultipleOptionDto } from &#39;../../../types/labelMultipleOption&#39;
29 29 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
30 30 import { Checkbox } from '../../ui/checkbox';
31 31 import { Trash2 } from 'lucide-react';
  32 +import {
  33 + BARCODE_FORMAT_OPTIONS,
  34 + DEFAULT_BARCODE_FORMAT,
  35 + normalizeBarcodeType,
  36 +} from '../../../lib/barcodeFormat';
32 37  
33 38 const DATE_FORMAT_OPTIONS = [
34 39 'DD/MM/YYYY',
... ... @@ -525,28 +530,63 @@ function ElementConfigFields({
525 530 return (
526 531 <>
527 532 <div>
528   - <Label className="text-xs">Data</Label>
529   - <Input
530   - value={(cfg.data as string) ?? '123456789'}
531   - onChange={(e) => update('data', e.target.value)}
532   - className="h-8 text-sm mt-1"
533   - />
534   - </div>
535   - <div>
536   - <Label className="text-xs">Orientation</Label>
  533 + <Label className="text-xs">Barcode Format</Label>
537 534 <Select
538   - value={(cfg.orientation as string) ?? 'horizontal'}
539   - onValueChange={(v) => update('orientation', v)}
  535 + value={normalizeBarcodeType(cfg.barcodeType ?? DEFAULT_BARCODE_FORMAT)}
  536 + onValueChange={(v) => update('barcodeType', v)}
540 537 >
541 538 <SelectTrigger className="h-8 text-sm mt-1">
542 539 <SelectValue />
543 540 </SelectTrigger>
544 541 <SelectContent>
545   - <SelectItem value="horizontal">Horizontal</SelectItem>
546   - <SelectItem value="vertical">Vertical</SelectItem>
  542 + {BARCODE_FORMAT_OPTIONS.map((opt) => (
  543 + <SelectItem key={opt.value} value={opt.value}>
  544 + {opt.label}
  545 + </SelectItem>
  546 + ))}
547 547 </SelectContent>
548 548 </Select>
549 549 </div>
  550 + <div>
  551 + <Label className="text-xs">Data</Label>
  552 + <Input
  553 + value={(cfg.data as string) ?? '123456789'}
  554 + onChange={(e) => update('data', e.target.value)}
  555 + className="h-8 text-sm mt-1"
  556 + />
  557 + </div>
  558 + <div className="grid grid-cols-2 gap-2">
  559 + <div>
  560 + <Label className="text-xs">Font size</Label>
  561 + <Input
  562 + type="number"
  563 + min={8}
  564 + max={72}
  565 + value={(cfg.fontSize as number) ?? 14}
  566 + onChange={(e) => update('fontSize', Number(e.target.value) || 14)}
  567 + className="h-8 text-sm mt-1"
  568 + />
  569 + </div>
  570 + <div>
  571 + <Label className="text-xs">Text align</Label>
  572 + <Select
  573 + value={(cfg.textAlign as string) ?? 'center'}
  574 + onValueChange={(v) => update('textAlign', v)}
  575 + >
  576 + <SelectTrigger className="h-8 text-sm mt-1">
  577 + <SelectValue />
  578 + </SelectTrigger>
  579 + <SelectContent>
  580 + <SelectItem value="left">Left</SelectItem>
  581 + <SelectItem value="center">Center</SelectItem>
  582 + <SelectItem value="right">Right</SelectItem>
  583 + </SelectContent>
  584 + </Select>
  585 + </div>
  586 + </div>
  587 + <p className="text-[10px] text-gray-400 -mt-1">
  588 + Rotation and border use the common fields above.
  589 + </p>
550 590 <div className="flex items-center gap-2">
551 591 <Switch
552 592 checked={(cfg.showText as boolean) !== false}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTypesView.tsx
... ... @@ -129,13 +129,41 @@ function hydrateScopeFromLocationIds(
129 129 return { regionNames, locationIds: lids };
130 130 }
131 131  
  132 +function normalizeIdList(ids: string[] | null | undefined): string[] {
  133 + return [...new Set((ids ?? []).map((x) => String(x).trim()).filter(Boolean))];
  134 +}
  135 +
  136 +function formatLocationLine(l: LocationDto): string {
  137 + const code = (l.locationCode ?? "").trim();
  138 + const name = (l.locationName ?? "").trim();
  139 + return code && name ? `${code} - ${name}` : name || code || l.id;
  140 +}
  141 +
  142 +/** 按 locationIds 顺序解析门店;目录未命中时回退显示 Id */
  143 +function linesFromLocationIds(locationIds: string[], catalog: LocationDto[]): string[] {
  144 + const ids = normalizeIdList(locationIds);
  145 + if (!ids.length) return [];
  146 + const byId = new Map(catalog.map((l) => [l.id, l]));
  147 + return ids.map((id) => {
  148 + const loc = byId.get(id);
  149 + return loc ? formatLocationLine(loc) : id;
  150 + });
  151 +}
  152 +
132 153 function locationsForTypeScope(item: LabelTypeDto, catalog: LocationDto[]): LocationDto[] {
133   - const idSet = new Set((item.locationIds ?? []).map((x) => String(x).trim()).filter(Boolean));
134   - if (!idSet.size) return [];
135   - return catalog.filter((l) => idSet.has(l.id));
  154 + const ids = normalizeIdList(item.locationIds);
  155 + if (!ids.length) return [];
  156 + const byId = new Map(catalog.map((l) => [l.id, l]));
  157 + return ids.map((id) => byId.get(id)).filter((l): l is LocationDto => !!l);
136 158 }
137 159  
138 160 function typeRegionSummary(item: LabelTypeDto, catalog: LocationDto[]): string {
  161 + const ids = normalizeIdList(item.locationIds);
  162 + if (ids.length > 0) {
  163 + const matched = locationsForTypeScope(item, catalog);
  164 + const regions = [...new Set(matched.map((l) => (l.groupName ?? "").trim()).filter(Boolean))];
  165 + if (regions.length) return regions.join(", ");
  166 + }
139 167 const apiRegion = (item.region ?? "").trim();
140 168 if (apiRegion) return apiRegion;
141 169 const matched = locationsForTypeScope(item, catalog);
... ... @@ -145,15 +173,13 @@ function typeRegionSummary(item: LabelTypeDto, catalog: LocationDto[]): string {
145 173 }
146 174  
147 175 function typeLocationLines(item: LabelTypeDto, catalog: LocationDto[]): string[] {
  176 + const fromIds = linesFromLocationIds(item.locationIds ?? [], catalog);
  177 + if (fromIds.length > 0) return fromIds;
148 178 const apiLoc = (item.location ?? "").trim();
149 179 if (apiLoc) return apiLoc.split(/\s*;\s*/).map((x) => x.trim()).filter(Boolean);
150 180 const matched = locationsForTypeScope(item, catalog);
151 181 if (!matched.length) return ["—"];
152   - return matched.map((l) => {
153   - const code = (l.locationCode ?? "").trim();
154   - const name = (l.locationName ?? "").trim();
155   - return code && name ? `${code} - ${name}` : name || code || l.id;
156   - });
  182 + return matched.map(formatLocationLine);
157 183 }
158 184  
159 185 async function enrichTypesWithLocationIds(
... ... @@ -284,85 +310,6 @@ function LabelTypeScopeFields({
284 310 );
285 311 }
286 312  
287   -async function fetchAllLabelTypesMatching(
288   - keyword: string | undefined,
289   - state: boolean | undefined,
290   - signal: AbortSignal,
291   -): Promise<LabelTypeDto[]> {
292   - const out: LabelTypeDto[] = [];
293   - let page = 1;
294   - const size = 500;
295   - for (;;) {
296   - const res = await getLabelTypes(
297   - {
298   - skipCount: skipCountForPage(page),
299   - maxResultCount: size,
300   - keyword,
301   - state,
302   - },
303   - signal,
304   - );
305   - const items = res.items ?? [];
306   - out.push(...items);
307   - if (items.length < size) break;
308   - page += 1;
309   - if (page > 200) break;
310   - }
311   - return out;
312   -}
313   -
314   -/** 解析 Region / Location 筛选对应的门店 Id 列表;未筛选时返回 null */
315   -function resolveScopedLocationIds(
316   - regionFilter: string,
317   - locationFilter: string,
318   - locationCatalog: LocationDto[],
319   - filterGroups: GroupListItem[],
320   -): string[] | null {
321   - if (regionFilter === "all" && locationFilter === "all") return null;
322   - if (locationFilter !== "all") {
323   - const lid = locationFilter.trim();
324   - return lid ? [lid] : [];
325   - }
326   - const g = filterGroups.find((x) => x.id === regionFilter);
327   - if (!g) return [];
328   - const gn = (g.groupName ?? "").trim();
329   - const pn = (g.partnerName ?? "").trim();
330   - return locationCatalog
331   - .filter((l) => (l.groupName ?? "").trim() === gn && (l.partner ?? "").trim() === pn)
332   - .map((l) => l.id)
333   - .filter(Boolean);
334   -}
335   -
336   -/** 在指定门店范围内,收集被标签引用的 LabelTypeId */
337   -async function fetchLabelTypeIdsInLocations(
338   - locationIds: string[],
339   - signal: AbortSignal,
340   -): Promise<Set<string>> {
341   - const typeIds = new Set<string>();
342   - for (const lid of locationIds) {
343   - let page = 1;
344   - for (;;) {
345   - const res = await getLabels(
346   - {
347   - skipCount: skipCountForPage(page),
348   - maxResultCount: 500,
349   - locationId: lid,
350   - },
351   - signal,
352   - );
353   - for (const lbl of res.items ?? []) {
354   - const tid = (lbl.labelTypeId ?? "").trim();
355   - if (tid) typeIds.add(tid);
356   - }
357   - if ((res.items?.length ?? 0) < 500) break;
358   - page += 1;
359   - if (page > 50) break;
360   - }
361   - if (signal.aborted) break;
362   - }
363   - return typeIds;
364   -}
365   -
366 313 export function LabelTypesView() {
367 314 const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
368 315 const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
... ... @@ -480,43 +427,21 @@ export function LabelTypesView() {
480 427 const skipCount = skipCountForPage(pageIndex);
481 428 const stateBool = stateFilter === "all" ? undefined : stateFilter === "true";
482 429 const kw = debouncedKeyword || undefined;
483   - const needsClientFilter = regionFilter !== "all" || locationFilter !== "all";
484   - const scopeLocationIds = resolveScopedLocationIds(
485   - regionFilter,
486   - locationFilter,
487   - locationCatalog,
488   - filterGroups,
  430 + const res = await getLabelTypes(
  431 + {
  432 + skipCount,
  433 + maxResultCount: pageSize,
  434 + keyword: kw,
  435 + state: stateBool,
  436 + groupId: regionFilter !== "all" ? regionFilter : undefined,
  437 + locationId: locationFilter !== "all" ? locationFilter : undefined,
  438 + },
  439 + ac.signal,
489 440 );
490   -
491   - if (!needsClientFilter) {
492   - const res = await getLabelTypes(
493   - {
494   - skipCount,
495   - maxResultCount: pageSize,
496   - keyword: kw,
497   - state: stateBool,
498   - groupId: regionFilter !== "all" ? regionFilter : undefined,
499   - locationId: locationFilter !== "all" ? locationFilter : undefined,
500   - },
501   - ac.signal,
502   - );
503   - const enriched = await enrichTypesWithLocationIds(res.items ?? [], ac.signal);
504   - if (ac.signal.aborted) return;
505   - setTypes(enriched);
506   - setTotal(res.totalCount ?? 0);
507   - } else {
508   - const scopedTypeIds = scopeLocationIds?.length
509   - ? await fetchLabelTypeIdsInLocations(scopeLocationIds, ac.signal)
510   - : new Set<string>();
511   - const all = await fetchAllLabelTypesMatching(kw, stateBool, ac.signal);
512   - const filtered = all.filter((t) => scopedTypeIds.has(t.id));
513   - setTotal(filtered.length);
514   - const start = (pageIndex - 1) * pageSize;
515   - const pageItems = filtered.slice(start, start + pageSize);
516   - const enriched = await enrichTypesWithLocationIds(pageItems, ac.signal);
517   - if (ac.signal.aborted) return;
518   - setTypes(enriched);
519   - }
  441 + const enriched = await enrichTypesWithLocationIds(res.items ?? [], ac.signal);
  442 + if (ac.signal.aborted) return;
  443 + setTypes(enriched);
  444 + setTotal(res.totalCount ?? 0);
520 445 } catch (e: any) {
521 446 if (e?.name === "AbortError") return;
522 447 toast.error("Failed to load label types.", {
... ... @@ -659,6 +584,7 @@ export function LabelTypesView() {
659 584 types.map((item) => {
660 585 const regionText = typeRegionSummary(item, locationCatalog);
661 586 const locLines = typeLocationLines(item, locationCatalog);
  587 + const locText = locLines.join(", ");
662 588 return (
663 589 <TableRow key={item.id} className="hover:bg-gray-50">
664 590 <TableCell className="text-sm font-normal text-gray-900 whitespace-nowrap">
... ... @@ -670,14 +596,11 @@ export function LabelTypesView() {
670 596 >
671 597 {regionText}
672 598 </TableCell>
673   - <TableCell className="text-sm font-normal text-gray-900 max-w-[260px]">
674   - <div className="flex flex-col gap-1">
675   - {locLines.map((line, idx) => (
676   - <div key={idx} className="truncate whitespace-nowrap" title={line}>
677   - {line}
678   - </div>
679   - ))}
680   - </div>
  599 + <TableCell
  600 + className="text-sm font-normal text-gray-900 max-w-[260px] truncate whitespace-nowrap"
  601 + title={locText}
  602 + >
  603 + {locText}
681 604 </TableCell>
682 605 <TableCell className="text-sm font-normal text-gray-900 tabular-nums whitespace-nowrap">
683 606 {typeof item.noOfLabels === "number" && Number.isFinite(item.noOfLabels)
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -71,6 +71,7 @@ import {
71 71 dataEntryColumnLabel,
72 72 isDataEntryTableColumnElement,
73 73 isDateTimeDataEntryField,
  74 + isTemplateSectionPersistedType,
74 75 labelElementsToApiPayload,
75 76 sortTemplateElementsForDisplay,
76 77 } from "../../types/labelTemplate";
... ... @@ -89,6 +90,10 @@ import {
89 90 nutritionManualValuesFromTemplateConfig,
90 91 type NutritionManualFieldSpec,
91 92 } from "../../lib/nutritionManualEntry";
  93 +import {
  94 + applyProductCodeValueToLabelElements,
  95 + buildTemplateBarcodeQrDefaultsFromCodeValue,
  96 +} from "../../lib/productCodeValueTemplate";
92 97  
93 98 function toDisplay(v: string | null | undefined): string {
94 99 const s = (v ?? "").trim();
... ... @@ -195,6 +200,7 @@ function buildCreateLabelPreviewTemplate(
195 200 textValues: Record<string, string>,
196 201 dateOffsets: Record<string, { unit: string; value: string }>,
197 202 nutritionByElementId: Record<string, Record<string, string>>,
  203 + productCodeValue?: string | null,
198 204 ): LabelTemplate | null {
199 205 if (!apiTpl) return null;
200 206 const tmpl = dtoToEditorTemplate(apiTpl);
... ... @@ -246,6 +252,7 @@ function buildCreateLabelPreviewTemplate(
246 252 const merged = mergeNutritionManualIntoConfig({ ...(el.config as Record<string, unknown>) }, manual);
247 253 el.config = merged as LabelElement["config"];
248 254 }
  255 + tmpl.elements = applyProductCodeValueToLabelElements(tmpl.elements, productCodeValue);
249 256 return tmpl;
250 257 }
251 258  
... ... @@ -254,6 +261,7 @@ function collectTemplateDefaultValuesForSave(
254 261 textValues: Record<string, string>,
255 262 dateOffsets: Record<string, { unit: string; value: string }>,
256 263 nutritionByElementId: Record<string, Record<string, string>>,
  264 + productCodeValue?: string | null,
257 265 ): Record<string, string> {
258 266 const out: Record<string, string> = {};
259 267 for (const el of getDataEntryElements(latest)) {
... ... @@ -279,6 +287,13 @@ function collectTemplateDefaultValuesForSave(
279 287 const j = nutritionDefaultValuesJsonForSave(manual);
280 288 if (j) out[nel.id] = j;
281 289 }
  290 + Object.assign(
  291 + out,
  292 + buildTemplateBarcodeQrDefaultsFromCodeValue(
  293 + (latest.elements ?? []) as LabelElement[],
  294 + productCodeValue,
  295 + ),
  296 + );
282 297 return out;
283 298 }
284 299  
... ... @@ -1506,11 +1521,18 @@ function CreateLabelDialog({
1506 1521 listNutritionElements((latest.elements ?? []) as LabelElement[]).length > 0;
1507 1522 if (dataEls.length === 0 && !hasNutritionRows) return;
1508 1523  
  1524 + const productCodeValue = (() => {
  1525 + const pid = form.productIds[0]?.trim();
  1526 + if (!pid) return "";
  1527 + const p = productsScoped.find((x) => x.id === pid);
  1528 + return (p?.codeValue ?? "").trim();
  1529 + })();
1509 1530 const inputDefaultValues = collectTemplateDefaultValuesForSave(
1510 1531 latest,
1511 1532 templateDataValues,
1512 1533 templateDateOffsets,
1513 1534 nutritionByElementId,
  1535 + productCodeValue,
1514 1536 );
1515 1537 const defaultsMap = buildTemplateDefaultsMap(latest);
1516 1538 for (const productId of form.productIds) {
... ... @@ -1657,6 +1679,32 @@ function CreateLabelDialog({
1657 1679 const showTemplateInputColumn =
1658 1680 dataEntryElements.length > 0 || nutritionFieldBlocks.length > 0;
1659 1681  
  1682 + const selectedProductCodeValue = useMemo(() => {
  1683 + const pid = (form.productIds[0] ?? "").trim();
  1684 + if (!pid) return "";
  1685 + const p = productsScoped.find((x) => x.id === pid);
  1686 + return (p?.codeValue ?? "").trim();
  1687 + }, [form.productIds, productsScoped]);
  1688 +
  1689 + useEffect(() => {
  1690 + if (!open || !selectedTemplate) return;
  1691 + const cv = selectedProductCodeValue;
  1692 + if (!cv) return;
  1693 + setTemplateDataValues((prev) => {
  1694 + let changed = false;
  1695 + const next = { ...prev };
  1696 + for (const el of getDataEntryElements(selectedTemplate)) {
  1697 + const t = canonicalElementType(el.type);
  1698 + if (t !== "BARCODE" && t !== "QRCODE") continue;
  1699 + if (isTemplateSectionPersistedType(el)) continue;
  1700 + if (next[el.id] === cv) continue;
  1701 + next[el.id] = cv;
  1702 + changed = true;
  1703 + }
  1704 + return changed ? next : prev;
  1705 + });
  1706 + }, [open, selectedTemplate, selectedProductCodeValue]);
  1707 +
1660 1708 const previewTemplate = useMemo(
1661 1709 () =>
1662 1710 buildCreateLabelPreviewTemplate(
... ... @@ -1664,8 +1712,15 @@ function CreateLabelDialog({
1664 1712 templateDataValues,
1665 1713 templateDateOffsets,
1666 1714 nutritionByElementId,
  1715 + selectedProductCodeValue,
1667 1716 ),
1668   - [selectedTemplate, templateDataValues, templateDateOffsets, nutritionByElementId],
  1717 + [
  1718 + selectedTemplate,
  1719 + templateDataValues,
  1720 + templateDateOffsets,
  1721 + nutritionByElementId,
  1722 + selectedProductCodeValue,
  1723 + ],
1669 1724 );
1670 1725 const hasTemplateSelected = form.templateCode.trim().length > 0;
1671 1726  
... ...
美国版/Food Labeling Management Platform/src/components/people/PeopleView.tsx
1   -import React, { useEffect, useMemo, useRef, useState } from "react";
  1 +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
2 2 import {
3 3 Search,
4 4 Plus,
... ... @@ -55,6 +55,7 @@ import { Switch } from &quot;../ui/switch&quot;;
55 55 import { Badge } from "../ui/badge";
56 56 import { Checkbox } from "../ui/checkbox";
57 57 import { ScrollArea } from "../ui/scroll-area";
  58 +import { SearchableMultiSelect } from "../ui/searchable-multi-select";
58 59 import { cn } from "../ui/utils";
59 60 import { toast } from "sonner";
60 61  
... ... @@ -97,6 +98,7 @@ import { BatchImportDialog } from &quot;../bulk/batch-import-dialog&quot;;
97 98 import { TeamMemberBulkEditPage } from "./team-member-bulk-edit-page";
98 99 import { useAuth } from "../auth/AuthProvider";
99 100 import { canMutateAccountManagementStructure } from "../../lib/accountManagementAccess";
  101 +import { skipCountForPage } from "../../lib/paginationQuery";
100 102  
101 103 function listStateFromFilter(v: "all" | "active" | "inactive"): boolean | undefined {
102 104 if (v === "all") return undefined;
... ... @@ -169,6 +171,11 @@ async function loadAllLocationsForMemberFilter(signal?: AbortSignal): Promise&lt;Lo
169 171 }
170 172  
171 173 /** 成员是否至少绑定在 scope 允许的门店之一(仅前端 company/region 筛选用) */
  174 +/** 门店 Id 比较(忽略大小写与首尾空格) */
  175 +function normLocationId(id: string | null | undefined): string {
  176 + return String(id ?? "").trim().toLowerCase();
  177 +}
  178 +
172 179 function memberMatchesLocationScope(m: TeamMemberDto, allowedLocationIds: Set<string> | null): boolean {
173 180 if (allowedLocationIds === null) return true;
174 181 if (allowedLocationIds.size === 0) return false;
... ... @@ -2719,21 +2726,26 @@ function MemberDialog({
2719 2726 const [roleId, setRoleId] = useState("");
2720 2727 const [state, setState] = useState(true);
2721 2728 const [selectedLocationIds, setSelectedLocationIds] = useState<Set<string>>(new Set());
2722   - /** 绑定公司 / 区域(单选;接口对接前仅前端状态与门店范围联动) */
  2729 + const [selectedRegionIds, setSelectedRegionIds] = useState<Set<string>>(new Set());
  2730 + /** 绑定公司(单选)/ Region(多选,fl_group.Id) */
2723 2731 const [companyId, setCompanyId] = useState("");
2724   - const [regionId, setRegionId] = useState("");
2725 2732  
2726 2733 const [roleOptions, setRoleOptions] = useState<RoleDto[]>([]);
2727 2734 const [loadingRoles, setLoadingRoles] = useState(false);
2728 2735  
2729 2736 const [locationOptions, setLocationOptions] = useState<LocationDto[]>([]);
  2737 + const [scopedLocationOptions, setScopedLocationOptions] = useState<LocationDto[]>([]);
2730 2738 const [loadingLocations, setLoadingLocations] = useState(false);
  2739 + const [loadingScopedLocations, setLoadingScopedLocations] = useState(false);
2731 2740 const [partnerOptions, setPartnerOptions] = useState<PartnerListItem[]>([]);
2732 2741 const [groupOptionsAll, setGroupOptionsAll] = useState<GroupListItem[]>([]);
2733 2742 const [loadingPartnersGroups, setLoadingPartnersGroups] = useState(false);
2734 2743 const [locationKeyword, setLocationKeyword] = useState("");
2735 2744  
2736 2745 const abortRef = useRef<AbortController | null>(null);
  2746 + /** 编辑详情里的已分配 Region / 门店 Id,scoped 列表加载完成后用于恢复勾选 */
  2747 + const assignedRegionIdsRef = useRef<string[]>([]);
  2748 + const assignedLocationIdsRef = useRef<string[]>([]);
2737 2749  
2738 2750 const resetForm = () => {
2739 2751 setFullName("");
... ... @@ -2744,8 +2756,11 @@ function MemberDialog({
2744 2756 setRoleId("");
2745 2757 setState(true);
2746 2758 setSelectedLocationIds(new Set());
  2759 + setSelectedRegionIds(new Set());
2747 2760 setCompanyId("");
2748   - setRegionId("");
  2761 + setScopedLocationOptions([]);
  2762 + assignedRegionIdsRef.current = [];
  2763 + assignedLocationIdsRef.current = [];
2749 2764 };
2750 2765  
2751 2766 const loadAllRolesForSelect = async (signal: AbortSignal) => {
... ... @@ -2767,20 +2782,89 @@ function MemberDialog({
2767 2782  
2768 2783 const loadAllLocationsForSelect = async (signal: AbortSignal) => {
2769 2784 const out: LocationDto[] = [];
2770   - let skip = 0;
  2785 + let page = 1;
2771 2786 const size = 200;
2772 2787 for (;;) {
2773   - const res = await getLocations({ skipCount: skip, maxResultCount: size }, signal);
  2788 + const res = await getLocations({ skipCount: skipCountForPage(page), maxResultCount: size }, signal);
2774 2789 out.push(...(res.items ?? []));
2775 2790 if (!res.items || res.items.length < size) break;
2776   - skip += size;
2777   - if (skip > 40000) break;
  2791 + page += 1;
  2792 + if (page > 200) break;
  2793 + }
  2794 + const m = new Map<string, LocationDto>();
  2795 + for (const l of out) if (l.id && !m.has(l.id)) m.set(l.id, l);
  2796 + return Array.from(m.values());
  2797 + };
  2798 +
  2799 + /** 按 Company + Region 调门店列表接口(与列表页筛选一致),保证该区域下全部门店都出现在勾选项 */
  2800 + const fetchLocationsPaged = async (
  2801 + query: { partner?: string; groupName?: string },
  2802 + signal: AbortSignal,
  2803 + ): Promise<LocationDto[]> => {
  2804 + const out: LocationDto[] = [];
  2805 + let page = 1;
  2806 + const size = 500;
  2807 + for (;;) {
  2808 + const res = await getLocations(
  2809 + {
  2810 + skipCount: skipCountForPage(page),
  2811 + maxResultCount: size,
  2812 + partner: query.partner,
  2813 + groupName: query.groupName,
  2814 + },
  2815 + signal,
  2816 + );
  2817 + out.push(...(res.items ?? []));
  2818 + if (!res.items || res.items.length < size) break;
  2819 + page += 1;
  2820 + if (page > 200) break;
2778 2821 }
2779 2822 const m = new Map<string, LocationDto>();
2780 2823 for (const l of out) if (l.id && !m.has(l.id)) m.set(l.id, l);
2781 2824 return Array.from(m.values());
2782 2825 };
2783 2826  
  2827 + /** 按 Company + Region 拉门店;partner 名不一致时仅用 groupName 兜底 */
  2828 + const loadScopedLocationsForPicker = async (
  2829 + partnerName: string,
  2830 + groupName: string,
  2831 + signal: AbortSignal,
  2832 + ): Promise<LocationDto[]> => {
  2833 + const withPartner = await fetchLocationsPaged(
  2834 + { partner: partnerName || undefined, groupName: groupName || undefined },
  2835 + signal,
  2836 + );
  2837 + if (!groupName.trim()) return withPartner;
  2838 + if (withPartner.length > 1 || !partnerName.trim()) return withPartner;
  2839 + const byGroupOnly = await fetchLocationsPaged({ groupName }, signal);
  2840 + if (byGroupOnly.length <= withPartner.length) return withPartner;
  2841 + const merged = new Map<string, LocationDto>();
  2842 + for (const l of [...withPartner, ...byGroupOnly]) {
  2843 + if (l.id) merged.set(l.id, l);
  2844 + }
  2845 + return Array.from(merged.values());
  2846 + };
  2847 +
  2848 + /** 多选 Region:合并各 Region 下门店列表 */
  2849 + const loadScopedLocationsForRegions = async (
  2850 + partnerName: string,
  2851 + regions: GroupListItem[],
  2852 + signal: AbortSignal,
  2853 + ): Promise<LocationDto[]> => {
  2854 + const merged = new Map<string, LocationDto>();
  2855 + for (const g of regions) {
  2856 + const gn = (g.groupName ?? "").trim();
  2857 + if (!gn) continue;
  2858 + const scoped = await loadScopedLocationsForPicker(partnerName, gn, signal);
  2859 + for (const l of scoped) {
  2860 + const id = String(l.id ?? "").trim();
  2861 + if (id) merged.set(id, l);
  2862 + }
  2863 + if (signal.aborted) break;
  2864 + }
  2865 + return Array.from(merged.values());
  2866 + };
  2867 +
2784 2868 useEffect(() => {
2785 2869 if (!open) return;
2786 2870 abortRef.current?.abort();
... ... @@ -2827,20 +2911,25 @@ function MemberDialog({
2827 2911 setRoleId(nextRoleId);
2828 2912 setState(!!detail.state);
2829 2913  
2830   - const optionIds = new Set(
2831   - locationsRes.map((l) => String(l.id ?? "").trim()).filter(Boolean),
2832   - );
  2914 + const catalogIdByNorm = new Map<string, string>();
  2915 + for (const l of locationsRes) {
  2916 + const id = String(l.id ?? "").trim();
  2917 + if (!id) continue;
  2918 + catalogIdByNorm.set(normLocationId(id), id);
  2919 + }
2833 2920 const rawIds = (detail.locationIds ?? [])
2834 2921 .map((x) => String(x).trim())
2835 2922 .filter(Boolean);
2836   - const matchedFromIds = new Set<string>();
  2923 + assignedLocationIdsRef.current = rawIds;
  2924 + const resolvedIds = new Set<string>();
2837 2925 for (const id of rawIds) {
2838   - if (optionIds.has(id)) matchedFromIds.add(id);
  2926 + const hit = catalogIdByNorm.get(normLocationId(id));
  2927 + resolvedIds.add(hit ?? id);
2839 2928 }
2840 2929 let assignedIds = new Set<string>();
2841   - if (matchedFromIds.size > 0) {
2842   - setSelectedLocationIds(matchedFromIds);
2843   - assignedIds = matchedFromIds;
  2930 + if (resolvedIds.size > 0) {
  2931 + setSelectedLocationIds(resolvedIds);
  2932 + assignedIds = resolvedIds;
2844 2933 } else if (detail.locations?.length) {
2845 2934 // 如果后端只返回 locations(名字),则尽力映射回 id
2846 2935 const labels = new Set(detail.locations);
... ... @@ -2851,32 +2940,45 @@ function MemberDialog({
2851 2940 const label3 = (l.locationCode ?? "").trim();
2852 2941 if (labels.has(label1) || labels.has(label2) || labels.has(label3)) inferred.add(l.id);
2853 2942 }
  2943 + assignedLocationIdsRef.current = [...inferred];
2854 2944 setSelectedLocationIds(inferred);
2855 2945 assignedIds = inferred;
  2946 + } else {
  2947 + assignedLocationIdsRef.current = [];
2856 2948 }
2857 2949  
2858   - let nextCompany = String(detail.partnerId ?? "").trim();
2859   - let nextRegion = String(detail.groupId ?? "").trim();
2860   - if ((!nextCompany || !nextRegion) && assignedIds.size > 0) {
  2950 + const rawRegionIds = [
  2951 + ...(detail.regionIds ?? []),
  2952 + ...(detail.groupIds ?? []),
  2953 + ]
  2954 + .map((x) => String(x).trim())
  2955 + .filter(Boolean);
  2956 + if (detail.groupId?.trim() && !rawRegionIds.includes(detail.groupId.trim())) {
  2957 + rawRegionIds.push(detail.groupId.trim());
  2958 + }
  2959 + const regionIdSet = new Set(rawRegionIds);
  2960 + assignedRegionIdsRef.current = [...regionIdSet];
  2961 + setSelectedRegionIds(regionIdSet);
  2962 +
  2963 + let nextCompany = String(
  2964 + detail.partnerId ?? detail.partnerIds?.[0] ?? "",
  2965 + ).trim();
  2966 + if (!nextCompany && assignedIds.size > 0) {
2861 2967 const firstId = [...assignedIds][0];
2862 2968 const loc0 = locationsRes.find((l) => l.id === firstId);
2863 2969 if (loc0) {
2864 2970 const pn = (loc0.partner ?? "").trim();
2865   - const gn = (loc0.groupName ?? "").trim();
2866   - if (!nextCompany && pn) {
  2971 + if (pn) {
2867 2972 const p = partnersRes.find((x) => (x.partnerName ?? "").trim() === pn);
2868 2973 if (p) nextCompany = p.id;
2869 2974 }
2870   - if (!nextRegion && gn && nextCompany) {
2871   - const g = groupsRes.find(
2872   - (x) => (x.groupName ?? "").trim() === gn && x.partnerId === nextCompany,
2873   - );
2874   - if (g) nextRegion = g.id;
2875   - }
2876 2975 }
2877 2976 }
  2977 + if (!nextCompany && regionIdSet.size > 0) {
  2978 + const g0 = groupsRes.find((g) => regionIdSet.has(g.id));
  2979 + if (g0?.partnerId) nextCompany = g0.partnerId;
  2980 + }
2878 2981 setCompanyId(nextCompany);
2879   - setRegionId(nextRegion);
2880 2982 }
2881 2983 } catch (e: any) {
2882 2984 if (e?.name !== "AbortError") {
... ... @@ -2901,45 +3003,164 @@ function MemberDialog({
2901 3003 [partnerOptions, companyId],
2902 3004 );
2903 3005  
2904   - const selectedGroupForScope = useMemo(
2905   - () => groupOptionsAll.find((g) => g.id === regionId) ?? null,
2906   - [groupOptionsAll, regionId],
2907   - );
2908   -
2909 3006 const regionSelectOptions = useMemo(
2910 3007 () => groupOptionsAll.filter((g) => g.partnerId === companyId),
2911 3008 [groupOptionsAll, companyId],
2912 3009 );
2913 3010  
  3011 + const regionMultiSelectOptions = useMemo(
  3012 + () =>
  3013 + regionSelectOptions.map((g) => ({
  3014 + value: g.id,
  3015 + label: (g.groupName ?? "").trim() || g.id,
  3016 + })),
  3017 + [regionSelectOptions],
  3018 + );
  3019 +
  3020 + const selectedRegionsForScope = useMemo(
  3021 + () => regionSelectOptions.filter((g) => selectedRegionIds.has(g.id)),
  3022 + [regionSelectOptions, selectedRegionIds],
  3023 + );
  3024 +
2914 3025 const locationsForMemberPicker = useMemo(() => {
2915   - if (!companyId.trim() || !regionId.trim()) return [];
  3026 + if (!companyId.trim() || selectedRegionIds.size === 0) return [];
  3027 + return scopedLocationOptions;
  3028 + }, [companyId, selectedRegionIds, scopedLocationOptions]);
  3029 +
  3030 + useEffect(() => {
  3031 + if (!open) return;
  3032 + if (!companyId.trim() || selectedRegionIds.size === 0) {
  3033 + setScopedLocationOptions([]);
  3034 + return;
  3035 + }
2916 3036 const p = selectedPartnerForScope;
2917   - const g = selectedGroupForScope;
2918   - if (!p || !g || g.partnerId !== companyId) return [];
  3037 + if (!p) {
  3038 + setScopedLocationOptions([]);
  3039 + return;
  3040 + }
2919 3041 const pn = (p.partnerName ?? "").trim();
2920   - const gn = (g.groupName ?? "").trim();
2921   - return locationOptions.filter(
2922   - (l) => (l.partner ?? "").trim() === pn && (l.groupName ?? "").trim() === gn,
2923   - );
2924   - }, [companyId, regionId, locationOptions, selectedPartnerForScope, selectedGroupForScope]);
  3042 + if (!pn || selectedRegionsForScope.length === 0) {
  3043 + setScopedLocationOptions([]);
  3044 + return;
  3045 + }
  3046 +
  3047 + const ac = new AbortController();
  3048 + setLoadingScopedLocations(true);
  3049 + (async () => {
  3050 + try {
  3051 + const scoped = await loadScopedLocationsForRegions(pn, selectedRegionsForScope, ac.signal);
  3052 + const merged = new Map<string, LocationDto>();
  3053 + for (const l of scoped) {
  3054 + const id = String(l.id ?? "").trim();
  3055 + if (id) merged.set(id, l);
  3056 + }
  3057 + const catalogByNorm = new Map(
  3058 + locationOptions.map((l) => [normLocationId(l.id), l] as const),
  3059 + );
  3060 + const idsToEnsure = [
  3061 + ...assignedLocationIdsRef.current,
  3062 + ...Array.from(selectedLocationIds),
  3063 + ];
  3064 + for (const rawId of idsToEnsure) {
  3065 + const hit = catalogByNorm.get(normLocationId(rawId));
  3066 + if (!hit) continue;
  3067 + const id = String(hit.id ?? "").trim();
  3068 + if (id && !merged.has(id)) merged.set(id, hit);
  3069 + }
  3070 + setScopedLocationOptions(Array.from(merged.values()));
  3071 +
  3072 + if (assignedRegionIdsRef.current.length > 0) {
  3073 + const nextRegions = new Set<string>();
  3074 + for (const rawId of assignedRegionIdsRef.current) {
  3075 + const hit = regionSelectOptions.find((g) => g.id === rawId);
  3076 + if (hit) nextRegions.add(hit.id);
  3077 + }
  3078 + setSelectedRegionIds(nextRegions);
  3079 + assignedRegionIdsRef.current = [];
  3080 + }
  3081 +
  3082 + if (assignedLocationIdsRef.current.length > 0) {
  3083 + const nextSelected = new Set<string>();
  3084 + for (const rawId of assignedLocationIdsRef.current) {
  3085 + const hit = catalogByNorm.get(normLocationId(rawId));
  3086 + nextSelected.add(hit?.id ?? rawId);
  3087 + }
  3088 + setSelectedLocationIds(nextSelected);
  3089 + assignedLocationIdsRef.current = [];
  3090 + }
  3091 + } catch (e: unknown) {
  3092 + if ((e as { name?: string })?.name !== "AbortError") {
  3093 + setScopedLocationOptions([]);
  3094 + }
  3095 + } finally {
  3096 + if (!ac.signal.aborted) setLoadingScopedLocations(false);
  3097 + }
  3098 + })();
  3099 +
  3100 + return () => ac.abort();
  3101 + // eslint-disable-next-line react-hooks/exhaustive-deps
  3102 + }, [open, companyId, selectedRegionIds, selectedPartnerForScope, selectedRegionsForScope, locationOptions]);
2925 3103  
2926 3104 useEffect(() => {
2927 3105 if (!open) return;
2928   - const allowed = new Set(locationsForMemberPicker.map((l) => String(l.id ?? "").trim()).filter(Boolean));
  3106 + if (loadingLocations || loadingScopedLocations) return;
  3107 + if (!companyId.trim() || selectedRegionIds.size === 0) return;
  3108 + const allowedRegionIds = new Set(regionSelectOptions.map((g) => g.id));
  3109 + setSelectedRegionIds((prev) => {
  3110 + const next = new Set([...prev].filter((id) => allowedRegionIds.has(id)));
  3111 + if (next.size === prev.size && [...next].every((id) => prev.has(id))) return prev;
  3112 + return next;
  3113 + });
  3114 + }, [open, companyId, regionSelectOptions, loadingLocations, loadingScopedLocations]);
  3115 +
  3116 + useEffect(() => {
  3117 + if (!open) return;
  3118 + if (loadingLocations || loadingScopedLocations) return;
  3119 + if (!companyId.trim() || selectedRegionIds.size === 0) return;
  3120 + if (locationsForMemberPicker.length === 0) return;
  3121 +
  3122 + const allowedNorm = new Set(
  3123 + locationsForMemberPicker.map((l) => normLocationId(l.id)).filter(Boolean),
  3124 + );
2929 3125 setSelectedLocationIds((prev) => {
2930   - const next = new Set([...prev].filter((id) => allowed.has(id)));
  3126 + const next = new Set(
  3127 + [...prev].filter((id) => allowedNorm.has(normLocationId(id))),
  3128 + );
2931 3129 if (next.size === prev.size && [...next].every((id) => prev.has(id))) return prev;
2932 3130 return next;
2933 3131 });
2934   - }, [open, locationsForMemberPicker]);
  3132 + }, [
  3133 + open,
  3134 + companyId,
  3135 + selectedRegionIds,
  3136 + locationsForMemberPicker,
  3137 + loadingLocations,
  3138 + loadingScopedLocations,
  3139 + ]);
  3140 +
  3141 + const selectedRegionIdsForSubmit = useMemo(
  3142 + () =>
  3143 + regionSelectOptions
  3144 + .map((g) => g.id)
  3145 + .filter((id) => selectedRegionIds.has(id)),
  3146 + [regionSelectOptions, selectedRegionIds],
  3147 + );
2935 3148  
2936 3149 const selectedLocationIdsForSubmit = useMemo(() => {
2937   - const allowed = new Set(
2938   - locationsForMemberPicker.map((l) => String(l.id ?? "").trim()).filter(Boolean),
  3150 + const allowedNorm = new Set(
  3151 + locationsForMemberPicker.map((l) => normLocationId(l.id)).filter(Boolean),
2939 3152 );
2940   - return Array.from(selectedLocationIds).filter((id) => allowed.has(id));
  3153 + return Array.from(selectedLocationIds).filter((id) => allowedNorm.has(normLocationId(id)));
2941 3154 }, [locationsForMemberPicker, selectedLocationIds]);
2942 3155  
  3156 + const isLocationSelected = useCallback(
  3157 + (locId: string) => {
  3158 + const n = normLocationId(locId);
  3159 + return [...selectedLocationIds].some((id) => normLocationId(id) === n);
  3160 + },
  3161 + [selectedLocationIds],
  3162 + );
  3163 +
2943 3164 // Same as create: Save enabled when required fields are filled; empty values show error on submit.
2944 3165 const canSubmit = useMemo(() => {
2945 3166 if (!fullName.trim()) return false;
... ... @@ -2948,7 +3169,7 @@ function MemberDialog({
2948 3169 if (!String(phone ?? "").trim()) return false;
2949 3170 if (!roleId.trim()) return false;
2950 3171 if (!companyId.trim()) return false;
2951   - if (!regionId.trim()) return false;
  3172 + if (selectedRegionIdsForSubmit.length === 0) return false;
2952 3173 if (selectedLocationIdsForSubmit.length === 0) return false;
2953 3174 if (!isEdit && !password.trim()) return false;
2954 3175 return true;
... ... @@ -2959,17 +3180,23 @@ function MemberDialog({
2959 3180 phone,
2960 3181 roleId,
2961 3182 companyId,
2962   - regionId,
  3183 + selectedRegionIdsForSubmit,
2963 3184 selectedLocationIdsForSubmit,
2964 3185 isEdit,
2965 3186 password,
2966 3187 ]);
2967 3188  
2968 3189 const toggleLocation = (id: string, checked: boolean) => {
  3190 + const n = normLocationId(id);
2969 3191 setSelectedLocationIds((prev) => {
2970 3192 const next = new Set(prev);
2971   - if (checked) next.add(id);
2972   - else next.delete(id);
  3193 + if (checked) {
  3194 + next.add(id);
  3195 + } else {
  3196 + for (const x of prev) {
  3197 + if (normLocationId(x) === n) next.delete(x);
  3198 + }
  3199 + }
2973 3200 return next;
2974 3201 });
2975 3202 };
... ... @@ -2982,14 +3209,14 @@ function MemberDialog({
2982 3209 const allLocationsSelected = useMemo(
2983 3210 () =>
2984 3211 allLocationIdsForToggle.length > 0 &&
2985   - allLocationIdsForToggle.every((id) => selectedLocationIds.has(id)),
2986   - [allLocationIdsForToggle, selectedLocationIds],
  3212 + allLocationIdsForToggle.every((id) => isLocationSelected(id)),
  3213 + [allLocationIdsForToggle, isLocationSelected],
2987 3214 );
2988 3215  
2989 3216 const someLocationsSelected = useMemo(
2990 3217 () =>
2991   - allLocationIdsForToggle.some((id) => selectedLocationIds.has(id)) && !allLocationsSelected,
2992   - [allLocationIdsForToggle, selectedLocationIds, allLocationsSelected],
  3218 + allLocationIdsForToggle.some((id) => isLocationSelected(id)) && !allLocationsSelected,
  3219 + [allLocationIdsForToggle, isLocationSelected, allLocationsSelected],
2993 3220 );
2994 3221  
2995 3222 const toggleAllLocations = (checked: boolean) => {
... ... @@ -3018,7 +3245,7 @@ function MemberDialog({
3018 3245 if (!String(phone ?? "").trim()) missing.push("Phone");
3019 3246 if (!roleId.trim()) missing.push("Role");
3020 3247 if (!companyId.trim()) missing.push("Company");
3021   - if (!regionId.trim()) missing.push("Region");
  3248 + if (selectedRegionIdsForSubmit.length === 0) missing.push("Region");
3022 3249 if (selectedLocationIdsForSubmit.length === 0) missing.push("Locations");
3023 3250 if (!isEdit && !password.trim()) missing.push("Password");
3024 3251 toast.error("Missing required fields.", {
... ... @@ -3032,7 +3259,9 @@ function MemberDialog({
3032 3259 setSubmitting(true);
3033 3260 try {
3034 3261 const locationIds = selectedLocationIdsForSubmit;
3035   - console.log("[MemberDialog] Creating user", { fullName, userName, roleId, locationIds });
  3262 + const regionIds = selectedRegionIdsForSubmit;
  3263 + const partnerId = companyId.trim();
  3264 + console.log("[MemberDialog] Creating user", { fullName, userName, roleId, partnerId, regionIds, locationIds });
3036 3265 await createTeamMember({
3037 3266 fullName: fullName.trim(),
3038 3267 userName: userName.trim(),
... ... @@ -3040,6 +3269,8 @@ function MemberDialog({
3040 3269 email: email.trim(),
3041 3270 phone: String(phone).trim(),
3042 3271 roleId: roleId.trim(),
  3272 + partnerId,
  3273 + regionIds,
3043 3274 locationIds,
3044 3275 state,
3045 3276 });
... ... @@ -3059,7 +3290,9 @@ function MemberDialog({
3059 3290 setSubmitting(true);
3060 3291 try {
3061 3292 const locationIds = selectedLocationIdsForSubmit;
3062   - console.log("[MemberDialog] Updating user", { id: member.id, fullName, userName, roleId, locationIds });
  3293 + const regionIds = selectedRegionIdsForSubmit;
  3294 + const partnerId = companyId.trim();
  3295 + console.log("[MemberDialog] Updating user", { id: member.id, fullName, userName, roleId, partnerId, regionIds, locationIds });
3063 3296 await updateTeamMember(member.id, {
3064 3297 fullName: fullName.trim(),
3065 3298 userName: userName.trim(),
... ... @@ -3067,6 +3300,8 @@ function MemberDialog({
3067 3300 email: email.trim(),
3068 3301 phone: String(phone).trim(),
3069 3302 roleId: roleId.trim(),
  3303 + partnerId,
  3304 + regionIds,
3070 3305 locationIds,
3071 3306 state,
3072 3307 });
... ... @@ -3108,8 +3343,8 @@ function MemberDialog({
3108 3343 <DialogHeader>
3109 3344 <DialogTitle>{isEdit ? "Edit User" : "New User"}</DialogTitle>
3110 3345 <DialogDescription>
3111   - Company and Region are single-select and scope which locations you can assign; Locations is
3112   - multi-select.
  3346 + Company is single-select; Region and Locations are multi-select and determine which stores you
  3347 + can assign.
3113 3348 </DialogDescription>
3114 3349 </DialogHeader>
3115 3350  
... ... @@ -3196,7 +3431,9 @@ function MemberDialog({
3196 3431 onValueChange={(v) => {
3197 3432 const next = (v && v.trim()) ? v.trim() : "";
3198 3433 setCompanyId(next);
3199   - setRegionId("");
  3434 + setSelectedRegionIds(new Set());
  3435 + setSelectedLocationIds(new Set());
  3436 + setScopedLocationOptions([]);
3200 3437 }}
3201 3438 disabled={loadingRoles || loadingPartnersGroups}
3202 3439 >
... ... @@ -3218,30 +3455,23 @@ function MemberDialog({
3218 3455 </div>
3219 3456 <div className="space-y-2">
3220 3457 <Label>Region *</Label>
3221   - <Select
3222   - value={regionId ? regionId : ""}
3223   - onValueChange={(v) => setRegionId((v && v.trim()) ? v.trim() : "")}
  3458 + <SearchableMultiSelect
  3459 + values={selectedRegionIdsForSubmit}
  3460 + onValuesChange={(next) => setSelectedRegionIds(new Set(next))}
  3461 + options={regionMultiSelectOptions}
  3462 + placeholder={
  3463 + !companyId.trim()
  3464 + ? "Select company first"
  3465 + : loadingPartnersGroups
  3466 + ? "Loading regions..."
  3467 + : "Select region(s)…"
  3468 + }
  3469 + searchPlaceholder="Search regions…"
  3470 + emptyText="No regions for this company."
  3471 + selectAllRowLabel="ALL"
3224 3472 disabled={loadingRoles || loadingPartnersGroups || !companyId.trim()}
3225   - >
3226   - <SelectTrigger className="h-10 rounded-md border border-gray-200 bg-white">
3227   - <SelectValue
3228   - placeholder={
3229   - !companyId.trim()
3230   - ? "Select company first"
3231   - : loadingPartnersGroups
3232   - ? "Loading regions..."
3233   - : "Select region"
3234   - }
3235   - />
3236   - </SelectTrigger>
3237   - <SelectContent>
3238   - {regionSelectOptions.map((g) => (
3239   - <SelectItem key={g.id} value={g.id}>
3240   - {(g.groupName ?? "").trim() || g.id}
3241   - </SelectItem>
3242   - ))}
3243   - </SelectContent>
3244   - </Select>
  3473 + className="h-10 rounded-md border border-gray-200 bg-white"
  3474 + />
3245 3475 </div>
3246 3476 </div>
3247 3477  
... ... @@ -3260,11 +3490,11 @@ function MemberDialog({
3260 3490 </div>
3261 3491 <ScrollArea className="h-[180px] w-full border rounded-md p-2">
3262 3492 <div className="space-y-2">
3263   - {loadingLocations ? (
  3493 + {loadingLocations || loadingScopedLocations ? (
3264 3494 <div className="text-sm text-gray-500 py-2">Loading...</div>
3265   - ) : !companyId.trim() || !regionId.trim() ? (
  3495 + ) : !companyId.trim() || selectedRegionIds.size === 0 ? (
3266 3496 <div className="text-sm text-gray-500 py-2">
3267   - Please select company and region to see locations for assignment.
  3497 + Please select company and at least one region to see locations for assignment.
3268 3498 </div>
3269 3499 ) : (
3270 3500 <>
... ... @@ -3298,7 +3528,7 @@ function MemberDialog({
3298 3528 <div key={l.id} className="flex items-center space-x-2">
3299 3529 <Checkbox
3300 3530 id={`loc-${l.id}`}
3301   - checked={selectedLocationIds.has(l.id)}
  3531 + checked={isLocationSelected(l.id)}
3302 3532 onCheckedChange={(v) => toggleLocation(l.id, !!v)}
3303 3533 />
3304 3534 <label htmlFor={`loc-${l.id}`} className="text-sm cursor-pointer w-full hover:bg-gray-50 p-1 rounded">
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -8,6 +8,7 @@ import {
8 8 MoreHorizontal,
9 9 Package,
10 10 Trash2,
  11 + Barcode,
11 12 } from "lucide-react";
12 13 import { Button } from "../ui/button";
13 14 import { Checkbox } from "../ui/checkbox";
... ... @@ -56,6 +57,15 @@ import {
56 57 tokensFromSelection,
57 58 visualInputFromAppearanceAndValueArray,
58 59 } from "../../lib/categoryButtonAppearance";
  60 +import {
  61 + FORM_DIALOG_CONTENT_CLASS,
  62 + FORM_DIALOG_CONTENT_STYLE,
  63 + FORM_DIALOG_FOOTER_CLASS,
  64 + FORM_DIALOG_HEADER_CLASS,
  65 + FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS,
  66 + FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX,
  67 + FORM_DIALOG_SCROLL_BODY_CLASS,
  68 +} from "../../lib/formDialogLayout";
59 69 import { getLocations } from "../../services/locationService";
60 70 import { getPartners } from "../../services/partnerService";
61 71 import { getGroups } from "../../services/groupService";
... ... @@ -1324,6 +1334,9 @@ function ProductFormDialog({
1324 1334 locationMap: Map<string, string[]>;
1325 1335 onSaved: () => void;
1326 1336 }) {
  1337 + const DISPLAY_TEXT_MAX = 7;
  1338 + const clampDisplayText = (s: string) => String(s ?? "").slice(0, DISPLAY_TEXT_MAX);
  1339 +
1327 1340 const [submitting, setSubmitting] = useState(false);
1328 1341 const [initLoading, setInitLoading] = useState(false);
1329 1342 const [scopeLoading, setScopeLoading] = useState(false);
... ... @@ -1332,12 +1345,88 @@ function ProductFormDialog({
1332 1345 const [partnerId, setPartnerId] = useState("");
1333 1346 const [groupId, setGroupId] = useState("");
1334 1347 const [categoryId, setCategoryId] = useState("");
1335   - const [productImageUrl, setProductImageUrl] = useState("");
  1348 + const [displayText, setDisplayText] = useState("");
  1349 + const [apSel, setApSel] = useState({ text: true, color: false, image: false });
  1350 + const [buttonBgColor, setButtonBgColor] = useState("");
  1351 + const [buttonImageUrl, setButtonImageUrl] = useState("");
  1352 + const [codeValue, setCodeValue] = useState("");
1336 1353 const [state, setState] = useState(true);
1337 1354 const [locationIds, setLocationIds] = useState<string[]>([]);
1338 1355 const [scopedCategoryOptions, setScopedCategoryOptions] = useState<{ value: string; label: string }[]>([]);
1339 1356 const [scopedLocationOptions, setScopedLocationOptions] = useState<{ value: string; label: string }[]>([]);
1340 1357  
  1358 + const textColorToggleValue = useMemo(() => {
  1359 + const v: string[] = [];
  1360 + if (apSel.text) v.push("TEXT");
  1361 + if (apSel.color) v.push("COLOR");
  1362 + return v;
  1363 + }, [apSel.text, apSel.color]);
  1364 +
  1365 + const colorPresets = useMemo(
  1366 + () => [
  1367 + "#111827",
  1368 + "#374151",
  1369 + "#6B7280",
  1370 + "#EF4444",
  1371 + "#F59E0B",
  1372 + "#10B981",
  1373 + "#3B82F6",
  1374 + "#8B5CF6",
  1375 + "#EC4899",
  1376 + ],
  1377 + [],
  1378 + );
  1379 +
  1380 + const applyAppearanceFromProduct = (detail: ProductDto) => {
  1381 + const parsed = parseCategoryButtonStyleV1(detail.buttonStyleJson);
  1382 + if (parsed) {
  1383 + setApSel(appearanceSelectionFromTokens(parsed.appearances));
  1384 + setDisplayText(clampDisplayText(parsed.displayText ?? ""));
  1385 + setButtonBgColor(parsed.buttonBgColor ?? "");
  1386 + setButtonImageUrl(parsed.buttonImageUrl ?? "");
  1387 + return;
  1388 + }
  1389 + const appearances = parseAppearanceTokens(detail.buttonAppearance);
  1390 + const valArr = parseCategoryPhotoUrlValueArray(detail.categoryPhotoUrl);
  1391 + if (valArr && appearances.length > 0 && appearances.length === valArr.length) {
  1392 + const merged = visualInputFromAppearanceAndValueArray(appearances, valArr, {
  1393 + categoryName: detail.productName,
  1394 + name: undefined,
  1395 + buttonTextColor: null,
  1396 + });
  1397 + setApSel(appearanceSelectionFromTokens(appearances));
  1398 + setDisplayText(clampDisplayText(merged.displayText ?? ""));
  1399 + setButtonBgColor(merged.buttonBgColor ?? "");
  1400 + setButtonImageUrl(merged.buttonImageUrl ?? "");
  1401 + return;
  1402 + }
  1403 + const img = (detail.buttonImageUrl ?? detail.productImageUrl ?? "").trim();
  1404 + if (img) {
  1405 + setApSel({ text: false, color: false, image: true });
  1406 + setDisplayText("");
  1407 + setButtonBgColor("");
  1408 + setButtonImageUrl(img);
  1409 + return;
  1410 + }
  1411 + setDisplayText(clampDisplayText(detail.displayText ?? ""));
  1412 + const tokens = parseAppearanceTokens(detail.buttonAppearance);
  1413 + if (tokens.length === 0) {
  1414 + setApSel({ text: true, color: false, image: false });
  1415 + } else {
  1416 + setApSel(appearanceSelectionFromTokens(tokens));
  1417 + }
  1418 + setButtonBgColor(detail.buttonBgColor ?? "");
  1419 + setButtonImageUrl(detail.buttonImageUrl ?? "");
  1420 + };
  1421 +
  1422 + const resetAppearance = () => {
  1423 + setDisplayText("");
  1424 + setApSel({ text: true, color: false, image: false });
  1425 + setButtonBgColor("");
  1426 + setButtonImageUrl("");
  1427 + setCodeValue("");
  1428 + };
  1429 +
1341 1430 const regionOptionsForForm = useMemo(() => {
1342 1431 let list = groups;
1343 1432 if (partnerId) list = list.filter((g) => g.partnerId === partnerId);
... ... @@ -1381,7 +1470,8 @@ function ProductFormDialog({
1381 1470 setProductCode(detail.productCode ?? editing.productCode ?? "");
1382 1471 setProductName(detail.productName ?? editing.productName ?? "");
1383 1472 setCategoryId((detail.categoryId ?? editing.categoryId ?? "").trim());
1384   - setProductImageUrl(detail.productImageUrl ?? editing.productImageUrl ?? "");
  1473 + applyAppearanceFromProduct(detail);
  1474 + setCodeValue((detail.codeValue ?? "").trim());
1385 1475 setState(detail.state !== false && editing.state !== false);
1386 1476 setPartnerId(pid);
1387 1477 setGroupId(gid);
... ... @@ -1392,7 +1482,7 @@ function ProductFormDialog({
1392 1482 setPartnerId("");
1393 1483 setGroupId("");
1394 1484 setCategoryId("");
1395   - setProductImageUrl("");
  1485 + resetAppearance();
1396 1486 setState(true);
1397 1487 setLocationIds([]);
1398 1488 }
... ... @@ -1490,11 +1580,54 @@ function ProductFormDialog({
1490 1580 return;
1491 1581 }
1492 1582  
  1583 + const tokens = tokensFromSelection(apSel);
  1584 + if (tokens.length === 0) {
  1585 + toast.error("Validation", { description: "Select at least one button appearance (Text, Color, or Image)." });
  1586 + return;
  1587 + }
  1588 + if (tokens[0] === "IMAGE") {
  1589 + if (!buttonImageUrl.trim()) {
  1590 + toast.error("Validation", { description: "Please upload an image for Image appearance." });
  1591 + return;
  1592 + }
  1593 + } else {
  1594 + if (apSel.text && !clampDisplayText(displayText.trim())) {
  1595 + toast.error("Validation", { description: "Please enter display text for Text appearance." });
  1596 + return;
  1597 + }
  1598 + if (apSel.color && !buttonBgColor.trim()) {
  1599 + toast.error("Validation", { description: "Please select a background color for Color appearance." });
  1600 + return;
  1601 + }
  1602 + }
  1603 +
  1604 + const displayTextForSave = clampDisplayText(displayText.trim()) || null;
  1605 + const categoryPhotoUrl = serializeCategoryPhotoUrlValueArray(tokens, {
  1606 + displayText: displayTextForSave ?? "",
  1607 + buttonBgColor: buttonBgColor.trim(),
  1608 + buttonImageUrl: buttonImageUrl.trim(),
  1609 + });
  1610 + const buttonStyleJson = serializeCategoryButtonStyleV1({
  1611 + appearances: tokens,
  1612 + displayText: displayTextForSave,
  1613 + buttonBgColor: apSel.image ? null : buttonBgColor.trim() || null,
  1614 + buttonTextColor: null,
  1615 + buttonImageUrl: apSel.image ? buttonImageUrl.trim() || null : null,
  1616 + });
  1617 + const imageUrlForLegacy = apSel.image ? buttonImageUrl.trim() || null : null;
  1618 +
1493 1619 const body: ProductCreateInput = {
1494 1620 productCode: productCode.trim() || null,
1495 1621 productName: productName.trim(),
1496 1622 categoryId: categoryId.trim() || null,
1497   - productImageUrl: productImageUrl.trim() || null,
  1623 + productImageUrl: imageUrlForLegacy,
  1624 + categoryPhotoUrl,
  1625 + displayText: displayTextForSave,
  1626 + buttonAppearance: tokens,
  1627 + buttonBgColor: buttonBgColor.trim() || null,
  1628 + buttonImageUrl: buttonImageUrl.trim() || null,
  1629 + buttonStyleJson,
  1630 + codeValue: codeValue.trim() || null,
1498 1631 state,
1499 1632 partnerId,
1500 1633 groupIds: [groupId],
... ... @@ -1521,8 +1654,8 @@ function ProductFormDialog({
1521 1654  
1522 1655 return (
1523 1656 <Dialog open={open} onOpenChange={onOpenChange}>
1524   - <DialogContent className="w-[min(50%,calc(100vw-2rem))] max-w-none sm:max-w-none max-h-[90vh] overflow-y-auto">
1525   - <DialogHeader>
  1657 + <DialogContent className={FORM_DIALOG_CONTENT_CLASS} style={FORM_DIALOG_CONTENT_STYLE}>
  1658 + <DialogHeader className={FORM_DIALOG_HEADER_CLASS}>
1526 1659 <DialogTitle>{editing ? "Edit Product" : "Add New Product"}</DialogTitle>
1527 1660 <DialogDescription>
1528 1661 {editing
... ... @@ -1531,7 +1664,8 @@ function ProductFormDialog({
1531 1664 </DialogDescription>
1532 1665 </DialogHeader>
1533 1666  
1534   - <div className="grid gap-4 py-4">
  1667 + <div className={FORM_DIALOG_SCROLL_BODY_CLASS}>
  1668 + <div className="grid gap-4 py-2">
1535 1669 <div className="grid grid-cols-2 gap-4">
1536 1670 <div className="space-y-2">
1537 1671 <Label>Product code</Label>
... ... @@ -1612,15 +1746,158 @@ function ProductFormDialog({
1612 1746 />
1613 1747 </div>
1614 1748 <div className="space-y-2">
1615   - <Label>Product image</Label>
1616   - <ImageUrlUpload
1617   - value={productImageUrl}
1618   - onChange={setProductImageUrl}
1619   - uploadSubDir="product"
1620   - oneImageOnly
1621   - hint="POST /api/app/picture/category/upload (subDir: product). JPG/PNG/WebP/GIF, max 5 MB."
  1749 + <Label>Button Appearance</Label>
  1750 + <div className="category-appearance-toggles rounded-2xl border border-gray-200 bg-gray-100 p-3 space-y-3">
  1751 + <div className="space-y-1">
  1752 + <div className="text-xs text-gray-600">Text &amp; Color — can combine</div>
  1753 + <ToggleGroup
  1754 + type="multiple"
  1755 + value={textColorToggleValue}
  1756 + onValueChange={(v) => {
  1757 + const arr = (v ?? []) as string[];
  1758 + setApSel((s) => ({
  1759 + ...s,
  1760 + text: arr.includes("TEXT"),
  1761 + color: arr.includes("COLOR"),
  1762 + image: false,
  1763 + }));
  1764 + }}
  1765 + variant="outline"
  1766 + size="sm"
  1767 + className="w-full bg-transparent gap-1 flex-wrap justify-stretch"
  1768 + >
  1769 + <ToggleGroupItem
  1770 + value="TEXT"
  1771 + className="flex-1 min-w-[100px] gap-2 h-10 rounded-full transition-colors border border-gray-200 bg-white text-gray-700 shadow-sm hover:bg-gray-50"
  1772 + >
  1773 + <span className="font-medium flex items-center gap-2">
  1774 + <span className="text-lg leading-none">T</span>
  1775 + Text
  1776 + </span>
  1777 + </ToggleGroupItem>
  1778 + <ToggleGroupItem
  1779 + value="COLOR"
  1780 + className="flex-1 min-w-[100px] gap-2 h-10 rounded-full transition-colors border border-gray-200 bg-white text-gray-700 shadow-sm hover:bg-gray-50"
  1781 + >
  1782 + <span className="font-medium flex items-center gap-2">
  1783 + <span className="text-lg leading-none">🎨</span>
  1784 + Color
  1785 + </span>
  1786 + </ToggleGroupItem>
  1787 + </ToggleGroup>
  1788 + </div>
  1789 + <div className="space-y-1">
  1790 + <div className="text-xs text-gray-600">Image — exclusive (clears Text &amp; Color)</div>
  1791 + <ToggleGroup
  1792 + type="single"
  1793 + value={apSel.image ? "IMAGE" : ""}
  1794 + onValueChange={(v) => {
  1795 + const up = String(v || "").toUpperCase();
  1796 + if (up === "IMAGE") setApSel({ text: false, color: false, image: true });
  1797 + else setApSel((s) => ({ ...s, image: false }));
  1798 + }}
  1799 + variant="outline"
  1800 + size="sm"
  1801 + className="w-full bg-transparent"
  1802 + >
  1803 + <ToggleGroupItem
  1804 + value="IMAGE"
  1805 + className="w-full gap-2 h-10 rounded-full transition-colors border border-gray-200 bg-white text-gray-700 shadow-sm hover:bg-gray-50"
  1806 + >
  1807 + <span className="font-medium flex items-center justify-center gap-2">
  1808 + <span className="text-lg leading-none">🖼</span>
  1809 + Image
  1810 + </span>
  1811 + </ToggleGroupItem>
  1812 + </ToggleGroup>
  1813 + </div>
  1814 + </div>
  1815 + </div>
  1816 +
  1817 + {apSel.text && !apSel.image ? (
  1818 + <div className="space-y-2">
  1819 + <Label>Display Text</Label>
  1820 + <Input
  1821 + className="h-10"
  1822 + value={displayText}
  1823 + onChange={(e) => setDisplayText(clampDisplayText(e.target.value))}
  1824 + placeholder="Category Name"
  1825 + maxLength={DISPLAY_TEXT_MAX}
  1826 + />
  1827 + <div className="text-xs text-gray-500">
  1828 + Max {DISPLAY_TEXT_MAX} characters. Saved to <span className="font-mono">photo</span> as this text.
  1829 + </div>
  1830 + </div>
  1831 + ) : null}
  1832 +
  1833 + {apSel.color && !apSel.image ? (
  1834 + <div className="space-y-2">
  1835 + <Label>Select Color</Label>
  1836 + <div className="flex flex-wrap items-center gap-3">
  1837 + {colorPresets.map((c) => (
  1838 + <button
  1839 + key={c}
  1840 + type="button"
  1841 + className={[
  1842 + "h-10 w-10 rounded-full border border-gray-200 shadow-sm",
  1843 + buttonBgColor.toLowerCase() === c.toLowerCase() ? "ring-2 ring-blue-500 ring-offset-2" : "",
  1844 + ].join(" ")}
  1845 + style={{ backgroundColor: c }}
  1846 + onClick={() => setButtonBgColor(c)}
  1847 + aria-label={`Select ${c}`}
  1848 + />
  1849 + ))}
  1850 + <button
  1851 + type="button"
  1852 + className="h-10 w-10 rounded-full border border-dashed border-gray-300 bg-white text-gray-500 hover:text-gray-700 hover:border-gray-400 flex items-center justify-center"
  1853 + onClick={() => {
  1854 + const el = document.getElementById("product-form-custom-color") as HTMLInputElement | null;
  1855 + el?.click();
  1856 + }}
  1857 + aria-label="Custom color"
  1858 + >
  1859 + +
  1860 + </button>
  1861 + <input
  1862 + id="product-form-custom-color"
  1863 + type="color"
  1864 + value={buttonBgColor || "#3B82F6"}
  1865 + onChange={(e) => setButtonBgColor(e.target.value)}
  1866 + className="h-0 w-0 opacity-0 pointer-events-none"
  1867 + aria-label="Custom color picker"
  1868 + />
  1869 + </div>
  1870 + </div>
  1871 + ) : null}
  1872 +
  1873 + {apSel.image ? (
  1874 + <div className="space-y-2">
  1875 + <Label>Button Image</Label>
  1876 + <ImageUrlUpload
  1877 + value={buttonImageUrl}
  1878 + onChange={setButtonImageUrl}
  1879 + uploadSubDir="product"
  1880 + oneImageOnly
  1881 + boxClassName={FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS}
  1882 + boxSizePx={FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX}
  1883 + hint="JPG, PNG, WebP, or GIF — max 5 MB."
  1884 + />
  1885 + </div>
  1886 + ) : null}
  1887 +
  1888 + <div className="space-y-3 rounded-md border border-gray-100 bg-gray-50 p-4">
  1889 + <Label className="flex items-center gap-2">
  1890 + <Barcode className="h-4 w-4 shrink-0" />
  1891 + CodeValue Setting
  1892 + </Label>
  1893 + <Input
  1894 + className="h-10"
  1895 + value={codeValue}
  1896 + onChange={(e) => setCodeValue(e.target.value)}
  1897 + placeholder="Barcode Value"
1622 1898 />
1623 1899 </div>
  1900 +
1624 1901 <div className="space-y-2">
1625 1902 <Label>Bind to stores *</Label>
1626 1903 <SearchableMultiSelect
... ... @@ -1640,8 +1917,9 @@ function ProductFormDialog({
1640 1917 <Switch checked={state} onCheckedChange={setState} />
1641 1918 </div>
1642 1919 </div>
  1920 + </div>
1643 1921  
1644   - <DialogFooter>
  1922 + <DialogFooter className={FORM_DIALOG_FOOTER_CLASS}>
1645 1923 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
1646 1924 Cancel
1647 1925 </Button>
... ... @@ -2032,15 +2310,15 @@ function ProductCategoryFormDialog({
2032 2310  
2033 2311 return (
2034 2312 <Dialog open={open} onOpenChange={onOpenChange}>
2035   - <DialogContent className="flex h-[min(85vh,720px)] max-h-[90vh] w-[min(50%,calc(100vw-2rem))] max-w-none flex-col gap-0 overflow-hidden p-0 sm:max-w-none">
2036   - <DialogHeader className="shrink-0 space-y-2 px-6 pt-6 pr-14 pb-2 text-center sm:text-left">
  2313 + <DialogContent className={FORM_DIALOG_CONTENT_CLASS} style={FORM_DIALOG_CONTENT_STYLE}>
  2314 + <DialogHeader className={FORM_DIALOG_HEADER_CLASS}>
2037 2315 <DialogTitle>{isEdit ? "Edit Category" : "New Category"}</DialogTitle>
2038 2316 <DialogDescription>
2039 2317 {isEdit ? "Update product category (API: /api/app/product-category)." : "Create a product category."}
2040 2318 </DialogDescription>
2041 2319 </DialogHeader>
2042 2320  
2043   - <div className="flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain px-6 pb-4">
  2321 + <div className={FORM_DIALOG_SCROLL_BODY_CLASS}>
2044 2322 <div className="grid gap-4 py-2">
2045 2323 <div className="grid grid-cols-2 gap-4">
2046 2324 <div className="space-y-2">
... ... @@ -2226,7 +2504,9 @@ function ProductCategoryFormDialog({
2226 2504 onChange={setButtonImageUrl}
2227 2505 uploadSubDir="category"
2228 2506 oneImageOnly
2229   - hint="One image only. Replace or clear to change. JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
  2507 + boxClassName={FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS}
  2508 + boxSizePx={FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX}
  2509 + hint="JPG, PNG, WebP, or GIF — max 5 MB. Saved as CategoryPhotoUrl."
2230 2510 />
2231 2511 </div>
2232 2512 ) : null}
... ... @@ -2248,7 +2528,7 @@ function ProductCategoryFormDialog({
2248 2528 </div>
2249 2529 </div>
2250 2530  
2251   - <DialogFooter className="shrink-0 gap-2 border-t border-gray-100 bg-background px-6 py-4 sm:flex-row sm:justify-end">
  2531 + <DialogFooter className={FORM_DIALOG_FOOTER_CLASS}>
2252 2532 <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
2253 2533 Cancel
2254 2534 </Button>
... ...
美国版/Food Labeling Management Platform/src/components/ui/image-url-upload.tsx
... ... @@ -18,6 +18,8 @@ export type ImageUrlUploadProps = {
18 18 className?: string;
19 19 /** 上传区域宽度(tailwind),默认 max-w-[200px] */
20 20 boxClassName?: string;
  21 + /** 固定上传区边长(px);优先于仅依赖 Tailwind 的 boxClassName,避免预构建 CSS 缺类名时撑满整行 */
  22 + boxSizePx?: number;
21 23 /** 传给后端的 multipart `subDir`(如 category、product) */
22 24 uploadSubDir?: string;
23 25 /** 明确单图:隐藏多选、提示文案 */
... ... @@ -34,6 +36,7 @@ export function ImageUrlUpload({
34 36 maxSizeMb = PICTURE_UPLOAD_MAX_BYTES / (1024 * 1024),
35 37 className,
36 38 boxClassName,
  39 + boxSizePx,
37 40 uploadSubDir,
38 41 oneImageOnly,
39 42 }: ImageUrlUploadProps) {
... ... @@ -72,14 +75,26 @@ export function ImageUrlUpload({
72 75 if (!busy) inputRef.current?.click();
73 76 };
74 77  
75   - /** 未传 boxClassName 时用默认「自适应宽度 + 正方形」;传入 boxClassName 时不再附带 w-full/aspect,避免与固定宽高冲突 */
76   - const hasCustomBox = Boolean(boxClassName?.trim());
  78 + const fixedSize = typeof boxSizePx === "number" && boxSizePx > 0 ? Math.round(boxSizePx) : 0;
  79 + const hasCustomBox = fixedSize > 0 || Boolean(boxClassName?.trim());
  80 + const boxSizeStyle: React.CSSProperties | undefined =
  81 + fixedSize > 0
  82 + ? {
  83 + width: fixedSize,
  84 + height: fixedSize,
  85 + minWidth: fixedSize,
  86 + minHeight: fixedSize,
  87 + maxWidth: fixedSize,
  88 + maxHeight: fixedSize,
  89 + boxSizing: "border-box",
  90 + }
  91 + : undefined;
77 92 const boxShell = hasCustomBox
78   - ? "rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
  93 + ? "box-border shrink-0 rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
79 94 : "w-full max-w-[200px] aspect-square rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2";
80 95  
81 96 return (
82   - <div className={cn("space-y-2", className)}>
  97 + <div className={cn("space-y-2", hasCustomBox && "w-fit max-w-full", className)}>
83 98 <input
84 99 ref={inputRef}
85 100 type="file"
... ... @@ -96,6 +111,7 @@ export function ImageUrlUpload({
96 111 disabled={busy}
97 112 onClick={openPicker}
98 113 aria-label={emptyLabel || "Upload image"}
  114 + style={boxSizeStyle}
99 115 className={cn(
100 116 boxShell,
101 117 hasCustomBox ? boxClassName : null,
... ... @@ -111,7 +127,13 @@ export function ImageUrlUpload({
111 127 <span className="px-3 text-center text-sm font-normal text-gray-500">Uploading…</span>
112 128 ) : (
113 129 <>
114   - <Plus className="h-10 w-10 shrink-0 stroke-[1.25]" aria-hidden />
  130 + <Plus
  131 + className={cn(
  132 + "shrink-0 stroke-[1.25]",
  133 + fixedSize > 0 && fixedSize <= 120 ? "h-8 w-8" : "h-10 w-10",
  134 + )}
  135 + aria-hidden
  136 + />
115 137 {emptyLabel ? (
116 138 <span className="px-3 text-center text-sm font-normal leading-tight text-gray-400">
117 139 {emptyLabel}
... ... @@ -122,6 +144,7 @@ export function ImageUrlUpload({
122 144 </button>
123 145 ) : (
124 146 <div
  147 + style={boxSizeStyle}
125 148 className={cn(
126 149 "group relative overflow-hidden border-2 border-dashed border-gray-300 bg-gray-50/80",
127 150 boxShell,
... ...
美国版/Food Labeling Management Platform/src/components/ui/searchable-multi-select.tsx
... ... @@ -117,15 +117,16 @@ export function SearchableMultiSelect({
117 117 aria-expanded={open}
118 118 disabled={disabled}
119 119 className={cn(
120   - "h-auto min-h-10 w-full justify-between px-3 py-2 font-normal border border-gray-300 bg-white",
  120 + "h-auto min-h-10 w-full justify-between overflow-hidden px-3 py-2 font-normal border border-gray-300 bg-white",
121 121 className,
122 122 )}
123 123 >
124 124 <span
125 125 className={cn(
126   - "line-clamp-2 text-left text-sm",
  126 + "min-w-0 flex-1 truncate text-left text-sm",
127 127 !summary && "text-gray-500",
128 128 )}
  129 + title={summary ?? undefined}
129 130 >
130 131 {summary ?? placeholder}
131 132 </span>
... ...
美国版/Food Labeling Management Platform/src/components/ui/searchable-select.tsx
... ... @@ -54,15 +54,16 @@ export function SearchableSelect({
54 54 aria-expanded={open}
55 55 disabled={disabled}
56 56 className={cn(
57   - "w-full justify-between h-10 px-3 font-normal border border-gray-300 bg-white",
  57 + "h-10 w-full justify-between overflow-hidden px-3 font-normal border border-gray-300 bg-white",
58 58 className,
59 59 )}
60 60 >
61 61 <span
62 62 className={cn(
63   - "truncate text-left text-sm",
  63 + "min-w-0 flex-1 truncate text-left text-sm",
64 64 !selectedLabel && "text-gray-500",
65 65 )}
  66 + title={selectedLabel ?? undefined}
66 67 >
67 68 {selectedLabel ?? placeholder}
68 69 </span>
... ...
美国版/Food Labeling Management Platform/src/components/ui/select.tsx
... ... @@ -41,7 +41,7 @@ function SelectTrigger({
41 41 data-slot="select-trigger"
42 42 data-size={size}
43 43 className={cn(
44   - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  44 + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 overflow-hidden rounded-md border bg-input-background px-3 py-2 text-sm transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1 *:data-[slot=select-value]:truncate [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
45 45 className,
46 46 )}
47 47 {...props}
... ...
美国版/Food Labeling Management Platform/src/lib/barcodeFormat.ts 0 → 100644
  1 +/** 模板编辑器 / 预览 / 打印共用的条形码制式选项(与需求文档顺序一致) */
  2 +
  3 +export const BARCODE_FORMAT_OPTIONS = [
  4 + { label: "Codabar", value: "CODABAR" },
  5 + { label: "Code39", value: "CODE39" },
  6 + { label: "Code128", value: "CODE128" },
  7 + { label: "EAN-2", value: "EAN2" },
  8 + { label: "EAN-5", value: "EAN5" },
  9 + { label: "EAN-8", value: "EAN8" },
  10 + { label: "EAN-13", value: "EAN13" },
  11 + { label: "ITF-14", value: "ITF14" },
  12 + { label: "MSI", value: "MSI" },
  13 + { label: "MSI10", value: "MSI10" },
  14 + { label: "MSI11", value: "MSI11" },
  15 + { label: "MSI1010", value: "MSI1010" },
  16 + { label: "MSI1110", value: "MSI1110" },
  17 + { label: "Pharmacode", value: "PHARMACODE" },
  18 + { label: "UPC(A)", value: "UPCA" },
  19 +] as const;
  20 +
  21 +export type BarcodeFormatValue = (typeof BARCODE_FORMAT_OPTIONS)[number]["value"];
  22 +
  23 +export const DEFAULT_BARCODE_FORMAT: BarcodeFormatValue = BARCODE_FORMAT_OPTIONS[0].value;
  24 +
  25 +/** 规范化模板 config.barcodeType(兼容旧值、带连字符写法) */
  26 +export function normalizeBarcodeType(raw: unknown): BarcodeFormatValue {
  27 + const key = String(raw ?? "")
  28 + .trim()
  29 + .toUpperCase()
  30 + .replace(/[^A-Z0-9]/g, "");
  31 + const allowed = new Set(BARCODE_FORMAT_OPTIONS.map((o) => o.value));
  32 + if (allowed.has(key as BarcodeFormatValue)) return key as BarcodeFormatValue;
  33 + if (key === "UPC" || key === "UPCA") return "UPCA";
  34 + if (key === "ITF") return "ITF14";
  35 + return DEFAULT_BARCODE_FORMAT;
  36 +}
  37 +
  38 +/** JsBarcode `format` 参数 */
  39 +export function toJsBarcodeFormat(barcodeType: unknown): string {
  40 + const key = normalizeBarcodeType(barcodeType);
  41 + const map: Record<BarcodeFormatValue, string> = {
  42 + CODABAR: "codabar",
  43 + CODE39: "CODE39",
  44 + CODE128: "CODE128",
  45 + EAN2: "ean2",
  46 + EAN5: "ean5",
  47 + EAN8: "EAN8",
  48 + EAN13: "EAN13",
  49 + ITF14: "ITF14",
  50 + MSI: "MSI",
  51 + MSI10: "MSI10",
  52 + MSI11: "MSI11",
  53 + MSI1010: "MSI1010",
  54 + MSI1110: "MSI1110",
  55 + PHARMACODE: "pharmacode",
  56 + UPCA: "upc",
  57 + };
  58 + return map[key] ?? "codabar";
  59 +}
  60 +
  61 +/** TSC 打印机 BARCODE 指令制式名 */
  62 +export function toTscBarcodeSymbology(barcodeType: unknown): string {
  63 + const key = normalizeBarcodeType(barcodeType);
  64 + const map: Record<BarcodeFormatValue, string> = {
  65 + CODABAR: "CODA",
  66 + CODE39: "39",
  67 + CODE128: "128",
  68 + EAN2: "128",
  69 + EAN5: "128",
  70 + EAN8: "EAN8",
  71 + EAN13: "EAN13",
  72 + ITF14: "ITF14",
  73 + MSI: "MSI",
  74 + MSI10: "MSI",
  75 + MSI11: "MSI",
  76 + MSI1010: "MSI",
  77 + MSI1110: "MSI",
  78 + PHARMACODE: "128",
  79 + UPCA: "UPCA",
  80 + };
  81 + return map[key] ?? "CODA";
  82 +}
  83 +
  84 +/** ESC/POS GS k 条码类型码 */
  85 +export function toEscBarcodeTypeCode(barcodeType: unknown): number {
  86 + const key = normalizeBarcodeType(barcodeType);
  87 + const map: Record<BarcodeFormatValue, number> = {
  88 + CODABAR: 71,
  89 + CODE39: 69,
  90 + CODE128: 73,
  91 + EAN2: 73,
  92 + EAN5: 73,
  93 + EAN8: 68,
  94 + EAN13: 67,
  95 + ITF14: 70,
  96 + MSI: 73,
  97 + MSI10: 73,
  98 + MSI11: 73,
  99 + MSI1010: 73,
  100 + MSI1110: 73,
  101 + PHARMACODE: 73,
  102 + UPCA: 65,
  103 + };
  104 + return map[key] ?? 71;
  105 +}
... ...
美国版/Food Labeling Management Platform/src/lib/formDialogLayout.ts 0 → 100644
  1 +import type { CSSProperties } from "react";
  2 +
  3 +/**
  4 + * 新增/编辑弹框:50% 宽、70vh 高;中间区域滚动。
  5 + * 使用 grid 三行(与 DialogContent 默认 display:grid 一致),避免 flex 被覆盖后高度失效。
  6 + */
  7 +export const FORM_DIALOG_CONTENT_CLASS =
  8 + "form-dialog-shell sm:max-w-none w-[min(50%,calc(100vw-2rem))] max-w-[min(50%,calc(100vw-2rem))] gap-0 overflow-hidden p-0 grid-rows-[auto_minmax(0,1fr)_auto]";
  9 +
  10 +export const FORM_DIALOG_CONTENT_STYLE: CSSProperties = {
  11 + display: "grid",
  12 + height: "70vh",
  13 + maxHeight: "70vh",
  14 + minHeight: 0,
  15 + width: "50%",
  16 + maxWidth: "min(50%, calc(100vw - 2rem))",
  17 + overflow: "hidden",
  18 + padding: 0,
  19 + boxSizing: "border-box",
  20 +};
  21 +
  22 +export const FORM_DIALOG_HEADER_CLASS =
  23 + "shrink-0 space-y-2 px-6 pt-6 pr-14 pb-2 text-center sm:text-left min-h-0";
  24 +
  25 +export const FORM_DIALOG_SCROLL_BODY_CLASS =
  26 + "form-dialog-scroll-body min-h-0 overflow-y-auto overflow-x-hidden overscroll-contain px-6 pb-4";
  27 +
  28 +export const FORM_DIALOG_FOOTER_CLASS =
  29 + "shrink-0 gap-2 border-t border-gray-100 bg-background px-6 py-4 sm:flex-row sm:justify-end min-h-0";
  30 +
  31 +/** 弹框内 Button Image 上传区边长(px) */
  32 +export const FORM_DIALOG_IMAGE_UPLOAD_SIZE_PX = 120;
  33 +
  34 +/** 弹框内 Button Image 上传区 class(配合 boxSizePx 使用) */
  35 +export const FORM_DIALOG_IMAGE_UPLOAD_BOX_CLASS = "shrink-0";
... ...
美国版/Food Labeling Management Platform/src/lib/productCodeValueTemplate.ts 0 → 100644
  1 +import type { LabelElement } from "../types/labelTemplate";
  2 +import { canonicalElementType, isTemplateSectionPersistedType } from "../types/labelTemplate";
  3 +
  4 +/** Template 面板中的条形码 / 二维码(typeAdd 以 template_ 开头) */
  5 +export function isTemplateSectionBarcodeOrQrElement(el: LabelElement): boolean {
  6 + if (!isTemplateSectionPersistedType(el)) return false;
  7 + const t = canonicalElementType(el.type);
  8 + return t === "BARCODE" || t === "QRCODE";
  9 +}
  10 +
  11 +/** 将产品 codeValue 写入 elementId → 字符串,供 templateProductDefaults / 预览合并 */
  12 +export function buildTemplateBarcodeQrDefaultsFromCodeValue(
  13 + elements: LabelElement[],
  14 + codeValue: string | null | undefined,
  15 +): Record<string, string> {
  16 + const cv = String(codeValue ?? "").trim();
  17 + if (!cv) return {};
  18 + const out: Record<string, string> = {};
  19 + for (const el of elements) {
  20 + if (!isTemplateSectionBarcodeOrQrElement(el)) continue;
  21 + out[el.id] = cv;
  22 + }
  23 + return out;
  24 +}
  25 +
  26 +/** 合并 codeValue 到元素 config.data(用于画布预览) */
  27 +export function applyProductCodeValueToLabelElements(
  28 + elements: LabelElement[],
  29 + codeValue: string | null | undefined,
  30 +): LabelElement[] {
  31 + const cv = String(codeValue ?? "").trim();
  32 + if (!cv) return elements;
  33 + return elements.map((el) => {
  34 + if (!isTemplateSectionBarcodeOrQrElement(el)) return el;
  35 + const cfg = { ...(el.config as Record<string, unknown>) };
  36 + cfg.data = cv;
  37 + return { ...el, config: cfg };
  38 + });
  39 +}
... ...
美国版/Food Labeling Management Platform/src/main.tsx
... ... @@ -4,6 +4,7 @@
4 4 import "react-day-picker/dist/style.css";
5 5 import "./index.css";
6 6 import "./styles/category-appearance-toggle.css";
  7 + import "./styles/form-dialog.css";
7 8 import "./styles/fonts.css";
8 9  
9 10 createRoot(document.getElementById("root")!).render(<App />);
... ...
美国版/Food Labeling Management Platform/src/services/productService.ts
... ... @@ -20,6 +20,33 @@ const api = createApiClient({
20 20  
21 21 const PATH = "/product";
22 22  
  23 +function buildProductWriteBody(input: ProductCreateInput): Record<string, unknown> {
  24 + const body: Record<string, unknown> = {
  25 + productCode: String(input.productCode ?? "").trim() || null,
  26 + productName: input.productName,
  27 + categoryId: input.categoryId?.trim() ? input.categoryId.trim() : null,
  28 + productImageUrl: input.productImageUrl ?? null,
  29 + categoryPhotoUrl: input.categoryPhotoUrl ?? null,
  30 + displayText: input.displayText ?? null,
  31 + buttonAppearance: input.buttonAppearance ?? null,
  32 + buttonBgColor: input.buttonBgColor ?? null,
  33 + buttonImageUrl: input.buttonImageUrl ?? null,
  34 + buttonStyleJson: input.buttonStyleJson ?? null,
  35 + codeValue: (input.codeValue ?? "").trim() || null,
  36 + state: input.state ?? true,
  37 + };
  38 + if (input.locationIds !== undefined) {
  39 + body.locationIds = input.locationIds;
  40 + }
  41 + if (input.partnerId?.trim()) {
  42 + body.partnerId = input.partnerId.trim();
  43 + }
  44 + if (input.groupIds?.length) {
  45 + body.groupIds = input.groupIds;
  46 + }
  47 + return body;
  48 +}
  49 +
23 50 function normalizeProductDto(raw: unknown): ProductDto {
24 51 const r = raw as Record<string, unknown>;
25 52 const id = String(r?.id ?? r?.Id ?? "").trim();
... ... @@ -35,6 +62,13 @@ function normalizeProductDto(raw: unknown): ProductDto {
35 62 categoryId: (r?.categoryId ?? r?.CategoryId) as string | null | undefined,
36 63 categoryName: (r?.categoryName ?? r?.CategoryName) as string | null | undefined,
37 64 productImageUrl: (r?.productImageUrl ?? r?.ProductImageUrl) as string | null | undefined,
  65 + categoryPhotoUrl: (r?.categoryPhotoUrl ?? r?.CategoryPhotoUrl) as string | null | undefined,
  66 + displayText: (r?.displayText ?? r?.DisplayText) as string | null | undefined,
  67 + buttonAppearance: (r?.buttonAppearance ?? r?.ButtonAppearance ?? "TEXT") as ProductDto["buttonAppearance"],
  68 + buttonBgColor: (r?.buttonBgColor ?? r?.ButtonBgColor) as string | null | undefined,
  69 + buttonImageUrl: (r?.buttonImageUrl ?? r?.ButtonImageUrl) as string | null | undefined,
  70 + buttonStyleJson: (r?.buttonStyleJson ?? r?.ButtonStyleJson) as string | null | undefined,
  71 + codeValue: (r?.codeValue ?? r?.CodeValue) as string | null | undefined,
38 72 state:
39 73 typeof r?.state === "boolean"
40 74 ? r.state
... ... @@ -84,22 +118,7 @@ export async function getProduct(id: string, signal?: AbortSignal): Promise&lt;Prod
84 118 }
85 119  
86 120 export async function createProduct(input: ProductCreateInput): Promise<ProductDto> {
87   - const body: Record<string, unknown> = {
88   - productCode: String(input.productCode ?? "").trim() || null,
89   - productName: input.productName,
90   - categoryId: input.categoryId?.trim() ? input.categoryId.trim() : null,
91   - productImageUrl: input.productImageUrl ?? null,
92   - state: input.state ?? true,
93   - };
94   - if (input.locationIds !== undefined) {
95   - body.locationIds = input.locationIds;
96   - }
97   - if (input.partnerId?.trim()) {
98   - body.partnerId = input.partnerId.trim();
99   - }
100   - if (input.groupIds?.length) {
101   - body.groupIds = input.groupIds;
102   - }
  121 + const body = buildProductWriteBody(input);
103 122 const raw = await api.requestJson<ProductDto>({
104 123 path: PATH,
105 124 method: "POST",
... ... @@ -109,22 +128,7 @@ export async function createProduct(input: ProductCreateInput): Promise&lt;ProductD
109 128 }
110 129  
111 130 export async function updateProduct(id: string, input: ProductUpdateInput): Promise<ProductDto> {
112   - const body: Record<string, unknown> = {
113   - productCode: String(input.productCode ?? "").trim() || null,
114   - productName: input.productName,
115   - categoryId: input.categoryId?.trim() ? input.categoryId.trim() : null,
116   - productImageUrl: input.productImageUrl ?? null,
117   - state: input.state ?? true,
118   - };
119   - if (input.locationIds !== undefined) {
120   - body.locationIds = input.locationIds;
121   - }
122   - if (input.partnerId?.trim()) {
123   - body.partnerId = input.partnerId.trim();
124   - }
125   - if (input.groupIds?.length) {
126   - body.groupIds = input.groupIds;
127   - }
  131 + const body = buildProductWriteBody(input);
128 132 const raw = await api.requestJson<ProductDto>({
129 133 path: `${PATH}/${encodeURIComponent(id)}`,
130 134 method: "PUT",
... ...
美国版/Food Labeling Management Platform/src/services/reportsService.ts
... ... @@ -6,6 +6,9 @@ import type {
6 6 ReportsPrintLogListItem,
7 7 ReportsPrintLogListResult,
8 8 ReportsPrintLogQueryInput,
  9 + TemplatePrintStatListItem,
  10 + TemplatePrintStatListResult,
  11 + TemplatePrintStatQueryInput,
9 12 } from "../types/reports";
10 13  
11 14 const api = createApiClient({
... ... @@ -296,6 +299,57 @@ export async function getLabelReport(
296 299 return normLabelReport(raw);
297 300 }
298 301  
  302 +function normTemplatePrintStatItem(raw: unknown): TemplatePrintStatListItem {
  303 + if (!raw || typeof raw !== "object") {
  304 + return { templateId: null, templateName: "None", printedCount: 0 };
  305 + }
  306 + const o = raw as Record<string, unknown>;
  307 + const tid = str(o, "templateId", "TemplateId", "");
  308 + return {
  309 + templateId: tid.trim() ? tid : null,
  310 + templateName: str(o, "templateName", "TemplateName", "None") || "None",
  311 + printedCount: num(o, "printedCount", "PrintedCount", 0),
  312 + };
  313 +}
  314 +
  315 +function buildTemplatePrintStatQuery(input: TemplatePrintStatQueryInput) {
  316 + return {
  317 + SkipCount: input.skipCount,
  318 + MaxResultCount: input.maxResultCount,
  319 + Sorting: input.sorting ?? "PrintedCount desc",
  320 + PartnerId: input.partnerId,
  321 + GroupId: input.groupId,
  322 + LocationId: input.locationId,
  323 + StartDate: input.startDate,
  324 + EndDate: input.endDate,
  325 + Keyword: input.keyword,
  326 + };
  327 +}
  328 +
  329 +/** 按模板汇总打印数量(`GET /api/app/reports/template-print-stat-list`) */
  330 +export async function getTemplatePrintStatList(
  331 + input: TemplatePrintStatQueryInput,
  332 + signal?: AbortSignal,
  333 +): Promise<TemplatePrintStatListResult> {
  334 + const raw = await api.requestJson<unknown>({
  335 + path: `${REPORTS_PREFIX}/template-print-stat-list`,
  336 + method: "GET",
  337 + query: buildTemplatePrintStatQuery(input),
  338 + signal,
  339 + });
  340 + const o = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
  341 + const itemsRaw = o.items ?? o.Items;
  342 + const list = Array.isArray(itemsRaw) ? itemsRaw.map(normTemplatePrintStatItem) : [];
  343 + const total = num(o, "totalCount", "TotalCount", list.length);
  344 + return {
  345 + items: list,
  346 + totalCount: total,
  347 + pageIndex: num(o, "pageIndex", "PageIndex", input.skipCount),
  348 + pageSize: num(o, "pageSize", "PageSize", input.maxResultCount),
  349 + totalPages: num(o, "totalPages", "TotalPages", 0),
  350 + };
  351 +}
  352 +
299 353 // --- 下载、重打:不走 JSON 解包,单独 fetch ---
300 354  
301 355 function joinUrl(baseUrl: string, path: string): string {
... ...
美国版/Food Labeling Management Platform/src/services/teamMemberService.ts
... ... @@ -127,6 +127,15 @@ function normalizeTeamMemberDto(row: unknown): TeamMemberDto {
127 127 if (inferredIds.length) locationIds = inferredIds;
128 128 }
129 129  
  130 + const partnerIds = toIdArray(r.partnerIds ?? r.PartnerIds);
  131 + const regionIds = toIdArray(
  132 + r.regionIds ?? r.RegionIds ?? r.groupIds ?? r.GroupIds,
  133 + );
  134 + const partnerId =
  135 + (r.partnerId ?? r.PartnerId ?? partnerIds[0] ?? null) as string | null | undefined;
  136 + const groupId =
  137 + (r.groupId ?? r.GroupId ?? regionIds[0] ?? null) as string | null | undefined;
  138 +
130 139 // 有些接口可能只返回一个 locations,但我们仍尽量填充
131 140 return {
132 141 id,
... ... @@ -138,6 +147,11 @@ function normalizeTeamMemberDto(row: unknown): TeamMemberDto {
138 147 roleName,
139 148 locationIds,
140 149 locations,
  150 + partnerId: partnerId != null ? String(partnerId) : null,
  151 + groupId: groupId != null ? String(groupId) : null,
  152 + partnerIds,
  153 + regionIds,
  154 + groupIds: regionIds,
141 155 state: state ?? (r.status ? (String(r.status).toLowerCase() === "active") : undefined),
142 156 };
143 157 }
... ... @@ -186,8 +200,15 @@ function toPhoneNumber(v: string | number | null | undefined): number | null {
186 200 return num;
187 201 }
188 202  
  203 +function scopeIdsForApi(ids: string[] | undefined): string[] | undefined {
  204 + const list = [...new Set((ids ?? []).map((x) => String(x).trim()).filter(Boolean))];
  205 + return list.length ? list : undefined;
  206 +}
  207 +
189 208 function buildCreatePayload(input: TeamMemberCreateInput): Record<string, unknown> {
190 209 const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null;
  210 + const partnerId = (input.partnerId ?? "").trim() || undefined;
  211 + const regionIds = scopeIdsForApi(input.regionIds);
191 212 return {
192 213 fullName: input.fullName,
193 214 userName: input.userName,
... ... @@ -195,7 +216,10 @@ function buildCreatePayload(input: TeamMemberCreateInput): Record&lt;string, unknow
195 216 email: input.email ?? null,
196 217 phone: phoneVal,
197 218 roleId: input.roleId,
198   - // 兼容:后端可能叫 locationIds 或 locations
  219 + partnerId,
  220 + partnerIds: partnerId ? [partnerId] : undefined,
  221 + regionIds,
  222 + groupIds: regionIds,
199 223 locationIds: input.locationIds,
200 224 locations: input.locationIds,
201 225 state: input.state,
... ... @@ -204,12 +228,18 @@ function buildCreatePayload(input: TeamMemberCreateInput): Record&lt;string, unknow
204 228  
205 229 function buildUpdatePayload(input: TeamMemberUpdateInput): Record<string, unknown> {
206 230 const phoneVal = input.phone != null && input.phone !== "" ? toPhoneNumber(String(input.phone)) : null;
  231 + const partnerId = (input.partnerId ?? "").trim() || undefined;
  232 + const regionIds = scopeIdsForApi(input.regionIds);
207 233 const payload: Record<string, unknown> = {
208 234 fullName: input.fullName,
209 235 userName: input.userName,
210 236 email: input.email ?? null,
211 237 phone: phoneVal,
212 238 roleId: input.roleId,
  239 + partnerId,
  240 + partnerIds: partnerId ? [partnerId] : undefined,
  241 + regionIds,
  242 + groupIds: regionIds,
213 243 locationIds: input.locationIds,
214 244 locations: input.locationIds,
215 245 state: input.state,
... ...
美国版/Food Labeling Management Platform/src/styles/form-dialog.css 0 → 100644
  1 +/* 固定尺寸弹框外壳(覆盖 DialogContent 默认 grid + 随内容增高) */
  2 +[data-slot="dialog-content"].form-dialog-shell {
  3 + display: grid !important;
  4 + height: 70vh !important;
  5 + max-height: 70vh !important;
  6 + min-height: 0 !important;
  7 + width: min(50%, calc(100vw - 2rem)) !important;
  8 + max-width: min(50%, calc(100vw - 2rem)) !important;
  9 + overflow: hidden !important;
  10 + padding: 0 !important;
  11 + box-sizing: border-box !important;
  12 +}
  13 +
  14 +[data-slot="dialog-content"].form-dialog-shell > .form-dialog-scroll-body {
  15 + min-height: 0 !important;
  16 + max-height: 100% !important;
  17 + overflow-y: auto !important;
  18 + overflow-x: hidden !important;
  19 +}
  20 +
  21 +/* 滚动区内图片预览不撑宽 */
  22 +.form-dialog-scroll-body img {
  23 + max-width: 100%;
  24 + max-height: 120px;
  25 + object-fit: contain;
  26 +}
  27 +
  28 +.form-dialog-scroll-body .group.relative {
  29 + max-width: 100%;
  30 +}
... ...
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
... ... @@ -320,7 +320,17 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
320 320 TEXT_STATIC: { width: 120, height: 24, config: { text: 'Text', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
321 321 TEXT_PRODUCT: { width: 120, height: 24, config: { text: 'Product name', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
322 322 TEXT_PRICE: { width: 80, height: 24, config: { text: '0.00', decimal: 2, fontFamily: 'Arial', fontSize: 14, fontWeight: 'bold', textAlign: 'right' } },
323   - BARCODE: { width: 160, height: 48, config: { barcodeType: 'CODE128', data: '123456789', showText: true, orientation: 'horizontal' } },
  323 + BARCODE: {
  324 + width: 160,
  325 + height: 48,
  326 + config: {
  327 + barcodeType: 'CODABAR',
  328 + data: '123456789',
  329 + showText: true,
  330 + fontSize: 14,
  331 + textAlign: 'center',
  332 + },
  333 + },
324 334 QRCODE: { width: 80, height: 80, config: { data: 'https://example.com', errorLevel: 'M' } },
325 335 IMAGE: { width: 60, height: 60, config: { src: '', scaleMode: 'contain' } },
326 336 DATE: {
... ...
美国版/Food Labeling Management Platform/src/types/labelType.ts
... ... @@ -31,9 +31,9 @@ export type LabelTypeGetListInput = {
31 31 sorting?: string;
32 32 keyword?: string;
33 33 state?: boolean;
34   - /** Region 筛选:`fl_group.Id`(后端就绪后生效;否则前端按标签门店关联过滤) */
  34 + /** Region 筛选:`fl_group.Id` */
35 35 groupId?: string;
36   - /** 门店筛选:`location.Id` */
  36 + /** 门店筛选:`location.Id`(`GET /label-type` Query) */
37 37 locationId?: string;
38 38 partnerId?: string;
39 39 };
... ...
美国版/Food Labeling Management Platform/src/types/product.ts
... ... @@ -5,6 +5,14 @@ export type ProductDto = {
5 5 categoryId?: string | null;
6 6 categoryName?: string | null;
7 7 productImageUrl?: string | null;
  8 + categoryPhotoUrl?: string | null;
  9 + displayText?: string | null;
  10 + buttonAppearance?: "TEXT" | "COLOR" | "IMAGE" | string | string[] | null;
  11 + buttonBgColor?: string | null;
  12 + buttonImageUrl?: string | null;
  13 + buttonStyleJson?: string | null;
  14 + /** 条码/编码值 */
  15 + codeValue?: string | null;
8 16 state?: boolean | null;
9 17 creationTime?: string | null;
10 18 /** 列表/详情可能返回:该产品绑定的门店 Id(`fl_location_product`) */
... ... @@ -39,6 +47,13 @@ export type ProductCreateInput = {
39 47 productName: string;
40 48 categoryId?: string | null;
41 49 productImageUrl?: string | null;
  50 + categoryPhotoUrl?: string | null;
  51 + displayText?: string | null;
  52 + buttonAppearance?: "TEXT" | "COLOR" | "IMAGE" | string | string[] | null;
  53 + buttonBgColor?: string | null;
  54 + buttonImageUrl?: string | null;
  55 + buttonStyleJson?: string | null;
  56 + codeValue?: string | null;
42 57 state?: boolean;
43 58 /**
44 59 * 门店 Id 列表;写入 `fl_location_product`(§6.3)。
... ...
美国版/Food Labeling Management Platform/src/types/reports.ts
... ... @@ -81,3 +81,28 @@ export type LabelReportQueryInput = {
81 81 endDate?: string;
82 82 keyword?: string;
83 83 };
  84 +
  85 +/** `GET /reports/template-print-stat-list` 列表行 */
  86 +export type TemplatePrintStatListItem = {
  87 + templateId: string | null;
  88 + templateName: string;
  89 + printedCount: number;
  90 +};
  91 +
  92 +export type TemplatePrintStatListResult = PagedResultDto<TemplatePrintStatListItem> & {
  93 + pageIndex?: number;
  94 + pageSize?: number;
  95 + totalPages?: number;
  96 +};
  97 +
  98 +export type TemplatePrintStatQueryInput = {
  99 + skipCount: number;
  100 + maxResultCount: number;
  101 + sorting?: string;
  102 + partnerId?: string;
  103 + groupId?: string;
  104 + locationId?: string;
  105 + startDate?: string;
  106 + endDate?: string;
  107 + keyword?: string;
  108 +};
... ...
美国版/Food Labeling Management Platform/src/types/teamMember.ts
... ... @@ -15,9 +15,12 @@ export type TeamMemberDto = {
15 15 locationIds?: string[];
16 16 locations?: string[];
17 17  
18   - // 绑定公司与区域(后端字段就绪后对接;前端先做单选与门店范围联动
  18 + // 绑定公司与区域(详情接口返回 partnerIds / regionIds 多选;表单单选取首项
19 19 partnerId?: string | null;
20 20 groupId?: string | null;
  21 + partnerIds?: string[];
  22 + regionIds?: string[];
  23 + groupIds?: string[];
21 24  
22 25 state?: boolean | null;
23 26 };
... ... @@ -44,6 +47,8 @@ export type TeamMemberCreateInput = {
44 47 email?: string | null;
45 48 phone?: string | null;
46 49 roleId: string;
  50 + partnerId?: string;
  51 + regionIds?: string[];
47 52 locationIds: string[];
48 53 state: boolean;
49 54 };
... ... @@ -55,6 +60,8 @@ export type TeamMemberUpdateInput = {
55 60 email?: string | null;
56 61 phone?: string | null;
57 62 roleId: string;
  63 + partnerId?: string;
  64 + regionIds?: string[];
58 65 locationIds: string[];
59 66 state: boolean;
60 67 };
... ...