Commit 540ac0e3210e4609b07008951a6c031b15a4d964

Authored by 杨鑫
1 parent 83ccb207

前端修改bug

Showing 201 changed files with 15144 additions and 4364 deletions

Too many changes.

To preserve performance only 100 of 201 files are displayed.

5-19泰额版.md deleted
1 -# 5-19 泰额版 — 多租户(独立库)与接口说明  
2 -  
3 -本文档说明 **泰额版后端**(`泰额版/Food Labeling Management Code/Yi.Abp.Net8`)在 **2026-05-19** 起的多租户改造:**每个租户独立 MySQL 业务库**,平台主库仅存 `yitenant`;以及泰额专用登录、租户开通相关接口。  
4 -  
5 -> 与美国版业务接口(`food-labeling-us`)共用同一宿主时,调用业务 API 须携带租户上下文(见 [租户上下文](#租户上下文))。  
6 -  
7 ----  
8 -  
9 -## 目录  
10 -  
11 -- [架构概览](#架构概览)  
12 -- [数据库与 SQL 脚本](#数据库与-sql-脚本)  
13 -- [应用配置](#应用配置)  
14 -- [租户上下文](#租户上下文)  
15 -- [泰额专用接口](#泰额专用接口)  
16 - - [登录](#post-apiappth-app-authlogin)  
17 - - [我的门店](#get-apiappth-app-authmy-locations)  
18 - - [租户下拉 / 当前租户](#thmulti-tenancy)  
19 - - [开通租户独立库](#post-apiappth-tenant-provisioningprovision)  
20 - - [框架租户管理(补充)](#框架租户管理补充)  
21 -- [业务接口联调](#业务接口联调)  
22 -- [代码与脚本路径](#代码与脚本路径)  
23 -- [常见问题](#常见问题)  
24 -  
25 ----  
26 -  
27 -## 架构概览  
28 -  
29 -| 库 | 用途 | 连接来源 |  
30 -|----|------|----------|  
31 -| **平台主库** `antis-foodlabeling-host` | 仅 `yitenant`(租户元数据) | `appsettings` → `DbConnOptions.Url` |  
32 -| **租户业务库** 如 `antis-foodlabeling-us` | `fl_*`、`location`、`user` 等 | `yitenant.TenantConnectionString` |  
33 -  
34 -```  
35 -antis-foodlabeling-host (主库)  
36 - └── yitenant  
37 - ├── Default → antis-foodlabeling-us(迁移期默认租户 / 现有数据)  
38 - └── 新租户 → antis-foodlabeling-{tenant}(Provision 自动建库)  
39 -```  
40 -  
41 -- **不做** 业务表 `TenantId` 行级隔离(勿执行给 `fl_*` 加 `TenantId` 的 ALTER)。  
42 -- 切换租户:请求头 `__tenant` 和/或 JWT 中的 `TenantId` Claim。  
43 -- 无租户上下文时:连接**平台主库**(用于租户 CRUD、开通租户等)。  
44 -  
45 -**默认租户(迁移期)**  
46 -  
47 -| 项 | 值 |  
48 -|----|-----|  
49 -| Id | `11111111-1111-1111-1111-111111111111` |  
50 -| Name | `Default` |  
51 -| 业务库 | `antis-foodlabeling-us`(连接串写在 `yitenant.TenantConnectionString`) |  
52 -  
53 ----  
54 -  
55 -## 数据库与 SQL 脚本  
56 -  
57 -脚本目录:`泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/scripts/`  
58 -  
59 -### 执行顺序  
60 -  
61 -| 顺序 | 文件 | 说明 |  
62 -|------|------|------|  
63 -| 1 | `create_platform_host_database.sql` | 创建主库 `antis-foodlabeling-host` 及 `yitenant` 表 |  
64 -| 2 | `migrate_yitenant_to_host.sql` | **可选**:若曾在业务库 `antis-foodlabeling-us` 中写过 `yitenant`,迁移到主库 |  
65 -| 3 | `separate_database_bootstrap.sql` | 在主库登记默认租户,`TenantConnectionString` 指向 `antis-foodlabeling-us` |  
66 -  
67 -### 勿执行  
68 -  
69 -以下 **不要** 在业务库执行(共享库 + 行级 `TenantId` 方案已废弃):  
70 -  
71 -```sql  
72 --- 勿执行  
73 -ALTER TABLE fl_product ADD COLUMN TenantId ...  
74 -UPDATE fl_product SET TenantId = ...  
75 -```  
76 -  
77 -### 自检 SQL  
78 -  
79 -```sql  
80 -USE antis-foodlabeling-host;  
81 -  
82 -SELECT Id, Name, LEFT(TenantConnectionString, 80) AS conn  
83 -FROM yitenant  
84 -WHERE Id = '11111111-1111-1111-1111-111111111111';  
85 -```  
86 -  
87 -应有一条 `Default`,且 `conn` 中含 `database=antis-foodlabeling-us`。  
88 -  
89 ----  
90 -  
91 -## 应用配置  
92 -  
93 -文件:`泰额版/.../src/Yi.Abp.Web/appsettings.json`  
94 -  
95 -| 配置项 | 说明 |  
96 -|--------|------|  
97 -| `DbConnOptions.Url` | 平台主库,如 `database=antis-foodlabeling-host` |  
98 -| `DbConnOptions.EnabledSaasMultiTenancy` | `true` |  
99 -| `FoodLabeling:MultiTenancy:Mode` | `SeparateDatabase` |  
100 -| `FoodLabeling:TenantDatabase` | 新租户库名模板、RDS 账号等 |  
101 -| `FoodLabeling:LegacyTenant` | 默认租户 Id / Name |  
102 -  
103 -新租户库名模板示例:`antis-foodlabeling-{tenant}`(`{tenant}` 为规范化后的租户名)。  
104 -  
105 ----  
106 -  
107 -## 租户上下文  
108 -  
109 -业务请求须让后端解析到 **租户 Id**,任选其一(推荐登录后仅用 Bearer Token):  
110 -  
111 -| 方式 | 说明 |  
112 -|------|------|  
113 -| 请求头 | `__tenant: {租户Guid}` |  
114 -| JWT | Claim:`TenantId`(`TokenTypeConst.TenantId`)及 `AbpClaimTypes.TenantId` |  
115 -  
116 -泰额登录签发的 Token 已写入上述 Claim;`JwtClaimTenantResolveContributor` 会自动解析。  
117 -  
118 -租户解析顺序(`YiAbpWebModule`):  
119 -  
120 -1. `HeaderTenantResolveContributor`(`__tenant`)  
121 -2. `JwtClaimTenantResolveContributor`(JWT)  
122 -  
123 -> 独立库模式下**不会**自动回落到默认租户;未传租户时走主库连接,业务表可能查不到数据。  
124 -  
125 ----  
126 -  
127 -## 泰额专用接口  
128 -  
129 -Swagger 分组:**泰额版-食品标签**(`FoodLabeling.Th.Application`)  
130 -  
131 -基础路径前缀:`/api/app/`(ABP 动态 API 约定,以 Swagger 为准)  
132 -  
133 -### `POST /api/app/th-app-auth/login`  
134 -  
135 -**应用服务**:`ThAppAuthAppService`  
136 -**鉴权**:匿名  
137 -  
138 -**说明**:  
139 -  
140 -1. 在**平台主库**校验 `tenantId` 是否存在且已配置 `TenantConnectionString`。  
141 -2. 切换到该租户业务库,按邮箱 + 密码校验 `user`(盐值哈希与美国版一致)。  
142 -3. 签发 JWT(含 `TenantId`)、RefreshToken,并返回绑定门店列表。  
143 -  
144 -#### 入参 `ThAppLoginInputVo`  
145 -  
146 -| 字段 | 类型 | 必填 | 说明 |  
147 -|------|------|------|------|  
148 -| tenantId | Guid | 是 | 平台主库 `yitenant.Id` |  
149 -| email | string | 是 | 登录邮箱(`user.Email` / 邮箱形 `UserName`) |  
150 -| password | string | 是 | 密码 |  
151 -| uuid | string | 否 | 图形验证码 UUID(系统开启验证码时必填) |  
152 -| code | string | 否 | 图形验证码 |  
153 -  
154 -#### 请求示例  
155 -  
156 -```http  
157 -POST /api/app/th-app-auth/login  
158 -Content-Type: application/json  
159 -```  
160 -  
161 -```json  
162 -{  
163 - "tenantId": "11111111-1111-1111-1111-111111111111",  
164 - "email": "admin@example.com",  
165 - "password": "YourPassword1!"  
166 -}  
167 -```  
168 -  
169 -#### 出参 `ThAppLoginOutputDto`  
170 -  
171 -| 字段 | 类型 | 说明 |  
172 -|------|------|------|  
173 -| token | string | 访问令牌(含 TenantId Claim) |  
174 -| refreshToken | string | 刷新令牌 |  
175 -| tenantId | Guid | 当前租户 Id |  
176 -| tenantName | string | 租户名称 |  
177 -| locations | array | 绑定门店(结构同美国版 `UsAppBoundLocationDto`) |  
178 -  
179 -#### 出参示例  
180 -  
181 -```json  
182 -{  
183 - "token": "eyJhbGciOiJIUzI1NiIs...",  
184 - "refreshToken": "...",  
185 - "tenantId": "11111111-1111-1111-1111-111111111111",  
186 - "tenantName": "Default",  
187 - "locations": [  
188 - {  
189 - "id": "...",  
190 - "locationCode": "LOC001",  
191 - "locationName": "Store A",  
192 - "fullAddress": "...",  
193 - "state": true  
194 - }  
195 - ]  
196 -}  
197 -```  
198 -  
199 -#### JWT Claims(节选)  
200 -  
201 -| Claim | 说明 |  
202 -|-------|------|  
203 -| `TenantId` / `tenantid` | 租户 Guid 字符串 |  
204 -| `client_kind` | `th_app` |  
205 -| `sub` / `UserId` | 用户 Id |  
206 -  
207 -#### 错误说明  
208 -  
209 -| 提示 | 原因 |  
210 -|------|------|  
211 -| 请输入租户、邮箱与密码 | 参数缺失 |  
212 -| 租户不存在或已停用 | 主库无该 `yitenant` 记录 |  
213 -| 租户未配置业务库连接串 | `TenantConnectionString` 为空 |  
214 -| 登录失败!邮箱不存在 | 租户库中无该用户 |  
215 -| 用户名或密码错误 | 密码校验失败 |  
216 -  
217 ----  
218 -  
219 -### `GET /api/app/th-app-auth/my-locations`  
220 -  
221 -**应用服务**:`ThAppAuthAppService`  
222 -**鉴权**:Bearer Token  
223 -  
224 -**说明**:在**当前 JWT 租户上下文**下,查询 `userlocation` + `location` 绑定门店。  
225 -  
226 -```http  
227 -GET /api/app/th-app-auth/my-locations  
228 -Authorization: Bearer {token}  
229 -```  
230 -  
231 -可选同时带:`__tenant: 11111111-1111-1111-1111-111111111111`(与 Token 中租户一致即可)。  
232 -  
233 ----  
234 -  
235 -### ThMulti-tenancy  
236 -  
237 -**应用服务**:`ThMultiTenancyAppService`  
238 -  
239 -#### `GET /api/app/th-multi-tenancy/tenant-select`(方法名以 Swagger 为准,一般为 `get-tenant-select`)  
240 -  
241 -租户下拉列表(供登录页选择租户)。  
242 -  
243 -**出参**:`ThTenantSelectDto[]`  
244 -  
245 -| 字段 | 类型 | 说明 |  
246 -|------|------|------|  
247 -| id | Guid | 租户 Id |  
248 -| name | string | 租户名称 |  
249 -  
250 -#### `GET /api/app/th-multi-tenancy/current-tenant`(`get-current-tenant`)  
251 -  
252 -当前请求解析到的租户(调试用)。  
253 -  
254 -**出参**:`ThCurrentTenantDto`  
255 -  
256 -| 字段 | 类型 | 说明 |  
257 -|------|------|------|  
258 -| tenantId | Guid? | 当前租户 Id |  
259 -| tenantName | string? | 当前租户名称 |  
260 -  
261 ----  
262 -  
263 -### `POST /api/app/th-tenant-provisioning/provision`  
264 -  
265 -**应用服务**:`ThTenantProvisioningAppService`  
266 -**鉴权**:需登录(平台管理员)  
267 -  
268 -**说明**:  
269 -  
270 -1. 在**平台主库**写入 `yitenant`(含 `TenantConnectionString`)。  
271 -2. 若未传连接串,按 `FoodLabeling:TenantDatabase` 生成,如 `antis-foodlabeling-{tenant}`。  
272 -3. `initializeDatabase=true` 时调用 `InitAsync`:建库 + CodeFirst 业务表(**不含** `yitenant`)。  
273 -  
274 -#### 入参 `ThProvisionTenantInputVo`  
275 -  
276 -| 字段 | 类型 | 必填 | 说明 |  
277 -|------|------|------|------|  
278 -| name | string | 是 | 租户名称(用于生成库名) |  
279 -| tenantConnectionString | string | 否 | 自定义连接串;空则按模板生成 |  
280 -| dbType | int | 否 | SqlSugar.DbType,默认 `0` = MySql |  
281 -| initializeDatabase | bool | 否 | 默认 `true`,是否立即建库建表 |  
282 -  
283 -#### 请求示例  
284 -  
285 -```http  
286 -POST /api/app/th-tenant-provisioning/provision  
287 -Content-Type: application/json  
288 -Authorization: Bearer {token}  
289 -```  
290 -  
291 -```json  
292 -{  
293 - "name": "acme",  
294 - "initializeDatabase": true  
295 -}  
296 -```  
297 -  
298 -#### 出参 `ThProvisionTenantOutputDto`  
299 -  
300 -| 字段 | 类型 | 说明 |  
301 -|------|------|------|  
302 -| tenantId | Guid | 新租户 Id |  
303 -| name | string | 租户名称 |  
304 -| databaseName | string | 业务库名 |  
305 -| tenantConnectionString | string | 完整连接串 |  
306 -| databaseInitialized | bool | 是否已执行 Init |  
307 -  
308 -#### `POST /api/app/th-tenant-provisioning/initialize-tenant-database`  
309 -  
310 -对已有租户补执行建库建表(入参:租户 `tenantId`,以 Swagger 为准)。  
311 -  
312 ----  
313 -  
314 -### 框架租户管理(补充)  
315 -  
316 -分组:**租户管理接口**(`Yi.Framework.TenantManagement.Application`)  
317 -  
318 -| 方法 | 路径 | 说明 |  
319 -|------|------|------|  
320 -| POST | `/api/app/tenant` | 创建租户(需 `tenantConnectionString`) |  
321 -| PUT | `/api/app/tenant/init/{id}` | 租户业务库 CodeFirst 初始化 |  
322 -  
323 -泰额推荐使用 `th-tenant-provisioning/provision` 一步完成登记 + 建库。  
324 -  
325 ----  
326 -  
327 -## 业务接口联调  
328 -  
329 -宿主同时加载 `FoodLabeling.Application`(美国版业务)与 `FoodLabeling.Th.Application`。  
330 -  
331 -调用任意业务 API(如 `/api/app/product`、`/api/app/location`)时:  
332 -  
333 -```http  
334 -GET /api/app/product?SkipCount=1&MaxResultCount=10  
335 -Authorization: Bearer {泰额登录返回的 token}  
336 -```  
337 -  
338 -或:  
339 -  
340 -```http  
341 -GET /api/app/product?SkipCount=1&MaxResultCount=10  
342 -Authorization: Bearer {token}  
343 -__tenant: 11111111-1111-1111-1111-111111111111  
344 -```  
345 -  
346 -**分页**:`SkipCount` 为 **1-based 页码**(与美国版一致,见 `5-18接口优化.md`)。  
347 -  
348 ----  
349 -  
350 -## 代码与脚本路径  
351 -  
352 -| 类型 | 路径 |  
353 -|------|------|  
354 -| 泰额应用层 | `module/food-labeling/FoodLabeling.Th.Application/` |  
355 -| 泰额契约 | `module/food-labeling/FoodLabeling.Th.Application.Contracts/` |  
356 -| 美国版业务(共用) | `module/food-labeling-us/FoodLabeling.Application/` |  
357 -| SQL 脚本 | `module/food-labeling/scripts/` |  
358 -| 多租户常量 | `module/food-labeling-us/FoodLabeling.Domain.Shared/MultiTenancy/FoodLabelingMultiTenancyConsts.cs` |  
359 -| JWT 租户解析 | `module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs` |  
360 -| Web 配置 | `src/Yi.Abp.Web/appsettings.json`、`YiAbpWebModule.cs` |  
361 -  
362 ----  
363 -  
364 -## 常见问题  
365 -  
366 -| 现象 | 处理 |  
367 -|------|------|  
368 -| 启动报连库失败 | 确认已执行 `create_platform_host_database.sql`,且 `DbConnOptions.Url` 指向 `antis-foodlabeling-host` |  
369 -| 登录报租户不存在 | 在主库执行 `separate_database_bootstrap.sql` |  
370 -| 登录成功但业务列表为空 | 检查 Token 是否带 `TenantId`;或请求头补 `__tenant` |  
371 -| 业务接口查到主库无数据 | 未解析租户,误连主库;须登录泰额接口或传 `__tenant` |  
372 -| 新租户无表 | 调用 `provision` 且 `initializeDatabase=true`,或 `PUT tenant/init/{id}` |  
373 -| 误加了 TenantId 列 | 独立库模式不需要;可保留列但不使用,建议勿加 |  
374 -  
375 ----  
376 -  
377 -## 变更记录  
378 -  
379 -| 日期 | 内容 |  
380 -|------|------|  
381 -| 2026-05-19 | 泰额版多租户独立库方案;平台主库分离;`ThAppAuth` 登录写 JWT TenantId;租户开通与 SQL 脚本说明 |  
5-26代码优化.md deleted
1 -# 5-26 代码优化  
2 -  
3 -本文档说明 **2026-05-26** 对美国版接口的变更。  
4 -  
5 -1. **`/api/app/product-category`**:新增/编辑 **`categoryCode` 取消必填**(见 [product-category-categoryCode](#product-category-categorycode-可选))。  
6 -2. **`/api/app/label-template`**:新增/编辑/列表/详情支持 **Region、Location 多选数组**;列表 Query 增加 **Region/Location 筛选**(见 [label-template-regionlocation](#label-template-regionlocation-多选))。  
7 -3. **`/api/app/rbac-role`**:修复 **`accessPermissions` JSON 数组**(如 `manage_labels`)无法绑定菜单(见 [rbac-role-accesspermissions](#rbac-role-accesspermissions-修复))。  
8 -4. **`/api/app/auth-scope`**:管理员(及按数据范围受限账号)登录后 **Company → Region → Location** 级联选店(见 [auth-scope-登录选店](#auth-scope-登录后-company--region--location-级联选店))。  
9 -5. **`/api/app/us-app-auth`**:App 管理员 Token 专用 **Company / Region / 门店筛选** 接口(见 [us-app-auth-管理员选店](#us-app-auth-app-管理员级联选店))。  
10 -  
11 -**应用服务**:`ProductCategoryAppService`、`LabelTemplateAppService`、`RbacRoleAppService`  
12 -**命名约定**(与 5-17 / 5-18 一致):UI **Region** = API **`regionIds` / `groupIds` / `groupId`**(`fl_group.Id`);UI **Location** = **`locationIds` / `locationId`**(`location.Id`)。  
13 -  
14 ----  
15 -  
16 -## product-category categoryCode 可选  
17 -  
18 -**影响接口**  
19 -  
20 -| 方法 | 路径 |  
21 -|------|------|  
22 -| POST | `/api/app/product-category` |  
23 -| PUT | `/api/app/product-category/{id}` |  
24 -  
25 -### 变更说明  
26 -  
27 -| 项 | 变更前 | 变更后 |  
28 -|----|--------|--------|  
29 -| **categoryCode** | 必填;空则报「类别编码和名称不能为空」 | **可选**;可不传、传 `null` 或 `""` |  
30 -| **categoryName** | 必填 | 仍必填 |  
31 -| **落库** | — | 未填编码时 `CategoryCode` 存 **空字符串** |  
32 -| **唯一性** | 编码或名称重复即报错 | 有编码:编码 **或** 名称重复报错;**无编码**:仅校验 **名称** 不重复 |  
33 -  
34 -### 入参(节选)  
35 -  
36 -| 字段 | 类型 | 必填 | 说明 |  
37 -|------|------|------|------|  
38 -| categoryCode | string | **否** | 类别编码 |  
39 -| categoryName | string | **是** | 类别名称 |  
40 -| regionIds / groupIds / locationIds | string[] | 否 | Region·Location 范围(规则见 `5-17接口优化.md`) |  
41 -  
42 -### 请求示例(无编码)  
43 -  
44 -```http  
45 -POST /api/app/product-category  
46 -Content-Type: application/json  
47 -Authorization: Bearer {token}  
48 -```  
49 -  
50 -```json  
51 -{  
52 - "categoryName": "Beverages",  
53 - "buttonAppearance": "TEXT",  
54 - "state": true,  
55 - "availabilityType": "ALL",  
56 - "orderNum": 0  
57 -}  
58 -```  
59 -  
60 -### 联调注意  
61 -  
62 -| 现象 | 处理 |  
63 -|------|------|  
64 -| 仍报「类别编码和名称不能为空」 | 确认已部署含本变更的后端;仅需保证 **categoryName** 非空 |  
65 -| 无编码时名称重复 | 正常:仅按 **categoryName** 判重 |  
66 -  
67 -> Region/Location 多选、列表 `region`/`location` 展示等完整说明见 `5-17接口优化.md` → product-category 章节。  
68 -  
69 ----  
70 -  
71 -## label-template Region·Location 多选  
72 -  
73 -**应用服务**:`LabelTemplateAppService`  
74 -**存储表**:`fl_label_template_location`(模板 ↔ 门店,**无新表**)  
75 -**主表字段**:`fl_label_template.AppliedLocationType` = `ALL` / `SPECIFIED`  
76 -  
77 -### 变更说明  
78 -  
79 -| 项 | 变更前 | 变更后 |  
80 -|----|--------|--------|  
81 -| **新增/编辑 Body** | 仅 `appliedLocation` + `appliedLocationIds` | 增加 **`regionIds`**、**`groupIds`**、**`locationIds`**(与 `appliedLocationIds` 合并) |  
82 -| **列表 Query** | 仅 `locationId` | 增加 **`groupId`**(Region);`locationId` 优先于 `groupId`(与 product-category 一致) |  
83 -| **列表出参** | 仅 `locationText`(单条展示) | 增加 **`region`**、**`location`** 展示 + **`regionIds`**、**`locationIds`** 数组 |  
84 -| **详情出参** | `appliedLocationIds` | 同上,并保留 **`appliedLocationIds`**(与 `locationIds` 一致,兼容编辑器) |  
85 -| **范围解析** | 仅显式门店 Id | Region 展开为门店后与门店 Id **取并集** 落库 |  
86 -  
87 -### 影响接口  
88 -  
89 -| 方法 | 路径 | 说明 |  
90 -|------|------|------|  
91 -| GET | `/api/app/label-template?SkipCount=1&MaxResultCount=10` | 列表支持 `groupId`/`locationId` 筛选;`items[]` 增加 `region`、`location`、`regionIds`、`locationIds` |  
92 -| GET | `/api/app/label-template/{id}` | 详情增加上述字段 |  
93 -| POST | `/api/app/label-template` | Body 支持 Region/Location 多选 |  
94 -| PUT | `/api/app/label-template/{id}` | 同新增 |  
95 -  
96 -路径参数 **`id`** 仍为模板编码 **`TemplateCode`**(与编辑器 JSON 的 `id` 一致)。  
97 -  
98 -### 新增/编辑入参(Body:`LabelTemplateCreateInputVo`)  
99 -  
100 -| 字段 | JSON 名 | 类型 | 必填 | 说明 |  
101 -|------|---------|------|------|------|  
102 -| TemplateCode | `id` | string | 是 | 模板编码 |  
103 -| TemplateName | `name` | string | 是 | 模板名称 |  
104 -| AppliedLocationType | `appliedLocation` | string | 否 | `ALL` / `SPECIFIED`,默认 `ALL` |  
105 -| RegionIds | `regionIds` | string[] | 否 | Region 多选(`fl_group.Id`) |  
106 -| GroupIds | `groupIds` | string[] | 否 | 与 `regionIds` 等价,合并去重 |  
107 -| LocationIds | `locationIds` | string[] | 否 | 门店多选(`location.Id`) |  
108 -| AppliedLocationIds | `appliedLocationIds` | string[] | 否 | 兼容旧字段,与 `locationIds` 合并 |  
109 -| Elements | `elements` | array | 否 | 模板组件,全量重建 |  
110 -| TemplateProductDefaults | `templateProductDefaults` | array | 否 | 仅 **编辑** 时显式传入才重建 |  
111 -  
112 -**自动规则**  
113 -  
114 -| 入参 | 行为 |  
115 -|------|------|  
116 -| `regionIds` / `groupIds` / `locationIds` / `appliedLocationIds` 任一有有效 Id | `appliedLocation` 按 **`SPECIFIED`** 处理 |  
117 -| 仅传空数组 `[]` 且 `appliedLocation` 为 `ALL` | 不绑定门店(全部门店) |  
118 -| `appliedLocation: "SPECIFIED"` 且合并后无有效门店 | 报错:`指定适用区域或门店时,至少需要匹配到一个有效门店` |  
119 -| `appliedLocation` 非法值 | 报错:`适用门店范围不合法(ALL/SPECIFIED)` |  
120 -  
121 -**合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds`、`appliedLocationIds` **取并集** → 写入 `fl_label_template_location`。  
122 -  
123 -### 请求示例(Region + 门店多选)  
124 -  
125 -```http  
126 -POST /api/app/label-template  
127 -Content-Type: application/json  
128 -Authorization: Bearer {token}  
129 -```  
130 -  
131 -```json  
132 -{  
133 - "id": "TPL_TEST_001",  
134 - "name": "Price Tag 4x6",  
135 - "labelType": "PRICE",  
136 - "unit": "inch",  
137 - "width": 4,  
138 - "height": 6,  
139 - "appliedLocation": "SPECIFIED",  
140 - "regionIds": [  
141 - "fl_group_id_east",  
142 - "fl_group_id_west"  
143 - ],  
144 - "locationIds": [  
145 - "11111111-1111-1111-1111-111111111111"  
146 - ],  
147 - "showRuler": true,  
148 - "showGrid": true,  
149 - "state": true,  
150 - "elements": []  
151 -}  
152 -```  
153 -  
154 -### 请求示例(全部门店,兼容旧版)  
155 -  
156 -```json  
157 -{  
158 - "id": "TPL_ALL",  
159 - "name": "Global Template",  
160 - "labelType": "PRICE",  
161 - "unit": "inch",  
162 - "width": 4,  
163 - "height": 6,  
164 - "appliedLocation": "ALL",  
165 - "appliedLocationIds": [],  
166 - "elements": []  
167 -}  
168 -```  
169 -  
170 -### 列表(`GET /api/app/label-template`)  
171 -  
172 -**Query 参数**  
173 -  
174 -| 字段 | 类型 | 说明 |  
175 -|------|------|------|  
176 -| SkipCount / MaxResultCount | int | 分页(项目约定 SkipCount 从 1 起) |  
177 -| keyword | string | 模板名称/编码模糊 |  
178 -| **groupId** | string | **按 Region 筛选**(`fl_group.Id`):命中 `appliedLocation=ALL` 的模板,或在 `fl_label_template_location` 中绑定了该 Region 下任一门门店的模板 |  
179 -| **locationId** | string | **按门店筛选**(`location.Id`);**优先于 groupId** |  
180 -| labelType | string | 如 `PRICE` |  
181 -| state | bool | 启用状态 |  
182 -| sorting | string | 排序(可选) |  
183 -  
184 -**筛选规则**(与 product-category / label-type 相同,内部 `LocationScopeBindingHelper.ResolveScopedLocationIdsAsync`)  
185 -  
186 -| 入参 | 行为 |  
187 -|------|------|  
188 -| 均未传 `groupId`、`locationId` | 不过滤适用范围 |  
189 -| 仅 `groupId` | 解析该 Region 下全部门店 Id,再筛模板 |  
190 -| 仅 `locationId` | 按该门店 Id 筛模板 |  
191 -| 同时传 | **以 `locationId` 为准**(忽略 `groupId`) |  
192 -| Region/门店无效或解析结果为空 | 仅返回 **`appliedLocation=ALL`** 的模板 |  
193 -  
194 -命中条件(满足其一即可出现在列表):  
195 -  
196 -- `fl_label_template.AppliedLocationType = 'ALL'`  
197 -- `SPECIFIED` 且 `fl_label_template_location` 中存在 `LocationId ∈` 解析得到的门店集合  
198 -  
199 -**请求示例**  
200 -  
201 -```http  
202 -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&groupId=fl_group_id_east HTTP/1.1  
203 -Authorization: Bearer {token}  
204 -```  
205 -  
206 -```http  
207 -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111 HTTP/1.1  
208 -Authorization: Bearer {token}  
209 -```  
210 -  
211 -**命名对照**:UI **Region** → Query **`groupId`**;UI **Location** → Query **`locationId`**。  
212 -  
213 -### 列表出参  
214 -  
215 -**`items[]` 新增/对齐字段**  
216 -  
217 -| 字段 | 类型 | 说明 |  
218 -|------|------|------|  
219 -| region | string | 适用 Region 展示文案 |  
220 -| location | string | 适用门店展示文案 |  
221 -| regionIds | string[] | Region Id 多选;`ALL` 时为 `[]` |  
222 -| locationIds | string[] | 门店 Id 多选;`ALL` 时为 `[]` |  
223 -| locationText | string | **兼容字段**,与 `location` 相同 |  
224 -  
225 -其它字段不变:`id`(= TemplateCode)、`templateName`、`contentsCount`、`sizeText`、`versionNo`、`lastEdited` 等。  
226 -  
227 -**列表响应示例片段**  
228 -  
229 -```json  
230 -{  
231 - "pageIndex": 1,  
232 - "pageSize": 10,  
233 - "totalCount": 2,  
234 - "items": [  
235 - {  
236 - "id": "TPL_ALL",  
237 - "templateCode": "TPL_ALL",  
238 - "templateName": "Global Template",  
239 - "labelType": "PRICE",  
240 - "region": "All Regions",  
241 - "location": "All Locations",  
242 - "locationText": "All Locations",  
243 - "regionIds": [],  
244 - "locationIds": [],  
245 - "contentsCount": 5,  
246 - "sizeText": "4x6inch",  
247 - "versionNo": 1,  
248 - "lastEdited": "2026-05-26T10:00:00"  
249 - },  
250 - {  
251 - "id": "TPL_TEST_001",  
252 - "templateName": "Price Tag 4x6",  
253 - "region": "East Region, West Region",  
254 - "location": "UNCC store, Central Park Store",  
255 - "locationText": "UNCC store, Central Park Store",  
256 - "regionIds": ["fl_group_id_east", "fl_group_id_west"],  
257 - "locationIds": [  
258 - "11111111-1111-1111-1111-111111111111",  
259 - "22222222-2222-2222-2222-222222222222"  
260 - ],  
261 - "contentsCount": 3,  
262 - "sizeText": "4x6inch",  
263 - "versionNo": 2,  
264 - "lastEdited": "2026-05-26T11:30:00"  
265 - }  
266 - ]  
267 -}  
268 -```  
269 -  
270 -### 详情出参(`GET /api/app/label-template/{id}`)  
271 -  
272 -在原有 `elements`、`templateProductDefaults`、`appliedLocationType` 等基础上增加:  
273 -  
274 -| 字段 | 类型 | 说明 |  
275 -|------|------|------|  
276 -| region | string | 展示文案 |  
277 -| location | string | 展示文案 |  
278 -| regionIds | string[] | Region Id 多选 |  
279 -| groupIds | string[] | 与 `regionIds` 相同(兼容) |  
280 -| locationIds | string[] | 门店 Id 多选 |  
281 -| appliedLocationIds | string[] | 与 `locationIds` 一致(编辑器回显) |  
282 -  
283 -### 展示规则  
284 -  
285 -| appliedLocation | region | location | regionIds / locationIds |  
286 -|-----------------|--------|----------|-------------------------|  
287 -| **ALL** | `All Regions` | `All Locations` | 空数组 `[]` |  
288 -| **SPECIFIED** | 绑定门店 `location.GroupName` 去重后 `, ` 拼接 | 门店名(优先 `LocationName`,否则 `LocationCode`)拼接 | 由绑定门店反推 / 直接为绑定 Id |  
289 -| **SPECIFIED** 无绑定 | `无` | `无` | `[]` |  
290 -  
291 -`regionIds` 由 `locationIds` 反查 `fl_group` 得到(与 product-category、label-type 一致)。  
292 -  
293 -### 编辑说明  
294 -  
295 -- `PUT` Body 字段与 `POST` 相同;传 `regionIds` / `locationIds` 会 **全量替换** 模板适用门店(先删 `fl_label_template_location` 再插入)。  
296 -- `elements` 仍为全量重建;`templateProductDefaults` 仅当 Body **显式包含** 该字段时才重建,避免普通保存误清空。  
297 -- 编辑成功 **`versionNo` +1**。  
298 -  
299 -### 联调注意  
300 -  
301 -| 现象 | 处理 |  
302 -|------|------|  
303 -| 列表无 `regionIds` | 确认已部署含本变更的后端 |  
304 -| 传 `groupId` 列表仍很多 | 正常:`appliedLocation=ALL` 的模板始终可见 |  
305 -| 传 `groupId` 列表为空 | 检查 Region 是否存在、其下是否有门店;无效 Region 时仅剩 ALL 模板 |  
306 -| 传了 Region 仍显示 All Locations | 检查 Region Id 是否有效、是否能在库中展开到门店 |  
307 -| 仅 `appliedLocationIds` 不传 `locationIds` | 仍支持,与 `locationIds` 合并 |  
308 -| 前端编辑器仍传 `appliedLocation: "ALL"` | 管理端若需多选,须在 Body 增加 `regionIds` / `locationIds`(见 `labelTemplateService.ts`) |  
309 -| 指定范围但 0 门店 | 后端报错,需至少 1 个有效门店 |  
310 -  
311 -### 与 product-category / label-type 的关系  
312 -  
313 -逻辑与 **`5-17接口优化.md`** 中 product-category、label-type 的 Region·Location 绑定一致,差异仅为:  
314 -  
315 -| 模块 | 范围字段名 | 关联表 |  
316 -|------|------------|--------|  
317 -| product-category | `availabilityType` | `fl_product_category_location` |  
318 -| label-type | `availabilityType` | `fl_label_type_location` |  
319 -| **label-template** | **`appliedLocation`** | **`fl_label_template_location`** |  
320 -  
321 ----  
322 -  
323 -## rbac-role accessPermissions 修复  
324 -  
325 -**应用服务**:`RbacRoleAppService`  
326 -**影响接口**:`POST` / `PUT /api/app/rbac-role/{id}`、`GET` 列表/详情回显  
327 -  
328 -### 问题与根因  
329 -  
330 -| 现象 | 根因 |  
331 -|------|------|  
332 -| 保存报 `accessPermissions 未匹配到任何菜单` | 前端提交 **JSON 数组字符串**(如 `["manage_labels",...]`),旧逻辑按逗号拆分,解析结果带 `["` 引号,无法匹配 |  
333 -| 传 `manage_labels` 等仍无菜单 | 表单权限码为 **UI 编码**(`manage_labels`),菜单侧为 **`menu.labels`**(由 `Menu.Router` 推导);二者未做映射 |  
334 -| 详情 `accessPermissionCodes` 为空 | 新增/编辑未写入 **`Role.AccessPermissionCodes`**(JSON 列),仅依赖 `RoleMenu` 反查 |  
335 -  
336 -### 变更说明  
337 -  
338 -| 项 | 变更后 |  
339 -|----|--------|  
340 -| **入参解析** | `accessPermissions` 支持 **JSON 数组字符串**、逗号分隔、以及 Body 字段 **`accessPermissionCodes`** 数组 |  
341 -| **菜单绑定** | UI 权限码经 **`RoleAccessPermissionMenuMapping`** 映射到 `Menu.Router`,再写入 **`RoleMenu`** |  
342 -| **落库** | 同时将勾选的 UI 编码写入 **`Role.AccessPermissionCodes`**(JSON 数组),供 GET 回显 |  
343 -| **PermissionCode 为空** | 仍可按 **`Router`** 推导 `menu.xxx`(建议执行 `menu_backfill_permission_code.sql`) |  
344 -  
345 -### UI 权限码 → 菜单 Router 映射(当前库)  
346 -  
347 -| accessPermissions(UI) | 绑定菜单 Router |  
348 -|-------------------------|-----------------|  
349 -| `manage_labels` | `/labeling`、`/labels`、`/label-categories`、`/label-types`、`/label-templates` |  
350 -| `manage_people` | `/account-management` |  
351 -| `edit_settings` | `/menu-management`、`/multiple-options` |  
352 -| `view_reports` | `/reports` |  
353 -| `manage_products` | (当前 `Menu` 表无 Products 路由,勾选不绑定菜单,**不单独报错**) |  
354 -| `approve_batches` | (当前无对应菜单路由,同上) |  
355 -  
356 -> 至少 **1 个** 权限码能匹配到菜单即保存成功;若 **全部** 均无法匹配(例如只勾 `manage_products` 且库中无对应菜单),仍返回业务错误。  
357 -  
358 -### 请求示例(与前端一致)  
359 -  
360 -```http  
361 -PUT /api/app/rbac-role/3a1f077b-3665-63f2-5fea-0fd7e7044b88  
362 -Content-Type: application/json  
363 -Authorization: Bearer {token}  
364 -```  
365 -  
366 -```json  
367 -{  
368 - "roleName": "Partner Admin",  
369 - "roleCode": "admin",  
370 - "remark": "Admin",  
371 - "dataScope": 0,  
372 - "state": true,  
373 - "orderNum": 999,  
374 - "accessPermissions": "[\"manage_labels\",\"edit_settings\",\"view_reports\",\"manage_people\",\"manage_products\",\"approve_batches\"]"  
375 -}  
376 -```  
377 -  
378 -也可使用逗号分隔(旧格式):  
379 -  
380 -```json  
381 -{  
382 - "accessPermissions": "manage_labels, view_reports, manage_people"  
383 -}  
384 -```  
385 -  
386 -或同时传数组字段(与 `accessPermissions` 合并去重):  
387 -  
388 -```json  
389 -{  
390 - "accessPermissionCodes": ["manage_labels", "view_reports"]  
391 -}  
392 -```  
393 -  
394 -### 入参优先级(与 5-18 一致)  
395 -  
396 -| menuIds | accessPermissions / accessPermissionCodes | 行为 |  
397 -|---------|-------------------------------------------|------|  
398 -| 非空数组 | 任意 | **以 menuIds 为准** |  
399 -| 不传 | 非空 | 按 UI 权限码映射菜单并覆盖 `RoleMenu` |  
400 -| 不传 | `""` 或空数组 | 清空 `RoleMenu` 与 `AccessPermissionCodes` |  
401 -| `[]` | 不传 | 清空绑定 |  
402 -  
403 -### 响应回显  
404 -  
405 -| 字段 | 说明 |  
406 -|------|------|  
407 -| `accessPermissionCodes` | 来自 **`Role.AccessPermissionCodes`**,如 `["manage_labels","view_reports"]` |  
408 -| `accessPermissions` | 已绑定菜单的 **`menu.xxx`** 汇总(逗号拼接,只读展示) |  
409 -| `menuIds` | 已绑定菜单 Guid 列表(`RoleMenu`) |  
410 -  
411 -### 数据库准备(推荐)  
412 -  
413 -```bash  
414 -美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/menu_backfill_permission_code.sql  
415 -```  
416 -  
417 -### 联调注意  
418 -  
419 -| 现象 | 处理 |  
420 -|------|------|  
421 -| 仍报未匹配到菜单 | 确认已部署本修复;检查 `Menu` 是否存在上表 Router |  
422 -| 只勾 Products/Batches 报错 | 当前库无对应菜单属预期;请同时勾选 Labels/Reports 等 |  
423 -| 同时传 `menuIds: []` | **menuIds 优先**,会清空绑定并忽略 accessPermissions |  
424 -  
425 -> 更完整的 RBAC 说明见 **`5-18接口优化.md`** → rbac-role 章节。  
426 -  
427 ----  
428 -  
429 -## auth-scope 登录后 Company · Region · Location 级联选店  
430 -  
431 -**应用服务**:`AuthScopeAppService`  
432 -**适用场景**:Web `POST /api/app/account/login` 或 App `POST /api/app/us-app-auth/login` 取得 Token 后,**管理员**无 `userlocation` 绑定时需先选工作门店;亦支持非管理员在数据范围内级联选择(须已绑定该门店)。  
433 -  
434 -**命名约定**(与 5-17 一致):UI **Company** = **`partnerId`**(`fl_partner.Id`);UI **Region** = **`groupId`**(`fl_group.Id`);UI **Location** = **`locationId`**(`location.Id`,Guid 字符串)。  
435 -  
436 -### 接口一览  
437 -  
438 -| 步骤 | 方法 | 路径 | 说明 |  
439 -|------|------|------|------|  
440 -| 1 | GET | `/api/app/auth-scope/companies` | 可选公司列表 |  
441 -| 2 | GET | `/api/app/auth-scope/regions?partnerId={partnerId}` | 指定公司下 Region |  
442 -| 3 | GET | `/api/app/auth-scope/locations?partnerId={partnerId}&groupId={groupId}` | 指定公司+Region 下门店 |  
443 -| 4 | POST | `/api/app/auth-scope/select-location` | 确认当前工作门店 |  
444 -| — | GET | `/api/app/auth-scope/current-scope` | 查询已选工作门店(未选返回 `null`) |  
445 -  
446 -**鉴权**:均需 `Authorization: Bearer {token}`。  
447 -  
448 -### 数据范围  
449 -  
450 -| 角色 | Company | Region | Location |  
451 -|------|---------|--------|----------|  
452 -| **管理员**(`admin` / 用户名 `admin` / 权限 `*:*:*`) | 全部未删除公司 | 该公司下全部 Region | 该 Region 下全部门店(`location.Partner` + `location.GroupName` 与 `fl_group` 一致) |  
453 -| **非管理员** | `userlocation` 绑定门店所属公司 | 绑定门店对应 Region | 上述 Region 内且符合 `LocationRegionScopeHelper` 的门店;**选店时**须已绑定该 `locationId` |  
454 -  
455 -### 1)公司列表  
456 -  
457 -```http  
458 -GET /api/app/auth-scope/companies HTTP/1.1  
459 -Authorization: Bearer {token}  
460 -```  
461 -  
462 -**响应**:`AuthScopeCompanyOptionDto[]`  
463 -  
464 -```json  
465 -[  
466 - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true }  
467 -]  
468 -```  
469 -  
470 -### 2)Region 列表  
471 -  
472 -```http  
473 -GET /api/app/auth-scope/regions?partnerId=fl_partner_id_1 HTTP/1.1  
474 -Authorization: Bearer {token}  
475 -```  
476 -  
477 -**响应**:`AuthScopeRegionOptionDto[]`  
478 -  
479 -```json  
480 -[  
481 - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true }  
482 -]  
483 -```  
484 -  
485 -### 3)门店列表  
486 -  
487 -```http  
488 -GET /api/app/auth-scope/locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1  
489 -Authorization: Bearer {token}  
490 -```  
491 -  
492 -**响应**:`AuthScopeLocationOptionDto[]`(含 `fullAddress`、`groupName` 等)  
493 -  
494 -### 4)确认选店(与现有 App 逻辑对齐)  
495 -  
496 -```http  
497 -POST /api/app/auth-scope/select-location HTTP/1.1  
498 -Authorization: Bearer {token}  
499 -Content-Type: application/json  
500 -```  
501 -  
502 -```json  
503 -{  
504 - "partnerId": "fl_partner_id_1",  
505 - "groupId": "fl_group_id_east",  
506 - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f"  
507 -}  
508 -```  
509 -  
510 -**响应**:`AuthScopeSelectLocationOutputDto`  
511 -  
512 -| 字段 | 说明 |  
513 -|------|------|  
514 -| partnerId / partnerName | 所选公司 |  
515 -| groupId / groupName | 所选 Region |  
516 -| location | 与 **`UsAppBoundLocationDto`** 相同(`id`、`locationCode`、`locationName`、`fullAddress`、`state`) |  
517 -  
518 -**选店后的服务端行为**(无需改前端即可对接 App):  
519 -  
520 -| 能力 | 行为 |  
521 -|------|------|  
522 -| **工作范围缓存** | 写入分布式缓存(24h);退出 `POST /api/app/auth-session/logout` 时清除 |  
523 -| **`GET /api/app/us-app-auth/my-locations`** | 管理员在缓存选店后,列表 **合并** 该门店(与 `userlocation` 并集) |  
524 -| **`GET .../location-detail/{locationId}`** | 管理员可不依赖 `userlocation` 访问已选门店(`UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync`) |  
525 -| **App 打印/报表** | 仍传 `locationId`;权限规则不变(见 `5-18接口优化.md`) |  
526 -  
527 -### 5)当前工作范围  
528 -  
529 -```http  
530 -GET /api/app/auth-scope/current-scope HTTP/1.1  
531 -Authorization: Bearer {token}  
532 -```  
533 -  
534 -未选店时响应体为 **`null`**(HTTP 200)。  
535 -  
536 -### 联调注意  
537 -  
538 -| 现象 | 处理 |  
539 -|------|------|  
540 -| `regions` 为空 | 公司下无 `fl_group` 或当前账号无 Region 数据范围 |  
541 -| `locations` 为空 | 门店 `Partner` / `GroupName` 未与 `fl_partner`、`fl_group` 对齐 |  
542 -| 选店报「门店与所选公司/区域不匹配」 | 检查 `location.Partner`、`location.GroupName` |  
543 -| 非管理员选店报未绑定 | 须在 **Team Member** 中为该账号绑定该门店 |  
544 -| 选店后 `my-locations` 仍为空 | 确认已调 `select-location` 且 Token 为管理员身份 |  
545 -  
546 -> Web 管理端报表等模块仍可按 Query 传 `partnerId` / `groupId` / `locationId` 收窄;本组接口主要解决 **登录后选工作门店** 与 **App 门店列表** 一致性问题。  
547 -  
548 ----  
549 -  
550 -## us-app-auth App 管理员级联选店  
551 -  
552 -**应用服务**:`UsAppAuthAppService`  
553 -**适用场景**:App 使用 **`POST /api/app/us-app-auth/login`** 登录后,持 **管理员** 身份(`admin` 角色 / 用户名 `admin` / 权限 `*:*:*`)且 JWT 含 **`client_kind=us-app`**,按 Company → Region 筛选门店。  
554 -  
555 -**与 `auth-scope` 关系**:查询逻辑共用 `AuthScopeQueryHelper`;App 侧路径统一在 **`us-app-auth`** 下,并 **强制 App Token + 管理员**,避免误用 Web Token。  
556 -  
557 -### 接口一览  
558 -  
559 -| 步骤 | 方法 | 路径 | 说明 |  
560 -|------|------|------|------|  
561 -| 0 | POST | `/api/app/us-app-auth/login` | 获取 App Token(须管理员账号) |  
562 -| 1 | GET | `/api/app/us-app-auth/admin-scope-companies` | 公司列表 → 取 `id` 作 `partnerId` |  
563 -| 2 | GET | `/api/app/us-app-auth/admin-scope-regions?partnerId={partnerId}` | Region 列表 → 取 `id` 作 `groupId` |  
564 -| 3 | GET | `/api/app/us-app-auth/admin-scope-locations?partnerId={partnerId}&groupId={groupId}` | **按公司与 Region Id 筛选门店** |  
565 -| 4 | POST | `/api/app/us-app-auth/select-admin-scope-location` | 确认工作门店 |  
566 -| — | GET | `/api/app/us-app-auth/my-locations` | 选店后刷新绑定门店(含缓存门店) |  
567 -  
568 -**鉴权**:步骤 1–4 须 Header `Authorization: Bearer {App登录返回的token}`。  
569 -  
570 -### 前置条件  
571 -  
572 -| 项 | 要求 |  
573 -|----|------|  
574 -| Token 来源 | 必须来自 **`/api/app/us-app-auth/login`**(非 Web `/api/app/account/login`) |  
575 -| JWT 声明 | `client_kind` = `us-app` |  
576 -| 角色 | 平台管理员(`ReportsRoleHelper.IsAdminRole`) |  
577 -| 违反时 | `请使用 App 登录令牌调用该接口` 或 `仅管理员可使用公司/区域/门店筛选接口` |  
578 -  
579 -### 1)公司列表  
580 -  
581 -```http  
582 -GET /api/app/us-app-auth/admin-scope-companies HTTP/1.1  
583 -Authorization: Bearer {app_token}  
584 -```  
585 -  
586 -**响应**:`AuthScopeCompanyOptionDto[]`(与 auth-scope 相同)  
587 -  
588 -```json  
589 -[  
590 - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true }  
591 -]  
592 -```  
593 -  
594 -### 2)Region 列表  
595 -  
596 -```http  
597 -GET /api/app/us-app-auth/admin-scope-regions?partnerId=fl_partner_id_1 HTTP/1.1  
598 -Authorization: Bearer {app_token}  
599 -```  
600 -  
601 -**响应**:`AuthScopeRegionOptionDto[]`  
602 -  
603 -```json  
604 -[  
605 - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true }  
606 -]  
607 -```  
608 -  
609 -### 3)门店列表(按 partnerId + groupId 筛选)  
610 -  
611 -```http  
612 -GET /api/app/us-app-auth/admin-scope-locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1  
613 -Authorization: Bearer {app_token}  
614 -```  
615 -  
616 -**Query**  
617 -  
618 -| 参数 | 必填 | 说明 |  
619 -|------|------|------|  
620 -| partnerId | 是 | 公司 Id(`fl_partner.Id`) |  
621 -| groupId | 是 | Region Id(`fl_group.Id`) |  
622 -  
623 -**响应**:`AuthScopeLocationOptionDto[]`  
624 -  
625 -```json  
626 -[  
627 - {  
628 - "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f",  
629 - "locationCode": "LOC-1",  
630 - "locationName": "Downtown Kitchen",  
631 - "fullAddress": "123 Main St, New York, NY 10001",  
632 - "state": true,  
633 - "partnerId": "fl_partner_id_1",  
634 - "groupId": "fl_group_id_east",  
635 - "groupName": "East Region"  
636 - }  
637 -]  
638 -```  
639 -  
640 -筛选规则:`location.Partner` 匹配该公司(Id 或名称),且 `location.GroupName` 与所选 `fl_group.GroupName` 一致。  
641 -  
642 -### 4)确认选店  
643 -  
644 -```http  
645 -POST /api/app/us-app-auth/select-admin-scope-location HTTP/1.1  
646 -Authorization: Bearer {app_token}  
647 -Content-Type: application/json  
648 -```  
649 -  
650 -```json  
651 -{  
652 - "partnerId": "fl_partner_id_1",  
653 - "groupId": "fl_group_id_east",  
654 - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f"  
655 -}  
656 -```  
657 -  
658 -**响应**:`AuthScopeSelectLocationOutputDto`(含 `location` 节点,结构同 `UsAppBoundLocationDto`)  
659 -  
660 -### 推荐调用顺序(App)  
661 -  
662 -```text  
663 -POST /api/app/us-app-auth/login  
664 - → GET admin-scope-companies  
665 - → GET admin-scope-regions?partnerId=...  
666 - → GET admin-scope-locations?partnerId=...&groupId=...  
667 - → POST select-admin-scope-location  
668 - → GET my-locations  
669 - → 后续业务接口传 locationId(打印、报表等,规则不变)  
670 -```  
671 -  
672 -### 联调注意  
673 -  
674 -| 现象 | 处理 |  
675 -|------|------|  
676 -| 报「请使用 App 登录令牌」 | 勿用 Web `account/login` 的 Token;须重新 App 登录 |  
677 -| 报「仅管理员可使用」 | 换管理员账号或绑定 `admin` 角色 |  
678 -| `locations` 为空 | 核对门店 `Partner`、`GroupName` 与 `fl_partner`、`fl_group` |  
679 -| 与 auth-scope 重复 | App 端 **优先** 使用本节前缀;Web 端用 `auth-scope` |  
680 -  
681 ----  
682 -  
683 -## 变更记录  
684 -  
685 -| 日期 | 说明 |  
686 -|------|------|  
687 -| 2026-05-26 | us-app-auth:App 管理员 `admin-scope-companies/regions/locations`、`select-admin-scope-location` |  
688 -| 2026-05-26 | auth-scope:登录后 Company/Region/Location 级联选店;选店缓存;`my-locations` / 门店详情与管理员选店对齐 |  
689 -| 2026-05-26 | product-category:`categoryCode` 新增/编辑改为可选 |  
690 -| 2026-05-26 | label-template:新增/编辑/列表/详情支持 `regionIds`、`locationIds` 及 `region`、`location` 展示 |  
691 -| 2026-05-26 | label-template 列表 Query 增加 `groupId`(Region)、`locationId`(门店)筛选 |  
692 -| 2026-05-26 | rbac-role:支持 accessPermissions JSON 数组 + UI 权限码映射 Menu;落库 AccessPermissionCodes |  
泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar
No preview for this file type
泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json
1 { 1 {
2 "name": "native-fast-printer", 2 "name": "native-fast-printer",
3 "id": "native-fast-printer", 3 "id": "native-fast-printer",
4 - "version": "1.2.8", 4 + "version": "1.0.3",
5 "description": "Android高速标签打印原生插件", 5 "description": "Android高速标签打印原生插件",
6 "_dp_type": "nativeplugin", 6 "_dp_type": "nativeplugin",
7 "_dp_nativeplugin": { 7 "_dp_nativeplugin": {
@@ -23,8 +23,7 @@ @@ -23,8 +23,7 @@
23 "abis": [ 23 "abis": [
24 "armeabi-v7a", 24 "armeabi-v7a",
25 "arm64-v8a", 25 "arm64-v8a",
26 - "x86",  
27 - "x86_64" 26 + "x86"
28 ], 27 ],
29 "minSdkVersion": "21", 28 "minSdkVersion": "21",
30 "useAndroidX": true, 29 "useAndroidX": true,
泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1
@@ -6,12 +6,17 @@ $dstPluginRoot = Join-Path $appRoot "nativeplugins\native-fast-printer" @@ -6,12 +6,17 @@ $dstPluginRoot = Join-Path $appRoot "nativeplugins\native-fast-printer"
6 $manifestPath = Join-Path $appRoot "src\manifest.json" 6 $manifestPath = Join-Path $appRoot "src\manifest.json"
7 7
8 Write-Host "[1/4] Validate source plugin files..." 8 Write-Host "[1/4] Validate source plugin files..."
9 -$sourceAarItem = Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Filter "native_fast_printer-release.aar" |  
10 - Where-Object { $_.FullName -notlike "*\nativeplugins\native-fast-printer\android\*" } |  
11 - Sort-Object LastWriteTime -Descending |  
12 - Select-Object -First 1 9 +$preferredAar = Join-Path $repoRoot "打印机安卓基座\native-fast-printer\android\native_fast_printer-release.aar"
  10 +$sourceAarItem = $null
  11 +if (Test-Path -LiteralPath $preferredAar) {
  12 + $sourceAarItem = Get-Item -LiteralPath $preferredAar
  13 +} else {
  14 + $sourceAarItem = Get-ChildItem -LiteralPath (Join-Path $repoRoot "打印机安卓基座") -Recurse -File -Filter "native_fast_printer-release.aar" -ErrorAction SilentlyContinue |
  15 + Sort-Object LastWriteTime -Descending |
  16 + Select-Object -First 1
  17 +}
13 if ($null -eq $sourceAarItem) { 18 if ($null -eq $sourceAarItem) {
14 - throw "AAR not found under repository root: $repoRoot" 19 + throw "AAR not found. Build first: 打印机安卓基座/native-fast-printer/android-src/build-aar-windows.bat"
15 } 20 }
16 21
17 $srcPluginRoot = Split-Path -Parent (Split-Path -Parent $sourceAarItem.FullName) 22 $srcPluginRoot = Split-Path -Parent (Split-Path -Parent $sourceAarItem.FullName)
泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue 0 → 100644
  1 +<template>
  2 + <view class="app-date-picker">
  3 + <view class="app-date-trigger" @click="openDialog">
  4 + <text class="app-date-trigger-text">{{ displayText }}</text>
  5 + </view>
  6 +
  7 + <view v-if="dialogVisible" class="app-date-mask" @click="cancelDialog">
  8 + <view class="app-date-dialog" @click.stop>
  9 + <text class="app-date-dialog-title">{{ dialogTitle }}</text>
  10 + <picker-view
  11 + class="app-date-picker-view"
  12 + :indicator-style="indicatorStyle"
  13 + :value="selection"
  14 + @change="onPickerChange"
  15 + >
  16 + <picker-view-column>
  17 + <view v-for="y in years" :key="'y-' + y" class="app-date-picker-item">{{ y }}</view>
  18 + </picker-view-column>
  19 + <picker-view-column>
  20 + <view v-for="(m, idx) in monthLabels" :key="'m-' + idx" class="app-date-picker-item">{{ m }}</view>
  21 + </picker-view-column>
  22 + <picker-view-column>
  23 + <view v-for="d in daysInMonth" :key="'d-' + d" class="app-date-picker-item">{{ d }}</view>
  24 + </picker-view-column>
  25 + </picker-view>
  26 + <view class="app-date-actions">
  27 + <view class="app-date-btn app-date-btn-cancel" @click="cancelDialog">
  28 + <text>Cancel</text>
  29 + </view>
  30 + <view class="app-date-btn app-date-btn-confirm" @click="confirmDialog">
  31 + <text>Confirm</text>
  32 + </view>
  33 + </view>
  34 + </view>
  35 + </view>
  36 + </view>
  37 +</template>
  38 +
  39 +<script setup lang="ts">
  40 +import { computed, ref, watch } from 'vue'
  41 +
  42 +const MONTH_LABELS = [
  43 + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
  44 + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
  45 +] as const
  46 +
  47 +const props = withDefaults(
  48 + defineProps<{
  49 + modelValue: string
  50 + /** yyyy-MM-dd */
  51 + min?: string
  52 + max?: string
  53 + placeholder?: string
  54 + dialogTitle?: string
  55 + }>(),
  56 + {
  57 + modelValue: '',
  58 + min: '2000-01-01',
  59 + max: '2099-12-31',
  60 + placeholder: 'Select',
  61 + dialogTitle: 'Select date',
  62 + },
  63 +)
  64 +
  65 +const emit = defineEmits<{
  66 + 'update:modelValue': [value: string]
  67 +}>()
  68 +
  69 +const dialogVisible = ref(false)
  70 +const selection = ref<[number, number, number]>([0, 0, 0])
  71 +const draftY = ref(2026)
  72 +const draftM = ref(1)
  73 +const draftD = ref(1)
  74 +
  75 +const indicatorStyle = 'height: 44px;'
  76 +
  77 +const years = computed(() => {
  78 + const minY = parseYmd(props.min).year
  79 + const maxY = parseYmd(props.max).year
  80 + const out: number[] = []
  81 + for (let y = minY; y <= maxY; y++) out.push(y)
  82 + return out.length ? out : [new Date().getFullYear()]
  83 +})
  84 +
  85 +const monthLabels = MONTH_LABELS
  86 +
  87 +const daysInMonth = computed(() => {
  88 + const max = daysInMonthCount(draftY.value, draftM.value)
  89 + return Array.from({ length: max }, (_, i) => String(i + 1).padStart(2, '0'))
  90 +})
  91 +
  92 +const displayText = computed(() => {
  93 + const v = (props.modelValue || '').trim()
  94 + if (!v) return props.placeholder
  95 + const p = parseYmd(v)
  96 + if (!p.valid) return v
  97 + return `${String(p.month).padStart(2, '0')}/${String(p.day).padStart(2, '0')}/${p.year}`
  98 +})
  99 +
  100 +function parseYmd(raw: string | undefined): { year: number; month: number; day: number; valid: boolean } {
  101 + const s = (raw || '').trim()
  102 + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
  103 + if (!m) {
  104 + const d = new Date()
  105 + return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), valid: false }
  106 + }
  107 + return {
  108 + year: Number(m[1]),
  109 + month: Number(m[2]),
  110 + day: Number(m[3]),
  111 + valid: true,
  112 + }
  113 +}
  114 +
  115 +function daysInMonthCount(year: number, month: number): number {
  116 + return new Date(year, month, 0).getDate()
  117 +}
  118 +
  119 +function clampDate(y: number, mo: number, d: number): { y: number; mo: number; d: number } {
  120 + const minP = parseYmd(props.min)
  121 + const maxP = parseYmd(props.max)
  122 + let year = y
  123 + let month = mo
  124 + let day = d
  125 + const maxDay = daysInMonthCount(year, month)
  126 + if (day > maxDay) day = maxDay
  127 +
  128 + const cur = new Date(year, month - 1, day)
  129 + const minD = new Date(minP.year, minP.month - 1, minP.day)
  130 + const maxD = new Date(maxP.year, maxP.month - 1, maxP.day)
  131 + let clamped = cur
  132 + if (cur < minD) clamped = minD
  133 + if (cur > maxD) clamped = maxD
  134 +
  135 + return {
  136 + y: clamped.getFullYear(),
  137 + mo: clamped.getMonth() + 1,
  138 + d: clamped.getDate(),
  139 + }
  140 +}
  141 +
  142 +function toYmd(y: number, mo: number, d: number): string {
  143 + return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`
  144 +}
  145 +
  146 +function syncSelectionFromDate(y: number, mo: number, d: number) {
  147 + const yi = Math.max(0, years.value.indexOf(y))
  148 + const mi = Math.max(0, Math.min(11, mo - 1))
  149 + const maxD = daysInMonthCount(y, mo)
  150 + const day = Math.min(d, maxD)
  151 + const di = Math.max(0, day - 1)
  152 + draftY.value = y
  153 + draftM.value = mo
  154 + draftD.value = day
  155 + selection.value = [yi, mi, di]
  156 +}
  157 +
  158 +function openDialog() {
  159 + const base = parseYmd(props.modelValue)
  160 + const clamped = clampDate(base.year, base.month, base.day)
  161 + syncSelectionFromDate(clamped.y, clamped.mo, clamped.d)
  162 + dialogVisible.value = true
  163 +}
  164 +
  165 +function onPickerChange(e: { detail: { value: number[] } }) {
  166 + const arr = e.detail.value || [0, 0, 0]
  167 + const y = years.value[arr[0]] ?? draftY.value
  168 + const mo = (arr[1] ?? 0) + 1
  169 + let d = (arr[2] ?? 0) + 1
  170 + const clamped = clampDate(y, mo, d)
  171 + syncSelectionFromDate(clamped.y, clamped.mo, clamped.d)
  172 +}
  173 +
  174 +function cancelDialog() {
  175 + dialogVisible.value = false
  176 +}
  177 +
  178 +function confirmDialog() {
  179 + const v = toYmd(draftY.value, draftM.value, draftD.value)
  180 + emit('update:modelValue', v)
  181 + dialogVisible.value = false
  182 +}
  183 +
  184 +watch(
  185 + () => props.modelValue,
  186 + (v) => {
  187 + if (dialogVisible.value) return
  188 + const p = parseYmd(v)
  189 + if (p.valid) {
  190 + const c = clampDate(p.year, p.month, p.day)
  191 + draftY.value = c.y
  192 + draftM.value = c.mo
  193 + draftD.value = c.d
  194 + }
  195 + },
  196 +)
  197 +</script>
  198 +
  199 +<style scoped>
  200 +.app-date-trigger {
  201 + height: 72rpx;
  202 + padding: 0 24rpx;
  203 + background: #fff;
  204 + border: 2rpx solid #e5e7eb;
  205 + border-radius: 12rpx;
  206 + display: flex;
  207 + align-items: center;
  208 + justify-content: center;
  209 + box-sizing: border-box;
  210 +}
  211 +
  212 +.app-date-trigger-text {
  213 + font-size: 28rpx;
  214 + color: #111827;
  215 +}
  216 +
  217 +.app-date-mask {
  218 + position: fixed;
  219 + left: 0;
  220 + right: 0;
  221 + top: 0;
  222 + bottom: 0;
  223 + z-index: 1000;
  224 + background: rgba(0, 0, 0, 0.45);
  225 + display: flex;
  226 + align-items: flex-end;
  227 + justify-content: center;
  228 +}
  229 +
  230 +.app-date-dialog {
  231 + width: 100%;
  232 + background: #fff;
  233 + border-radius: 24rpx 24rpx 0 0;
  234 + padding: 28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom));
  235 + box-sizing: border-box;
  236 +}
  237 +
  238 +.app-date-dialog-title {
  239 + display: block;
  240 + text-align: center;
  241 + font-size: 32rpx;
  242 + font-weight: 600;
  243 + color: #111827;
  244 + margin-bottom: 16rpx;
  245 +}
  246 +
  247 +.app-date-picker-view {
  248 + width: 100%;
  249 + height: 440rpx;
  250 +}
  251 +
  252 +.app-date-picker-item {
  253 + height: 44px;
  254 + line-height: 44px;
  255 + text-align: center;
  256 + font-size: 30rpx;
  257 + color: #111827;
  258 +}
  259 +
  260 +.app-date-actions {
  261 + display: flex;
  262 + gap: 24rpx;
  263 + margin-top: 20rpx;
  264 +}
  265 +
  266 +.app-date-btn {
  267 + flex: 1;
  268 + height: 88rpx;
  269 + border-radius: 16rpx;
  270 + display: flex;
  271 + align-items: center;
  272 + justify-content: center;
  273 + font-size: 30rpx;
  274 + font-weight: 600;
  275 +}
  276 +
  277 +.app-date-btn-cancel {
  278 + border: 2rpx solid #d1d5db;
  279 + color: #374151;
  280 + background: #fff;
  281 +}
  282 +
  283 +.app-date-btn-confirm {
  284 + background: var(--theme-primary, #4f46e5);
  285 + color: #fff;
  286 +}
  287 +</style>
泰额版/Food Labeling Management App UniApp/src/locales/en.ts
@@ -50,6 +50,14 @@ export default { @@ -50,6 +50,14 @@ export default {
50 selectStoreDesc: 'Select the store where you\'ll be working today', 50 selectStoreDesc: 'Select the store where you\'ll be working today',
51 selectStoreError: 'Please select a store', 51 selectStoreError: 'Please select a store',
52 storeSelected: 'Store selected successfully', 52 storeSelected: 'Store selected successfully',
  53 + scopeCompany: 'Company',
  54 + scopeRegion: 'Region',
  55 + scopeLoading: 'Loading…',
  56 + scopeNoCompanies: 'No companies available',
  57 + scopeNoRegions: 'No regions for this company',
  58 + selectCompanyRegionFirst: 'Select company and region first',
  59 + scopeLoadFail: 'Could not load options',
  60 + scopeSelectFail: 'Could not confirm store',
53 store1: 'Downtown Kitchen', store2: 'Brooklyn Central', store3: 'Queens Food Hub', store4: 'Manhattan Express', 61 store1: 'Downtown Kitchen', store2: 'Brooklyn Central', store3: 'Queens Food Hub', store4: 'Manhattan Express',
54 }, 62 },
55 dashboard: { 63 dashboard: {
泰额版/Food Labeling Management App UniApp/src/locales/zh.ts
@@ -50,6 +50,14 @@ export default { @@ -50,6 +50,14 @@ export default {
50 selectStoreDesc: '选择您今天工作的店铺', 50 selectStoreDesc: '选择您今天工作的店铺',
51 selectStoreError: '请选择一个店铺', 51 selectStoreError: '请选择一个店铺',
52 storeSelected: '店铺选择成功', 52 storeSelected: '店铺选择成功',
  53 + scopeCompany: '公司',
  54 + scopeRegion: '区域',
  55 + scopeLoading: '加载中…',
  56 + scopeNoCompanies: '暂无可用公司',
  57 + scopeNoRegions: '该公司下暂无区域',
  58 + selectCompanyRegionFirst: '请先选择公司与区域',
  59 + scopeLoadFail: '选项加载失败',
  60 + scopeSelectFail: '确认门店失败',
53 store1: '市中心厨房', store2: '布鲁克林中心', store3: '皇后食品中心', store4: '曼哈顿快速店', 61 store1: '市中心厨房', store2: '布鲁克林中心', store3: '皇后食品中心', store4: '曼哈顿快速店',
54 }, 62 },
55 dashboard: { 63 dashboard: {
泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
@@ -225,8 +225,8 @@ @@ -225,8 +225,8 @@
225 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text> 225 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text>
226 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text> 226 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text>
227 <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text> 227 <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text>
228 - <text class="tips-item">4. Devices with no name show as "Unknown Device"—you can still connect</text>  
229 - <text class="tips-item">5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering</text> 228 + <text class="tips-item">4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer</text>
  229 + <text class="tips-item">5. Pair the printer in system Bluetooth first if it does not appear after Scan</text>
230 <text class="tips-item">6. Restart the printer or app if not visible</text> 230 <text class="tips-item">6. Restart the printer or app if not visible</text>
231 <text class="tips-item tips-item-last"> 231 <text class="tips-item tips-item-last">
232 7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only. 232 7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only.
@@ -290,6 +290,7 @@ import { @@ -290,6 +290,7 @@ import {
290 setBuiltinPrinter, 290 setBuiltinPrinter,
291 } from '../../utils/print/printerConnection' 291 } from '../../utils/print/printerConnection'
292 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' 292 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
  293 +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist'
293 import { 294 import {
294 getNativeFastPrinterDebugInfo, 295 getNativeFastPrinterDebugInfo,
295 getNativeFastPrinterState, 296 getNativeFastPrinterState,
@@ -432,14 +433,12 @@ function handleResetUposOptions () { @@ -432,14 +433,12 @@ function handleResetUposOptions () {
432 } 433 }
433 434
434 function hasPreferredClassicDeviceInList () { 435 function hasPreferredClassicDeviceInList () {
435 - return devices.value.some((item: any) => {  
436 - const name = String(item?.name || '').toLowerCase()  
437 - const driverKey = String(item?.driverKey || '').toLowerCase()  
438 - return name.includes('virtual bt printer') || driverKey === 'd320fax'  
439 - }) 436 + return devices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name))
440 } 437 }
441 438
442 function upsertDevice (device: any) { 439 function upsertDevice (device: any) {
  440 + const rawName = (device?.name || device?.localName || '').trim()
  441 + if (!isAllowedBluetoothPrinterName(rawName)) return
443 const described = describeDiscoveredPrinter(device) 442 const described = describeDiscoveredPrinter(device)
444 const existing = devices.value.find(item => item.deviceId === described.deviceId) 443 const existing = devices.value.find(item => item.deviceId === described.deviceId)
445 if (!existing) { 444 if (!existing) {
@@ -573,7 +572,9 @@ function addPairedDevices () { @@ -573,7 +572,9 @@ function addPairedDevices () {
573 try { 572 try {
574 const paired = classic.getPairedDevices() 573 const paired = classic.getPairedDevices()
575 debugInfo.value.pairedCount = (paired || []).length 574 debugInfo.value.pairedCount = (paired || []).length
576 - debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer')) 575 + debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) =>
  576 + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'),
  577 + )
577 for (const p of paired) { 578 for (const p of paired) {
578 upsertDevice({ 579 upsertDevice({
579 deviceId: p.deviceId, 580 deviceId: p.deviceId,
泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
@@ -105,16 +105,20 @@ @@ -105,16 +105,20 @@
105 :key="productCategoryRowKey(pCat, pIdx)" 105 :key="productCategoryRowKey(pCat, pIdx)"
106 class="cat-section" 106 class="cat-section"
107 > 107 >
108 - <view class="cat-header" @click="toggleCategory(productCategoryRowKey(pCat, pIdx))">  
109 - <view class="cat-header-left"> 108 + <view
  109 + class="cat-header"
  110 + :style="catHeaderRowStyle"
  111 + @click="toggleCategory(productCategoryRowKey(pCat, pIdx))"
  112 + >
  113 + <view class="cat-header-left" :style="catHeaderLeftStyle">
110 <view 114 <view
111 - class="cat-icon cat-header-thumb" 115 + class="cat-header-thumb"
112 :class=" 116 :class="
113 productCategoryVisual(pCat).mode === 'image' 117 productCategoryVisual(pCat).mode === 'image'
114 - ? 'cat-icon--photo'  
115 - : 'cat-icon--fallback' 118 + ? 'cat-header-thumb--photo'
  119 + : 'cat-header-thumb--fallback'
116 " 120 "
117 - :style="productCategoryThumbStyle(pCat)" 121 + :style="catHeaderThumbStyle(pCat)"
118 > 122 >
119 <image 123 <image
120 v-if="productCategoryVisual(pCat).mode === 'image'" 124 v-if="productCategoryVisual(pCat).mode === 'image'"
@@ -144,25 +148,28 @@ @@ -144,25 +148,28 @@
144 <AppIcon name="food" size="sm" color="white" /> 148 <AppIcon name="food" size="sm" color="white" />
145 </view> 149 </view>
146 </view> 150 </view>
147 - <view class="cat-header-info"> 151 + <view class="cat-header-info" :style="catHeaderInfoStyle">
148 <text class="cat-header-name">{{ pCat.name }}</text> 152 <text class="cat-header-name">{{ pCat.name }}</text>
149 <text class="cat-header-count">{{ displayProductCategoryItemCount(pCat) }} items</text> 153 <text class="cat-header-count">{{ displayProductCategoryItemCount(pCat) }} items</text>
150 </view> 154 </view>
151 </view> 155 </view>
152 - <AppIcon  
153 - :name="  
154 - expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0  
155 - ? 'chevronUp'  
156 - : 'chevronDown'  
157 - "  
158 - size="sm"  
159 - color="gray"  
160 - /> 156 + <view class="cat-header-chevron" :style="catHeaderChevronStyle">
  157 + <AppIcon
  158 + :name="
  159 + expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0
  160 + ? 'chevronUp'
  161 + : 'chevronDown'
  162 + "
  163 + size="sm"
  164 + color="gray"
  165 + />
  166 + </view>
161 </view> 167 </view>
162 168
163 <view 169 <view
164 v-if="expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0" 170 v-if="expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0"
165 class="cat-foods" 171 class="cat-foods"
  172 + :style="catFoodsStyle"
166 > 173 >
167 <view class="food-grid"> 174 <view class="food-grid">
168 <view 175 <view
@@ -174,35 +181,35 @@ @@ -174,35 +181,35 @@
174 <view 181 <view
175 class="food-img-wrap" 182 class="food-img-wrap"
176 :class=" 183 :class="
177 - productVisual(product).mode === 'image' 184 + productVisual(product, pCat).mode === 'image'
178 ? 'food-img-wrap--photo' 185 ? 'food-img-wrap--photo'
179 : 'food-img-wrap--fallback' 186 : 'food-img-wrap--fallback'
180 " 187 "
181 - :style="productThumbWrapStyle(product)" 188 + :style="productThumbWrapStyle(product, pCat)"
182 > 189 >
183 <image 190 <image
184 - v-if="productVisual(product).mode === 'image'"  
185 - :src="resolveMediaUrlForApp(productVisual(product).imageUrl) || ''" 191 + v-if="productVisual(product, pCat).mode === 'image'"
  192 + :src="resolveMediaUrlForApp(productVisual(product, pCat).imageUrl) || ''"
186 class="food-img" 193 class="food-img"
187 mode="aspectFill" 194 mode="aspectFill"
188 /> 195 />
189 <text 196 <text
190 - v-else-if="productVisual(product).mode === 'colorText'" 197 + v-else-if="productVisual(product, pCat).mode === 'colorText'"
191 class="food-thumb-text food-thumb-text--on-color" 198 class="food-thumb-text food-thumb-text--on-color"
192 - :style="{ color: productVisual(product).textColor || '#ffffff' }" 199 + :style="{ color: productVisual(product, pCat).textColor || '#ffffff' }"
193 > 200 >
194 - {{ productVisual(product).text }} 201 + {{ productVisual(product, pCat).text }}
195 </text> 202 </text>
196 <text 203 <text
197 - v-else-if="productVisual(product).mode === 'text'" 204 + v-else-if="productVisual(product, pCat).mode === 'text'"
198 class="food-thumb-text" 205 class="food-thumb-text"
199 > 206 >
200 - {{ productVisual(product).text }} 207 + {{ productVisual(product, pCat).text }}
201 </text> 208 </text>
202 <view 209 <view
203 - v-else-if="productVisual(product).mode === 'color'" 210 + v-else-if="productVisual(product, pCat).mode === 'color'"
204 class="food-thumb-color-fill" 211 class="food-thumb-color-fill"
205 - :style="{ backgroundColor: productVisual(product).bg }" 212 + :style="{ backgroundColor: productVisual(product, pCat).bg }"
206 /> 213 />
207 <view 214 <view
208 v-else 215 v-else
@@ -418,10 +425,76 @@ function productCategoryVisual(p: UsAppProductCategoryNodeDto): CategoryVisualRe @@ -418,10 +425,76 @@ function productCategoryVisual(p: UsAppProductCategoryNodeDto): CategoryVisualRe
418 }) 425 })
419 } 426 }
420 427
421 -function productCategoryThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, string> { 428 +/** 安卓基座对内联 style 命中最稳;勿与侧栏共用 .cat-icon(64rpx) */
  429 +const catHeaderRowStyle: Record<string, string> = {
  430 + padding: '16rpx 20rpx',
  431 + display: 'flex',
  432 + flexDirection: 'row',
  433 + alignItems: 'center',
  434 + justifyContent: 'space-between',
  435 + boxSizing: 'border-box',
  436 + width: '100%',
  437 +}
  438 +
  439 +const catHeaderLeftStyle: Record<string, string> = {
  440 + display: 'flex',
  441 + flexDirection: 'row',
  442 + alignItems: 'center',
  443 + flex: '1',
  444 + minWidth: '0',
  445 +}
  446 +
  447 +const catHeaderInfoStyle: Record<string, string> = {
  448 + flex: '1',
  449 + minWidth: '0',
  450 + marginLeft: '0',
  451 + paddingLeft: '0',
  452 +}
  453 +
  454 +const catHeaderChevronStyle: Record<string, string> = {
  455 + flexShrink: '0',
  456 + width: '44rpx',
  457 + height: '44rpx',
  458 + marginLeft: '8rpx',
  459 + display: 'flex',
  460 + alignItems: 'center',
  461 + justifyContent: 'center',
  462 +}
  463 +
  464 +const catFoodsStyle: Record<string, string> = {
  465 + boxSizing: 'border-box',
  466 + paddingTop: '4rpx',
  467 + paddingRight: '20rpx',
  468 + paddingBottom: '16rpx',
  469 + paddingLeft: '20rpx',
  470 +}
  471 +
  472 +function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, string> {
422 const v = productCategoryVisual(p) 473 const v = productCategoryVisual(p)
423 - if (v.mode === 'colorText') return { backgroundColor: v.bg }  
424 - return {} 474 + const style: Record<string, string> = {
  475 + width: '52rpx',
  476 + height: '52rpx',
  477 + minWidth: '52rpx',
  478 + minHeight: '52rpx',
  479 + maxWidth: '52rpx',
  480 + maxHeight: '52rpx',
  481 + marginRight: '16rpx',
  482 + marginBottom: '0',
  483 + marginTop: '0',
  484 + marginLeft: '0',
  485 + flexShrink: '0',
  486 + borderRadius: '12rpx',
  487 + overflow: 'hidden',
  488 + display: 'flex',
  489 + alignItems: 'center',
  490 + justifyContent: 'center',
  491 + boxSizing: 'border-box',
  492 + backgroundColor: '#f3f4f6',
  493 + }
  494 + if (v.mode === 'colorText' || v.mode === 'color') {
  495 + style.backgroundColor = v.bg
  496 + }
  497 + return style
425 } 498 }
426 499
427 function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string { 500 function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string {
@@ -444,7 +517,10 @@ function colorClassForName(name: string): string { @@ -444,7 +517,10 @@ function colorClassForName(name: string): string {
444 return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length] 517 return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length]
445 } 518 }
446 519
447 -function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { 520 +function productVisual(
  521 + p: UsAppLabelingProductNodeDto,
  522 + pCat?: UsAppProductCategoryNodeDto,
  523 +): CategoryVisualRender {
448 const v = resolveCategoryButtonVisualFromDto({ 524 const v = resolveCategoryButtonVisualFromDto({
449 buttonStyleJson: p.buttonStyleJson, 525 buttonStyleJson: p.buttonStyleJson,
450 buttonAppearance: p.buttonAppearance, 526 buttonAppearance: p.buttonAppearance,
@@ -457,13 +533,20 @@ function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { @@ -457,13 +533,20 @@ function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender {
457 name: p.productName, 533 name: p.productName,
458 }) 534 })
459 if (v.mode !== 'none') return v 535 if (v.mode !== 'none') return v
  536 + if (pCat) {
  537 + const fromCat = productCategoryVisual(pCat)
  538 + if (fromCat.mode !== 'none') return fromCat
  539 + }
460 const legacyImg = (p.productImageUrl ?? '').trim() 540 const legacyImg = (p.productImageUrl ?? '').trim()
461 if (legacyImg) return { mode: 'image', imageUrl: legacyImg } 541 if (legacyImg) return { mode: 'image', imageUrl: legacyImg }
462 return { mode: 'none' } 542 return { mode: 'none' }
463 } 543 }
464 544
465 -function productThumbWrapStyle(p: UsAppLabelingProductNodeDto): Record<string, string> {  
466 - const v = productVisual(p) 545 +function productThumbWrapStyle(
  546 + p: UsAppLabelingProductNodeDto,
  547 + pCat?: UsAppProductCategoryNodeDto,
  548 +): Record<string, string> {
  549 + const v = productVisual(p, pCat)
467 if (v.mode === 'colorText' || v.mode === 'color') { 550 if (v.mode === 'colorText' || v.mode === 'color') {
468 return { backgroundColor: v.bg } 551 return { backgroundColor: v.bg }
469 } 552 }
@@ -482,6 +565,8 @@ function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string { @@ -482,6 +565,8 @@ function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string {
482 565
483 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */ 566 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
484 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string { 567 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
  568 + const fromCard = (p.templateLabelSizeText ?? '').trim()
  569 + if (fromCard) return fromCard
485 const types = p.labelTypes || [] 570 const types = p.labelTypes || []
486 if (types.length === 0) return '—' 571 if (types.length === 0) return '—'
487 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean) 572 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
@@ -782,8 +867,16 @@ const goBluetoothPage = () =&gt; { @@ -782,8 +867,16 @@ const goBluetoothPage = () =&gt; {
782 height: 100%; 867 height: 100%;
783 } 868 }
784 869
  870 +/**
  871 + * 产品列表布局:手写 rpx(UniApp 安卓部分机型不支持 CSS var,var 会导致 padding 整段失效)。
  872 + */
  873 +.page {
  874 + box-sizing: border-box;
  875 +}
  876 +
785 .panel-inner { 877 .panel-inner {
786 - padding: 24rpx; 878 + padding: 16rpx 20rpx;
  879 + box-sizing: border-box;
787 } 880 }
788 881
789 .search-box { 882 .search-box {
@@ -826,38 +919,37 @@ const goBluetoothPage = () =&gt; { @@ -826,38 +919,37 @@ const goBluetoothPage = () =&gt; {
826 .category-list { 919 .category-list {
827 display: flex; 920 display: flex;
828 flex-direction: column; 921 flex-direction: column;
829 - gap: 16rpx; 922 + gap: 12rpx;
830 } 923 }
831 924
832 .cat-section { 925 .cat-section {
833 background: #fff; 926 background: #fff;
834 - border-radius: 16rpx; 927 + border-radius: 12rpx;
835 box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); 928 box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
836 overflow: hidden; 929 overflow: hidden;
  930 + box-sizing: border-box;
837 } 931 }
838 932
  933 +/* 产品分类标题行:主样式见 script 内联 catHeaderRowStyle(安卓 scoped 易失效) */
839 .cat-header { 934 .cat-header {
840 - display: flex;  
841 - align-items: center;  
842 - justify-content: space-between;  
843 - padding: 24rpx; 935 + box-sizing: border-box;
  936 + width: 100%;
844 } 937 }
845 938
846 -.cat-header-left {  
847 - display: flex;  
848 - align-items: center;  
849 - gap: 16rpx;  
850 - flex: 1;  
851 - min-width: 0; 939 +.cat-header-thumb {
  940 + box-sizing: border-box;
852 } 941 }
853 942
854 -/* 与侧栏 .cat-icon 同尺寸,仅去掉侧栏下边距 */  
855 -.cat-header-thumb {  
856 - width: 64rpx;  
857 - height: 64rpx;  
858 - margin-bottom: 0;  
859 - flex-shrink: 0;  
860 - background: #f3f4f6; 943 +.cat-header-thumb--photo,
  944 +.cat-header-thumb--fallback {
  945 + overflow: hidden;
  946 +}
  947 +
  948 +.cat-header .cat-header-thumb .cat-icon-text,
  949 +.cat-header .cat-header-thumb .cat-icon-text--on-color {
  950 + font-size: 18rpx;
  951 + line-height: 1.1;
  952 + padding: 0 4rpx;
861 } 953 }
862 954
863 .cat-header-color-fill { 955 .cat-header-color-fill {
@@ -893,39 +985,61 @@ const goBluetoothPage = () =&gt; { @@ -893,39 +985,61 @@ const goBluetoothPage = () =&gt; {
893 .cat-header-info { 985 .cat-header-info {
894 flex: 1; 986 flex: 1;
895 min-width: 0; 987 min-width: 0;
  988 + margin: 0;
  989 + padding: 0;
896 } 990 }
897 991
898 .cat-header-name { 992 .cat-header-name {
899 - font-size: 28rpx; 993 + font-size: 26rpx;
900 font-weight: 600; 994 font-weight: 600;
901 color: #111827; 995 color: #111827;
902 display: block; 996 display: block;
  997 + line-height: 1.25;
903 } 998 }
904 999
905 .cat-header-count { 1000 .cat-header-count {
906 - font-size: 22rpx; 1001 + font-size: 20rpx;
907 color: #9ca3af; 1002 color: #9ca3af;
908 display: block; 1003 display: block;
909 - margin-top: 2rpx; 1004 + margin-top: 4rpx;
  1005 + line-height: 1.2;
  1006 +}
  1007 +
  1008 +.cat-header-chevron {
  1009 + flex-shrink: 0;
  1010 + display: flex;
  1011 + align-items: center;
  1012 + justify-content: center;
  1013 + width: 44rpx;
  1014 + height: 44rpx;
  1015 + margin-left: 8rpx;
  1016 + margin-right: 0;
910 } 1017 }
911 1018
912 .cat-foods { 1019 .cat-foods {
913 - padding: 0 16rpx 16rpx; 1020 + box-sizing: border-box;
914 border-top: 1rpx solid #f3f4f6; 1021 border-top: 1rpx solid #f3f4f6;
915 } 1022 }
916 1023
917 .food-grid { 1024 .food-grid {
918 - display: grid;  
919 - grid-template-columns: repeat(2, minmax(0, 1fr));  
920 - column-gap: 12rpx;  
921 - row-gap: 12rpx;  
922 - padding-top: 16rpx; 1025 + display: flex;
  1026 + flex-direction: row;
  1027 + flex-wrap: wrap;
  1028 + align-content: flex-start;
  1029 + padding-top: 8rpx;
  1030 + margin-left: -5rpx;
  1031 + margin-right: -5rpx;
923 } 1032 }
924 1033
925 .food-card { 1034 .food-card {
  1035 + width: 47%;
  1036 + max-width: 240rpx;
  1037 + flex: 0 0 auto;
  1038 + box-sizing: border-box;
  1039 + margin: 0 5rpx 12rpx 5rpx;
926 background: #f9fafb; 1040 background: #f9fafb;
927 - padding: 10rpx;  
928 - border-radius: 14rpx; 1041 + padding: 8rpx;
  1042 + border-radius: 12rpx;
929 } 1043 }
930 1044
931 .food-card:active { 1045 .food-card:active {
@@ -935,10 +1049,11 @@ const goBluetoothPage = () =&gt; { @@ -935,10 +1049,11 @@ const goBluetoothPage = () =&gt; {
935 .food-img-wrap { 1049 .food-img-wrap {
936 width: 100%; 1050 width: 100%;
937 position: relative; 1051 position: relative;
938 - padding-top: 75%;  
939 - border-radius: 10rpx; 1052 + height: 0;
  1053 + padding-top: 58%;
  1054 + border-radius: 8rpx;
940 background: #e5e7eb; 1055 background: #e5e7eb;
941 - margin-bottom: 10rpx; 1056 + margin-bottom: 8rpx;
942 overflow: hidden; 1057 overflow: hidden;
943 } 1058 }
944 1059
@@ -965,9 +1080,9 @@ const goBluetoothPage = () =&gt; { @@ -965,9 +1080,9 @@ const goBluetoothPage = () =&gt; {
965 display: flex; 1080 display: flex;
966 align-items: center; 1081 align-items: center;
967 justify-content: center; 1082 justify-content: center;
968 - padding: 12rpx; 1083 + padding: 8rpx;
969 box-sizing: border-box; 1084 box-sizing: border-box;
970 - font-size: 32rpx; 1085 + font-size: 26rpx;
971 font-weight: 700; 1086 font-weight: 700;
972 color: #111827; 1087 color: #111827;
973 line-height: 1.15; 1088 line-height: 1.15;
@@ -976,7 +1091,7 @@ const goBluetoothPage = () =&gt; { @@ -976,7 +1091,7 @@ const goBluetoothPage = () =&gt; {
976 } 1091 }
977 1092
978 .food-thumb-text--on-color { 1093 .food-thumb-text--on-color {
979 - font-size: 30rpx; 1094 + font-size: 24rpx;
980 color: #ffffff; 1095 color: #ffffff;
981 } 1096 }
982 1097
@@ -1048,7 +1163,7 @@ const goBluetoothPage = () =&gt; { @@ -1048,7 +1163,7 @@ const goBluetoothPage = () =&gt; {
1048 } 1163 }
1049 1164
1050 .food-name { 1165 .food-name {
1051 - font-size: 24rpx; 1166 + font-size: 22rpx;
1052 font-weight: 600; 1167 font-weight: 600;
1053 color: #111827; 1168 color: #111827;
1054 display: block; 1169 display: block;
@@ -1059,7 +1174,7 @@ const goBluetoothPage = () =&gt; { @@ -1059,7 +1174,7 @@ const goBluetoothPage = () =&gt; {
1059 } 1174 }
1060 1175
1061 .food-desc { 1176 .food-desc {
1062 - font-size: 20rpx; 1177 + font-size: 18rpx;
1063 color: #6b7280; 1178 color: #6b7280;
1064 display: block; 1179 display: block;
1065 overflow: hidden; 1180 overflow: hidden;
@@ -1152,14 +1267,25 @@ const goBluetoothPage = () =&gt; { @@ -1152,14 +1267,25 @@ const goBluetoothPage = () =&gt; {
1152 } 1267 }
1153 1268
1154 @media (min-width: 768px) { 1269 @media (min-width: 768px) {
1155 - .sidebar {  
1156 - width: 260rpx; 1270 + .panel-inner {
  1271 + padding: 20rpx 24rpx;
  1272 + }
  1273 +
  1274 + /* 平板:内联样式在运行时由 rpx 换算,此处仅作 H5 预览补充 */
  1275 +
  1276 + .food-card {
  1277 + width: 31%;
  1278 + max-width: 280rpx;
  1279 + padding: 10rpx;
  1280 + margin: 0 7rpx 14rpx 7rpx;
  1281 + }
  1282 +
  1283 + .food-img-wrap {
  1284 + padding-top: 62%;
1157 } 1285 }
1158 1286
1159 - .food-grid {  
1160 - grid-template-columns: repeat(3, minmax(0, 1fr));  
1161 - column-gap: 16rpx;  
1162 - row-gap: 16rpx; 1287 + .sidebar {
  1288 + width: 260rpx;
1163 } 1289 }
1164 } 1290 }
1165 </style> 1291 </style>
泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
@@ -306,7 +306,12 @@ import { @@ -306,7 +306,12 @@ import {
306 normalizeTemplateForNativeFastJob, 306 normalizeTemplateForNativeFastJob,
307 templateHasUnsupportedNativeFastElements, 307 templateHasUnsupportedNativeFastElements,
308 } from '../../utils/print/nativeTemplateElementSupport' 308 } from '../../utils/print/nativeTemplateElementSupport'
309 -import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm' 309 +import {
  310 + ensureTemplateHeightCoversElements,
  311 + getTemplatePhysicalSizeMm,
  312 + isTemplateWithinNativeFastPrintBounds,
  313 + templateContentHeightPx,
  314 +} from '../../utils/print/templatePhysicalMm'
310 import { isPrinterReadySync } from '../../utils/print/printerReadiness' 315 import { isPrinterReadySync } from '../../utils/print/printerReadiness'
311 import { 316 import {
312 ensureNativeClassicTransportIfPossible, 317 ensureNativeClassicTransportIfPossible,
@@ -462,7 +467,7 @@ function buildPrintPreflightDeviceInfoText (args: { @@ -462,7 +467,7 @@ function buildPrintPreflightDeviceInfoText (args: {
462 lines.push(`- nativeUnsupportedElements: ${args.nativeUnsupported ? 'YES' : 'NO'}`) 467 lines.push(`- nativeUnsupportedElements: ${args.nativeUnsupported ? 'YES' : 'NO'}`)
463 lines.push('') 468 lines.push('')
464 lines.push('Note') 469 lines.push('Note')
465 - lines.push('- Built-in 打印可能走 UPOS(内置/串口)或本机端口服务(127.0.0.1)。若“成功但不出纸”,优先看 uposWillTry / tcpPluginAvailable 与 native-fast-printer 的 lastError/stage。') 470 + lines.push('- Built-in may use UPOS (on-device/serial) or localhost (127.0.0.1). If the app reports success but nothing prints, check uposWillTry / tcpPluginAvailable and native-fast-printer lastError/stage.')
466 return lines.join('\n') 471 return lines.join('\n')
467 } 472 }
468 473
@@ -1189,6 +1194,14 @@ async function waitForCanvasLayout(): Promise&lt;void&gt; { @@ -1189,6 +1194,14 @@ async function waitForCanvasLayout(): Promise&lt;void&gt; {
1189 }) 1194 })
1190 } 1195 }
1191 1196
  1197 +/** 光栅打印前同步隐藏 canvas 像素尺寸,避免缓冲区高度仍为预览值导致底部裁切 */
  1198 +async function applyPrintCanvasLayout(layout: { outW: number; outH: number }) {
  1199 + canvasCssW.value = layout.outW
  1200 + canvasCssH.value = layout.outH
  1201 + await waitForCanvasLayout()
  1202 + await new Promise<void>((r) => setTimeout(r, 80))
  1203 +}
  1204 +
1192 let deferredPreviewRedrawTimer: ReturnType<typeof setTimeout> | null = null 1205 let deferredPreviewRedrawTimer: ReturnType<typeof setTimeout> | null = null
1193 1206
1194 /** 首屏内容与 loading 切换后布局才稳定,补绘一次与「改输入后变正常」同路径 */ 1207 /** 首屏内容与 loading 切换后布局才稳定,补绘一次与「改输入后变正常」同路径 */
@@ -1496,10 +1509,17 @@ const handlePrint = async () =&gt; { @@ -1496,10 +1509,17 @@ const handlePrint = async () =&gt; {
1496 uni.showLoading({ title: 'Rendering…', mask: true }) 1509 uni.showLoading({ title: 'Rendering…', mask: true })
1497 /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */ 1510 /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */
1498 const mergedForPrint = computeMergedPreviewTemplate() 1511 const mergedForPrint = computeMergedPreviewTemplate()
1499 - const tmpl = mergedForPrint ?? systemTemplate.value  
1500 - if (!tmpl || !instance) { 1512 + const tmplBase = mergedForPrint ?? systemTemplate.value
  1513 + if (!tmplBase || !instance) {
1501 throw new Error('No label to print.') 1514 throw new Error('No label to print.')
1502 } 1515 }
  1516 + const tmpl = ensureTemplateHeightCoversElements(tmplBase)
  1517 + const phys = getTemplatePhysicalSizeMm(tmpl)
  1518 + console.info(
  1519 + '[preview] print physical size',
  1520 + `${phys.widthMm}×${phys.heightMm}mm`,
  1521 + `template=${tmpl.width}×${tmpl.height}${phys.unit}`,
  1522 + )
1503 ensurePrintJobActive(printJobId) 1523 ensurePrintJobActive(printJobId)
1504 1524
1505 const printInputJson = buildPrintInputJson( 1525 const printInputJson = buildPrintInputJson(
@@ -1509,7 +1529,8 @@ const handlePrint = async () =&gt; { @@ -1509,7 +1529,8 @@ const handlePrint = async () =&gt; {
1509 ) 1529 )
1510 printStage = 'payload-ready' 1530 printStage = 'payload-ready'
1511 /** 1531 /**
1512 - * 一体机(经典蓝牙 + native-fast-printer 基座,如 Virtual BT):走原生 printLabelPrintJob。 1532 + * 经典蓝牙 + native-plugin(含 Virtual BT):走原生 printTemplate(JSON,快,与预览坐标一致)。
  1533 + * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。
1513 * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。 1534 * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。
1514 */ 1535 */
1515 const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) 1536 const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
@@ -1544,13 +1565,13 @@ const handlePrint = async () =&gt; { @@ -1544,13 +1565,13 @@ const handlePrint = async () =&gt; {
1544 /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */ 1565 /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */
1545 let tmplForNativePayload = tmplForNativeJob 1566 let tmplForNativePayload = tmplForNativeJob
1546 if (useNativeTemplatePrint) { 1567 if (useNativeTemplatePrint) {
1547 - // D320+经典蓝牙仍走 printTemplate(JSON,快);略收紧横向与右侧安全区,减轻裁切/错位。  
1548 - tmplForNativePayload = applyNativeTemplateStyleScale(  
1549 - tmplForNativePayload,  
1550 - isD320faxClassicNative  
1551 - ? { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 }  
1552 - : { textScale: 1, xScale: 1, safeRightRatio: 0.93 },  
1553 - ) 1568 + // 真机 GP-D320FX 可略收窄;Virtual BT 必须与预览同坐标,禁止 xScale。
  1569 + if (isD320faxClassicNative && !isVirtualBtPrinter) {
  1570 + tmplForNativePayload = applyNativeTemplateStyleScale(
  1571 + tmplForNativePayload,
  1572 + { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 },
  1573 + )
  1574 + }
1554 resetHydrateImageDebugRecords() 1575 resetHydrateImageDebugRecords()
1555 tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativePayload) 1576 tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativePayload)
1556 } 1577 }
@@ -1651,10 +1672,7 @@ const handlePrint = async () =&gt; { @@ -1651,10 +1672,7 @@ const handlePrint = async () =&gt; {
1651 canvasRaster: { 1672 canvasRaster: {
1652 canvasId: 'labelPreviewCanvas', 1673 canvasId: 'labelPreviewCanvas',
1653 componentInstance: instance, 1674 componentInstance: instance,
1654 - applyLayout: (layout) => {  
1655 - canvasCssW.value = layout.outW  
1656 - canvasCssH.value = layout.outH  
1657 - }, 1675 + applyLayout: applyPrintCanvasLayout,
1658 }, 1676 },
1659 }, 1677 },
1660 (percent) => { 1678 (percent) => {
@@ -1702,10 +1720,7 @@ const handlePrint = async () =&gt; { @@ -1702,10 +1720,7 @@ const handlePrint = async () =&gt; {
1702 canvasRaster: { 1720 canvasRaster: {
1703 canvasId: 'labelPreviewCanvas', 1721 canvasId: 'labelPreviewCanvas',
1704 componentInstance: instance, 1722 componentInstance: instance,
1705 - applyLayout: (layout) => {  
1706 - canvasCssW.value = layout.outW  
1707 - canvasCssH.value = layout.outH  
1708 - }, 1723 + applyLayout: applyPrintCanvasLayout,
1709 }, 1724 },
1710 }, 1725 },
1711 (percent) => { 1726 (percent) => {
@@ -1798,7 +1813,14 @@ const handlePrint = async () =&gt; { @@ -1798,7 +1813,14 @@ const handlePrint = async () =&gt; {
1798 } 1813 }
1799 const maxDots = 1814 const maxDots =
1800 rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) 1815 rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576)
1801 - const layout = getLabelPrintRasterLayout(tmpl, maxDots, rasterDriver.imageDpi || 203) 1816 + const escUseContentH = rasterDriver.protocol === 'esc'
  1817 + const contentHpx = escUseContentH ? templateContentHeightPx(tmpl) : undefined
  1818 + const layout = getLabelPrintRasterLayout(
  1819 + tmpl,
  1820 + maxDots,
  1821 + rasterDriver.imageDpi || 203,
  1822 + contentHpx != null ? { contentHeightPx: contentHpx } : undefined,
  1823 + )
1802 1824
1803 canvasCssW.value = layout.outW 1825 canvasCssW.value = layout.outW
1804 canvasCssH.value = layout.outH 1826 canvasCssH.value = layout.outH
@@ -1862,9 +1884,11 @@ const handlePrint = async () =&gt; { @@ -1862,9 +1884,11 @@ const handlePrint = async () =&gt; {
1862 imageData, 1884 imageData,
1863 { 1885 {
1864 printQty: printQty.value, 1886 printQty: printQty.value,
1865 - clearTopRasterRows: 1, 1887 + clearTopRasterRows: 0,
1866 targetWidthDots: layout.outW, 1888 targetWidthDots: layout.outW,
1867 targetHeightDots: layout.outH, 1889 targetHeightDots: layout.outH,
  1890 + useContentHeight: true,
  1891 + cutBetweenCopies: true,
1868 }, 1892 },
1869 (percent) => { 1893 (percent) => {
1870 if (!isPrintJobActive(printJobId)) return 1894 if (!isPrintJobActive(printJobId)) return
@@ -1880,6 +1904,7 @@ const handlePrint = async () =&gt; { @@ -1880,6 +1904,7 @@ const handlePrint = async () =&gt; {
1880 clearTopRasterRows: 1, 1904 clearTopRasterRows: 1,
1881 targetWidthDots: layout.outW, 1905 targetWidthDots: layout.outW,
1882 targetHeightDots: layout.outH, 1906 targetHeightDots: layout.outH,
  1907 + labelRasterFixedHeight: true,
1883 }, 1908 },
1884 (percent) => { 1909 (percent) => {
1885 if (!isPrintJobActive(printJobId)) return 1910 if (!isPrintJobActive(printJobId)) return
@@ -1958,9 +1983,11 @@ const handlePrint = async () =&gt; { @@ -1958,9 +1983,11 @@ const handlePrint = async () =&gt; {
1958 tmpPath, 1983 tmpPath,
1959 { 1984 {
1960 printQty: printQty.value, 1985 printQty: printQty.value,
1961 - clearTopRasterRows: 1, 1986 + clearTopRasterRows: 0,
1962 targetWidthDots: layout.outW, 1987 targetWidthDots: layout.outW,
1963 targetHeightDots: layout.outH, 1988 targetHeightDots: layout.outH,
  1989 + useContentHeight: true,
  1990 + cutBetweenCopies: true,
1964 }, 1991 },
1965 (percent) => { 1992 (percent) => {
1966 if (!isPrintJobActive(printJobId)) return 1993 if (!isPrintJobActive(printJobId)) return
@@ -1975,6 +2002,7 @@ const handlePrint = async () =&gt; { @@ -1975,6 +2002,7 @@ const handlePrint = async () =&gt; {
1975 clearTopRasterRows: 1, 2002 clearTopRasterRows: 1,
1976 targetWidthDots: layout.outW, 2003 targetWidthDots: layout.outW,
1977 targetHeightDots: layout.outH, 2004 targetHeightDots: layout.outH,
  2005 + labelRasterFixedHeight: true,
1978 }, 2006 },
1979 (percent) => { 2007 (percent) => {
1980 if (!isPrintJobActive(printJobId)) return 2008 if (!isPrintJobActive(printJobId)) return
泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue
1 <template> 1 <template>
  2 +
2 <view class="page"> 3 <view class="page">
  4 +
3 <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }"> 5 <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">
  6 +
4 <view class="top-bar"> 7 <view class="top-bar">
  8 +
5 <view class="top-left" @click="goBack"> 9 <view class="top-left" @click="goBack">
  10 +
6 <AppIcon name="chevronLeft" size="sm" color="white" /> 11 <AppIcon name="chevronLeft" size="sm" color="white" />
  12 +
7 </view> 13 </view>
  14 +
8 <view class="top-center"> 15 <view class="top-center">
  16 +
9 <text class="page-title">Label Report</text> 17 <text class="page-title">Label Report</text>
  18 +
10 <LocationPicker /> 19 <LocationPicker />
  20 +
11 </view> 21 </view>
  22 +
12 <view class="top-right" @click="isMenuOpen = true"> 23 <view class="top-right" @click="isMenuOpen = true">
  24 +
13 <AppIcon name="menu" size="sm" color="white" /> 25 <AppIcon name="menu" size="sm" color="white" />
  26 +
14 </view> 27 </view>
  28 +
  29 + </view>
  30 +
  31 + </view>
  32 +
  33 +
  34 +
  35 + <view class="period-bar">
  36 +
  37 + <view
  38 +
  39 + v-for="p in periodOptions"
  40 +
  41 + :key="p.value"
  42 +
  43 + class="period-btn"
  44 +
  45 + :class="{ active: period === p.value }"
  46 +
  47 + @click="selectPeriod(p.value)"
  48 +
  49 + >
  50 +
  51 + <text class="period-text">{{ p.label }}</text>
  52 +
  53 + </view>
  54 +
  55 + </view>
  56 +
  57 +
  58 +
  59 + <view v-if="period === 'custom'" class="custom-date-bar">
  60 +
  61 + <view class="date-field">
  62 +
  63 + <text class="date-label">Start</text>
  64 +
  65 + <AppDatePicker
  66 +
  67 + v-model="customStart"
  68 +
  69 + :max="customEnd || '2099-12-31'"
  70 +
  71 + placeholder="Select"
  72 +
  73 + dialog-title="Start date"
  74 +
  75 + />
  76 +
15 </view> 77 </view>
  78 +
  79 + <view class="date-field">
  80 +
  81 + <text class="date-label">End</text>
  82 +
  83 + <AppDatePicker
  84 +
  85 + v-model="customEnd"
  86 +
  87 + :min="customStart || '2000-01-01'"
  88 +
  89 + placeholder="Select"
  90 +
  91 + dialog-title="End date"
  92 +
  93 + />
  94 +
  95 + </view>
  96 +
16 </view> 97 </view>
17 98
18 - <view class="period-bar">  
19 - <view  
20 - v-for="p in periodOptions"  
21 - :key="p.value"  
22 - class="period-btn"  
23 - :class="{ active: period === p.value }"  
24 - @click="selectPeriod(p.value)"  
25 - >  
26 - <text class="period-text">{{ p.label }}</text>  
27 - </view>  
28 - </view>  
29 99
30 - <view v-if="period === 'custom'" class="custom-date-bar">  
31 - <view class="date-field">  
32 - <text class="date-label">Start</text>  
33 - <picker mode="date" :value="customStart" :end="customEnd || '2099-12-31'" @change="onStartChange">  
34 - <view class="date-picker-btn">{{ customStart || 'Select' }}</view>  
35 - </picker>  
36 - </view>  
37 - <view class="date-field">  
38 - <text class="date-label">End</text>  
39 - <picker mode="date" :value="customEnd" :start="customStart || '2000-01-01'" @change="onEndChange">  
40 - <view class="date-picker-btn">{{ customEnd || 'Select' }}</view>  
41 - </picker>  
42 - </view>  
43 - </view>  
44 100
45 - <scroll-view class="content" scroll-y>  
46 - <!-- 指标卡 -->  
47 - <view class="metrics-grid">  
48 - <view class="metric-card">  
49 - <text class="metric-label">Total Labels Printed</text>  
50 - <text class="metric-value">2,543</text>  
51 - <text class="metric-trend up">+20.1% from last month</text>  
52 - </view>  
53 - <view class="metric-card">  
54 - <text class="metric-label">Most Printed Category</text>  
55 - <text class="metric-value">Dairy</text>  
56 - <text class="metric-trend">450 labels generated</text>  
57 - </view>  
58 - <view class="metric-card">  
59 - <text class="metric-label">Top Product</text>  
60 - <text class="metric-value">Whole Milk</text>  
61 - <text class="metric-trend">182 labels generated</text>  
62 - </view>  
63 - <view class="metric-card">  
64 - <text class="metric-label">Avg. Daily Prints</text>  
65 - <text class="metric-value">85</text>  
66 - <text class="metric-trend up">+12% from last week</text>  
67 - </view>  
68 - </view> 101 + <scroll-view class="content" scroll-y>
  102 +
  103 + <view v-if="loading" class="state-block">
  104 +
  105 + <text class="state-text">Loading…</text>
  106 +
  107 + </view>
  108 +
  109 +
  110 +
  111 + <template v-else>
  112 +
  113 + <!-- 指标卡 -->
  114 +
  115 + <view class="metrics-grid">
  116 +
  117 + <view class="metric-card">
  118 +
  119 + <text class="metric-label">Total Labels Printed</text>
  120 +
  121 + <text class="metric-value">{{ totalPrintedDisplay }}</text>
  122 +
  123 + <text class="metric-trend" :class="totalChangeClass">{{ totalChangeText }}</text>
  124 +
  125 + </view>
  126 +
  127 + <view class="metric-card">
  128 +
  129 + <text class="metric-label">Most Printed Category</text>
  130 +
  131 + <text class="metric-value">{{ topCategoryName }}</text>
  132 +
  133 + <text class="metric-trend">{{ topCategorySubtext }}</text>
  134 +
  135 + </view>
  136 +
  137 + <view class="metric-card">
  138 +
  139 + <text class="metric-label">Top Product</text>
  140 +
  141 + <text class="metric-value">{{ topProductName }}</text>
  142 +
  143 + <text class="metric-trend">{{ topProductSubtext }}</text>
  144 +
  145 + </view>
  146 +
  147 + <view class="metric-card">
  148 +
  149 + <text class="metric-label">Avg. Daily Prints</text>
  150 +
  151 + <text class="metric-value">{{ avgDailyDisplay }}</text>
  152 +
  153 + <text class="metric-trend" :class="avgChangeClass">{{ avgChangeText }}</text>
  154 +
  155 + </view>
  156 +
  157 + </view>
  158 +
  159 +
  160 +
  161 + <!-- 柱状图 -->
  162 +
  163 + <view class="section">
  164 +
  165 + <text class="section-title">Labels by Category</text>
  166 +
  167 + <text class="section-desc">Distribution of printed labels across product categories.</text>
  168 +
  169 + <view v-if="!categoryChart.length" class="empty-panel">
  170 +
  171 + <text class="empty-text">No category data for this period</text>
  172 +
  173 + </view>
  174 +
  175 + <view v-else class="bar-chart">
  176 +
  177 + <view v-for="item in categoryChart" :key="item.key" class="bar-row">
  178 +
  179 + <text class="bar-label">{{ item.name }}</text>
  180 +
  181 + <view class="bar-track">
  182 +
  183 + <view class="bar-fill" :style="{ width: item.percent + '%' }" />
  184 +
  185 + </view>
  186 +
  187 + <text class="bar-value">{{ item.value }}</text>
  188 +
  189 + </view>
  190 +
  191 + </view>
  192 +
  193 + </view>
  194 +
  195 +
  196 +
  197 + <!-- 走势图 -->
  198 +
  199 + <view class="section">
  200 +
  201 + <text class="section-title">Print Volume Trends</text>
  202 +
  203 + <text class="section-desc">{{ trendDescription }}</text>
  204 +
  205 + <view v-if="!trendChart.length" class="empty-panel">
  206 +
  207 + <text class="empty-text">No trend data for this period</text>
  208 +
  209 + </view>
  210 +
  211 + <view v-else class="line-chart">
  212 +
  213 + <view class="chart-bars">
  214 +
  215 + <view v-for="(bar, i) in trendChart" :key="'t-' + i" class="day-bar-wrap">
  216 +
  217 + <view class="day-bar" :style="{ height: bar.heightPct + '%' }" />
  218 +
  219 + </view>
  220 +
  221 + </view>
  222 +
  223 + <view class="chart-labels">
  224 +
  225 + <text v-for="(bar, i) in trendChart" :key="'l-' + i" class="day-label">{{ bar.label }}</text>
  226 +
  227 + </view>
  228 +
  229 + </view>
  230 +
  231 + </view>
  232 +
  233 +
  234 +
  235 + <!-- 最常用产品列表 -->
  236 +
  237 + <view class="section">
  238 +
  239 + <text class="section-title">Most Used Products</text>
  240 +
  241 + <view class="product-table">
  242 +
  243 + <view class="product-row header">
  244 +
  245 + <text class="col-name">Product Name</text>
  246 +
  247 + <text class="col-cat">Category</text>
  248 +
  249 + <text class="col-total">Total Printed</text>
  250 +
  251 + <text class="col-pct">Usage %</text>
  252 +
  253 + </view>
  254 +
  255 + <view v-if="!topProducts.length" class="product-row empty-row">
  256 +
  257 + <text class="empty-row-text">No products in this period</text>
  258 +
  259 + </view>
  260 +
  261 + <view v-for="p in topProducts" :key="p.key" class="product-row">
  262 +
  263 + <text class="col-name">{{ p.name }}</text>
  264 +
  265 + <text class="col-cat">{{ p.category }}</text>
  266 +
  267 + <text class="col-total">{{ p.total }}</text>
  268 +
  269 + <text class="col-pct">{{ p.usage }}%</text>
  270 +
  271 + </view>
  272 +
  273 + </view>
  274 +
  275 + </view>
  276 +
  277 + </template>
  278 +
  279 + </scroll-view>
  280 +
  281 +
  282 +
  283 + <SideMenu v-model="isMenuOpen" />
  284 +
  285 + </view>
  286 +
  287 +</template>
  288 +
  289 +
  290 +
  291 +<script setup lang="ts">
  292 +
  293 +import { ref, computed, watch } from 'vue'
  294 +
  295 +import { onShow } from '@dcloudio/uni-app'
  296 +
  297 +import AppIcon from '../../components/AppIcon.vue'
  298 +
  299 +import AppDatePicker from '../../components/AppDatePicker.vue'
  300 +
  301 +import SideMenu from '../../components/SideMenu.vue'
  302 +
  303 +import LocationPicker from '../../components/LocationPicker.vue'
  304 +
  305 +import { getStatusBarHeight } from '../../utils/statusBar'
  306 +
  307 +import { getCurrentStoreId } from '../../utils/stores'
  308 +
  309 +import { fetchUsAppLabelReport } from '../../services/usAppLabeling'
  310 +
  311 +import type { UsAppLabelReportOutputDto, UsAppLabelReportPeriod } from '../../types/usAppLabeling'
  312 +
  313 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  314 +
  315 +import { formatDisplayText } from '../../utils/emptyDisplay'
  316 +
  317 +
  318 +
  319 +const statusBarHeight = getStatusBarHeight()
  320 +
  321 +const isMenuOpen = ref(false)
  322 +
  323 +const loading = ref(false)
  324 +
  325 +const period = ref<UsAppLabelReportPeriod>('7d')
  326 +
  327 +const customStart = ref('')
  328 +
  329 +const customEnd = ref('')
  330 +
  331 +const report = ref<UsAppLabelReportOutputDto | null>(null)
  332 +
  333 +
  334 +
  335 +const periodOptions = [
  336 +
  337 + { value: '7d' as const, label: 'Last 7 Days' },
  338 +
  339 + { value: '30d' as const, label: 'Last 30 Days' },
  340 +
  341 + { value: '90d' as const, label: 'Last 90 Days' },
  342 +
  343 + { value: 'custom' as const, label: 'Custom' },
  344 +
  345 +]
  346 +
  347 +
  348 +
  349 +function formatDate(d: Date): string {
  350 +
  351 + const y = d.getFullYear()
  352 +
  353 + const m = String(d.getMonth() + 1).padStart(2, '0')
  354 +
  355 + const day = String(d.getDate()).padStart(2, '0')
  356 +
  357 + return `${y}-${m}-${day}`
  358 +
  359 +}
  360 +
  361 +
  362 +
  363 +function selectPeriod(val: UsAppLabelReportPeriod) {
  364 +
  365 + period.value = val
  366 +
  367 + if (val === 'custom') {
  368 +
  369 + const today = formatDate(new Date())
  370 +
  371 + const weekAgo = new Date()
  372 +
  373 + weekAgo.setDate(weekAgo.getDate() - 7)
  374 +
  375 + customStart.value = customStart.value || formatDate(weekAgo)
  376 +
  377 + customEnd.value = customEnd.value || today
  378 +
  379 + }
  380 +
  381 + loadReport()
  382 +
  383 +}
  384 +
  385 +
  386 +
  387 +watch(customStart, (start) => {
  388 +
  389 + if (!start || !customEnd.value) return
  390 +
  391 + if (start > customEnd.value) customEnd.value = start
  392 +
  393 +})
  394 +
  395 +
  396 +
  397 +watch([customStart, customEnd], () => {
  398 +
  399 + if (period.value === 'custom' && customStart.value && customEnd.value) {
  400 +
  401 + loadReport()
  402 +
  403 + }
  404 +
  405 +})
  406 +
  407 +
  408 +
  409 +function formatNumber(n: number): string {
  410 +
  411 + return Math.round(n).toLocaleString('en-US')
  412 +
  413 +}
  414 +
  415 +
  416 +
  417 +function formatChangeRate(rate: number): { text: string; cls: string } {
  418 +
  419 + const r = Number(rate)
  420 +
  421 + if (!Number.isFinite(r) || r === 0) {
  422 +
  423 + return { text: 'No change vs previous period', cls: '' }
  424 +
  425 + }
  426 +
  427 + const sign = r > 0 ? '+' : ''
  428 +
  429 + const cls = r > 0 ? 'up' : 'down'
  430 +
  431 + return {
  432 +
  433 + text: `${sign}${r.toFixed(1)}% vs previous period`,
  434 +
  435 + cls,
  436 +
  437 + }
  438 +
  439 +}
  440 +
  441 +
  442 +
  443 +const summary = computed(() => report.value?.summary)
  444 +
  445 +
  446 +
  447 +const totalPrintedDisplay = computed(() =>
  448 +
  449 + formatNumber(summary.value?.totalLabelsPrinted ?? 0),
  450 +
  451 +)
  452 +
  453 +
  454 +
  455 +const totalChangeText = computed(() =>
  456 +
  457 + formatChangeRate(summary.value?.totalLabelsPrintedChangeRate ?? 0).text,
  458 +
  459 +)
  460 +
  461 +const totalChangeClass = computed(() =>
  462 +
  463 + formatChangeRate(summary.value?.totalLabelsPrintedChangeRate ?? 0).cls,
  464 +
  465 +)
  466 +
  467 +
  468 +
  469 +const topCategoryName = computed(() =>
  470 +
  471 + formatDisplayText(summary.value?.mostPrintedCategoryName, 'None'),
  472 +
  473 +)
  474 +
  475 +const topCategorySubtext = computed(() => {
  476 +
  477 + const n = summary.value?.mostPrintedCategoryCount ?? 0
  478 +
  479 + return n > 0 ? `${formatNumber(n)} labels generated` : 'No labels in period'
  480 +
  481 +})
  482 +
  483 +
  484 +
  485 +const topProductName = computed(() =>
  486 +
  487 + formatDisplayText(summary.value?.topProductName, 'None'),
  488 +
  489 +)
  490 +
  491 +const topProductSubtext = computed(() => {
  492 +
  493 + const n = summary.value?.topProductCount ?? 0
  494 +
  495 + return n > 0 ? `${formatNumber(n)} labels generated` : 'No labels in period'
  496 +
  497 +})
  498 +
  499 +
  500 +
  501 +const avgDailyDisplay = computed(() => {
  502 +
  503 + const v = summary.value?.avgDailyPrints ?? 0
  504 +
  505 + const rounded = Math.round(v * 10) / 10
  506 +
  507 + return rounded % 1 === 0 ? String(Math.round(rounded)) : rounded.toFixed(1)
  508 +
  509 +})
  510 +
  511 +
  512 +
  513 +const avgChangeText = computed(() =>
  514 +
  515 + formatChangeRate(summary.value?.avgDailyPrintsChangeRate ?? 0).text,
  516 +
  517 +)
  518 +
  519 +const avgChangeClass = computed(() =>
  520 +
  521 + formatChangeRate(summary.value?.avgDailyPrintsChangeRate ?? 0).cls,
  522 +
  523 +)
  524 +
  525 +
  526 +
  527 +const trendDescription = computed(() => {
  528 +
  529 + const fromApi = report.value?.appliedRange?.trendDescription?.trim()
  530 +
  531 + if (fromApi) return fromApi
  532 +
  533 + const days = report.value?.appliedRange?.dayCount
  534 +
  535 + if (days && days > 7) {
  536 +
  537 + return `Daily label printing volume for the last ${days} days.`
  538 +
  539 + }
  540 +
  541 + return 'Daily label printing volume for the selected period.'
  542 +
  543 +})
  544 +
  545 +
  546 +
  547 +const categoryChart = computed(() => {
  548 +
  549 + const rows = report.value?.labelsByCategory ?? []
  550 +
  551 + const max = Math.max(1, ...rows.map((d) => d.count))
  552 +
  553 + return rows.map((d, i) => ({
  554 +
  555 + key: (d.categoryId || d.categoryName || 'cat') + i,
  556 +
  557 + name: formatDisplayText(d.categoryName, 'Uncategorized'),
  558 +
  559 + value: formatNumber(d.count),
  560 +
  561 + percent: (d.count / max) * 100,
  562 +
  563 + }))
  564 +
  565 +})
  566 +
  567 +
  568 +
  569 +function formatTrendMd(dateStr: string): string {
  570 +
  571 + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr)
  572 +
  573 + if (!m) return dateStr.slice(5) || dateStr
  574 +
  575 + return `${m[2]}/${m[3]}`
  576 +
  577 +}
  578 +
  579 +
69 580
70 - <!-- 柱状图 -->  
71 - <view class="section">  
72 - <text class="section-title">Labels by Category</text>  
73 - <text class="section-desc">Distribution of printed labels across product categories.</text>  
74 - <view class="bar-chart">  
75 - <view v-for="item in categoryData" :key="item.name" class="bar-row">  
76 - <text class="bar-label">{{ item.name }}</text>  
77 - <view class="bar-track">  
78 - <view class="bar-fill" :style="{ width: item.percent + '%' }" />  
79 - </view>  
80 - <text class="bar-value">{{ item.value }}</text>  
81 - </view>  
82 - </view>  
83 - </view> 581 +const trendChart = computed(() => {
84 582
85 - <!-- 走势图 -->  
86 - <view class="section">  
87 - <text class="section-title">Print Volume Trends</text>  
88 - <text class="section-desc">Daily label printing volume for the last 7 days.</text>  
89 - <view class="line-chart">  
90 - <view class="chart-bars">  
91 - <view v-for="(pct, i) in trendData" :key="i" class="day-bar-wrap">  
92 - <view class="day-bar" :style="{ height: pct + '%' }" />  
93 - </view>  
94 - </view>  
95 - <view class="chart-labels">  
96 - <text v-for="d in dayLabels" :key="d" class="day-label">{{ d }}</text>  
97 - </view>  
98 - </view>  
99 - </view> 583 + const pts = report.value?.printVolumeTrend ?? []
100 584
101 - <!-- 最常用产品列表 -->  
102 - <view class="section">  
103 - <text class="section-title">Most Used Products</text>  
104 - <view class="product-table">  
105 - <view class="product-row header">  
106 - <text class="col-name">Product Name</text>  
107 - <text class="col-cat">Category</text>  
108 - <text class="col-total">Total Printed</text>  
109 - <text class="col-pct">Usage %</text>  
110 - </view>  
111 - <view v-for="p in topProducts" :key="p.name" class="product-row">  
112 - <text class="col-name">{{ p.name }}</text>  
113 - <text class="col-cat">{{ p.category }}</text>  
114 - <text class="col-total">{{ p.total }}</text>  
115 - <text class="col-pct">{{ p.usage }}%</text>  
116 - </view>  
117 - </view>  
118 - </view>  
119 - </scroll-view> 585 + if (!pts.length) return []
120 586
121 - <SideMenu v-model="isMenuOpen" />  
122 - </view>  
123 -</template> 587 + const max = Math.max(1, ...pts.map((p) => p.count))
124 588
125 -<script setup lang="ts">  
126 -import { ref, computed } from 'vue'  
127 -import AppIcon from '../../components/AppIcon.vue'  
128 -import SideMenu from '../../components/SideMenu.vue'  
129 -import LocationPicker from '../../components/LocationPicker.vue'  
130 -import { getStatusBarHeight } from '../../utils/statusBar' 589 + const n = pts.length
131 590
132 -const statusBarHeight = getStatusBarHeight()  
133 -const isMenuOpen = ref(false)  
134 -const period = ref('7d')  
135 -const customStart = ref('')  
136 -const customEnd = ref('') 591 + const labelStep = n <= 7 ? 1 : Math.max(1, Math.ceil(n / 6))
137 592
138 -const periodOptions = [  
139 - { value: '7d', label: 'Last 7 Days' },  
140 - { value: '30d', label: 'Last 30 Days' },  
141 - { value: '90d', label: 'Last 90 Days' },  
142 - { value: 'custom', label: 'Custom' },  
143 -] 593 + return pts.map((p, i) => ({
  594 +
  595 + heightPct: (p.count / max) * 100,
  596 +
  597 + label: i % labelStep === 0 || i === n - 1 ? formatTrendMd(p.date) : '',
  598 +
  599 + }))
  600 +
  601 +})
  602 +
  603 +
  604 +
  605 +const topProducts = computed(() =>
  606 +
  607 + (report.value?.mostUsedProducts ?? []).map((p, i) => ({
  608 +
  609 + key: (p.productId || p.productName) + i,
  610 +
  611 + name: formatDisplayText(p.productName, 'None'),
  612 +
  613 + category: formatDisplayText(p.categoryName, '—'),
  614 +
  615 + total: formatNumber(p.totalPrinted),
  616 +
  617 + usage: Number(p.usagePercent).toFixed(1),
  618 +
  619 + })),
  620 +
  621 +)
  622 +
  623 +
  624 +
  625 +let loadSeq = 0
  626 +
  627 +
  628 +
  629 +async function loadReport() {
  630 +
  631 + const locationId = getCurrentStoreId()
  632 +
  633 + if (!locationId) {
  634 +
  635 + report.value = null
  636 +
  637 + return
144 638
145 -function selectPeriod(val: string) {  
146 - period.value = val  
147 - if (val === 'custom') {  
148 - const today = formatDate(new Date())  
149 - const weekAgo = new Date()  
150 - weekAgo.setDate(weekAgo.getDate() - 7)  
151 - customStart.value = customStart.value || formatDate(weekAgo)  
152 - customEnd.value = customEnd.value || today  
153 } 639 }
154 -}  
155 640
156 -function formatDate(d: Date): string {  
157 - const y = d.getFullYear()  
158 - const m = String(d.getMonth() + 1).padStart(2, '0')  
159 - const day = String(d.getDate()).padStart(2, '0')  
160 - return `${y}-${m}-${day}`  
161 -} 641 + if (period.value === 'custom' && (!customStart.value || !customEnd.value)) {
162 642
163 -function onStartChange(e: any) {  
164 - customStart.value = e.detail.value  
165 -} 643 + return
  644 +
  645 + }
  646 +
  647 +
  648 +
  649 + const seq = ++loadSeq
  650 +
  651 + loading.value = true
  652 +
  653 + try {
  654 +
  655 + const data = await fetchUsAppLabelReport({
  656 +
  657 + locationId,
  658 +
  659 + period: period.value,
  660 +
  661 + startDate: period.value === 'custom' ? customStart.value : undefined,
  662 +
  663 + endDate: period.value === 'custom' ? customEnd.value : undefined,
  664 +
  665 + })
  666 +
  667 + if (seq !== loadSeq) return
  668 +
  669 + report.value = data
  670 +
  671 + } catch (e) {
  672 +
  673 + if (seq !== loadSeq) return
  674 +
  675 + if (!isUsAppSessionExpiredError(e)) {
  676 +
  677 + const msg = e instanceof Error ? e.message : 'Load failed'
  678 +
  679 + uni.showToast({ title: msg, icon: 'none', duration: 2500 })
  680 +
  681 + }
  682 +
  683 + report.value = null
  684 +
  685 + } finally {
  686 +
  687 + if (seq === loadSeq) loading.value = false
  688 +
  689 + }
166 690
167 -function onEndChange(e: any) {  
168 - customEnd.value = e.detail.value  
169 } 691 }
170 692
171 -const categoryDataRaw = [  
172 - { name: 'Dairy', value: 450 },  
173 - { name: 'Meat', value: 380 },  
174 - { name: 'Bakery', value: 320 },  
175 - { name: 'Deli', value: 280 },  
176 - { name: 'Produce', value: 220 },  
177 - { name: 'Beverage', value: 180 },  
178 -]  
179 693
180 -const categoryData = computed(() => {  
181 - const max = Math.max(...categoryDataRaw.map(d => d.value))  
182 - return categoryDataRaw.map(d => ({ ...d, percent: (d.value / max) * 100 }))  
183 -})  
184 694
185 -const trendDataRaw = [72, 85, 78, 92, 88, 95, 90]  
186 -const trendData = computed(() => {  
187 - const max = Math.max(...trendDataRaw)  
188 - return trendDataRaw.map(v => (v / max) * 100) 695 +onShow(() => {
  696 +
  697 + loadReport()
  698 +
189 }) 699 })
190 700
191 -const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']  
192 701
193 -const topProducts = ref([  
194 - { name: 'Whole Milk', category: 'Dairy', total: 182, usage: '7.2' },  
195 - { name: 'Ground Beef 80/20', category: 'Meat', total: 145, usage: '5.7' },  
196 - { name: 'Chicken Breast', category: 'Meat', total: 132, usage: '5.2' },  
197 - { name: 'Sliced Ham', category: 'Deli', total: 98, usage: '3.8' },  
198 -])  
199 702
200 const goBack = () => { 703 const goBack = () => {
  704 +
201 const pages = getCurrentPages() 705 const pages = getCurrentPages()
  706 +
202 if (pages.length > 1) { 707 if (pages.length > 1) {
  708 +
203 uni.navigateBack() 709 uni.navigateBack()
  710 +
204 } else { 711 } else {
  712 +
205 uni.redirectTo({ url: '/pages/index/index' }) 713 uni.redirectTo({ url: '/pages/index/index' })
  714 +
206 } 715 }
  716 +
207 } 717 }
  718 +
208 </script> 719 </script>
209 720
  721 +
  722 +
210 <style scoped> 723 <style scoped>
  724 +
211 .page { 725 .page {
  726 +
212 min-height: 100vh; 727 min-height: 100vh;
  728 +
213 background: #f3f4f6; 729 background: #f3f4f6;
  730 +
214 display: flex; 731 display: flex;
  732 +
215 flex-direction: column; 733 flex-direction: column;
  734 +
216 } 735 }
217 736
  737 +
  738 +
218 .header-hero { 739 .header-hero {
  740 +
219 background: linear-gradient(135deg, #1F3A8A, #142a6c); 741 background: linear-gradient(135deg, #1F3A8A, #142a6c);
  742 +
220 padding: 16rpx 32rpx 24rpx; 743 padding: 16rpx 32rpx 24rpx;
  744 +
221 } 745 }
222 746
  747 +
  748 +
223 .top-bar { 749 .top-bar {
  750 +
224 height: 96rpx; 751 height: 96rpx;
  752 +
225 display: flex; 753 display: flex;
  754 +
226 align-items: center; 755 align-items: center;
  756 +
227 justify-content: space-between; 757 justify-content: space-between;
  758 +
228 } 759 }
229 760
  761 +
  762 +
230 .top-left, .top-right { 763 .top-left, .top-right {
  764 +
231 width: 64rpx; 765 width: 64rpx;
  766 +
232 height: 64rpx; 767 height: 64rpx;
  768 +
233 border-radius: 999rpx; 769 border-radius: 999rpx;
  770 +
234 background: rgba(255, 255, 255, 0.15); 771 background: rgba(255, 255, 255, 0.15);
  772 +
235 display: flex; 773 display: flex;
  774 +
236 align-items: center; 775 align-items: center;
  776 +
237 justify-content: center; 777 justify-content: center;
  778 +
238 } 779 }
239 780
  781 +
  782 +
240 .top-center { 783 .top-center {
  784 +
241 flex: 1; 785 flex: 1;
  786 +
242 display: flex; 787 display: flex;
  788 +
243 flex-direction: column; 789 flex-direction: column;
  790 +
244 align-items: center; 791 align-items: center;
  792 +
245 } 793 }
246 794
  795 +
  796 +
247 .page-title { 797 .page-title {
  798 +
248 font-size: 34rpx; 799 font-size: 34rpx;
  800 +
249 font-weight: 600; 801 font-weight: 600;
  802 +
250 color: #fff; 803 color: #fff;
  804 +
251 } 805 }
252 806
  807 +
  808 +
253 .period-bar { 809 .period-bar {
  810 +
254 display: flex; 811 display: flex;
  812 +
255 gap: 12rpx; 813 gap: 12rpx;
  814 +
256 padding: 16rpx 28rpx; 815 padding: 16rpx 28rpx;
  816 +
257 background: #fff; 817 background: #fff;
  818 +
258 border-bottom: 1rpx solid #e5e7eb; 819 border-bottom: 1rpx solid #e5e7eb;
  820 +
259 } 821 }
260 822
  823 +
  824 +
261 .period-btn { 825 .period-btn {
  826 +
262 flex: 1; 827 flex: 1;
  828 +
263 padding: 16rpx 24rpx; 829 padding: 16rpx 24rpx;
  830 +
264 border-radius: 12rpx; 831 border-radius: 12rpx;
  832 +
265 background: #f3f4f6; 833 background: #f3f4f6;
  834 +
266 text-align: center; 835 text-align: center;
  836 +
267 transition: all 0.2s; 837 transition: all 0.2s;
  838 +
268 } 839 }
269 840
  841 +
  842 +
270 .period-btn.active { 843 .period-btn.active {
  844 +
271 background: linear-gradient(135deg, #1F3A8A, #1447E6); 845 background: linear-gradient(135deg, #1F3A8A, #1447E6);
  846 +
272 } 847 }
273 848
  849 +
  850 +
274 .period-btn.active .period-text { 851 .period-btn.active .period-text {
  852 +
275 color: #fff; 853 color: #fff;
  854 +
276 } 855 }
277 856
  857 +
  858 +
278 .period-text { 859 .period-text {
  860 +
279 font-size: 26rpx; 861 font-size: 26rpx;
  862 +
280 font-weight: 500; 863 font-weight: 500;
  864 +
281 color: #6b7280; 865 color: #6b7280;
  866 +
282 } 867 }
283 868
  869 +
  870 +
284 .custom-date-bar { 871 .custom-date-bar {
  872 +
285 display: flex; 873 display: flex;
  874 +
286 gap: 24rpx; 875 gap: 24rpx;
  876 +
287 padding: 20rpx 28rpx; 877 padding: 20rpx 28rpx;
  878 +
288 background: #fff; 879 background: #fff;
  880 +
289 border-bottom: 1rpx solid #e5e7eb; 881 border-bottom: 1rpx solid #e5e7eb;
  882 +
290 } 883 }
291 884
  885 +
  886 +
292 .date-field { 887 .date-field {
  888 +
293 flex: 1; 889 flex: 1;
  890 +
294 display: flex; 891 display: flex;
  892 +
295 flex-direction: column; 893 flex-direction: column;
  894 +
296 gap: 8rpx; 895 gap: 8rpx;
  896 +
297 } 897 }
298 898
  899 +
  900 +
299 .date-label { 901 .date-label {
  902 +
300 font-size: 24rpx; 903 font-size: 24rpx;
  904 +
301 color: #6b7280; 905 color: #6b7280;
  906 +
302 font-weight: 500; 907 font-weight: 500;
  908 +
303 } 909 }
304 910
305 -.date-picker-btn {  
306 - padding: 20rpx 24rpx;  
307 - background: #f3f4f6;  
308 - border-radius: 12rpx;  
309 - font-size: 28rpx;  
310 - color: #111827;  
311 - border: 2rpx solid #e5e7eb; 911 +
  912 +
  913 +.content {
  914 +
  915 + flex: 1;
  916 +
  917 + padding: 24rpx 28rpx 40rpx;
  918 +
  919 + box-sizing: border-box;
  920 +
  921 +}
  922 +
  923 +
  924 +
  925 +.state-block {
  926 +
  927 + padding: 80rpx 0;
  928 +
  929 + text-align: center;
  930 +
312 } 931 }
313 932
314 -.content {  
315 - flex: 1;  
316 - padding: 24rpx 28rpx 40rpx;  
317 - box-sizing: border-box; 933 +
  934 +
  935 +.state-text {
  936 +
  937 + font-size: 28rpx;
  938 +
  939 + color: #6b7280;
  940 +
318 } 941 }
319 942
  943 +
  944 +
320 .metrics-grid { 945 .metrics-grid {
  946 +
321 display: grid; 947 display: grid;
  948 +
322 grid-template-columns: 1fr 1fr; 949 grid-template-columns: 1fr 1fr;
  950 +
323 gap: 20rpx; 951 gap: 20rpx;
  952 +
324 margin-bottom: 32rpx; 953 margin-bottom: 32rpx;
  954 +
325 } 955 }
326 956
  957 +
  958 +
327 .metric-card { 959 .metric-card {
  960 +
328 background: #fff; 961 background: #fff;
  962 +
329 padding: 28rpx; 963 padding: 28rpx;
  964 +
330 border-radius: 20rpx; 965 border-radius: 20rpx;
  966 +
331 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); 967 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  968 +
332 border: 1rpx solid #e5e7eb; 969 border: 1rpx solid #e5e7eb;
  970 +
333 } 971 }
334 972
  973 +
  974 +
335 .metric-label { 975 .metric-label {
  976 +
336 font-size: 24rpx; 977 font-size: 24rpx;
  978 +
337 color: #6b7280; 979 color: #6b7280;
  980 +
338 display: block; 981 display: block;
  982 +
339 margin-bottom: 12rpx; 983 margin-bottom: 12rpx;
  984 +
340 line-height: 1.5; 985 line-height: 1.5;
  986 +
341 } 987 }
342 988
  989 +
  990 +
343 .metric-value { 991 .metric-value {
  992 +
344 font-size: 38rpx; 993 font-size: 38rpx;
  994 +
345 font-weight: 700; 995 font-weight: 700;
  996 +
346 color: #111827; 997 color: #111827;
  998 +
347 display: block; 999 display: block;
  1000 +
348 margin-bottom: 8rpx; 1001 margin-bottom: 8rpx;
  1002 +
349 letter-spacing: -0.02em; 1003 letter-spacing: -0.02em;
  1004 +
350 } 1005 }
351 1006
  1007 +
  1008 +
352 .metric-trend { 1009 .metric-trend {
  1010 +
353 font-size: 24rpx; 1011 font-size: 24rpx;
  1012 +
354 color: #6b7280; 1013 color: #6b7280;
  1014 +
355 } 1015 }
356 1016
  1017 +
  1018 +
357 .metric-trend.up { 1019 .metric-trend.up {
  1020 +
358 color: #16a34a; 1021 color: #16a34a;
  1022 +
  1023 + font-weight: 500;
  1024 +
  1025 +}
  1026 +
  1027 +
  1028 +
  1029 +.metric-trend.down {
  1030 +
  1031 + color: #dc2626;
  1032 +
359 font-weight: 500; 1033 font-weight: 500;
  1034 +
360 } 1035 }
361 1036
  1037 +
  1038 +
362 .section { 1039 .section {
  1040 +
363 margin-bottom: 32rpx; 1041 margin-bottom: 32rpx;
  1042 +
364 } 1043 }
365 1044
  1045 +
  1046 +
366 .section-title { 1047 .section-title {
  1048 +
367 font-size: 32rpx; 1049 font-size: 32rpx;
  1050 +
368 font-weight: 600; 1051 font-weight: 600;
  1052 +
369 color: #111827; 1053 color: #111827;
  1054 +
370 display: block; 1055 display: block;
  1056 +
371 margin-bottom: 8rpx; 1057 margin-bottom: 8rpx;
  1058 +
372 } 1059 }
373 1060
  1061 +
  1062 +
374 .section-desc { 1063 .section-desc {
  1064 +
375 font-size: 26rpx; 1065 font-size: 26rpx;
  1066 +
376 color: #6b7280; 1067 color: #6b7280;
  1068 +
377 display: block; 1069 display: block;
  1070 +
378 margin-bottom: 24rpx; 1071 margin-bottom: 24rpx;
  1072 +
379 line-height: 1.5; 1073 line-height: 1.5;
  1074 +
  1075 +}
  1076 +
  1077 +
  1078 +
  1079 +.empty-panel {
  1080 +
  1081 + background: #fff;
  1082 +
  1083 + padding: 48rpx 28rpx;
  1084 +
  1085 + border-radius: 20rpx;
  1086 +
  1087 + border: 1rpx solid #e5e7eb;
  1088 +
  1089 + text-align: center;
  1090 +
  1091 +}
  1092 +
  1093 +
  1094 +
  1095 +.empty-text {
  1096 +
  1097 + font-size: 26rpx;
  1098 +
  1099 + color: #9ca3af;
  1100 +
380 } 1101 }
381 1102
  1103 +
  1104 +
382 .bar-chart { 1105 .bar-chart {
  1106 +
383 background: #fff; 1107 background: #fff;
  1108 +
384 padding: 28rpx; 1109 padding: 28rpx;
  1110 +
385 border-radius: 20rpx; 1111 border-radius: 20rpx;
  1112 +
386 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); 1113 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1114 +
387 border: 1rpx solid #e5e7eb; 1115 border: 1rpx solid #e5e7eb;
  1116 +
388 } 1117 }
389 1118
  1119 +
  1120 +
390 .bar-row { 1121 .bar-row {
  1122 +
391 display: flex; 1123 display: flex;
  1124 +
392 align-items: center; 1125 align-items: center;
  1126 +
393 gap: 16rpx; 1127 gap: 16rpx;
  1128 +
394 margin-bottom: 24rpx; 1129 margin-bottom: 24rpx;
  1130 +
395 } 1131 }
396 1132
  1133 +
  1134 +
397 .bar-row:last-child { 1135 .bar-row:last-child {
  1136 +
398 margin-bottom: 0; 1137 margin-bottom: 0;
  1138 +
399 } 1139 }
400 1140
  1141 +
  1142 +
401 .bar-label { 1143 .bar-label {
  1144 +
402 width: 120rpx; 1145 width: 120rpx;
  1146 +
403 font-size: 26rpx; 1147 font-size: 26rpx;
  1148 +
404 color: #374151; 1149 color: #374151;
  1150 +
405 flex-shrink: 0; 1151 flex-shrink: 0;
  1152 +
406 font-weight: 500; 1153 font-weight: 500;
  1154 +
407 } 1155 }
408 1156
  1157 +
  1158 +
409 .bar-track { 1159 .bar-track {
  1160 +
410 flex: 1; 1161 flex: 1;
  1162 +
411 height: 36rpx; 1163 height: 36rpx;
  1164 +
412 background: #f3f4f6; 1165 background: #f3f4f6;
  1166 +
413 border-radius: 10rpx; 1167 border-radius: 10rpx;
  1168 +
414 overflow: hidden; 1169 overflow: hidden;
  1170 +
415 } 1171 }
416 1172
  1173 +
  1174 +
417 .bar-fill { 1175 .bar-fill {
  1176 +
418 height: 100%; 1177 height: 100%;
  1178 +
419 background: linear-gradient(90deg, #1F3A8A, #1447E6); 1179 background: linear-gradient(90deg, #1F3A8A, #1447E6);
  1180 +
420 border-radius: 10rpx; 1181 border-radius: 10rpx;
  1182 +
421 transition: width 0.3s ease; 1183 transition: width 0.3s ease;
  1184 +
422 } 1185 }
423 1186
  1187 +
  1188 +
424 .bar-value { 1189 .bar-value {
  1190 +
425 width: 88rpx; 1191 width: 88rpx;
  1192 +
426 text-align: right; 1193 text-align: right;
  1194 +
427 font-size: 26rpx; 1195 font-size: 26rpx;
  1196 +
428 font-weight: 600; 1197 font-weight: 600;
  1198 +
429 color: #111827; 1199 color: #111827;
  1200 +
430 } 1201 }
431 1202
  1203 +
  1204 +
432 .line-chart { 1205 .line-chart {
  1206 +
433 background: #fff; 1207 background: #fff;
  1208 +
434 padding: 28rpx; 1209 padding: 28rpx;
  1210 +
435 border-radius: 20rpx; 1211 border-radius: 20rpx;
  1212 +
436 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); 1213 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1214 +
437 border: 1rpx solid #e5e7eb; 1215 border: 1rpx solid #e5e7eb;
  1216 +
438 } 1217 }
439 1218
  1219 +
  1220 +
440 .chart-bars { 1221 .chart-bars {
  1222 +
441 display: flex; 1223 display: flex;
  1224 +
442 align-items: flex-end; 1225 align-items: flex-end;
  1226 +
443 justify-content: space-between; 1227 justify-content: space-between;
444 - gap: 12rpx; 1228 +
  1229 + gap: 4rpx;
  1230 +
445 height: 220rpx; 1231 height: 220rpx;
  1232 +
446 margin-bottom: 20rpx; 1233 margin-bottom: 20rpx;
  1234 +
447 } 1235 }
448 1236
  1237 +
  1238 +
449 .day-bar-wrap { 1239 .day-bar-wrap {
  1240 +
450 flex: 1; 1241 flex: 1;
  1242 +
451 display: flex; 1243 display: flex;
  1244 +
452 align-items: flex-end; 1245 align-items: flex-end;
  1246 +
453 justify-content: center; 1247 justify-content: center;
  1248 +
  1249 + min-width: 0;
  1250 +
454 } 1251 }
455 1252
  1253 +
  1254 +
456 .day-bar { 1255 .day-bar {
  1256 +
457 width: 100%; 1257 width: 100%;
  1258 +
458 max-width: 52rpx; 1259 max-width: 52rpx;
  1260 +
459 min-height: 12rpx; 1261 min-height: 12rpx;
  1262 +
460 background: linear-gradient(180deg, #1F3A8A, #1447E6); 1263 background: linear-gradient(180deg, #1F3A8A, #1447E6);
  1264 +
461 border-radius: 10rpx 10rpx 0 0; 1265 border-radius: 10rpx 10rpx 0 0;
  1266 +
462 transition: height 0.3s ease; 1267 transition: height 0.3s ease;
  1268 +
463 } 1269 }
464 1270
  1271 +
  1272 +
465 .chart-labels { 1273 .chart-labels {
  1274 +
466 display: flex; 1275 display: flex;
  1276 +
467 justify-content: space-between; 1277 justify-content: space-between;
468 - gap: 12rpx; 1278 +
  1279 + gap: 4rpx;
  1280 +
469 } 1281 }
470 1282
  1283 +
  1284 +
471 .day-label { 1285 .day-label {
  1286 +
472 flex: 1; 1287 flex: 1;
473 - padding: 0 4rpx;  
474 - font-size: 24rpx; 1288 +
  1289 + min-width: 0;
  1290 +
  1291 + padding: 0 2rpx;
  1292 +
  1293 + font-size: 20rpx;
  1294 +
475 color: #6b7280; 1295 color: #6b7280;
  1296 +
476 text-align: center; 1297 text-align: center;
  1298 +
  1299 + overflow: hidden;
  1300 +
  1301 + text-overflow: ellipsis;
  1302 +
  1303 + white-space: nowrap;
  1304 +
477 } 1305 }
478 1306
  1307 +
  1308 +
479 .product-table { 1309 .product-table {
  1310 +
480 background: #fff; 1311 background: #fff;
  1312 +
481 border-radius: 20rpx; 1313 border-radius: 20rpx;
  1314 +
482 overflow: hidden; 1315 overflow: hidden;
  1316 +
483 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); 1317 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1318 +
484 border: 1rpx solid #e5e7eb; 1319 border: 1rpx solid #e5e7eb;
  1320 +
485 } 1321 }
486 1322
  1323 +
  1324 +
487 .product-row { 1325 .product-row {
  1326 +
488 display: flex; 1327 display: flex;
  1328 +
489 padding: 24rpx 28rpx; 1329 padding: 24rpx 28rpx;
  1330 +
490 border-bottom: 1rpx solid #e5e7eb; 1331 border-bottom: 1rpx solid #e5e7eb;
  1332 +
491 font-size: 28rpx; 1333 font-size: 28rpx;
  1334 +
492 } 1335 }
493 1336
  1337 +
  1338 +
494 .product-row:last-child { 1339 .product-row:last-child {
  1340 +
495 border-bottom: none; 1341 border-bottom: none;
  1342 +
496 } 1343 }
497 1344
  1345 +
  1346 +
498 .product-row.header { 1347 .product-row.header {
  1348 +
499 background: #fafbfc; 1349 background: #fafbfc;
  1350 +
500 font-weight: 600; 1351 font-weight: 600;
  1352 +
501 color: #6b7280; 1353 color: #6b7280;
  1354 +
502 font-size: 24rpx; 1355 font-size: 24rpx;
  1356 +
  1357 +}
  1358 +
  1359 +
  1360 +
  1361 +.product-row.empty-row {
  1362 +
  1363 + justify-content: center;
  1364 +
  1365 +}
  1366 +
  1367 +
  1368 +
  1369 +.empty-row-text {
  1370 +
  1371 + font-size: 26rpx;
  1372 +
  1373 + color: #9ca3af;
  1374 +
503 } 1375 }
504 1376
  1377 +
  1378 +
505 .col-name { flex: 1; min-width: 0; text-align: left; } 1379 .col-name { flex: 1; min-width: 0; text-align: left; }
  1380 +
506 .col-cat { flex: 0 0 140rpx; text-align: left; } 1381 .col-cat { flex: 0 0 140rpx; text-align: left; }
  1382 +
507 .col-total { flex: 0 0 120rpx; text-align: left; } 1383 .col-total { flex: 0 0 120rpx; text-align: left; }
  1384 +
508 .col-pct { flex: 0 0 88rpx; text-align: left; } 1385 .col-pct { flex: 0 0 88rpx; text-align: left; }
509 1386
  1387 +
  1388 +
510 .product-row:not(.header) .col-name { color: #111827; font-weight: 500; } 1389 .product-row:not(.header) .col-name { color: #111827; font-weight: 500; }
  1390 +
511 .product-row:not(.header) .col-cat { color: #6b7280; } 1391 .product-row:not(.header) .col-cat { color: #6b7280; }
  1392 +
512 .product-row:not(.header) .col-total { color: #111827; } 1393 .product-row:not(.header) .col-total { color: #111827; }
  1394 +
513 .product-row:not(.header) .col-pct { color: #1F3A8A; font-weight: 600; } 1395 .product-row:not(.header) .col-pct { color: #1F3A8A; font-weight: 600; }
  1396 +
514 </style> 1397 </style>
  1398 +
泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
@@ -53,7 +53,7 @@ @@ -53,7 +53,7 @@
53 class="log-card" 53 class="log-card"
54 > 54 >
55 <view class="card-header"> 55 <view class="card-header">
56 - <text class="product-name">{{ row.productName || '无' }}</text> 56 + <text class="product-name">{{ displayField(row.productName) }}</text>
57 <text class="label-id">{{ shortRef(row) }}</text> 57 <text class="label-id">{{ shortRef(row) }}</text>
58 </view> 58 </view>
59 <view class="card-tags"> 59 <view class="card-tags">
@@ -67,11 +67,11 @@ @@ -67,11 +67,11 @@
67 </view> 67 </view>
68 <view class="detail-row"> 68 <view class="detail-row">
69 <AppIcon name="user" size="sm" color="gray" /> 69 <AppIcon name="user" size="sm" color="gray" />
70 - <text class="detail-text">{{ row.operatorName || '无' }}</text> 70 + <text class="detail-text">{{ displayField(row.operatorName) }}</text>
71 </view> 71 </view>
72 <view class="detail-row"> 72 <view class="detail-row">
73 <AppIcon name="mapPin" size="sm" color="gray" /> 73 <AppIcon name="mapPin" size="sm" color="gray" />
74 - <text class="detail-text">{{ row.locationName || '无' }}</text> 74 + <text class="detail-text">{{ displayField(row.locationName) }}</text>
75 </view> 75 </view>
76 </view> 76 </view>
77 <view class="card-footer"> 77 <view class="card-footer">
@@ -105,7 +105,7 @@ @@ -105,7 +105,7 @@
105 :key="row.taskId + '-' + row.copyIndex" 105 :key="row.taskId + '-' + row.copyIndex"
106 class="log-table-row" 106 class="log-table-row"
107 > 107 >
108 - <text class="td td-product">{{ row.productName || '无' }}</text> 108 + <text class="td td-product">{{ displayField(row.productName) }}</text>
109 <text class="td td-id">{{ shortRef(row) }}</text> 109 <text class="td td-id">{{ shortRef(row) }}</text>
110 <text class="td td-size">{{ tagLabelSize(row) }}</text> 110 <text class="td td-size">{{ tagLabelSize(row) }}</text>
111 <text class="td td-type">{{ tagTypeName(row) }}</text> 111 <text class="td td-type">{{ tagTypeName(row) }}</text>
@@ -157,6 +157,7 @@ import { @@ -157,6 +157,7 @@ import {
157 } from '../../utils/print/printerConnection' 157 } from '../../utils/print/printerConnection'
158 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' 158 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
159 import type { PrintLogItemDto } from '../../types/usAppLabeling' 159 import type { PrintLogItemDto } from '../../types/usAppLabeling'
  160 +import { formatDisplayText } from '../../utils/emptyDisplay'
160 161
161 const statusBarHeight = getStatusBarHeight() 162 const statusBarHeight = getStatusBarHeight()
162 const isMenuOpen = ref(false) 163 const isMenuOpen = ref(false)
@@ -208,6 +209,10 @@ function createClientRequestId (): string { @@ -208,6 +209,10 @@ function createClientRequestId (): string {
208 return `reprint-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` 209 return `reprint-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
209 } 210 }
210 211
  212 +function displayField (value: string | null | undefined): string {
  213 + return formatDisplayText(value, 'None')
  214 +}
  215 +
211 function shortRef (row: PrintLogItemDto): string { 216 function shortRef (row: PrintLogItemDto): string {
212 const b = String(row.batchId || row.taskId || '').trim() 217 const b = String(row.batchId || row.taskId || '').trim()
213 if (b.length > 14) return `${b.slice(0, 8)}…` 218 if (b.length > 14) return `${b.slice(0, 8)}…`
@@ -218,14 +223,14 @@ function shortRef (row: PrintLogItemDto): string { @@ -218,14 +223,14 @@ function shortRef (row: PrintLogItemDto): string {
218 function tagLabelSize (row: PrintLogItemDto): string { 223 function tagLabelSize (row: PrintLogItemDto): string {
219 const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText 224 const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText
220 const s = String(raw ?? '').trim() 225 const s = String(raw ?? '').trim()
221 - return s || '无' 226 + return formatDisplayText(s, 'None')
222 } 227 }
223 228
224 /** 红框右:typeName(兼容 PascalCase) */ 229 /** 红框右:typeName(兼容 PascalCase) */
225 function tagTypeName (row: PrintLogItemDto): string { 230 function tagTypeName (row: PrintLogItemDto): string {
226 const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName 231 const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName
227 const s = String(raw ?? '').trim() 232 const s = String(raw ?? '').trim()
228 - return s || '无' 233 + return formatDisplayText(s, 'None')
229 } 234 }
230 235
231 async function loadPage (reset: boolean) { 236 async function loadPage (reset: boolean) {
泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue
@@ -109,8 +109,8 @@ @@ -109,8 +109,8 @@
109 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text> 109 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text>
110 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text> 110 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text>
111 <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text> 111 <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text>
112 - <text class="tips-item">4. Devices with no name show as "Unknown Device"—you can still connect</text>  
113 - <text class="tips-item">5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering</text> 112 + <text class="tips-item">4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer</text>
  113 + <text class="tips-item">5. Pair the printer in system Bluetooth first if it does not appear after Scan</text>
114 <text class="tips-item">6. Restart the printer or app if not visible</text> 114 <text class="tips-item">6. Restart the printer or app if not visible</text>
115 <text class="tips-item tips-item-last">7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only.</text> 115 <text class="tips-item tips-item-last">7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only.</text>
116 </view> 116 </view>
@@ -173,6 +173,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39; @@ -173,6 +173,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39;
173 import { getDeviceIdentity } from '../../utils/deviceInfo' 173 import { getDeviceIdentity } from '../../utils/deviceInfo'
174 import classicBluetooth from '../../utils/print/bluetoothTool.js' 174 import classicBluetooth from '../../utils/print/bluetoothTool.js'
175 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' 175 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
  176 +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist'
176 import { getAvailablePrinterTypes } from '../../utils/print/printerConnection' 177 import { getAvailablePrinterTypes } from '../../utils/print/printerConnection'
177 import { 178 import {
178 connectBluetoothPrinter, 179 connectBluetoothPrinter,
@@ -269,14 +270,12 @@ const normalizeDeviceName = (device: any) =&gt; { @@ -269,14 +270,12 @@ const normalizeDeviceName = (device: any) =&gt; {
269 } 270 }
270 271
271 const hasPreferredClassicDevice = () => { 272 const hasPreferredClassicDevice = () => {
272 - return pairedDevices.value.some((item: any) => {  
273 - const name = String(item?.name || '').toLowerCase()  
274 - const driverKey = String(item?.driverKey || '').toLowerCase()  
275 - return name.includes('virtual bt printer') || driverKey === 'd320fax'  
276 - }) 273 + return pairedDevices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name))
277 } 274 }
278 275
279 const addDeviceDedup = (device: any) => { 276 const addDeviceDedup = (device: any) => {
  277 + const displayName = normalizeDeviceName(device)
  278 + if (!isAllowedBluetoothPrinterName(displayName)) return
280 const described = describeDiscoveredPrinter(device) 279 const described = describeDiscoveredPrinter(device)
281 const existing = devices.value.find(d => d.deviceId === device.deviceId) 280 const existing = devices.value.find(d => d.deviceId === device.deviceId)
282 if (!existing) { 281 if (!existing) {
@@ -291,11 +290,15 @@ const loadPairedDevices = () =&gt; { @@ -291,11 +290,15 @@ const loadPairedDevices = () =&gt; {
291 try { 290 try {
292 const list = classicBluetooth.getPairedDevices() 291 const list = classicBluetooth.getPairedDevices()
293 debugInfo.value.pairedCount = (list || []).length 292 debugInfo.value.pairedCount = (list || []).length
294 - debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer'))  
295 - pairedDevices.value = (list || []).map((item: any) => ({  
296 - ...describeDiscoveredPrinter(item),  
297 - name: normalizeDeviceName(item),  
298 - })) 293 + debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) =>
  294 + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'),
  295 + )
  296 + pairedDevices.value = (list || [])
  297 + .map((item: any) => ({
  298 + ...describeDiscoveredPrinter(item),
  299 + name: normalizeDeviceName(item),
  300 + }))
  301 + .filter((item: any) => isAllowedBluetoothPrinterName(item?.name))
299 debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices' 302 debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices'
300 } catch (e) { 303 } catch (e) {
301 console.error('Failed to load paired devices', e) 304 console.error('Failed to load paired devices', e)
泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue
@@ -84,6 +84,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39; @@ -84,6 +84,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39;
84 import LocationPicker from '../../components/LocationPicker.vue' 84 import LocationPicker from '../../components/LocationPicker.vue'
85 import { getStatusBarHeight } from '../../utils/statusBar' 85 import { getStatusBarHeight } from '../../utils/statusBar'
86 import { usAppFetchMyProfile } from '../../services/usAppAuth' 86 import { usAppFetchMyProfile } from '../../services/usAppAuth'
  87 +import { formatDisplayText } from '../../utils/emptyDisplay'
87 88
88 const { t } = useI18n() 89 const { t } = useI18n()
89 const statusBarHeight = getStatusBarHeight() 90 const statusBarHeight = getStatusBarHeight()
@@ -98,11 +99,11 @@ async function loadProfile() { @@ -98,11 +99,11 @@ async function loadProfile() {
98 uni.showLoading({ title: 'Loading...', mask: true }) 99 uni.showLoading({ title: 'Loading...', mask: true })
99 try { 100 try {
100 const p = await usAppFetchMyProfile() 101 const p = await usAppFetchMyProfile()
101 - name.value = p.fullName?.trim() ? p.fullName : '—'  
102 - email.value = p.email?.trim() ? p.email : '—'  
103 - phone.value = p.phone?.trim() ? p.phone : '—'  
104 - employeeId.value = p.employeeId?.trim() ? p.employeeId : '—'  
105 - roleDisplay.value = p.roleDisplay?.trim() ? p.roleDisplay : '—' 102 + name.value = formatDisplayText(p.fullName, '—')
  103 + email.value = formatDisplayText(p.email, '—')
  104 + phone.value = formatDisplayText(p.phone, '—')
  105 + employeeId.value = formatDisplayText(p.employeeId, '—')
  106 + roleDisplay.value = formatDisplayText(p.roleDisplay, '—')
106 if (p.fullName?.trim()) { 107 if (p.fullName?.trim()) {
107 uni.setStorageSync('userName', p.fullName.trim()) 108 uni.setStorageSync('userName', p.fullName.trim())
108 } 109 }
泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
@@ -11,8 +11,55 @@ @@ -11,8 +11,55 @@
11 </view> 11 </view>
12 </view> 12 </view>
13 13
  14 + <view v-if="isAdminUser" class="scope-panel">
  15 + <view class="scope-section">
  16 + <text class="scope-label">{{ t('login.scopeCompany') }} *</text>
  17 + <view v-if="scopeLoading && !companies.length" class="scope-hint">
  18 + <text class="scope-hint-text">{{ t('login.scopeLoading') }}</text>
  19 + </view>
  20 + <view v-else-if="!companies.length" class="scope-hint">
  21 + <text class="scope-hint-text">{{ t('login.scopeNoCompanies') }}</text>
  22 + </view>
  23 + <view v-else class="scope-chips">
  24 + <view
  25 + v-for="c in companies"
  26 + :key="c.id"
  27 + class="scope-chip"
  28 + :class="{ active: selectedPartnerId === c.id }"
  29 + @click="onSelectCompany(c.id)"
  30 + >
  31 + <text class="scope-chip-text">{{ c.partnerName }}</text>
  32 + </view>
  33 + </view>
  34 + </view>
  35 +
  36 + <view v-if="selectedPartnerId" class="scope-section">
  37 + <text class="scope-label">{{ t('login.scopeRegion') }} *</text>
  38 + <view v-if="regionsLoading" class="scope-hint">
  39 + <text class="scope-hint-text">{{ t('login.scopeLoading') }}</text>
  40 + </view>
  41 + <view v-else-if="!regions.length" class="scope-hint">
  42 + <text class="scope-hint-text">{{ t('login.scopeNoRegions') }}</text>
  43 + </view>
  44 + <view v-else class="scope-chips">
  45 + <view
  46 + v-for="r in regions"
  47 + :key="r.id"
  48 + class="scope-chip"
  49 + :class="{ active: selectedGroupId === r.id }"
  50 + @click="onSelectRegion(r.id)"
  51 + >
  52 + <text class="scope-chip-text">{{ r.groupName }}</text>
  53 + </view>
  54 + </view>
  55 + </view>
  56 + </view>
  57 +
14 <view class="list"> 58 <view class="list">
15 - <view v-if="!loading && stores.length === 0" class="empty-hint"> 59 + <view v-if="isAdminUser && !selectedGroupId" class="empty-hint">
  60 + <text class="empty-text">{{ t('login.selectCompanyRegionFirst') }}</text>
  61 + </view>
  62 + <view v-else-if="!loading && stores.length === 0" class="empty-hint">
16 <text class="empty-text">{{ t('login.noStoresBound') }}</text> 63 <text class="empty-text">{{ t('login.noStoresBound') }}</text>
17 </view> 64 </view>
18 <view 65 <view
@@ -51,22 +98,18 @@ @@ -51,22 +98,18 @@
51 </view> 98 </view>
52 99
53 <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> 100 <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }">
54 - <view v-if="!loading && stores.length === 0" class="bottom-actions-row"> 101 + <view class="bottom-actions-row">
55 <view class="back-btn" @click="handleBackToLogin"> 102 <view class="back-btn" @click="handleBackToLogin">
56 - <text class="back-btn-text">{{ t('common.back') }}</text> 103 + <text class="back-btn-text">{{ t('login.backToSignIn') }}</text>
57 </view> 104 </view>
58 - <view class="confirm-btn disabled">  
59 - <text class="confirm-btn-text">{{ t('common.confirm') }}</text> 105 + <view
  106 + class="confirm-btn"
  107 + :class="{ disabled: !canConfirm }"
  108 + @click="handleConfirm"
  109 + >
  110 + <text class="confirm-btn-text">{{ loading || confirming ? '…' : t('common.confirm') }}</text>
60 </view> 111 </view>
61 </view> 112 </view>
62 - <view  
63 - v-else  
64 - class="confirm-btn"  
65 - :class="{ disabled: !selectedStore || loading }"  
66 - @click="handleConfirm"  
67 - >  
68 - <text class="confirm-btn-text">{{ loading ? '…' : t('common.confirm') }}</text>  
69 - </view>  
70 </view> 113 </view>
71 </view> 114 </view>
72 </template> 115 </template>
@@ -77,9 +120,18 @@ import { useI18n } from &#39;vue-i18n&#39; @@ -77,9 +120,18 @@ import { useI18n } from &#39;vue-i18n&#39;
77 import { onShow } from '@dcloudio/uni-app' 120 import { onShow } from '@dcloudio/uni-app'
78 import AppIcon from '../../components/AppIcon.vue' 121 import AppIcon from '../../components/AppIcon.vue'
79 import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' 122 import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar'
80 -import { usAppFetchMyLocations } from '../../services/usAppAuth' 123 +import {
  124 + usAppFetchAdminScopeCompanies,
  125 + usAppFetchAdminScopeLocations,
  126 + usAppFetchAdminScopeRegions,
  127 + usAppFetchMyLocations,
  128 + usAppFetchMyProfile,
  129 + usAppSelectAdminScopeLocation,
  130 +} from '../../services/usAppAuth'
  131 +import type { AuthScopeCompanyOption, AuthScopeRegionOption } from '../../types/usAppAdminScope'
81 import type { UsAppBoundLocationDto } from '../../types/usAppBound' 132 import type { UsAppBoundLocationDto } from '../../types/usAppBound'
82 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' 133 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  134 +import { isAppAdminUser } from '../../utils/appAdminRole'
83 import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession' 135 import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession'
84 import { switchStore } from '../../utils/stores' 136 import { switchStore } from '../../utils/stores'
85 137
@@ -90,6 +142,15 @@ const userName = computed(() =&gt; uni.getStorageSync(&#39;userName&#39;) || &#39;Employee&#39;) @@ -90,6 +142,15 @@ const userName = computed(() =&gt; uni.getStorageSync(&#39;userName&#39;) || &#39;Employee&#39;)
90 const selectedStore = ref('') 142 const selectedStore = ref('')
91 const stores = ref<UsAppBoundLocationDto[]>([]) 143 const stores = ref<UsAppBoundLocationDto[]>([])
92 const loading = ref(false) 144 const loading = ref(false)
  145 +const confirming = ref(false)
  146 +
  147 +const isAdminUser = ref(false)
  148 +const scopeLoading = ref(false)
  149 +const regionsLoading = ref(false)
  150 +const companies = ref<AuthScopeCompanyOption[]>([])
  151 +const regions = ref<AuthScopeRegionOption[]>([])
  152 +const selectedPartnerId = ref('')
  153 +const selectedGroupId = ref('')
93 154
94 function applyList(list: UsAppBoundLocationDto[]) { 155 function applyList(list: UsAppBoundLocationDto[]) {
95 const enabled = list.filter((s) => s.state !== false) 156 const enabled = list.filter((s) => s.state !== false)
@@ -97,7 +158,7 @@ function applyList(list: UsAppBoundLocationDto[]) { @@ -97,7 +158,7 @@ function applyList(list: UsAppBoundLocationDto[]) {
97 setBoundLocations(enabled) 158 setBoundLocations(enabled)
98 } 159 }
99 160
100 -async function refreshFromApi() { 161 +async function refreshEmployeeStores() {
101 loading.value = true 162 loading.value = true
102 try { 163 try {
103 const list = await usAppFetchMyLocations() 164 const list = await usAppFetchMyLocations()
@@ -111,13 +172,117 @@ async function refreshFromApi() { @@ -111,13 +172,117 @@ async function refreshFromApi() {
111 } 172 }
112 } 173 }
113 174
  175 +async function loadCompanies() {
  176 + scopeLoading.value = true
  177 + try {
  178 + companies.value = await usAppFetchAdminScopeCompanies()
  179 + } catch (e) {
  180 + if (isUsAppSessionExpiredError(e)) return
  181 + companies.value = []
  182 + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' })
  183 + } finally {
  184 + scopeLoading.value = false
  185 + }
  186 +}
  187 +
  188 +async function loadRegions(partnerId: string) {
  189 + regionsLoading.value = true
  190 + regions.value = []
  191 + try {
  192 + regions.value = await usAppFetchAdminScopeRegions(partnerId)
  193 + } catch (e) {
  194 + if (isUsAppSessionExpiredError(e)) return
  195 + regions.value = []
  196 + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' })
  197 + } finally {
  198 + regionsLoading.value = false
  199 + }
  200 +}
  201 +
  202 +async function loadAdminLocations(partnerId: string, groupId: string) {
  203 + loading.value = true
  204 + selectedStore.value = ''
  205 + try {
  206 + const list = await usAppFetchAdminScopeLocations(partnerId, groupId)
  207 + applyList(
  208 + list.map((x) => ({
  209 + id: x.id,
  210 + locationCode: x.locationCode,
  211 + locationName: x.locationName,
  212 + fullAddress: x.fullAddress,
  213 + state: x.state,
  214 + })),
  215 + )
  216 + } catch (e) {
  217 + if (isUsAppSessionExpiredError(e)) return
  218 + applyList([])
  219 + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' })
  220 + } finally {
  221 + loading.value = false
  222 + }
  223 +}
  224 +
  225 +function onSelectCompany(partnerId: string) {
  226 + const id = partnerId.trim()
  227 + if (selectedPartnerId.value === id) return
  228 + selectedPartnerId.value = id
  229 + selectedGroupId.value = ''
  230 + selectedStore.value = ''
  231 + stores.value = []
  232 + regions.value = []
  233 + if (id) loadRegions(id)
  234 +}
  235 +
  236 +function onSelectRegion(groupId: string) {
  237 + const gid = groupId.trim()
  238 + if (selectedGroupId.value === gid) return
  239 + selectedGroupId.value = gid
  240 + selectedStore.value = ''
  241 + if (gid && selectedPartnerId.value) {
  242 + loadAdminLocations(selectedPartnerId.value, gid)
  243 + } else {
  244 + stores.value = []
  245 + }
  246 +}
  247 +
  248 +async function initPage() {
  249 + loading.value = true
  250 + try {
  251 + const profile = await usAppFetchMyProfile()
  252 + isAdminUser.value = isAppAdminUser(profile)
  253 + if (isAdminUser.value) {
  254 + applyList([])
  255 + await loadCompanies()
  256 + } else {
  257 + await refreshEmployeeStores()
  258 + }
  259 + } catch (e) {
  260 + if (isUsAppSessionExpiredError(e)) return
  261 + isAdminUser.value = false
  262 + applyList(getBoundLocations())
  263 + await refreshEmployeeStores()
  264 + } finally {
  265 + loading.value = false
  266 + }
  267 +}
  268 +
114 onMounted(() => { 269 onMounted(() => {
115 applyList(getBoundLocations()) 270 applyList(getBoundLocations())
116 - refreshFromApi() 271 + initPage()
117 }) 272 })
118 273
119 onShow(() => { 274 onShow(() => {
120 - applyList(getBoundLocations()) 275 + if (!isAdminUser.value) {
  276 + applyList(getBoundLocations())
  277 + }
  278 +})
  279 +
  280 +const canConfirm = computed(() => {
  281 + if (loading.value || confirming.value || !selectedStore.value) return false
  282 + if (isAdminUser.value) {
  283 + return !!(selectedPartnerId.value && selectedGroupId.value)
  284 + }
  285 + return stores.value.length > 0
121 }) 286 })
122 287
123 const handleBackToLogin = () => { 288 const handleBackToLogin = () => {
@@ -125,20 +290,43 @@ const handleBackToLogin = () =&gt; { @@ -125,20 +290,43 @@ const handleBackToLogin = () =&gt; {
125 uni.redirectTo({ url: '/pages/login/login' }) 290 uni.redirectTo({ url: '/pages/login/login' })
126 } 291 }
127 292
128 -const handleConfirm = () => {  
129 - if (loading.value || !selectedStore.value) { 293 +const handleConfirm = async () => {
  294 + if (!canConfirm.value) {
130 if (!selectedStore.value) { 295 if (!selectedStore.value) {
131 uni.showToast({ title: t('login.selectStoreError'), icon: 'none' }) 296 uni.showToast({ title: t('login.selectStoreError'), icon: 'none' })
  297 + } else if (isAdminUser.value && (!selectedPartnerId.value || !selectedGroupId.value)) {
  298 + uni.showToast({ title: t('login.selectCompanyRegionFirst'), icon: 'none' })
132 } 299 }
133 return 300 return
134 } 301 }
135 const store = stores.value.find((s) => s.id === selectedStore.value) 302 const store = stores.value.find((s) => s.id === selectedStore.value)
136 if (!store) return 303 if (!store) return
137 - switchStore(store.id, store.locationName, store.locationCode)  
138 - uni.showToast({ title: t('login.storeSelected'), icon: 'success' })  
139 - setTimeout(() => {  
140 - uni.redirectTo({ url: '/pages/index/index' })  
141 - }, 400) 304 +
  305 + confirming.value = true
  306 + try {
  307 + if (isAdminUser.value) {
  308 + const res = await usAppSelectAdminScopeLocation({
  309 + partnerId: selectedPartnerId.value,
  310 + groupId: selectedGroupId.value,
  311 + locationId: store.id,
  312 + })
  313 + const loc = res.location ?? store
  314 + setBoundLocations([loc])
  315 + switchStore(loc.id, loc.locationName, loc.locationCode)
  316 + } else {
  317 + switchStore(store.id, store.locationName, store.locationCode)
  318 + }
  319 + uni.showToast({ title: t('login.storeSelected'), icon: 'success' })
  320 + setTimeout(() => {
  321 + uni.redirectTo({ url: '/pages/index/index' })
  322 + }, 400)
  323 + } catch (e: unknown) {
  324 + if (isUsAppSessionExpiredError(e)) return
  325 + const msg = e instanceof Error ? e.message : String(e)
  326 + uni.showToast({ title: msg || t('login.scopeSelectFail'), icon: 'none' })
  327 + } finally {
  328 + confirming.value = false
  329 + }
142 } 330 }
143 </script> 331 </script>
144 332
@@ -187,11 +375,67 @@ const handleConfirm = () =&gt; { @@ -187,11 +375,67 @@ const handleConfirm = () =&gt; {
187 color: rgba(255, 255, 255, 0.85); 375 color: rgba(255, 255, 255, 0.85);
188 } 376 }
189 377
  378 +.scope-panel {
  379 + flex-shrink: 0;
  380 + padding: 24rpx 32rpx 0;
  381 + background: #f9fafb;
  382 +}
  383 +
  384 +.scope-section {
  385 + margin-bottom: 20rpx;
  386 +}
  387 +
  388 +.scope-label {
  389 + font-size: 26rpx;
  390 + font-weight: 600;
  391 + color: #374151;
  392 + display: block;
  393 + margin-bottom: 12rpx;
  394 +}
  395 +
  396 +.scope-hint {
  397 + padding: 16rpx 0;
  398 +}
  399 +
  400 +.scope-hint-text {
  401 + font-size: 26rpx;
  402 + color: #9ca3af;
  403 +}
  404 +
  405 +.scope-chips {
  406 + display: flex;
  407 + flex-wrap: wrap;
  408 + gap: 12rpx 16rpx;
  409 +}
  410 +
  411 +.scope-chip {
  412 + display: inline-block;
  413 + padding: 16rpx 28rpx;
  414 + background: #fff;
  415 + border-radius: 999rpx;
  416 + border: 2rpx solid #e5e7eb;
  417 +}
  418 +
  419 +.scope-chip.active {
  420 + border-color: var(--theme-primary);
  421 + background: var(--theme-primary-light);
  422 +}
  423 +
  424 +.scope-chip-text {
  425 + font-size: 26rpx;
  426 + color: #111827;
  427 +}
  428 +
  429 +.scope-chip.active .scope-chip-text {
  430 + color: var(--theme-primary);
  431 + font-weight: 600;
  432 +}
  433 +
190 .list { 434 .list {
191 flex: 1; 435 flex: 1;
192 min-height: 0; 436 min-height: 0;
193 overflow-y: auto; 437 overflow-y: auto;
194 - padding: 32rpx; 438 + padding: 24rpx 32rpx;
195 padding-bottom: 24rpx; 439 padding-bottom: 24rpx;
196 } 440 }
197 441
泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts
  1 +import type {
  2 + AuthScopeCompanyOption,
  3 + AuthScopeLocationOption,
  4 + AuthScopeRegionOption,
  5 + AuthScopeSelectLocationOutput,
  6 + UsAppSelectAdminScopeLocationInput,
  7 +} from '../types/usAppAdminScope'
1 import type { UsAppBoundLocationDto } from '../types/usAppBound' 8 import type { UsAppBoundLocationDto } from '../types/usAppBound'
2 -import { usAppApiRequest } from '../utils/usAppApiRequest' 9 +import { usAppApiRequest, unwrapApiPayload } from '../utils/usAppApiRequest'
3 import { fetchWithOfflineCache } from '../utils/sqliteSync' 10 import { fetchWithOfflineCache } from '../utils/sqliteSync'
4 11
5 /** GET /api/app/us-app-auth/my-profile → UsAppMyProfileOutputDto */ 12 /** GET /api/app/us-app-auth/my-profile → UsAppMyProfileOutputDto */
@@ -162,6 +169,115 @@ export async function usAppFetchLocationDetail(locationId: string): Promise&lt;UsAp @@ -162,6 +169,115 @@ export async function usAppFetchLocationDetail(locationId: string): Promise&lt;UsAp
162 } 169 }
163 170
164 /** POST /api/app/us-app-auth/change-password */ 171 /** POST /api/app/us-app-auth/change-password */
  172 +function normalizeCompanyOption(raw: Record<string, unknown>): AuthScopeCompanyOption {
  173 + return {
  174 + id: String(raw.id ?? raw.Id ?? '').trim(),
  175 + partnerName: String(raw.partnerName ?? raw.PartnerName ?? '').trim(),
  176 + state: raw.state !== false && raw.State !== false,
  177 + }
  178 +}
  179 +
  180 +function normalizeRegionOption(raw: Record<string, unknown>): AuthScopeRegionOption {
  181 + return {
  182 + id: String(raw.id ?? raw.Id ?? '').trim(),
  183 + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim(),
  184 + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim(),
  185 + state: raw.state !== false && raw.State !== false,
  186 + }
  187 +}
  188 +
  189 +function normalizeScopeLocationOption(raw: Record<string, unknown>): AuthScopeLocationOption {
  190 + return {
  191 + id: String(raw.id ?? raw.Id ?? '').trim(),
  192 + locationCode: String(raw.locationCode ?? raw.LocationCode ?? '').trim(),
  193 + locationName: String(raw.locationName ?? raw.LocationName ?? '').trim(),
  194 + fullAddress: String(raw.fullAddress ?? raw.FullAddress ?? '').trim(),
  195 + state: raw.state !== false && raw.State !== false,
  196 + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim() || undefined,
  197 + groupId: String(raw.groupId ?? raw.GroupId ?? '').trim() || undefined,
  198 + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim() || undefined,
  199 + }
  200 +}
  201 +
  202 +function normalizeScopeLocationList(raw: unknown): AuthScopeLocationOption[] {
  203 + const arr = Array.isArray(raw) ? raw : []
  204 + return arr
  205 + .map((x) => normalizeScopeLocationOption(x as Record<string, unknown>))
  206 + .filter((x) => x.id)
  207 +}
  208 +
  209 +/** GET /api/app/us-app-auth/admin-scope-companies */
  210 +export async function usAppFetchAdminScopeCompanies(): Promise<AuthScopeCompanyOption[]> {
  211 + const raw = await usAppApiRequest<unknown>({
  212 + path: '/api/app/us-app-auth/admin-scope-companies',
  213 + method: 'GET',
  214 + auth: true,
  215 + })
  216 + const list = unwrapApiPayload<unknown>(raw)
  217 + const arr = Array.isArray(list) ? list : []
  218 + return arr
  219 + .map((x) => normalizeCompanyOption(x as Record<string, unknown>))
  220 + .filter((x) => x.id)
  221 +}
  222 +
  223 +/** GET /api/app/us-app-auth/admin-scope-regions */
  224 +export async function usAppFetchAdminScopeRegions(partnerId: string): Promise<AuthScopeRegionOption[]> {
  225 + const pid = partnerId.trim()
  226 + const raw = await usAppApiRequest<unknown>({
  227 + path: '/api/app/us-app-auth/admin-scope-regions',
  228 + method: 'GET',
  229 + auth: true,
  230 + data: { partnerId: pid },
  231 + })
  232 + const list = unwrapApiPayload<unknown>(raw)
  233 + const arr = Array.isArray(list) ? list : []
  234 + return arr
  235 + .map((x) => normalizeRegionOption(x as Record<string, unknown>))
  236 + .filter((x) => x.id)
  237 +}
  238 +
  239 +/** GET /api/app/us-app-auth/admin-scope-locations */
  240 +export async function usAppFetchAdminScopeLocations(
  241 + partnerId: string,
  242 + groupId: string,
  243 +): Promise<AuthScopeLocationOption[]> {
  244 + const raw = await usAppApiRequest<unknown>({
  245 + path: '/api/app/us-app-auth/admin-scope-locations',
  246 + method: 'GET',
  247 + auth: true,
  248 + data: {
  249 + partnerId: partnerId.trim(),
  250 + groupId: groupId.trim(),
  251 + },
  252 + })
  253 + return normalizeScopeLocationList(unwrapApiPayload(raw))
  254 +}
  255 +
  256 +/** POST /api/app/us-app-auth/select-admin-scope-location */
  257 +export async function usAppSelectAdminScopeLocation(
  258 + input: UsAppSelectAdminScopeLocationInput,
  259 +): Promise<AuthScopeSelectLocationOutput> {
  260 + const raw = await usAppApiRequest<unknown>({
  261 + path: '/api/app/us-app-auth/select-admin-scope-location',
  262 + method: 'POST',
  263 + auth: true,
  264 + data: {
  265 + partnerId: input.partnerId.trim(),
  266 + groupId: input.groupId.trim(),
  267 + locationId: input.locationId.trim(),
  268 + },
  269 + })
  270 + const o = (unwrapApiPayload(raw) ?? {}) as Record<string, unknown>
  271 + const locRaw = (o.location ?? o.Location ?? {}) as Record<string, unknown>
  272 + return {
  273 + partnerId: String(o.partnerId ?? o.PartnerId ?? '').trim(),
  274 + partnerName: String(o.partnerName ?? o.PartnerName ?? '').trim(),
  275 + groupId: String(o.groupId ?? o.GroupId ?? '').trim(),
  276 + groupName: String(o.groupName ?? o.GroupName ?? '').trim(),
  277 + location: normalizeLocation(locRaw),
  278 + }
  279 +}
  280 +
165 export async function usAppChangePassword(input: UsAppChangePasswordInput): Promise<void> { 281 export async function usAppChangePassword(input: UsAppChangePasswordInput): Promise<void> {
166 await usAppApiRequest<unknown>({ 282 await usAppApiRequest<unknown>({
167 path: '/api/app/us-app-auth/change-password', 283 path: '/api/app/us-app-auth/change-password',
泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
@@ -6,6 +6,8 @@ import type { @@ -6,6 +6,8 @@ import type {
6 UsAppLabelPreviewInputVo, 6 UsAppLabelPreviewInputVo,
7 UsAppLabelPrintInputVo, 7 UsAppLabelPrintInputVo,
8 UsAppLabelPrintOutputDto, 8 UsAppLabelPrintOutputDto,
  9 + UsAppLabelReportOutputDto,
  10 + UsAppLabelReportQueryInputVo,
9 UsAppLabelReprintInputVo, 11 UsAppLabelReprintInputVo,
10 UsAppLabelTypeNodeDto, 12 UsAppLabelTypeNodeDto,
11 UsAppProductCategoryNodeDto, 13 UsAppProductCategoryNodeDto,
@@ -40,9 +42,21 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD @@ -40,9 +42,21 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD
40 })) 42 }))
41 return { 43 return {
42 productId: String(x?.productId ?? x?.ProductId ?? ''), 44 productId: String(x?.productId ?? x?.ProductId ?? ''),
  45 + templateId: String(x?.templateId ?? x?.TemplateId ?? '').trim() || undefined,
  46 + templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null,
  47 + templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as
  48 + | string
  49 + | null,
43 productName: String(x?.productName ?? x?.ProductName ?? ''), 50 productName: String(x?.productName ?? x?.ProductName ?? ''),
44 productCode: String(x?.productCode ?? x?.ProductCode ?? ''), 51 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
45 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, 52 productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null,
  53 + displayText: (x?.displayText ?? x?.DisplayText ?? null) as string | null,
  54 + categoryPhotoUrl: (x?.categoryPhotoUrl ?? x?.CategoryPhotoUrl ?? null) as string | null,
  55 + buttonAppearance: (x?.buttonAppearance ?? x?.ButtonAppearance ?? null) as string | null,
  56 + buttonBgColor: (x?.buttonBgColor ?? x?.ButtonBgColor ?? null) as string | null,
  57 + buttonImageUrl: (x?.buttonImageUrl ?? x?.ButtonImageUrl ?? null) as string | null,
  58 + buttonTextColor: (x?.buttonTextColor ?? x?.ButtonTextColor ?? null) as string | null,
  59 + buttonStyleJson: (x?.buttonStyleJson ?? x?.ButtonStyleJson ?? null) as string | null,
46 subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''), 60 subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''),
47 labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length), 61 labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length),
48 labelTypes, 62 labelTypes,
@@ -270,6 +284,192 @@ export async function reportUsAppLabelPrintIfReady(input: { @@ -270,6 +284,192 @@ export async function reportUsAppLabelPrintIfReady(input: {
270 return postUsAppLabelPrint(body) 284 return postUsAppLabelPrint(body)
271 } 285 }
272 286
  287 +function numField (o: Record<string, unknown>, camel: string, pascal: string, fallback = 0): number {
  288 + const v = o[camel] ?? o[pascal]
  289 + const n = Number(v)
  290 + return Number.isFinite(n) ? n : fallback
  291 +}
  292 +
  293 +function strField (o: Record<string, unknown>, camel: string, pascal: string): string {
  294 + const v = o[camel] ?? o[pascal]
  295 + return typeof v === 'string' ? v.trim() : String(v ?? '').trim()
  296 +}
  297 +
  298 +/** 规范化 Label Report 响应(camelCase / PascalCase) */
  299 +export function normalizeUsAppLabelReport (raw: unknown): UsAppLabelReportOutputDto {
  300 + const empty: UsAppLabelReportOutputDto = {
  301 + summary: {
  302 + totalLabelsPrinted: 0,
  303 + totalLabelsPrintedChangeRate: 0,
  304 + mostPrintedCategoryCount: 0,
  305 + topProductCount: 0,
  306 + avgDailyPrints: 0,
  307 + avgDailyPrintsChangeRate: 0,
  308 + },
  309 + labelsByCategory: [],
  310 + printVolumeTrend: [],
  311 + mostUsedProducts: [],
  312 + }
  313 + if (!raw || typeof raw !== 'object') return empty
  314 + const root = raw as Record<string, unknown>
  315 +
  316 + const rangeRaw = root.appliedRange ?? root.AppliedRange
  317 + let appliedRange: UsAppLabelReportOutputDto['appliedRange']
  318 + if (rangeRaw && typeof rangeRaw === 'object') {
  319 + const r = rangeRaw as Record<string, unknown>
  320 + appliedRange = {
  321 + period: strField(r, 'period', 'Period') || undefined,
  322 + startDate: strField(r, 'startDate', 'StartDate') || undefined,
  323 + endDate: strField(r, 'endDate', 'EndDate') || undefined,
  324 + dayCount: numField(r, 'dayCount', 'DayCount', 0) || undefined,
  325 + trendDescription: strField(r, 'trendDescription', 'TrendDescription') || undefined,
  326 + }
  327 + }
  328 +
  329 + const sumRaw = root.summary ?? root.Summary
  330 + const s = (sumRaw && typeof sumRaw === 'object' ? sumRaw : {}) as Record<string, unknown>
  331 + const summary = {
  332 + totalLabelsPrinted: numField(s, 'totalLabelsPrinted', 'TotalLabelsPrinted', 0),
  333 + totalLabelsPrintedPrevPeriod: numField(s, 'totalLabelsPrintedPrevPeriod', 'TotalLabelsPrintedPrevPeriod', 0),
  334 + totalLabelsPrintedChangeRate: numField(s, 'totalLabelsPrintedChangeRate', 'TotalLabelsPrintedChangeRate', 0),
  335 + mostPrintedCategoryName: strField(s, 'mostPrintedCategoryName', 'MostPrintedCategoryName') || null,
  336 + mostPrintedCategoryCount: numField(s, 'mostPrintedCategoryCount', 'MostPrintedCategoryCount', 0),
  337 + topProductName: strField(s, 'topProductName', 'TopProductName') || null,
  338 + topProductCount: numField(s, 'topProductCount', 'TopProductCount', 0),
  339 + avgDailyPrints: numField(s, 'avgDailyPrints', 'AvgDailyPrints', 0),
  340 + avgDailyPrintsPrevPeriod: numField(s, 'avgDailyPrintsPrevPeriod', 'AvgDailyPrintsPrevPeriod', 0),
  341 + avgDailyPrintsChangeRate: numField(s, 'avgDailyPrintsChangeRate', 'AvgDailyPrintsChangeRate', 0),
  342 + }
  343 +
  344 + const labelsByCategory: UsAppLabelReportOutputDto['labelsByCategory'] = []
  345 + const labelsRaw = root.labelsByCategory ?? root.LabelsByCategory
  346 + if (Array.isArray(labelsRaw)) {
  347 + for (const x of labelsRaw) {
  348 + if (!x || typeof x !== 'object') continue
  349 + const row = x as Record<string, unknown>
  350 + const name = strField(row, 'categoryName', 'CategoryName')
  351 + labelsByCategory.push({
  352 + categoryId: strField(row, 'categoryId', 'CategoryId') || null,
  353 + categoryName: name || 'Uncategorized',
  354 + count: numField(row, 'count', 'Count', 0),
  355 + })
  356 + }
  357 + }
  358 +
  359 + const printVolumeTrend: UsAppLabelReportOutputDto['printVolumeTrend'] = []
  360 + const trendRaw = root.printVolumeTrend ?? root.PrintVolumeTrend
  361 + if (Array.isArray(trendRaw)) {
  362 + for (const x of trendRaw) {
  363 + if (!x || typeof x !== 'object') continue
  364 + const row = x as Record<string, unknown>
  365 + const date = strField(row, 'date', 'Date')
  366 + if (!date) continue
  367 + printVolumeTrend.push({
  368 + date,
  369 + count: numField(row, 'count', 'Count', 0),
  370 + })
  371 + }
  372 + }
  373 +
  374 + const mostUsedProducts: UsAppLabelReportOutputDto['mostUsedProducts'] = []
  375 + const productsRaw = root.mostUsedProducts ?? root.MostUsedProducts
  376 + if (Array.isArray(productsRaw)) {
  377 + for (const x of productsRaw) {
  378 + if (!x || typeof x !== 'object') continue
  379 + const row = x as Record<string, unknown>
  380 + const productName = strField(row, 'productName', 'ProductName')
  381 + if (!productName) continue
  382 + mostUsedProducts.push({
  383 + productId: strField(row, 'productId', 'ProductId') || null,
  384 + productName,
  385 + categoryName: strField(row, 'categoryName', 'CategoryName') || '—',
  386 + totalPrinted: numField(row, 'totalPrinted', 'TotalPrinted', 0),
  387 + usagePercent: numField(row, 'usagePercent', 'UsagePercent', 0),
  388 + })
  389 + }
  390 + }
  391 +
  392 + return {
  393 + appliedRange,
  394 + summary,
  395 + labelsByCategory,
  396 + printVolumeTrend,
  397 + mostUsedProducts,
  398 + }
  399 +}
  400 +
  401 +/** 按 5-27 文档计算自然日区间(含起止日) */
  402 +export function buildUsAppLabelReportDateRange (input: {
  403 + period: UsAppLabelReportQueryInputVo['period']
  404 + customStart?: string
  405 + customEnd?: string
  406 +}): { startDate: string; endDate: string } {
  407 + const pad = (n: number) => String(n).padStart(2, '0')
  408 + const fmt = (d: Date) =>
  409 + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  410 +
  411 + const parseYmd = (s: string): Date | null => {
  412 + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim())
  413 + if (!m) return null
  414 + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
  415 + return Number.isNaN(d.getTime()) ? null : d
  416 + }
  417 +
  418 + const today = new Date()
  419 + today.setHours(0, 0, 0, 0)
  420 +
  421 + const period = input.period || '7d'
  422 + let end = today
  423 + let start: Date
  424 +
  425 + if (period === 'custom') {
  426 + const cs = input.customStart ? parseYmd(input.customStart) : null
  427 + const ce = input.customEnd ? parseYmd(input.customEnd) : null
  428 + end = ce || today
  429 + start = cs || new Date(end)
  430 + start.setDate(start.getDate() - 6)
  431 + if (start > end) start = new Date(end)
  432 + } else {
  433 + const days =
  434 + period === '90d' ? 90 : period === '30d' ? 30 : 7
  435 + end = today
  436 + start = new Date(end)
  437 + start.setDate(start.getDate() - (days - 1))
  438 + }
  439 +
  440 + return { startDate: fmt(start), endDate: fmt(end) }
  441 +}
  442 +
  443 +/** Label Report:POST get-label-report */
  444 +export async function fetchUsAppLabelReport (
  445 + input: UsAppLabelReportQueryInputVo,
  446 +): Promise<UsAppLabelReportOutputDto> {
  447 + const period = input.period || '7d'
  448 + const { startDate, endDate } = buildUsAppLabelReportDateRange({
  449 + period,
  450 + customStart: input.startDate,
  451 + customEnd: input.endDate,
  452 + })
  453 +
  454 + const body: Record<string, unknown> = {
  455 + locationId: input.locationId,
  456 + period,
  457 + startDate: period === 'custom' ? input.startDate || startDate : startDate,
  458 + endDate: period === 'custom' ? input.endDate || endDate : endDate,
  459 + }
  460 + if (input.keyword?.trim()) {
  461 + body.keyword = input.keyword.trim()
  462 + }
  463 +
  464 + const raw = await usAppApiRequest<unknown>({
  465 + path: '/api/app/us-app-labeling/get-label-report',
  466 + method: 'POST',
  467 + auth: true,
  468 + data: body,
  469 + })
  470 + return normalizeUsAppLabelReport(raw)
  471 +}
  472 +
273 /** 接口 10:分页打印日志 */ 473 /** 接口 10:分页打印日志 */
274 export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) { 474 export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) {
275 const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` 475 const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}`
泰额版/Food Labeling Management App UniApp/src/types/usAppAdminScope.ts 0 → 100644
  1 +/** 与 AuthScopeCompanyOptionDto 对齐 */
  2 +export interface AuthScopeCompanyOption {
  3 + id: string
  4 + partnerName: string
  5 + state?: boolean
  6 +}
  7 +
  8 +/** 与 AuthScopeRegionOptionDto 对齐 */
  9 +export interface AuthScopeRegionOption {
  10 + id: string
  11 + groupName: string
  12 + partnerId: string
  13 + state?: boolean
  14 +}
  15 +
  16 +/** 与 AuthScopeLocationOptionDto 对齐 */
  17 +export interface AuthScopeLocationOption {
  18 + id: string
  19 + locationCode: string
  20 + locationName: string
  21 + fullAddress: string
  22 + state: boolean
  23 + partnerId?: string
  24 + groupId?: string
  25 + groupName?: string
  26 +}
  27 +
  28 +export interface UsAppSelectAdminScopeLocationInput {
  29 + partnerId: string
  30 + groupId: string
  31 + locationId: string
  32 +}
  33 +
  34 +export interface AuthScopeSelectLocationOutput {
  35 + partnerId: string
  36 + partnerName: string
  37 + groupId: string
  38 + groupName: string
  39 + location: {
  40 + id: string
  41 + locationCode: string
  42 + locationName: string
  43 + fullAddress: string
  44 + state: boolean
  45 + }
  46 +}
泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
@@ -164,3 +164,64 @@ export interface UsAppLabelReprintInputVo { @@ -164,3 +164,64 @@ export interface UsAppLabelReprintInputVo {
164 printerMac?: string 164 printerMac?: string
165 printerAddress?: string 165 printerAddress?: string
166 } 166 }
  167 +
  168 +/** Label Report 周期(5-27 get-label-report) */
  169 +export type UsAppLabelReportPeriod = '7d' | '30d' | '90d' | 'custom'
  170 +
  171 +/** Label Report 入参 */
  172 +export interface UsAppLabelReportQueryInputVo {
  173 + locationId: string
  174 + period?: UsAppLabelReportPeriod
  175 + startDate?: string
  176 + endDate?: string
  177 + keyword?: string
  178 +}
  179 +
  180 +export interface UsAppLabelReportAppliedRangeDto {
  181 + period?: UsAppLabelReportPeriod | string
  182 + startDate?: string
  183 + endDate?: string
  184 + dayCount?: number
  185 + trendDescription?: string
  186 +}
  187 +
  188 +export interface UsAppLabelReportSummaryDto {
  189 + totalLabelsPrinted: number
  190 + totalLabelsPrintedPrevPeriod?: number
  191 + totalLabelsPrintedChangeRate: number
  192 + mostPrintedCategoryName?: string | null
  193 + mostPrintedCategoryCount: number
  194 + topProductName?: string | null
  195 + topProductCount: number
  196 + avgDailyPrints: number
  197 + avgDailyPrintsPrevPeriod?: number
  198 + avgDailyPrintsChangeRate: number
  199 +}
  200 +
  201 +export interface UsAppLabelReportCategoryRowDto {
  202 + categoryId?: string | null
  203 + categoryName: string
  204 + count: number
  205 +}
  206 +
  207 +export interface UsAppLabelReportTrendPointDto {
  208 + date: string
  209 + count: number
  210 +}
  211 +
  212 +export interface UsAppLabelReportTopProductDto {
  213 + productId?: string | null
  214 + productName: string
  215 + categoryName: string
  216 + totalPrinted: number
  217 + usagePercent: number
  218 +}
  219 +
  220 +/** Label Report 出参 */
  221 +export interface UsAppLabelReportOutputDto {
  222 + appliedRange?: UsAppLabelReportAppliedRangeDto
  223 + summary: UsAppLabelReportSummaryDto
  224 + labelsByCategory: UsAppLabelReportCategoryRowDto[]
  225 + printVolumeTrend: UsAppLabelReportTrendPointDto[]
  226 + mostUsedProducts: UsAppLabelReportTopProductDto[]
  227 +}
泰额版/Food Labeling Management App UniApp/src/utils/appAdminRole.ts 0 → 100644
  1 +import type { UsAppMyProfileOutputDto } from '../services/usAppAuth'
  2 +
  3 +/** 与后端 ReportsRoleHelper.IsAdminRole 对齐(App 管理员级联选店) */
  4 +export function isAppAdminUser(profile: UsAppMyProfileOutputDto | null | undefined): boolean {
  5 + if (!profile) return false
  6 + const code = (profile.primaryRoleCode ?? '').trim().toLowerCase()
  7 + if (code === 'admin') return true
  8 + const display = (profile.roleDisplay ?? '').trim().toLowerCase()
  9 + if (!display) return false
  10 + if (display.includes('administrator')) return true
  11 + if (display.includes('super admin')) return true
  12 + return false
  13 +}
泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts
@@ -56,6 +56,24 @@ export function toTscBarcodeSymbology (barcodeType: unknown): string { @@ -56,6 +56,24 @@ export function toTscBarcodeSymbology (barcodeType: unknown): string {
56 return map[key] ?? 'CODA' 56 return map[key] ?? 'CODA'
57 } 57 }
58 58
  59 +/**
  60 + * TSC/Gprinter CODABAR 常要求起止符;纯数字在部分机型上 BARCODE 指令会静默失败。
  61 + * 与预览 JsBarcode 展示可不同,但能保证出纸。
  62 + */
  63 +export function formatBarcodeValueForTsc (value: unknown, barcodeType: unknown): string {
  64 + const raw = String(value ?? '').trim()
  65 + if (!raw) return ''
  66 + const type = normalizeBarcodeType(barcodeType)
  67 + if (type !== 'CODABAR') return raw
  68 + const upper = raw.toUpperCase()
  69 + const hasStart = /^[ABCD]/.test(upper)
  70 + const hasStop = /[TNE*]$/.test(upper)
  71 + if (hasStart && hasStop) return upper
  72 + const body = raw.replace(/[^0-9\-$:/.+]/g, '')
  73 + if (!body) return raw
  74 + return `A${body}B`
  75 +}
  76 +
59 export function toEscBarcodeTypeCode (barcodeType: unknown): number { 77 export function toEscBarcodeTypeCode (barcodeType: unknown): number {
60 const key = normalizeBarcodeType(barcodeType) 78 const key = normalizeBarcodeType(barcodeType)
61 const map: Record<BarcodeFormatValue, number> = { 79 const map: Record<BarcodeFormatValue, number> = {
泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts
@@ -65,6 +65,15 @@ export type CategoryVisualRender = @@ -65,6 +65,15 @@ export type CategoryVisualRender =
65 | { mode: 'text'; text: string } 65 | { mode: 'text'; text: string }
66 | { mode: 'none' } 66 | { mode: 'none' }
67 67
  68 +/** 手提端产品列表布局(可选,存于 buttonStyleJson.appList) */
  69 +export type AppListLayoutStyle = {
  70 + gridColumns?: number
  71 + gapRpx?: number
  72 + cardPaddingRpx?: number
  73 + thumbAspectPercent?: number
  74 + panelPaddingRpx?: number
  75 +}
  76 +
68 export type StoredCategoryButtonStyleV1 = { 77 export type StoredCategoryButtonStyleV1 = {
69 v: 1 78 v: 1
70 appearances: AppearanceToken[] 79 appearances: AppearanceToken[]
@@ -72,6 +81,7 @@ export type StoredCategoryButtonStyleV1 = { @@ -72,6 +81,7 @@ export type StoredCategoryButtonStyleV1 = {
72 buttonBgColor?: string | null 81 buttonBgColor?: string | null
73 buttonTextColor?: string | null 82 buttonTextColor?: string | null
74 buttonImageUrl?: string | null 83 buttonImageUrl?: string | null
  84 + appList?: AppListLayoutStyle | null
75 } 85 }
76 86
77 export function serializeCategoryButtonStyleV1(input: { 87 export function serializeCategoryButtonStyleV1(input: {
@@ -95,11 +105,15 @@ export function serializeCategoryButtonStyleV1(input: { @@ -95,11 +105,15 @@ export function serializeCategoryButtonStyleV1(input: {
95 /** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */ 105 /** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */
96 export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null { 106 export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null {
97 const raw = String(s ?? '').trim() 107 const raw = String(s ?? '').trim()
98 - if (!raw.startsWith('[')) return null 108 + if (!raw) return null
99 try { 109 try {
100 const j = JSON.parse(raw) as unknown 110 const j = JSON.parse(raw) as unknown
101 - if (!Array.isArray(j)) return null  
102 - return j.map((x) => (x == null ? '' : String(x))) 111 + if (Array.isArray(j)) return j.map((x) => (x == null ? '' : String(x)))
  112 + if (j && typeof j === 'object') {
  113 + const values = (j as { values?: unknown }).values
  114 + if (Array.isArray(values)) return values.map((x) => (x == null ? '' : String(x)))
  115 + }
  116 + return null
103 } catch { 117 } catch {
104 return null 118 return null
105 } 119 }
@@ -170,6 +184,31 @@ export function serializeButtonAppearanceForApi(raw: unknown): string | null { @@ -170,6 +184,31 @@ export function serializeButtonAppearanceForApi(raw: unknown): string | null {
170 return s 184 return s
171 } 185 }
172 186
  187 +function parseAppListLayout(raw: unknown): AppListLayoutStyle | null {
  188 + if (!raw || typeof raw !== 'object') return null
  189 + const o = raw as Record<string, unknown>
  190 + const gridColumns = Number(o.gridColumns ?? o.GridColumns)
  191 + const gapRpx = Number(o.gapRpx ?? o.GapRpx)
  192 + const cardPaddingRpx = Number(o.cardPaddingRpx ?? o.CardPaddingRpx)
  193 + const thumbAspectPercent = Number(o.thumbAspectPercent ?? o.ThumbAspectPercent)
  194 + const panelPaddingRpx = Number(o.panelPaddingRpx ?? o.PanelPaddingRpx)
  195 + const layout: AppListLayoutStyle = {}
  196 + if (Number.isFinite(gridColumns) && gridColumns >= 1 && gridColumns <= 4) {
  197 + layout.gridColumns = Math.round(gridColumns)
  198 + }
  199 + if (Number.isFinite(gapRpx) && gapRpx >= 0) layout.gapRpx = Math.round(gapRpx)
  200 + if (Number.isFinite(cardPaddingRpx) && cardPaddingRpx >= 0) {
  201 + layout.cardPaddingRpx = Math.round(cardPaddingRpx)
  202 + }
  203 + if (Number.isFinite(thumbAspectPercent) && thumbAspectPercent > 0 && thumbAspectPercent <= 200) {
  204 + layout.thumbAspectPercent = Math.round(thumbAspectPercent)
  205 + }
  206 + if (Number.isFinite(panelPaddingRpx) && panelPaddingRpx >= 0) {
  207 + layout.panelPaddingRpx = Math.round(panelPaddingRpx)
  208 + }
  209 + return Object.keys(layout).length > 0 ? layout : null
  210 +}
  211 +
173 export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null { 212 export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null {
174 const raw = (jsonStr ?? '').trim() 213 const raw = (jsonStr ?? '').trim()
175 if (!raw) return null 214 if (!raw) return null
@@ -185,12 +224,36 @@ export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): @@ -185,12 +224,36 @@ export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined):
185 buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null, 224 buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null,
186 buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null, 225 buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null,
187 buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null, 226 buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null,
  227 + appList: parseAppListLayout(o.appList ?? o.AppList),
188 } 228 }
189 } catch { 229 } catch {
190 return null 230 return null
191 } 231 }
192 } 232 }
193 233
  234 +const DEFAULT_APP_LIST_LAYOUT: Required<AppListLayoutStyle> = {
  235 + gridColumns: 2,
  236 + gapRpx: 12,
  237 + cardPaddingRpx: 10,
  238 + thumbAspectPercent: 75,
  239 + panelPaddingRpx: 16,
  240 +}
  241 +
  242 +/** 解析标签分类/产品分类上的 buttonStyleJson.appList,供手提列表页动态样式 */
  243 +export function resolveAppListLayoutFromDto(row: {
  244 + buttonStyleJson?: string | null
  245 +}): Required<AppListLayoutStyle> {
  246 + const parsed = parseCategoryButtonStyleV1(row.buttonStyleJson)
  247 + const a = parsed?.appList
  248 + return {
  249 + gridColumns: a?.gridColumns ?? DEFAULT_APP_LIST_LAYOUT.gridColumns,
  250 + gapRpx: a?.gapRpx ?? DEFAULT_APP_LIST_LAYOUT.gapRpx,
  251 + cardPaddingRpx: a?.cardPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.cardPaddingRpx,
  252 + thumbAspectPercent: a?.thumbAspectPercent ?? DEFAULT_APP_LIST_LAYOUT.thumbAspectPercent,
  253 + panelPaddingRpx: a?.panelPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.panelPaddingRpx,
  254 + }
  255 +}
  256 +
194 export type CategoryDtoLike = { 257 export type CategoryDtoLike = {
195 buttonStyleJson?: string | null 258 buttonStyleJson?: string | null
196 buttonAppearance?: unknown 259 buttonAppearance?: unknown
泰额版/Food Labeling Management App UniApp/src/utils/emptyDisplay.ts 0 → 100644
  1 +/** Backend empty placeholder (US API may return this literal). */
  2 +export const BACKEND_EMPTY_DISPLAY = '无'
  3 +
  4 +/** Map API empty sentinels to English UI text. */
  5 +export function formatDisplayText (
  6 + value: string | null | undefined,
  7 + fallback = 'None',
  8 +): string {
  9 + const s = String(value ?? '').trim()
  10 + if (!s || s === BACKEND_EMPTY_DISPLAY) return fallback
  11 + return s
  12 +}
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
@@ -235,7 +235,7 @@ function drawBarcodeLikePreview( @@ -235,7 +235,7 @@ function drawBarcodeLikePreview(
235 let cursor = x + pad 235 let cursor = x + pad
236 for (let i = 0; i < modules.length; i++) { 236 for (let i = 0; i < modules.length; i++) {
237 if (modules[i] === 1) { 237 if (modules[i] === 1) {
238 - const rw = Math.max(0.7, moduleW * 0.86) 238 + const rw = Math.max(0.7, moduleW * 0.72)
239 ctx.fillRect(cursor, y + pad, rw, barH) 239 ctx.fillRect(cursor, y + pad, rw, barH)
240 } 240 }
241 cursor += moduleW 241 cursor += moduleW
@@ -257,7 +257,7 @@ function drawBarcodeLikePreview( @@ -257,7 +257,7 @@ function drawBarcodeLikePreview(
257 let cursorY = y + pad 257 let cursorY = y + pad
258 for (let i = 0; i < modules.length; i++) { 258 for (let i = 0; i < modules.length; i++) {
259 if (modules[i] === 1) { 259 if (modules[i] === 1) {
260 - const rh = Math.max(0.7, moduleH * 0.86) 260 + const rh = Math.max(0.7, moduleH * 0.72)
261 ctx.fillRect(x + pad, cursorY, barW, rh) 261 ctx.fillRect(x + pad, cursorY, barW, rh)
262 } 262 }
263 cursorY += moduleH 263 cursorY += moduleH
@@ -433,7 +433,8 @@ function runLabelPreviewCanvasDraw( @@ -433,7 +433,8 @@ function runLabelPreviewCanvasDraw(
433 const bw = Math.max(40, w || 120) 433 const bw = Math.max(40, w || 120)
434 const bh = Math.max(36, h || 96) 434 const bh = Math.max(36, h || 96)
435 const pad = 3 435 const pad = 3
436 - const rightX = x + bw - pad 436 + /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */
  437 + const rightX = x + bw - 1
437 const maxY = y + bh - 2 438 const maxY = y + bh - 2
438 const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16)) 439 const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16))
439 const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72))) 440 const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72)))
@@ -739,14 +740,32 @@ export function renderLabelPreviewCanvasImageDataForPrint( @@ -739,14 +740,32 @@ export function renderLabelPreviewCanvasImageDataForPrint(
739 /** 740 /**
740 * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。 741 * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。
741 */ 742 */
  743 +/** 调整 canvas :width/:height 后等待绘图缓冲区就绪,避免光栅导出高度不足导致底部条码/日期被裁切 */
  744 +export async function settleAfterLabelCanvasResize (): Promise<void> {
  745 + await new Promise<void>((r) => setTimeout(r, 16))
  746 + await new Promise<void>((resolve) => {
  747 + if (typeof requestAnimationFrame === 'function') {
  748 + requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
  749 + } else {
  750 + setTimeout(() => resolve(), 32)
  751 + }
  752 + })
  753 + await new Promise<void>((r) => setTimeout(r, 80))
  754 +}
  755 +
742 export function getLabelPrintRasterLayout( 756 export function getLabelPrintRasterLayout(
743 template: SystemLabelTemplate, 757 template: SystemLabelTemplate,
744 maxWidthDots: number, 758 maxWidthDots: number,
745 - printDpi = 203 759 + printDpi = 203,
  760 + opts?: { contentHeightPx?: number },
746 ): { cw: number, ch: number, outW: number, outH: number, scale: number } { 761 ): { cw: number, ch: number, outW: number, outH: number, scale: number } {
747 const unit = template.unit || 'inch' 762 const unit = template.unit || 'inch'
748 const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) 763 const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
749 - const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) 764 + const fullH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
  765 + const ch =
  766 + opts?.contentHeightPx != null && opts.contentHeightPx > 0
  767 + ? Math.max(40, Math.round(opts.contentHeightPx))
  768 + : fullH
750 const designDpi = 96 769 const designDpi = 96
751 const idealW = Math.round(cw * (printDpi / designDpi)) 770 const idealW = Math.round(cw * (printDpi / designDpi))
752 const cap = Math.max(8, Math.round(maxWidthDots || 576)) 771 const cap = Math.max(8, Math.round(maxWidthDots || 576))
泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts 0 → 100644
  1 +/** 扫描/配对列表仅展示以下蓝牙名称(大小写不敏感,须完整匹配) */
  2 +export const ALLOWED_BLUETOOTH_PRINTER_NAMES = [
  3 + 'GP-D320FX-spp_A7FO',
  4 + 'Virtual BT Printer',
  5 +] as const
  6 +
  7 +const ALLOWED_BLUETOOTH_PRINTER_NAME_SET = new Set(
  8 + ALLOWED_BLUETOOTH_PRINTER_NAMES.map((name) => name.toLowerCase()),
  9 +)
  10 +
  11 +export function normalizeBluetoothPrinterName (name: string | undefined | null): string {
  12 + return String(name ?? '').trim()
  13 +}
  14 +
  15 +/** 仅允许白名单内的蓝牙打印机名称出现在连接列表 */
  16 +export function isAllowedBluetoothPrinterName (name: string | undefined | null): boolean {
  17 + const normalized = normalizeBluetoothPrinterName(name)
  18 + if (!normalized) return false
  19 + return ALLOWED_BLUETOOTH_PRINTER_NAME_SET.has(normalized.toLowerCase())
  20 +}
  21 +
  22 +/** 一体机虚拟蓝牙名(走整页光栅与预览一致,不走 native printTemplate) */
  23 +export function isVirtualBtPrinterDeviceName (name: string | undefined | null): boolean {
  24 + return normalizeBluetoothPrinterName(name).toLowerCase() === 'virtual bt printer'
  25 +}
泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
@@ -3,6 +3,33 @@ import { printRunDiag } from &#39;./printRunDiagnostics&#39; @@ -3,6 +3,33 @@ import { printRunDiag } from &#39;./printRunDiagnostics&#39;
3 3
4 const DEFAULT_IMAGE_THRESHOLD = 180 4 const DEFAULT_IMAGE_THRESHOLD = 180
5 5
  6 +/** 去掉位图底部全白行(内置 ESC 光栅按图像高度走纸,否则会拖很长空白) */
  7 +export function trimMonochromeImageBottomWhitespace (
  8 + image: MonochromeImageData,
  9 + paddingRows = 10,
  10 +): MonochromeImageData {
  11 + const { width, height, pixels } = image
  12 + if (!width || !height || !pixels?.length) return image
  13 + let lastBlack = -1
  14 + for (let y = height - 1; y >= 0; y--) {
  15 + for (let x = 0; x < width; x++) {
  16 + if (pixels[y * width + x]) {
  17 + lastBlack = y
  18 + break
  19 + }
  20 + }
  21 + if (lastBlack >= 0) break
  22 + }
  23 + if (lastBlack < 0) return image
  24 + const newH = Math.min(height, lastBlack + 1 + Math.max(0, paddingRows))
  25 + if (newH >= height) return image
  26 + return {
  27 + width,
  28 + height: newH,
  29 + pixels: pixels.slice(0, width * newH),
  30 + }
  31 +}
  32 +
6 function yieldToUi (): Promise<void> { 33 function yieldToUi (): Promise<void> {
7 return new Promise((resolve) => { 34 return new Promise((resolve) => {
8 setTimeout(resolve, 0) 35 setTimeout(resolve, 0)
泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
@@ -13,7 +13,7 @@ import { @@ -13,7 +13,7 @@ import {
13 } from '../printerConnection' 13 } from '../printerConnection'
14 // @ts-ignore - js bridge module (app-plus only) 14 // @ts-ignore - js bridge module (app-plus only)
15 import classicBluetooth from '../bluetoothTool.js' 15 import classicBluetooth from '../bluetoothTool.js'
16 -import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' 16 +import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace } from '../imageRaster'
17 import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' 17 import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder'
18 import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' 18 import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol'
19 import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' 19 import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload'
@@ -28,7 +28,13 @@ import { @@ -28,7 +28,13 @@ import {
28 getLabelPrintRasterLayout, 28 getLabelPrintRasterLayout,
29 renderLabelPreviewCanvasImageDataForPrint, 29 renderLabelPreviewCanvasImageDataForPrint,
30 renderLabelPreviewCanvasToTempPathForPrint, 30 renderLabelPreviewCanvasToTempPathForPrint,
  31 + settleAfterLabelCanvasResize,
31 } from '../../labelPreview/renderLabelPreviewCanvas' 32 } from '../../labelPreview/renderLabelPreviewCanvas'
  33 +import {
  34 + ensureTemplateHeightCoversElements,
  35 + templateContentHeightPx,
  36 + templateSizeToMillimeters,
  37 +} from '../templatePhysicalMm'
32 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' 38 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl'
33 import { printRunDiag } from '../printRunDiagnostics' 39 import { printRunDiag } from '../printRunDiagnostics'
34 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' 40 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter'
@@ -853,9 +859,10 @@ export async function printImageForCurrentPrinter ( @@ -853,9 +859,10 @@ export async function printImageForCurrentPrinter (
853 } 859 }
854 } 860 }
855 861
  862 + const rasterForPrint = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options)
856 let data: number[] = [] 863 let data: number[] = []
857 if (rasterDriver.protocol === 'esc') { 864 if (rasterDriver.protocol === 'esc') {
858 - data = buildEscPosImageData(raster, options) 865 + data = buildEscPosImageData(rasterForPrint, options)
859 } else { 866 } else {
860 data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203) 867 data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203)
861 } 868 }
@@ -964,7 +971,8 @@ export async function printImageDataForCurrentPrinter ( @@ -964,7 +971,8 @@ export async function printImageDataForCurrentPrinter (
964 ): Promise<PrinterDriver> { 971 ): Promise<PrinterDriver> {
965 const driver = getCurrentPrinterDriver() 972 const driver = getCurrentPrinterDriver()
966 const rasterDriver = resolveRasterPrintDriver(driver) 973 const rasterDriver = resolveRasterPrintDriver(driver)
967 - const raster = rasterizeImageData(imageData, options) 974 + let raster = rasterizeImageData(imageData, options)
  975 + raster = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options)
968 if (onProgress) onProgress(5) 976 if (onProgress) onProgress(5)
969 const data = rasterDriver.protocol === 'esc' 977 const data = rasterDriver.protocol === 'esc'
970 ? buildEscPosImageData(raster, options) 978 ? buildEscPosImageData(raster, options)
@@ -1000,6 +1008,20 @@ export type SystemTemplatePrintCanvasRasterOptions = { @@ -1000,6 +1008,20 @@ export type SystemTemplatePrintCanvasRasterOptions = {
1000 }) => void | Promise<void> 1008 }) => void | Promise<void>
1001 } 1009 }
1002 1010
  1011 +function templateForRasterPrint (template: SystemLabelTemplate): SystemLabelTemplate {
  1012 + return ensureTemplateHeightCoversElements(template)
  1013 +}
  1014 +
  1015 +/** 内置 ESC 光栅:按图像高度走纸,裁掉底部全白行(布局已按 contentHeight 时仍可去掉少量留白) */
  1016 +function finalizeRasterForEscPrint (
  1017 + raster: { width: number; height: number; pixels: number[] },
  1018 + protocol: string,
  1019 + options: PrintImageOptions,
  1020 +) {
  1021 + if (protocol !== 'esc' || options.useContentHeight === false) return raster
  1022 + return trimMonochromeImageBottomWhitespace(raster, 8)
  1023 +}
  1024 +
1003 export async function printSystemTemplateForCurrentPrinter ( 1025 export async function printSystemTemplateForCurrentPrinter (
1004 template: SystemLabelTemplate, 1026 template: SystemLabelTemplate,
1005 data: LabelTemplateData = {}, 1027 data: LabelTemplateData = {},
@@ -1019,21 +1041,49 @@ export async function printSystemTemplateForCurrentPrinter ( @@ -1019,21 +1041,49 @@ export async function printSystemTemplateForCurrentPrinter (
1019 1041
1020 if (canvasRaster && !bypassCanvasRasterForQr) { 1042 if (canvasRaster && !bypassCanvasRasterForQr) {
1021 if (onProgress) onProgress(1) 1043 if (onProgress) onProgress(1)
  1044 + const templateForDraw = templateForRasterPrint(template)
1022 const maxDots = 1045 const maxDots =
1023 rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) 1046 rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576)
1024 - const layout = getLabelPrintRasterLayout(template, maxDots, rasterDriver.imageDpi || 203) 1047 + const escUseContentH = rasterDriver.protocol === 'esc'
  1048 + const contentHpx = escUseContentH ? templateContentHeightPx(templateForDraw) : undefined
  1049 + const layout = getLabelPrintRasterLayout(
  1050 + templateForDraw,
  1051 + maxDots,
  1052 + rasterDriver.imageDpi || 203,
  1053 + contentHpx != null ? { contentHeightPx: contentHpx } : undefined,
  1054 + )
  1055 + if (escUseContentH) {
  1056 + printRunDiag('raster_layout_content_height', {
  1057 + contentHpx,
  1058 + layoutCh: layout.ch,
  1059 + outW: layout.outW,
  1060 + outH: layout.outH,
  1061 + })
  1062 + }
1025 if (onProgress) onProgress(4) 1063 if (onProgress) onProgress(4)
1026 if (canvasRaster.applyLayout) { 1064 if (canvasRaster.applyLayout) {
1027 - await canvasRaster.applyLayout(layout) 1065 + await Promise.resolve(canvasRaster.applyLayout(layout))
1028 } 1066 }
1029 if (onProgress) onProgress(7) 1067 if (onProgress) onProgress(7)
1030 - await new Promise<void>((r) => setTimeout(r, 50)) 1068 + await settleAfterLabelCanvasResize()
1031 if (onProgress) onProgress(9) 1069 if (onProgress) onProgress(9)
1032 const printOpts: PrintImageOptions = { 1070 const printOpts: PrintImageOptions = {
1033 printQty: options.printQty || 1, 1071 printQty: options.printQty || 1,
1034 - clearTopRasterRows: 1, 1072 + clearTopRasterRows: 0,
1035 targetWidthDots: layout.outW, 1073 targetWidthDots: layout.outW,
1036 targetHeightDots: layout.outH, 1074 targetHeightDots: layout.outH,
  1075 + useContentHeight: escUseContentH,
  1076 + cutBetweenCopies: true,
  1077 + widthMm: templateSizeToMillimeters(
  1078 + templateForDraw.unit,
  1079 + Number(templateForDraw.width) || 0,
  1080 + Number(templateForDraw.height) || 0,
  1081 + ).widthMm,
  1082 + heightMm: templateSizeToMillimeters(
  1083 + templateForDraw.unit,
  1084 + Number(templateForDraw.width) || 0,
  1085 + Number(templateForDraw.height) || 0,
  1086 + ).heightMm,
1037 } 1087 }
1038 const mapRasterProgress = onProgress 1088 const mapRasterProgress = onProgress
1039 ? (p: number) => { 1089 ? (p: number) => {
@@ -1046,7 +1096,7 @@ export async function printSystemTemplateForCurrentPrinter ( @@ -1046,7 +1096,7 @@ export async function printSystemTemplateForCurrentPrinter (
1046 const imageData = await renderLabelPreviewCanvasImageDataForPrint( 1096 const imageData = await renderLabelPreviewCanvasImageDataForPrint(
1047 canvasRaster.canvasId, 1097 canvasRaster.canvasId,
1048 canvasRaster.componentInstance, 1098 canvasRaster.componentInstance,
1049 - template, 1099 + templateForDraw,
1050 layout, 1100 layout,
1051 ) 1101 )
1052 if (onProgress) onProgress(12) 1102 if (onProgress) onProgress(12)
@@ -1059,7 +1109,7 @@ export async function printSystemTemplateForCurrentPrinter ( @@ -1059,7 +1109,7 @@ export async function printSystemTemplateForCurrentPrinter (
1059 const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint( 1109 const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint(
1060 canvasRaster.canvasId, 1110 canvasRaster.canvasId,
1061 canvasRaster.componentInstance, 1111 canvasRaster.componentInstance,
1062 - template, 1112 + templateForDraw,
1063 layout, 1113 layout,
1064 ) 1114 )
1065 if (onProgress) onProgress(12) 1115 if (onProgress) onProgress(12)
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
@@ -7,6 +7,7 @@ import type { @@ -7,6 +7,7 @@ import type {
7 SystemLabelTemplate, 7 SystemLabelTemplate,
8 SystemTemplateElementBase, 8 SystemTemplateElementBase,
9 } from './types/printer' 9 } from './types/printer'
  10 +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat'
10 import { applyTemplateData } from './templateRenderer' 11 import { applyTemplateData } from './templateRenderer'
11 12
12 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { 13 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
@@ -19,6 +20,74 @@ function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): bo @@ -19,6 +20,74 @@ function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): bo
19 return false 20 return false
20 } 21 }
21 22
  23 +/** 原生营养表:严格使用模板 height,打印时再按 dpi 缩放;勿在 JS 侧抬高 height 以免压住下方 DATE */
  24 +function prepareNutritionElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase {
  25 + const cfg = { ...(el.config || {}) } as Record<string, unknown>
  26 + const x = Math.max(0, Number(el.x) || 0)
  27 + const y = Math.max(0, Number(el.y) || 0)
  28 + const w = Math.max(40, Number(el.width) || 0)
  29 + const h = Math.max(40, Number(el.height) || 72)
  30 + cfg.nativePrintHeight = h
  31 + cfg.NativePrintHeight = h
  32 + cfg.nativePadLeft = Number(cfg.nativePadLeft ?? 2)
  33 + cfg.nativePadRight = Number(cfg.nativePadRight ?? 4)
  34 + cfg.nutritionTitleBold = false
  35 + cfg.nutritionBodyBold = false
  36 + return {
  37 + ...el,
  38 + x,
  39 + y,
  40 + width: w,
  41 + height: h,
  42 + config: cfg,
  43 + }
  44 +}
  45 +
  46 +/** 原生打印按 dpi 放大营养表后,保证 DATE/TIME/BARCODE 在营养表底边之下(与预览留白一致) */
  47 +function resolveNativeLayoutCollisions (
  48 + elements: SystemTemplateElementBase[],
  49 +): SystemTemplateElementBase[] {
  50 + const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION')
  51 + if (!nutrition) return elements
  52 + const nutY = Number(nutrition.y) || 0
  53 + const nutH = Number(nutrition.height) || 0
  54 + /** 设计 px 最小间距;预览里常见 8–12px */
  55 + const minGapPx = 10
  56 + const reservedBottom = nutY + nutH + minGapPx
  57 + return elements.map((el) => {
  58 + if (el.id === nutrition.id) return el
  59 + const y = Number(el.y) || 0
  60 + if (y < reservedBottom && y >= nutY - 1) {
  61 + return { ...el, y: reservedBottom }
  62 + }
  63 + return el
  64 + })
  65 +}
  66 +
  67 +function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase {
  68 + const cfg = { ...(el.config || {}) } as Record<string, unknown>
  69 + const barcodeType = normalizeBarcodeType(cfg.barcodeType ?? cfg.BarcodeType)
  70 + const raw = String(
  71 + cfg.data ?? cfg.Data ?? cfg.value ?? cfg.Value ?? cfg.barcodeData ?? cfg.BarcodeData ?? ''
  72 + ).trim()
  73 + const data = formatBarcodeValueForTsc(raw, barcodeType)
  74 + /** 人读数字与预览一致(1234),编码串(A1234B)仅给原生画条用 */
  75 + cfg.barcodeDisplayText = raw
  76 + cfg.BarcodeDisplayText = raw
  77 + if (data) {
  78 + cfg.data = data
  79 + cfg.Data = data
  80 + cfg.value = data
  81 + cfg.Value = data
  82 + }
  83 + /** CODABAR 在 Virtual BT / 佳博上 TSC BARCODE 易失败,改走与预览一致的位图条 */
  84 + if (barcodeType === 'CODABAR') {
  85 + cfg.nativeBarcodeBitmap = true
  86 + cfg.NativeBarcodeBitmap = true
  87 + }
  88 + return { ...el, config: cfg }
  89 +}
  90 +
22 /** 91 /**
23 * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致), 92 * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致),
24 * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。 93 * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。
@@ -60,6 +129,7 @@ export function normalizeTemplateForNativeFastJob ( @@ -60,6 +129,7 @@ export function normalizeTemplateForNativeFastJob (
60 } 129 }
61 } 130 }
62 131
  132 + const extras: SystemTemplateElementBase[] = []
63 const elements = (template.elements || []).map((el) => { 133 const elements = (template.elements || []).map((el) => {
64 const type = String(el.type || '').toUpperCase() 134 const type = String(el.type || '').toUpperCase()
65 const config = (el.config || {}) as Record<string, any> 135 const config = (el.config || {}) as Record<string, any>
@@ -119,9 +189,41 @@ export function normalizeTemplateForNativeFastJob ( @@ -119,9 +189,41 @@ export function normalizeTemplateForNativeFastJob (
119 config: { ...config, text, nativeSourceType: type }, 189 config: { ...config, text, nativeSourceType: type },
120 } 190 }
121 } 191 }
  192 + if (type === 'NUTRITION') {
  193 + return prepareNutritionElementForNativePrint(el)
  194 + }
  195 + if (type === 'BARCODE') {
  196 + const prepared = prepareBarcodeElementForNativePrint(el)
  197 + const pcfg = { ...(prepared.config || {}) } as Record<string, unknown>
  198 + const human = String(pcfg.barcodeDisplayText ?? pcfg.BarcodeDisplayText ?? '').trim()
  199 + const showHuman = String(pcfg.showText ?? pcfg.ShowText ?? 'true').toLowerCase() !== 'false'
  200 + if (human && showHuman) {
  201 + pcfg.showText = false
  202 + pcfg.ShowText = false
  203 + extras.push({
  204 + id: `${String(prepared.id || 'barcode')}_label`,
  205 + type: 'TEXT_STATIC',
  206 + x: Number(prepared.x) || 0,
  207 + y: (Number(prepared.y) || 0) + (Number(prepared.height) || 40) + 6,
  208 + width: Number(prepared.width) || 140,
  209 + height: 20,
  210 + rotation: prepared.rotation ?? 'horizontal',
  211 + border: 'none',
  212 + config: {
  213 + text: human,
  214 + fontSize: 12,
  215 + textAlign: 'center',
  216 + TextAlign: 'center',
  217 + forceRasterText: true,
  218 + },
  219 + } as SystemTemplateElementBase)
  220 + }
  221 + return { ...prepared, config: pcfg }
  222 + }
122 return el 223 return el
123 }) 224 })
124 - return { ...template, elements } 225 + const merged = resolveNativeLayoutCollisions([...elements, ...extras])
  226 + return { ...template, elements: merged }
125 } 227 }
126 228
127 /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */ 229 /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */
泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts
@@ -40,5 +40,5 @@ export function getPrintRunDiagnosticsText (): string { @@ -40,5 +40,5 @@ export function getPrintRunDiagnosticsText (): string {
40 export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string { 40 export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string {
41 const full = getPrintRunDiagnosticsText() 41 const full = getPrintRunDiagnosticsText()
42 if (full.length <= maxChars) return full 42 if (full.length <= maxChars) return full
43 - return `...(省略开头 ${full.length - maxChars} 字)\n\n${full.slice(-maxChars)}` 43 + return `...(start omitted, ${full.length - maxChars} chars)\n\n${full.slice(-maxChars)}`
44 } 44 }
泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts
@@ -124,14 +124,27 @@ function appendBoxLine (out: number[], text = &#39;&#39;, width = 32) { @@ -124,14 +124,27 @@ function appendBoxLine (out: number[], text = &#39;&#39;, width = 32) {
124 appendLine(out, `| ${value} |`) 124 appendLine(out, `| ${value} |`)
125 } 125 }
126 126
127 -function createEscDocument (builder: (out: number[]) => void): number[] { 127 +/** GS V:不支持切刀的机芯通常会忽略,不阻断后续打印 */
  128 +function appendEscCut (out: number[], mode: 'partial' | 'full' = 'partial') {
  129 + if (mode === 'full') {
  130 + out.push(0x1d, 0x56, 0x00)
  131 + } else {
  132 + out.push(0x1d, 0x56, 0x01)
  133 + }
  134 +}
  135 +
  136 +function createEscDocument (
  137 + builder: (out: number[]) => void,
  138 + endOpts: { feedLines?: number; cut?: 'none' | 'partial' | 'full' } = {},
  139 +): number[] {
128 const out: number[] = [] 140 const out: number[] = []
129 out.push(0x1b, 0x40) 141 out.push(0x1b, 0x40)
130 out.push(0x1b, 0x74, 16) 142 out.push(0x1b, 0x74, 16)
131 builder(out) 143 builder(out)
132 - out.push(0x1b, 0x64, 0x04)  
133 - // 打印完成后执行切刀(GS V 0):适配当前内置小票机,避免长纸不断。  
134 - out.push(0x1d, 0x56, 0x00) 144 + const feed = Math.max(0, Math.min(8, Math.round(endOpts.feedLines ?? 4)))
  145 + if (feed > 0) out.push(0x1b, 0x64, feed)
  146 + const cut = endOpts.cut ?? 'partial'
  147 + if (cut !== 'none') appendEscCut(out, cut === 'full' ? 'full' : 'partial')
135 return out 148 return out
136 } 149 }
137 150
@@ -242,14 +255,22 @@ export function buildEscPosImageData ( @@ -242,14 +255,22 @@ export function buildEscPosImageData (
242 options: PrintImageOptions = {} 255 options: PrintImageOptions = {}
243 ): number[] { 256 ): number[] {
244 const printQty = Math.max(1, Math.round(options.printQty || 1)) 257 const printQty = Math.max(1, Math.round(options.printQty || 1))
245 - return createEscDocument((out) => {  
246 - for (let i = 0; i < printQty; i++) {  
247 - appendAlign(out, 1)  
248 - appendRasterImage(out, image)  
249 - appendLine(out)  
250 - appendLine(out) 258 + const cutBetween = options.cutBetweenCopies !== false
  259 + const out: number[] = []
  260 + out.push(0x1b, 0x40)
  261 + out.push(0x1b, 0x74, 16)
  262 + for (let i = 0; i < printQty; i++) {
  263 + appendAlign(out, 1)
  264 + appendRasterImage(out, image)
  265 + out.push(0x1b, 0x64, 1)
  266 + const isLast = i >= printQty - 1
  267 + if (!isLast && cutBetween) {
  268 + appendEscCut(out, 'partial')
251 } 269 }
252 - }) 270 + }
  271 + out.push(0x1b, 0x64, 2)
  272 + if (cutBetween) appendEscCut(out, 'partial')
  273 + return out
253 } 274 }
254 275
255 export function buildEscPosTemplateData ( 276 export function buildEscPosTemplateData (
泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
1 -import { normalizeBarcodeType } from '../barcodeFormat' 1 +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat'
2 import { storedValueLooksLikeImagePath } from '../resolveMediaUrl' 2 import { storedValueLooksLikeImagePath } from '../resolveMediaUrl'
3 import { 3 import {
4 createImageBitmapPatch, 4 createImageBitmapPatch,
@@ -486,9 +486,9 @@ function buildTscTemplate ( @@ -486,9 +486,9 @@ function buildTscTemplate (
486 } 486 }
487 487
488 if (type === 'BARCODE') { 488 if (type === 'BARCODE') {
489 - const value = resolveElementDataValue(element, data)  
490 - if (!value) return  
491 const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) 489 const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], ''))
  490 + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology)
  491 + if (!value) return
492 const rotation = resolveRotation( 492 const rotation = resolveRotation(
493 element.rotation || getConfigString(config, ['orientation'], 'horizontal') 493 element.rotation || getConfigString(config, ['orientation'], 'horizontal')
494 ) 494 )
@@ -612,7 +612,8 @@ function buildEscTemplate ( @@ -612,7 +612,8 @@ function buildEscTemplate (
612 } 612 }
613 613
614 if (type === 'BARCODE') { 614 if (type === 'BARCODE') {
615 - const value = resolveElementDataValue(element, data) 615 + const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], ''))
  616 + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology)
616 if (!value) return 617 if (!value) return
617 items.push({ 618 items.push({
618 type: 'barcode', 619 type: 'barcode',
泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
@@ -2,14 +2,105 @@ @@ -2,14 +2,105 @@
2 * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm), 2 * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm),
3 * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。 3 * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。
4 */ 4 */
5 -import type { SystemLabelTemplate } from './types/printer' 5 +import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer'
6 6
7 const DESIGN_DPI = 96 7 const DESIGN_DPI = 96
  8 +const PX_PER_INCH = 96
  9 +const PX_PER_CM = 37.8
  10 +
  11 +function toCanvasPx (value: number, unit: string): number {
  12 + const u = String(unit || 'inch').toLowerCase()
  13 + if (u === 'mm') return (value / 25.4) * PX_PER_INCH
  14 + if (u === 'cm') return value * PX_PER_CM
  15 + if (u === 'px') return value
  16 + return value * PX_PER_INCH
  17 +}
  18 +
  19 +function fromCanvasPx (px: number, unit: string): number {
  20 + const u = String(unit || 'inch').toLowerCase()
  21 + if (u === 'mm') return (px * 25.4) / PX_PER_INCH
  22 + if (u === 'cm') return px / PX_PER_CM
  23 + if (u === 'px') return px
  24 + return px / PX_PER_INCH
  25 +}
  26 +
  27 +function roundTemplateDim (value: number, unit: string): number {
  28 + const u = String(unit || 'inch').toLowerCase()
  29 + if (u === 'px') return Math.max(1, Math.round(value))
  30 + if (u === 'mm' || u === 'cm') return Math.round(value * 10) / 10
  31 + return Math.round(value * 1000) / 1000
  32 +}
  33 +
  34 +function elementBottomPx (el: SystemTemplateElementBase): number {
  35 + const y = Number(el.y) || 0
  36 + const h = Math.max(0, Number(el.height) || 0)
  37 + let bottom = y + h
  38 + const type = String(el.type || '').toUpperCase()
  39 + const cfg = (el.config || {}) as Record<string, unknown>
  40 + if (type === 'BARCODE') {
  41 + const showText = String(cfg.showText ?? cfg.ShowText ?? 'true').toLowerCase() !== 'false'
  42 + if (showText) bottom += 28
  43 + else bottom += 4
  44 + }
  45 + if (type === 'QRCODE') bottom += 4
  46 + return bottom
  47 +}
  48 +
  49 +/**
  50 + * 原生 printTemplate 的 SIZE 高度仅取自模板根 height,不会按元素自动增高;
  51 + * 条码/日期贴在底部时易被裁掉。与 systemTemplateAdapter.buildTscTemplate 增高逻辑对齐。
  52 + */
  53 +/** 设计 px:元素最底边 + 留白,且不超过模板根 height(用于光栅出纸高度,避免整纸 5cm 空白) */
  54 +export function templateContentHeightPx (
  55 + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height' | 'elements'>,
  56 + extraBottomPx = 14,
  57 +): number {
  58 + const unit = String(template.unit || 'inch')
  59 + const canvasH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 0, unit)))
  60 + const elements = template.elements || []
  61 + if (!elements.length) return canvasH
  62 + let maxBottom = 0
  63 + for (const el of elements) {
  64 + maxBottom = Math.max(maxBottom, elementBottomPx(el))
  65 + }
  66 + return Math.max(40, Math.min(canvasH, maxBottom + extraBottomPx))
  67 +}
  68 +
  69 +export function ensureTemplateHeightCoversElements (
  70 + template: SystemLabelTemplate,
  71 + extraBottomPx = 14
  72 +): SystemLabelTemplate {
  73 + const unit = String(template.unit || 'inch')
  74 + const elements = template.elements || []
  75 + if (!elements.length) return template
  76 + let maxBottom = 0
  77 + for (const el of elements) {
  78 + maxBottom = Math.max(maxBottom, elementBottomPx(el))
  79 + }
  80 + const currentHpx = toCanvasPx(Number(template.height) || 0, unit)
  81 + const neededPx = maxBottom + extraBottomPx
  82 + if (neededPx <= currentHpx + 1) return template
  83 + return {
  84 + ...template,
  85 + height: roundTemplateDim(fromCanvasPx(neededPx, unit), unit),
  86 + }
  87 +}
8 88
9 /** 常见 4″ 标签机安全上限(mm),略放宽 */ 89 /** 常见 4″ 标签机安全上限(mm),略放宽 */
10 const NATIVE_FAST_MAX_WIDTH_MM = 112 90 const NATIVE_FAST_MAX_WIDTH_MM = 112
11 const NATIVE_FAST_MAX_HEIGHT_MM = 320 91 const NATIVE_FAST_MAX_HEIGHT_MM = 320
12 92
  93 +/** 打印/预览统一:模板根 width/height + unit → 物理毫米(与 NativeTemplateCommandBuilder.toMillimeter 一致) */
  94 +export function getTemplatePhysicalSizeMm (
  95 + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height'>
  96 +): { widthMm: number; heightMm: number; unit: string } {
  97 + const unit = String(template.unit || 'inch')
  98 + const w = Number(template.width) || 0
  99 + const h = Number(template.height) || 0
  100 + const { widthMm, heightMm } = templateSizeToMillimeters(unit, w, h)
  101 + return { widthMm, heightMm, unit }
  102 +}
  103 +
13 export function templateSizeToMillimeters ( 104 export function templateSizeToMillimeters (
14 unit: string | undefined, 105 unit: string | undefined,
15 width: number, 106 width: number,
泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
@@ -26,6 +26,12 @@ export interface PrintImageOptions { @@ -26,6 +26,12 @@ export interface PrintImageOptions {
26 maxWidthDots?: number 26 maxWidthDots?: number
27 targetWidthDots?: number 27 targetWidthDots?: number
28 targetHeightDots?: number 28 targetHeightDots?: number
  29 + /** @deprecated 光栅已按内容高度裁切;保留兼容 */
  30 + labelRasterFixedHeight?: boolean
  31 + /** 多份打印时:每打完一张尝试半切(GS V 1);不支持则机芯忽略并继续下一张 */
  32 + cutBetweenCopies?: boolean
  33 + /** 光栅按元素底边高度布局,而非整模板 height */
  34 + useContentHeight?: boolean
29 widthMm?: number 35 widthMm?: number
30 heightMm?: number 36 heightMm?: number
31 x?: number 37 x?: number
泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
@@ -14,9 +14,7 @@ import { @@ -14,9 +14,7 @@ import {
14 setLastLabelPrintJobPayload, 14 setLastLabelPrintJobPayload,
15 } from './labelPreview/buildLabelPrintPayload' 15 } from './labelPreview/buildLabelPrintPayload'
16 import { getCurrentStoreId } from './stores' 16 import { getCurrentStoreId } from './stores'
17 -import {  
18 - ensureNativeClassicTransportIfPossible,  
19 -} from './print/printerConnection' 17 +import { ensureNativeClassicTransportIfPossible } from './print/printerConnection'
20 import { 18 import {
21 hydrateSystemTemplateImagesForPrint, 19 hydrateSystemTemplateImagesForPrint,
22 resetHydrateImageDebugRecords, 20 resetHydrateImageDebugRecords,
@@ -25,7 +23,10 @@ import { @@ -25,7 +23,10 @@ import {
25 normalizeTemplateForNativeFastJob, 23 normalizeTemplateForNativeFastJob,
26 templateHasUnsupportedNativeFastElements, 24 templateHasUnsupportedNativeFastElements,
27 } from './print/nativeTemplateElementSupport' 25 } from './print/nativeTemplateElementSupport'
28 -import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm' 26 +import {
  27 + ensureTemplateHeightCoversElements,
  28 + isTemplateWithinNativeFastPrintBounds,
  29 +} from './print/templatePhysicalMm'
29 import type { 30 import type {
30 LabelTemplateData, 31 LabelTemplateData,
31 SystemLabelTemplate, 32 SystemLabelTemplate,
@@ -357,10 +358,11 @@ async function printReprintTemplateWithPreviewStrategy ( @@ -357,10 +358,11 @@ async function printReprintTemplateWithPreviewStrategy (
357 await ensureNativeClassicTransportIfPossible() 358 await ensureNativeClassicTransportIfPossible()
358 const templateData = labelTemplateDataForSnapshotReprint() 359 const templateData = labelTemplateDataForSnapshotReprint()
359 const printInputJson: Record<string, unknown> = {} 360 const printInputJson: Record<string, unknown> = {}
360 - const tmplForNative = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) 361 + const tmplSized = ensureTemplateHeightCoversElements(tmpl)
  362 + const tmplForNative = normalizeTemplateForNativeFastJob(tmplSized, printInputJson as any)
361 const useNative = 363 const useNative =
362 canPrintCurrentLabelViaNativeFastJob() 364 canPrintCurrentLabelViaNativeFastJob()
363 - && isTemplateWithinNativeFastPrintBounds(tmpl) 365 + && isTemplateWithinNativeFastPrintBounds(tmplSized)
364 && !templateHasUnsupportedNativeFastElements(tmplForNative) 366 && !templateHasUnsupportedNativeFastElements(tmplForNative)
365 367
366 const printQty = options.printQty ?? 1 368 const printQty = options.printQty ?? 1
@@ -384,7 +386,7 @@ async function printReprintTemplateWithPreviewStrategy ( @@ -384,7 +386,7 @@ async function printReprintTemplateWithPreviewStrategy (
384 } 386 }
385 387
386 await printSystemTemplateForCurrentPrinter( 388 await printSystemTemplateForCurrentPrinter(
387 - tmpl, 389 + tmplSized,
388 templateData, 390 templateData,
389 { printQty, canvasRaster: options.canvasRaster }, 391 { printQty, canvasRaster: options.canvasRaster },
390 options.onProgress, 392 options.onProgress,
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development
1 # 端口号 1 # 端口号
2 -VITE_PORT=17001 2 +VITE_PORT=3100
3 3
4 VITE_BASE=/ 4 VITE_BASE=/
  5 +# vue-router 模式:hash 刷新不依赖服务器 try_files
  6 +VITE_ROUTER_HISTORY=hash
5 # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 7 # 是否开启 Nitro Mock服务,true 为开启,false 为关闭
6 VITE_NITRO_MOCK=false 8 VITE_NITRO_MOCK=false
7 # 是否打开 devtools,true 为打开,false 为关闭 9 # 是否打开 devtools,true 为打开,false 为关闭
@@ -9,9 +11,9 @@ VITE_DEVTOOLS=false @@ -9,9 +11,9 @@ VITE_DEVTOOLS=false
9 # 是否注入全局loading 11 # 是否注入全局loading
10 VITE_INJECT_APP_LOADING=true 12 VITE_INJECT_APP_LOADING=true
11 13
12 -# 后台请求路径 具体在vite.config.mts配置代理  
13 -VITE_GLOB_API_URL="/dev-api"  
14 -VITE_APP_URL="http://flus-test.3ffoodsafety.com/api/app" 14 +# 本地开发:直连线上 API(不走 Vite 代理;VITE_GLOB_API_URL 为绝对地址时 proxy 不启用)
  15 +# 线上:http://saas-test.3ffoodsafety.com/api/app
  16 +VITE_GLOB_API_URL=http://saas-test.3ffoodsafety.com/api/app
15 17
16 # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) 18 # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应)
17 VITE_GLOB_ENABLE_ENCRYPT=false 19 VITE_GLOB_ENABLE_ENCRYPT=false
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production
@@ -7,7 +7,7 @@ VITE_COMPRESS=gzip @@ -7,7 +7,7 @@ VITE_COMPRESS=gzip
7 VITE_PWA=false 7 VITE_PWA=false
8 8
9 # vue-router 的模式 9 # vue-router 的模式
10 -VITE_ROUTER_HISTORY=history 10 +VITE_ROUTER_HISTORY=hash
11 11
12 # 是否注入全局loading 12 # 是否注入全局loading
13 VITE_INJECT_APP_LOADING=true 13 VITE_INJECT_APP_LOADING=true
@@ -15,8 +15,8 @@ VITE_INJECT_APP_LOADING=true @@ -15,8 +15,8 @@ VITE_INJECT_APP_LOADING=true
15 # 打包后是否生成dist.zip 15 # 打包后是否生成dist.zip
16 VITE_ARCHIVER=true 16 VITE_ARCHIVER=true
17 17
18 -# 后端接口地址(ABP 动态 API:/api/app)  
19 -VITE_GLOB_API_URL=http://flus-test.3ffoodsafety.com/api/app 18 +# 生产部署在 saas-test 同域时用相对路径,避免跨域;若前后端不同域再改为完整 URL
  19 +VITE_GLOB_API_URL=/api/app
20 20
21 # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) 21 # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应)
22 VITE_GLOB_ENABLE_ENCRYPT=false 22 VITE_GLOB_ENABLE_ENCRYPT=false
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test
@@ -10,7 +10,7 @@ VITE_COMPRESS=gzip @@ -10,7 +10,7 @@ VITE_COMPRESS=gzip
10 VITE_PWA=false 10 VITE_PWA=false
11 11
12 # vue-router 的模式 12 # vue-router 的模式
13 -VITE_ROUTER_HISTORY=history 13 +VITE_ROUTER_HISTORY=hash
14 14
15 # 是否注入全局loading 15 # 是否注入全局loading
16 VITE_INJECT_APP_LOADING=true 16 VITE_INJECT_APP_LOADING=true
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json
@@ -61,6 +61,7 @@ @@ -61,6 +61,7 @@
61 }, 61 },
62 "devDependencies": { 62 "devDependencies": {
63 "@types/crypto-js": "^4.2.2", 63 "@types/crypto-js": "^4.2.2",
64 - "@types/lodash-es": "^4.17.12" 64 + "@types/lodash-es": "^4.17.12",
  65 + "cssnano": "catalog:"
65 } 66 }
66 } 67 }
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts
@@ -19,6 +19,7 @@ export interface RoleDto { @@ -19,6 +19,7 @@ export interface RoleDto {
19 orderNum?: number | null; 19 orderNum?: number | null;
20 creationTime?: string | null; 20 creationTime?: string | null;
21 accessPermissionCodes?: string[] | null; 21 accessPermissionCodes?: string[] | null;
  22 + menuPermissionKeys?: string[] | null;
22 } 23 }
23 24
24 export interface RoleGetListQuery extends FlPageQuery { 25 export interface RoleGetListQuery extends FlPageQuery {
@@ -145,6 +146,8 @@ export interface TeamMemberDto { @@ -145,6 +146,8 @@ export interface TeamMemberDto {
145 locationIds?: string[] | null; 146 locationIds?: string[] | null;
146 locations?: string[] | null; 147 locations?: string[] | null;
147 state?: boolean | null; 148 state?: boolean | null;
  149 + useCustomMenuPermissions?: boolean | null;
  150 + menuPermissionKeys?: string[] | null;
148 } 151 }
149 152
150 export interface TeamMemberGetListQuery extends FlPageQuery { 153 export interface TeamMemberGetListQuery extends FlPageQuery {
@@ -164,6 +167,8 @@ export interface TeamMemberCreateInput { @@ -164,6 +167,8 @@ export interface TeamMemberCreateInput {
164 regionIds?: string[]; 167 regionIds?: string[];
165 locationIds: string[]; 168 locationIds: string[];
166 state: boolean; 169 state: boolean;
  170 + useCustomMenuPermissions?: boolean;
  171 + menuPermissionKeys?: string[];
167 } 172 }
168 173
169 export interface TeamMemberUpdateInput { 174 export interface TeamMemberUpdateInput {
@@ -177,4 +182,6 @@ export interface TeamMemberUpdateInput { @@ -177,4 +182,6 @@ export interface TeamMemberUpdateInput {
177 regionIds?: string[]; 182 regionIds?: string[];
178 locationIds: string[]; 183 locationIds: string[];
179 state: boolean; 184 state: boolean;
  185 + useCustomMenuPermissions?: boolean;
  186 + menuPermissionKeys?: string[];
180 } 187 }
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts
@@ -19,7 +19,11 @@ export { @@ -19,7 +19,11 @@ export {
19 locationSupportGet, 19 locationSupportGet,
20 locationSupportUpdate, 20 locationSupportUpdate,
21 } from './location-support'; 21 } from './location-support';
22 -export * from './lookups'; 22 +export {
  23 + groupList as lookupGroupList,
  24 + locationList as lookupLocationList,
  25 + productList as lookupProductList,
  26 +} from './lookups';
23 export * from './partner'; 27 export * from './partner';
24 export * from './product'; 28 export * from './product';
25 export * from './product-category'; 29 export * from './product-category';
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts
1 import type { 1 import type {
2 - FlPagedResult,  
3 ProductCategoryCreateInput, 2 ProductCategoryCreateInput,
4 ProductCategoryDto, 3 ProductCategoryDto,
5 ProductCategoryGetListQuery, 4 ProductCategoryGetListQuery,
6 ProductCategoryUpdateInput, 5 ProductCategoryUpdateInput,
7 } from './product-types'; 6 } from './product-types';
  7 +import type { FlPagedResult } from './types';
8 8
9 import { requestClient } from '#/api/request'; 9 import { requestClient } from '#/api/request';
10 10
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts
1 import type { 1 import type {
2 - FlPagedResult,  
3 ProductBatchImportResultDto, 2 ProductBatchImportResultDto,
4 ProductCreateInput, 3 ProductCreateInput,
5 ProductDto, 4 ProductDto,
@@ -7,6 +6,7 @@ import type { @@ -7,6 +6,7 @@ import type {
7 ProductGetListQuery, 6 ProductGetListQuery,
8 ProductUpdateInput, 7 ProductUpdateInput,
9 } from './product-types'; 8 } from './product-types';
  9 +import type { FlPagedResult } from './types';
10 10
11 import { ContentTypeEnum } from '#/api/helper'; 11 import { ContentTypeEnum } from '#/api/helper';
12 import { requestClient } from '#/api/request'; 12 import { requestClient } from '#/api/request';
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts
1 import type { 1 import type {
2 - FlPagedResult,  
3 LabelReportDataDto, 2 LabelReportDataDto,
4 LabelReportQuery, 3 LabelReportQuery,
5 ReportsPrintLogItemDto, 4 ReportsPrintLogItemDto,
@@ -7,6 +6,7 @@ import type { @@ -7,6 +6,7 @@ import type {
7 ReportsTemplatePrintStatItemDto, 6 ReportsTemplatePrintStatItemDto,
8 ReportsTemplatePrintStatQuery, 7 ReportsTemplatePrintStatQuery,
9 } from './reports-types'; 8 } from './reports-types';
  9 +import type { FlPagedResult } from './types';
10 10
11 import { requestClient } from '#/api/request'; 11 import { requestClient } from '#/api/request';
12 12
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts
1 -import { useAppConfig } from '@vben/hooks';  
2 -import { preferences } from '@vben/preferences';  
3 -  
4 -import { unwrapAbpResponse } from '#/api/th/abp-unwrap';  
5 -  
6 /** 绑定门店(与 ThAppLoginOutputDto.locations 一致) */ 1 /** 绑定门店(与 ThAppLoginOutputDto.locations 一致) */
7 export interface ThAppBoundLocationDto { 2 export interface ThAppBoundLocationDto {
8 id: string; 3 id: string;
@@ -28,13 +23,13 @@ export interface ThAppLoginOutputDto { @@ -28,13 +23,13 @@ export interface ThAppLoginOutputDto {
28 locations: ThAppBoundLocationDto[]; 23 locations: ThAppBoundLocationDto[];
29 } 24 }
30 25
31 -const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);  
32 -  
33 /** 防止重复提交导致前一个请求被 Abort、后一个已在 Network 成功但 UI 仍报错 */ 26 /** 防止重复提交导致前一个请求被 Abort、后一个已在 Network 成功但 UI 仍报错 */
34 let loginInFlight: Promise<ThAppLoginOutputDto> | null = null; 27 let loginInFlight: Promise<ThAppLoginOutputDto> | null = null;
35 28
36 function getApiBase(): string { 29 function getApiBase(): string {
37 - const raw = (import.meta.env.VITE_GLOB_API_URL as string | undefined) ?? '/dev-api'; 30 + const raw =
  31 + (import.meta.env.VITE_GLOB_API_URL as string | undefined) ??
  32 + 'http://saas-test.3ffoodsafety.com/api/app';
38 return raw.replace(/^"|"$/g, '').replace(/\/$/, ''); 33 return raw.replace(/^"|"$/g, '').replace(/\/$/, '');
39 } 34 }
40 35
@@ -68,88 +63,56 @@ function normalizeLocationList(raw: unknown): ThAppBoundLocationDto[] { @@ -68,88 +63,56 @@ function normalizeLocationList(raw: unknown): ThAppBoundLocationDto[] {
68 return arr.map((x) => normalizeLocation(x as Record<string, unknown>)); 63 return arr.map((x) => normalizeLocation(x as Record<string, unknown>));
69 } 64 }
70 65
71 -/**  
72 - * 使用 XHR 登录:dev 代理下 fetch/axios 常出现「Network 已有 body 但 JS 一直等连接结束」。  
73 - * XHR onload 在收齐 responseText 后即触发,不依赖 fetch 的 body 流结束。  
74 - */  
75 -function thAppLoginXhr(input: ThAppLoginInput): Promise<ThAppLoginOutputDto> {  
76 - const url = `${getApiBase()}/th-app-auth/login`;  
77 - const language = preferences.app.locale.replace('-', '_');  
78 - const body = JSON.stringify({  
79 - tenantId: input.tenantId,  
80 - email: input.email.trim(),  
81 - password: input.password,  
82 - ...(input.uuid ? { uuid: input.uuid } : {}),  
83 - ...(input.code != null && input.code !== '' ? { code: input.code } : {}),  
84 - });  
85 -  
86 - return new Promise((resolve, reject) => {  
87 - const xhr = new XMLHttpRequest();  
88 - xhr.open('POST', url, true);  
89 - xhr.timeout = 60_000;  
90 - xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8');  
91 - xhr.setRequestHeader('Accept', 'application/json');  
92 - xhr.setRequestHeader('Accept-Language', language);  
93 - xhr.setRequestHeader('Content-Language', language);  
94 - if (clientId) {  
95 - xhr.setRequestHeader('ClientID', clientId);  
96 - }  
97 -  
98 - xhr.onload = () => {  
99 - try {  
100 - const text = xhr.responseText ?? '';  
101 - const json = text ? JSON.parse(text) : null;  
102 - if (xhr.status < 200 || xhr.status >= 300) {  
103 - try {  
104 - unwrapAbpResponse(json);  
105 - } catch (e) {  
106 - reject(e instanceof Error ? e : new Error(`登录失败 HTTP ${xhr.status}`));  
107 - return;  
108 - }  
109 - reject(new Error(`登录失败 HTTP ${xhr.status}`));  
110 - return;  
111 - }  
112 - const data = unwrapAbpResponse<unknown>(json);  
113 - resolve(normalizeLoginOutput(data));  
114 - } catch (e) {  
115 - reject(  
116 - e instanceof Error  
117 - ? e  
118 - : new Error('登录响应解析失败,请查看 Network 中 login 的 Response'),  
119 - );  
120 - }  
121 - };  
122 -  
123 - xhr.onerror = () => {  
124 - reject(  
125 - new Error(  
126 - '登录网络异常:请确认 dev 服务已启动且代理地址正确(/dev-api → saas-test)',  
127 - ),  
128 - );  
129 - };  
130 -  
131 - xhr.ontimeout = () => {  
132 - reject(  
133 - new Error(  
134 - '登录请求超时:若 Network 中已有 token 响应,请刷新页面后只点一次登录',  
135 - ),  
136 - );  
137 - };  
138 -  
139 - xhr.send(body);  
140 - }); 66 +function loginNetworkError(): Error {
  67 + return new Error(
  68 + `登录网络异常:无法连接 ${getApiBase()},请检查网络、后端是否可用,以及是否已配置 CORS 允许本地前端域名`,
  69 + );
141 } 70 }
142 71
143 -/** POST /api/app/th-app-auth/login(匿名) */ 72 +/**
  73 + * POST /api/app/th-app-auth/login(匿名)
  74 + * 与租户下拉一致,走 requestClient 直连 VITE_GLOB_API_URL(不再使用 XHR + dev 代理)
  75 + */
144 export async function thAppLogin( 76 export async function thAppLogin(
145 input: ThAppLoginInput, 77 input: ThAppLoginInput,
146 ): Promise<ThAppLoginOutputDto> { 78 ): Promise<ThAppLoginOutputDto> {
147 if (loginInFlight) { 79 if (loginInFlight) {
148 return loginInFlight; 80 return loginInFlight;
149 } 81 }
150 - loginInFlight = thAppLoginXhr(input).finally(() => { 82 +
  83 + loginInFlight = (async () => {
  84 + const { requestClient } = await import('#/api/request');
  85 + try {
  86 + const raw = await requestClient.post<unknown>(
  87 + 'th-app-auth/login',
  88 + {
  89 + tenantId: input.tenantId,
  90 + email: input.email.trim(),
  91 + password: input.password,
  92 + ...(input.uuid ? { uuid: input.uuid } : {}),
  93 + ...(input.code != null && input.code !== ''
  94 + ? { code: input.code }
  95 + : {}),
  96 + },
  97 + {
  98 + errorMessageMode: 'none',
  99 + successMessageMode: 'none',
  100 + headers: {
  101 + __tenant: input.tenantId,
  102 + },
  103 + },
  104 + );
  105 + return normalizeLoginOutput(raw);
  106 + } catch (error) {
  107 + if (error instanceof Error && error.message.trim()) {
  108 + throw error;
  109 + }
  110 + throw loginNetworkError();
  111 + }
  112 + })().finally(() => {
151 loginInFlight = null; 113 loginInFlight = null;
152 }); 114 });
  115 +
153 return loginInFlight; 116 return loginInFlight;
154 } 117 }
155 118
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json
@@ -19,7 +19,9 @@ @@ -19,7 +19,9 @@
19 "specifiedCount": "{count} location(s)", 19 "specifiedCount": "{count} location(s)",
20 "codeOptional": "Leave empty to auto-generate", 20 "codeOptional": "Leave empty to auto-generate",
21 "staticDemoBanner": "Static demo mode: no API calls. Set FOOD_LABELING_STATIC_ONLY to false in shared/static-mode.ts when integrating APIs.", 21 "staticDemoBanner": "Static demo mode: no API calls. Set FOOD_LABELING_STATIC_ONLY to false in shared/static-mode.ts when integrating APIs.",
22 - "staticDemoAction": "Static demo: action simulated (no API call)" 22 + "staticDemoAction": "Static demo: action simulated (no API call)",
  23 + "yes": "Yes",
  24 + "no": "No"
23 }, 25 },
24 "labeling": { 26 "labeling": {
25 "root": "Labeling", 27 "root": "Labeling",
@@ -244,6 +246,60 @@ @@ -244,6 +246,60 @@
244 } 246 }
245 } 247 }
246 }, 248 },
  249 + "platform": {
  250 + "section": "Platform",
  251 + "tenants": "SAAS Companies",
  252 + "saasBanner": "Platform admin: provision SAAS companies (separate DB tenants), assign company-level menus, and create company admin accounts.",
  253 + "addCompany": "Add Company",
  254 + "editCompany": "Edit Company",
  255 + "companyName": "Company Name",
  256 + "companyCode": "Company Code",
  257 + "logoUrl": "Logo URL",
  258 + "logo": "Company Logo",
  259 + "logoUpload": "Upload Logo",
  260 + "logoUploadImageOnly": "Images only",
  261 + "logoUploadMaxSize": "Image must be under 2MB",
  262 + "contactName": "Contact",
  263 + "address": "Address",
  264 + "tenantAdmin": "Company Admin",
  265 + "menuPermissions": "Menu Permissions",
  266 + "configureMenus": "Configure Menus",
  267 + "configureMenusHint": "Select menus available to \"{name}\". Roles and users cannot exceed this set.",
  268 + "menuSaved": "Company menu permissions saved",
  269 + "manageTenantAdmin": "Company Admin · {name}",
  270 + "tenantAdminHint": "Company admins manage regions, locations, users and roles within assigned menus.",
  271 + "initialAdminSection": "Initial Company Admin",
  272 + "enterAsAdmin": "Enter as Admin",
  273 + "backToPlatform": "Back to Platform",
  274 + "deleteCompanyConfirm": "Delete this company (tenant)? Demo only until API is wired."
  275 + },
  276 + "saas": {
  277 + "menu": {
  278 + "dashboard": "Overview",
  279 + "analytics": "Home",
  280 + "labeling": "Labeling",
  281 + "labels": "Labels",
  282 + "labelCategories": "Label Categories",
  283 + "labelTypes": "Label Types",
  284 + "labelTemplates": "Label Templates",
  285 + "multipleOptions": "Multiple Options",
  286 + "modules": "Modules",
  287 + "training": "Training",
  288 + "alerts": "Alerts",
  289 + "tasks": "Tasks",
  290 + "foodWaste": "Food Waste",
  291 + "eLabel": "E-Label",
  292 + "management": "Management",
  293 + "accountManagement": "Account Management",
  294 + "menuManagement": "Menu Management",
  295 + "devices": "Devices",
  296 + "reports": "Reports",
  297 + "invoices": "Invoices",
  298 + "qrCodes": "QR Codes",
  299 + "support": "Support",
  300 + "api": "API Settings"
  301 + }
  302 + },
247 "management": { 303 "management": {
248 "section": "Management", 304 "section": "Management",
249 "locationManager": "Location Manager", 305 "locationManager": "Location Manager",
@@ -293,7 +349,13 @@ @@ -293,7 +349,13 @@
293 "permEditSettings": "Edit Settings", 349 "permEditSettings": "Edit Settings",
294 "permManageProducts": "Manage Products", 350 "permManageProducts": "Manage Products",
295 "permViewReports": "View Reports", 351 "permViewReports": "View Reports",
296 - "permApproveBatches": "Approve Batches" 352 + "permApproveBatches": "Approve Batches",
  353 + "menuPermissions": "Menu Permissions",
  354 + "useCustomMenus": "Custom Menus",
  355 + "userMenuOverride": "User Menus",
  356 + "inheritRoleMenus": "Inherit role",
  357 + "tenantAdminBanner": "Managing as company admin for \"{company}\". Menu permissions cannot exceed company provisioned menus.",
  358 + "platformOrTenantHint": "Tenant org: Roles → Partner companies → Regions → Locations → Team members. Provision SAAS companies under Platform > SAAS Companies."
297 }, 359 },
298 "menuManagement": { 360 "menuManagement": {
299 "tabProducts": "Products", 361 "tabProducts": "Products",
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json
@@ -19,7 +19,9 @@ @@ -19,7 +19,9 @@
19 "specifiedCount": "指定 {count} 个门店", 19 "specifiedCount": "指定 {count} 个门店",
20 "codeOptional": "留空则由后端生成", 20 "codeOptional": "留空则由后端生成",
21 "staticDemoBanner": "当前为静态演示模式,未请求后端接口。联调时将 shared/static-mode.ts 中 FOOD_LABELING_STATIC_ONLY 改为 false。", 21 "staticDemoBanner": "当前为静态演示模式,未请求后端接口。联调时将 shared/static-mode.ts 中 FOOD_LABELING_STATIC_ONLY 改为 false。",
22 - "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)" 22 + "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)",
  23 + "yes": "是",
  24 + "no": "否"
23 }, 25 },
24 "labeling": { 26 "labeling": {
25 "root": "标签管理", 27 "root": "标签管理",
@@ -244,6 +246,60 @@ @@ -244,6 +246,60 @@
244 } 246 }
245 } 247 }
246 }, 248 },
  249 + "platform": {
  250 + "section": "平台管理",
  251 + "tenants": "SAAS 公司",
  252 + "saasBanner": "平台管理员:在此开通 SAAS 公司(独立库租户)、配置公司级菜单,并创建公司管理员账号。公司管理员登录后可在「账户管理」中维护区域、门店与成员。",
  253 + "addCompany": "开通公司",
  254 + "editCompany": "编辑公司",
  255 + "companyName": "公司名称",
  256 + "companyCode": "公司编码",
  257 + "logoUrl": "Logo 地址",
  258 + "logo": "公司 Logo",
  259 + "logoUpload": "上传 Logo",
  260 + "logoUploadImageOnly": "仅支持图片文件",
  261 + "logoUploadMaxSize": "图片不能超过 2MB",
  262 + "contactName": "联系人",
  263 + "address": "地址",
  264 + "tenantAdmin": "公司管理员",
  265 + "menuPermissions": "菜单权限",
  266 + "configureMenus": "配置公司菜单",
  267 + "configureMenusHint": "为「{name}」勾选可使用的系统菜单(公司内角色与用户权限不能超过此范围)。",
  268 + "menuSaved": "公司菜单权限已保存",
  269 + "manageTenantAdmin": "管理公司管理员 · {name}",
  270 + "tenantAdminHint": "公司管理员拥有该公司下区域、门店、用户与角色配置权限(在其公司菜单范围内)。",
  271 + "initialAdminSection": "首任公司管理员",
  272 + "enterAsAdmin": "进入该公司",
  273 + "backToPlatform": "返回平台管理",
  274 + "deleteCompanyConfirm": "确认删除该公司(租户)吗?此操作仅演示,联调后走后端接口。"
  275 + },
  276 + "saas": {
  277 + "menu": {
  278 + "dashboard": "概览",
  279 + "analytics": "首页概览",
  280 + "labeling": "标签管理",
  281 + "labels": "标签",
  282 + "labelCategories": "标签分类",
  283 + "labelTypes": "标签类型",
  284 + "labelTemplates": "标签模板",
  285 + "multipleOptions": "多选选项集",
  286 + "modules": "业务模块",
  287 + "training": "培训",
  288 + "alerts": "告警",
  289 + "tasks": "任务",
  290 + "foodWaste": "食物浪费",
  291 + "eLabel": "电子标签",
  292 + "management": "管理",
  293 + "accountManagement": "账户管理",
  294 + "menuManagement": "菜单管理",
  295 + "devices": "设备",
  296 + "reports": "报表",
  297 + "invoices": "发票",
  298 + "qrCodes": "二维码",
  299 + "support": "支持",
  300 + "api": "API 设置"
  301 + }
  302 + },
247 "management": { 303 "management": {
248 "section": "管理", 304 "section": "管理",
249 "locationManager": "门店管理", 305 "locationManager": "门店管理",
@@ -293,7 +349,13 @@ @@ -293,7 +349,13 @@
293 "permEditSettings": "编辑设置", 349 "permEditSettings": "编辑设置",
294 "permManageProducts": "管理产品", 350 "permManageProducts": "管理产品",
295 "permViewReports": "查看报表", 351 "permViewReports": "查看报表",
296 - "permApproveBatches": "审批批次" 352 + "permApproveBatches": "审批批次",
  353 + "menuPermissions": "菜单权限",
  354 + "useCustomMenus": "自定义菜单",
  355 + "userMenuOverride": "用户菜单",
  356 + "inheritRoleMenus": "继承角色",
  357 + "tenantAdminBanner": "当前以「{company}」公司管理员身份配置:可管理角色、业务公司、区域、门店与成员;菜单权限不能超过公司已开通范围。",
  358 + "platformOrTenantHint": "租户内组织架构:角色 → 业务公司(Partner) → 区域 → 门店 → 团队成员。平台开通 SAAS 公司请前往「平台管理 > SAAS 公司」。"
297 }, 359 },
298 "menuManagement": { 360 "menuManagement": {
299 "tabProducts": "产品", 361 "tabProducts": "产品",
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts
@@ -215,6 +215,28 @@ export const localMenuList: RouteRecordStringComponent[] = [ @@ -215,6 +215,28 @@ export const localMenuList: RouteRecordStringComponent[] = [
215 }, 215 },
216 { 216 {
217 meta: { 217 meta: {
  218 + icon: 'lucide:cloud-cog',
  219 + order: 5,
  220 + title: 'foodLabeling.platform.section',
  221 + },
  222 + name: 'FoodLabelingPlatform',
  223 + path: '/platform',
  224 + redirect: '/platform/tenants',
  225 + children: [
  226 + {
  227 + name: 'FoodLabelingPlatformTenants',
  228 + path: '/platform/tenants',
  229 + component: '/food-labeling/platform/tenants/index',
  230 + meta: {
  231 + icon: 'lucide:building',
  232 + title: 'foodLabeling.platform.tenants',
  233 + ...managementRouteMeta,
  234 + },
  235 + },
  236 + ],
  237 + },
  238 + {
  239 + meta: {
218 icon: 'lucide:building-2', 240 icon: 'lucide:building-2',
219 order: 20, 241 order: 20,
220 title: 'foodLabeling.management.section', 242 title: 'foodLabeling.management.section',
@@ -224,15 +246,6 @@ export const localMenuList: RouteRecordStringComponent[] = [ @@ -224,15 +246,6 @@ export const localMenuList: RouteRecordStringComponent[] = [
224 redirect: '/account-management', 246 redirect: '/account-management',
225 children: [ 247 children: [
226 { 248 {
227 - name: 'FoodLabelingLocationManager',  
228 - path: '/location-manager',  
229 - redirect: { path: '/account-management', query: { tab: 'locations' } },  
230 - meta: {  
231 - icon: 'lucide:map-pin',  
232 - title: 'foodLabeling.management.locationManager',  
233 - },  
234 - },  
235 - {  
236 name: 'FoodLabelingAccountManagement', 249 name: 'FoodLabelingAccountManagement',
237 path: '/account-management', 250 path: '/account-management',
238 component: '/food-labeling/management/account-management/index', 251 component: '/food-labeling/management/account-management/index',
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts
@@ -138,6 +138,16 @@ export const useAuthStore = defineStore(&#39;auth&#39;, () =&gt; { @@ -138,6 +138,16 @@ export const useAuthStore = defineStore(&#39;auth&#39;, () =&gt; {
138 return cached; 138 return cached;
139 } 139 }
140 140
  141 + // 泰额 SAAS:用户信息来自 th-app-auth/login,不走 Yi 框架 /account
  142 + if (thTenantStore.tenantId && accessStore.accessToken) {
  143 + const fallback = buildUserInfoFromLogin(
  144 + cached?.email || cached?.username || 'user',
  145 + thTenantStore.tenantName || '',
  146 + );
  147 + userStore.setUserInfo(fallback);
  148 + return fallback;
  149 + }
  150 +
141 try { 151 try {
142 const { getUserInfoApi } = await import('#/api'); 152 const { getUserInfoApi } = await import('#/api');
143 const backUserInfo = await getUserInfoApi(); 153 const backUserInfo = await getUserInfoApi();
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/saas-context.ts 0 → 100644
  1 +import { acceptHMRUpdate, defineStore } from 'pinia';
  2 +
  3 +import { MOCK_SAAS_TENANTS } from '#/views/food-labeling/shared/mock-platform-data';
  4 +
  5 +/** 当前登录身份(前端 Mock;联调后由后端 JWT / 用户信息注入) */
  6 +export type SaasActorType = 'platform' | 'tenant_admin' | 'tenant_user';
  7 +
  8 +interface SaasContextState {
  9 + actorType: SaasActorType;
  10 + tenantId: string | null;
  11 + tenantName: string | null;
  12 + /** 当前租户已开通的菜单 key(公司管理员及其下属授权的上限) */
  13 + tenantMenuKeys: string[];
  14 +}
  15 +
  16 +/**
  17 + * SAAS 权限上下文:平台管理员 vs 公司(租户)管理员
  18 + */
  19 +export const useSaasContextStore = defineStore('food-saas-context', {
  20 + actions: {
  21 + /** 模拟以某公司管理员身份进入系统 */
  22 + impersonateTenantAdmin(tenantId: string) {
  23 + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === tenantId);
  24 + if (!tenant) {
  25 + return;
  26 + }
  27 + this.actorType = 'tenant_admin';
  28 + this.tenantId = tenant.id;
  29 + this.tenantName = tenant.companyName;
  30 + this.tenantMenuKeys = [...tenant.menuPermissionKeys];
  31 + },
  32 + resetToPlatformAdmin() {
  33 + this.actorType = 'platform';
  34 + this.tenantId = null;
  35 + this.tenantName = null;
  36 + this.tenantMenuKeys = [];
  37 + },
  38 + setTenantMenuKeys(keys: string[]) {
  39 + this.tenantMenuKeys = keys;
  40 + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === this.tenantId);
  41 + if (tenant) {
  42 + tenant.menuPermissionKeys = [...keys];
  43 + }
  44 + },
  45 + },
  46 + getters: {
  47 + isPlatformAdmin: (state) => state.actorType === 'platform',
  48 + isTenantAdmin: (state) => state.actorType === 'tenant_admin',
  49 + /** 角色/用户配置菜单权限时的可选上限 */
  50 + allowedMenuKeys(state): string[] {
  51 + if (state.actorType === 'platform') {
  52 + return [];
  53 + }
  54 + return state.tenantMenuKeys;
  55 + },
  56 + },
  57 + state: (): SaasContextState => ({
  58 + actorType: 'platform',
  59 + tenantId: null,
  60 + tenantName: null,
  61 + tenantMenuKeys: [],
  62 + }),
  63 +});
  64 +
  65 +const hot = import.meta.hot;
  66 +if (hot) {
  67 + hot.accept(acceptHMRUpdate(useSaasContextStore, hot));
  68 +}
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue
@@ -26,6 +26,7 @@ import { @@ -26,6 +26,7 @@ import {
26 MOCK_TEMPLATE_PRINT_STATS, 26 MOCK_TEMPLATE_PRINT_STATS,
27 } from '../shared/mock-management-data'; 27 } from '../shared/mock-management-data';
28 import { FOOD_LABELING_STATIC_ONLY } from '../shared/static-mode'; 28 import { FOOD_LABELING_STATIC_ONLY } from '../shared/static-mode';
  29 +import { managementTabPageContentClass } from '../management/shared/management-grid';
29 import DashboardCategoryChart from './dashboard-category-chart.vue'; 30 import DashboardCategoryChart from './dashboard-category-chart.vue';
30 import DashboardKpiCard from './dashboard-kpi-card.vue'; 31 import DashboardKpiCard from './dashboard-kpi-card.vue';
31 import DashboardWeeklyChart from './dashboard-weekly-chart.vue'; 32 import DashboardWeeklyChart from './dashboard-weekly-chart.vue';
@@ -308,10 +309,10 @@ function goReports() { @@ -308,10 +309,10 @@ function goReports() {
308 </script> 309 </script>
309 310
310 <template> 311 <template>
311 - <Page> 312 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
312 <a-alert 313 <a-alert
313 v-if="FOOD_LABELING_STATIC_ONLY" 314 v-if="FOOD_LABELING_STATIC_ONLY"
314 - class="mb-4" 315 + class="mb-4 shrink-0"
315 :message="$t('foodLabeling.common.staticDemoBanner')" 316 :message="$t('foodLabeling.common.staticDemoBanner')"
316 show-icon 317 show-icon
317 type="info" 318 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-categories/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -52,14 +53,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -52,14 +53,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
52 minWidth: 140, 53 minWidth: 140,
53 slots: { default: 'lastEdited' }, 54 slots: { default: 'lastEdited' },
54 }, 55 },
55 - {  
56 - field: 'action',  
57 - fixed: 'right',  
58 - slots: { default: 'action' },  
59 - title: $t('foodLabeling.common.action'),  
60 - width: 140,  
61 - resizable: false,  
62 - }, 56 + foodLabelingActionColumn(),
63 ]; 57 ];
64 58
65 export function modalSchemaBasic(options: { 59 export function modalSchemaBasic(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-categories/index.vue
@@ -14,8 +14,11 @@ import { Alert, message, Popconfirm, Space, Tag } from &#39;ant-design-vue&#39;; @@ -14,8 +14,11 @@ import { Alert, message, Popconfirm, Space, Tag } from &#39;ant-design-vue&#39;;
14 14
15 import { useVbenVxeGrid } from '#/adapter/vxe-table'; 15 import { useVbenVxeGrid } from '#/adapter/vxe-table';
16 16
  17 +import { buildManagementTabFormOptions } from '../../management/shared/management-grid';
17 import { 18 import {
18 labelingGridBase, 19 labelingGridBase,
  20 + labelingPageContentClass,
  21 + labelingTableClass,
19 labelingVxeGridClass, 22 labelingVxeGridClass,
20 } from '../../shared/labeling-grid'; 23 } from '../../shared/labeling-grid';
21 import { MOCK_LABEL_CATEGORIES } from '../../shared/mock-data'; 24 import { MOCK_LABEL_CATEGORIES } from '../../shared/mock-data';
@@ -33,14 +36,7 @@ function displayText(v: string | null | undefined) { @@ -33,14 +36,7 @@ function displayText(v: string | null | undefined) {
33 return s || $t('foodLabeling.common.none'); 36 return s || $t('foodLabeling.common.none');
34 } 37 }
35 38
36 -const formOptions: VbenFormProps = {  
37 - commonConfig: {  
38 - labelWidth: 90,  
39 - componentProps: { allowClear: true },  
40 - },  
41 - schema: querySchema(),  
42 - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',  
43 -}; 39 +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema());
44 40
45 const gridOptions: VxeGridProps = { 41 const gridOptions: VxeGridProps = {
46 ...labelingGridBase, 42 ...labelingGridBase,
@@ -133,15 +129,15 @@ async function handleDelete(row: Recordable&lt;LabelCategoryDto&gt;) { @@ -133,15 +129,15 @@ async function handleDelete(row: Recordable&lt;LabelCategoryDto&gt;) {
133 </script> 129 </script>
134 130
135 <template> 131 <template>
136 - <Page> 132 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
137 <Alert 133 <Alert
138 v-if="FOOD_LABELING_STATIC_ONLY" 134 v-if="FOOD_LABELING_STATIC_ONLY"
139 - class="mb-3" 135 + class="mb-3 shrink-0"
140 :message="$t('foodLabeling.common.staticDemoBanner')" 136 :message="$t('foodLabeling.common.staticDemoBanner')"
141 show-icon 137 show-icon
142 type="info" 138 type="info"
143 /> 139 />
144 - <BasicTable> 140 + <BasicTable :class="labelingTableClass">
145 <template #toolbar-tools> 141 <template #toolbar-tools>
146 <Space> 142 <Space>
147 <a-button type="primary" @click="handleAdd"> 143 <a-button type="primary" @click="handleAdd">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 import { 7 import {
7 keywordFilterField, 8 keywordFilterField,
8 stateFilterField, 9 stateFilterField,
@@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
83 minWidth: 120, 84 minWidth: 120,
84 slots: { default: 'sizeText' }, 85 slots: { default: 'sizeText' },
85 }, 86 },
86 - {  
87 - field: 'action',  
88 - fixed: 'right',  
89 - slots: { default: 'action' },  
90 - title: $t('foodLabeling.common.action'),  
91 - width: 140,  
92 - resizable: false,  
93 - }, 87 + foodLabelingActionColumn(),
94 ]; 88 ];
95 89
96 export function modalSchema(options: { 90 export function modalSchema(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/index.vue
@@ -20,6 +20,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -20,6 +20,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
20 20
21 import { 21 import {
22 labelingGridBase, 22 labelingGridBase,
  23 + labelingPageContentClass,
  24 + labelingTableClass,
23 labelingVxeGridClass, 25 labelingVxeGridClass,
24 } from '../../shared/labeling-grid'; 26 } from '../../shared/labeling-grid';
25 import { MOCK_LABEL_TEMPLATES } from '../../shared/mock-data'; 27 import { MOCK_LABEL_TEMPLATES } from '../../shared/mock-data';
@@ -76,6 +78,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -76,6 +78,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
76 locationOptions: lookups.locationOptions.value, 78 locationOptions: lookups.locationOptions.value,
77 })(), 79 })(),
78 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 80 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
  81 + collapseTriggerResize: false,
79 })); 82 }));
80 83
81 const gridOptions: VxeGridProps = { 84 const gridOptions: VxeGridProps = {
@@ -158,15 +161,15 @@ async function handleDelete(row: Recordable&lt;LabelTemplateDto&gt;) { @@ -158,15 +161,15 @@ async function handleDelete(row: Recordable&lt;LabelTemplateDto&gt;) {
158 </script> 161 </script>
159 162
160 <template> 163 <template>
161 - <Page> 164 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
162 <a-alert 165 <a-alert
163 v-if="FOOD_LABELING_STATIC_ONLY" 166 v-if="FOOD_LABELING_STATIC_ONLY"
164 - class="mb-3" 167 + class="mb-3 shrink-0"
165 :message="$t('foodLabeling.common.staticDemoBanner')" 168 :message="$t('foodLabeling.common.staticDemoBanner')"
166 show-icon 169 show-icon
167 type="info" 170 type="info"
168 /> 171 />
169 - <BasicTable> 172 + <BasicTable :class="labelingTableClass">
170 <template #toolbar-tools> 173 <template #toolbar-tools>
171 <Space> 174 <Space>
172 <a-button type="primary" @click="handleAdd"> 175 <a-button type="primary" @click="handleAdd">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-types/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 import { 7 import {
7 keywordFilterField, 8 keywordFilterField,
8 stateFilterField, 9 stateFilterField,
@@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
83 minWidth: 140, 84 minWidth: 140,
84 slots: { default: 'lastEdited' }, 85 slots: { default: 'lastEdited' },
85 }, 86 },
86 - {  
87 - field: 'action',  
88 - fixed: 'right',  
89 - slots: { default: 'action' },  
90 - title: $t('foodLabeling.common.action'),  
91 - width: 140,  
92 - resizable: false,  
93 - }, 87 + foodLabelingActionColumn(),
94 ]; 88 ];
95 89
96 export function modalSchema(options: { 90 export function modalSchema(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-types/index.vue
@@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
19 19
20 import { 20 import {
21 labelingGridBase, 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 labelingVxeGridClass, 24 labelingVxeGridClass,
23 } from '../../shared/labeling-grid'; 25 } from '../../shared/labeling-grid';
24 import { MOCK_LABEL_TYPES } from '../../shared/mock-data'; 26 import { MOCK_LABEL_TYPES } from '../../shared/mock-data';
@@ -54,6 +56,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -54,6 +56,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
54 locationOptions: lookups.locationOptions.value, 56 locationOptions: lookups.locationOptions.value,
55 })(), 57 })(),
56 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 58 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
  59 + collapseTriggerResize: false,
57 })); 60 }));
58 61
59 const gridOptions: VxeGridProps = { 62 const gridOptions: VxeGridProps = {
@@ -132,15 +135,15 @@ async function handleDelete(row: Recordable&lt;LabelTypeDto&gt;) { @@ -132,15 +135,15 @@ async function handleDelete(row: Recordable&lt;LabelTypeDto&gt;) {
132 </script> 135 </script>
133 136
134 <template> 137 <template>
135 - <Page> 138 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
136 <a-alert 139 <a-alert
137 v-if="FOOD_LABELING_STATIC_ONLY" 140 v-if="FOOD_LABELING_STATIC_ONLY"
138 - class="mb-3" 141 + class="mb-3 shrink-0"
139 :message="$t('foodLabeling.common.staticDemoBanner')" 142 :message="$t('foodLabeling.common.staticDemoBanner')"
140 show-icon 143 show-icon
141 type="info" 144 type="info"
142 /> 145 />
143 - <BasicTable> 146 + <BasicTable :class="labelingTableClass">
144 <template #toolbar-tools> 147 <template #toolbar-tools>
145 <Space> 148 <Space>
146 <a-button type="primary" @click="handleAdd"> 149 <a-button type="primary" @click="handleAdd">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/labels/data.ts
@@ -3,6 +3,8 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,8 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
  7 +
6 import type { SelectOption } from './use-label-lookups'; 8 import type { SelectOption } from './use-label-lookups';
7 9
8 export function querySchema(options: { 10 export function querySchema(options: {
@@ -125,14 +127,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -125,14 +127,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
125 minWidth: 150, 127 minWidth: 150,
126 slots: { default: 'lastEdited' }, 128 slots: { default: 'lastEdited' },
127 }, 129 },
128 - {  
129 - field: 'action',  
130 - fixed: 'right',  
131 - slots: { default: 'action' },  
132 - title: $t('foodLabeling.labels.action'),  
133 - width: 140,  
134 - resizable: false,  
135 - }, 130 + foodLabelingActionColumn({ title: $t('foodLabeling.labels.action') }),
136 ]; 131 ];
137 132
138 export function modalSchema(options: { 133 export function modalSchema(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/labels/index.vue
@@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
19 19
20 import { 20 import {
21 labelingGridBase, 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 labelingVxeGridClass, 24 labelingVxeGridClass,
23 } from '../../shared/labeling-grid'; 25 } from '../../shared/labeling-grid';
24 import { MOCK_LABELS } from '../../shared/mock-data'; 26 import { MOCK_LABELS } from '../../shared/mock-data';
@@ -50,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -50,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
50 templateOptions: lookups.templateOptions.value, 52 templateOptions: lookups.templateOptions.value,
51 })(), 53 })(),
52 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 54 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
  55 + collapseTriggerResize: false,
53 })); 56 }));
54 57
55 const gridOptions: VxeGridProps = { 58 const gridOptions: VxeGridProps = {
@@ -156,15 +159,15 @@ async function handleDelete(row: Recordable&lt;LabelDto&gt;) { @@ -156,15 +159,15 @@ async function handleDelete(row: Recordable&lt;LabelDto&gt;) {
156 </script> 159 </script>
157 160
158 <template> 161 <template>
159 - <Page> 162 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
160 <a-alert 163 <a-alert
161 v-if="FOOD_LABELING_STATIC_ONLY" 164 v-if="FOOD_LABELING_STATIC_ONLY"
162 - class="mb-3" 165 + class="mb-3 shrink-0"
163 :message="$t('foodLabeling.common.staticDemoBanner')" 166 :message="$t('foodLabeling.common.staticDemoBanner')"
164 show-icon 167 show-icon
165 type="info" 168 type="info"
166 /> 169 />
167 - <BasicTable> 170 + <BasicTable :class="labelingTableClass">
168 <template #toolbar-tools> 171 <template #toolbar-tools>
169 <Space> 172 <Space>
170 <a-button type="primary" @click="handleAdd"> 173 <a-button type="primary" @click="handleAdd">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/multiple-options/data.ts
@@ -7,6 +7,7 @@ import { $t } from &#39;@vben/locales&#39;; @@ -7,6 +7,7 @@ import { $t } from &#39;@vben/locales&#39;;
7 7
8 import { z } from '#/adapter/form'; 8 import { z } from '#/adapter/form';
9 9
  10 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
10 import OptionValuesEditor from './option-values-editor.vue'; 11 import OptionValuesEditor from './option-values-editor.vue';
11 12
12 import { 13 import {
@@ -77,14 +78,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -77,14 +78,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
77 minWidth: 140, 78 minWidth: 140,
78 slots: { default: 'lastEdited' }, 79 slots: { default: 'lastEdited' },
79 }, 80 },
80 - {  
81 - field: 'action',  
82 - fixed: 'right',  
83 - slots: { default: 'action' },  
84 - title: $t('foodLabeling.common.action'),  
85 - width: 140,  
86 - resizable: false,  
87 - }, 81 + foodLabelingActionColumn(),
88 ]; 82 ];
89 83
90 export function modalSchemaBasic(options: { 84 export function modalSchemaBasic(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/multiple-options/index.vue
@@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
19 19
20 import { 20 import {
21 labelingGridBase, 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 labelingVxeGridClass, 24 labelingVxeGridClass,
23 } from '../../shared/labeling-grid'; 25 } from '../../shared/labeling-grid';
24 import { MOCK_MULTIPLE_OPTIONS } from '../../shared/mock-data'; 26 import { MOCK_MULTIPLE_OPTIONS } from '../../shared/mock-data';
@@ -62,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -62,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
62 locationOptions: lookups.locationOptions.value, 64 locationOptions: lookups.locationOptions.value,
63 })(), 65 })(),
64 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 66 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
  67 + collapseTriggerResize: false,
65 })); 68 }));
66 69
67 const gridOptions: VxeGridProps = { 70 const gridOptions: VxeGridProps = {
@@ -144,15 +147,15 @@ async function handleDelete(row: Recordable&lt;LabelMultipleOptionDto&gt;) { @@ -144,15 +147,15 @@ async function handleDelete(row: Recordable&lt;LabelMultipleOptionDto&gt;) {
144 </script> 147 </script>
145 148
146 <template> 149 <template>
147 - <Page> 150 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
148 <a-alert 151 <a-alert
149 v-if="FOOD_LABELING_STATIC_ONLY" 152 v-if="FOOD_LABELING_STATIC_ONLY"
150 - class="mb-3" 153 + class="mb-3 shrink-0"
151 :message="$t('foodLabeling.common.staticDemoBanner')" 154 :message="$t('foodLabeling.common.staticDemoBanner')"
152 show-icon 155 show-icon
153 type="info" 156 type="info"
154 /> 157 />
155 - <BasicTable> 158 + <BasicTable :class="labelingTableClass">
156 <template #toolbar-tools> 159 <template #toolbar-tools>
157 <Space> 160 <Space>
158 <a-button type="primary" @click="handleAdd"> 161 <a-button type="primary" @click="handleAdd">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/groups/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -54,14 +55,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -54,14 +55,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
54 minWidth: 140, 55 minWidth: 140,
55 slots: { default: 'creationTime' }, 56 slots: { default: 'creationTime' },
56 }, 57 },
57 - {  
58 - field: 'action',  
59 - fixed: 'right',  
60 - slots: { default: 'action' },  
61 - title: $t('foodLabeling.common.action'),  
62 - width: 140,  
63 - resizable: false,  
64 - }, 58 + foodLabelingActionColumn(),
65 ]; 59 ];
66 60
67 export function modalSchema(options: { 61 export function modalSchema(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/groups/index.vue
@@ -26,6 +26,7 @@ import { @@ -26,6 +26,7 @@ import {
26 import { useAccountLookups } from '../../../shared/use-account-lookups'; 26 import { useAccountLookups } from '../../../shared/use-account-lookups';
27 import { 27 import {
28 managementTabGridBase, 28 managementTabGridBase,
  29 + managementTabTableClass,
29 managementTabVxeGridClass, 30 managementTabVxeGridClass,
30 } from '../../shared/management-grid'; 31 } from '../../shared/management-grid';
31 import { columns, querySchema } from './data'; 32 import { columns, querySchema } from './data';
@@ -51,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -51,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
51 partnerOptions: accountLookups.partnerOptions.value, 52 partnerOptions: accountLookups.partnerOptions.value,
52 })(), 53 })(),
53 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', 54 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
  55 + collapseTriggerResize: false,
54 })); 56 }));
55 57
56 const gridOptions: VxeGridProps = { 58 const gridOptions: VxeGridProps = {
@@ -132,7 +134,8 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) { @@ -132,7 +134,8 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) {
132 </script> 134 </script>
133 135
134 <template> 136 <template>
135 - <BasicTable> 137 + <div class="flex h-full min-h-0 flex-col">
  138 + <BasicTable :class="managementTabTableClass">
136 <template #toolbar-tools> 139 <template #toolbar-tools>
137 <Space> 140 <Space>
138 <a-button type="primary" @click="handleAdd"> 141 <a-button type="primary" @click="handleAdd">
@@ -173,6 +176,7 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) { @@ -173,6 +176,7 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) {
173 </Popconfirm> 176 </Popconfirm>
174 </Space> 177 </Space>
175 </template> 178 </template>
176 - </BasicTable>  
177 - <GroupModalHost @reload="tableApi.query()" /> 179 + </BasicTable>
  180 + <GroupModalHost @reload="tableApi.query()" />
  181 + </div>
178 </template> 182 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/index.vue
@@ -2,12 +2,17 @@ @@ -2,12 +2,17 @@
2 import type { Component } from 'vue'; 2 import type { Component } from 'vue';
3 3
4 import { computed, onMounted, ref, watch } from 'vue'; 4 import { computed, onMounted, ref, watch } from 'vue';
5 -import { useRoute } from 'vue-router'; 5 +import { useRoute, useRouter } from 'vue-router';
6 6
7 import { Page } from '@vben/common-ui'; 7 import { Page } from '@vben/common-ui';
8 import { $t } from '@vben/locales'; 8 import { $t } from '@vben/locales';
9 9
  10 +import { Alert, Button, Space } from 'ant-design-vue';
  11 +
  12 +import { useSaasContextStore } from '#/store/saas-context';
  13 +
10 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; 14 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
  15 +import { managementTabPageContentClass } from '../shared/management-grid';
11 import ManagementSubTabs from '../shared/management-sub-tabs.vue'; 16 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
12 import ManagementTabBody from '../shared/management-tab-body.vue'; 17 import ManagementTabBody from '../shared/management-tab-body.vue';
13 import GroupsTab from './groups/index.vue'; 18 import GroupsTab from './groups/index.vue';
@@ -17,6 +22,8 @@ import RolesTab from &#39;./roles/index.vue&#39;; @@ -17,6 +22,8 @@ import RolesTab from &#39;./roles/index.vue&#39;;
17 import TeamMembersTab from './team-members/index.vue'; 22 import TeamMembersTab from './team-members/index.vue';
18 23
19 const route = useRoute(); 24 const route = useRoute();
  25 +const router = useRouter();
  26 +const saasStore = useSaasContextStore();
20 27
21 const ACCOUNT_TABS = [ 28 const ACCOUNT_TABS = [
22 'roles', 29 'roles',
@@ -47,6 +54,13 @@ watch( @@ -47,6 +54,13 @@ watch(
47 }, 54 },
48 ); 55 );
49 56
  57 +watch(activeTab, (tab) => {
  58 + if (String(route.query.tab ?? '') === tab) {
  59 + return;
  60 + }
  61 + router.replace({ path: route.path, query: { ...route.query, tab } });
  62 +});
  63 +
50 const tabItems = computed(() => [ 64 const tabItems = computed(() => [
51 { key: 'roles', label: $t('foodLabeling.accountManagement.tabRoles') }, 65 { key: 'roles', label: $t('foodLabeling.accountManagement.tabRoles') },
52 { key: 'partners', label: $t('foodLabeling.accountManagement.tabCompany') }, 66 { key: 'partners', label: $t('foodLabeling.accountManagement.tabCompany') },
@@ -62,21 +76,54 @@ const tabPanels: Record&lt;string, Component&gt; = { @@ -62,21 +76,54 @@ const tabPanels: Record&lt;string, Component&gt; = {
62 locations: LocationsTab, 76 locations: LocationsTab,
63 teamMembers: TeamMembersTab, 77 teamMembers: TeamMembersTab,
64 }; 78 };
  79 +
  80 +function backToPlatform() {
  81 + saasStore.resetToPlatformAdmin();
  82 + router.push('/platform/tenants');
  83 +}
65 </script> 84 </script>
66 85
67 <template> 86 <template>
68 - <Page> 87 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
  88 + <Alert
  89 + v-if="saasStore.isTenantAdmin"
  90 + class="mb-3 shrink-0"
  91 + :message="
  92 + $t('foodLabeling.accountManagement.tenantAdminBanner', {
  93 + company: saasStore.tenantName ?? '',
  94 + })
  95 + "
  96 + show-icon
  97 + type="info"
  98 + >
  99 + <template #action>
  100 + <Space>
  101 + <Button size="small" @click="backToPlatform">
  102 + {{ $t('foodLabeling.platform.backToPlatform') }}
  103 + </Button>
  104 + </Space>
  105 + </template>
  106 + </Alert>
  107 +
  108 + <Alert
  109 + v-else
  110 + class="mb-3 shrink-0"
  111 + :message="$t('foodLabeling.accountManagement.platformOrTenantHint')"
  112 + show-icon
  113 + type="info"
  114 + />
  115 +
69 <a-alert 116 <a-alert
70 v-if="FOOD_LABELING_STATIC_ONLY" 117 v-if="FOOD_LABELING_STATIC_ONLY"
71 - class="mb-3" 118 + class="mb-3 shrink-0"
72 :message="$t('foodLabeling.common.staticDemoBanner')" 119 :message="$t('foodLabeling.common.staticDemoBanner')"
73 show-icon 120 show-icon
74 - type="info" 121 + type="warning"
75 /> 122 />
76 123
77 - <ManagementSubTabs v-model="activeTab" :tabs="tabItems" /> 124 + <ManagementSubTabs v-model="activeTab" :tabs="tabItems" class="shrink-0" />
78 125
79 - <div class="mt-3"> 126 + <div class="mt-3 min-h-0 flex-1 overflow-hidden">
80 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" /> 127 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" />
81 </div> 128 </div>
82 </Page> 129 </Page>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/locations/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -82,14 +83,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -82,14 +83,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
82 width: 90, 83 width: 90,
83 slots: { default: 'state' }, 84 slots: { default: 'state' },
84 }, 85 },
85 - {  
86 - field: 'action',  
87 - fixed: 'right',  
88 - slots: { default: 'action' },  
89 - title: $t('foodLabeling.common.action'),  
90 - width: 140,  
91 - resizable: false,  
92 - }, 86 + foodLabelingActionColumn(),
93 ]; 87 ];
94 88
95 export function modalSchema(options: { 89 export function modalSchema(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/locations/index.vue
@@ -30,6 +30,7 @@ import { @@ -30,6 +30,7 @@ import {
30 import type { SelectOption } from '../../../shared/use-account-lookups'; 30 import type { SelectOption } from '../../../shared/use-account-lookups';
31 import { 31 import {
32 managementTabGridBase, 32 managementTabGridBase,
  33 + managementTabTableClass,
33 managementTabVxeGridClass, 34 managementTabVxeGridClass,
34 } from '../../shared/management-grid'; 35 } from '../../shared/management-grid';
35 import { columns, querySchema } from './data'; 36 import { columns, querySchema } from './data';
@@ -81,6 +82,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -81,6 +82,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
81 groupNameOptions: groupNameOptions.value, 82 groupNameOptions: groupNameOptions.value,
82 })(), 83 })(),
83 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5', 84 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5',
  85 + collapseTriggerResize: false,
84 })); 86 }));
85 87
86 const gridOptions: VxeGridProps = { 88 const gridOptions: VxeGridProps = {
@@ -170,7 +172,8 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) { @@ -170,7 +172,8 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) {
170 </script> 172 </script>
171 173
172 <template> 174 <template>
173 - <BasicTable> 175 + <div class="flex h-full min-h-0 flex-col">
  176 + <BasicTable :class="managementTabTableClass">
174 <template #toolbar-tools> 177 <template #toolbar-tools>
175 <Space> 178 <Space>
176 <a-button type="primary" @click="handleAdd"> 179 <a-button type="primary" @click="handleAdd">
@@ -217,6 +220,7 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) { @@ -217,6 +220,7 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) {
217 </Popconfirm> 220 </Popconfirm>
218 </Space> 221 </Space>
219 </template> 222 </template>
220 - </BasicTable>  
221 - <LocationModalHost @reload="tableApi.query()" /> 223 + </BasicTable>
  224 + <LocationModalHost @reload="tableApi.query()" />
  225 + </div>
222 </template> 226 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/partners/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -33,6 +34,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -33,6 +34,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
33 slots: { default: 'phoneNumber' }, 34 slots: { default: 'phoneNumber' },
34 }, 35 },
35 { 36 {
  37 + field: 'street',
  38 + title: $t('foodLabeling.accountManagement.street'),
  39 + minWidth: 140,
  40 + slots: { default: 'street' },
  41 + },
  42 + {
36 field: 'city', 43 field: 'city',
37 title: $t('foodLabeling.accountManagement.city'), 44 title: $t('foodLabeling.accountManagement.city'),
38 minWidth: 100, 45 minWidth: 100,
@@ -45,19 +52,24 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -45,19 +52,24 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
45 slots: { default: 'stateCode' }, 52 slots: { default: 'stateCode' },
46 }, 53 },
47 { 54 {
  55 + field: 'country',
  56 + title: $t('foodLabeling.accountManagement.country'),
  57 + minWidth: 90,
  58 + slots: { default: 'country' },
  59 + },
  60 + {
  61 + field: 'zipCode',
  62 + title: $t('foodLabeling.accountManagement.zipCode'),
  63 + minWidth: 90,
  64 + slots: { default: 'zipCode' },
  65 + },
  66 + {
48 field: 'state', 67 field: 'state',
49 title: $t('foodLabeling.common.status'), 68 title: $t('foodLabeling.common.status'),
50 width: 90, 69 width: 90,
51 slots: { default: 'state' }, 70 slots: { default: 'state' },
52 }, 71 },
53 - {  
54 - field: 'action',  
55 - fixed: 'right',  
56 - slots: { default: 'action' },  
57 - title: $t('foodLabeling.common.action'),  
58 - width: 140,  
59 - resizable: false,  
60 - }, 72 + foodLabelingActionColumn(),
61 ]; 73 ];
62 74
63 export const modalSchema: FormSchemaGetter = () => [ 75 export const modalSchema: FormSchemaGetter = () => [
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/partners/index.vue
@@ -22,7 +22,9 @@ import { @@ -22,7 +22,9 @@ import {
22 slicePage, 22 slicePage,
23 } from '../../../shared/static-mode'; 23 } from '../../../shared/static-mode';
24 import { 24 import {
  25 + buildManagementTabFormOptions,
25 managementTabGridBase, 26 managementTabGridBase,
  27 + managementTabTableClass,
26 managementTabVxeGridClass, 28 managementTabVxeGridClass,
27 } from '../../shared/management-grid'; 29 } from '../../shared/management-grid';
28 import { columns, querySchema } from './data'; 30 import { columns, querySchema } from './data';
@@ -33,14 +35,7 @@ function displayText(v: string | null | undefined) { @@ -33,14 +35,7 @@ function displayText(v: string | null | undefined) {
33 return s || $t('foodLabeling.common.none'); 35 return s || $t('foodLabeling.common.none');
34 } 36 }
35 37
36 -const formOptions: VbenFormProps = {  
37 - commonConfig: {  
38 - labelWidth: 90,  
39 - componentProps: { allowClear: true },  
40 - },  
41 - schema: querySchema(),  
42 - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',  
43 -}; 38 +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema());
44 39
45 const gridOptions: VxeGridProps = { 40 const gridOptions: VxeGridProps = {
46 ...managementTabGridBase, 41 ...managementTabGridBase,
@@ -59,7 +54,11 @@ const gridOptions: VxeGridProps = { @@ -59,7 +54,11 @@ const gridOptions: VxeGridProps = {
59 'partnerName', 54 'partnerName',
60 'contactEmail', 55 'contactEmail',
61 'phoneNumber', 56 'phoneNumber',
  57 + 'street',
62 'city', 58 'city',
  59 + 'stateCode',
  60 + 'country',
  61 + 'zipCode',
63 ]), 62 ]),
64 ); 63 );
65 } 64 }
@@ -120,7 +119,8 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) { @@ -120,7 +119,8 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) {
120 </script> 119 </script>
121 120
122 <template> 121 <template>
123 - <BasicTable> 122 + <div class="flex h-full min-h-0 flex-col">
  123 + <BasicTable :class="managementTabTableClass">
124 <template #toolbar-tools> 124 <template #toolbar-tools>
125 <Space> 125 <Space>
126 <a-button type="primary" @click="handleAdd"> 126 <a-button type="primary" @click="handleAdd">
@@ -135,12 +135,21 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) { @@ -135,12 +135,21 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) {
135 <template #phoneNumber="{ row }"> 135 <template #phoneNumber="{ row }">
136 {{ displayText(row.phoneNumber) }} 136 {{ displayText(row.phoneNumber) }}
137 </template> 137 </template>
  138 + <template #street="{ row }">
  139 + <span class="truncate">{{ displayText(row.street) }}</span>
  140 + </template>
138 <template #city="{ row }"> 141 <template #city="{ row }">
139 {{ displayText(row.city) }} 142 {{ displayText(row.city) }}
140 </template> 143 </template>
141 <template #stateCode="{ row }"> 144 <template #stateCode="{ row }">
142 {{ displayText(row.stateCode) }} 145 {{ displayText(row.stateCode) }}
143 </template> 146 </template>
  147 + <template #country="{ row }">
  148 + {{ displayText(row.country) }}
  149 + </template>
  150 + <template #zipCode="{ row }">
  151 + {{ displayText(row.zipCode) }}
  152 + </template>
144 <template #state="{ row }"> 153 <template #state="{ row }">
145 <Tag :color="row.state !== false ? 'success' : 'default'"> 154 <Tag :color="row.state !== false ? 'success' : 'default'">
146 {{ 155 {{
@@ -167,6 +176,7 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) { @@ -167,6 +176,7 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) {
167 </Popconfirm> 176 </Popconfirm>
168 </Space> 177 </Space>
169 </template> 178 </template>
170 - </BasicTable>  
171 - <PartnerModalHost @reload="tableApi.query()" /> 179 + </BasicTable>
  180 + <PartnerModalHost @reload="tableApi.query()" />
  181 + </div>
172 </template> 182 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/data.ts
1 import type { FormSchemaGetter } from '#/adapter/form'; 1 import type { FormSchemaGetter } from '#/adapter/form';
2 import type { VxeGridProps } from '#/adapter/vxe-table'; 2 import type { VxeGridProps } from '#/adapter/vxe-table';
3 3
  4 +import { markRaw } from 'vue';
  5 +
4 import { $t } from '@vben/locales'; 6 import { $t } from '@vben/locales';
5 7
  8 +import MenuPermissionTreeField from '../../../shared/menu-permission-tree-field.vue';
6 import { 9 import {
7 keywordFilterSchema, 10 keywordFilterSchema,
8 stateFilterSchema, 11 stateFilterSchema,
9 stateModalSchema, 12 stateModalSchema,
10 } from '../../../shared/form-schema'; 13 } from '../../../shared/form-schema';
  14 +import { foodLabelingActionColumn } from '../../shared/management-grid';
11 import { accessPermissionCheckboxOptions } from '../../../shared/use-account-lookups'; 15 import { accessPermissionCheckboxOptions } from '../../../shared/use-account-lookups';
12 16
13 export const querySchema: FormSchemaGetter = () => [ 17 export const querySchema: FormSchemaGetter = () => [
@@ -33,6 +37,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -33,6 +37,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
33 slots: { default: 'remark' }, 37 slots: { default: 'remark' },
34 }, 38 },
35 { 39 {
  40 + field: 'menuPermissionKeys',
  41 + title: $t('foodLabeling.accountManagement.menuPermissions'),
  42 + minWidth: 120,
  43 + slots: { default: 'menuPermissions' },
  44 + },
  45 + {
36 field: 'accessPermissionCodes', 46 field: 'accessPermissionCodes',
37 title: $t('foodLabeling.accountManagement.accessPermissions'), 47 title: $t('foodLabeling.accountManagement.accessPermissions'),
38 minWidth: 200, 48 minWidth: 200,
@@ -50,17 +60,13 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -50,17 +60,13 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
50 width: 90, 60 width: 90,
51 slots: { default: 'state' }, 61 slots: { default: 'state' },
52 }, 62 },
53 - {  
54 - field: 'action',  
55 - fixed: 'right',  
56 - slots: { default: 'action' },  
57 - title: $t('foodLabeling.common.action'),  
58 - width: 140,  
59 - resizable: false,  
60 - }, 63 + foodLabelingActionColumn(),
61 ]; 64 ];
62 65
63 -export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter { 66 +export function modalSchema(options: {
  67 + isUpdate: boolean;
  68 + allowedMenuKeys?: string[] | null;
  69 +}): FormSchemaGetter {
64 return () => [ 70 return () => [
65 { 71 {
66 component: 'Input', 72 component: 'Input',
@@ -88,6 +94,18 @@ export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter { @@ -88,6 +94,18 @@ export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter {
88 componentProps: { min: 0, class: 'w-full' }, 94 componentProps: { min: 0, class: 'w-full' },
89 }, 95 },
90 { 96 {
  97 + component: markRaw(MenuPermissionTreeField),
  98 + fieldName: 'menuPermissionKeys',
  99 + label: $t('foodLabeling.accountManagement.menuPermissions'),
  100 + formItemClass: 'col-span-2',
  101 + defaultValue: [],
  102 + componentProps: {
  103 + allowedKeys: options.allowedMenuKeys?.length
  104 + ? options.allowedMenuKeys
  105 + : null,
  106 + },
  107 + },
  108 + {
91 component: 'CheckboxGroup', 109 component: 'CheckboxGroup',
92 fieldName: 'accessPermissionCodes', 110 fieldName: 'accessPermissionCodes',
93 label: $t('foodLabeling.accountManagement.accessPermissions'), 111 label: $t('foodLabeling.accountManagement.accessPermissions'),
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/index.vue
@@ -23,7 +23,9 @@ import { @@ -23,7 +23,9 @@ import {
23 } from '../../../shared/static-mode'; 23 } from '../../../shared/static-mode';
24 import { displayAccessPermissionLabel } from '../../../shared/use-account-lookups'; 24 import { displayAccessPermissionLabel } from '../../../shared/use-account-lookups';
25 import { 25 import {
  26 + buildManagementTabFormOptions,
26 managementTabGridBase, 27 managementTabGridBase,
  28 + managementTabTableClass,
27 managementTabVxeGridClass, 29 managementTabVxeGridClass,
28 } from '../../shared/management-grid'; 30 } from '../../shared/management-grid';
29 import { columns, querySchema } from './data'; 31 import { columns, querySchema } from './data';
@@ -34,14 +36,7 @@ function displayText(v: string | null | undefined) { @@ -34,14 +36,7 @@ function displayText(v: string | null | undefined) {
34 return s || $t('foodLabeling.common.none'); 36 return s || $t('foodLabeling.common.none');
35 } 37 }
36 38
37 -const formOptions: VbenFormProps = {  
38 - commonConfig: {  
39 - labelWidth: 90,  
40 - componentProps: { allowClear: true },  
41 - },  
42 - schema: querySchema(),  
43 - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',  
44 -}; 39 +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema());
45 40
46 const gridOptions: VxeGridProps = { 41 const gridOptions: VxeGridProps = {
47 ...managementTabGridBase, 42 ...managementTabGridBase,
@@ -128,7 +123,8 @@ function permissionsText(row: RoleDto) { @@ -128,7 +123,8 @@ function permissionsText(row: RoleDto) {
128 </script> 123 </script>
129 124
130 <template> 125 <template>
131 - <BasicTable> 126 + <div class="flex h-full min-h-0 flex-col">
  127 + <BasicTable :class="managementTabTableClass">
132 <template #toolbar-tools> 128 <template #toolbar-tools>
133 <Space> 129 <Space>
134 <a-button type="primary" @click="handleAdd"> 130 <a-button type="primary" @click="handleAdd">
@@ -140,6 +136,11 @@ function permissionsText(row: RoleDto) { @@ -140,6 +136,11 @@ function permissionsText(row: RoleDto) {
140 <template #remark="{ row }"> 136 <template #remark="{ row }">
141 <span class="truncate">{{ displayText(row.remark) }}</span> 137 <span class="truncate">{{ displayText(row.remark) }}</span>
142 </template> 138 </template>
  139 + <template #menuPermissions="{ row }">
  140 + <Tag color="processing">
  141 + {{ (row.menuPermissionKeys ?? []).length }}
  142 + </Tag>
  143 + </template>
143 <template #accessPermissions="{ row }"> 144 <template #accessPermissions="{ row }">
144 <span class="truncate">{{ permissionsText(row) }}</span> 145 <span class="truncate">{{ permissionsText(row) }}</span>
145 </template> 146 </template>
@@ -172,6 +173,7 @@ function permissionsText(row: RoleDto) { @@ -172,6 +173,7 @@ function permissionsText(row: RoleDto) {
172 </Popconfirm> 173 </Popconfirm>
173 </Space> 174 </Space>
174 </template> 175 </template>
175 - </BasicTable>  
176 - <RoleModalHost @reload="tableApi.query()" /> 176 + </BasicTable>
  177 + <RoleModalHost @reload="tableApi.query()" />
  178 + </div>
177 </template> 179 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/role-modal.vue
@@ -10,11 +10,14 @@ import { cloneDeep } from &#39;@vben/utils&#39;; @@ -10,11 +10,14 @@ import { cloneDeep } from &#39;@vben/utils&#39;;
10 import { message } from 'ant-design-vue'; 10 import { message } from 'ant-design-vue';
11 11
12 import { useVbenForm } from '#/adapter/form'; 12 import { useVbenForm } from '#/adapter/form';
  13 +import { useSaasContextStore } from '#/store/saas-context';
13 14
14 import { MOCK_ROLES } from '../../../shared/mock-management-data'; 15 import { MOCK_ROLES } from '../../../shared/mock-management-data';
15 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode'; 16 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode';
16 import { modalSchema } from './data'; 17 import { modalSchema } from './data';
17 18
  19 +const saasStore = useSaasContextStore();
  20 +
18 const emit = defineEmits<{ reload: [] }>(); 21 const emit = defineEmits<{ reload: [] }>();
19 22
20 const isUpdate = ref(false); 23 const isUpdate = ref(false);
@@ -30,13 +33,16 @@ const [BasicForm, formApi] = useVbenForm({ @@ -30,13 +33,16 @@ const [BasicForm, formApi] = useVbenForm({
30 labelWidth: 110, 33 labelWidth: 110,
31 componentProps: { class: 'w-full' }, 34 componentProps: { class: 'w-full' },
32 }, 35 },
33 - schema: modalSchema({ isUpdate: false })(), 36 + schema: modalSchema({
  37 + isUpdate: false,
  38 + allowedMenuKeys: saasStore.allowedMenuKeys,
  39 + })(),
34 showDefaultActions: false, 40 showDefaultActions: false,
35 wrapperClass: 'grid-cols-2', 41 wrapperClass: 'grid-cols-2',
36 }); 42 });
37 43
38 const [BasicModal, modalApi] = useVbenModal({ 44 const [BasicModal, modalApi] = useVbenModal({
39 - class: 'w-[720px]', 45 + class: 'w-[800px]',
40 fullscreenButton: false, 46 fullscreenButton: false,
41 onCancel: handleCancel, 47 onCancel: handleCancel,
42 onConfirm: handleConfirm, 48 onConfirm: handleConfirm,
@@ -50,7 +56,10 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -50,7 +56,10 @@ const [BasicModal, modalApi] = useVbenModal({
50 recordId.value = data?.id ?? ''; 56 recordId.value = data?.id ?? '';
51 57
52 formApi.setState({ 58 formApi.setState({
53 - schema: modalSchema({ isUpdate: isUpdate.value })(), 59 + schema: modalSchema({
  60 + isUpdate: isUpdate.value,
  61 + allowedMenuKeys: saasStore.allowedMenuKeys,
  62 + })(),
54 }); 63 });
55 64
56 if (isUpdate.value && recordId.value) { 65 if (isUpdate.value && recordId.value) {
@@ -68,6 +77,7 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -68,6 +77,7 @@ const [BasicModal, modalApi] = useVbenModal({
68 remark: record.remark ?? '', 77 remark: record.remark ?? '',
69 orderNum: record.orderNum ?? undefined, 78 orderNum: record.orderNum ?? undefined,
70 accessPermissionCodes: record.accessPermissionCodes ?? [], 79 accessPermissionCodes: record.accessPermissionCodes ?? [],
  80 + menuPermissionKeys: record.menuPermissionKeys ?? [],
71 state: record.state !== false, 81 state: record.state !== false,
72 }); 82 });
73 } else { 83 } else {
@@ -75,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -75,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({
75 await formApi.setValues({ 85 await formApi.setValues({
76 state: true, 86 state: true,
77 accessPermissionCodes: [], 87 accessPermissionCodes: [],
  88 + menuPermissionKeys: [],
78 }); 89 });
79 } 90 }
80 modalApi.modalLoading(false); 91 modalApi.modalLoading(false);
@@ -91,6 +102,7 @@ function formToPayload(values: Record&lt;string, unknown&gt;) { @@ -91,6 +102,7 @@ function formToPayload(values: Record&lt;string, unknown&gt;) {
91 ? null 102 ? null
92 : Number(values.orderNum), 103 : Number(values.orderNum),
93 accessPermissionCodes: (values.accessPermissionCodes as string[]) ?? [], 104 accessPermissionCodes: (values.accessPermissionCodes as string[]) ?? [],
  105 + menuPermissionKeys: (values.menuPermissionKeys as string[]) ?? [],
94 state: values.state !== false, 106 state: values.state !== false,
95 }; 107 };
96 } 108 }
@@ -108,6 +120,18 @@ async function handleConfirm() { @@ -108,6 +120,18 @@ async function handleConfirm() {
108 return; 120 return;
109 } 121 }
110 if (FOOD_LABELING_STATIC_ONLY) { 122 if (FOOD_LABELING_STATIC_ONLY) {
  123 + if (isUpdate.value && recordId.value) {
  124 + const idx = MOCK_ROLES.findIndex((x) => x.id === recordId.value);
  125 + if (idx >= 0) {
  126 + MOCK_ROLES[idx] = { ...MOCK_ROLES[idx]!, ...payload };
  127 + }
  128 + } else {
  129 + MOCK_ROLES.push({
  130 + id: `role-${Date.now()}`,
  131 + creationTime: new Date().toISOString().slice(0, 16).replace('T', ' '),
  132 + ...payload,
  133 + });
  134 + }
111 message.success($t('foodLabeling.common.staticDemoAction')); 135 message.success($t('foodLabeling.common.staticDemoAction'));
112 emit('reload'); 136 emit('reload');
113 await handleCancel(); 137 await handleCancel();
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/data.ts
1 import type { FormSchemaGetter } from '#/adapter/form'; 1 import type { FormSchemaGetter } from '#/adapter/form';
2 import type { VxeGridProps } from '#/adapter/vxe-table'; 2 import type { VxeGridProps } from '#/adapter/vxe-table';
3 3
  4 +import { markRaw } from 'vue';
  5 +
4 import { $t } from '@vben/locales'; 6 import { $t } from '@vben/locales';
5 7
6 import { z } from '#/adapter/form'; 8 import { z } from '#/adapter/form';
7 9
  10 +import MenuPermissionTreeField from '../../../shared/menu-permission-tree-field.vue';
  11 +
  12 +import { foodLabelingActionColumn } from '../../shared/management-grid';
8 import { 13 import {
9 keywordFilterSchema, 14 keywordFilterSchema,
10 stateFilterSchema, 15 stateFilterSchema,
@@ -62,6 +67,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -62,6 +67,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
62 slots: { default: 'roleName' }, 67 slots: { default: 'roleName' },
63 }, 68 },
64 { 69 {
  70 + field: 'menuPermissionKeys',
  71 + title: $t('foodLabeling.accountManagement.userMenuOverride'),
  72 + minWidth: 110,
  73 + slots: { default: 'userMenus' },
  74 + },
  75 + {
65 field: 'locations', 76 field: 'locations',
66 title: $t('foodLabeling.common.locations'), 77 title: $t('foodLabeling.common.locations'),
67 minWidth: 180, 78 minWidth: 180,
@@ -73,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -73,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
73 width: 90, 84 width: 90,
74 slots: { default: 'state' }, 85 slots: { default: 'state' },
75 }, 86 },
76 - {  
77 - field: 'action',  
78 - fixed: 'right',  
79 - slots: { default: 'action' },  
80 - title: $t('foodLabeling.common.action'),  
81 - width: 140,  
82 - resizable: false,  
83 - }, 87 + foodLabelingActionColumn(),
84 ]; 88 ];
85 89
86 export function modalSchema(options: { 90 export function modalSchema(options: {
@@ -89,6 +93,7 @@ export function modalSchema(options: { @@ -89,6 +93,7 @@ export function modalSchema(options: {
89 partnerOptions: SelectOption[]; 93 partnerOptions: SelectOption[];
90 groupOptions: SelectOption[]; 94 groupOptions: SelectOption[];
91 locationOptions: SelectOption[]; 95 locationOptions: SelectOption[];
  96 + allowedMenuKeys?: string[] | null;
92 }): FormSchemaGetter { 97 }): FormSchemaGetter {
93 return () => [ 98 return () => [
94 { 99 {
@@ -172,6 +177,32 @@ export function modalSchema(options: { @@ -172,6 +177,32 @@ export function modalSchema(options: {
172 optionFilterProp: 'label', 177 optionFilterProp: 'label',
173 }, 178 },
174 }, 179 },
  180 + {
  181 + component: 'Switch',
  182 + fieldName: 'useCustomMenuPermissions',
  183 + label: $t('foodLabeling.accountManagement.useCustomMenus'),
  184 + defaultValue: false,
  185 + componentProps: {
  186 + checkedChildren: $t('foodLabeling.common.yes'),
  187 + unCheckedChildren: $t('foodLabeling.common.no'),
  188 + },
  189 + },
  190 + {
  191 + component: markRaw(MenuPermissionTreeField),
  192 + fieldName: 'menuPermissionKeys',
  193 + label: $t('foodLabeling.accountManagement.menuPermissions'),
  194 + formItemClass: 'col-span-2',
  195 + defaultValue: [],
  196 + componentProps: {
  197 + allowedKeys: options.allowedMenuKeys?.length
  198 + ? options.allowedMenuKeys
  199 + : null,
  200 + },
  201 + dependencies: {
  202 + if: (values) => values.useCustomMenuPermissions === true,
  203 + triggerFields: ['useCustomMenuPermissions'],
  204 + },
  205 + },
175 ...stateModalSchema(), 206 ...stateModalSchema(),
176 ]; 207 ];
177 } 208 }
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/index.vue
@@ -26,6 +26,7 @@ import { @@ -26,6 +26,7 @@ import {
26 import { useAccountLookups } from '../../../shared/use-account-lookups'; 26 import { useAccountLookups } from '../../../shared/use-account-lookups';
27 import { 27 import {
28 managementTabGridBase, 28 managementTabGridBase,
  29 + managementTabTableClass,
29 managementTabVxeGridClass, 30 managementTabVxeGridClass,
30 } from '../../shared/management-grid'; 31 } from '../../shared/management-grid';
31 import { columns, querySchema } from './data'; 32 import { columns, querySchema } from './data';
@@ -63,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({ @@ -63,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
63 roleOptions: accountLookups.roleOptions.value, 64 roleOptions: accountLookups.roleOptions.value,
64 })(), 65 })(),
65 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', 66 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
  67 + collapseTriggerResize: false,
66 })); 68 }));
67 69
68 const gridOptions: VxeGridProps = { 70 const gridOptions: VxeGridProps = {
@@ -148,7 +150,8 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) { @@ -148,7 +150,8 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
148 </script> 150 </script>
149 151
150 <template> 152 <template>
151 - <BasicTable> 153 + <div class="flex h-full min-h-0 flex-col">
  154 + <BasicTable :class="managementTabTableClass">
152 <template #toolbar-tools> 155 <template #toolbar-tools>
153 <Space> 156 <Space>
154 <a-button type="primary" @click="handleAdd"> 157 <a-button type="primary" @click="handleAdd">
@@ -166,6 +169,12 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) { @@ -166,6 +169,12 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
166 <template #roleName="{ row }"> 169 <template #roleName="{ row }">
167 {{ displayText(row.roleName) }} 170 {{ displayText(row.roleName) }}
168 </template> 171 </template>
  172 + <template #userMenus="{ row }">
  173 + <Tag v-if="row.useCustomMenuPermissions" color="blue">
  174 + {{ (row.menuPermissionKeys ?? []).length }}
  175 + </Tag>
  176 + <span v-else class="text-gray-400">{{ $t('foodLabeling.accountManagement.inheritRoleMenus') }}</span>
  177 + </template>
169 <template #locations="{ row }"> 178 <template #locations="{ row }">
170 <span class="truncate">{{ locationsText(row) }}</span> 179 <span class="truncate">{{ locationsText(row) }}</span>
171 </template> 180 </template>
@@ -195,6 +204,7 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) { @@ -195,6 +204,7 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
195 </Popconfirm> 204 </Popconfirm>
196 </Space> 205 </Space>
197 </template> 206 </template>
198 - </BasicTable>  
199 - <TeamMemberModalHost @reload="tableApi.query()" /> 207 + </BasicTable>
  208 + <TeamMemberModalHost @reload="tableApi.query()" />
  209 + </div>
200 </template> 210 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/team-member-modal.vue
@@ -14,6 +14,7 @@ import { cloneDeep } from &#39;@vben/utils&#39;; @@ -14,6 +14,7 @@ import { cloneDeep } from &#39;@vben/utils&#39;;
14 import { message } from 'ant-design-vue'; 14 import { message } from 'ant-design-vue';
15 15
16 import { useVbenForm } from '#/adapter/form'; 16 import { useVbenForm } from '#/adapter/form';
  17 +import { useSaasContextStore } from '#/store/saas-context';
17 18
18 import { MOCK_TEAM_MEMBERS } from '../../../shared/mock-management-data'; 19 import { MOCK_TEAM_MEMBERS } from '../../../shared/mock-management-data';
19 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode'; 20 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode';
@@ -23,6 +24,8 @@ import { modalSchema } from &#39;./data&#39;; @@ -23,6 +24,8 @@ import { modalSchema } from &#39;./data&#39;;
23 24
24 const emit = defineEmits<{ reload: [] }>(); 25 const emit = defineEmits<{ reload: [] }>();
25 26
  27 +const saasStore = useSaasContextStore();
  28 +
26 const isUpdate = ref(false); 29 const isUpdate = ref(false);
27 const recordId = ref(''); 30 const recordId = ref('');
28 31
@@ -61,7 +64,7 @@ const [BasicForm, formApi] = useVbenForm({ @@ -61,7 +64,7 @@ const [BasicForm, formApi] = useVbenForm({
61 }); 64 });
62 65
63 const [BasicModal, modalApi] = useVbenModal({ 66 const [BasicModal, modalApi] = useVbenModal({
64 - class: 'w-[720px]', 67 + class: 'w-[800px]',
65 fullscreenButton: false, 68 fullscreenButton: false,
66 onCancel: handleCancel, 69 onCancel: handleCancel,
67 onConfirm: handleConfirm, 70 onConfirm: handleConfirm,
@@ -82,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -82,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({
82 partnerOptions: partnerOptions.value, 85 partnerOptions: partnerOptions.value,
83 groupOptions: groupOptions.value, 86 groupOptions: groupOptions.value,
84 locationOptions: locationOptions.value, 87 locationOptions: locationOptions.value,
  88 + allowedMenuKeys: saasStore.allowedMenuKeys,
85 })(), 89 })(),
86 }); 90 });
87 91
@@ -107,6 +111,8 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -107,6 +111,8 @@ const [BasicModal, modalApi] = useVbenModal({
107 partnerId: record.partnerId ?? undefined, 111 partnerId: record.partnerId ?? undefined,
108 regionIds: record.regionIds ?? [], 112 regionIds: record.regionIds ?? [],
109 locationIds: record.locationIds ?? [], 113 locationIds: record.locationIds ?? [],
  114 + useCustomMenuPermissions: record.useCustomMenuPermissions === true,
  115 + menuPermissionKeys: record.menuPermissionKeys ?? [],
110 state: record.state !== false, 116 state: record.state !== false,
111 }); 117 });
112 } else { 118 } else {
@@ -115,6 +121,8 @@ const [BasicModal, modalApi] = useVbenModal({ @@ -115,6 +121,8 @@ const [BasicModal, modalApi] = useVbenModal({
115 state: true, 121 state: true,
116 regionIds: [], 122 regionIds: [],
117 locationIds: [], 123 locationIds: [],
  124 + useCustomMenuPermissions: false,
  125 + menuPermissionKeys: [],
118 }); 126 });
119 } 127 }
120 modalApi.modalLoading(false); 128 modalApi.modalLoading(false);
@@ -141,6 +149,11 @@ function formToPayload(values: Record&lt;string, unknown&gt;, updating: boolean) { @@ -141,6 +149,11 @@ function formToPayload(values: Record&lt;string, unknown&gt;, updating: boolean) {
141 partnerId: String(values.partnerId ?? '').trim() || undefined, 149 partnerId: String(values.partnerId ?? '').trim() || undefined,
142 regionIds: regionIds.length ? regionIds : undefined, 150 regionIds: regionIds.length ? regionIds : undefined,
143 locationIds, 151 locationIds,
  152 + useCustomMenuPermissions: values.useCustomMenuPermissions === true,
  153 + menuPermissionKeys:
  154 + values.useCustomMenuPermissions === true
  155 + ? ((values.menuPermissionKeys as string[]) ?? [])
  156 + : undefined,
144 state: values.state !== false, 157 state: values.state !== false,
145 }; 158 };
146 const pwd = String(values.password ?? '').trim(); 159 const pwd = String(values.password ?? '').trim();
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/categories/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -89,14 +90,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -89,14 +90,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
89 minWidth: 140, 90 minWidth: 140,
90 slots: { default: 'lastEdited' }, 91 slots: { default: 'lastEdited' },
91 }, 92 },
92 - {  
93 - field: 'action',  
94 - fixed: 'right',  
95 - slots: { default: 'action' },  
96 - title: $t('foodLabeling.common.action'),  
97 - width: 140,  
98 - resizable: false,  
99 - }, 93 + foodLabelingActionColumn(),
100 ]; 94 ];
101 95
102 export function modalSchemaBasic(options: { 96 export function modalSchemaBasic(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/categories/index.vue
@@ -31,6 +31,7 @@ import { useScopeCatalog } from &#39;../../../shared/use-scope-catalog&#39;; @@ -31,6 +31,7 @@ import { useScopeCatalog } from &#39;../../../shared/use-scope-catalog&#39;;
31 import { useScopeLookups } from '../../../shared/use-scope-lookups'; 31 import { useScopeLookups } from '../../../shared/use-scope-lookups';
32 import { 32 import {
33 managementTabGridBase, 33 managementTabGridBase,
  34 + managementTabTableClass,
34 managementTabVxeGridClass, 35 managementTabVxeGridClass,
35 } from '../../shared/management-grid'; 36 } from '../../shared/management-grid';
36 import { columns, querySchema } from './data'; 37 import { columns, querySchema } from './data';
@@ -62,6 +63,7 @@ const formOptions = computed&lt;VbenFormProps&gt;(() =&gt; ({ @@ -62,6 +63,7 @@ const formOptions = computed&lt;VbenFormProps&gt;(() =&gt; ({
62 locationOptions: locationOptions.value, 63 locationOptions: locationOptions.value,
63 })(), 64 })(),
64 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', 65 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
  66 + collapseTriggerResize: false,
65 })); 67 }));
66 68
67 const gridOptions: VxeGridProps = { 69 const gridOptions: VxeGridProps = {
@@ -177,7 +179,8 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) { @@ -177,7 +179,8 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) {
177 </script> 179 </script>
178 180
179 <template> 181 <template>
180 - <BasicTable> 182 + <div class="flex h-full min-h-0 flex-col">
  183 + <BasicTable :class="managementTabTableClass">
181 <template #toolbar-tools> 184 <template #toolbar-tools>
182 <Space> 185 <Space>
183 <a-button type="primary" @click="handleAdd"> 186 <a-button type="primary" @click="handleAdd">
@@ -234,6 +237,7 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) { @@ -234,6 +237,7 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) {
234 </Popconfirm> 237 </Popconfirm>
235 </Space> 238 </Space>
236 </template> 239 </template>
237 - </BasicTable>  
238 - <CategoryModalHost @reload="tableApi.query()" /> 240 + </BasicTable>
  241 + <CategoryModalHost @reload="tableApi.query()" />
  242 + </div>
239 </template> 243 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/index.vue
@@ -7,6 +7,7 @@ import { Page } from &#39;@vben/common-ui&#39;; @@ -7,6 +7,7 @@ import { Page } from &#39;@vben/common-ui&#39;;
7 import { $t } from '@vben/locales'; 7 import { $t } from '@vben/locales';
8 8
9 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; 9 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
  10 +import { managementTabPageContentClass } from '../shared/management-grid';
10 import ManagementSubTabs from '../shared/management-sub-tabs.vue'; 11 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
11 import ManagementTabBody from '../shared/management-tab-body.vue'; 12 import ManagementTabBody from '../shared/management-tab-body.vue';
12 import CategoriesTab from './categories/index.vue'; 13 import CategoriesTab from './categories/index.vue';
@@ -26,18 +27,18 @@ const tabPanels: Record&lt;string, Component&gt; = { @@ -26,18 +27,18 @@ const tabPanels: Record&lt;string, Component&gt; = {
26 </script> 27 </script>
27 28
28 <template> 29 <template>
29 - <Page> 30 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
30 <a-alert 31 <a-alert
31 v-if="FOOD_LABELING_STATIC_ONLY" 32 v-if="FOOD_LABELING_STATIC_ONLY"
32 - class="mb-3" 33 + class="mb-3 shrink-0"
33 :message="$t('foodLabeling.common.staticDemoBanner')" 34 :message="$t('foodLabeling.common.staticDemoBanner')"
34 show-icon 35 show-icon
35 type="info" 36 type="info"
36 /> 37 />
37 38
38 - <ManagementSubTabs v-model="activeTab" :tabs="tabItems" /> 39 + <ManagementSubTabs v-model="activeTab" :tabs="tabItems" class="shrink-0" />
39 40
40 - <div class="mt-3"> 41 + <div class="mt-3 min-h-0 flex-1 overflow-hidden">
41 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" /> 42 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" />
42 </div> 43 </div>
43 </Page> 44 </Page>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/products/data.ts
@@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -3,6 +3,7 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
3 3
4 import { $t } from '@vben/locales'; 4 import { $t } from '@vben/locales';
5 5
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 import { 7 import {
7 keywordFilterSchema, 8 keywordFilterSchema,
8 stateFilterSchema, 9 stateFilterSchema,
@@ -106,14 +107,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [ @@ -106,14 +107,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
106 minWidth: 140, 107 minWidth: 140,
107 slots: { default: 'lastEdited' }, 108 slots: { default: 'lastEdited' },
108 }, 109 },
109 - {  
110 - field: 'action',  
111 - fixed: 'right',  
112 - slots: { default: 'action' },  
113 - title: $t('foodLabeling.common.action'),  
114 - width: 140,  
115 - resizable: false,  
116 - }, 110 + foodLabelingActionColumn(),
117 ]; 111 ];
118 112
119 export function modalSchemaBasic(options: { 113 export function modalSchemaBasic(options: {
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/products/index.vue
@@ -33,6 +33,7 @@ import { useScopeCatalog } from &#39;../../../shared/use-scope-catalog&#39;; @@ -33,6 +33,7 @@ import { useScopeCatalog } from &#39;../../../shared/use-scope-catalog&#39;;
33 import { useScopeLookups } from '../../../shared/use-scope-lookups'; 33 import { useScopeLookups } from '../../../shared/use-scope-lookups';
34 import { 34 import {
35 managementTabGridBase, 35 managementTabGridBase,
  36 + managementTabTableClass,
36 managementTabVxeGridClass, 37 managementTabVxeGridClass,
37 } from '../../shared/management-grid'; 38 } from '../../shared/management-grid';
38 import { columns, querySchema } from './data'; 39 import { columns, querySchema } from './data';
@@ -78,6 +79,7 @@ const formOptions = computed(() =&gt; ({ @@ -78,6 +79,7 @@ const formOptions = computed(() =&gt; ({
78 locationOptions: locationOptions.value, 79 locationOptions: locationOptions.value,
79 })(), 80 })(),
80 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', 81 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
  82 + collapseTriggerResize: false,
81 })); 83 }));
82 84
83 const gridOptions: VxeGridProps = { 85 const gridOptions: VxeGridProps = {
@@ -231,7 +233,8 @@ function locationCellText(row: ProductDto) { @@ -231,7 +233,8 @@ function locationCellText(row: ProductDto) {
231 </script> 233 </script>
232 234
233 <template> 235 <template>
234 - <BasicTable> 236 + <div class="flex h-full min-h-0 flex-col">
  237 + <BasicTable :class="managementTabTableClass">
235 <template #toolbar-tools> 238 <template #toolbar-tools>
236 <Space> 239 <Space>
237 <a-button @click="handleImport"> 240 <a-button @click="handleImport">
@@ -295,7 +298,8 @@ function locationCellText(row: ProductDto) { @@ -295,7 +298,8 @@ function locationCellText(row: ProductDto) {
295 </Popconfirm> 298 </Popconfirm>
296 </Space> 299 </Space>
297 </template> 300 </template>
298 - </BasicTable>  
299 - <ProductModalHost @reload="tableApi.query()" />  
300 - <ProductImportModalHost @reload="tableApi.query()" /> 301 + </BasicTable>
  302 + <ProductModalHost @reload="tableApi.query()" />
  303 + <ProductImportModalHost @reload="tableApi.query()" />
  304 + </div>
301 </template> 305 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/reports/index.vue
@@ -17,6 +17,7 @@ import { commonDownloadExcel } from &#39;#/utils/file/download&#39;; @@ -17,6 +17,7 @@ import { commonDownloadExcel } from &#39;#/utils/file/download&#39;;
17 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; 17 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
18 import { useAccountLookups } from '../../shared/use-account-lookups'; 18 import { useAccountLookups } from '../../shared/use-account-lookups';
19 import { useScopeLookups } from '../../shared/use-scope-lookups'; 19 import { useScopeLookups } from '../../shared/use-scope-lookups';
  20 +import { managementTabPageContentClass } from '../shared/management-grid';
20 import ManagementSubTabs from '../shared/management-sub-tabs.vue'; 21 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
21 import { defaultReportDateRange, filterSchema } from './data'; 22 import { defaultReportDateRange, filterSchema } from './data';
22 import LabelReportTab from './label-report-tab.vue'; 23 import LabelReportTab from './label-report-tab.vue';
@@ -118,10 +119,10 @@ async function handleExport() { @@ -118,10 +119,10 @@ async function handleExport() {
118 </script> 119 </script>
119 120
120 <template> 121 <template>
121 - <Page v-if="pageAlive"> 122 + <Page v-if="pageAlive" :auto-content-height="true" :content-class="managementTabPageContentClass">
122 <a-alert 123 <a-alert
123 v-if="FOOD_LABELING_STATIC_ONLY" 124 v-if="FOOD_LABELING_STATIC_ONLY"
124 - class="mb-3" 125 + class="mb-3 shrink-0"
125 :message="$t('foodLabeling.common.staticDemoBanner')" 126 :message="$t('foodLabeling.common.staticDemoBanner')"
126 show-icon 127 show-icon
127 type="info" 128 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/shared/management-grid.ts
  1 +import type { VbenFormProps } from '@vben/common-ui';
  2 +
  3 +import type { FormSchemaGetter, VbenFormSchema } from '#/adapter/form';
  4 +
1 import type { VxeGridProps } from '#/adapter/vxe-table'; 5 import type { VxeGridProps } from '#/adapter/vxe-table';
2 6
  7 +import { $t } from '@vben/locales';
  8 +
3 /** 9 /**
4 * 管理模块「页内 Tab」表格基础配置。 10 * 管理模块「页内 Tab」表格基础配置。
5 * 必须关闭 autoResize:在 Tab/KeepAlive 嵌套下,vxe 默认 ResizeObserver 会与 height:auto 11 * 必须关闭 autoResize:在 Tab/KeepAlive 嵌套下,vxe 默认 ResizeObserver 会与 height:auto
@@ -7,14 +13,73 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;; @@ -7,14 +13,73 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
7 */ 13 */
8 export const managementTabGridBase: Pick< 14 export const managementTabGridBase: Pick<
9 VxeGridProps['gridOptions'], 15 VxeGridProps['gridOptions'],
10 - 'autoResize' | 'height' | 'scrollY' 16 + 'autoResize' | 'height' | 'minHeight' | 'scrollX' | 'scrollY' | 'syncResize' | 'toolbarConfig'
11 > = { 17 > = {
12 autoResize: false, 18 autoResize: false,
13 - height: 'auto', 19 + /** 避免窗口横向缩放时 vxe 反复 sync 尺寸导致滚动区域持续增长 */
  20 + syncResize: false,
  21 + height: '100%',
  22 + minHeight: 0,
14 scrollY: { 23 scrollY: {
15 - enabled: false, 24 + enabled: true,
  25 + gt: 0,
  26 + },
  27 + scrollX: {
  28 + enabled: true,
  29 + gt: 0,
  30 + },
  31 + toolbarConfig: {
  32 + /** 全屏缩放会锁定异常高度,列表页关闭 */
  33 + zoom: false,
16 }, 34 },
17 }; 35 };
18 36
19 -/** 覆盖 use-vxe-grid 外层默认 h-full,避免撑满无界父容器 */  
20 -export const managementTabVxeGridClass = '!h-auto min-h-0'; 37 +export const managementTabVxeGridClass =
  38 + 'h-full w-full min-h-0 min-w-0 overflow-hidden';
  39 +
  40 +export const managementTabTableClass =
  41 + 'min-h-0 w-full flex-1 overflow-hidden';
  42 +
  43 +export const managementTabPageContentClass =
  44 + 'flex min-h-0 w-full min-w-0 flex-col';
  45 +
  46 +type ActionColumnOverrides = NonNullable<VxeGridProps['columns']>[number];
  47 +
  48 +/** 操作列:width auto + 右侧固定,避免表格右侧留白 */
  49 +export function foodLabelingActionColumn(
  50 + overrides?: Partial<ActionColumnOverrides>,
  51 +): ActionColumnOverrides {
  52 + return {
  53 + field: 'action',
  54 + fixed: 'right',
  55 + slots: { default: 'action' },
  56 + title: $t('foodLabeling.common.action'),
  57 + resizable: false,
  58 + showOverflow: false,
  59 + width: 'auto',
  60 + minWidth: 140,
  61 + ...overrides,
  62 + };
  63 +}
  64 +
  65 +/**
  66 + * 管理 Tab 列表通用查询表单配置。
  67 + * 大屏 3 列下 ≤3 个筛选项只占一行,收起无效且会误出内部滚动条,故默认隐藏收起按钮。
  68 + */
  69 +export function buildManagementTabFormOptions(
  70 + schema: VbenFormSchema[] | FormSchemaGetter,
  71 + overrides?: Partial<VbenFormProps>,
  72 +): VbenFormProps {
  73 + const resolved = typeof schema === 'function' ? schema() : schema;
  74 + return {
  75 + commonConfig: {
  76 + labelWidth: 90,
  77 + componentProps: { allowClear: true },
  78 + },
  79 + schema: typeof schema === 'function' ? schema : resolved,
  80 + wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
  81 + showCollapseButton: resolved.length > 3,
  82 + collapseTriggerResize: false,
  83 + ...overrides,
  84 + };
  85 +}
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/shared/management-tab-body.vue
@@ -12,8 +12,10 @@ const activeComponent = computed(() =&gt; props.panels[props.activeKey]); @@ -12,8 +12,10 @@ const activeComponent = computed(() =&gt; props.panels[props.activeKey]);
12 </script> 12 </script>
13 13
14 <template> 14 <template>
15 - <!-- KeepAlive:页内 Tab 切换不销毁 VxeGrid,避免 parentNode 报错 -->  
16 - <KeepAlive :max="8">  
17 - <component :is="activeComponent" v-if="activeComponent" :key="activeKey" />  
18 - </KeepAlive> 15 + <div class="h-full min-h-0 w-full overflow-hidden">
  16 + <!-- KeepAlive:页内 Tab 切换不销毁 VxeGrid,避免 parentNode 报错 -->
  17 + <KeepAlive :max="8">
  18 + <component :is="activeComponent" v-if="activeComponent" :key="activeKey" />
  19 + </KeepAlive>
  20 + </div>
19 </template> 21 </template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/support/index.vue
@@ -89,10 +89,10 @@ onMounted(() =&gt; { @@ -89,10 +89,10 @@ onMounted(() =&gt; {
89 </script> 89 </script>
90 90
91 <template> 91 <template>
92 - <Page> 92 + <Page :auto-content-height="true">
93 <a-alert 93 <a-alert
94 v-if="FOOD_LABELING_STATIC_ONLY" 94 v-if="FOOD_LABELING_STATIC_ONLY"
95 - class="mb-3" 95 + class="mb-3 shrink-0"
96 :message="$t('foodLabeling.common.staticDemoBanner')" 96 :message="$t('foodLabeling.common.staticDemoBanner')"
97 show-icon 97 show-icon
98 type="info" 98 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/alerts/index.vue
@@ -38,9 +38,9 @@ function removeTimer(id: number) { @@ -38,9 +38,9 @@ function removeTimer(id: number) {
38 </script> 38 </script>
39 39
40 <template> 40 <template>
41 - <Page> 41 + <Page :auto-content-height="true">
42 <a-alert 42 <a-alert
43 - class="mb-4" 43 + class="mb-4 shrink-0"
44 :message="$t('foodLabeling.modules.frontendOnlyBanner')" 44 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
45 show-icon 45 show-icon
46 type="info" 46 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/devices/index.vue
@@ -17,6 +17,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -17,6 +17,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
17 17
18 import { 18 import {
19 labelingGridBase, 19 labelingGridBase,
  20 + labelingPageContentClass,
  21 + labelingTableClass,
20 labelingVxeGridClass, 22 labelingVxeGridClass,
21 } from '../../shared/labeling-grid'; 23 } from '../../shared/labeling-grid';
22 import { MOCK_DEVICES } from '../../shared/mock-modules-data'; 24 import { MOCK_DEVICES } from '../../shared/mock-modules-data';
@@ -71,6 +73,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({ @@ -71,6 +73,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({
71 }, 73 },
72 schema: querySchema()(), 74 schema: querySchema()(),
73 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', 75 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
  76 + collapseTriggerResize: false,
74 }, 77 },
75 gridOptions, 78 gridOptions,
76 }); 79 });
@@ -97,14 +100,14 @@ function handleBulk(action: string) { @@ -97,14 +100,14 @@ function handleBulk(action: string) {
97 </script> 100 </script>
98 101
99 <template> 102 <template>
100 - <Page> 103 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
101 <a-alert 104 <a-alert
102 - class="mb-3" 105 + class="mb-3 shrink-0"
103 :message="$t('foodLabeling.modules.frontendOnlyBanner')" 106 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
104 show-icon 107 show-icon
105 type="info" 108 type="info"
106 /> 109 />
107 - <BasicTable v-if="pageReady"> 110 + <BasicTable v-if="pageReady" :class="labelingTableClass">
108 <template #toolbar-tools> 111 <template #toolbar-tools>
109 <Space wrap> 112 <Space wrap>
110 <a-button @click="handleBulk('Bulk Bind')"> 113 <a-button @click="handleBulk('Bulk Bind')">
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/invoices/index.vue
@@ -15,6 +15,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;; @@ -15,6 +15,8 @@ import { useVbenVxeGrid } from &#39;#/adapter/vxe-table&#39;;
15 15
16 import { 16 import {
17 labelingGridBase, 17 labelingGridBase,
  18 + labelingPageContentClass,
  19 + labelingTableClass,
18 labelingVxeGridClass, 20 labelingVxeGridClass,
19 } from '../../shared/labeling-grid'; 21 } from '../../shared/labeling-grid';
20 import { MOCK_INVOICES } from '../../shared/mock-modules-data'; 22 import { MOCK_INVOICES } from '../../shared/mock-modules-data';
@@ -59,6 +61,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({ @@ -59,6 +61,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({
59 }, 61 },
60 schema: querySchema()(), 62 schema: querySchema()(),
61 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', 63 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
  64 + collapseTriggerResize: false,
62 }, 65 },
63 gridOptions, 66 gridOptions,
64 }); 67 });
@@ -102,14 +105,14 @@ function overdueClass(row: InvoiceRow) { @@ -102,14 +105,14 @@ function overdueClass(row: InvoiceRow) {
102 </script> 105 </script>
103 106
104 <template> 107 <template>
105 - <Page> 108 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
106 <a-alert 109 <a-alert
107 - class="mb-3" 110 + class="mb-3 shrink-0"
108 :message="$t('foodLabeling.modules.frontendOnlyBanner')" 111 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
109 show-icon 112 show-icon
110 type="info" 113 type="info"
111 /> 114 />
112 - <BasicTable v-if="pageReady"> 115 + <BasicTable v-if="pageReady" :class="labelingTableClass">
113 <template #toolbar-tools> 116 <template #toolbar-tools>
114 <a-button @click="handleExport"> 117 <a-button @click="handleExport">
115 {{ $t('foodLabeling.modules.invoices.exportBills') }} 118 {{ $t('foodLabeling.modules.invoices.exportBills') }}
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/qr-codes/index.vue
@@ -10,6 +10,7 @@ import QRCode from &#39;qrcode&#39;; @@ -10,6 +10,7 @@ import QRCode from &#39;qrcode&#39;;
10 import { message } from 'ant-design-vue'; 10 import { message } from 'ant-design-vue';
11 import { Card, Col, InputNumber, Row, Select, Space, Table, Tag } from 'ant-design-vue'; 11 import { Card, Col, InputNumber, Row, Select, Space, Table, Tag } from 'ant-design-vue';
12 12
  13 +import { labelingPageContentClass } from '../../shared/labeling-grid';
13 import ManagementSubTabs from '../../management/shared/management-sub-tabs.vue'; 14 import ManagementSubTabs from '../../management/shared/management-sub-tabs.vue';
14 import { MOCK_QR_CODES } from '../../shared/mock-modules-data'; 15 import { MOCK_QR_CODES } from '../../shared/mock-modules-data';
15 16
@@ -103,9 +104,9 @@ onMounted(() =&gt; { @@ -103,9 +104,9 @@ onMounted(() =&gt; {
103 </script> 104 </script>
104 105
105 <template> 106 <template>
106 - <Page> 107 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
107 <a-alert 108 <a-alert
108 - class="mb-3" 109 + class="mb-3 shrink-0"
109 :message="$t('foodLabeling.modules.frontendOnlyBanner')" 110 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
110 show-icon 111 show-icon
111 type="info" 112 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/training/index.vue
@@ -61,9 +61,9 @@ function isSubOpen(id: string) { @@ -61,9 +61,9 @@ function isSubOpen(id: string) {
61 </script> 61 </script>
62 62
63 <template> 63 <template>
64 - <Page> 64 + <Page :auto-content-height="true">
65 <a-alert 65 <a-alert
66 - class="mb-4" 66 + class="mb-4 shrink-0"
67 :message="$t('foodLabeling.modules.frontendOnlyBanner')" 67 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
68 show-icon 68 show-icon
69 type="info" 69 type="info"
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/data.ts 0 → 100644
  1 +import type { FormSchemaGetter } from '#/adapter/form';
  2 +import type { VxeGridProps } from '#/adapter/vxe-table';
  3 +
  4 +import { $t } from '@vben/locales';
  5 +
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
  7 +import { keywordFilterSchema, stateFilterSchema } from '../../shared/form-schema';
  8 +
  9 +export const querySchema: FormSchemaGetter = () => [
  10 + ...keywordFilterSchema(),
  11 + ...stateFilterSchema(),
  12 +];
  13 +
  14 +export const columns: VxeGridProps['columns'] = [
  15 + {
  16 + field: 'companyName',
  17 + title: $t('foodLabeling.platform.companyName'),
  18 + minWidth: 160,
  19 + slots: { default: 'companyName' },
  20 + },
  21 + {
  22 + field: 'companyCode',
  23 + title: $t('foodLabeling.platform.companyCode'),
  24 + minWidth: 120,
  25 + slots: { default: 'companyCode' },
  26 + },
  27 + {
  28 + field: 'adminUserName',
  29 + title: $t('foodLabeling.platform.tenantAdmin'),
  30 + minWidth: 130,
  31 + slots: { default: 'adminUserName' },
  32 + },
  33 + {
  34 + field: 'menuPermissionKeys',
  35 + title: $t('foodLabeling.platform.menuPermissions'),
  36 + minWidth: 100,
  37 + slots: { default: 'menuCount' },
  38 + },
  39 + {
  40 + field: 'contactEmail',
  41 + title: $t('foodLabeling.accountManagement.contactEmail'),
  42 + minWidth: 160,
  43 + slots: { default: 'contactEmail' },
  44 + },
  45 + {
  46 + field: 'state',
  47 + title: $t('foodLabeling.common.status'),
  48 + width: 90,
  49 + slots: { default: 'state' },
  50 + },
  51 + foodLabelingActionColumn({ minWidth: 160 }),
  52 +];
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/index.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { VbenFormProps } from '@vben/common-ui';
  3 +import type { Recordable } from '@vben/types';
  4 +
  5 +import type { SaasTenantDto } from '../../shared/saas-types';
  6 +
  7 +import type { VxeGridProps } from '#/adapter/vxe-table';
  8 +
  9 +import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';
  10 +import { $t } from '@vben/locales';
  11 +
  12 +import { Alert, Avatar, Button, Dropdown, Menu, Modal, Space, Tag } from 'ant-design-vue';
  13 +
  14 +import { useVbenVxeGrid } from '#/adapter/vxe-table';
  15 +
  16 +import { matchKeyword, slicePage } from '../../shared/static-mode';
  17 +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data';
  18 +import {
  19 + buildManagementTabFormOptions,
  20 + managementTabGridBase,
  21 + managementTabPageContentClass,
  22 + managementTabTableClass,
  23 + managementTabVxeGridClass,
  24 +} from '../../management/shared/management-grid';
  25 +import { columns, querySchema } from './data';
  26 +import TenantAdminModal from './tenant-admin-modal.vue';
  27 +import TenantMenuDrawer from './tenant-menu-drawer.vue';
  28 +import TenantModal from './tenant-modal.vue';
  29 +
  30 +defineOptions({ name: 'FoodLabelingPlatformTenants' });
  31 +
  32 +function displayText(v: string | null | undefined) {
  33 + const s = (v ?? '').trim();
  34 + return s || $t('foodLabeling.common.none');
  35 +}
  36 +
  37 +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema());
  38 +
  39 +const gridOptions: VxeGridProps = {
  40 + ...managementTabGridBase,
  41 + columns,
  42 + keepSource: true,
  43 + pagerConfig: {},
  44 + proxyConfig: {
  45 + ajax: {
  46 + query: async ({ page }, formValues = {}) => {
  47 + let rows = [...MOCK_SAAS_TENANTS];
  48 + const kw = String(formValues.Keyword ?? '');
  49 + if (kw) {
  50 + rows = rows.filter((r) =>
  51 + matchKeyword(r as Recordable, kw, [
  52 + 'companyName',
  53 + 'companyCode',
  54 + 'adminUserName',
  55 + 'contactEmail',
  56 + 'contactName',
  57 + ]),
  58 + );
  59 + }
  60 + if (formValues.State === true || formValues.State === false) {
  61 + rows = rows.filter((r) => r.state === formValues.State);
  62 + }
  63 + return slicePage(rows, page.currentPage, page.pageSize);
  64 + },
  65 + },
  66 + },
  67 + rowConfig: { isHover: true, keyField: 'id' },
  68 + showOverflow: false,
  69 +};
  70 +
  71 +const [BasicTable, tableApi] = useVbenVxeGrid({
  72 + class: managementTabVxeGridClass,
  73 + formOptions,
  74 + gridClass: 'min-w-0 max-w-full',
  75 + gridOptions: {
  76 + ...gridOptions,
  77 + id: 'food-labeling-saas-tenants',
  78 + },
  79 +});
  80 +
  81 +const [TenantModalHost, tenantModalApi] = useVbenModal({
  82 + connectedComponent: TenantModal,
  83 +});
  84 +
  85 +const [TenantMenuDrawerHost, tenantMenuDrawerApi] = useVbenDrawer({
  86 + connectedComponent: TenantMenuDrawer,
  87 +});
  88 +
  89 +const [TenantAdminModalHost, tenantAdminModalApi] = useVbenModal({
  90 + connectedComponent: TenantAdminModal,
  91 +});
  92 +
  93 +function handleAdd() {
  94 + tenantModalApi.setData({});
  95 + tenantModalApi.open();
  96 +}
  97 +
  98 +function handleEdit(row: SaasTenantDto) {
  99 + tenantModalApi.setData({ id: row.id });
  100 + tenantModalApi.open();
  101 +}
  102 +
  103 +function handleMenus(row: SaasTenantDto) {
  104 + tenantMenuDrawerApi.setData({ id: row.id });
  105 + tenantMenuDrawerApi.open();
  106 +}
  107 +
  108 +function handleAdmin(row: SaasTenantDto) {
  109 + tenantAdminModalApi.setData({ id: row.id });
  110 + tenantAdminModalApi.open();
  111 +}
  112 +
  113 +function handleDelete(row: SaasTenantDto) {
  114 + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === row.id);
  115 + if (idx >= 0) {
  116 + MOCK_SAAS_TENANTS.splice(idx, 1);
  117 + }
  118 + tableApi.reload();
  119 +}
  120 +
  121 +function menuCount(row: SaasTenantDto) {
  122 + return row.menuPermissionKeys?.length ?? 0;
  123 +}
  124 +
  125 +function confirmDelete(row: SaasTenantDto) {
  126 + Modal.confirm({
  127 + okType: 'danger',
  128 + title: $t('foodLabeling.platform.deleteCompanyConfirm'),
  129 + onOk: () => handleDelete(row),
  130 + });
  131 +}
  132 +
  133 +function onActionMenu(key: string, row: SaasTenantDto) {
  134 + if (key === 'menus') {
  135 + handleMenus(row);
  136 + return;
  137 + }
  138 + if (key === 'admin') {
  139 + handleAdmin(row);
  140 + return;
  141 + }
  142 + if (key === 'delete') {
  143 + confirmDelete(row);
  144 + }
  145 +}
  146 +</script>
  147 +
  148 +<template>
  149 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
  150 + <Alert
  151 + class="mb-3 shrink-0"
  152 + :message="$t('foodLabeling.platform.saasBanner')"
  153 + show-icon
  154 + type="info"
  155 + />
  156 +
  157 + <BasicTable :class="managementTabTableClass">
  158 + <template #toolbar-tools>
  159 + <Button type="primary" @click="handleAdd">
  160 + {{ $t('foodLabeling.platform.addCompany') }}
  161 + </Button>
  162 + </template>
  163 +
  164 + <template #companyName="{ row }">
  165 + <div class="flex min-w-0 items-center gap-2">
  166 + <Avatar v-if="row.logoUrl" :src="row.logoUrl" size="small" />
  167 + <Avatar v-else size="small">{{ (row.companyName ?? '?').slice(0, 1) }}</Avatar>
  168 + <span class="truncate font-medium">{{ displayText(row.companyName) }}</span>
  169 + </div>
  170 + </template>
  171 +
  172 + <template #companyCode="{ row }">
  173 + {{ displayText(row.companyCode) }}
  174 + </template>
  175 +
  176 + <template #adminUserName="{ row }">
  177 + <div class="truncate">
  178 + <div>{{ displayText(row.adminFullName) }}</div>
  179 + <div class="text-xs text-gray-500">{{ displayText(row.adminUserName) }}</div>
  180 + </div>
  181 + </template>
  182 +
  183 + <template #menuCount="{ row }">
  184 + <Tag color="blue">{{ menuCount(row) }}</Tag>
  185 + </template>
  186 +
  187 + <template #contactEmail="{ row }">
  188 + {{ displayText(row.contactEmail) }}
  189 + </template>
  190 +
  191 + <template #state="{ row }">
  192 + <Tag :color="row.state !== false ? 'green' : 'default'">
  193 + {{
  194 + row.state !== false
  195 + ? $t('foodLabeling.common.enabled')
  196 + : $t('foodLabeling.common.disabled')
  197 + }}
  198 + </Tag>
  199 + </template>
  200 +
  201 + <template #action="{ row }">
  202 + <div class="saas-tenant-row-actions">
  203 + <Space :size="8">
  204 + <ghost-button size="small" @click="handleEdit(row)">
  205 + {{ $t('pages.common.edit') }}
  206 + </ghost-button>
  207 + <Dropdown :trigger="['click']">
  208 + <ghost-button size="small">
  209 + {{ $t('pages.common.more') }}
  210 + </ghost-button>
  211 + <template #overlay>
  212 + <Menu @click="({ key }) => onActionMenu(String(key), row)">
  213 + <Menu.Item key="menus">
  214 + {{ $t('foodLabeling.platform.menuPermissions') }}
  215 + </Menu.Item>
  216 + <Menu.Item key="admin">
  217 + {{ $t('foodLabeling.platform.tenantAdmin') }}
  218 + </Menu.Item>
  219 + <Menu.Divider />
  220 + <Menu.Item key="delete" danger>
  221 + {{ $t('pages.common.delete') }}
  222 + </Menu.Item>
  223 + </Menu>
  224 + </template>
  225 + </Dropdown>
  226 + </Space>
  227 + </div>
  228 + </template>
  229 + </BasicTable>
  230 +
  231 + <TenantModalHost @reload="tableApi.reload()" />
  232 + <TenantMenuDrawerHost @reload="tableApi.reload()" />
  233 + <TenantAdminModalHost @reload="tableApi.reload()" />
  234 + </Page>
  235 +</template>
  236 +
  237 +<style scoped>
  238 +.saas-tenant-row-actions {
  239 + display: flex;
  240 + align-items: center;
  241 + padding: 4px 0;
  242 + line-height: 1.5;
  243 + white-space: nowrap;
  244 +}
  245 +
  246 +.saas-tenant-row-actions :deep(.ant-space) {
  247 + flex-wrap: nowrap;
  248 +}
  249 +</style>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-admin-modal.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { SaasTenantDto } from '../../shared/saas-types';
  3 +
  4 +import { computed, ref } from 'vue';
  5 +
  6 +import { useVbenModal } from '@vben/common-ui';
  7 +import { $t } from '@vben/locales';
  8 +import { cloneDeep } from '@vben/utils';
  9 +
  10 +import { message } from 'ant-design-vue';
  11 +
  12 +import { useVbenForm } from '#/adapter/form';
  13 +
  14 +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data';
  15 +
  16 +const emit = defineEmits<{ reload: [] }>();
  17 +
  18 +const tenantId = ref('');
  19 +const tenantName = ref('');
  20 +
  21 +const title = computed(() =>
  22 + $t('foodLabeling.platform.manageTenantAdmin', { name: tenantName.value }),
  23 +);
  24 +
  25 +const [BasicForm, formApi] = useVbenForm({
  26 + commonConfig: {
  27 + formItemClass: 'col-span-2',
  28 + labelWidth: 110,
  29 + componentProps: { class: 'w-full' },
  30 + },
  31 + schema: [
  32 + {
  33 + component: 'Input',
  34 + fieldName: 'adminUserName',
  35 + label: $t('foodLabeling.accountManagement.userName'),
  36 + rules: 'required',
  37 + },
  38 + {
  39 + component: 'InputPassword',
  40 + fieldName: 'adminPassword',
  41 + label: $t('foodLabeling.accountManagement.password'),
  42 + componentProps: {
  43 + placeholder: $t('foodLabeling.accountManagement.passwordOptional'),
  44 + },
  45 + },
  46 + {
  47 + component: 'Input',
  48 + fieldName: 'adminFullName',
  49 + label: $t('foodLabeling.accountManagement.fullName'),
  50 + rules: 'required',
  51 + },
  52 + {
  53 + component: 'Input',
  54 + fieldName: 'adminEmail',
  55 + label: $t('foodLabeling.accountManagement.contactEmail'),
  56 + },
  57 + {
  58 + component: 'Input',
  59 + fieldName: 'adminPhone',
  60 + label: $t('foodLabeling.accountManagement.phoneNumber'),
  61 + },
  62 + ],
  63 + showDefaultActions: false,
  64 + wrapperClass: 'grid-cols-2',
  65 +});
  66 +
  67 +const [BasicModal, modalApi] = useVbenModal({
  68 + class: 'w-[560px]',
  69 + fullscreenButton: false,
  70 + onCancel: handleCancel,
  71 + onConfirm: handleConfirm,
  72 + onOpenChange: async (isOpen) => {
  73 + if (!isOpen) {
  74 + return null;
  75 + }
  76 + const data = modalApi.getData() as { id?: string };
  77 + tenantId.value = data?.id ?? '';
  78 + const record = MOCK_SAAS_TENANTS.find((x) => x.id === tenantId.value);
  79 + tenantName.value = record?.companyName ?? '';
  80 + await formApi.setValues({
  81 + adminUserName: record?.adminUserName ?? '',
  82 + adminFullName: record?.adminFullName ?? '',
  83 + adminEmail: record?.adminEmail ?? '',
  84 + adminPassword: '',
  85 + });
  86 + },
  87 +});
  88 +
  89 +async function handleConfirm() {
  90 + const { valid } = await formApi.validate();
  91 + if (!valid || !tenantId.value) {
  92 + return;
  93 + }
  94 + const values = cloneDeep(await formApi.getValues()) as Record<string, unknown>;
  95 + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === tenantId.value);
  96 + if (idx >= 0) {
  97 + const row = MOCK_SAAS_TENANTS[idx]!;
  98 + row.adminUserName = String(values.adminUserName ?? '').trim();
  99 + row.adminFullName = String(values.adminFullName ?? '').trim();
  100 + row.adminEmail = String(values.adminEmail ?? '').trim() || null;
  101 + if (!row.adminUserId) {
  102 + row.adminUserId = `admin-${tenantId.value.slice(0, 8)}`;
  103 + }
  104 + }
  105 + message.success($t('foodLabeling.common.staticDemoAction'));
  106 + emit('reload');
  107 + await handleCancel();
  108 +}
  109 +
  110 +async function handleCancel() {
  111 + modalApi.close();
  112 + await formApi.resetForm();
  113 +}
  114 +</script>
  115 +
  116 +<template>
  117 + <BasicModal :title="title">
  118 + <a-alert
  119 + class="mb-4"
  120 + :message="$t('foodLabeling.platform.tenantAdminHint')"
  121 + show-icon
  122 + type="info"
  123 + />
  124 + <BasicForm />
  125 + </BasicModal>
  126 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-logo-upload-field.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import TenantLogoUpload from './tenant-logo-upload.vue';
  3 +
  4 +const modelValue = defineModel<string>({ default: '' });
  5 +</script>
  6 +
  7 +<template>
  8 + <TenantLogoUpload v-model="modelValue" />
  9 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-logo-upload.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { UploadFile, UploadProps } from 'ant-design-vue';
  3 +
  4 +import { ref, watch } from 'vue';
  5 +
  6 +import { PlusOutlined } from '@ant-design/icons-vue';
  7 +import { message, Upload } from 'ant-design-vue';
  8 +import { $t } from '@vben/locales';
  9 +
  10 +const logoUrl = defineModel<string>({ default: '' });
  11 +
  12 +const fileList = ref<UploadFile[]>([]);
  13 +
  14 +function syncFileListFromUrl(url: string) {
  15 + if (!url?.trim()) {
  16 + fileList.value = [];
  17 + return;
  18 + }
  19 + if (fileList.value[0]?.url === url) {
  20 + return;
  21 + }
  22 + fileList.value = [
  23 + {
  24 + name: 'logo',
  25 + status: 'done',
  26 + uid: '-1',
  27 + url,
  28 + },
  29 + ];
  30 +}
  31 +
  32 +watch(
  33 + logoUrl,
  34 + (url) => {
  35 + syncFileListFromUrl(url ?? '');
  36 + },
  37 + { immediate: true },
  38 +);
  39 +
  40 +const beforeUpload: UploadProps['beforeUpload'] = (file) => {
  41 + const isImage = file.type?.startsWith('image/');
  42 + if (!isImage) {
  43 + message.error($t('foodLabeling.platform.logoUploadImageOnly'));
  44 + return Upload.LIST_IGNORE;
  45 + }
  46 + if (file.size / 1024 / 1024 > 2) {
  47 + message.error($t('foodLabeling.platform.logoUploadMaxSize'));
  48 + return Upload.LIST_IGNORE;
  49 + }
  50 +
  51 + const reader = new FileReader();
  52 + reader.onload = () => {
  53 + const dataUrl = String(reader.result ?? '');
  54 + logoUrl.value = dataUrl;
  55 + fileList.value = [
  56 + {
  57 + name: file.name,
  58 + status: 'done',
  59 + uid: file.uid,
  60 + url: dataUrl,
  61 + },
  62 + ];
  63 + };
  64 + reader.readAsDataURL(file);
  65 + return false;
  66 +};
  67 +
  68 +function handleRemove() {
  69 + logoUrl.value = '';
  70 + fileList.value = [];
  71 +}
  72 +</script>
  73 +
  74 +<template>
  75 + <Upload
  76 + v-model:file-list="fileList"
  77 + accept="image/*"
  78 + class="saas-tenant-logo-upload"
  79 + list-type="picture-card"
  80 + :max-count="1"
  81 + :before-upload="beforeUpload"
  82 + @remove="handleRemove"
  83 + >
  84 + <div v-if="!fileList.length" class="flex flex-col items-center text-gray-500">
  85 + <PlusOutlined />
  86 + <span class="mt-1 text-xs">{{ $t('foodLabeling.platform.logoUpload') }}</span>
  87 + </div>
  88 + </Upload>
  89 +</template>
  90 +
  91 +<style scoped>
  92 +.saas-tenant-logo-upload :deep(.ant-upload-select),
  93 +.saas-tenant-logo-upload :deep(.ant-upload-list-item-container) {
  94 + width: 96px !important;
  95 + height: 96px !important;
  96 +}
  97 +</style>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-menu-drawer.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { SaasTenantDto } from '../../shared/saas-types';
  3 +
  4 +import { ref } from 'vue';
  5 +
  6 +import { useVbenDrawer } from '@vben/common-ui';
  7 +import { $t } from '@vben/locales';
  8 +
  9 +import { message } from 'ant-design-vue';
  10 +
  11 +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data';
  12 +import MenuPermissionTree from '../../shared/menu-permission-tree.vue';
  13 +
  14 +const emit = defineEmits<{ reload: [] }>();
  15 +
  16 +const record = ref<SaasTenantDto | null>(null);
  17 +const menuKeys = ref<string[]>([]);
  18 +
  19 +const [BasicDrawer, drawerApi] = useVbenDrawer({
  20 + class: 'w-[480px]',
  21 + onConfirm: handleSave,
  22 + onOpenChange(isOpen) {
  23 + if (!isOpen) {
  24 + record.value = null;
  25 + return;
  26 + }
  27 + const data = drawerApi.getData() as { id?: string };
  28 + const found = MOCK_SAAS_TENANTS.find((x) => x.id === data?.id) ?? null;
  29 + record.value = found;
  30 + menuKeys.value = found ? [...found.menuPermissionKeys] : [];
  31 + },
  32 +});
  33 +
  34 +async function handleSave() {
  35 + if (!record.value) {
  36 + return;
  37 + }
  38 + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === record.value!.id);
  39 + if (idx >= 0) {
  40 + MOCK_SAAS_TENANTS[idx]!.menuPermissionKeys = [...menuKeys.value];
  41 + }
  42 + message.success($t('foodLabeling.platform.menuSaved'));
  43 + emit('reload');
  44 + drawerApi.close();
  45 +}
  46 +</script>
  47 +
  48 +<template>
  49 + <BasicDrawer :title="$t('foodLabeling.platform.configureMenus')">
  50 + <p v-if="record" class="mb-3 text-sm text-gray-600">
  51 + {{ $t('foodLabeling.platform.configureMenusHint', { name: record.companyName }) }}
  52 + </p>
  53 + <MenuPermissionTree v-model="menuKeys" :height="420" />
  54 + </BasicDrawer>
  55 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-modal.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { SaasTenantDto } from '../../shared/saas-types';
  3 +
  4 +import { computed, markRaw, ref } from 'vue';
  5 +
  6 +import { useVbenModal } from '@vben/common-ui';
  7 +import { $t } from '@vben/locales';
  8 +import { cloneDeep } from '@vben/utils';
  9 +
  10 +import { message } from 'ant-design-vue';
  11 +
  12 +import { useVbenForm } from '#/adapter/form';
  13 +
  14 +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data';
  15 +import { stateModalSchema } from '../../shared/form-schema';
  16 +import { ALL_SAAS_MENU_KEYS } from '../../shared/saas-menu-tree';
  17 +import TenantLogoUploadField from './tenant-logo-upload-field.vue';
  18 +
  19 +const emit = defineEmits<{ reload: [] }>();
  20 +
  21 +const isUpdate = ref(false);
  22 +const recordId = ref('');
  23 +
  24 +const title = computed(() =>
  25 + isUpdate.value
  26 + ? $t('foodLabeling.platform.editCompany')
  27 + : $t('foodLabeling.platform.addCompany'),
  28 +);
  29 +
  30 +const [BasicForm, formApi] = useVbenForm({
  31 + commonConfig: {
  32 + formItemClass: 'col-span-2',
  33 + labelWidth: 120,
  34 + componentProps: { class: 'w-full' },
  35 + },
  36 + schema: [
  37 + {
  38 + component: 'Input',
  39 + fieldName: 'companyName',
  40 + label: $t('foodLabeling.platform.companyName'),
  41 + rules: 'required',
  42 + },
  43 + {
  44 + component: 'Input',
  45 + fieldName: 'companyCode',
  46 + label: $t('foodLabeling.platform.companyCode'),
  47 + rules: 'required',
  48 + componentProps: { placeholder: 'SIAM_FRESH' },
  49 + },
  50 + {
  51 + component: markRaw(TenantLogoUploadField),
  52 + fieldName: 'logoUrl',
  53 + label: $t('foodLabeling.platform.logo'),
  54 + formItemClass: 'col-span-2 items-start',
  55 + componentProps: { class: 'w-auto' },
  56 + },
  57 + {
  58 + component: 'Input',
  59 + fieldName: 'contactName',
  60 + label: $t('foodLabeling.platform.contactName'),
  61 + },
  62 + {
  63 + component: 'Input',
  64 + fieldName: 'contactEmail',
  65 + label: $t('foodLabeling.accountManagement.contactEmail'),
  66 + },
  67 + {
  68 + component: 'Input',
  69 + fieldName: 'contactPhone',
  70 + label: $t('foodLabeling.accountManagement.phoneNumber'),
  71 + },
  72 + {
  73 + component: 'Textarea',
  74 + fieldName: 'address',
  75 + label: $t('foodLabeling.platform.address'),
  76 + formItemClass: 'col-span-2',
  77 + },
  78 + {
  79 + component: 'Textarea',
  80 + fieldName: 'remark',
  81 + label: $t('foodLabeling.accountManagement.remark'),
  82 + formItemClass: 'col-span-2',
  83 + },
  84 + {
  85 + component: 'Input',
  86 + fieldName: 'adminUserName',
  87 + label: $t('foodLabeling.accountManagement.userName'),
  88 + rules: 'required',
  89 + dependencies: {
  90 + if: () => !isUpdate.value,
  91 + triggerFields: ['companyName'],
  92 + },
  93 + },
  94 + {
  95 + component: 'InputPassword',
  96 + fieldName: 'adminPassword',
  97 + label: $t('foodLabeling.accountManagement.password'),
  98 + rules: 'required',
  99 + dependencies: {
  100 + if: () => !isUpdate.value,
  101 + triggerFields: ['companyName'],
  102 + },
  103 + },
  104 + {
  105 + component: 'Input',
  106 + fieldName: 'adminFullName',
  107 + label: $t('foodLabeling.accountManagement.fullName'),
  108 + rules: 'required',
  109 + dependencies: {
  110 + if: () => !isUpdate.value,
  111 + triggerFields: ['companyName'],
  112 + },
  113 + },
  114 + {
  115 + component: 'Input',
  116 + fieldName: 'adminEmail',
  117 + label: $t('foodLabeling.accountManagement.contactEmail'),
  118 + dependencies: {
  119 + if: () => !isUpdate.value,
  120 + triggerFields: ['companyName'],
  121 + },
  122 + },
  123 + ...stateModalSchema().map((item) => ({
  124 + ...item,
  125 + componentProps: {
  126 + ...(item.componentProps as Record<string, unknown>),
  127 + class: 'w-fit',
  128 + },
  129 + })),
  130 + ],
  131 + showDefaultActions: false,
  132 + wrapperClass: 'grid-cols-2',
  133 +});
  134 +
  135 +const [BasicModal, modalApi] = useVbenModal({
  136 + class: 'w-[760px]',
  137 + fullscreenButton: false,
  138 + onCancel: handleCancel,
  139 + onConfirm: handleConfirm,
  140 + onOpenChange: async (isOpen) => {
  141 + if (!isOpen) {
  142 + return null;
  143 + }
  144 + modalApi.modalLoading(true);
  145 + const data = modalApi.getData() as { id?: string };
  146 + isUpdate.value = !!data?.id;
  147 + recordId.value = data?.id ?? '';
  148 +
  149 + if (isUpdate.value && recordId.value) {
  150 + const record =
  151 + MOCK_SAAS_TENANTS.find((x) => x.id === recordId.value) ?? ({} as SaasTenantDto);
  152 + await formApi.setValues({
  153 + companyName: record.companyName,
  154 + companyCode: record.companyCode,
  155 + logoUrl: record.logoUrl ?? '',
  156 + contactName: record.contactName ?? '',
  157 + contactEmail: record.contactEmail ?? '',
  158 + contactPhone: record.contactPhone ?? '',
  159 + address: record.address ?? '',
  160 + remark: record.remark ?? '',
  161 + state: record.state !== false,
  162 + });
  163 + } else {
  164 + await formApi.resetForm();
  165 + await formApi.setValues({ state: true });
  166 + }
  167 + modalApi.modalLoading(false);
  168 + },
  169 +});
  170 +
  171 +async function handleConfirm() {
  172 + try {
  173 + modalApi.modalLoading(true);
  174 + const { valid } = await formApi.validate();
  175 + if (!valid) {
  176 + return;
  177 + }
  178 + const values = cloneDeep(await formApi.getValues()) as Record<string, unknown>;
  179 + if (isUpdate.value && recordId.value) {
  180 + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === recordId.value);
  181 + if (idx >= 0) {
  182 + const prev = MOCK_SAAS_TENANTS[idx]!;
  183 + MOCK_SAAS_TENANTS[idx] = {
  184 + ...prev,
  185 + companyName: String(values.companyName ?? '').trim(),
  186 + companyCode: String(values.companyCode ?? '').trim(),
  187 + logoUrl: String(values.logoUrl ?? '').trim() || null,
  188 + contactName: String(values.contactName ?? '').trim() || null,
  189 + contactEmail: String(values.contactEmail ?? '').trim() || null,
  190 + contactPhone: String(values.contactPhone ?? '').trim() || null,
  191 + address: String(values.address ?? '').trim() || null,
  192 + remark: String(values.remark ?? '').trim() || null,
  193 + state: values.state !== false,
  194 + };
  195 + }
  196 + } else {
  197 + const id = crypto.randomUUID();
  198 + MOCK_SAAS_TENANTS.push({
  199 + id,
  200 + companyName: String(values.companyName ?? '').trim(),
  201 + companyCode: String(values.companyCode ?? '').trim(),
  202 + logoUrl: String(values.logoUrl ?? '').trim() || null,
  203 + contactName: String(values.contactName ?? '').trim() || null,
  204 + contactEmail: String(values.contactEmail ?? '').trim() || null,
  205 + contactPhone: String(values.contactPhone ?? '').trim() || null,
  206 + address: String(values.address ?? '').trim() || null,
  207 + remark: String(values.remark ?? '').trim() || null,
  208 + state: values.state !== false,
  209 + menuPermissionKeys: [...ALL_SAAS_MENU_KEYS],
  210 + adminUserId: `admin-${id.slice(0, 8)}`,
  211 + adminUserName: String(values.adminUserName ?? '').trim(),
  212 + adminFullName: String(values.adminFullName ?? '').trim(),
  213 + adminEmail: String(values.adminEmail ?? '').trim() || null,
  214 + creationTime: new Date().toISOString().slice(0, 16).replace('T', ' '),
  215 + });
  216 + }
  217 + message.success($t('foodLabeling.common.staticDemoAction'));
  218 + emit('reload');
  219 + await handleCancel();
  220 + } finally {
  221 + modalApi.modalLoading(false);
  222 + }
  223 +}
  224 +
  225 +async function handleCancel() {
  226 + modalApi.close();
  227 + await formApi.resetForm();
  228 +}
  229 +</script>
  230 +
  231 +<template>
  232 + <BasicModal :title="title">
  233 + <a-alert
  234 + v-if="!isUpdate"
  235 + class="mb-4"
  236 + :message="$t('foodLabeling.platform.initialAdminSection')"
  237 + show-icon
  238 + type="info"
  239 + />
  240 + <BasicForm />
  241 + </BasicModal>
  242 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/labeling-grid.ts
1 export { 1 export {
  2 + foodLabelingActionColumn,
2 managementTabGridBase as labelingGridBase, 3 managementTabGridBase as labelingGridBase,
  4 + managementTabPageContentClass as labelingPageContentClass,
  5 + managementTabTableClass as labelingTableClass,
3 managementTabVxeGridClass as labelingVxeGridClass, 6 managementTabVxeGridClass as labelingVxeGridClass,
4 } from '../management/shared/management-grid'; 7 } from '../management/shared/management-grid';
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import MenuPermissionTree from './menu-permission-tree.vue';
  3 +
  4 +const modelValue = defineModel<string[]>({ default: () => [] });
  5 +
  6 +defineProps<{
  7 + allowedKeys?: string[] | null;
  8 +}>();
  9 +</script>
  10 +
  11 +<template>
  12 + <MenuPermissionTree v-model="modelValue" :allowed-keys="allowedKeys" />
  13 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue 0 → 100644
  1 +<script lang="ts" setup>
  2 +import type { SaasMenuTreeNode } from './saas-types';
  3 +
  4 +import { computed, watch } from 'vue';
  5 +
  6 +import { $t } from '@vben/locales';
  7 +
  8 +import { Tree } from 'ant-design-vue';
  9 +
  10 +import {
  11 + filterMenuTreeByAllowed,
  12 + normalizeMenuKeysWithParents,
  13 + SAAS_MENU_TREE,
  14 +} from './saas-menu-tree';
  15 +
  16 +const props = withDefaults(
  17 + defineProps<{
  18 + /** 为空表示平台配置,展示全部菜单 */
  19 + allowedKeys?: string[] | null;
  20 + disabled?: boolean;
  21 + height?: number;
  22 + }>(),
  23 + {
  24 + allowedKeys: null,
  25 + disabled: false,
  26 + height: 320,
  27 + },
  28 +);
  29 +
  30 +const modelValue = defineModel<string[]>({ default: () => [] });
  31 +
  32 +const checkedKeys = computed({
  33 + get: () => modelValue.value ?? [],
  34 + set: (keys: string[]) => {
  35 + modelValue.value = normalizeMenuKeysWithParents(keys);
  36 + },
  37 +});
  38 +
  39 +const treeData = computed(() => {
  40 + const nodes = props.allowedKeys?.length
  41 + ? filterMenuTreeByAllowed(props.allowedKeys, SAAS_MENU_TREE)
  42 + : SAAS_MENU_TREE;
  43 + return mapNodes(nodes);
  44 +});
  45 +
  46 +function mapNodes(nodes: SaasMenuTreeNode[]) {
  47 + return nodes.map((node) => ({
  48 + key: node.key,
  49 + title: $t(node.titleKey),
  50 + children: node.children?.length ? mapNodes(node.children) : undefined,
  51 + }));
  52 +}
  53 +
  54 +watch(
  55 + () => props.allowedKeys,
  56 + (allowed) => {
  57 + if (!allowed?.length || !modelValue.value?.length) {
  58 + return;
  59 + }
  60 + const set = new Set(allowed);
  61 + const filtered = modelValue.value.filter((k) => set.has(k));
  62 + if (filtered.length !== modelValue.value.length) {
  63 + modelValue.value = filtered;
  64 + }
  65 + },
  66 +);
  67 +</script>
  68 +
  69 +<template>
  70 + <div
  71 + class="rounded-md border border-gray-200 bg-gray-50/80 p-3"
  72 + :style="{ maxHeight: `${height}px`, overflow: 'auto' }"
  73 + >
  74 + <Tree
  75 + v-model:checked-keys="checkedKeys"
  76 + checkable
  77 + :disabled="disabled"
  78 + :selectable="false"
  79 + :tree-data="treeData"
  80 + default-expand-all
  81 + />
  82 + </div>
  83 +</template>
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/mock-management-data.ts
@@ -40,6 +40,19 @@ export const MOCK_ROLES: RoleDto[] = [ @@ -40,6 +40,19 @@ export const MOCK_ROLES: RoleDto[] = [
40 'view_reports', 40 'view_reports',
41 'approve_batches', 41 'approve_batches',
42 ], 42 ],
  43 + menuPermissionKeys: [
  44 + 'dashboard',
  45 + 'dashboard:analytics',
  46 + 'labeling',
  47 + 'labeling:labels',
  48 + 'labeling:categories',
  49 + 'labeling:types',
  50 + 'labeling:templates',
  51 + 'management',
  52 + 'management:account',
  53 + 'management:menu',
  54 + 'management:reports',
  55 + ],
43 creationTime: '2026-01-10 09:00', 56 creationTime: '2026-01-10 09:00',
44 }, 57 },
45 { 58 {
@@ -50,6 +63,13 @@ export const MOCK_ROLES: RoleDto[] = [ @@ -50,6 +63,13 @@ export const MOCK_ROLES: RoleDto[] = [
50 state: true, 63 state: true,
51 orderNum: 2, 64 orderNum: 2,
52 accessPermissionCodes: ['manage_labels', 'manage_products', 'view_reports'], 65 accessPermissionCodes: ['manage_labels', 'manage_products', 'view_reports'],
  66 + menuPermissionKeys: [
  67 + 'dashboard',
  68 + 'dashboard:analytics',
  69 + 'labeling:labels',
  70 + 'management:menu',
  71 + 'management:reports',
  72 + ],
53 creationTime: '2026-01-12 14:30', 73 creationTime: '2026-01-12 14:30',
54 }, 74 },
55 { 75 {
@@ -60,6 +80,7 @@ export const MOCK_ROLES: RoleDto[] = [ @@ -60,6 +80,7 @@ export const MOCK_ROLES: RoleDto[] = [
60 state: false, 80 state: false,
61 orderNum: 3, 81 orderNum: 3,
62 accessPermissionCodes: ['manage_labels'], 82 accessPermissionCodes: ['manage_labels'],
  83 + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels'],
63 creationTime: '2026-02-01 11:00', 84 creationTime: '2026-02-01 11:00',
64 }, 85 },
65 ]; 86 ];
@@ -371,6 +392,8 @@ export const MOCK_TEAM_MEMBERS: TeamMemberDto[] = [ @@ -371,6 +392,8 @@ export const MOCK_TEAM_MEMBERS: TeamMemberDto[] = [
371 locationIds: ['loc-1', 'loc-2'], 392 locationIds: ['loc-1', 'loc-2'],
372 locations: ['SA001 - Store A', 'SB001 - Store B'], 393 locations: ['SA001 - Store A', 'SB001 - Store B'],
373 state: true, 394 state: true,
  395 + useCustomMenuPermissions: true,
  396 + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels', 'management:reports'],
374 }, 397 },
375 { 398 {
376 id: 'tm-2', 399 id: 'tm-2',
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/mock-platform-data.ts 0 → 100644
  1 +import type { SaasTenantDto } from './saas-types';
  2 +
  3 +import { ALL_SAAS_MENU_KEYS } from './saas-menu-tree';
  4 +
  5 +export const MOCK_SAAS_TENANTS: SaasTenantDto[] = [
  6 + {
  7 + id: '11111111-1111-1111-1111-111111111111',
  8 + companyName: 'Default 演示公司',
  9 + companyCode: 'DEFAULT',
  10 + logoUrl: null,
  11 + contactName: '平台演示',
  12 + contactEmail: 'admin@default.example.com',
  13 + contactPhone: '400-000-0001',
  14 + address: 'Bangkok, TH',
  15 + remark: '迁移期默认租户',
  16 + state: true,
  17 + menuPermissionKeys: [...ALL_SAAS_MENU_KEYS],
  18 + adminUserId: 'admin-default',
  19 + adminUserName: 'company.admin',
  20 + adminFullName: '公司管理员',
  21 + adminEmail: 'admin@default.example.com',
  22 + creationTime: '2026-01-01 00:00',
  23 + },
  24 + {
  25 + id: '22222222-2222-2222-2222-222222222222',
  26 + companyName: 'Siam Fresh Foods',
  27 + companyCode: 'SIAM_FRESH',
  28 + logoUrl: null,
  29 + contactName: 'Somchai',
  30 + contactEmail: 'contact@siamfresh.example.com',
  31 + contactPhone: '+66-2-123-4567',
  32 + address: 'Bangkok',
  33 + remark: null,
  34 + state: true,
  35 + menuPermissionKeys: [
  36 + 'dashboard',
  37 + 'dashboard:analytics',
  38 + 'labeling',
  39 + 'labeling:labels',
  40 + 'labeling:templates',
  41 + 'management',
  42 + 'management:account',
  43 + 'management:reports',
  44 + 'management:devices',
  45 + ],
  46 + adminUserId: 'admin-siam',
  47 + adminUserName: 'siam.admin',
  48 + adminFullName: 'Siam Admin',
  49 + adminEmail: 'admin@siamfresh.example.com',
  50 + creationTime: '2026-03-15 10:00',
  51 + },
  52 + {
  53 + id: '33333333-3333-3333-3333-333333333333',
  54 + companyName: 'Northern Retail Group',
  55 + companyCode: 'NORTH_RETAIL',
  56 + logoUrl: null,
  57 + contactName: 'Jane',
  58 + contactEmail: 'ops@northretail.example.com',
  59 + contactPhone: '+66-53-999-8888',
  60 + address: 'Chiang Mai',
  61 + remark: '试用中',
  62 + state: false,
  63 + menuPermissionKeys: [
  64 + 'dashboard',
  65 + 'dashboard:analytics',
  66 + 'management',
  67 + 'management:account',
  68 + ],
  69 + adminUserId: 'admin-north',
  70 + adminUserName: 'north.admin',
  71 + adminFullName: 'North Admin',
  72 + adminEmail: 'ops@northretail.example.com',
  73 + creationTime: '2026-04-01 14:20',
  74 + },
  75 +];