优化配送订单详情关联与参数展示

This commit is contained in:
czl231
2026-08-15 20:32:43 +08:00
parent 42a4fc2f41
commit a20e228296
14 changed files with 584 additions and 55 deletions

View File

@@ -1,5 +1,5 @@
// 功能描述:实现平台配送合同、配送订单及履约状态动作。
// 版本v1.2.0
// 版本v1.3.0
package gasorder
import (
@@ -171,12 +171,13 @@ func getGasorderBasic(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
displayOrder := gasorderDetailDisplay{
GasorderBasic: order,
ContractDisplayName: contractDisplayName(contractNames[order.GasorderContractID]),
displayOrder, displayAssignments, err := buildGasorderDetailDisplays(order, assignments, contractNames[order.GasorderContractID])
if err != nil {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(gin.H{
"order": displayOrder, "items": items, "assignments": assignments, "statuses": statuses,
"order": displayOrder, "items": items, "assignments": displayAssignments, "statuses": statuses,
"tracks": tracks, "confirmations": confirmations, "payments": payments,
})
if err != nil {
@@ -685,7 +686,7 @@ func CreateGasorderBasic(ctx *gin.Context) {
return err
}
}
return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "order created", operatorIdentity, operatorName)).Error
return tx.Create(gasorderStatusRecord(order.ID, common.StatusDraft, common.StatusCreated, "订单创建", operatorIdentity, operatorName)).Error
})
if err != nil {
if lookupErr := impl.DBService.Where("request_no = ?", request.RequestNo).First(&order).Error; lookupErr == nil {

View File

@@ -1,5 +1,5 @@
// 功能描述:为平台配送订单列表补充创建方名称、收货地址和合同气瓶摘要。
// 版本v1.0.0
// 功能描述:为平台配送订单列表与详情补充关联名称、地址和合同气瓶摘要。
// 版本v1.1.0
package gasorder
import (
@@ -21,10 +21,31 @@ type gasorderListDisplay struct {
ContractProductsSummary string `json:"contract_products_summary"`
}
// gasorderDetailDisplay 为订单详情补充合同当前标题和编号,不改变订单持久化快照。
// gasorderDetailDisplay 为订单详情补充当前关联对象的可读名称,不改变订单持久化快照。
type gasorderDetailDisplay struct {
models.GasorderBasic
ContractDisplayName string `json:"contract_display_name"`
ContractDisplayName string `json:"contract_display_name"`
CreatorDisplayName string `json:"creator_display_name"`
UserAccountDisplayName string `json:"user_account_display_name"`
GasBasicDisplayName string `json:"gas_basic_display_name"`
DeliveryDisplayName string `json:"delivery_basic_display_name"`
StaffAccountDisplayName string `json:"staff_account_display_name"`
}
// gasorderAssignmentDisplay 为不可变分配事实补充当前机构和人员名称。
type gasorderAssignmentDisplay struct {
models.GasorderAssign
GasBasicDisplayName string `json:"gas_basic_display_name"`
DeliveryDisplayName string `json:"delivery_basic_display_name"`
StaffAccountDisplayName string `json:"staff_account_display_name"`
}
// gasorderRelationDisplayNames 保存订单详情所需的当前关联名称索引。
type gasorderRelationDisplayNames struct {
gas map[uint64]string
delivery map[uint64]string
staff map[uint64]string
user map[uint64]string
}
// ListGasorderBasic 返回平台订单分页列表,并补充只用于管理端展示的可读摘要。
@@ -178,6 +199,114 @@ func firstReadableName(values ...string) string {
return ""
}
// buildGasorderDetailDisplays 统一组装订单主体和分配记录的可读名称。
func buildGasorderDetailDisplays(order models.GasorderBasic, assignments []models.GasorderAssign, contract models.GasorderContract) (gasorderDetailDisplay, []gasorderAssignmentDisplay, error) {
creatorNames, err := loadGasorderCreatorNames([]models.GasorderBasic{order})
if err != nil {
return gasorderDetailDisplay{}, nil, err
}
names, err := loadGasorderRelationDisplayNames(order, assignments)
if err != nil {
return gasorderDetailDisplay{}, nil, err
}
detail := gasorderDetailDisplay{
GasorderBasic: order,
ContractDisplayName: contractDisplayName(contract),
CreatorDisplayName: readableRelationName(creatorNames, creatorLookupKey(order.CreatorType, order.CreatorID), order.CreatorID, "记录已失效"),
UserAccountDisplayName: readableRelationName(names.user, order.UserAccountID, order.UserAccountID, "记录已失效"),
GasBasicDisplayName: readableRelationName(names.gas, order.GasBasicID, order.GasBasicID, "记录已失效"),
DeliveryDisplayName: readableRelationName(names.delivery, order.DeliveryBasicID, order.DeliveryBasicID, "暂未分配"),
StaffAccountDisplayName: readableRelationName(names.staff, order.StaffAccountID, order.StaffAccountID, "暂未分配"),
}
displays := make([]gasorderAssignmentDisplay, 0, len(assignments))
for _, assignment := range assignments {
displays = append(displays, gasorderAssignmentDisplay{
GasorderAssign: assignment,
GasBasicDisplayName: readableRelationName(names.gas, assignment.GasBasicID, assignment.GasBasicID, "记录已失效"),
DeliveryDisplayName: readableRelationName(names.delivery, assignment.DeliveryBasicID, assignment.DeliveryBasicID, "记录已失效"),
StaffAccountDisplayName: readableRelationName(names.staff, assignment.StaffAccountID, assignment.StaffAccountID, "记录已失效"),
})
}
return detail, displays, nil
}
// readableRelationName 按关联主键读取名称,并为未分配或失效记录提供稳定文案。
func readableRelationName[K comparable](names map[K]string, key K, id uint64, emptyText string) string {
if id == 0 {
return emptyText
}
if name := strings.TrimSpace(names[key]); name != "" {
return name
}
return "记录已失效"
}
// loadGasorderRelationDisplayNames 批量读取订单主体与全部分配历史引用的机构和人员名称。
func loadGasorderRelationDisplayNames(order models.GasorderBasic, assignments []models.GasorderAssign) (gasorderRelationDisplayNames, error) {
gasIDs := map[uint64]struct{}{order.GasBasicID: {}}
deliveryIDs := map[uint64]struct{}{}
staffIDs := map[uint64]struct{}{}
userIDs := map[uint64]struct{}{order.UserAccountID: {}}
if order.DeliveryBasicID > 0 {
deliveryIDs[order.DeliveryBasicID] = struct{}{}
}
if order.StaffAccountID > 0 {
staffIDs[order.StaffAccountID] = struct{}{}
}
for _, assignment := range assignments {
gasIDs[assignment.GasBasicID] = struct{}{}
deliveryIDs[assignment.DeliveryBasicID] = struct{}{}
staffIDs[assignment.StaffAccountID] = struct{}{}
}
names := gasorderRelationDisplayNames{
gas: map[uint64]string{}, delivery: map[uint64]string{},
staff: map[uint64]string{}, user: map[uint64]string{},
}
var stations []models.GasBasic
if err := loadGasorderDisplayRecords(gasIDs, &stations); err != nil {
return names, err
}
for _, item := range stations {
names.gas[item.ID] = firstReadableName(item.Name, item.Code)
}
var deliveries []models.DeliveryBasic
if err := loadGasorderDisplayRecords(deliveryIDs, &deliveries); err != nil {
return names, err
}
for _, item := range deliveries {
names.delivery[item.ID] = firstReadableName(item.Name, item.DeliveryCode)
}
var staff []models.StaffAccount
if err := loadGasorderDisplayRecords(staffIDs, &staff); err != nil {
return names, err
}
for _, item := range staff {
names.staff[item.ID] = firstReadableName(item.Name, item.Username)
}
var users []models.UserAccount
if err := loadGasorderDisplayRecords(userIDs, &users); err != nil {
return names, err
}
for _, item := range users {
names.user[item.ID] = firstReadableName(item.Name, item.RealName, item.Username)
}
return names, nil
}
// loadGasorderDisplayRecords 按去重主键读取可读档案;零值主键不发起查询。
func loadGasorderDisplayRecords(ids map[uint64]struct{}, records any) error {
values := make([]uint64, 0, len(ids))
for id := range ids {
if id > 0 {
values = append(values, id)
}
}
if len(values) == 0 {
return nil
}
return impl.DBService.Where("id IN ?", values).Find(records).Error
}
// loadGasorderProductSummaries 生成“名称(类型)”或“名称(类型)等 N 个”的订单气瓶摘要。
func loadGasorderProductSummaries(orders []models.GasorderBasic) (map[uint64]string, error) {
orderIDs := make([]uint64, 0, len(orders))

View File

@@ -1,5 +1,5 @@
// 功能描述:验证平台配送订单列表的可读名称、地址恢复和空值兜底规则。
// 版本v1.0.0
// 版本v1.1.0
package gasorder
import (
@@ -56,3 +56,25 @@ func TestContractDisplayName(t *testing.T) {
})
}
}
// TestReadableRelationName 验证详情关联名称区分正常、未分配和关联失效三种状态。
func TestReadableRelationName(t *testing.T) {
names := map[uint64]string{1: " 和气一站 "}
tests := []struct {
name string
key uint64
id uint64
want string
}{
{name: "正常名称", key: 1, id: 1, want: "和气一站"},
{name: "尚未分配", key: 0, id: 0, want: "暂未分配"},
{name: "关联失效", key: 2, id: 2, want: "记录已失效"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := readableRelationName(names, test.key, test.id, "暂未分配"); got != test.want {
t.Fatalf("关联展示名称 = %q期望 %q", got, test.want)
}
})
}
}

View File

@@ -210,6 +210,8 @@
订单合同候选必须同时满足“生效中、已到生效时间、尚未到期且到期时间有效”。草稿、未到生效时间、已到期和已终止合同不得进入订单候选;没有可履约合同时应引导管理员先启用或续签合同并禁止提交。
配送订单详情以订单号作为主要业务识别字段,请求流水号作为幂等追踪字段,系统唯一标识仅用于技术定位和复制。配送合同、创建方、服务用户、履约气站、当前配送点、当前配送人员和建单操作人均以当前业务名称或订单快照名称为主文案,并保留对应唯一标识复制能力;关联失效或尚未分配时显示明确中文状态。分配记录固定展示分配时间、气站、配送点、配送人员、分配人和分配原因,表头不得回退为英文接口键名。
订单状态机如下:
```text

View File

@@ -0,0 +1,55 @@
# 操作日志:配送订单设备参数展示
操作时间2026-08-15
操作类型:修改
影响模块:平台总后台、配送订单详情、订单明细关联表
## 操作前状态
订单明细的 `product_params` 使用普通固定布局表格单元格展示,并统一启用省略号。较长 JSON 只能看到开头字符,列位于表格右侧时几乎无法辨认完整内容。
## 具体操作
- 将订单明细设备参数列改为宽度固定的“查看参数”入口。
- 悬浮或点击入口后,在受视口宽度和高度限制的浮层中展示完整参数。
- JSON 字符串和对象统一使用两空格缩进格式化;历史非 JSON 文本保持原样。
- 增加详情设备参数静态回归检查及 npm 脚本。
## 操作后状态
订单详情表格不再直接塞入被截断的参数原文。用户可通过“查看参数”读取完整格式化内容,长参数支持浮层内滚动,不会继续撑宽页面。
## 代码变更
- `frontend/platform_admin/src/views/resource/ResourceDetailContent.vue`:设备参数专用单元格、格式化逻辑及浮层样式。
- `frontend/platform_admin/scripts/check-resource-detail-json-display.mjs`:设备参数完整查看回归检查。
- `frontend/platform_admin/package.json`:新增 `resource-detail-json:check` 命令。
- `docs/项目文档_配送订单合同气瓶显示优化_v1.0.md`:同步订单详情参数展示规则。
## 验证结果
- `pnpm.cmd resource-detail-json:check`:通过。
- `pnpm.cmd type:check`:通过。
- `pnpm.cmd build`通过Vite 成功构建 2621 个模块。
## 风险评估
- 本次仅修改展示层,不改变数据库、订单快照或 API 响应结构。
-`product_params` 列维持原表格展示逻辑。
- 历史空值显示“暂无参数”,历史非法 JSON 不会丢失,按原文本完整展示。
## 100% 缩放补充修复
操作前状态:关联表的内部最小宽度会参与详情页网格的固有宽度计算。在浏览器 100% 缩放、内容区约 1100 像素时,关联表可能反向撑宽整个详情页,导致基本信息右列和订单明细右侧列落到视口之外;缩小浏览器只能暂时掩盖问题。
具体操作:为记录页、详情栈、详情卡、页签内容和表格外壳补充宽度收缩边界;关联表改为在独立容器内处理横向溢出;长标识和值容器允许收缩换行。
操作后状态:页面按 100% 缩放下的真实内容区宽度布局,子表不能再扩张整页;宽度不足时仅表格区域内部滚动,基本信息和业务操作保持在可视区域内。
代码变更:`ResourceDetailContent.vue` 增加关联表宽度外壳和详情内容收缩约束;`ResourceRecordPage.less` 增加页面级最大宽度约束;静态检查同步覆盖整页横向溢出防护。
验证策略:在 1365×768 的 100% 等效视口验证页面壳宽度,并执行设备参数静态检查、类型检查和生产构建。目标历史订单在新 root 会话中返回“无权访问该记录”,因此该会话不能执行真实数据视觉回归,未通过修改权限或数据绕过限制。
风险评估:仅改变展示层的宽度收缩与溢出边界;不改变 API、订单状态、合同约束或设备参数内容。极窄视口下的多列表格可能出现表格内部横向滚动这是保留列信息的预期兜底行为。

View File

@@ -0,0 +1,39 @@
# 操作日志:配送订单详情关联可读展示
操作时间2026-08-15
操作类型:修改
影响模块:平台总后台、配送订单详情、分配记录
## 操作前状态
订单详情突出系统唯一标识,订单号位于后续动态字段中;除配送合同外,气站、用户、配送点、工作人员和创建方主要显示裸唯一标识。分配记录使用通用字段推断,缺少关联名称,并可能显示 `assigner_identity``assigner_name` 等英文表头。
## 具体操作
- 订单详情接口增加创建方、服务用户、气站、配送点和配送人员的只读名称字段。
- 分配历史增加气站、配送点和配送人员名称,不修改不可变事实表。
- 订单号、请求流水号和订单状态提前展示,系统唯一标识改称“系统唯一标识”。
- 分配记录固定为六个中文业务列,不再追加未知响应字段。
- 订单明细和状态记录同步改为固定业务列,移除英文 `active` 表头及多余技术标识;历史 `order created` 展示为“订单创建”,新记录直接写入中文原因。
- 关联名称和建单操作人姓名快照为主文案,稳定唯一标识继续提供复制能力;重复的操作人姓名字段不再单独展示。
## 操作后状态
业务人员可通过订单号和中文业务名称识别订单及分配历史;技术标识仍可复制。分配记录不再泄漏英文接口键名或内部主键。
## 验证结果
- 后端配送订单专项测试、全量 `go test ./...``go vet ./...` 和主程序构建通过。
- 前端配送订单可读显示静态检查、设备参数回归检查和 TypeScript 类型检查通过。
- 前端生产构建通过Vite 成功构建 2621 个模块。
- 本地后端已保留旧版本备份并重启12426 端口恢复监听,健康接口返回 `platform-api / ok`
- 内置浏览器重新登录并访问目标详情后完成真实数据渲染:订单号位于首项,创建方、服务用户、气站、配送点、配送人员和建单操作人均显示名称与可复制标识;分配记录显示六个中文业务列,整页无横向溢出。
- 浏览器验收发现的订单明细 `active` 英文列和状态记录技术列已继续收口为固定中文业务列,并由静态检查、类型检查和生产构建覆盖。
## 风险评估
- 新字段仅用于管理端只读展示,原 JSON 字段和客户端兼容性保持不变。
- 名称取自当前关联档案,档案改名后详情随之变化;分配时间、原因和关联标识仍保持历史事实。
- 关联档案被删除或不可读取时显示“记录已失效”,不伪造历史名称。

View File

@@ -12,6 +12,7 @@
- 支持按气瓶编码和气瓶类型名称搜索。
- 选项显示气瓶类型名称,类型名称缺失时才降级显示唯一标识。
- 多选后完整展示全部已选气瓶标签,方便业务人员逐项核对。
- 订单详情的设备参数列使用紧凑“查看参数”入口,在受视口限制的浮层中格式化展示完整 JSON历史非 JSON 文本保持原样。
- 切换配送合同时清空已选气瓶,并重新加载当前合同候选。
- 没有候选时提示先为合同绑定气瓶,并禁止提交。
- 合同气瓶管理列表不带合同筛选参数时继续展示完整历史。
@@ -34,6 +35,7 @@ GET /gasorder_contract_product?contract_identity=<配送合同唯一标识>&keyw
- `frontend/platform_admin/src/api/resources.ts`:业务标签和空状态配置。
- `frontend/platform_admin/src/api/resource-display.ts`:多字段关系标签组合。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:父级选择顺序提示。
- `frontend/platform_admin/src/views/resource/ResourceDetailContent.vue`:订单明细设备参数完整查看。
- `frontend/platform_admin/scripts/check-gasorder-contract-product-display.mjs`:前端专项静态检查。
## 5. 维护指南
@@ -57,3 +59,15 @@ GET /gasorder_contract_product?contract_identity=<配送合同唯一标识>&keyw
操作后状态:业务人员可通过气瓶类型名称识别候选,订单页面只展示当前合同尚未解绑的气瓶。
风险评估:管理列表不带筛选参数时保持原行为;数据库无变更;订单创建最终校验保持不变。
### v1.0.12026-08-15
- 修复订单详情中设备参数被表格省略号截断的问题。
- 参数列改为紧凑查看入口,完整内容在浮层中格式化展示。
- 本次仅修改展示层,不改变订单快照和 API 响应。
### v1.0.22026-08-15
- 修复浏览器 100% 缩放时,订单明细表反向撑宽详情页并导致右侧内容不可见的问题。
- 详情页、详情卡和关联表增加 `min-width: 0` 与最大宽度约束;关联表仅在自身宽度确实不足时内部滚动,不再产生整页横向溢出。
- 保留设备参数完整查看入口,不改变订单数据、接口字段和业务流程。

View File

@@ -0,0 +1,35 @@
# 项目文档:配送订单详情关联可读展示 v1.0
## 1. 项目概述
本次调整统一平台总后台配送订单详情的业务识别信息。订单号作为主要业务编号;系统唯一标识保留为辅助复制信息。订单主体和分配历史中的气站、配送点、用户、创建方及配送人员使用当前档案名称展示。
## 2. 展示规则
| 场景 | 主展示 | 辅助信息 |
| --- | --- | --- |
| 订单主体 | 订单号 | 请求流水号、系统唯一标识 |
| 配送合同 | 标题(合同编号) | 合同唯一标识 |
| 创建方 | 当前业务名称 | 创建方唯一标识 |
| 服务用户 | 用户名称或账号 | 用户唯一标识 |
| 履约气站 | 气站名称 | 气站唯一标识 |
| 当前配送点 | 配送点名称 | 配送点唯一标识 |
| 当前配送人员 | 人员姓名 | 工作人员唯一标识 |
| 建单操作人 | 操作人姓名快照 | 操作人唯一标识 |
分配记录固定显示“分配时间、气站、配送点、配送人员、分配人、分配原因”;订单明细固定显示设备名称、编码、类型、参数和成交单价;状态记录固定显示发生时间、原状态、新状态、操作人和原因。关联记录标识、订单标识和服务端内部主键不作为默认表格列,历史 `order created` 原因统一显示为“订单创建”。
## 3. 核心文件
- `backend/api/internal/logic/platform/gasorder/order_list_display.go`:批量读取详情关联名称并组装只读响应。
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:订单详情返回可读订单主体和分配历史。
- `frontend/platform_admin/src/api/resource-display.ts`:订单详情及分配记录中文名称。
- `frontend/platform_admin/src/views/resource/ResourceDetailContent.vue`:订单号排序、名称与标识组合显示、固定分配记录列。
## 4. 接口兼容性
原订单和分配记录字段全部保留,仅新增 `*_display_name` 只读字段。数据库结构、业务动作参数和订单状态机不变。关联档案不存在时返回“记录已失效”;订单尚未分配配送点或人员时返回“暂未分配”。
## 5. 维护指南
新增订单聚合子表字段时必须同时声明固定中文列和业务顺序,不允许直接将未知 JSON 键名作为最终表头。新增关联对象时同时提供业务名称和稳定唯一标识,名称用于识别,标识用于复制和接口传输。

View File

@@ -29,6 +29,7 @@
"gasorder-assignment:check": "node scripts/check-gasorder-assignment-linkage.mjs",
"gasorder-list-display:check": "node scripts/check-gasorder-list-display.mjs",
"gasorder-status-display:check": "node scripts/check-gasorder-status-display.mjs",
"resource-detail-json:check": "node scripts/check-resource-detail-json-display.mjs",
"product-ownership:check": "node scripts/check-product-ownership-action.mjs",
"product-ownership-display:check": "node scripts/check-product-ownership-display.mjs",
"product-lifecycle-display:check": "node scripts/check-product-lifecycle-display.mjs",

View File

@@ -1,10 +1,11 @@
// 功能描述:静态校验配送订单列表优先显示可读创建方,并保留唯一标识复制入口。
// 版本v1.0.0
// 版本v1.1.0
import fs from 'node:fs';
const resources = fs.readFileSync(new URL('../src/api/resources.ts', import.meta.url), 'utf8');
const listPage = fs.readFileSync(new URL('../src/views/shared/CrudListPage.vue', import.meta.url), 'utf8');
const detailPage = fs.readFileSync(new URL('../src/views/resource/ResourceDetailContent.vue', import.meta.url), 'utf8');
const resourceDisplay = fs.readFileSync(new URL('../src/api/resource-display.ts', import.meta.url), 'utf8');
const assertions = [
[resources.includes("gasorder_basic_identity: '配送订单唯一标识'"), '状态记录中的配送订单标识缺少中文名称'],
@@ -13,6 +14,17 @@ const assertions = [
[detailPage.includes("'contract_display_name'"), '订单详情未隐藏重复的合同标题辅助字段'],
[detailPage.includes("column.endsWith('_at')) return 150"), '关联记录的日期时间列未固定为 150px'],
[detailPage.includes("key.endsWith('_masked')"), '详情页未去除重复的脱敏辅助字段'],
[detailPage.includes("'order_no',\n 'request_no',\n 'order_status'"), '订单详情未将订单号置于主要位置'],
[detailPage.includes("'gasorder_basic.assignments'"), '分配记录缺少固定业务列'],
[detailPage.includes("['assignments', 'items', 'statuses'].includes(key)"), '订单关联表仍会泄漏任意英文响应字段'],
[detailPage.includes("'gasorder_basic.statuses'"), '状态记录缺少固定中文业务列'],
[resourceDisplay.includes("active: '是否有效'"), '订单明细 active 字段未中文化'],
[resourceDisplay.includes("String(value) === 'order created'"), '历史订单创建原因未中文化'],
[detailPage.includes("creator_identity: 'creator_display_name'"), '订单创建方未使用可读名称'],
[resourceDisplay.includes("'gasorder_basic.assignments'"), '分配记录缺少专用中文字段名称'],
[resourceDisplay.includes("staff_account_display_name: '配送人员'"), '分配人员名称未中文化'],
[resourceDisplay.includes("identity: '系统唯一标识'"), '订单内部标识仍与业务订单号混用'],
[resourceDisplay.includes("operator_identity: '建单操作人'"), '建单操作人仍以裸标识单独展示'],
[resources.includes("listDisplayKey: 'creator_display_name'"), '创建方未配置可读名称字段'],
[resources.includes('listDisplayIdentityCopy: true'), '创建方未保留唯一标识复制入口'],
[resources.includes("detailDisplayKey: 'contract_display_name'"), '订单详情未配置合同标题展示字段'],

View File

@@ -0,0 +1,23 @@
/**
* 功能:静态检查资源详情关联表中的设备参数可完整查看且不会撑宽表格。
* 版本v1.1.0
*/
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const detail = readFileSync(
new URL('../src/views/resource/ResourceDetailContent.vue', import.meta.url),
'utf8',
);
assert.match(detail, /isCollectionJsonColumn\(column\)/, '关联表 JSON 字段缺少专用展示分支');
assert.match(detail, />查看参数<\/a-button>/, '设备参数缺少完整查看入口');
assert.match(detail, /formatCollectionJson\(record\[column\]\)/, '设备参数没有格式化完整内容');
assert.match(detail, /JSON\.stringify\(JSON\.parse\(text\), null, 2\)/, '字符串 JSON 未格式化');
assert.match(detail, /isCollectionJsonColumn\(column\)\) return 100/, '设备参数列未使用紧凑宽度');
assert.match(detail, /max-width: min\(560px, 70vw\)/, '参数浮层缺少视口宽度限制');
assert.match(detail, /class="collection-table-shell"/, '关联表缺少独立宽度约束容器');
assert.match(detail, /\.detail-stack[\s\S]*?min-width: 0;/, '详情栈仍可能被子内容反向撑宽');
assert.match(detail, /\.collection-table-shell[\s\S]*?overflow-x: auto;/, '关联表缺少内部横向溢出兜底');
console.log('资源详情设备参数检查通过100% 视口不被关联表撑宽,参数可完整查看。');

View File

@@ -1,15 +1,15 @@
/**
* 功能:统一资源列表与详情页面的字段名称、关系、金额、状态和时间展示。
* 版本v1.4.0
* 版本v1.5.0
*/
import dayjs from 'dayjs';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
import { staffRoleLabel } from './resource-staff-relation';
import {
defaultResourceFieldLabel,
type ResourceField,
type ResourceUiDefinition,
} from './resources';
import type { RecordPageMode, ResourceRow } from './resource-page-rules';
const aliases: Record<string, string> = {
identity: '唯一标识',
@@ -54,6 +54,35 @@ const collectionFieldAliases: Record<string, Record<string, string>> = {
product_type_name: '设备类型',
product_params: '设备参数',
unit_price: '成交单价(元)',
active: '是否有效',
},
'gasorder_basic.assignments': {
assigned_at: '分配时间',
gas_basic_display_name: '气站',
delivery_basic_display_name: '配送点',
staff_account_display_name: '配送人员',
assigner_name: '分配人',
reason: '分配原因',
},
'gasorder_basic.statuses': {
occurred_at: '发生时间',
from_status: '原状态',
to_status: '新状态',
operator_name: '操作人',
reason: '原因',
},
};
/** 主资源详情中的关联标识使用业务名称,技术标识仅作为辅助复制信息。 */
const resourceFieldAliases: Record<string, Record<string, string>> = {
gasorder_basic: {
identity: '系统唯一标识',
gas_basic_identity: '履约气站',
delivery_basic_identity: '当前配送点',
staff_account_identity: '当前配送人员',
user_account_identity: '服务用户',
creator_identity: '创建方',
operator_identity: '建单操作人',
},
};
@@ -107,6 +136,9 @@ export function resourceFieldLabel(
return (
collectionAliases?.[normalized] ??
definition.fields.find((field) => field.key === normalized)?.label ??
(collectionKey
? undefined
: resourceFieldAliases[definition.name]?.[normalized]) ??
defaultResourceFieldLabel(normalized) ??
aliases[normalized] ??
key
@@ -186,9 +218,10 @@ export function relationOptionLabel(
const configuredLabel = field.relationOptionLabelKey
? option[field.relationOptionLabelKey]
: undefined;
const label = configuredLabel == null || configuredLabel === ''
? optionLabel(option)
: String(configuredLabel);
const label =
configuredLabel == null || configuredLabel === ''
? optionLabel(option)
: String(configuredLabel);
if (
field.relationOptionDefaultKey &&
option[field.relationOptionDefaultKey]
@@ -253,7 +286,15 @@ export function displayRawValue(key: string, value: unknown) {
if (key === 'status') return recordStatusLabel(Number(value));
if (key === 'contract_status') return contractStatusLabel(Number(value));
if (key === 'previous_order_status' && Number(value) === 0) return '无';
if (['order_status', 'previous_order_status', 'from_status', 'to_status'].includes(key))
if (key === 'reason' && String(value) === 'order created') return '订单创建';
if (
[
'order_status',
'previous_order_status',
'from_status',
'to_status',
].includes(key)
)
return gasorderStatusLabel(Number(value));
if (key === 'action') {
return (
@@ -301,7 +342,9 @@ export function displayResourceField(
Array<{ label: string; value: string | number }>
> = {},
) {
const displayValue = field.detailDisplayKey ? row[field.detailDisplayKey] : undefined;
const displayValue = field.detailDisplayKey
? row[field.detailDisplayKey]
: undefined;
const value = displayValue ?? row[field.key] ?? row[`${field.key}_masked`];
if (value == null || value === '') return field.emptyText ?? '-';
const hasOptionSource =

View File

@@ -1,6 +1,6 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.4.0
版本v1.7.0
-->
<template>
<div class="detail-stack">
@@ -22,23 +22,38 @@
<a-card v-if="collections.length" title="关联记录" :bordered="false" class="detail-card">
<a-tabs>
<a-tab-pane v-for="collection in collections" :key="collection.key" :title="collection.title">
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
<template #columns>
<a-table-column
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<IdentityText v-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
<div class="collection-table-shell">
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
<template #columns>
<a-table-column
v-for="column in collection.columns"
:key="column"
:title="resourceFieldLabel(definition, column, collection.key)"
:width="collectionColumnWidth(column)"
ellipsis
tooltip
>
<template #cell="{ record }">
<div v-if="collectionRelationIdentityKey(collection.key, column)" class="collection-relation-value">
<span>{{ displayRawValue(column, record[column]) }}</span>
<IdentityText
v-if="record[collectionRelationIdentityKey(collection.key, column)]"
:value="String(record[collectionRelationIdentityKey(collection.key, column)])"
/>
</div>
<IdentityText v-else-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<a-popover v-else-if="isCollectionJsonColumn(column)" position="left">
<a-button type="text" size="mini">查看参数</a-button>
<template #content>
<pre class="collection-json-value">{{ formatCollectionJson(record[column]) }}</pre>
</template>
</a-popover>
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
</div>
</a-tab-pane>
</a-tabs>
</a-card>
@@ -47,7 +62,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
displayResourceField,
@@ -55,8 +69,9 @@ import {
primaryRecord,
resourceFieldLabel,
} from '@/api/resource-display';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
const props = defineProps<{
definition: ResourceUiDefinition;
@@ -82,9 +97,55 @@ type DetailEntry = {
function collectionColumnWidth(column: string) {
if (column.endsWith('_at')) return 150;
if (column.includes('identity')) return 220;
if (isCollectionJsonColumn(column)) return 100;
return 160;
}
/** 标识关联子表中需要完整查看的 JSON 快照字段。 */
function isCollectionJsonColumn(column: string) {
return column === 'product_params';
}
/** 将字符串或对象参数格式化为可读 JSON历史非 JSON 文本保持原样。 */
function formatCollectionJson(value: unknown) {
if (value == null || value === '') return '暂无参数';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
const text = String(value).trim();
if (!text) return '暂无参数';
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}
/** 返回集合可读名称对应的稳定标识字段。 */
function collectionRelationIdentityKey(collectionKey: string, column: string) {
if (collectionKey !== 'assignments') return '';
return (
{
gas_basic_display_name: 'gas_basic_identity',
delivery_basic_display_name: 'delivery_basic_identity',
staff_account_display_name: 'staff_account_identity',
}[column] ?? ''
);
}
/** 返回订单详情关联标识对应的服务端可读名称字段。 */
function detailRelationDisplayName(row: ResourceRow, key: string) {
const displayKey =
{
creator_identity: 'creator_display_name',
user_account_identity: 'user_account_display_name',
gas_basic_identity: 'gas_basic_display_name',
delivery_basic_identity: 'delivery_basic_display_name',
staff_account_identity: 'staff_account_display_name',
operator_identity: 'operator_name',
}[key] ?? '';
if (!displayKey) return '';
return String(row[displayKey] ?? '').trim();
}
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const excluded = new Set([
@@ -99,9 +160,25 @@ const entries = computed<DetailEntry[]>(() => {
if (props.accountSummary) {
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
}
const leadingKeys =
props.definition.name === 'gasorder_basic'
? [
'order_no',
'request_no',
'order_status',
'gasorder_contract_identity',
'creator_type',
'creator_identity',
'user_account_identity',
'gas_basic_identity',
'delivery_basic_identity',
'staff_account_identity',
'identity',
'status',
]
: ['identity', 'status'];
const preferred = [
'identity',
'status',
...leadingKeys,
...props.definition.fields.map((field) => field.key),
'created_at',
'updated_at',
@@ -120,20 +197,30 @@ const entries = computed<DetailEntry[]>(() => {
: Object.prototype.hasOwnProperty.call(row, maskedKey)
? maskedKey
: '';
if (!actualKey || excluded.has(key) || key.endsWith('_id')) return [];
if (
!actualKey ||
excluded.has(key) ||
key.endsWith('_id') ||
key.endsWith('_display_name') ||
(props.definition.name === 'gasorder_basic' && key === 'operator_name')
)
return [];
const value = row[actualKey];
if (Array.isArray(value)) return [];
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
return [];
const field = props.definition.fields.find((item) => item.key === key);
const display = field
? displayResourceField(
field,
row,
props.relationOptions,
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const relationDisplayName = detailRelationDisplayName(row, key);
const display = relationDisplayName
? relationDisplayName
: field
? displayResourceField(
field,
row,
props.relationOptions,
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
return [
{
@@ -142,7 +229,10 @@ const entries = computed<DetailEntry[]>(() => {
value: display,
objectValue,
identityValue:
(field?.showIdentityCopy || field?.staffRelation?.showIdentityCopy) && value
(relationDisplayName ||
field?.showIdentityCopy ||
field?.staffRelation?.showIdentityCopy) &&
value
? String(value)
: '',
wide:
@@ -188,13 +278,31 @@ const collections = computed(() =>
'product_type_name',
'product_params',
'unit_price',
'active',
'product_info_identity',
],
'gasorder_basic.assignments': [
'assigned_at',
'gas_basic_display_name',
'delivery_basic_display_name',
'staff_account_display_name',
'assigner_name',
'reason',
],
'gasorder_basic.statuses': [
'occurred_at',
'from_status',
'to_status',
'operator_name',
'reason',
],
};
const preferred =
preferredColumns[`${props.definition.name}.${key}`] ?? [];
const columns = [...new Set([...preferred, ...availableColumns])]
const fixedColumns = ['assignments', 'items', 'statuses'].includes(key);
const columns = (
fixedColumns
? preferred
: [...new Set([...preferred, ...availableColumns])]
)
.filter((column) => availableColumns.includes(column))
.slice(0, 8);
return {
@@ -210,9 +318,15 @@ const collections = computed(() =>
<style scoped lang="less">
.detail-stack {
display: grid;
width: 100%;
min-width: 0;
gap: 12px;
}
.detail-card {
width: 100%;
min-width: 0;
max-width: 100%;
overflow: hidden;
border-radius: 10px;
}
.detail-card :deep(.arco-card-header) {
@@ -220,8 +334,15 @@ const collections = computed(() =>
padding: 0 24px;
}
.detail-card :deep(.arco-card-body) {
min-width: 0;
padding: 20px 24px;
}
.detail-card :deep(.arco-tabs),
.detail-card :deep(.arco-tabs-content),
.detail-card :deep(.arco-tabs-pane) {
min-width: 0;
max-width: 100%;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -237,6 +358,11 @@ const collections = computed(() =>
padding: 8px 0;
border-bottom: 1px solid var(--color-border-1);
}
.detail-item,
.detail-value,
.detail-relation-value {
min-width: 0;
}
.detail-item-wide {
grid-column: 1 / -1;
}
@@ -263,6 +389,28 @@ const collections = computed(() =>
border-radius: 6px;
white-space: pre-wrap;
}
.collection-json-value {
min-width: 280px;
max-width: min(560px, 70vw);
max-height: 360px;
margin: 0;
overflow: auto;
color: var(--color-text-1);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
word-break: break-word;
}
.collection-table-shell {
width: 100%;
min-width: 0;
max-width: 100%;
overflow-x: auto;
}
.collection-relation-value {
display: grid;
min-width: 0;
gap: 2px;
}
@media (max-width: 1280px) {
.detail-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -1,10 +1,13 @@
/*
* 功能:定义标准资源独立页面的布局、卡片和响应式样式。
* 版本v1.3.0
* 版本v1.4.0
*/
.record-page {
display: grid;
align-content: start;
width: 100%;
min-width: 0;
max-width: 100%;
gap: 12px;
min-height: 100%;
padding: 16px 20px 20px;
@@ -25,6 +28,8 @@
}
.section-card,
.form-card {
min-width: 0;
max-width: 100%;
border-radius: 10px;
}
.section-card :deep(.arco-card-header) {