完善气站客服工单详情展示

This commit is contained in:
czl231
2026-08-22 10:48:36 +08:00
parent b85e879130
commit 79749685ee
9 changed files with 252 additions and 10 deletions

View File

@@ -11,12 +11,45 @@ import (
)
func ticketQuery(gasID uint64) *gorm.DB {
return common.ActiveRecords(impl.DBService.Model(&models.CsTicket{})).
return ticketQueryWithDB(impl.DBService, gasID)
}
// ticketQueryWithDB 统一客服工单的数据范围查询,并允许只读 SQL 测试注入数据库连接。
func ticketQueryWithDB(databaseService *gorm.DB, gasID uint64) *gorm.DB {
return common.ActiveRecords(databaseService.Model(&models.CsTicket{})).
Joins("JOIN user_service_relation ON user_service_relation.user_account_id = cs_ticket.user_account_id AND user_service_relation.status <> ?",
common.StatusArchived).
Where("user_service_relation.gas_basic_id = ?", gasID)
}
// ticketDetailDisplay 为工单详情附加当前记录所引用关系的可读名称,不修改工单归属数据。
type ticketDetailDisplay struct {
models.CsTicket
UserAccountDisplayName string `gorm:"column:user_account_display_name" json:"user_account_display_name"`
DeliveryBasicDisplayName string `gorm:"column:delivery_basic_display_name" json:"delivery_basic_display_name"`
StaffAccountDisplayName string `gorm:"column:staff_account_display_name" json:"staff_account_display_name"`
GasBasicDisplayName string `gorm:"column:gas_basic_display_name" json:"gas_basic_display_name"`
}
// ticketDetailQuery 在当前气站可见范围内解析关系名称,已失效关系仍保留可信的历史名称。
func ticketDetailQuery(gasID uint64) *gorm.DB {
return ticketDetailQueryWithDB(impl.DBService, gasID)
}
// ticketDetailQueryWithDB 构造可测试的详情名称查询,不改变通用列表查询。
func ticketDetailQueryWithDB(databaseService *gorm.DB, gasID uint64) *gorm.DB {
return ticketQueryWithDB(databaseService, gasID).
Select(`cs_ticket.*,
COALESCE(NULLIF(ticket_user.name, ''), NULLIF(ticket_user.real_name, ''), NULLIF(ticket_user.username, ''), '用户记录已失效') AS user_account_display_name,
CASE WHEN cs_ticket.delivery_basic_id = 0 THEN '' ELSE COALESCE(NULLIF(ticket_delivery.name, ''), '配送点记录已失效') END AS delivery_basic_display_name,
CASE WHEN cs_ticket.staff_account_id = 0 THEN '' ELSE COALESCE(NULLIF(ticket_staff.name, ''), NULLIF(ticket_staff.username, ''), '工作人员记录已失效') END AS staff_account_display_name,
CASE WHEN cs_ticket.gas_basic_id = 0 THEN '' ELSE COALESCE(NULLIF(ticket_gas.name, ''), '气站记录已失效') END AS gas_basic_display_name`).
Joins("LEFT JOIN user_account AS ticket_user ON ticket_user.id = cs_ticket.user_account_id").
Joins("LEFT JOIN delivery_basic AS ticket_delivery ON ticket_delivery.id = cs_ticket.delivery_basic_id").
Joins("LEFT JOIN staff_account AS ticket_staff ON ticket_staff.id = cs_ticket.staff_account_id").
Joins("LEFT JOIN gas_basic AS ticket_gas ON ticket_gas.id = cs_ticket.gas_basic_id")
}
func ListTicket(ctx *gin.Context) {
station, ok := currentGas(ctx)
if !ok {
@@ -30,8 +63,8 @@ func GetTicket(ctx *gin.Context) {
if !ok {
return
}
var ticket models.CsTicket
respondScopedRecord(ctx, ticketQuery(station.ID).Where("cs_ticket.identity = ?", ctx.Param("identity")), &ticket)
var ticket ticketDetailDisplay
respondScopedRecord(ctx, ticketDetailQuery(station.ID).Where("cs_ticket.identity = ?", ctx.Param("identity")), &ticket)
}
type ticketRequest struct {

View File

@@ -1,6 +1,13 @@
package gas
import "testing"
import (
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// TestSupportedTicketValues 验证气站端工单枚举白名单。
func TestSupportedTicketValues(t *testing.T) {
@@ -20,3 +27,34 @@ func TestSupportedTicketValues(t *testing.T) {
}
}
}
// TestTicketDetailQueryIncludesScopedRelationNames 验证详情名称查询保留气站范围并解析全部关系名称。
func TestTicketDetailQueryIncludesScopedRelationNames(t *testing.T) {
connection, _, err := sqlmock.New()
if err != nil {
t.Fatalf("create SQL mock: %v", err)
}
t.Cleanup(func() { _ = connection.Close() })
databaseService, err := gorm.Open(postgres.New(postgres.Config{Conn: connection}), &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("open GORM database: %v", err)
}
statement := ticketDetailQueryWithDB(databaseService, 42).
Find(&ticketDetailDisplay{}).Statement.SQL.String()
for _, fragment := range []string{
"user_service_relation.gas_basic_id =",
"LEFT JOIN user_account AS ticket_user",
"LEFT JOIN delivery_basic AS ticket_delivery",
"LEFT JOIN staff_account AS ticket_staff",
"LEFT JOIN gas_basic AS ticket_gas",
"user_account_display_name",
"delivery_basic_display_name",
"staff_account_display_name",
"gas_basic_display_name",
} {
if !strings.Contains(statement, fragment) {
t.Fatalf("ticket detail display SQL must include %q, got: %s", fragment, statement)
}
}
}

View File

@@ -138,6 +138,9 @@
- 不得读取或操作其他气站用户的工单。
- 不新增重复工单模型,统一复用 `cs_ticket`
- 工单分类使用配送、安装、维修、安检、复检、客服咨询六项必选枚举,不设置默认分类;优先级使用低、普通、高、紧急四项必选枚举,默认普通。前端显示中文,接口保存统一编码,气站接口拒绝未定义枚举。
- 工单详情结构与平台总后台保持一致,完整展示用户、工单号、分类、优先级、配送点、处理人员、服务气站、最近操作人标识、问题描述、请求流水号、上门地址、预约时间、开始时间、完成时间和处理结果;关系字段优先展示可读名称并保留业务标识复制。
- 尚未产生的详情数据不得统一显示裸 `-`:应分别显示“尚未分配配送点”“尚未分配处理人员”“尚未关联服务气站”“暂无操作记录”“未填写问题描述”“未记录请求流水号”“未填写上门地址”“未预约”“尚未开始处理”“尚未完成”和“尚未提交处理结果”。这些辅助字段只在详情页展示,不扩展气站端新建和编辑表单。
- 详情展示对齐不得根据用户当前服务关系回填历史工单,也不得修改工单创建、归属或状态流程;历史记录缺少数据时只展示可信的业务空状态。
### 4.9 邀请注册

View File

@@ -0,0 +1,47 @@
# 操作日志:气站客服工单详情展示对齐
操作时间2026-08-22
操作类型:修改
影响模块:气站管理后台客服工单详情、气站客服工单详情接口
## 操作前状态
- 5176 客服工单资源只声明用户、工单号、分类和优先级,详情页其余字段依赖通用回退,出现裸 `-`、内部字段名称以及“预约时间”缺失。
- 已有关联标识的工单详情没有随记录返回可读名称,详情页需要额外加载关系资源,服务气站关系在气站端没有可用的全局查询入口。
## 具体操作
- 将 5176 客服工单详情字段结构、顺序、中文名称和业务空状态与 5173 对齐。
- 补回“预约时间”,空值显示“未预约”;将“地址/说明/检修结果”等通用名称改为“上门地址/问题描述/处理结果”。
- 配送点、处理人员和服务气站在详情接口中按工单已保存的关系主键附加可读名称,并继续返回可复制的业务标识。
- 详情页仅对存在值且没有服务端可读名称的关系执行补载,空关系不发起无意义请求。
- 保留既有页面规则:辅助字段只在详情页展示,新建和编辑仍只处理用户、工单号、分类和优先级。
## 操作后状态
- 5176 客服工单详情与 5173 使用一致的字段结构和业务用语。
- 未分配、未预约、未开始、未完成等状态可直接理解,不再显示无语义的裸 `-`
- 本次不修改工单存储数据,不回填历史归属,不改变创建、编辑、归档和状态流程。
## 代码变更
- `frontend/gas_admin/src/api/resources.ts`:新增完整客服工单详情字段契约。
- `frontend/gas_admin/src/views/resource/load-resource-record-relations.ts`:收敛详情关系预加载范围。
- `frontend/gas_admin/scripts/check-backend-contract.mjs`:增加详情字段和关系加载回归检查。
- `backend/api/internal/logic/gas/ticket.go`:详情响应附加当前工单关系的可读名称。
- `docs/06-气站管理系统需求.md`:补充详情展示、空状态与历史数据边界。
## 验证结果
- `pnpm contract:check` 通过20 个资源契约一致。
- `pnpm resource-pages:check` 通过,详情 20 类、新建 11 类、编辑 8 类。
- `pnpm type:check``pnpm build` 通过。
- 本次前端修改文件定向 Biome 检查无新增错误;`resource-display.ts` 仍有 1 条既有 `hasOwnProperty` 风格警告,全量 lint 的既有警告未在本次扩展范围内处理。
- `go test ./...``backend/api`)通过,新增详情 SQL 测试验证气站范围、四类关系名称连接和展示别名。
- 本地页面热更新后不再出现“预约时间缺少中文名称”的启动错误;未登录浏览器被重定向到登录页,因此未代替用户登录验证受保护详情数据。
## 风险评估
- 详情接口新增只读展示字段,原有字段和接口路径保持兼容。
- 关系名称严格从工单已保存主键读取,不用当前服务关系推断或覆盖历史归属。
- 历史工单未保存关系主键时仍保持空值,仅以明确文案说明当前状态。

View File

@@ -64,6 +64,7 @@ platforms/
- 工作人员资质按来源角色校验;订单分配只加载配送人员。
- 工作人员和用户头像使用 5173 同款账户摘要、选择预览和保存流程,并通过气站数据范围内的受控接口读取;`file_uri` 仍保持普通字段。
- 支付与退款资源使用后端真实名称 `payment_order``payment_refund`
- 客服工单详情完整沿用 5173 的字段结构和顺序,包含预约时间,并为未分配、未预约、未开始、未完成等情况提供业务空状态;关系名称按工单已保存主键解析,不回填历史数据。
### 4.4 只读详情接口
@@ -77,6 +78,7 @@ platforms/
- 同步气站资源契约和独立页面覆盖检查。
- 为工作人员与用户补充受气站范围保护的头像读取接口,并避免普通资料编辑误清空头像。
- 客服工单分类和优先级改用中文下拉,优先级默认普通,并由气站接口校验统一枚举。
- 客服工单详情字段、关系名称和空状态与 5173 对齐;辅助处理字段保持详情专用,不扩展新建和编辑表单。
- 未修改数据库模型、状态机、金额规则或现有写接口。
## 6. 维护指南

View File

@@ -17,6 +17,10 @@ const fieldFormSource = readFileSync(
resolve(root, 'src/views/resource/ResourceFieldForm.vue'),
'utf8',
);
const relationLoaderSource = readFileSync(
resolve(root, 'src/views/resource/load-resource-record-relations.ts'),
'utf8',
);
const contract = JSON.parse(
readFileSync(resolve(root, 'src/contracts/gas-resources.json'), 'utf8'),
);
@@ -89,6 +93,25 @@ for (const option of [
]) {
if (!source.includes(option)) throw new Error(`客服工单枚举缺失:${option}`);
}
for (const display of [
"label: '配送点'",
"emptyText: '尚未分配配送点'",
"label: '处理人员'",
"emptyText: '尚未分配处理人员'",
"label: '服务气站'",
"emptyText: '尚未关联服务气站'",
"label: '问题描述'",
"emptyText: '未填写问题描述'",
"label: '预约时间'",
"emptyText: '未预约'",
"label: '处理结果'",
"emptyText: '尚未提交处理结果'",
]) {
if (!source.includes(display))
throw new Error(`客服工单详情展示契约缺失:${display}`);
}
if (!relationLoaderSource.includes('relationDisplayName(field, options.record)'))
throw new Error('详情关系已有可读名称时不得重复请求关联资源');
const forbidden = [
'/platform/platform_', '/gas/gas_', '/delivery/delivery_', '/staff/',

View File

@@ -85,6 +85,7 @@ const collectionFieldAliases: Record<string, Record<string, string>> = {
attempt_no: '配送尝试次数',
staff_account_display_name: '配送人员',
staff_account_identity: '配送人员唯一标识',
appointment_at: '预约时间',
started_at: '开始时间',
completed_at: '完成时间',
},

View File

@@ -162,6 +162,7 @@ const fieldLabels: Record<string, string> = {
reconciliation_status: '对账状态',
repair_no: '检修单号',
repair_type: '检修类型',
appointment_at: '预约时间',
started_at: '开始时间',
completed_at: '完成时间',
result: '检修结果',
@@ -309,7 +310,7 @@ const money = new Set([
const booleans = new Set(['is_default', 'is_cover', 'selected', 'withdrawable']);
const dates = new Set(['bill_date']);
const datetimes = new Set([
'expired_at', 'produced_at', 'enabled_at', 'started_at', 'completed_at',
'expired_at', 'produced_at', 'enabled_at', 'appointment_at', 'started_at', 'completed_at',
'occurred_at', 'reviewed_at', 'signed_at', 'effective_at', 'unbound_at',
'assigned_at', 'confirmed_at', 'paid_at', 'period_start', 'period_end',
]);
@@ -429,6 +430,91 @@ const ticketPriorityField = f('priority', {
],
});
/** 客服工单详情字段完整沿用平台口径,辅助处理字段只在详情页展示。 */
const ticketFields = [
relation('user_account_identity', '/user_account', true, {
label: '用户',
listLabel: '用户',
listRelationNameOnly: true,
listDisplayIdentityCopy: true,
displayRelationLabel: true,
showIdentityCopy: true,
}),
f('ticket_no', { required: true }),
ticketCategoryField,
ticketPriorityField,
relation('delivery_basic_identity', '/delivery_basic', false, {
label: '配送点',
listHidden: true,
displayRelationLabel: true,
showIdentityCopy: true,
emptyText: '尚未分配配送点',
}),
relation('staff_account_identity', '/staff_account', false, {
label: '处理人员',
listHidden: true,
displayRelationLabel: true,
showIdentityCopy: true,
emptyText: '尚未分配处理人员',
staffRelation: { roles: ['installer', 'delivery', 'operations'] },
}),
relation('gas_basic_identity', '/gas_basic', false, {
label: '服务气站',
listHidden: true,
displayRelationLabel: true,
showIdentityCopy: true,
emptyText: '尚未关联服务气站',
}),
f('operator_identity', {
label: '最近操作人标识',
listHidden: true,
listCopyable: true,
emptyText: '暂无操作记录',
}),
f('description', {
label: '问题描述',
type: 'textarea',
listHidden: true,
emptyText: '未填写问题描述',
}),
f('request_no', {
label: '请求流水号',
listHidden: true,
listCopyable: true,
emptyText: '未记录请求流水号',
}),
f('address', {
label: '上门地址',
type: 'textarea',
listHidden: true,
emptyText: '未填写上门地址',
}),
f('appointment_at', {
label: '预约时间',
type: 'datetime',
listHidden: true,
emptyText: '未预约',
}),
f('started_at', {
label: '开始时间',
type: 'datetime',
listHidden: true,
emptyText: '尚未开始处理',
}),
f('completed_at', {
label: '完成时间',
type: 'datetime',
listHidden: true,
emptyText: '尚未完成',
}),
f('result', {
label: '处理结果',
type: 'textarea',
listHidden: true,
emptyText: '尚未提交处理结果',
}),
];
/** 提现渠道保留未来选项,但当前仅开放银行卡提现。 */
const withdrawalChannelField = f('channel', {
required: true,
@@ -538,7 +624,7 @@ const platformResources: ResourceUiDefinition[] = [
define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', { required: true }), f('period_start', { required: true }), f('period_end', { required: true })]),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), ticketCategoryField, ticketPriorityField]),
define('cs_ticket', '客服工单', 'writable', ticketFields),
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true }), f('phone')]),
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: [{ label: '脱敏坐标', value: 'standard' }, { label: '精确坐标', value: 'precise' }] })], 'list', [
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
@@ -585,7 +671,7 @@ const gasOverrides: ResourceUiDefinition[] = [
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]),
define('fin_settlement', '财务结算', 'readonly', []),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), ticketCategoryField, ticketPriorityField]),
define('cs_ticket', '客服工单', 'writable', ticketFields),
];
const gasResourceNames = new Set(gasOverrides.map((item) => item.name));

View File

@@ -4,9 +4,10 @@
*/
import { Message } from '@arco-design/web-vue';
import type { Ref } from 'vue';
import { platformApi, type PlatformRole } from '@/api/platform';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { type PlatformRole, platformApi } from '@/api/platform';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { relationDisplayName } from '../shared/resource-list-field-display';
import type { ResourceRelations } from './use-resource-relations';
type LoaderOptions = {
@@ -26,7 +27,15 @@ type LoaderOptions = {
/** 加载当前页面实际使用的关系和动态平台角色。 */
export async function loadResourceRecordRelations(options: LoaderOptions) {
if (options.detailMode) {
await options.relations.preload(options.definition.fields);
// 详情只加载实际存在且服务端未提供可读名称的关系,避免为空字段发起无意义请求。
const unresolvedFields = options.definition.fields.filter((field) => {
const value = options.record[field.key];
const hasValue = Array.isArray(value)
? value.some(Boolean)
: String(value ?? '').trim() !== '';
return hasValue && !relationDisplayName(field, options.record);
});
await options.relations.preload(unresolvedFields);
await options.relations.ensureValues(
options.definition.fields,
options.record,