4286 字
21 分钟
关于前端数据与业务上解耦的思考

1. 前言#

最近基本都在写在LLM下工作的东西,作为从前端入门的码农来说,写中后台的前端UI已经是写到吐了,之前受限于时间关系,要严格遵循下文的规范是很难的,主要体现在时间紧任务重,还要经常几个项目之间切换。

Vibe Coding发展到现在,总算将我的注意力从细碎的代码中挪开,将DDD分层结构用在前端上。目前的LLM搭配CLI工具链已经足够强大,至少手搓相对重复代码这件事给我写代码带来的挫败感下降了太多太多。

话不多说,简单介绍下目前我让LLM遵循的范式吧。

2. 结构#

分为逻辑结构与目录结构。

2.1 逻辑结构#

直接抛图示乍一看可能会看的有些云里雾里,先简单说下逻辑链路。

对于前端而言,数据从服务端到前端,再从前端到服务端的路径无非就是:

服务端的数据读取(axios/fetch)-> 创建一个Response DTO作为后端这个API接口Typescript数据结构->使用一个Request DTO将数据回传给服务端(axios/fetch)。

在此过程中,后端提供的DTOVO里头还会有一些枚举相关的字段,比如说类似于registerType(1个人2公司)这种字段,前端也需要做枚举化实现。 这三种与服务端直接相关的东西,在DDD分层架构中,统一归类到domain

但只有domain的话,后端修改的时候很容易牵一发而动全身,无论是DTO还是VO,从后端获取回来的时候都有可能会变更/缺少字段,如果直接依赖domain,一改可能就是整条链路都需要修改。

所以对于application层来说,它需要有一个不受后端字段影响的稳定数据类型供application层消费,就叫它model吧。

domain映射到model这个过程,需要有一个mapper工具函数,每一个moduleapplication中几乎都会有这么一个mapper,而在mapper的过程中,一些脏活累活也都可以在里面解决。

比如说关键字段null,"null",""这三个典型值的处理,时间值的转换,后端如果直接传枚举对象而非枚举值的转换,特殊字段的值转换,都可以在里面直接进行脏数据清理,避免application层出现异常。

对于复杂交互,application层为了数据展示也会有特定数据结构的要求。比如说项目要做一个标签页的展示,要求将form items拆成几个标签页group,这样就不得不将flat的数据类型转换为嵌套object。从modelui,则又要进行一层转换,就叫ui-mapper吧。

ui-mapper能承载的功能也不止映射,对于未填写的表单来说,其可能是Partial<T>这么个类型,到了提交的时候,又会根据业务的要求,对部分字段的必填与非必填做出改动。 还可能在前端数据提交的时候,要转换为后端DTO所需要的数据结构,这个ui-mappermapper一样,在做双向脏数据处理。

如果你还需要缓存/持久化管理/重发/按条件重新触发请求的话,还要引入hooks,也就是TanStack Query这种工具来帮你完成请求的状态管理。

这也是为什么前端一直以来都很难做到类型安全以及解耦,很多前端开发都在写面条代码跟巨型组件──光是这些脏代码就已经让写的人足够厌烦了,业务都没开始光写这些东西就已经占用了很多时间,这些要求属于是看着很好,但实现起来很麻烦,有时候不得不舍去一些规范而追求效率。

时间紧张的时候,自然是怎么快怎么来,后面的事情就后面再说。

不过在有了LLM后,一切都不一样起来,你可以很轻松地要求LLM将后端跟你对接的数据结构拆成domainapplication中的mappermodel以及ui-mapper,也就几分钟的事情,这些过去收益不是很明显的东西又重新焕发出不一样的光彩。

Presentation Layer

Application Layer

Domain Layer

Server Side

Fetch Data

Transform

Transform

Bind

Valid

Transform

Submit

Server API

Response DTO

Domain Model

Request DTO

Response Mapper

UI Mapper: To UI

UI Mapper: To DTO

UI Model

Hook / Store

Validation

Component / Form

简化表示如下:

Application (Logic)

Domain (Contracts)

Server

Mapper

UI-Mapper

UI-Mapper (Reverse)

Server API

Response DTO

Request DTO

Domain Model

UI Model / Form State

2.2 目录结构#

src/modules/[module-name]/
├── domain/ # Domain Layer: Pure business definitions
│ ├── dto.ts # DTOs (Request Data Transfer Objects)
│ ├── enums.ts # Business Enums
| └── types.ts # VOs (Response Data)
├── application/ # Application Layer: Logic & Glue
│ ├── mappers.ts # Data Mappers (Response Mapper: DTO -> Domain)
│ ├── ui-mappers.ts # UI Mappers (Domain -> UI Model, UI Model -> Request DTO)
│ ├── models.ts # UI Models (View Objects, Form State Definitions)
│ ├── validation.ts # Form Binding & Validation Rules
│ └── hooks/ # Hooks / Store (State Management)
│ └── use[Feature].ts # Helper hook to bind UI Model <-> Service/Store
├── infrastructure/ # Infrastructure Layer: External communication
│ └── api.ts # Server API implementation
└── presentation/ # Presentation Layer: UI Components
├── [Feature]Page.tsx # Container Component (Page level)
└── components/ # Dumb/Presentational Components
└── [SubComponent].tsx

3. 过度设计判断#

架构设计的目标是降低改动成本,而不是增加工作量。如果一套繁琐的流程仅仅是为了“遵循规范”,那就是本末倒置。

以下是如何判断是否需要合并层级(简化架构)的决策维度。

维度需要全层级 (Full Layers)可以合并/裁剪 (Simplified)
业务逻辑复杂度:有复杂的状态流转、联动校验、前端计算(如电费计算)。:纯 CRUD,后端给什么就展示什么,改改就存回去。
UI 差异度:界面展示形式和数据库存储形式完全不同(如向导式分步表单)。:表单字段和数据库字段几乎一一对应。
数据流方向双向:既有读取展示,又有大量编辑提交。单向:主要是只读展示(Dashboard、列表页)。

3.1 怎么合并?#

如果判定该模块比较简单,可以采取以下两种策略之一:

策略 A:合并 Domain Model & UI Model#

这是最安全的简化方式,适用于后端数据结构已经很合理,且 UI 不需要特殊转换的场景。

  • 做法

    • 保留domain/dto.ts domain/types.ts
    • 删除domain/model.ts(无需重复定义纯净entity)
    • UI Model(application/models.ts)直接继承或者使用domain/dto.ts domain/types.ts中的类型
  • 例子:即上面的 user 模块。

    application/models.ts
    import type { User } from "@/modules/user/domain/types"; // 直接引用后端定义
    export interface UserInfo extends User {
    // 仅补充少量 UI 独有字段
    isActive: boolean;
    // ......
    }

策略 B:跳过 Mapping 层 (极简)#

适用于极简单的字典表、配置项或只读大屏数据。

  • 做法
    1. API 直接返回 DTO。
    2. Component 直接消费 DTO。
    3. 完全没有 mapper.tsmodels.ts
  • 代价:后端改字段名,Component 必须跟着改。
  • 适用:非核心边缘模块,或者生命周期很短的活动页。

什么时候必须拆分?#

不过哪怕一开始简化了分层,出现以下情况后,还是需要重构并拆分

  1. 脏数据满天飞:组件里到处都在写 if (user.role && user.role.id) 这种防御性代码 -> 需要 Response Mapper 清洗数据。
  2. 类型体操UI Model 用了很多 Omit<User, 'id'> & { id: string } 这种复杂的类型修补 -> 需要独立的 Domain Model
  3. 心智负担:每改一个后端接口字段,都在担心不知道哪个页面会显示不正常或者直接报错白屏 -> 需要与后端隔离的防腐层。

至于改动一个字段要改4~5个文件这件事,其实是没有LLM之前最大的痛点,现在只需要给个明确的指示即可。 使用LLM的办法,我觉得目前自己用下来来说小步快跑是比较合适的一种策略──进入Plan模式将任务拆碎,使用Agent集群分布执行,人工对结果进行Review。

4. 流程展示#

Service Agreement模块节选#

基本是纯代码展示环节,关键内容已经在前面中说明。

DTO#

export interface ServiceAgreementRequestDTO {
id: number | null;
companyName: string;
companyArea: string;
companyAddress: string;
industry: string | null;
status: ServiceAgreementStatus;
liaisonName: string;
liaisonPosition: string;
liaisonPhone: string;
yearUsableCharge: number;
isTimeOfUsePricingEnabled: boolean;
22 collapsed lines
peakPercentage: number | null;
superPeakPercentage: number | null;
standardPercentage: number | null;
valleyPercentage: number | null;
comment: string | null;
priceModel: PriceModel | null;
priceType: PriceType | null;
priceCategory: PriceCategory | null;
fixedPrice: string | null;
fixedSpread: string | null;
revenueShareRatio: number | null;
expirationTime: string | null;
contractScanIds: number[] | null;
billIds: number[] | null;
supplementaryAttachmentIds: number[] | null;
servicePointSpecifications: ServicePointSpecificationInput[] | null;
creator: number | null;
}
export interface ServicePointSpecificationInput {
id?: number;
agreementId?: number;
serviceAccount: string;
transformerCapacity: number;
electricityConsumptionType: ElectricityConsumptionType;
voltageClass: string;
}

Enum#

export const ServiceAgreementStatusEnum = {
Record: 1,
Sign: 2,
Invalid: 3,
} as const;
export type ServiceAgreementStatus =
(typeof ServiceAgreementStatusEnum)[keyof typeof ServiceAgreementStatusEnum];
export const PriceModelEnum = {
RevenueShare: 1,
Guaranteed: 2,
GuaranteedAndShare: 3,
27 collapsed lines
Other: 4,
} as const;
export type PriceModel = (typeof PriceModelEnum)[keyof typeof PriceModelEnum];
export const PriceTypeEnum = {
PowerPlantSide: 1,
UserSide: 2,
SalesCompanySide: 3,
} as const;
export type PriceType = (typeof PriceTypeEnum)[keyof typeof PriceTypeEnum];
export const PriceCategoryEnum = {
FixedPrice: 1,
FixedSpread: 2,
ShareRatio: 3,
} as const;
export type PriceCategory =
(typeof PriceCategoryEnum)[keyof typeof PriceCategoryEnum];
export const ElectricityConsumptionTypeEnum = {
LargeIndustrial: 1,
CommercialAndIndustrial: 2,
} as const;
export type ElectricityConsumptionType =
(typeof ElectricityConsumptionTypeEnum)[keyof typeof ElectricityConsumptionTypeEnum];
export const FileCategoryEnum = {
BILL: "BILL",
CONTRACT: "CONTRACT",
ATTACHMENT: "ATTACHMENT",
} as const;
export type FileCategory =
(typeof FileCategoryEnum)[keyof typeof FileCategoryEnum];

types#

export interface ServiceAgreement {
id: number;
companyName: string;
companyArea: string;
companyAddress: string;
industry: string | null;
status: ServiceAgreementStatus;
liaisonName: string;
liaisonPosition: string;
liaisonPhone: string;
yearUsableCharge: number;
isTimeOfUsePricingEnabled: boolean;
39 collapsed lines
peakPercentage: number | null;
superPeakPercentage: number | null;
standardPercentage: number | null;
valleyPercentage: number | null;
comment: string | null;
priceModel: PriceModel | null;
priceType: PriceType | null;
priceCategory: PriceCategory | null;
fixedPrice: string | null;
fixedSpread: string | null;
revenueShareRatio: number | null;
expirationTime: string | null;
creator: number;
createdTime: string;
updatedTime: string;
}
export interface ServicePointSpecification {
id: number;
agreementId: number;
serviceAccount: string;
transformerCapacity: number;
electricityConsumptionType: ElectricityConsumptionType;
voltageClass: string;
createdTime: string;
updatedTime: string;
}
export interface ServiceAgreementAttachmentsVO {
billFiles: OssCallbackDTO[];
supplementaryAttachmentFiles: OssCallbackDTO[];
contractScanFiles: OssCallbackDTO[];
}
export interface ServiceAgreementVo extends ServiceAgreement {
contractScanFiles: OssCallbackDTO[];
billFiles: OssCallbackDTO[];
supplementaryAttachmentFiles: OssCallbackDTO[];
servicePointSpecifications: ServicePointSpecification[];
}
export interface ServiceAgreementFileUploadResult {
fileCategory: FileCategory;
file: OssCallbackDTO;
}

Application#

UI-model#

/**
* 备案/签约数据前端展示体
* @description 对应 Java 中的 ServiceAgreementVo 类
*/
export interface ServiceAgreementDetail extends Omit<
DomainServiceAgreement,
"expirationTime" | "createdTime" | "updatedTime"
> {
/**
* 合同到期时间 (UI 使用 Timestamp)
*/
expirationTime: number | null;
82 collapsed lines
createdTime: number;
updatedTime: number;
/**
* 合同扫描件文件列表
*/
contractScanFiles: OssCallbackView[];
contractScanIds?: number[];
/**
* 近一个月电费单文件列表
*/
billFiles: OssCallbackView[];
billIds?: number[];
/**
* 其它附件文件列表
*/
supplementaryAttachmentFiles: OssCallbackView[];
supplementaryAttachmentIds?: number[];
/**
* 营销户号集合
*/
servicePointSpecifications: ServicePointSpecification[];
}
// 但是,后端传过来的数据,哪怕已经考虑到展示了,实际上可能会需要扁平转嵌套,或者嵌套转扁平
/**
* [UI-Facing] 更新整个表单的UI状态树
*/
export interface ServiceAgreementUIMap {
customerInfo: CustomerInfoDataForUI;
signInfo: SignInfoDataForUI;
attachmentInfo: AttachmentDataForUI;
}
/**
* [UI-Facing] 客户基本信息分区的数据结构
* 这是从扁平的 ServiceAgreement 中分离出来的,专门用于 CustomerInfoPart 组件。
*/
export interface CustomerInfoDataForUI {
id: number | null;
status: DomainServiceAgreementStatus;
companyName: string;
industry: string | null;
companyArea: string;
companyAddress: string;
liaisonName: string;
liaisonPosition: string;
liaisonPhone: string;
yearUsableCharge: number | null;
isTimeOfUsePricingEnabled: boolean;
peakPercentage: number | null;
superPeakPercentage: number | null;
standardPercentage: number | null;
valleyPercentage: number | null;
comment: string | null;
}
/**
* [UI-Facing] 签约详情分区的数据结构
*/
export interface SignInfoDataForUI {
/** 价格模式 */
priceModel: DomainPriceModel | null;
priceType: DomainPriceType | null;
/** 价格种类 */
priceCategory: DomainPriceCategory | null;
/** 固定价格 */
fixedPrice: string | null;
/** 固定价差 */
fixedSpread: string | null;
/** 收入分成比例 (%) */
revenueShareRatio: number | null;
/** 合同到期时间 */
expirationTime: number | null;
/** 营销户号列表 */
servicePointSpecifications: ServicePointSpecification[];
/** 价格模式为其它时,备注 */
comment: string | null;
}
/**
* [UI-Facing] 附件上传分区的数据结构
*/
export interface AttachmentDataForUI {
contractScanIds: number[] | null;
billIds: number[] | null;
supplementaryAttachmentIds: number[] | null;
}

Mappers#

import dayjs from "dayjs";
import type {
PreviewAttachmentsVO as DomainPreviewAttachmentsVO,
ServiceAgreementPageVo as DomainServiceAgreementPageVo,
ServiceAgreementVo as DomainServiceAgreementVo,
ServiceAgreementAttachmentsVO as DomainServiceAgreementAttachmentsVO,
} from "../domain/types";
import type {
ServiceAgreementRequestDTO as DomainServiceAgreementRequestDTO,
ServicePointSpecificationInput,
178 collapsed lines
} from "../domain/dto";
import type {
PreviewAttachmentsData as ViewPreviewAttachmentsData,
ServiceAgreementPageItem as ViewServiceAgreementPageItem,
ServiceAgreementAttachmentsData as ViewServiceAgreementAttachmentsData,
ServiceAgreementForm,
ServiceAgreementDetail as ViewServiceAgreementDetail,
ServicePointSpecification,
} from "./models";
import { toOssCallbackView } from "@/modules/file/application/models";
// ... (toTimestamp, toDateTimeString, normalizeCompanyArea are unchanged? No, mapAttachments uses rename)
const toTimestamp = (value: string | null | undefined): number | null => {
if (!value) return null;
const date = new Date(value);
const time = date.getTime();
return Number.isNaN(time) ? null : time;
};
const toDateTimeString = (value: number | null | undefined): string | null => {
if (value == null) return null;
return dayjs(value).format("YYYY-MM-DD HH:mm:ss");
};
const normalizeCompanyArea = (value: string | null | undefined): string => {
if (!value) return "";
if (value.includes("/")) {
const parts = value.split("/");
return parts[parts.length - 1] || "";
}
return value;
};
const mapAttachments = (
attachments: DomainServiceAgreementAttachmentsVO,
): ViewServiceAgreementAttachmentsData => {
return {
billFiles: attachments.billFiles.map(toOssCallbackView),
supplementaryAttachmentFiles:
attachments.supplementaryAttachmentFiles.map(toOssCallbackView),
contractScanFiles: attachments.contractScanFiles.map(toOssCallbackView),
};
};
export const toViewServiceAgreement = (
domain: DomainServiceAgreementVo,
): ViewServiceAgreementDetail => {
return {
...domain,
companyArea: normalizeCompanyArea(domain.companyArea),
expirationTime: toTimestamp(domain.expirationTime),
createdTime: toTimestamp(domain.createdTime) ?? 0,
updatedTime: toTimestamp(domain.updatedTime) ?? 0,
contractScanFiles: domain.contractScanFiles.map(toOssCallbackView),
billFiles: domain.billFiles.map(toOssCallbackView),
supplementaryAttachmentFiles:
domain.supplementaryAttachmentFiles.map(toOssCallbackView),
};
};
export const toViewServiceAgreementPage = (
domain: DomainServiceAgreementPageVo,
): ViewServiceAgreementPageItem => {
return {
...domain,
companyArea: normalizeCompanyArea(domain.companyArea),
};
};
export const toViewPreviewAttachments = (
domain: DomainPreviewAttachmentsVO,
): ViewPreviewAttachmentsData => {
return {
newFiles: mapAttachments(domain.newFiles),
oldFiles: mapAttachments(domain.oldFiles),
};
};
const toServicePointInput = (item: ServicePointSpecification) => ({
id: item.id,
agreementId: item.agreementId,
serviceAccount: item.serviceAccount,
transformerCapacity: item.transformerCapacity,
electricityConsumptionType: item.electricityConsumptionType,
voltageClass: item.voltageClass,
});
const toViewServicePoint = (
item: ServicePointSpecificationInput,
): ServicePointSpecification => ({
id: item.id ?? 0,
agreementId: item.agreementId ?? 0,
serviceAccount: item.serviceAccount,
transformerCapacity: item.transformerCapacity,
electricityConsumptionType: item.electricityConsumptionType,
voltageClass: item.voltageClass,
});
export const toDomainServiceAgreementRequest = (
view: ServiceAgreementForm,
): DomainServiceAgreementRequestDTO => {
return {
...view,
companyName: view.companyName || "",
companyAddress: view.companyAddress || "",
liaisonName: view.liaisonName || "",
liaisonPosition: view.liaisonPosition || "",
liaisonPhone: view.liaisonPhone || "",
industry: view.industry || null, // If DTO industry is string | null, this is fine. If string, use || ''
comment: view.comment || null,
companyArea: normalizeCompanyArea(view.companyArea),
expirationTime: toDateTimeString(view.expirationTime),
servicePointSpecifications: view.servicePointSpecifications
? view.servicePointSpecifications.map(toServicePointInput)
: null,
creator: null,
};
};
export const toViewServiceAgreementRequest = (
domain: DomainServiceAgreementRequestDTO,
): ServiceAgreementForm => {
const { servicePointSpecifications, ...rest } = domain;
return {
...rest,
companyArea: normalizeCompanyArea(domain.companyArea),
expirationTime: toTimestamp(domain.expirationTime),
servicePointSpecifications: servicePointSpecifications
? servicePointSpecifications.map(toViewServicePoint)
: null,
};
};

UI-Mappers#

import type {
AttachmentDataForUI,
CustomerInfoDataForUI,
ServiceAgreementUIMap,
ServiceAgreementDetail,
SignInfoDataForUI,
} from "./models";
import type { OssCallbackView } from "@/modules/file/application/models";
import type { ServiceAgreementStatus } from "../domain/enums";
221 collapsed lines
const DEFAULT_STATUS: ServiceAgreementStatus = 1;
/**
* 将后端 VO 转换为 UI 所需的 CustomerInfo
*/
export const createCustomerInfoModel = (
origin: Partial<ServiceAgreementDetail> = {},
): CustomerInfoDataForUI => {
return {
id: origin.id ?? null,
status: origin.status ?? DEFAULT_STATUS,
companyName: origin.companyName ?? "",
companyArea: origin.companyArea ?? "",
companyAddress: origin.companyAddress ?? "",
industry: origin.industry ?? null,
liaisonName: origin.liaisonName ?? "",
liaisonPosition: origin.liaisonPosition ?? "",
liaisonPhone: origin.liaisonPhone ?? "",
yearUsableCharge: origin.yearUsableCharge ?? 0,
isTimeOfUsePricingEnabled: origin.isTimeOfUsePricingEnabled ?? false,
peakPercentage: origin.peakPercentage ?? null,
superPeakPercentage: origin.superPeakPercentage ?? null,
standardPercentage: origin.standardPercentage ?? null,
valleyPercentage: origin.valleyPercentage ?? null,
comment: origin.comment ?? "",
};
};
/**
* 将后端 VO 转换为 UI 所需的 SignInfo
*/
export const createSignInfoModel = (
origin: Partial<ServiceAgreementDetail> = {},
): SignInfoDataForUI => {
return {
priceModel: origin.priceModel ?? null,
priceType: origin.priceType ?? null,
priceCategory: origin.priceCategory ?? null,
fixedPrice: origin.fixedPrice ?? null,
fixedSpread: origin.fixedSpread ?? null,
revenueShareRatio: origin.revenueShareRatio ?? null,
expirationTime: origin.expirationTime
? new Date(origin.expirationTime).getTime()
: null,
comment: null,
servicePointSpecifications: Array.isArray(origin.servicePointSpecifications)
? origin.servicePointSpecifications
: [],
};
};
/**
* 将后端 VO (含文件对象列表) 转换为 UI 提交所需的 (ID 列表)
*/
export const createAttachmentInfoModel = (
origin: Partial<ServiceAgreementDetail> = {},
): AttachmentDataForUI => {
const mapIds = (files?: OssCallbackView[]): number[] => {
if (!Array.isArray(files)) return [];
return files.map((f) => f.id);
};
return {
contractScanIds: origin.contractScanIds || mapIds(origin.contractScanFiles),
billIds: origin.billIds || mapIds(origin.billFiles),
supplementaryAttachmentIds:
origin.supplementaryAttachmentIds ||
mapIds(origin.supplementaryAttachmentFiles),
};
};
/**
* 主入口:将可能为空的后端 VO 转换为 UI 状态树
*/
export const createServiceAgreementModel = (
origin?: Partial<ServiceAgreementDetail>,
): ServiceAgreementUIMap => {
const safeOrigin = origin || {};
return {
customerInfo: createCustomerInfoModel(safeOrigin),
signInfo: createSignInfoModel(safeOrigin),
attachmentInfo: createAttachmentInfoModel(safeOrigin),
};
};
/**
* 将 UI 状态树转换为后端所需的 DTO
*/
const trimOrNull = (val: string | null | undefined): string | null => {
if (val === null || val === undefined) return null;
const trimmed = val.trim();
return trimmed === "" ? null : trimmed;
};
/**
* 将 UI 状态树转换为后端所需的 DTO
*/
import dayjs from "dayjs";
import type { ServiceAgreementRequestDTO } from "../domain/dto";
/**
* 将 UI 状态树转换为后端所需的 DTO
*/
export const convertUIToRequestDTO = (
uiModel: ServiceAgreementUIMap,
): ServiceAgreementRequestDTO => {
const { customerInfo, signInfo, attachmentInfo } = uiModel;
return {
id: customerInfo.id,
status: customerInfo.status,
companyName: trimOrNull(customerInfo.companyName) ?? "",
companyArea: trimOrNull(customerInfo.companyArea) ?? "",
companyAddress: trimOrNull(customerInfo.companyAddress) ?? "",
industry: trimOrNull(customerInfo.industry),
liaisonName: trimOrNull(customerInfo.liaisonName) ?? "",
liaisonPosition: trimOrNull(customerInfo.liaisonPosition) ?? "",
liaisonPhone: trimOrNull(customerInfo.liaisonPhone) ?? "",
yearUsableCharge: customerInfo.yearUsableCharge || 0,
isTimeOfUsePricingEnabled: customerInfo.isTimeOfUsePricingEnabled,
peakPercentage: customerInfo.peakPercentage,
superPeakPercentage: customerInfo.superPeakPercentage,
standardPercentage: customerInfo.standardPercentage,
valleyPercentage: customerInfo.valleyPercentage,
comment: trimOrNull(customerInfo.comment),
priceModel: signInfo.priceModel,
priceType: signInfo.priceType,
priceCategory: signInfo.priceCategory,
fixedPrice: signInfo.fixedPrice,
fixedSpread: signInfo.fixedSpread,
revenueShareRatio: signInfo.revenueShareRatio,
expirationTime: signInfo.expirationTime
? dayjs(signInfo.expirationTime).format("YYYY-MM-DD HH:mm:ss")
: null,
servicePointSpecifications: signInfo.servicePointSpecifications,
contractScanIds: attachmentInfo.contractScanIds,
billIds: attachmentInfo.billIds,
supplementaryAttachmentIds: attachmentInfo.supplementaryAttachmentIds,
creator: null,
};
};

对于最终的presentation,可以很方便地定位到出问题的地方进行修改,不用看面条式代码

import { $t } from "@/_utils/i18n";
import type { ServiceAgreementUIMap } from "@/modules/service-agreement/application/models";
import { ServiceAgreementStatusEnum } from "@/modules/service-agreement/application/constants";
import { convertUIToRequestDTO } from "@/modules/service-agreement/application/ui-mappers";
import ServiceAgreementFormComponent from "@/modules/service-agreement/presentation/sign/ServiceAgreementForm";
import {
useServiceAgreementDetail,
187 collapsed lines
useSubmitRecordMutation,
useSubmitSignMutation,
} from "@/modules/service-agreement/application/hooks/useSignService";
import { NButton, NSpace } from "naive-ui";
import { match } from "ts-pattern";
import { computed, defineComponent } from "vue";
import { useRouter } from "vue-router";
import { usePrint } from "@/modules/approval/application/hooks/usePrint";
import UnifiedFormPrint from "@/modules/shared/presentation/diff-check/components/print/UnifiedFormPrint";
import {
PreviewTypeEnum,
ServiceAgreementStatusOption,
} from "@/modules/service-agreement/application/constants";
import {
buildServiceAgreementDiffCheckFields,
toServiceAgreementDetailDiffCheckForm,
} from "@/modules/service-agreement/presentation/diff-check/serviceAgreementDiffCheck";
export default defineComponent({
name: "ServiceAgreementDetail",
props: {
id: {
type: Number,
},
},
setup(props) {
const router = useRouter();
const { print } = usePrint();
// 获取详情数据
const { data: serviceAgreementData, isLoading: initialDataLoading } =
useServiceAgreementDetail(
computed(() => {
return props.id ? props.id : null;
}),
);
const initialData = computed(() => serviceAgreementData.value);
// 备案提交
const submitRecord = useSubmitRecordMutation((resp) => {
router.push({
path: "/auth/sign/result",
query: {
id: String(resp.id),
status: String(ServiceAgreementStatusEnum.Record),
},
});
});
// 签约提交
const submitSign = useSubmitSignMutation((resp) => {
router.push({
path: "/auth/sign/result",
query: {
id: String(resp.id),
status: String(ServiceAgreementStatusEnum.Sign),
},
});
});
// 提交处理
const handleClick = async (
formValue: ServiceAgreementUIMap,
handleValidate: () => Promise<boolean>,
) => {
const isValid = await handleValidate();
if (!isValid) return;
const converter = convertUIToRequestDTO(formValue);
const payload = converter;
match(formValue.customerInfo.status)
.with(ServiceAgreementStatusEnum.Record, () => {
submitRecord.mutate(payload);
})
.with(ServiceAgreementStatusEnum.Sign, () => {
submitSign.mutate(payload);
})
.otherwise(() => {
console.warn("Unknown status:", formValue.customerInfo.status);
});
};
const handlePrint = () => {
print("printable-approval-area");
};
const previewUrl = computed(() => {
const origin = window.location.origin;
const id = props.id ?? initialData.value?.id;
return `${origin}/sign/preview/attachments?id=${id}&type=${PreviewTypeEnum.FORM_VIEW}`;
});
const printTitle = computed(() => {
if (!initialData.value) return "表单";
const statusLabel =
ServiceAgreementStatusOption.find(
(o) => o.value === initialData.value!.status,
)?.label || "表单";
return `${statusLabel}`;
});
return () => (
<>
<ServiceAgreementFormComponent
initialValue={initialData.value}
loading={initialDataLoading.value}
>
{{
Button: ({
formValue,
handleValidate,
}: {
formValue: ServiceAgreementUIMap;
handleValidate: () => Promise<boolean>;
}) => (
<NSpace>
<NButton
onClick={() => handleClick(formValue, handleValidate)}
loading={
submitRecord.isPending.value || submitSign.isPending.value
}
>
{$t("common.action.submit")}
</NButton>
<NButton
onClick={() => {
handlePrint();
}}
>
{$t("common.action.print")}
</NButton>
</NSpace>
),
}}
</ServiceAgreementFormComponent>
{initialData.value && (
<div style="display: none;">
<div id={"printable-approval-area"}>
<UnifiedFormPrint
title={printTitle.value}
docNo={String(props.id ?? initialData.value.id ?? "")}
previewUrl={previewUrl.value}
fields={buildServiceAgreementDiffCheckFields()}
data={toServiceAgreementDetailDiffCheckForm(initialData.value)}
oldData={null}
/>
</div>
</div>
)}
</>
);
},
});
关于前端数据与业务上解耦的思考
https://blog.astro777.cfd/posts/guide/decoupling-data-from-business-in-frontend/
作者
ASTRO
发布于
2026-02-19
许可协议
CC BY-NC-SA 4.0