liu
3 天以前 fe2d6627b8f1002af7a31b08f6f43bec034d04ee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
<script lang="ts" setup>
import type { FormType } from '../data';
import type { FinanceReimbursementApi } from '#/api/finance/reimbursement';
import type { SystemStorageApi } from '#/api/system/storage';
 
import { computed, ref } from 'vue';
 
import { useVbenModal } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { useUserStore } from '@vben/stores';
 
import { message } from 'ant-design-vue';
import dayjs from 'dayjs';
 
import { useVbenForm } from '#/adapter/form';
import {
  createReimbursement,
  getReimbursement,
  updateReimbursement,
} from '#/api/finance/reimbursement';
 
import { toSubmitItems, useFormSchema } from '../data';
import ItemForm from './item-form.vue';
 
const emit = defineEmits(['success']);
 
const userStore = useUserStore();
 
/** 当前登录用户 ID(用于本人发起时回填申请人) */
const currentUserId = computed(() => {
  const id = Number(userStore.userInfo?.userId);
  return Number.isFinite(id) && id > 0 ? id : undefined;
});
 
const formData = ref<FinanceReimbursementApi.Reimbursement>({
  title: '',
  totalAmount: 0,
  applyDate: '',
  remark: '',
  items: [],
});
 
const formType = ref<FormType>('create'); // 表单类型:'create' | 'edit' | 'detail'
const itemFormRef = ref<InstanceType<typeof ItemForm>>();
 
/**
 * 明细表格的回显数据源:只在打开弹窗时赋值(编辑回显 / 新增置空)。
 * 用户在表格里的增删改只通过 emit 单向流到表单值,绝不回写到这里,
 * 否则 prop 变化会让表格反复重载(且与子组件 watcher 形成死循环)。
 */
const initialItems = ref<FinanceReimbursementApi.ReimbursementItem[]>([]);
 
const getTitle = computed(() => {
  if (formType.value === 'create') {
    return $t('ui.actionTitle.create', ['报销单']);
  } else if (formType.value === 'edit') {
    return $t('ui.actionTitle.edit', ['报销单']);
  } else {
    return '报销单详情';
  }
});
 
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'vertical',
  schema: useFormSchema(formType.value),
  showDefaultActions: false,
});
 
/** 同步明细行到表单值,保证随主表一次性提交 */
function handleUpdateItems(items: FinanceReimbursementApi.ReimbursementItem[]) {
  // 第二个参数为 false:跳过合并算法,否则数组类型的值会被过滤掉
  formApi.setValues({ items }, false);
}
 
/** 明细金额变化时回填主表报销总金额(用户仍可手动修改) */
function handleUpdateTotalAmount(totalAmount: number) {
  formApi.setValues({ totalAmount }, false);
}
 
/**
 * OCR 识别时上传的发票文件同步到「发票附件」,保证识别过的发票随单保存。
 * 按 blobId 去重,避免同一张发票被重复识别时出现重复附件。
 */
async function handleOcrBlobs(blobs: SystemStorageApi.StorageBlob[]) {
  if (blobs.length === 0) {
    return;
  }
  const values = await formApi.getValues();
  const existing = (values.blobIds ?? []) as Array<
    | number
    | { id?: number; name?: string; status?: string; uid?: string; url?: string }
  >;
  const existingFiles = existing.map((item) =>
    typeof item === 'number'
      ? { uid: String(item), status: 'done', id: item }
      : item,
  );
  const existingIds = new Set(
    existingFiles
      .map((item) => item.id)
      .filter((id): id is number => typeof id === 'number'),
  );
  const appended = blobs
    .filter((blob) => !existingIds.has(blob.id))
    .map((blob) => ({
      uid: String(blob.id),
      name: blob.name || blob.originalFilename || '',
      url: blob.url || blob.previewURL || '',
      status: 'done',
      id: blob.id,
    }));
  if (appended.length === 0) {
    return;
  }
  await formApi.setValues(
    { blobIds: [...existingFiles, ...appended] },
    false,
  );
}
 
/** 创建或更新报销单 */
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    const itemFormInstance = Array.isArray(itemFormRef.value)
      ? itemFormRef.value[0]
      : itemFormRef.value;
    try {
      itemFormInstance?.validate();
    } catch (error: any) {
      message.error(error.message || '报销明细验证失败');
      return;
    }
 
    const data = (await formApi.getValues()) as FinanceReimbursementApi.Reimbursement & {
      proxyFlag?: boolean;
    };
    // 代他人发起必须选择实际申请人;本人发起时以当前登录用户为准
    if (data.proxyFlag) {
      if (!data.applicantId) {
        message.error('代他人发起时,请选择实际申请人');
        return;
      }
    } else {
      data.applicantId = currentUserId.value;
    }
    // proxyFlag 仅用于前端交互,不提交给后端
    delete data.proxyFlag;
    // 发票附件为全量替换语义:清空全部发票时必须显式传空数组,
    // 传 null 后端会视为「不动附件」,导致删除操作不生效
    data.blobIds = data.blobIds ?? [];
    modalApi.lock();
    try {
      await (formType.value === 'create'
        ? createReimbursement(data)
        : updateReimbursement(data));
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      formData.value = {} as FinanceReimbursementApi.Reimbursement;
      initialItems.value = [];
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setDisabled(formType.value === 'detail');
    formApi.updateSchema(useFormSchema(formType.value));
    if (!data || !data.id) {
      initialItems.value = [];
      // 新建时默认本人发起:申请人展示当前登录用户,申请日期为今天
      await formApi.setValues(
        {
          applicantId: currentUserId.value,
          applicantName: userStore.userInfo?.nickname ?? '',
          proxyFlag: false,
          applyDate: dayjs().format('YYYY-MM-DD'),
        },
        false,
      );
      return;
    }
    modalApi.lock();
    try {
      formData.value = await getReimbursement(data.id);
      // 明细表格的行 key 用数据库 id,故回显数据源保留原 id
      initialItems.value = formData.value.items ?? [];
      // 已有发票转成上传组件可识别的文件对象,提交时按 valueKey=id 提取为 blobId
      const blobIds = (formData.value.attachmentList ?? []).map((item) => ({
        uid: String(item.id),
        name: item.name || '',
        url: item.url || '',
        status: 'done',
        id: item.id,
      }));
      // 实际申请人与当前登录用户不一致时,视为代他人发起,回显申请人选择器
      const isProxy =
        formType.value === 'edit' &&
        formData.value.applicantId != null &&
        formData.value.applicantId !== currentUserId.value;
      await formApi.setValues(
        {
          ...formData.value,
          proxyFlag: isProxy,
          // 表单里的 items 只用于提交,必须剥掉行 id(后端「先删后增」会与软删行主键冲突)
          items: toSubmitItems(formData.value.items),
          blobIds,
        },
        false,
      );
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
 
<template>
  <Modal
    :title="getTitle"
    class="w-3/4"
    :close-on-click-modal="false"
    :show-confirm-button="formType !== 'detail'"
  >
    <Form class="mx-3">
      <template #items>
        <ItemForm
          ref="itemFormRef"
          :items="initialItems"
          :disabled="formType === 'detail'"
          class="w-full"
          @ocr-blobs="handleOcrBlobs"
          @update:items="handleUpdateItems"
          @update:total-amount="handleUpdateTotalAmount"
        />
      </template>
    </Form>
  </Modal>
</template>