evaluation-form-review.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558
  1. <template>
  2. <div class="about main-background main-background-type0">
  3. <div v-if="!isInMiniProgram" class="nav-placeholder" />
  4. <section class="main-section large">
  5. <div class="content">
  6. <div class="title left-right">
  7. <a-button :icon="h(ArrowLeftOutlined)" @click="router.back()">返回</a-button>
  8. <h2>自查评估表审核</h2>
  9. <div class="w-20" />
  10. </div>
  11. <a-spin :spinning="loader.loading.value">
  12. <div
  13. v-if="loader.loading.value" class="h-50"
  14. />
  15. <a-result
  16. v-else-if="!currentForm"
  17. status="warning"
  18. title="无法加载评估表"
  19. sub-title="请从管理员列表进入,并确认自查表 ID 与传承人用户 ID 正确。"
  20. />
  21. <div v-else>
  22. <a-alert
  23. type="info"
  24. show-icon
  25. class="mb-4"
  26. :message="progressHint"
  27. />
  28. <SelfAssessmentFormDisplay
  29. :current-form="(currentForm as SelfAssessmentDetail)"
  30. :check-item-list="(checkItemList as CheckItemInfo[])"
  31. :current-form-check-items="(currentFormCheckItems as SelfAssessmentCheckItemAnswer[])"
  32. :readonly="true"
  33. />
  34. <a-divider />
  35. <a-card title="审核提交" size="small" class="review-submit-card" :class="{ collapsed: reviewCardCollapsed }">
  36. <template #extra>
  37. <a-button
  38. size="small"
  39. type="text"
  40. :icon="h(FileTextOutlined)"
  41. @click="goToAssessmentProtocol"
  42. >
  43. 传承协议审核
  44. </a-button>
  45. <a-button
  46. size="small"
  47. type="text"
  48. :icon="h(reviewCardCollapsed ? UpOutlined : DownOutlined)"
  49. @click="reviewCardCollapsed = !reviewCardCollapsed"
  50. >
  51. {{ reviewCardCollapsed ? '展开' : '收起' }}
  52. </a-button>
  53. </template>
  54. <a-alert
  55. v-if="!canSubmitReview"
  56. type="warning"
  57. show-icon
  58. class="mb-4"
  59. message="当前账号用户组无权在此环节审核"
  60. />
  61. <a-alert
  62. v-else-if="currentForm.rejectType > 0"
  63. type="warning"
  64. show-icon
  65. class="mb-4"
  66. :message="`${getRejectTypeText(currentForm.rejectType)}已退回: ${currentForm.rejectReason}`"
  67. />
  68. <a-form v-else layout="vertical" size="middle">
  69. <div class="flex flex-col md:flex-row lg:flex-row w-full gap-3 py-3">
  70. <div
  71. v-for="{ gid, def } in displayFieldDefs"
  72. :key="gid"
  73. class="flex flex-col flex-1"
  74. >
  75. <div class="flex flex-row gap-3">
  76. <a-form-item :required="gid === currentUserGroupId" :label="`${def.label} — 审核意见`">
  77. <a-select
  78. :value="reviewFields[gid]?.opinion"
  79. allow-clear
  80. placeholder="请选择审核意见"
  81. :options="opinionSelectOptions"
  82. @update:value="(v: number | null) => reviewFields[gid].opinion = v"
  83. />
  84. </a-form-item>
  85. <a-form-item :label="`${def.label} — 评分`">
  86. <a-input-number
  87. :value="reviewFields[gid]?.points"
  88. :min="0"
  89. :max="100"
  90. class="w-full"
  91. placeholder="请填写分数"
  92. @update:value="(v: number | null) => reviewFields[gid].points = v"
  93. />
  94. </a-form-item>
  95. </div>
  96. <a-form-item :label="`${def.label} — 修改意见`">
  97. <a-textarea
  98. :value="reviewFields[gid]?.reason"
  99. @update:value="(v: string | null) => reviewFields[gid].reason = v"
  100. allow-clear
  101. placeholder="请输入修改意见/回退原因"
  102. :rows="5"
  103. :maxlength="500"
  104. show-count
  105. />
  106. </a-form-item>
  107. <a-alert
  108. v-if="alreadySubmitted && gid === currentUserGroupId"
  109. type="success"
  110. show-icon
  111. class="mb-4"
  112. message="当前角色已提交意见"
  113. />
  114. </div>
  115. <div v-if="displayFieldDefs.length === 0" class="flex flex-col flex-1">
  116. <a-form-item required :label="isNoProgressChange ? `提交意见:${reviewLevelLabel}` : `审核通过:${reviewLevelLabel}`">
  117. <a-select
  118. :value="reviewFields[currentUserGroupId]?.opinion"
  119. allow-clear
  120. placeholder="请选择审核意见"
  121. :options="opinionSelectOptions"
  122. :disabled="alreadySubmitted"
  123. @update:value="(v: number | null) => reviewFields[currentUserGroupId].opinion = v"
  124. />
  125. </a-form-item>
  126. <a-form-item label="评分">
  127. <a-input-number
  128. :value="reviewFields[currentUserGroupId]?.points"
  129. :min="0"
  130. :max="100"
  131. class="w-full"
  132. placeholder="请填写分数"
  133. :disabled="alreadySubmitted"
  134. @update:value="(v: number | null) => reviewFields[currentUserGroupId].points = v"
  135. />
  136. </a-form-item>
  137. </div>
  138. <div v-if="displayFieldDefs.length <= 1 && (!isNoProgressChange || reviewProgressInfo?.canReject)" class="flex flex-col flex-1">
  139. <a-form-item required label="不通过回退:回退原因">
  140. <a-textarea
  141. :value="reviewFields[currentUserGroupId].reason"
  142. @update:value="(v: string | null) => reviewFields[currentUserGroupId].reason = v"
  143. allow-clear
  144. placeholder="请输入回退原因"
  145. :rows="5"
  146. :maxlength="500"
  147. show-count
  148. />
  149. </a-form-item>
  150. </div>
  151. </div>
  152. <div class="flex row w-full gap-3">
  153. <template v-if="!alreadySubmitted || isMulitRole">
  154. <div class="flex flex-col flex-1">
  155. <a-button
  156. block
  157. type="primary"
  158. :loading="submitLoading"
  159. :disabled="!canSubmitReview"
  160. @click="submitReview"
  161. >
  162. {{ isNoProgressChange ? '提交意见' : '通过审核' }}
  163. </a-button>
  164. </div>
  165. <div v-if="isMulitRole" class="flex flex-col flex-1">
  166. <a-button
  167. block
  168. type="primary"
  169. :loading="submitLoading"
  170. :disabled="!canSubmitReview"
  171. @click="saveReasions"
  172. >
  173. 保存意见
  174. </a-button>
  175. </div>
  176. <div v-if="reviewProgressInfo?.canReject" class="flex flex-col flex-1">
  177. <a-button
  178. block
  179. danger
  180. :loading="rejectLoading"
  181. :disabled="!canSubmitReview"
  182. @click="submitReject"
  183. >
  184. 回退
  185. </a-button>
  186. </div>
  187. </template>
  188. </div>
  189. </a-form>
  190. </a-card>
  191. </div>
  192. </a-spin>
  193. </div>
  194. </section>
  195. </div>
  196. </template>
  197. <script setup lang="ts">
  198. import { computed, h, onMounted, ref, watch } from 'vue';
  199. import { useRoute, useRouter } from 'vue-router';
  200. import { message, Modal } from 'ant-design-vue';
  201. import { RequestApiError } from '@imengyu/imengyu-utils';
  202. import { ArrowLeftOutlined, UpOutlined, DownOutlined, FileTextOutlined } from '@ant-design/icons-vue';
  203. import AssessmentContentApi, {
  204. type CheckItemInfo,
  205. getRejectTypeText,
  206. type SelfAssessmentCheckItemAnswer,
  207. SelfAssessmentDetail,
  208. } from '@/api/collect/AssessmentContent';
  209. import SelfAssessmentFormDisplay from './components/SelfAssessmentFormDisplay.vue';
  210. import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
  211. import { useAuthStore } from '@/stores/auth';
  212. import { useMemorizeVar } from '@/composeables/useMemorizeVar';
  213. import { GROUP_TO_REVIEW_PROGRESS, GROUP_DISTRICT_ST, GROUP_DISTRICT_ND, GROUP_DISTRICT_RD, GROUP_DISTRICT_TH, GROUP_DISTRICT_FI, PROGRESS_SUBMIT_LABELS, GROUP_FIELD_MAP, type GroupFieldDef } from './composeables/GroupData.ts';
  214. import { isInMiniProgram } from '@/composeables/MiniProgramIng.ts';
  215. import { useReview } from './composeables/Review.ts';
  216. function formatErr(e: unknown): string {
  217. if (e instanceof RequestApiError)
  218. return e.errorMessage;
  219. if (e instanceof Error)
  220. return e.message;
  221. return String(e);
  222. }
  223. const router = useRouter();
  224. const route = useRoute();
  225. const authStore = useAuthStore();
  226. const currentUserGroups = computed(() => authStore.userInfo?.adminGroup || []);
  227. const queryFormId = computed(() => Number(route.query.id) || 0);
  228. const queryUserId = computed(() => Number(route.query.userId) || 0);
  229. /** 列表当前进度(与管理员列表一致) */
  230. const listProgress = computed(() => {
  231. const p = Number(route.query.progress);
  232. return Number.isFinite(p) ? p : NaN;
  233. });
  234. const currentForm = ref<SelfAssessmentDetail | null>(null);
  235. const currentFormCheckItems = ref([] as SelfAssessmentCheckItemAnswer[]);
  236. const checkItemList = ref([] as CheckItemInfo[]);
  237. const { variable: reviewCardCollapsed } = useMemorizeVar<boolean>('evaluation-form-review-card-collapsed', false);
  238. const submitLoading = ref(false);
  239. const rejectLoading = ref(false);
  240. const rejectReason = ref<string | null>(null);
  241. interface ReviewFieldState {
  242. opinion: number | null;
  243. points: number | null;
  244. reason: string|null;
  245. }
  246. const reviewFields = ref<Record<number, ReviewFieldState>>({});
  247. const displayGroupIds = computed(() => {
  248. const info = reviewProgressInfo.value;
  249. if (!info) return [currentUserGroupId.value];
  250. return info.displayGroup ?? [currentUserGroupId.value];
  251. });
  252. const displayFieldDefs = computed(() => {
  253. return displayGroupIds.value
  254. .map((gid) => ({ gid, def: GROUP_FIELD_MAP[gid] }))
  255. .filter((item): item is { gid: number; def: GroupFieldDef } => !!item.def);
  256. });
  257. const isMulitRole = computed(() => displayFieldDefs.value.length > 1);
  258. const opinionSelectOptions = [
  259. { label: '请选择', value: 0 },
  260. { label: '优秀', value: 1 },
  261. { label: '合格', value: 2 },
  262. { label: '不合格', value: 3 },
  263. { label: '丧失传承能力', value: 4 },
  264. { label: '取消资格', value: 5 },
  265. ];
  266. const currentProgress = computed(() => currentForm.value?.progress ?? 0);
  267. const { reviewProgressInfo, canSubmitReview, reviewLevelLabel, progressHint, isNoProgressChange } = useReview(currentProgress);
  268. const currentUserGroupId = computed(() => {
  269. const groups = [...currentUserGroups.value].sort((a, b) => b.id - a.id);
  270. return groups.find((g) => GROUP_TO_REVIEW_PROGRESS[g.id])?.id ?? 0;
  271. });
  272. /** 当前子角色是否已提交过意见(不可重复提交) */
  273. const alreadySubmitted = computed(() => {
  274. const f = currentForm.value;
  275. if (!f) return false;
  276. const gid = currentUserGroupId.value;
  277. if (gid === GROUP_DISTRICT_ST) return (f.districtSt ?? 0) > 0;
  278. if (gid === GROUP_DISTRICT_ND) return (f.districtNd ?? 0) > 0;
  279. if (gid === GROUP_DISTRICT_FI) return (f.districtFi ?? 0) > 0;
  280. return false;
  281. });
  282. function levelTitleFromForm(f: SelfAssessmentDetail) {
  283. if (f.level === 23) return '国家级';
  284. if (f.level === 24) return '省级';
  285. if (f.level === 25) return '市级';
  286. return '国家级';
  287. }
  288. function loadEditorContent(f: SelfAssessmentDetail) {
  289. if (typeof f.content !== 'object' || f.content === null)
  290. f.content = {};
  291. f.content.title = `传承人填写${f.year}年1月1日至${f.year}年12月31日${levelTitleFromForm(f)}非遗传承人义务履行和传承补助经费使用情况等,不超过1000字,如未履行职责请进行说明。参考提纲如下:`;
  292. for (let i = 0; i < 8; i++) {
  293. if (typeof f.content[`item${i}`] !== 'string')
  294. f.content[`item${i}`] = '';
  295. }
  296. }
  297. async function loadCheckItems(f: SelfAssessmentDetail) {
  298. const { top } = await AssessmentContentApi.getCheckItems(Number(f.level));
  299. checkItemList.value = top as CheckItemInfo[];
  300. currentFormCheckItems.value = [...f.checkItems] as SelfAssessmentCheckItemAnswer[];
  301. }
  302. function applyPrefillFromDetail(f: SelfAssessmentDetail) {
  303. rejectReason.value = f.rejectReason;
  304. const fields: Record<number, ReviewFieldState> = {};
  305. for (const { gid, def } of displayFieldDefs.value) {
  306. fields[gid] = {
  307. opinion: (f as any)[def.opinionField] ?? null,
  308. points: (f as any)[def.pointsField] || null,
  309. reason: f.reasons?.[gid] || null,
  310. };
  311. }
  312. // fallback for non-displayGroup roles (progress-based)
  313. if (displayFieldDefs.value.length === 0) {
  314. const gid = currentUserGroupId.value;
  315. const t = reviewProgressInfo.value?.target;
  316. if (t === 2) fields[gid] = { opinion: f.ichUnit ?? null, points: f.unitPoints || null, reason: f.rejectReason || null };
  317. else if (t === 3) fields[gid] = { opinion: f.county ?? null, points: f.countyPoints || null, reason: f.rejectReason || null };
  318. else if (t === 4) fields[gid] = { opinion: f.district ?? null, points: f.districtPoints || null, reason: f.rejectReason || null };
  319. else if (t === 5) fields[gid] = { opinion: f.province ?? null, points: f.provincePoints || null, reason: f.rejectReason || null };
  320. }
  321. reviewFields.value = fields;
  322. }
  323. const loader = useSimpleDataLoader(async () => {
  324. const id = queryFormId.value;
  325. const uid = queryUserId.value;
  326. if (!id || !uid) {
  327. currentForm.value = null;
  328. return null;
  329. }
  330. const detail = await AssessmentContentApi.getSelfAssessmentDetail(id, uid);
  331. currentForm.value = detail;
  332. loadEditorContent(detail);
  333. await loadCheckItems(detail);
  334. applyPrefillFromDetail(detail);
  335. return detail;
  336. }, { immediate: false });
  337. watch(reviewProgressInfo, () => {
  338. const f = currentForm.value;
  339. if (f)
  340. applyPrefillFromDetail(f as SelfAssessmentDetail);
  341. });
  342. watch(
  343. () => [queryFormId.value, queryUserId.value, listProgress.value] as const,
  344. () => {
  345. loader.load();
  346. },
  347. );
  348. onMounted(() => {
  349. loader.load();
  350. setTimeout(() => {
  351. console.log('currentUserGroups', currentUserGroups.value, 'currentUserGroupId', currentUserGroupId.value);
  352. console.log('reviewProgressInfo', reviewProgressInfo.value, 'canSubmitReview', canSubmitReview.value);
  353. console.log('isNoProgressChange', isNoProgressChange.value);
  354. console.log(reviewFields.value);
  355. }, 800);
  356. });
  357. async function goToAssessmentProtocol() {
  358. const list = await AssessmentContentApi.getUserAgreementList({
  359. year: new Date().getFullYear(),
  360. page: 1,
  361. pageSize: 10,
  362. keywords: currentForm.value?.inheritor || undefined
  363. });
  364. if (list.data.length === 0) {
  365. message.warning('无可审核的传承协议');
  366. return;
  367. }
  368. const item = list.data[0];
  369. const path = router.resolve({
  370. name: 'CollectAgreementSignReview',
  371. query: {
  372. id: String(item.agreementId),
  373. userId: String(item.userId),
  374. ...(item.progress != null ? { progress: String(item.progress) } : {}),
  375. },
  376. });
  377. window.open(path.href, '_blank');
  378. }
  379. async function saveReasions() {
  380. if (!isMulitRole.value) {
  381. message.warning('非多角色账号');
  382. return;
  383. }
  384. const f = currentForm.value;
  385. if (!f?.id) {
  386. message.warning('缺少自查表 ID');
  387. return;
  388. }
  389. const reasons = {} as Record<number, string>;
  390. for (const { gid, def } of displayFieldDefs.value) {
  391. reasons[gid] = reviewFields.value[gid].reason || '';
  392. }
  393. try {
  394. submitLoading.value = true;
  395. await AssessmentContentApi.reviewSelfAssessment({
  396. id: f.id,
  397. progress: f.progress,
  398. reasons
  399. });
  400. message.success('保存成功');
  401. } catch (e) {
  402. Modal.error({ title: '保存失败', content: formatErr(e) });
  403. } finally {
  404. submitLoading.value = false;
  405. }
  406. }
  407. async function submitReject() {
  408. const f = currentForm.value;
  409. if (!f?.id) {
  410. message.warning('缺少自查表 ID');
  411. return;
  412. }
  413. if (!canSubmitReview.value) {
  414. message.warning('当前账号用户组无权回退');
  415. return;
  416. }
  417. if (isMulitRole.value) {
  418. let result = '';
  419. for (const { gid, def } of displayFieldDefs.value) {
  420. if (gid === currentUserGroupId.value) {
  421. result = reviewFields.value[gid].reason || '';
  422. break;
  423. }
  424. }
  425. if (!result) {
  426. Modal.error({
  427. title: '提示',
  428. content: '未找到对应id的回退原因',
  429. });
  430. return;
  431. }
  432. rejectReason.value = result
  433. }
  434. const reason = (rejectReason.value ?? '').trim();
  435. if (!reason) {
  436. message.warning('请输入回退原因');
  437. return;
  438. }
  439. try {
  440. rejectLoading.value = true;
  441. await AssessmentContentApi.reviewSelfAssessment({
  442. id: f.id,
  443. progress: f.progress,
  444. rejectType: reviewProgressInfo.value.rejectTarget,
  445. rejectReason: reason,
  446. });
  447. message.success('已回退');
  448. loader.load();
  449. } catch (e) {
  450. Modal.error({ title: '回退失败', content: formatErr(e) });
  451. } finally {
  452. rejectLoading.value = false;
  453. }
  454. }
  455. async function submitReview() {
  456. const f = currentForm.value;
  457. if (!f?.id) {
  458. message.warning('缺少自查表 ID');
  459. return;
  460. }
  461. if (!canSubmitReview.value) {
  462. message.warning('当前账号用户组无权提交审核');
  463. return;
  464. }
  465. const gid = currentUserGroupId.value;
  466. const myField = reviewFields.value[gid];
  467. if (myField?.opinion == null || myField.opinion === 0) {
  468. message.warning('请选择审核意见');
  469. return;
  470. }
  471. if (myField?.points == null) {
  472. message.warning('请输入审核评分');
  473. return;
  474. }
  475. const noProgressChange = isNoProgressChange.value;
  476. const base: Record<string, any> = { id: f.id, progress: noProgressChange ? f.progress : reviewProgressInfo.value.target };
  477. // Collect all fields from the display group
  478. for (const { gid: fieldGid, def } of displayFieldDefs.value) {
  479. const state = reviewFields.value[fieldGid];
  480. if (state?.opinion != null && state.opinion > 0) {
  481. base[def.opinionField] = state.opinion;
  482. }
  483. if (state?.points != null) {
  484. base[def.pointsField] = state.points;
  485. }
  486. }
  487. // Fallback for non-displayGroup (progress-based) roles
  488. if (displayFieldDefs.value.length === 0) {
  489. const op = myField.opinion;
  490. const points = myField.points;
  491. if (base.progress === 2) { base.ichUnit = op; base.unitPoints = points; }
  492. else if (base.progress === 3) { base.county = op; base.countyPoints = points; }
  493. else if (base.progress === 4) { base.district = op; base.districtPoints = points; }
  494. else { base.province = op; base.provincePoints = points; }
  495. }
  496. try {
  497. submitLoading.value = true;
  498. await AssessmentContentApi.reviewSelfAssessment(base as any);
  499. message.success(noProgressChange ? '意见提交成功' : '审核提交成功');
  500. loader.load();
  501. } catch (e) {
  502. Modal.error({ title: '审核提交失败', content: formatErr(e) });
  503. } finally {
  504. submitLoading.value = false;
  505. }
  506. }
  507. </script>
  508. <style lang="scss" scoped>
  509. .review-submit-card {
  510. position: sticky;
  511. bottom: 0;
  512. z-index: 10;
  513. box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  514. :deep(.ant-form-item) {
  515. margin-bottom: 0!important;
  516. }
  517. &.collapsed :deep(.ant-card-body) {
  518. display: none;
  519. }
  520. }
  521. </style>