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 2 "name": "native-fast-printer",
3 3 "id": "native-fast-printer",
4   - "version": "1.2.8",
  4 + "version": "1.0.3",
5 5 "description": "Android高速标签打印原生插件",
6 6 "_dp_type": "nativeplugin",
7 7 "_dp_nativeplugin": {
... ... @@ -23,8 +23,7 @@
23 23 "abis": [
24 24 "armeabi-v7a",
25 25 "arm64-v8a",
26   - "x86",
27   - "x86_64"
  26 + "x86"
28 27 ],
29 28 "minSdkVersion": "21",
30 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 6 $manifestPath = Join-Path $appRoot "src\manifest.json"
7 7  
8 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 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 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 50 selectStoreDesc: 'Select the store where you\'ll be working today',
51 51 selectStoreError: 'Please select a store',
52 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 61 store1: 'Downtown Kitchen', store2: 'Brooklyn Central', store3: 'Queens Food Hub', store4: 'Manhattan Express',
54 62 },
55 63 dashboard: {
... ...
泰额版/Food Labeling Management App UniApp/src/locales/zh.ts
... ... @@ -50,6 +50,14 @@ export default {
50 50 selectStoreDesc: '选择您今天工作的店铺',
51 51 selectStoreError: '请选择一个店铺',
52 52 storeSelected: '店铺选择成功',
  53 + scopeCompany: '公司',
  54 + scopeRegion: '区域',
  55 + scopeLoading: '加载中…',
  56 + scopeNoCompanies: '暂无可用公司',
  57 + scopeNoRegions: '该公司下暂无区域',
  58 + selectCompanyRegionFirst: '请先选择公司与区域',
  59 + scopeLoadFail: '选项加载失败',
  60 + scopeSelectFail: '确认门店失败',
53 61 store1: '市中心厨房', store2: '布鲁克林中心', store3: '皇后食品中心', store4: '曼哈顿快速店',
54 62 },
55 63 dashboard: {
... ...
泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
... ... @@ -225,8 +225,8 @@
225 225 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text>
226 226 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text>
227 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 230 <text class="tips-item">6. Restart the printer or app if not visible</text>
231 231 <text class="tips-item tips-item-last">
232 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 290 setBuiltinPrinter,
291 291 } from '../../utils/print/printerConnection'
292 292 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
  293 +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist'
293 294 import {
294 295 getNativeFastPrinterDebugInfo,
295 296 getNativeFastPrinterState,
... ... @@ -432,14 +433,12 @@ function handleResetUposOptions () {
432 433 }
433 434  
434 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 439 function upsertDevice (device: any) {
  440 + const rawName = (device?.name || device?.localName || '').trim()
  441 + if (!isAllowedBluetoothPrinterName(rawName)) return
443 442 const described = describeDiscoveredPrinter(device)
444 443 const existing = devices.value.find(item => item.deviceId === described.deviceId)
445 444 if (!existing) {
... ... @@ -573,7 +572,9 @@ function addPairedDevices () {
573 572 try {
574 573 const paired = classic.getPairedDevices()
575 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 578 for (const p of paired) {
578 579 upsertDevice({
579 580 deviceId: p.deviceId,
... ...
泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
... ... @@ -105,16 +105,20 @@
105 105 :key="productCategoryRowKey(pCat, pIdx)"
106 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 114 <view
111   - class="cat-icon cat-header-thumb"
  115 + class="cat-header-thumb"
112 116 :class="
113 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 123 <image
120 124 v-if="productCategoryVisual(pCat).mode === 'image'"
... ... @@ -144,25 +148,28 @@
144 148 <AppIcon name="food" size="sm" color="white" />
145 149 </view>
146 150 </view>
147   - <view class="cat-header-info">
  151 + <view class="cat-header-info" :style="catHeaderInfoStyle">
148 152 <text class="cat-header-name">{{ pCat.name }}</text>
149 153 <text class="cat-header-count">{{ displayProductCategoryItemCount(pCat) }} items</text>
150 154 </view>
151 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 167 </view>
162 168  
163 169 <view
164 170 v-if="expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0"
165 171 class="cat-foods"
  172 + :style="catFoodsStyle"
166 173 >
167 174 <view class="food-grid">
168 175 <view
... ... @@ -174,35 +181,35 @@
174 181 <view
175 182 class="food-img-wrap"
176 183 :class="
177   - productVisual(product).mode === 'image'
  184 + productVisual(product, pCat).mode === 'image'
178 185 ? 'food-img-wrap--photo'
179 186 : 'food-img-wrap--fallback'
180 187 "
181   - :style="productThumbWrapStyle(product)"
  188 + :style="productThumbWrapStyle(product, pCat)"
182 189 >
183 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 193 class="food-img"
187 194 mode="aspectFill"
188 195 />
189 196 <text
190   - v-else-if="productVisual(product).mode === 'colorText'"
  197 + v-else-if="productVisual(product, pCat).mode === 'colorText'"
191 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 202 </text>
196 203 <text
197   - v-else-if="productVisual(product).mode === 'text'"
  204 + v-else-if="productVisual(product, pCat).mode === 'text'"
198 205 class="food-thumb-text"
199 206 >
200   - {{ productVisual(product).text }}
  207 + {{ productVisual(product, pCat).text }}
201 208 </text>
202 209 <view
203   - v-else-if="productVisual(product).mode === 'color'"
  210 + v-else-if="productVisual(product, pCat).mode === 'color'"
204 211 class="food-thumb-color-fill"
205   - :style="{ backgroundColor: productVisual(product).bg }"
  212 + :style="{ backgroundColor: productVisual(product, pCat).bg }"
206 213 />
207 214 <view
208 215 v-else
... ... @@ -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 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 500 function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string {
... ... @@ -444,7 +517,10 @@ function colorClassForName(name: string): string {
444 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 524 const v = resolveCategoryButtonVisualFromDto({
449 525 buttonStyleJson: p.buttonStyleJson,
450 526 buttonAppearance: p.buttonAppearance,
... ... @@ -457,13 +533,20 @@ function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender {
457 533 name: p.productName,
458 534 })
459 535 if (v.mode !== 'none') return v
  536 + if (pCat) {
  537 + const fromCat = productCategoryVisual(pCat)
  538 + if (fromCat.mode !== 'none') return fromCat
  539 + }
460 540 const legacyImg = (p.productImageUrl ?? '').trim()
461 541 if (legacyImg) return { mode: 'image', imageUrl: legacyImg }
462 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 550 if (v.mode === 'colorText' || v.mode === 'color') {
468 551 return { backgroundColor: v.bg }
469 552 }
... ... @@ -482,6 +565,8 @@ function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string {
482 565  
483 566 /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */
484 567 function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string {
  568 + const fromCard = (p.templateLabelSizeText ?? '').trim()
  569 + if (fromCard) return fromCard
485 570 const types = p.labelTypes || []
486 571 if (types.length === 0) return '—'
487 572 const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean)
... ... @@ -782,8 +867,16 @@ const goBluetoothPage = () =&gt; {
782 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 877 .panel-inner {
786   - padding: 24rpx;
  878 + padding: 16rpx 20rpx;
  879 + box-sizing: border-box;
787 880 }
788 881  
789 882 .search-box {
... ... @@ -826,38 +919,37 @@ const goBluetoothPage = () =&gt; {
826 919 .category-list {
827 920 display: flex;
828 921 flex-direction: column;
829   - gap: 16rpx;
  922 + gap: 12rpx;
830 923 }
831 924  
832 925 .cat-section {
833 926 background: #fff;
834   - border-radius: 16rpx;
  927 + border-radius: 12rpx;
835 928 box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
836 929 overflow: hidden;
  930 + box-sizing: border-box;
837 931 }
838 932  
  933 +/* 产品分类标题行:主样式见 script 内联 catHeaderRowStyle(安卓 scoped 易失效) */
839 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 955 .cat-header-color-fill {
... ... @@ -893,39 +985,61 @@ const goBluetoothPage = () =&gt; {
893 985 .cat-header-info {
894 986 flex: 1;
895 987 min-width: 0;
  988 + margin: 0;
  989 + padding: 0;
896 990 }
897 991  
898 992 .cat-header-name {
899   - font-size: 28rpx;
  993 + font-size: 26rpx;
900 994 font-weight: 600;
901 995 color: #111827;
902 996 display: block;
  997 + line-height: 1.25;
903 998 }
904 999  
905 1000 .cat-header-count {
906   - font-size: 22rpx;
  1001 + font-size: 20rpx;
907 1002 color: #9ca3af;
908 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 1019 .cat-foods {
913   - padding: 0 16rpx 16rpx;
  1020 + box-sizing: border-box;
914 1021 border-top: 1rpx solid #f3f4f6;
915 1022 }
916 1023  
917 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 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 1040 background: #f9fafb;
927   - padding: 10rpx;
928   - border-radius: 14rpx;
  1041 + padding: 8rpx;
  1042 + border-radius: 12rpx;
929 1043 }
930 1044  
931 1045 .food-card:active {
... ... @@ -935,10 +1049,11 @@ const goBluetoothPage = () =&gt; {
935 1049 .food-img-wrap {
936 1050 width: 100%;
937 1051 position: relative;
938   - padding-top: 75%;
939   - border-radius: 10rpx;
  1052 + height: 0;
  1053 + padding-top: 58%;
  1054 + border-radius: 8rpx;
940 1055 background: #e5e7eb;
941   - margin-bottom: 10rpx;
  1056 + margin-bottom: 8rpx;
942 1057 overflow: hidden;
943 1058 }
944 1059  
... ... @@ -965,9 +1080,9 @@ const goBluetoothPage = () =&gt; {
965 1080 display: flex;
966 1081 align-items: center;
967 1082 justify-content: center;
968   - padding: 12rpx;
  1083 + padding: 8rpx;
969 1084 box-sizing: border-box;
970   - font-size: 32rpx;
  1085 + font-size: 26rpx;
971 1086 font-weight: 700;
972 1087 color: #111827;
973 1088 line-height: 1.15;
... ... @@ -976,7 +1091,7 @@ const goBluetoothPage = () =&gt; {
976 1091 }
977 1092  
978 1093 .food-thumb-text--on-color {
979   - font-size: 30rpx;
  1094 + font-size: 24rpx;
980 1095 color: #ffffff;
981 1096 }
982 1097  
... ... @@ -1048,7 +1163,7 @@ const goBluetoothPage = () =&gt; {
1048 1163 }
1049 1164  
1050 1165 .food-name {
1051   - font-size: 24rpx;
  1166 + font-size: 22rpx;
1052 1167 font-weight: 600;
1053 1168 color: #111827;
1054 1169 display: block;
... ... @@ -1059,7 +1174,7 @@ const goBluetoothPage = () =&gt; {
1059 1174 }
1060 1175  
1061 1176 .food-desc {
1062   - font-size: 20rpx;
  1177 + font-size: 18rpx;
1063 1178 color: #6b7280;
1064 1179 display: block;
1065 1180 overflow: hidden;
... ... @@ -1152,14 +1267,25 @@ const goBluetoothPage = () =&gt; {
1152 1267 }
1153 1268  
1154 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 1291 </style>
... ...
泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -306,7 +306,12 @@ import {
306 306 normalizeTemplateForNativeFastJob,
307 307 templateHasUnsupportedNativeFastElements,
308 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 315 import { isPrinterReadySync } from '../../utils/print/printerReadiness'
311 316 import {
312 317 ensureNativeClassicTransportIfPossible,
... ... @@ -462,7 +467,7 @@ function buildPrintPreflightDeviceInfoText (args: {
462 467 lines.push(`- nativeUnsupportedElements: ${args.nativeUnsupported ? 'YES' : 'NO'}`)
463 468 lines.push('')
464 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 471 return lines.join('\n')
467 472 }
468 473  
... ... @@ -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 1205 let deferredPreviewRedrawTimer: ReturnType<typeof setTimeout> | null = null
1193 1206  
1194 1207 /** 首屏内容与 loading 切换后布局才稳定,补绘一次与「改输入后变正常」同路径 */
... ... @@ -1496,10 +1509,17 @@ const handlePrint = async () =&gt; {
1496 1509 uni.showLoading({ title: 'Rendering…', mask: true })
1497 1510 /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */
1498 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 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 1523 ensurePrintJobActive(printJobId)
1504 1524  
1505 1525 const printInputJson = buildPrintInputJson(
... ... @@ -1509,7 +1529,8 @@ const handlePrint = async () =&gt; {
1509 1529 )
1510 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 1534 * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。
1514 1535 */
1515 1536 const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
... ... @@ -1544,13 +1565,13 @@ const handlePrint = async () =&gt; {
1544 1565 /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */
1545 1566 let tmplForNativePayload = tmplForNativeJob
1546 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 1575 resetHydrateImageDebugRecords()
1555 1576 tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativePayload)
1556 1577 }
... ... @@ -1651,10 +1672,7 @@ const handlePrint = async () =&gt; {
1651 1672 canvasRaster: {
1652 1673 canvasId: 'labelPreviewCanvas',
1653 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 1678 (percent) => {
... ... @@ -1702,10 +1720,7 @@ const handlePrint = async () =&gt; {
1702 1720 canvasRaster: {
1703 1721 canvasId: 'labelPreviewCanvas',
1704 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 1726 (percent) => {
... ... @@ -1798,7 +1813,14 @@ const handlePrint = async () =&gt; {
1798 1813 }
1799 1814 const maxDots =
1800 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 1825 canvasCssW.value = layout.outW
1804 1826 canvasCssH.value = layout.outH
... ... @@ -1862,9 +1884,11 @@ const handlePrint = async () =&gt; {
1862 1884 imageData,
1863 1885 {
1864 1886 printQty: printQty.value,
1865   - clearTopRasterRows: 1,
  1887 + clearTopRasterRows: 0,
1866 1888 targetWidthDots: layout.outW,
1867 1889 targetHeightDots: layout.outH,
  1890 + useContentHeight: true,
  1891 + cutBetweenCopies: true,
1868 1892 },
1869 1893 (percent) => {
1870 1894 if (!isPrintJobActive(printJobId)) return
... ... @@ -1880,6 +1904,7 @@ const handlePrint = async () =&gt; {
1880 1904 clearTopRasterRows: 1,
1881 1905 targetWidthDots: layout.outW,
1882 1906 targetHeightDots: layout.outH,
  1907 + labelRasterFixedHeight: true,
1883 1908 },
1884 1909 (percent) => {
1885 1910 if (!isPrintJobActive(printJobId)) return
... ... @@ -1958,9 +1983,11 @@ const handlePrint = async () =&gt; {
1958 1983 tmpPath,
1959 1984 {
1960 1985 printQty: printQty.value,
1961   - clearTopRasterRows: 1,
  1986 + clearTopRasterRows: 0,
1962 1987 targetWidthDots: layout.outW,
1963 1988 targetHeightDots: layout.outH,
  1989 + useContentHeight: true,
  1990 + cutBetweenCopies: true,
1964 1991 },
1965 1992 (percent) => {
1966 1993 if (!isPrintJobActive(printJobId)) return
... ... @@ -1975,6 +2002,7 @@ const handlePrint = async () =&gt; {
1975 2002 clearTopRasterRows: 1,
1976 2003 targetWidthDots: layout.outW,
1977 2004 targetHeightDots: layout.outH,
  2005 + labelRasterFixedHeight: true,
1978 2006 },
1979 2007 (percent) => {
1980 2008 if (!isPrintJobActive(printJobId)) return
... ...
泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue
1 1 <template>
  2 +
2 3 <view class="page">
  4 +
3 5 <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }">
  6 +
4 7 <view class="top-bar">
  8 +
5 9 <view class="top-left" @click="goBack">
  10 +
6 11 <AppIcon name="chevronLeft" size="sm" color="white" />
  12 +
7 13 </view>
  14 +
8 15 <view class="top-center">
  16 +
9 17 <text class="page-title">Label Report</text>
  18 +
10 19 <LocationPicker />
  20 +
11 21 </view>
  22 +
12 23 <view class="top-right" @click="isMenuOpen = true">
  24 +
13 25 <AppIcon name="menu" size="sm" color="white" />
  26 +
14 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 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 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 703 const goBack = () => {
  704 +
201 705 const pages = getCurrentPages()
  706 +
202 707 if (pages.length > 1) {
  708 +
203 709 uni.navigateBack()
  710 +
204 711 } else {
  712 +
205 713 uni.redirectTo({ url: '/pages/index/index' })
  714 +
206 715 }
  716 +
207 717 }
  718 +
208 719 </script>
209 720  
  721 +
  722 +
210 723 <style scoped>
  724 +
211 725 .page {
  726 +
212 727 min-height: 100vh;
  728 +
213 729 background: #f3f4f6;
  730 +
214 731 display: flex;
  732 +
215 733 flex-direction: column;
  734 +
216 735 }
217 736  
  737 +
  738 +
218 739 .header-hero {
  740 +
219 741 background: linear-gradient(135deg, #1F3A8A, #142a6c);
  742 +
220 743 padding: 16rpx 32rpx 24rpx;
  744 +
221 745 }
222 746  
  747 +
  748 +
223 749 .top-bar {
  750 +
224 751 height: 96rpx;
  752 +
225 753 display: flex;
  754 +
226 755 align-items: center;
  756 +
227 757 justify-content: space-between;
  758 +
228 759 }
229 760  
  761 +
  762 +
230 763 .top-left, .top-right {
  764 +
231 765 width: 64rpx;
  766 +
232 767 height: 64rpx;
  768 +
233 769 border-radius: 999rpx;
  770 +
234 771 background: rgba(255, 255, 255, 0.15);
  772 +
235 773 display: flex;
  774 +
236 775 align-items: center;
  776 +
237 777 justify-content: center;
  778 +
238 779 }
239 780  
  781 +
  782 +
240 783 .top-center {
  784 +
241 785 flex: 1;
  786 +
242 787 display: flex;
  788 +
243 789 flex-direction: column;
  790 +
244 791 align-items: center;
  792 +
245 793 }
246 794  
  795 +
  796 +
247 797 .page-title {
  798 +
248 799 font-size: 34rpx;
  800 +
249 801 font-weight: 600;
  802 +
250 803 color: #fff;
  804 +
251 805 }
252 806  
  807 +
  808 +
253 809 .period-bar {
  810 +
254 811 display: flex;
  812 +
255 813 gap: 12rpx;
  814 +
256 815 padding: 16rpx 28rpx;
  816 +
257 817 background: #fff;
  818 +
258 819 border-bottom: 1rpx solid #e5e7eb;
  820 +
259 821 }
260 822  
  823 +
  824 +
261 825 .period-btn {
  826 +
262 827 flex: 1;
  828 +
263 829 padding: 16rpx 24rpx;
  830 +
264 831 border-radius: 12rpx;
  832 +
265 833 background: #f3f4f6;
  834 +
266 835 text-align: center;
  836 +
267 837 transition: all 0.2s;
  838 +
268 839 }
269 840  
  841 +
  842 +
270 843 .period-btn.active {
  844 +
271 845 background: linear-gradient(135deg, #1F3A8A, #1447E6);
  846 +
272 847 }
273 848  
  849 +
  850 +
274 851 .period-btn.active .period-text {
  852 +
275 853 color: #fff;
  854 +
276 855 }
277 856  
  857 +
  858 +
278 859 .period-text {
  860 +
279 861 font-size: 26rpx;
  862 +
280 863 font-weight: 500;
  864 +
281 865 color: #6b7280;
  866 +
282 867 }
283 868  
  869 +
  870 +
284 871 .custom-date-bar {
  872 +
285 873 display: flex;
  874 +
286 875 gap: 24rpx;
  876 +
287 877 padding: 20rpx 28rpx;
  878 +
288 879 background: #fff;
  880 +
289 881 border-bottom: 1rpx solid #e5e7eb;
  882 +
290 883 }
291 884  
  885 +
  886 +
292 887 .date-field {
  888 +
293 889 flex: 1;
  890 +
294 891 display: flex;
  892 +
295 893 flex-direction: column;
  894 +
296 895 gap: 8rpx;
  896 +
297 897 }
298 898  
  899 +
  900 +
299 901 .date-label {
  902 +
300 903 font-size: 24rpx;
  904 +
301 905 color: #6b7280;
  906 +
302 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 945 .metrics-grid {
  946 +
321 947 display: grid;
  948 +
322 949 grid-template-columns: 1fr 1fr;
  950 +
323 951 gap: 20rpx;
  952 +
324 953 margin-bottom: 32rpx;
  954 +
325 955 }
326 956  
  957 +
  958 +
327 959 .metric-card {
  960 +
328 961 background: #fff;
  962 +
329 963 padding: 28rpx;
  964 +
330 965 border-radius: 20rpx;
  966 +
331 967 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  968 +
332 969 border: 1rpx solid #e5e7eb;
  970 +
333 971 }
334 972  
  973 +
  974 +
335 975 .metric-label {
  976 +
336 977 font-size: 24rpx;
  978 +
337 979 color: #6b7280;
  980 +
338 981 display: block;
  982 +
339 983 margin-bottom: 12rpx;
  984 +
340 985 line-height: 1.5;
  986 +
341 987 }
342 988  
  989 +
  990 +
343 991 .metric-value {
  992 +
344 993 font-size: 38rpx;
  994 +
345 995 font-weight: 700;
  996 +
346 997 color: #111827;
  998 +
347 999 display: block;
  1000 +
348 1001 margin-bottom: 8rpx;
  1002 +
349 1003 letter-spacing: -0.02em;
  1004 +
350 1005 }
351 1006  
  1007 +
  1008 +
352 1009 .metric-trend {
  1010 +
353 1011 font-size: 24rpx;
  1012 +
354 1013 color: #6b7280;
  1014 +
355 1015 }
356 1016  
  1017 +
  1018 +
357 1019 .metric-trend.up {
  1020 +
358 1021 color: #16a34a;
  1022 +
  1023 + font-weight: 500;
  1024 +
  1025 +}
  1026 +
  1027 +
  1028 +
  1029 +.metric-trend.down {
  1030 +
  1031 + color: #dc2626;
  1032 +
359 1033 font-weight: 500;
  1034 +
360 1035 }
361 1036  
  1037 +
  1038 +
362 1039 .section {
  1040 +
363 1041 margin-bottom: 32rpx;
  1042 +
364 1043 }
365 1044  
  1045 +
  1046 +
366 1047 .section-title {
  1048 +
367 1049 font-size: 32rpx;
  1050 +
368 1051 font-weight: 600;
  1052 +
369 1053 color: #111827;
  1054 +
370 1055 display: block;
  1056 +
371 1057 margin-bottom: 8rpx;
  1058 +
372 1059 }
373 1060  
  1061 +
  1062 +
374 1063 .section-desc {
  1064 +
375 1065 font-size: 26rpx;
  1066 +
376 1067 color: #6b7280;
  1068 +
377 1069 display: block;
  1070 +
378 1071 margin-bottom: 24rpx;
  1072 +
379 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 1105 .bar-chart {
  1106 +
383 1107 background: #fff;
  1108 +
384 1109 padding: 28rpx;
  1110 +
385 1111 border-radius: 20rpx;
  1112 +
386 1113 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1114 +
387 1115 border: 1rpx solid #e5e7eb;
  1116 +
388 1117 }
389 1118  
  1119 +
  1120 +
390 1121 .bar-row {
  1122 +
391 1123 display: flex;
  1124 +
392 1125 align-items: center;
  1126 +
393 1127 gap: 16rpx;
  1128 +
394 1129 margin-bottom: 24rpx;
  1130 +
395 1131 }
396 1132  
  1133 +
  1134 +
397 1135 .bar-row:last-child {
  1136 +
398 1137 margin-bottom: 0;
  1138 +
399 1139 }
400 1140  
  1141 +
  1142 +
401 1143 .bar-label {
  1144 +
402 1145 width: 120rpx;
  1146 +
403 1147 font-size: 26rpx;
  1148 +
404 1149 color: #374151;
  1150 +
405 1151 flex-shrink: 0;
  1152 +
406 1153 font-weight: 500;
  1154 +
407 1155 }
408 1156  
  1157 +
  1158 +
409 1159 .bar-track {
  1160 +
410 1161 flex: 1;
  1162 +
411 1163 height: 36rpx;
  1164 +
412 1165 background: #f3f4f6;
  1166 +
413 1167 border-radius: 10rpx;
  1168 +
414 1169 overflow: hidden;
  1170 +
415 1171 }
416 1172  
  1173 +
  1174 +
417 1175 .bar-fill {
  1176 +
418 1177 height: 100%;
  1178 +
419 1179 background: linear-gradient(90deg, #1F3A8A, #1447E6);
  1180 +
420 1181 border-radius: 10rpx;
  1182 +
421 1183 transition: width 0.3s ease;
  1184 +
422 1185 }
423 1186  
  1187 +
  1188 +
424 1189 .bar-value {
  1190 +
425 1191 width: 88rpx;
  1192 +
426 1193 text-align: right;
  1194 +
427 1195 font-size: 26rpx;
  1196 +
428 1197 font-weight: 600;
  1198 +
429 1199 color: #111827;
  1200 +
430 1201 }
431 1202  
  1203 +
  1204 +
432 1205 .line-chart {
  1206 +
433 1207 background: #fff;
  1208 +
434 1209 padding: 28rpx;
  1210 +
435 1211 border-radius: 20rpx;
  1212 +
436 1213 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1214 +
437 1215 border: 1rpx solid #e5e7eb;
  1216 +
438 1217 }
439 1218  
  1219 +
  1220 +
440 1221 .chart-bars {
  1222 +
441 1223 display: flex;
  1224 +
442 1225 align-items: flex-end;
  1226 +
443 1227 justify-content: space-between;
444   - gap: 12rpx;
  1228 +
  1229 + gap: 4rpx;
  1230 +
445 1231 height: 220rpx;
  1232 +
446 1233 margin-bottom: 20rpx;
  1234 +
447 1235 }
448 1236  
  1237 +
  1238 +
449 1239 .day-bar-wrap {
  1240 +
450 1241 flex: 1;
  1242 +
451 1243 display: flex;
  1244 +
452 1245 align-items: flex-end;
  1246 +
453 1247 justify-content: center;
  1248 +
  1249 + min-width: 0;
  1250 +
454 1251 }
455 1252  
  1253 +
  1254 +
456 1255 .day-bar {
  1256 +
457 1257 width: 100%;
  1258 +
458 1259 max-width: 52rpx;
  1260 +
459 1261 min-height: 12rpx;
  1262 +
460 1263 background: linear-gradient(180deg, #1F3A8A, #1447E6);
  1264 +
461 1265 border-radius: 10rpx 10rpx 0 0;
  1266 +
462 1267 transition: height 0.3s ease;
  1268 +
463 1269 }
464 1270  
  1271 +
  1272 +
465 1273 .chart-labels {
  1274 +
466 1275 display: flex;
  1276 +
467 1277 justify-content: space-between;
468   - gap: 12rpx;
  1278 +
  1279 + gap: 4rpx;
  1280 +
469 1281 }
470 1282  
  1283 +
  1284 +
471 1285 .day-label {
  1286 +
472 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 1295 color: #6b7280;
  1296 +
476 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 1309 .product-table {
  1310 +
480 1311 background: #fff;
  1312 +
481 1313 border-radius: 20rpx;
  1314 +
482 1315 overflow: hidden;
  1316 +
483 1317 box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  1318 +
484 1319 border: 1rpx solid #e5e7eb;
  1320 +
485 1321 }
486 1322  
  1323 +
  1324 +
487 1325 .product-row {
  1326 +
488 1327 display: flex;
  1328 +
489 1329 padding: 24rpx 28rpx;
  1330 +
490 1331 border-bottom: 1rpx solid #e5e7eb;
  1332 +
491 1333 font-size: 28rpx;
  1334 +
492 1335 }
493 1336  
  1337 +
  1338 +
494 1339 .product-row:last-child {
  1340 +
495 1341 border-bottom: none;
  1342 +
496 1343 }
497 1344  
  1345 +
  1346 +
498 1347 .product-row.header {
  1348 +
499 1349 background: #fafbfc;
  1350 +
500 1351 font-weight: 600;
  1352 +
501 1353 color: #6b7280;
  1354 +
502 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 1379 .col-name { flex: 1; min-width: 0; text-align: left; }
  1380 +
506 1381 .col-cat { flex: 0 0 140rpx; text-align: left; }
  1382 +
507 1383 .col-total { flex: 0 0 120rpx; text-align: left; }
  1384 +
508 1385 .col-pct { flex: 0 0 88rpx; text-align: left; }
509 1386  
  1387 +
  1388 +
510 1389 .product-row:not(.header) .col-name { color: #111827; font-weight: 500; }
  1390 +
511 1391 .product-row:not(.header) .col-cat { color: #6b7280; }
  1392 +
512 1393 .product-row:not(.header) .col-total { color: #111827; }
  1394 +
513 1395 .product-row:not(.header) .col-pct { color: #1F3A8A; font-weight: 600; }
  1396 +
514 1397 </style>
  1398 +
... ...
泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
... ... @@ -53,7 +53,7 @@
53 53 class="log-card"
54 54 >
55 55 <view class="card-header">
56   - <text class="product-name">{{ row.productName || '无' }}</text>
  56 + <text class="product-name">{{ displayField(row.productName) }}</text>
57 57 <text class="label-id">{{ shortRef(row) }}</text>
58 58 </view>
59 59 <view class="card-tags">
... ... @@ -67,11 +67,11 @@
67 67 </view>
68 68 <view class="detail-row">
69 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 71 </view>
72 72 <view class="detail-row">
73 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 75 </view>
76 76 </view>
77 77 <view class="card-footer">
... ... @@ -105,7 +105,7 @@
105 105 :key="row.taskId + '-' + row.copyIndex"
106 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 109 <text class="td td-id">{{ shortRef(row) }}</text>
110 110 <text class="td td-size">{{ tagLabelSize(row) }}</text>
111 111 <text class="td td-type">{{ tagTypeName(row) }}</text>
... ... @@ -157,6 +157,7 @@ import {
157 157 } from '../../utils/print/printerConnection'
158 158 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
159 159 import type { PrintLogItemDto } from '../../types/usAppLabeling'
  160 +import { formatDisplayText } from '../../utils/emptyDisplay'
160 161  
161 162 const statusBarHeight = getStatusBarHeight()
162 163 const isMenuOpen = ref(false)
... ... @@ -208,6 +209,10 @@ function createClientRequestId (): string {
208 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 216 function shortRef (row: PrintLogItemDto): string {
212 217 const b = String(row.batchId || row.taskId || '').trim()
213 218 if (b.length > 14) return `${b.slice(0, 8)}…`
... ... @@ -218,14 +223,14 @@ function shortRef (row: PrintLogItemDto): string {
218 223 function tagLabelSize (row: PrintLogItemDto): string {
219 224 const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText
220 225 const s = String(raw ?? '').trim()
221   - return s || '无'
  226 + return formatDisplayText(s, 'None')
222 227 }
223 228  
224 229 /** 红框右:typeName(兼容 PascalCase) */
225 230 function tagTypeName (row: PrintLogItemDto): string {
226 231 const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName
227 232 const s = String(raw ?? '').trim()
228   - return s || '无'
  233 + return formatDisplayText(s, 'None')
229 234 }
230 235  
231 236 async function loadPage (reset: boolean) {
... ...
泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue
... ... @@ -109,8 +109,8 @@
109 109 <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text>
110 110 <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text>
111 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 114 <text class="tips-item">6. Restart the printer or app if not visible</text>
115 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 116 </view>
... ... @@ -173,6 +173,7 @@ import SideMenu from &#39;../../components/SideMenu.vue&#39;
173 173 import { getDeviceIdentity } from '../../utils/deviceInfo'
174 174 import classicBluetooth from '../../utils/print/bluetoothTool.js'
175 175 import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions'
  176 +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist'
176 177 import { getAvailablePrinterTypes } from '../../utils/print/printerConnection'
177 178 import {
178 179 connectBluetoothPrinter,
... ... @@ -269,14 +270,12 @@ const normalizeDeviceName = (device: any) =&gt; {
269 270 }
270 271  
271 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 276 const addDeviceDedup = (device: any) => {
  277 + const displayName = normalizeDeviceName(device)
  278 + if (!isAllowedBluetoothPrinterName(displayName)) return
280 279 const described = describeDiscoveredPrinter(device)
281 280 const existing = devices.value.find(d => d.deviceId === device.deviceId)
282 281 if (!existing) {
... ... @@ -291,11 +290,15 @@ const loadPairedDevices = () =&gt; {
291 290 try {
292 291 const list = classicBluetooth.getPairedDevices()
293 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 302 debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices'
300 303 } catch (e) {
301 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 84 import LocationPicker from '../../components/LocationPicker.vue'
85 85 import { getStatusBarHeight } from '../../utils/statusBar'
86 86 import { usAppFetchMyProfile } from '../../services/usAppAuth'
  87 +import { formatDisplayText } from '../../utils/emptyDisplay'
87 88  
88 89 const { t } = useI18n()
89 90 const statusBarHeight = getStatusBarHeight()
... ... @@ -98,11 +99,11 @@ async function loadProfile() {
98 99 uni.showLoading({ title: 'Loading...', mask: true })
99 100 try {
100 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 107 if (p.fullName?.trim()) {
107 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 11 </view>
12 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 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 63 <text class="empty-text">{{ t('login.noStoresBound') }}</text>
17 64 </view>
18 65 <view
... ... @@ -51,22 +98,18 @@
51 98 </view>
52 99  
53 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 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 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 111 </view>
61 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 113 </view>
71 114 </view>
72 115 </template>
... ... @@ -77,9 +120,18 @@ import { useI18n } from &#39;vue-i18n&#39;
77 120 import { onShow } from '@dcloudio/uni-app'
78 121 import AppIcon from '../../components/AppIcon.vue'
79 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 132 import type { UsAppBoundLocationDto } from '../../types/usAppBound'
82 133 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  134 +import { isAppAdminUser } from '../../utils/appAdminRole'
83 135 import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession'
84 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 142 const selectedStore = ref('')
91 143 const stores = ref<UsAppBoundLocationDto[]>([])
92 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 155 function applyList(list: UsAppBoundLocationDto[]) {
95 156 const enabled = list.filter((s) => s.state !== false)
... ... @@ -97,7 +158,7 @@ function applyList(list: UsAppBoundLocationDto[]) {
97 158 setBoundLocations(enabled)
98 159 }
99 160  
100   -async function refreshFromApi() {
  161 +async function refreshEmployeeStores() {
101 162 loading.value = true
102 163 try {
103 164 const list = await usAppFetchMyLocations()
... ... @@ -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 269 onMounted(() => {
115 270 applyList(getBoundLocations())
116   - refreshFromApi()
  271 + initPage()
117 272 })
118 273  
119 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 288 const handleBackToLogin = () => {
... ... @@ -125,20 +290,43 @@ const handleBackToLogin = () =&gt; {
125 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 295 if (!selectedStore.value) {
131 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 300 return
134 301 }
135 302 const store = stores.value.find((s) => s.id === selectedStore.value)
136 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 331 </script>
144 332  
... ... @@ -187,11 +375,67 @@ const handleConfirm = () =&gt; {
187 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 434 .list {
191 435 flex: 1;
192 436 min-height: 0;
193 437 overflow-y: auto;
194   - padding: 32rpx;
  438 + padding: 24rpx 32rpx;
195 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 8 import type { UsAppBoundLocationDto } from '../types/usAppBound'
2   -import { usAppApiRequest } from '../utils/usAppApiRequest'
  9 +import { usAppApiRequest, unwrapApiPayload } from '../utils/usAppApiRequest'
3 10 import { fetchWithOfflineCache } from '../utils/sqliteSync'
4 11  
5 12 /** GET /api/app/us-app-auth/my-profile → UsAppMyProfileOutputDto */
... ... @@ -162,6 +169,115 @@ export async function usAppFetchLocationDetail(locationId: string): Promise&lt;UsAp
162 169 }
163 170  
164 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 281 export async function usAppChangePassword(input: UsAppChangePasswordInput): Promise<void> {
166 282 await usAppApiRequest<unknown>({
167 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 UsAppLabelPreviewInputVo,
7 7 UsAppLabelPrintInputVo,
8 8 UsAppLabelPrintOutputDto,
  9 + UsAppLabelReportOutputDto,
  10 + UsAppLabelReportQueryInputVo,
9 11 UsAppLabelReprintInputVo,
10 12 UsAppLabelTypeNodeDto,
11 13 UsAppProductCategoryNodeDto,
... ... @@ -40,9 +42,21 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD
40 42 }))
41 43 return {
42 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 50 productName: String(x?.productName ?? x?.ProductName ?? ''),
44 51 productCode: String(x?.productCode ?? x?.ProductCode ?? ''),
45 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 60 subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''),
47 61 labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length),
48 62 labelTypes,
... ... @@ -270,6 +284,192 @@ export async function reportUsAppLabelPrintIfReady(input: {
270 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 473 /** 接口 10:分页打印日志 */
274 474 export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) {
275 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 164 printerMac?: string
165 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 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 77 export function toEscBarcodeTypeCode (barcodeType: unknown): number {
60 78 const key = normalizeBarcodeType(barcodeType)
61 79 const map: Record<BarcodeFormatValue, number> = {
... ...
泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts
... ... @@ -65,6 +65,15 @@ export type CategoryVisualRender =
65 65 | { mode: 'text'; text: string }
66 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 77 export type StoredCategoryButtonStyleV1 = {
69 78 v: 1
70 79 appearances: AppearanceToken[]
... ... @@ -72,6 +81,7 @@ export type StoredCategoryButtonStyleV1 = {
72 81 buttonBgColor?: string | null
73 82 buttonTextColor?: string | null
74 83 buttonImageUrl?: string | null
  84 + appList?: AppListLayoutStyle | null
75 85 }
76 86  
77 87 export function serializeCategoryButtonStyleV1(input: {
... ... @@ -95,11 +105,15 @@ export function serializeCategoryButtonStyleV1(input: {
95 105 /** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */
96 106 export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null {
97 107 const raw = String(s ?? '').trim()
98   - if (!raw.startsWith('[')) return null
  108 + if (!raw) return null
99 109 try {
100 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 117 } catch {
104 118 return null
105 119 }
... ... @@ -170,6 +184,31 @@ export function serializeButtonAppearanceForApi(raw: unknown): string | null {
170 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 212 export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null {
174 213 const raw = (jsonStr ?? '').trim()
175 214 if (!raw) return null
... ... @@ -185,12 +224,36 @@ export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined):
185 224 buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null,
186 225 buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null,
187 226 buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null,
  227 + appList: parseAppListLayout(o.appList ?? o.AppList),
188 228 }
189 229 } catch {
190 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 257 export type CategoryDtoLike = {
195 258 buttonStyleJson?: string | null
196 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 235 let cursor = x + pad
236 236 for (let i = 0; i < modules.length; i++) {
237 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 239 ctx.fillRect(cursor, y + pad, rw, barH)
240 240 }
241 241 cursor += moduleW
... ... @@ -257,7 +257,7 @@ function drawBarcodeLikePreview(
257 257 let cursorY = y + pad
258 258 for (let i = 0; i < modules.length; i++) {
259 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 261 ctx.fillRect(x + pad, cursorY, barW, rh)
262 262 }
263 263 cursorY += moduleH
... ... @@ -433,7 +433,8 @@ function runLabelPreviewCanvasDraw(
433 433 const bw = Math.max(40, w || 120)
434 434 const bh = Math.max(36, h || 96)
435 435 const pad = 3
436   - const rightX = x + bw - pad
  436 + /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */
  437 + const rightX = x + bw - 1
437 438 const maxY = y + bh - 2
438 439 const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16))
439 440 const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72)))
... ... @@ -739,14 +740,32 @@ export function renderLabelPreviewCanvasImageDataForPrint(
739 740 /**
740 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 756 export function getLabelPrintRasterLayout(
743 757 template: SystemLabelTemplate,
744 758 maxWidthDots: number,
745   - printDpi = 203
  759 + printDpi = 203,
  760 + opts?: { contentHeightPx?: number },
746 761 ): { cw: number, ch: number, outW: number, outH: number, scale: number } {
747 762 const unit = template.unit || 'inch'
748 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 769 const designDpi = 96
751 770 const idealW = Math.round(cw * (printDpi / designDpi))
752 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 3  
4 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 33 function yieldToUi (): Promise<void> {
7 34 return new Promise((resolve) => {
8 35 setTimeout(resolve, 0)
... ...
泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
... ... @@ -13,7 +13,7 @@ import {
13 13 } from '../printerConnection'
14 14 // @ts-ignore - js bridge module (app-plus only)
15 15 import classicBluetooth from '../bluetoothTool.js'
16   -import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster'
  16 +import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace } from '../imageRaster'
17 17 import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder'
18 18 import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol'
19 19 import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload'
... ... @@ -28,7 +28,13 @@ import {
28 28 getLabelPrintRasterLayout,
29 29 renderLabelPreviewCanvasImageDataForPrint,
30 30 renderLabelPreviewCanvasToTempPathForPrint,
  31 + settleAfterLabelCanvasResize,
31 32 } from '../../labelPreview/renderLabelPreviewCanvas'
  33 +import {
  34 + ensureTemplateHeightCoversElements,
  35 + templateContentHeightPx,
  36 + templateSizeToMillimeters,
  37 +} from '../templatePhysicalMm'
32 38 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl'
33 39 import { printRunDiag } from '../printRunDiagnostics'
34 40 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter'
... ... @@ -853,9 +859,10 @@ export async function printImageForCurrentPrinter (
853 859 }
854 860 }
855 861  
  862 + const rasterForPrint = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options)
856 863 let data: number[] = []
857 864 if (rasterDriver.protocol === 'esc') {
858   - data = buildEscPosImageData(raster, options)
  865 + data = buildEscPosImageData(rasterForPrint, options)
859 866 } else {
860 867 data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203)
861 868 }
... ... @@ -964,7 +971,8 @@ export async function printImageDataForCurrentPrinter (
964 971 ): Promise<PrinterDriver> {
965 972 const driver = getCurrentPrinterDriver()
966 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 976 if (onProgress) onProgress(5)
969 977 const data = rasterDriver.protocol === 'esc'
970 978 ? buildEscPosImageData(raster, options)
... ... @@ -1000,6 +1008,20 @@ export type SystemTemplatePrintCanvasRasterOptions = {
1000 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 1025 export async function printSystemTemplateForCurrentPrinter (
1004 1026 template: SystemLabelTemplate,
1005 1027 data: LabelTemplateData = {},
... ... @@ -1019,21 +1041,49 @@ export async function printSystemTemplateForCurrentPrinter (
1019 1041  
1020 1042 if (canvasRaster && !bypassCanvasRasterForQr) {
1021 1043 if (onProgress) onProgress(1)
  1044 + const templateForDraw = templateForRasterPrint(template)
1022 1045 const maxDots =
1023 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 1063 if (onProgress) onProgress(4)
1026 1064 if (canvasRaster.applyLayout) {
1027   - await canvasRaster.applyLayout(layout)
  1065 + await Promise.resolve(canvasRaster.applyLayout(layout))
1028 1066 }
1029 1067 if (onProgress) onProgress(7)
1030   - await new Promise<void>((r) => setTimeout(r, 50))
  1068 + await settleAfterLabelCanvasResize()
1031 1069 if (onProgress) onProgress(9)
1032 1070 const printOpts: PrintImageOptions = {
1033 1071 printQty: options.printQty || 1,
1034   - clearTopRasterRows: 1,
  1072 + clearTopRasterRows: 0,
1035 1073 targetWidthDots: layout.outW,
1036 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 1088 const mapRasterProgress = onProgress
1039 1089 ? (p: number) => {
... ... @@ -1046,7 +1096,7 @@ export async function printSystemTemplateForCurrentPrinter (
1046 1096 const imageData = await renderLabelPreviewCanvasImageDataForPrint(
1047 1097 canvasRaster.canvasId,
1048 1098 canvasRaster.componentInstance,
1049   - template,
  1099 + templateForDraw,
1050 1100 layout,
1051 1101 )
1052 1102 if (onProgress) onProgress(12)
... ... @@ -1059,7 +1109,7 @@ export async function printSystemTemplateForCurrentPrinter (
1059 1109 const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint(
1060 1110 canvasRaster.canvasId,
1061 1111 canvasRaster.componentInstance,
1062   - template,
  1112 + templateForDraw,
1063 1113 layout,
1064 1114 )
1065 1115 if (onProgress) onProgress(12)
... ...
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
... ... @@ -7,6 +7,7 @@ import type {
7 7 SystemLabelTemplate,
8 8 SystemTemplateElementBase,
9 9 } from './types/printer'
  10 +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat'
10 11 import { applyTemplateData } from './templateRenderer'
11 12  
12 13 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
... ... @@ -19,6 +20,74 @@ function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): bo
19 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 92 * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致),
24 93 * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。
... ... @@ -60,6 +129,7 @@ export function normalizeTemplateForNativeFastJob (
60 129 }
61 130 }
62 131  
  132 + const extras: SystemTemplateElementBase[] = []
63 133 const elements = (template.elements || []).map((el) => {
64 134 const type = String(el.type || '').toUpperCase()
65 135 const config = (el.config || {}) as Record<string, any>
... ... @@ -119,9 +189,41 @@ export function normalizeTemplateForNativeFastJob (
119 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 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 40 export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string {
41 41 const full = getPrintRunDiagnosticsText()
42 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 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 140 const out: number[] = []
129 141 out.push(0x1b, 0x40)
130 142 out.push(0x1b, 0x74, 16)
131 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 148 return out
136 149 }
137 150  
... ... @@ -242,14 +255,22 @@ export function buildEscPosImageData (
242 255 options: PrintImageOptions = {}
243 256 ): number[] {
244 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 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 2 import { storedValueLooksLikeImagePath } from '../resolveMediaUrl'
3 3 import {
4 4 createImageBitmapPatch,
... ... @@ -486,9 +486,9 @@ function buildTscTemplate (
486 486 }
487 487  
488 488 if (type === 'BARCODE') {
489   - const value = resolveElementDataValue(element, data)
490   - if (!value) return
491 489 const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], ''))
  490 + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology)
  491 + if (!value) return
492 492 const rotation = resolveRotation(
493 493 element.rotation || getConfigString(config, ['orientation'], 'horizontal')
494 494 )
... ... @@ -612,7 +612,8 @@ function buildEscTemplate (
612 612 }
613 613  
614 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 617 if (!value) return
617 618 items.push({
618 619 type: 'barcode',
... ...
泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
... ... @@ -2,14 +2,105 @@
2 2 * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm),
3 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 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 89 /** 常见 4″ 标签机安全上限(mm),略放宽 */
10 90 const NATIVE_FAST_MAX_WIDTH_MM = 112
11 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 104 export function templateSizeToMillimeters (
14 105 unit: string | undefined,
15 106 width: number,
... ...
泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
... ... @@ -26,6 +26,12 @@ export interface PrintImageOptions {
26 26 maxWidthDots?: number
27 27 targetWidthDots?: number
28 28 targetHeightDots?: number
  29 + /** @deprecated 光栅已按内容高度裁切;保留兼容 */
  30 + labelRasterFixedHeight?: boolean
  31 + /** 多份打印时:每打完一张尝试半切(GS V 1);不支持则机芯忽略并继续下一张 */
  32 + cutBetweenCopies?: boolean
  33 + /** 光栅按元素底边高度布局,而非整模板 height */
  34 + useContentHeight?: boolean
29 35 widthMm?: number
30 36 heightMm?: number
31 37 x?: number
... ...
泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
... ... @@ -14,9 +14,7 @@ import {
14 14 setLastLabelPrintJobPayload,
15 15 } from './labelPreview/buildLabelPrintPayload'
16 16 import { getCurrentStoreId } from './stores'
17   -import {
18   - ensureNativeClassicTransportIfPossible,
19   -} from './print/printerConnection'
  17 +import { ensureNativeClassicTransportIfPossible } from './print/printerConnection'
20 18 import {
21 19 hydrateSystemTemplateImagesForPrint,
22 20 resetHydrateImageDebugRecords,
... ... @@ -25,7 +23,10 @@ import {
25 23 normalizeTemplateForNativeFastJob,
26 24 templateHasUnsupportedNativeFastElements,
27 25 } from './print/nativeTemplateElementSupport'
28   -import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm'
  26 +import {
  27 + ensureTemplateHeightCoversElements,
  28 + isTemplateWithinNativeFastPrintBounds,
  29 +} from './print/templatePhysicalMm'
29 30 import type {
30 31 LabelTemplateData,
31 32 SystemLabelTemplate,
... ... @@ -357,10 +358,11 @@ async function printReprintTemplateWithPreviewStrategy (
357 358 await ensureNativeClassicTransportIfPossible()
358 359 const templateData = labelTemplateDataForSnapshotReprint()
359 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 363 const useNative =
362 364 canPrintCurrentLabelViaNativeFastJob()
363   - && isTemplateWithinNativeFastPrintBounds(tmpl)
  365 + && isTemplateWithinNativeFastPrintBounds(tmplSized)
364 366 && !templateHasUnsupportedNativeFastElements(tmplForNative)
365 367  
366 368 const printQty = options.printQty ?? 1
... ... @@ -384,7 +386,7 @@ async function printReprintTemplateWithPreviewStrategy (
384 386 }
385 387  
386 388 await printSystemTemplateForCurrentPrinter(
387   - tmpl,
  389 + tmplSized,
388 390 templateData,
389 391 { printQty, canvasRaster: options.canvasRaster },
390 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 4 VITE_BASE=/
  5 +# vue-router 模式:hash 刷新不依赖服务器 try_files
  6 +VITE_ROUTER_HISTORY=hash
5 7 # 是否开启 Nitro Mock服务,true 为开启,false 为关闭
6 8 VITE_NITRO_MOCK=false
7 9 # 是否打开 devtools,true 为打开,false 为关闭
... ... @@ -9,9 +11,9 @@ VITE_DEVTOOLS=false
9 11 # 是否注入全局loading
10 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 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 VITE_PWA=false
8 8  
9 9 # vue-router 的模式
10   -VITE_ROUTER_HISTORY=history
  10 +VITE_ROUTER_HISTORY=hash
11 11  
12 12 # 是否注入全局loading
13 13 VITE_INJECT_APP_LOADING=true
... ... @@ -15,8 +15,8 @@ VITE_INJECT_APP_LOADING=true
15 15 # 打包后是否生成dist.zip
16 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 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 10 VITE_PWA=false
11 11  
12 12 # vue-router 的模式
13   -VITE_ROUTER_HISTORY=history
  13 +VITE_ROUTER_HISTORY=hash
14 14  
15 15 # 是否注入全局loading
16 16 VITE_INJECT_APP_LOADING=true
... ...
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json
... ... @@ -61,6 +61,7 @@
61 61 },
62 62 "devDependencies": {
63 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 19 orderNum?: number | null;
20 20 creationTime?: string | null;
21 21 accessPermissionCodes?: string[] | null;
  22 + menuPermissionKeys?: string[] | null;
22 23 }
23 24  
24 25 export interface RoleGetListQuery extends FlPageQuery {
... ... @@ -145,6 +146,8 @@ export interface TeamMemberDto {
145 146 locationIds?: string[] | null;
146 147 locations?: string[] | null;
147 148 state?: boolean | null;
  149 + useCustomMenuPermissions?: boolean | null;
  150 + menuPermissionKeys?: string[] | null;
148 151 }
149 152  
150 153 export interface TeamMemberGetListQuery extends FlPageQuery {
... ... @@ -164,6 +167,8 @@ export interface TeamMemberCreateInput {
164 167 regionIds?: string[];
165 168 locationIds: string[];
166 169 state: boolean;
  170 + useCustomMenuPermissions?: boolean;
  171 + menuPermissionKeys?: string[];
167 172 }
168 173  
169 174 export interface TeamMemberUpdateInput {
... ... @@ -177,4 +182,6 @@ export interface TeamMemberUpdateInput {
177 182 regionIds?: string[];
178 183 locationIds: string[];
179 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 19 locationSupportGet,
20 20 locationSupportUpdate,
21 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 27 export * from './partner';
24 28 export * from './product';
25 29 export * from './product-category';
... ...
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts
1 1 import type {
2   - FlPagedResult,
3 2 ProductCategoryCreateInput,
4 3 ProductCategoryDto,
5 4 ProductCategoryGetListQuery,
6 5 ProductCategoryUpdateInput,
7 6 } from './product-types';
  7 +import type { FlPagedResult } from './types';
8 8  
9 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 1 import type {
2   - FlPagedResult,
3 2 ProductBatchImportResultDto,
4 3 ProductCreateInput,
5 4 ProductDto,
... ... @@ -7,6 +6,7 @@ import type {
7 6 ProductGetListQuery,
8 7 ProductUpdateInput,
9 8 } from './product-types';
  9 +import type { FlPagedResult } from './types';
10 10  
11 11 import { ContentTypeEnum } from '#/api/helper';
12 12 import { requestClient } from '#/api/request';
... ...
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts
1 1 import type {
2   - FlPagedResult,
3 2 LabelReportDataDto,
4 3 LabelReportQuery,
5 4 ReportsPrintLogItemDto,
... ... @@ -7,6 +6,7 @@ import type {
7 6 ReportsTemplatePrintStatItemDto,
8 7 ReportsTemplatePrintStatQuery,
9 8 } from './reports-types';
  9 +import type { FlPagedResult } from './types';
10 10  
11 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 1 /** 绑定门店(与 ThAppLoginOutputDto.locations 一致) */
7 2 export interface ThAppBoundLocationDto {
8 3 id: string;
... ... @@ -28,13 +23,13 @@ export interface ThAppLoginOutputDto {
28 23 locations: ThAppBoundLocationDto[];
29 24 }
30 25  
31   -const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD);
32   -
33 26 /** 防止重复提交导致前一个请求被 Abort、后一个已在 Network 成功但 UI 仍报错 */
34 27 let loginInFlight: Promise<ThAppLoginOutputDto> | null = null;
35 28  
36 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 33 return raw.replace(/^"|"$/g, '').replace(/\/$/, '');
39 34 }
40 35  
... ... @@ -68,88 +63,56 @@ function normalizeLocationList(raw: unknown): ThAppBoundLocationDto[] {
68 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 76 export async function thAppLogin(
145 77 input: ThAppLoginInput,
146 78 ): Promise<ThAppLoginOutputDto> {
147 79 if (loginInFlight) {
148 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 113 loginInFlight = null;
152 114 });
  115 +
153 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 19 "specifiedCount": "{count} location(s)",
20 20 "codeOptional": "Leave empty to auto-generate",
21 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 26 "labeling": {
25 27 "root": "Labeling",
... ... @@ -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 303 "management": {
248 304 "section": "Management",
249 305 "locationManager": "Location Manager",
... ... @@ -293,7 +349,13 @@
293 349 "permEditSettings": "Edit Settings",
294 350 "permManageProducts": "Manage Products",
295 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 360 "menuManagement": {
299 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 19 "specifiedCount": "指定 {count} 个门店",
20 20 "codeOptional": "留空则由后端生成",
21 21 "staticDemoBanner": "当前为静态演示模式,未请求后端接口。联调时将 shared/static-mode.ts 中 FOOD_LABELING_STATIC_ONLY 改为 false。",
22   - "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)"
  22 + "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)",
  23 + "yes": "是",
  24 + "no": "否"
23 25 },
24 26 "labeling": {
25 27 "root": "标签管理",
... ... @@ -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 303 "management": {
248 304 "section": "管理",
249 305 "locationManager": "门店管理",
... ... @@ -293,7 +349,13 @@
293 349 "permEditSettings": "编辑设置",
294 350 "permManageProducts": "管理产品",
295 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 360 "menuManagement": {
299 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 215 },
216 216 {
217 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 240 icon: 'lucide:building-2',
219 241 order: 20,
220 242 title: 'foodLabeling.management.section',
... ... @@ -224,15 +246,6 @@ export const localMenuList: RouteRecordStringComponent[] = [
224 246 redirect: '/account-management',
225 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 249 name: 'FoodLabelingAccountManagement',
237 250 path: '/account-management',
238 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 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 151 try {
142 152 const { getUserInfoApi } = await import('#/api');
143 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 26 MOCK_TEMPLATE_PRINT_STATS,
27 27 } from '../shared/mock-management-data';
28 28 import { FOOD_LABELING_STATIC_ONLY } from '../shared/static-mode';
  29 +import { managementTabPageContentClass } from '../management/shared/management-grid';
29 30 import DashboardCategoryChart from './dashboard-category-chart.vue';
30 31 import DashboardKpiCard from './dashboard-kpi-card.vue';
31 32 import DashboardWeeklyChart from './dashboard-weekly-chart.vue';
... ... @@ -308,10 +309,10 @@ function goReports() {
308 309 </script>
309 310  
310 311 <template>
311   - <Page>
  312 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
312 313 <a-alert
313 314 v-if="FOOD_LABELING_STATIC_ONLY"
314   - class="mb-4"
  315 + class="mb-4 shrink-0"
315 316 :message="$t('foodLabeling.common.staticDemoBanner')"
316 317 show-icon
317 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -52,14 +53,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
52 53 minWidth: 140,
53 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 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 14  
15 15 import { useVbenVxeGrid } from '#/adapter/vxe-table';
16 16  
  17 +import { buildManagementTabFormOptions } from '../../management/shared/management-grid';
17 18 import {
18 19 labelingGridBase,
  20 + labelingPageContentClass,
  21 + labelingTableClass,
19 22 labelingVxeGridClass,
20 23 } from '../../shared/labeling-grid';
21 24 import { MOCK_LABEL_CATEGORIES } from '../../shared/mock-data';
... ... @@ -33,14 +36,7 @@ function displayText(v: string | null | undefined) {
33 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 41 const gridOptions: VxeGridProps = {
46 42 ...labelingGridBase,
... ... @@ -133,15 +129,15 @@ async function handleDelete(row: Recordable&lt;LabelCategoryDto&gt;) {
133 129 </script>
134 130  
135 131 <template>
136   - <Page>
  132 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
137 133 <Alert
138 134 v-if="FOOD_LABELING_STATIC_ONLY"
139   - class="mb-3"
  135 + class="mb-3 shrink-0"
140 136 :message="$t('foodLabeling.common.staticDemoBanner')"
141 137 show-icon
142 138 type="info"
143 139 />
144   - <BasicTable>
  140 + <BasicTable :class="labelingTableClass">
145 141 <template #toolbar-tools>
146 142 <Space>
147 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 7 import {
7 8 keywordFilterField,
8 9 stateFilterField,
... ... @@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
83 84 minWidth: 120,
84 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 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 20  
21 21 import {
22 22 labelingGridBase,
  23 + labelingPageContentClass,
  24 + labelingTableClass,
23 25 labelingVxeGridClass,
24 26 } from '../../shared/labeling-grid';
25 27 import { MOCK_LABEL_TEMPLATES } from '../../shared/mock-data';
... ... @@ -76,6 +78,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
76 78 locationOptions: lookups.locationOptions.value,
77 79 })(),
78 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 84 const gridOptions: VxeGridProps = {
... ... @@ -158,15 +161,15 @@ async function handleDelete(row: Recordable&lt;LabelTemplateDto&gt;) {
158 161 </script>
159 162  
160 163 <template>
161   - <Page>
  164 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
162 165 <a-alert
163 166 v-if="FOOD_LABELING_STATIC_ONLY"
164   - class="mb-3"
  167 + class="mb-3 shrink-0"
165 168 :message="$t('foodLabeling.common.staticDemoBanner')"
166 169 show-icon
167 170 type="info"
168 171 />
169   - <BasicTable>
  172 + <BasicTable :class="labelingTableClass">
170 173 <template #toolbar-tools>
171 174 <Space>
172 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
6 7 import {
7 8 keywordFilterField,
8 9 stateFilterField,
... ... @@ -83,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
83 84 minWidth: 140,
84 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 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 19  
20 20 import {
21 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 24 labelingVxeGridClass,
23 25 } from '../../shared/labeling-grid';
24 26 import { MOCK_LABEL_TYPES } from '../../shared/mock-data';
... ... @@ -54,6 +56,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
54 56 locationOptions: lookups.locationOptions.value,
55 57 })(),
56 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 62 const gridOptions: VxeGridProps = {
... ... @@ -132,15 +135,15 @@ async function handleDelete(row: Recordable&lt;LabelTypeDto&gt;) {
132 135 </script>
133 136  
134 137 <template>
135   - <Page>
  138 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
136 139 <a-alert
137 140 v-if="FOOD_LABELING_STATIC_ONLY"
138   - class="mb-3"
  141 + class="mb-3 shrink-0"
139 142 :message="$t('foodLabeling.common.staticDemoBanner')"
140 143 show-icon
141 144 type="info"
142 145 />
143   - <BasicTable>
  146 + <BasicTable :class="labelingTableClass">
144 147 <template #toolbar-tools>
145 148 <Space>
146 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
  7 +
6 8 import type { SelectOption } from './use-label-lookups';
7 9  
8 10 export function querySchema(options: {
... ... @@ -125,14 +127,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
125 127 minWidth: 150,
126 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 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 19  
20 20 import {
21 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 24 labelingVxeGridClass,
23 25 } from '../../shared/labeling-grid';
24 26 import { MOCK_LABELS } from '../../shared/mock-data';
... ... @@ -50,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
50 52 templateOptions: lookups.templateOptions.value,
51 53 })(),
52 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 58 const gridOptions: VxeGridProps = {
... ... @@ -156,15 +159,15 @@ async function handleDelete(row: Recordable&lt;LabelDto&gt;) {
156 159 </script>
157 160  
158 161 <template>
159   - <Page>
  162 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
160 163 <a-alert
161 164 v-if="FOOD_LABELING_STATIC_ONLY"
162   - class="mb-3"
  165 + class="mb-3 shrink-0"
163 166 :message="$t('foodLabeling.common.staticDemoBanner')"
164 167 show-icon
165 168 type="info"
166 169 />
167   - <BasicTable>
  170 + <BasicTable :class="labelingTableClass">
168 171 <template #toolbar-tools>
169 172 <Space>
170 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 7  
8 8 import { z } from '#/adapter/form';
9 9  
  10 +import { foodLabelingActionColumn } from '../../management/shared/management-grid';
10 11 import OptionValuesEditor from './option-values-editor.vue';
11 12  
12 13 import {
... ... @@ -77,14 +78,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
77 78 minWidth: 140,
78 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 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 19  
20 20 import {
21 21 labelingGridBase,
  22 + labelingPageContentClass,
  23 + labelingTableClass,
22 24 labelingVxeGridClass,
23 25 } from '../../shared/labeling-grid';
24 26 import { MOCK_MULTIPLE_OPTIONS } from '../../shared/mock-data';
... ... @@ -62,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
62 64 locationOptions: lookups.locationOptions.value,
63 65 })(),
64 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 70 const gridOptions: VxeGridProps = {
... ... @@ -144,15 +147,15 @@ async function handleDelete(row: Recordable&lt;LabelMultipleOptionDto&gt;) {
144 147 </script>
145 148  
146 149 <template>
147   - <Page>
  150 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
148 151 <a-alert
149 152 v-if="FOOD_LABELING_STATIC_ONLY"
150   - class="mb-3"
  153 + class="mb-3 shrink-0"
151 154 :message="$t('foodLabeling.common.staticDemoBanner')"
152 155 show-icon
153 156 type="info"
154 157 />
155   - <BasicTable>
  158 + <BasicTable :class="labelingTableClass">
156 159 <template #toolbar-tools>
157 160 <Space>
158 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -54,14 +55,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
54 55 minWidth: 140,
55 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 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 26 import { useAccountLookups } from '../../../shared/use-account-lookups';
27 27 import {
28 28 managementTabGridBase,
  29 + managementTabTableClass,
29 30 managementTabVxeGridClass,
30 31 } from '../../shared/management-grid';
31 32 import { columns, querySchema } from './data';
... ... @@ -51,6 +52,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
51 52 partnerOptions: accountLookups.partnerOptions.value,
52 53 })(),
53 54 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
  55 + collapseTriggerResize: false,
54 56 }));
55 57  
56 58 const gridOptions: VxeGridProps = {
... ... @@ -132,7 +134,8 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) {
132 134 </script>
133 135  
134 136 <template>
135   - <BasicTable>
  137 + <div class="flex h-full min-h-0 flex-col">
  138 + <BasicTable :class="managementTabTableClass">
136 139 <template #toolbar-tools>
137 140 <Space>
138 141 <a-button type="primary" @click="handleAdd">
... ... @@ -173,6 +176,7 @@ async function handleDelete(row: Recordable&lt;GroupListItemDto&gt;) {
173 176 </Popconfirm>
174 177 </Space>
175 178 </template>
176   - </BasicTable>
177   - <GroupModalHost @reload="tableApi.query()" />
  179 + </BasicTable>
  180 + <GroupModalHost @reload="tableApi.query()" />
  181 + </div>
178 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 2 import type { Component } from 'vue';
3 3  
4 4 import { computed, onMounted, ref, watch } from 'vue';
5   -import { useRoute } from 'vue-router';
  5 +import { useRoute, useRouter } from 'vue-router';
6 6  
7 7 import { Page } from '@vben/common-ui';
8 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 14 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
  15 +import { managementTabPageContentClass } from '../shared/management-grid';
11 16 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
12 17 import ManagementTabBody from '../shared/management-tab-body.vue';
13 18 import GroupsTab from './groups/index.vue';
... ... @@ -17,6 +22,8 @@ import RolesTab from &#39;./roles/index.vue&#39;;
17 22 import TeamMembersTab from './team-members/index.vue';
18 23  
19 24 const route = useRoute();
  25 +const router = useRouter();
  26 +const saasStore = useSaasContextStore();
20 27  
21 28 const ACCOUNT_TABS = [
22 29 'roles',
... ... @@ -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 64 const tabItems = computed(() => [
51 65 { key: 'roles', label: $t('foodLabeling.accountManagement.tabRoles') },
52 66 { key: 'partners', label: $t('foodLabeling.accountManagement.tabCompany') },
... ... @@ -62,21 +76,54 @@ const tabPanels: Record&lt;string, Component&gt; = {
62 76 locations: LocationsTab,
63 77 teamMembers: TeamMembersTab,
64 78 };
  79 +
  80 +function backToPlatform() {
  81 + saasStore.resetToPlatformAdmin();
  82 + router.push('/platform/tenants');
  83 +}
65 84 </script>
66 85  
67 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 116 <a-alert
70 117 v-if="FOOD_LABELING_STATIC_ONLY"
71   - class="mb-3"
  118 + class="mb-3 shrink-0"
72 119 :message="$t('foodLabeling.common.staticDemoBanner')"
73 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 127 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" />
81 128 </div>
82 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -82,14 +83,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
82 83 width: 90,
83 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 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 30 import type { SelectOption } from '../../../shared/use-account-lookups';
31 31 import {
32 32 managementTabGridBase,
  33 + managementTabTableClass,
33 34 managementTabVxeGridClass,
34 35 } from '../../shared/management-grid';
35 36 import { columns, querySchema } from './data';
... ... @@ -81,6 +82,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
81 82 groupNameOptions: groupNameOptions.value,
82 83 })(),
83 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 88 const gridOptions: VxeGridProps = {
... ... @@ -170,7 +172,8 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) {
170 172 </script>
171 173  
172 174 <template>
173   - <BasicTable>
  175 + <div class="flex h-full min-h-0 flex-col">
  176 + <BasicTable :class="managementTabTableClass">
174 177 <template #toolbar-tools>
175 178 <Space>
176 179 <a-button type="primary" @click="handleAdd">
... ... @@ -217,6 +220,7 @@ async function handleDelete(row: Recordable&lt;AccountLocationDto&gt;) {
217 220 </Popconfirm>
218 221 </Space>
219 222 </template>
220   - </BasicTable>
221   - <LocationModalHost @reload="tableApi.query()" />
  223 + </BasicTable>
  224 + <LocationModalHost @reload="tableApi.query()" />
  225 + </div>
222 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -33,6 +34,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
33 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 43 field: 'city',
37 44 title: $t('foodLabeling.accountManagement.city'),
38 45 minWidth: 100,
... ... @@ -45,19 +52,24 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
45 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 67 field: 'state',
49 68 title: $t('foodLabeling.common.status'),
50 69 width: 90,
51 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 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 22 slicePage,
23 23 } from '../../../shared/static-mode';
24 24 import {
  25 + buildManagementTabFormOptions,
25 26 managementTabGridBase,
  27 + managementTabTableClass,
26 28 managementTabVxeGridClass,
27 29 } from '../../shared/management-grid';
28 30 import { columns, querySchema } from './data';
... ... @@ -33,14 +35,7 @@ function displayText(v: string | null | undefined) {
33 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 40 const gridOptions: VxeGridProps = {
46 41 ...managementTabGridBase,
... ... @@ -59,7 +54,11 @@ const gridOptions: VxeGridProps = {
59 54 'partnerName',
60 55 'contactEmail',
61 56 'phoneNumber',
  57 + 'street',
62 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 119 </script>
121 120  
122 121 <template>
123   - <BasicTable>
  122 + <div class="flex h-full min-h-0 flex-col">
  123 + <BasicTable :class="managementTabTableClass">
124 124 <template #toolbar-tools>
125 125 <Space>
126 126 <a-button type="primary" @click="handleAdd">
... ... @@ -135,12 +135,21 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) {
135 135 <template #phoneNumber="{ row }">
136 136 {{ displayText(row.phoneNumber) }}
137 137 </template>
  138 + <template #street="{ row }">
  139 + <span class="truncate">{{ displayText(row.street) }}</span>
  140 + </template>
138 141 <template #city="{ row }">
139 142 {{ displayText(row.city) }}
140 143 </template>
141 144 <template #stateCode="{ row }">
142 145 {{ displayText(row.stateCode) }}
143 146 </template>
  147 + <template #country="{ row }">
  148 + {{ displayText(row.country) }}
  149 + </template>
  150 + <template #zipCode="{ row }">
  151 + {{ displayText(row.zipCode) }}
  152 + </template>
144 153 <template #state="{ row }">
145 154 <Tag :color="row.state !== false ? 'success' : 'default'">
146 155 {{
... ... @@ -167,6 +176,7 @@ async function handleDelete(row: Recordable&lt;PartnerDto&gt;) {
167 176 </Popconfirm>
168 177 </Space>
169 178 </template>
170   - </BasicTable>
171   - <PartnerModalHost @reload="tableApi.query()" />
  179 + </BasicTable>
  180 + <PartnerModalHost @reload="tableApi.query()" />
  181 + </div>
172 182 </template>
... ...
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/data.ts
1 1 import type { FormSchemaGetter } from '#/adapter/form';
2 2 import type { VxeGridProps } from '#/adapter/vxe-table';
3 3  
  4 +import { markRaw } from 'vue';
  5 +
4 6 import { $t } from '@vben/locales';
5 7  
  8 +import MenuPermissionTreeField from '../../../shared/menu-permission-tree-field.vue';
6 9 import {
7 10 keywordFilterSchema,
8 11 stateFilterSchema,
9 12 stateModalSchema,
10 13 } from '../../../shared/form-schema';
  14 +import { foodLabelingActionColumn } from '../../shared/management-grid';
11 15 import { accessPermissionCheckboxOptions } from '../../../shared/use-account-lookups';
12 16  
13 17 export const querySchema: FormSchemaGetter = () => [
... ... @@ -33,6 +37,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
33 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 46 field: 'accessPermissionCodes',
37 47 title: $t('foodLabeling.accountManagement.accessPermissions'),
38 48 minWidth: 200,
... ... @@ -50,17 +60,13 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
50 60 width: 90,
51 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 70 return () => [
65 71 {
66 72 component: 'Input',
... ... @@ -88,6 +94,18 @@ export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter {
88 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 109 component: 'CheckboxGroup',
92 110 fieldName: 'accessPermissionCodes',
93 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 23 } from '../../../shared/static-mode';
24 24 import { displayAccessPermissionLabel } from '../../../shared/use-account-lookups';
25 25 import {
  26 + buildManagementTabFormOptions,
26 27 managementTabGridBase,
  28 + managementTabTableClass,
27 29 managementTabVxeGridClass,
28 30 } from '../../shared/management-grid';
29 31 import { columns, querySchema } from './data';
... ... @@ -34,14 +36,7 @@ function displayText(v: string | null | undefined) {
34 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 41 const gridOptions: VxeGridProps = {
47 42 ...managementTabGridBase,
... ... @@ -128,7 +123,8 @@ function permissionsText(row: RoleDto) {
128 123 </script>
129 124  
130 125 <template>
131   - <BasicTable>
  126 + <div class="flex h-full min-h-0 flex-col">
  127 + <BasicTable :class="managementTabTableClass">
132 128 <template #toolbar-tools>
133 129 <Space>
134 130 <a-button type="primary" @click="handleAdd">
... ... @@ -140,6 +136,11 @@ function permissionsText(row: RoleDto) {
140 136 <template #remark="{ row }">
141 137 <span class="truncate">{{ displayText(row.remark) }}</span>
142 138 </template>
  139 + <template #menuPermissions="{ row }">
  140 + <Tag color="processing">
  141 + {{ (row.menuPermissionKeys ?? []).length }}
  142 + </Tag>
  143 + </template>
143 144 <template #accessPermissions="{ row }">
144 145 <span class="truncate">{{ permissionsText(row) }}</span>
145 146 </template>
... ... @@ -172,6 +173,7 @@ function permissionsText(row: RoleDto) {
172 173 </Popconfirm>
173 174 </Space>
174 175 </template>
175   - </BasicTable>
176   - <RoleModalHost @reload="tableApi.query()" />
  176 + </BasicTable>
  177 + <RoleModalHost @reload="tableApi.query()" />
  178 + </div>
177 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 10 import { message } from 'ant-design-vue';
11 11  
12 12 import { useVbenForm } from '#/adapter/form';
  13 +import { useSaasContextStore } from '#/store/saas-context';
13 14  
14 15 import { MOCK_ROLES } from '../../../shared/mock-management-data';
15 16 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode';
16 17 import { modalSchema } from './data';
17 18  
  19 +const saasStore = useSaasContextStore();
  20 +
18 21 const emit = defineEmits<{ reload: [] }>();
19 22  
20 23 const isUpdate = ref(false);
... ... @@ -30,13 +33,16 @@ const [BasicForm, formApi] = useVbenForm({
30 33 labelWidth: 110,
31 34 componentProps: { class: 'w-full' },
32 35 },
33   - schema: modalSchema({ isUpdate: false })(),
  36 + schema: modalSchema({
  37 + isUpdate: false,
  38 + allowedMenuKeys: saasStore.allowedMenuKeys,
  39 + })(),
34 40 showDefaultActions: false,
35 41 wrapperClass: 'grid-cols-2',
36 42 });
37 43  
38 44 const [BasicModal, modalApi] = useVbenModal({
39   - class: 'w-[720px]',
  45 + class: 'w-[800px]',
40 46 fullscreenButton: false,
41 47 onCancel: handleCancel,
42 48 onConfirm: handleConfirm,
... ... @@ -50,7 +56,10 @@ const [BasicModal, modalApi] = useVbenModal({
50 56 recordId.value = data?.id ?? '';
51 57  
52 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 65 if (isUpdate.value && recordId.value) {
... ... @@ -68,6 +77,7 @@ const [BasicModal, modalApi] = useVbenModal({
68 77 remark: record.remark ?? '',
69 78 orderNum: record.orderNum ?? undefined,
70 79 accessPermissionCodes: record.accessPermissionCodes ?? [],
  80 + menuPermissionKeys: record.menuPermissionKeys ?? [],
71 81 state: record.state !== false,
72 82 });
73 83 } else {
... ... @@ -75,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({
75 85 await formApi.setValues({
76 86 state: true,
77 87 accessPermissionCodes: [],
  88 + menuPermissionKeys: [],
78 89 });
79 90 }
80 91 modalApi.modalLoading(false);
... ... @@ -91,6 +102,7 @@ function formToPayload(values: Record&lt;string, unknown&gt;) {
91 102 ? null
92 103 : Number(values.orderNum),
93 104 accessPermissionCodes: (values.accessPermissionCodes as string[]) ?? [],
  105 + menuPermissionKeys: (values.menuPermissionKeys as string[]) ?? [],
94 106 state: values.state !== false,
95 107 };
96 108 }
... ... @@ -108,6 +120,18 @@ async function handleConfirm() {
108 120 return;
109 121 }
110 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 135 message.success($t('foodLabeling.common.staticDemoAction'));
112 136 emit('reload');
113 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 1 import type { FormSchemaGetter } from '#/adapter/form';
2 2 import type { VxeGridProps } from '#/adapter/vxe-table';
3 3  
  4 +import { markRaw } from 'vue';
  5 +
4 6 import { $t } from '@vben/locales';
5 7  
6 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 13 import {
9 14 keywordFilterSchema,
10 15 stateFilterSchema,
... ... @@ -62,6 +67,12 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
62 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 76 field: 'locations',
66 77 title: $t('foodLabeling.common.locations'),
67 78 minWidth: 180,
... ... @@ -73,14 +84,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
73 84 width: 90,
74 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 90 export function modalSchema(options: {
... ... @@ -89,6 +93,7 @@ export function modalSchema(options: {
89 93 partnerOptions: SelectOption[];
90 94 groupOptions: SelectOption[];
91 95 locationOptions: SelectOption[];
  96 + allowedMenuKeys?: string[] | null;
92 97 }): FormSchemaGetter {
93 98 return () => [
94 99 {
... ... @@ -172,6 +177,32 @@ export function modalSchema(options: {
172 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 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 26 import { useAccountLookups } from '../../../shared/use-account-lookups';
27 27 import {
28 28 managementTabGridBase,
  29 + managementTabTableClass,
29 30 managementTabVxeGridClass,
30 31 } from '../../shared/management-grid';
31 32 import { columns, querySchema } from './data';
... ... @@ -63,6 +64,7 @@ const formOptions = computed((): VbenFormProps =&gt; ({
63 64 roleOptions: accountLookups.roleOptions.value,
64 65 })(),
65 66 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
  67 + collapseTriggerResize: false,
66 68 }));
67 69  
68 70 const gridOptions: VxeGridProps = {
... ... @@ -148,7 +150,8 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
148 150 </script>
149 151  
150 152 <template>
151   - <BasicTable>
  153 + <div class="flex h-full min-h-0 flex-col">
  154 + <BasicTable :class="managementTabTableClass">
152 155 <template #toolbar-tools>
153 156 <Space>
154 157 <a-button type="primary" @click="handleAdd">
... ... @@ -166,6 +169,12 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
166 169 <template #roleName="{ row }">
167 170 {{ displayText(row.roleName) }}
168 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 178 <template #locations="{ row }">
170 179 <span class="truncate">{{ locationsText(row) }}</span>
171 180 </template>
... ... @@ -195,6 +204,7 @@ async function handleDelete(row: Recordable&lt;TeamMemberDto&gt;) {
195 204 </Popconfirm>
196 205 </Space>
197 206 </template>
198   - </BasicTable>
199   - <TeamMemberModalHost @reload="tableApi.query()" />
  207 + </BasicTable>
  208 + <TeamMemberModalHost @reload="tableApi.query()" />
  209 + </div>
200 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 14 import { message } from 'ant-design-vue';
15 15  
16 16 import { useVbenForm } from '#/adapter/form';
  17 +import { useSaasContextStore } from '#/store/saas-context';
17 18  
18 19 import { MOCK_TEAM_MEMBERS } from '../../../shared/mock-management-data';
19 20 import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode';
... ... @@ -23,6 +24,8 @@ import { modalSchema } from &#39;./data&#39;;
23 24  
24 25 const emit = defineEmits<{ reload: [] }>();
25 26  
  27 +const saasStore = useSaasContextStore();
  28 +
26 29 const isUpdate = ref(false);
27 30 const recordId = ref('');
28 31  
... ... @@ -61,7 +64,7 @@ const [BasicForm, formApi] = useVbenForm({
61 64 });
62 65  
63 66 const [BasicModal, modalApi] = useVbenModal({
64   - class: 'w-[720px]',
  67 + class: 'w-[800px]',
65 68 fullscreenButton: false,
66 69 onCancel: handleCancel,
67 70 onConfirm: handleConfirm,
... ... @@ -82,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({
82 85 partnerOptions: partnerOptions.value,
83 86 groupOptions: groupOptions.value,
84 87 locationOptions: locationOptions.value,
  88 + allowedMenuKeys: saasStore.allowedMenuKeys,
85 89 })(),
86 90 });
87 91  
... ... @@ -107,6 +111,8 @@ const [BasicModal, modalApi] = useVbenModal({
107 111 partnerId: record.partnerId ?? undefined,
108 112 regionIds: record.regionIds ?? [],
109 113 locationIds: record.locationIds ?? [],
  114 + useCustomMenuPermissions: record.useCustomMenuPermissions === true,
  115 + menuPermissionKeys: record.menuPermissionKeys ?? [],
110 116 state: record.state !== false,
111 117 });
112 118 } else {
... ... @@ -115,6 +121,8 @@ const [BasicModal, modalApi] = useVbenModal({
115 121 state: true,
116 122 regionIds: [],
117 123 locationIds: [],
  124 + useCustomMenuPermissions: false,
  125 + menuPermissionKeys: [],
118 126 });
119 127 }
120 128 modalApi.modalLoading(false);
... ... @@ -141,6 +149,11 @@ function formToPayload(values: Record&lt;string, unknown&gt;, updating: boolean) {
141 149 partnerId: String(values.partnerId ?? '').trim() || undefined,
142 150 regionIds: regionIds.length ? regionIds : undefined,
143 151 locationIds,
  152 + useCustomMenuPermissions: values.useCustomMenuPermissions === true,
  153 + menuPermissionKeys:
  154 + values.useCustomMenuPermissions === true
  155 + ? ((values.menuPermissionKeys as string[]) ?? [])
  156 + : undefined,
144 157 state: values.state !== false,
145 158 };
146 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -89,14 +90,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
89 90 minWidth: 140,
90 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 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 31 import { useScopeLookups } from '../../../shared/use-scope-lookups';
32 32 import {
33 33 managementTabGridBase,
  34 + managementTabTableClass,
34 35 managementTabVxeGridClass,
35 36 } from '../../shared/management-grid';
36 37 import { columns, querySchema } from './data';
... ... @@ -62,6 +63,7 @@ const formOptions = computed&lt;VbenFormProps&gt;(() =&gt; ({
62 63 locationOptions: locationOptions.value,
63 64 })(),
64 65 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
  66 + collapseTriggerResize: false,
65 67 }));
66 68  
67 69 const gridOptions: VxeGridProps = {
... ... @@ -177,7 +179,8 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) {
177 179 </script>
178 180  
179 181 <template>
180   - <BasicTable>
  182 + <div class="flex h-full min-h-0 flex-col">
  183 + <BasicTable :class="managementTabTableClass">
181 184 <template #toolbar-tools>
182 185 <Space>
183 186 <a-button type="primary" @click="handleAdd">
... ... @@ -234,6 +237,7 @@ async function handleDelete(row: Recordable&lt;ProductCategoryDto&gt;) {
234 237 </Popconfirm>
235 238 </Space>
236 239 </template>
237   - </BasicTable>
238   - <CategoryModalHost @reload="tableApi.query()" />
  240 + </BasicTable>
  241 + <CategoryModalHost @reload="tableApi.query()" />
  242 + </div>
239 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 7 import { $t } from '@vben/locales';
8 8  
9 9 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
  10 +import { managementTabPageContentClass } from '../shared/management-grid';
10 11 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
11 12 import ManagementTabBody from '../shared/management-tab-body.vue';
12 13 import CategoriesTab from './categories/index.vue';
... ... @@ -26,18 +27,18 @@ const tabPanels: Record&lt;string, Component&gt; = {
26 27 </script>
27 28  
28 29 <template>
29   - <Page>
  30 + <Page :auto-content-height="true" :content-class="managementTabPageContentClass">
30 31 <a-alert
31 32 v-if="FOOD_LABELING_STATIC_ONLY"
32   - class="mb-3"
  33 + class="mb-3 shrink-0"
33 34 :message="$t('foodLabeling.common.staticDemoBanner')"
34 35 show-icon
35 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 42 <ManagementTabBody :active-key="activeTab" :panels="tabPanels" />
42 43 </div>
43 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 3  
4 4 import { $t } from '@vben/locales';
5 5  
  6 +import { foodLabelingActionColumn } from '../../shared/management-grid';
6 7 import {
7 8 keywordFilterSchema,
8 9 stateFilterSchema,
... ... @@ -106,14 +107,7 @@ export const columns: VxeGridProps[&#39;columns&#39;] = [
106 107 minWidth: 140,
107 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 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 33 import { useScopeLookups } from '../../../shared/use-scope-lookups';
34 34 import {
35 35 managementTabGridBase,
  36 + managementTabTableClass,
36 37 managementTabVxeGridClass,
37 38 } from '../../shared/management-grid';
38 39 import { columns, querySchema } from './data';
... ... @@ -78,6 +79,7 @@ const formOptions = computed(() =&gt; ({
78 79 locationOptions: locationOptions.value,
79 80 })(),
80 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 85 const gridOptions: VxeGridProps = {
... ... @@ -231,7 +233,8 @@ function locationCellText(row: ProductDto) {
231 233 </script>
232 234  
233 235 <template>
234   - <BasicTable>
  236 + <div class="flex h-full min-h-0 flex-col">
  237 + <BasicTable :class="managementTabTableClass">
235 238 <template #toolbar-tools>
236 239 <Space>
237 240 <a-button @click="handleImport">
... ... @@ -295,7 +298,8 @@ function locationCellText(row: ProductDto) {
295 298 </Popconfirm>
296 299 </Space>
297 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 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 17 import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode';
18 18 import { useAccountLookups } from '../../shared/use-account-lookups';
19 19 import { useScopeLookups } from '../../shared/use-scope-lookups';
  20 +import { managementTabPageContentClass } from '../shared/management-grid';
20 21 import ManagementSubTabs from '../shared/management-sub-tabs.vue';
21 22 import { defaultReportDateRange, filterSchema } from './data';
22 23 import LabelReportTab from './label-report-tab.vue';
... ... @@ -118,10 +119,10 @@ async function handleExport() {
118 119 </script>
119 120  
120 121 <template>
121   - <Page v-if="pageAlive">
  122 + <Page v-if="pageAlive" :auto-content-height="true" :content-class="managementTabPageContentClass">
122 123 <a-alert
123 124 v-if="FOOD_LABELING_STATIC_ONLY"
124   - class="mb-3"
  125 + class="mb-3 shrink-0"
125 126 :message="$t('foodLabeling.common.staticDemoBanner')"
126 127 show-icon
127 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 5 import type { VxeGridProps } from '#/adapter/vxe-table';
2 6  
  7 +import { $t } from '@vben/locales';
  8 +
3 9 /**
4 10 * 管理模块「页内 Tab」表格基础配置。
5 11 * 必须关闭 autoResize:在 Tab/KeepAlive 嵌套下,vxe 默认 ResizeObserver 会与 height:auto
... ... @@ -7,14 +13,73 @@ import type { VxeGridProps } from &#39;#/adapter/vxe-table&#39;;
7 13 */
8 14 export const managementTabGridBase: Pick<
9 15 VxeGridProps['gridOptions'],
10   - 'autoResize' | 'height' | 'scrollY'
  16 + 'autoResize' | 'height' | 'minHeight' | 'scrollX' | 'scrollY' | 'syncResize' | 'toolbarConfig'
11 17 > = {
12 18 autoResize: false,
13   - height: 'auto',
  19 + /** 避免窗口横向缩放时 vxe 反复 sync 尺寸导致滚动区域持续增长 */
  20 + syncResize: false,
  21 + height: '100%',
  22 + minHeight: 0,
14 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 12 </script>
13 13  
14 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 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 89 </script>
90 90  
91 91 <template>
92   - <Page>
  92 + <Page :auto-content-height="true">
93 93 <a-alert
94 94 v-if="FOOD_LABELING_STATIC_ONLY"
95   - class="mb-3"
  95 + class="mb-3 shrink-0"
96 96 :message="$t('foodLabeling.common.staticDemoBanner')"
97 97 show-icon
98 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 38 </script>
39 39  
40 40 <template>
41   - <Page>
  41 + <Page :auto-content-height="true">
42 42 <a-alert
43   - class="mb-4"
  43 + class="mb-4 shrink-0"
44 44 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
45 45 show-icon
46 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 17  
18 18 import {
19 19 labelingGridBase,
  20 + labelingPageContentClass,
  21 + labelingTableClass,
20 22 labelingVxeGridClass,
21 23 } from '../../shared/labeling-grid';
22 24 import { MOCK_DEVICES } from '../../shared/mock-modules-data';
... ... @@ -71,6 +73,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({
71 73 },
72 74 schema: querySchema()(),
73 75 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
  76 + collapseTriggerResize: false,
74 77 },
75 78 gridOptions,
76 79 });
... ... @@ -97,14 +100,14 @@ function handleBulk(action: string) {
97 100 </script>
98 101  
99 102 <template>
100   - <Page>
  103 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
101 104 <a-alert
102   - class="mb-3"
  105 + class="mb-3 shrink-0"
103 106 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
104 107 show-icon
105 108 type="info"
106 109 />
107   - <BasicTable v-if="pageReady">
  110 + <BasicTable v-if="pageReady" :class="labelingTableClass">
108 111 <template #toolbar-tools>
109 112 <Space wrap>
110 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 15  
16 16 import {
17 17 labelingGridBase,
  18 + labelingPageContentClass,
  19 + labelingTableClass,
18 20 labelingVxeGridClass,
19 21 } from '../../shared/labeling-grid';
20 22 import { MOCK_INVOICES } from '../../shared/mock-modules-data';
... ... @@ -59,6 +61,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({
59 61 },
60 62 schema: querySchema()(),
61 63 wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
  64 + collapseTriggerResize: false,
62 65 },
63 66 gridOptions,
64 67 });
... ... @@ -102,14 +105,14 @@ function overdueClass(row: InvoiceRow) {
102 105 </script>
103 106  
104 107 <template>
105   - <Page>
  108 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
106 109 <a-alert
107   - class="mb-3"
  110 + class="mb-3 shrink-0"
108 111 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
109 112 show-icon
110 113 type="info"
111 114 />
112   - <BasicTable v-if="pageReady">
  115 + <BasicTable v-if="pageReady" :class="labelingTableClass">
113 116 <template #toolbar-tools>
114 117 <a-button @click="handleExport">
115 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 10 import { message } from 'ant-design-vue';
11 11 import { Card, Col, InputNumber, Row, Select, Space, Table, Tag } from 'ant-design-vue';
12 12  
  13 +import { labelingPageContentClass } from '../../shared/labeling-grid';
13 14 import ManagementSubTabs from '../../management/shared/management-sub-tabs.vue';
14 15 import { MOCK_QR_CODES } from '../../shared/mock-modules-data';
15 16  
... ... @@ -103,9 +104,9 @@ onMounted(() =&gt; {
103 104 </script>
104 105  
105 106 <template>
106   - <Page>
  107 + <Page :auto-content-height="true" :content-class="labelingPageContentClass">
107 108 <a-alert
108   - class="mb-3"
  109 + class="mb-3 shrink-0"
109 110 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
110 111 show-icon
111 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 61 </script>
62 62  
63 63 <template>
64   - <Page>
  64 + <Page :auto-content-height="true">
65 65 <a-alert
66   - class="mb-4"
  66 + class="mb-4 shrink-0"
67 67 :message="$t('foodLabeling.modules.frontendOnlyBanner')"
68 68 show-icon
69 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 1 export {
  2 + foodLabelingActionColumn,
2 3 managementTabGridBase as labelingGridBase,
  4 + managementTabPageContentClass as labelingPageContentClass,
  5 + managementTabTableClass as labelingTableClass,
3 6 managementTabVxeGridClass as labelingVxeGridClass,
4 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 40 'view_reports',
41 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 56 creationTime: '2026-01-10 09:00',
44 57 },
45 58 {
... ... @@ -50,6 +63,13 @@ export const MOCK_ROLES: RoleDto[] = [
50 63 state: true,
51 64 orderNum: 2,
52 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 73 creationTime: '2026-01-12 14:30',
54 74 },
55 75 {
... ... @@ -60,6 +80,7 @@ export const MOCK_ROLES: RoleDto[] = [
60 80 state: false,
61 81 orderNum: 3,
62 82 accessPermissionCodes: ['manage_labels'],
  83 + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels'],
63 84 creationTime: '2026-02-01 11:00',
64 85 },
65 86 ];
... ... @@ -371,6 +392,8 @@ export const MOCK_TEAM_MEMBERS: TeamMemberDto[] = [
371 392 locationIds: ['loc-1', 'loc-2'],
372 393 locations: ['SA001 - Store A', 'SB001 - Store B'],
373 394 state: true,
  395 + useCustomMenuPermissions: true,
  396 + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels', 'management:reports'],
374 397 },
375 398 {
376 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 +];
... ...