前端各种单据各种状态各种操作?看我用TypeScript和面向对象来盘了它!

一、需求描述

系统设计了 销售单、采购单、入库单、出库单、盘点单、移库单、生产计划单等等等等类型的单据,单据都有着 单据编号单据明细单据状态 等属性,而且每个单据都有不同的状态,但又都包含了 待审核已驳回已完成 等状态,且每个单据都有自己的单据号,如果被驳回审核的话,又都包含了驳回审核的原因等等。每种单据又都有自己的明细单,比如销售单对应的销售明细单等。这些明细单又分别的有数量、已完成数量等。

为了代码复用且便于维护,于是我们将所有单据抽出基类来统一实现公共的一些功能。

二、设计思路

我们需要设计一个数据结构来存储单据信息,包括单据编号、单据明细、单据总数量、单据已完成数量、单据状态等。

1. 抽象单据基类

typescript 复制代码
export abstract class AbstractBaseBillEntity<D extends AbstractBaseBillDetailEntity> extends BaseEntity {
  /**
   * # 单据编号
   * 
   * 所有的单据都会有单据编号的字段
   */
  @Table({
    orderNumber: 99,
    forceShow: true,
  })
  @Form({
    placeholder: '不填写按编码规则自动生成',
  })
  @Field('单据编号') billCode!: string

  /**
   * # 单据状态码
   * 
   * 因为单据的状态码都不确定,所以这里定义为抽象属性
   */
  abstract status: number

  /**
   * # 单据明细列表
   * 
   * 因为单据明细的具体类型未知,所以这里给定泛型,并且抽象
   * 子类可以按自己的设计去实现,并且标记强制数据转换的类
   */
  abstract details: D[]

  /**
   * # 驳回原因
   * 
   * 所有被驳回的单据都需要驳回的原因字段
   */
  @Form({
    textarea: true,
  })
  @Field('驳回原因') rejectReason!: string
}

2. 抽象单据明细基类

typescript 复制代码
export abstract class AbstractBaseBillDetailEntity extends BaseEntity {
  /**
   * # 单据ID
   */
  billId!: number

  /**
   * # 数量
   * 
   * 有的叫采购数量,有的叫出库数量
   */
  abstract quantity: number

  /**
   * # 已完成数量
   * 
   * 有的叫已采购数量,有的叫已出库数量
   */
  abstract finishQuantity: number
}

3. 抽象单据操作Service基类

因为所有的单据都会有审核、驳回、完成等操作,所以这里定义一个抽象类来统一实现这些功能。

typescript 复制代码
  /**
   * # 单据抽象服务基类
   * 完成单据的审核、驳回、完成等操作
   */
  export abstract class AbstractBaseBillService<D extends AbstractBaseBillDetailEntity, B extends AbstractBaseBillEntity<D>> extends AbstractBaseService<B> {
    /**
     * # 审核单据
     * @param bill 单据
     */
    async audit(bill: B): Promise<void> {
      await this.api('audit').post(bill)
    }
  
    /**
     * # 驳回单据
     * @param bill 单据
     */
    async reject(bill: B): Promise<void> {
      await this.api('reject').post(bill)
    }
  
    /**
     * # 完成单据
     * @param bill 单据
     */
    async finish(bill: B): Promise<void> {
      await this.api('finish').post(bill)
    }
  
    /**
     * # 添加完成数量
     * @param bill 单据
     */
    async addFinish(bill: D): Promise<void> {
      await this.api('addFinish').post(bill)
    }
  }

4. 封装单据的统一表格Hook

typescript 复制代码
/**
 * # 单据的表格Hook
 * @param entityClass 单据实体类
 * @param serviceClass 单据服务类
 * @param option 配置项
 */
export function useBillTable<
  D extends AbstractBaseBillDetailEntity,
  B extends AbstractBaseBillEntity<D>,
  S extends AbstractBaseBillService<D, B>
>(
  entityClass: ClassConstructor<B>,
  serviceClass: ClassConstructor<S>,
  option: IUseTableOption<B> = {},
): IUseBillTableResult<D, B, S> {
  const result = useAirTable(entityClass, serviceClass, option)

  async function onAudit(bill: B) {
    await AirConfirm.warning(`是否确认审核选择的${result.entity.getModelName()}?`)
    await result.service.audit(bill)
    result.onReloadData()
  }

  async function onFinish(bill: B) {
    await AirConfirm.warning(`是否确认手动完成选择的${result.entity.getModelName()}?`)
    await result.service.finish(bill)
    result.onReloadData()
  }

  async function onReject(bill: B) {
    const rejectReason: string = await AirDialog.show(BillRejectDialog, `驳回${result.entity.getModelName()}的原因`)
    await AirConfirm.warning(`是否确认驳回选择的${result.entity.getModelName()}?`)
    bill.rejectReason = rejectReason
    await result.service.reject(bill)
    result.onReloadData()
  }

  return {
    onFinish,
    onAudit,
    onReject,
    ...result,
  } as IUseBillTableResult<D, B, S>
}
typescript 复制代码
/**
 * # 单据表格结构体声明
 * @param D 单据明细类型
 * @param B 单据类型
 * @param S 单据服务类型
 */
export interface IUseBillTableResult<
  D extends AbstractBaseBillDetailEntity,
  B extends AbstractBaseBillEntity<D>,
  S extends AbstractBaseBillService<D, B>
> extends IUseTableResult<B, S> {
  /**
   * # 审核
   * @param bill 单据
   */
  onAudit: (bill: B) => void

  /**
   * # 驳回
   * @param bill 单据
   */
  onReject: (bill: B) => void

  /**
   * # 完成单据
   * @param bill
   */
  onFinish: (bill: B) => void
}

5. 部分子类的实现

接下来,我们就可以按照具体的单据类型去实现子类了,比如下面的采购单:
*

复制代码
### 1). 采购单实体类

```typescript
@Model('采购单')
export class PurchaseEntity extends AbstractBaseBillEntity<PurchaseDetailEntity> {
  @Field('采购单号') declare billCode: string

  @Table({
    nowrap: true,
  })
  @Form({
    textarea: true,
    maxLength: 80,
    requiredString: true,
  })
  @Field('采购事由') reason!: string

  @Table({
    width: 150,
    suffixText: '元',
    align: 'right',
    forceShow: true,
  })
  @Form({
    suffixText: '元',
  })
  @Field('总金额') totalPrice!: number

  @Table({
    width: 150,
    suffixText: '元',
    align: 'right',
    forceShow: true,
  })
  @Form({
    suffixText: '元',
  })
  @Field('实际金额') totalRealPrice!: number

  @Table({
    width: 100,
    showColor: true,
    orderNumber: -80,
    forceShow: true,
  })
  @Dictionary(PurchaseStatusDictionary)
  @Search()
  @Field('采购状态') status!: PurchaseStatus

  @Field('采购明细')
  @Type(PurchaseDetailEntity, true) details: PurchaseDetailEntity[] = []
}
```
复制代码
### 2). 采购单明细实体类

```typescript
@Model('采购明细')
export class PurchaseDetailEntity extends AbstractBaseBillDetailEntity {
  /**
   * # 物料
  */
  @Form({
    requiredNumber: true,
  })
  @Type(MaterialEntity) material!: MaterialEntity

  /**
   * # 供应商
  */
  @Form({
    requiredNumber: true,
  })
  @Type(SupplierEntity) supplier!: SupplierEntity

  @Field('采购单价')
  @Form({
    requiredNumber: true,
    number: true,
  })
  @Table({
    width: 150,
    suffixText: '元',
    align: 'right',
    orderNumber: -1,
  })
  @Type(Number) price!: number

  @Field('采购数量')
  @Form({
    requiredNumber: true,
    number: true,
  })
  @Table({
    align: 'right',
    width: 150,
    orderNumber: -2,
  })
  @Type(Number) quantity!: number

  @Field('已采购数量')
  @Table({
    align: 'right',
    width: 150,
    orderNumber: -3,
  })
  @Type(Number) finishQuantity!: number
}
```
复制代码
### 3). 采购单状态枚举和字典

这里需要按照后端给定的状态码去定义好枚举:

```typescript
/**
 * # 采购单状态枚举
 */
export enum PurchaseStatus {
  /**
   * # 审核中
  */
  AUDITING = 1,

  /**
   * # 已驳回
  */
  REJECTED = 2,

  /**
   * # 采购中
   */
  PURCHASING = 3,

  /**
   * # 已完成
   */
  DONE = 4,

  /**
   * # 已入库
   */
  FINISHED = 5,
}
```

还需要将声明的状态枚举搭配一个字典,用来翻译和显示不同的状态。

```typescript
/**
 * # 采购单状态枚举字典
 */
export const PurchaseStatusDictionary = AirDictionaryArray.create([
  { key: PurchaseStatus.AUDITING, color: AirColor.WARNING, label: '审核中' },
  { key: PurchaseStatus.REJECTED, color: AirColor.DANGER, label: '已驳回' },
  { key: PurchaseStatus.PURCHASING, color: AirColor.SUCCESS, label: '采购中' },
  { key: PurchaseStatus.DONE, color: AirColor.NORMAL, label: '已完成' },
  { key: PurchaseStatus.FINISHED, color: AirColor.NORMAL, label: '已入库' },
])

```

三、使用方式

1. 列表页面 list.vue

html 复制代码
<template>
  <APanel>
    <AToolBar
      :loading="isLoading"
      :entity="PurchaseEntity"
      :service="PurchaseService"
      show-filter
      @on-add="onAdd"
      @on-search="onSearch"
    />
    <ATable
      v-loading="isLoading"
      :data-list="response.list"
      :entity="PurchaseEntity"
      :select-list="selectList"
      :disable-edit="(row: PurchaseEntity) => row.status !== PurchaseStatus.REJECTED"
      hide-delete
      show-detail
      :ctrl-width="160"
      @on-detail="onDetail"
      @on-edit="onEdit"
      @on-sort-change="onSortChanged"
      @on-select="onSelected"
    >
      <template #customRow="row">
        <AButton
          link-button
          tooltip="审核"
          type="CONFIRM"
          :disabled="(row.data as PurchaseEntity).status !== PurchaseStatus.AUDITING"
          @click="onAudit(row.data)"
        >
          审核
        </AButton>
        <AButton
          link-button
          tooltip="驳回"
          type="LOCK"
          :disabled="(row.data as PurchaseEntity).status !== PurchaseStatus.AUDITING"
          @click="onReject(row.data)"
        >
          驳回
        </AButton>
      </template>
    </ATable>
    <template #footerLeft>
      <APage
        :response="response"
        @on-change="onPageChanged"
      />
    </template>
  </APanel>
</template>

<script lang="ts" setup>
// import codes here
const {
  isLoading,
  response,
  selectList,
  onSearch,
  onAdd,
  onEdit,
  onPageChanged,
  onSortChanged,
  onSelected,
  onDetail,
  onReject,
  onAudit,
} = useBillTable(PurchaseEntity, PurchaseService, {
  editView: PurchaseEditor,
  detailView: PurchaseDetail,
})
</script>

2. 新增、修改页面 edit.vue

html 复制代码
<template>
  <ADialog
    :title="title + PurchaseEntity.getModelName()"
    :form-ref="formRef"
    :loading="isLoading"
    width="80%"
    height="80%"
    @on-confirm="onSubmit()"
    @on-cancel="onCancel()"
  >
    <el-form
      ref="formRef"
      :model="formData"
      label-width="120px"
      :rules="rules"
      @submit.prevent
    >
      <AGroup
        title="采购单"
        :column="2"
      >
        <el-form-item
          :label="PurchaseEntity.getFieldName('billCode')"
          prop="billCode"
        >
          <AInput
            v-model.billCode="formData.billCode"
            :entity="PurchaseEntity"
          />
        </el-form-item>
        <el-form-item
          style="width: 100%;"
          :label="PurchaseEntity.getFieldName('reason')"
          prop="reason"
        >
          <AInput
            v-model.reason="formData.reason"
            :entity="PurchaseEntity"
          />
        </el-form-item>
      </AGroup>
      <AGroup title="采购明细">
        <ATable
          :entity="PurchaseDetailEntity"
          :data-list="formData.details"
          :field-list="PurchaseDetailEntity.getTableFieldConfigList().filter(item => !['createTime'].includes(item.key))"
          hide-edit
          hide-delete
        >
          <template #addButton>
            <AButton
              type="ADD"
              @click="addDetail()"
            >
              添加{{ PurchaseEntity.getFieldName('details') }}
            </AButton>
          </template>
          <template #customRow="row">
            <AButton
              type="DELETE"
              danger
              icon-button
              @click="deleteDetail(row.index)"
            />
          </template>
        </ATable>
      </AGroup>
    </el-form>
  </ADialog>
</template>

<script lang="ts" setup>
// import codes here...
const props = defineProps(airPropsParam(new PurchaseEntity()))

const {
  title, formData, rules, formRef, isLoading,
  onSubmit,
} = useAirEditor(props, PurchaseEntity, PurchaseService, {
  afterGetDetail(detailData) {
    return detailData
  },
  beforeSubmit(submitData) {
    if (submitData.details.length === 0) {
      AirNotification.warning('请添加明细后再提交')
      return null
    }
    return submitData
  },
})

async function addDetail() {
  const detail: PurchaseDetailEntity = await AirDialog.show(PurchaseDetailEditor)
  formData.value.details.push(detail)
}

async function deleteDetail(index: number) {
  await AirConfirm.warning('是否删除选中行的采购明细?')
  formData.value.details.splice(index, 1)
}
</script>

四、总结

按上述的设计,我们实现了一个简单的采购管理模块,包括采购单的列表、新增、修改、审核等功能。

来吧,你给上述的封装思路和代码打几分?在评论区留下你的看法吧~

基于装饰器的更多文章,可以阅读我的专栏 "用TypeScript写前端"。

1. 相关的源代码:

2. 使用的基础库:

相关推荐
腾讯TNTWeb前端团队3 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰7 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪7 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪7 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy8 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom8 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom8 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom8 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom9 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom9 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试