EvaluationFormBlock.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. <template>
  2. <DynamicForm
  3. ref="formRef"
  4. :model="currentForm"
  5. :options="mergedFormOptions"
  6. />
  7. <FlexCol>
  8. <Divider />
  9. <H3>自查项目选择</H3>
  10. <Height :height="30" />
  11. <FlexCol gap="gap.md">
  12. <FlexCol v-for="(item, index) in checkItemList" :key="item.id" gap="gap.md">
  13. <FlexRow justify="space-between" align="center">
  14. <Text fontConfig="subTitleText" :text="`${index + 1}. ${item.name}`" />
  15. <Tag :text="getCheckModeText(item.checkType)" />
  16. </FlexRow>
  17. <FlexCol v-if="item.checkType == 3" gap="gap.sm">
  18. <FlexRow v-for="child in item.children" :key="child.id" justify="space-between">
  19. <CheckBox
  20. :disabled="readonly"
  21. :text="`${child.name} (${child.points}分)`"
  22. :modelValue="hasCheckedItem(child.id)"
  23. @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)"
  24. />
  25. <Stepper
  26. v-if="hasCheckedItem(child.id)"
  27. :disabled="readonly"
  28. :min="0"
  29. :max="20"
  30. :step="1"
  31. :modelValue="getCheckedItemCount(child.id) ?? 0"
  32. addonAfter="次"
  33. @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)"
  34. />
  35. <view v-else></view>
  36. </FlexRow>
  37. </FlexCol>
  38. <FlexCol v-else gap="gap.sm">
  39. <CheckBox
  40. v-for="child in item.children" :key="child.id"
  41. :disabled="readonly"
  42. :text="`${child.name} (${child.points}分)`"
  43. :modelValue="hasCheckedItem(child.id)"
  44. @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)"
  45. />
  46. </FlexCol>
  47. <FlexCol gap="gap.sm" border="1px solid #e0e0e0" radius="radius.md" padding="space.md">
  48. <Text
  49. fontConfig="subText"
  50. color="text.second"
  51. :text="readonly ? '佐证资料' : '佐证材料上传,支持图片、视频、音频、文档(word、pdf、excel、ppt)等格式'"
  52. />
  53. <Text
  54. v-if="!currentForm.id"
  55. fontConfig="subText"
  56. color="text.second"
  57. text="请先保存评估表后再上传佐证资料"
  58. />
  59. <Uploader
  60. v-show="currentForm.id"
  61. :ref="(el) => bindUploaderRef(item.id, el)"
  62. :upload="getAnnexUpload(item.id)"
  63. :onDeleteClick="(file) => handleAnnexRemove(item.id, file)"
  64. :max-upload-count="100"
  65. :max-file-size="20 * 1024 * 1024"
  66. :group-type="true"
  67. chooseType="file"
  68. list-type="list"
  69. :itemExtraButtons="readonly ? [] : [{ icon: 'edit', onClick: (item2) => editAnnexDesc(item.id, item2) }]"
  70. :readonly="readonly"
  71. />
  72. </FlexCol>
  73. </FlexCol>
  74. </FlexCol>
  75. </FlexCol>
  76. <FlexCol>
  77. <Divider />
  78. <FlexRow justify="space-between" align="center">
  79. <H3>自评总分</H3>
  80. <Text fontConfig="subTitleText" color="text.primary" :text="`${totalPoints} 分`" />
  81. </FlexRow>
  82. </FlexCol>
  83. <DynamicForm
  84. ref="formRef2"
  85. :model="currentForm"
  86. :options="mergedFormOptionsEnd"
  87. />
  88. </template>
  89. <script setup lang="ts">
  90. import { computed, ref, watch } from 'vue';
  91. import DynamicForm from '@/components/dynamic/DynamicForm.vue';
  92. import FlexCol from '@/components/layout/FlexCol.vue';
  93. import H3 from '@/components/typography/H3.vue';
  94. import Text from '@/components/basic/Text.vue';
  95. import FlexRow from '@/components/layout/FlexRow.vue';
  96. import CheckBox from '@/components/form/CheckBox.vue';
  97. import Stepper from '@/components/form/Stepper.vue';
  98. import Uploader, { type UploaderInstance } from '@/components/form/Uploader.vue';
  99. import AssessmentContentApi, { getCheckAnnexType, SelfAssessmentCheckItemAnswer, type CheckItemInfo, type SelfAssessmentDetail } from '@/api/collect/AssessmentContent';
  100. import AgentWorkApi from '@/api/agent/AgentWorks';
  101. import type { IDynamicFormOptions, IDynamicFormRef } from '@/components/dynamic';
  102. import { ArrayUtils } from '@imengyu/imengyu-utils';
  103. import Tag from '@/components/display/Tag.vue';
  104. import Divider from '@/components/display/Divider.vue';
  105. import Height from '@/components/layout/space/Height.vue';
  106. import { useAliOssUploadCo } from '@/common/components/upload/AliOssUploadCo';
  107. import { getMimeType } from '@/common/components/upload/mimes';
  108. import { stringUrlToUploaderItem, type UploaderAction, type UploaderItem } from '@/components/form/Uploader';
  109. const props = withDefaults(defineProps<{
  110. currentForm: SelfAssessmentDetail;
  111. formOptions: IDynamicFormOptions;
  112. formOptionsEnd: IDynamicFormOptions;
  113. checkItemList: CheckItemInfo[];
  114. currentFormCheckItems: SelfAssessmentCheckItemAnswer[];
  115. /** 管理员只读查看 / 审核页 */
  116. readonly?: boolean;
  117. }>(), {
  118. readonly: false,
  119. });
  120. const formRef = ref<IDynamicFormRef | null>(null);
  121. const formRef2 = ref<IDynamicFormRef | null>(null);
  122. const uploaderRefMap = new Map<number, UploaderInstance | null>();
  123. const uploadCoMap = new Map<number, (action: UploaderAction) => (() => void)>();
  124. const mergedFormOptions = computed<IDynamicFormOptions>(() => ({
  125. ...props.formOptions,
  126. formAdditionaProps: {
  127. ...props.formOptions.formAdditionaProps,
  128. disabled: props.readonly,
  129. },
  130. }));
  131. const mergedFormOptionsEnd = computed<IDynamicFormOptions>(() => ({
  132. ...props.formOptionsEnd,
  133. formAdditionaProps: {
  134. ...props.formOptionsEnd.formAdditionaProps,
  135. disabled: props.readonly,
  136. },
  137. }));
  138. function getCheckModeText(checkMode: number) {
  139. switch (checkMode) {
  140. case 1:
  141. return '单选';
  142. case 2:
  143. return '多选';
  144. case 3:
  145. return '可多次';
  146. }
  147. }
  148. function hasCheckedItem(id: number) {
  149. return props.currentFormCheckItems.some(item => item.id === id);
  150. }
  151. function getCheckedItemCount(id: number) {
  152. return props.currentFormCheckItems.find(item => item.id === id)?.count;
  153. }
  154. function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, count: number|boolean) {
  155. if (props.readonly)
  156. return;
  157. if (typeof count === 'boolean') {
  158. count = count ? 1 : 0;
  159. }
  160. let item = props.currentFormCheckItems.find(item => item.id === childItem.id);
  161. if (!item) {
  162. item = new SelfAssessmentCheckItemAnswer();
  163. props.currentFormCheckItems.push(item);
  164. }
  165. if (item.count === count)
  166. return;
  167. item.id = childItem.id;
  168. item.points = childItem.points;
  169. item.count = count;
  170. switch (checkItem.checkType) {
  171. case 1: {
  172. /** 单选,清除其他选项 */
  173. const allChildren = checkItem.children.map(child => child.id);
  174. props.currentFormCheckItems.forEach(item => {
  175. if (allChildren.includes(item.id) && item.id !== childItem.id)
  176. item.count = 0;
  177. });
  178. for (let i = props.currentFormCheckItems.length - 1; i >= 0; i--) {
  179. if (props.currentFormCheckItems[i].count === 0)
  180. props.currentFormCheckItems.splice(i, 1);
  181. }
  182. break;
  183. }
  184. }
  185. if (item.count === 0)
  186. ArrayUtils.remove(props.currentFormCheckItems, item);
  187. }
  188. function formatAnnexDisplayName(desc: string | null | undefined, name: string) {
  189. const descText = (desc ?? '').trim();
  190. if (!descText)
  191. return name;
  192. return `${descText}(${name})`;
  193. }
  194. function buildAnnexDescInputDoc() {
  195. const content = props.currentForm.content;
  196. if (!content)
  197. return '';
  198. const lines: string[] = [];
  199. for (let i = 0; i <= 6; i++) {
  200. const value = String(content[`item${i}`] ?? '').trim();
  201. if (!value)
  202. continue;
  203. lines.push(`第${i + 1}项:${value}`);
  204. }
  205. return lines.join('\n');
  206. }
  207. type AnnexUploaderItem = UploaderItem & {
  208. annexMeta?: {
  209. id: number;
  210. rawName: string;
  211. desc: string;
  212. url: string;
  213. type: number;
  214. mimetype?: string|null;
  215. attachId?: number|null;
  216. fileSize?: number|null;
  217. };
  218. };
  219. function createAnnexUploaderItem(item: {
  220. id: number;
  221. name: string;
  222. desc?: string|null;
  223. url: string;
  224. type: number;
  225. mimetype?: string|null;
  226. attachId?: number|null;
  227. fileSize?: number|null;
  228. }) {
  229. const displayName = formatAnnexDisplayName(item.desc, item.name);
  230. const uploaderItem = stringUrlToUploaderItem(item.url, displayName) as AnnexUploaderItem;
  231. uploaderItem.annexMeta = {
  232. id: item.id,
  233. rawName: item.name,
  234. desc: item.desc ?? '',
  235. url: item.url,
  236. type: item.type,
  237. mimetype: item.mimetype,
  238. attachId: item.attachId,
  239. fileSize: item.fileSize,
  240. };
  241. return uploaderItem;
  242. }
  243. function promptAnnexDesc(initialDesc: string, title: string) {
  244. return new Promise<string|null>((resolve) => {
  245. uni.showModal({
  246. title,
  247. editable: true,
  248. placeholderText: '请输入佐证资料说明(可选)',
  249. content: initialDesc,
  250. success: (res) => {
  251. if (!res.confirm) {
  252. resolve(null);
  253. return;
  254. }
  255. resolve((res.content ?? '').trim());
  256. },
  257. fail: () => resolve(null),
  258. });
  259. });
  260. }
  261. async function editAnnexDesc(itemId: number, item: UploaderItem, isAfterUpload = false) {
  262. const formId = props.currentForm.id;
  263. if (!formId)
  264. return;
  265. const annexItem = item as AnnexUploaderItem;
  266. const meta = annexItem.annexMeta;
  267. if (!meta?.id) {
  268. uni.showToast({ title: '附件信息不完整,无法编辑说明', icon: 'none' });
  269. return;
  270. }
  271. const nextDesc = await promptAnnexDesc(
  272. meta.desc ?? '',
  273. isAfterUpload ? '上传成功,请补充附件说明' : '编辑附件说明',
  274. );
  275. if (nextDesc === null)
  276. return;
  277. item.name = nextDesc;
  278. await AssessmentContentApi.saveAnnex({
  279. id: meta.id,
  280. name: meta.rawName,
  281. formId,
  282. itemId,
  283. url: meta.url,
  284. type: meta.type,
  285. desc: nextDesc,
  286. mimetype: meta.mimetype ?? undefined,
  287. attachId: meta.attachId ?? undefined,
  288. fileSize: meta.fileSize ?? undefined,
  289. });
  290. await loadAnnexListByItem(itemId);
  291. }
  292. function bindUploaderRef(itemId: number, el: unknown) {
  293. uploaderRefMap.set(itemId, (el as UploaderInstance | null) || null);
  294. if (el)
  295. loadAnnexListByItem(itemId);
  296. }
  297. function handleAnnexRemove(itemId: number, item: UploaderItem): Promise<void> {
  298. const annexItem = item as AnnexUploaderItem;
  299. const meta = annexItem.annexMeta;
  300. if (!meta?.id || item.state !== 'success')
  301. return Promise.resolve();
  302. return new Promise((resolve, reject) => {
  303. uni.showModal({
  304. title: '确认删除',
  305. content: '确定要删除该佐证材料吗?',
  306. confirmText: '删除',
  307. confirmColor: '#ff4d4f',
  308. success: async (res) => {
  309. if (!res.confirm) {
  310. reject(new Error('cancel'));
  311. return;
  312. }
  313. try {
  314. await AssessmentContentApi.delAnnex(meta.id);
  315. await loadAnnexListByItem(itemId);
  316. uni.showToast({ title: '删除成功', icon: 'success' });
  317. resolve();
  318. } catch (err) {
  319. uni.showToast({
  320. title: err instanceof Error ? err.message : '删除失败',
  321. icon: 'none',
  322. });
  323. reject(err);
  324. }
  325. },
  326. fail: () => reject(new Error('cancel')),
  327. });
  328. });
  329. }
  330. function getAnnexUpload(itemId: number) {
  331. const cached = uploadCoMap.get(itemId);
  332. if (cached)
  333. return cached;
  334. const uploadCo = useAliOssUploadCo('assessment/annex', async (res, item) => {
  335. const formId = props.currentForm.id;
  336. if (!formId)
  337. return;
  338. const mimetype = getMimeType(item.filePath);
  339. const inputDoc = buildAnnexDescInputDoc();
  340. const desc = /*inputDoc
  341. ? await AgentWorkApi.generateFileDescByInputDoc(inputDoc, item.name)
  342. :*/ '';
  343. await AssessmentContentApi.saveAnnex({
  344. name: item.name,
  345. formId,
  346. itemId,
  347. desc,
  348. url: res,
  349. type: getCheckAnnexType(mimetype),
  350. mimetype,
  351. fileSize: item.size
  352. ? Math.max(1, Math.ceil(item.size / 1024))
  353. : undefined,
  354. });
  355. await loadAnnexListByItem(itemId);
  356. const uploaded = uploaderRefMap.get(itemId)?.getList().find((listItem) => {
  357. const meta = (listItem as AnnexUploaderItem).annexMeta;
  358. return meta?.url === res;
  359. });
  360. if (uploaded)
  361. await editAnnexDesc(itemId, uploaded, true);
  362. });
  363. uploadCoMap.set(itemId, uploadCo);
  364. return uploadCo;
  365. }
  366. async function loadAnnexListByItem(itemId: number) {
  367. const formId = props.currentForm.id;
  368. const uploaderRef = uploaderRefMap.get(itemId);
  369. if (!uploaderRef)
  370. return;
  371. if (!formId) {
  372. uploaderRef.setList([]);
  373. return;
  374. }
  375. const annexList = await AssessmentContentApi.getAnnexList(formId, itemId);
  376. uploaderRef.setList(annexList.data.map((item) => createAnnexUploaderItem(item)));
  377. }
  378. async function reloadAllAnnexList() {
  379. const itemIds = props.checkItemList.map((item) => item.id);
  380. await Promise.all(itemIds.map(loadAnnexListByItem));
  381. }
  382. const checkItemMap = computed(() => {
  383. const m = new Map<number, CheckItemInfo & { parent?: CheckItemInfo }>();
  384. for (const n of props.checkItemList) {
  385. m.set(n.id, n);
  386. if (n.children?.length) {
  387. for (const child of n.children) {
  388. (child as any).parent = n;
  389. m.set(child.id, child as CheckItemInfo & { parent?: CheckItemInfo });
  390. }
  391. }
  392. }
  393. return m;
  394. });
  395. const totalPoints = computed(() => {
  396. const groupMap = new Map<number, number>();
  397. for (const item of props.currentFormCheckItems) {
  398. const checkItem = checkItemMap.value.get(item.id);
  399. if (!checkItem || !checkItem.parent)
  400. continue;
  401. const parentId = checkItem.parent.id;
  402. const score = (item.points ?? 0) * (item.count ?? 1);
  403. groupMap.set(parentId, (groupMap.get(parentId) ?? 0) + score);
  404. }
  405. let total = 0;
  406. for (const [parentId, groupScore] of groupMap) {
  407. const parent = checkItemMap.value.get(parentId);
  408. const maxPoints = parent?.points ?? Infinity;
  409. total += Math.min(groupScore, maxPoints);
  410. }
  411. total -= (props.currentForm.deductPoints ?? 0);
  412. return total;
  413. });
  414. async function validate() {
  415. if (props.readonly)
  416. return;
  417. await formRef.value?.validate();
  418. await formRef2.value?.validate();
  419. }
  420. watch(
  421. () => [props.currentForm.id, props.checkItemList.map((item) => item.id).join(',')],
  422. () => {
  423. setTimeout(() => {
  424. reloadAllAnnexList();
  425. }, 2000);
  426. },
  427. { immediate: true },
  428. );
  429. defineExpose({ validate });
  430. </script>