Ver código fonte

📦 增加审核统计图表

快乐的梦鱼 2 dias atrás
pai
commit
74b5214225

+ 0 - 1
.npmrc

@@ -1 +0,0 @@
-registry=https://registry.npmjs.com/

+ 43 - 0
package-lock.json

@@ -21,6 +21,7 @@
         "async-validator": "^4.2.5",
         "axios": "^1.11.0",
         "bootstrap": "^5.3.0",
+        "echarts": "^6.1.0",
         "lodash-es": "^4.17.21",
         "md5": "^2.3.0",
         "mitt": "^3.0.1",
@@ -33,6 +34,7 @@
         "tslib": "^2.8.1",
         "vue": "^3.5.18",
         "vue-clipboard3": "^2.0.0",
+        "vue-echarts": "^8.0.1",
         "vue-esign": "^1.1.4",
         "vue-router": "^4.5.1",
         "vue3-carousel": "^0.15.0"
@@ -8659,6 +8661,22 @@
       "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
       "license": "MIT"
     },
+    "node_modules/echarts": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.1.0.tgz",
+      "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "2.3.0",
+        "zrender": "6.1.0"
+      }
+    },
+    "node_modules/echarts/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
+    },
     "node_modules/ee-first": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -19888,6 +19906,16 @@
       "integrity": "sha512-RutnB7X8c5hjq39NceArgXg28WZtZpGc3+J16ljMiYnFhKvd8hITxSWQSQ5bvldxMDU6gG5mkxl1MTQLXckVSQ==",
       "license": "MIT"
     },
+    "node_modules/vue-echarts": {
+      "version": "8.0.1",
+      "resolved": "https://registry.npmmirror.com/vue-echarts/-/vue-echarts-8.0.1.tgz",
+      "integrity": "sha512-23rJTFLu1OUEGRWjJGmdGt8fP+8+ja1gVgzMYPIPaHWpXegcO1viIAaeu2H4QHESlVeHzUAHIxKXGrwjsyXAaA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "echarts": "^6.0.0",
+        "vue": "^3.3.0"
+      }
+    },
     "node_modules/vue-esign": {
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/vue-esign/-/vue-esign-1.1.4.tgz",
@@ -20531,6 +20559,21 @@
       "funding": {
         "url": "https://github.com/sponsors/colinhacks"
       }
+    },
+    "node_modules/zrender": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.1.0.tgz",
+      "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "tslib": "2.3.0"
+      }
+    },
+    "node_modules/zrender/node_modules/tslib": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz",
+      "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
+      "license": "0BSD"
     }
   }
 }

+ 2 - 0
package.json

@@ -28,6 +28,7 @@
     "async-validator": "^4.2.5",
     "axios": "^1.11.0",
     "bootstrap": "^5.3.0",
+    "echarts": "^6.1.0",
     "lodash-es": "^4.17.21",
     "md5": "^2.3.0",
     "mitt": "^3.0.1",
@@ -40,6 +41,7 @@
     "tslib": "^2.8.1",
     "vue": "^3.5.18",
     "vue-clipboard3": "^2.0.0",
+    "vue-echarts": "^8.0.1",
     "vue-esign": "^1.1.4",
     "vue-router": "^4.5.1",
     "vue3-carousel": "^0.15.0"

+ 40 - 0
src/api/collect/AssessmentContent.ts

@@ -706,6 +706,27 @@ export class CheckAnnexListItem extends DataModel<CheckAnnexListItem> {
   updatetime = '' as string|null;
 }
 
+export class DataCountItem extends DataModel<DataCountItem> {
+  constructor() {
+    super(DataCountItem, '统计条目');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      num: { clientSide: 'number' },
+    };
+    this._nameMapperServer = {
+      progress: 'key',
+      reject_type: 'key',
+      progress_text: 'title',
+      reject_type_text: 'title',
+      num: 'value',
+    };
+  }
+
+  title = '';
+  key = 0 as number;
+  value = 0 as number;
+}
+
 export type IchCheckPaginated<T> = {
   total: number;
   perPage: number;
@@ -762,6 +783,25 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     super();
   }
 
+  async getDataCount() {
+    const res = (await this.post<{
+      agreement: object;
+      agreement_reject: object;
+      check: object;
+      check_reject: object;
+    }>('/ich/check/dataCount', '自查/协议数据统计', {})).requireData();
+    return {
+      /**自查评估表统计 {进度值progress:数量} */
+      check: transformArrayDataModel<DataCountItem>(DataCountItem, transformSomeToArray(res.check), 'data.check'),
+      /**自查评估表退回统计 {退回类型reject_type:数量} */
+      checkReject: transformArrayDataModel<DataCountItem>(DataCountItem, transformSomeToArray(res.check_reject), 'data.checkReject'),
+      /**传承协议统计 {进度值progress:数量} */
+      agreement: transformArrayDataModel<DataCountItem>(DataCountItem, transformSomeToArray(res.agreement), 'data.agreement'),
+      /**传承协议退回统计 {退回类型reject_type:数量} */
+      agreementReject: transformArrayDataModel<DataCountItem>(DataCountItem, transformSomeToArray(res.agreement_reject), 'data.agreementReject'),
+    };
+  }
+
   async getCheckItems(level: number) {
     const res = await this.post('/ich/check/getCheckItems', '自查计分项目', { level });
     const list = transformSomeToArray(res.data) as KeyValue[];

+ 102 - 10
src/pages/collect/assessment/argeement-sign-list.vue

@@ -1,11 +1,31 @@
 <template>
-  <CommonListBlock
-    ref="listRef"
-    :show-total="true"
-    :row-count="1"
-    :row-type="5"
-    :page-size="10"
-    :drop-down-names="[
+    <div class="mb-4 p-4 bg-gray-50 rounded border border-gray-200 flex flex-row flex-wrap">
+      <div class="w-full lg:w-1/2 flex flex-row flex-wrap">
+        <div v-for="s in agreementProgressStats" :key="s.key" class="flex-1 flex flex-col p-2 items-center" :style="{width: agreementProgressStatsSpan + '%'}">
+          <span class="text-2xl bold">{{ s.value }}</span>
+          <span class="text-xs text-secondary text-align-center max-h-12 overflow-hidden text-ellipsis">{{ s.title }}</span>
+        </div>
+        <div class="p-2" :style="{width: agreementProgressStatsSpan + '%'}">
+          <v-chart :option="agreementPieChartOption" autoresize />
+        </div>
+      </div>
+      <div class="w-full lg:w-1/2 flex flex-row  flex-wrap border-l border-gray-200">
+        <div v-for="s in agreementRejectStats" :key="s.key" class="flex-1 flex flex-col p-2 items-center" :style="{width: agreementRejectStatsSpan + '%'}">
+          <span class="text-2xl bold">{{ s.value }}</span>
+          <span class="text-xs text-secondary text-align-center max-h-12 overflow-hidden text-ellipsi">{{ s.title }}</span>
+        </div>
+        <div class="p-2" :style="{width: agreementProgressStatsSpan + '%'}">
+          <v-chart :option="agreementBarChartOption" autoresize />
+        </div>
+      </div>
+    </div>
+    <CommonListBlock
+      ref="listRef"
+      :show-total="true"
+      :row-count="1"
+      :row-type="5"
+      :page-size="10"
+      :drop-down-names="[
       {
         options: agreementProgressOptions,
         label: '状态',
@@ -58,15 +78,87 @@
 </template>
 
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
-import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
-import AssessmentContentApi, { type UserAgreementListRow } from '@/api/collect/AssessmentContent';
 import { useMemorizeVar } from '@/composeables/useMemorizeVar';
 import { useAuthStore } from '@/stores/auth';
 import { GROUP_TO_REVIEW_PROGRESS } from './composeables/GroupData';
 import { useReview } from './composeables/Review';
+import { use } from "echarts/core";
+import { CanvasRenderer } from "echarts/renderers";
+import { PieChart, BarChart, LineChart } from "echarts/charts";
+import {
+  TitleComponent,
+  TooltipComponent,
+  LegendComponent,
+  GridComponent,
+} from "echarts/components";
+use([CanvasRenderer, PieChart, BarChart, LineChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent]);
+import VChart from "vue-echarts";
+import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
+import AssessmentContentApi, { type UserAgreementListRow } from '@/api/collect/AssessmentContent';
+
+const dataStats = ref<Awaited<ReturnType<typeof AssessmentContentApi.getDataCount>>|null>(null);
+
+const agreementProgressLabels: Record<string, string> = {
+  '-1': '未提交',
+  '0': '草稿',
+  '1': '已提交待审核',
+  '2': '单位审核完成',
+  '3': '县级审核完成',
+  '4': '市级审核完成',
+  '5': '省级审核完成',
+};
+
+const agreementRejectLabels: Record<string, string> = {
+  '1': '自评阶段退回',
+  '2': '项目保护单位退回',
+  '3': '县(区)退回',
+  '4': '设区市/省非遗中心退回',
+  '5': '省文旅厅退回',
+};
+
+const agreementProgressStats = computed(() => dataStats.value?.agreement || []);
+const agreementProgressStatsSpan = computed(() => 100 / (Math.max(agreementProgressStats.value.length, 1) - 1));
+
+const agreementRejectStats = computed(() => dataStats.value?.agreementReject || []);
+const agreementRejectStatsSpan = computed(() => 100 / (Math.max(agreementRejectStats.value.length, 1) - 1));
+
+const agreementPieChartOption = computed(() => {
+  const data = (dataStats.value?.agreement || []).map((s: any) => ({ name: s.title, value: s.value }));
+  return {
+    tooltip: { trigger: 'item' },
+    series: [{
+      type: 'pie',
+      radius: ['40%', '70%'],
+      center: ['50%', '50%'],
+      data,
+      label: { show: false },
+      emphasis: { label: { show: true } },
+    }],
+  };
+});
+
+const agreementBarChartOption = computed(() => {
+  const data = (dataStats.value?.agreementReject || []).map((s: any) => s.value);
+  const titles = (dataStats.value?.agreementReject || []).map((s: any) => s.title);
+  const max = Math.max(...data, 1);
+  return {
+    tooltip: { trigger: 'axis' },
+    grid: { left: 0, right: 0, top: 8, bottom: 0, containLabel: true },
+    xAxis: { type: 'category', data: titles, axisLabel: { show: false }, axisTick: { show: false } },
+    yAxis: { type: 'value', axisLabel: { show: false }, splitLine: { show: false }, max },
+    series: [
+      { type: 'bar', data: data.map(v => max - v), barWidth: 12, itemStyle: { color: '#f0f0f0', borderRadius: 2 }, stack: 'total', silent: true, z: 1 },
+      { type: 'bar', data, barWidth: 12, itemStyle: { color: '#1890ff', borderRadius: 2 }, stack: 'total', z: 2 },
+    ],
+  };
+});
+
+onMounted(async () => {
+  dataStats.value = await AssessmentContentApi.getDataCount();
+});
 
 const router = useRouter();
 const route = useRoute();

+ 2 - 2
src/pages/collect/assessment/argeement-sign-review.vue

@@ -193,7 +193,7 @@ async function submitReview() {
       progress: reviewProgressInfo.value.target,
     });
     message.success('审核通过');
-    router.back();
+    loader.load();
   } catch (e) {
     Modal.error({ title: '审核提交失败', content: formatErr(e) });
   } finally {
@@ -225,7 +225,7 @@ async function submitReject() {
       rejectReason: reason,
     });
     message.success('已回退');
-    router.back();
+    loader.load();
   } catch (e) {
     Modal.error({ title: '回退失败', content: formatErr(e) });
   } finally {

+ 6 - 3
src/pages/collect/assessment/components/SelfAssessmentFormBlock.vue

@@ -31,7 +31,7 @@
     <a-typography-title :level="4">自查项目选择</a-typography-title>
     <div v-for="(item, index) in checkItemList" :key="item.id" class="check-item-block">
       <div class="check-item-head">
-        <span>{{ index + 1 }}. {{ item.name }}</span>
+        <span>{{ index + 1 }}. {{ item.name }} {{ isDev ? `ID: ${item.id}` : '' }})</span>
         <a-tag>{{ getCheckModeText(item.checkType) }}</a-tag>
       </div>
       <template v-if="item.checkType == 3">
@@ -152,6 +152,8 @@ import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
 import type { RadioValueFormItemProps, SelectIdProps } from '@imengyu/vue-dynamic-form-ant';
 import type { SignProps } from '@imengyu/vue-dynamic-form-rich';
 
+const isDev = import.meta.env.MODE === 'development';
+
 const props = withDefaults(defineProps<{
   currentForm: SelfAssessmentDetail;
   checkItemList: any[];
@@ -386,13 +388,14 @@ const totalPoints = computed(() => {
     if (!checkItem || checkItem.isTitle || !checkItem.parent)
       continue;
     const parentId = checkItem.parent.id;
-    const score = (item.points ?? 0) * (item.count ?? 1);
+    const score = (checkItem.points ?? 0) * (item.count ?? 1);
     groupMap.set(parentId, (groupMap.get(parentId) ?? 0) + score);
   }
-  let total = 0;
+  let total = 0, i = 0;
   for (const [parentId, groupScore] of groupMap) {
     const parent = checkItemMap.value.get(parentId);
     const maxPoints = parent?.points ?? Infinity;
+    console.log(++i, parent?.id, 'get', groupScore, 'max', maxPoints, 'final', Math.min(groupScore, maxPoints));
     total += Math.min(groupScore, maxPoints);
   }
   total -= (props.currentForm.deductPoints ?? 0);

+ 104 - 2
src/pages/collect/assessment/evaluation-form-list.vue

@@ -1,6 +1,26 @@
 <template>
   <a-tabs v-model:activeKey="activeKey" centered type="card">
     <a-tab-pane key="1" tab="传承人列表">
+      <div class="mb-4 p-4 bg-gray-50 rounded border border-gray-200 flex flex-row flex-wrap">
+        <div class="w-full lg:w-1/2 flex flex-row flex-wrap">
+          <div v-for="s in checkProgressStats" :key="s.key" class="flex-1 flex flex-col p-2 items-center" :style="{width: checkProgressStatsSpan + '%'}">
+            <span class="text-2xl bold">{{ s.count }}</span>
+            <span class="text-xs text-secondary text-align-center max-h-12 overflow-hidden text-ellipsis">{{ s.label }}</span>
+          </div>
+          <div class="p-2" :style="{width: checkProgressStatsSpan + '%'}">
+            <v-chart :option="checkPieChartOption" autoresize />
+          </div>
+        </div>
+        <div class="w-full lg:w-1/2 flex flex-row flex-wrap border-l border-gray-200">
+          <div v-for="s in checkRejectStats" :key="s.key" class="flex-1 flex flex-col p-2 items-center" :style="{width: checkRejectStatsSpan + '%'}">
+            <span class="text-2xl bold">{{ s.count }}</span>
+            <span class="text-xs text-secondary text-align-center max-h-12 overflow-hidden text-ellipsi">{{ s.label }}</span>
+          </div>
+          <div class="p-2" :style="{width: checkRejectStatsSpan + '%'}">
+            <v-chart :option="checkBarChartOption" autoresize />
+          </div>
+        </div>
+      </div>
       <CommonListBlock
         ref="listRef"
         :show-total="true"
@@ -126,14 +146,23 @@
 </template>
 
 <script setup lang="ts">
-import { ref, watch } from 'vue';
+import { computed, onMounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useMemorizeVar } from '@/composeables/useMemorizeVar';
 import { useReview } from './composeables/Review';
 import { message } from 'ant-design-vue';
 import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+import { use } from "echarts/core";
+import { CanvasRenderer } from "echarts/renderers";
+import { PieChart, BarChart } from "echarts/charts";
+import {
+  TooltipComponent,
+  GridComponent,
+} from "echarts/components";
+use([CanvasRenderer, PieChart, BarChart, TooltipComponent, GridComponent]);
+import VChart from "vue-echarts";
 import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
-import AssessmentContentApi, { SelfAssessmentDetail } from '@/api/collect/AssessmentContent';
+import  AssessmentContentApi, { SelfAssessmentDetail } from '@/api/collect/AssessmentContent';
 
 const router = useRouter();
 const route = useRoute();
@@ -146,6 +175,79 @@ const downloading = ref(false);
 const downloadYear = ref(new Date().getFullYear() - 1);
 const downloadProgress = ref(-100);
 
+const dataStats = ref<Awaited<ReturnType<typeof AssessmentContentApi.getDataCount>>|null>(null);
+
+const checkProgressLabels: Record<string, string> = {
+  '-1': '未提交',
+  '0': '草稿',
+  '1': '已提交审核',
+  '2': '单位审核完成',
+  '3': '县级审核完成',
+  '4': '市级审核完成',
+  '5': '省级审核完成',
+};
+
+const checkRejectLabels: Record<string, string> = {
+  '1': '自评阶段退回',
+  '2': '项目保护单位退回',
+  '3': '县(区)退回',
+  '4': '设区市/省非遗中心退回',
+  '5': '省文旅厅退回',
+};
+
+const checkProgressStats = computed(() => {
+  if (!dataStats.value?.check) return [];
+  return dataStats.value.check.map((d) => ({ key: d.key, label: d.title, count: d.value, isReject: false }));
+});
+const checkProgressStatsSpan = computed(() => {
+  const len = checkProgressStats.value.length;
+  return len > 1 ? 100 / (len - 1) : 100;
+});
+const checkRejectStats = computed(() => {
+  if (!dataStats.value?.checkReject) return [];
+  return dataStats.value.checkReject.map((d) => ({ key: 'r' + d.key, label: d.title, count: d.value, isReject: true }));
+});
+const checkRejectStatsSpan = computed(() => {
+  const len = checkRejectStats.value.length;
+  return len > 1 ? 100 / (len - 1) : 100;
+});
+
+const checkPieChartOption = computed(() => {
+  const data = (dataStats.value?.check || []).map((s: any) => ({ name: s.title, value: s.value }));
+  return {
+    tooltip: { trigger: 'item' },
+    series: [{
+      type: 'pie',
+      radius: ['40%', '70%'],
+      center: ['50%', '50%'],
+      data,
+      label: { show: false },
+      emphasis: { label: { show: true } },
+    }],
+  };
+});
+
+const checkBarChartOption = computed(() => {
+  const data = (dataStats.value?.checkReject || []).map((s: any) => s.value);
+  const titles = (dataStats.value?.checkReject || []).map((s: any) => s.title);
+  const max = Math.max(...data, 1);
+  return {
+    tooltip: { trigger: 'axis' },
+    grid: { left: 0, right: 0, top: 8, bottom: 0, containLabel: true },
+    xAxis: { type: 'category', data: titles, axisLabel: { show: false }, axisTick: { show: false } },
+    yAxis: { type: 'value', axisLabel: { show: false }, splitLine: { show: false }, max },
+    series: [
+      { type: 'bar', data: data.map(v => max - v), barWidth: 12, itemStyle: { color: '#f0f0f0', borderRadius: 2 }, stack: 'total', silent: true, z: 1 },
+      { type: 'bar', data, barWidth: 12, itemStyle: { color: '#1890ff', borderRadius: 2 }, stack: 'total', z: 2 },
+    ],
+  };
+});
+
+
+onMounted(async () => {
+  dataStats.value = await AssessmentContentApi.getDataCount();
+});
+
 const { variable: lastSelfAssessmentProgress } = useMemorizeVar('adminSelfAssessmentProgress', -100);
 const { variable: lastSelfAssessmentLevel } = useMemorizeVar('adminSelfAssessmentLevel', 0);
 const { variable: lastCheckLogStatus } = useMemorizeVar('adminCheckLogStatus', 0);

+ 2 - 2
src/pages/collect/assessment/evaluation-form-review.vue

@@ -287,7 +287,7 @@ async function submitReject() {
       rejectReason: reason,
     });
     message.success('已回退');
-    router.back();
+    loader.load();
   } catch (e) {
     Modal.error({ title: '回退失败', content: formatErr(e) });
   } finally {
@@ -328,7 +328,7 @@ async function submitReview() {
     else
       await AssessmentContentApi.reviewSelfAssessment({ ...base, province: op, provincePoints: points });
     message.success('审核提交成功');
-    router.back();
+    loader.load();
   } catch (e) {
     Modal.error({ title: '审核提交失败', content: formatErr(e) });
   } finally {