Commit 923d50c0ca7766029554147e4bd5a93d476c5a26
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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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 () => { |
| 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 () => { |
| 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<void> { |
| 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<void> { |
| 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<T>( |
| 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 "../../services/locationService"; |
| 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 '../../../types/labelMultipleOption' |
| 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 "../ui/switch"; |
| 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 "../bulk/batch-import-dialog"; |
| 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<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 & 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 & 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<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<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<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<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 | }; | ... | ... |