前端各种单据各种状态各种操作?看我用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. 使用的基础库:

相关推荐
baiduopenmap10 分钟前
百度世界2024精选公开课:基于地图智能体的导航出行AI应用创新实践
前端·人工智能·百度地图
loooseFish18 分钟前
小程序webview我爱死你了 小程序webview和H5通讯
前端
请叫我欧皇i30 分钟前
html本地离线引入vant和vue2(详细步骤)
开发语言·前端·javascript
533_33 分钟前
[vue] 深拷贝 lodash cloneDeep
前端·javascript·vue.js
guokanglun39 分钟前
空间数据存储格式GeoJSON
前端
zhang-zan1 小时前
nodejs操作selenium-webdriver
前端·javascript·selenium
ZBY520311 小时前
【Vue】 npm install amap-js-api-loader指南
javascript·vue.js·npm
猫爪笔记1 小时前
前端:HTML (学习笔记)【2】
前端·笔记·学习·html
brief of gali1 小时前
记录一个奇怪的前端布局现象
前端
计算机毕设指导62 小时前
基于 SpringBoot 的作业管理系统【附源码】
java·vue.js·spring boot·后端·mysql·spring·intellij-idea