diff --git a/5-19泰额版.md b/5-19泰额版.md deleted file mode 100644 index 3740ee5..0000000 --- a/5-19泰额版.md +++ /dev/null @@ -1,381 +0,0 @@ -# 5-19 泰额版 — 多租户(独立库)与接口说明 - -本文档说明 **泰额版后端**(`泰额版/Food Labeling Management Code/Yi.Abp.Net8`)在 **2026-05-19** 起的多租户改造:**每个租户独立 MySQL 业务库**,平台主库仅存 `yitenant`;以及泰额专用登录、租户开通相关接口。 - -> 与美国版业务接口(`food-labeling-us`)共用同一宿主时,调用业务 API 须携带租户上下文(见 [租户上下文](#租户上下文))。 - ---- - -## 目录 - -- [架构概览](#架构概览) -- [数据库与 SQL 脚本](#数据库与-sql-脚本) -- [应用配置](#应用配置) -- [租户上下文](#租户上下文) -- [泰额专用接口](#泰额专用接口) - - [登录](#post-apiappth-app-authlogin) - - [我的门店](#get-apiappth-app-authmy-locations) - - [租户下拉 / 当前租户](#thmulti-tenancy) - - [开通租户独立库](#post-apiappth-tenant-provisioningprovision) - - [框架租户管理(补充)](#框架租户管理补充) -- [业务接口联调](#业务接口联调) -- [代码与脚本路径](#代码与脚本路径) -- [常见问题](#常见问题) - ---- - -## 架构概览 - -| 库 | 用途 | 连接来源 | -|----|------|----------| -| **平台主库** `antis-foodlabeling-host` | 仅 `yitenant`(租户元数据) | `appsettings` → `DbConnOptions.Url` | -| **租户业务库** 如 `antis-foodlabeling-us` | `fl_*`、`location`、`user` 等 | `yitenant.TenantConnectionString` | - -``` -antis-foodlabeling-host (主库) - └── yitenant - ├── Default → antis-foodlabeling-us(迁移期默认租户 / 现有数据) - └── 新租户 → antis-foodlabeling-{tenant}(Provision 自动建库) -``` - -- **不做** 业务表 `TenantId` 行级隔离(勿执行给 `fl_*` 加 `TenantId` 的 ALTER)。 -- 切换租户:请求头 `__tenant` 和/或 JWT 中的 `TenantId` Claim。 -- 无租户上下文时:连接**平台主库**(用于租户 CRUD、开通租户等)。 - -**默认租户(迁移期)** - -| 项 | 值 | -|----|-----| -| Id | `11111111-1111-1111-1111-111111111111` | -| Name | `Default` | -| 业务库 | `antis-foodlabeling-us`(连接串写在 `yitenant.TenantConnectionString`) | - ---- - -## 数据库与 SQL 脚本 - -脚本目录:`泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/scripts/` - -### 执行顺序 - -| 顺序 | 文件 | 说明 | -|------|------|------| -| 1 | `create_platform_host_database.sql` | 创建主库 `antis-foodlabeling-host` 及 `yitenant` 表 | -| 2 | `migrate_yitenant_to_host.sql` | **可选**:若曾在业务库 `antis-foodlabeling-us` 中写过 `yitenant`,迁移到主库 | -| 3 | `separate_database_bootstrap.sql` | 在主库登记默认租户,`TenantConnectionString` 指向 `antis-foodlabeling-us` | - -### 勿执行 - -以下 **不要** 在业务库执行(共享库 + 行级 `TenantId` 方案已废弃): - -```sql --- 勿执行 -ALTER TABLE fl_product ADD COLUMN TenantId ... -UPDATE fl_product SET TenantId = ... -``` - -### 自检 SQL - -```sql -USE antis-foodlabeling-host; - -SELECT Id, Name, LEFT(TenantConnectionString, 80) AS conn -FROM yitenant -WHERE Id = '11111111-1111-1111-1111-111111111111'; -``` - -应有一条 `Default`,且 `conn` 中含 `database=antis-foodlabeling-us`。 - ---- - -## 应用配置 - -文件:`泰额版/.../src/Yi.Abp.Web/appsettings.json` - -| 配置项 | 说明 | -|--------|------| -| `DbConnOptions.Url` | 平台主库,如 `database=antis-foodlabeling-host` | -| `DbConnOptions.EnabledSaasMultiTenancy` | `true` | -| `FoodLabeling:MultiTenancy:Mode` | `SeparateDatabase` | -| `FoodLabeling:TenantDatabase` | 新租户库名模板、RDS 账号等 | -| `FoodLabeling:LegacyTenant` | 默认租户 Id / Name | - -新租户库名模板示例:`antis-foodlabeling-{tenant}`(`{tenant}` 为规范化后的租户名)。 - ---- - -## 租户上下文 - -业务请求须让后端解析到 **租户 Id**,任选其一(推荐登录后仅用 Bearer Token): - -| 方式 | 说明 | -|------|------| -| 请求头 | `__tenant: {租户Guid}` | -| JWT | Claim:`TenantId`(`TokenTypeConst.TenantId`)及 `AbpClaimTypes.TenantId` | - -泰额登录签发的 Token 已写入上述 Claim;`JwtClaimTenantResolveContributor` 会自动解析。 - -租户解析顺序(`YiAbpWebModule`): - -1. `HeaderTenantResolveContributor`(`__tenant`) -2. `JwtClaimTenantResolveContributor`(JWT) - -> 独立库模式下**不会**自动回落到默认租户;未传租户时走主库连接,业务表可能查不到数据。 - ---- - -## 泰额专用接口 - -Swagger 分组:**泰额版-食品标签**(`FoodLabeling.Th.Application`) - -基础路径前缀:`/api/app/`(ABP 动态 API 约定,以 Swagger 为准) - -### `POST /api/app/th-app-auth/login` - -**应用服务**:`ThAppAuthAppService` -**鉴权**:匿名 - -**说明**: - -1. 在**平台主库**校验 `tenantId` 是否存在且已配置 `TenantConnectionString`。 -2. 切换到该租户业务库,按邮箱 + 密码校验 `user`(盐值哈希与美国版一致)。 -3. 签发 JWT(含 `TenantId`)、RefreshToken,并返回绑定门店列表。 - -#### 入参 `ThAppLoginInputVo` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| tenantId | Guid | 是 | 平台主库 `yitenant.Id` | -| email | string | 是 | 登录邮箱(`user.Email` / 邮箱形 `UserName`) | -| password | string | 是 | 密码 | -| uuid | string | 否 | 图形验证码 UUID(系统开启验证码时必填) | -| code | string | 否 | 图形验证码 | - -#### 请求示例 - -```http -POST /api/app/th-app-auth/login -Content-Type: application/json -``` - -```json -{ - "tenantId": "11111111-1111-1111-1111-111111111111", - "email": "admin@example.com", - "password": "YourPassword1!" -} -``` - -#### 出参 `ThAppLoginOutputDto` - -| 字段 | 类型 | 说明 | -|------|------|------| -| token | string | 访问令牌(含 TenantId Claim) | -| refreshToken | string | 刷新令牌 | -| tenantId | Guid | 当前租户 Id | -| tenantName | string | 租户名称 | -| locations | array | 绑定门店(结构同美国版 `UsAppBoundLocationDto`) | - -#### 出参示例 - -```json -{ - "token": "eyJhbGciOiJIUzI1NiIs...", - "refreshToken": "...", - "tenantId": "11111111-1111-1111-1111-111111111111", - "tenantName": "Default", - "locations": [ - { - "id": "...", - "locationCode": "LOC001", - "locationName": "Store A", - "fullAddress": "...", - "state": true - } - ] -} -``` - -#### JWT Claims(节选) - -| Claim | 说明 | -|-------|------| -| `TenantId` / `tenantid` | 租户 Guid 字符串 | -| `client_kind` | `th_app` | -| `sub` / `UserId` | 用户 Id | - -#### 错误说明 - -| 提示 | 原因 | -|------|------| -| 请输入租户、邮箱与密码 | 参数缺失 | -| 租户不存在或已停用 | 主库无该 `yitenant` 记录 | -| 租户未配置业务库连接串 | `TenantConnectionString` 为空 | -| 登录失败!邮箱不存在 | 租户库中无该用户 | -| 用户名或密码错误 | 密码校验失败 | - ---- - -### `GET /api/app/th-app-auth/my-locations` - -**应用服务**:`ThAppAuthAppService` -**鉴权**:Bearer Token - -**说明**:在**当前 JWT 租户上下文**下,查询 `userlocation` + `location` 绑定门店。 - -```http -GET /api/app/th-app-auth/my-locations -Authorization: Bearer {token} -``` - -可选同时带:`__tenant: 11111111-1111-1111-1111-111111111111`(与 Token 中租户一致即可)。 - ---- - -### ThMulti-tenancy - -**应用服务**:`ThMultiTenancyAppService` - -#### `GET /api/app/th-multi-tenancy/tenant-select`(方法名以 Swagger 为准,一般为 `get-tenant-select`) - -租户下拉列表(供登录页选择租户)。 - -**出参**:`ThTenantSelectDto[]` - -| 字段 | 类型 | 说明 | -|------|------|------| -| id | Guid | 租户 Id | -| name | string | 租户名称 | - -#### `GET /api/app/th-multi-tenancy/current-tenant`(`get-current-tenant`) - -当前请求解析到的租户(调试用)。 - -**出参**:`ThCurrentTenantDto` - -| 字段 | 类型 | 说明 | -|------|------|------| -| tenantId | Guid? | 当前租户 Id | -| tenantName | string? | 当前租户名称 | - ---- - -### `POST /api/app/th-tenant-provisioning/provision` - -**应用服务**:`ThTenantProvisioningAppService` -**鉴权**:需登录(平台管理员) - -**说明**: - -1. 在**平台主库**写入 `yitenant`(含 `TenantConnectionString`)。 -2. 若未传连接串,按 `FoodLabeling:TenantDatabase` 生成,如 `antis-foodlabeling-{tenant}`。 -3. `initializeDatabase=true` 时调用 `InitAsync`:建库 + CodeFirst 业务表(**不含** `yitenant`)。 - -#### 入参 `ThProvisionTenantInputVo` - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| name | string | 是 | 租户名称(用于生成库名) | -| tenantConnectionString | string | 否 | 自定义连接串;空则按模板生成 | -| dbType | int | 否 | SqlSugar.DbType,默认 `0` = MySql | -| initializeDatabase | bool | 否 | 默认 `true`,是否立即建库建表 | - -#### 请求示例 - -```http -POST /api/app/th-tenant-provisioning/provision -Content-Type: application/json -Authorization: Bearer {token} -``` - -```json -{ - "name": "acme", - "initializeDatabase": true -} -``` - -#### 出参 `ThProvisionTenantOutputDto` - -| 字段 | 类型 | 说明 | -|------|------|------| -| tenantId | Guid | 新租户 Id | -| name | string | 租户名称 | -| databaseName | string | 业务库名 | -| tenantConnectionString | string | 完整连接串 | -| databaseInitialized | bool | 是否已执行 Init | - -#### `POST /api/app/th-tenant-provisioning/initialize-tenant-database` - -对已有租户补执行建库建表(入参:租户 `tenantId`,以 Swagger 为准)。 - ---- - -### 框架租户管理(补充) - -分组:**租户管理接口**(`Yi.Framework.TenantManagement.Application`) - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/app/tenant` | 创建租户(需 `tenantConnectionString`) | -| PUT | `/api/app/tenant/init/{id}` | 租户业务库 CodeFirst 初始化 | - -泰额推荐使用 `th-tenant-provisioning/provision` 一步完成登记 + 建库。 - ---- - -## 业务接口联调 - -宿主同时加载 `FoodLabeling.Application`(美国版业务)与 `FoodLabeling.Th.Application`。 - -调用任意业务 API(如 `/api/app/product`、`/api/app/location`)时: - -```http -GET /api/app/product?SkipCount=1&MaxResultCount=10 -Authorization: Bearer {泰额登录返回的 token} -``` - -或: - -```http -GET /api/app/product?SkipCount=1&MaxResultCount=10 -Authorization: Bearer {token} -__tenant: 11111111-1111-1111-1111-111111111111 -``` - -**分页**:`SkipCount` 为 **1-based 页码**(与美国版一致,见 `5-18接口优化.md`)。 - ---- - -## 代码与脚本路径 - -| 类型 | 路径 | -|------|------| -| 泰额应用层 | `module/food-labeling/FoodLabeling.Th.Application/` | -| 泰额契约 | `module/food-labeling/FoodLabeling.Th.Application.Contracts/` | -| 美国版业务(共用) | `module/food-labeling-us/FoodLabeling.Application/` | -| SQL 脚本 | `module/food-labeling/scripts/` | -| 多租户常量 | `module/food-labeling-us/FoodLabeling.Domain.Shared/MultiTenancy/FoodLabelingMultiTenancyConsts.cs` | -| JWT 租户解析 | `module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs` | -| Web 配置 | `src/Yi.Abp.Web/appsettings.json`、`YiAbpWebModule.cs` | - ---- - -## 常见问题 - -| 现象 | 处理 | -|------|------| -| 启动报连库失败 | 确认已执行 `create_platform_host_database.sql`,且 `DbConnOptions.Url` 指向 `antis-foodlabeling-host` | -| 登录报租户不存在 | 在主库执行 `separate_database_bootstrap.sql` | -| 登录成功但业务列表为空 | 检查 Token 是否带 `TenantId`;或请求头补 `__tenant` | -| 业务接口查到主库无数据 | 未解析租户,误连主库;须登录泰额接口或传 `__tenant` | -| 新租户无表 | 调用 `provision` 且 `initializeDatabase=true`,或 `PUT tenant/init/{id}` | -| 误加了 TenantId 列 | 独立库模式不需要;可保留列但不使用,建议勿加 | - ---- - -## 变更记录 - -| 日期 | 内容 | -|------|------| -| 2026-05-19 | 泰额版多租户独立库方案;平台主库分离;`ThAppAuth` 登录写 JWT TenantId;租户开通与 SQL 脚本说明 | diff --git a/5-26代码优化.md b/5-26代码优化.md deleted file mode 100644 index ca99bdc..0000000 --- a/5-26代码优化.md +++ /dev/null @@ -1,692 +0,0 @@ -# 5-26 代码优化 - -本文档说明 **2026-05-26** 对美国版接口的变更。 - -1. **`/api/app/product-category`**:新增/编辑 **`categoryCode` 取消必填**(见 [product-category-categoryCode](#product-category-categorycode-可选))。 -2. **`/api/app/label-template`**:新增/编辑/列表/详情支持 **Region、Location 多选数组**;列表 Query 增加 **Region/Location 筛选**(见 [label-template-regionlocation](#label-template-regionlocation-多选))。 -3. **`/api/app/rbac-role`**:修复 **`accessPermissions` JSON 数组**(如 `manage_labels`)无法绑定菜单(见 [rbac-role-accesspermissions](#rbac-role-accesspermissions-修复))。 -4. **`/api/app/auth-scope`**:管理员(及按数据范围受限账号)登录后 **Company → Region → Location** 级联选店(见 [auth-scope-登录选店](#auth-scope-登录后-company--region--location-级联选店))。 -5. **`/api/app/us-app-auth`**:App 管理员 Token 专用 **Company / Region / 门店筛选** 接口(见 [us-app-auth-管理员选店](#us-app-auth-app-管理员级联选店))。 - -**应用服务**:`ProductCategoryAppService`、`LabelTemplateAppService`、`RbacRoleAppService` -**命名约定**(与 5-17 / 5-18 一致):UI **Region** = API **`regionIds` / `groupIds` / `groupId`**(`fl_group.Id`);UI **Location** = **`locationIds` / `locationId`**(`location.Id`)。 - ---- - -## product-category categoryCode 可选 - -**影响接口** - -| 方法 | 路径 | -|------|------| -| POST | `/api/app/product-category` | -| PUT | `/api/app/product-category/{id}` | - -### 变更说明 - -| 项 | 变更前 | 变更后 | -|----|--------|--------| -| **categoryCode** | 必填;空则报「类别编码和名称不能为空」 | **可选**;可不传、传 `null` 或 `""` | -| **categoryName** | 必填 | 仍必填 | -| **落库** | — | 未填编码时 `CategoryCode` 存 **空字符串** | -| **唯一性** | 编码或名称重复即报错 | 有编码:编码 **或** 名称重复报错;**无编码**:仅校验 **名称** 不重复 | - -### 入参(节选) - -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| categoryCode | string | **否** | 类别编码 | -| categoryName | string | **是** | 类别名称 | -| regionIds / groupIds / locationIds | string[] | 否 | Region·Location 范围(规则见 `5-17接口优化.md`) | - -### 请求示例(无编码) - -```http -POST /api/app/product-category -Content-Type: application/json -Authorization: Bearer {token} -``` - -```json -{ - "categoryName": "Beverages", - "buttonAppearance": "TEXT", - "state": true, - "availabilityType": "ALL", - "orderNum": 0 -} -``` - -### 联调注意 - -| 现象 | 处理 | -|------|------| -| 仍报「类别编码和名称不能为空」 | 确认已部署含本变更的后端;仅需保证 **categoryName** 非空 | -| 无编码时名称重复 | 正常:仅按 **categoryName** 判重 | - -> Region/Location 多选、列表 `region`/`location` 展示等完整说明见 `5-17接口优化.md` → product-category 章节。 - ---- - -## label-template Region·Location 多选 - -**应用服务**:`LabelTemplateAppService` -**存储表**:`fl_label_template_location`(模板 ↔ 门店,**无新表**) -**主表字段**:`fl_label_template.AppliedLocationType` = `ALL` / `SPECIFIED` - -### 变更说明 - -| 项 | 变更前 | 变更后 | -|----|--------|--------| -| **新增/编辑 Body** | 仅 `appliedLocation` + `appliedLocationIds` | 增加 **`regionIds`**、**`groupIds`**、**`locationIds`**(与 `appliedLocationIds` 合并) | -| **列表 Query** | 仅 `locationId` | 增加 **`groupId`**(Region);`locationId` 优先于 `groupId`(与 product-category 一致) | -| **列表出参** | 仅 `locationText`(单条展示) | 增加 **`region`**、**`location`** 展示 + **`regionIds`**、**`locationIds`** 数组 | -| **详情出参** | `appliedLocationIds` | 同上,并保留 **`appliedLocationIds`**(与 `locationIds` 一致,兼容编辑器) | -| **范围解析** | 仅显式门店 Id | Region 展开为门店后与门店 Id **取并集** 落库 | - -### 影响接口 - -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/app/label-template?SkipCount=1&MaxResultCount=10` | 列表支持 `groupId`/`locationId` 筛选;`items[]` 增加 `region`、`location`、`regionIds`、`locationIds` | -| GET | `/api/app/label-template/{id}` | 详情增加上述字段 | -| POST | `/api/app/label-template` | Body 支持 Region/Location 多选 | -| PUT | `/api/app/label-template/{id}` | 同新增 | - -路径参数 **`id`** 仍为模板编码 **`TemplateCode`**(与编辑器 JSON 的 `id` 一致)。 - -### 新增/编辑入参(Body:`LabelTemplateCreateInputVo`) - -| 字段 | JSON 名 | 类型 | 必填 | 说明 | -|------|---------|------|------|------| -| TemplateCode | `id` | string | 是 | 模板编码 | -| TemplateName | `name` | string | 是 | 模板名称 | -| AppliedLocationType | `appliedLocation` | string | 否 | `ALL` / `SPECIFIED`,默认 `ALL` | -| RegionIds | `regionIds` | string[] | 否 | Region 多选(`fl_group.Id`) | -| GroupIds | `groupIds` | string[] | 否 | 与 `regionIds` 等价,合并去重 | -| LocationIds | `locationIds` | string[] | 否 | 门店多选(`location.Id`) | -| AppliedLocationIds | `appliedLocationIds` | string[] | 否 | 兼容旧字段,与 `locationIds` 合并 | -| Elements | `elements` | array | 否 | 模板组件,全量重建 | -| TemplateProductDefaults | `templateProductDefaults` | array | 否 | 仅 **编辑** 时显式传入才重建 | - -**自动规则** - -| 入参 | 行为 | -|------|------| -| `regionIds` / `groupIds` / `locationIds` / `appliedLocationIds` 任一有有效 Id | `appliedLocation` 按 **`SPECIFIED`** 处理 | -| 仅传空数组 `[]` 且 `appliedLocation` 为 `ALL` | 不绑定门店(全部门店) | -| `appliedLocation: "SPECIFIED"` 且合并后无有效门店 | 报错:`指定适用区域或门店时,至少需要匹配到一个有效门店` | -| `appliedLocation` 非法值 | 报错:`适用门店范围不合法(ALL/SPECIFIED)` | - -**合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds`、`appliedLocationIds` **取并集** → 写入 `fl_label_template_location`。 - -### 请求示例(Region + 门店多选) - -```http -POST /api/app/label-template -Content-Type: application/json -Authorization: Bearer {token} -``` - -```json -{ - "id": "TPL_TEST_001", - "name": "Price Tag 4x6", - "labelType": "PRICE", - "unit": "inch", - "width": 4, - "height": 6, - "appliedLocation": "SPECIFIED", - "regionIds": [ - "fl_group_id_east", - "fl_group_id_west" - ], - "locationIds": [ - "11111111-1111-1111-1111-111111111111" - ], - "showRuler": true, - "showGrid": true, - "state": true, - "elements": [] -} -``` - -### 请求示例(全部门店,兼容旧版) - -```json -{ - "id": "TPL_ALL", - "name": "Global Template", - "labelType": "PRICE", - "unit": "inch", - "width": 4, - "height": 6, - "appliedLocation": "ALL", - "appliedLocationIds": [], - "elements": [] -} -``` - -### 列表(`GET /api/app/label-template`) - -**Query 参数** - -| 字段 | 类型 | 说明 | -|------|------|------| -| SkipCount / MaxResultCount | int | 分页(项目约定 SkipCount 从 1 起) | -| keyword | string | 模板名称/编码模糊 | -| **groupId** | string | **按 Region 筛选**(`fl_group.Id`):命中 `appliedLocation=ALL` 的模板,或在 `fl_label_template_location` 中绑定了该 Region 下任一门门店的模板 | -| **locationId** | string | **按门店筛选**(`location.Id`);**优先于 groupId** | -| labelType | string | 如 `PRICE` | -| state | bool | 启用状态 | -| sorting | string | 排序(可选) | - -**筛选规则**(与 product-category / label-type 相同,内部 `LocationScopeBindingHelper.ResolveScopedLocationIdsAsync`) - -| 入参 | 行为 | -|------|------| -| 均未传 `groupId`、`locationId` | 不过滤适用范围 | -| 仅 `groupId` | 解析该 Region 下全部门店 Id,再筛模板 | -| 仅 `locationId` | 按该门店 Id 筛模板 | -| 同时传 | **以 `locationId` 为准**(忽略 `groupId`) | -| Region/门店无效或解析结果为空 | 仅返回 **`appliedLocation=ALL`** 的模板 | - -命中条件(满足其一即可出现在列表): - -- `fl_label_template.AppliedLocationType = 'ALL'` -- `SPECIFIED` 且 `fl_label_template_location` 中存在 `LocationId ∈` 解析得到的门店集合 - -**请求示例** - -```http -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&groupId=fl_group_id_east HTTP/1.1 -Authorization: Bearer {token} -``` - -```http -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111 HTTP/1.1 -Authorization: Bearer {token} -``` - -**命名对照**:UI **Region** → Query **`groupId`**;UI **Location** → Query **`locationId`**。 - -### 列表出参 - -**`items[]` 新增/对齐字段** - -| 字段 | 类型 | 说明 | -|------|------|------| -| region | string | 适用 Region 展示文案 | -| location | string | 适用门店展示文案 | -| regionIds | string[] | Region Id 多选;`ALL` 时为 `[]` | -| locationIds | string[] | 门店 Id 多选;`ALL` 时为 `[]` | -| locationText | string | **兼容字段**,与 `location` 相同 | - -其它字段不变:`id`(= TemplateCode)、`templateName`、`contentsCount`、`sizeText`、`versionNo`、`lastEdited` 等。 - -**列表响应示例片段** - -```json -{ - "pageIndex": 1, - "pageSize": 10, - "totalCount": 2, - "items": [ - { - "id": "TPL_ALL", - "templateCode": "TPL_ALL", - "templateName": "Global Template", - "labelType": "PRICE", - "region": "All Regions", - "location": "All Locations", - "locationText": "All Locations", - "regionIds": [], - "locationIds": [], - "contentsCount": 5, - "sizeText": "4x6inch", - "versionNo": 1, - "lastEdited": "2026-05-26T10:00:00" - }, - { - "id": "TPL_TEST_001", - "templateName": "Price Tag 4x6", - "region": "East Region, West Region", - "location": "UNCC store, Central Park Store", - "locationText": "UNCC store, Central Park Store", - "regionIds": ["fl_group_id_east", "fl_group_id_west"], - "locationIds": [ - "11111111-1111-1111-1111-111111111111", - "22222222-2222-2222-2222-222222222222" - ], - "contentsCount": 3, - "sizeText": "4x6inch", - "versionNo": 2, - "lastEdited": "2026-05-26T11:30:00" - } - ] -} -``` - -### 详情出参(`GET /api/app/label-template/{id}`) - -在原有 `elements`、`templateProductDefaults`、`appliedLocationType` 等基础上增加: - -| 字段 | 类型 | 说明 | -|------|------|------| -| region | string | 展示文案 | -| location | string | 展示文案 | -| regionIds | string[] | Region Id 多选 | -| groupIds | string[] | 与 `regionIds` 相同(兼容) | -| locationIds | string[] | 门店 Id 多选 | -| appliedLocationIds | string[] | 与 `locationIds` 一致(编辑器回显) | - -### 展示规则 - -| appliedLocation | region | location | regionIds / locationIds | -|-----------------|--------|----------|-------------------------| -| **ALL** | `All Regions` | `All Locations` | 空数组 `[]` | -| **SPECIFIED** | 绑定门店 `location.GroupName` 去重后 `, ` 拼接 | 门店名(优先 `LocationName`,否则 `LocationCode`)拼接 | 由绑定门店反推 / 直接为绑定 Id | -| **SPECIFIED** 无绑定 | `无` | `无` | `[]` | - -`regionIds` 由 `locationIds` 反查 `fl_group` 得到(与 product-category、label-type 一致)。 - -### 编辑说明 - -- `PUT` Body 字段与 `POST` 相同;传 `regionIds` / `locationIds` 会 **全量替换** 模板适用门店(先删 `fl_label_template_location` 再插入)。 -- `elements` 仍为全量重建;`templateProductDefaults` 仅当 Body **显式包含** 该字段时才重建,避免普通保存误清空。 -- 编辑成功 **`versionNo` +1**。 - -### 联调注意 - -| 现象 | 处理 | -|------|------| -| 列表无 `regionIds` | 确认已部署含本变更的后端 | -| 传 `groupId` 列表仍很多 | 正常:`appliedLocation=ALL` 的模板始终可见 | -| 传 `groupId` 列表为空 | 检查 Region 是否存在、其下是否有门店;无效 Region 时仅剩 ALL 模板 | -| 传了 Region 仍显示 All Locations | 检查 Region Id 是否有效、是否能在库中展开到门店 | -| 仅 `appliedLocationIds` 不传 `locationIds` | 仍支持,与 `locationIds` 合并 | -| 前端编辑器仍传 `appliedLocation: "ALL"` | 管理端若需多选,须在 Body 增加 `regionIds` / `locationIds`(见 `labelTemplateService.ts`) | -| 指定范围但 0 门店 | 后端报错,需至少 1 个有效门店 | - -### 与 product-category / label-type 的关系 - -逻辑与 **`5-17接口优化.md`** 中 product-category、label-type 的 Region·Location 绑定一致,差异仅为: - -| 模块 | 范围字段名 | 关联表 | -|------|------------|--------| -| product-category | `availabilityType` | `fl_product_category_location` | -| label-type | `availabilityType` | `fl_label_type_location` | -| **label-template** | **`appliedLocation`** | **`fl_label_template_location`** | - ---- - -## rbac-role accessPermissions 修复 - -**应用服务**:`RbacRoleAppService` -**影响接口**:`POST` / `PUT /api/app/rbac-role/{id}`、`GET` 列表/详情回显 - -### 问题与根因 - -| 现象 | 根因 | -|------|------| -| 保存报 `accessPermissions 未匹配到任何菜单` | 前端提交 **JSON 数组字符串**(如 `["manage_labels",...]`),旧逻辑按逗号拆分,解析结果带 `["` 引号,无法匹配 | -| 传 `manage_labels` 等仍无菜单 | 表单权限码为 **UI 编码**(`manage_labels`),菜单侧为 **`menu.labels`**(由 `Menu.Router` 推导);二者未做映射 | -| 详情 `accessPermissionCodes` 为空 | 新增/编辑未写入 **`Role.AccessPermissionCodes`**(JSON 列),仅依赖 `RoleMenu` 反查 | - -### 变更说明 - -| 项 | 变更后 | -|----|--------| -| **入参解析** | `accessPermissions` 支持 **JSON 数组字符串**、逗号分隔、以及 Body 字段 **`accessPermissionCodes`** 数组 | -| **菜单绑定** | UI 权限码经 **`RoleAccessPermissionMenuMapping`** 映射到 `Menu.Router`,再写入 **`RoleMenu`** | -| **落库** | 同时将勾选的 UI 编码写入 **`Role.AccessPermissionCodes`**(JSON 数组),供 GET 回显 | -| **PermissionCode 为空** | 仍可按 **`Router`** 推导 `menu.xxx`(建议执行 `menu_backfill_permission_code.sql`) | - -### UI 权限码 → 菜单 Router 映射(当前库) - -| accessPermissions(UI) | 绑定菜单 Router | -|-------------------------|-----------------| -| `manage_labels` | `/labeling`、`/labels`、`/label-categories`、`/label-types`、`/label-templates` | -| `manage_people` | `/account-management` | -| `edit_settings` | `/menu-management`、`/multiple-options` | -| `view_reports` | `/reports` | -| `manage_products` | (当前 `Menu` 表无 Products 路由,勾选不绑定菜单,**不单独报错**) | -| `approve_batches` | (当前无对应菜单路由,同上) | - -> 至少 **1 个** 权限码能匹配到菜单即保存成功;若 **全部** 均无法匹配(例如只勾 `manage_products` 且库中无对应菜单),仍返回业务错误。 - -### 请求示例(与前端一致) - -```http -PUT /api/app/rbac-role/3a1f077b-3665-63f2-5fea-0fd7e7044b88 -Content-Type: application/json -Authorization: Bearer {token} -``` - -```json -{ - "roleName": "Partner Admin", - "roleCode": "admin", - "remark": "Admin", - "dataScope": 0, - "state": true, - "orderNum": 999, - "accessPermissions": "[\"manage_labels\",\"edit_settings\",\"view_reports\",\"manage_people\",\"manage_products\",\"approve_batches\"]" -} -``` - -也可使用逗号分隔(旧格式): - -```json -{ - "accessPermissions": "manage_labels, view_reports, manage_people" -} -``` - -或同时传数组字段(与 `accessPermissions` 合并去重): - -```json -{ - "accessPermissionCodes": ["manage_labels", "view_reports"] -} -``` - -### 入参优先级(与 5-18 一致) - -| menuIds | accessPermissions / accessPermissionCodes | 行为 | -|---------|-------------------------------------------|------| -| 非空数组 | 任意 | **以 menuIds 为准** | -| 不传 | 非空 | 按 UI 权限码映射菜单并覆盖 `RoleMenu` | -| 不传 | `""` 或空数组 | 清空 `RoleMenu` 与 `AccessPermissionCodes` | -| `[]` | 不传 | 清空绑定 | - -### 响应回显 - -| 字段 | 说明 | -|------|------| -| `accessPermissionCodes` | 来自 **`Role.AccessPermissionCodes`**,如 `["manage_labels","view_reports"]` | -| `accessPermissions` | 已绑定菜单的 **`menu.xxx`** 汇总(逗号拼接,只读展示) | -| `menuIds` | 已绑定菜单 Guid 列表(`RoleMenu`) | - -### 数据库准备(推荐) - -```bash -美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/menu_backfill_permission_code.sql -``` - -### 联调注意 - -| 现象 | 处理 | -|------|------| -| 仍报未匹配到菜单 | 确认已部署本修复;检查 `Menu` 是否存在上表 Router | -| 只勾 Products/Batches 报错 | 当前库无对应菜单属预期;请同时勾选 Labels/Reports 等 | -| 同时传 `menuIds: []` | **menuIds 优先**,会清空绑定并忽略 accessPermissions | - -> 更完整的 RBAC 说明见 **`5-18接口优化.md`** → rbac-role 章节。 - ---- - -## auth-scope 登录后 Company · Region · Location 级联选店 - -**应用服务**:`AuthScopeAppService` -**适用场景**:Web `POST /api/app/account/login` 或 App `POST /api/app/us-app-auth/login` 取得 Token 后,**管理员**无 `userlocation` 绑定时需先选工作门店;亦支持非管理员在数据范围内级联选择(须已绑定该门店)。 - -**命名约定**(与 5-17 一致):UI **Company** = **`partnerId`**(`fl_partner.Id`);UI **Region** = **`groupId`**(`fl_group.Id`);UI **Location** = **`locationId`**(`location.Id`,Guid 字符串)。 - -### 接口一览 - -| 步骤 | 方法 | 路径 | 说明 | -|------|------|------|------| -| 1 | GET | `/api/app/auth-scope/companies` | 可选公司列表 | -| 2 | GET | `/api/app/auth-scope/regions?partnerId={partnerId}` | 指定公司下 Region | -| 3 | GET | `/api/app/auth-scope/locations?partnerId={partnerId}&groupId={groupId}` | 指定公司+Region 下门店 | -| 4 | POST | `/api/app/auth-scope/select-location` | 确认当前工作门店 | -| — | GET | `/api/app/auth-scope/current-scope` | 查询已选工作门店(未选返回 `null`) | - -**鉴权**:均需 `Authorization: Bearer {token}`。 - -### 数据范围 - -| 角色 | Company | Region | Location | -|------|---------|--------|----------| -| **管理员**(`admin` / 用户名 `admin` / 权限 `*:*:*`) | 全部未删除公司 | 该公司下全部 Region | 该 Region 下全部门店(`location.Partner` + `location.GroupName` 与 `fl_group` 一致) | -| **非管理员** | `userlocation` 绑定门店所属公司 | 绑定门店对应 Region | 上述 Region 内且符合 `LocationRegionScopeHelper` 的门店;**选店时**须已绑定该 `locationId` | - -### 1)公司列表 - -```http -GET /api/app/auth-scope/companies HTTP/1.1 -Authorization: Bearer {token} -``` - -**响应**:`AuthScopeCompanyOptionDto[]` - -```json -[ - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true } -] -``` - -### 2)Region 列表 - -```http -GET /api/app/auth-scope/regions?partnerId=fl_partner_id_1 HTTP/1.1 -Authorization: Bearer {token} -``` - -**响应**:`AuthScopeRegionOptionDto[]` - -```json -[ - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true } -] -``` - -### 3)门店列表 - -```http -GET /api/app/auth-scope/locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1 -Authorization: Bearer {token} -``` - -**响应**:`AuthScopeLocationOptionDto[]`(含 `fullAddress`、`groupName` 等) - -### 4)确认选店(与现有 App 逻辑对齐) - -```http -POST /api/app/auth-scope/select-location HTTP/1.1 -Authorization: Bearer {token} -Content-Type: application/json -``` - -```json -{ - "partnerId": "fl_partner_id_1", - "groupId": "fl_group_id_east", - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f" -} -``` - -**响应**:`AuthScopeSelectLocationOutputDto` - -| 字段 | 说明 | -|------|------| -| partnerId / partnerName | 所选公司 | -| groupId / groupName | 所选 Region | -| location | 与 **`UsAppBoundLocationDto`** 相同(`id`、`locationCode`、`locationName`、`fullAddress`、`state`) | - -**选店后的服务端行为**(无需改前端即可对接 App): - -| 能力 | 行为 | -|------|------| -| **工作范围缓存** | 写入分布式缓存(24h);退出 `POST /api/app/auth-session/logout` 时清除 | -| **`GET /api/app/us-app-auth/my-locations`** | 管理员在缓存选店后,列表 **合并** 该门店(与 `userlocation` 并集) | -| **`GET .../location-detail/{locationId}`** | 管理员可不依赖 `userlocation` 访问已选门店(`UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync`) | -| **App 打印/报表** | 仍传 `locationId`;权限规则不变(见 `5-18接口优化.md`) | - -### 5)当前工作范围 - -```http -GET /api/app/auth-scope/current-scope HTTP/1.1 -Authorization: Bearer {token} -``` - -未选店时响应体为 **`null`**(HTTP 200)。 - -### 联调注意 - -| 现象 | 处理 | -|------|------| -| `regions` 为空 | 公司下无 `fl_group` 或当前账号无 Region 数据范围 | -| `locations` 为空 | 门店 `Partner` / `GroupName` 未与 `fl_partner`、`fl_group` 对齐 | -| 选店报「门店与所选公司/区域不匹配」 | 检查 `location.Partner`、`location.GroupName` | -| 非管理员选店报未绑定 | 须在 **Team Member** 中为该账号绑定该门店 | -| 选店后 `my-locations` 仍为空 | 确认已调 `select-location` 且 Token 为管理员身份 | - -> Web 管理端报表等模块仍可按 Query 传 `partnerId` / `groupId` / `locationId` 收窄;本组接口主要解决 **登录后选工作门店** 与 **App 门店列表** 一致性问题。 - ---- - -## us-app-auth App 管理员级联选店 - -**应用服务**:`UsAppAuthAppService` -**适用场景**:App 使用 **`POST /api/app/us-app-auth/login`** 登录后,持 **管理员** 身份(`admin` 角色 / 用户名 `admin` / 权限 `*:*:*`)且 JWT 含 **`client_kind=us-app`**,按 Company → Region 筛选门店。 - -**与 `auth-scope` 关系**:查询逻辑共用 `AuthScopeQueryHelper`;App 侧路径统一在 **`us-app-auth`** 下,并 **强制 App Token + 管理员**,避免误用 Web Token。 - -### 接口一览 - -| 步骤 | 方法 | 路径 | 说明 | -|------|------|------|------| -| 0 | POST | `/api/app/us-app-auth/login` | 获取 App Token(须管理员账号) | -| 1 | GET | `/api/app/us-app-auth/admin-scope-companies` | 公司列表 → 取 `id` 作 `partnerId` | -| 2 | GET | `/api/app/us-app-auth/admin-scope-regions?partnerId={partnerId}` | Region 列表 → 取 `id` 作 `groupId` | -| 3 | GET | `/api/app/us-app-auth/admin-scope-locations?partnerId={partnerId}&groupId={groupId}` | **按公司与 Region Id 筛选门店** | -| 4 | POST | `/api/app/us-app-auth/select-admin-scope-location` | 确认工作门店 | -| — | GET | `/api/app/us-app-auth/my-locations` | 选店后刷新绑定门店(含缓存门店) | - -**鉴权**:步骤 1–4 须 Header `Authorization: Bearer {App登录返回的token}`。 - -### 前置条件 - -| 项 | 要求 | -|----|------| -| Token 来源 | 必须来自 **`/api/app/us-app-auth/login`**(非 Web `/api/app/account/login`) | -| JWT 声明 | `client_kind` = `us-app` | -| 角色 | 平台管理员(`ReportsRoleHelper.IsAdminRole`) | -| 违反时 | `请使用 App 登录令牌调用该接口` 或 `仅管理员可使用公司/区域/门店筛选接口` | - -### 1)公司列表 - -```http -GET /api/app/us-app-auth/admin-scope-companies HTTP/1.1 -Authorization: Bearer {app_token} -``` - -**响应**:`AuthScopeCompanyOptionDto[]`(与 auth-scope 相同) - -```json -[ - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true } -] -``` - -### 2)Region 列表 - -```http -GET /api/app/us-app-auth/admin-scope-regions?partnerId=fl_partner_id_1 HTTP/1.1 -Authorization: Bearer {app_token} -``` - -**响应**:`AuthScopeRegionOptionDto[]` - -```json -[ - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true } -] -``` - -### 3)门店列表(按 partnerId + groupId 筛选) - -```http -GET /api/app/us-app-auth/admin-scope-locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1 -Authorization: Bearer {app_token} -``` - -**Query** - -| 参数 | 必填 | 说明 | -|------|------|------| -| partnerId | 是 | 公司 Id(`fl_partner.Id`) | -| groupId | 是 | Region Id(`fl_group.Id`) | - -**响应**:`AuthScopeLocationOptionDto[]` - -```json -[ - { - "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", - "locationCode": "LOC-1", - "locationName": "Downtown Kitchen", - "fullAddress": "123 Main St, New York, NY 10001", - "state": true, - "partnerId": "fl_partner_id_1", - "groupId": "fl_group_id_east", - "groupName": "East Region" - } -] -``` - -筛选规则:`location.Partner` 匹配该公司(Id 或名称),且 `location.GroupName` 与所选 `fl_group.GroupName` 一致。 - -### 4)确认选店 - -```http -POST /api/app/us-app-auth/select-admin-scope-location HTTP/1.1 -Authorization: Bearer {app_token} -Content-Type: application/json -``` - -```json -{ - "partnerId": "fl_partner_id_1", - "groupId": "fl_group_id_east", - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f" -} -``` - -**响应**:`AuthScopeSelectLocationOutputDto`(含 `location` 节点,结构同 `UsAppBoundLocationDto`) - -### 推荐调用顺序(App) - -```text -POST /api/app/us-app-auth/login - → GET admin-scope-companies - → GET admin-scope-regions?partnerId=... - → GET admin-scope-locations?partnerId=...&groupId=... - → POST select-admin-scope-location - → GET my-locations - → 后续业务接口传 locationId(打印、报表等,规则不变) -``` - -### 联调注意 - -| 现象 | 处理 | -|------|------| -| 报「请使用 App 登录令牌」 | 勿用 Web `account/login` 的 Token;须重新 App 登录 | -| 报「仅管理员可使用」 | 换管理员账号或绑定 `admin` 角色 | -| `locations` 为空 | 核对门店 `Partner`、`GroupName` 与 `fl_partner`、`fl_group` | -| 与 auth-scope 重复 | App 端 **优先** 使用本节前缀;Web 端用 `auth-scope` | - ---- - -## 变更记录 - -| 日期 | 说明 | -|------|------| -| 2026-05-26 | us-app-auth:App 管理员 `admin-scope-companies/regions/locations`、`select-admin-scope-location` | -| 2026-05-26 | auth-scope:登录后 Company/Region/Location 级联选店;选店缓存;`my-locations` / 门店详情与管理员选店对齐 | -| 2026-05-26 | product-category:`categoryCode` 新增/编辑改为可选 | -| 2026-05-26 | label-template:新增/编辑/列表/详情支持 `regionIds`、`locationIds` 及 `region`、`location` 展示 | -| 2026-05-26 | label-template 列表 Query 增加 `groupId`(Region)、`locationId`(门店)筛选 | -| 2026-05-26 | rbac-role:支持 accessPermissions JSON 数组 + UI 权限码映射 Menu;落库 AccessPermissionCodes | diff --git a/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar b/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar index 4334e46..d64c21f 100644 --- a/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar +++ b/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar diff --git a/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json b/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json index dc819d1..6e9e837 100644 --- a/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json +++ b/泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json @@ -1,7 +1,7 @@ { "name": "native-fast-printer", "id": "native-fast-printer", - "version": "1.2.8", + "version": "1.0.3", "description": "Android高速标签打印原生插件", "_dp_type": "nativeplugin", "_dp_nativeplugin": { @@ -23,8 +23,7 @@ "abis": [ "armeabi-v7a", "arm64-v8a", - "x86", - "x86_64" + "x86" ], "minSdkVersion": "21", "useAndroidX": true, diff --git a/泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1 b/泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1 index 6969aa1..595025e 100644 --- a/泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1 +++ b/泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1 @@ -6,12 +6,17 @@ $dstPluginRoot = Join-Path $appRoot "nativeplugins\native-fast-printer" $manifestPath = Join-Path $appRoot "src\manifest.json" Write-Host "[1/4] Validate source plugin files..." -$sourceAarItem = Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Filter "native_fast_printer-release.aar" | - Where-Object { $_.FullName -notlike "*\nativeplugins\native-fast-printer\android\*" } | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 +$preferredAar = Join-Path $repoRoot "打印机安卓基座\native-fast-printer\android\native_fast_printer-release.aar" +$sourceAarItem = $null +if (Test-Path -LiteralPath $preferredAar) { + $sourceAarItem = Get-Item -LiteralPath $preferredAar +} else { + $sourceAarItem = Get-ChildItem -LiteralPath (Join-Path $repoRoot "打印机安卓基座") -Recurse -File -Filter "native_fast_printer-release.aar" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 +} if ($null -eq $sourceAarItem) { - throw "AAR not found under repository root: $repoRoot" + throw "AAR not found. Build first: 打印机安卓基座/native-fast-printer/android-src/build-aar-windows.bat" } $srcPluginRoot = Split-Path -Parent (Split-Path -Parent $sourceAarItem.FullName) diff --git a/泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue b/泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue new file mode 100644 index 0000000..76043da --- /dev/null +++ b/泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/泰额版/Food Labeling Management App UniApp/src/locales/en.ts b/泰额版/Food Labeling Management App UniApp/src/locales/en.ts index 64a4095..3f55f9e 100644 --- a/泰额版/Food Labeling Management App UniApp/src/locales/en.ts +++ b/泰额版/Food Labeling Management App UniApp/src/locales/en.ts @@ -50,6 +50,14 @@ export default { selectStoreDesc: 'Select the store where you\'ll be working today', selectStoreError: 'Please select a store', storeSelected: 'Store selected successfully', + scopeCompany: 'Company', + scopeRegion: 'Region', + scopeLoading: 'Loading…', + scopeNoCompanies: 'No companies available', + scopeNoRegions: 'No regions for this company', + selectCompanyRegionFirst: 'Select company and region first', + scopeLoadFail: 'Could not load options', + scopeSelectFail: 'Could not confirm store', store1: 'Downtown Kitchen', store2: 'Brooklyn Central', store3: 'Queens Food Hub', store4: 'Manhattan Express', }, dashboard: { diff --git a/泰额版/Food Labeling Management App UniApp/src/locales/zh.ts b/泰额版/Food Labeling Management App UniApp/src/locales/zh.ts index f15fd1e..87a55c4 100644 --- a/泰额版/Food Labeling Management App UniApp/src/locales/zh.ts +++ b/泰额版/Food Labeling Management App UniApp/src/locales/zh.ts @@ -50,6 +50,14 @@ export default { selectStoreDesc: '选择您今天工作的店铺', selectStoreError: '请选择一个店铺', storeSelected: '店铺选择成功', + scopeCompany: '公司', + scopeRegion: '区域', + scopeLoading: '加载中…', + scopeNoCompanies: '暂无可用公司', + scopeNoRegions: '该公司下暂无区域', + selectCompanyRegionFirst: '请先选择公司与区域', + scopeLoadFail: '选项加载失败', + scopeSelectFail: '确认门店失败', store1: '市中心厨房', store2: '布鲁克林中心', store3: '皇后食品中心', store4: '曼哈顿快速店', }, dashboard: { diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue b/泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue index 50fc183..ba8b132 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue @@ -225,8 +225,8 @@ 1. Ensure the printer is powered on and in pairing mode 2. On Android: enable Location (required for Bluetooth scan) 3. Place the printer within 10 m and tap Scan again - 4. Devices with no name show as "Unknown Device"—you can still connect - 5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering + 4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer + 5. Pair the printer in system Bluetooth first if it does not appear after Scan 6. Restart the printer or app if not visible 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 { setBuiltinPrinter, } from '../../utils/print/printerConnection' import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist' import { getNativeFastPrinterDebugInfo, getNativeFastPrinterState, @@ -432,14 +433,12 @@ function handleResetUposOptions () { } function hasPreferredClassicDeviceInList () { - return devices.value.some((item: any) => { - const name = String(item?.name || '').toLowerCase() - const driverKey = String(item?.driverKey || '').toLowerCase() - return name.includes('virtual bt printer') || driverKey === 'd320fax' - }) + return devices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name)) } function upsertDevice (device: any) { + const rawName = (device?.name || device?.localName || '').trim() + if (!isAllowedBluetoothPrinterName(rawName)) return const described = describeDiscoveredPrinter(device) const existing = devices.value.find(item => item.deviceId === described.deviceId) if (!existing) { @@ -573,7 +572,9 @@ function addPairedDevices () { try { const paired = classic.getPairedDevices() debugInfo.value.pairedCount = (paired || []).length - debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer')) + debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'), + ) for (const p of paired) { upsertDevice({ deviceId: p.deviceId, diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue b/泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue index 2d33469..0c0d584 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue @@ -105,16 +105,20 @@ :key="productCategoryRowKey(pCat, pIdx)" class="cat-section" > - - + + - + {{ pCat.name }} {{ displayProductCategoryItemCount(pCat) }} items - + + + - {{ productVisual(product).text }} + {{ productVisual(product, pCat).text }} - {{ productVisual(product).text }} + {{ productVisual(product, pCat).text }} { +/** 安卓基座对内联 style 命中最稳;勿与侧栏共用 .cat-icon(64rpx) */ +const catHeaderRowStyle: Record = { + padding: '16rpx 20rpx', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + boxSizing: 'border-box', + width: '100%', +} + +const catHeaderLeftStyle: Record = { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + flex: '1', + minWidth: '0', +} + +const catHeaderInfoStyle: Record = { + flex: '1', + minWidth: '0', + marginLeft: '0', + paddingLeft: '0', +} + +const catHeaderChevronStyle: Record = { + flexShrink: '0', + width: '44rpx', + height: '44rpx', + marginLeft: '8rpx', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +} + +const catFoodsStyle: Record = { + boxSizing: 'border-box', + paddingTop: '4rpx', + paddingRight: '20rpx', + paddingBottom: '16rpx', + paddingLeft: '20rpx', +} + +function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record { const v = productCategoryVisual(p) - if (v.mode === 'colorText') return { backgroundColor: v.bg } - return {} + const style: Record = { + width: '52rpx', + height: '52rpx', + minWidth: '52rpx', + minHeight: '52rpx', + maxWidth: '52rpx', + maxHeight: '52rpx', + marginRight: '16rpx', + marginBottom: '0', + marginTop: '0', + marginLeft: '0', + flexShrink: '0', + borderRadius: '12rpx', + overflow: 'hidden', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxSizing: 'border-box', + backgroundColor: '#f3f4f6', + } + if (v.mode === 'colorText' || v.mode === 'color') { + style.backgroundColor = v.bg + } + return style } function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string { @@ -444,7 +517,10 @@ function colorClassForName(name: string): string { return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length] } -function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { +function productVisual( + p: UsAppLabelingProductNodeDto, + pCat?: UsAppProductCategoryNodeDto, +): CategoryVisualRender { const v = resolveCategoryButtonVisualFromDto({ buttonStyleJson: p.buttonStyleJson, buttonAppearance: p.buttonAppearance, @@ -457,13 +533,20 @@ function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { name: p.productName, }) if (v.mode !== 'none') return v + if (pCat) { + const fromCat = productCategoryVisual(pCat) + if (fromCat.mode !== 'none') return fromCat + } const legacyImg = (p.productImageUrl ?? '').trim() if (legacyImg) return { mode: 'image', imageUrl: legacyImg } return { mode: 'none' } } -function productThumbWrapStyle(p: UsAppLabelingProductNodeDto): Record { - const v = productVisual(p) +function productThumbWrapStyle( + p: UsAppLabelingProductNodeDto, + pCat?: UsAppProductCategoryNodeDto, +): Record { + const v = productVisual(p, pCat) if (v.mode === 'colorText' || v.mode === 'color') { return { backgroundColor: v.bg } } @@ -482,6 +565,8 @@ function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string { /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */ function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string { + const fromCard = (p.templateLabelSizeText ?? '').trim() + if (fromCard) return fromCard const types = p.labelTypes || [] if (types.length === 0) return '—' const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean) @@ -782,8 +867,16 @@ const goBluetoothPage = () => { height: 100%; } +/** + * 产品列表布局:手写 rpx(UniApp 安卓部分机型不支持 CSS var,var 会导致 padding 整段失效)。 + */ +.page { + box-sizing: border-box; +} + .panel-inner { - padding: 24rpx; + padding: 16rpx 20rpx; + box-sizing: border-box; } .search-box { @@ -826,38 +919,37 @@ const goBluetoothPage = () => { .category-list { display: flex; flex-direction: column; - gap: 16rpx; + gap: 12rpx; } .cat-section { background: #fff; - border-radius: 16rpx; + border-radius: 12rpx; box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); overflow: hidden; + box-sizing: border-box; } +/* 产品分类标题行:主样式见 script 内联 catHeaderRowStyle(安卓 scoped 易失效) */ .cat-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 24rpx; + box-sizing: border-box; + width: 100%; } -.cat-header-left { - display: flex; - align-items: center; - gap: 16rpx; - flex: 1; - min-width: 0; +.cat-header-thumb { + box-sizing: border-box; } -/* 与侧栏 .cat-icon 同尺寸,仅去掉侧栏下边距 */ -.cat-header-thumb { - width: 64rpx; - height: 64rpx; - margin-bottom: 0; - flex-shrink: 0; - background: #f3f4f6; +.cat-header-thumb--photo, +.cat-header-thumb--fallback { + overflow: hidden; +} + +.cat-header .cat-header-thumb .cat-icon-text, +.cat-header .cat-header-thumb .cat-icon-text--on-color { + font-size: 18rpx; + line-height: 1.1; + padding: 0 4rpx; } .cat-header-color-fill { @@ -893,39 +985,61 @@ const goBluetoothPage = () => { .cat-header-info { flex: 1; min-width: 0; + margin: 0; + padding: 0; } .cat-header-name { - font-size: 28rpx; + font-size: 26rpx; font-weight: 600; color: #111827; display: block; + line-height: 1.25; } .cat-header-count { - font-size: 22rpx; + font-size: 20rpx; color: #9ca3af; display: block; - margin-top: 2rpx; + margin-top: 4rpx; + line-height: 1.2; +} + +.cat-header-chevron { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 44rpx; + height: 44rpx; + margin-left: 8rpx; + margin-right: 0; } .cat-foods { - padding: 0 16rpx 16rpx; + box-sizing: border-box; border-top: 1rpx solid #f3f4f6; } .food-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 12rpx; - row-gap: 12rpx; - padding-top: 16rpx; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-content: flex-start; + padding-top: 8rpx; + margin-left: -5rpx; + margin-right: -5rpx; } .food-card { + width: 47%; + max-width: 240rpx; + flex: 0 0 auto; + box-sizing: border-box; + margin: 0 5rpx 12rpx 5rpx; background: #f9fafb; - padding: 10rpx; - border-radius: 14rpx; + padding: 8rpx; + border-radius: 12rpx; } .food-card:active { @@ -935,10 +1049,11 @@ const goBluetoothPage = () => { .food-img-wrap { width: 100%; position: relative; - padding-top: 75%; - border-radius: 10rpx; + height: 0; + padding-top: 58%; + border-radius: 8rpx; background: #e5e7eb; - margin-bottom: 10rpx; + margin-bottom: 8rpx; overflow: hidden; } @@ -965,9 +1080,9 @@ const goBluetoothPage = () => { display: flex; align-items: center; justify-content: center; - padding: 12rpx; + padding: 8rpx; box-sizing: border-box; - font-size: 32rpx; + font-size: 26rpx; font-weight: 700; color: #111827; line-height: 1.15; @@ -976,7 +1091,7 @@ const goBluetoothPage = () => { } .food-thumb-text--on-color { - font-size: 30rpx; + font-size: 24rpx; color: #ffffff; } @@ -1048,7 +1163,7 @@ const goBluetoothPage = () => { } .food-name { - font-size: 24rpx; + font-size: 22rpx; font-weight: 600; color: #111827; display: block; @@ -1059,7 +1174,7 @@ const goBluetoothPage = () => { } .food-desc { - font-size: 20rpx; + font-size: 18rpx; color: #6b7280; display: block; overflow: hidden; @@ -1152,14 +1267,25 @@ const goBluetoothPage = () => { } @media (min-width: 768px) { - .sidebar { - width: 260rpx; + .panel-inner { + padding: 20rpx 24rpx; + } + + /* 平板:内联样式在运行时由 rpx 换算,此处仅作 H5 预览补充 */ + + .food-card { + width: 31%; + max-width: 280rpx; + padding: 10rpx; + margin: 0 7rpx 14rpx 7rpx; + } + + .food-img-wrap { + padding-top: 62%; } - .food-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - column-gap: 16rpx; - row-gap: 16rpx; + .sidebar { + width: 260rpx; } } diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue b/泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue index 6923f5c..2428336 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue @@ -306,7 +306,12 @@ import { normalizeTemplateForNativeFastJob, templateHasUnsupportedNativeFastElements, } from '../../utils/print/nativeTemplateElementSupport' -import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm' +import { + ensureTemplateHeightCoversElements, + getTemplatePhysicalSizeMm, + isTemplateWithinNativeFastPrintBounds, + templateContentHeightPx, +} from '../../utils/print/templatePhysicalMm' import { isPrinterReadySync } from '../../utils/print/printerReadiness' import { ensureNativeClassicTransportIfPossible, @@ -462,7 +467,7 @@ function buildPrintPreflightDeviceInfoText (args: { lines.push(`- nativeUnsupportedElements: ${args.nativeUnsupported ? 'YES' : 'NO'}`) lines.push('') lines.push('Note') - lines.push('- Built-in 打印可能走 UPOS(内置/串口)或本机端口服务(127.0.0.1)。若“成功但不出纸”,优先看 uposWillTry / tcpPluginAvailable 与 native-fast-printer 的 lastError/stage。') + 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.') return lines.join('\n') } @@ -1189,6 +1194,14 @@ async function waitForCanvasLayout(): Promise { }) } +/** 光栅打印前同步隐藏 canvas 像素尺寸,避免缓冲区高度仍为预览值导致底部裁切 */ +async function applyPrintCanvasLayout(layout: { outW: number; outH: number }) { + canvasCssW.value = layout.outW + canvasCssH.value = layout.outH + await waitForCanvasLayout() + await new Promise((r) => setTimeout(r, 80)) +} + let deferredPreviewRedrawTimer: ReturnType | null = null /** 首屏内容与 loading 切换后布局才稳定,补绘一次与「改输入后变正常」同路径 */ @@ -1496,10 +1509,17 @@ const handlePrint = async () => { uni.showLoading({ title: 'Rendering…', mask: true }) /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */ const mergedForPrint = computeMergedPreviewTemplate() - const tmpl = mergedForPrint ?? systemTemplate.value - if (!tmpl || !instance) { + const tmplBase = mergedForPrint ?? systemTemplate.value + if (!tmplBase || !instance) { throw new Error('No label to print.') } + const tmpl = ensureTemplateHeightCoversElements(tmplBase) + const phys = getTemplatePhysicalSizeMm(tmpl) + console.info( + '[preview] print physical size', + `${phys.widthMm}×${phys.heightMm}mm`, + `template=${tmpl.width}×${tmpl.height}${phys.unit}`, + ) ensurePrintJobActive(printJobId) const printInputJson = buildPrintInputJson( @@ -1509,7 +1529,8 @@ const handlePrint = async () => { ) printStage = 'payload-ready' /** - * 一体机(经典蓝牙 + native-fast-printer 基座,如 Virtual BT):走原生 printLabelPrintJob。 + * 经典蓝牙 + native-plugin(含 Virtual BT):走原生 printTemplate(JSON,快,与预览坐标一致)。 + * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。 * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。 */ const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) @@ -1544,13 +1565,13 @@ const handlePrint = async () => { /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */ let tmplForNativePayload = tmplForNativeJob if (useNativeTemplatePrint) { - // D320+经典蓝牙仍走 printTemplate(JSON,快);略收紧横向与右侧安全区,减轻裁切/错位。 - tmplForNativePayload = applyNativeTemplateStyleScale( - tmplForNativePayload, - isD320faxClassicNative - ? { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 } - : { textScale: 1, xScale: 1, safeRightRatio: 0.93 }, - ) + // 真机 GP-D320FX 可略收窄;Virtual BT 必须与预览同坐标,禁止 xScale。 + if (isD320faxClassicNative && !isVirtualBtPrinter) { + tmplForNativePayload = applyNativeTemplateStyleScale( + tmplForNativePayload, + { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 }, + ) + } resetHydrateImageDebugRecords() tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativePayload) } @@ -1651,10 +1672,7 @@ const handlePrint = async () => { canvasRaster: { canvasId: 'labelPreviewCanvas', componentInstance: instance, - applyLayout: (layout) => { - canvasCssW.value = layout.outW - canvasCssH.value = layout.outH - }, + applyLayout: applyPrintCanvasLayout, }, }, (percent) => { @@ -1702,10 +1720,7 @@ const handlePrint = async () => { canvasRaster: { canvasId: 'labelPreviewCanvas', componentInstance: instance, - applyLayout: (layout) => { - canvasCssW.value = layout.outW - canvasCssH.value = layout.outH - }, + applyLayout: applyPrintCanvasLayout, }, }, (percent) => { @@ -1798,7 +1813,14 @@ const handlePrint = async () => { } const maxDots = rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) - const layout = getLabelPrintRasterLayout(tmpl, maxDots, rasterDriver.imageDpi || 203) + const escUseContentH = rasterDriver.protocol === 'esc' + const contentHpx = escUseContentH ? templateContentHeightPx(tmpl) : undefined + const layout = getLabelPrintRasterLayout( + tmpl, + maxDots, + rasterDriver.imageDpi || 203, + contentHpx != null ? { contentHeightPx: contentHpx } : undefined, + ) canvasCssW.value = layout.outW canvasCssH.value = layout.outH @@ -1862,9 +1884,11 @@ const handlePrint = async () => { imageData, { printQty: printQty.value, - clearTopRasterRows: 1, + clearTopRasterRows: 0, targetWidthDots: layout.outW, targetHeightDots: layout.outH, + useContentHeight: true, + cutBetweenCopies: true, }, (percent) => { if (!isPrintJobActive(printJobId)) return @@ -1880,6 +1904,7 @@ const handlePrint = async () => { clearTopRasterRows: 1, targetWidthDots: layout.outW, targetHeightDots: layout.outH, + labelRasterFixedHeight: true, }, (percent) => { if (!isPrintJobActive(printJobId)) return @@ -1958,9 +1983,11 @@ const handlePrint = async () => { tmpPath, { printQty: printQty.value, - clearTopRasterRows: 1, + clearTopRasterRows: 0, targetWidthDots: layout.outW, targetHeightDots: layout.outH, + useContentHeight: true, + cutBetweenCopies: true, }, (percent) => { if (!isPrintJobActive(printJobId)) return @@ -1975,6 +2002,7 @@ const handlePrint = async () => { clearTopRasterRows: 1, targetWidthDots: layout.outW, targetHeightDots: layout.outH, + labelRasterFixedHeight: true, }, (percent) => { if (!isPrintJobActive(printJobId)) return diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue b/泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue index a997f7f..79f9fad 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue @@ -1,514 +1,1398 @@ + + + + + + + diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue b/泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue index 6885268..1adb36d 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue @@ -53,7 +53,7 @@ class="log-card" > - {{ row.productName || '无' }} + {{ displayField(row.productName) }} {{ shortRef(row) }} @@ -67,11 +67,11 @@ - {{ row.operatorName || '无' }} + {{ displayField(row.operatorName) }} - {{ row.locationName || '无' }} + {{ displayField(row.locationName) }} @@ -105,7 +105,7 @@ :key="row.taskId + '-' + row.copyIndex" class="log-table-row" > - {{ row.productName || '无' }} + {{ displayField(row.productName) }} {{ shortRef(row) }} {{ tagLabelSize(row) }} {{ tagTypeName(row) }} @@ -157,6 +157,7 @@ import { } from '../../utils/print/printerConnection' import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' import type { PrintLogItemDto } from '../../types/usAppLabeling' +import { formatDisplayText } from '../../utils/emptyDisplay' const statusBarHeight = getStatusBarHeight() const isMenuOpen = ref(false) @@ -208,6 +209,10 @@ function createClientRequestId (): string { return `reprint-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` } +function displayField (value: string | null | undefined): string { + return formatDisplayText(value, 'None') +} + function shortRef (row: PrintLogItemDto): string { const b = String(row.batchId || row.taskId || '').trim() if (b.length > 14) return `${b.slice(0, 8)}…` @@ -218,14 +223,14 @@ function shortRef (row: PrintLogItemDto): string { function tagLabelSize (row: PrintLogItemDto): string { const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText const s = String(raw ?? '').trim() - return s || '无' + return formatDisplayText(s, 'None') } /** 红框右:typeName(兼容 PascalCase) */ function tagTypeName (row: PrintLogItemDto): string { const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName const s = String(raw ?? '').trim() - return s || '无' + return formatDisplayText(s, 'None') } async function loadPage (reset: boolean) { diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue b/泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue index a6150ec..a32b8ab 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue @@ -109,8 +109,8 @@ 1. Ensure the printer is powered on and in pairing mode 2. On Android: enable Location (required for Bluetooth scan) 3. Place the printer within 10 m and tap Scan again - 4. Devices with no name show as "Unknown Device"—you can still connect - 5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering + 4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer + 5. Pair the printer in system Bluetooth first if it does not appear after Scan 6. Restart the printer or app if not visible 7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only. @@ -173,6 +173,7 @@ import SideMenu from '../../components/SideMenu.vue' import { getDeviceIdentity } from '../../utils/deviceInfo' import classicBluetooth from '../../utils/print/bluetoothTool.js' import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist' import { getAvailablePrinterTypes } from '../../utils/print/printerConnection' import { connectBluetoothPrinter, @@ -269,14 +270,12 @@ const normalizeDeviceName = (device: any) => { } const hasPreferredClassicDevice = () => { - return pairedDevices.value.some((item: any) => { - const name = String(item?.name || '').toLowerCase() - const driverKey = String(item?.driverKey || '').toLowerCase() - return name.includes('virtual bt printer') || driverKey === 'd320fax' - }) + return pairedDevices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name)) } const addDeviceDedup = (device: any) => { + const displayName = normalizeDeviceName(device) + if (!isAllowedBluetoothPrinterName(displayName)) return const described = describeDiscoveredPrinter(device) const existing = devices.value.find(d => d.deviceId === device.deviceId) if (!existing) { @@ -291,11 +290,15 @@ const loadPairedDevices = () => { try { const list = classicBluetooth.getPairedDevices() debugInfo.value.pairedCount = (list || []).length - debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer')) - pairedDevices.value = (list || []).map((item: any) => ({ - ...describeDiscoveredPrinter(item), - name: normalizeDeviceName(item), - })) + debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'), + ) + pairedDevices.value = (list || []) + .map((item: any) => ({ + ...describeDiscoveredPrinter(item), + name: normalizeDeviceName(item), + })) + .filter((item: any) => isAllowedBluetoothPrinterName(item?.name)) debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices' } catch (e) { console.error('Failed to load paired devices', e) diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue b/泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue index 3ba957d..ade0fda 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue @@ -84,6 +84,7 @@ import SideMenu from '../../components/SideMenu.vue' import LocationPicker from '../../components/LocationPicker.vue' import { getStatusBarHeight } from '../../utils/statusBar' import { usAppFetchMyProfile } from '../../services/usAppAuth' +import { formatDisplayText } from '../../utils/emptyDisplay' const { t } = useI18n() const statusBarHeight = getStatusBarHeight() @@ -98,11 +99,11 @@ async function loadProfile() { uni.showLoading({ title: 'Loading...', mask: true }) try { const p = await usAppFetchMyProfile() - name.value = p.fullName?.trim() ? p.fullName : '—' - email.value = p.email?.trim() ? p.email : '—' - phone.value = p.phone?.trim() ? p.phone : '—' - employeeId.value = p.employeeId?.trim() ? p.employeeId : '—' - roleDisplay.value = p.roleDisplay?.trim() ? p.roleDisplay : '—' + name.value = formatDisplayText(p.fullName, '—') + email.value = formatDisplayText(p.email, '—') + phone.value = formatDisplayText(p.phone, '—') + employeeId.value = formatDisplayText(p.employeeId, '—') + roleDisplay.value = formatDisplayText(p.roleDisplay, '—') if (p.fullName?.trim()) { uni.setStorageSync('userName', p.fullName.trim()) } diff --git a/泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue b/泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue index a1d0133..180d824 100644 --- a/泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue +++ b/泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue @@ -11,8 +11,55 @@ + + + {{ t('login.scopeCompany') }} * + + {{ t('login.scopeLoading') }} + + + {{ t('login.scopeNoCompanies') }} + + + + {{ c.partnerName }} + + + + + + {{ t('login.scopeRegion') }} * + + {{ t('login.scopeLoading') }} + + + {{ t('login.scopeNoRegions') }} + + + + {{ r.groupName }} + + + + + - + + {{ t('login.selectCompanyRegionFirst') }} + + {{ t('login.noStoresBound') }} - + - {{ t('common.back') }} + {{ t('login.backToSignIn') }} - - {{ t('common.confirm') }} + + {{ loading || confirming ? '…' : t('common.confirm') }} - - {{ loading ? '…' : t('common.confirm') }} - @@ -77,9 +120,18 @@ import { useI18n } from 'vue-i18n' import { onShow } from '@dcloudio/uni-app' import AppIcon from '../../components/AppIcon.vue' import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' -import { usAppFetchMyLocations } from '../../services/usAppAuth' +import { + usAppFetchAdminScopeCompanies, + usAppFetchAdminScopeLocations, + usAppFetchAdminScopeRegions, + usAppFetchMyLocations, + usAppFetchMyProfile, + usAppSelectAdminScopeLocation, +} from '../../services/usAppAuth' +import type { AuthScopeCompanyOption, AuthScopeRegionOption } from '../../types/usAppAdminScope' import type { UsAppBoundLocationDto } from '../../types/usAppBound' import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' +import { isAppAdminUser } from '../../utils/appAdminRole' import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession' import { switchStore } from '../../utils/stores' @@ -90,6 +142,15 @@ const userName = computed(() => uni.getStorageSync('userName') || 'Employee') const selectedStore = ref('') const stores = ref([]) const loading = ref(false) +const confirming = ref(false) + +const isAdminUser = ref(false) +const scopeLoading = ref(false) +const regionsLoading = ref(false) +const companies = ref([]) +const regions = ref([]) +const selectedPartnerId = ref('') +const selectedGroupId = ref('') function applyList(list: UsAppBoundLocationDto[]) { const enabled = list.filter((s) => s.state !== false) @@ -97,7 +158,7 @@ function applyList(list: UsAppBoundLocationDto[]) { setBoundLocations(enabled) } -async function refreshFromApi() { +async function refreshEmployeeStores() { loading.value = true try { const list = await usAppFetchMyLocations() @@ -111,13 +172,117 @@ async function refreshFromApi() { } } +async function loadCompanies() { + scopeLoading.value = true + try { + companies.value = await usAppFetchAdminScopeCompanies() + } catch (e) { + if (isUsAppSessionExpiredError(e)) return + companies.value = [] + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) + } finally { + scopeLoading.value = false + } +} + +async function loadRegions(partnerId: string) { + regionsLoading.value = true + regions.value = [] + try { + regions.value = await usAppFetchAdminScopeRegions(partnerId) + } catch (e) { + if (isUsAppSessionExpiredError(e)) return + regions.value = [] + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) + } finally { + regionsLoading.value = false + } +} + +async function loadAdminLocations(partnerId: string, groupId: string) { + loading.value = true + selectedStore.value = '' + try { + const list = await usAppFetchAdminScopeLocations(partnerId, groupId) + applyList( + list.map((x) => ({ + id: x.id, + locationCode: x.locationCode, + locationName: x.locationName, + fullAddress: x.fullAddress, + state: x.state, + })), + ) + } catch (e) { + if (isUsAppSessionExpiredError(e)) return + applyList([]) + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) + } finally { + loading.value = false + } +} + +function onSelectCompany(partnerId: string) { + const id = partnerId.trim() + if (selectedPartnerId.value === id) return + selectedPartnerId.value = id + selectedGroupId.value = '' + selectedStore.value = '' + stores.value = [] + regions.value = [] + if (id) loadRegions(id) +} + +function onSelectRegion(groupId: string) { + const gid = groupId.trim() + if (selectedGroupId.value === gid) return + selectedGroupId.value = gid + selectedStore.value = '' + if (gid && selectedPartnerId.value) { + loadAdminLocations(selectedPartnerId.value, gid) + } else { + stores.value = [] + } +} + +async function initPage() { + loading.value = true + try { + const profile = await usAppFetchMyProfile() + isAdminUser.value = isAppAdminUser(profile) + if (isAdminUser.value) { + applyList([]) + await loadCompanies() + } else { + await refreshEmployeeStores() + } + } catch (e) { + if (isUsAppSessionExpiredError(e)) return + isAdminUser.value = false + applyList(getBoundLocations()) + await refreshEmployeeStores() + } finally { + loading.value = false + } +} + onMounted(() => { applyList(getBoundLocations()) - refreshFromApi() + initPage() }) onShow(() => { - applyList(getBoundLocations()) + if (!isAdminUser.value) { + applyList(getBoundLocations()) + } +}) + +const canConfirm = computed(() => { + if (loading.value || confirming.value || !selectedStore.value) return false + if (isAdminUser.value) { + return !!(selectedPartnerId.value && selectedGroupId.value) + } + return stores.value.length > 0 }) const handleBackToLogin = () => { @@ -125,20 +290,43 @@ const handleBackToLogin = () => { uni.redirectTo({ url: '/pages/login/login' }) } -const handleConfirm = () => { - if (loading.value || !selectedStore.value) { +const handleConfirm = async () => { + if (!canConfirm.value) { if (!selectedStore.value) { uni.showToast({ title: t('login.selectStoreError'), icon: 'none' }) + } else if (isAdminUser.value && (!selectedPartnerId.value || !selectedGroupId.value)) { + uni.showToast({ title: t('login.selectCompanyRegionFirst'), icon: 'none' }) } return } const store = stores.value.find((s) => s.id === selectedStore.value) if (!store) return - switchStore(store.id, store.locationName, store.locationCode) - uni.showToast({ title: t('login.storeSelected'), icon: 'success' }) - setTimeout(() => { - uni.redirectTo({ url: '/pages/index/index' }) - }, 400) + + confirming.value = true + try { + if (isAdminUser.value) { + const res = await usAppSelectAdminScopeLocation({ + partnerId: selectedPartnerId.value, + groupId: selectedGroupId.value, + locationId: store.id, + }) + const loc = res.location ?? store + setBoundLocations([loc]) + switchStore(loc.id, loc.locationName, loc.locationCode) + } else { + switchStore(store.id, store.locationName, store.locationCode) + } + uni.showToast({ title: t('login.storeSelected'), icon: 'success' }) + setTimeout(() => { + uni.redirectTo({ url: '/pages/index/index' }) + }, 400) + } catch (e: unknown) { + if (isUsAppSessionExpiredError(e)) return + const msg = e instanceof Error ? e.message : String(e) + uni.showToast({ title: msg || t('login.scopeSelectFail'), icon: 'none' }) + } finally { + confirming.value = false + } } @@ -187,11 +375,67 @@ const handleConfirm = () => { color: rgba(255, 255, 255, 0.85); } +.scope-panel { + flex-shrink: 0; + padding: 24rpx 32rpx 0; + background: #f9fafb; +} + +.scope-section { + margin-bottom: 20rpx; +} + +.scope-label { + font-size: 26rpx; + font-weight: 600; + color: #374151; + display: block; + margin-bottom: 12rpx; +} + +.scope-hint { + padding: 16rpx 0; +} + +.scope-hint-text { + font-size: 26rpx; + color: #9ca3af; +} + +.scope-chips { + display: flex; + flex-wrap: wrap; + gap: 12rpx 16rpx; +} + +.scope-chip { + display: inline-block; + padding: 16rpx 28rpx; + background: #fff; + border-radius: 999rpx; + border: 2rpx solid #e5e7eb; +} + +.scope-chip.active { + border-color: var(--theme-primary); + background: var(--theme-primary-light); +} + +.scope-chip-text { + font-size: 26rpx; + color: #111827; +} + +.scope-chip.active .scope-chip-text { + color: var(--theme-primary); + font-weight: 600; +} + .list { flex: 1; min-height: 0; overflow-y: auto; - padding: 32rpx; + padding: 24rpx 32rpx; padding-bottom: 24rpx; } diff --git a/泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts b/泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts index f922f75..2d4a1ff 100644 --- a/泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts +++ b/泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts @@ -1,5 +1,12 @@ +import type { + AuthScopeCompanyOption, + AuthScopeLocationOption, + AuthScopeRegionOption, + AuthScopeSelectLocationOutput, + UsAppSelectAdminScopeLocationInput, +} from '../types/usAppAdminScope' import type { UsAppBoundLocationDto } from '../types/usAppBound' -import { usAppApiRequest } from '../utils/usAppApiRequest' +import { usAppApiRequest, unwrapApiPayload } from '../utils/usAppApiRequest' import { fetchWithOfflineCache } from '../utils/sqliteSync' /** GET /api/app/us-app-auth/my-profile → UsAppMyProfileOutputDto */ @@ -162,6 +169,115 @@ export async function usAppFetchLocationDetail(locationId: string): Promise): AuthScopeCompanyOption { + return { + id: String(raw.id ?? raw.Id ?? '').trim(), + partnerName: String(raw.partnerName ?? raw.PartnerName ?? '').trim(), + state: raw.state !== false && raw.State !== false, + } +} + +function normalizeRegionOption(raw: Record): AuthScopeRegionOption { + return { + id: String(raw.id ?? raw.Id ?? '').trim(), + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim(), + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim(), + state: raw.state !== false && raw.State !== false, + } +} + +function normalizeScopeLocationOption(raw: Record): AuthScopeLocationOption { + return { + id: String(raw.id ?? raw.Id ?? '').trim(), + locationCode: String(raw.locationCode ?? raw.LocationCode ?? '').trim(), + locationName: String(raw.locationName ?? raw.LocationName ?? '').trim(), + fullAddress: String(raw.fullAddress ?? raw.FullAddress ?? '').trim(), + state: raw.state !== false && raw.State !== false, + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim() || undefined, + groupId: String(raw.groupId ?? raw.GroupId ?? '').trim() || undefined, + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim() || undefined, + } +} + +function normalizeScopeLocationList(raw: unknown): AuthScopeLocationOption[] { + const arr = Array.isArray(raw) ? raw : [] + return arr + .map((x) => normalizeScopeLocationOption(x as Record)) + .filter((x) => x.id) +} + +/** GET /api/app/us-app-auth/admin-scope-companies */ +export async function usAppFetchAdminScopeCompanies(): Promise { + const raw = await usAppApiRequest({ + path: '/api/app/us-app-auth/admin-scope-companies', + method: 'GET', + auth: true, + }) + const list = unwrapApiPayload(raw) + const arr = Array.isArray(list) ? list : [] + return arr + .map((x) => normalizeCompanyOption(x as Record)) + .filter((x) => x.id) +} + +/** GET /api/app/us-app-auth/admin-scope-regions */ +export async function usAppFetchAdminScopeRegions(partnerId: string): Promise { + const pid = partnerId.trim() + const raw = await usAppApiRequest({ + path: '/api/app/us-app-auth/admin-scope-regions', + method: 'GET', + auth: true, + data: { partnerId: pid }, + }) + const list = unwrapApiPayload(raw) + const arr = Array.isArray(list) ? list : [] + return arr + .map((x) => normalizeRegionOption(x as Record)) + .filter((x) => x.id) +} + +/** GET /api/app/us-app-auth/admin-scope-locations */ +export async function usAppFetchAdminScopeLocations( + partnerId: string, + groupId: string, +): Promise { + const raw = await usAppApiRequest({ + path: '/api/app/us-app-auth/admin-scope-locations', + method: 'GET', + auth: true, + data: { + partnerId: partnerId.trim(), + groupId: groupId.trim(), + }, + }) + return normalizeScopeLocationList(unwrapApiPayload(raw)) +} + +/** POST /api/app/us-app-auth/select-admin-scope-location */ +export async function usAppSelectAdminScopeLocation( + input: UsAppSelectAdminScopeLocationInput, +): Promise { + const raw = await usAppApiRequest({ + path: '/api/app/us-app-auth/select-admin-scope-location', + method: 'POST', + auth: true, + data: { + partnerId: input.partnerId.trim(), + groupId: input.groupId.trim(), + locationId: input.locationId.trim(), + }, + }) + const o = (unwrapApiPayload(raw) ?? {}) as Record + const locRaw = (o.location ?? o.Location ?? {}) as Record + return { + partnerId: String(o.partnerId ?? o.PartnerId ?? '').trim(), + partnerName: String(o.partnerName ?? o.PartnerName ?? '').trim(), + groupId: String(o.groupId ?? o.GroupId ?? '').trim(), + groupName: String(o.groupName ?? o.GroupName ?? '').trim(), + location: normalizeLocation(locRaw), + } +} + export async function usAppChangePassword(input: UsAppChangePasswordInput): Promise { await usAppApiRequest({ path: '/api/app/us-app-auth/change-password', diff --git a/泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts b/泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts index 3f3b969..77baa05 100644 --- a/泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts +++ b/泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts @@ -6,6 +6,8 @@ import type { UsAppLabelPreviewInputVo, UsAppLabelPrintInputVo, UsAppLabelPrintOutputDto, + UsAppLabelReportOutputDto, + UsAppLabelReportQueryInputVo, UsAppLabelReprintInputVo, UsAppLabelTypeNodeDto, UsAppProductCategoryNodeDto, @@ -40,9 +42,21 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD })) return { productId: String(x?.productId ?? x?.ProductId ?? ''), + templateId: String(x?.templateId ?? x?.TemplateId ?? '').trim() || undefined, + templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null, + templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as + | string + | null, productName: String(x?.productName ?? x?.ProductName ?? ''), productCode: String(x?.productCode ?? x?.ProductCode ?? ''), productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, + displayText: (x?.displayText ?? x?.DisplayText ?? null) as string | null, + categoryPhotoUrl: (x?.categoryPhotoUrl ?? x?.CategoryPhotoUrl ?? null) as string | null, + buttonAppearance: (x?.buttonAppearance ?? x?.ButtonAppearance ?? null) as string | null, + buttonBgColor: (x?.buttonBgColor ?? x?.ButtonBgColor ?? null) as string | null, + buttonImageUrl: (x?.buttonImageUrl ?? x?.ButtonImageUrl ?? null) as string | null, + buttonTextColor: (x?.buttonTextColor ?? x?.ButtonTextColor ?? null) as string | null, + buttonStyleJson: (x?.buttonStyleJson ?? x?.ButtonStyleJson ?? null) as string | null, subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''), labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length), labelTypes, @@ -270,6 +284,192 @@ export async function reportUsAppLabelPrintIfReady(input: { return postUsAppLabelPrint(body) } +function numField (o: Record, camel: string, pascal: string, fallback = 0): number { + const v = o[camel] ?? o[pascal] + const n = Number(v) + return Number.isFinite(n) ? n : fallback +} + +function strField (o: Record, camel: string, pascal: string): string { + const v = o[camel] ?? o[pascal] + return typeof v === 'string' ? v.trim() : String(v ?? '').trim() +} + +/** 规范化 Label Report 响应(camelCase / PascalCase) */ +export function normalizeUsAppLabelReport (raw: unknown): UsAppLabelReportOutputDto { + const empty: UsAppLabelReportOutputDto = { + summary: { + totalLabelsPrinted: 0, + totalLabelsPrintedChangeRate: 0, + mostPrintedCategoryCount: 0, + topProductCount: 0, + avgDailyPrints: 0, + avgDailyPrintsChangeRate: 0, + }, + labelsByCategory: [], + printVolumeTrend: [], + mostUsedProducts: [], + } + if (!raw || typeof raw !== 'object') return empty + const root = raw as Record + + const rangeRaw = root.appliedRange ?? root.AppliedRange + let appliedRange: UsAppLabelReportOutputDto['appliedRange'] + if (rangeRaw && typeof rangeRaw === 'object') { + const r = rangeRaw as Record + appliedRange = { + period: strField(r, 'period', 'Period') || undefined, + startDate: strField(r, 'startDate', 'StartDate') || undefined, + endDate: strField(r, 'endDate', 'EndDate') || undefined, + dayCount: numField(r, 'dayCount', 'DayCount', 0) || undefined, + trendDescription: strField(r, 'trendDescription', 'TrendDescription') || undefined, + } + } + + const sumRaw = root.summary ?? root.Summary + const s = (sumRaw && typeof sumRaw === 'object' ? sumRaw : {}) as Record + const summary = { + totalLabelsPrinted: numField(s, 'totalLabelsPrinted', 'TotalLabelsPrinted', 0), + totalLabelsPrintedPrevPeriod: numField(s, 'totalLabelsPrintedPrevPeriod', 'TotalLabelsPrintedPrevPeriod', 0), + totalLabelsPrintedChangeRate: numField(s, 'totalLabelsPrintedChangeRate', 'TotalLabelsPrintedChangeRate', 0), + mostPrintedCategoryName: strField(s, 'mostPrintedCategoryName', 'MostPrintedCategoryName') || null, + mostPrintedCategoryCount: numField(s, 'mostPrintedCategoryCount', 'MostPrintedCategoryCount', 0), + topProductName: strField(s, 'topProductName', 'TopProductName') || null, + topProductCount: numField(s, 'topProductCount', 'TopProductCount', 0), + avgDailyPrints: numField(s, 'avgDailyPrints', 'AvgDailyPrints', 0), + avgDailyPrintsPrevPeriod: numField(s, 'avgDailyPrintsPrevPeriod', 'AvgDailyPrintsPrevPeriod', 0), + avgDailyPrintsChangeRate: numField(s, 'avgDailyPrintsChangeRate', 'AvgDailyPrintsChangeRate', 0), + } + + const labelsByCategory: UsAppLabelReportOutputDto['labelsByCategory'] = [] + const labelsRaw = root.labelsByCategory ?? root.LabelsByCategory + if (Array.isArray(labelsRaw)) { + for (const x of labelsRaw) { + if (!x || typeof x !== 'object') continue + const row = x as Record + const name = strField(row, 'categoryName', 'CategoryName') + labelsByCategory.push({ + categoryId: strField(row, 'categoryId', 'CategoryId') || null, + categoryName: name || 'Uncategorized', + count: numField(row, 'count', 'Count', 0), + }) + } + } + + const printVolumeTrend: UsAppLabelReportOutputDto['printVolumeTrend'] = [] + const trendRaw = root.printVolumeTrend ?? root.PrintVolumeTrend + if (Array.isArray(trendRaw)) { + for (const x of trendRaw) { + if (!x || typeof x !== 'object') continue + const row = x as Record + const date = strField(row, 'date', 'Date') + if (!date) continue + printVolumeTrend.push({ + date, + count: numField(row, 'count', 'Count', 0), + }) + } + } + + const mostUsedProducts: UsAppLabelReportOutputDto['mostUsedProducts'] = [] + const productsRaw = root.mostUsedProducts ?? root.MostUsedProducts + if (Array.isArray(productsRaw)) { + for (const x of productsRaw) { + if (!x || typeof x !== 'object') continue + const row = x as Record + const productName = strField(row, 'productName', 'ProductName') + if (!productName) continue + mostUsedProducts.push({ + productId: strField(row, 'productId', 'ProductId') || null, + productName, + categoryName: strField(row, 'categoryName', 'CategoryName') || '—', + totalPrinted: numField(row, 'totalPrinted', 'TotalPrinted', 0), + usagePercent: numField(row, 'usagePercent', 'UsagePercent', 0), + }) + } + } + + return { + appliedRange, + summary, + labelsByCategory, + printVolumeTrend, + mostUsedProducts, + } +} + +/** 按 5-27 文档计算自然日区间(含起止日) */ +export function buildUsAppLabelReportDateRange (input: { + period: UsAppLabelReportQueryInputVo['period'] + customStart?: string + customEnd?: string +}): { startDate: string; endDate: string } { + const pad = (n: number) => String(n).padStart(2, '0') + const fmt = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + const parseYmd = (s: string): Date | null => { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim()) + if (!m) return null + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) + return Number.isNaN(d.getTime()) ? null : d + } + + const today = new Date() + today.setHours(0, 0, 0, 0) + + const period = input.period || '7d' + let end = today + let start: Date + + if (period === 'custom') { + const cs = input.customStart ? parseYmd(input.customStart) : null + const ce = input.customEnd ? parseYmd(input.customEnd) : null + end = ce || today + start = cs || new Date(end) + start.setDate(start.getDate() - 6) + if (start > end) start = new Date(end) + } else { + const days = + period === '90d' ? 90 : period === '30d' ? 30 : 7 + end = today + start = new Date(end) + start.setDate(start.getDate() - (days - 1)) + } + + return { startDate: fmt(start), endDate: fmt(end) } +} + +/** Label Report:POST get-label-report */ +export async function fetchUsAppLabelReport ( + input: UsAppLabelReportQueryInputVo, +): Promise { + const period = input.period || '7d' + const { startDate, endDate } = buildUsAppLabelReportDateRange({ + period, + customStart: input.startDate, + customEnd: input.endDate, + }) + + const body: Record = { + locationId: input.locationId, + period, + startDate: period === 'custom' ? input.startDate || startDate : startDate, + endDate: period === 'custom' ? input.endDate || endDate : endDate, + } + if (input.keyword?.trim()) { + body.keyword = input.keyword.trim() + } + + const raw = await usAppApiRequest({ + path: '/api/app/us-app-labeling/get-label-report', + method: 'POST', + auth: true, + data: body, + }) + return normalizeUsAppLabelReport(raw) +} + /** 接口 10:分页打印日志 */ export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) { const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` diff --git a/泰额版/Food Labeling Management App UniApp/src/types/usAppAdminScope.ts b/泰额版/Food Labeling Management App UniApp/src/types/usAppAdminScope.ts new file mode 100644 index 0000000..a960e6c --- /dev/null +++ b/泰额版/Food Labeling Management App UniApp/src/types/usAppAdminScope.ts @@ -0,0 +1,46 @@ +/** 与 AuthScopeCompanyOptionDto 对齐 */ +export interface AuthScopeCompanyOption { + id: string + partnerName: string + state?: boolean +} + +/** 与 AuthScopeRegionOptionDto 对齐 */ +export interface AuthScopeRegionOption { + id: string + groupName: string + partnerId: string + state?: boolean +} + +/** 与 AuthScopeLocationOptionDto 对齐 */ +export interface AuthScopeLocationOption { + id: string + locationCode: string + locationName: string + fullAddress: string + state: boolean + partnerId?: string + groupId?: string + groupName?: string +} + +export interface UsAppSelectAdminScopeLocationInput { + partnerId: string + groupId: string + locationId: string +} + +export interface AuthScopeSelectLocationOutput { + partnerId: string + partnerName: string + groupId: string + groupName: string + location: { + id: string + locationCode: string + locationName: string + fullAddress: string + state: boolean + } +} diff --git a/泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts b/泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts index 3e4e90c..0067e92 100644 --- a/泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts +++ b/泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts @@ -164,3 +164,64 @@ export interface UsAppLabelReprintInputVo { printerMac?: string printerAddress?: string } + +/** Label Report 周期(5-27 get-label-report) */ +export type UsAppLabelReportPeriod = '7d' | '30d' | '90d' | 'custom' + +/** Label Report 入参 */ +export interface UsAppLabelReportQueryInputVo { + locationId: string + period?: UsAppLabelReportPeriod + startDate?: string + endDate?: string + keyword?: string +} + +export interface UsAppLabelReportAppliedRangeDto { + period?: UsAppLabelReportPeriod | string + startDate?: string + endDate?: string + dayCount?: number + trendDescription?: string +} + +export interface UsAppLabelReportSummaryDto { + totalLabelsPrinted: number + totalLabelsPrintedPrevPeriod?: number + totalLabelsPrintedChangeRate: number + mostPrintedCategoryName?: string | null + mostPrintedCategoryCount: number + topProductName?: string | null + topProductCount: number + avgDailyPrints: number + avgDailyPrintsPrevPeriod?: number + avgDailyPrintsChangeRate: number +} + +export interface UsAppLabelReportCategoryRowDto { + categoryId?: string | null + categoryName: string + count: number +} + +export interface UsAppLabelReportTrendPointDto { + date: string + count: number +} + +export interface UsAppLabelReportTopProductDto { + productId?: string | null + productName: string + categoryName: string + totalPrinted: number + usagePercent: number +} + +/** Label Report 出参 */ +export interface UsAppLabelReportOutputDto { + appliedRange?: UsAppLabelReportAppliedRangeDto + summary: UsAppLabelReportSummaryDto + labelsByCategory: UsAppLabelReportCategoryRowDto[] + printVolumeTrend: UsAppLabelReportTrendPointDto[] + mostUsedProducts: UsAppLabelReportTopProductDto[] +} diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/appAdminRole.ts b/泰额版/Food Labeling Management App UniApp/src/utils/appAdminRole.ts new file mode 100644 index 0000000..da000a3 --- /dev/null +++ b/泰额版/Food Labeling Management App UniApp/src/utils/appAdminRole.ts @@ -0,0 +1,13 @@ +import type { UsAppMyProfileOutputDto } from '../services/usAppAuth' + +/** 与后端 ReportsRoleHelper.IsAdminRole 对齐(App 管理员级联选店) */ +export function isAppAdminUser(profile: UsAppMyProfileOutputDto | null | undefined): boolean { + if (!profile) return false + const code = (profile.primaryRoleCode ?? '').trim().toLowerCase() + if (code === 'admin') return true + const display = (profile.roleDisplay ?? '').trim().toLowerCase() + if (!display) return false + if (display.includes('administrator')) return true + if (display.includes('super admin')) return true + return false +} diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts b/泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts index dc25c99..a974186 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts @@ -56,6 +56,24 @@ export function toTscBarcodeSymbology (barcodeType: unknown): string { return map[key] ?? 'CODA' } +/** + * TSC/Gprinter CODABAR 常要求起止符;纯数字在部分机型上 BARCODE 指令会静默失败。 + * 与预览 JsBarcode 展示可不同,但能保证出纸。 + */ +export function formatBarcodeValueForTsc (value: unknown, barcodeType: unknown): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + const type = normalizeBarcodeType(barcodeType) + if (type !== 'CODABAR') return raw + const upper = raw.toUpperCase() + const hasStart = /^[ABCD]/.test(upper) + const hasStop = /[TNE*]$/.test(upper) + if (hasStart && hasStop) return upper + const body = raw.replace(/[^0-9\-$:/.+]/g, '') + if (!body) return raw + return `A${body}B` +} + export function toEscBarcodeTypeCode (barcodeType: unknown): number { const key = normalizeBarcodeType(barcodeType) const map: Record = { diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts b/泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts index 07c59a2..cbcf39d 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts @@ -65,6 +65,15 @@ export type CategoryVisualRender = | { mode: 'text'; text: string } | { mode: 'none' } +/** 手提端产品列表布局(可选,存于 buttonStyleJson.appList) */ +export type AppListLayoutStyle = { + gridColumns?: number + gapRpx?: number + cardPaddingRpx?: number + thumbAspectPercent?: number + panelPaddingRpx?: number +} + export type StoredCategoryButtonStyleV1 = { v: 1 appearances: AppearanceToken[] @@ -72,6 +81,7 @@ export type StoredCategoryButtonStyleV1 = { buttonBgColor?: string | null buttonTextColor?: string | null buttonImageUrl?: string | null + appList?: AppListLayoutStyle | null } export function serializeCategoryButtonStyleV1(input: { @@ -95,11 +105,15 @@ export function serializeCategoryButtonStyleV1(input: { /** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */ export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null { const raw = String(s ?? '').trim() - if (!raw.startsWith('[')) return null + if (!raw) return null try { const j = JSON.parse(raw) as unknown - if (!Array.isArray(j)) return null - return j.map((x) => (x == null ? '' : String(x))) + if (Array.isArray(j)) return j.map((x) => (x == null ? '' : String(x))) + if (j && typeof j === 'object') { + const values = (j as { values?: unknown }).values + if (Array.isArray(values)) return values.map((x) => (x == null ? '' : String(x))) + } + return null } catch { return null } @@ -170,6 +184,31 @@ export function serializeButtonAppearanceForApi(raw: unknown): string | null { return s } +function parseAppListLayout(raw: unknown): AppListLayoutStyle | null { + if (!raw || typeof raw !== 'object') return null + const o = raw as Record + const gridColumns = Number(o.gridColumns ?? o.GridColumns) + const gapRpx = Number(o.gapRpx ?? o.GapRpx) + const cardPaddingRpx = Number(o.cardPaddingRpx ?? o.CardPaddingRpx) + const thumbAspectPercent = Number(o.thumbAspectPercent ?? o.ThumbAspectPercent) + const panelPaddingRpx = Number(o.panelPaddingRpx ?? o.PanelPaddingRpx) + const layout: AppListLayoutStyle = {} + if (Number.isFinite(gridColumns) && gridColumns >= 1 && gridColumns <= 4) { + layout.gridColumns = Math.round(gridColumns) + } + if (Number.isFinite(gapRpx) && gapRpx >= 0) layout.gapRpx = Math.round(gapRpx) + if (Number.isFinite(cardPaddingRpx) && cardPaddingRpx >= 0) { + layout.cardPaddingRpx = Math.round(cardPaddingRpx) + } + if (Number.isFinite(thumbAspectPercent) && thumbAspectPercent > 0 && thumbAspectPercent <= 200) { + layout.thumbAspectPercent = Math.round(thumbAspectPercent) + } + if (Number.isFinite(panelPaddingRpx) && panelPaddingRpx >= 0) { + layout.panelPaddingRpx = Math.round(panelPaddingRpx) + } + return Object.keys(layout).length > 0 ? layout : null +} + export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null { const raw = (jsonStr ?? '').trim() if (!raw) return null @@ -185,12 +224,36 @@ export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null, buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null, buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null, + appList: parseAppListLayout(o.appList ?? o.AppList), } } catch { return null } } +const DEFAULT_APP_LIST_LAYOUT: Required = { + gridColumns: 2, + gapRpx: 12, + cardPaddingRpx: 10, + thumbAspectPercent: 75, + panelPaddingRpx: 16, +} + +/** 解析标签分类/产品分类上的 buttonStyleJson.appList,供手提列表页动态样式 */ +export function resolveAppListLayoutFromDto(row: { + buttonStyleJson?: string | null +}): Required { + const parsed = parseCategoryButtonStyleV1(row.buttonStyleJson) + const a = parsed?.appList + return { + gridColumns: a?.gridColumns ?? DEFAULT_APP_LIST_LAYOUT.gridColumns, + gapRpx: a?.gapRpx ?? DEFAULT_APP_LIST_LAYOUT.gapRpx, + cardPaddingRpx: a?.cardPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.cardPaddingRpx, + thumbAspectPercent: a?.thumbAspectPercent ?? DEFAULT_APP_LIST_LAYOUT.thumbAspectPercent, + panelPaddingRpx: a?.panelPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.panelPaddingRpx, + } +} + export type CategoryDtoLike = { buttonStyleJson?: string | null buttonAppearance?: unknown diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/emptyDisplay.ts b/泰额版/Food Labeling Management App UniApp/src/utils/emptyDisplay.ts new file mode 100644 index 0000000..04c8119 --- /dev/null +++ b/泰额版/Food Labeling Management App UniApp/src/utils/emptyDisplay.ts @@ -0,0 +1,12 @@ +/** Backend empty placeholder (US API may return this literal). */ +export const BACKEND_EMPTY_DISPLAY = '无' + +/** Map API empty sentinels to English UI text. */ +export function formatDisplayText ( + value: string | null | undefined, + fallback = 'None', +): string { + const s = String(value ?? '').trim() + if (!s || s === BACKEND_EMPTY_DISPLAY) return fallback + return s +} diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts b/泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts index 9386d1f..4e42fd5 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts @@ -235,7 +235,7 @@ function drawBarcodeLikePreview( let cursor = x + pad for (let i = 0; i < modules.length; i++) { if (modules[i] === 1) { - const rw = Math.max(0.7, moduleW * 0.86) + const rw = Math.max(0.7, moduleW * 0.72) ctx.fillRect(cursor, y + pad, rw, barH) } cursor += moduleW @@ -257,7 +257,7 @@ function drawBarcodeLikePreview( let cursorY = y + pad for (let i = 0; i < modules.length; i++) { if (modules[i] === 1) { - const rh = Math.max(0.7, moduleH * 0.86) + const rh = Math.max(0.7, moduleH * 0.72) ctx.fillRect(x + pad, cursorY, barW, rh) } cursorY += moduleH @@ -433,7 +433,8 @@ function runLabelPreviewCanvasDraw( const bw = Math.max(40, w || 120) const bh = Math.max(36, h || 96) const pad = 3 - const rightX = x + bw - pad + /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */ + const rightX = x + bw - 1 const maxY = y + bh - 2 const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16)) const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72))) @@ -739,14 +740,32 @@ export function renderLabelPreviewCanvasImageDataForPrint( /** * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。 */ +/** 调整 canvas :width/:height 后等待绘图缓冲区就绪,避免光栅导出高度不足导致底部条码/日期被裁切 */ +export async function settleAfterLabelCanvasResize (): Promise { + await new Promise((r) => setTimeout(r, 16)) + await new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + } else { + setTimeout(() => resolve(), 32) + } + }) + await new Promise((r) => setTimeout(r, 80)) +} + export function getLabelPrintRasterLayout( template: SystemLabelTemplate, maxWidthDots: number, - printDpi = 203 + printDpi = 203, + opts?: { contentHeightPx?: number }, ): { cw: number, ch: number, outW: number, outH: number, scale: number } { const unit = template.unit || 'inch' const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) - const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) + const fullH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) + const ch = + opts?.contentHeightPx != null && opts.contentHeightPx > 0 + ? Math.max(40, Math.round(opts.contentHeightPx)) + : fullH const designDpi = 96 const idealW = Math.round(cw * (printDpi / designDpi)) const cap = Math.max(8, Math.round(maxWidthDots || 576)) diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts new file mode 100644 index 0000000..74a28ad --- /dev/null +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts @@ -0,0 +1,25 @@ +/** 扫描/配对列表仅展示以下蓝牙名称(大小写不敏感,须完整匹配) */ +export const ALLOWED_BLUETOOTH_PRINTER_NAMES = [ + 'GP-D320FX-spp_A7FO', + 'Virtual BT Printer', +] as const + +const ALLOWED_BLUETOOTH_PRINTER_NAME_SET = new Set( + ALLOWED_BLUETOOTH_PRINTER_NAMES.map((name) => name.toLowerCase()), +) + +export function normalizeBluetoothPrinterName (name: string | undefined | null): string { + return String(name ?? '').trim() +} + +/** 仅允许白名单内的蓝牙打印机名称出现在连接列表 */ +export function isAllowedBluetoothPrinterName (name: string | undefined | null): boolean { + const normalized = normalizeBluetoothPrinterName(name) + if (!normalized) return false + return ALLOWED_BLUETOOTH_PRINTER_NAME_SET.has(normalized.toLowerCase()) +} + +/** 一体机虚拟蓝牙名(走整页光栅与预览一致,不走 native printTemplate) */ +export function isVirtualBtPrinterDeviceName (name: string | undefined | null): boolean { + return normalizeBluetoothPrinterName(name).toLowerCase() === 'virtual bt printer' +} diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts index 13ffc5b..892afea 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts @@ -3,6 +3,33 @@ import { printRunDiag } from './printRunDiagnostics' const DEFAULT_IMAGE_THRESHOLD = 180 +/** 去掉位图底部全白行(内置 ESC 光栅按图像高度走纸,否则会拖很长空白) */ +export function trimMonochromeImageBottomWhitespace ( + image: MonochromeImageData, + paddingRows = 10, +): MonochromeImageData { + const { width, height, pixels } = image + if (!width || !height || !pixels?.length) return image + let lastBlack = -1 + for (let y = height - 1; y >= 0; y--) { + for (let x = 0; x < width; x++) { + if (pixels[y * width + x]) { + lastBlack = y + break + } + } + if (lastBlack >= 0) break + } + if (lastBlack < 0) return image + const newH = Math.min(height, lastBlack + 1 + Math.max(0, paddingRows)) + if (newH >= height) return image + return { + width, + height: newH, + pixels: pixels.slice(0, width * newH), + } +} + function yieldToUi (): Promise { return new Promise((resolve) => { setTimeout(resolve, 0) diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts index f3b8836..d03913f 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts @@ -13,7 +13,7 @@ import { } from '../printerConnection' // @ts-ignore - js bridge module (app-plus only) import classicBluetooth from '../bluetoothTool.js' -import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' +import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace } from '../imageRaster' import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' @@ -28,7 +28,13 @@ import { getLabelPrintRasterLayout, renderLabelPreviewCanvasImageDataForPrint, renderLabelPreviewCanvasToTempPathForPrint, + settleAfterLabelCanvasResize, } from '../../labelPreview/renderLabelPreviewCanvas' +import { + ensureTemplateHeightCoversElements, + templateContentHeightPx, + templateSizeToMillimeters, +} from '../templatePhysicalMm' import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' import { printRunDiag } from '../printRunDiagnostics' import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' @@ -853,9 +859,10 @@ export async function printImageForCurrentPrinter ( } } + const rasterForPrint = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) let data: number[] = [] if (rasterDriver.protocol === 'esc') { - data = buildEscPosImageData(raster, options) + data = buildEscPosImageData(rasterForPrint, options) } else { data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203) } @@ -964,7 +971,8 @@ export async function printImageDataForCurrentPrinter ( ): Promise { const driver = getCurrentPrinterDriver() const rasterDriver = resolveRasterPrintDriver(driver) - const raster = rasterizeImageData(imageData, options) + let raster = rasterizeImageData(imageData, options) + raster = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) if (onProgress) onProgress(5) const data = rasterDriver.protocol === 'esc' ? buildEscPosImageData(raster, options) @@ -1000,6 +1008,20 @@ export type SystemTemplatePrintCanvasRasterOptions = { }) => void | Promise } +function templateForRasterPrint (template: SystemLabelTemplate): SystemLabelTemplate { + return ensureTemplateHeightCoversElements(template) +} + +/** 内置 ESC 光栅:按图像高度走纸,裁掉底部全白行(布局已按 contentHeight 时仍可去掉少量留白) */ +function finalizeRasterForEscPrint ( + raster: { width: number; height: number; pixels: number[] }, + protocol: string, + options: PrintImageOptions, +) { + if (protocol !== 'esc' || options.useContentHeight === false) return raster + return trimMonochromeImageBottomWhitespace(raster, 8) +} + export async function printSystemTemplateForCurrentPrinter ( template: SystemLabelTemplate, data: LabelTemplateData = {}, @@ -1019,21 +1041,49 @@ export async function printSystemTemplateForCurrentPrinter ( if (canvasRaster && !bypassCanvasRasterForQr) { if (onProgress) onProgress(1) + const templateForDraw = templateForRasterPrint(template) const maxDots = rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) - const layout = getLabelPrintRasterLayout(template, maxDots, rasterDriver.imageDpi || 203) + const escUseContentH = rasterDriver.protocol === 'esc' + const contentHpx = escUseContentH ? templateContentHeightPx(templateForDraw) : undefined + const layout = getLabelPrintRasterLayout( + templateForDraw, + maxDots, + rasterDriver.imageDpi || 203, + contentHpx != null ? { contentHeightPx: contentHpx } : undefined, + ) + if (escUseContentH) { + printRunDiag('raster_layout_content_height', { + contentHpx, + layoutCh: layout.ch, + outW: layout.outW, + outH: layout.outH, + }) + } if (onProgress) onProgress(4) if (canvasRaster.applyLayout) { - await canvasRaster.applyLayout(layout) + await Promise.resolve(canvasRaster.applyLayout(layout)) } if (onProgress) onProgress(7) - await new Promise((r) => setTimeout(r, 50)) + await settleAfterLabelCanvasResize() if (onProgress) onProgress(9) const printOpts: PrintImageOptions = { printQty: options.printQty || 1, - clearTopRasterRows: 1, + clearTopRasterRows: 0, targetWidthDots: layout.outW, targetHeightDots: layout.outH, + useContentHeight: escUseContentH, + cutBetweenCopies: true, + widthMm: templateSizeToMillimeters( + templateForDraw.unit, + Number(templateForDraw.width) || 0, + Number(templateForDraw.height) || 0, + ).widthMm, + heightMm: templateSizeToMillimeters( + templateForDraw.unit, + Number(templateForDraw.width) || 0, + Number(templateForDraw.height) || 0, + ).heightMm, } const mapRasterProgress = onProgress ? (p: number) => { @@ -1046,7 +1096,7 @@ export async function printSystemTemplateForCurrentPrinter ( const imageData = await renderLabelPreviewCanvasImageDataForPrint( canvasRaster.canvasId, canvasRaster.componentInstance, - template, + templateForDraw, layout, ) if (onProgress) onProgress(12) @@ -1059,7 +1109,7 @@ export async function printSystemTemplateForCurrentPrinter ( const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint( canvasRaster.canvasId, canvasRaster.componentInstance, - template, + templateForDraw, layout, ) if (onProgress) onProgress(12) diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts index 9f4ea65..395b341 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts @@ -7,6 +7,7 @@ import type { SystemLabelTemplate, SystemTemplateElementBase, } from './types/printer' +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' import { applyTemplateData } from './templateRenderer' function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { @@ -19,6 +20,74 @@ function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): bo return false } +/** 原生营养表:严格使用模板 height,打印时再按 dpi 缩放;勿在 JS 侧抬高 height 以免压住下方 DATE */ +function prepareNutritionElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { + const cfg = { ...(el.config || {}) } as Record + const x = Math.max(0, Number(el.x) || 0) + const y = Math.max(0, Number(el.y) || 0) + const w = Math.max(40, Number(el.width) || 0) + const h = Math.max(40, Number(el.height) || 72) + cfg.nativePrintHeight = h + cfg.NativePrintHeight = h + cfg.nativePadLeft = Number(cfg.nativePadLeft ?? 2) + cfg.nativePadRight = Number(cfg.nativePadRight ?? 4) + cfg.nutritionTitleBold = false + cfg.nutritionBodyBold = false + return { + ...el, + x, + y, + width: w, + height: h, + config: cfg, + } +} + +/** 原生打印按 dpi 放大营养表后,保证 DATE/TIME/BARCODE 在营养表底边之下(与预览留白一致) */ +function resolveNativeLayoutCollisions ( + elements: SystemTemplateElementBase[], +): SystemTemplateElementBase[] { + const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION') + if (!nutrition) return elements + const nutY = Number(nutrition.y) || 0 + const nutH = Number(nutrition.height) || 0 + /** 设计 px 最小间距;预览里常见 8–12px */ + const minGapPx = 10 + const reservedBottom = nutY + nutH + minGapPx + return elements.map((el) => { + if (el.id === nutrition.id) return el + const y = Number(el.y) || 0 + if (y < reservedBottom && y >= nutY - 1) { + return { ...el, y: reservedBottom } + } + return el + }) +} + +function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { + const cfg = { ...(el.config || {}) } as Record + const barcodeType = normalizeBarcodeType(cfg.barcodeType ?? cfg.BarcodeType) + const raw = String( + cfg.data ?? cfg.Data ?? cfg.value ?? cfg.Value ?? cfg.barcodeData ?? cfg.BarcodeData ?? '' + ).trim() + const data = formatBarcodeValueForTsc(raw, barcodeType) + /** 人读数字与预览一致(1234),编码串(A1234B)仅给原生画条用 */ + cfg.barcodeDisplayText = raw + cfg.BarcodeDisplayText = raw + if (data) { + cfg.data = data + cfg.Data = data + cfg.value = data + cfg.Value = data + } + /** CODABAR 在 Virtual BT / 佳博上 TSC BARCODE 易失败,改走与预览一致的位图条 */ + if (barcodeType === 'CODABAR') { + cfg.nativeBarcodeBitmap = true + cfg.NativeBarcodeBitmap = true + } + return { ...el, config: cfg } +} + /** * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致), * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。 @@ -60,6 +129,7 @@ export function normalizeTemplateForNativeFastJob ( } } + const extras: SystemTemplateElementBase[] = [] const elements = (template.elements || []).map((el) => { const type = String(el.type || '').toUpperCase() const config = (el.config || {}) as Record @@ -119,9 +189,41 @@ export function normalizeTemplateForNativeFastJob ( config: { ...config, text, nativeSourceType: type }, } } + if (type === 'NUTRITION') { + return prepareNutritionElementForNativePrint(el) + } + if (type === 'BARCODE') { + const prepared = prepareBarcodeElementForNativePrint(el) + const pcfg = { ...(prepared.config || {}) } as Record + const human = String(pcfg.barcodeDisplayText ?? pcfg.BarcodeDisplayText ?? '').trim() + const showHuman = String(pcfg.showText ?? pcfg.ShowText ?? 'true').toLowerCase() !== 'false' + if (human && showHuman) { + pcfg.showText = false + pcfg.ShowText = false + extras.push({ + id: `${String(prepared.id || 'barcode')}_label`, + type: 'TEXT_STATIC', + x: Number(prepared.x) || 0, + y: (Number(prepared.y) || 0) + (Number(prepared.height) || 40) + 6, + width: Number(prepared.width) || 140, + height: 20, + rotation: prepared.rotation ?? 'horizontal', + border: 'none', + config: { + text: human, + fontSize: 12, + textAlign: 'center', + TextAlign: 'center', + forceRasterText: true, + }, + } as SystemTemplateElementBase) + } + return { ...prepared, config: pcfg } + } return el }) - return { ...template, elements } + const merged = resolveNativeLayoutCollisions([...elements, ...extras]) + return { ...template, elements: merged } } /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */ diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts index d37348d..794cf25 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts @@ -40,5 +40,5 @@ export function getPrintRunDiagnosticsText (): string { export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string { const full = getPrintRunDiagnosticsText() if (full.length <= maxChars) return full - return `...(省略开头 ${full.length - maxChars} 字)\n\n${full.slice(-maxChars)}` + return `...(start omitted, ${full.length - maxChars} chars)\n\n${full.slice(-maxChars)}` } diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts index c55e454..541fd90 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts @@ -124,14 +124,27 @@ function appendBoxLine (out: number[], text = '', width = 32) { appendLine(out, `| ${value} |`) } -function createEscDocument (builder: (out: number[]) => void): number[] { +/** GS V:不支持切刀的机芯通常会忽略,不阻断后续打印 */ +function appendEscCut (out: number[], mode: 'partial' | 'full' = 'partial') { + if (mode === 'full') { + out.push(0x1d, 0x56, 0x00) + } else { + out.push(0x1d, 0x56, 0x01) + } +} + +function createEscDocument ( + builder: (out: number[]) => void, + endOpts: { feedLines?: number; cut?: 'none' | 'partial' | 'full' } = {}, +): number[] { const out: number[] = [] out.push(0x1b, 0x40) out.push(0x1b, 0x74, 16) builder(out) - out.push(0x1b, 0x64, 0x04) - // 打印完成后执行切刀(GS V 0):适配当前内置小票机,避免长纸不断。 - out.push(0x1d, 0x56, 0x00) + const feed = Math.max(0, Math.min(8, Math.round(endOpts.feedLines ?? 4))) + if (feed > 0) out.push(0x1b, 0x64, feed) + const cut = endOpts.cut ?? 'partial' + if (cut !== 'none') appendEscCut(out, cut === 'full' ? 'full' : 'partial') return out } @@ -242,14 +255,22 @@ export function buildEscPosImageData ( options: PrintImageOptions = {} ): number[] { const printQty = Math.max(1, Math.round(options.printQty || 1)) - return createEscDocument((out) => { - for (let i = 0; i < printQty; i++) { - appendAlign(out, 1) - appendRasterImage(out, image) - appendLine(out) - appendLine(out) + const cutBetween = options.cutBetweenCopies !== false + const out: number[] = [] + out.push(0x1b, 0x40) + out.push(0x1b, 0x74, 16) + for (let i = 0; i < printQty; i++) { + appendAlign(out, 1) + appendRasterImage(out, image) + out.push(0x1b, 0x64, 1) + const isLast = i >= printQty - 1 + if (!isLast && cutBetween) { + appendEscCut(out, 'partial') } - }) + } + out.push(0x1b, 0x64, 2) + if (cutBetween) appendEscCut(out, 'partial') + return out } export function buildEscPosTemplateData ( diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts index 87b6d36..38c8d88 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts @@ -1,4 +1,4 @@ -import { normalizeBarcodeType } from '../barcodeFormat' +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' import { storedValueLooksLikeImagePath } from '../resolveMediaUrl' import { createImageBitmapPatch, @@ -486,9 +486,9 @@ function buildTscTemplate ( } if (type === 'BARCODE') { - const value = resolveElementDataValue(element, data) - if (!value) return const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology) + if (!value) return const rotation = resolveRotation( element.rotation || getConfigString(config, ['orientation'], 'horizontal') ) @@ -612,7 +612,8 @@ function buildEscTemplate ( } if (type === 'BARCODE') { - const value = resolveElementDataValue(element, data) + const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology) if (!value) return items.push({ type: 'barcode', diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts index e8f4cc1..5b1f2e0 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts @@ -2,14 +2,105 @@ * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm), * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。 */ -import type { SystemLabelTemplate } from './types/printer' +import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer' const DESIGN_DPI = 96 +const PX_PER_INCH = 96 +const PX_PER_CM = 37.8 + +function toCanvasPx (value: number, unit: string): number { + const u = String(unit || 'inch').toLowerCase() + if (u === 'mm') return (value / 25.4) * PX_PER_INCH + if (u === 'cm') return value * PX_PER_CM + if (u === 'px') return value + return value * PX_PER_INCH +} + +function fromCanvasPx (px: number, unit: string): number { + const u = String(unit || 'inch').toLowerCase() + if (u === 'mm') return (px * 25.4) / PX_PER_INCH + if (u === 'cm') return px / PX_PER_CM + if (u === 'px') return px + return px / PX_PER_INCH +} + +function roundTemplateDim (value: number, unit: string): number { + const u = String(unit || 'inch').toLowerCase() + if (u === 'px') return Math.max(1, Math.round(value)) + if (u === 'mm' || u === 'cm') return Math.round(value * 10) / 10 + return Math.round(value * 1000) / 1000 +} + +function elementBottomPx (el: SystemTemplateElementBase): number { + const y = Number(el.y) || 0 + const h = Math.max(0, Number(el.height) || 0) + let bottom = y + h + const type = String(el.type || '').toUpperCase() + const cfg = (el.config || {}) as Record + if (type === 'BARCODE') { + const showText = String(cfg.showText ?? cfg.ShowText ?? 'true').toLowerCase() !== 'false' + if (showText) bottom += 28 + else bottom += 4 + } + if (type === 'QRCODE') bottom += 4 + return bottom +} + +/** + * 原生 printTemplate 的 SIZE 高度仅取自模板根 height,不会按元素自动增高; + * 条码/日期贴在底部时易被裁掉。与 systemTemplateAdapter.buildTscTemplate 增高逻辑对齐。 + */ +/** 设计 px:元素最底边 + 留白,且不超过模板根 height(用于光栅出纸高度,避免整纸 5cm 空白) */ +export function templateContentHeightPx ( + template: Pick, + extraBottomPx = 14, +): number { + const unit = String(template.unit || 'inch') + const canvasH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 0, unit))) + const elements = template.elements || [] + if (!elements.length) return canvasH + let maxBottom = 0 + for (const el of elements) { + maxBottom = Math.max(maxBottom, elementBottomPx(el)) + } + return Math.max(40, Math.min(canvasH, maxBottom + extraBottomPx)) +} + +export function ensureTemplateHeightCoversElements ( + template: SystemLabelTemplate, + extraBottomPx = 14 +): SystemLabelTemplate { + const unit = String(template.unit || 'inch') + const elements = template.elements || [] + if (!elements.length) return template + let maxBottom = 0 + for (const el of elements) { + maxBottom = Math.max(maxBottom, elementBottomPx(el)) + } + const currentHpx = toCanvasPx(Number(template.height) || 0, unit) + const neededPx = maxBottom + extraBottomPx + if (neededPx <= currentHpx + 1) return template + return { + ...template, + height: roundTemplateDim(fromCanvasPx(neededPx, unit), unit), + } +} /** 常见 4″ 标签机安全上限(mm),略放宽 */ const NATIVE_FAST_MAX_WIDTH_MM = 112 const NATIVE_FAST_MAX_HEIGHT_MM = 320 +/** 打印/预览统一:模板根 width/height + unit → 物理毫米(与 NativeTemplateCommandBuilder.toMillimeter 一致) */ +export function getTemplatePhysicalSizeMm ( + template: Pick +): { widthMm: number; heightMm: number; unit: string } { + const unit = String(template.unit || 'inch') + const w = Number(template.width) || 0 + const h = Number(template.height) || 0 + const { widthMm, heightMm } = templateSizeToMillimeters(unit, w, h) + return { widthMm, heightMm, unit } +} + export function templateSizeToMillimeters ( unit: string | undefined, width: number, diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts b/泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts index de34aa4..30abf07 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts @@ -26,6 +26,12 @@ export interface PrintImageOptions { maxWidthDots?: number targetWidthDots?: number targetHeightDots?: number + /** @deprecated 光栅已按内容高度裁切;保留兼容 */ + labelRasterFixedHeight?: boolean + /** 多份打印时:每打完一张尝试半切(GS V 1);不支持则机芯忽略并继续下一张 */ + cutBetweenCopies?: boolean + /** 光栅按元素底边高度布局,而非整模板 height */ + useContentHeight?: boolean widthMm?: number heightMm?: number x?: number diff --git a/泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts b/泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts index 2f4a96c..8474fe5 100644 --- a/泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts +++ b/泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts @@ -14,9 +14,7 @@ import { setLastLabelPrintJobPayload, } from './labelPreview/buildLabelPrintPayload' import { getCurrentStoreId } from './stores' -import { - ensureNativeClassicTransportIfPossible, -} from './print/printerConnection' +import { ensureNativeClassicTransportIfPossible } from './print/printerConnection' import { hydrateSystemTemplateImagesForPrint, resetHydrateImageDebugRecords, @@ -25,7 +23,10 @@ import { normalizeTemplateForNativeFastJob, templateHasUnsupportedNativeFastElements, } from './print/nativeTemplateElementSupport' -import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm' +import { + ensureTemplateHeightCoversElements, + isTemplateWithinNativeFastPrintBounds, +} from './print/templatePhysicalMm' import type { LabelTemplateData, SystemLabelTemplate, @@ -357,10 +358,11 @@ async function printReprintTemplateWithPreviewStrategy ( await ensureNativeClassicTransportIfPossible() const templateData = labelTemplateDataForSnapshotReprint() const printInputJson: Record = {} - const tmplForNative = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) + const tmplSized = ensureTemplateHeightCoversElements(tmpl) + const tmplForNative = normalizeTemplateForNativeFastJob(tmplSized, printInputJson as any) const useNative = canPrintCurrentLabelViaNativeFastJob() - && isTemplateWithinNativeFastPrintBounds(tmpl) + && isTemplateWithinNativeFastPrintBounds(tmplSized) && !templateHasUnsupportedNativeFastElements(tmplForNative) const printQty = options.printQty ?? 1 @@ -384,7 +386,7 @@ async function printReprintTemplateWithPreviewStrategy ( } await printSystemTemplateForCurrentPrinter( - tmpl, + tmplSized, templateData, { printQty, canvasRaster: options.canvasRaster }, options.onProgress, diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development index be2c9d4..42479eb 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development @@ -1,7 +1,9 @@ # 端口号 -VITE_PORT=17001 +VITE_PORT=3100 VITE_BASE=/ +# vue-router 模式:hash 刷新不依赖服务器 try_files +VITE_ROUTER_HISTORY=hash # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 VITE_NITRO_MOCK=false # 是否打开 devtools,true 为打开,false 为关闭 @@ -9,9 +11,9 @@ VITE_DEVTOOLS=false # 是否注入全局loading VITE_INJECT_APP_LOADING=true -# 后台请求路径 具体在vite.config.mts配置代理 -VITE_GLOB_API_URL="/dev-api" -VITE_APP_URL="http://flus-test.3ffoodsafety.com/api/app" +# 本地开发:直连线上 API(不走 Vite 代理;VITE_GLOB_API_URL 为绝对地址时 proxy 不启用) +# 线上:http://saas-test.3ffoodsafety.com/api/app +VITE_GLOB_API_URL=http://saas-test.3ffoodsafety.com/api/app # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) VITE_GLOB_ENABLE_ENCRYPT=false diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production index 44b36e8..a3c9ce7 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production @@ -7,7 +7,7 @@ VITE_COMPRESS=gzip VITE_PWA=false # vue-router 的模式 -VITE_ROUTER_HISTORY=history +VITE_ROUTER_HISTORY=hash # 是否注入全局loading VITE_INJECT_APP_LOADING=true @@ -15,8 +15,8 @@ VITE_INJECT_APP_LOADING=true # 打包后是否生成dist.zip VITE_ARCHIVER=true -# 后端接口地址(ABP 动态 API:/api/app) -VITE_GLOB_API_URL=http://flus-test.3ffoodsafety.com/api/app +# 生产部署在 saas-test 同域时用相对路径,避免跨域;若前后端不同域再改为完整 URL +VITE_GLOB_API_URL=/api/app # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) VITE_GLOB_ENABLE_ENCRYPT=false diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test index f66a206..41548e9 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test @@ -10,7 +10,7 @@ VITE_COMPRESS=gzip VITE_PWA=false # vue-router 的模式 -VITE_ROUTER_HISTORY=history +VITE_ROUTER_HISTORY=hash # 是否注入全局loading VITE_INJECT_APP_LOADING=true diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json index 33358ad..8f71d92 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json @@ -61,6 +61,7 @@ }, "devDependencies": { "@types/crypto-js": "^4.2.2", - "@types/lodash-es": "^4.17.12" + "@types/lodash-es": "^4.17.12", + "cssnano": "catalog:" } } diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts index f3d9e9d..fdfceda 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts @@ -19,6 +19,7 @@ export interface RoleDto { orderNum?: number | null; creationTime?: string | null; accessPermissionCodes?: string[] | null; + menuPermissionKeys?: string[] | null; } export interface RoleGetListQuery extends FlPageQuery { @@ -145,6 +146,8 @@ export interface TeamMemberDto { locationIds?: string[] | null; locations?: string[] | null; state?: boolean | null; + useCustomMenuPermissions?: boolean | null; + menuPermissionKeys?: string[] | null; } export interface TeamMemberGetListQuery extends FlPageQuery { @@ -164,6 +167,8 @@ export interface TeamMemberCreateInput { regionIds?: string[]; locationIds: string[]; state: boolean; + useCustomMenuPermissions?: boolean; + menuPermissionKeys?: string[]; } export interface TeamMemberUpdateInput { @@ -177,4 +182,6 @@ export interface TeamMemberUpdateInput { regionIds?: string[]; locationIds: string[]; state: boolean; + useCustomMenuPermissions?: boolean; + menuPermissionKeys?: string[]; } diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts index 24c8147..6970b1d 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts @@ -19,7 +19,11 @@ export { locationSupportGet, locationSupportUpdate, } from './location-support'; -export * from './lookups'; +export { + groupList as lookupGroupList, + locationList as lookupLocationList, + productList as lookupProductList, +} from './lookups'; export * from './partner'; export * from './product'; export * from './product-category'; diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts index 1ba069e..186a7d8 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts @@ -1,10 +1,10 @@ import type { - FlPagedResult, ProductCategoryCreateInput, ProductCategoryDto, ProductCategoryGetListQuery, ProductCategoryUpdateInput, } from './product-types'; +import type { FlPagedResult } from './types'; import { requestClient } from '#/api/request'; diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts index 68024b7..92c8386 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts @@ -1,5 +1,4 @@ import type { - FlPagedResult, ProductBatchImportResultDto, ProductCreateInput, ProductDto, @@ -7,6 +6,7 @@ import type { ProductGetListQuery, ProductUpdateInput, } from './product-types'; +import type { FlPagedResult } from './types'; import { ContentTypeEnum } from '#/api/helper'; import { requestClient } from '#/api/request'; diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts index 6fb9d62..dc203d5 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts @@ -1,5 +1,4 @@ import type { - FlPagedResult, LabelReportDataDto, LabelReportQuery, ReportsPrintLogItemDto, @@ -7,6 +6,7 @@ import type { ReportsTemplatePrintStatItemDto, ReportsTemplatePrintStatQuery, } from './reports-types'; +import type { FlPagedResult } from './types'; import { requestClient } from '#/api/request'; diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts index 6c2d645..f7b4340 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts @@ -1,8 +1,3 @@ -import { useAppConfig } from '@vben/hooks'; -import { preferences } from '@vben/preferences'; - -import { unwrapAbpResponse } from '#/api/th/abp-unwrap'; - /** 绑定门店(与 ThAppLoginOutputDto.locations 一致) */ export interface ThAppBoundLocationDto { id: string; @@ -28,13 +23,13 @@ export interface ThAppLoginOutputDto { locations: ThAppBoundLocationDto[]; } -const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD); - /** 防止重复提交导致前一个请求被 Abort、后一个已在 Network 成功但 UI 仍报错 */ let loginInFlight: Promise | null = null; function getApiBase(): string { - const raw = (import.meta.env.VITE_GLOB_API_URL as string | undefined) ?? '/dev-api'; + const raw = + (import.meta.env.VITE_GLOB_API_URL as string | undefined) ?? + 'http://saas-test.3ffoodsafety.com/api/app'; return raw.replace(/^"|"$/g, '').replace(/\/$/, ''); } @@ -68,88 +63,56 @@ function normalizeLocationList(raw: unknown): ThAppBoundLocationDto[] { return arr.map((x) => normalizeLocation(x as Record)); } -/** - * 使用 XHR 登录:dev 代理下 fetch/axios 常出现「Network 已有 body 但 JS 一直等连接结束」。 - * XHR onload 在收齐 responseText 后即触发,不依赖 fetch 的 body 流结束。 - */ -function thAppLoginXhr(input: ThAppLoginInput): Promise { - const url = `${getApiBase()}/th-app-auth/login`; - const language = preferences.app.locale.replace('-', '_'); - const body = JSON.stringify({ - tenantId: input.tenantId, - email: input.email.trim(), - password: input.password, - ...(input.uuid ? { uuid: input.uuid } : {}), - ...(input.code != null && input.code !== '' ? { code: input.code } : {}), - }); - - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open('POST', url, true); - xhr.timeout = 60_000; - xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8'); - xhr.setRequestHeader('Accept', 'application/json'); - xhr.setRequestHeader('Accept-Language', language); - xhr.setRequestHeader('Content-Language', language); - if (clientId) { - xhr.setRequestHeader('ClientID', clientId); - } - - xhr.onload = () => { - try { - const text = xhr.responseText ?? ''; - const json = text ? JSON.parse(text) : null; - if (xhr.status < 200 || xhr.status >= 300) { - try { - unwrapAbpResponse(json); - } catch (e) { - reject(e instanceof Error ? e : new Error(`登录失败 HTTP ${xhr.status}`)); - return; - } - reject(new Error(`登录失败 HTTP ${xhr.status}`)); - return; - } - const data = unwrapAbpResponse(json); - resolve(normalizeLoginOutput(data)); - } catch (e) { - reject( - e instanceof Error - ? e - : new Error('登录响应解析失败,请查看 Network 中 login 的 Response'), - ); - } - }; - - xhr.onerror = () => { - reject( - new Error( - '登录网络异常:请确认 dev 服务已启动且代理地址正确(/dev-api → saas-test)', - ), - ); - }; - - xhr.ontimeout = () => { - reject( - new Error( - '登录请求超时:若 Network 中已有 token 响应,请刷新页面后只点一次登录', - ), - ); - }; - - xhr.send(body); - }); +function loginNetworkError(): Error { + return new Error( + `登录网络异常:无法连接 ${getApiBase()},请检查网络、后端是否可用,以及是否已配置 CORS 允许本地前端域名`, + ); } -/** POST /api/app/th-app-auth/login(匿名) */ +/** + * POST /api/app/th-app-auth/login(匿名) + * 与租户下拉一致,走 requestClient 直连 VITE_GLOB_API_URL(不再使用 XHR + dev 代理) + */ export async function thAppLogin( input: ThAppLoginInput, ): Promise { if (loginInFlight) { return loginInFlight; } - loginInFlight = thAppLoginXhr(input).finally(() => { + + loginInFlight = (async () => { + const { requestClient } = await import('#/api/request'); + try { + const raw = await requestClient.post( + 'th-app-auth/login', + { + tenantId: input.tenantId, + email: input.email.trim(), + password: input.password, + ...(input.uuid ? { uuid: input.uuid } : {}), + ...(input.code != null && input.code !== '' + ? { code: input.code } + : {}), + }, + { + errorMessageMode: 'none', + successMessageMode: 'none', + headers: { + __tenant: input.tenantId, + }, + }, + ); + return normalizeLoginOutput(raw); + } catch (error) { + if (error instanceof Error && error.message.trim()) { + throw error; + } + throw loginNetworkError(); + } + })().finally(() => { loginInFlight = null; }); + return loginInFlight; } diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json index f3b91c7..63dfc2d 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json @@ -19,7 +19,9 @@ "specifiedCount": "{count} location(s)", "codeOptional": "Leave empty to auto-generate", "staticDemoBanner": "Static demo mode: no API calls. Set FOOD_LABELING_STATIC_ONLY to false in shared/static-mode.ts when integrating APIs.", - "staticDemoAction": "Static demo: action simulated (no API call)" + "staticDemoAction": "Static demo: action simulated (no API call)", + "yes": "Yes", + "no": "No" }, "labeling": { "root": "Labeling", @@ -244,6 +246,60 @@ } } }, + "platform": { + "section": "Platform", + "tenants": "SAAS Companies", + "saasBanner": "Platform admin: provision SAAS companies (separate DB tenants), assign company-level menus, and create company admin accounts.", + "addCompany": "Add Company", + "editCompany": "Edit Company", + "companyName": "Company Name", + "companyCode": "Company Code", + "logoUrl": "Logo URL", + "logo": "Company Logo", + "logoUpload": "Upload Logo", + "logoUploadImageOnly": "Images only", + "logoUploadMaxSize": "Image must be under 2MB", + "contactName": "Contact", + "address": "Address", + "tenantAdmin": "Company Admin", + "menuPermissions": "Menu Permissions", + "configureMenus": "Configure Menus", + "configureMenusHint": "Select menus available to \"{name}\". Roles and users cannot exceed this set.", + "menuSaved": "Company menu permissions saved", + "manageTenantAdmin": "Company Admin · {name}", + "tenantAdminHint": "Company admins manage regions, locations, users and roles within assigned menus.", + "initialAdminSection": "Initial Company Admin", + "enterAsAdmin": "Enter as Admin", + "backToPlatform": "Back to Platform", + "deleteCompanyConfirm": "Delete this company (tenant)? Demo only until API is wired." + }, + "saas": { + "menu": { + "dashboard": "Overview", + "analytics": "Home", + "labeling": "Labeling", + "labels": "Labels", + "labelCategories": "Label Categories", + "labelTypes": "Label Types", + "labelTemplates": "Label Templates", + "multipleOptions": "Multiple Options", + "modules": "Modules", + "training": "Training", + "alerts": "Alerts", + "tasks": "Tasks", + "foodWaste": "Food Waste", + "eLabel": "E-Label", + "management": "Management", + "accountManagement": "Account Management", + "menuManagement": "Menu Management", + "devices": "Devices", + "reports": "Reports", + "invoices": "Invoices", + "qrCodes": "QR Codes", + "support": "Support", + "api": "API Settings" + } + }, "management": { "section": "Management", "locationManager": "Location Manager", @@ -293,7 +349,13 @@ "permEditSettings": "Edit Settings", "permManageProducts": "Manage Products", "permViewReports": "View Reports", - "permApproveBatches": "Approve Batches" + "permApproveBatches": "Approve Batches", + "menuPermissions": "Menu Permissions", + "useCustomMenus": "Custom Menus", + "userMenuOverride": "User Menus", + "inheritRoleMenus": "Inherit role", + "tenantAdminBanner": "Managing as company admin for \"{company}\". Menu permissions cannot exceed company provisioned menus.", + "platformOrTenantHint": "Tenant org: Roles → Partner companies → Regions → Locations → Team members. Provision SAAS companies under Platform > SAAS Companies." }, "menuManagement": { "tabProducts": "Products", diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json index b1967b5..ef85b83 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json @@ -19,7 +19,9 @@ "specifiedCount": "指定 {count} 个门店", "codeOptional": "留空则由后端生成", "staticDemoBanner": "当前为静态演示模式,未请求后端接口。联调时将 shared/static-mode.ts 中 FOOD_LABELING_STATIC_ONLY 改为 false。", - "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)" + "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)", + "yes": "是", + "no": "否" }, "labeling": { "root": "标签管理", @@ -244,6 +246,60 @@ } } }, + "platform": { + "section": "平台管理", + "tenants": "SAAS 公司", + "saasBanner": "平台管理员:在此开通 SAAS 公司(独立库租户)、配置公司级菜单,并创建公司管理员账号。公司管理员登录后可在「账户管理」中维护区域、门店与成员。", + "addCompany": "开通公司", + "editCompany": "编辑公司", + "companyName": "公司名称", + "companyCode": "公司编码", + "logoUrl": "Logo 地址", + "logo": "公司 Logo", + "logoUpload": "上传 Logo", + "logoUploadImageOnly": "仅支持图片文件", + "logoUploadMaxSize": "图片不能超过 2MB", + "contactName": "联系人", + "address": "地址", + "tenantAdmin": "公司管理员", + "menuPermissions": "菜单权限", + "configureMenus": "配置公司菜单", + "configureMenusHint": "为「{name}」勾选可使用的系统菜单(公司内角色与用户权限不能超过此范围)。", + "menuSaved": "公司菜单权限已保存", + "manageTenantAdmin": "管理公司管理员 · {name}", + "tenantAdminHint": "公司管理员拥有该公司下区域、门店、用户与角色配置权限(在其公司菜单范围内)。", + "initialAdminSection": "首任公司管理员", + "enterAsAdmin": "进入该公司", + "backToPlatform": "返回平台管理", + "deleteCompanyConfirm": "确认删除该公司(租户)吗?此操作仅演示,联调后走后端接口。" + }, + "saas": { + "menu": { + "dashboard": "概览", + "analytics": "首页概览", + "labeling": "标签管理", + "labels": "标签", + "labelCategories": "标签分类", + "labelTypes": "标签类型", + "labelTemplates": "标签模板", + "multipleOptions": "多选选项集", + "modules": "业务模块", + "training": "培训", + "alerts": "告警", + "tasks": "任务", + "foodWaste": "食物浪费", + "eLabel": "电子标签", + "management": "管理", + "accountManagement": "账户管理", + "menuManagement": "菜单管理", + "devices": "设备", + "reports": "报表", + "invoices": "发票", + "qrCodes": "二维码", + "support": "支持", + "api": "API 设置" + } + }, "management": { "section": "管理", "locationManager": "门店管理", @@ -293,7 +349,13 @@ "permEditSettings": "编辑设置", "permManageProducts": "管理产品", "permViewReports": "查看报表", - "permApproveBatches": "审批批次" + "permApproveBatches": "审批批次", + "menuPermissions": "菜单权限", + "useCustomMenus": "自定义菜单", + "userMenuOverride": "用户菜单", + "inheritRoleMenus": "继承角色", + "tenantAdminBanner": "当前以「{company}」公司管理员身份配置:可管理角色、业务公司、区域、门店与成员;菜单权限不能超过公司已开通范围。", + "platformOrTenantHint": "租户内组织架构:角色 → 业务公司(Partner) → 区域 → 门店 → 团队成员。平台开通 SAAS 公司请前往「平台管理 > SAAS 公司」。" }, "menuManagement": { "tabProducts": "产品", diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts index 94197d1..442bc4b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts @@ -215,6 +215,28 @@ export const localMenuList: RouteRecordStringComponent[] = [ }, { meta: { + icon: 'lucide:cloud-cog', + order: 5, + title: 'foodLabeling.platform.section', + }, + name: 'FoodLabelingPlatform', + path: '/platform', + redirect: '/platform/tenants', + children: [ + { + name: 'FoodLabelingPlatformTenants', + path: '/platform/tenants', + component: '/food-labeling/platform/tenants/index', + meta: { + icon: 'lucide:building', + title: 'foodLabeling.platform.tenants', + ...managementRouteMeta, + }, + }, + ], + }, + { + meta: { icon: 'lucide:building-2', order: 20, title: 'foodLabeling.management.section', @@ -224,15 +246,6 @@ export const localMenuList: RouteRecordStringComponent[] = [ redirect: '/account-management', children: [ { - name: 'FoodLabelingLocationManager', - path: '/location-manager', - redirect: { path: '/account-management', query: { tab: 'locations' } }, - meta: { - icon: 'lucide:map-pin', - title: 'foodLabeling.management.locationManager', - }, - }, - { name: 'FoodLabelingAccountManagement', path: '/account-management', component: '/food-labeling/management/account-management/index', diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts index 7b1699f..18b301b 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts @@ -138,6 +138,16 @@ export const useAuthStore = defineStore('auth', () => { return cached; } + // 泰额 SAAS:用户信息来自 th-app-auth/login,不走 Yi 框架 /account + if (thTenantStore.tenantId && accessStore.accessToken) { + const fallback = buildUserInfoFromLogin( + cached?.email || cached?.username || 'user', + thTenantStore.tenantName || '', + ); + userStore.setUserInfo(fallback); + return fallback; + } + try { const { getUserInfoApi } = await import('#/api'); const backUserInfo = await getUserInfoApi(); diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/saas-context.ts b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/saas-context.ts new file mode 100644 index 0000000..b3babe6 --- /dev/null +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/saas-context.ts @@ -0,0 +1,68 @@ +import { acceptHMRUpdate, defineStore } from 'pinia'; + +import { MOCK_SAAS_TENANTS } from '#/views/food-labeling/shared/mock-platform-data'; + +/** 当前登录身份(前端 Mock;联调后由后端 JWT / 用户信息注入) */ +export type SaasActorType = 'platform' | 'tenant_admin' | 'tenant_user'; + +interface SaasContextState { + actorType: SaasActorType; + tenantId: string | null; + tenantName: string | null; + /** 当前租户已开通的菜单 key(公司管理员及其下属授权的上限) */ + tenantMenuKeys: string[]; +} + +/** + * SAAS 权限上下文:平台管理员 vs 公司(租户)管理员 + */ +export const useSaasContextStore = defineStore('food-saas-context', { + actions: { + /** 模拟以某公司管理员身份进入系统 */ + impersonateTenantAdmin(tenantId: string) { + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === tenantId); + if (!tenant) { + return; + } + this.actorType = 'tenant_admin'; + this.tenantId = tenant.id; + this.tenantName = tenant.companyName; + this.tenantMenuKeys = [...tenant.menuPermissionKeys]; + }, + resetToPlatformAdmin() { + this.actorType = 'platform'; + this.tenantId = null; + this.tenantName = null; + this.tenantMenuKeys = []; + }, + setTenantMenuKeys(keys: string[]) { + this.tenantMenuKeys = keys; + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === this.tenantId); + if (tenant) { + tenant.menuPermissionKeys = [...keys]; + } + }, + }, + getters: { + isPlatformAdmin: (state) => state.actorType === 'platform', + isTenantAdmin: (state) => state.actorType === 'tenant_admin', + /** 角色/用户配置菜单权限时的可选上限 */ + allowedMenuKeys(state): string[] { + if (state.actorType === 'platform') { + return []; + } + return state.tenantMenuKeys; + }, + }, + state: (): SaasContextState => ({ + actorType: 'platform', + tenantId: null, + tenantName: null, + tenantMenuKeys: [], + }), +}); + +const hot = import.meta.hot; +if (hot) { + hot.accept(acceptHMRUpdate(useSaasContextStore, hot)); +} diff --git a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue index 5d49c8b..d3d0162 100644 --- a/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue +++ b/泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue @@ -26,6 +26,7 @@ import { MOCK_TEMPLATE_PRINT_STATS, } from '../shared/mock-management-data'; import { FOOD_LABELING_STATIC_ONLY } from '../shared/static-mode'; +import { managementTabPageContentClass } from '../management/shared/management-grid'; import DashboardCategoryChart from './dashboard-category-chart.vue'; import DashboardKpiCard from './dashboard-kpi-card.vue'; import DashboardWeeklyChart from './dashboard-weekly-chart.vue'; @@ -308,10 +309,10 @@ function goReports() {