Commit 540ac0e3210e4609b07008951a6c031b15a4d964
1 parent
83ccb207
前端修改bug
Showing
201 changed files
with
15144 additions
and
4364 deletions
Too many changes.
To preserve performance only 100 of 201 files are displayed.
5-19泰额版.md deleted
| 1 | -# 5-19 泰额版 — 多租户(独立库)与接口说明 | |
| 2 | - | |
| 3 | -本文档说明 **泰额版后端**(`泰额版/Food Labeling Management Code/Yi.Abp.Net8`)在 **2026-05-19** 起的多租户改造:**每个租户独立 MySQL 业务库**,平台主库仅存 `yitenant`;以及泰额专用登录、租户开通相关接口。 | |
| 4 | - | |
| 5 | -> 与美国版业务接口(`food-labeling-us`)共用同一宿主时,调用业务 API 须携带租户上下文(见 [租户上下文](#租户上下文))。 | |
| 6 | - | |
| 7 | ---- | |
| 8 | - | |
| 9 | -## 目录 | |
| 10 | - | |
| 11 | -- [架构概览](#架构概览) | |
| 12 | -- [数据库与 SQL 脚本](#数据库与-sql-脚本) | |
| 13 | -- [应用配置](#应用配置) | |
| 14 | -- [租户上下文](#租户上下文) | |
| 15 | -- [泰额专用接口](#泰额专用接口) | |
| 16 | - - [登录](#post-apiappth-app-authlogin) | |
| 17 | - - [我的门店](#get-apiappth-app-authmy-locations) | |
| 18 | - - [租户下拉 / 当前租户](#thmulti-tenancy) | |
| 19 | - - [开通租户独立库](#post-apiappth-tenant-provisioningprovision) | |
| 20 | - - [框架租户管理(补充)](#框架租户管理补充) | |
| 21 | -- [业务接口联调](#业务接口联调) | |
| 22 | -- [代码与脚本路径](#代码与脚本路径) | |
| 23 | -- [常见问题](#常见问题) | |
| 24 | - | |
| 25 | ---- | |
| 26 | - | |
| 27 | -## 架构概览 | |
| 28 | - | |
| 29 | -| 库 | 用途 | 连接来源 | | |
| 30 | -|----|------|----------| | |
| 31 | -| **平台主库** `antis-foodlabeling-host` | 仅 `yitenant`(租户元数据) | `appsettings` → `DbConnOptions.Url` | | |
| 32 | -| **租户业务库** 如 `antis-foodlabeling-us` | `fl_*`、`location`、`user` 等 | `yitenant.TenantConnectionString` | | |
| 33 | - | |
| 34 | -``` | |
| 35 | -antis-foodlabeling-host (主库) | |
| 36 | - └── yitenant | |
| 37 | - ├── Default → antis-foodlabeling-us(迁移期默认租户 / 现有数据) | |
| 38 | - └── 新租户 → antis-foodlabeling-{tenant}(Provision 自动建库) | |
| 39 | -``` | |
| 40 | - | |
| 41 | -- **不做** 业务表 `TenantId` 行级隔离(勿执行给 `fl_*` 加 `TenantId` 的 ALTER)。 | |
| 42 | -- 切换租户:请求头 `__tenant` 和/或 JWT 中的 `TenantId` Claim。 | |
| 43 | -- 无租户上下文时:连接**平台主库**(用于租户 CRUD、开通租户等)。 | |
| 44 | - | |
| 45 | -**默认租户(迁移期)** | |
| 46 | - | |
| 47 | -| 项 | 值 | | |
| 48 | -|----|-----| | |
| 49 | -| Id | `11111111-1111-1111-1111-111111111111` | | |
| 50 | -| Name | `Default` | | |
| 51 | -| 业务库 | `antis-foodlabeling-us`(连接串写在 `yitenant.TenantConnectionString`) | | |
| 52 | - | |
| 53 | ---- | |
| 54 | - | |
| 55 | -## 数据库与 SQL 脚本 | |
| 56 | - | |
| 57 | -脚本目录:`泰额版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling/scripts/` | |
| 58 | - | |
| 59 | -### 执行顺序 | |
| 60 | - | |
| 61 | -| 顺序 | 文件 | 说明 | | |
| 62 | -|------|------|------| | |
| 63 | -| 1 | `create_platform_host_database.sql` | 创建主库 `antis-foodlabeling-host` 及 `yitenant` 表 | | |
| 64 | -| 2 | `migrate_yitenant_to_host.sql` | **可选**:若曾在业务库 `antis-foodlabeling-us` 中写过 `yitenant`,迁移到主库 | | |
| 65 | -| 3 | `separate_database_bootstrap.sql` | 在主库登记默认租户,`TenantConnectionString` 指向 `antis-foodlabeling-us` | | |
| 66 | - | |
| 67 | -### 勿执行 | |
| 68 | - | |
| 69 | -以下 **不要** 在业务库执行(共享库 + 行级 `TenantId` 方案已废弃): | |
| 70 | - | |
| 71 | -```sql | |
| 72 | --- 勿执行 | |
| 73 | -ALTER TABLE fl_product ADD COLUMN TenantId ... | |
| 74 | -UPDATE fl_product SET TenantId = ... | |
| 75 | -``` | |
| 76 | - | |
| 77 | -### 自检 SQL | |
| 78 | - | |
| 79 | -```sql | |
| 80 | -USE antis-foodlabeling-host; | |
| 81 | - | |
| 82 | -SELECT Id, Name, LEFT(TenantConnectionString, 80) AS conn | |
| 83 | -FROM yitenant | |
| 84 | -WHERE Id = '11111111-1111-1111-1111-111111111111'; | |
| 85 | -``` | |
| 86 | - | |
| 87 | -应有一条 `Default`,且 `conn` 中含 `database=antis-foodlabeling-us`。 | |
| 88 | - | |
| 89 | ---- | |
| 90 | - | |
| 91 | -## 应用配置 | |
| 92 | - | |
| 93 | -文件:`泰额版/.../src/Yi.Abp.Web/appsettings.json` | |
| 94 | - | |
| 95 | -| 配置项 | 说明 | | |
| 96 | -|--------|------| | |
| 97 | -| `DbConnOptions.Url` | 平台主库,如 `database=antis-foodlabeling-host` | | |
| 98 | -| `DbConnOptions.EnabledSaasMultiTenancy` | `true` | | |
| 99 | -| `FoodLabeling:MultiTenancy:Mode` | `SeparateDatabase` | | |
| 100 | -| `FoodLabeling:TenantDatabase` | 新租户库名模板、RDS 账号等 | | |
| 101 | -| `FoodLabeling:LegacyTenant` | 默认租户 Id / Name | | |
| 102 | - | |
| 103 | -新租户库名模板示例:`antis-foodlabeling-{tenant}`(`{tenant}` 为规范化后的租户名)。 | |
| 104 | - | |
| 105 | ---- | |
| 106 | - | |
| 107 | -## 租户上下文 | |
| 108 | - | |
| 109 | -业务请求须让后端解析到 **租户 Id**,任选其一(推荐登录后仅用 Bearer Token): | |
| 110 | - | |
| 111 | -| 方式 | 说明 | | |
| 112 | -|------|------| | |
| 113 | -| 请求头 | `__tenant: {租户Guid}` | | |
| 114 | -| JWT | Claim:`TenantId`(`TokenTypeConst.TenantId`)及 `AbpClaimTypes.TenantId` | | |
| 115 | - | |
| 116 | -泰额登录签发的 Token 已写入上述 Claim;`JwtClaimTenantResolveContributor` 会自动解析。 | |
| 117 | - | |
| 118 | -租户解析顺序(`YiAbpWebModule`): | |
| 119 | - | |
| 120 | -1. `HeaderTenantResolveContributor`(`__tenant`) | |
| 121 | -2. `JwtClaimTenantResolveContributor`(JWT) | |
| 122 | - | |
| 123 | -> 独立库模式下**不会**自动回落到默认租户;未传租户时走主库连接,业务表可能查不到数据。 | |
| 124 | - | |
| 125 | ---- | |
| 126 | - | |
| 127 | -## 泰额专用接口 | |
| 128 | - | |
| 129 | -Swagger 分组:**泰额版-食品标签**(`FoodLabeling.Th.Application`) | |
| 130 | - | |
| 131 | -基础路径前缀:`/api/app/`(ABP 动态 API 约定,以 Swagger 为准) | |
| 132 | - | |
| 133 | -### `POST /api/app/th-app-auth/login` | |
| 134 | - | |
| 135 | -**应用服务**:`ThAppAuthAppService` | |
| 136 | -**鉴权**:匿名 | |
| 137 | - | |
| 138 | -**说明**: | |
| 139 | - | |
| 140 | -1. 在**平台主库**校验 `tenantId` 是否存在且已配置 `TenantConnectionString`。 | |
| 141 | -2. 切换到该租户业务库,按邮箱 + 密码校验 `user`(盐值哈希与美国版一致)。 | |
| 142 | -3. 签发 JWT(含 `TenantId`)、RefreshToken,并返回绑定门店列表。 | |
| 143 | - | |
| 144 | -#### 入参 `ThAppLoginInputVo` | |
| 145 | - | |
| 146 | -| 字段 | 类型 | 必填 | 说明 | | |
| 147 | -|------|------|------|------| | |
| 148 | -| tenantId | Guid | 是 | 平台主库 `yitenant.Id` | | |
| 149 | -| email | string | 是 | 登录邮箱(`user.Email` / 邮箱形 `UserName`) | | |
| 150 | -| password | string | 是 | 密码 | | |
| 151 | -| uuid | string | 否 | 图形验证码 UUID(系统开启验证码时必填) | | |
| 152 | -| code | string | 否 | 图形验证码 | | |
| 153 | - | |
| 154 | -#### 请求示例 | |
| 155 | - | |
| 156 | -```http | |
| 157 | -POST /api/app/th-app-auth/login | |
| 158 | -Content-Type: application/json | |
| 159 | -``` | |
| 160 | - | |
| 161 | -```json | |
| 162 | -{ | |
| 163 | - "tenantId": "11111111-1111-1111-1111-111111111111", | |
| 164 | - "email": "admin@example.com", | |
| 165 | - "password": "YourPassword1!" | |
| 166 | -} | |
| 167 | -``` | |
| 168 | - | |
| 169 | -#### 出参 `ThAppLoginOutputDto` | |
| 170 | - | |
| 171 | -| 字段 | 类型 | 说明 | | |
| 172 | -|------|------|------| | |
| 173 | -| token | string | 访问令牌(含 TenantId Claim) | | |
| 174 | -| refreshToken | string | 刷新令牌 | | |
| 175 | -| tenantId | Guid | 当前租户 Id | | |
| 176 | -| tenantName | string | 租户名称 | | |
| 177 | -| locations | array | 绑定门店(结构同美国版 `UsAppBoundLocationDto`) | | |
| 178 | - | |
| 179 | -#### 出参示例 | |
| 180 | - | |
| 181 | -```json | |
| 182 | -{ | |
| 183 | - "token": "eyJhbGciOiJIUzI1NiIs...", | |
| 184 | - "refreshToken": "...", | |
| 185 | - "tenantId": "11111111-1111-1111-1111-111111111111", | |
| 186 | - "tenantName": "Default", | |
| 187 | - "locations": [ | |
| 188 | - { | |
| 189 | - "id": "...", | |
| 190 | - "locationCode": "LOC001", | |
| 191 | - "locationName": "Store A", | |
| 192 | - "fullAddress": "...", | |
| 193 | - "state": true | |
| 194 | - } | |
| 195 | - ] | |
| 196 | -} | |
| 197 | -``` | |
| 198 | - | |
| 199 | -#### JWT Claims(节选) | |
| 200 | - | |
| 201 | -| Claim | 说明 | | |
| 202 | -|-------|------| | |
| 203 | -| `TenantId` / `tenantid` | 租户 Guid 字符串 | | |
| 204 | -| `client_kind` | `th_app` | | |
| 205 | -| `sub` / `UserId` | 用户 Id | | |
| 206 | - | |
| 207 | -#### 错误说明 | |
| 208 | - | |
| 209 | -| 提示 | 原因 | | |
| 210 | -|------|------| | |
| 211 | -| 请输入租户、邮箱与密码 | 参数缺失 | | |
| 212 | -| 租户不存在或已停用 | 主库无该 `yitenant` 记录 | | |
| 213 | -| 租户未配置业务库连接串 | `TenantConnectionString` 为空 | | |
| 214 | -| 登录失败!邮箱不存在 | 租户库中无该用户 | | |
| 215 | -| 用户名或密码错误 | 密码校验失败 | | |
| 216 | - | |
| 217 | ---- | |
| 218 | - | |
| 219 | -### `GET /api/app/th-app-auth/my-locations` | |
| 220 | - | |
| 221 | -**应用服务**:`ThAppAuthAppService` | |
| 222 | -**鉴权**:Bearer Token | |
| 223 | - | |
| 224 | -**说明**:在**当前 JWT 租户上下文**下,查询 `userlocation` + `location` 绑定门店。 | |
| 225 | - | |
| 226 | -```http | |
| 227 | -GET /api/app/th-app-auth/my-locations | |
| 228 | -Authorization: Bearer {token} | |
| 229 | -``` | |
| 230 | - | |
| 231 | -可选同时带:`__tenant: 11111111-1111-1111-1111-111111111111`(与 Token 中租户一致即可)。 | |
| 232 | - | |
| 233 | ---- | |
| 234 | - | |
| 235 | -### ThMulti-tenancy | |
| 236 | - | |
| 237 | -**应用服务**:`ThMultiTenancyAppService` | |
| 238 | - | |
| 239 | -#### `GET /api/app/th-multi-tenancy/tenant-select`(方法名以 Swagger 为准,一般为 `get-tenant-select`) | |
| 240 | - | |
| 241 | -租户下拉列表(供登录页选择租户)。 | |
| 242 | - | |
| 243 | -**出参**:`ThTenantSelectDto[]` | |
| 244 | - | |
| 245 | -| 字段 | 类型 | 说明 | | |
| 246 | -|------|------|------| | |
| 247 | -| id | Guid | 租户 Id | | |
| 248 | -| name | string | 租户名称 | | |
| 249 | - | |
| 250 | -#### `GET /api/app/th-multi-tenancy/current-tenant`(`get-current-tenant`) | |
| 251 | - | |
| 252 | -当前请求解析到的租户(调试用)。 | |
| 253 | - | |
| 254 | -**出参**:`ThCurrentTenantDto` | |
| 255 | - | |
| 256 | -| 字段 | 类型 | 说明 | | |
| 257 | -|------|------|------| | |
| 258 | -| tenantId | Guid? | 当前租户 Id | | |
| 259 | -| tenantName | string? | 当前租户名称 | | |
| 260 | - | |
| 261 | ---- | |
| 262 | - | |
| 263 | -### `POST /api/app/th-tenant-provisioning/provision` | |
| 264 | - | |
| 265 | -**应用服务**:`ThTenantProvisioningAppService` | |
| 266 | -**鉴权**:需登录(平台管理员) | |
| 267 | - | |
| 268 | -**说明**: | |
| 269 | - | |
| 270 | -1. 在**平台主库**写入 `yitenant`(含 `TenantConnectionString`)。 | |
| 271 | -2. 若未传连接串,按 `FoodLabeling:TenantDatabase` 生成,如 `antis-foodlabeling-{tenant}`。 | |
| 272 | -3. `initializeDatabase=true` 时调用 `InitAsync`:建库 + CodeFirst 业务表(**不含** `yitenant`)。 | |
| 273 | - | |
| 274 | -#### 入参 `ThProvisionTenantInputVo` | |
| 275 | - | |
| 276 | -| 字段 | 类型 | 必填 | 说明 | | |
| 277 | -|------|------|------|------| | |
| 278 | -| name | string | 是 | 租户名称(用于生成库名) | | |
| 279 | -| tenantConnectionString | string | 否 | 自定义连接串;空则按模板生成 | | |
| 280 | -| dbType | int | 否 | SqlSugar.DbType,默认 `0` = MySql | | |
| 281 | -| initializeDatabase | bool | 否 | 默认 `true`,是否立即建库建表 | | |
| 282 | - | |
| 283 | -#### 请求示例 | |
| 284 | - | |
| 285 | -```http | |
| 286 | -POST /api/app/th-tenant-provisioning/provision | |
| 287 | -Content-Type: application/json | |
| 288 | -Authorization: Bearer {token} | |
| 289 | -``` | |
| 290 | - | |
| 291 | -```json | |
| 292 | -{ | |
| 293 | - "name": "acme", | |
| 294 | - "initializeDatabase": true | |
| 295 | -} | |
| 296 | -``` | |
| 297 | - | |
| 298 | -#### 出参 `ThProvisionTenantOutputDto` | |
| 299 | - | |
| 300 | -| 字段 | 类型 | 说明 | | |
| 301 | -|------|------|------| | |
| 302 | -| tenantId | Guid | 新租户 Id | | |
| 303 | -| name | string | 租户名称 | | |
| 304 | -| databaseName | string | 业务库名 | | |
| 305 | -| tenantConnectionString | string | 完整连接串 | | |
| 306 | -| databaseInitialized | bool | 是否已执行 Init | | |
| 307 | - | |
| 308 | -#### `POST /api/app/th-tenant-provisioning/initialize-tenant-database` | |
| 309 | - | |
| 310 | -对已有租户补执行建库建表(入参:租户 `tenantId`,以 Swagger 为准)。 | |
| 311 | - | |
| 312 | ---- | |
| 313 | - | |
| 314 | -### 框架租户管理(补充) | |
| 315 | - | |
| 316 | -分组:**租户管理接口**(`Yi.Framework.TenantManagement.Application`) | |
| 317 | - | |
| 318 | -| 方法 | 路径 | 说明 | | |
| 319 | -|------|------|------| | |
| 320 | -| POST | `/api/app/tenant` | 创建租户(需 `tenantConnectionString`) | | |
| 321 | -| PUT | `/api/app/tenant/init/{id}` | 租户业务库 CodeFirst 初始化 | | |
| 322 | - | |
| 323 | -泰额推荐使用 `th-tenant-provisioning/provision` 一步完成登记 + 建库。 | |
| 324 | - | |
| 325 | ---- | |
| 326 | - | |
| 327 | -## 业务接口联调 | |
| 328 | - | |
| 329 | -宿主同时加载 `FoodLabeling.Application`(美国版业务)与 `FoodLabeling.Th.Application`。 | |
| 330 | - | |
| 331 | -调用任意业务 API(如 `/api/app/product`、`/api/app/location`)时: | |
| 332 | - | |
| 333 | -```http | |
| 334 | -GET /api/app/product?SkipCount=1&MaxResultCount=10 | |
| 335 | -Authorization: Bearer {泰额登录返回的 token} | |
| 336 | -``` | |
| 337 | - | |
| 338 | -或: | |
| 339 | - | |
| 340 | -```http | |
| 341 | -GET /api/app/product?SkipCount=1&MaxResultCount=10 | |
| 342 | -Authorization: Bearer {token} | |
| 343 | -__tenant: 11111111-1111-1111-1111-111111111111 | |
| 344 | -``` | |
| 345 | - | |
| 346 | -**分页**:`SkipCount` 为 **1-based 页码**(与美国版一致,见 `5-18接口优化.md`)。 | |
| 347 | - | |
| 348 | ---- | |
| 349 | - | |
| 350 | -## 代码与脚本路径 | |
| 351 | - | |
| 352 | -| 类型 | 路径 | | |
| 353 | -|------|------| | |
| 354 | -| 泰额应用层 | `module/food-labeling/FoodLabeling.Th.Application/` | | |
| 355 | -| 泰额契约 | `module/food-labeling/FoodLabeling.Th.Application.Contracts/` | | |
| 356 | -| 美国版业务(共用) | `module/food-labeling-us/FoodLabeling.Application/` | | |
| 357 | -| SQL 脚本 | `module/food-labeling/scripts/` | | |
| 358 | -| 多租户常量 | `module/food-labeling-us/FoodLabeling.Domain.Shared/MultiTenancy/FoodLabelingMultiTenancyConsts.cs` | | |
| 359 | -| JWT 租户解析 | `module/food-labeling-us/FoodLabeling.Application/MultiTenancy/JwtClaimTenantResolveContributor.cs` | | |
| 360 | -| Web 配置 | `src/Yi.Abp.Web/appsettings.json`、`YiAbpWebModule.cs` | | |
| 361 | - | |
| 362 | ---- | |
| 363 | - | |
| 364 | -## 常见问题 | |
| 365 | - | |
| 366 | -| 现象 | 处理 | | |
| 367 | -|------|------| | |
| 368 | -| 启动报连库失败 | 确认已执行 `create_platform_host_database.sql`,且 `DbConnOptions.Url` 指向 `antis-foodlabeling-host` | | |
| 369 | -| 登录报租户不存在 | 在主库执行 `separate_database_bootstrap.sql` | | |
| 370 | -| 登录成功但业务列表为空 | 检查 Token 是否带 `TenantId`;或请求头补 `__tenant` | | |
| 371 | -| 业务接口查到主库无数据 | 未解析租户,误连主库;须登录泰额接口或传 `__tenant` | | |
| 372 | -| 新租户无表 | 调用 `provision` 且 `initializeDatabase=true`,或 `PUT tenant/init/{id}` | | |
| 373 | -| 误加了 TenantId 列 | 独立库模式不需要;可保留列但不使用,建议勿加 | | |
| 374 | - | |
| 375 | ---- | |
| 376 | - | |
| 377 | -## 变更记录 | |
| 378 | - | |
| 379 | -| 日期 | 内容 | | |
| 380 | -|------|------| | |
| 381 | -| 2026-05-19 | 泰额版多租户独立库方案;平台主库分离;`ThAppAuth` 登录写 JWT TenantId;租户开通与 SQL 脚本说明 | |
5-26代码优化.md deleted
| 1 | -# 5-26 代码优化 | |
| 2 | - | |
| 3 | -本文档说明 **2026-05-26** 对美国版接口的变更。 | |
| 4 | - | |
| 5 | -1. **`/api/app/product-category`**:新增/编辑 **`categoryCode` 取消必填**(见 [product-category-categoryCode](#product-category-categorycode-可选))。 | |
| 6 | -2. **`/api/app/label-template`**:新增/编辑/列表/详情支持 **Region、Location 多选数组**;列表 Query 增加 **Region/Location 筛选**(见 [label-template-regionlocation](#label-template-regionlocation-多选))。 | |
| 7 | -3. **`/api/app/rbac-role`**:修复 **`accessPermissions` JSON 数组**(如 `manage_labels`)无法绑定菜单(见 [rbac-role-accesspermissions](#rbac-role-accesspermissions-修复))。 | |
| 8 | -4. **`/api/app/auth-scope`**:管理员(及按数据范围受限账号)登录后 **Company → Region → Location** 级联选店(见 [auth-scope-登录选店](#auth-scope-登录后-company--region--location-级联选店))。 | |
| 9 | -5. **`/api/app/us-app-auth`**:App 管理员 Token 专用 **Company / Region / 门店筛选** 接口(见 [us-app-auth-管理员选店](#us-app-auth-app-管理员级联选店))。 | |
| 10 | - | |
| 11 | -**应用服务**:`ProductCategoryAppService`、`LabelTemplateAppService`、`RbacRoleAppService` | |
| 12 | -**命名约定**(与 5-17 / 5-18 一致):UI **Region** = API **`regionIds` / `groupIds` / `groupId`**(`fl_group.Id`);UI **Location** = **`locationIds` / `locationId`**(`location.Id`)。 | |
| 13 | - | |
| 14 | ---- | |
| 15 | - | |
| 16 | -## product-category categoryCode 可选 | |
| 17 | - | |
| 18 | -**影响接口** | |
| 19 | - | |
| 20 | -| 方法 | 路径 | | |
| 21 | -|------|------| | |
| 22 | -| POST | `/api/app/product-category` | | |
| 23 | -| PUT | `/api/app/product-category/{id}` | | |
| 24 | - | |
| 25 | -### 变更说明 | |
| 26 | - | |
| 27 | -| 项 | 变更前 | 变更后 | | |
| 28 | -|----|--------|--------| | |
| 29 | -| **categoryCode** | 必填;空则报「类别编码和名称不能为空」 | **可选**;可不传、传 `null` 或 `""` | | |
| 30 | -| **categoryName** | 必填 | 仍必填 | | |
| 31 | -| **落库** | — | 未填编码时 `CategoryCode` 存 **空字符串** | | |
| 32 | -| **唯一性** | 编码或名称重复即报错 | 有编码:编码 **或** 名称重复报错;**无编码**:仅校验 **名称** 不重复 | | |
| 33 | - | |
| 34 | -### 入参(节选) | |
| 35 | - | |
| 36 | -| 字段 | 类型 | 必填 | 说明 | | |
| 37 | -|------|------|------|------| | |
| 38 | -| categoryCode | string | **否** | 类别编码 | | |
| 39 | -| categoryName | string | **是** | 类别名称 | | |
| 40 | -| regionIds / groupIds / locationIds | string[] | 否 | Region·Location 范围(规则见 `5-17接口优化.md`) | | |
| 41 | - | |
| 42 | -### 请求示例(无编码) | |
| 43 | - | |
| 44 | -```http | |
| 45 | -POST /api/app/product-category | |
| 46 | -Content-Type: application/json | |
| 47 | -Authorization: Bearer {token} | |
| 48 | -``` | |
| 49 | - | |
| 50 | -```json | |
| 51 | -{ | |
| 52 | - "categoryName": "Beverages", | |
| 53 | - "buttonAppearance": "TEXT", | |
| 54 | - "state": true, | |
| 55 | - "availabilityType": "ALL", | |
| 56 | - "orderNum": 0 | |
| 57 | -} | |
| 58 | -``` | |
| 59 | - | |
| 60 | -### 联调注意 | |
| 61 | - | |
| 62 | -| 现象 | 处理 | | |
| 63 | -|------|------| | |
| 64 | -| 仍报「类别编码和名称不能为空」 | 确认已部署含本变更的后端;仅需保证 **categoryName** 非空 | | |
| 65 | -| 无编码时名称重复 | 正常:仅按 **categoryName** 判重 | | |
| 66 | - | |
| 67 | -> Region/Location 多选、列表 `region`/`location` 展示等完整说明见 `5-17接口优化.md` → product-category 章节。 | |
| 68 | - | |
| 69 | ---- | |
| 70 | - | |
| 71 | -## label-template Region·Location 多选 | |
| 72 | - | |
| 73 | -**应用服务**:`LabelTemplateAppService` | |
| 74 | -**存储表**:`fl_label_template_location`(模板 ↔ 门店,**无新表**) | |
| 75 | -**主表字段**:`fl_label_template.AppliedLocationType` = `ALL` / `SPECIFIED` | |
| 76 | - | |
| 77 | -### 变更说明 | |
| 78 | - | |
| 79 | -| 项 | 变更前 | 变更后 | | |
| 80 | -|----|--------|--------| | |
| 81 | -| **新增/编辑 Body** | 仅 `appliedLocation` + `appliedLocationIds` | 增加 **`regionIds`**、**`groupIds`**、**`locationIds`**(与 `appliedLocationIds` 合并) | | |
| 82 | -| **列表 Query** | 仅 `locationId` | 增加 **`groupId`**(Region);`locationId` 优先于 `groupId`(与 product-category 一致) | | |
| 83 | -| **列表出参** | 仅 `locationText`(单条展示) | 增加 **`region`**、**`location`** 展示 + **`regionIds`**、**`locationIds`** 数组 | | |
| 84 | -| **详情出参** | `appliedLocationIds` | 同上,并保留 **`appliedLocationIds`**(与 `locationIds` 一致,兼容编辑器) | | |
| 85 | -| **范围解析** | 仅显式门店 Id | Region 展开为门店后与门店 Id **取并集** 落库 | | |
| 86 | - | |
| 87 | -### 影响接口 | |
| 88 | - | |
| 89 | -| 方法 | 路径 | 说明 | | |
| 90 | -|------|------|------| | |
| 91 | -| GET | `/api/app/label-template?SkipCount=1&MaxResultCount=10` | 列表支持 `groupId`/`locationId` 筛选;`items[]` 增加 `region`、`location`、`regionIds`、`locationIds` | | |
| 92 | -| GET | `/api/app/label-template/{id}` | 详情增加上述字段 | | |
| 93 | -| POST | `/api/app/label-template` | Body 支持 Region/Location 多选 | | |
| 94 | -| PUT | `/api/app/label-template/{id}` | 同新增 | | |
| 95 | - | |
| 96 | -路径参数 **`id`** 仍为模板编码 **`TemplateCode`**(与编辑器 JSON 的 `id` 一致)。 | |
| 97 | - | |
| 98 | -### 新增/编辑入参(Body:`LabelTemplateCreateInputVo`) | |
| 99 | - | |
| 100 | -| 字段 | JSON 名 | 类型 | 必填 | 说明 | | |
| 101 | -|------|---------|------|------|------| | |
| 102 | -| TemplateCode | `id` | string | 是 | 模板编码 | | |
| 103 | -| TemplateName | `name` | string | 是 | 模板名称 | | |
| 104 | -| AppliedLocationType | `appliedLocation` | string | 否 | `ALL` / `SPECIFIED`,默认 `ALL` | | |
| 105 | -| RegionIds | `regionIds` | string[] | 否 | Region 多选(`fl_group.Id`) | | |
| 106 | -| GroupIds | `groupIds` | string[] | 否 | 与 `regionIds` 等价,合并去重 | | |
| 107 | -| LocationIds | `locationIds` | string[] | 否 | 门店多选(`location.Id`) | | |
| 108 | -| AppliedLocationIds | `appliedLocationIds` | string[] | 否 | 兼容旧字段,与 `locationIds` 合并 | | |
| 109 | -| Elements | `elements` | array | 否 | 模板组件,全量重建 | | |
| 110 | -| TemplateProductDefaults | `templateProductDefaults` | array | 否 | 仅 **编辑** 时显式传入才重建 | | |
| 111 | - | |
| 112 | -**自动规则** | |
| 113 | - | |
| 114 | -| 入参 | 行为 | | |
| 115 | -|------|------| | |
| 116 | -| `regionIds` / `groupIds` / `locationIds` / `appliedLocationIds` 任一有有效 Id | `appliedLocation` 按 **`SPECIFIED`** 处理 | | |
| 117 | -| 仅传空数组 `[]` 且 `appliedLocation` 为 `ALL` | 不绑定门店(全部门店) | | |
| 118 | -| `appliedLocation: "SPECIFIED"` 且合并后无有效门店 | 报错:`指定适用区域或门店时,至少需要匹配到一个有效门店` | | |
| 119 | -| `appliedLocation` 非法值 | 报错:`适用门店范围不合法(ALL/SPECIFIED)` | | |
| 120 | - | |
| 121 | -**合并规则**:每个 `regionIds` 展开为该 Region 下全部门店,再与 `locationIds`、`appliedLocationIds` **取并集** → 写入 `fl_label_template_location`。 | |
| 122 | - | |
| 123 | -### 请求示例(Region + 门店多选) | |
| 124 | - | |
| 125 | -```http | |
| 126 | -POST /api/app/label-template | |
| 127 | -Content-Type: application/json | |
| 128 | -Authorization: Bearer {token} | |
| 129 | -``` | |
| 130 | - | |
| 131 | -```json | |
| 132 | -{ | |
| 133 | - "id": "TPL_TEST_001", | |
| 134 | - "name": "Price Tag 4x6", | |
| 135 | - "labelType": "PRICE", | |
| 136 | - "unit": "inch", | |
| 137 | - "width": 4, | |
| 138 | - "height": 6, | |
| 139 | - "appliedLocation": "SPECIFIED", | |
| 140 | - "regionIds": [ | |
| 141 | - "fl_group_id_east", | |
| 142 | - "fl_group_id_west" | |
| 143 | - ], | |
| 144 | - "locationIds": [ | |
| 145 | - "11111111-1111-1111-1111-111111111111" | |
| 146 | - ], | |
| 147 | - "showRuler": true, | |
| 148 | - "showGrid": true, | |
| 149 | - "state": true, | |
| 150 | - "elements": [] | |
| 151 | -} | |
| 152 | -``` | |
| 153 | - | |
| 154 | -### 请求示例(全部门店,兼容旧版) | |
| 155 | - | |
| 156 | -```json | |
| 157 | -{ | |
| 158 | - "id": "TPL_ALL", | |
| 159 | - "name": "Global Template", | |
| 160 | - "labelType": "PRICE", | |
| 161 | - "unit": "inch", | |
| 162 | - "width": 4, | |
| 163 | - "height": 6, | |
| 164 | - "appliedLocation": "ALL", | |
| 165 | - "appliedLocationIds": [], | |
| 166 | - "elements": [] | |
| 167 | -} | |
| 168 | -``` | |
| 169 | - | |
| 170 | -### 列表(`GET /api/app/label-template`) | |
| 171 | - | |
| 172 | -**Query 参数** | |
| 173 | - | |
| 174 | -| 字段 | 类型 | 说明 | | |
| 175 | -|------|------|------| | |
| 176 | -| SkipCount / MaxResultCount | int | 分页(项目约定 SkipCount 从 1 起) | | |
| 177 | -| keyword | string | 模板名称/编码模糊 | | |
| 178 | -| **groupId** | string | **按 Region 筛选**(`fl_group.Id`):命中 `appliedLocation=ALL` 的模板,或在 `fl_label_template_location` 中绑定了该 Region 下任一门门店的模板 | | |
| 179 | -| **locationId** | string | **按门店筛选**(`location.Id`);**优先于 groupId** | | |
| 180 | -| labelType | string | 如 `PRICE` | | |
| 181 | -| state | bool | 启用状态 | | |
| 182 | -| sorting | string | 排序(可选) | | |
| 183 | - | |
| 184 | -**筛选规则**(与 product-category / label-type 相同,内部 `LocationScopeBindingHelper.ResolveScopedLocationIdsAsync`) | |
| 185 | - | |
| 186 | -| 入参 | 行为 | | |
| 187 | -|------|------| | |
| 188 | -| 均未传 `groupId`、`locationId` | 不过滤适用范围 | | |
| 189 | -| 仅 `groupId` | 解析该 Region 下全部门店 Id,再筛模板 | | |
| 190 | -| 仅 `locationId` | 按该门店 Id 筛模板 | | |
| 191 | -| 同时传 | **以 `locationId` 为准**(忽略 `groupId`) | | |
| 192 | -| Region/门店无效或解析结果为空 | 仅返回 **`appliedLocation=ALL`** 的模板 | | |
| 193 | - | |
| 194 | -命中条件(满足其一即可出现在列表): | |
| 195 | - | |
| 196 | -- `fl_label_template.AppliedLocationType = 'ALL'` | |
| 197 | -- `SPECIFIED` 且 `fl_label_template_location` 中存在 `LocationId ∈` 解析得到的门店集合 | |
| 198 | - | |
| 199 | -**请求示例** | |
| 200 | - | |
| 201 | -```http | |
| 202 | -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&groupId=fl_group_id_east HTTP/1.1 | |
| 203 | -Authorization: Bearer {token} | |
| 204 | -``` | |
| 205 | - | |
| 206 | -```http | |
| 207 | -GET /api/app/label-template?SkipCount=1&MaxResultCount=10&locationId=11111111-1111-1111-1111-111111111111 HTTP/1.1 | |
| 208 | -Authorization: Bearer {token} | |
| 209 | -``` | |
| 210 | - | |
| 211 | -**命名对照**:UI **Region** → Query **`groupId`**;UI **Location** → Query **`locationId`**。 | |
| 212 | - | |
| 213 | -### 列表出参 | |
| 214 | - | |
| 215 | -**`items[]` 新增/对齐字段** | |
| 216 | - | |
| 217 | -| 字段 | 类型 | 说明 | | |
| 218 | -|------|------|------| | |
| 219 | -| region | string | 适用 Region 展示文案 | | |
| 220 | -| location | string | 适用门店展示文案 | | |
| 221 | -| regionIds | string[] | Region Id 多选;`ALL` 时为 `[]` | | |
| 222 | -| locationIds | string[] | 门店 Id 多选;`ALL` 时为 `[]` | | |
| 223 | -| locationText | string | **兼容字段**,与 `location` 相同 | | |
| 224 | - | |
| 225 | -其它字段不变:`id`(= TemplateCode)、`templateName`、`contentsCount`、`sizeText`、`versionNo`、`lastEdited` 等。 | |
| 226 | - | |
| 227 | -**列表响应示例片段** | |
| 228 | - | |
| 229 | -```json | |
| 230 | -{ | |
| 231 | - "pageIndex": 1, | |
| 232 | - "pageSize": 10, | |
| 233 | - "totalCount": 2, | |
| 234 | - "items": [ | |
| 235 | - { | |
| 236 | - "id": "TPL_ALL", | |
| 237 | - "templateCode": "TPL_ALL", | |
| 238 | - "templateName": "Global Template", | |
| 239 | - "labelType": "PRICE", | |
| 240 | - "region": "All Regions", | |
| 241 | - "location": "All Locations", | |
| 242 | - "locationText": "All Locations", | |
| 243 | - "regionIds": [], | |
| 244 | - "locationIds": [], | |
| 245 | - "contentsCount": 5, | |
| 246 | - "sizeText": "4x6inch", | |
| 247 | - "versionNo": 1, | |
| 248 | - "lastEdited": "2026-05-26T10:00:00" | |
| 249 | - }, | |
| 250 | - { | |
| 251 | - "id": "TPL_TEST_001", | |
| 252 | - "templateName": "Price Tag 4x6", | |
| 253 | - "region": "East Region, West Region", | |
| 254 | - "location": "UNCC store, Central Park Store", | |
| 255 | - "locationText": "UNCC store, Central Park Store", | |
| 256 | - "regionIds": ["fl_group_id_east", "fl_group_id_west"], | |
| 257 | - "locationIds": [ | |
| 258 | - "11111111-1111-1111-1111-111111111111", | |
| 259 | - "22222222-2222-2222-2222-222222222222" | |
| 260 | - ], | |
| 261 | - "contentsCount": 3, | |
| 262 | - "sizeText": "4x6inch", | |
| 263 | - "versionNo": 2, | |
| 264 | - "lastEdited": "2026-05-26T11:30:00" | |
| 265 | - } | |
| 266 | - ] | |
| 267 | -} | |
| 268 | -``` | |
| 269 | - | |
| 270 | -### 详情出参(`GET /api/app/label-template/{id}`) | |
| 271 | - | |
| 272 | -在原有 `elements`、`templateProductDefaults`、`appliedLocationType` 等基础上增加: | |
| 273 | - | |
| 274 | -| 字段 | 类型 | 说明 | | |
| 275 | -|------|------|------| | |
| 276 | -| region | string | 展示文案 | | |
| 277 | -| location | string | 展示文案 | | |
| 278 | -| regionIds | string[] | Region Id 多选 | | |
| 279 | -| groupIds | string[] | 与 `regionIds` 相同(兼容) | | |
| 280 | -| locationIds | string[] | 门店 Id 多选 | | |
| 281 | -| appliedLocationIds | string[] | 与 `locationIds` 一致(编辑器回显) | | |
| 282 | - | |
| 283 | -### 展示规则 | |
| 284 | - | |
| 285 | -| appliedLocation | region | location | regionIds / locationIds | | |
| 286 | -|-----------------|--------|----------|-------------------------| | |
| 287 | -| **ALL** | `All Regions` | `All Locations` | 空数组 `[]` | | |
| 288 | -| **SPECIFIED** | 绑定门店 `location.GroupName` 去重后 `, ` 拼接 | 门店名(优先 `LocationName`,否则 `LocationCode`)拼接 | 由绑定门店反推 / 直接为绑定 Id | | |
| 289 | -| **SPECIFIED** 无绑定 | `无` | `无` | `[]` | | |
| 290 | - | |
| 291 | -`regionIds` 由 `locationIds` 反查 `fl_group` 得到(与 product-category、label-type 一致)。 | |
| 292 | - | |
| 293 | -### 编辑说明 | |
| 294 | - | |
| 295 | -- `PUT` Body 字段与 `POST` 相同;传 `regionIds` / `locationIds` 会 **全量替换** 模板适用门店(先删 `fl_label_template_location` 再插入)。 | |
| 296 | -- `elements` 仍为全量重建;`templateProductDefaults` 仅当 Body **显式包含** 该字段时才重建,避免普通保存误清空。 | |
| 297 | -- 编辑成功 **`versionNo` +1**。 | |
| 298 | - | |
| 299 | -### 联调注意 | |
| 300 | - | |
| 301 | -| 现象 | 处理 | | |
| 302 | -|------|------| | |
| 303 | -| 列表无 `regionIds` | 确认已部署含本变更的后端 | | |
| 304 | -| 传 `groupId` 列表仍很多 | 正常:`appliedLocation=ALL` 的模板始终可见 | | |
| 305 | -| 传 `groupId` 列表为空 | 检查 Region 是否存在、其下是否有门店;无效 Region 时仅剩 ALL 模板 | | |
| 306 | -| 传了 Region 仍显示 All Locations | 检查 Region Id 是否有效、是否能在库中展开到门店 | | |
| 307 | -| 仅 `appliedLocationIds` 不传 `locationIds` | 仍支持,与 `locationIds` 合并 | | |
| 308 | -| 前端编辑器仍传 `appliedLocation: "ALL"` | 管理端若需多选,须在 Body 增加 `regionIds` / `locationIds`(见 `labelTemplateService.ts`) | | |
| 309 | -| 指定范围但 0 门店 | 后端报错,需至少 1 个有效门店 | | |
| 310 | - | |
| 311 | -### 与 product-category / label-type 的关系 | |
| 312 | - | |
| 313 | -逻辑与 **`5-17接口优化.md`** 中 product-category、label-type 的 Region·Location 绑定一致,差异仅为: | |
| 314 | - | |
| 315 | -| 模块 | 范围字段名 | 关联表 | | |
| 316 | -|------|------------|--------| | |
| 317 | -| product-category | `availabilityType` | `fl_product_category_location` | | |
| 318 | -| label-type | `availabilityType` | `fl_label_type_location` | | |
| 319 | -| **label-template** | **`appliedLocation`** | **`fl_label_template_location`** | | |
| 320 | - | |
| 321 | ---- | |
| 322 | - | |
| 323 | -## rbac-role accessPermissions 修复 | |
| 324 | - | |
| 325 | -**应用服务**:`RbacRoleAppService` | |
| 326 | -**影响接口**:`POST` / `PUT /api/app/rbac-role/{id}`、`GET` 列表/详情回显 | |
| 327 | - | |
| 328 | -### 问题与根因 | |
| 329 | - | |
| 330 | -| 现象 | 根因 | | |
| 331 | -|------|------| | |
| 332 | -| 保存报 `accessPermissions 未匹配到任何菜单` | 前端提交 **JSON 数组字符串**(如 `["manage_labels",...]`),旧逻辑按逗号拆分,解析结果带 `["` 引号,无法匹配 | | |
| 333 | -| 传 `manage_labels` 等仍无菜单 | 表单权限码为 **UI 编码**(`manage_labels`),菜单侧为 **`menu.labels`**(由 `Menu.Router` 推导);二者未做映射 | | |
| 334 | -| 详情 `accessPermissionCodes` 为空 | 新增/编辑未写入 **`Role.AccessPermissionCodes`**(JSON 列),仅依赖 `RoleMenu` 反查 | | |
| 335 | - | |
| 336 | -### 变更说明 | |
| 337 | - | |
| 338 | -| 项 | 变更后 | | |
| 339 | -|----|--------| | |
| 340 | -| **入参解析** | `accessPermissions` 支持 **JSON 数组字符串**、逗号分隔、以及 Body 字段 **`accessPermissionCodes`** 数组 | | |
| 341 | -| **菜单绑定** | UI 权限码经 **`RoleAccessPermissionMenuMapping`** 映射到 `Menu.Router`,再写入 **`RoleMenu`** | | |
| 342 | -| **落库** | 同时将勾选的 UI 编码写入 **`Role.AccessPermissionCodes`**(JSON 数组),供 GET 回显 | | |
| 343 | -| **PermissionCode 为空** | 仍可按 **`Router`** 推导 `menu.xxx`(建议执行 `menu_backfill_permission_code.sql`) | | |
| 344 | - | |
| 345 | -### UI 权限码 → 菜单 Router 映射(当前库) | |
| 346 | - | |
| 347 | -| accessPermissions(UI) | 绑定菜单 Router | | |
| 348 | -|-------------------------|-----------------| | |
| 349 | -| `manage_labels` | `/labeling`、`/labels`、`/label-categories`、`/label-types`、`/label-templates` | | |
| 350 | -| `manage_people` | `/account-management` | | |
| 351 | -| `edit_settings` | `/menu-management`、`/multiple-options` | | |
| 352 | -| `view_reports` | `/reports` | | |
| 353 | -| `manage_products` | (当前 `Menu` 表无 Products 路由,勾选不绑定菜单,**不单独报错**) | | |
| 354 | -| `approve_batches` | (当前无对应菜单路由,同上) | | |
| 355 | - | |
| 356 | -> 至少 **1 个** 权限码能匹配到菜单即保存成功;若 **全部** 均无法匹配(例如只勾 `manage_products` 且库中无对应菜单),仍返回业务错误。 | |
| 357 | - | |
| 358 | -### 请求示例(与前端一致) | |
| 359 | - | |
| 360 | -```http | |
| 361 | -PUT /api/app/rbac-role/3a1f077b-3665-63f2-5fea-0fd7e7044b88 | |
| 362 | -Content-Type: application/json | |
| 363 | -Authorization: Bearer {token} | |
| 364 | -``` | |
| 365 | - | |
| 366 | -```json | |
| 367 | -{ | |
| 368 | - "roleName": "Partner Admin", | |
| 369 | - "roleCode": "admin", | |
| 370 | - "remark": "Admin", | |
| 371 | - "dataScope": 0, | |
| 372 | - "state": true, | |
| 373 | - "orderNum": 999, | |
| 374 | - "accessPermissions": "[\"manage_labels\",\"edit_settings\",\"view_reports\",\"manage_people\",\"manage_products\",\"approve_batches\"]" | |
| 375 | -} | |
| 376 | -``` | |
| 377 | - | |
| 378 | -也可使用逗号分隔(旧格式): | |
| 379 | - | |
| 380 | -```json | |
| 381 | -{ | |
| 382 | - "accessPermissions": "manage_labels, view_reports, manage_people" | |
| 383 | -} | |
| 384 | -``` | |
| 385 | - | |
| 386 | -或同时传数组字段(与 `accessPermissions` 合并去重): | |
| 387 | - | |
| 388 | -```json | |
| 389 | -{ | |
| 390 | - "accessPermissionCodes": ["manage_labels", "view_reports"] | |
| 391 | -} | |
| 392 | -``` | |
| 393 | - | |
| 394 | -### 入参优先级(与 5-18 一致) | |
| 395 | - | |
| 396 | -| menuIds | accessPermissions / accessPermissionCodes | 行为 | | |
| 397 | -|---------|-------------------------------------------|------| | |
| 398 | -| 非空数组 | 任意 | **以 menuIds 为准** | | |
| 399 | -| 不传 | 非空 | 按 UI 权限码映射菜单并覆盖 `RoleMenu` | | |
| 400 | -| 不传 | `""` 或空数组 | 清空 `RoleMenu` 与 `AccessPermissionCodes` | | |
| 401 | -| `[]` | 不传 | 清空绑定 | | |
| 402 | - | |
| 403 | -### 响应回显 | |
| 404 | - | |
| 405 | -| 字段 | 说明 | | |
| 406 | -|------|------| | |
| 407 | -| `accessPermissionCodes` | 来自 **`Role.AccessPermissionCodes`**,如 `["manage_labels","view_reports"]` | | |
| 408 | -| `accessPermissions` | 已绑定菜单的 **`menu.xxx`** 汇总(逗号拼接,只读展示) | | |
| 409 | -| `menuIds` | 已绑定菜单 Guid 列表(`RoleMenu`) | | |
| 410 | - | |
| 411 | -### 数据库准备(推荐) | |
| 412 | - | |
| 413 | -```bash | |
| 414 | -美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/menu_backfill_permission_code.sql | |
| 415 | -``` | |
| 416 | - | |
| 417 | -### 联调注意 | |
| 418 | - | |
| 419 | -| 现象 | 处理 | | |
| 420 | -|------|------| | |
| 421 | -| 仍报未匹配到菜单 | 确认已部署本修复;检查 `Menu` 是否存在上表 Router | | |
| 422 | -| 只勾 Products/Batches 报错 | 当前库无对应菜单属预期;请同时勾选 Labels/Reports 等 | | |
| 423 | -| 同时传 `menuIds: []` | **menuIds 优先**,会清空绑定并忽略 accessPermissions | | |
| 424 | - | |
| 425 | -> 更完整的 RBAC 说明见 **`5-18接口优化.md`** → rbac-role 章节。 | |
| 426 | - | |
| 427 | ---- | |
| 428 | - | |
| 429 | -## auth-scope 登录后 Company · Region · Location 级联选店 | |
| 430 | - | |
| 431 | -**应用服务**:`AuthScopeAppService` | |
| 432 | -**适用场景**:Web `POST /api/app/account/login` 或 App `POST /api/app/us-app-auth/login` 取得 Token 后,**管理员**无 `userlocation` 绑定时需先选工作门店;亦支持非管理员在数据范围内级联选择(须已绑定该门店)。 | |
| 433 | - | |
| 434 | -**命名约定**(与 5-17 一致):UI **Company** = **`partnerId`**(`fl_partner.Id`);UI **Region** = **`groupId`**(`fl_group.Id`);UI **Location** = **`locationId`**(`location.Id`,Guid 字符串)。 | |
| 435 | - | |
| 436 | -### 接口一览 | |
| 437 | - | |
| 438 | -| 步骤 | 方法 | 路径 | 说明 | | |
| 439 | -|------|------|------|------| | |
| 440 | -| 1 | GET | `/api/app/auth-scope/companies` | 可选公司列表 | | |
| 441 | -| 2 | GET | `/api/app/auth-scope/regions?partnerId={partnerId}` | 指定公司下 Region | | |
| 442 | -| 3 | GET | `/api/app/auth-scope/locations?partnerId={partnerId}&groupId={groupId}` | 指定公司+Region 下门店 | | |
| 443 | -| 4 | POST | `/api/app/auth-scope/select-location` | 确认当前工作门店 | | |
| 444 | -| — | GET | `/api/app/auth-scope/current-scope` | 查询已选工作门店(未选返回 `null`) | | |
| 445 | - | |
| 446 | -**鉴权**:均需 `Authorization: Bearer {token}`。 | |
| 447 | - | |
| 448 | -### 数据范围 | |
| 449 | - | |
| 450 | -| 角色 | Company | Region | Location | | |
| 451 | -|------|---------|--------|----------| | |
| 452 | -| **管理员**(`admin` / 用户名 `admin` / 权限 `*:*:*`) | 全部未删除公司 | 该公司下全部 Region | 该 Region 下全部门店(`location.Partner` + `location.GroupName` 与 `fl_group` 一致) | | |
| 453 | -| **非管理员** | `userlocation` 绑定门店所属公司 | 绑定门店对应 Region | 上述 Region 内且符合 `LocationRegionScopeHelper` 的门店;**选店时**须已绑定该 `locationId` | | |
| 454 | - | |
| 455 | -### 1)公司列表 | |
| 456 | - | |
| 457 | -```http | |
| 458 | -GET /api/app/auth-scope/companies HTTP/1.1 | |
| 459 | -Authorization: Bearer {token} | |
| 460 | -``` | |
| 461 | - | |
| 462 | -**响应**:`AuthScopeCompanyOptionDto[]` | |
| 463 | - | |
| 464 | -```json | |
| 465 | -[ | |
| 466 | - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true } | |
| 467 | -] | |
| 468 | -``` | |
| 469 | - | |
| 470 | -### 2)Region 列表 | |
| 471 | - | |
| 472 | -```http | |
| 473 | -GET /api/app/auth-scope/regions?partnerId=fl_partner_id_1 HTTP/1.1 | |
| 474 | -Authorization: Bearer {token} | |
| 475 | -``` | |
| 476 | - | |
| 477 | -**响应**:`AuthScopeRegionOptionDto[]` | |
| 478 | - | |
| 479 | -```json | |
| 480 | -[ | |
| 481 | - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true } | |
| 482 | -] | |
| 483 | -``` | |
| 484 | - | |
| 485 | -### 3)门店列表 | |
| 486 | - | |
| 487 | -```http | |
| 488 | -GET /api/app/auth-scope/locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1 | |
| 489 | -Authorization: Bearer {token} | |
| 490 | -``` | |
| 491 | - | |
| 492 | -**响应**:`AuthScopeLocationOptionDto[]`(含 `fullAddress`、`groupName` 等) | |
| 493 | - | |
| 494 | -### 4)确认选店(与现有 App 逻辑对齐) | |
| 495 | - | |
| 496 | -```http | |
| 497 | -POST /api/app/auth-scope/select-location HTTP/1.1 | |
| 498 | -Authorization: Bearer {token} | |
| 499 | -Content-Type: application/json | |
| 500 | -``` | |
| 501 | - | |
| 502 | -```json | |
| 503 | -{ | |
| 504 | - "partnerId": "fl_partner_id_1", | |
| 505 | - "groupId": "fl_group_id_east", | |
| 506 | - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f" | |
| 507 | -} | |
| 508 | -``` | |
| 509 | - | |
| 510 | -**响应**:`AuthScopeSelectLocationOutputDto` | |
| 511 | - | |
| 512 | -| 字段 | 说明 | | |
| 513 | -|------|------| | |
| 514 | -| partnerId / partnerName | 所选公司 | | |
| 515 | -| groupId / groupName | 所选 Region | | |
| 516 | -| location | 与 **`UsAppBoundLocationDto`** 相同(`id`、`locationCode`、`locationName`、`fullAddress`、`state`) | | |
| 517 | - | |
| 518 | -**选店后的服务端行为**(无需改前端即可对接 App): | |
| 519 | - | |
| 520 | -| 能力 | 行为 | | |
| 521 | -|------|------| | |
| 522 | -| **工作范围缓存** | 写入分布式缓存(24h);退出 `POST /api/app/auth-session/logout` 时清除 | | |
| 523 | -| **`GET /api/app/us-app-auth/my-locations`** | 管理员在缓存选店后,列表 **合并** 该门店(与 `userlocation` 并集) | | |
| 524 | -| **`GET .../location-detail/{locationId}`** | 管理员可不依赖 `userlocation` 访问已选门店(`UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync`) | | |
| 525 | -| **App 打印/报表** | 仍传 `locationId`;权限规则不变(见 `5-18接口优化.md`) | | |
| 526 | - | |
| 527 | -### 5)当前工作范围 | |
| 528 | - | |
| 529 | -```http | |
| 530 | -GET /api/app/auth-scope/current-scope HTTP/1.1 | |
| 531 | -Authorization: Bearer {token} | |
| 532 | -``` | |
| 533 | - | |
| 534 | -未选店时响应体为 **`null`**(HTTP 200)。 | |
| 535 | - | |
| 536 | -### 联调注意 | |
| 537 | - | |
| 538 | -| 现象 | 处理 | | |
| 539 | -|------|------| | |
| 540 | -| `regions` 为空 | 公司下无 `fl_group` 或当前账号无 Region 数据范围 | | |
| 541 | -| `locations` 为空 | 门店 `Partner` / `GroupName` 未与 `fl_partner`、`fl_group` 对齐 | | |
| 542 | -| 选店报「门店与所选公司/区域不匹配」 | 检查 `location.Partner`、`location.GroupName` | | |
| 543 | -| 非管理员选店报未绑定 | 须在 **Team Member** 中为该账号绑定该门店 | | |
| 544 | -| 选店后 `my-locations` 仍为空 | 确认已调 `select-location` 且 Token 为管理员身份 | | |
| 545 | - | |
| 546 | -> Web 管理端报表等模块仍可按 Query 传 `partnerId` / `groupId` / `locationId` 收窄;本组接口主要解决 **登录后选工作门店** 与 **App 门店列表** 一致性问题。 | |
| 547 | - | |
| 548 | ---- | |
| 549 | - | |
| 550 | -## us-app-auth App 管理员级联选店 | |
| 551 | - | |
| 552 | -**应用服务**:`UsAppAuthAppService` | |
| 553 | -**适用场景**:App 使用 **`POST /api/app/us-app-auth/login`** 登录后,持 **管理员** 身份(`admin` 角色 / 用户名 `admin` / 权限 `*:*:*`)且 JWT 含 **`client_kind=us-app`**,按 Company → Region 筛选门店。 | |
| 554 | - | |
| 555 | -**与 `auth-scope` 关系**:查询逻辑共用 `AuthScopeQueryHelper`;App 侧路径统一在 **`us-app-auth`** 下,并 **强制 App Token + 管理员**,避免误用 Web Token。 | |
| 556 | - | |
| 557 | -### 接口一览 | |
| 558 | - | |
| 559 | -| 步骤 | 方法 | 路径 | 说明 | | |
| 560 | -|------|------|------|------| | |
| 561 | -| 0 | POST | `/api/app/us-app-auth/login` | 获取 App Token(须管理员账号) | | |
| 562 | -| 1 | GET | `/api/app/us-app-auth/admin-scope-companies` | 公司列表 → 取 `id` 作 `partnerId` | | |
| 563 | -| 2 | GET | `/api/app/us-app-auth/admin-scope-regions?partnerId={partnerId}` | Region 列表 → 取 `id` 作 `groupId` | | |
| 564 | -| 3 | GET | `/api/app/us-app-auth/admin-scope-locations?partnerId={partnerId}&groupId={groupId}` | **按公司与 Region Id 筛选门店** | | |
| 565 | -| 4 | POST | `/api/app/us-app-auth/select-admin-scope-location` | 确认工作门店 | | |
| 566 | -| — | GET | `/api/app/us-app-auth/my-locations` | 选店后刷新绑定门店(含缓存门店) | | |
| 567 | - | |
| 568 | -**鉴权**:步骤 1–4 须 Header `Authorization: Bearer {App登录返回的token}`。 | |
| 569 | - | |
| 570 | -### 前置条件 | |
| 571 | - | |
| 572 | -| 项 | 要求 | | |
| 573 | -|----|------| | |
| 574 | -| Token 来源 | 必须来自 **`/api/app/us-app-auth/login`**(非 Web `/api/app/account/login`) | | |
| 575 | -| JWT 声明 | `client_kind` = `us-app` | | |
| 576 | -| 角色 | 平台管理员(`ReportsRoleHelper.IsAdminRole`) | | |
| 577 | -| 违反时 | `请使用 App 登录令牌调用该接口` 或 `仅管理员可使用公司/区域/门店筛选接口` | | |
| 578 | - | |
| 579 | -### 1)公司列表 | |
| 580 | - | |
| 581 | -```http | |
| 582 | -GET /api/app/us-app-auth/admin-scope-companies HTTP/1.1 | |
| 583 | -Authorization: Bearer {app_token} | |
| 584 | -``` | |
| 585 | - | |
| 586 | -**响应**:`AuthScopeCompanyOptionDto[]`(与 auth-scope 相同) | |
| 587 | - | |
| 588 | -```json | |
| 589 | -[ | |
| 590 | - { "id": "fl_partner_id_1", "partnerName": "Acme Foods", "state": true } | |
| 591 | -] | |
| 592 | -``` | |
| 593 | - | |
| 594 | -### 2)Region 列表 | |
| 595 | - | |
| 596 | -```http | |
| 597 | -GET /api/app/us-app-auth/admin-scope-regions?partnerId=fl_partner_id_1 HTTP/1.1 | |
| 598 | -Authorization: Bearer {app_token} | |
| 599 | -``` | |
| 600 | - | |
| 601 | -**响应**:`AuthScopeRegionOptionDto[]` | |
| 602 | - | |
| 603 | -```json | |
| 604 | -[ | |
| 605 | - { "id": "fl_group_id_east", "groupName": "East Region", "partnerId": "fl_partner_id_1", "state": true } | |
| 606 | -] | |
| 607 | -``` | |
| 608 | - | |
| 609 | -### 3)门店列表(按 partnerId + groupId 筛选) | |
| 610 | - | |
| 611 | -```http | |
| 612 | -GET /api/app/us-app-auth/admin-scope-locations?partnerId=fl_partner_id_1&groupId=fl_group_id_east HTTP/1.1 | |
| 613 | -Authorization: Bearer {app_token} | |
| 614 | -``` | |
| 615 | - | |
| 616 | -**Query** | |
| 617 | - | |
| 618 | -| 参数 | 必填 | 说明 | | |
| 619 | -|------|------|------| | |
| 620 | -| partnerId | 是 | 公司 Id(`fl_partner.Id`) | | |
| 621 | -| groupId | 是 | Region Id(`fl_group.Id`) | | |
| 622 | - | |
| 623 | -**响应**:`AuthScopeLocationOptionDto[]` | |
| 624 | - | |
| 625 | -```json | |
| 626 | -[ | |
| 627 | - { | |
| 628 | - "id": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f", | |
| 629 | - "locationCode": "LOC-1", | |
| 630 | - "locationName": "Downtown Kitchen", | |
| 631 | - "fullAddress": "123 Main St, New York, NY 10001", | |
| 632 | - "state": true, | |
| 633 | - "partnerId": "fl_partner_id_1", | |
| 634 | - "groupId": "fl_group_id_east", | |
| 635 | - "groupName": "East Region" | |
| 636 | - } | |
| 637 | -] | |
| 638 | -``` | |
| 639 | - | |
| 640 | -筛选规则:`location.Partner` 匹配该公司(Id 或名称),且 `location.GroupName` 与所选 `fl_group.GroupName` 一致。 | |
| 641 | - | |
| 642 | -### 4)确认选店 | |
| 643 | - | |
| 644 | -```http | |
| 645 | -POST /api/app/us-app-auth/select-admin-scope-location HTTP/1.1 | |
| 646 | -Authorization: Bearer {app_token} | |
| 647 | -Content-Type: application/json | |
| 648 | -``` | |
| 649 | - | |
| 650 | -```json | |
| 651 | -{ | |
| 652 | - "partnerId": "fl_partner_id_1", | |
| 653 | - "groupId": "fl_group_id_east", | |
| 654 | - "locationId": "a2696b9e-2277-11f1-b4c6-00163e0c7c4f" | |
| 655 | -} | |
| 656 | -``` | |
| 657 | - | |
| 658 | -**响应**:`AuthScopeSelectLocationOutputDto`(含 `location` 节点,结构同 `UsAppBoundLocationDto`) | |
| 659 | - | |
| 660 | -### 推荐调用顺序(App) | |
| 661 | - | |
| 662 | -```text | |
| 663 | -POST /api/app/us-app-auth/login | |
| 664 | - → GET admin-scope-companies | |
| 665 | - → GET admin-scope-regions?partnerId=... | |
| 666 | - → GET admin-scope-locations?partnerId=...&groupId=... | |
| 667 | - → POST select-admin-scope-location | |
| 668 | - → GET my-locations | |
| 669 | - → 后续业务接口传 locationId(打印、报表等,规则不变) | |
| 670 | -``` | |
| 671 | - | |
| 672 | -### 联调注意 | |
| 673 | - | |
| 674 | -| 现象 | 处理 | | |
| 675 | -|------|------| | |
| 676 | -| 报「请使用 App 登录令牌」 | 勿用 Web `account/login` 的 Token;须重新 App 登录 | | |
| 677 | -| 报「仅管理员可使用」 | 换管理员账号或绑定 `admin` 角色 | | |
| 678 | -| `locations` 为空 | 核对门店 `Partner`、`GroupName` 与 `fl_partner`、`fl_group` | | |
| 679 | -| 与 auth-scope 重复 | App 端 **优先** 使用本节前缀;Web 端用 `auth-scope` | | |
| 680 | - | |
| 681 | ---- | |
| 682 | - | |
| 683 | -## 变更记录 | |
| 684 | - | |
| 685 | -| 日期 | 说明 | | |
| 686 | -|------|------| | |
| 687 | -| 2026-05-26 | us-app-auth:App 管理员 `admin-scope-companies/regions/locations`、`select-admin-scope-location` | | |
| 688 | -| 2026-05-26 | auth-scope:登录后 Company/Region/Location 级联选店;选店缓存;`my-locations` / 门店详情与管理员选店对齐 | | |
| 689 | -| 2026-05-26 | product-category:`categoryCode` 新增/编辑改为可选 | | |
| 690 | -| 2026-05-26 | label-template:新增/编辑/列表/详情支持 `regionIds`、`locationIds` 及 `region`、`location` 展示 | | |
| 691 | -| 2026-05-26 | label-template 列表 Query 增加 `groupId`(Region)、`locationId`(门店)筛选 | | |
| 692 | -| 2026-05-26 | rbac-role:支持 accessPermissions JSON 数组 + UI 权限码映射 Menu;落库 AccessPermissionCodes | |
泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/android/native_fast_printer-release.aar
No preview for this file type
泰额版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/package.json
| 1 | 1 | { |
| 2 | 2 | "name": "native-fast-printer", |
| 3 | 3 | "id": "native-fast-printer", |
| 4 | - "version": "1.2.8", | |
| 4 | + "version": "1.0.3", | |
| 5 | 5 | "description": "Android高速标签打印原生插件", |
| 6 | 6 | "_dp_type": "nativeplugin", |
| 7 | 7 | "_dp_nativeplugin": { |
| ... | ... | @@ -23,8 +23,7 @@ |
| 23 | 23 | "abis": [ |
| 24 | 24 | "armeabi-v7a", |
| 25 | 25 | "arm64-v8a", |
| 26 | - "x86", | |
| 27 | - "x86_64" | |
| 26 | + "x86" | |
| 28 | 27 | ], |
| 29 | 28 | "minSdkVersion": "21", |
| 30 | 29 | "useAndroidX": true, | ... | ... |
泰额版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1
| ... | ... | @@ -6,12 +6,17 @@ $dstPluginRoot = Join-Path $appRoot "nativeplugins\native-fast-printer" |
| 6 | 6 | $manifestPath = Join-Path $appRoot "src\manifest.json" |
| 7 | 7 | |
| 8 | 8 | Write-Host "[1/4] Validate source plugin files..." |
| 9 | -$sourceAarItem = Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Filter "native_fast_printer-release.aar" | | |
| 10 | - Where-Object { $_.FullName -notlike "*\nativeplugins\native-fast-printer\android\*" } | | |
| 11 | - Sort-Object LastWriteTime -Descending | | |
| 12 | - Select-Object -First 1 | |
| 9 | +$preferredAar = Join-Path $repoRoot "打印机安卓基座\native-fast-printer\android\native_fast_printer-release.aar" | |
| 10 | +$sourceAarItem = $null | |
| 11 | +if (Test-Path -LiteralPath $preferredAar) { | |
| 12 | + $sourceAarItem = Get-Item -LiteralPath $preferredAar | |
| 13 | +} else { | |
| 14 | + $sourceAarItem = Get-ChildItem -LiteralPath (Join-Path $repoRoot "打印机安卓基座") -Recurse -File -Filter "native_fast_printer-release.aar" -ErrorAction SilentlyContinue | | |
| 15 | + Sort-Object LastWriteTime -Descending | | |
| 16 | + Select-Object -First 1 | |
| 17 | +} | |
| 13 | 18 | if ($null -eq $sourceAarItem) { |
| 14 | - throw "AAR not found under repository root: $repoRoot" | |
| 19 | + throw "AAR not found. Build first: 打印机安卓基座/native-fast-printer/android-src/build-aar-windows.bat" | |
| 15 | 20 | } |
| 16 | 21 | |
| 17 | 22 | $srcPluginRoot = Split-Path -Parent (Split-Path -Parent $sourceAarItem.FullName) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <view class="app-date-picker"> | |
| 3 | + <view class="app-date-trigger" @click="openDialog"> | |
| 4 | + <text class="app-date-trigger-text">{{ displayText }}</text> | |
| 5 | + </view> | |
| 6 | + | |
| 7 | + <view v-if="dialogVisible" class="app-date-mask" @click="cancelDialog"> | |
| 8 | + <view class="app-date-dialog" @click.stop> | |
| 9 | + <text class="app-date-dialog-title">{{ dialogTitle }}</text> | |
| 10 | + <picker-view | |
| 11 | + class="app-date-picker-view" | |
| 12 | + :indicator-style="indicatorStyle" | |
| 13 | + :value="selection" | |
| 14 | + @change="onPickerChange" | |
| 15 | + > | |
| 16 | + <picker-view-column> | |
| 17 | + <view v-for="y in years" :key="'y-' + y" class="app-date-picker-item">{{ y }}</view> | |
| 18 | + </picker-view-column> | |
| 19 | + <picker-view-column> | |
| 20 | + <view v-for="(m, idx) in monthLabels" :key="'m-' + idx" class="app-date-picker-item">{{ m }}</view> | |
| 21 | + </picker-view-column> | |
| 22 | + <picker-view-column> | |
| 23 | + <view v-for="d in daysInMonth" :key="'d-' + d" class="app-date-picker-item">{{ d }}</view> | |
| 24 | + </picker-view-column> | |
| 25 | + </picker-view> | |
| 26 | + <view class="app-date-actions"> | |
| 27 | + <view class="app-date-btn app-date-btn-cancel" @click="cancelDialog"> | |
| 28 | + <text>Cancel</text> | |
| 29 | + </view> | |
| 30 | + <view class="app-date-btn app-date-btn-confirm" @click="confirmDialog"> | |
| 31 | + <text>Confirm</text> | |
| 32 | + </view> | |
| 33 | + </view> | |
| 34 | + </view> | |
| 35 | + </view> | |
| 36 | + </view> | |
| 37 | +</template> | |
| 38 | + | |
| 39 | +<script setup lang="ts"> | |
| 40 | +import { computed, ref, watch } from 'vue' | |
| 41 | + | |
| 42 | +const MONTH_LABELS = [ | |
| 43 | + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', | |
| 44 | + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', | |
| 45 | +] as const | |
| 46 | + | |
| 47 | +const props = withDefaults( | |
| 48 | + defineProps<{ | |
| 49 | + modelValue: string | |
| 50 | + /** yyyy-MM-dd */ | |
| 51 | + min?: string | |
| 52 | + max?: string | |
| 53 | + placeholder?: string | |
| 54 | + dialogTitle?: string | |
| 55 | + }>(), | |
| 56 | + { | |
| 57 | + modelValue: '', | |
| 58 | + min: '2000-01-01', | |
| 59 | + max: '2099-12-31', | |
| 60 | + placeholder: 'Select', | |
| 61 | + dialogTitle: 'Select date', | |
| 62 | + }, | |
| 63 | +) | |
| 64 | + | |
| 65 | +const emit = defineEmits<{ | |
| 66 | + 'update:modelValue': [value: string] | |
| 67 | +}>() | |
| 68 | + | |
| 69 | +const dialogVisible = ref(false) | |
| 70 | +const selection = ref<[number, number, number]>([0, 0, 0]) | |
| 71 | +const draftY = ref(2026) | |
| 72 | +const draftM = ref(1) | |
| 73 | +const draftD = ref(1) | |
| 74 | + | |
| 75 | +const indicatorStyle = 'height: 44px;' | |
| 76 | + | |
| 77 | +const years = computed(() => { | |
| 78 | + const minY = parseYmd(props.min).year | |
| 79 | + const maxY = parseYmd(props.max).year | |
| 80 | + const out: number[] = [] | |
| 81 | + for (let y = minY; y <= maxY; y++) out.push(y) | |
| 82 | + return out.length ? out : [new Date().getFullYear()] | |
| 83 | +}) | |
| 84 | + | |
| 85 | +const monthLabels = MONTH_LABELS | |
| 86 | + | |
| 87 | +const daysInMonth = computed(() => { | |
| 88 | + const max = daysInMonthCount(draftY.value, draftM.value) | |
| 89 | + return Array.from({ length: max }, (_, i) => String(i + 1).padStart(2, '0')) | |
| 90 | +}) | |
| 91 | + | |
| 92 | +const displayText = computed(() => { | |
| 93 | + const v = (props.modelValue || '').trim() | |
| 94 | + if (!v) return props.placeholder | |
| 95 | + const p = parseYmd(v) | |
| 96 | + if (!p.valid) return v | |
| 97 | + return `${String(p.month).padStart(2, '0')}/${String(p.day).padStart(2, '0')}/${p.year}` | |
| 98 | +}) | |
| 99 | + | |
| 100 | +function parseYmd(raw: string | undefined): { year: number; month: number; day: number; valid: boolean } { | |
| 101 | + const s = (raw || '').trim() | |
| 102 | + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s) | |
| 103 | + if (!m) { | |
| 104 | + const d = new Date() | |
| 105 | + return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), valid: false } | |
| 106 | + } | |
| 107 | + return { | |
| 108 | + year: Number(m[1]), | |
| 109 | + month: Number(m[2]), | |
| 110 | + day: Number(m[3]), | |
| 111 | + valid: true, | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +function daysInMonthCount(year: number, month: number): number { | |
| 116 | + return new Date(year, month, 0).getDate() | |
| 117 | +} | |
| 118 | + | |
| 119 | +function clampDate(y: number, mo: number, d: number): { y: number; mo: number; d: number } { | |
| 120 | + const minP = parseYmd(props.min) | |
| 121 | + const maxP = parseYmd(props.max) | |
| 122 | + let year = y | |
| 123 | + let month = mo | |
| 124 | + let day = d | |
| 125 | + const maxDay = daysInMonthCount(year, month) | |
| 126 | + if (day > maxDay) day = maxDay | |
| 127 | + | |
| 128 | + const cur = new Date(year, month - 1, day) | |
| 129 | + const minD = new Date(minP.year, minP.month - 1, minP.day) | |
| 130 | + const maxD = new Date(maxP.year, maxP.month - 1, maxP.day) | |
| 131 | + let clamped = cur | |
| 132 | + if (cur < minD) clamped = minD | |
| 133 | + if (cur > maxD) clamped = maxD | |
| 134 | + | |
| 135 | + return { | |
| 136 | + y: clamped.getFullYear(), | |
| 137 | + mo: clamped.getMonth() + 1, | |
| 138 | + d: clamped.getDate(), | |
| 139 | + } | |
| 140 | +} | |
| 141 | + | |
| 142 | +function toYmd(y: number, mo: number, d: number): string { | |
| 143 | + return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}` | |
| 144 | +} | |
| 145 | + | |
| 146 | +function syncSelectionFromDate(y: number, mo: number, d: number) { | |
| 147 | + const yi = Math.max(0, years.value.indexOf(y)) | |
| 148 | + const mi = Math.max(0, Math.min(11, mo - 1)) | |
| 149 | + const maxD = daysInMonthCount(y, mo) | |
| 150 | + const day = Math.min(d, maxD) | |
| 151 | + const di = Math.max(0, day - 1) | |
| 152 | + draftY.value = y | |
| 153 | + draftM.value = mo | |
| 154 | + draftD.value = day | |
| 155 | + selection.value = [yi, mi, di] | |
| 156 | +} | |
| 157 | + | |
| 158 | +function openDialog() { | |
| 159 | + const base = parseYmd(props.modelValue) | |
| 160 | + const clamped = clampDate(base.year, base.month, base.day) | |
| 161 | + syncSelectionFromDate(clamped.y, clamped.mo, clamped.d) | |
| 162 | + dialogVisible.value = true | |
| 163 | +} | |
| 164 | + | |
| 165 | +function onPickerChange(e: { detail: { value: number[] } }) { | |
| 166 | + const arr = e.detail.value || [0, 0, 0] | |
| 167 | + const y = years.value[arr[0]] ?? draftY.value | |
| 168 | + const mo = (arr[1] ?? 0) + 1 | |
| 169 | + let d = (arr[2] ?? 0) + 1 | |
| 170 | + const clamped = clampDate(y, mo, d) | |
| 171 | + syncSelectionFromDate(clamped.y, clamped.mo, clamped.d) | |
| 172 | +} | |
| 173 | + | |
| 174 | +function cancelDialog() { | |
| 175 | + dialogVisible.value = false | |
| 176 | +} | |
| 177 | + | |
| 178 | +function confirmDialog() { | |
| 179 | + const v = toYmd(draftY.value, draftM.value, draftD.value) | |
| 180 | + emit('update:modelValue', v) | |
| 181 | + dialogVisible.value = false | |
| 182 | +} | |
| 183 | + | |
| 184 | +watch( | |
| 185 | + () => props.modelValue, | |
| 186 | + (v) => { | |
| 187 | + if (dialogVisible.value) return | |
| 188 | + const p = parseYmd(v) | |
| 189 | + if (p.valid) { | |
| 190 | + const c = clampDate(p.year, p.month, p.day) | |
| 191 | + draftY.value = c.y | |
| 192 | + draftM.value = c.mo | |
| 193 | + draftD.value = c.d | |
| 194 | + } | |
| 195 | + }, | |
| 196 | +) | |
| 197 | +</script> | |
| 198 | + | |
| 199 | +<style scoped> | |
| 200 | +.app-date-trigger { | |
| 201 | + height: 72rpx; | |
| 202 | + padding: 0 24rpx; | |
| 203 | + background: #fff; | |
| 204 | + border: 2rpx solid #e5e7eb; | |
| 205 | + border-radius: 12rpx; | |
| 206 | + display: flex; | |
| 207 | + align-items: center; | |
| 208 | + justify-content: center; | |
| 209 | + box-sizing: border-box; | |
| 210 | +} | |
| 211 | + | |
| 212 | +.app-date-trigger-text { | |
| 213 | + font-size: 28rpx; | |
| 214 | + color: #111827; | |
| 215 | +} | |
| 216 | + | |
| 217 | +.app-date-mask { | |
| 218 | + position: fixed; | |
| 219 | + left: 0; | |
| 220 | + right: 0; | |
| 221 | + top: 0; | |
| 222 | + bottom: 0; | |
| 223 | + z-index: 1000; | |
| 224 | + background: rgba(0, 0, 0, 0.45); | |
| 225 | + display: flex; | |
| 226 | + align-items: flex-end; | |
| 227 | + justify-content: center; | |
| 228 | +} | |
| 229 | + | |
| 230 | +.app-date-dialog { | |
| 231 | + width: 100%; | |
| 232 | + background: #fff; | |
| 233 | + border-radius: 24rpx 24rpx 0 0; | |
| 234 | + padding: 28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom)); | |
| 235 | + box-sizing: border-box; | |
| 236 | +} | |
| 237 | + | |
| 238 | +.app-date-dialog-title { | |
| 239 | + display: block; | |
| 240 | + text-align: center; | |
| 241 | + font-size: 32rpx; | |
| 242 | + font-weight: 600; | |
| 243 | + color: #111827; | |
| 244 | + margin-bottom: 16rpx; | |
| 245 | +} | |
| 246 | + | |
| 247 | +.app-date-picker-view { | |
| 248 | + width: 100%; | |
| 249 | + height: 440rpx; | |
| 250 | +} | |
| 251 | + | |
| 252 | +.app-date-picker-item { | |
| 253 | + height: 44px; | |
| 254 | + line-height: 44px; | |
| 255 | + text-align: center; | |
| 256 | + font-size: 30rpx; | |
| 257 | + color: #111827; | |
| 258 | +} | |
| 259 | + | |
| 260 | +.app-date-actions { | |
| 261 | + display: flex; | |
| 262 | + gap: 24rpx; | |
| 263 | + margin-top: 20rpx; | |
| 264 | +} | |
| 265 | + | |
| 266 | +.app-date-btn { | |
| 267 | + flex: 1; | |
| 268 | + height: 88rpx; | |
| 269 | + border-radius: 16rpx; | |
| 270 | + display: flex; | |
| 271 | + align-items: center; | |
| 272 | + justify-content: center; | |
| 273 | + font-size: 30rpx; | |
| 274 | + font-weight: 600; | |
| 275 | +} | |
| 276 | + | |
| 277 | +.app-date-btn-cancel { | |
| 278 | + border: 2rpx solid #d1d5db; | |
| 279 | + color: #374151; | |
| 280 | + background: #fff; | |
| 281 | +} | |
| 282 | + | |
| 283 | +.app-date-btn-confirm { | |
| 284 | + background: var(--theme-primary, #4f46e5); | |
| 285 | + color: #fff; | |
| 286 | +} | |
| 287 | +</style> | ... | ... |
泰额版/Food Labeling Management App UniApp/src/locales/en.ts
| ... | ... | @@ -50,6 +50,14 @@ export default { |
| 50 | 50 | selectStoreDesc: 'Select the store where you\'ll be working today', |
| 51 | 51 | selectStoreError: 'Please select a store', |
| 52 | 52 | storeSelected: 'Store selected successfully', |
| 53 | + scopeCompany: 'Company', | |
| 54 | + scopeRegion: 'Region', | |
| 55 | + scopeLoading: 'Loading…', | |
| 56 | + scopeNoCompanies: 'No companies available', | |
| 57 | + scopeNoRegions: 'No regions for this company', | |
| 58 | + selectCompanyRegionFirst: 'Select company and region first', | |
| 59 | + scopeLoadFail: 'Could not load options', | |
| 60 | + scopeSelectFail: 'Could not confirm store', | |
| 53 | 61 | store1: 'Downtown Kitchen', store2: 'Brooklyn Central', store3: 'Queens Food Hub', store4: 'Manhattan Express', |
| 54 | 62 | }, |
| 55 | 63 | dashboard: { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/locales/zh.ts
| ... | ... | @@ -50,6 +50,14 @@ export default { |
| 50 | 50 | selectStoreDesc: '选择您今天工作的店铺', |
| 51 | 51 | selectStoreError: '请选择一个店铺', |
| 52 | 52 | storeSelected: '店铺选择成功', |
| 53 | + scopeCompany: '公司', | |
| 54 | + scopeRegion: '区域', | |
| 55 | + scopeLoading: '加载中…', | |
| 56 | + scopeNoCompanies: '暂无可用公司', | |
| 57 | + scopeNoRegions: '该公司下暂无区域', | |
| 58 | + selectCompanyRegionFirst: '请先选择公司与区域', | |
| 59 | + scopeLoadFail: '选项加载失败', | |
| 60 | + scopeSelectFail: '确认门店失败', | |
| 53 | 61 | store1: '市中心厨房', store2: '布鲁克林中心', store3: '皇后食品中心', store4: '曼哈顿快速店', |
| 54 | 62 | }, |
| 55 | 63 | dashboard: { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
| ... | ... | @@ -225,8 +225,8 @@ |
| 225 | 225 | <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text> |
| 226 | 226 | <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text> |
| 227 | 227 | <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text> |
| 228 | - <text class="tips-item">4. Devices with no name show as "Unknown Device"—you can still connect</text> | |
| 229 | - <text class="tips-item">5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering</text> | |
| 228 | + <text class="tips-item">4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer</text> | |
| 229 | + <text class="tips-item">5. Pair the printer in system Bluetooth first if it does not appear after Scan</text> | |
| 230 | 230 | <text class="tips-item">6. Restart the printer or app if not visible</text> |
| 231 | 231 | <text class="tips-item tips-item-last"> |
| 232 | 232 | 7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only. |
| ... | ... | @@ -290,6 +290,7 @@ import { |
| 290 | 290 | setBuiltinPrinter, |
| 291 | 291 | } from '../../utils/print/printerConnection' |
| 292 | 292 | import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' |
| 293 | +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist' | |
| 293 | 294 | import { |
| 294 | 295 | getNativeFastPrinterDebugInfo, |
| 295 | 296 | getNativeFastPrinterState, |
| ... | ... | @@ -432,14 +433,12 @@ function handleResetUposOptions () { |
| 432 | 433 | } |
| 433 | 434 | |
| 434 | 435 | function hasPreferredClassicDeviceInList () { |
| 435 | - return devices.value.some((item: any) => { | |
| 436 | - const name = String(item?.name || '').toLowerCase() | |
| 437 | - const driverKey = String(item?.driverKey || '').toLowerCase() | |
| 438 | - return name.includes('virtual bt printer') || driverKey === 'd320fax' | |
| 439 | - }) | |
| 436 | + return devices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name)) | |
| 440 | 437 | } |
| 441 | 438 | |
| 442 | 439 | function upsertDevice (device: any) { |
| 440 | + const rawName = (device?.name || device?.localName || '').trim() | |
| 441 | + if (!isAllowedBluetoothPrinterName(rawName)) return | |
| 443 | 442 | const described = describeDiscoveredPrinter(device) |
| 444 | 443 | const existing = devices.value.find(item => item.deviceId === described.deviceId) |
| 445 | 444 | if (!existing) { |
| ... | ... | @@ -573,7 +572,9 @@ function addPairedDevices () { |
| 573 | 572 | try { |
| 574 | 573 | const paired = classic.getPairedDevices() |
| 575 | 574 | debugInfo.value.pairedCount = (paired || []).length |
| 576 | - debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer')) | |
| 575 | + debugInfo.value.foundVirtualPrinter = (paired || []).some((item: any) => | |
| 576 | + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'), | |
| 577 | + ) | |
| 577 | 578 | for (const p of paired) { |
| 578 | 579 | upsertDevice({ |
| 579 | 580 | deviceId: p.deviceId, | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
| ... | ... | @@ -105,16 +105,20 @@ |
| 105 | 105 | :key="productCategoryRowKey(pCat, pIdx)" |
| 106 | 106 | class="cat-section" |
| 107 | 107 | > |
| 108 | - <view class="cat-header" @click="toggleCategory(productCategoryRowKey(pCat, pIdx))"> | |
| 109 | - <view class="cat-header-left"> | |
| 108 | + <view | |
| 109 | + class="cat-header" | |
| 110 | + :style="catHeaderRowStyle" | |
| 111 | + @click="toggleCategory(productCategoryRowKey(pCat, pIdx))" | |
| 112 | + > | |
| 113 | + <view class="cat-header-left" :style="catHeaderLeftStyle"> | |
| 110 | 114 | <view |
| 111 | - class="cat-icon cat-header-thumb" | |
| 115 | + class="cat-header-thumb" | |
| 112 | 116 | :class=" |
| 113 | 117 | productCategoryVisual(pCat).mode === 'image' |
| 114 | - ? 'cat-icon--photo' | |
| 115 | - : 'cat-icon--fallback' | |
| 118 | + ? 'cat-header-thumb--photo' | |
| 119 | + : 'cat-header-thumb--fallback' | |
| 116 | 120 | " |
| 117 | - :style="productCategoryThumbStyle(pCat)" | |
| 121 | + :style="catHeaderThumbStyle(pCat)" | |
| 118 | 122 | > |
| 119 | 123 | <image |
| 120 | 124 | v-if="productCategoryVisual(pCat).mode === 'image'" |
| ... | ... | @@ -144,25 +148,28 @@ |
| 144 | 148 | <AppIcon name="food" size="sm" color="white" /> |
| 145 | 149 | </view> |
| 146 | 150 | </view> |
| 147 | - <view class="cat-header-info"> | |
| 151 | + <view class="cat-header-info" :style="catHeaderInfoStyle"> | |
| 148 | 152 | <text class="cat-header-name">{{ pCat.name }}</text> |
| 149 | 153 | <text class="cat-header-count">{{ displayProductCategoryItemCount(pCat) }} items</text> |
| 150 | 154 | </view> |
| 151 | 155 | </view> |
| 152 | - <AppIcon | |
| 153 | - :name=" | |
| 154 | - expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0 | |
| 155 | - ? 'chevronUp' | |
| 156 | - : 'chevronDown' | |
| 157 | - " | |
| 158 | - size="sm" | |
| 159 | - color="gray" | |
| 160 | - /> | |
| 156 | + <view class="cat-header-chevron" :style="catHeaderChevronStyle"> | |
| 157 | + <AppIcon | |
| 158 | + :name=" | |
| 159 | + expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0 | |
| 160 | + ? 'chevronUp' | |
| 161 | + : 'chevronDown' | |
| 162 | + " | |
| 163 | + size="sm" | |
| 164 | + color="gray" | |
| 165 | + /> | |
| 166 | + </view> | |
| 161 | 167 | </view> |
| 162 | 168 | |
| 163 | 169 | <view |
| 164 | 170 | v-if="expandedCategories.indexOf(productCategoryRowKey(pCat, pIdx)) >= 0" |
| 165 | 171 | class="cat-foods" |
| 172 | + :style="catFoodsStyle" | |
| 166 | 173 | > |
| 167 | 174 | <view class="food-grid"> |
| 168 | 175 | <view |
| ... | ... | @@ -174,35 +181,35 @@ |
| 174 | 181 | <view |
| 175 | 182 | class="food-img-wrap" |
| 176 | 183 | :class=" |
| 177 | - productVisual(product).mode === 'image' | |
| 184 | + productVisual(product, pCat).mode === 'image' | |
| 178 | 185 | ? 'food-img-wrap--photo' |
| 179 | 186 | : 'food-img-wrap--fallback' |
| 180 | 187 | " |
| 181 | - :style="productThumbWrapStyle(product)" | |
| 188 | + :style="productThumbWrapStyle(product, pCat)" | |
| 182 | 189 | > |
| 183 | 190 | <image |
| 184 | - v-if="productVisual(product).mode === 'image'" | |
| 185 | - :src="resolveMediaUrlForApp(productVisual(product).imageUrl) || ''" | |
| 191 | + v-if="productVisual(product, pCat).mode === 'image'" | |
| 192 | + :src="resolveMediaUrlForApp(productVisual(product, pCat).imageUrl) || ''" | |
| 186 | 193 | class="food-img" |
| 187 | 194 | mode="aspectFill" |
| 188 | 195 | /> |
| 189 | 196 | <text |
| 190 | - v-else-if="productVisual(product).mode === 'colorText'" | |
| 197 | + v-else-if="productVisual(product, pCat).mode === 'colorText'" | |
| 191 | 198 | class="food-thumb-text food-thumb-text--on-color" |
| 192 | - :style="{ color: productVisual(product).textColor || '#ffffff' }" | |
| 199 | + :style="{ color: productVisual(product, pCat).textColor || '#ffffff' }" | |
| 193 | 200 | > |
| 194 | - {{ productVisual(product).text }} | |
| 201 | + {{ productVisual(product, pCat).text }} | |
| 195 | 202 | </text> |
| 196 | 203 | <text |
| 197 | - v-else-if="productVisual(product).mode === 'text'" | |
| 204 | + v-else-if="productVisual(product, pCat).mode === 'text'" | |
| 198 | 205 | class="food-thumb-text" |
| 199 | 206 | > |
| 200 | - {{ productVisual(product).text }} | |
| 207 | + {{ productVisual(product, pCat).text }} | |
| 201 | 208 | </text> |
| 202 | 209 | <view |
| 203 | - v-else-if="productVisual(product).mode === 'color'" | |
| 210 | + v-else-if="productVisual(product, pCat).mode === 'color'" | |
| 204 | 211 | class="food-thumb-color-fill" |
| 205 | - :style="{ backgroundColor: productVisual(product).bg }" | |
| 212 | + :style="{ backgroundColor: productVisual(product, pCat).bg }" | |
| 206 | 213 | /> |
| 207 | 214 | <view |
| 208 | 215 | v-else |
| ... | ... | @@ -418,10 +425,76 @@ function productCategoryVisual(p: UsAppProductCategoryNodeDto): CategoryVisualRe |
| 418 | 425 | }) |
| 419 | 426 | } |
| 420 | 427 | |
| 421 | -function productCategoryThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, string> { | |
| 428 | +/** 安卓基座对内联 style 命中最稳;勿与侧栏共用 .cat-icon(64rpx) */ | |
| 429 | +const catHeaderRowStyle: Record<string, string> = { | |
| 430 | + padding: '16rpx 20rpx', | |
| 431 | + display: 'flex', | |
| 432 | + flexDirection: 'row', | |
| 433 | + alignItems: 'center', | |
| 434 | + justifyContent: 'space-between', | |
| 435 | + boxSizing: 'border-box', | |
| 436 | + width: '100%', | |
| 437 | +} | |
| 438 | + | |
| 439 | +const catHeaderLeftStyle: Record<string, string> = { | |
| 440 | + display: 'flex', | |
| 441 | + flexDirection: 'row', | |
| 442 | + alignItems: 'center', | |
| 443 | + flex: '1', | |
| 444 | + minWidth: '0', | |
| 445 | +} | |
| 446 | + | |
| 447 | +const catHeaderInfoStyle: Record<string, string> = { | |
| 448 | + flex: '1', | |
| 449 | + minWidth: '0', | |
| 450 | + marginLeft: '0', | |
| 451 | + paddingLeft: '0', | |
| 452 | +} | |
| 453 | + | |
| 454 | +const catHeaderChevronStyle: Record<string, string> = { | |
| 455 | + flexShrink: '0', | |
| 456 | + width: '44rpx', | |
| 457 | + height: '44rpx', | |
| 458 | + marginLeft: '8rpx', | |
| 459 | + display: 'flex', | |
| 460 | + alignItems: 'center', | |
| 461 | + justifyContent: 'center', | |
| 462 | +} | |
| 463 | + | |
| 464 | +const catFoodsStyle: Record<string, string> = { | |
| 465 | + boxSizing: 'border-box', | |
| 466 | + paddingTop: '4rpx', | |
| 467 | + paddingRight: '20rpx', | |
| 468 | + paddingBottom: '16rpx', | |
| 469 | + paddingLeft: '20rpx', | |
| 470 | +} | |
| 471 | + | |
| 472 | +function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, string> { | |
| 422 | 473 | const v = productCategoryVisual(p) |
| 423 | - if (v.mode === 'colorText') return { backgroundColor: v.bg } | |
| 424 | - return {} | |
| 474 | + const style: Record<string, string> = { | |
| 475 | + width: '52rpx', | |
| 476 | + height: '52rpx', | |
| 477 | + minWidth: '52rpx', | |
| 478 | + minHeight: '52rpx', | |
| 479 | + maxWidth: '52rpx', | |
| 480 | + maxHeight: '52rpx', | |
| 481 | + marginRight: '16rpx', | |
| 482 | + marginBottom: '0', | |
| 483 | + marginTop: '0', | |
| 484 | + marginLeft: '0', | |
| 485 | + flexShrink: '0', | |
| 486 | + borderRadius: '12rpx', | |
| 487 | + overflow: 'hidden', | |
| 488 | + display: 'flex', | |
| 489 | + alignItems: 'center', | |
| 490 | + justifyContent: 'center', | |
| 491 | + boxSizing: 'border-box', | |
| 492 | + backgroundColor: '#f3f4f6', | |
| 493 | + } | |
| 494 | + if (v.mode === 'colorText' || v.mode === 'color') { | |
| 495 | + style.backgroundColor = v.bg | |
| 496 | + } | |
| 497 | + return style | |
| 425 | 498 | } |
| 426 | 499 | |
| 427 | 500 | function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string { |
| ... | ... | @@ -444,7 +517,10 @@ function colorClassForName(name: string): string { |
| 444 | 517 | return COLOR_CLASSES[Math.abs(h) % COLOR_CLASSES.length] |
| 445 | 518 | } |
| 446 | 519 | |
| 447 | -function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { | |
| 520 | +function productVisual( | |
| 521 | + p: UsAppLabelingProductNodeDto, | |
| 522 | + pCat?: UsAppProductCategoryNodeDto, | |
| 523 | +): CategoryVisualRender { | |
| 448 | 524 | const v = resolveCategoryButtonVisualFromDto({ |
| 449 | 525 | buttonStyleJson: p.buttonStyleJson, |
| 450 | 526 | buttonAppearance: p.buttonAppearance, |
| ... | ... | @@ -457,13 +533,20 @@ function productVisual(p: UsAppLabelingProductNodeDto): CategoryVisualRender { |
| 457 | 533 | name: p.productName, |
| 458 | 534 | }) |
| 459 | 535 | if (v.mode !== 'none') return v |
| 536 | + if (pCat) { | |
| 537 | + const fromCat = productCategoryVisual(pCat) | |
| 538 | + if (fromCat.mode !== 'none') return fromCat | |
| 539 | + } | |
| 460 | 540 | const legacyImg = (p.productImageUrl ?? '').trim() |
| 461 | 541 | if (legacyImg) return { mode: 'image', imageUrl: legacyImg } |
| 462 | 542 | return { mode: 'none' } |
| 463 | 543 | } |
| 464 | 544 | |
| 465 | -function productThumbWrapStyle(p: UsAppLabelingProductNodeDto): Record<string, string> { | |
| 466 | - const v = productVisual(p) | |
| 545 | +function productThumbWrapStyle( | |
| 546 | + p: UsAppLabelingProductNodeDto, | |
| 547 | + pCat?: UsAppProductCategoryNodeDto, | |
| 548 | +): Record<string, string> { | |
| 549 | + const v = productVisual(p, pCat) | |
| 467 | 550 | if (v.mode === 'colorText' || v.mode === 'color') { |
| 468 | 551 | return { backgroundColor: v.bg } |
| 469 | 552 | } |
| ... | ... | @@ -482,6 +565,8 @@ function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string { |
| 482 | 565 | |
| 483 | 566 | /** 无商品图时由标签类型尺寸文案拼接展示(接口无单独预览图字段) */ |
| 484 | 567 | function primaryLabelSizeText(p: UsAppLabelingProductNodeDto): string { |
| 568 | + const fromCard = (p.templateLabelSizeText ?? '').trim() | |
| 569 | + if (fromCard) return fromCard | |
| 485 | 570 | const types = p.labelTypes || [] |
| 486 | 571 | if (types.length === 0) return '—' |
| 487 | 572 | const texts = types.map((t) => (t.labelSizeText || '').trim()).filter(Boolean) |
| ... | ... | @@ -782,8 +867,16 @@ const goBluetoothPage = () => { |
| 782 | 867 | height: 100%; |
| 783 | 868 | } |
| 784 | 869 | |
| 870 | +/** | |
| 871 | + * 产品列表布局:手写 rpx(UniApp 安卓部分机型不支持 CSS var,var 会导致 padding 整段失效)。 | |
| 872 | + */ | |
| 873 | +.page { | |
| 874 | + box-sizing: border-box; | |
| 875 | +} | |
| 876 | + | |
| 785 | 877 | .panel-inner { |
| 786 | - padding: 24rpx; | |
| 878 | + padding: 16rpx 20rpx; | |
| 879 | + box-sizing: border-box; | |
| 787 | 880 | } |
| 788 | 881 | |
| 789 | 882 | .search-box { |
| ... | ... | @@ -826,38 +919,37 @@ const goBluetoothPage = () => { |
| 826 | 919 | .category-list { |
| 827 | 920 | display: flex; |
| 828 | 921 | flex-direction: column; |
| 829 | - gap: 16rpx; | |
| 922 | + gap: 12rpx; | |
| 830 | 923 | } |
| 831 | 924 | |
| 832 | 925 | .cat-section { |
| 833 | 926 | background: #fff; |
| 834 | - border-radius: 16rpx; | |
| 927 | + border-radius: 12rpx; | |
| 835 | 928 | box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); |
| 836 | 929 | overflow: hidden; |
| 930 | + box-sizing: border-box; | |
| 837 | 931 | } |
| 838 | 932 | |
| 933 | +/* 产品分类标题行:主样式见 script 内联 catHeaderRowStyle(安卓 scoped 易失效) */ | |
| 839 | 934 | .cat-header { |
| 840 | - display: flex; | |
| 841 | - align-items: center; | |
| 842 | - justify-content: space-between; | |
| 843 | - padding: 24rpx; | |
| 935 | + box-sizing: border-box; | |
| 936 | + width: 100%; | |
| 844 | 937 | } |
| 845 | 938 | |
| 846 | -.cat-header-left { | |
| 847 | - display: flex; | |
| 848 | - align-items: center; | |
| 849 | - gap: 16rpx; | |
| 850 | - flex: 1; | |
| 851 | - min-width: 0; | |
| 939 | +.cat-header-thumb { | |
| 940 | + box-sizing: border-box; | |
| 852 | 941 | } |
| 853 | 942 | |
| 854 | -/* 与侧栏 .cat-icon 同尺寸,仅去掉侧栏下边距 */ | |
| 855 | -.cat-header-thumb { | |
| 856 | - width: 64rpx; | |
| 857 | - height: 64rpx; | |
| 858 | - margin-bottom: 0; | |
| 859 | - flex-shrink: 0; | |
| 860 | - background: #f3f4f6; | |
| 943 | +.cat-header-thumb--photo, | |
| 944 | +.cat-header-thumb--fallback { | |
| 945 | + overflow: hidden; | |
| 946 | +} | |
| 947 | + | |
| 948 | +.cat-header .cat-header-thumb .cat-icon-text, | |
| 949 | +.cat-header .cat-header-thumb .cat-icon-text--on-color { | |
| 950 | + font-size: 18rpx; | |
| 951 | + line-height: 1.1; | |
| 952 | + padding: 0 4rpx; | |
| 861 | 953 | } |
| 862 | 954 | |
| 863 | 955 | .cat-header-color-fill { |
| ... | ... | @@ -893,39 +985,61 @@ const goBluetoothPage = () => { |
| 893 | 985 | .cat-header-info { |
| 894 | 986 | flex: 1; |
| 895 | 987 | min-width: 0; |
| 988 | + margin: 0; | |
| 989 | + padding: 0; | |
| 896 | 990 | } |
| 897 | 991 | |
| 898 | 992 | .cat-header-name { |
| 899 | - font-size: 28rpx; | |
| 993 | + font-size: 26rpx; | |
| 900 | 994 | font-weight: 600; |
| 901 | 995 | color: #111827; |
| 902 | 996 | display: block; |
| 997 | + line-height: 1.25; | |
| 903 | 998 | } |
| 904 | 999 | |
| 905 | 1000 | .cat-header-count { |
| 906 | - font-size: 22rpx; | |
| 1001 | + font-size: 20rpx; | |
| 907 | 1002 | color: #9ca3af; |
| 908 | 1003 | display: block; |
| 909 | - margin-top: 2rpx; | |
| 1004 | + margin-top: 4rpx; | |
| 1005 | + line-height: 1.2; | |
| 1006 | +} | |
| 1007 | + | |
| 1008 | +.cat-header-chevron { | |
| 1009 | + flex-shrink: 0; | |
| 1010 | + display: flex; | |
| 1011 | + align-items: center; | |
| 1012 | + justify-content: center; | |
| 1013 | + width: 44rpx; | |
| 1014 | + height: 44rpx; | |
| 1015 | + margin-left: 8rpx; | |
| 1016 | + margin-right: 0; | |
| 910 | 1017 | } |
| 911 | 1018 | |
| 912 | 1019 | .cat-foods { |
| 913 | - padding: 0 16rpx 16rpx; | |
| 1020 | + box-sizing: border-box; | |
| 914 | 1021 | border-top: 1rpx solid #f3f4f6; |
| 915 | 1022 | } |
| 916 | 1023 | |
| 917 | 1024 | .food-grid { |
| 918 | - display: grid; | |
| 919 | - grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| 920 | - column-gap: 12rpx; | |
| 921 | - row-gap: 12rpx; | |
| 922 | - padding-top: 16rpx; | |
| 1025 | + display: flex; | |
| 1026 | + flex-direction: row; | |
| 1027 | + flex-wrap: wrap; | |
| 1028 | + align-content: flex-start; | |
| 1029 | + padding-top: 8rpx; | |
| 1030 | + margin-left: -5rpx; | |
| 1031 | + margin-right: -5rpx; | |
| 923 | 1032 | } |
| 924 | 1033 | |
| 925 | 1034 | .food-card { |
| 1035 | + width: 47%; | |
| 1036 | + max-width: 240rpx; | |
| 1037 | + flex: 0 0 auto; | |
| 1038 | + box-sizing: border-box; | |
| 1039 | + margin: 0 5rpx 12rpx 5rpx; | |
| 926 | 1040 | background: #f9fafb; |
| 927 | - padding: 10rpx; | |
| 928 | - border-radius: 14rpx; | |
| 1041 | + padding: 8rpx; | |
| 1042 | + border-radius: 12rpx; | |
| 929 | 1043 | } |
| 930 | 1044 | |
| 931 | 1045 | .food-card:active { |
| ... | ... | @@ -935,10 +1049,11 @@ const goBluetoothPage = () => { |
| 935 | 1049 | .food-img-wrap { |
| 936 | 1050 | width: 100%; |
| 937 | 1051 | position: relative; |
| 938 | - padding-top: 75%; | |
| 939 | - border-radius: 10rpx; | |
| 1052 | + height: 0; | |
| 1053 | + padding-top: 58%; | |
| 1054 | + border-radius: 8rpx; | |
| 940 | 1055 | background: #e5e7eb; |
| 941 | - margin-bottom: 10rpx; | |
| 1056 | + margin-bottom: 8rpx; | |
| 942 | 1057 | overflow: hidden; |
| 943 | 1058 | } |
| 944 | 1059 | |
| ... | ... | @@ -965,9 +1080,9 @@ const goBluetoothPage = () => { |
| 965 | 1080 | display: flex; |
| 966 | 1081 | align-items: center; |
| 967 | 1082 | justify-content: center; |
| 968 | - padding: 12rpx; | |
| 1083 | + padding: 8rpx; | |
| 969 | 1084 | box-sizing: border-box; |
| 970 | - font-size: 32rpx; | |
| 1085 | + font-size: 26rpx; | |
| 971 | 1086 | font-weight: 700; |
| 972 | 1087 | color: #111827; |
| 973 | 1088 | line-height: 1.15; |
| ... | ... | @@ -976,7 +1091,7 @@ const goBluetoothPage = () => { |
| 976 | 1091 | } |
| 977 | 1092 | |
| 978 | 1093 | .food-thumb-text--on-color { |
| 979 | - font-size: 30rpx; | |
| 1094 | + font-size: 24rpx; | |
| 980 | 1095 | color: #ffffff; |
| 981 | 1096 | } |
| 982 | 1097 | |
| ... | ... | @@ -1048,7 +1163,7 @@ const goBluetoothPage = () => { |
| 1048 | 1163 | } |
| 1049 | 1164 | |
| 1050 | 1165 | .food-name { |
| 1051 | - font-size: 24rpx; | |
| 1166 | + font-size: 22rpx; | |
| 1052 | 1167 | font-weight: 600; |
| 1053 | 1168 | color: #111827; |
| 1054 | 1169 | display: block; |
| ... | ... | @@ -1059,7 +1174,7 @@ const goBluetoothPage = () => { |
| 1059 | 1174 | } |
| 1060 | 1175 | |
| 1061 | 1176 | .food-desc { |
| 1062 | - font-size: 20rpx; | |
| 1177 | + font-size: 18rpx; | |
| 1063 | 1178 | color: #6b7280; |
| 1064 | 1179 | display: block; |
| 1065 | 1180 | overflow: hidden; |
| ... | ... | @@ -1152,14 +1267,25 @@ const goBluetoothPage = () => { |
| 1152 | 1267 | } |
| 1153 | 1268 | |
| 1154 | 1269 | @media (min-width: 768px) { |
| 1155 | - .sidebar { | |
| 1156 | - width: 260rpx; | |
| 1270 | + .panel-inner { | |
| 1271 | + padding: 20rpx 24rpx; | |
| 1272 | + } | |
| 1273 | + | |
| 1274 | + /* 平板:内联样式在运行时由 rpx 换算,此处仅作 H5 预览补充 */ | |
| 1275 | + | |
| 1276 | + .food-card { | |
| 1277 | + width: 31%; | |
| 1278 | + max-width: 280rpx; | |
| 1279 | + padding: 10rpx; | |
| 1280 | + margin: 0 7rpx 14rpx 7rpx; | |
| 1281 | + } | |
| 1282 | + | |
| 1283 | + .food-img-wrap { | |
| 1284 | + padding-top: 62%; | |
| 1157 | 1285 | } |
| 1158 | 1286 | |
| 1159 | - .food-grid { | |
| 1160 | - grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 1161 | - column-gap: 16rpx; | |
| 1162 | - row-gap: 16rpx; | |
| 1287 | + .sidebar { | |
| 1288 | + width: 260rpx; | |
| 1163 | 1289 | } |
| 1164 | 1290 | } |
| 1165 | 1291 | </style> | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
| ... | ... | @@ -306,7 +306,12 @@ import { |
| 306 | 306 | normalizeTemplateForNativeFastJob, |
| 307 | 307 | templateHasUnsupportedNativeFastElements, |
| 308 | 308 | } from '../../utils/print/nativeTemplateElementSupport' |
| 309 | -import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm' | |
| 309 | +import { | |
| 310 | + ensureTemplateHeightCoversElements, | |
| 311 | + getTemplatePhysicalSizeMm, | |
| 312 | + isTemplateWithinNativeFastPrintBounds, | |
| 313 | + templateContentHeightPx, | |
| 314 | +} from '../../utils/print/templatePhysicalMm' | |
| 310 | 315 | import { isPrinterReadySync } from '../../utils/print/printerReadiness' |
| 311 | 316 | import { |
| 312 | 317 | ensureNativeClassicTransportIfPossible, |
| ... | ... | @@ -462,7 +467,7 @@ function buildPrintPreflightDeviceInfoText (args: { |
| 462 | 467 | lines.push(`- nativeUnsupportedElements: ${args.nativeUnsupported ? 'YES' : 'NO'}`) |
| 463 | 468 | lines.push('') |
| 464 | 469 | lines.push('Note') |
| 465 | - lines.push('- Built-in 打印可能走 UPOS(内置/串口)或本机端口服务(127.0.0.1)。若“成功但不出纸”,优先看 uposWillTry / tcpPluginAvailable 与 native-fast-printer 的 lastError/stage。') | |
| 470 | + lines.push('- Built-in may use UPOS (on-device/serial) or localhost (127.0.0.1). If the app reports success but nothing prints, check uposWillTry / tcpPluginAvailable and native-fast-printer lastError/stage.') | |
| 466 | 471 | return lines.join('\n') |
| 467 | 472 | } |
| 468 | 473 | |
| ... | ... | @@ -1189,6 +1194,14 @@ async function waitForCanvasLayout(): Promise<void> { |
| 1189 | 1194 | }) |
| 1190 | 1195 | } |
| 1191 | 1196 | |
| 1197 | +/** 光栅打印前同步隐藏 canvas 像素尺寸,避免缓冲区高度仍为预览值导致底部裁切 */ | |
| 1198 | +async function applyPrintCanvasLayout(layout: { outW: number; outH: number }) { | |
| 1199 | + canvasCssW.value = layout.outW | |
| 1200 | + canvasCssH.value = layout.outH | |
| 1201 | + await waitForCanvasLayout() | |
| 1202 | + await new Promise<void>((r) => setTimeout(r, 80)) | |
| 1203 | +} | |
| 1204 | + | |
| 1192 | 1205 | let deferredPreviewRedrawTimer: ReturnType<typeof setTimeout> | null = null |
| 1193 | 1206 | |
| 1194 | 1207 | /** 首屏内容与 loading 切换后布局才稳定,补绘一次与「改输入后变正常」同路径 */ |
| ... | ... | @@ -1496,10 +1509,17 @@ const handlePrint = async () => { |
| 1496 | 1509 | uni.showLoading({ title: 'Rendering…', mask: true }) |
| 1497 | 1510 | /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */ |
| 1498 | 1511 | const mergedForPrint = computeMergedPreviewTemplate() |
| 1499 | - const tmpl = mergedForPrint ?? systemTemplate.value | |
| 1500 | - if (!tmpl || !instance) { | |
| 1512 | + const tmplBase = mergedForPrint ?? systemTemplate.value | |
| 1513 | + if (!tmplBase || !instance) { | |
| 1501 | 1514 | throw new Error('No label to print.') |
| 1502 | 1515 | } |
| 1516 | + const tmpl = ensureTemplateHeightCoversElements(tmplBase) | |
| 1517 | + const phys = getTemplatePhysicalSizeMm(tmpl) | |
| 1518 | + console.info( | |
| 1519 | + '[preview] print physical size', | |
| 1520 | + `${phys.widthMm}×${phys.heightMm}mm`, | |
| 1521 | + `template=${tmpl.width}×${tmpl.height}${phys.unit}`, | |
| 1522 | + ) | |
| 1503 | 1523 | ensurePrintJobActive(printJobId) |
| 1504 | 1524 | |
| 1505 | 1525 | const printInputJson = buildPrintInputJson( |
| ... | ... | @@ -1509,7 +1529,8 @@ const handlePrint = async () => { |
| 1509 | 1529 | ) |
| 1510 | 1530 | printStage = 'payload-ready' |
| 1511 | 1531 | /** |
| 1512 | - * 一体机(经典蓝牙 + native-fast-printer 基座,如 Virtual BT):走原生 printLabelPrintJob。 | |
| 1532 | + * 经典蓝牙 + native-plugin(含 Virtual BT):走原生 printTemplate(JSON,快,与预览坐标一致)。 | |
| 1533 | + * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。 | |
| 1513 | 1534 | * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。 |
| 1514 | 1535 | */ |
| 1515 | 1536 | const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) |
| ... | ... | @@ -1544,13 +1565,13 @@ const handlePrint = async () => { |
| 1544 | 1565 | /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */ |
| 1545 | 1566 | let tmplForNativePayload = tmplForNativeJob |
| 1546 | 1567 | if (useNativeTemplatePrint) { |
| 1547 | - // D320+经典蓝牙仍走 printTemplate(JSON,快);略收紧横向与右侧安全区,减轻裁切/错位。 | |
| 1548 | - tmplForNativePayload = applyNativeTemplateStyleScale( | |
| 1549 | - tmplForNativePayload, | |
| 1550 | - isD320faxClassicNative | |
| 1551 | - ? { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 } | |
| 1552 | - : { textScale: 1, xScale: 1, safeRightRatio: 0.93 }, | |
| 1553 | - ) | |
| 1568 | + // 真机 GP-D320FX 可略收窄;Virtual BT 必须与预览同坐标,禁止 xScale。 | |
| 1569 | + if (isD320faxClassicNative && !isVirtualBtPrinter) { | |
| 1570 | + tmplForNativePayload = applyNativeTemplateStyleScale( | |
| 1571 | + tmplForNativePayload, | |
| 1572 | + { textScale: 1, xScale: 0.9, safeRightRatio: 0.88 }, | |
| 1573 | + ) | |
| 1574 | + } | |
| 1554 | 1575 | resetHydrateImageDebugRecords() |
| 1555 | 1576 | tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativePayload) |
| 1556 | 1577 | } |
| ... | ... | @@ -1651,10 +1672,7 @@ const handlePrint = async () => { |
| 1651 | 1672 | canvasRaster: { |
| 1652 | 1673 | canvasId: 'labelPreviewCanvas', |
| 1653 | 1674 | componentInstance: instance, |
| 1654 | - applyLayout: (layout) => { | |
| 1655 | - canvasCssW.value = layout.outW | |
| 1656 | - canvasCssH.value = layout.outH | |
| 1657 | - }, | |
| 1675 | + applyLayout: applyPrintCanvasLayout, | |
| 1658 | 1676 | }, |
| 1659 | 1677 | }, |
| 1660 | 1678 | (percent) => { |
| ... | ... | @@ -1702,10 +1720,7 @@ const handlePrint = async () => { |
| 1702 | 1720 | canvasRaster: { |
| 1703 | 1721 | canvasId: 'labelPreviewCanvas', |
| 1704 | 1722 | componentInstance: instance, |
| 1705 | - applyLayout: (layout) => { | |
| 1706 | - canvasCssW.value = layout.outW | |
| 1707 | - canvasCssH.value = layout.outH | |
| 1708 | - }, | |
| 1723 | + applyLayout: applyPrintCanvasLayout, | |
| 1709 | 1724 | }, |
| 1710 | 1725 | }, |
| 1711 | 1726 | (percent) => { |
| ... | ... | @@ -1798,7 +1813,14 @@ const handlePrint = async () => { |
| 1798 | 1813 | } |
| 1799 | 1814 | const maxDots = |
| 1800 | 1815 | rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) |
| 1801 | - const layout = getLabelPrintRasterLayout(tmpl, maxDots, rasterDriver.imageDpi || 203) | |
| 1816 | + const escUseContentH = rasterDriver.protocol === 'esc' | |
| 1817 | + const contentHpx = escUseContentH ? templateContentHeightPx(tmpl) : undefined | |
| 1818 | + const layout = getLabelPrintRasterLayout( | |
| 1819 | + tmpl, | |
| 1820 | + maxDots, | |
| 1821 | + rasterDriver.imageDpi || 203, | |
| 1822 | + contentHpx != null ? { contentHeightPx: contentHpx } : undefined, | |
| 1823 | + ) | |
| 1802 | 1824 | |
| 1803 | 1825 | canvasCssW.value = layout.outW |
| 1804 | 1826 | canvasCssH.value = layout.outH |
| ... | ... | @@ -1862,9 +1884,11 @@ const handlePrint = async () => { |
| 1862 | 1884 | imageData, |
| 1863 | 1885 | { |
| 1864 | 1886 | printQty: printQty.value, |
| 1865 | - clearTopRasterRows: 1, | |
| 1887 | + clearTopRasterRows: 0, | |
| 1866 | 1888 | targetWidthDots: layout.outW, |
| 1867 | 1889 | targetHeightDots: layout.outH, |
| 1890 | + useContentHeight: true, | |
| 1891 | + cutBetweenCopies: true, | |
| 1868 | 1892 | }, |
| 1869 | 1893 | (percent) => { |
| 1870 | 1894 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -1880,6 +1904,7 @@ const handlePrint = async () => { |
| 1880 | 1904 | clearTopRasterRows: 1, |
| 1881 | 1905 | targetWidthDots: layout.outW, |
| 1882 | 1906 | targetHeightDots: layout.outH, |
| 1907 | + labelRasterFixedHeight: true, | |
| 1883 | 1908 | }, |
| 1884 | 1909 | (percent) => { |
| 1885 | 1910 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -1958,9 +1983,11 @@ const handlePrint = async () => { |
| 1958 | 1983 | tmpPath, |
| 1959 | 1984 | { |
| 1960 | 1985 | printQty: printQty.value, |
| 1961 | - clearTopRasterRows: 1, | |
| 1986 | + clearTopRasterRows: 0, | |
| 1962 | 1987 | targetWidthDots: layout.outW, |
| 1963 | 1988 | targetHeightDots: layout.outH, |
| 1989 | + useContentHeight: true, | |
| 1990 | + cutBetweenCopies: true, | |
| 1964 | 1991 | }, |
| 1965 | 1992 | (percent) => { |
| 1966 | 1993 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -1975,6 +2002,7 @@ const handlePrint = async () => { |
| 1975 | 2002 | clearTopRasterRows: 1, |
| 1976 | 2003 | targetWidthDots: layout.outW, |
| 1977 | 2004 | targetHeightDots: layout.outH, |
| 2005 | + labelRasterFixedHeight: true, | |
| 1978 | 2006 | }, |
| 1979 | 2007 | (percent) => { |
| 1980 | 2008 | if (!isPrintJobActive(printJobId)) return | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/label-report.vue
| 1 | 1 | <template> |
| 2 | + | |
| 2 | 3 | <view class="page"> |
| 4 | + | |
| 3 | 5 | <view class="header-hero" :style="{ paddingTop: statusBarHeight + 'px' }"> |
| 6 | + | |
| 4 | 7 | <view class="top-bar"> |
| 8 | + | |
| 5 | 9 | <view class="top-left" @click="goBack"> |
| 10 | + | |
| 6 | 11 | <AppIcon name="chevronLeft" size="sm" color="white" /> |
| 12 | + | |
| 7 | 13 | </view> |
| 14 | + | |
| 8 | 15 | <view class="top-center"> |
| 16 | + | |
| 9 | 17 | <text class="page-title">Label Report</text> |
| 18 | + | |
| 10 | 19 | <LocationPicker /> |
| 20 | + | |
| 11 | 21 | </view> |
| 22 | + | |
| 12 | 23 | <view class="top-right" @click="isMenuOpen = true"> |
| 24 | + | |
| 13 | 25 | <AppIcon name="menu" size="sm" color="white" /> |
| 26 | + | |
| 14 | 27 | </view> |
| 28 | + | |
| 29 | + </view> | |
| 30 | + | |
| 31 | + </view> | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + <view class="period-bar"> | |
| 36 | + | |
| 37 | + <view | |
| 38 | + | |
| 39 | + v-for="p in periodOptions" | |
| 40 | + | |
| 41 | + :key="p.value" | |
| 42 | + | |
| 43 | + class="period-btn" | |
| 44 | + | |
| 45 | + :class="{ active: period === p.value }" | |
| 46 | + | |
| 47 | + @click="selectPeriod(p.value)" | |
| 48 | + | |
| 49 | + > | |
| 50 | + | |
| 51 | + <text class="period-text">{{ p.label }}</text> | |
| 52 | + | |
| 53 | + </view> | |
| 54 | + | |
| 55 | + </view> | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + <view v-if="period === 'custom'" class="custom-date-bar"> | |
| 60 | + | |
| 61 | + <view class="date-field"> | |
| 62 | + | |
| 63 | + <text class="date-label">Start</text> | |
| 64 | + | |
| 65 | + <AppDatePicker | |
| 66 | + | |
| 67 | + v-model="customStart" | |
| 68 | + | |
| 69 | + :max="customEnd || '2099-12-31'" | |
| 70 | + | |
| 71 | + placeholder="Select" | |
| 72 | + | |
| 73 | + dialog-title="Start date" | |
| 74 | + | |
| 75 | + /> | |
| 76 | + | |
| 15 | 77 | </view> |
| 78 | + | |
| 79 | + <view class="date-field"> | |
| 80 | + | |
| 81 | + <text class="date-label">End</text> | |
| 82 | + | |
| 83 | + <AppDatePicker | |
| 84 | + | |
| 85 | + v-model="customEnd" | |
| 86 | + | |
| 87 | + :min="customStart || '2000-01-01'" | |
| 88 | + | |
| 89 | + placeholder="Select" | |
| 90 | + | |
| 91 | + dialog-title="End date" | |
| 92 | + | |
| 93 | + /> | |
| 94 | + | |
| 95 | + </view> | |
| 96 | + | |
| 16 | 97 | </view> |
| 17 | 98 | |
| 18 | - <view class="period-bar"> | |
| 19 | - <view | |
| 20 | - v-for="p in periodOptions" | |
| 21 | - :key="p.value" | |
| 22 | - class="period-btn" | |
| 23 | - :class="{ active: period === p.value }" | |
| 24 | - @click="selectPeriod(p.value)" | |
| 25 | - > | |
| 26 | - <text class="period-text">{{ p.label }}</text> | |
| 27 | - </view> | |
| 28 | - </view> | |
| 29 | 99 | |
| 30 | - <view v-if="period === 'custom'" class="custom-date-bar"> | |
| 31 | - <view class="date-field"> | |
| 32 | - <text class="date-label">Start</text> | |
| 33 | - <picker mode="date" :value="customStart" :end="customEnd || '2099-12-31'" @change="onStartChange"> | |
| 34 | - <view class="date-picker-btn">{{ customStart || 'Select' }}</view> | |
| 35 | - </picker> | |
| 36 | - </view> | |
| 37 | - <view class="date-field"> | |
| 38 | - <text class="date-label">End</text> | |
| 39 | - <picker mode="date" :value="customEnd" :start="customStart || '2000-01-01'" @change="onEndChange"> | |
| 40 | - <view class="date-picker-btn">{{ customEnd || 'Select' }}</view> | |
| 41 | - </picker> | |
| 42 | - </view> | |
| 43 | - </view> | |
| 44 | 100 | |
| 45 | - <scroll-view class="content" scroll-y> | |
| 46 | - <!-- 指标卡 --> | |
| 47 | - <view class="metrics-grid"> | |
| 48 | - <view class="metric-card"> | |
| 49 | - <text class="metric-label">Total Labels Printed</text> | |
| 50 | - <text class="metric-value">2,543</text> | |
| 51 | - <text class="metric-trend up">+20.1% from last month</text> | |
| 52 | - </view> | |
| 53 | - <view class="metric-card"> | |
| 54 | - <text class="metric-label">Most Printed Category</text> | |
| 55 | - <text class="metric-value">Dairy</text> | |
| 56 | - <text class="metric-trend">450 labels generated</text> | |
| 57 | - </view> | |
| 58 | - <view class="metric-card"> | |
| 59 | - <text class="metric-label">Top Product</text> | |
| 60 | - <text class="metric-value">Whole Milk</text> | |
| 61 | - <text class="metric-trend">182 labels generated</text> | |
| 62 | - </view> | |
| 63 | - <view class="metric-card"> | |
| 64 | - <text class="metric-label">Avg. Daily Prints</text> | |
| 65 | - <text class="metric-value">85</text> | |
| 66 | - <text class="metric-trend up">+12% from last week</text> | |
| 67 | - </view> | |
| 68 | - </view> | |
| 101 | + <scroll-view class="content" scroll-y> | |
| 102 | + | |
| 103 | + <view v-if="loading" class="state-block"> | |
| 104 | + | |
| 105 | + <text class="state-text">Loading…</text> | |
| 106 | + | |
| 107 | + </view> | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + <template v-else> | |
| 112 | + | |
| 113 | + <!-- 指标卡 --> | |
| 114 | + | |
| 115 | + <view class="metrics-grid"> | |
| 116 | + | |
| 117 | + <view class="metric-card"> | |
| 118 | + | |
| 119 | + <text class="metric-label">Total Labels Printed</text> | |
| 120 | + | |
| 121 | + <text class="metric-value">{{ totalPrintedDisplay }}</text> | |
| 122 | + | |
| 123 | + <text class="metric-trend" :class="totalChangeClass">{{ totalChangeText }}</text> | |
| 124 | + | |
| 125 | + </view> | |
| 126 | + | |
| 127 | + <view class="metric-card"> | |
| 128 | + | |
| 129 | + <text class="metric-label">Most Printed Category</text> | |
| 130 | + | |
| 131 | + <text class="metric-value">{{ topCategoryName }}</text> | |
| 132 | + | |
| 133 | + <text class="metric-trend">{{ topCategorySubtext }}</text> | |
| 134 | + | |
| 135 | + </view> | |
| 136 | + | |
| 137 | + <view class="metric-card"> | |
| 138 | + | |
| 139 | + <text class="metric-label">Top Product</text> | |
| 140 | + | |
| 141 | + <text class="metric-value">{{ topProductName }}</text> | |
| 142 | + | |
| 143 | + <text class="metric-trend">{{ topProductSubtext }}</text> | |
| 144 | + | |
| 145 | + </view> | |
| 146 | + | |
| 147 | + <view class="metric-card"> | |
| 148 | + | |
| 149 | + <text class="metric-label">Avg. Daily Prints</text> | |
| 150 | + | |
| 151 | + <text class="metric-value">{{ avgDailyDisplay }}</text> | |
| 152 | + | |
| 153 | + <text class="metric-trend" :class="avgChangeClass">{{ avgChangeText }}</text> | |
| 154 | + | |
| 155 | + </view> | |
| 156 | + | |
| 157 | + </view> | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + <!-- 柱状图 --> | |
| 162 | + | |
| 163 | + <view class="section"> | |
| 164 | + | |
| 165 | + <text class="section-title">Labels by Category</text> | |
| 166 | + | |
| 167 | + <text class="section-desc">Distribution of printed labels across product categories.</text> | |
| 168 | + | |
| 169 | + <view v-if="!categoryChart.length" class="empty-panel"> | |
| 170 | + | |
| 171 | + <text class="empty-text">No category data for this period</text> | |
| 172 | + | |
| 173 | + </view> | |
| 174 | + | |
| 175 | + <view v-else class="bar-chart"> | |
| 176 | + | |
| 177 | + <view v-for="item in categoryChart" :key="item.key" class="bar-row"> | |
| 178 | + | |
| 179 | + <text class="bar-label">{{ item.name }}</text> | |
| 180 | + | |
| 181 | + <view class="bar-track"> | |
| 182 | + | |
| 183 | + <view class="bar-fill" :style="{ width: item.percent + '%' }" /> | |
| 184 | + | |
| 185 | + </view> | |
| 186 | + | |
| 187 | + <text class="bar-value">{{ item.value }}</text> | |
| 188 | + | |
| 189 | + </view> | |
| 190 | + | |
| 191 | + </view> | |
| 192 | + | |
| 193 | + </view> | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + <!-- 走势图 --> | |
| 198 | + | |
| 199 | + <view class="section"> | |
| 200 | + | |
| 201 | + <text class="section-title">Print Volume Trends</text> | |
| 202 | + | |
| 203 | + <text class="section-desc">{{ trendDescription }}</text> | |
| 204 | + | |
| 205 | + <view v-if="!trendChart.length" class="empty-panel"> | |
| 206 | + | |
| 207 | + <text class="empty-text">No trend data for this period</text> | |
| 208 | + | |
| 209 | + </view> | |
| 210 | + | |
| 211 | + <view v-else class="line-chart"> | |
| 212 | + | |
| 213 | + <view class="chart-bars"> | |
| 214 | + | |
| 215 | + <view v-for="(bar, i) in trendChart" :key="'t-' + i" class="day-bar-wrap"> | |
| 216 | + | |
| 217 | + <view class="day-bar" :style="{ height: bar.heightPct + '%' }" /> | |
| 218 | + | |
| 219 | + </view> | |
| 220 | + | |
| 221 | + </view> | |
| 222 | + | |
| 223 | + <view class="chart-labels"> | |
| 224 | + | |
| 225 | + <text v-for="(bar, i) in trendChart" :key="'l-' + i" class="day-label">{{ bar.label }}</text> | |
| 226 | + | |
| 227 | + </view> | |
| 228 | + | |
| 229 | + </view> | |
| 230 | + | |
| 231 | + </view> | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + <!-- 最常用产品列表 --> | |
| 236 | + | |
| 237 | + <view class="section"> | |
| 238 | + | |
| 239 | + <text class="section-title">Most Used Products</text> | |
| 240 | + | |
| 241 | + <view class="product-table"> | |
| 242 | + | |
| 243 | + <view class="product-row header"> | |
| 244 | + | |
| 245 | + <text class="col-name">Product Name</text> | |
| 246 | + | |
| 247 | + <text class="col-cat">Category</text> | |
| 248 | + | |
| 249 | + <text class="col-total">Total Printed</text> | |
| 250 | + | |
| 251 | + <text class="col-pct">Usage %</text> | |
| 252 | + | |
| 253 | + </view> | |
| 254 | + | |
| 255 | + <view v-if="!topProducts.length" class="product-row empty-row"> | |
| 256 | + | |
| 257 | + <text class="empty-row-text">No products in this period</text> | |
| 258 | + | |
| 259 | + </view> | |
| 260 | + | |
| 261 | + <view v-for="p in topProducts" :key="p.key" class="product-row"> | |
| 262 | + | |
| 263 | + <text class="col-name">{{ p.name }}</text> | |
| 264 | + | |
| 265 | + <text class="col-cat">{{ p.category }}</text> | |
| 266 | + | |
| 267 | + <text class="col-total">{{ p.total }}</text> | |
| 268 | + | |
| 269 | + <text class="col-pct">{{ p.usage }}%</text> | |
| 270 | + | |
| 271 | + </view> | |
| 272 | + | |
| 273 | + </view> | |
| 274 | + | |
| 275 | + </view> | |
| 276 | + | |
| 277 | + </template> | |
| 278 | + | |
| 279 | + </scroll-view> | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + <SideMenu v-model="isMenuOpen" /> | |
| 284 | + | |
| 285 | + </view> | |
| 286 | + | |
| 287 | +</template> | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | +<script setup lang="ts"> | |
| 292 | + | |
| 293 | +import { ref, computed, watch } from 'vue' | |
| 294 | + | |
| 295 | +import { onShow } from '@dcloudio/uni-app' | |
| 296 | + | |
| 297 | +import AppIcon from '../../components/AppIcon.vue' | |
| 298 | + | |
| 299 | +import AppDatePicker from '../../components/AppDatePicker.vue' | |
| 300 | + | |
| 301 | +import SideMenu from '../../components/SideMenu.vue' | |
| 302 | + | |
| 303 | +import LocationPicker from '../../components/LocationPicker.vue' | |
| 304 | + | |
| 305 | +import { getStatusBarHeight } from '../../utils/statusBar' | |
| 306 | + | |
| 307 | +import { getCurrentStoreId } from '../../utils/stores' | |
| 308 | + | |
| 309 | +import { fetchUsAppLabelReport } from '../../services/usAppLabeling' | |
| 310 | + | |
| 311 | +import type { UsAppLabelReportOutputDto, UsAppLabelReportPeriod } from '../../types/usAppLabeling' | |
| 312 | + | |
| 313 | +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' | |
| 314 | + | |
| 315 | +import { formatDisplayText } from '../../utils/emptyDisplay' | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | +const statusBarHeight = getStatusBarHeight() | |
| 320 | + | |
| 321 | +const isMenuOpen = ref(false) | |
| 322 | + | |
| 323 | +const loading = ref(false) | |
| 324 | + | |
| 325 | +const period = ref<UsAppLabelReportPeriod>('7d') | |
| 326 | + | |
| 327 | +const customStart = ref('') | |
| 328 | + | |
| 329 | +const customEnd = ref('') | |
| 330 | + | |
| 331 | +const report = ref<UsAppLabelReportOutputDto | null>(null) | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | +const periodOptions = [ | |
| 336 | + | |
| 337 | + { value: '7d' as const, label: 'Last 7 Days' }, | |
| 338 | + | |
| 339 | + { value: '30d' as const, label: 'Last 30 Days' }, | |
| 340 | + | |
| 341 | + { value: '90d' as const, label: 'Last 90 Days' }, | |
| 342 | + | |
| 343 | + { value: 'custom' as const, label: 'Custom' }, | |
| 344 | + | |
| 345 | +] | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | +function formatDate(d: Date): string { | |
| 350 | + | |
| 351 | + const y = d.getFullYear() | |
| 352 | + | |
| 353 | + const m = String(d.getMonth() + 1).padStart(2, '0') | |
| 354 | + | |
| 355 | + const day = String(d.getDate()).padStart(2, '0') | |
| 356 | + | |
| 357 | + return `${y}-${m}-${day}` | |
| 358 | + | |
| 359 | +} | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | +function selectPeriod(val: UsAppLabelReportPeriod) { | |
| 364 | + | |
| 365 | + period.value = val | |
| 366 | + | |
| 367 | + if (val === 'custom') { | |
| 368 | + | |
| 369 | + const today = formatDate(new Date()) | |
| 370 | + | |
| 371 | + const weekAgo = new Date() | |
| 372 | + | |
| 373 | + weekAgo.setDate(weekAgo.getDate() - 7) | |
| 374 | + | |
| 375 | + customStart.value = customStart.value || formatDate(weekAgo) | |
| 376 | + | |
| 377 | + customEnd.value = customEnd.value || today | |
| 378 | + | |
| 379 | + } | |
| 380 | + | |
| 381 | + loadReport() | |
| 382 | + | |
| 383 | +} | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | +watch(customStart, (start) => { | |
| 388 | + | |
| 389 | + if (!start || !customEnd.value) return | |
| 390 | + | |
| 391 | + if (start > customEnd.value) customEnd.value = start | |
| 392 | + | |
| 393 | +}) | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | +watch([customStart, customEnd], () => { | |
| 398 | + | |
| 399 | + if (period.value === 'custom' && customStart.value && customEnd.value) { | |
| 400 | + | |
| 401 | + loadReport() | |
| 402 | + | |
| 403 | + } | |
| 404 | + | |
| 405 | +}) | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | +function formatNumber(n: number): string { | |
| 410 | + | |
| 411 | + return Math.round(n).toLocaleString('en-US') | |
| 412 | + | |
| 413 | +} | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | +function formatChangeRate(rate: number): { text: string; cls: string } { | |
| 418 | + | |
| 419 | + const r = Number(rate) | |
| 420 | + | |
| 421 | + if (!Number.isFinite(r) || r === 0) { | |
| 422 | + | |
| 423 | + return { text: 'No change vs previous period', cls: '' } | |
| 424 | + | |
| 425 | + } | |
| 426 | + | |
| 427 | + const sign = r > 0 ? '+' : '' | |
| 428 | + | |
| 429 | + const cls = r > 0 ? 'up' : 'down' | |
| 430 | + | |
| 431 | + return { | |
| 432 | + | |
| 433 | + text: `${sign}${r.toFixed(1)}% vs previous period`, | |
| 434 | + | |
| 435 | + cls, | |
| 436 | + | |
| 437 | + } | |
| 438 | + | |
| 439 | +} | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | +const summary = computed(() => report.value?.summary) | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | +const totalPrintedDisplay = computed(() => | |
| 448 | + | |
| 449 | + formatNumber(summary.value?.totalLabelsPrinted ?? 0), | |
| 450 | + | |
| 451 | +) | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | +const totalChangeText = computed(() => | |
| 456 | + | |
| 457 | + formatChangeRate(summary.value?.totalLabelsPrintedChangeRate ?? 0).text, | |
| 458 | + | |
| 459 | +) | |
| 460 | + | |
| 461 | +const totalChangeClass = computed(() => | |
| 462 | + | |
| 463 | + formatChangeRate(summary.value?.totalLabelsPrintedChangeRate ?? 0).cls, | |
| 464 | + | |
| 465 | +) | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | +const topCategoryName = computed(() => | |
| 470 | + | |
| 471 | + formatDisplayText(summary.value?.mostPrintedCategoryName, 'None'), | |
| 472 | + | |
| 473 | +) | |
| 474 | + | |
| 475 | +const topCategorySubtext = computed(() => { | |
| 476 | + | |
| 477 | + const n = summary.value?.mostPrintedCategoryCount ?? 0 | |
| 478 | + | |
| 479 | + return n > 0 ? `${formatNumber(n)} labels generated` : 'No labels in period' | |
| 480 | + | |
| 481 | +}) | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | +const topProductName = computed(() => | |
| 486 | + | |
| 487 | + formatDisplayText(summary.value?.topProductName, 'None'), | |
| 488 | + | |
| 489 | +) | |
| 490 | + | |
| 491 | +const topProductSubtext = computed(() => { | |
| 492 | + | |
| 493 | + const n = summary.value?.topProductCount ?? 0 | |
| 494 | + | |
| 495 | + return n > 0 ? `${formatNumber(n)} labels generated` : 'No labels in period' | |
| 496 | + | |
| 497 | +}) | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | +const avgDailyDisplay = computed(() => { | |
| 502 | + | |
| 503 | + const v = summary.value?.avgDailyPrints ?? 0 | |
| 504 | + | |
| 505 | + const rounded = Math.round(v * 10) / 10 | |
| 506 | + | |
| 507 | + return rounded % 1 === 0 ? String(Math.round(rounded)) : rounded.toFixed(1) | |
| 508 | + | |
| 509 | +}) | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | +const avgChangeText = computed(() => | |
| 514 | + | |
| 515 | + formatChangeRate(summary.value?.avgDailyPrintsChangeRate ?? 0).text, | |
| 516 | + | |
| 517 | +) | |
| 518 | + | |
| 519 | +const avgChangeClass = computed(() => | |
| 520 | + | |
| 521 | + formatChangeRate(summary.value?.avgDailyPrintsChangeRate ?? 0).cls, | |
| 522 | + | |
| 523 | +) | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | +const trendDescription = computed(() => { | |
| 528 | + | |
| 529 | + const fromApi = report.value?.appliedRange?.trendDescription?.trim() | |
| 530 | + | |
| 531 | + if (fromApi) return fromApi | |
| 532 | + | |
| 533 | + const days = report.value?.appliedRange?.dayCount | |
| 534 | + | |
| 535 | + if (days && days > 7) { | |
| 536 | + | |
| 537 | + return `Daily label printing volume for the last ${days} days.` | |
| 538 | + | |
| 539 | + } | |
| 540 | + | |
| 541 | + return 'Daily label printing volume for the selected period.' | |
| 542 | + | |
| 543 | +}) | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | +const categoryChart = computed(() => { | |
| 548 | + | |
| 549 | + const rows = report.value?.labelsByCategory ?? [] | |
| 550 | + | |
| 551 | + const max = Math.max(1, ...rows.map((d) => d.count)) | |
| 552 | + | |
| 553 | + return rows.map((d, i) => ({ | |
| 554 | + | |
| 555 | + key: (d.categoryId || d.categoryName || 'cat') + i, | |
| 556 | + | |
| 557 | + name: formatDisplayText(d.categoryName, 'Uncategorized'), | |
| 558 | + | |
| 559 | + value: formatNumber(d.count), | |
| 560 | + | |
| 561 | + percent: (d.count / max) * 100, | |
| 562 | + | |
| 563 | + })) | |
| 564 | + | |
| 565 | +}) | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | +function formatTrendMd(dateStr: string): string { | |
| 570 | + | |
| 571 | + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(dateStr) | |
| 572 | + | |
| 573 | + if (!m) return dateStr.slice(5) || dateStr | |
| 574 | + | |
| 575 | + return `${m[2]}/${m[3]}` | |
| 576 | + | |
| 577 | +} | |
| 578 | + | |
| 579 | + | |
| 69 | 580 | |
| 70 | - <!-- 柱状图 --> | |
| 71 | - <view class="section"> | |
| 72 | - <text class="section-title">Labels by Category</text> | |
| 73 | - <text class="section-desc">Distribution of printed labels across product categories.</text> | |
| 74 | - <view class="bar-chart"> | |
| 75 | - <view v-for="item in categoryData" :key="item.name" class="bar-row"> | |
| 76 | - <text class="bar-label">{{ item.name }}</text> | |
| 77 | - <view class="bar-track"> | |
| 78 | - <view class="bar-fill" :style="{ width: item.percent + '%' }" /> | |
| 79 | - </view> | |
| 80 | - <text class="bar-value">{{ item.value }}</text> | |
| 81 | - </view> | |
| 82 | - </view> | |
| 83 | - </view> | |
| 581 | +const trendChart = computed(() => { | |
| 84 | 582 | |
| 85 | - <!-- 走势图 --> | |
| 86 | - <view class="section"> | |
| 87 | - <text class="section-title">Print Volume Trends</text> | |
| 88 | - <text class="section-desc">Daily label printing volume for the last 7 days.</text> | |
| 89 | - <view class="line-chart"> | |
| 90 | - <view class="chart-bars"> | |
| 91 | - <view v-for="(pct, i) in trendData" :key="i" class="day-bar-wrap"> | |
| 92 | - <view class="day-bar" :style="{ height: pct + '%' }" /> | |
| 93 | - </view> | |
| 94 | - </view> | |
| 95 | - <view class="chart-labels"> | |
| 96 | - <text v-for="d in dayLabels" :key="d" class="day-label">{{ d }}</text> | |
| 97 | - </view> | |
| 98 | - </view> | |
| 99 | - </view> | |
| 583 | + const pts = report.value?.printVolumeTrend ?? [] | |
| 100 | 584 | |
| 101 | - <!-- 最常用产品列表 --> | |
| 102 | - <view class="section"> | |
| 103 | - <text class="section-title">Most Used Products</text> | |
| 104 | - <view class="product-table"> | |
| 105 | - <view class="product-row header"> | |
| 106 | - <text class="col-name">Product Name</text> | |
| 107 | - <text class="col-cat">Category</text> | |
| 108 | - <text class="col-total">Total Printed</text> | |
| 109 | - <text class="col-pct">Usage %</text> | |
| 110 | - </view> | |
| 111 | - <view v-for="p in topProducts" :key="p.name" class="product-row"> | |
| 112 | - <text class="col-name">{{ p.name }}</text> | |
| 113 | - <text class="col-cat">{{ p.category }}</text> | |
| 114 | - <text class="col-total">{{ p.total }}</text> | |
| 115 | - <text class="col-pct">{{ p.usage }}%</text> | |
| 116 | - </view> | |
| 117 | - </view> | |
| 118 | - </view> | |
| 119 | - </scroll-view> | |
| 585 | + if (!pts.length) return [] | |
| 120 | 586 | |
| 121 | - <SideMenu v-model="isMenuOpen" /> | |
| 122 | - </view> | |
| 123 | -</template> | |
| 587 | + const max = Math.max(1, ...pts.map((p) => p.count)) | |
| 124 | 588 | |
| 125 | -<script setup lang="ts"> | |
| 126 | -import { ref, computed } from 'vue' | |
| 127 | -import AppIcon from '../../components/AppIcon.vue' | |
| 128 | -import SideMenu from '../../components/SideMenu.vue' | |
| 129 | -import LocationPicker from '../../components/LocationPicker.vue' | |
| 130 | -import { getStatusBarHeight } from '../../utils/statusBar' | |
| 589 | + const n = pts.length | |
| 131 | 590 | |
| 132 | -const statusBarHeight = getStatusBarHeight() | |
| 133 | -const isMenuOpen = ref(false) | |
| 134 | -const period = ref('7d') | |
| 135 | -const customStart = ref('') | |
| 136 | -const customEnd = ref('') | |
| 591 | + const labelStep = n <= 7 ? 1 : Math.max(1, Math.ceil(n / 6)) | |
| 137 | 592 | |
| 138 | -const periodOptions = [ | |
| 139 | - { value: '7d', label: 'Last 7 Days' }, | |
| 140 | - { value: '30d', label: 'Last 30 Days' }, | |
| 141 | - { value: '90d', label: 'Last 90 Days' }, | |
| 142 | - { value: 'custom', label: 'Custom' }, | |
| 143 | -] | |
| 593 | + return pts.map((p, i) => ({ | |
| 594 | + | |
| 595 | + heightPct: (p.count / max) * 100, | |
| 596 | + | |
| 597 | + label: i % labelStep === 0 || i === n - 1 ? formatTrendMd(p.date) : '', | |
| 598 | + | |
| 599 | + })) | |
| 600 | + | |
| 601 | +}) | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | +const topProducts = computed(() => | |
| 606 | + | |
| 607 | + (report.value?.mostUsedProducts ?? []).map((p, i) => ({ | |
| 608 | + | |
| 609 | + key: (p.productId || p.productName) + i, | |
| 610 | + | |
| 611 | + name: formatDisplayText(p.productName, 'None'), | |
| 612 | + | |
| 613 | + category: formatDisplayText(p.categoryName, '—'), | |
| 614 | + | |
| 615 | + total: formatNumber(p.totalPrinted), | |
| 616 | + | |
| 617 | + usage: Number(p.usagePercent).toFixed(1), | |
| 618 | + | |
| 619 | + })), | |
| 620 | + | |
| 621 | +) | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | +let loadSeq = 0 | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | +async function loadReport() { | |
| 630 | + | |
| 631 | + const locationId = getCurrentStoreId() | |
| 632 | + | |
| 633 | + if (!locationId) { | |
| 634 | + | |
| 635 | + report.value = null | |
| 636 | + | |
| 637 | + return | |
| 144 | 638 | |
| 145 | -function selectPeriod(val: string) { | |
| 146 | - period.value = val | |
| 147 | - if (val === 'custom') { | |
| 148 | - const today = formatDate(new Date()) | |
| 149 | - const weekAgo = new Date() | |
| 150 | - weekAgo.setDate(weekAgo.getDate() - 7) | |
| 151 | - customStart.value = customStart.value || formatDate(weekAgo) | |
| 152 | - customEnd.value = customEnd.value || today | |
| 153 | 639 | } |
| 154 | -} | |
| 155 | 640 | |
| 156 | -function formatDate(d: Date): string { | |
| 157 | - const y = d.getFullYear() | |
| 158 | - const m = String(d.getMonth() + 1).padStart(2, '0') | |
| 159 | - const day = String(d.getDate()).padStart(2, '0') | |
| 160 | - return `${y}-${m}-${day}` | |
| 161 | -} | |
| 641 | + if (period.value === 'custom' && (!customStart.value || !customEnd.value)) { | |
| 162 | 642 | |
| 163 | -function onStartChange(e: any) { | |
| 164 | - customStart.value = e.detail.value | |
| 165 | -} | |
| 643 | + return | |
| 644 | + | |
| 645 | + } | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + const seq = ++loadSeq | |
| 650 | + | |
| 651 | + loading.value = true | |
| 652 | + | |
| 653 | + try { | |
| 654 | + | |
| 655 | + const data = await fetchUsAppLabelReport({ | |
| 656 | + | |
| 657 | + locationId, | |
| 658 | + | |
| 659 | + period: period.value, | |
| 660 | + | |
| 661 | + startDate: period.value === 'custom' ? customStart.value : undefined, | |
| 662 | + | |
| 663 | + endDate: period.value === 'custom' ? customEnd.value : undefined, | |
| 664 | + | |
| 665 | + }) | |
| 666 | + | |
| 667 | + if (seq !== loadSeq) return | |
| 668 | + | |
| 669 | + report.value = data | |
| 670 | + | |
| 671 | + } catch (e) { | |
| 672 | + | |
| 673 | + if (seq !== loadSeq) return | |
| 674 | + | |
| 675 | + if (!isUsAppSessionExpiredError(e)) { | |
| 676 | + | |
| 677 | + const msg = e instanceof Error ? e.message : 'Load failed' | |
| 678 | + | |
| 679 | + uni.showToast({ title: msg, icon: 'none', duration: 2500 }) | |
| 680 | + | |
| 681 | + } | |
| 682 | + | |
| 683 | + report.value = null | |
| 684 | + | |
| 685 | + } finally { | |
| 686 | + | |
| 687 | + if (seq === loadSeq) loading.value = false | |
| 688 | + | |
| 689 | + } | |
| 166 | 690 | |
| 167 | -function onEndChange(e: any) { | |
| 168 | - customEnd.value = e.detail.value | |
| 169 | 691 | } |
| 170 | 692 | |
| 171 | -const categoryDataRaw = [ | |
| 172 | - { name: 'Dairy', value: 450 }, | |
| 173 | - { name: 'Meat', value: 380 }, | |
| 174 | - { name: 'Bakery', value: 320 }, | |
| 175 | - { name: 'Deli', value: 280 }, | |
| 176 | - { name: 'Produce', value: 220 }, | |
| 177 | - { name: 'Beverage', value: 180 }, | |
| 178 | -] | |
| 179 | 693 | |
| 180 | -const categoryData = computed(() => { | |
| 181 | - const max = Math.max(...categoryDataRaw.map(d => d.value)) | |
| 182 | - return categoryDataRaw.map(d => ({ ...d, percent: (d.value / max) * 100 })) | |
| 183 | -}) | |
| 184 | 694 | |
| 185 | -const trendDataRaw = [72, 85, 78, 92, 88, 95, 90] | |
| 186 | -const trendData = computed(() => { | |
| 187 | - const max = Math.max(...trendDataRaw) | |
| 188 | - return trendDataRaw.map(v => (v / max) * 100) | |
| 695 | +onShow(() => { | |
| 696 | + | |
| 697 | + loadReport() | |
| 698 | + | |
| 189 | 699 | }) |
| 190 | 700 | |
| 191 | -const dayLabels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] | |
| 192 | 701 | |
| 193 | -const topProducts = ref([ | |
| 194 | - { name: 'Whole Milk', category: 'Dairy', total: 182, usage: '7.2' }, | |
| 195 | - { name: 'Ground Beef 80/20', category: 'Meat', total: 145, usage: '5.7' }, | |
| 196 | - { name: 'Chicken Breast', category: 'Meat', total: 132, usage: '5.2' }, | |
| 197 | - { name: 'Sliced Ham', category: 'Deli', total: 98, usage: '3.8' }, | |
| 198 | -]) | |
| 199 | 702 | |
| 200 | 703 | const goBack = () => { |
| 704 | + | |
| 201 | 705 | const pages = getCurrentPages() |
| 706 | + | |
| 202 | 707 | if (pages.length > 1) { |
| 708 | + | |
| 203 | 709 | uni.navigateBack() |
| 710 | + | |
| 204 | 711 | } else { |
| 712 | + | |
| 205 | 713 | uni.redirectTo({ url: '/pages/index/index' }) |
| 714 | + | |
| 206 | 715 | } |
| 716 | + | |
| 207 | 717 | } |
| 718 | + | |
| 208 | 719 | </script> |
| 209 | 720 | |
| 721 | + | |
| 722 | + | |
| 210 | 723 | <style scoped> |
| 724 | + | |
| 211 | 725 | .page { |
| 726 | + | |
| 212 | 727 | min-height: 100vh; |
| 728 | + | |
| 213 | 729 | background: #f3f4f6; |
| 730 | + | |
| 214 | 731 | display: flex; |
| 732 | + | |
| 215 | 733 | flex-direction: column; |
| 734 | + | |
| 216 | 735 | } |
| 217 | 736 | |
| 737 | + | |
| 738 | + | |
| 218 | 739 | .header-hero { |
| 740 | + | |
| 219 | 741 | background: linear-gradient(135deg, #1F3A8A, #142a6c); |
| 742 | + | |
| 220 | 743 | padding: 16rpx 32rpx 24rpx; |
| 744 | + | |
| 221 | 745 | } |
| 222 | 746 | |
| 747 | + | |
| 748 | + | |
| 223 | 749 | .top-bar { |
| 750 | + | |
| 224 | 751 | height: 96rpx; |
| 752 | + | |
| 225 | 753 | display: flex; |
| 754 | + | |
| 226 | 755 | align-items: center; |
| 756 | + | |
| 227 | 757 | justify-content: space-between; |
| 758 | + | |
| 228 | 759 | } |
| 229 | 760 | |
| 761 | + | |
| 762 | + | |
| 230 | 763 | .top-left, .top-right { |
| 764 | + | |
| 231 | 765 | width: 64rpx; |
| 766 | + | |
| 232 | 767 | height: 64rpx; |
| 768 | + | |
| 233 | 769 | border-radius: 999rpx; |
| 770 | + | |
| 234 | 771 | background: rgba(255, 255, 255, 0.15); |
| 772 | + | |
| 235 | 773 | display: flex; |
| 774 | + | |
| 236 | 775 | align-items: center; |
| 776 | + | |
| 237 | 777 | justify-content: center; |
| 778 | + | |
| 238 | 779 | } |
| 239 | 780 | |
| 781 | + | |
| 782 | + | |
| 240 | 783 | .top-center { |
| 784 | + | |
| 241 | 785 | flex: 1; |
| 786 | + | |
| 242 | 787 | display: flex; |
| 788 | + | |
| 243 | 789 | flex-direction: column; |
| 790 | + | |
| 244 | 791 | align-items: center; |
| 792 | + | |
| 245 | 793 | } |
| 246 | 794 | |
| 795 | + | |
| 796 | + | |
| 247 | 797 | .page-title { |
| 798 | + | |
| 248 | 799 | font-size: 34rpx; |
| 800 | + | |
| 249 | 801 | font-weight: 600; |
| 802 | + | |
| 250 | 803 | color: #fff; |
| 804 | + | |
| 251 | 805 | } |
| 252 | 806 | |
| 807 | + | |
| 808 | + | |
| 253 | 809 | .period-bar { |
| 810 | + | |
| 254 | 811 | display: flex; |
| 812 | + | |
| 255 | 813 | gap: 12rpx; |
| 814 | + | |
| 256 | 815 | padding: 16rpx 28rpx; |
| 816 | + | |
| 257 | 817 | background: #fff; |
| 818 | + | |
| 258 | 819 | border-bottom: 1rpx solid #e5e7eb; |
| 820 | + | |
| 259 | 821 | } |
| 260 | 822 | |
| 823 | + | |
| 824 | + | |
| 261 | 825 | .period-btn { |
| 826 | + | |
| 262 | 827 | flex: 1; |
| 828 | + | |
| 263 | 829 | padding: 16rpx 24rpx; |
| 830 | + | |
| 264 | 831 | border-radius: 12rpx; |
| 832 | + | |
| 265 | 833 | background: #f3f4f6; |
| 834 | + | |
| 266 | 835 | text-align: center; |
| 836 | + | |
| 267 | 837 | transition: all 0.2s; |
| 838 | + | |
| 268 | 839 | } |
| 269 | 840 | |
| 841 | + | |
| 842 | + | |
| 270 | 843 | .period-btn.active { |
| 844 | + | |
| 271 | 845 | background: linear-gradient(135deg, #1F3A8A, #1447E6); |
| 846 | + | |
| 272 | 847 | } |
| 273 | 848 | |
| 849 | + | |
| 850 | + | |
| 274 | 851 | .period-btn.active .period-text { |
| 852 | + | |
| 275 | 853 | color: #fff; |
| 854 | + | |
| 276 | 855 | } |
| 277 | 856 | |
| 857 | + | |
| 858 | + | |
| 278 | 859 | .period-text { |
| 860 | + | |
| 279 | 861 | font-size: 26rpx; |
| 862 | + | |
| 280 | 863 | font-weight: 500; |
| 864 | + | |
| 281 | 865 | color: #6b7280; |
| 866 | + | |
| 282 | 867 | } |
| 283 | 868 | |
| 869 | + | |
| 870 | + | |
| 284 | 871 | .custom-date-bar { |
| 872 | + | |
| 285 | 873 | display: flex; |
| 874 | + | |
| 286 | 875 | gap: 24rpx; |
| 876 | + | |
| 287 | 877 | padding: 20rpx 28rpx; |
| 878 | + | |
| 288 | 879 | background: #fff; |
| 880 | + | |
| 289 | 881 | border-bottom: 1rpx solid #e5e7eb; |
| 882 | + | |
| 290 | 883 | } |
| 291 | 884 | |
| 885 | + | |
| 886 | + | |
| 292 | 887 | .date-field { |
| 888 | + | |
| 293 | 889 | flex: 1; |
| 890 | + | |
| 294 | 891 | display: flex; |
| 892 | + | |
| 295 | 893 | flex-direction: column; |
| 894 | + | |
| 296 | 895 | gap: 8rpx; |
| 896 | + | |
| 297 | 897 | } |
| 298 | 898 | |
| 899 | + | |
| 900 | + | |
| 299 | 901 | .date-label { |
| 902 | + | |
| 300 | 903 | font-size: 24rpx; |
| 904 | + | |
| 301 | 905 | color: #6b7280; |
| 906 | + | |
| 302 | 907 | font-weight: 500; |
| 908 | + | |
| 303 | 909 | } |
| 304 | 910 | |
| 305 | -.date-picker-btn { | |
| 306 | - padding: 20rpx 24rpx; | |
| 307 | - background: #f3f4f6; | |
| 308 | - border-radius: 12rpx; | |
| 309 | - font-size: 28rpx; | |
| 310 | - color: #111827; | |
| 311 | - border: 2rpx solid #e5e7eb; | |
| 911 | + | |
| 912 | + | |
| 913 | +.content { | |
| 914 | + | |
| 915 | + flex: 1; | |
| 916 | + | |
| 917 | + padding: 24rpx 28rpx 40rpx; | |
| 918 | + | |
| 919 | + box-sizing: border-box; | |
| 920 | + | |
| 921 | +} | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | +.state-block { | |
| 926 | + | |
| 927 | + padding: 80rpx 0; | |
| 928 | + | |
| 929 | + text-align: center; | |
| 930 | + | |
| 312 | 931 | } |
| 313 | 932 | |
| 314 | -.content { | |
| 315 | - flex: 1; | |
| 316 | - padding: 24rpx 28rpx 40rpx; | |
| 317 | - box-sizing: border-box; | |
| 933 | + | |
| 934 | + | |
| 935 | +.state-text { | |
| 936 | + | |
| 937 | + font-size: 28rpx; | |
| 938 | + | |
| 939 | + color: #6b7280; | |
| 940 | + | |
| 318 | 941 | } |
| 319 | 942 | |
| 943 | + | |
| 944 | + | |
| 320 | 945 | .metrics-grid { |
| 946 | + | |
| 321 | 947 | display: grid; |
| 948 | + | |
| 322 | 949 | grid-template-columns: 1fr 1fr; |
| 950 | + | |
| 323 | 951 | gap: 20rpx; |
| 952 | + | |
| 324 | 953 | margin-bottom: 32rpx; |
| 954 | + | |
| 325 | 955 | } |
| 326 | 956 | |
| 957 | + | |
| 958 | + | |
| 327 | 959 | .metric-card { |
| 960 | + | |
| 328 | 961 | background: #fff; |
| 962 | + | |
| 329 | 963 | padding: 28rpx; |
| 964 | + | |
| 330 | 965 | border-radius: 20rpx; |
| 966 | + | |
| 331 | 967 | box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); |
| 968 | + | |
| 332 | 969 | border: 1rpx solid #e5e7eb; |
| 970 | + | |
| 333 | 971 | } |
| 334 | 972 | |
| 973 | + | |
| 974 | + | |
| 335 | 975 | .metric-label { |
| 976 | + | |
| 336 | 977 | font-size: 24rpx; |
| 978 | + | |
| 337 | 979 | color: #6b7280; |
| 980 | + | |
| 338 | 981 | display: block; |
| 982 | + | |
| 339 | 983 | margin-bottom: 12rpx; |
| 984 | + | |
| 340 | 985 | line-height: 1.5; |
| 986 | + | |
| 341 | 987 | } |
| 342 | 988 | |
| 989 | + | |
| 990 | + | |
| 343 | 991 | .metric-value { |
| 992 | + | |
| 344 | 993 | font-size: 38rpx; |
| 994 | + | |
| 345 | 995 | font-weight: 700; |
| 996 | + | |
| 346 | 997 | color: #111827; |
| 998 | + | |
| 347 | 999 | display: block; |
| 1000 | + | |
| 348 | 1001 | margin-bottom: 8rpx; |
| 1002 | + | |
| 349 | 1003 | letter-spacing: -0.02em; |
| 1004 | + | |
| 350 | 1005 | } |
| 351 | 1006 | |
| 1007 | + | |
| 1008 | + | |
| 352 | 1009 | .metric-trend { |
| 1010 | + | |
| 353 | 1011 | font-size: 24rpx; |
| 1012 | + | |
| 354 | 1013 | color: #6b7280; |
| 1014 | + | |
| 355 | 1015 | } |
| 356 | 1016 | |
| 1017 | + | |
| 1018 | + | |
| 357 | 1019 | .metric-trend.up { |
| 1020 | + | |
| 358 | 1021 | color: #16a34a; |
| 1022 | + | |
| 1023 | + font-weight: 500; | |
| 1024 | + | |
| 1025 | +} | |
| 1026 | + | |
| 1027 | + | |
| 1028 | + | |
| 1029 | +.metric-trend.down { | |
| 1030 | + | |
| 1031 | + color: #dc2626; | |
| 1032 | + | |
| 359 | 1033 | font-weight: 500; |
| 1034 | + | |
| 360 | 1035 | } |
| 361 | 1036 | |
| 1037 | + | |
| 1038 | + | |
| 362 | 1039 | .section { |
| 1040 | + | |
| 363 | 1041 | margin-bottom: 32rpx; |
| 1042 | + | |
| 364 | 1043 | } |
| 365 | 1044 | |
| 1045 | + | |
| 1046 | + | |
| 366 | 1047 | .section-title { |
| 1048 | + | |
| 367 | 1049 | font-size: 32rpx; |
| 1050 | + | |
| 368 | 1051 | font-weight: 600; |
| 1052 | + | |
| 369 | 1053 | color: #111827; |
| 1054 | + | |
| 370 | 1055 | display: block; |
| 1056 | + | |
| 371 | 1057 | margin-bottom: 8rpx; |
| 1058 | + | |
| 372 | 1059 | } |
| 373 | 1060 | |
| 1061 | + | |
| 1062 | + | |
| 374 | 1063 | .section-desc { |
| 1064 | + | |
| 375 | 1065 | font-size: 26rpx; |
| 1066 | + | |
| 376 | 1067 | color: #6b7280; |
| 1068 | + | |
| 377 | 1069 | display: block; |
| 1070 | + | |
| 378 | 1071 | margin-bottom: 24rpx; |
| 1072 | + | |
| 379 | 1073 | line-height: 1.5; |
| 1074 | + | |
| 1075 | +} | |
| 1076 | + | |
| 1077 | + | |
| 1078 | + | |
| 1079 | +.empty-panel { | |
| 1080 | + | |
| 1081 | + background: #fff; | |
| 1082 | + | |
| 1083 | + padding: 48rpx 28rpx; | |
| 1084 | + | |
| 1085 | + border-radius: 20rpx; | |
| 1086 | + | |
| 1087 | + border: 1rpx solid #e5e7eb; | |
| 1088 | + | |
| 1089 | + text-align: center; | |
| 1090 | + | |
| 1091 | +} | |
| 1092 | + | |
| 1093 | + | |
| 1094 | + | |
| 1095 | +.empty-text { | |
| 1096 | + | |
| 1097 | + font-size: 26rpx; | |
| 1098 | + | |
| 1099 | + color: #9ca3af; | |
| 1100 | + | |
| 380 | 1101 | } |
| 381 | 1102 | |
| 1103 | + | |
| 1104 | + | |
| 382 | 1105 | .bar-chart { |
| 1106 | + | |
| 383 | 1107 | background: #fff; |
| 1108 | + | |
| 384 | 1109 | padding: 28rpx; |
| 1110 | + | |
| 385 | 1111 | border-radius: 20rpx; |
| 1112 | + | |
| 386 | 1113 | box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); |
| 1114 | + | |
| 387 | 1115 | border: 1rpx solid #e5e7eb; |
| 1116 | + | |
| 388 | 1117 | } |
| 389 | 1118 | |
| 1119 | + | |
| 1120 | + | |
| 390 | 1121 | .bar-row { |
| 1122 | + | |
| 391 | 1123 | display: flex; |
| 1124 | + | |
| 392 | 1125 | align-items: center; |
| 1126 | + | |
| 393 | 1127 | gap: 16rpx; |
| 1128 | + | |
| 394 | 1129 | margin-bottom: 24rpx; |
| 1130 | + | |
| 395 | 1131 | } |
| 396 | 1132 | |
| 1133 | + | |
| 1134 | + | |
| 397 | 1135 | .bar-row:last-child { |
| 1136 | + | |
| 398 | 1137 | margin-bottom: 0; |
| 1138 | + | |
| 399 | 1139 | } |
| 400 | 1140 | |
| 1141 | + | |
| 1142 | + | |
| 401 | 1143 | .bar-label { |
| 1144 | + | |
| 402 | 1145 | width: 120rpx; |
| 1146 | + | |
| 403 | 1147 | font-size: 26rpx; |
| 1148 | + | |
| 404 | 1149 | color: #374151; |
| 1150 | + | |
| 405 | 1151 | flex-shrink: 0; |
| 1152 | + | |
| 406 | 1153 | font-weight: 500; |
| 1154 | + | |
| 407 | 1155 | } |
| 408 | 1156 | |
| 1157 | + | |
| 1158 | + | |
| 409 | 1159 | .bar-track { |
| 1160 | + | |
| 410 | 1161 | flex: 1; |
| 1162 | + | |
| 411 | 1163 | height: 36rpx; |
| 1164 | + | |
| 412 | 1165 | background: #f3f4f6; |
| 1166 | + | |
| 413 | 1167 | border-radius: 10rpx; |
| 1168 | + | |
| 414 | 1169 | overflow: hidden; |
| 1170 | + | |
| 415 | 1171 | } |
| 416 | 1172 | |
| 1173 | + | |
| 1174 | + | |
| 417 | 1175 | .bar-fill { |
| 1176 | + | |
| 418 | 1177 | height: 100%; |
| 1178 | + | |
| 419 | 1179 | background: linear-gradient(90deg, #1F3A8A, #1447E6); |
| 1180 | + | |
| 420 | 1181 | border-radius: 10rpx; |
| 1182 | + | |
| 421 | 1183 | transition: width 0.3s ease; |
| 1184 | + | |
| 422 | 1185 | } |
| 423 | 1186 | |
| 1187 | + | |
| 1188 | + | |
| 424 | 1189 | .bar-value { |
| 1190 | + | |
| 425 | 1191 | width: 88rpx; |
| 1192 | + | |
| 426 | 1193 | text-align: right; |
| 1194 | + | |
| 427 | 1195 | font-size: 26rpx; |
| 1196 | + | |
| 428 | 1197 | font-weight: 600; |
| 1198 | + | |
| 429 | 1199 | color: #111827; |
| 1200 | + | |
| 430 | 1201 | } |
| 431 | 1202 | |
| 1203 | + | |
| 1204 | + | |
| 432 | 1205 | .line-chart { |
| 1206 | + | |
| 433 | 1207 | background: #fff; |
| 1208 | + | |
| 434 | 1209 | padding: 28rpx; |
| 1210 | + | |
| 435 | 1211 | border-radius: 20rpx; |
| 1212 | + | |
| 436 | 1213 | box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); |
| 1214 | + | |
| 437 | 1215 | border: 1rpx solid #e5e7eb; |
| 1216 | + | |
| 438 | 1217 | } |
| 439 | 1218 | |
| 1219 | + | |
| 1220 | + | |
| 440 | 1221 | .chart-bars { |
| 1222 | + | |
| 441 | 1223 | display: flex; |
| 1224 | + | |
| 442 | 1225 | align-items: flex-end; |
| 1226 | + | |
| 443 | 1227 | justify-content: space-between; |
| 444 | - gap: 12rpx; | |
| 1228 | + | |
| 1229 | + gap: 4rpx; | |
| 1230 | + | |
| 445 | 1231 | height: 220rpx; |
| 1232 | + | |
| 446 | 1233 | margin-bottom: 20rpx; |
| 1234 | + | |
| 447 | 1235 | } |
| 448 | 1236 | |
| 1237 | + | |
| 1238 | + | |
| 449 | 1239 | .day-bar-wrap { |
| 1240 | + | |
| 450 | 1241 | flex: 1; |
| 1242 | + | |
| 451 | 1243 | display: flex; |
| 1244 | + | |
| 452 | 1245 | align-items: flex-end; |
| 1246 | + | |
| 453 | 1247 | justify-content: center; |
| 1248 | + | |
| 1249 | + min-width: 0; | |
| 1250 | + | |
| 454 | 1251 | } |
| 455 | 1252 | |
| 1253 | + | |
| 1254 | + | |
| 456 | 1255 | .day-bar { |
| 1256 | + | |
| 457 | 1257 | width: 100%; |
| 1258 | + | |
| 458 | 1259 | max-width: 52rpx; |
| 1260 | + | |
| 459 | 1261 | min-height: 12rpx; |
| 1262 | + | |
| 460 | 1263 | background: linear-gradient(180deg, #1F3A8A, #1447E6); |
| 1264 | + | |
| 461 | 1265 | border-radius: 10rpx 10rpx 0 0; |
| 1266 | + | |
| 462 | 1267 | transition: height 0.3s ease; |
| 1268 | + | |
| 463 | 1269 | } |
| 464 | 1270 | |
| 1271 | + | |
| 1272 | + | |
| 465 | 1273 | .chart-labels { |
| 1274 | + | |
| 466 | 1275 | display: flex; |
| 1276 | + | |
| 467 | 1277 | justify-content: space-between; |
| 468 | - gap: 12rpx; | |
| 1278 | + | |
| 1279 | + gap: 4rpx; | |
| 1280 | + | |
| 469 | 1281 | } |
| 470 | 1282 | |
| 1283 | + | |
| 1284 | + | |
| 471 | 1285 | .day-label { |
| 1286 | + | |
| 472 | 1287 | flex: 1; |
| 473 | - padding: 0 4rpx; | |
| 474 | - font-size: 24rpx; | |
| 1288 | + | |
| 1289 | + min-width: 0; | |
| 1290 | + | |
| 1291 | + padding: 0 2rpx; | |
| 1292 | + | |
| 1293 | + font-size: 20rpx; | |
| 1294 | + | |
| 475 | 1295 | color: #6b7280; |
| 1296 | + | |
| 476 | 1297 | text-align: center; |
| 1298 | + | |
| 1299 | + overflow: hidden; | |
| 1300 | + | |
| 1301 | + text-overflow: ellipsis; | |
| 1302 | + | |
| 1303 | + white-space: nowrap; | |
| 1304 | + | |
| 477 | 1305 | } |
| 478 | 1306 | |
| 1307 | + | |
| 1308 | + | |
| 479 | 1309 | .product-table { |
| 1310 | + | |
| 480 | 1311 | background: #fff; |
| 1312 | + | |
| 481 | 1313 | border-radius: 20rpx; |
| 1314 | + | |
| 482 | 1315 | overflow: hidden; |
| 1316 | + | |
| 483 | 1317 | box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04); |
| 1318 | + | |
| 484 | 1319 | border: 1rpx solid #e5e7eb; |
| 1320 | + | |
| 485 | 1321 | } |
| 486 | 1322 | |
| 1323 | + | |
| 1324 | + | |
| 487 | 1325 | .product-row { |
| 1326 | + | |
| 488 | 1327 | display: flex; |
| 1328 | + | |
| 489 | 1329 | padding: 24rpx 28rpx; |
| 1330 | + | |
| 490 | 1331 | border-bottom: 1rpx solid #e5e7eb; |
| 1332 | + | |
| 491 | 1333 | font-size: 28rpx; |
| 1334 | + | |
| 492 | 1335 | } |
| 493 | 1336 | |
| 1337 | + | |
| 1338 | + | |
| 494 | 1339 | .product-row:last-child { |
| 1340 | + | |
| 495 | 1341 | border-bottom: none; |
| 1342 | + | |
| 496 | 1343 | } |
| 497 | 1344 | |
| 1345 | + | |
| 1346 | + | |
| 498 | 1347 | .product-row.header { |
| 1348 | + | |
| 499 | 1349 | background: #fafbfc; |
| 1350 | + | |
| 500 | 1351 | font-weight: 600; |
| 1352 | + | |
| 501 | 1353 | color: #6b7280; |
| 1354 | + | |
| 502 | 1355 | font-size: 24rpx; |
| 1356 | + | |
| 1357 | +} | |
| 1358 | + | |
| 1359 | + | |
| 1360 | + | |
| 1361 | +.product-row.empty-row { | |
| 1362 | + | |
| 1363 | + justify-content: center; | |
| 1364 | + | |
| 1365 | +} | |
| 1366 | + | |
| 1367 | + | |
| 1368 | + | |
| 1369 | +.empty-row-text { | |
| 1370 | + | |
| 1371 | + font-size: 26rpx; | |
| 1372 | + | |
| 1373 | + color: #9ca3af; | |
| 1374 | + | |
| 503 | 1375 | } |
| 504 | 1376 | |
| 1377 | + | |
| 1378 | + | |
| 505 | 1379 | .col-name { flex: 1; min-width: 0; text-align: left; } |
| 1380 | + | |
| 506 | 1381 | .col-cat { flex: 0 0 140rpx; text-align: left; } |
| 1382 | + | |
| 507 | 1383 | .col-total { flex: 0 0 120rpx; text-align: left; } |
| 1384 | + | |
| 508 | 1385 | .col-pct { flex: 0 0 88rpx; text-align: left; } |
| 509 | 1386 | |
| 1387 | + | |
| 1388 | + | |
| 510 | 1389 | .product-row:not(.header) .col-name { color: #111827; font-weight: 500; } |
| 1390 | + | |
| 511 | 1391 | .product-row:not(.header) .col-cat { color: #6b7280; } |
| 1392 | + | |
| 512 | 1393 | .product-row:not(.header) .col-total { color: #111827; } |
| 1394 | + | |
| 513 | 1395 | .product-row:not(.header) .col-pct { color: #1F3A8A; font-weight: 600; } |
| 1396 | + | |
| 514 | 1397 | </style> |
| 1398 | + | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
| ... | ... | @@ -53,7 +53,7 @@ |
| 53 | 53 | class="log-card" |
| 54 | 54 | > |
| 55 | 55 | <view class="card-header"> |
| 56 | - <text class="product-name">{{ row.productName || '无' }}</text> | |
| 56 | + <text class="product-name">{{ displayField(row.productName) }}</text> | |
| 57 | 57 | <text class="label-id">{{ shortRef(row) }}</text> |
| 58 | 58 | </view> |
| 59 | 59 | <view class="card-tags"> |
| ... | ... | @@ -67,11 +67,11 @@ |
| 67 | 67 | </view> |
| 68 | 68 | <view class="detail-row"> |
| 69 | 69 | <AppIcon name="user" size="sm" color="gray" /> |
| 70 | - <text class="detail-text">{{ row.operatorName || '无' }}</text> | |
| 70 | + <text class="detail-text">{{ displayField(row.operatorName) }}</text> | |
| 71 | 71 | </view> |
| 72 | 72 | <view class="detail-row"> |
| 73 | 73 | <AppIcon name="mapPin" size="sm" color="gray" /> |
| 74 | - <text class="detail-text">{{ row.locationName || '无' }}</text> | |
| 74 | + <text class="detail-text">{{ displayField(row.locationName) }}</text> | |
| 75 | 75 | </view> |
| 76 | 76 | </view> |
| 77 | 77 | <view class="card-footer"> |
| ... | ... | @@ -105,7 +105,7 @@ |
| 105 | 105 | :key="row.taskId + '-' + row.copyIndex" |
| 106 | 106 | class="log-table-row" |
| 107 | 107 | > |
| 108 | - <text class="td td-product">{{ row.productName || '无' }}</text> | |
| 108 | + <text class="td td-product">{{ displayField(row.productName) }}</text> | |
| 109 | 109 | <text class="td td-id">{{ shortRef(row) }}</text> |
| 110 | 110 | <text class="td td-size">{{ tagLabelSize(row) }}</text> |
| 111 | 111 | <text class="td td-type">{{ tagTypeName(row) }}</text> |
| ... | ... | @@ -157,6 +157,7 @@ import { |
| 157 | 157 | } from '../../utils/print/printerConnection' |
| 158 | 158 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 159 | 159 | import type { PrintLogItemDto } from '../../types/usAppLabeling' |
| 160 | +import { formatDisplayText } from '../../utils/emptyDisplay' | |
| 160 | 161 | |
| 161 | 162 | const statusBarHeight = getStatusBarHeight() |
| 162 | 163 | const isMenuOpen = ref(false) |
| ... | ... | @@ -208,6 +209,10 @@ function createClientRequestId (): string { |
| 208 | 209 | return `reprint-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` |
| 209 | 210 | } |
| 210 | 211 | |
| 212 | +function displayField (value: string | null | undefined): string { | |
| 213 | + return formatDisplayText(value, 'None') | |
| 214 | +} | |
| 215 | + | |
| 211 | 216 | function shortRef (row: PrintLogItemDto): string { |
| 212 | 217 | const b = String(row.batchId || row.taskId || '').trim() |
| 213 | 218 | if (b.length > 14) return `${b.slice(0, 8)}…` |
| ... | ... | @@ -218,14 +223,14 @@ function shortRef (row: PrintLogItemDto): string { |
| 218 | 223 | function tagLabelSize (row: PrintLogItemDto): string { |
| 219 | 224 | const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText |
| 220 | 225 | const s = String(raw ?? '').trim() |
| 221 | - return s || '无' | |
| 226 | + return formatDisplayText(s, 'None') | |
| 222 | 227 | } |
| 223 | 228 | |
| 224 | 229 | /** 红框右:typeName(兼容 PascalCase) */ |
| 225 | 230 | function tagTypeName (row: PrintLogItemDto): string { |
| 226 | 231 | const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName |
| 227 | 232 | const s = String(raw ?? '').trim() |
| 228 | - return s || '无' | |
| 233 | + return formatDisplayText(s, 'None') | |
| 229 | 234 | } |
| 230 | 235 | |
| 231 | 236 | async function loadPage (reset: boolean) { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue
| ... | ... | @@ -109,8 +109,8 @@ |
| 109 | 109 | <text class="tips-item">1. Ensure the printer is powered on and in pairing mode</text> |
| 110 | 110 | <text class="tips-item">2. On Android: enable Location (required for Bluetooth scan)</text> |
| 111 | 111 | <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text> |
| 112 | - <text class="tips-item">4. Devices with no name show as "Unknown Device"—you can still connect</text> | |
| 113 | - <text class="tips-item">5. GP-D320FX (d320fx_xxxx): use Bluetooth mode, tap Scan—shows paired + nearby devices, no filtering</text> | |
| 112 | + <text class="tips-item">4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer</text> | |
| 113 | + <text class="tips-item">5. Pair the printer in system Bluetooth first if it does not appear after Scan</text> | |
| 114 | 114 | <text class="tips-item">6. Restart the printer or app if not visible</text> |
| 115 | 115 | <text class="tips-item tips-item-last">7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only.</text> |
| 116 | 116 | </view> |
| ... | ... | @@ -173,6 +173,7 @@ import SideMenu from '../../components/SideMenu.vue' |
| 173 | 173 | import { getDeviceIdentity } from '../../utils/deviceInfo' |
| 174 | 174 | import classicBluetooth from '../../utils/print/bluetoothTool.js' |
| 175 | 175 | import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' |
| 176 | +import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist' | |
| 176 | 177 | import { getAvailablePrinterTypes } from '../../utils/print/printerConnection' |
| 177 | 178 | import { |
| 178 | 179 | connectBluetoothPrinter, |
| ... | ... | @@ -269,14 +270,12 @@ const normalizeDeviceName = (device: any) => { |
| 269 | 270 | } |
| 270 | 271 | |
| 271 | 272 | const hasPreferredClassicDevice = () => { |
| 272 | - return pairedDevices.value.some((item: any) => { | |
| 273 | - const name = String(item?.name || '').toLowerCase() | |
| 274 | - const driverKey = String(item?.driverKey || '').toLowerCase() | |
| 275 | - return name.includes('virtual bt printer') || driverKey === 'd320fax' | |
| 276 | - }) | |
| 273 | + return pairedDevices.value.some((item: any) => isAllowedBluetoothPrinterName(item?.name)) | |
| 277 | 274 | } |
| 278 | 275 | |
| 279 | 276 | const addDeviceDedup = (device: any) => { |
| 277 | + const displayName = normalizeDeviceName(device) | |
| 278 | + if (!isAllowedBluetoothPrinterName(displayName)) return | |
| 280 | 279 | const described = describeDiscoveredPrinter(device) |
| 281 | 280 | const existing = devices.value.find(d => d.deviceId === device.deviceId) |
| 282 | 281 | if (!existing) { |
| ... | ... | @@ -291,11 +290,15 @@ const loadPairedDevices = () => { |
| 291 | 290 | try { |
| 292 | 291 | const list = classicBluetooth.getPairedDevices() |
| 293 | 292 | debugInfo.value.pairedCount = (list || []).length |
| 294 | - debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => String(item?.name || '').toLowerCase().includes('virtual bt printer')) | |
| 295 | - pairedDevices.value = (list || []).map((item: any) => ({ | |
| 296 | - ...describeDiscoveredPrinter(item), | |
| 297 | - name: normalizeDeviceName(item), | |
| 298 | - })) | |
| 293 | + debugInfo.value.foundVirtualPrinter = (list || []).some((item: any) => | |
| 294 | + isAllowedBluetoothPrinterName(item?.name) && String(item?.name || '').toLowerCase().includes('virtual bt'), | |
| 295 | + ) | |
| 296 | + pairedDevices.value = (list || []) | |
| 297 | + .map((item: any) => ({ | |
| 298 | + ...describeDiscoveredPrinter(item), | |
| 299 | + name: normalizeDeviceName(item), | |
| 300 | + })) | |
| 301 | + .filter((item: any) => isAllowedBluetoothPrinterName(item?.name)) | |
| 299 | 302 | debugInfo.value.lastClassicEvent = pairedDevices.value.length > 0 ? 'paired devices loaded' : 'no paired devices' |
| 300 | 303 | } catch (e) { |
| 301 | 304 | console.error('Failed to load paired devices', e) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/profile.vue
| ... | ... | @@ -84,6 +84,7 @@ import SideMenu from '../../components/SideMenu.vue' |
| 84 | 84 | import LocationPicker from '../../components/LocationPicker.vue' |
| 85 | 85 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 86 | 86 | import { usAppFetchMyProfile } from '../../services/usAppAuth' |
| 87 | +import { formatDisplayText } from '../../utils/emptyDisplay' | |
| 87 | 88 | |
| 88 | 89 | const { t } = useI18n() |
| 89 | 90 | const statusBarHeight = getStatusBarHeight() |
| ... | ... | @@ -98,11 +99,11 @@ async function loadProfile() { |
| 98 | 99 | uni.showLoading({ title: 'Loading...', mask: true }) |
| 99 | 100 | try { |
| 100 | 101 | const p = await usAppFetchMyProfile() |
| 101 | - name.value = p.fullName?.trim() ? p.fullName : '—' | |
| 102 | - email.value = p.email?.trim() ? p.email : '—' | |
| 103 | - phone.value = p.phone?.trim() ? p.phone : '—' | |
| 104 | - employeeId.value = p.employeeId?.trim() ? p.employeeId : '—' | |
| 105 | - roleDisplay.value = p.roleDisplay?.trim() ? p.roleDisplay : '—' | |
| 102 | + name.value = formatDisplayText(p.fullName, '—') | |
| 103 | + email.value = formatDisplayText(p.email, '—') | |
| 104 | + phone.value = formatDisplayText(p.phone, '—') | |
| 105 | + employeeId.value = formatDisplayText(p.employeeId, '—') | |
| 106 | + roleDisplay.value = formatDisplayText(p.roleDisplay, '—') | |
| 106 | 107 | if (p.fullName?.trim()) { |
| 107 | 108 | uni.setStorageSync('userName', p.fullName.trim()) |
| 108 | 109 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/store-select/store-select.vue
| ... | ... | @@ -11,8 +11,55 @@ |
| 11 | 11 | </view> |
| 12 | 12 | </view> |
| 13 | 13 | |
| 14 | + <view v-if="isAdminUser" class="scope-panel"> | |
| 15 | + <view class="scope-section"> | |
| 16 | + <text class="scope-label">{{ t('login.scopeCompany') }} *</text> | |
| 17 | + <view v-if="scopeLoading && !companies.length" class="scope-hint"> | |
| 18 | + <text class="scope-hint-text">{{ t('login.scopeLoading') }}</text> | |
| 19 | + </view> | |
| 20 | + <view v-else-if="!companies.length" class="scope-hint"> | |
| 21 | + <text class="scope-hint-text">{{ t('login.scopeNoCompanies') }}</text> | |
| 22 | + </view> | |
| 23 | + <view v-else class="scope-chips"> | |
| 24 | + <view | |
| 25 | + v-for="c in companies" | |
| 26 | + :key="c.id" | |
| 27 | + class="scope-chip" | |
| 28 | + :class="{ active: selectedPartnerId === c.id }" | |
| 29 | + @click="onSelectCompany(c.id)" | |
| 30 | + > | |
| 31 | + <text class="scope-chip-text">{{ c.partnerName }}</text> | |
| 32 | + </view> | |
| 33 | + </view> | |
| 34 | + </view> | |
| 35 | + | |
| 36 | + <view v-if="selectedPartnerId" class="scope-section"> | |
| 37 | + <text class="scope-label">{{ t('login.scopeRegion') }} *</text> | |
| 38 | + <view v-if="regionsLoading" class="scope-hint"> | |
| 39 | + <text class="scope-hint-text">{{ t('login.scopeLoading') }}</text> | |
| 40 | + </view> | |
| 41 | + <view v-else-if="!regions.length" class="scope-hint"> | |
| 42 | + <text class="scope-hint-text">{{ t('login.scopeNoRegions') }}</text> | |
| 43 | + </view> | |
| 44 | + <view v-else class="scope-chips"> | |
| 45 | + <view | |
| 46 | + v-for="r in regions" | |
| 47 | + :key="r.id" | |
| 48 | + class="scope-chip" | |
| 49 | + :class="{ active: selectedGroupId === r.id }" | |
| 50 | + @click="onSelectRegion(r.id)" | |
| 51 | + > | |
| 52 | + <text class="scope-chip-text">{{ r.groupName }}</text> | |
| 53 | + </view> | |
| 54 | + </view> | |
| 55 | + </view> | |
| 56 | + </view> | |
| 57 | + | |
| 14 | 58 | <view class="list"> |
| 15 | - <view v-if="!loading && stores.length === 0" class="empty-hint"> | |
| 59 | + <view v-if="isAdminUser && !selectedGroupId" class="empty-hint"> | |
| 60 | + <text class="empty-text">{{ t('login.selectCompanyRegionFirst') }}</text> | |
| 61 | + </view> | |
| 62 | + <view v-else-if="!loading && stores.length === 0" class="empty-hint"> | |
| 16 | 63 | <text class="empty-text">{{ t('login.noStoresBound') }}</text> |
| 17 | 64 | </view> |
| 18 | 65 | <view |
| ... | ... | @@ -51,22 +98,18 @@ |
| 51 | 98 | </view> |
| 52 | 99 | |
| 53 | 100 | <view class="bottom-bar" :style="{ paddingBottom: (bottomSafeArea + 24) + 'px' }"> |
| 54 | - <view v-if="!loading && stores.length === 0" class="bottom-actions-row"> | |
| 101 | + <view class="bottom-actions-row"> | |
| 55 | 102 | <view class="back-btn" @click="handleBackToLogin"> |
| 56 | - <text class="back-btn-text">{{ t('common.back') }}</text> | |
| 103 | + <text class="back-btn-text">{{ t('login.backToSignIn') }}</text> | |
| 57 | 104 | </view> |
| 58 | - <view class="confirm-btn disabled"> | |
| 59 | - <text class="confirm-btn-text">{{ t('common.confirm') }}</text> | |
| 105 | + <view | |
| 106 | + class="confirm-btn" | |
| 107 | + :class="{ disabled: !canConfirm }" | |
| 108 | + @click="handleConfirm" | |
| 109 | + > | |
| 110 | + <text class="confirm-btn-text">{{ loading || confirming ? '…' : t('common.confirm') }}</text> | |
| 60 | 111 | </view> |
| 61 | 112 | </view> |
| 62 | - <view | |
| 63 | - v-else | |
| 64 | - class="confirm-btn" | |
| 65 | - :class="{ disabled: !selectedStore || loading }" | |
| 66 | - @click="handleConfirm" | |
| 67 | - > | |
| 68 | - <text class="confirm-btn-text">{{ loading ? '…' : t('common.confirm') }}</text> | |
| 69 | - </view> | |
| 70 | 113 | </view> |
| 71 | 114 | </view> |
| 72 | 115 | </template> |
| ... | ... | @@ -77,9 +120,18 @@ import { useI18n } from 'vue-i18n' |
| 77 | 120 | import { onShow } from '@dcloudio/uni-app' |
| 78 | 121 | import AppIcon from '../../components/AppIcon.vue' |
| 79 | 122 | import { getStatusBarHeight, getBottomSafeArea } from '../../utils/statusBar' |
| 80 | -import { usAppFetchMyLocations } from '../../services/usAppAuth' | |
| 123 | +import { | |
| 124 | + usAppFetchAdminScopeCompanies, | |
| 125 | + usAppFetchAdminScopeLocations, | |
| 126 | + usAppFetchAdminScopeRegions, | |
| 127 | + usAppFetchMyLocations, | |
| 128 | + usAppFetchMyProfile, | |
| 129 | + usAppSelectAdminScopeLocation, | |
| 130 | +} from '../../services/usAppAuth' | |
| 131 | +import type { AuthScopeCompanyOption, AuthScopeRegionOption } from '../../types/usAppAdminScope' | |
| 81 | 132 | import type { UsAppBoundLocationDto } from '../../types/usAppBound' |
| 82 | 133 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 134 | +import { isAppAdminUser } from '../../utils/appAdminRole' | |
| 83 | 135 | import { setBoundLocations, getBoundLocations, clearAuthSession } from '../../utils/authSession' |
| 84 | 136 | import { switchStore } from '../../utils/stores' |
| 85 | 137 | |
| ... | ... | @@ -90,6 +142,15 @@ const userName = computed(() => uni.getStorageSync('userName') || 'Employee') |
| 90 | 142 | const selectedStore = ref('') |
| 91 | 143 | const stores = ref<UsAppBoundLocationDto[]>([]) |
| 92 | 144 | const loading = ref(false) |
| 145 | +const confirming = ref(false) | |
| 146 | + | |
| 147 | +const isAdminUser = ref(false) | |
| 148 | +const scopeLoading = ref(false) | |
| 149 | +const regionsLoading = ref(false) | |
| 150 | +const companies = ref<AuthScopeCompanyOption[]>([]) | |
| 151 | +const regions = ref<AuthScopeRegionOption[]>([]) | |
| 152 | +const selectedPartnerId = ref('') | |
| 153 | +const selectedGroupId = ref('') | |
| 93 | 154 | |
| 94 | 155 | function applyList(list: UsAppBoundLocationDto[]) { |
| 95 | 156 | const enabled = list.filter((s) => s.state !== false) |
| ... | ... | @@ -97,7 +158,7 @@ function applyList(list: UsAppBoundLocationDto[]) { |
| 97 | 158 | setBoundLocations(enabled) |
| 98 | 159 | } |
| 99 | 160 | |
| 100 | -async function refreshFromApi() { | |
| 161 | +async function refreshEmployeeStores() { | |
| 101 | 162 | loading.value = true |
| 102 | 163 | try { |
| 103 | 164 | const list = await usAppFetchMyLocations() |
| ... | ... | @@ -111,13 +172,117 @@ async function refreshFromApi() { |
| 111 | 172 | } |
| 112 | 173 | } |
| 113 | 174 | |
| 175 | +async function loadCompanies() { | |
| 176 | + scopeLoading.value = true | |
| 177 | + try { | |
| 178 | + companies.value = await usAppFetchAdminScopeCompanies() | |
| 179 | + } catch (e) { | |
| 180 | + if (isUsAppSessionExpiredError(e)) return | |
| 181 | + companies.value = [] | |
| 182 | + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) | |
| 183 | + } finally { | |
| 184 | + scopeLoading.value = false | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +async function loadRegions(partnerId: string) { | |
| 189 | + regionsLoading.value = true | |
| 190 | + regions.value = [] | |
| 191 | + try { | |
| 192 | + regions.value = await usAppFetchAdminScopeRegions(partnerId) | |
| 193 | + } catch (e) { | |
| 194 | + if (isUsAppSessionExpiredError(e)) return | |
| 195 | + regions.value = [] | |
| 196 | + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) | |
| 197 | + } finally { | |
| 198 | + regionsLoading.value = false | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +async function loadAdminLocations(partnerId: string, groupId: string) { | |
| 203 | + loading.value = true | |
| 204 | + selectedStore.value = '' | |
| 205 | + try { | |
| 206 | + const list = await usAppFetchAdminScopeLocations(partnerId, groupId) | |
| 207 | + applyList( | |
| 208 | + list.map((x) => ({ | |
| 209 | + id: x.id, | |
| 210 | + locationCode: x.locationCode, | |
| 211 | + locationName: x.locationName, | |
| 212 | + fullAddress: x.fullAddress, | |
| 213 | + state: x.state, | |
| 214 | + })), | |
| 215 | + ) | |
| 216 | + } catch (e) { | |
| 217 | + if (isUsAppSessionExpiredError(e)) return | |
| 218 | + applyList([]) | |
| 219 | + uni.showToast({ title: t('login.scopeLoadFail'), icon: 'none' }) | |
| 220 | + } finally { | |
| 221 | + loading.value = false | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +function onSelectCompany(partnerId: string) { | |
| 226 | + const id = partnerId.trim() | |
| 227 | + if (selectedPartnerId.value === id) return | |
| 228 | + selectedPartnerId.value = id | |
| 229 | + selectedGroupId.value = '' | |
| 230 | + selectedStore.value = '' | |
| 231 | + stores.value = [] | |
| 232 | + regions.value = [] | |
| 233 | + if (id) loadRegions(id) | |
| 234 | +} | |
| 235 | + | |
| 236 | +function onSelectRegion(groupId: string) { | |
| 237 | + const gid = groupId.trim() | |
| 238 | + if (selectedGroupId.value === gid) return | |
| 239 | + selectedGroupId.value = gid | |
| 240 | + selectedStore.value = '' | |
| 241 | + if (gid && selectedPartnerId.value) { | |
| 242 | + loadAdminLocations(selectedPartnerId.value, gid) | |
| 243 | + } else { | |
| 244 | + stores.value = [] | |
| 245 | + } | |
| 246 | +} | |
| 247 | + | |
| 248 | +async function initPage() { | |
| 249 | + loading.value = true | |
| 250 | + try { | |
| 251 | + const profile = await usAppFetchMyProfile() | |
| 252 | + isAdminUser.value = isAppAdminUser(profile) | |
| 253 | + if (isAdminUser.value) { | |
| 254 | + applyList([]) | |
| 255 | + await loadCompanies() | |
| 256 | + } else { | |
| 257 | + await refreshEmployeeStores() | |
| 258 | + } | |
| 259 | + } catch (e) { | |
| 260 | + if (isUsAppSessionExpiredError(e)) return | |
| 261 | + isAdminUser.value = false | |
| 262 | + applyList(getBoundLocations()) | |
| 263 | + await refreshEmployeeStores() | |
| 264 | + } finally { | |
| 265 | + loading.value = false | |
| 266 | + } | |
| 267 | +} | |
| 268 | + | |
| 114 | 269 | onMounted(() => { |
| 115 | 270 | applyList(getBoundLocations()) |
| 116 | - refreshFromApi() | |
| 271 | + initPage() | |
| 117 | 272 | }) |
| 118 | 273 | |
| 119 | 274 | onShow(() => { |
| 120 | - applyList(getBoundLocations()) | |
| 275 | + if (!isAdminUser.value) { | |
| 276 | + applyList(getBoundLocations()) | |
| 277 | + } | |
| 278 | +}) | |
| 279 | + | |
| 280 | +const canConfirm = computed(() => { | |
| 281 | + if (loading.value || confirming.value || !selectedStore.value) return false | |
| 282 | + if (isAdminUser.value) { | |
| 283 | + return !!(selectedPartnerId.value && selectedGroupId.value) | |
| 284 | + } | |
| 285 | + return stores.value.length > 0 | |
| 121 | 286 | }) |
| 122 | 287 | |
| 123 | 288 | const handleBackToLogin = () => { |
| ... | ... | @@ -125,20 +290,43 @@ const handleBackToLogin = () => { |
| 125 | 290 | uni.redirectTo({ url: '/pages/login/login' }) |
| 126 | 291 | } |
| 127 | 292 | |
| 128 | -const handleConfirm = () => { | |
| 129 | - if (loading.value || !selectedStore.value) { | |
| 293 | +const handleConfirm = async () => { | |
| 294 | + if (!canConfirm.value) { | |
| 130 | 295 | if (!selectedStore.value) { |
| 131 | 296 | uni.showToast({ title: t('login.selectStoreError'), icon: 'none' }) |
| 297 | + } else if (isAdminUser.value && (!selectedPartnerId.value || !selectedGroupId.value)) { | |
| 298 | + uni.showToast({ title: t('login.selectCompanyRegionFirst'), icon: 'none' }) | |
| 132 | 299 | } |
| 133 | 300 | return |
| 134 | 301 | } |
| 135 | 302 | const store = stores.value.find((s) => s.id === selectedStore.value) |
| 136 | 303 | if (!store) return |
| 137 | - switchStore(store.id, store.locationName, store.locationCode) | |
| 138 | - uni.showToast({ title: t('login.storeSelected'), icon: 'success' }) | |
| 139 | - setTimeout(() => { | |
| 140 | - uni.redirectTo({ url: '/pages/index/index' }) | |
| 141 | - }, 400) | |
| 304 | + | |
| 305 | + confirming.value = true | |
| 306 | + try { | |
| 307 | + if (isAdminUser.value) { | |
| 308 | + const res = await usAppSelectAdminScopeLocation({ | |
| 309 | + partnerId: selectedPartnerId.value, | |
| 310 | + groupId: selectedGroupId.value, | |
| 311 | + locationId: store.id, | |
| 312 | + }) | |
| 313 | + const loc = res.location ?? store | |
| 314 | + setBoundLocations([loc]) | |
| 315 | + switchStore(loc.id, loc.locationName, loc.locationCode) | |
| 316 | + } else { | |
| 317 | + switchStore(store.id, store.locationName, store.locationCode) | |
| 318 | + } | |
| 319 | + uni.showToast({ title: t('login.storeSelected'), icon: 'success' }) | |
| 320 | + setTimeout(() => { | |
| 321 | + uni.redirectTo({ url: '/pages/index/index' }) | |
| 322 | + }, 400) | |
| 323 | + } catch (e: unknown) { | |
| 324 | + if (isUsAppSessionExpiredError(e)) return | |
| 325 | + const msg = e instanceof Error ? e.message : String(e) | |
| 326 | + uni.showToast({ title: msg || t('login.scopeSelectFail'), icon: 'none' }) | |
| 327 | + } finally { | |
| 328 | + confirming.value = false | |
| 329 | + } | |
| 142 | 330 | } |
| 143 | 331 | </script> |
| 144 | 332 | |
| ... | ... | @@ -187,11 +375,67 @@ const handleConfirm = () => { |
| 187 | 375 | color: rgba(255, 255, 255, 0.85); |
| 188 | 376 | } |
| 189 | 377 | |
| 378 | +.scope-panel { | |
| 379 | + flex-shrink: 0; | |
| 380 | + padding: 24rpx 32rpx 0; | |
| 381 | + background: #f9fafb; | |
| 382 | +} | |
| 383 | + | |
| 384 | +.scope-section { | |
| 385 | + margin-bottom: 20rpx; | |
| 386 | +} | |
| 387 | + | |
| 388 | +.scope-label { | |
| 389 | + font-size: 26rpx; | |
| 390 | + font-weight: 600; | |
| 391 | + color: #374151; | |
| 392 | + display: block; | |
| 393 | + margin-bottom: 12rpx; | |
| 394 | +} | |
| 395 | + | |
| 396 | +.scope-hint { | |
| 397 | + padding: 16rpx 0; | |
| 398 | +} | |
| 399 | + | |
| 400 | +.scope-hint-text { | |
| 401 | + font-size: 26rpx; | |
| 402 | + color: #9ca3af; | |
| 403 | +} | |
| 404 | + | |
| 405 | +.scope-chips { | |
| 406 | + display: flex; | |
| 407 | + flex-wrap: wrap; | |
| 408 | + gap: 12rpx 16rpx; | |
| 409 | +} | |
| 410 | + | |
| 411 | +.scope-chip { | |
| 412 | + display: inline-block; | |
| 413 | + padding: 16rpx 28rpx; | |
| 414 | + background: #fff; | |
| 415 | + border-radius: 999rpx; | |
| 416 | + border: 2rpx solid #e5e7eb; | |
| 417 | +} | |
| 418 | + | |
| 419 | +.scope-chip.active { | |
| 420 | + border-color: var(--theme-primary); | |
| 421 | + background: var(--theme-primary-light); | |
| 422 | +} | |
| 423 | + | |
| 424 | +.scope-chip-text { | |
| 425 | + font-size: 26rpx; | |
| 426 | + color: #111827; | |
| 427 | +} | |
| 428 | + | |
| 429 | +.scope-chip.active .scope-chip-text { | |
| 430 | + color: var(--theme-primary); | |
| 431 | + font-weight: 600; | |
| 432 | +} | |
| 433 | + | |
| 190 | 434 | .list { |
| 191 | 435 | flex: 1; |
| 192 | 436 | min-height: 0; |
| 193 | 437 | overflow-y: auto; |
| 194 | - padding: 32rpx; | |
| 438 | + padding: 24rpx 32rpx; | |
| 195 | 439 | padding-bottom: 24rpx; |
| 196 | 440 | } |
| 197 | 441 | ... | ... |
泰额版/Food Labeling Management App UniApp/src/services/usAppAuth.ts
| 1 | +import type { | |
| 2 | + AuthScopeCompanyOption, | |
| 3 | + AuthScopeLocationOption, | |
| 4 | + AuthScopeRegionOption, | |
| 5 | + AuthScopeSelectLocationOutput, | |
| 6 | + UsAppSelectAdminScopeLocationInput, | |
| 7 | +} from '../types/usAppAdminScope' | |
| 1 | 8 | import type { UsAppBoundLocationDto } from '../types/usAppBound' |
| 2 | -import { usAppApiRequest } from '../utils/usAppApiRequest' | |
| 9 | +import { usAppApiRequest, unwrapApiPayload } from '../utils/usAppApiRequest' | |
| 3 | 10 | import { fetchWithOfflineCache } from '../utils/sqliteSync' |
| 4 | 11 | |
| 5 | 12 | /** GET /api/app/us-app-auth/my-profile → UsAppMyProfileOutputDto */ |
| ... | ... | @@ -162,6 +169,115 @@ export async function usAppFetchLocationDetail(locationId: string): Promise<UsAp |
| 162 | 169 | } |
| 163 | 170 | |
| 164 | 171 | /** POST /api/app/us-app-auth/change-password */ |
| 172 | +function normalizeCompanyOption(raw: Record<string, unknown>): AuthScopeCompanyOption { | |
| 173 | + return { | |
| 174 | + id: String(raw.id ?? raw.Id ?? '').trim(), | |
| 175 | + partnerName: String(raw.partnerName ?? raw.PartnerName ?? '').trim(), | |
| 176 | + state: raw.state !== false && raw.State !== false, | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 180 | +function normalizeRegionOption(raw: Record<string, unknown>): AuthScopeRegionOption { | |
| 181 | + return { | |
| 182 | + id: String(raw.id ?? raw.Id ?? '').trim(), | |
| 183 | + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim(), | |
| 184 | + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim(), | |
| 185 | + state: raw.state !== false && raw.State !== false, | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +function normalizeScopeLocationOption(raw: Record<string, unknown>): AuthScopeLocationOption { | |
| 190 | + return { | |
| 191 | + id: String(raw.id ?? raw.Id ?? '').trim(), | |
| 192 | + locationCode: String(raw.locationCode ?? raw.LocationCode ?? '').trim(), | |
| 193 | + locationName: String(raw.locationName ?? raw.LocationName ?? '').trim(), | |
| 194 | + fullAddress: String(raw.fullAddress ?? raw.FullAddress ?? '').trim(), | |
| 195 | + state: raw.state !== false && raw.State !== false, | |
| 196 | + partnerId: String(raw.partnerId ?? raw.PartnerId ?? '').trim() || undefined, | |
| 197 | + groupId: String(raw.groupId ?? raw.GroupId ?? '').trim() || undefined, | |
| 198 | + groupName: String(raw.groupName ?? raw.GroupName ?? '').trim() || undefined, | |
| 199 | + } | |
| 200 | +} | |
| 201 | + | |
| 202 | +function normalizeScopeLocationList(raw: unknown): AuthScopeLocationOption[] { | |
| 203 | + const arr = Array.isArray(raw) ? raw : [] | |
| 204 | + return arr | |
| 205 | + .map((x) => normalizeScopeLocationOption(x as Record<string, unknown>)) | |
| 206 | + .filter((x) => x.id) | |
| 207 | +} | |
| 208 | + | |
| 209 | +/** GET /api/app/us-app-auth/admin-scope-companies */ | |
| 210 | +export async function usAppFetchAdminScopeCompanies(): Promise<AuthScopeCompanyOption[]> { | |
| 211 | + const raw = await usAppApiRequest<unknown>({ | |
| 212 | + path: '/api/app/us-app-auth/admin-scope-companies', | |
| 213 | + method: 'GET', | |
| 214 | + auth: true, | |
| 215 | + }) | |
| 216 | + const list = unwrapApiPayload<unknown>(raw) | |
| 217 | + const arr = Array.isArray(list) ? list : [] | |
| 218 | + return arr | |
| 219 | + .map((x) => normalizeCompanyOption(x as Record<string, unknown>)) | |
| 220 | + .filter((x) => x.id) | |
| 221 | +} | |
| 222 | + | |
| 223 | +/** GET /api/app/us-app-auth/admin-scope-regions */ | |
| 224 | +export async function usAppFetchAdminScopeRegions(partnerId: string): Promise<AuthScopeRegionOption[]> { | |
| 225 | + const pid = partnerId.trim() | |
| 226 | + const raw = await usAppApiRequest<unknown>({ | |
| 227 | + path: '/api/app/us-app-auth/admin-scope-regions', | |
| 228 | + method: 'GET', | |
| 229 | + auth: true, | |
| 230 | + data: { partnerId: pid }, | |
| 231 | + }) | |
| 232 | + const list = unwrapApiPayload<unknown>(raw) | |
| 233 | + const arr = Array.isArray(list) ? list : [] | |
| 234 | + return arr | |
| 235 | + .map((x) => normalizeRegionOption(x as Record<string, unknown>)) | |
| 236 | + .filter((x) => x.id) | |
| 237 | +} | |
| 238 | + | |
| 239 | +/** GET /api/app/us-app-auth/admin-scope-locations */ | |
| 240 | +export async function usAppFetchAdminScopeLocations( | |
| 241 | + partnerId: string, | |
| 242 | + groupId: string, | |
| 243 | +): Promise<AuthScopeLocationOption[]> { | |
| 244 | + const raw = await usAppApiRequest<unknown>({ | |
| 245 | + path: '/api/app/us-app-auth/admin-scope-locations', | |
| 246 | + method: 'GET', | |
| 247 | + auth: true, | |
| 248 | + data: { | |
| 249 | + partnerId: partnerId.trim(), | |
| 250 | + groupId: groupId.trim(), | |
| 251 | + }, | |
| 252 | + }) | |
| 253 | + return normalizeScopeLocationList(unwrapApiPayload(raw)) | |
| 254 | +} | |
| 255 | + | |
| 256 | +/** POST /api/app/us-app-auth/select-admin-scope-location */ | |
| 257 | +export async function usAppSelectAdminScopeLocation( | |
| 258 | + input: UsAppSelectAdminScopeLocationInput, | |
| 259 | +): Promise<AuthScopeSelectLocationOutput> { | |
| 260 | + const raw = await usAppApiRequest<unknown>({ | |
| 261 | + path: '/api/app/us-app-auth/select-admin-scope-location', | |
| 262 | + method: 'POST', | |
| 263 | + auth: true, | |
| 264 | + data: { | |
| 265 | + partnerId: input.partnerId.trim(), | |
| 266 | + groupId: input.groupId.trim(), | |
| 267 | + locationId: input.locationId.trim(), | |
| 268 | + }, | |
| 269 | + }) | |
| 270 | + const o = (unwrapApiPayload(raw) ?? {}) as Record<string, unknown> | |
| 271 | + const locRaw = (o.location ?? o.Location ?? {}) as Record<string, unknown> | |
| 272 | + return { | |
| 273 | + partnerId: String(o.partnerId ?? o.PartnerId ?? '').trim(), | |
| 274 | + partnerName: String(o.partnerName ?? o.PartnerName ?? '').trim(), | |
| 275 | + groupId: String(o.groupId ?? o.GroupId ?? '').trim(), | |
| 276 | + groupName: String(o.groupName ?? o.GroupName ?? '').trim(), | |
| 277 | + location: normalizeLocation(locRaw), | |
| 278 | + } | |
| 279 | +} | |
| 280 | + | |
| 165 | 281 | export async function usAppChangePassword(input: UsAppChangePasswordInput): Promise<void> { |
| 166 | 282 | await usAppApiRequest<unknown>({ |
| 167 | 283 | path: '/api/app/us-app-auth/change-password', | ... | ... |
泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
| ... | ... | @@ -6,6 +6,8 @@ import type { |
| 6 | 6 | UsAppLabelPreviewInputVo, |
| 7 | 7 | UsAppLabelPrintInputVo, |
| 8 | 8 | UsAppLabelPrintOutputDto, |
| 9 | + UsAppLabelReportOutputDto, | |
| 10 | + UsAppLabelReportQueryInputVo, | |
| 9 | 11 | UsAppLabelReprintInputVo, |
| 10 | 12 | UsAppLabelTypeNodeDto, |
| 11 | 13 | UsAppProductCategoryNodeDto, |
| ... | ... | @@ -40,9 +42,21 @@ function normalizeLabelingTreePayload(raw: unknown): UsAppLabelCategoryTreeNodeD |
| 40 | 42 | })) |
| 41 | 43 | return { |
| 42 | 44 | productId: String(x?.productId ?? x?.ProductId ?? ''), |
| 45 | + templateId: String(x?.templateId ?? x?.TemplateId ?? '').trim() || undefined, | |
| 46 | + templateCode: (x?.templateCode ?? x?.TemplateCode ?? null) as string | null, | |
| 47 | + templateLabelSizeText: (x?.templateLabelSizeText ?? x?.TemplateLabelSizeText ?? null) as | |
| 48 | + | string | |
| 49 | + | null, | |
| 43 | 50 | productName: String(x?.productName ?? x?.ProductName ?? ''), |
| 44 | 51 | productCode: String(x?.productCode ?? x?.ProductCode ?? ''), |
| 45 | 52 | productImageUrl: (x?.productImageUrl ?? x?.ProductImageUrl ?? null) as string | null, |
| 53 | + displayText: (x?.displayText ?? x?.DisplayText ?? null) as string | null, | |
| 54 | + categoryPhotoUrl: (x?.categoryPhotoUrl ?? x?.CategoryPhotoUrl ?? null) as string | null, | |
| 55 | + buttonAppearance: (x?.buttonAppearance ?? x?.ButtonAppearance ?? null) as string | null, | |
| 56 | + buttonBgColor: (x?.buttonBgColor ?? x?.ButtonBgColor ?? null) as string | null, | |
| 57 | + buttonImageUrl: (x?.buttonImageUrl ?? x?.ButtonImageUrl ?? null) as string | null, | |
| 58 | + buttonTextColor: (x?.buttonTextColor ?? x?.ButtonTextColor ?? null) as string | null, | |
| 59 | + buttonStyleJson: (x?.buttonStyleJson ?? x?.ButtonStyleJson ?? null) as string | null, | |
| 46 | 60 | subtitle: String(x?.subtitle ?? x?.Subtitle ?? ''), |
| 47 | 61 | labelTypeCount: Number(x?.labelTypeCount ?? x?.LabelTypeCount ?? labelTypes.length), |
| 48 | 62 | labelTypes, |
| ... | ... | @@ -270,6 +284,192 @@ export async function reportUsAppLabelPrintIfReady(input: { |
| 270 | 284 | return postUsAppLabelPrint(body) |
| 271 | 285 | } |
| 272 | 286 | |
| 287 | +function numField (o: Record<string, unknown>, camel: string, pascal: string, fallback = 0): number { | |
| 288 | + const v = o[camel] ?? o[pascal] | |
| 289 | + const n = Number(v) | |
| 290 | + return Number.isFinite(n) ? n : fallback | |
| 291 | +} | |
| 292 | + | |
| 293 | +function strField (o: Record<string, unknown>, camel: string, pascal: string): string { | |
| 294 | + const v = o[camel] ?? o[pascal] | |
| 295 | + return typeof v === 'string' ? v.trim() : String(v ?? '').trim() | |
| 296 | +} | |
| 297 | + | |
| 298 | +/** 规范化 Label Report 响应(camelCase / PascalCase) */ | |
| 299 | +export function normalizeUsAppLabelReport (raw: unknown): UsAppLabelReportOutputDto { | |
| 300 | + const empty: UsAppLabelReportOutputDto = { | |
| 301 | + summary: { | |
| 302 | + totalLabelsPrinted: 0, | |
| 303 | + totalLabelsPrintedChangeRate: 0, | |
| 304 | + mostPrintedCategoryCount: 0, | |
| 305 | + topProductCount: 0, | |
| 306 | + avgDailyPrints: 0, | |
| 307 | + avgDailyPrintsChangeRate: 0, | |
| 308 | + }, | |
| 309 | + labelsByCategory: [], | |
| 310 | + printVolumeTrend: [], | |
| 311 | + mostUsedProducts: [], | |
| 312 | + } | |
| 313 | + if (!raw || typeof raw !== 'object') return empty | |
| 314 | + const root = raw as Record<string, unknown> | |
| 315 | + | |
| 316 | + const rangeRaw = root.appliedRange ?? root.AppliedRange | |
| 317 | + let appliedRange: UsAppLabelReportOutputDto['appliedRange'] | |
| 318 | + if (rangeRaw && typeof rangeRaw === 'object') { | |
| 319 | + const r = rangeRaw as Record<string, unknown> | |
| 320 | + appliedRange = { | |
| 321 | + period: strField(r, 'period', 'Period') || undefined, | |
| 322 | + startDate: strField(r, 'startDate', 'StartDate') || undefined, | |
| 323 | + endDate: strField(r, 'endDate', 'EndDate') || undefined, | |
| 324 | + dayCount: numField(r, 'dayCount', 'DayCount', 0) || undefined, | |
| 325 | + trendDescription: strField(r, 'trendDescription', 'TrendDescription') || undefined, | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 329 | + const sumRaw = root.summary ?? root.Summary | |
| 330 | + const s = (sumRaw && typeof sumRaw === 'object' ? sumRaw : {}) as Record<string, unknown> | |
| 331 | + const summary = { | |
| 332 | + totalLabelsPrinted: numField(s, 'totalLabelsPrinted', 'TotalLabelsPrinted', 0), | |
| 333 | + totalLabelsPrintedPrevPeriod: numField(s, 'totalLabelsPrintedPrevPeriod', 'TotalLabelsPrintedPrevPeriod', 0), | |
| 334 | + totalLabelsPrintedChangeRate: numField(s, 'totalLabelsPrintedChangeRate', 'TotalLabelsPrintedChangeRate', 0), | |
| 335 | + mostPrintedCategoryName: strField(s, 'mostPrintedCategoryName', 'MostPrintedCategoryName') || null, | |
| 336 | + mostPrintedCategoryCount: numField(s, 'mostPrintedCategoryCount', 'MostPrintedCategoryCount', 0), | |
| 337 | + topProductName: strField(s, 'topProductName', 'TopProductName') || null, | |
| 338 | + topProductCount: numField(s, 'topProductCount', 'TopProductCount', 0), | |
| 339 | + avgDailyPrints: numField(s, 'avgDailyPrints', 'AvgDailyPrints', 0), | |
| 340 | + avgDailyPrintsPrevPeriod: numField(s, 'avgDailyPrintsPrevPeriod', 'AvgDailyPrintsPrevPeriod', 0), | |
| 341 | + avgDailyPrintsChangeRate: numField(s, 'avgDailyPrintsChangeRate', 'AvgDailyPrintsChangeRate', 0), | |
| 342 | + } | |
| 343 | + | |
| 344 | + const labelsByCategory: UsAppLabelReportOutputDto['labelsByCategory'] = [] | |
| 345 | + const labelsRaw = root.labelsByCategory ?? root.LabelsByCategory | |
| 346 | + if (Array.isArray(labelsRaw)) { | |
| 347 | + for (const x of labelsRaw) { | |
| 348 | + if (!x || typeof x !== 'object') continue | |
| 349 | + const row = x as Record<string, unknown> | |
| 350 | + const name = strField(row, 'categoryName', 'CategoryName') | |
| 351 | + labelsByCategory.push({ | |
| 352 | + categoryId: strField(row, 'categoryId', 'CategoryId') || null, | |
| 353 | + categoryName: name || 'Uncategorized', | |
| 354 | + count: numField(row, 'count', 'Count', 0), | |
| 355 | + }) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + | |
| 359 | + const printVolumeTrend: UsAppLabelReportOutputDto['printVolumeTrend'] = [] | |
| 360 | + const trendRaw = root.printVolumeTrend ?? root.PrintVolumeTrend | |
| 361 | + if (Array.isArray(trendRaw)) { | |
| 362 | + for (const x of trendRaw) { | |
| 363 | + if (!x || typeof x !== 'object') continue | |
| 364 | + const row = x as Record<string, unknown> | |
| 365 | + const date = strField(row, 'date', 'Date') | |
| 366 | + if (!date) continue | |
| 367 | + printVolumeTrend.push({ | |
| 368 | + date, | |
| 369 | + count: numField(row, 'count', 'Count', 0), | |
| 370 | + }) | |
| 371 | + } | |
| 372 | + } | |
| 373 | + | |
| 374 | + const mostUsedProducts: UsAppLabelReportOutputDto['mostUsedProducts'] = [] | |
| 375 | + const productsRaw = root.mostUsedProducts ?? root.MostUsedProducts | |
| 376 | + if (Array.isArray(productsRaw)) { | |
| 377 | + for (const x of productsRaw) { | |
| 378 | + if (!x || typeof x !== 'object') continue | |
| 379 | + const row = x as Record<string, unknown> | |
| 380 | + const productName = strField(row, 'productName', 'ProductName') | |
| 381 | + if (!productName) continue | |
| 382 | + mostUsedProducts.push({ | |
| 383 | + productId: strField(row, 'productId', 'ProductId') || null, | |
| 384 | + productName, | |
| 385 | + categoryName: strField(row, 'categoryName', 'CategoryName') || '—', | |
| 386 | + totalPrinted: numField(row, 'totalPrinted', 'TotalPrinted', 0), | |
| 387 | + usagePercent: numField(row, 'usagePercent', 'UsagePercent', 0), | |
| 388 | + }) | |
| 389 | + } | |
| 390 | + } | |
| 391 | + | |
| 392 | + return { | |
| 393 | + appliedRange, | |
| 394 | + summary, | |
| 395 | + labelsByCategory, | |
| 396 | + printVolumeTrend, | |
| 397 | + mostUsedProducts, | |
| 398 | + } | |
| 399 | +} | |
| 400 | + | |
| 401 | +/** 按 5-27 文档计算自然日区间(含起止日) */ | |
| 402 | +export function buildUsAppLabelReportDateRange (input: { | |
| 403 | + period: UsAppLabelReportQueryInputVo['period'] | |
| 404 | + customStart?: string | |
| 405 | + customEnd?: string | |
| 406 | +}): { startDate: string; endDate: string } { | |
| 407 | + const pad = (n: number) => String(n).padStart(2, '0') | |
| 408 | + const fmt = (d: Date) => | |
| 409 | + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` | |
| 410 | + | |
| 411 | + const parseYmd = (s: string): Date | null => { | |
| 412 | + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim()) | |
| 413 | + if (!m) return null | |
| 414 | + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) | |
| 415 | + return Number.isNaN(d.getTime()) ? null : d | |
| 416 | + } | |
| 417 | + | |
| 418 | + const today = new Date() | |
| 419 | + today.setHours(0, 0, 0, 0) | |
| 420 | + | |
| 421 | + const period = input.period || '7d' | |
| 422 | + let end = today | |
| 423 | + let start: Date | |
| 424 | + | |
| 425 | + if (period === 'custom') { | |
| 426 | + const cs = input.customStart ? parseYmd(input.customStart) : null | |
| 427 | + const ce = input.customEnd ? parseYmd(input.customEnd) : null | |
| 428 | + end = ce || today | |
| 429 | + start = cs || new Date(end) | |
| 430 | + start.setDate(start.getDate() - 6) | |
| 431 | + if (start > end) start = new Date(end) | |
| 432 | + } else { | |
| 433 | + const days = | |
| 434 | + period === '90d' ? 90 : period === '30d' ? 30 : 7 | |
| 435 | + end = today | |
| 436 | + start = new Date(end) | |
| 437 | + start.setDate(start.getDate() - (days - 1)) | |
| 438 | + } | |
| 439 | + | |
| 440 | + return { startDate: fmt(start), endDate: fmt(end) } | |
| 441 | +} | |
| 442 | + | |
| 443 | +/** Label Report:POST get-label-report */ | |
| 444 | +export async function fetchUsAppLabelReport ( | |
| 445 | + input: UsAppLabelReportQueryInputVo, | |
| 446 | +): Promise<UsAppLabelReportOutputDto> { | |
| 447 | + const period = input.period || '7d' | |
| 448 | + const { startDate, endDate } = buildUsAppLabelReportDateRange({ | |
| 449 | + period, | |
| 450 | + customStart: input.startDate, | |
| 451 | + customEnd: input.endDate, | |
| 452 | + }) | |
| 453 | + | |
| 454 | + const body: Record<string, unknown> = { | |
| 455 | + locationId: input.locationId, | |
| 456 | + period, | |
| 457 | + startDate: period === 'custom' ? input.startDate || startDate : startDate, | |
| 458 | + endDate: period === 'custom' ? input.endDate || endDate : endDate, | |
| 459 | + } | |
| 460 | + if (input.keyword?.trim()) { | |
| 461 | + body.keyword = input.keyword.trim() | |
| 462 | + } | |
| 463 | + | |
| 464 | + const raw = await usAppApiRequest<unknown>({ | |
| 465 | + path: '/api/app/us-app-labeling/get-label-report', | |
| 466 | + method: 'POST', | |
| 467 | + auth: true, | |
| 468 | + data: body, | |
| 469 | + }) | |
| 470 | + return normalizeUsAppLabelReport(raw) | |
| 471 | +} | |
| 472 | + | |
| 273 | 473 | /** 接口 10:分页打印日志 */ |
| 274 | 474 | export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) { |
| 275 | 475 | const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` | ... | ... |
泰额版/Food Labeling Management App UniApp/src/types/usAppAdminScope.ts
0 → 100644
| 1 | +/** 与 AuthScopeCompanyOptionDto 对齐 */ | |
| 2 | +export interface AuthScopeCompanyOption { | |
| 3 | + id: string | |
| 4 | + partnerName: string | |
| 5 | + state?: boolean | |
| 6 | +} | |
| 7 | + | |
| 8 | +/** 与 AuthScopeRegionOptionDto 对齐 */ | |
| 9 | +export interface AuthScopeRegionOption { | |
| 10 | + id: string | |
| 11 | + groupName: string | |
| 12 | + partnerId: string | |
| 13 | + state?: boolean | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** 与 AuthScopeLocationOptionDto 对齐 */ | |
| 17 | +export interface AuthScopeLocationOption { | |
| 18 | + id: string | |
| 19 | + locationCode: string | |
| 20 | + locationName: string | |
| 21 | + fullAddress: string | |
| 22 | + state: boolean | |
| 23 | + partnerId?: string | |
| 24 | + groupId?: string | |
| 25 | + groupName?: string | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface UsAppSelectAdminScopeLocationInput { | |
| 29 | + partnerId: string | |
| 30 | + groupId: string | |
| 31 | + locationId: string | |
| 32 | +} | |
| 33 | + | |
| 34 | +export interface AuthScopeSelectLocationOutput { | |
| 35 | + partnerId: string | |
| 36 | + partnerName: string | |
| 37 | + groupId: string | |
| 38 | + groupName: string | |
| 39 | + location: { | |
| 40 | + id: string | |
| 41 | + locationCode: string | |
| 42 | + locationName: string | |
| 43 | + fullAddress: string | |
| 44 | + state: boolean | |
| 45 | + } | |
| 46 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
| ... | ... | @@ -164,3 +164,64 @@ export interface UsAppLabelReprintInputVo { |
| 164 | 164 | printerMac?: string |
| 165 | 165 | printerAddress?: string |
| 166 | 166 | } |
| 167 | + | |
| 168 | +/** Label Report 周期(5-27 get-label-report) */ | |
| 169 | +export type UsAppLabelReportPeriod = '7d' | '30d' | '90d' | 'custom' | |
| 170 | + | |
| 171 | +/** Label Report 入参 */ | |
| 172 | +export interface UsAppLabelReportQueryInputVo { | |
| 173 | + locationId: string | |
| 174 | + period?: UsAppLabelReportPeriod | |
| 175 | + startDate?: string | |
| 176 | + endDate?: string | |
| 177 | + keyword?: string | |
| 178 | +} | |
| 179 | + | |
| 180 | +export interface UsAppLabelReportAppliedRangeDto { | |
| 181 | + period?: UsAppLabelReportPeriod | string | |
| 182 | + startDate?: string | |
| 183 | + endDate?: string | |
| 184 | + dayCount?: number | |
| 185 | + trendDescription?: string | |
| 186 | +} | |
| 187 | + | |
| 188 | +export interface UsAppLabelReportSummaryDto { | |
| 189 | + totalLabelsPrinted: number | |
| 190 | + totalLabelsPrintedPrevPeriod?: number | |
| 191 | + totalLabelsPrintedChangeRate: number | |
| 192 | + mostPrintedCategoryName?: string | null | |
| 193 | + mostPrintedCategoryCount: number | |
| 194 | + topProductName?: string | null | |
| 195 | + topProductCount: number | |
| 196 | + avgDailyPrints: number | |
| 197 | + avgDailyPrintsPrevPeriod?: number | |
| 198 | + avgDailyPrintsChangeRate: number | |
| 199 | +} | |
| 200 | + | |
| 201 | +export interface UsAppLabelReportCategoryRowDto { | |
| 202 | + categoryId?: string | null | |
| 203 | + categoryName: string | |
| 204 | + count: number | |
| 205 | +} | |
| 206 | + | |
| 207 | +export interface UsAppLabelReportTrendPointDto { | |
| 208 | + date: string | |
| 209 | + count: number | |
| 210 | +} | |
| 211 | + | |
| 212 | +export interface UsAppLabelReportTopProductDto { | |
| 213 | + productId?: string | null | |
| 214 | + productName: string | |
| 215 | + categoryName: string | |
| 216 | + totalPrinted: number | |
| 217 | + usagePercent: number | |
| 218 | +} | |
| 219 | + | |
| 220 | +/** Label Report 出参 */ | |
| 221 | +export interface UsAppLabelReportOutputDto { | |
| 222 | + appliedRange?: UsAppLabelReportAppliedRangeDto | |
| 223 | + summary: UsAppLabelReportSummaryDto | |
| 224 | + labelsByCategory: UsAppLabelReportCategoryRowDto[] | |
| 225 | + printVolumeTrend: UsAppLabelReportTrendPointDto[] | |
| 226 | + mostUsedProducts: UsAppLabelReportTopProductDto[] | |
| 227 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/appAdminRole.ts
0 → 100644
| 1 | +import type { UsAppMyProfileOutputDto } from '../services/usAppAuth' | |
| 2 | + | |
| 3 | +/** 与后端 ReportsRoleHelper.IsAdminRole 对齐(App 管理员级联选店) */ | |
| 4 | +export function isAppAdminUser(profile: UsAppMyProfileOutputDto | null | undefined): boolean { | |
| 5 | + if (!profile) return false | |
| 6 | + const code = (profile.primaryRoleCode ?? '').trim().toLowerCase() | |
| 7 | + if (code === 'admin') return true | |
| 8 | + const display = (profile.roleDisplay ?? '').trim().toLowerCase() | |
| 9 | + if (!display) return false | |
| 10 | + if (display.includes('administrator')) return true | |
| 11 | + if (display.includes('super admin')) return true | |
| 12 | + return false | |
| 13 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/barcodeFormat.ts
| ... | ... | @@ -56,6 +56,24 @@ export function toTscBarcodeSymbology (barcodeType: unknown): string { |
| 56 | 56 | return map[key] ?? 'CODA' |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | +/** | |
| 60 | + * TSC/Gprinter CODABAR 常要求起止符;纯数字在部分机型上 BARCODE 指令会静默失败。 | |
| 61 | + * 与预览 JsBarcode 展示可不同,但能保证出纸。 | |
| 62 | + */ | |
| 63 | +export function formatBarcodeValueForTsc (value: unknown, barcodeType: unknown): string { | |
| 64 | + const raw = String(value ?? '').trim() | |
| 65 | + if (!raw) return '' | |
| 66 | + const type = normalizeBarcodeType(barcodeType) | |
| 67 | + if (type !== 'CODABAR') return raw | |
| 68 | + const upper = raw.toUpperCase() | |
| 69 | + const hasStart = /^[ABCD]/.test(upper) | |
| 70 | + const hasStop = /[TNE*]$/.test(upper) | |
| 71 | + if (hasStart && hasStop) return upper | |
| 72 | + const body = raw.replace(/[^0-9\-$:/.+]/g, '') | |
| 73 | + if (!body) return raw | |
| 74 | + return `A${body}B` | |
| 75 | +} | |
| 76 | + | |
| 59 | 77 | export function toEscBarcodeTypeCode (barcodeType: unknown): number { |
| 60 | 78 | const key = normalizeBarcodeType(barcodeType) |
| 61 | 79 | const map: Record<BarcodeFormatValue, number> = { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/categoryButtonAppearance.ts
| ... | ... | @@ -65,6 +65,15 @@ export type CategoryVisualRender = |
| 65 | 65 | | { mode: 'text'; text: string } |
| 66 | 66 | | { mode: 'none' } |
| 67 | 67 | |
| 68 | +/** 手提端产品列表布局(可选,存于 buttonStyleJson.appList) */ | |
| 69 | +export type AppListLayoutStyle = { | |
| 70 | + gridColumns?: number | |
| 71 | + gapRpx?: number | |
| 72 | + cardPaddingRpx?: number | |
| 73 | + thumbAspectPercent?: number | |
| 74 | + panelPaddingRpx?: number | |
| 75 | +} | |
| 76 | + | |
| 68 | 77 | export type StoredCategoryButtonStyleV1 = { |
| 69 | 78 | v: 1 |
| 70 | 79 | appearances: AppearanceToken[] |
| ... | ... | @@ -72,6 +81,7 @@ export type StoredCategoryButtonStyleV1 = { |
| 72 | 81 | buttonBgColor?: string | null |
| 73 | 82 | buttonTextColor?: string | null |
| 74 | 83 | buttonImageUrl?: string | null |
| 84 | + appList?: AppListLayoutStyle | null | |
| 75 | 85 | } |
| 76 | 86 | |
| 77 | 87 | export function serializeCategoryButtonStyleV1(input: { |
| ... | ... | @@ -95,11 +105,15 @@ export function serializeCategoryButtonStyleV1(input: { |
| 95 | 105 | /** `categoryPhotoUrl` 存 JSON 数组:与 `buttonAppearance` 顺序一一对应(TEXT=文案,COLOR=色值,IMAGE=图片 URL) */ |
| 96 | 106 | export function parseCategoryPhotoUrlValueArray(s: string | null | undefined): string[] | null { |
| 97 | 107 | const raw = String(s ?? '').trim() |
| 98 | - if (!raw.startsWith('[')) return null | |
| 108 | + if (!raw) return null | |
| 99 | 109 | try { |
| 100 | 110 | const j = JSON.parse(raw) as unknown |
| 101 | - if (!Array.isArray(j)) return null | |
| 102 | - return j.map((x) => (x == null ? '' : String(x))) | |
| 111 | + if (Array.isArray(j)) return j.map((x) => (x == null ? '' : String(x))) | |
| 112 | + if (j && typeof j === 'object') { | |
| 113 | + const values = (j as { values?: unknown }).values | |
| 114 | + if (Array.isArray(values)) return values.map((x) => (x == null ? '' : String(x))) | |
| 115 | + } | |
| 116 | + return null | |
| 103 | 117 | } catch { |
| 104 | 118 | return null |
| 105 | 119 | } |
| ... | ... | @@ -170,6 +184,31 @@ export function serializeButtonAppearanceForApi(raw: unknown): string | null { |
| 170 | 184 | return s |
| 171 | 185 | } |
| 172 | 186 | |
| 187 | +function parseAppListLayout(raw: unknown): AppListLayoutStyle | null { | |
| 188 | + if (!raw || typeof raw !== 'object') return null | |
| 189 | + const o = raw as Record<string, unknown> | |
| 190 | + const gridColumns = Number(o.gridColumns ?? o.GridColumns) | |
| 191 | + const gapRpx = Number(o.gapRpx ?? o.GapRpx) | |
| 192 | + const cardPaddingRpx = Number(o.cardPaddingRpx ?? o.CardPaddingRpx) | |
| 193 | + const thumbAspectPercent = Number(o.thumbAspectPercent ?? o.ThumbAspectPercent) | |
| 194 | + const panelPaddingRpx = Number(o.panelPaddingRpx ?? o.PanelPaddingRpx) | |
| 195 | + const layout: AppListLayoutStyle = {} | |
| 196 | + if (Number.isFinite(gridColumns) && gridColumns >= 1 && gridColumns <= 4) { | |
| 197 | + layout.gridColumns = Math.round(gridColumns) | |
| 198 | + } | |
| 199 | + if (Number.isFinite(gapRpx) && gapRpx >= 0) layout.gapRpx = Math.round(gapRpx) | |
| 200 | + if (Number.isFinite(cardPaddingRpx) && cardPaddingRpx >= 0) { | |
| 201 | + layout.cardPaddingRpx = Math.round(cardPaddingRpx) | |
| 202 | + } | |
| 203 | + if (Number.isFinite(thumbAspectPercent) && thumbAspectPercent > 0 && thumbAspectPercent <= 200) { | |
| 204 | + layout.thumbAspectPercent = Math.round(thumbAspectPercent) | |
| 205 | + } | |
| 206 | + if (Number.isFinite(panelPaddingRpx) && panelPaddingRpx >= 0) { | |
| 207 | + layout.panelPaddingRpx = Math.round(panelPaddingRpx) | |
| 208 | + } | |
| 209 | + return Object.keys(layout).length > 0 ? layout : null | |
| 210 | +} | |
| 211 | + | |
| 173 | 212 | export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): StoredCategoryButtonStyleV1 | null { |
| 174 | 213 | const raw = (jsonStr ?? '').trim() |
| 175 | 214 | if (!raw) return null |
| ... | ... | @@ -185,12 +224,36 @@ export function parseCategoryButtonStyleV1(jsonStr: string | null | undefined): |
| 185 | 224 | buttonBgColor: o.buttonBgColor != null ? String(o.buttonBgColor) : null, |
| 186 | 225 | buttonTextColor: o.buttonTextColor != null ? String(o.buttonTextColor) : null, |
| 187 | 226 | buttonImageUrl: o.buttonImageUrl != null ? String(o.buttonImageUrl) : null, |
| 227 | + appList: parseAppListLayout(o.appList ?? o.AppList), | |
| 188 | 228 | } |
| 189 | 229 | } catch { |
| 190 | 230 | return null |
| 191 | 231 | } |
| 192 | 232 | } |
| 193 | 233 | |
| 234 | +const DEFAULT_APP_LIST_LAYOUT: Required<AppListLayoutStyle> = { | |
| 235 | + gridColumns: 2, | |
| 236 | + gapRpx: 12, | |
| 237 | + cardPaddingRpx: 10, | |
| 238 | + thumbAspectPercent: 75, | |
| 239 | + panelPaddingRpx: 16, | |
| 240 | +} | |
| 241 | + | |
| 242 | +/** 解析标签分类/产品分类上的 buttonStyleJson.appList,供手提列表页动态样式 */ | |
| 243 | +export function resolveAppListLayoutFromDto(row: { | |
| 244 | + buttonStyleJson?: string | null | |
| 245 | +}): Required<AppListLayoutStyle> { | |
| 246 | + const parsed = parseCategoryButtonStyleV1(row.buttonStyleJson) | |
| 247 | + const a = parsed?.appList | |
| 248 | + return { | |
| 249 | + gridColumns: a?.gridColumns ?? DEFAULT_APP_LIST_LAYOUT.gridColumns, | |
| 250 | + gapRpx: a?.gapRpx ?? DEFAULT_APP_LIST_LAYOUT.gapRpx, | |
| 251 | + cardPaddingRpx: a?.cardPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.cardPaddingRpx, | |
| 252 | + thumbAspectPercent: a?.thumbAspectPercent ?? DEFAULT_APP_LIST_LAYOUT.thumbAspectPercent, | |
| 253 | + panelPaddingRpx: a?.panelPaddingRpx ?? DEFAULT_APP_LIST_LAYOUT.panelPaddingRpx, | |
| 254 | + } | |
| 255 | +} | |
| 256 | + | |
| 194 | 257 | export type CategoryDtoLike = { |
| 195 | 258 | buttonStyleJson?: string | null |
| 196 | 259 | buttonAppearance?: unknown | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/emptyDisplay.ts
0 → 100644
| 1 | +/** Backend empty placeholder (US API may return this literal). */ | |
| 2 | +export const BACKEND_EMPTY_DISPLAY = '无' | |
| 3 | + | |
| 4 | +/** Map API empty sentinels to English UI text. */ | |
| 5 | +export function formatDisplayText ( | |
| 6 | + value: string | null | undefined, | |
| 7 | + fallback = 'None', | |
| 8 | +): string { | |
| 9 | + const s = String(value ?? '').trim() | |
| 10 | + if (!s || s === BACKEND_EMPTY_DISPLAY) return fallback | |
| 11 | + return s | |
| 12 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
| ... | ... | @@ -235,7 +235,7 @@ function drawBarcodeLikePreview( |
| 235 | 235 | let cursor = x + pad |
| 236 | 236 | for (let i = 0; i < modules.length; i++) { |
| 237 | 237 | if (modules[i] === 1) { |
| 238 | - const rw = Math.max(0.7, moduleW * 0.86) | |
| 238 | + const rw = Math.max(0.7, moduleW * 0.72) | |
| 239 | 239 | ctx.fillRect(cursor, y + pad, rw, barH) |
| 240 | 240 | } |
| 241 | 241 | cursor += moduleW |
| ... | ... | @@ -257,7 +257,7 @@ function drawBarcodeLikePreview( |
| 257 | 257 | let cursorY = y + pad |
| 258 | 258 | for (let i = 0; i < modules.length; i++) { |
| 259 | 259 | if (modules[i] === 1) { |
| 260 | - const rh = Math.max(0.7, moduleH * 0.86) | |
| 260 | + const rh = Math.max(0.7, moduleH * 0.72) | |
| 261 | 261 | ctx.fillRect(x + pad, cursorY, barW, rh) |
| 262 | 262 | } |
| 263 | 263 | cursorY += moduleH |
| ... | ... | @@ -433,7 +433,8 @@ function runLabelPreviewCanvasDraw( |
| 433 | 433 | const bw = Math.max(40, w || 120) |
| 434 | 434 | const bh = Math.max(36, h || 96) |
| 435 | 435 | const pad = 3 |
| 436 | - const rightX = x + bw - pad | |
| 436 | + /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */ | |
| 437 | + const rightX = x + bw - 1 | |
| 437 | 438 | const maxY = y + bh - 2 |
| 438 | 439 | const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16)) |
| 439 | 440 | const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72))) |
| ... | ... | @@ -739,14 +740,32 @@ export function renderLabelPreviewCanvasImageDataForPrint( |
| 739 | 740 | /** |
| 740 | 741 | * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。 |
| 741 | 742 | */ |
| 743 | +/** 调整 canvas :width/:height 后等待绘图缓冲区就绪,避免光栅导出高度不足导致底部条码/日期被裁切 */ | |
| 744 | +export async function settleAfterLabelCanvasResize (): Promise<void> { | |
| 745 | + await new Promise<void>((r) => setTimeout(r, 16)) | |
| 746 | + await new Promise<void>((resolve) => { | |
| 747 | + if (typeof requestAnimationFrame === 'function') { | |
| 748 | + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) | |
| 749 | + } else { | |
| 750 | + setTimeout(() => resolve(), 32) | |
| 751 | + } | |
| 752 | + }) | |
| 753 | + await new Promise<void>((r) => setTimeout(r, 80)) | |
| 754 | +} | |
| 755 | + | |
| 742 | 756 | export function getLabelPrintRasterLayout( |
| 743 | 757 | template: SystemLabelTemplate, |
| 744 | 758 | maxWidthDots: number, |
| 745 | - printDpi = 203 | |
| 759 | + printDpi = 203, | |
| 760 | + opts?: { contentHeightPx?: number }, | |
| 746 | 761 | ): { cw: number, ch: number, outW: number, outH: number, scale: number } { |
| 747 | 762 | const unit = template.unit || 'inch' |
| 748 | 763 | const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) |
| 749 | - const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 764 | + const fullH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 765 | + const ch = | |
| 766 | + opts?.contentHeightPx != null && opts.contentHeightPx > 0 | |
| 767 | + ? Math.max(40, Math.round(opts.contentHeightPx)) | |
| 768 | + : fullH | |
| 750 | 769 | const designDpi = 96 |
| 751 | 770 | const idealW = Math.round(cw * (printDpi / designDpi)) |
| 752 | 771 | const cap = Math.max(8, Math.round(maxWidthDots || 576)) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts
0 → 100644
| 1 | +/** 扫描/配对列表仅展示以下蓝牙名称(大小写不敏感,须完整匹配) */ | |
| 2 | +export const ALLOWED_BLUETOOTH_PRINTER_NAMES = [ | |
| 3 | + 'GP-D320FX-spp_A7FO', | |
| 4 | + 'Virtual BT Printer', | |
| 5 | +] as const | |
| 6 | + | |
| 7 | +const ALLOWED_BLUETOOTH_PRINTER_NAME_SET = new Set( | |
| 8 | + ALLOWED_BLUETOOTH_PRINTER_NAMES.map((name) => name.toLowerCase()), | |
| 9 | +) | |
| 10 | + | |
| 11 | +export function normalizeBluetoothPrinterName (name: string | undefined | null): string { | |
| 12 | + return String(name ?? '').trim() | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** 仅允许白名单内的蓝牙打印机名称出现在连接列表 */ | |
| 16 | +export function isAllowedBluetoothPrinterName (name: string | undefined | null): boolean { | |
| 17 | + const normalized = normalizeBluetoothPrinterName(name) | |
| 18 | + if (!normalized) return false | |
| 19 | + return ALLOWED_BLUETOOTH_PRINTER_NAME_SET.has(normalized.toLowerCase()) | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** 一体机虚拟蓝牙名(走整页光栅与预览一致,不走 native printTemplate) */ | |
| 23 | +export function isVirtualBtPrinterDeviceName (name: string | undefined | null): boolean { | |
| 24 | + return normalizeBluetoothPrinterName(name).toLowerCase() === 'virtual bt printer' | |
| 25 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
| ... | ... | @@ -3,6 +3,33 @@ import { printRunDiag } from './printRunDiagnostics' |
| 3 | 3 | |
| 4 | 4 | const DEFAULT_IMAGE_THRESHOLD = 180 |
| 5 | 5 | |
| 6 | +/** 去掉位图底部全白行(内置 ESC 光栅按图像高度走纸,否则会拖很长空白) */ | |
| 7 | +export function trimMonochromeImageBottomWhitespace ( | |
| 8 | + image: MonochromeImageData, | |
| 9 | + paddingRows = 10, | |
| 10 | +): MonochromeImageData { | |
| 11 | + const { width, height, pixels } = image | |
| 12 | + if (!width || !height || !pixels?.length) return image | |
| 13 | + let lastBlack = -1 | |
| 14 | + for (let y = height - 1; y >= 0; y--) { | |
| 15 | + for (let x = 0; x < width; x++) { | |
| 16 | + if (pixels[y * width + x]) { | |
| 17 | + lastBlack = y | |
| 18 | + break | |
| 19 | + } | |
| 20 | + } | |
| 21 | + if (lastBlack >= 0) break | |
| 22 | + } | |
| 23 | + if (lastBlack < 0) return image | |
| 24 | + const newH = Math.min(height, lastBlack + 1 + Math.max(0, paddingRows)) | |
| 25 | + if (newH >= height) return image | |
| 26 | + return { | |
| 27 | + width, | |
| 28 | + height: newH, | |
| 29 | + pixels: pixels.slice(0, width * newH), | |
| 30 | + } | |
| 31 | +} | |
| 32 | + | |
| 6 | 33 | function yieldToUi (): Promise<void> { |
| 7 | 34 | return new Promise((resolve) => { |
| 8 | 35 | setTimeout(resolve, 0) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
| ... | ... | @@ -13,7 +13,7 @@ import { |
| 13 | 13 | } from '../printerConnection' |
| 14 | 14 | // @ts-ignore - js bridge module (app-plus only) |
| 15 | 15 | import classicBluetooth from '../bluetoothTool.js' |
| 16 | -import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' | |
| 16 | +import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace } from '../imageRaster' | |
| 17 | 17 | import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' |
| 18 | 18 | import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' |
| 19 | 19 | import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' |
| ... | ... | @@ -28,7 +28,13 @@ import { |
| 28 | 28 | getLabelPrintRasterLayout, |
| 29 | 29 | renderLabelPreviewCanvasImageDataForPrint, |
| 30 | 30 | renderLabelPreviewCanvasToTempPathForPrint, |
| 31 | + settleAfterLabelCanvasResize, | |
| 31 | 32 | } from '../../labelPreview/renderLabelPreviewCanvas' |
| 33 | +import { | |
| 34 | + ensureTemplateHeightCoversElements, | |
| 35 | + templateContentHeightPx, | |
| 36 | + templateSizeToMillimeters, | |
| 37 | +} from '../templatePhysicalMm' | |
| 32 | 38 | import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' |
| 33 | 39 | import { printRunDiag } from '../printRunDiagnostics' |
| 34 | 40 | import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' |
| ... | ... | @@ -853,9 +859,10 @@ export async function printImageForCurrentPrinter ( |
| 853 | 859 | } |
| 854 | 860 | } |
| 855 | 861 | |
| 862 | + const rasterForPrint = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) | |
| 856 | 863 | let data: number[] = [] |
| 857 | 864 | if (rasterDriver.protocol === 'esc') { |
| 858 | - data = buildEscPosImageData(raster, options) | |
| 865 | + data = buildEscPosImageData(rasterForPrint, options) | |
| 859 | 866 | } else { |
| 860 | 867 | data = buildTscImageData(raster, options, rasterDriver.imageDpi || 203) |
| 861 | 868 | } |
| ... | ... | @@ -964,7 +971,8 @@ export async function printImageDataForCurrentPrinter ( |
| 964 | 971 | ): Promise<PrinterDriver> { |
| 965 | 972 | const driver = getCurrentPrinterDriver() |
| 966 | 973 | const rasterDriver = resolveRasterPrintDriver(driver) |
| 967 | - const raster = rasterizeImageData(imageData, options) | |
| 974 | + let raster = rasterizeImageData(imageData, options) | |
| 975 | + raster = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) | |
| 968 | 976 | if (onProgress) onProgress(5) |
| 969 | 977 | const data = rasterDriver.protocol === 'esc' |
| 970 | 978 | ? buildEscPosImageData(raster, options) |
| ... | ... | @@ -1000,6 +1008,20 @@ export type SystemTemplatePrintCanvasRasterOptions = { |
| 1000 | 1008 | }) => void | Promise<void> |
| 1001 | 1009 | } |
| 1002 | 1010 | |
| 1011 | +function templateForRasterPrint (template: SystemLabelTemplate): SystemLabelTemplate { | |
| 1012 | + return ensureTemplateHeightCoversElements(template) | |
| 1013 | +} | |
| 1014 | + | |
| 1015 | +/** 内置 ESC 光栅:按图像高度走纸,裁掉底部全白行(布局已按 contentHeight 时仍可去掉少量留白) */ | |
| 1016 | +function finalizeRasterForEscPrint ( | |
| 1017 | + raster: { width: number; height: number; pixels: number[] }, | |
| 1018 | + protocol: string, | |
| 1019 | + options: PrintImageOptions, | |
| 1020 | +) { | |
| 1021 | + if (protocol !== 'esc' || options.useContentHeight === false) return raster | |
| 1022 | + return trimMonochromeImageBottomWhitespace(raster, 8) | |
| 1023 | +} | |
| 1024 | + | |
| 1003 | 1025 | export async function printSystemTemplateForCurrentPrinter ( |
| 1004 | 1026 | template: SystemLabelTemplate, |
| 1005 | 1027 | data: LabelTemplateData = {}, |
| ... | ... | @@ -1019,21 +1041,49 @@ export async function printSystemTemplateForCurrentPrinter ( |
| 1019 | 1041 | |
| 1020 | 1042 | if (canvasRaster && !bypassCanvasRasterForQr) { |
| 1021 | 1043 | if (onProgress) onProgress(1) |
| 1044 | + const templateForDraw = templateForRasterPrint(template) | |
| 1022 | 1045 | const maxDots = |
| 1023 | 1046 | rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) |
| 1024 | - const layout = getLabelPrintRasterLayout(template, maxDots, rasterDriver.imageDpi || 203) | |
| 1047 | + const escUseContentH = rasterDriver.protocol === 'esc' | |
| 1048 | + const contentHpx = escUseContentH ? templateContentHeightPx(templateForDraw) : undefined | |
| 1049 | + const layout = getLabelPrintRasterLayout( | |
| 1050 | + templateForDraw, | |
| 1051 | + maxDots, | |
| 1052 | + rasterDriver.imageDpi || 203, | |
| 1053 | + contentHpx != null ? { contentHeightPx: contentHpx } : undefined, | |
| 1054 | + ) | |
| 1055 | + if (escUseContentH) { | |
| 1056 | + printRunDiag('raster_layout_content_height', { | |
| 1057 | + contentHpx, | |
| 1058 | + layoutCh: layout.ch, | |
| 1059 | + outW: layout.outW, | |
| 1060 | + outH: layout.outH, | |
| 1061 | + }) | |
| 1062 | + } | |
| 1025 | 1063 | if (onProgress) onProgress(4) |
| 1026 | 1064 | if (canvasRaster.applyLayout) { |
| 1027 | - await canvasRaster.applyLayout(layout) | |
| 1065 | + await Promise.resolve(canvasRaster.applyLayout(layout)) | |
| 1028 | 1066 | } |
| 1029 | 1067 | if (onProgress) onProgress(7) |
| 1030 | - await new Promise<void>((r) => setTimeout(r, 50)) | |
| 1068 | + await settleAfterLabelCanvasResize() | |
| 1031 | 1069 | if (onProgress) onProgress(9) |
| 1032 | 1070 | const printOpts: PrintImageOptions = { |
| 1033 | 1071 | printQty: options.printQty || 1, |
| 1034 | - clearTopRasterRows: 1, | |
| 1072 | + clearTopRasterRows: 0, | |
| 1035 | 1073 | targetWidthDots: layout.outW, |
| 1036 | 1074 | targetHeightDots: layout.outH, |
| 1075 | + useContentHeight: escUseContentH, | |
| 1076 | + cutBetweenCopies: true, | |
| 1077 | + widthMm: templateSizeToMillimeters( | |
| 1078 | + templateForDraw.unit, | |
| 1079 | + Number(templateForDraw.width) || 0, | |
| 1080 | + Number(templateForDraw.height) || 0, | |
| 1081 | + ).widthMm, | |
| 1082 | + heightMm: templateSizeToMillimeters( | |
| 1083 | + templateForDraw.unit, | |
| 1084 | + Number(templateForDraw.width) || 0, | |
| 1085 | + Number(templateForDraw.height) || 0, | |
| 1086 | + ).heightMm, | |
| 1037 | 1087 | } |
| 1038 | 1088 | const mapRasterProgress = onProgress |
| 1039 | 1089 | ? (p: number) => { |
| ... | ... | @@ -1046,7 +1096,7 @@ export async function printSystemTemplateForCurrentPrinter ( |
| 1046 | 1096 | const imageData = await renderLabelPreviewCanvasImageDataForPrint( |
| 1047 | 1097 | canvasRaster.canvasId, |
| 1048 | 1098 | canvasRaster.componentInstance, |
| 1049 | - template, | |
| 1099 | + templateForDraw, | |
| 1050 | 1100 | layout, |
| 1051 | 1101 | ) |
| 1052 | 1102 | if (onProgress) onProgress(12) |
| ... | ... | @@ -1059,7 +1109,7 @@ export async function printSystemTemplateForCurrentPrinter ( |
| 1059 | 1109 | const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint( |
| 1060 | 1110 | canvasRaster.canvasId, |
| 1061 | 1111 | canvasRaster.componentInstance, |
| 1062 | - template, | |
| 1112 | + templateForDraw, | |
| 1063 | 1113 | layout, |
| 1064 | 1114 | ) |
| 1065 | 1115 | if (onProgress) onProgress(12) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
| ... | ... | @@ -7,6 +7,7 @@ import type { |
| 7 | 7 | SystemLabelTemplate, |
| 8 | 8 | SystemTemplateElementBase, |
| 9 | 9 | } from './types/printer' |
| 10 | +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' | |
| 10 | 11 | import { applyTemplateData } from './templateRenderer' |
| 11 | 12 | |
| 12 | 13 | function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { |
| ... | ... | @@ -19,6 +20,74 @@ function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): bo |
| 19 | 20 | return false |
| 20 | 21 | } |
| 21 | 22 | |
| 23 | +/** 原生营养表:严格使用模板 height,打印时再按 dpi 缩放;勿在 JS 侧抬高 height 以免压住下方 DATE */ | |
| 24 | +function prepareNutritionElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { | |
| 25 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 26 | + const x = Math.max(0, Number(el.x) || 0) | |
| 27 | + const y = Math.max(0, Number(el.y) || 0) | |
| 28 | + const w = Math.max(40, Number(el.width) || 0) | |
| 29 | + const h = Math.max(40, Number(el.height) || 72) | |
| 30 | + cfg.nativePrintHeight = h | |
| 31 | + cfg.NativePrintHeight = h | |
| 32 | + cfg.nativePadLeft = Number(cfg.nativePadLeft ?? 2) | |
| 33 | + cfg.nativePadRight = Number(cfg.nativePadRight ?? 4) | |
| 34 | + cfg.nutritionTitleBold = false | |
| 35 | + cfg.nutritionBodyBold = false | |
| 36 | + return { | |
| 37 | + ...el, | |
| 38 | + x, | |
| 39 | + y, | |
| 40 | + width: w, | |
| 41 | + height: h, | |
| 42 | + config: cfg, | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +/** 原生打印按 dpi 放大营养表后,保证 DATE/TIME/BARCODE 在营养表底边之下(与预览留白一致) */ | |
| 47 | +function resolveNativeLayoutCollisions ( | |
| 48 | + elements: SystemTemplateElementBase[], | |
| 49 | +): SystemTemplateElementBase[] { | |
| 50 | + const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION') | |
| 51 | + if (!nutrition) return elements | |
| 52 | + const nutY = Number(nutrition.y) || 0 | |
| 53 | + const nutH = Number(nutrition.height) || 0 | |
| 54 | + /** 设计 px 最小间距;预览里常见 8–12px */ | |
| 55 | + const minGapPx = 10 | |
| 56 | + const reservedBottom = nutY + nutH + minGapPx | |
| 57 | + return elements.map((el) => { | |
| 58 | + if (el.id === nutrition.id) return el | |
| 59 | + const y = Number(el.y) || 0 | |
| 60 | + if (y < reservedBottom && y >= nutY - 1) { | |
| 61 | + return { ...el, y: reservedBottom } | |
| 62 | + } | |
| 63 | + return el | |
| 64 | + }) | |
| 65 | +} | |
| 66 | + | |
| 67 | +function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): SystemTemplateElementBase { | |
| 68 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 69 | + const barcodeType = normalizeBarcodeType(cfg.barcodeType ?? cfg.BarcodeType) | |
| 70 | + const raw = String( | |
| 71 | + cfg.data ?? cfg.Data ?? cfg.value ?? cfg.Value ?? cfg.barcodeData ?? cfg.BarcodeData ?? '' | |
| 72 | + ).trim() | |
| 73 | + const data = formatBarcodeValueForTsc(raw, barcodeType) | |
| 74 | + /** 人读数字与预览一致(1234),编码串(A1234B)仅给原生画条用 */ | |
| 75 | + cfg.barcodeDisplayText = raw | |
| 76 | + cfg.BarcodeDisplayText = raw | |
| 77 | + if (data) { | |
| 78 | + cfg.data = data | |
| 79 | + cfg.Data = data | |
| 80 | + cfg.value = data | |
| 81 | + cfg.Value = data | |
| 82 | + } | |
| 83 | + /** CODABAR 在 Virtual BT / 佳博上 TSC BARCODE 易失败,改走与预览一致的位图条 */ | |
| 84 | + if (barcodeType === 'CODABAR') { | |
| 85 | + cfg.nativeBarcodeBitmap = true | |
| 86 | + cfg.NativeBarcodeBitmap = true | |
| 87 | + } | |
| 88 | + return { ...el, config: cfg } | |
| 89 | +} | |
| 90 | + | |
| 22 | 91 | /** |
| 23 | 92 | * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致), |
| 24 | 93 | * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。 |
| ... | ... | @@ -60,6 +129,7 @@ export function normalizeTemplateForNativeFastJob ( |
| 60 | 129 | } |
| 61 | 130 | } |
| 62 | 131 | |
| 132 | + const extras: SystemTemplateElementBase[] = [] | |
| 63 | 133 | const elements = (template.elements || []).map((el) => { |
| 64 | 134 | const type = String(el.type || '').toUpperCase() |
| 65 | 135 | const config = (el.config || {}) as Record<string, any> |
| ... | ... | @@ -119,9 +189,41 @@ export function normalizeTemplateForNativeFastJob ( |
| 119 | 189 | config: { ...config, text, nativeSourceType: type }, |
| 120 | 190 | } |
| 121 | 191 | } |
| 192 | + if (type === 'NUTRITION') { | |
| 193 | + return prepareNutritionElementForNativePrint(el) | |
| 194 | + } | |
| 195 | + if (type === 'BARCODE') { | |
| 196 | + const prepared = prepareBarcodeElementForNativePrint(el) | |
| 197 | + const pcfg = { ...(prepared.config || {}) } as Record<string, unknown> | |
| 198 | + const human = String(pcfg.barcodeDisplayText ?? pcfg.BarcodeDisplayText ?? '').trim() | |
| 199 | + const showHuman = String(pcfg.showText ?? pcfg.ShowText ?? 'true').toLowerCase() !== 'false' | |
| 200 | + if (human && showHuman) { | |
| 201 | + pcfg.showText = false | |
| 202 | + pcfg.ShowText = false | |
| 203 | + extras.push({ | |
| 204 | + id: `${String(prepared.id || 'barcode')}_label`, | |
| 205 | + type: 'TEXT_STATIC', | |
| 206 | + x: Number(prepared.x) || 0, | |
| 207 | + y: (Number(prepared.y) || 0) + (Number(prepared.height) || 40) + 6, | |
| 208 | + width: Number(prepared.width) || 140, | |
| 209 | + height: 20, | |
| 210 | + rotation: prepared.rotation ?? 'horizontal', | |
| 211 | + border: 'none', | |
| 212 | + config: { | |
| 213 | + text: human, | |
| 214 | + fontSize: 12, | |
| 215 | + textAlign: 'center', | |
| 216 | + TextAlign: 'center', | |
| 217 | + forceRasterText: true, | |
| 218 | + }, | |
| 219 | + } as SystemTemplateElementBase) | |
| 220 | + } | |
| 221 | + return { ...prepared, config: pcfg } | |
| 222 | + } | |
| 122 | 223 | return el |
| 123 | 224 | }) |
| 124 | - return { ...template, elements } | |
| 225 | + const merged = resolveNativeLayoutCollisions([...elements, ...extras]) | |
| 226 | + return { ...template, elements: merged } | |
| 125 | 227 | } |
| 126 | 228 | |
| 127 | 229 | /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */ | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts
| ... | ... | @@ -40,5 +40,5 @@ export function getPrintRunDiagnosticsText (): string { |
| 40 | 40 | export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string { |
| 41 | 41 | const full = getPrintRunDiagnosticsText() |
| 42 | 42 | if (full.length <= maxChars) return full |
| 43 | - return `...(省略开头 ${full.length - maxChars} 字)\n\n${full.slice(-maxChars)}` | |
| 43 | + return `...(start omitted, ${full.length - maxChars} chars)\n\n${full.slice(-maxChars)}` | |
| 44 | 44 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/protocols/escPosBuilder.ts
| ... | ... | @@ -124,14 +124,27 @@ function appendBoxLine (out: number[], text = '', width = 32) { |
| 124 | 124 | appendLine(out, `| ${value} |`) |
| 125 | 125 | } |
| 126 | 126 | |
| 127 | -function createEscDocument (builder: (out: number[]) => void): number[] { | |
| 127 | +/** GS V:不支持切刀的机芯通常会忽略,不阻断后续打印 */ | |
| 128 | +function appendEscCut (out: number[], mode: 'partial' | 'full' = 'partial') { | |
| 129 | + if (mode === 'full') { | |
| 130 | + out.push(0x1d, 0x56, 0x00) | |
| 131 | + } else { | |
| 132 | + out.push(0x1d, 0x56, 0x01) | |
| 133 | + } | |
| 134 | +} | |
| 135 | + | |
| 136 | +function createEscDocument ( | |
| 137 | + builder: (out: number[]) => void, | |
| 138 | + endOpts: { feedLines?: number; cut?: 'none' | 'partial' | 'full' } = {}, | |
| 139 | +): number[] { | |
| 128 | 140 | const out: number[] = [] |
| 129 | 141 | out.push(0x1b, 0x40) |
| 130 | 142 | out.push(0x1b, 0x74, 16) |
| 131 | 143 | builder(out) |
| 132 | - out.push(0x1b, 0x64, 0x04) | |
| 133 | - // 打印完成后执行切刀(GS V 0):适配当前内置小票机,避免长纸不断。 | |
| 134 | - out.push(0x1d, 0x56, 0x00) | |
| 144 | + const feed = Math.max(0, Math.min(8, Math.round(endOpts.feedLines ?? 4))) | |
| 145 | + if (feed > 0) out.push(0x1b, 0x64, feed) | |
| 146 | + const cut = endOpts.cut ?? 'partial' | |
| 147 | + if (cut !== 'none') appendEscCut(out, cut === 'full' ? 'full' : 'partial') | |
| 135 | 148 | return out |
| 136 | 149 | } |
| 137 | 150 | |
| ... | ... | @@ -242,14 +255,22 @@ export function buildEscPosImageData ( |
| 242 | 255 | options: PrintImageOptions = {} |
| 243 | 256 | ): number[] { |
| 244 | 257 | const printQty = Math.max(1, Math.round(options.printQty || 1)) |
| 245 | - return createEscDocument((out) => { | |
| 246 | - for (let i = 0; i < printQty; i++) { | |
| 247 | - appendAlign(out, 1) | |
| 248 | - appendRasterImage(out, image) | |
| 249 | - appendLine(out) | |
| 250 | - appendLine(out) | |
| 258 | + const cutBetween = options.cutBetweenCopies !== false | |
| 259 | + const out: number[] = [] | |
| 260 | + out.push(0x1b, 0x40) | |
| 261 | + out.push(0x1b, 0x74, 16) | |
| 262 | + for (let i = 0; i < printQty; i++) { | |
| 263 | + appendAlign(out, 1) | |
| 264 | + appendRasterImage(out, image) | |
| 265 | + out.push(0x1b, 0x64, 1) | |
| 266 | + const isLast = i >= printQty - 1 | |
| 267 | + if (!isLast && cutBetween) { | |
| 268 | + appendEscCut(out, 'partial') | |
| 251 | 269 | } |
| 252 | - }) | |
| 270 | + } | |
| 271 | + out.push(0x1b, 0x64, 2) | |
| 272 | + if (cutBetween) appendEscCut(out, 'partial') | |
| 273 | + return out | |
| 253 | 274 | } |
| 254 | 275 | |
| 255 | 276 | export function buildEscPosTemplateData ( | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
| 1 | -import { normalizeBarcodeType } from '../barcodeFormat' | |
| 1 | +import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' | |
| 2 | 2 | import { storedValueLooksLikeImagePath } from '../resolveMediaUrl' |
| 3 | 3 | import { |
| 4 | 4 | createImageBitmapPatch, |
| ... | ... | @@ -486,9 +486,9 @@ function buildTscTemplate ( |
| 486 | 486 | } |
| 487 | 487 | |
| 488 | 488 | if (type === 'BARCODE') { |
| 489 | - const value = resolveElementDataValue(element, data) | |
| 490 | - if (!value) return | |
| 491 | 489 | const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) |
| 490 | + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology) | |
| 491 | + if (!value) return | |
| 492 | 492 | const rotation = resolveRotation( |
| 493 | 493 | element.rotation || getConfigString(config, ['orientation'], 'horizontal') |
| 494 | 494 | ) |
| ... | ... | @@ -612,7 +612,8 @@ function buildEscTemplate ( |
| 612 | 612 | } |
| 613 | 613 | |
| 614 | 614 | if (type === 'BARCODE') { |
| 615 | - const value = resolveElementDataValue(element, data) | |
| 615 | + const symbology = normalizeBarcodeType(getConfigString(config, ['barcodeType'], '')) | |
| 616 | + const value = formatBarcodeValueForTsc(resolveElementDataValue(element, data), symbology) | |
| 616 | 617 | if (!value) return |
| 617 | 618 | items.push({ |
| 618 | 619 | type: 'barcode', | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
| ... | ... | @@ -2,14 +2,105 @@ |
| 2 | 2 | * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm), |
| 3 | 3 | * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。 |
| 4 | 4 | */ |
| 5 | -import type { SystemLabelTemplate } from './types/printer' | |
| 5 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer' | |
| 6 | 6 | |
| 7 | 7 | const DESIGN_DPI = 96 |
| 8 | +const PX_PER_INCH = 96 | |
| 9 | +const PX_PER_CM = 37.8 | |
| 10 | + | |
| 11 | +function toCanvasPx (value: number, unit: string): number { | |
| 12 | + const u = String(unit || 'inch').toLowerCase() | |
| 13 | + if (u === 'mm') return (value / 25.4) * PX_PER_INCH | |
| 14 | + if (u === 'cm') return value * PX_PER_CM | |
| 15 | + if (u === 'px') return value | |
| 16 | + return value * PX_PER_INCH | |
| 17 | +} | |
| 18 | + | |
| 19 | +function fromCanvasPx (px: number, unit: string): number { | |
| 20 | + const u = String(unit || 'inch').toLowerCase() | |
| 21 | + if (u === 'mm') return (px * 25.4) / PX_PER_INCH | |
| 22 | + if (u === 'cm') return px / PX_PER_CM | |
| 23 | + if (u === 'px') return px | |
| 24 | + return px / PX_PER_INCH | |
| 25 | +} | |
| 26 | + | |
| 27 | +function roundTemplateDim (value: number, unit: string): number { | |
| 28 | + const u = String(unit || 'inch').toLowerCase() | |
| 29 | + if (u === 'px') return Math.max(1, Math.round(value)) | |
| 30 | + if (u === 'mm' || u === 'cm') return Math.round(value * 10) / 10 | |
| 31 | + return Math.round(value * 1000) / 1000 | |
| 32 | +} | |
| 33 | + | |
| 34 | +function elementBottomPx (el: SystemTemplateElementBase): number { | |
| 35 | + const y = Number(el.y) || 0 | |
| 36 | + const h = Math.max(0, Number(el.height) || 0) | |
| 37 | + let bottom = y + h | |
| 38 | + const type = String(el.type || '').toUpperCase() | |
| 39 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 40 | + if (type === 'BARCODE') { | |
| 41 | + const showText = String(cfg.showText ?? cfg.ShowText ?? 'true').toLowerCase() !== 'false' | |
| 42 | + if (showText) bottom += 28 | |
| 43 | + else bottom += 4 | |
| 44 | + } | |
| 45 | + if (type === 'QRCODE') bottom += 4 | |
| 46 | + return bottom | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** | |
| 50 | + * 原生 printTemplate 的 SIZE 高度仅取自模板根 height,不会按元素自动增高; | |
| 51 | + * 条码/日期贴在底部时易被裁掉。与 systemTemplateAdapter.buildTscTemplate 增高逻辑对齐。 | |
| 52 | + */ | |
| 53 | +/** 设计 px:元素最底边 + 留白,且不超过模板根 height(用于光栅出纸高度,避免整纸 5cm 空白) */ | |
| 54 | +export function templateContentHeightPx ( | |
| 55 | + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height' | 'elements'>, | |
| 56 | + extraBottomPx = 14, | |
| 57 | +): number { | |
| 58 | + const unit = String(template.unit || 'inch') | |
| 59 | + const canvasH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 0, unit))) | |
| 60 | + const elements = template.elements || [] | |
| 61 | + if (!elements.length) return canvasH | |
| 62 | + let maxBottom = 0 | |
| 63 | + for (const el of elements) { | |
| 64 | + maxBottom = Math.max(maxBottom, elementBottomPx(el)) | |
| 65 | + } | |
| 66 | + return Math.max(40, Math.min(canvasH, maxBottom + extraBottomPx)) | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function ensureTemplateHeightCoversElements ( | |
| 70 | + template: SystemLabelTemplate, | |
| 71 | + extraBottomPx = 14 | |
| 72 | +): SystemLabelTemplate { | |
| 73 | + const unit = String(template.unit || 'inch') | |
| 74 | + const elements = template.elements || [] | |
| 75 | + if (!elements.length) return template | |
| 76 | + let maxBottom = 0 | |
| 77 | + for (const el of elements) { | |
| 78 | + maxBottom = Math.max(maxBottom, elementBottomPx(el)) | |
| 79 | + } | |
| 80 | + const currentHpx = toCanvasPx(Number(template.height) || 0, unit) | |
| 81 | + const neededPx = maxBottom + extraBottomPx | |
| 82 | + if (neededPx <= currentHpx + 1) return template | |
| 83 | + return { | |
| 84 | + ...template, | |
| 85 | + height: roundTemplateDim(fromCanvasPx(neededPx, unit), unit), | |
| 86 | + } | |
| 87 | +} | |
| 8 | 88 | |
| 9 | 89 | /** 常见 4″ 标签机安全上限(mm),略放宽 */ |
| 10 | 90 | const NATIVE_FAST_MAX_WIDTH_MM = 112 |
| 11 | 91 | const NATIVE_FAST_MAX_HEIGHT_MM = 320 |
| 12 | 92 | |
| 93 | +/** 打印/预览统一:模板根 width/height + unit → 物理毫米(与 NativeTemplateCommandBuilder.toMillimeter 一致) */ | |
| 94 | +export function getTemplatePhysicalSizeMm ( | |
| 95 | + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height'> | |
| 96 | +): { widthMm: number; heightMm: number; unit: string } { | |
| 97 | + const unit = String(template.unit || 'inch') | |
| 98 | + const w = Number(template.width) || 0 | |
| 99 | + const h = Number(template.height) || 0 | |
| 100 | + const { widthMm, heightMm } = templateSizeToMillimeters(unit, w, h) | |
| 101 | + return { widthMm, heightMm, unit } | |
| 102 | +} | |
| 103 | + | |
| 13 | 104 | export function templateSizeToMillimeters ( |
| 14 | 105 | unit: string | undefined, |
| 15 | 106 | width: number, | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
| ... | ... | @@ -26,6 +26,12 @@ export interface PrintImageOptions { |
| 26 | 26 | maxWidthDots?: number |
| 27 | 27 | targetWidthDots?: number |
| 28 | 28 | targetHeightDots?: number |
| 29 | + /** @deprecated 光栅已按内容高度裁切;保留兼容 */ | |
| 30 | + labelRasterFixedHeight?: boolean | |
| 31 | + /** 多份打印时:每打完一张尝试半切(GS V 1);不支持则机芯忽略并继续下一张 */ | |
| 32 | + cutBetweenCopies?: boolean | |
| 33 | + /** 光栅按元素底边高度布局,而非整模板 height */ | |
| 34 | + useContentHeight?: boolean | |
| 29 | 35 | widthMm?: number |
| 30 | 36 | heightMm?: number |
| 31 | 37 | x?: number | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
| ... | ... | @@ -14,9 +14,7 @@ import { |
| 14 | 14 | setLastLabelPrintJobPayload, |
| 15 | 15 | } from './labelPreview/buildLabelPrintPayload' |
| 16 | 16 | import { getCurrentStoreId } from './stores' |
| 17 | -import { | |
| 18 | - ensureNativeClassicTransportIfPossible, | |
| 19 | -} from './print/printerConnection' | |
| 17 | +import { ensureNativeClassicTransportIfPossible } from './print/printerConnection' | |
| 20 | 18 | import { |
| 21 | 19 | hydrateSystemTemplateImagesForPrint, |
| 22 | 20 | resetHydrateImageDebugRecords, |
| ... | ... | @@ -25,7 +23,10 @@ import { |
| 25 | 23 | normalizeTemplateForNativeFastJob, |
| 26 | 24 | templateHasUnsupportedNativeFastElements, |
| 27 | 25 | } from './print/nativeTemplateElementSupport' |
| 28 | -import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm' | |
| 26 | +import { | |
| 27 | + ensureTemplateHeightCoversElements, | |
| 28 | + isTemplateWithinNativeFastPrintBounds, | |
| 29 | +} from './print/templatePhysicalMm' | |
| 29 | 30 | import type { |
| 30 | 31 | LabelTemplateData, |
| 31 | 32 | SystemLabelTemplate, |
| ... | ... | @@ -357,10 +358,11 @@ async function printReprintTemplateWithPreviewStrategy ( |
| 357 | 358 | await ensureNativeClassicTransportIfPossible() |
| 358 | 359 | const templateData = labelTemplateDataForSnapshotReprint() |
| 359 | 360 | const printInputJson: Record<string, unknown> = {} |
| 360 | - const tmplForNative = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) | |
| 361 | + const tmplSized = ensureTemplateHeightCoversElements(tmpl) | |
| 362 | + const tmplForNative = normalizeTemplateForNativeFastJob(tmplSized, printInputJson as any) | |
| 361 | 363 | const useNative = |
| 362 | 364 | canPrintCurrentLabelViaNativeFastJob() |
| 363 | - && isTemplateWithinNativeFastPrintBounds(tmpl) | |
| 365 | + && isTemplateWithinNativeFastPrintBounds(tmplSized) | |
| 364 | 366 | && !templateHasUnsupportedNativeFastElements(tmplForNative) |
| 365 | 367 | |
| 366 | 368 | const printQty = options.printQty ?? 1 |
| ... | ... | @@ -384,7 +386,7 @@ async function printReprintTemplateWithPreviewStrategy ( |
| 384 | 386 | } |
| 385 | 387 | |
| 386 | 388 | await printSystemTemplateForCurrentPrinter( |
| 387 | - tmpl, | |
| 389 | + tmplSized, | |
| 388 | 390 | templateData, |
| 389 | 391 | { printQty, canvasRaster: options.canvasRaster }, |
| 390 | 392 | options.onProgress, | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.development
| 1 | 1 | # 端口号 |
| 2 | -VITE_PORT=17001 | |
| 2 | +VITE_PORT=3100 | |
| 3 | 3 | |
| 4 | 4 | VITE_BASE=/ |
| 5 | +# vue-router 模式:hash 刷新不依赖服务器 try_files | |
| 6 | +VITE_ROUTER_HISTORY=hash | |
| 5 | 7 | # 是否开启 Nitro Mock服务,true 为开启,false 为关闭 |
| 6 | 8 | VITE_NITRO_MOCK=false |
| 7 | 9 | # 是否打开 devtools,true 为打开,false 为关闭 |
| ... | ... | @@ -9,9 +11,9 @@ VITE_DEVTOOLS=false |
| 9 | 11 | # 是否注入全局loading |
| 10 | 12 | VITE_INJECT_APP_LOADING=true |
| 11 | 13 | |
| 12 | -# 后台请求路径 具体在vite.config.mts配置代理 | |
| 13 | -VITE_GLOB_API_URL="/dev-api" | |
| 14 | -VITE_APP_URL="http://flus-test.3ffoodsafety.com/api/app" | |
| 14 | +# 本地开发:直连线上 API(不走 Vite 代理;VITE_GLOB_API_URL 为绝对地址时 proxy 不启用) | |
| 15 | +# 线上:http://saas-test.3ffoodsafety.com/api/app | |
| 16 | +VITE_GLOB_API_URL=http://saas-test.3ffoodsafety.com/api/app | |
| 15 | 17 | |
| 16 | 18 | # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) |
| 17 | 19 | VITE_GLOB_ENABLE_ENCRYPT=false | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.production
| ... | ... | @@ -7,7 +7,7 @@ VITE_COMPRESS=gzip |
| 7 | 7 | VITE_PWA=false |
| 8 | 8 | |
| 9 | 9 | # vue-router 的模式 |
| 10 | -VITE_ROUTER_HISTORY=history | |
| 10 | +VITE_ROUTER_HISTORY=hash | |
| 11 | 11 | |
| 12 | 12 | # 是否注入全局loading |
| 13 | 13 | VITE_INJECT_APP_LOADING=true |
| ... | ... | @@ -15,8 +15,8 @@ VITE_INJECT_APP_LOADING=true |
| 15 | 15 | # 打包后是否生成dist.zip |
| 16 | 16 | VITE_ARCHIVER=true |
| 17 | 17 | |
| 18 | -# 后端接口地址(ABP 动态 API:/api/app) | |
| 19 | -VITE_GLOB_API_URL=http://flus-test.3ffoodsafety.com/api/app | |
| 18 | +# 生产部署在 saas-test 同域时用相对路径,避免跨域;若前后端不同域再改为完整 URL | |
| 19 | +VITE_GLOB_API_URL=/api/app | |
| 20 | 20 | |
| 21 | 21 | # 全局加密开关(即开启了加解密功能才会生效 不是全部接口加密 需要和后端对应) |
| 22 | 22 | VITE_GLOB_ENABLE_ENCRYPT=false | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/.env.test
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/package.json
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/account-types.ts
| ... | ... | @@ -19,6 +19,7 @@ export interface RoleDto { |
| 19 | 19 | orderNum?: number | null; |
| 20 | 20 | creationTime?: string | null; |
| 21 | 21 | accessPermissionCodes?: string[] | null; |
| 22 | + menuPermissionKeys?: string[] | null; | |
| 22 | 23 | } |
| 23 | 24 | |
| 24 | 25 | export interface RoleGetListQuery extends FlPageQuery { |
| ... | ... | @@ -145,6 +146,8 @@ export interface TeamMemberDto { |
| 145 | 146 | locationIds?: string[] | null; |
| 146 | 147 | locations?: string[] | null; |
| 147 | 148 | state?: boolean | null; |
| 149 | + useCustomMenuPermissions?: boolean | null; | |
| 150 | + menuPermissionKeys?: string[] | null; | |
| 148 | 151 | } |
| 149 | 152 | |
| 150 | 153 | export interface TeamMemberGetListQuery extends FlPageQuery { |
| ... | ... | @@ -164,6 +167,8 @@ export interface TeamMemberCreateInput { |
| 164 | 167 | regionIds?: string[]; |
| 165 | 168 | locationIds: string[]; |
| 166 | 169 | state: boolean; |
| 170 | + useCustomMenuPermissions?: boolean; | |
| 171 | + menuPermissionKeys?: string[]; | |
| 167 | 172 | } |
| 168 | 173 | |
| 169 | 174 | export interface TeamMemberUpdateInput { |
| ... | ... | @@ -177,4 +182,6 @@ export interface TeamMemberUpdateInput { |
| 177 | 182 | regionIds?: string[]; |
| 178 | 183 | locationIds: string[]; |
| 179 | 184 | state: boolean; |
| 185 | + useCustomMenuPermissions?: boolean; | |
| 186 | + menuPermissionKeys?: string[]; | |
| 180 | 187 | } | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/index.ts
| ... | ... | @@ -19,7 +19,11 @@ export { |
| 19 | 19 | locationSupportGet, |
| 20 | 20 | locationSupportUpdate, |
| 21 | 21 | } from './location-support'; |
| 22 | -export * from './lookups'; | |
| 22 | +export { | |
| 23 | + groupList as lookupGroupList, | |
| 24 | + locationList as lookupLocationList, | |
| 25 | + productList as lookupProductList, | |
| 26 | +} from './lookups'; | |
| 23 | 27 | export * from './partner'; |
| 24 | 28 | export * from './product'; |
| 25 | 29 | export * from './product-category'; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product-category.ts
| 1 | 1 | import type { |
| 2 | - FlPagedResult, | |
| 3 | 2 | ProductCategoryCreateInput, |
| 4 | 3 | ProductCategoryDto, |
| 5 | 4 | ProductCategoryGetListQuery, |
| 6 | 5 | ProductCategoryUpdateInput, |
| 7 | 6 | } from './product-types'; |
| 7 | +import type { FlPagedResult } from './types'; | |
| 8 | 8 | |
| 9 | 9 | import { requestClient } from '#/api/request'; |
| 10 | 10 | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/product.ts
| 1 | 1 | import type { |
| 2 | - FlPagedResult, | |
| 3 | 2 | ProductBatchImportResultDto, |
| 4 | 3 | ProductCreateInput, |
| 5 | 4 | ProductDto, |
| ... | ... | @@ -7,6 +6,7 @@ import type { |
| 7 | 6 | ProductGetListQuery, |
| 8 | 7 | ProductUpdateInput, |
| 9 | 8 | } from './product-types'; |
| 9 | +import type { FlPagedResult } from './types'; | |
| 10 | 10 | |
| 11 | 11 | import { ContentTypeEnum } from '#/api/helper'; |
| 12 | 12 | import { requestClient } from '#/api/request'; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/food-labeling/reports.ts
| 1 | 1 | import type { |
| 2 | - FlPagedResult, | |
| 3 | 2 | LabelReportDataDto, |
| 4 | 3 | LabelReportQuery, |
| 5 | 4 | ReportsPrintLogItemDto, |
| ... | ... | @@ -7,6 +6,7 @@ import type { |
| 7 | 6 | ReportsTemplatePrintStatItemDto, |
| 8 | 7 | ReportsTemplatePrintStatQuery, |
| 9 | 8 | } from './reports-types'; |
| 9 | +import type { FlPagedResult } from './types'; | |
| 10 | 10 | |
| 11 | 11 | import { requestClient } from '#/api/request'; |
| 12 | 12 | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/api/th/th-app-auth.ts
| 1 | -import { useAppConfig } from '@vben/hooks'; | |
| 2 | -import { preferences } from '@vben/preferences'; | |
| 3 | - | |
| 4 | -import { unwrapAbpResponse } from '#/api/th/abp-unwrap'; | |
| 5 | - | |
| 6 | 1 | /** 绑定门店(与 ThAppLoginOutputDto.locations 一致) */ |
| 7 | 2 | export interface ThAppBoundLocationDto { |
| 8 | 3 | id: string; |
| ... | ... | @@ -28,13 +23,13 @@ export interface ThAppLoginOutputDto { |
| 28 | 23 | locations: ThAppBoundLocationDto[]; |
| 29 | 24 | } |
| 30 | 25 | |
| 31 | -const { clientId } = useAppConfig(import.meta.env, import.meta.env.PROD); | |
| 32 | - | |
| 33 | 26 | /** 防止重复提交导致前一个请求被 Abort、后一个已在 Network 成功但 UI 仍报错 */ |
| 34 | 27 | let loginInFlight: Promise<ThAppLoginOutputDto> | null = null; |
| 35 | 28 | |
| 36 | 29 | function getApiBase(): string { |
| 37 | - const raw = (import.meta.env.VITE_GLOB_API_URL as string | undefined) ?? '/dev-api'; | |
| 30 | + const raw = | |
| 31 | + (import.meta.env.VITE_GLOB_API_URL as string | undefined) ?? | |
| 32 | + 'http://saas-test.3ffoodsafety.com/api/app'; | |
| 38 | 33 | return raw.replace(/^"|"$/g, '').replace(/\/$/, ''); |
| 39 | 34 | } |
| 40 | 35 | |
| ... | ... | @@ -68,88 +63,56 @@ function normalizeLocationList(raw: unknown): ThAppBoundLocationDto[] { |
| 68 | 63 | return arr.map((x) => normalizeLocation(x as Record<string, unknown>)); |
| 69 | 64 | } |
| 70 | 65 | |
| 71 | -/** | |
| 72 | - * 使用 XHR 登录:dev 代理下 fetch/axios 常出现「Network 已有 body 但 JS 一直等连接结束」。 | |
| 73 | - * XHR onload 在收齐 responseText 后即触发,不依赖 fetch 的 body 流结束。 | |
| 74 | - */ | |
| 75 | -function thAppLoginXhr(input: ThAppLoginInput): Promise<ThAppLoginOutputDto> { | |
| 76 | - const url = `${getApiBase()}/th-app-auth/login`; | |
| 77 | - const language = preferences.app.locale.replace('-', '_'); | |
| 78 | - const body = JSON.stringify({ | |
| 79 | - tenantId: input.tenantId, | |
| 80 | - email: input.email.trim(), | |
| 81 | - password: input.password, | |
| 82 | - ...(input.uuid ? { uuid: input.uuid } : {}), | |
| 83 | - ...(input.code != null && input.code !== '' ? { code: input.code } : {}), | |
| 84 | - }); | |
| 85 | - | |
| 86 | - return new Promise((resolve, reject) => { | |
| 87 | - const xhr = new XMLHttpRequest(); | |
| 88 | - xhr.open('POST', url, true); | |
| 89 | - xhr.timeout = 60_000; | |
| 90 | - xhr.setRequestHeader('Content-Type', 'application/json;charset=utf-8'); | |
| 91 | - xhr.setRequestHeader('Accept', 'application/json'); | |
| 92 | - xhr.setRequestHeader('Accept-Language', language); | |
| 93 | - xhr.setRequestHeader('Content-Language', language); | |
| 94 | - if (clientId) { | |
| 95 | - xhr.setRequestHeader('ClientID', clientId); | |
| 96 | - } | |
| 97 | - | |
| 98 | - xhr.onload = () => { | |
| 99 | - try { | |
| 100 | - const text = xhr.responseText ?? ''; | |
| 101 | - const json = text ? JSON.parse(text) : null; | |
| 102 | - if (xhr.status < 200 || xhr.status >= 300) { | |
| 103 | - try { | |
| 104 | - unwrapAbpResponse(json); | |
| 105 | - } catch (e) { | |
| 106 | - reject(e instanceof Error ? e : new Error(`登录失败 HTTP ${xhr.status}`)); | |
| 107 | - return; | |
| 108 | - } | |
| 109 | - reject(new Error(`登录失败 HTTP ${xhr.status}`)); | |
| 110 | - return; | |
| 111 | - } | |
| 112 | - const data = unwrapAbpResponse<unknown>(json); | |
| 113 | - resolve(normalizeLoginOutput(data)); | |
| 114 | - } catch (e) { | |
| 115 | - reject( | |
| 116 | - e instanceof Error | |
| 117 | - ? e | |
| 118 | - : new Error('登录响应解析失败,请查看 Network 中 login 的 Response'), | |
| 119 | - ); | |
| 120 | - } | |
| 121 | - }; | |
| 122 | - | |
| 123 | - xhr.onerror = () => { | |
| 124 | - reject( | |
| 125 | - new Error( | |
| 126 | - '登录网络异常:请确认 dev 服务已启动且代理地址正确(/dev-api → saas-test)', | |
| 127 | - ), | |
| 128 | - ); | |
| 129 | - }; | |
| 130 | - | |
| 131 | - xhr.ontimeout = () => { | |
| 132 | - reject( | |
| 133 | - new Error( | |
| 134 | - '登录请求超时:若 Network 中已有 token 响应,请刷新页面后只点一次登录', | |
| 135 | - ), | |
| 136 | - ); | |
| 137 | - }; | |
| 138 | - | |
| 139 | - xhr.send(body); | |
| 140 | - }); | |
| 66 | +function loginNetworkError(): Error { | |
| 67 | + return new Error( | |
| 68 | + `登录网络异常:无法连接 ${getApiBase()},请检查网络、后端是否可用,以及是否已配置 CORS 允许本地前端域名`, | |
| 69 | + ); | |
| 141 | 70 | } |
| 142 | 71 | |
| 143 | -/** POST /api/app/th-app-auth/login(匿名) */ | |
| 72 | +/** | |
| 73 | + * POST /api/app/th-app-auth/login(匿名) | |
| 74 | + * 与租户下拉一致,走 requestClient 直连 VITE_GLOB_API_URL(不再使用 XHR + dev 代理) | |
| 75 | + */ | |
| 144 | 76 | export async function thAppLogin( |
| 145 | 77 | input: ThAppLoginInput, |
| 146 | 78 | ): Promise<ThAppLoginOutputDto> { |
| 147 | 79 | if (loginInFlight) { |
| 148 | 80 | return loginInFlight; |
| 149 | 81 | } |
| 150 | - loginInFlight = thAppLoginXhr(input).finally(() => { | |
| 82 | + | |
| 83 | + loginInFlight = (async () => { | |
| 84 | + const { requestClient } = await import('#/api/request'); | |
| 85 | + try { | |
| 86 | + const raw = await requestClient.post<unknown>( | |
| 87 | + 'th-app-auth/login', | |
| 88 | + { | |
| 89 | + tenantId: input.tenantId, | |
| 90 | + email: input.email.trim(), | |
| 91 | + password: input.password, | |
| 92 | + ...(input.uuid ? { uuid: input.uuid } : {}), | |
| 93 | + ...(input.code != null && input.code !== '' | |
| 94 | + ? { code: input.code } | |
| 95 | + : {}), | |
| 96 | + }, | |
| 97 | + { | |
| 98 | + errorMessageMode: 'none', | |
| 99 | + successMessageMode: 'none', | |
| 100 | + headers: { | |
| 101 | + __tenant: input.tenantId, | |
| 102 | + }, | |
| 103 | + }, | |
| 104 | + ); | |
| 105 | + return normalizeLoginOutput(raw); | |
| 106 | + } catch (error) { | |
| 107 | + if (error instanceof Error && error.message.trim()) { | |
| 108 | + throw error; | |
| 109 | + } | |
| 110 | + throw loginNetworkError(); | |
| 111 | + } | |
| 112 | + })().finally(() => { | |
| 151 | 113 | loginInFlight = null; |
| 152 | 114 | }); |
| 115 | + | |
| 153 | 116 | return loginInFlight; |
| 154 | 117 | } |
| 155 | 118 | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/en-US/foodLabeling.json
| ... | ... | @@ -19,7 +19,9 @@ |
| 19 | 19 | "specifiedCount": "{count} location(s)", |
| 20 | 20 | "codeOptional": "Leave empty to auto-generate", |
| 21 | 21 | "staticDemoBanner": "Static demo mode: no API calls. Set FOOD_LABELING_STATIC_ONLY to false in shared/static-mode.ts when integrating APIs.", |
| 22 | - "staticDemoAction": "Static demo: action simulated (no API call)" | |
| 22 | + "staticDemoAction": "Static demo: action simulated (no API call)", | |
| 23 | + "yes": "Yes", | |
| 24 | + "no": "No" | |
| 23 | 25 | }, |
| 24 | 26 | "labeling": { |
| 25 | 27 | "root": "Labeling", |
| ... | ... | @@ -244,6 +246,60 @@ |
| 244 | 246 | } |
| 245 | 247 | } |
| 246 | 248 | }, |
| 249 | + "platform": { | |
| 250 | + "section": "Platform", | |
| 251 | + "tenants": "SAAS Companies", | |
| 252 | + "saasBanner": "Platform admin: provision SAAS companies (separate DB tenants), assign company-level menus, and create company admin accounts.", | |
| 253 | + "addCompany": "Add Company", | |
| 254 | + "editCompany": "Edit Company", | |
| 255 | + "companyName": "Company Name", | |
| 256 | + "companyCode": "Company Code", | |
| 257 | + "logoUrl": "Logo URL", | |
| 258 | + "logo": "Company Logo", | |
| 259 | + "logoUpload": "Upload Logo", | |
| 260 | + "logoUploadImageOnly": "Images only", | |
| 261 | + "logoUploadMaxSize": "Image must be under 2MB", | |
| 262 | + "contactName": "Contact", | |
| 263 | + "address": "Address", | |
| 264 | + "tenantAdmin": "Company Admin", | |
| 265 | + "menuPermissions": "Menu Permissions", | |
| 266 | + "configureMenus": "Configure Menus", | |
| 267 | + "configureMenusHint": "Select menus available to \"{name}\". Roles and users cannot exceed this set.", | |
| 268 | + "menuSaved": "Company menu permissions saved", | |
| 269 | + "manageTenantAdmin": "Company Admin · {name}", | |
| 270 | + "tenantAdminHint": "Company admins manage regions, locations, users and roles within assigned menus.", | |
| 271 | + "initialAdminSection": "Initial Company Admin", | |
| 272 | + "enterAsAdmin": "Enter as Admin", | |
| 273 | + "backToPlatform": "Back to Platform", | |
| 274 | + "deleteCompanyConfirm": "Delete this company (tenant)? Demo only until API is wired." | |
| 275 | + }, | |
| 276 | + "saas": { | |
| 277 | + "menu": { | |
| 278 | + "dashboard": "Overview", | |
| 279 | + "analytics": "Home", | |
| 280 | + "labeling": "Labeling", | |
| 281 | + "labels": "Labels", | |
| 282 | + "labelCategories": "Label Categories", | |
| 283 | + "labelTypes": "Label Types", | |
| 284 | + "labelTemplates": "Label Templates", | |
| 285 | + "multipleOptions": "Multiple Options", | |
| 286 | + "modules": "Modules", | |
| 287 | + "training": "Training", | |
| 288 | + "alerts": "Alerts", | |
| 289 | + "tasks": "Tasks", | |
| 290 | + "foodWaste": "Food Waste", | |
| 291 | + "eLabel": "E-Label", | |
| 292 | + "management": "Management", | |
| 293 | + "accountManagement": "Account Management", | |
| 294 | + "menuManagement": "Menu Management", | |
| 295 | + "devices": "Devices", | |
| 296 | + "reports": "Reports", | |
| 297 | + "invoices": "Invoices", | |
| 298 | + "qrCodes": "QR Codes", | |
| 299 | + "support": "Support", | |
| 300 | + "api": "API Settings" | |
| 301 | + } | |
| 302 | + }, | |
| 247 | 303 | "management": { |
| 248 | 304 | "section": "Management", |
| 249 | 305 | "locationManager": "Location Manager", |
| ... | ... | @@ -293,7 +349,13 @@ |
| 293 | 349 | "permEditSettings": "Edit Settings", |
| 294 | 350 | "permManageProducts": "Manage Products", |
| 295 | 351 | "permViewReports": "View Reports", |
| 296 | - "permApproveBatches": "Approve Batches" | |
| 352 | + "permApproveBatches": "Approve Batches", | |
| 353 | + "menuPermissions": "Menu Permissions", | |
| 354 | + "useCustomMenus": "Custom Menus", | |
| 355 | + "userMenuOverride": "User Menus", | |
| 356 | + "inheritRoleMenus": "Inherit role", | |
| 357 | + "tenantAdminBanner": "Managing as company admin for \"{company}\". Menu permissions cannot exceed company provisioned menus.", | |
| 358 | + "platformOrTenantHint": "Tenant org: Roles → Partner companies → Regions → Locations → Team members. Provision SAAS companies under Platform > SAAS Companies." | |
| 297 | 359 | }, |
| 298 | 360 | "menuManagement": { |
| 299 | 361 | "tabProducts": "Products", | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/locales/langs/zh-CN/foodLabeling.json
| ... | ... | @@ -19,7 +19,9 @@ |
| 19 | 19 | "specifiedCount": "指定 {count} 个门店", |
| 20 | 20 | "codeOptional": "留空则由后端生成", |
| 21 | 21 | "staticDemoBanner": "当前为静态演示模式,未请求后端接口。联调时将 shared/static-mode.ts 中 FOOD_LABELING_STATIC_ONLY 改为 false。", |
| 22 | - "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)" | |
| 22 | + "staticDemoAction": "静态演示:操作已模拟成功(未调用接口)", | |
| 23 | + "yes": "是", | |
| 24 | + "no": "否" | |
| 23 | 25 | }, |
| 24 | 26 | "labeling": { |
| 25 | 27 | "root": "标签管理", |
| ... | ... | @@ -244,6 +246,60 @@ |
| 244 | 246 | } |
| 245 | 247 | } |
| 246 | 248 | }, |
| 249 | + "platform": { | |
| 250 | + "section": "平台管理", | |
| 251 | + "tenants": "SAAS 公司", | |
| 252 | + "saasBanner": "平台管理员:在此开通 SAAS 公司(独立库租户)、配置公司级菜单,并创建公司管理员账号。公司管理员登录后可在「账户管理」中维护区域、门店与成员。", | |
| 253 | + "addCompany": "开通公司", | |
| 254 | + "editCompany": "编辑公司", | |
| 255 | + "companyName": "公司名称", | |
| 256 | + "companyCode": "公司编码", | |
| 257 | + "logoUrl": "Logo 地址", | |
| 258 | + "logo": "公司 Logo", | |
| 259 | + "logoUpload": "上传 Logo", | |
| 260 | + "logoUploadImageOnly": "仅支持图片文件", | |
| 261 | + "logoUploadMaxSize": "图片不能超过 2MB", | |
| 262 | + "contactName": "联系人", | |
| 263 | + "address": "地址", | |
| 264 | + "tenantAdmin": "公司管理员", | |
| 265 | + "menuPermissions": "菜单权限", | |
| 266 | + "configureMenus": "配置公司菜单", | |
| 267 | + "configureMenusHint": "为「{name}」勾选可使用的系统菜单(公司内角色与用户权限不能超过此范围)。", | |
| 268 | + "menuSaved": "公司菜单权限已保存", | |
| 269 | + "manageTenantAdmin": "管理公司管理员 · {name}", | |
| 270 | + "tenantAdminHint": "公司管理员拥有该公司下区域、门店、用户与角色配置权限(在其公司菜单范围内)。", | |
| 271 | + "initialAdminSection": "首任公司管理员", | |
| 272 | + "enterAsAdmin": "进入该公司", | |
| 273 | + "backToPlatform": "返回平台管理", | |
| 274 | + "deleteCompanyConfirm": "确认删除该公司(租户)吗?此操作仅演示,联调后走后端接口。" | |
| 275 | + }, | |
| 276 | + "saas": { | |
| 277 | + "menu": { | |
| 278 | + "dashboard": "概览", | |
| 279 | + "analytics": "首页概览", | |
| 280 | + "labeling": "标签管理", | |
| 281 | + "labels": "标签", | |
| 282 | + "labelCategories": "标签分类", | |
| 283 | + "labelTypes": "标签类型", | |
| 284 | + "labelTemplates": "标签模板", | |
| 285 | + "multipleOptions": "多选选项集", | |
| 286 | + "modules": "业务模块", | |
| 287 | + "training": "培训", | |
| 288 | + "alerts": "告警", | |
| 289 | + "tasks": "任务", | |
| 290 | + "foodWaste": "食物浪费", | |
| 291 | + "eLabel": "电子标签", | |
| 292 | + "management": "管理", | |
| 293 | + "accountManagement": "账户管理", | |
| 294 | + "menuManagement": "菜单管理", | |
| 295 | + "devices": "设备", | |
| 296 | + "reports": "报表", | |
| 297 | + "invoices": "发票", | |
| 298 | + "qrCodes": "二维码", | |
| 299 | + "support": "支持", | |
| 300 | + "api": "API 设置" | |
| 301 | + } | |
| 302 | + }, | |
| 247 | 303 | "management": { |
| 248 | 304 | "section": "管理", |
| 249 | 305 | "locationManager": "门店管理", |
| ... | ... | @@ -293,7 +349,13 @@ |
| 293 | 349 | "permEditSettings": "编辑设置", |
| 294 | 350 | "permManageProducts": "管理产品", |
| 295 | 351 | "permViewReports": "查看报表", |
| 296 | - "permApproveBatches": "审批批次" | |
| 352 | + "permApproveBatches": "审批批次", | |
| 353 | + "menuPermissions": "菜单权限", | |
| 354 | + "useCustomMenus": "自定义菜单", | |
| 355 | + "userMenuOverride": "用户菜单", | |
| 356 | + "inheritRoleMenus": "继承角色", | |
| 357 | + "tenantAdminBanner": "当前以「{company}」公司管理员身份配置:可管理角色、业务公司、区域、门店与成员;菜单权限不能超过公司已开通范围。", | |
| 358 | + "platformOrTenantHint": "租户内组织架构:角色 → 业务公司(Partner) → 区域 → 门店 → 团队成员。平台开通 SAAS 公司请前往「平台管理 > SAAS 公司」。" | |
| 297 | 359 | }, |
| 298 | 360 | "menuManagement": { |
| 299 | 361 | "tabProducts": "产品", | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/router/routes/local.ts
| ... | ... | @@ -215,6 +215,28 @@ export const localMenuList: RouteRecordStringComponent[] = [ |
| 215 | 215 | }, |
| 216 | 216 | { |
| 217 | 217 | meta: { |
| 218 | + icon: 'lucide:cloud-cog', | |
| 219 | + order: 5, | |
| 220 | + title: 'foodLabeling.platform.section', | |
| 221 | + }, | |
| 222 | + name: 'FoodLabelingPlatform', | |
| 223 | + path: '/platform', | |
| 224 | + redirect: '/platform/tenants', | |
| 225 | + children: [ | |
| 226 | + { | |
| 227 | + name: 'FoodLabelingPlatformTenants', | |
| 228 | + path: '/platform/tenants', | |
| 229 | + component: '/food-labeling/platform/tenants/index', | |
| 230 | + meta: { | |
| 231 | + icon: 'lucide:building', | |
| 232 | + title: 'foodLabeling.platform.tenants', | |
| 233 | + ...managementRouteMeta, | |
| 234 | + }, | |
| 235 | + }, | |
| 236 | + ], | |
| 237 | + }, | |
| 238 | + { | |
| 239 | + meta: { | |
| 218 | 240 | icon: 'lucide:building-2', |
| 219 | 241 | order: 20, |
| 220 | 242 | title: 'foodLabeling.management.section', |
| ... | ... | @@ -224,15 +246,6 @@ export const localMenuList: RouteRecordStringComponent[] = [ |
| 224 | 246 | redirect: '/account-management', |
| 225 | 247 | children: [ |
| 226 | 248 | { |
| 227 | - name: 'FoodLabelingLocationManager', | |
| 228 | - path: '/location-manager', | |
| 229 | - redirect: { path: '/account-management', query: { tab: 'locations' } }, | |
| 230 | - meta: { | |
| 231 | - icon: 'lucide:map-pin', | |
| 232 | - title: 'foodLabeling.management.locationManager', | |
| 233 | - }, | |
| 234 | - }, | |
| 235 | - { | |
| 236 | 249 | name: 'FoodLabelingAccountManagement', |
| 237 | 250 | path: '/account-management', |
| 238 | 251 | component: '/food-labeling/management/account-management/index', | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/auth.ts
| ... | ... | @@ -138,6 +138,16 @@ export const useAuthStore = defineStore('auth', () => { |
| 138 | 138 | return cached; |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | + // 泰额 SAAS:用户信息来自 th-app-auth/login,不走 Yi 框架 /account | |
| 142 | + if (thTenantStore.tenantId && accessStore.accessToken) { | |
| 143 | + const fallback = buildUserInfoFromLogin( | |
| 144 | + cached?.email || cached?.username || 'user', | |
| 145 | + thTenantStore.tenantName || '', | |
| 146 | + ); | |
| 147 | + userStore.setUserInfo(fallback); | |
| 148 | + return fallback; | |
| 149 | + } | |
| 150 | + | |
| 141 | 151 | try { |
| 142 | 152 | const { getUserInfoApi } = await import('#/api'); |
| 143 | 153 | const backUserInfo = await getUserInfoApi(); | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/store/saas-context.ts
0 → 100644
| 1 | +import { acceptHMRUpdate, defineStore } from 'pinia'; | |
| 2 | + | |
| 3 | +import { MOCK_SAAS_TENANTS } from '#/views/food-labeling/shared/mock-platform-data'; | |
| 4 | + | |
| 5 | +/** 当前登录身份(前端 Mock;联调后由后端 JWT / 用户信息注入) */ | |
| 6 | +export type SaasActorType = 'platform' | 'tenant_admin' | 'tenant_user'; | |
| 7 | + | |
| 8 | +interface SaasContextState { | |
| 9 | + actorType: SaasActorType; | |
| 10 | + tenantId: string | null; | |
| 11 | + tenantName: string | null; | |
| 12 | + /** 当前租户已开通的菜单 key(公司管理员及其下属授权的上限) */ | |
| 13 | + tenantMenuKeys: string[]; | |
| 14 | +} | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * SAAS 权限上下文:平台管理员 vs 公司(租户)管理员 | |
| 18 | + */ | |
| 19 | +export const useSaasContextStore = defineStore('food-saas-context', { | |
| 20 | + actions: { | |
| 21 | + /** 模拟以某公司管理员身份进入系统 */ | |
| 22 | + impersonateTenantAdmin(tenantId: string) { | |
| 23 | + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === tenantId); | |
| 24 | + if (!tenant) { | |
| 25 | + return; | |
| 26 | + } | |
| 27 | + this.actorType = 'tenant_admin'; | |
| 28 | + this.tenantId = tenant.id; | |
| 29 | + this.tenantName = tenant.companyName; | |
| 30 | + this.tenantMenuKeys = [...tenant.menuPermissionKeys]; | |
| 31 | + }, | |
| 32 | + resetToPlatformAdmin() { | |
| 33 | + this.actorType = 'platform'; | |
| 34 | + this.tenantId = null; | |
| 35 | + this.tenantName = null; | |
| 36 | + this.tenantMenuKeys = []; | |
| 37 | + }, | |
| 38 | + setTenantMenuKeys(keys: string[]) { | |
| 39 | + this.tenantMenuKeys = keys; | |
| 40 | + const tenant = MOCK_SAAS_TENANTS.find((t) => t.id === this.tenantId); | |
| 41 | + if (tenant) { | |
| 42 | + tenant.menuPermissionKeys = [...keys]; | |
| 43 | + } | |
| 44 | + }, | |
| 45 | + }, | |
| 46 | + getters: { | |
| 47 | + isPlatformAdmin: (state) => state.actorType === 'platform', | |
| 48 | + isTenantAdmin: (state) => state.actorType === 'tenant_admin', | |
| 49 | + /** 角色/用户配置菜单权限时的可选上限 */ | |
| 50 | + allowedMenuKeys(state): string[] { | |
| 51 | + if (state.actorType === 'platform') { | |
| 52 | + return []; | |
| 53 | + } | |
| 54 | + return state.tenantMenuKeys; | |
| 55 | + }, | |
| 56 | + }, | |
| 57 | + state: (): SaasContextState => ({ | |
| 58 | + actorType: 'platform', | |
| 59 | + tenantId: null, | |
| 60 | + tenantName: null, | |
| 61 | + tenantMenuKeys: [], | |
| 62 | + }), | |
| 63 | +}); | |
| 64 | + | |
| 65 | +const hot = import.meta.hot; | |
| 66 | +if (hot) { | |
| 67 | + hot.accept(acceptHMRUpdate(useSaasContextStore, hot)); | |
| 68 | +} | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/dashboard/index.vue
| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | MOCK_TEMPLATE_PRINT_STATS, |
| 27 | 27 | } from '../shared/mock-management-data'; |
| 28 | 28 | import { FOOD_LABELING_STATIC_ONLY } from '../shared/static-mode'; |
| 29 | +import { managementTabPageContentClass } from '../management/shared/management-grid'; | |
| 29 | 30 | import DashboardCategoryChart from './dashboard-category-chart.vue'; |
| 30 | 31 | import DashboardKpiCard from './dashboard-kpi-card.vue'; |
| 31 | 32 | import DashboardWeeklyChart from './dashboard-weekly-chart.vue'; |
| ... | ... | @@ -308,10 +309,10 @@ function goReports() { |
| 308 | 309 | </script> |
| 309 | 310 | |
| 310 | 311 | <template> |
| 311 | - <Page> | |
| 312 | + <Page :auto-content-height="true" :content-class="managementTabPageContentClass"> | |
| 312 | 313 | <a-alert |
| 313 | 314 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 314 | - class="mb-4" | |
| 315 | + class="mb-4 shrink-0" | |
| 315 | 316 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 316 | 317 | show-icon |
| 317 | 318 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-categories/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -52,14 +53,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 52 | 53 | minWidth: 140, |
| 53 | 54 | slots: { default: 'lastEdited' }, |
| 54 | 55 | }, |
| 55 | - { | |
| 56 | - field: 'action', | |
| 57 | - fixed: 'right', | |
| 58 | - slots: { default: 'action' }, | |
| 59 | - title: $t('foodLabeling.common.action'), | |
| 60 | - width: 140, | |
| 61 | - resizable: false, | |
| 62 | - }, | |
| 56 | + foodLabelingActionColumn(), | |
| 63 | 57 | ]; |
| 64 | 58 | |
| 65 | 59 | export function modalSchemaBasic(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-categories/index.vue
| ... | ... | @@ -14,8 +14,11 @@ import { Alert, message, Popconfirm, Space, Tag } from 'ant-design-vue'; |
| 14 | 14 | |
| 15 | 15 | import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 16 | 16 | |
| 17 | +import { buildManagementTabFormOptions } from '../../management/shared/management-grid'; | |
| 17 | 18 | import { |
| 18 | 19 | labelingGridBase, |
| 20 | + labelingPageContentClass, | |
| 21 | + labelingTableClass, | |
| 19 | 22 | labelingVxeGridClass, |
| 20 | 23 | } from '../../shared/labeling-grid'; |
| 21 | 24 | import { MOCK_LABEL_CATEGORIES } from '../../shared/mock-data'; |
| ... | ... | @@ -33,14 +36,7 @@ function displayText(v: string | null | undefined) { |
| 33 | 36 | return s || $t('foodLabeling.common.none'); |
| 34 | 37 | } |
| 35 | 38 | |
| 36 | -const formOptions: VbenFormProps = { | |
| 37 | - commonConfig: { | |
| 38 | - labelWidth: 90, | |
| 39 | - componentProps: { allowClear: true }, | |
| 40 | - }, | |
| 41 | - schema: querySchema(), | |
| 42 | - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', | |
| 43 | -}; | |
| 39 | +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema()); | |
| 44 | 40 | |
| 45 | 41 | const gridOptions: VxeGridProps = { |
| 46 | 42 | ...labelingGridBase, |
| ... | ... | @@ -133,15 +129,15 @@ async function handleDelete(row: Recordable<LabelCategoryDto>) { |
| 133 | 129 | </script> |
| 134 | 130 | |
| 135 | 131 | <template> |
| 136 | - <Page> | |
| 132 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 137 | 133 | <Alert |
| 138 | 134 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 139 | - class="mb-3" | |
| 135 | + class="mb-3 shrink-0" | |
| 140 | 136 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 141 | 137 | show-icon |
| 142 | 138 | type="info" |
| 143 | 139 | /> |
| 144 | - <BasicTable> | |
| 140 | + <BasicTable :class="labelingTableClass"> | |
| 145 | 141 | <template #toolbar-tools> |
| 146 | 142 | <Space> |
| 147 | 143 | <a-button type="primary" @click="handleAdd"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterField, |
| 8 | 9 | stateFilterField, |
| ... | ... | @@ -83,14 +84,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 83 | 84 | minWidth: 120, |
| 84 | 85 | slots: { default: 'sizeText' }, |
| 85 | 86 | }, |
| 86 | - { | |
| 87 | - field: 'action', | |
| 88 | - fixed: 'right', | |
| 89 | - slots: { default: 'action' }, | |
| 90 | - title: $t('foodLabeling.common.action'), | |
| 91 | - width: 140, | |
| 92 | - resizable: false, | |
| 93 | - }, | |
| 87 | + foodLabelingActionColumn(), | |
| 94 | 88 | ]; |
| 95 | 89 | |
| 96 | 90 | export function modalSchema(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/index.vue
| ... | ... | @@ -20,6 +20,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 20 | 20 | |
| 21 | 21 | import { |
| 22 | 22 | labelingGridBase, |
| 23 | + labelingPageContentClass, | |
| 24 | + labelingTableClass, | |
| 23 | 25 | labelingVxeGridClass, |
| 24 | 26 | } from '../../shared/labeling-grid'; |
| 25 | 27 | import { MOCK_LABEL_TEMPLATES } from '../../shared/mock-data'; |
| ... | ... | @@ -76,6 +78,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 76 | 78 | locationOptions: lookups.locationOptions.value, |
| 77 | 79 | })(), |
| 78 | 80 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', |
| 81 | + collapseTriggerResize: false, | |
| 79 | 82 | })); |
| 80 | 83 | |
| 81 | 84 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -158,15 +161,15 @@ async function handleDelete(row: Recordable<LabelTemplateDto>) { |
| 158 | 161 | </script> |
| 159 | 162 | |
| 160 | 163 | <template> |
| 161 | - <Page> | |
| 164 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 162 | 165 | <a-alert |
| 163 | 166 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 164 | - class="mb-3" | |
| 167 | + class="mb-3 shrink-0" | |
| 165 | 168 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 166 | 169 | show-icon |
| 167 | 170 | type="info" |
| 168 | 171 | /> |
| 169 | - <BasicTable> | |
| 172 | + <BasicTable :class="labelingTableClass"> | |
| 170 | 173 | <template #toolbar-tools> |
| 171 | 174 | <Space> |
| 172 | 175 | <a-button type="primary" @click="handleAdd"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-types/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterField, |
| 8 | 9 | stateFilterField, |
| ... | ... | @@ -83,14 +84,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 83 | 84 | minWidth: 140, |
| 84 | 85 | slots: { default: 'lastEdited' }, |
| 85 | 86 | }, |
| 86 | - { | |
| 87 | - field: 'action', | |
| 88 | - fixed: 'right', | |
| 89 | - slots: { default: 'action' }, | |
| 90 | - title: $t('foodLabeling.common.action'), | |
| 91 | - width: 140, | |
| 92 | - resizable: false, | |
| 93 | - }, | |
| 87 | + foodLabelingActionColumn(), | |
| 94 | 88 | ]; |
| 95 | 89 | |
| 96 | 90 | export function modalSchema(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-types/index.vue
| ... | ... | @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | labelingGridBase, |
| 22 | + labelingPageContentClass, | |
| 23 | + labelingTableClass, | |
| 22 | 24 | labelingVxeGridClass, |
| 23 | 25 | } from '../../shared/labeling-grid'; |
| 24 | 26 | import { MOCK_LABEL_TYPES } from '../../shared/mock-data'; |
| ... | ... | @@ -54,6 +56,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 54 | 56 | locationOptions: lookups.locationOptions.value, |
| 55 | 57 | })(), |
| 56 | 58 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', |
| 59 | + collapseTriggerResize: false, | |
| 57 | 60 | })); |
| 58 | 61 | |
| 59 | 62 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -132,15 +135,15 @@ async function handleDelete(row: Recordable<LabelTypeDto>) { |
| 132 | 135 | </script> |
| 133 | 136 | |
| 134 | 137 | <template> |
| 135 | - <Page> | |
| 138 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 136 | 139 | <a-alert |
| 137 | 140 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 138 | - class="mb-3" | |
| 141 | + class="mb-3 shrink-0" | |
| 139 | 142 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 140 | 143 | show-icon |
| 141 | 144 | type="info" |
| 142 | 145 | /> |
| 143 | - <BasicTable> | |
| 146 | + <BasicTable :class="labelingTableClass"> | |
| 144 | 147 | <template #toolbar-tools> |
| 145 | 148 | <Space> |
| 146 | 149 | <a-button type="primary" @click="handleAdd"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/labels/data.ts
| ... | ... | @@ -3,6 +3,8 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 7 | + | |
| 6 | 8 | import type { SelectOption } from './use-label-lookups'; |
| 7 | 9 | |
| 8 | 10 | export function querySchema(options: { |
| ... | ... | @@ -125,14 +127,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 125 | 127 | minWidth: 150, |
| 126 | 128 | slots: { default: 'lastEdited' }, |
| 127 | 129 | }, |
| 128 | - { | |
| 129 | - field: 'action', | |
| 130 | - fixed: 'right', | |
| 131 | - slots: { default: 'action' }, | |
| 132 | - title: $t('foodLabeling.labels.action'), | |
| 133 | - width: 140, | |
| 134 | - resizable: false, | |
| 135 | - }, | |
| 130 | + foodLabelingActionColumn({ title: $t('foodLabeling.labels.action') }), | |
| 136 | 131 | ]; |
| 137 | 132 | |
| 138 | 133 | export function modalSchema(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/labels/index.vue
| ... | ... | @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | labelingGridBase, |
| 22 | + labelingPageContentClass, | |
| 23 | + labelingTableClass, | |
| 22 | 24 | labelingVxeGridClass, |
| 23 | 25 | } from '../../shared/labeling-grid'; |
| 24 | 26 | import { MOCK_LABELS } from '../../shared/mock-data'; |
| ... | ... | @@ -50,6 +52,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 50 | 52 | templateOptions: lookups.templateOptions.value, |
| 51 | 53 | })(), |
| 52 | 54 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', |
| 55 | + collapseTriggerResize: false, | |
| 53 | 56 | })); |
| 54 | 57 | |
| 55 | 58 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -156,15 +159,15 @@ async function handleDelete(row: Recordable<LabelDto>) { |
| 156 | 159 | </script> |
| 157 | 160 | |
| 158 | 161 | <template> |
| 159 | - <Page> | |
| 162 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 160 | 163 | <a-alert |
| 161 | 164 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 162 | - class="mb-3" | |
| 165 | + class="mb-3 shrink-0" | |
| 163 | 166 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 164 | 167 | show-icon |
| 165 | 168 | type="info" |
| 166 | 169 | /> |
| 167 | - <BasicTable> | |
| 170 | + <BasicTable :class="labelingTableClass"> | |
| 168 | 171 | <template #toolbar-tools> |
| 169 | 172 | <Space> |
| 170 | 173 | <a-button type="primary" @click="handleAdd"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/multiple-options/data.ts
| ... | ... | @@ -7,6 +7,7 @@ import { $t } from '@vben/locales'; |
| 7 | 7 | |
| 8 | 8 | import { z } from '#/adapter/form'; |
| 9 | 9 | |
| 10 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 10 | 11 | import OptionValuesEditor from './option-values-editor.vue'; |
| 11 | 12 | |
| 12 | 13 | import { |
| ... | ... | @@ -77,14 +78,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 77 | 78 | minWidth: 140, |
| 78 | 79 | slots: { default: 'lastEdited' }, |
| 79 | 80 | }, |
| 80 | - { | |
| 81 | - field: 'action', | |
| 82 | - fixed: 'right', | |
| 83 | - slots: { default: 'action' }, | |
| 84 | - title: $t('foodLabeling.common.action'), | |
| 85 | - width: 140, | |
| 86 | - resizable: false, | |
| 87 | - }, | |
| 81 | + foodLabelingActionColumn(), | |
| 88 | 82 | ]; |
| 89 | 83 | |
| 90 | 84 | export function modalSchemaBasic(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/multiple-options/index.vue
| ... | ... | @@ -19,6 +19,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 19 | 19 | |
| 20 | 20 | import { |
| 21 | 21 | labelingGridBase, |
| 22 | + labelingPageContentClass, | |
| 23 | + labelingTableClass, | |
| 22 | 24 | labelingVxeGridClass, |
| 23 | 25 | } from '../../shared/labeling-grid'; |
| 24 | 26 | import { MOCK_MULTIPLE_OPTIONS } from '../../shared/mock-data'; |
| ... | ... | @@ -62,6 +64,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 62 | 64 | locationOptions: lookups.locationOptions.value, |
| 63 | 65 | })(), |
| 64 | 66 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', |
| 67 | + collapseTriggerResize: false, | |
| 65 | 68 | })); |
| 66 | 69 | |
| 67 | 70 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -144,15 +147,15 @@ async function handleDelete(row: Recordable<LabelMultipleOptionDto>) { |
| 144 | 147 | </script> |
| 145 | 148 | |
| 146 | 149 | <template> |
| 147 | - <Page> | |
| 150 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 148 | 151 | <a-alert |
| 149 | 152 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 150 | - class="mb-3" | |
| 153 | + class="mb-3 shrink-0" | |
| 151 | 154 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 152 | 155 | show-icon |
| 153 | 156 | type="info" |
| 154 | 157 | /> |
| 155 | - <BasicTable> | |
| 158 | + <BasicTable :class="labelingTableClass"> | |
| 156 | 159 | <template #toolbar-tools> |
| 157 | 160 | <Space> |
| 158 | 161 | <a-button type="primary" @click="handleAdd"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/groups/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -54,14 +55,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 54 | 55 | minWidth: 140, |
| 55 | 56 | slots: { default: 'creationTime' }, |
| 56 | 57 | }, |
| 57 | - { | |
| 58 | - field: 'action', | |
| 59 | - fixed: 'right', | |
| 60 | - slots: { default: 'action' }, | |
| 61 | - title: $t('foodLabeling.common.action'), | |
| 62 | - width: 140, | |
| 63 | - resizable: false, | |
| 64 | - }, | |
| 58 | + foodLabelingActionColumn(), | |
| 65 | 59 | ]; |
| 66 | 60 | |
| 67 | 61 | export function modalSchema(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/groups/index.vue
| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | import { useAccountLookups } from '../../../shared/use-account-lookups'; |
| 27 | 27 | import { |
| 28 | 28 | managementTabGridBase, |
| 29 | + managementTabTableClass, | |
| 29 | 30 | managementTabVxeGridClass, |
| 30 | 31 | } from '../../shared/management-grid'; |
| 31 | 32 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -51,6 +52,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 51 | 52 | partnerOptions: accountLookups.partnerOptions.value, |
| 52 | 53 | })(), |
| 53 | 54 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', |
| 55 | + collapseTriggerResize: false, | |
| 54 | 56 | })); |
| 55 | 57 | |
| 56 | 58 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -132,7 +134,8 @@ async function handleDelete(row: Recordable<GroupListItemDto>) { |
| 132 | 134 | </script> |
| 133 | 135 | |
| 134 | 136 | <template> |
| 135 | - <BasicTable> | |
| 137 | + <div class="flex h-full min-h-0 flex-col"> | |
| 138 | + <BasicTable :class="managementTabTableClass"> | |
| 136 | 139 | <template #toolbar-tools> |
| 137 | 140 | <Space> |
| 138 | 141 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -173,6 +176,7 @@ async function handleDelete(row: Recordable<GroupListItemDto>) { |
| 173 | 176 | </Popconfirm> |
| 174 | 177 | </Space> |
| 175 | 178 | </template> |
| 176 | - </BasicTable> | |
| 177 | - <GroupModalHost @reload="tableApi.query()" /> | |
| 179 | + </BasicTable> | |
| 180 | + <GroupModalHost @reload="tableApi.query()" /> | |
| 181 | + </div> | |
| 178 | 182 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/index.vue
| ... | ... | @@ -2,12 +2,17 @@ |
| 2 | 2 | import type { Component } from 'vue'; |
| 3 | 3 | |
| 4 | 4 | import { computed, onMounted, ref, watch } from 'vue'; |
| 5 | -import { useRoute } from 'vue-router'; | |
| 5 | +import { useRoute, useRouter } from 'vue-router'; | |
| 6 | 6 | |
| 7 | 7 | import { Page } from '@vben/common-ui'; |
| 8 | 8 | import { $t } from '@vben/locales'; |
| 9 | 9 | |
| 10 | +import { Alert, Button, Space } from 'ant-design-vue'; | |
| 11 | + | |
| 12 | +import { useSaasContextStore } from '#/store/saas-context'; | |
| 13 | + | |
| 10 | 14 | import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; |
| 15 | +import { managementTabPageContentClass } from '../shared/management-grid'; | |
| 11 | 16 | import ManagementSubTabs from '../shared/management-sub-tabs.vue'; |
| 12 | 17 | import ManagementTabBody from '../shared/management-tab-body.vue'; |
| 13 | 18 | import GroupsTab from './groups/index.vue'; |
| ... | ... | @@ -17,6 +22,8 @@ import RolesTab from './roles/index.vue'; |
| 17 | 22 | import TeamMembersTab from './team-members/index.vue'; |
| 18 | 23 | |
| 19 | 24 | const route = useRoute(); |
| 25 | +const router = useRouter(); | |
| 26 | +const saasStore = useSaasContextStore(); | |
| 20 | 27 | |
| 21 | 28 | const ACCOUNT_TABS = [ |
| 22 | 29 | 'roles', |
| ... | ... | @@ -47,6 +54,13 @@ watch( |
| 47 | 54 | }, |
| 48 | 55 | ); |
| 49 | 56 | |
| 57 | +watch(activeTab, (tab) => { | |
| 58 | + if (String(route.query.tab ?? '') === tab) { | |
| 59 | + return; | |
| 60 | + } | |
| 61 | + router.replace({ path: route.path, query: { ...route.query, tab } }); | |
| 62 | +}); | |
| 63 | + | |
| 50 | 64 | const tabItems = computed(() => [ |
| 51 | 65 | { key: 'roles', label: $t('foodLabeling.accountManagement.tabRoles') }, |
| 52 | 66 | { key: 'partners', label: $t('foodLabeling.accountManagement.tabCompany') }, |
| ... | ... | @@ -62,21 +76,54 @@ const tabPanels: Record<string, Component> = { |
| 62 | 76 | locations: LocationsTab, |
| 63 | 77 | teamMembers: TeamMembersTab, |
| 64 | 78 | }; |
| 79 | + | |
| 80 | +function backToPlatform() { | |
| 81 | + saasStore.resetToPlatformAdmin(); | |
| 82 | + router.push('/platform/tenants'); | |
| 83 | +} | |
| 65 | 84 | </script> |
| 66 | 85 | |
| 67 | 86 | <template> |
| 68 | - <Page> | |
| 87 | + <Page :auto-content-height="true" :content-class="managementTabPageContentClass"> | |
| 88 | + <Alert | |
| 89 | + v-if="saasStore.isTenantAdmin" | |
| 90 | + class="mb-3 shrink-0" | |
| 91 | + :message=" | |
| 92 | + $t('foodLabeling.accountManagement.tenantAdminBanner', { | |
| 93 | + company: saasStore.tenantName ?? '', | |
| 94 | + }) | |
| 95 | + " | |
| 96 | + show-icon | |
| 97 | + type="info" | |
| 98 | + > | |
| 99 | + <template #action> | |
| 100 | + <Space> | |
| 101 | + <Button size="small" @click="backToPlatform"> | |
| 102 | + {{ $t('foodLabeling.platform.backToPlatform') }} | |
| 103 | + </Button> | |
| 104 | + </Space> | |
| 105 | + </template> | |
| 106 | + </Alert> | |
| 107 | + | |
| 108 | + <Alert | |
| 109 | + v-else | |
| 110 | + class="mb-3 shrink-0" | |
| 111 | + :message="$t('foodLabeling.accountManagement.platformOrTenantHint')" | |
| 112 | + show-icon | |
| 113 | + type="info" | |
| 114 | + /> | |
| 115 | + | |
| 69 | 116 | <a-alert |
| 70 | 117 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 71 | - class="mb-3" | |
| 118 | + class="mb-3 shrink-0" | |
| 72 | 119 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 73 | 120 | show-icon |
| 74 | - type="info" | |
| 121 | + type="warning" | |
| 75 | 122 | /> |
| 76 | 123 | |
| 77 | - <ManagementSubTabs v-model="activeTab" :tabs="tabItems" /> | |
| 124 | + <ManagementSubTabs v-model="activeTab" :tabs="tabItems" class="shrink-0" /> | |
| 78 | 125 | |
| 79 | - <div class="mt-3"> | |
| 126 | + <div class="mt-3 min-h-0 flex-1 overflow-hidden"> | |
| 80 | 127 | <ManagementTabBody :active-key="activeTab" :panels="tabPanels" /> |
| 81 | 128 | </div> |
| 82 | 129 | </Page> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/locations/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -82,14 +83,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 82 | 83 | width: 90, |
| 83 | 84 | slots: { default: 'state' }, |
| 84 | 85 | }, |
| 85 | - { | |
| 86 | - field: 'action', | |
| 87 | - fixed: 'right', | |
| 88 | - slots: { default: 'action' }, | |
| 89 | - title: $t('foodLabeling.common.action'), | |
| 90 | - width: 140, | |
| 91 | - resizable: false, | |
| 92 | - }, | |
| 86 | + foodLabelingActionColumn(), | |
| 93 | 87 | ]; |
| 94 | 88 | |
| 95 | 89 | export function modalSchema(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/locations/index.vue
| ... | ... | @@ -30,6 +30,7 @@ import { |
| 30 | 30 | import type { SelectOption } from '../../../shared/use-account-lookups'; |
| 31 | 31 | import { |
| 32 | 32 | managementTabGridBase, |
| 33 | + managementTabTableClass, | |
| 33 | 34 | managementTabVxeGridClass, |
| 34 | 35 | } from '../../shared/management-grid'; |
| 35 | 36 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -81,6 +82,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 81 | 82 | groupNameOptions: groupNameOptions.value, |
| 82 | 83 | })(), |
| 83 | 84 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5', |
| 85 | + collapseTriggerResize: false, | |
| 84 | 86 | })); |
| 85 | 87 | |
| 86 | 88 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -170,7 +172,8 @@ async function handleDelete(row: Recordable<AccountLocationDto>) { |
| 170 | 172 | </script> |
| 171 | 173 | |
| 172 | 174 | <template> |
| 173 | - <BasicTable> | |
| 175 | + <div class="flex h-full min-h-0 flex-col"> | |
| 176 | + <BasicTable :class="managementTabTableClass"> | |
| 174 | 177 | <template #toolbar-tools> |
| 175 | 178 | <Space> |
| 176 | 179 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -217,6 +220,7 @@ async function handleDelete(row: Recordable<AccountLocationDto>) { |
| 217 | 220 | </Popconfirm> |
| 218 | 221 | </Space> |
| 219 | 222 | </template> |
| 220 | - </BasicTable> | |
| 221 | - <LocationModalHost @reload="tableApi.query()" /> | |
| 223 | + </BasicTable> | |
| 224 | + <LocationModalHost @reload="tableApi.query()" /> | |
| 225 | + </div> | |
| 222 | 226 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/partners/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -33,6 +34,12 @@ export const columns: VxeGridProps['columns'] = [ |
| 33 | 34 | slots: { default: 'phoneNumber' }, |
| 34 | 35 | }, |
| 35 | 36 | { |
| 37 | + field: 'street', | |
| 38 | + title: $t('foodLabeling.accountManagement.street'), | |
| 39 | + minWidth: 140, | |
| 40 | + slots: { default: 'street' }, | |
| 41 | + }, | |
| 42 | + { | |
| 36 | 43 | field: 'city', |
| 37 | 44 | title: $t('foodLabeling.accountManagement.city'), |
| 38 | 45 | minWidth: 100, |
| ... | ... | @@ -45,19 +52,24 @@ export const columns: VxeGridProps['columns'] = [ |
| 45 | 52 | slots: { default: 'stateCode' }, |
| 46 | 53 | }, |
| 47 | 54 | { |
| 55 | + field: 'country', | |
| 56 | + title: $t('foodLabeling.accountManagement.country'), | |
| 57 | + minWidth: 90, | |
| 58 | + slots: { default: 'country' }, | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + field: 'zipCode', | |
| 62 | + title: $t('foodLabeling.accountManagement.zipCode'), | |
| 63 | + minWidth: 90, | |
| 64 | + slots: { default: 'zipCode' }, | |
| 65 | + }, | |
| 66 | + { | |
| 48 | 67 | field: 'state', |
| 49 | 68 | title: $t('foodLabeling.common.status'), |
| 50 | 69 | width: 90, |
| 51 | 70 | slots: { default: 'state' }, |
| 52 | 71 | }, |
| 53 | - { | |
| 54 | - field: 'action', | |
| 55 | - fixed: 'right', | |
| 56 | - slots: { default: 'action' }, | |
| 57 | - title: $t('foodLabeling.common.action'), | |
| 58 | - width: 140, | |
| 59 | - resizable: false, | |
| 60 | - }, | |
| 72 | + foodLabelingActionColumn(), | |
| 61 | 73 | ]; |
| 62 | 74 | |
| 63 | 75 | export const modalSchema: FormSchemaGetter = () => [ | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/partners/index.vue
| ... | ... | @@ -22,7 +22,9 @@ import { |
| 22 | 22 | slicePage, |
| 23 | 23 | } from '../../../shared/static-mode'; |
| 24 | 24 | import { |
| 25 | + buildManagementTabFormOptions, | |
| 25 | 26 | managementTabGridBase, |
| 27 | + managementTabTableClass, | |
| 26 | 28 | managementTabVxeGridClass, |
| 27 | 29 | } from '../../shared/management-grid'; |
| 28 | 30 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -33,14 +35,7 @@ function displayText(v: string | null | undefined) { |
| 33 | 35 | return s || $t('foodLabeling.common.none'); |
| 34 | 36 | } |
| 35 | 37 | |
| 36 | -const formOptions: VbenFormProps = { | |
| 37 | - commonConfig: { | |
| 38 | - labelWidth: 90, | |
| 39 | - componentProps: { allowClear: true }, | |
| 40 | - }, | |
| 41 | - schema: querySchema(), | |
| 42 | - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', | |
| 43 | -}; | |
| 38 | +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema()); | |
| 44 | 39 | |
| 45 | 40 | const gridOptions: VxeGridProps = { |
| 46 | 41 | ...managementTabGridBase, |
| ... | ... | @@ -59,7 +54,11 @@ const gridOptions: VxeGridProps = { |
| 59 | 54 | 'partnerName', |
| 60 | 55 | 'contactEmail', |
| 61 | 56 | 'phoneNumber', |
| 57 | + 'street', | |
| 62 | 58 | 'city', |
| 59 | + 'stateCode', | |
| 60 | + 'country', | |
| 61 | + 'zipCode', | |
| 63 | 62 | ]), |
| 64 | 63 | ); |
| 65 | 64 | } |
| ... | ... | @@ -120,7 +119,8 @@ async function handleDelete(row: Recordable<PartnerDto>) { |
| 120 | 119 | </script> |
| 121 | 120 | |
| 122 | 121 | <template> |
| 123 | - <BasicTable> | |
| 122 | + <div class="flex h-full min-h-0 flex-col"> | |
| 123 | + <BasicTable :class="managementTabTableClass"> | |
| 124 | 124 | <template #toolbar-tools> |
| 125 | 125 | <Space> |
| 126 | 126 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -135,12 +135,21 @@ async function handleDelete(row: Recordable<PartnerDto>) { |
| 135 | 135 | <template #phoneNumber="{ row }"> |
| 136 | 136 | {{ displayText(row.phoneNumber) }} |
| 137 | 137 | </template> |
| 138 | + <template #street="{ row }"> | |
| 139 | + <span class="truncate">{{ displayText(row.street) }}</span> | |
| 140 | + </template> | |
| 138 | 141 | <template #city="{ row }"> |
| 139 | 142 | {{ displayText(row.city) }} |
| 140 | 143 | </template> |
| 141 | 144 | <template #stateCode="{ row }"> |
| 142 | 145 | {{ displayText(row.stateCode) }} |
| 143 | 146 | </template> |
| 147 | + <template #country="{ row }"> | |
| 148 | + {{ displayText(row.country) }} | |
| 149 | + </template> | |
| 150 | + <template #zipCode="{ row }"> | |
| 151 | + {{ displayText(row.zipCode) }} | |
| 152 | + </template> | |
| 144 | 153 | <template #state="{ row }"> |
| 145 | 154 | <Tag :color="row.state !== false ? 'success' : 'default'"> |
| 146 | 155 | {{ |
| ... | ... | @@ -167,6 +176,7 @@ async function handleDelete(row: Recordable<PartnerDto>) { |
| 167 | 176 | </Popconfirm> |
| 168 | 177 | </Space> |
| 169 | 178 | </template> |
| 170 | - </BasicTable> | |
| 171 | - <PartnerModalHost @reload="tableApi.query()" /> | |
| 179 | + </BasicTable> | |
| 180 | + <PartnerModalHost @reload="tableApi.query()" /> | |
| 181 | + </div> | |
| 172 | 182 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/data.ts
| 1 | 1 | import type { FormSchemaGetter } from '#/adapter/form'; |
| 2 | 2 | import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | +import { markRaw } from 'vue'; | |
| 5 | + | |
| 4 | 6 | import { $t } from '@vben/locales'; |
| 5 | 7 | |
| 8 | +import MenuPermissionTreeField from '../../../shared/menu-permission-tree-field.vue'; | |
| 6 | 9 | import { |
| 7 | 10 | keywordFilterSchema, |
| 8 | 11 | stateFilterSchema, |
| 9 | 12 | stateModalSchema, |
| 10 | 13 | } from '../../../shared/form-schema'; |
| 14 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 11 | 15 | import { accessPermissionCheckboxOptions } from '../../../shared/use-account-lookups'; |
| 12 | 16 | |
| 13 | 17 | export const querySchema: FormSchemaGetter = () => [ |
| ... | ... | @@ -33,6 +37,12 @@ export const columns: VxeGridProps['columns'] = [ |
| 33 | 37 | slots: { default: 'remark' }, |
| 34 | 38 | }, |
| 35 | 39 | { |
| 40 | + field: 'menuPermissionKeys', | |
| 41 | + title: $t('foodLabeling.accountManagement.menuPermissions'), | |
| 42 | + minWidth: 120, | |
| 43 | + slots: { default: 'menuPermissions' }, | |
| 44 | + }, | |
| 45 | + { | |
| 36 | 46 | field: 'accessPermissionCodes', |
| 37 | 47 | title: $t('foodLabeling.accountManagement.accessPermissions'), |
| 38 | 48 | minWidth: 200, |
| ... | ... | @@ -50,17 +60,13 @@ export const columns: VxeGridProps['columns'] = [ |
| 50 | 60 | width: 90, |
| 51 | 61 | slots: { default: 'state' }, |
| 52 | 62 | }, |
| 53 | - { | |
| 54 | - field: 'action', | |
| 55 | - fixed: 'right', | |
| 56 | - slots: { default: 'action' }, | |
| 57 | - title: $t('foodLabeling.common.action'), | |
| 58 | - width: 140, | |
| 59 | - resizable: false, | |
| 60 | - }, | |
| 63 | + foodLabelingActionColumn(), | |
| 61 | 64 | ]; |
| 62 | 65 | |
| 63 | -export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter { | |
| 66 | +export function modalSchema(options: { | |
| 67 | + isUpdate: boolean; | |
| 68 | + allowedMenuKeys?: string[] | null; | |
| 69 | +}): FormSchemaGetter { | |
| 64 | 70 | return () => [ |
| 65 | 71 | { |
| 66 | 72 | component: 'Input', |
| ... | ... | @@ -88,6 +94,18 @@ export function modalSchema(options: { isUpdate: boolean }): FormSchemaGetter { |
| 88 | 94 | componentProps: { min: 0, class: 'w-full' }, |
| 89 | 95 | }, |
| 90 | 96 | { |
| 97 | + component: markRaw(MenuPermissionTreeField), | |
| 98 | + fieldName: 'menuPermissionKeys', | |
| 99 | + label: $t('foodLabeling.accountManagement.menuPermissions'), | |
| 100 | + formItemClass: 'col-span-2', | |
| 101 | + defaultValue: [], | |
| 102 | + componentProps: { | |
| 103 | + allowedKeys: options.allowedMenuKeys?.length | |
| 104 | + ? options.allowedMenuKeys | |
| 105 | + : null, | |
| 106 | + }, | |
| 107 | + }, | |
| 108 | + { | |
| 91 | 109 | component: 'CheckboxGroup', |
| 92 | 110 | fieldName: 'accessPermissionCodes', |
| 93 | 111 | label: $t('foodLabeling.accountManagement.accessPermissions'), | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/index.vue
| ... | ... | @@ -23,7 +23,9 @@ import { |
| 23 | 23 | } from '../../../shared/static-mode'; |
| 24 | 24 | import { displayAccessPermissionLabel } from '../../../shared/use-account-lookups'; |
| 25 | 25 | import { |
| 26 | + buildManagementTabFormOptions, | |
| 26 | 27 | managementTabGridBase, |
| 28 | + managementTabTableClass, | |
| 27 | 29 | managementTabVxeGridClass, |
| 28 | 30 | } from '../../shared/management-grid'; |
| 29 | 31 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -34,14 +36,7 @@ function displayText(v: string | null | undefined) { |
| 34 | 36 | return s || $t('foodLabeling.common.none'); |
| 35 | 37 | } |
| 36 | 38 | |
| 37 | -const formOptions: VbenFormProps = { | |
| 38 | - commonConfig: { | |
| 39 | - labelWidth: 90, | |
| 40 | - componentProps: { allowClear: true }, | |
| 41 | - }, | |
| 42 | - schema: querySchema(), | |
| 43 | - wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', | |
| 44 | -}; | |
| 39 | +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema()); | |
| 45 | 40 | |
| 46 | 41 | const gridOptions: VxeGridProps = { |
| 47 | 42 | ...managementTabGridBase, |
| ... | ... | @@ -128,7 +123,8 @@ function permissionsText(row: RoleDto) { |
| 128 | 123 | </script> |
| 129 | 124 | |
| 130 | 125 | <template> |
| 131 | - <BasicTable> | |
| 126 | + <div class="flex h-full min-h-0 flex-col"> | |
| 127 | + <BasicTable :class="managementTabTableClass"> | |
| 132 | 128 | <template #toolbar-tools> |
| 133 | 129 | <Space> |
| 134 | 130 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -140,6 +136,11 @@ function permissionsText(row: RoleDto) { |
| 140 | 136 | <template #remark="{ row }"> |
| 141 | 137 | <span class="truncate">{{ displayText(row.remark) }}</span> |
| 142 | 138 | </template> |
| 139 | + <template #menuPermissions="{ row }"> | |
| 140 | + <Tag color="processing"> | |
| 141 | + {{ (row.menuPermissionKeys ?? []).length }} | |
| 142 | + </Tag> | |
| 143 | + </template> | |
| 143 | 144 | <template #accessPermissions="{ row }"> |
| 144 | 145 | <span class="truncate">{{ permissionsText(row) }}</span> |
| 145 | 146 | </template> |
| ... | ... | @@ -172,6 +173,7 @@ function permissionsText(row: RoleDto) { |
| 172 | 173 | </Popconfirm> |
| 173 | 174 | </Space> |
| 174 | 175 | </template> |
| 175 | - </BasicTable> | |
| 176 | - <RoleModalHost @reload="tableApi.query()" /> | |
| 176 | + </BasicTable> | |
| 177 | + <RoleModalHost @reload="tableApi.query()" /> | |
| 178 | + </div> | |
| 177 | 179 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/roles/role-modal.vue
| ... | ... | @@ -10,11 +10,14 @@ import { cloneDeep } from '@vben/utils'; |
| 10 | 10 | import { message } from 'ant-design-vue'; |
| 11 | 11 | |
| 12 | 12 | import { useVbenForm } from '#/adapter/form'; |
| 13 | +import { useSaasContextStore } from '#/store/saas-context'; | |
| 13 | 14 | |
| 14 | 15 | import { MOCK_ROLES } from '../../../shared/mock-management-data'; |
| 15 | 16 | import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode'; |
| 16 | 17 | import { modalSchema } from './data'; |
| 17 | 18 | |
| 19 | +const saasStore = useSaasContextStore(); | |
| 20 | + | |
| 18 | 21 | const emit = defineEmits<{ reload: [] }>(); |
| 19 | 22 | |
| 20 | 23 | const isUpdate = ref(false); |
| ... | ... | @@ -30,13 +33,16 @@ const [BasicForm, formApi] = useVbenForm({ |
| 30 | 33 | labelWidth: 110, |
| 31 | 34 | componentProps: { class: 'w-full' }, |
| 32 | 35 | }, |
| 33 | - schema: modalSchema({ isUpdate: false })(), | |
| 36 | + schema: modalSchema({ | |
| 37 | + isUpdate: false, | |
| 38 | + allowedMenuKeys: saasStore.allowedMenuKeys, | |
| 39 | + })(), | |
| 34 | 40 | showDefaultActions: false, |
| 35 | 41 | wrapperClass: 'grid-cols-2', |
| 36 | 42 | }); |
| 37 | 43 | |
| 38 | 44 | const [BasicModal, modalApi] = useVbenModal({ |
| 39 | - class: 'w-[720px]', | |
| 45 | + class: 'w-[800px]', | |
| 40 | 46 | fullscreenButton: false, |
| 41 | 47 | onCancel: handleCancel, |
| 42 | 48 | onConfirm: handleConfirm, |
| ... | ... | @@ -50,7 +56,10 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 50 | 56 | recordId.value = data?.id ?? ''; |
| 51 | 57 | |
| 52 | 58 | formApi.setState({ |
| 53 | - schema: modalSchema({ isUpdate: isUpdate.value })(), | |
| 59 | + schema: modalSchema({ | |
| 60 | + isUpdate: isUpdate.value, | |
| 61 | + allowedMenuKeys: saasStore.allowedMenuKeys, | |
| 62 | + })(), | |
| 54 | 63 | }); |
| 55 | 64 | |
| 56 | 65 | if (isUpdate.value && recordId.value) { |
| ... | ... | @@ -68,6 +77,7 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 68 | 77 | remark: record.remark ?? '', |
| 69 | 78 | orderNum: record.orderNum ?? undefined, |
| 70 | 79 | accessPermissionCodes: record.accessPermissionCodes ?? [], |
| 80 | + menuPermissionKeys: record.menuPermissionKeys ?? [], | |
| 71 | 81 | state: record.state !== false, |
| 72 | 82 | }); |
| 73 | 83 | } else { |
| ... | ... | @@ -75,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 75 | 85 | await formApi.setValues({ |
| 76 | 86 | state: true, |
| 77 | 87 | accessPermissionCodes: [], |
| 88 | + menuPermissionKeys: [], | |
| 78 | 89 | }); |
| 79 | 90 | } |
| 80 | 91 | modalApi.modalLoading(false); |
| ... | ... | @@ -91,6 +102,7 @@ function formToPayload(values: Record<string, unknown>) { |
| 91 | 102 | ? null |
| 92 | 103 | : Number(values.orderNum), |
| 93 | 104 | accessPermissionCodes: (values.accessPermissionCodes as string[]) ?? [], |
| 105 | + menuPermissionKeys: (values.menuPermissionKeys as string[]) ?? [], | |
| 94 | 106 | state: values.state !== false, |
| 95 | 107 | }; |
| 96 | 108 | } |
| ... | ... | @@ -108,6 +120,18 @@ async function handleConfirm() { |
| 108 | 120 | return; |
| 109 | 121 | } |
| 110 | 122 | if (FOOD_LABELING_STATIC_ONLY) { |
| 123 | + if (isUpdate.value && recordId.value) { | |
| 124 | + const idx = MOCK_ROLES.findIndex((x) => x.id === recordId.value); | |
| 125 | + if (idx >= 0) { | |
| 126 | + MOCK_ROLES[idx] = { ...MOCK_ROLES[idx]!, ...payload }; | |
| 127 | + } | |
| 128 | + } else { | |
| 129 | + MOCK_ROLES.push({ | |
| 130 | + id: `role-${Date.now()}`, | |
| 131 | + creationTime: new Date().toISOString().slice(0, 16).replace('T', ' '), | |
| 132 | + ...payload, | |
| 133 | + }); | |
| 134 | + } | |
| 111 | 135 | message.success($t('foodLabeling.common.staticDemoAction')); |
| 112 | 136 | emit('reload'); |
| 113 | 137 | await handleCancel(); | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/data.ts
| 1 | 1 | import type { FormSchemaGetter } from '#/adapter/form'; |
| 2 | 2 | import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | +import { markRaw } from 'vue'; | |
| 5 | + | |
| 4 | 6 | import { $t } from '@vben/locales'; |
| 5 | 7 | |
| 6 | 8 | import { z } from '#/adapter/form'; |
| 7 | 9 | |
| 10 | +import MenuPermissionTreeField from '../../../shared/menu-permission-tree-field.vue'; | |
| 11 | + | |
| 12 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 8 | 13 | import { |
| 9 | 14 | keywordFilterSchema, |
| 10 | 15 | stateFilterSchema, |
| ... | ... | @@ -62,6 +67,12 @@ export const columns: VxeGridProps['columns'] = [ |
| 62 | 67 | slots: { default: 'roleName' }, |
| 63 | 68 | }, |
| 64 | 69 | { |
| 70 | + field: 'menuPermissionKeys', | |
| 71 | + title: $t('foodLabeling.accountManagement.userMenuOverride'), | |
| 72 | + minWidth: 110, | |
| 73 | + slots: { default: 'userMenus' }, | |
| 74 | + }, | |
| 75 | + { | |
| 65 | 76 | field: 'locations', |
| 66 | 77 | title: $t('foodLabeling.common.locations'), |
| 67 | 78 | minWidth: 180, |
| ... | ... | @@ -73,14 +84,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 73 | 84 | width: 90, |
| 74 | 85 | slots: { default: 'state' }, |
| 75 | 86 | }, |
| 76 | - { | |
| 77 | - field: 'action', | |
| 78 | - fixed: 'right', | |
| 79 | - slots: { default: 'action' }, | |
| 80 | - title: $t('foodLabeling.common.action'), | |
| 81 | - width: 140, | |
| 82 | - resizable: false, | |
| 83 | - }, | |
| 87 | + foodLabelingActionColumn(), | |
| 84 | 88 | ]; |
| 85 | 89 | |
| 86 | 90 | export function modalSchema(options: { |
| ... | ... | @@ -89,6 +93,7 @@ export function modalSchema(options: { |
| 89 | 93 | partnerOptions: SelectOption[]; |
| 90 | 94 | groupOptions: SelectOption[]; |
| 91 | 95 | locationOptions: SelectOption[]; |
| 96 | + allowedMenuKeys?: string[] | null; | |
| 92 | 97 | }): FormSchemaGetter { |
| 93 | 98 | return () => [ |
| 94 | 99 | { |
| ... | ... | @@ -172,6 +177,32 @@ export function modalSchema(options: { |
| 172 | 177 | optionFilterProp: 'label', |
| 173 | 178 | }, |
| 174 | 179 | }, |
| 180 | + { | |
| 181 | + component: 'Switch', | |
| 182 | + fieldName: 'useCustomMenuPermissions', | |
| 183 | + label: $t('foodLabeling.accountManagement.useCustomMenus'), | |
| 184 | + defaultValue: false, | |
| 185 | + componentProps: { | |
| 186 | + checkedChildren: $t('foodLabeling.common.yes'), | |
| 187 | + unCheckedChildren: $t('foodLabeling.common.no'), | |
| 188 | + }, | |
| 189 | + }, | |
| 190 | + { | |
| 191 | + component: markRaw(MenuPermissionTreeField), | |
| 192 | + fieldName: 'menuPermissionKeys', | |
| 193 | + label: $t('foodLabeling.accountManagement.menuPermissions'), | |
| 194 | + formItemClass: 'col-span-2', | |
| 195 | + defaultValue: [], | |
| 196 | + componentProps: { | |
| 197 | + allowedKeys: options.allowedMenuKeys?.length | |
| 198 | + ? options.allowedMenuKeys | |
| 199 | + : null, | |
| 200 | + }, | |
| 201 | + dependencies: { | |
| 202 | + if: (values) => values.useCustomMenuPermissions === true, | |
| 203 | + triggerFields: ['useCustomMenuPermissions'], | |
| 204 | + }, | |
| 205 | + }, | |
| 175 | 206 | ...stateModalSchema(), |
| 176 | 207 | ]; |
| 177 | 208 | } | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/index.vue
| ... | ... | @@ -26,6 +26,7 @@ import { |
| 26 | 26 | import { useAccountLookups } from '../../../shared/use-account-lookups'; |
| 27 | 27 | import { |
| 28 | 28 | managementTabGridBase, |
| 29 | + managementTabTableClass, | |
| 29 | 30 | managementTabVxeGridClass, |
| 30 | 31 | } from '../../shared/management-grid'; |
| 31 | 32 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -63,6 +64,7 @@ const formOptions = computed((): VbenFormProps => ({ |
| 63 | 64 | roleOptions: accountLookups.roleOptions.value, |
| 64 | 65 | })(), |
| 65 | 66 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4', |
| 67 | + collapseTriggerResize: false, | |
| 66 | 68 | })); |
| 67 | 69 | |
| 68 | 70 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -148,7 +150,8 @@ async function handleDelete(row: Recordable<TeamMemberDto>) { |
| 148 | 150 | </script> |
| 149 | 151 | |
| 150 | 152 | <template> |
| 151 | - <BasicTable> | |
| 153 | + <div class="flex h-full min-h-0 flex-col"> | |
| 154 | + <BasicTable :class="managementTabTableClass"> | |
| 152 | 155 | <template #toolbar-tools> |
| 153 | 156 | <Space> |
| 154 | 157 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -166,6 +169,12 @@ async function handleDelete(row: Recordable<TeamMemberDto>) { |
| 166 | 169 | <template #roleName="{ row }"> |
| 167 | 170 | {{ displayText(row.roleName) }} |
| 168 | 171 | </template> |
| 172 | + <template #userMenus="{ row }"> | |
| 173 | + <Tag v-if="row.useCustomMenuPermissions" color="blue"> | |
| 174 | + {{ (row.menuPermissionKeys ?? []).length }} | |
| 175 | + </Tag> | |
| 176 | + <span v-else class="text-gray-400">{{ $t('foodLabeling.accountManagement.inheritRoleMenus') }}</span> | |
| 177 | + </template> | |
| 169 | 178 | <template #locations="{ row }"> |
| 170 | 179 | <span class="truncate">{{ locationsText(row) }}</span> |
| 171 | 180 | </template> |
| ... | ... | @@ -195,6 +204,7 @@ async function handleDelete(row: Recordable<TeamMemberDto>) { |
| 195 | 204 | </Popconfirm> |
| 196 | 205 | </Space> |
| 197 | 206 | </template> |
| 198 | - </BasicTable> | |
| 199 | - <TeamMemberModalHost @reload="tableApi.query()" /> | |
| 207 | + </BasicTable> | |
| 208 | + <TeamMemberModalHost @reload="tableApi.query()" /> | |
| 209 | + </div> | |
| 200 | 210 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/account-management/team-members/team-member-modal.vue
| ... | ... | @@ -14,6 +14,7 @@ import { cloneDeep } from '@vben/utils'; |
| 14 | 14 | import { message } from 'ant-design-vue'; |
| 15 | 15 | |
| 16 | 16 | import { useVbenForm } from '#/adapter/form'; |
| 17 | +import { useSaasContextStore } from '#/store/saas-context'; | |
| 17 | 18 | |
| 18 | 19 | import { MOCK_TEAM_MEMBERS } from '../../../shared/mock-management-data'; |
| 19 | 20 | import { FOOD_LABELING_STATIC_ONLY } from '../../../shared/static-mode'; |
| ... | ... | @@ -23,6 +24,8 @@ import { modalSchema } from './data'; |
| 23 | 24 | |
| 24 | 25 | const emit = defineEmits<{ reload: [] }>(); |
| 25 | 26 | |
| 27 | +const saasStore = useSaasContextStore(); | |
| 28 | + | |
| 26 | 29 | const isUpdate = ref(false); |
| 27 | 30 | const recordId = ref(''); |
| 28 | 31 | |
| ... | ... | @@ -61,7 +64,7 @@ const [BasicForm, formApi] = useVbenForm({ |
| 61 | 64 | }); |
| 62 | 65 | |
| 63 | 66 | const [BasicModal, modalApi] = useVbenModal({ |
| 64 | - class: 'w-[720px]', | |
| 67 | + class: 'w-[800px]', | |
| 65 | 68 | fullscreenButton: false, |
| 66 | 69 | onCancel: handleCancel, |
| 67 | 70 | onConfirm: handleConfirm, |
| ... | ... | @@ -82,6 +85,7 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 82 | 85 | partnerOptions: partnerOptions.value, |
| 83 | 86 | groupOptions: groupOptions.value, |
| 84 | 87 | locationOptions: locationOptions.value, |
| 88 | + allowedMenuKeys: saasStore.allowedMenuKeys, | |
| 85 | 89 | })(), |
| 86 | 90 | }); |
| 87 | 91 | |
| ... | ... | @@ -107,6 +111,8 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 107 | 111 | partnerId: record.partnerId ?? undefined, |
| 108 | 112 | regionIds: record.regionIds ?? [], |
| 109 | 113 | locationIds: record.locationIds ?? [], |
| 114 | + useCustomMenuPermissions: record.useCustomMenuPermissions === true, | |
| 115 | + menuPermissionKeys: record.menuPermissionKeys ?? [], | |
| 110 | 116 | state: record.state !== false, |
| 111 | 117 | }); |
| 112 | 118 | } else { |
| ... | ... | @@ -115,6 +121,8 @@ const [BasicModal, modalApi] = useVbenModal({ |
| 115 | 121 | state: true, |
| 116 | 122 | regionIds: [], |
| 117 | 123 | locationIds: [], |
| 124 | + useCustomMenuPermissions: false, | |
| 125 | + menuPermissionKeys: [], | |
| 118 | 126 | }); |
| 119 | 127 | } |
| 120 | 128 | modalApi.modalLoading(false); |
| ... | ... | @@ -141,6 +149,11 @@ function formToPayload(values: Record<string, unknown>, updating: boolean) { |
| 141 | 149 | partnerId: String(values.partnerId ?? '').trim() || undefined, |
| 142 | 150 | regionIds: regionIds.length ? regionIds : undefined, |
| 143 | 151 | locationIds, |
| 152 | + useCustomMenuPermissions: values.useCustomMenuPermissions === true, | |
| 153 | + menuPermissionKeys: | |
| 154 | + values.useCustomMenuPermissions === true | |
| 155 | + ? ((values.menuPermissionKeys as string[]) ?? []) | |
| 156 | + : undefined, | |
| 144 | 157 | state: values.state !== false, |
| 145 | 158 | }; |
| 146 | 159 | const pwd = String(values.password ?? '').trim(); | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/categories/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -89,14 +90,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 89 | 90 | minWidth: 140, |
| 90 | 91 | slots: { default: 'lastEdited' }, |
| 91 | 92 | }, |
| 92 | - { | |
| 93 | - field: 'action', | |
| 94 | - fixed: 'right', | |
| 95 | - slots: { default: 'action' }, | |
| 96 | - title: $t('foodLabeling.common.action'), | |
| 97 | - width: 140, | |
| 98 | - resizable: false, | |
| 99 | - }, | |
| 93 | + foodLabelingActionColumn(), | |
| 100 | 94 | ]; |
| 101 | 95 | |
| 102 | 96 | export function modalSchemaBasic(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/categories/index.vue
| ... | ... | @@ -31,6 +31,7 @@ import { useScopeCatalog } from '../../../shared/use-scope-catalog'; |
| 31 | 31 | import { useScopeLookups } from '../../../shared/use-scope-lookups'; |
| 32 | 32 | import { |
| 33 | 33 | managementTabGridBase, |
| 34 | + managementTabTableClass, | |
| 34 | 35 | managementTabVxeGridClass, |
| 35 | 36 | } from '../../shared/management-grid'; |
| 36 | 37 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -62,6 +63,7 @@ const formOptions = computed<VbenFormProps>(() => ({ |
| 62 | 63 | locationOptions: locationOptions.value, |
| 63 | 64 | })(), |
| 64 | 65 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', |
| 66 | + collapseTriggerResize: false, | |
| 65 | 67 | })); |
| 66 | 68 | |
| 67 | 69 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -177,7 +179,8 @@ async function handleDelete(row: Recordable<ProductCategoryDto>) { |
| 177 | 179 | </script> |
| 178 | 180 | |
| 179 | 181 | <template> |
| 180 | - <BasicTable> | |
| 182 | + <div class="flex h-full min-h-0 flex-col"> | |
| 183 | + <BasicTable :class="managementTabTableClass"> | |
| 181 | 184 | <template #toolbar-tools> |
| 182 | 185 | <Space> |
| 183 | 186 | <a-button type="primary" @click="handleAdd"> |
| ... | ... | @@ -234,6 +237,7 @@ async function handleDelete(row: Recordable<ProductCategoryDto>) { |
| 234 | 237 | </Popconfirm> |
| 235 | 238 | </Space> |
| 236 | 239 | </template> |
| 237 | - </BasicTable> | |
| 238 | - <CategoryModalHost @reload="tableApi.query()" /> | |
| 240 | + </BasicTable> | |
| 241 | + <CategoryModalHost @reload="tableApi.query()" /> | |
| 242 | + </div> | |
| 239 | 243 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/index.vue
| ... | ... | @@ -7,6 +7,7 @@ import { Page } from '@vben/common-ui'; |
| 7 | 7 | import { $t } from '@vben/locales'; |
| 8 | 8 | |
| 9 | 9 | import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; |
| 10 | +import { managementTabPageContentClass } from '../shared/management-grid'; | |
| 10 | 11 | import ManagementSubTabs from '../shared/management-sub-tabs.vue'; |
| 11 | 12 | import ManagementTabBody from '../shared/management-tab-body.vue'; |
| 12 | 13 | import CategoriesTab from './categories/index.vue'; |
| ... | ... | @@ -26,18 +27,18 @@ const tabPanels: Record<string, Component> = { |
| 26 | 27 | </script> |
| 27 | 28 | |
| 28 | 29 | <template> |
| 29 | - <Page> | |
| 30 | + <Page :auto-content-height="true" :content-class="managementTabPageContentClass"> | |
| 30 | 31 | <a-alert |
| 31 | 32 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 32 | - class="mb-3" | |
| 33 | + class="mb-3 shrink-0" | |
| 33 | 34 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 34 | 35 | show-icon |
| 35 | 36 | type="info" |
| 36 | 37 | /> |
| 37 | 38 | |
| 38 | - <ManagementSubTabs v-model="activeTab" :tabs="tabItems" /> | |
| 39 | + <ManagementSubTabs v-model="activeTab" :tabs="tabItems" class="shrink-0" /> | |
| 39 | 40 | |
| 40 | - <div class="mt-3"> | |
| 41 | + <div class="mt-3 min-h-0 flex-1 overflow-hidden"> | |
| 41 | 42 | <ManagementTabBody :active-key="activeTab" :panels="tabPanels" /> |
| 42 | 43 | </div> |
| 43 | 44 | </Page> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/products/data.ts
| ... | ... | @@ -3,6 +3,7 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 3 | 3 | |
| 4 | 4 | import { $t } from '@vben/locales'; |
| 5 | 5 | |
| 6 | +import { foodLabelingActionColumn } from '../../shared/management-grid'; | |
| 6 | 7 | import { |
| 7 | 8 | keywordFilterSchema, |
| 8 | 9 | stateFilterSchema, |
| ... | ... | @@ -106,14 +107,7 @@ export const columns: VxeGridProps['columns'] = [ |
| 106 | 107 | minWidth: 140, |
| 107 | 108 | slots: { default: 'lastEdited' }, |
| 108 | 109 | }, |
| 109 | - { | |
| 110 | - field: 'action', | |
| 111 | - fixed: 'right', | |
| 112 | - slots: { default: 'action' }, | |
| 113 | - title: $t('foodLabeling.common.action'), | |
| 114 | - width: 140, | |
| 115 | - resizable: false, | |
| 116 | - }, | |
| 110 | + foodLabelingActionColumn(), | |
| 117 | 111 | ]; |
| 118 | 112 | |
| 119 | 113 | export function modalSchemaBasic(options: { | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/menu-management/products/index.vue
| ... | ... | @@ -33,6 +33,7 @@ import { useScopeCatalog } from '../../../shared/use-scope-catalog'; |
| 33 | 33 | import { useScopeLookups } from '../../../shared/use-scope-lookups'; |
| 34 | 34 | import { |
| 35 | 35 | managementTabGridBase, |
| 36 | + managementTabTableClass, | |
| 36 | 37 | managementTabVxeGridClass, |
| 37 | 38 | } from '../../shared/management-grid'; |
| 38 | 39 | import { columns, querySchema } from './data'; |
| ... | ... | @@ -78,6 +79,7 @@ const formOptions = computed(() => ({ |
| 78 | 79 | locationOptions: locationOptions.value, |
| 79 | 80 | })(), |
| 80 | 81 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4', |
| 82 | + collapseTriggerResize: false, | |
| 81 | 83 | })); |
| 82 | 84 | |
| 83 | 85 | const gridOptions: VxeGridProps = { |
| ... | ... | @@ -231,7 +233,8 @@ function locationCellText(row: ProductDto) { |
| 231 | 233 | </script> |
| 232 | 234 | |
| 233 | 235 | <template> |
| 234 | - <BasicTable> | |
| 236 | + <div class="flex h-full min-h-0 flex-col"> | |
| 237 | + <BasicTable :class="managementTabTableClass"> | |
| 235 | 238 | <template #toolbar-tools> |
| 236 | 239 | <Space> |
| 237 | 240 | <a-button @click="handleImport"> |
| ... | ... | @@ -295,7 +298,8 @@ function locationCellText(row: ProductDto) { |
| 295 | 298 | </Popconfirm> |
| 296 | 299 | </Space> |
| 297 | 300 | </template> |
| 298 | - </BasicTable> | |
| 299 | - <ProductModalHost @reload="tableApi.query()" /> | |
| 300 | - <ProductImportModalHost @reload="tableApi.query()" /> | |
| 301 | + </BasicTable> | |
| 302 | + <ProductModalHost @reload="tableApi.query()" /> | |
| 303 | + <ProductImportModalHost @reload="tableApi.query()" /> | |
| 304 | + </div> | |
| 301 | 305 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/reports/index.vue
| ... | ... | @@ -17,6 +17,7 @@ import { commonDownloadExcel } from '#/utils/file/download'; |
| 17 | 17 | import { FOOD_LABELING_STATIC_ONLY } from '../../shared/static-mode'; |
| 18 | 18 | import { useAccountLookups } from '../../shared/use-account-lookups'; |
| 19 | 19 | import { useScopeLookups } from '../../shared/use-scope-lookups'; |
| 20 | +import { managementTabPageContentClass } from '../shared/management-grid'; | |
| 20 | 21 | import ManagementSubTabs from '../shared/management-sub-tabs.vue'; |
| 21 | 22 | import { defaultReportDateRange, filterSchema } from './data'; |
| 22 | 23 | import LabelReportTab from './label-report-tab.vue'; |
| ... | ... | @@ -118,10 +119,10 @@ async function handleExport() { |
| 118 | 119 | </script> |
| 119 | 120 | |
| 120 | 121 | <template> |
| 121 | - <Page v-if="pageAlive"> | |
| 122 | + <Page v-if="pageAlive" :auto-content-height="true" :content-class="managementTabPageContentClass"> | |
| 122 | 123 | <a-alert |
| 123 | 124 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 124 | - class="mb-3" | |
| 125 | + class="mb-3 shrink-0" | |
| 125 | 126 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 126 | 127 | show-icon |
| 127 | 128 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/shared/management-grid.ts
| 1 | +import type { VbenFormProps } from '@vben/common-ui'; | |
| 2 | + | |
| 3 | +import type { FormSchemaGetter, VbenFormSchema } from '#/adapter/form'; | |
| 4 | + | |
| 1 | 5 | import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 2 | 6 | |
| 7 | +import { $t } from '@vben/locales'; | |
| 8 | + | |
| 3 | 9 | /** |
| 4 | 10 | * 管理模块「页内 Tab」表格基础配置。 |
| 5 | 11 | * 必须关闭 autoResize:在 Tab/KeepAlive 嵌套下,vxe 默认 ResizeObserver 会与 height:auto |
| ... | ... | @@ -7,14 +13,73 @@ import type { VxeGridProps } from '#/adapter/vxe-table'; |
| 7 | 13 | */ |
| 8 | 14 | export const managementTabGridBase: Pick< |
| 9 | 15 | VxeGridProps['gridOptions'], |
| 10 | - 'autoResize' | 'height' | 'scrollY' | |
| 16 | + 'autoResize' | 'height' | 'minHeight' | 'scrollX' | 'scrollY' | 'syncResize' | 'toolbarConfig' | |
| 11 | 17 | > = { |
| 12 | 18 | autoResize: false, |
| 13 | - height: 'auto', | |
| 19 | + /** 避免窗口横向缩放时 vxe 反复 sync 尺寸导致滚动区域持续增长 */ | |
| 20 | + syncResize: false, | |
| 21 | + height: '100%', | |
| 22 | + minHeight: 0, | |
| 14 | 23 | scrollY: { |
| 15 | - enabled: false, | |
| 24 | + enabled: true, | |
| 25 | + gt: 0, | |
| 26 | + }, | |
| 27 | + scrollX: { | |
| 28 | + enabled: true, | |
| 29 | + gt: 0, | |
| 30 | + }, | |
| 31 | + toolbarConfig: { | |
| 32 | + /** 全屏缩放会锁定异常高度,列表页关闭 */ | |
| 33 | + zoom: false, | |
| 16 | 34 | }, |
| 17 | 35 | }; |
| 18 | 36 | |
| 19 | -/** 覆盖 use-vxe-grid 外层默认 h-full,避免撑满无界父容器 */ | |
| 20 | -export const managementTabVxeGridClass = '!h-auto min-h-0'; | |
| 37 | +export const managementTabVxeGridClass = | |
| 38 | + 'h-full w-full min-h-0 min-w-0 overflow-hidden'; | |
| 39 | + | |
| 40 | +export const managementTabTableClass = | |
| 41 | + 'min-h-0 w-full flex-1 overflow-hidden'; | |
| 42 | + | |
| 43 | +export const managementTabPageContentClass = | |
| 44 | + 'flex min-h-0 w-full min-w-0 flex-col'; | |
| 45 | + | |
| 46 | +type ActionColumnOverrides = NonNullable<VxeGridProps['columns']>[number]; | |
| 47 | + | |
| 48 | +/** 操作列:width auto + 右侧固定,避免表格右侧留白 */ | |
| 49 | +export function foodLabelingActionColumn( | |
| 50 | + overrides?: Partial<ActionColumnOverrides>, | |
| 51 | +): ActionColumnOverrides { | |
| 52 | + return { | |
| 53 | + field: 'action', | |
| 54 | + fixed: 'right', | |
| 55 | + slots: { default: 'action' }, | |
| 56 | + title: $t('foodLabeling.common.action'), | |
| 57 | + resizable: false, | |
| 58 | + showOverflow: false, | |
| 59 | + width: 'auto', | |
| 60 | + minWidth: 140, | |
| 61 | + ...overrides, | |
| 62 | + }; | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** | |
| 66 | + * 管理 Tab 列表通用查询表单配置。 | |
| 67 | + * 大屏 3 列下 ≤3 个筛选项只占一行,收起无效且会误出内部滚动条,故默认隐藏收起按钮。 | |
| 68 | + */ | |
| 69 | +export function buildManagementTabFormOptions( | |
| 70 | + schema: VbenFormSchema[] | FormSchemaGetter, | |
| 71 | + overrides?: Partial<VbenFormProps>, | |
| 72 | +): VbenFormProps { | |
| 73 | + const resolved = typeof schema === 'function' ? schema() : schema; | |
| 74 | + return { | |
| 75 | + commonConfig: { | |
| 76 | + labelWidth: 90, | |
| 77 | + componentProps: { allowClear: true }, | |
| 78 | + }, | |
| 79 | + schema: typeof schema === 'function' ? schema : resolved, | |
| 80 | + wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', | |
| 81 | + showCollapseButton: resolved.length > 3, | |
| 82 | + collapseTriggerResize: false, | |
| 83 | + ...overrides, | |
| 84 | + }; | |
| 85 | +} | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/shared/management-tab-body.vue
| ... | ... | @@ -12,8 +12,10 @@ const activeComponent = computed(() => props.panels[props.activeKey]); |
| 12 | 12 | </script> |
| 13 | 13 | |
| 14 | 14 | <template> |
| 15 | - <!-- KeepAlive:页内 Tab 切换不销毁 VxeGrid,避免 parentNode 报错 --> | |
| 16 | - <KeepAlive :max="8"> | |
| 17 | - <component :is="activeComponent" v-if="activeComponent" :key="activeKey" /> | |
| 18 | - </KeepAlive> | |
| 15 | + <div class="h-full min-h-0 w-full overflow-hidden"> | |
| 16 | + <!-- KeepAlive:页内 Tab 切换不销毁 VxeGrid,避免 parentNode 报错 --> | |
| 17 | + <KeepAlive :max="8"> | |
| 18 | + <component :is="activeComponent" v-if="activeComponent" :key="activeKey" /> | |
| 19 | + </KeepAlive> | |
| 20 | + </div> | |
| 19 | 21 | </template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/management/support/index.vue
| ... | ... | @@ -89,10 +89,10 @@ onMounted(() => { |
| 89 | 89 | </script> |
| 90 | 90 | |
| 91 | 91 | <template> |
| 92 | - <Page> | |
| 92 | + <Page :auto-content-height="true"> | |
| 93 | 93 | <a-alert |
| 94 | 94 | v-if="FOOD_LABELING_STATIC_ONLY" |
| 95 | - class="mb-3" | |
| 95 | + class="mb-3 shrink-0" | |
| 96 | 96 | :message="$t('foodLabeling.common.staticDemoBanner')" |
| 97 | 97 | show-icon |
| 98 | 98 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/alerts/index.vue
| ... | ... | @@ -38,9 +38,9 @@ function removeTimer(id: number) { |
| 38 | 38 | </script> |
| 39 | 39 | |
| 40 | 40 | <template> |
| 41 | - <Page> | |
| 41 | + <Page :auto-content-height="true"> | |
| 42 | 42 | <a-alert |
| 43 | - class="mb-4" | |
| 43 | + class="mb-4 shrink-0" | |
| 44 | 44 | :message="$t('foodLabeling.modules.frontendOnlyBanner')" |
| 45 | 45 | show-icon |
| 46 | 46 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/devices/index.vue
| ... | ... | @@ -17,6 +17,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 17 | 17 | |
| 18 | 18 | import { |
| 19 | 19 | labelingGridBase, |
| 20 | + labelingPageContentClass, | |
| 21 | + labelingTableClass, | |
| 20 | 22 | labelingVxeGridClass, |
| 21 | 23 | } from '../../shared/labeling-grid'; |
| 22 | 24 | import { MOCK_DEVICES } from '../../shared/mock-modules-data'; |
| ... | ... | @@ -71,6 +73,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({ |
| 71 | 73 | }, |
| 72 | 74 | schema: querySchema()(), |
| 73 | 75 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', |
| 76 | + collapseTriggerResize: false, | |
| 74 | 77 | }, |
| 75 | 78 | gridOptions, |
| 76 | 79 | }); |
| ... | ... | @@ -97,14 +100,14 @@ function handleBulk(action: string) { |
| 97 | 100 | </script> |
| 98 | 101 | |
| 99 | 102 | <template> |
| 100 | - <Page> | |
| 103 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 101 | 104 | <a-alert |
| 102 | - class="mb-3" | |
| 105 | + class="mb-3 shrink-0" | |
| 103 | 106 | :message="$t('foodLabeling.modules.frontendOnlyBanner')" |
| 104 | 107 | show-icon |
| 105 | 108 | type="info" |
| 106 | 109 | /> |
| 107 | - <BasicTable v-if="pageReady"> | |
| 110 | + <BasicTable v-if="pageReady" :class="labelingTableClass"> | |
| 108 | 111 | <template #toolbar-tools> |
| 109 | 112 | <Space wrap> |
| 110 | 113 | <a-button @click="handleBulk('Bulk Bind')"> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/invoices/index.vue
| ... | ... | @@ -15,6 +15,8 @@ import { useVbenVxeGrid } from '#/adapter/vxe-table'; |
| 15 | 15 | |
| 16 | 16 | import { |
| 17 | 17 | labelingGridBase, |
| 18 | + labelingPageContentClass, | |
| 19 | + labelingTableClass, | |
| 18 | 20 | labelingVxeGridClass, |
| 19 | 21 | } from '../../shared/labeling-grid'; |
| 20 | 22 | import { MOCK_INVOICES } from '../../shared/mock-modules-data'; |
| ... | ... | @@ -59,6 +61,7 @@ const [BasicTable, tableApi] = useVbenVxeGrid({ |
| 59 | 61 | }, |
| 60 | 62 | schema: querySchema()(), |
| 61 | 63 | wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5', |
| 64 | + collapseTriggerResize: false, | |
| 62 | 65 | }, |
| 63 | 66 | gridOptions, |
| 64 | 67 | }); |
| ... | ... | @@ -102,14 +105,14 @@ function overdueClass(row: InvoiceRow) { |
| 102 | 105 | </script> |
| 103 | 106 | |
| 104 | 107 | <template> |
| 105 | - <Page> | |
| 108 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 106 | 109 | <a-alert |
| 107 | - class="mb-3" | |
| 110 | + class="mb-3 shrink-0" | |
| 108 | 111 | :message="$t('foodLabeling.modules.frontendOnlyBanner')" |
| 109 | 112 | show-icon |
| 110 | 113 | type="info" |
| 111 | 114 | /> |
| 112 | - <BasicTable v-if="pageReady"> | |
| 115 | + <BasicTable v-if="pageReady" :class="labelingTableClass"> | |
| 113 | 116 | <template #toolbar-tools> |
| 114 | 117 | <a-button @click="handleExport"> |
| 115 | 118 | {{ $t('foodLabeling.modules.invoices.exportBills') }} | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/qr-codes/index.vue
| ... | ... | @@ -10,6 +10,7 @@ import QRCode from 'qrcode'; |
| 10 | 10 | import { message } from 'ant-design-vue'; |
| 11 | 11 | import { Card, Col, InputNumber, Row, Select, Space, Table, Tag } from 'ant-design-vue'; |
| 12 | 12 | |
| 13 | +import { labelingPageContentClass } from '../../shared/labeling-grid'; | |
| 13 | 14 | import ManagementSubTabs from '../../management/shared/management-sub-tabs.vue'; |
| 14 | 15 | import { MOCK_QR_CODES } from '../../shared/mock-modules-data'; |
| 15 | 16 | |
| ... | ... | @@ -103,9 +104,9 @@ onMounted(() => { |
| 103 | 104 | </script> |
| 104 | 105 | |
| 105 | 106 | <template> |
| 106 | - <Page> | |
| 107 | + <Page :auto-content-height="true" :content-class="labelingPageContentClass"> | |
| 107 | 108 | <a-alert |
| 108 | - class="mb-3" | |
| 109 | + class="mb-3 shrink-0" | |
| 109 | 110 | :message="$t('foodLabeling.modules.frontendOnlyBanner')" |
| 110 | 111 | show-icon |
| 111 | 112 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/modules/training/index.vue
| ... | ... | @@ -61,9 +61,9 @@ function isSubOpen(id: string) { |
| 61 | 61 | </script> |
| 62 | 62 | |
| 63 | 63 | <template> |
| 64 | - <Page> | |
| 64 | + <Page :auto-content-height="true"> | |
| 65 | 65 | <a-alert |
| 66 | - class="mb-4" | |
| 66 | + class="mb-4 shrink-0" | |
| 67 | 67 | :message="$t('foodLabeling.modules.frontendOnlyBanner')" |
| 68 | 68 | show-icon |
| 69 | 69 | type="info" | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/data.ts
0 → 100644
| 1 | +import type { FormSchemaGetter } from '#/adapter/form'; | |
| 2 | +import type { VxeGridProps } from '#/adapter/vxe-table'; | |
| 3 | + | |
| 4 | +import { $t } from '@vben/locales'; | |
| 5 | + | |
| 6 | +import { foodLabelingActionColumn } from '../../management/shared/management-grid'; | |
| 7 | +import { keywordFilterSchema, stateFilterSchema } from '../../shared/form-schema'; | |
| 8 | + | |
| 9 | +export const querySchema: FormSchemaGetter = () => [ | |
| 10 | + ...keywordFilterSchema(), | |
| 11 | + ...stateFilterSchema(), | |
| 12 | +]; | |
| 13 | + | |
| 14 | +export const columns: VxeGridProps['columns'] = [ | |
| 15 | + { | |
| 16 | + field: 'companyName', | |
| 17 | + title: $t('foodLabeling.platform.companyName'), | |
| 18 | + minWidth: 160, | |
| 19 | + slots: { default: 'companyName' }, | |
| 20 | + }, | |
| 21 | + { | |
| 22 | + field: 'companyCode', | |
| 23 | + title: $t('foodLabeling.platform.companyCode'), | |
| 24 | + minWidth: 120, | |
| 25 | + slots: { default: 'companyCode' }, | |
| 26 | + }, | |
| 27 | + { | |
| 28 | + field: 'adminUserName', | |
| 29 | + title: $t('foodLabeling.platform.tenantAdmin'), | |
| 30 | + minWidth: 130, | |
| 31 | + slots: { default: 'adminUserName' }, | |
| 32 | + }, | |
| 33 | + { | |
| 34 | + field: 'menuPermissionKeys', | |
| 35 | + title: $t('foodLabeling.platform.menuPermissions'), | |
| 36 | + minWidth: 100, | |
| 37 | + slots: { default: 'menuCount' }, | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + field: 'contactEmail', | |
| 41 | + title: $t('foodLabeling.accountManagement.contactEmail'), | |
| 42 | + minWidth: 160, | |
| 43 | + slots: { default: 'contactEmail' }, | |
| 44 | + }, | |
| 45 | + { | |
| 46 | + field: 'state', | |
| 47 | + title: $t('foodLabeling.common.status'), | |
| 48 | + width: 90, | |
| 49 | + slots: { default: 'state' }, | |
| 50 | + }, | |
| 51 | + foodLabelingActionColumn({ minWidth: 160 }), | |
| 52 | +]; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/index.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { VbenFormProps } from '@vben/common-ui'; | |
| 3 | +import type { Recordable } from '@vben/types'; | |
| 4 | + | |
| 5 | +import type { SaasTenantDto } from '../../shared/saas-types'; | |
| 6 | + | |
| 7 | +import type { VxeGridProps } from '#/adapter/vxe-table'; | |
| 8 | + | |
| 9 | +import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui'; | |
| 10 | +import { $t } from '@vben/locales'; | |
| 11 | + | |
| 12 | +import { Alert, Avatar, Button, Dropdown, Menu, Modal, Space, Tag } from 'ant-design-vue'; | |
| 13 | + | |
| 14 | +import { useVbenVxeGrid } from '#/adapter/vxe-table'; | |
| 15 | + | |
| 16 | +import { matchKeyword, slicePage } from '../../shared/static-mode'; | |
| 17 | +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data'; | |
| 18 | +import { | |
| 19 | + buildManagementTabFormOptions, | |
| 20 | + managementTabGridBase, | |
| 21 | + managementTabPageContentClass, | |
| 22 | + managementTabTableClass, | |
| 23 | + managementTabVxeGridClass, | |
| 24 | +} from '../../management/shared/management-grid'; | |
| 25 | +import { columns, querySchema } from './data'; | |
| 26 | +import TenantAdminModal from './tenant-admin-modal.vue'; | |
| 27 | +import TenantMenuDrawer from './tenant-menu-drawer.vue'; | |
| 28 | +import TenantModal from './tenant-modal.vue'; | |
| 29 | + | |
| 30 | +defineOptions({ name: 'FoodLabelingPlatformTenants' }); | |
| 31 | + | |
| 32 | +function displayText(v: string | null | undefined) { | |
| 33 | + const s = (v ?? '').trim(); | |
| 34 | + return s || $t('foodLabeling.common.none'); | |
| 35 | +} | |
| 36 | + | |
| 37 | +const formOptions: VbenFormProps = buildManagementTabFormOptions(querySchema()); | |
| 38 | + | |
| 39 | +const gridOptions: VxeGridProps = { | |
| 40 | + ...managementTabGridBase, | |
| 41 | + columns, | |
| 42 | + keepSource: true, | |
| 43 | + pagerConfig: {}, | |
| 44 | + proxyConfig: { | |
| 45 | + ajax: { | |
| 46 | + query: async ({ page }, formValues = {}) => { | |
| 47 | + let rows = [...MOCK_SAAS_TENANTS]; | |
| 48 | + const kw = String(formValues.Keyword ?? ''); | |
| 49 | + if (kw) { | |
| 50 | + rows = rows.filter((r) => | |
| 51 | + matchKeyword(r as Recordable, kw, [ | |
| 52 | + 'companyName', | |
| 53 | + 'companyCode', | |
| 54 | + 'adminUserName', | |
| 55 | + 'contactEmail', | |
| 56 | + 'contactName', | |
| 57 | + ]), | |
| 58 | + ); | |
| 59 | + } | |
| 60 | + if (formValues.State === true || formValues.State === false) { | |
| 61 | + rows = rows.filter((r) => r.state === formValues.State); | |
| 62 | + } | |
| 63 | + return slicePage(rows, page.currentPage, page.pageSize); | |
| 64 | + }, | |
| 65 | + }, | |
| 66 | + }, | |
| 67 | + rowConfig: { isHover: true, keyField: 'id' }, | |
| 68 | + showOverflow: false, | |
| 69 | +}; | |
| 70 | + | |
| 71 | +const [BasicTable, tableApi] = useVbenVxeGrid({ | |
| 72 | + class: managementTabVxeGridClass, | |
| 73 | + formOptions, | |
| 74 | + gridClass: 'min-w-0 max-w-full', | |
| 75 | + gridOptions: { | |
| 76 | + ...gridOptions, | |
| 77 | + id: 'food-labeling-saas-tenants', | |
| 78 | + }, | |
| 79 | +}); | |
| 80 | + | |
| 81 | +const [TenantModalHost, tenantModalApi] = useVbenModal({ | |
| 82 | + connectedComponent: TenantModal, | |
| 83 | +}); | |
| 84 | + | |
| 85 | +const [TenantMenuDrawerHost, tenantMenuDrawerApi] = useVbenDrawer({ | |
| 86 | + connectedComponent: TenantMenuDrawer, | |
| 87 | +}); | |
| 88 | + | |
| 89 | +const [TenantAdminModalHost, tenantAdminModalApi] = useVbenModal({ | |
| 90 | + connectedComponent: TenantAdminModal, | |
| 91 | +}); | |
| 92 | + | |
| 93 | +function handleAdd() { | |
| 94 | + tenantModalApi.setData({}); | |
| 95 | + tenantModalApi.open(); | |
| 96 | +} | |
| 97 | + | |
| 98 | +function handleEdit(row: SaasTenantDto) { | |
| 99 | + tenantModalApi.setData({ id: row.id }); | |
| 100 | + tenantModalApi.open(); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function handleMenus(row: SaasTenantDto) { | |
| 104 | + tenantMenuDrawerApi.setData({ id: row.id }); | |
| 105 | + tenantMenuDrawerApi.open(); | |
| 106 | +} | |
| 107 | + | |
| 108 | +function handleAdmin(row: SaasTenantDto) { | |
| 109 | + tenantAdminModalApi.setData({ id: row.id }); | |
| 110 | + tenantAdminModalApi.open(); | |
| 111 | +} | |
| 112 | + | |
| 113 | +function handleDelete(row: SaasTenantDto) { | |
| 114 | + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === row.id); | |
| 115 | + if (idx >= 0) { | |
| 116 | + MOCK_SAAS_TENANTS.splice(idx, 1); | |
| 117 | + } | |
| 118 | + tableApi.reload(); | |
| 119 | +} | |
| 120 | + | |
| 121 | +function menuCount(row: SaasTenantDto) { | |
| 122 | + return row.menuPermissionKeys?.length ?? 0; | |
| 123 | +} | |
| 124 | + | |
| 125 | +function confirmDelete(row: SaasTenantDto) { | |
| 126 | + Modal.confirm({ | |
| 127 | + okType: 'danger', | |
| 128 | + title: $t('foodLabeling.platform.deleteCompanyConfirm'), | |
| 129 | + onOk: () => handleDelete(row), | |
| 130 | + }); | |
| 131 | +} | |
| 132 | + | |
| 133 | +function onActionMenu(key: string, row: SaasTenantDto) { | |
| 134 | + if (key === 'menus') { | |
| 135 | + handleMenus(row); | |
| 136 | + return; | |
| 137 | + } | |
| 138 | + if (key === 'admin') { | |
| 139 | + handleAdmin(row); | |
| 140 | + return; | |
| 141 | + } | |
| 142 | + if (key === 'delete') { | |
| 143 | + confirmDelete(row); | |
| 144 | + } | |
| 145 | +} | |
| 146 | +</script> | |
| 147 | + | |
| 148 | +<template> | |
| 149 | + <Page :auto-content-height="true" :content-class="managementTabPageContentClass"> | |
| 150 | + <Alert | |
| 151 | + class="mb-3 shrink-0" | |
| 152 | + :message="$t('foodLabeling.platform.saasBanner')" | |
| 153 | + show-icon | |
| 154 | + type="info" | |
| 155 | + /> | |
| 156 | + | |
| 157 | + <BasicTable :class="managementTabTableClass"> | |
| 158 | + <template #toolbar-tools> | |
| 159 | + <Button type="primary" @click="handleAdd"> | |
| 160 | + {{ $t('foodLabeling.platform.addCompany') }} | |
| 161 | + </Button> | |
| 162 | + </template> | |
| 163 | + | |
| 164 | + <template #companyName="{ row }"> | |
| 165 | + <div class="flex min-w-0 items-center gap-2"> | |
| 166 | + <Avatar v-if="row.logoUrl" :src="row.logoUrl" size="small" /> | |
| 167 | + <Avatar v-else size="small">{{ (row.companyName ?? '?').slice(0, 1) }}</Avatar> | |
| 168 | + <span class="truncate font-medium">{{ displayText(row.companyName) }}</span> | |
| 169 | + </div> | |
| 170 | + </template> | |
| 171 | + | |
| 172 | + <template #companyCode="{ row }"> | |
| 173 | + {{ displayText(row.companyCode) }} | |
| 174 | + </template> | |
| 175 | + | |
| 176 | + <template #adminUserName="{ row }"> | |
| 177 | + <div class="truncate"> | |
| 178 | + <div>{{ displayText(row.adminFullName) }}</div> | |
| 179 | + <div class="text-xs text-gray-500">{{ displayText(row.adminUserName) }}</div> | |
| 180 | + </div> | |
| 181 | + </template> | |
| 182 | + | |
| 183 | + <template #menuCount="{ row }"> | |
| 184 | + <Tag color="blue">{{ menuCount(row) }}</Tag> | |
| 185 | + </template> | |
| 186 | + | |
| 187 | + <template #contactEmail="{ row }"> | |
| 188 | + {{ displayText(row.contactEmail) }} | |
| 189 | + </template> | |
| 190 | + | |
| 191 | + <template #state="{ row }"> | |
| 192 | + <Tag :color="row.state !== false ? 'green' : 'default'"> | |
| 193 | + {{ | |
| 194 | + row.state !== false | |
| 195 | + ? $t('foodLabeling.common.enabled') | |
| 196 | + : $t('foodLabeling.common.disabled') | |
| 197 | + }} | |
| 198 | + </Tag> | |
| 199 | + </template> | |
| 200 | + | |
| 201 | + <template #action="{ row }"> | |
| 202 | + <div class="saas-tenant-row-actions"> | |
| 203 | + <Space :size="8"> | |
| 204 | + <ghost-button size="small" @click="handleEdit(row)"> | |
| 205 | + {{ $t('pages.common.edit') }} | |
| 206 | + </ghost-button> | |
| 207 | + <Dropdown :trigger="['click']"> | |
| 208 | + <ghost-button size="small"> | |
| 209 | + {{ $t('pages.common.more') }} | |
| 210 | + </ghost-button> | |
| 211 | + <template #overlay> | |
| 212 | + <Menu @click="({ key }) => onActionMenu(String(key), row)"> | |
| 213 | + <Menu.Item key="menus"> | |
| 214 | + {{ $t('foodLabeling.platform.menuPermissions') }} | |
| 215 | + </Menu.Item> | |
| 216 | + <Menu.Item key="admin"> | |
| 217 | + {{ $t('foodLabeling.platform.tenantAdmin') }} | |
| 218 | + </Menu.Item> | |
| 219 | + <Menu.Divider /> | |
| 220 | + <Menu.Item key="delete" danger> | |
| 221 | + {{ $t('pages.common.delete') }} | |
| 222 | + </Menu.Item> | |
| 223 | + </Menu> | |
| 224 | + </template> | |
| 225 | + </Dropdown> | |
| 226 | + </Space> | |
| 227 | + </div> | |
| 228 | + </template> | |
| 229 | + </BasicTable> | |
| 230 | + | |
| 231 | + <TenantModalHost @reload="tableApi.reload()" /> | |
| 232 | + <TenantMenuDrawerHost @reload="tableApi.reload()" /> | |
| 233 | + <TenantAdminModalHost @reload="tableApi.reload()" /> | |
| 234 | + </Page> | |
| 235 | +</template> | |
| 236 | + | |
| 237 | +<style scoped> | |
| 238 | +.saas-tenant-row-actions { | |
| 239 | + display: flex; | |
| 240 | + align-items: center; | |
| 241 | + padding: 4px 0; | |
| 242 | + line-height: 1.5; | |
| 243 | + white-space: nowrap; | |
| 244 | +} | |
| 245 | + | |
| 246 | +.saas-tenant-row-actions :deep(.ant-space) { | |
| 247 | + flex-wrap: nowrap; | |
| 248 | +} | |
| 249 | +</style> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-admin-modal.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { SaasTenantDto } from '../../shared/saas-types'; | |
| 3 | + | |
| 4 | +import { computed, ref } from 'vue'; | |
| 5 | + | |
| 6 | +import { useVbenModal } from '@vben/common-ui'; | |
| 7 | +import { $t } from '@vben/locales'; | |
| 8 | +import { cloneDeep } from '@vben/utils'; | |
| 9 | + | |
| 10 | +import { message } from 'ant-design-vue'; | |
| 11 | + | |
| 12 | +import { useVbenForm } from '#/adapter/form'; | |
| 13 | + | |
| 14 | +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data'; | |
| 15 | + | |
| 16 | +const emit = defineEmits<{ reload: [] }>(); | |
| 17 | + | |
| 18 | +const tenantId = ref(''); | |
| 19 | +const tenantName = ref(''); | |
| 20 | + | |
| 21 | +const title = computed(() => | |
| 22 | + $t('foodLabeling.platform.manageTenantAdmin', { name: tenantName.value }), | |
| 23 | +); | |
| 24 | + | |
| 25 | +const [BasicForm, formApi] = useVbenForm({ | |
| 26 | + commonConfig: { | |
| 27 | + formItemClass: 'col-span-2', | |
| 28 | + labelWidth: 110, | |
| 29 | + componentProps: { class: 'w-full' }, | |
| 30 | + }, | |
| 31 | + schema: [ | |
| 32 | + { | |
| 33 | + component: 'Input', | |
| 34 | + fieldName: 'adminUserName', | |
| 35 | + label: $t('foodLabeling.accountManagement.userName'), | |
| 36 | + rules: 'required', | |
| 37 | + }, | |
| 38 | + { | |
| 39 | + component: 'InputPassword', | |
| 40 | + fieldName: 'adminPassword', | |
| 41 | + label: $t('foodLabeling.accountManagement.password'), | |
| 42 | + componentProps: { | |
| 43 | + placeholder: $t('foodLabeling.accountManagement.passwordOptional'), | |
| 44 | + }, | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + component: 'Input', | |
| 48 | + fieldName: 'adminFullName', | |
| 49 | + label: $t('foodLabeling.accountManagement.fullName'), | |
| 50 | + rules: 'required', | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + component: 'Input', | |
| 54 | + fieldName: 'adminEmail', | |
| 55 | + label: $t('foodLabeling.accountManagement.contactEmail'), | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + component: 'Input', | |
| 59 | + fieldName: 'adminPhone', | |
| 60 | + label: $t('foodLabeling.accountManagement.phoneNumber'), | |
| 61 | + }, | |
| 62 | + ], | |
| 63 | + showDefaultActions: false, | |
| 64 | + wrapperClass: 'grid-cols-2', | |
| 65 | +}); | |
| 66 | + | |
| 67 | +const [BasicModal, modalApi] = useVbenModal({ | |
| 68 | + class: 'w-[560px]', | |
| 69 | + fullscreenButton: false, | |
| 70 | + onCancel: handleCancel, | |
| 71 | + onConfirm: handleConfirm, | |
| 72 | + onOpenChange: async (isOpen) => { | |
| 73 | + if (!isOpen) { | |
| 74 | + return null; | |
| 75 | + } | |
| 76 | + const data = modalApi.getData() as { id?: string }; | |
| 77 | + tenantId.value = data?.id ?? ''; | |
| 78 | + const record = MOCK_SAAS_TENANTS.find((x) => x.id === tenantId.value); | |
| 79 | + tenantName.value = record?.companyName ?? ''; | |
| 80 | + await formApi.setValues({ | |
| 81 | + adminUserName: record?.adminUserName ?? '', | |
| 82 | + adminFullName: record?.adminFullName ?? '', | |
| 83 | + adminEmail: record?.adminEmail ?? '', | |
| 84 | + adminPassword: '', | |
| 85 | + }); | |
| 86 | + }, | |
| 87 | +}); | |
| 88 | + | |
| 89 | +async function handleConfirm() { | |
| 90 | + const { valid } = await formApi.validate(); | |
| 91 | + if (!valid || !tenantId.value) { | |
| 92 | + return; | |
| 93 | + } | |
| 94 | + const values = cloneDeep(await formApi.getValues()) as Record<string, unknown>; | |
| 95 | + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === tenantId.value); | |
| 96 | + if (idx >= 0) { | |
| 97 | + const row = MOCK_SAAS_TENANTS[idx]!; | |
| 98 | + row.adminUserName = String(values.adminUserName ?? '').trim(); | |
| 99 | + row.adminFullName = String(values.adminFullName ?? '').trim(); | |
| 100 | + row.adminEmail = String(values.adminEmail ?? '').trim() || null; | |
| 101 | + if (!row.adminUserId) { | |
| 102 | + row.adminUserId = `admin-${tenantId.value.slice(0, 8)}`; | |
| 103 | + } | |
| 104 | + } | |
| 105 | + message.success($t('foodLabeling.common.staticDemoAction')); | |
| 106 | + emit('reload'); | |
| 107 | + await handleCancel(); | |
| 108 | +} | |
| 109 | + | |
| 110 | +async function handleCancel() { | |
| 111 | + modalApi.close(); | |
| 112 | + await formApi.resetForm(); | |
| 113 | +} | |
| 114 | +</script> | |
| 115 | + | |
| 116 | +<template> | |
| 117 | + <BasicModal :title="title"> | |
| 118 | + <a-alert | |
| 119 | + class="mb-4" | |
| 120 | + :message="$t('foodLabeling.platform.tenantAdminHint')" | |
| 121 | + show-icon | |
| 122 | + type="info" | |
| 123 | + /> | |
| 124 | + <BasicForm /> | |
| 125 | + </BasicModal> | |
| 126 | +</template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-logo-upload-field.vue
0 → 100644
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-logo-upload.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { UploadFile, UploadProps } from 'ant-design-vue'; | |
| 3 | + | |
| 4 | +import { ref, watch } from 'vue'; | |
| 5 | + | |
| 6 | +import { PlusOutlined } from '@ant-design/icons-vue'; | |
| 7 | +import { message, Upload } from 'ant-design-vue'; | |
| 8 | +import { $t } from '@vben/locales'; | |
| 9 | + | |
| 10 | +const logoUrl = defineModel<string>({ default: '' }); | |
| 11 | + | |
| 12 | +const fileList = ref<UploadFile[]>([]); | |
| 13 | + | |
| 14 | +function syncFileListFromUrl(url: string) { | |
| 15 | + if (!url?.trim()) { | |
| 16 | + fileList.value = []; | |
| 17 | + return; | |
| 18 | + } | |
| 19 | + if (fileList.value[0]?.url === url) { | |
| 20 | + return; | |
| 21 | + } | |
| 22 | + fileList.value = [ | |
| 23 | + { | |
| 24 | + name: 'logo', | |
| 25 | + status: 'done', | |
| 26 | + uid: '-1', | |
| 27 | + url, | |
| 28 | + }, | |
| 29 | + ]; | |
| 30 | +} | |
| 31 | + | |
| 32 | +watch( | |
| 33 | + logoUrl, | |
| 34 | + (url) => { | |
| 35 | + syncFileListFromUrl(url ?? ''); | |
| 36 | + }, | |
| 37 | + { immediate: true }, | |
| 38 | +); | |
| 39 | + | |
| 40 | +const beforeUpload: UploadProps['beforeUpload'] = (file) => { | |
| 41 | + const isImage = file.type?.startsWith('image/'); | |
| 42 | + if (!isImage) { | |
| 43 | + message.error($t('foodLabeling.platform.logoUploadImageOnly')); | |
| 44 | + return Upload.LIST_IGNORE; | |
| 45 | + } | |
| 46 | + if (file.size / 1024 / 1024 > 2) { | |
| 47 | + message.error($t('foodLabeling.platform.logoUploadMaxSize')); | |
| 48 | + return Upload.LIST_IGNORE; | |
| 49 | + } | |
| 50 | + | |
| 51 | + const reader = new FileReader(); | |
| 52 | + reader.onload = () => { | |
| 53 | + const dataUrl = String(reader.result ?? ''); | |
| 54 | + logoUrl.value = dataUrl; | |
| 55 | + fileList.value = [ | |
| 56 | + { | |
| 57 | + name: file.name, | |
| 58 | + status: 'done', | |
| 59 | + uid: file.uid, | |
| 60 | + url: dataUrl, | |
| 61 | + }, | |
| 62 | + ]; | |
| 63 | + }; | |
| 64 | + reader.readAsDataURL(file); | |
| 65 | + return false; | |
| 66 | +}; | |
| 67 | + | |
| 68 | +function handleRemove() { | |
| 69 | + logoUrl.value = ''; | |
| 70 | + fileList.value = []; | |
| 71 | +} | |
| 72 | +</script> | |
| 73 | + | |
| 74 | +<template> | |
| 75 | + <Upload | |
| 76 | + v-model:file-list="fileList" | |
| 77 | + accept="image/*" | |
| 78 | + class="saas-tenant-logo-upload" | |
| 79 | + list-type="picture-card" | |
| 80 | + :max-count="1" | |
| 81 | + :before-upload="beforeUpload" | |
| 82 | + @remove="handleRemove" | |
| 83 | + > | |
| 84 | + <div v-if="!fileList.length" class="flex flex-col items-center text-gray-500"> | |
| 85 | + <PlusOutlined /> | |
| 86 | + <span class="mt-1 text-xs">{{ $t('foodLabeling.platform.logoUpload') }}</span> | |
| 87 | + </div> | |
| 88 | + </Upload> | |
| 89 | +</template> | |
| 90 | + | |
| 91 | +<style scoped> | |
| 92 | +.saas-tenant-logo-upload :deep(.ant-upload-select), | |
| 93 | +.saas-tenant-logo-upload :deep(.ant-upload-list-item-container) { | |
| 94 | + width: 96px !important; | |
| 95 | + height: 96px !important; | |
| 96 | +} | |
| 97 | +</style> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-menu-drawer.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { SaasTenantDto } from '../../shared/saas-types'; | |
| 3 | + | |
| 4 | +import { ref } from 'vue'; | |
| 5 | + | |
| 6 | +import { useVbenDrawer } from '@vben/common-ui'; | |
| 7 | +import { $t } from '@vben/locales'; | |
| 8 | + | |
| 9 | +import { message } from 'ant-design-vue'; | |
| 10 | + | |
| 11 | +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data'; | |
| 12 | +import MenuPermissionTree from '../../shared/menu-permission-tree.vue'; | |
| 13 | + | |
| 14 | +const emit = defineEmits<{ reload: [] }>(); | |
| 15 | + | |
| 16 | +const record = ref<SaasTenantDto | null>(null); | |
| 17 | +const menuKeys = ref<string[]>([]); | |
| 18 | + | |
| 19 | +const [BasicDrawer, drawerApi] = useVbenDrawer({ | |
| 20 | + class: 'w-[480px]', | |
| 21 | + onConfirm: handleSave, | |
| 22 | + onOpenChange(isOpen) { | |
| 23 | + if (!isOpen) { | |
| 24 | + record.value = null; | |
| 25 | + return; | |
| 26 | + } | |
| 27 | + const data = drawerApi.getData() as { id?: string }; | |
| 28 | + const found = MOCK_SAAS_TENANTS.find((x) => x.id === data?.id) ?? null; | |
| 29 | + record.value = found; | |
| 30 | + menuKeys.value = found ? [...found.menuPermissionKeys] : []; | |
| 31 | + }, | |
| 32 | +}); | |
| 33 | + | |
| 34 | +async function handleSave() { | |
| 35 | + if (!record.value) { | |
| 36 | + return; | |
| 37 | + } | |
| 38 | + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === record.value!.id); | |
| 39 | + if (idx >= 0) { | |
| 40 | + MOCK_SAAS_TENANTS[idx]!.menuPermissionKeys = [...menuKeys.value]; | |
| 41 | + } | |
| 42 | + message.success($t('foodLabeling.platform.menuSaved')); | |
| 43 | + emit('reload'); | |
| 44 | + drawerApi.close(); | |
| 45 | +} | |
| 46 | +</script> | |
| 47 | + | |
| 48 | +<template> | |
| 49 | + <BasicDrawer :title="$t('foodLabeling.platform.configureMenus')"> | |
| 50 | + <p v-if="record" class="mb-3 text-sm text-gray-600"> | |
| 51 | + {{ $t('foodLabeling.platform.configureMenusHint', { name: record.companyName }) }} | |
| 52 | + </p> | |
| 53 | + <MenuPermissionTree v-model="menuKeys" :height="420" /> | |
| 54 | + </BasicDrawer> | |
| 55 | +</template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/platform/tenants/tenant-modal.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { SaasTenantDto } from '../../shared/saas-types'; | |
| 3 | + | |
| 4 | +import { computed, markRaw, ref } from 'vue'; | |
| 5 | + | |
| 6 | +import { useVbenModal } from '@vben/common-ui'; | |
| 7 | +import { $t } from '@vben/locales'; | |
| 8 | +import { cloneDeep } from '@vben/utils'; | |
| 9 | + | |
| 10 | +import { message } from 'ant-design-vue'; | |
| 11 | + | |
| 12 | +import { useVbenForm } from '#/adapter/form'; | |
| 13 | + | |
| 14 | +import { MOCK_SAAS_TENANTS } from '../../shared/mock-platform-data'; | |
| 15 | +import { stateModalSchema } from '../../shared/form-schema'; | |
| 16 | +import { ALL_SAAS_MENU_KEYS } from '../../shared/saas-menu-tree'; | |
| 17 | +import TenantLogoUploadField from './tenant-logo-upload-field.vue'; | |
| 18 | + | |
| 19 | +const emit = defineEmits<{ reload: [] }>(); | |
| 20 | + | |
| 21 | +const isUpdate = ref(false); | |
| 22 | +const recordId = ref(''); | |
| 23 | + | |
| 24 | +const title = computed(() => | |
| 25 | + isUpdate.value | |
| 26 | + ? $t('foodLabeling.platform.editCompany') | |
| 27 | + : $t('foodLabeling.platform.addCompany'), | |
| 28 | +); | |
| 29 | + | |
| 30 | +const [BasicForm, formApi] = useVbenForm({ | |
| 31 | + commonConfig: { | |
| 32 | + formItemClass: 'col-span-2', | |
| 33 | + labelWidth: 120, | |
| 34 | + componentProps: { class: 'w-full' }, | |
| 35 | + }, | |
| 36 | + schema: [ | |
| 37 | + { | |
| 38 | + component: 'Input', | |
| 39 | + fieldName: 'companyName', | |
| 40 | + label: $t('foodLabeling.platform.companyName'), | |
| 41 | + rules: 'required', | |
| 42 | + }, | |
| 43 | + { | |
| 44 | + component: 'Input', | |
| 45 | + fieldName: 'companyCode', | |
| 46 | + label: $t('foodLabeling.platform.companyCode'), | |
| 47 | + rules: 'required', | |
| 48 | + componentProps: { placeholder: 'SIAM_FRESH' }, | |
| 49 | + }, | |
| 50 | + { | |
| 51 | + component: markRaw(TenantLogoUploadField), | |
| 52 | + fieldName: 'logoUrl', | |
| 53 | + label: $t('foodLabeling.platform.logo'), | |
| 54 | + formItemClass: 'col-span-2 items-start', | |
| 55 | + componentProps: { class: 'w-auto' }, | |
| 56 | + }, | |
| 57 | + { | |
| 58 | + component: 'Input', | |
| 59 | + fieldName: 'contactName', | |
| 60 | + label: $t('foodLabeling.platform.contactName'), | |
| 61 | + }, | |
| 62 | + { | |
| 63 | + component: 'Input', | |
| 64 | + fieldName: 'contactEmail', | |
| 65 | + label: $t('foodLabeling.accountManagement.contactEmail'), | |
| 66 | + }, | |
| 67 | + { | |
| 68 | + component: 'Input', | |
| 69 | + fieldName: 'contactPhone', | |
| 70 | + label: $t('foodLabeling.accountManagement.phoneNumber'), | |
| 71 | + }, | |
| 72 | + { | |
| 73 | + component: 'Textarea', | |
| 74 | + fieldName: 'address', | |
| 75 | + label: $t('foodLabeling.platform.address'), | |
| 76 | + formItemClass: 'col-span-2', | |
| 77 | + }, | |
| 78 | + { | |
| 79 | + component: 'Textarea', | |
| 80 | + fieldName: 'remark', | |
| 81 | + label: $t('foodLabeling.accountManagement.remark'), | |
| 82 | + formItemClass: 'col-span-2', | |
| 83 | + }, | |
| 84 | + { | |
| 85 | + component: 'Input', | |
| 86 | + fieldName: 'adminUserName', | |
| 87 | + label: $t('foodLabeling.accountManagement.userName'), | |
| 88 | + rules: 'required', | |
| 89 | + dependencies: { | |
| 90 | + if: () => !isUpdate.value, | |
| 91 | + triggerFields: ['companyName'], | |
| 92 | + }, | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + component: 'InputPassword', | |
| 96 | + fieldName: 'adminPassword', | |
| 97 | + label: $t('foodLabeling.accountManagement.password'), | |
| 98 | + rules: 'required', | |
| 99 | + dependencies: { | |
| 100 | + if: () => !isUpdate.value, | |
| 101 | + triggerFields: ['companyName'], | |
| 102 | + }, | |
| 103 | + }, | |
| 104 | + { | |
| 105 | + component: 'Input', | |
| 106 | + fieldName: 'adminFullName', | |
| 107 | + label: $t('foodLabeling.accountManagement.fullName'), | |
| 108 | + rules: 'required', | |
| 109 | + dependencies: { | |
| 110 | + if: () => !isUpdate.value, | |
| 111 | + triggerFields: ['companyName'], | |
| 112 | + }, | |
| 113 | + }, | |
| 114 | + { | |
| 115 | + component: 'Input', | |
| 116 | + fieldName: 'adminEmail', | |
| 117 | + label: $t('foodLabeling.accountManagement.contactEmail'), | |
| 118 | + dependencies: { | |
| 119 | + if: () => !isUpdate.value, | |
| 120 | + triggerFields: ['companyName'], | |
| 121 | + }, | |
| 122 | + }, | |
| 123 | + ...stateModalSchema().map((item) => ({ | |
| 124 | + ...item, | |
| 125 | + componentProps: { | |
| 126 | + ...(item.componentProps as Record<string, unknown>), | |
| 127 | + class: 'w-fit', | |
| 128 | + }, | |
| 129 | + })), | |
| 130 | + ], | |
| 131 | + showDefaultActions: false, | |
| 132 | + wrapperClass: 'grid-cols-2', | |
| 133 | +}); | |
| 134 | + | |
| 135 | +const [BasicModal, modalApi] = useVbenModal({ | |
| 136 | + class: 'w-[760px]', | |
| 137 | + fullscreenButton: false, | |
| 138 | + onCancel: handleCancel, | |
| 139 | + onConfirm: handleConfirm, | |
| 140 | + onOpenChange: async (isOpen) => { | |
| 141 | + if (!isOpen) { | |
| 142 | + return null; | |
| 143 | + } | |
| 144 | + modalApi.modalLoading(true); | |
| 145 | + const data = modalApi.getData() as { id?: string }; | |
| 146 | + isUpdate.value = !!data?.id; | |
| 147 | + recordId.value = data?.id ?? ''; | |
| 148 | + | |
| 149 | + if (isUpdate.value && recordId.value) { | |
| 150 | + const record = | |
| 151 | + MOCK_SAAS_TENANTS.find((x) => x.id === recordId.value) ?? ({} as SaasTenantDto); | |
| 152 | + await formApi.setValues({ | |
| 153 | + companyName: record.companyName, | |
| 154 | + companyCode: record.companyCode, | |
| 155 | + logoUrl: record.logoUrl ?? '', | |
| 156 | + contactName: record.contactName ?? '', | |
| 157 | + contactEmail: record.contactEmail ?? '', | |
| 158 | + contactPhone: record.contactPhone ?? '', | |
| 159 | + address: record.address ?? '', | |
| 160 | + remark: record.remark ?? '', | |
| 161 | + state: record.state !== false, | |
| 162 | + }); | |
| 163 | + } else { | |
| 164 | + await formApi.resetForm(); | |
| 165 | + await formApi.setValues({ state: true }); | |
| 166 | + } | |
| 167 | + modalApi.modalLoading(false); | |
| 168 | + }, | |
| 169 | +}); | |
| 170 | + | |
| 171 | +async function handleConfirm() { | |
| 172 | + try { | |
| 173 | + modalApi.modalLoading(true); | |
| 174 | + const { valid } = await formApi.validate(); | |
| 175 | + if (!valid) { | |
| 176 | + return; | |
| 177 | + } | |
| 178 | + const values = cloneDeep(await formApi.getValues()) as Record<string, unknown>; | |
| 179 | + if (isUpdate.value && recordId.value) { | |
| 180 | + const idx = MOCK_SAAS_TENANTS.findIndex((x) => x.id === recordId.value); | |
| 181 | + if (idx >= 0) { | |
| 182 | + const prev = MOCK_SAAS_TENANTS[idx]!; | |
| 183 | + MOCK_SAAS_TENANTS[idx] = { | |
| 184 | + ...prev, | |
| 185 | + companyName: String(values.companyName ?? '').trim(), | |
| 186 | + companyCode: String(values.companyCode ?? '').trim(), | |
| 187 | + logoUrl: String(values.logoUrl ?? '').trim() || null, | |
| 188 | + contactName: String(values.contactName ?? '').trim() || null, | |
| 189 | + contactEmail: String(values.contactEmail ?? '').trim() || null, | |
| 190 | + contactPhone: String(values.contactPhone ?? '').trim() || null, | |
| 191 | + address: String(values.address ?? '').trim() || null, | |
| 192 | + remark: String(values.remark ?? '').trim() || null, | |
| 193 | + state: values.state !== false, | |
| 194 | + }; | |
| 195 | + } | |
| 196 | + } else { | |
| 197 | + const id = crypto.randomUUID(); | |
| 198 | + MOCK_SAAS_TENANTS.push({ | |
| 199 | + id, | |
| 200 | + companyName: String(values.companyName ?? '').trim(), | |
| 201 | + companyCode: String(values.companyCode ?? '').trim(), | |
| 202 | + logoUrl: String(values.logoUrl ?? '').trim() || null, | |
| 203 | + contactName: String(values.contactName ?? '').trim() || null, | |
| 204 | + contactEmail: String(values.contactEmail ?? '').trim() || null, | |
| 205 | + contactPhone: String(values.contactPhone ?? '').trim() || null, | |
| 206 | + address: String(values.address ?? '').trim() || null, | |
| 207 | + remark: String(values.remark ?? '').trim() || null, | |
| 208 | + state: values.state !== false, | |
| 209 | + menuPermissionKeys: [...ALL_SAAS_MENU_KEYS], | |
| 210 | + adminUserId: `admin-${id.slice(0, 8)}`, | |
| 211 | + adminUserName: String(values.adminUserName ?? '').trim(), | |
| 212 | + adminFullName: String(values.adminFullName ?? '').trim(), | |
| 213 | + adminEmail: String(values.adminEmail ?? '').trim() || null, | |
| 214 | + creationTime: new Date().toISOString().slice(0, 16).replace('T', ' '), | |
| 215 | + }); | |
| 216 | + } | |
| 217 | + message.success($t('foodLabeling.common.staticDemoAction')); | |
| 218 | + emit('reload'); | |
| 219 | + await handleCancel(); | |
| 220 | + } finally { | |
| 221 | + modalApi.modalLoading(false); | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +async function handleCancel() { | |
| 226 | + modalApi.close(); | |
| 227 | + await formApi.resetForm(); | |
| 228 | +} | |
| 229 | +</script> | |
| 230 | + | |
| 231 | +<template> | |
| 232 | + <BasicModal :title="title"> | |
| 233 | + <a-alert | |
| 234 | + v-if="!isUpdate" | |
| 235 | + class="mb-4" | |
| 236 | + :message="$t('foodLabeling.platform.initialAdminSection')" | |
| 237 | + show-icon | |
| 238 | + type="info" | |
| 239 | + /> | |
| 240 | + <BasicForm /> | |
| 241 | + </BasicModal> | |
| 242 | +</template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/labeling-grid.ts
| 1 | 1 | export { |
| 2 | + foodLabelingActionColumn, | |
| 2 | 3 | managementTabGridBase as labelingGridBase, |
| 4 | + managementTabPageContentClass as labelingPageContentClass, | |
| 5 | + managementTabTableClass as labelingTableClass, | |
| 3 | 6 | managementTabVxeGridClass as labelingVxeGridClass, |
| 4 | 7 | } from '../management/shared/management-grid'; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree-field.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import MenuPermissionTree from './menu-permission-tree.vue'; | |
| 3 | + | |
| 4 | +const modelValue = defineModel<string[]>({ default: () => [] }); | |
| 5 | + | |
| 6 | +defineProps<{ | |
| 7 | + allowedKeys?: string[] | null; | |
| 8 | +}>(); | |
| 9 | +</script> | |
| 10 | + | |
| 11 | +<template> | |
| 12 | + <MenuPermissionTree v-model="modelValue" :allowed-keys="allowedKeys" /> | |
| 13 | +</template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/menu-permission-tree.vue
0 → 100644
| 1 | +<script lang="ts" setup> | |
| 2 | +import type { SaasMenuTreeNode } from './saas-types'; | |
| 3 | + | |
| 4 | +import { computed, watch } from 'vue'; | |
| 5 | + | |
| 6 | +import { $t } from '@vben/locales'; | |
| 7 | + | |
| 8 | +import { Tree } from 'ant-design-vue'; | |
| 9 | + | |
| 10 | +import { | |
| 11 | + filterMenuTreeByAllowed, | |
| 12 | + normalizeMenuKeysWithParents, | |
| 13 | + SAAS_MENU_TREE, | |
| 14 | +} from './saas-menu-tree'; | |
| 15 | + | |
| 16 | +const props = withDefaults( | |
| 17 | + defineProps<{ | |
| 18 | + /** 为空表示平台配置,展示全部菜单 */ | |
| 19 | + allowedKeys?: string[] | null; | |
| 20 | + disabled?: boolean; | |
| 21 | + height?: number; | |
| 22 | + }>(), | |
| 23 | + { | |
| 24 | + allowedKeys: null, | |
| 25 | + disabled: false, | |
| 26 | + height: 320, | |
| 27 | + }, | |
| 28 | +); | |
| 29 | + | |
| 30 | +const modelValue = defineModel<string[]>({ default: () => [] }); | |
| 31 | + | |
| 32 | +const checkedKeys = computed({ | |
| 33 | + get: () => modelValue.value ?? [], | |
| 34 | + set: (keys: string[]) => { | |
| 35 | + modelValue.value = normalizeMenuKeysWithParents(keys); | |
| 36 | + }, | |
| 37 | +}); | |
| 38 | + | |
| 39 | +const treeData = computed(() => { | |
| 40 | + const nodes = props.allowedKeys?.length | |
| 41 | + ? filterMenuTreeByAllowed(props.allowedKeys, SAAS_MENU_TREE) | |
| 42 | + : SAAS_MENU_TREE; | |
| 43 | + return mapNodes(nodes); | |
| 44 | +}); | |
| 45 | + | |
| 46 | +function mapNodes(nodes: SaasMenuTreeNode[]) { | |
| 47 | + return nodes.map((node) => ({ | |
| 48 | + key: node.key, | |
| 49 | + title: $t(node.titleKey), | |
| 50 | + children: node.children?.length ? mapNodes(node.children) : undefined, | |
| 51 | + })); | |
| 52 | +} | |
| 53 | + | |
| 54 | +watch( | |
| 55 | + () => props.allowedKeys, | |
| 56 | + (allowed) => { | |
| 57 | + if (!allowed?.length || !modelValue.value?.length) { | |
| 58 | + return; | |
| 59 | + } | |
| 60 | + const set = new Set(allowed); | |
| 61 | + const filtered = modelValue.value.filter((k) => set.has(k)); | |
| 62 | + if (filtered.length !== modelValue.value.length) { | |
| 63 | + modelValue.value = filtered; | |
| 64 | + } | |
| 65 | + }, | |
| 66 | +); | |
| 67 | +</script> | |
| 68 | + | |
| 69 | +<template> | |
| 70 | + <div | |
| 71 | + class="rounded-md border border-gray-200 bg-gray-50/80 p-3" | |
| 72 | + :style="{ maxHeight: `${height}px`, overflow: 'auto' }" | |
| 73 | + > | |
| 74 | + <Tree | |
| 75 | + v-model:checked-keys="checkedKeys" | |
| 76 | + checkable | |
| 77 | + :disabled="disabled" | |
| 78 | + :selectable="false" | |
| 79 | + :tree-data="treeData" | |
| 80 | + default-expand-all | |
| 81 | + /> | |
| 82 | + </div> | |
| 83 | +</template> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/mock-management-data.ts
| ... | ... | @@ -40,6 +40,19 @@ export const MOCK_ROLES: RoleDto[] = [ |
| 40 | 40 | 'view_reports', |
| 41 | 41 | 'approve_batches', |
| 42 | 42 | ], |
| 43 | + menuPermissionKeys: [ | |
| 44 | + 'dashboard', | |
| 45 | + 'dashboard:analytics', | |
| 46 | + 'labeling', | |
| 47 | + 'labeling:labels', | |
| 48 | + 'labeling:categories', | |
| 49 | + 'labeling:types', | |
| 50 | + 'labeling:templates', | |
| 51 | + 'management', | |
| 52 | + 'management:account', | |
| 53 | + 'management:menu', | |
| 54 | + 'management:reports', | |
| 55 | + ], | |
| 43 | 56 | creationTime: '2026-01-10 09:00', |
| 44 | 57 | }, |
| 45 | 58 | { |
| ... | ... | @@ -50,6 +63,13 @@ export const MOCK_ROLES: RoleDto[] = [ |
| 50 | 63 | state: true, |
| 51 | 64 | orderNum: 2, |
| 52 | 65 | accessPermissionCodes: ['manage_labels', 'manage_products', 'view_reports'], |
| 66 | + menuPermissionKeys: [ | |
| 67 | + 'dashboard', | |
| 68 | + 'dashboard:analytics', | |
| 69 | + 'labeling:labels', | |
| 70 | + 'management:menu', | |
| 71 | + 'management:reports', | |
| 72 | + ], | |
| 53 | 73 | creationTime: '2026-01-12 14:30', |
| 54 | 74 | }, |
| 55 | 75 | { |
| ... | ... | @@ -60,6 +80,7 @@ export const MOCK_ROLES: RoleDto[] = [ |
| 60 | 80 | state: false, |
| 61 | 81 | orderNum: 3, |
| 62 | 82 | accessPermissionCodes: ['manage_labels'], |
| 83 | + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels'], | |
| 63 | 84 | creationTime: '2026-02-01 11:00', |
| 64 | 85 | }, |
| 65 | 86 | ]; |
| ... | ... | @@ -371,6 +392,8 @@ export const MOCK_TEAM_MEMBERS: TeamMemberDto[] = [ |
| 371 | 392 | locationIds: ['loc-1', 'loc-2'], |
| 372 | 393 | locations: ['SA001 - Store A', 'SB001 - Store B'], |
| 373 | 394 | state: true, |
| 395 | + useCustomMenuPermissions: true, | |
| 396 | + menuPermissionKeys: ['dashboard:analytics', 'labeling:labels', 'management:reports'], | |
| 374 | 397 | }, |
| 375 | 398 | { |
| 376 | 399 | id: 'tm-2', | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/mock-platform-data.ts
0 → 100644
| 1 | +import type { SaasTenantDto } from './saas-types'; | |
| 2 | + | |
| 3 | +import { ALL_SAAS_MENU_KEYS } from './saas-menu-tree'; | |
| 4 | + | |
| 5 | +export const MOCK_SAAS_TENANTS: SaasTenantDto[] = [ | |
| 6 | + { | |
| 7 | + id: '11111111-1111-1111-1111-111111111111', | |
| 8 | + companyName: 'Default 演示公司', | |
| 9 | + companyCode: 'DEFAULT', | |
| 10 | + logoUrl: null, | |
| 11 | + contactName: '平台演示', | |
| 12 | + contactEmail: 'admin@default.example.com', | |
| 13 | + contactPhone: '400-000-0001', | |
| 14 | + address: 'Bangkok, TH', | |
| 15 | + remark: '迁移期默认租户', | |
| 16 | + state: true, | |
| 17 | + menuPermissionKeys: [...ALL_SAAS_MENU_KEYS], | |
| 18 | + adminUserId: 'admin-default', | |
| 19 | + adminUserName: 'company.admin', | |
| 20 | + adminFullName: '公司管理员', | |
| 21 | + adminEmail: 'admin@default.example.com', | |
| 22 | + creationTime: '2026-01-01 00:00', | |
| 23 | + }, | |
| 24 | + { | |
| 25 | + id: '22222222-2222-2222-2222-222222222222', | |
| 26 | + companyName: 'Siam Fresh Foods', | |
| 27 | + companyCode: 'SIAM_FRESH', | |
| 28 | + logoUrl: null, | |
| 29 | + contactName: 'Somchai', | |
| 30 | + contactEmail: 'contact@siamfresh.example.com', | |
| 31 | + contactPhone: '+66-2-123-4567', | |
| 32 | + address: 'Bangkok', | |
| 33 | + remark: null, | |
| 34 | + state: true, | |
| 35 | + menuPermissionKeys: [ | |
| 36 | + 'dashboard', | |
| 37 | + 'dashboard:analytics', | |
| 38 | + 'labeling', | |
| 39 | + 'labeling:labels', | |
| 40 | + 'labeling:templates', | |
| 41 | + 'management', | |
| 42 | + 'management:account', | |
| 43 | + 'management:reports', | |
| 44 | + 'management:devices', | |
| 45 | + ], | |
| 46 | + adminUserId: 'admin-siam', | |
| 47 | + adminUserName: 'siam.admin', | |
| 48 | + adminFullName: 'Siam Admin', | |
| 49 | + adminEmail: 'admin@siamfresh.example.com', | |
| 50 | + creationTime: '2026-03-15 10:00', | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + id: '33333333-3333-3333-3333-333333333333', | |
| 54 | + companyName: 'Northern Retail Group', | |
| 55 | + companyCode: 'NORTH_RETAIL', | |
| 56 | + logoUrl: null, | |
| 57 | + contactName: 'Jane', | |
| 58 | + contactEmail: 'ops@northretail.example.com', | |
| 59 | + contactPhone: '+66-53-999-8888', | |
| 60 | + address: 'Chiang Mai', | |
| 61 | + remark: '试用中', | |
| 62 | + state: false, | |
| 63 | + menuPermissionKeys: [ | |
| 64 | + 'dashboard', | |
| 65 | + 'dashboard:analytics', | |
| 66 | + 'management', | |
| 67 | + 'management:account', | |
| 68 | + ], | |
| 69 | + adminUserId: 'admin-north', | |
| 70 | + adminUserName: 'north.admin', | |
| 71 | + adminFullName: 'North Admin', | |
| 72 | + adminEmail: 'ops@northretail.example.com', | |
| 73 | + creationTime: '2026-04-01 14:20', | |
| 74 | + }, | |
| 75 | +]; | ... | ... |