Преглед изворни кода

完善数据统计模块的同比/环比逻辑

gongtianxiao пре 1 месец
родитељ
комит
2a4ed2b045

+ 1 - 1
src/components/chart/GroupAreaCompareBarChart.vue

@@ -45,7 +45,7 @@ const chartRef = ref(null);
 let chartInstance = null;
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
 );
 
 const getUsedAreaSqm = (item) =>

+ 2 - 2
src/components/manage/pages/ManageBasicArchive.vue

@@ -253,13 +253,13 @@ const currentPeriod = computed(() => {
   return `${now.getFullYear()}-${pad2(now.getMonth() + 1)}`;
 });
 
-/** 截止:次月 5 日 */
+/** 截止:次月 1 日 */
 const periodDeadline = computed(() => {
   const now = new Date();
   const month = now.getMonth() + 1;
   const nextMonth = month === 12 ? 1 : month + 1;
   const nextYear = month === 12 ? now.getFullYear() + 1 : now.getFullYear();
-  return `${nextYear}-${pad2(nextMonth)}-05`;
+  return `${nextYear}-${pad2(nextMonth)}-01`;
 });
 
 const showPickModal = ref(false);

+ 23 - 1
src/components/manage/pages/ManageHistoryQuery.vue

@@ -54,11 +54,20 @@ import BatchUploadModal from '@/components/manage/asset/BatchUploadModal.vue';
 import FullAssetListPanel from '@/components/manage/asset/FullAssetListPanel.vue';
 import { submitAssetEdit, submitNewAsset } from '@/api/enterpriseList';
 import { useEnterpriseStore, REPORT_STATE } from '@/store/enterprise';
+import { useNotificationStore } from '@/store/notification';
+import { useUserStore } from '@/store/user';
+import {
+  appendHistoryToPatch,
+  buildBatchHistoryEntry,
+  resolveOperatorName,
+} from '@/utils/assetHistoryRecord';
 import { buildBatchFieldPatch, mergeAssetsForCodeCheck, validateAssetForm } from '@/utils/assetDetailFields';
 import { downloadAssetBatchTemplate } from '@/utils/downloadAssetBatchTemplate';
 import { normalizeAsset } from '@/utils/fullAssetList';
 
 const enterpriseStore = useEnterpriseStore();
+const notificationStore = useNotificationStore();
+const userStore = useUserStore();
 const showAssetModal = ref(false);
 const showBatchEditModal = ref(false);
 const showBatchUploadModal = ref(false);
@@ -207,6 +216,14 @@ const openBatchEditModal = () => {
   showBatchEditModal.value = true;
 };
 
+const syncNotificationsAfterChange = async () => {
+  try {
+    await notificationStore.syncAfterReportChange();
+  } catch (e) {
+    console.log('通知同步失败:', e);
+  }
+};
+
 const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
   const idSet = new Set(selectedAssetIds.value.map(String));
   const assets = enterpriseStore.approvedList
@@ -224,10 +241,14 @@ const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
   let lastError = '';
 
   try {
+    const operator = resolveOperatorName(userStore.userInfo);
+
     for (const asset of assets) {
       try {
         const patch = buildBatchFieldPatch(asset, fieldKey, value);
-        await submitAssetEdit(asset, patch);
+        const historyEntry = buildBatchHistoryEntry(asset, fieldKey, value, operator);
+        const patchWithHistory = appendHistoryToPatch(asset, patch, historyEntry);
+        await submitAssetEdit(asset, { ...patchWithHistory, fill_time: Date.now() });
         successCount += 1;
       } catch (e) {
         failCount += 1;
@@ -239,6 +260,7 @@ const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
     if (successCount > 0) {
       await enterpriseStore.fetchReportProgress({ force: true });
       await enterpriseStore.fetchByState(REPORT_STATE.APPROVED, { force: true });
+      await syncNotificationsAfterChange();
     }
 
     if (failCount === 0) {

+ 5 - 6
src/components/stats/ChartSpecial.vue

@@ -54,7 +54,6 @@ import TopicCard from '@/components/stats/TopicCard.vue';
 import PieChart from '@/components/chart/PieChart.vue';
 import { ref, onMounted, onUnmounted, computed } from 'vue';
 import { useRouter } from 'vue-router';
-import { storeToRefs } from 'pinia';
 import { useEnterpriseStore } from '@/store/enterprise';
 import { useFilterStore } from '@/store/filter';
 import { sumFieldToWan,toWanSquareMeters } from '@/utils/areaMath';
@@ -62,7 +61,6 @@ import { sumFieldToWan,toWanSquareMeters } from '@/utils/areaMath';
 const router = useRouter();
 const filterStore = useFilterStore();
 const enterpriseStore = useEnterpriseStore();
-const { approvedList: originList } = storeToRefs(enterpriseStore);
 
 const mq = window.matchMedia('(max-width: 1440px)');
 const isSmallScreen = ref(mq.matches);
@@ -88,13 +86,14 @@ const onMainBarClick = (groupName) => {
   router.push('/topic1');
 };
 
-const filteredList = computed(() => {
-  return enterpriseStore.filteredByGroup(filterStore.selectGroup)
-})
+const filteredList = computed(() =>
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
 
 //柱状图数据
 const groupChartData = computed(() => {
-  const groupMap = originList.value.reduce((acc, item) => {
+  const list = enterpriseStore.filteredByGroup('全部', filterStore.selectDate);
+  const groupMap = list.reduce((acc, item) => {
     const name = (item.c_ssqpjt ?? '其他').trim()
     acc[name] = (acc[name] ?? 0) + Number(item.c_sjjzmj || 0)
     return acc

+ 53 - 14
src/components/stats/QueryFilter.vue

@@ -1,29 +1,68 @@
 <template>
   <div class="select-wrapper">
-    <BaseSelect text="统计周期" :selectedText="filterStore.selectDate" :options="dateOptions" @selected="filterStore.setDate" />
+    <BaseSelect
+      text="统计周期"
+      :selectedText="filterStore.selectDate || periodPlaceholder"
+      :options="dateOptions"
+      @selected="filterStore.setDate"
+    />
     <BaseSelect text="集团范围" :selectedText="filterStore.selectGroup" :options="QingpuGroupOptions" @selected="filterStore.setGroup" />
-    <BaseButton text="刷新数据"/>
-    <BaseButton text="重置数据" @click="filterStore.resetFilter" />
+    <BaseButton text="刷新数据" @click="handleRefresh" />
+    <BaseButton text="重置数据" @click="handleReset" />
   </div>
 </template>
 
 <script setup>
 import BaseSelect from '@/components/base/BaseSelect.vue';
 import BaseButton from '@/components/base/BaseButton.vue';
-import { ref, computed } from 'vue';
+import { computed, onMounted, watch } from 'vue';
+import { storeToRefs } from 'pinia';
 import { useFilterStore } from '@/store/filter';
-import { GROUP_ORDER } from '@/utils/aggregateReportRecords';
+import { useEnterpriseStore } from '@/store/enterprise';
+import { collectReportPeriods, GROUP_ORDER } from '@/utils/aggregateReportRecords';
 
 const filterStore = useFilterStore();
+const enterpriseStore = useEnterpriseStore();
+const { approvedList } = storeToRefs(enterpriseStore);
 
-const dateOptions = ref([
-  {text: '2026-01', value: '2026-01'},
-  {text: '2026-02', value: '2026-02'},
-  {text: '2026-03', value: '2026-03'},
-  {text: '2026-04', value: '2026-04'},
-  {text: '2026-05', value: '2026-05'},
-  {text: '2026-06', value: '2026-06'},
-])
+const dateOptions = computed(() =>
+  collectReportPeriods(approvedList.value).map((period) => ({
+    text: period,
+    value: period,
+  })),
+);
+
+const periodPlaceholder = computed(() =>
+  (dateOptions.value.length ? '请选择' : '暂无数据'),
+);
+
+const syncDefaultPeriod = () => {
+  const options = dateOptions.value;
+  if (!options.length) {
+    if (filterStore.selectDate) filterStore.setDate('');
+    return;
+  }
+  const values = options.map((item) => item.value);
+  if (!values.includes(filterStore.selectDate)) {
+    filterStore.setDate(values[0]);
+  }
+};
+
+watch(dateOptions, syncDefaultPeriod, { immediate: true });
+
+onMounted(() => {
+  enterpriseStore.fetchStatisticsList();
+});
+
+const handleRefresh = async () => {
+  await enterpriseStore.fetchStatisticsList(true);
+  syncDefaultPeriod();
+};
+
+const handleReset = () => {
+  filterStore.resetFilter();
+  syncDefaultPeriod();
+};
 
 const QingpuGroupOptions = computed(() => [
   { text: '全部', value: '全部' },
@@ -48,4 +87,4 @@ const QingpuGroupOptions = computed(() => [
     height: auto;
   }
 }
-</style>
+</style>

+ 14 - 1
src/pages/AssetDetailPage.vue

@@ -51,6 +51,13 @@ import BaseButton from '@/components/base/BaseButton.vue';
 import AssetEditModal from '@/components/manage/asset/AssetEditModal.vue';
 import { submitAssetEdit } from '@/api/enterpriseList';
 import { useEnterpriseStore } from '@/store/enterprise';
+import { useNotificationStore } from '@/store/notification';
+import { useUserStore } from '@/store/user';
+import {
+  appendHistoryToPatch,
+  buildDetailHistoryEntry,
+  resolveOperatorName,
+} from '@/utils/assetHistoryRecord';
 import { buildManageRoute } from '@/utils/manageRoutes';
 import {
   ASSET_DETAIL_FIELDS,
@@ -61,6 +68,8 @@ import {
 const route = useRoute();
 const router = useRouter();
 const enterpriseStore = useEnterpriseStore();
+const notificationStore = useNotificationStore();
+const userStore = useUserStore();
 const showEditModal = ref(false);
 const savingEdit = ref(false);
 
@@ -106,10 +115,14 @@ const handleSaveEdit = async (patch) => {
 
   savingEdit.value = true;
   try {
-    await submitAssetEdit(asset.value, patch);
+    const operator = resolveOperatorName(userStore.userInfo);
+    const historyEntry = buildDetailHistoryEntry(asset.value, patch, operator);
+    const patchWithHistory = appendHistoryToPatch(asset.value, patch, historyEntry);
+    await submitAssetEdit(asset.value, patchWithHistory);
     enterpriseStore.clearAssetEditDraft(assetId.value);
     showEditModal.value = false;
     await enterpriseStore.fetchReportProgress({ force: true });
+    await notificationStore.syncAfterReportChange();
     showSuccess('保存成功,已提交待审核。请前往「管理端 · 审核管理」查看;审核通过后全量列表才会更新。');
     router.push(buildManageRoute({ tab: 'audit', sub: 'pending' }));
   } catch (e) {

+ 123 - 50
src/pages/HomePage.vue

@@ -3,14 +3,16 @@
   <div class="main">
     <QueryFilter />
     <div class="top-wrapper">
-      <MetricCard v-for="item in metricCardList" :key="item.id"
-      :title="item.title"
-      :text="item.text"
-      :showTrendIcon="item.showTrendIcon"
-      :trend="item.trend"
-      :percent="item.percent"
-      :value="item.value"
-      :showUnit="item.showUnit"
+      <MetricCard
+        v-for="item in metricCardList"
+        :key="item.id"
+        :title="item.title"
+        :text="item.text"
+        :showTrendIcon="item.showTrendIcon"
+        :trend="item.trend"
+        :percent="item.percent"
+        :value="item.value"
+        :showUnit="item.showUnit"
       />
     </div>
     <ChartSpecial />
@@ -24,61 +26,132 @@ import QueryFilter from '@/components/stats/QueryFilter.vue';
 import ChartSpecial from '@/components/stats/ChartSpecial.vue';
 import MonthlyReport from '@/components/stats/MonthlyReport.vue';
 import MetricCard from '@/components/stats/MetricCard.vue';
-import { ref, onMounted,computed } from 'vue';
+import { computed, onMounted } from 'vue';
 import { useEnterpriseStore } from '@/store/enterprise';
 import { useFilterStore } from '@/store/filter';
 import { sumFieldToWan, sumFieldsToWan } from '@/utils/areaMath';
 import { formatPercent2 } from '@/utils/calculatePercentage';
+import {
+  buildHistoricalSnapshots,
+  computeCertifiedRateFromSnapshots,
+  computeIdleRateFromSnapshots,
+  emptyComparison,
+  formatRatePointChange,
+  formatRelativeChange,
+  getPreviousMonthPeriod,
+  getYearAgoPeriod,
+  sumSnapshotsToWan,
+} from '@/utils/statsComparison';
 
 const filterStore = useFilterStore();
 const enterpriseStore = useEnterpriseStore();
 
-const filteredList = computed(() => {
-  return enterpriseStore.filteredByGroup(filterStore.selectGroup)
-})
+const filteredList = computed(() =>
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
 
-//总建筑面积
-const TotalBuildingArea= computed(()=>{
-  return sumFieldToWan(filteredList.value, 'c_sjjzmj')
-})
+const previousMonth = computed(() => getPreviousMonthPeriod(filterStore.selectDate));
+const previousYear = computed(() => getYearAgoPeriod(filterStore.selectDate));
 
-//已出租/在用建筑面积
-const TotalRentedArea = computed(() =>
+const momSnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousMonth.value)
+);
+const yoySnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousYear.value)
+);
+
+const totalBuildingArea = computed(() => sumFieldToWan(filteredList.value, 'c_sjjzmj'));
+
+const totalRentedArea = computed(() =>
   sumFieldsToWan(filteredList.value, ['c_zymj', 'c_cjmj', 'c_czmj'])
-)
-
-//已出租/在用建筑面积占比
-const rentPercent = computed(()=>{
-  return formatPercent2(TotalRentedArea.value, TotalBuildingArea.value)
-})
-
-//闲置面积
-const idleArea = computed(()=>{
-  return sumFieldToWan(filteredList.value, 'c_xzmj')
-})
-
-//闲置率
-const idleRate = computed(()=>{
-  return formatPercent2(idleArea.value, TotalBuildingArea.value)
-})
-
-//已办证资产占比
-const certifiedAssetPercent = computed(()=>{
-  const sum = filteredList.value.filter(item => item.c_sfblcz === '是').length
-  return formatPercent2(sum, filteredList.value.length)
-})
-
-const metricCardList=ref([
-  { id:1,title:"建筑总面积",text:"同比去年",percent:"+1.2%",value:TotalBuildingArea},
-  { id:2,title:"已出租/在用建筑面积",text:"占总建筑面积",showTrendIcon:false,percent:rentPercent,value:TotalRentedArea},
-  { id:3,title:"闲置率", text:"环比上月",trend:"down",percent:"-1.2%",value:idleRate,showUnit:false},
-  { id:4,title:"已办证资产占比",text:"环比上月",percent:"+0.5%",value:certifiedAssetPercent,showUnit:false}
-])
-
-onMounted(async() => {
-  await enterpriseStore.fetchStatisticsList()
+);
+
+const rentPercent = computed(() =>
+  formatPercent2(totalRentedArea.value, totalBuildingArea.value)
+);
+
+const idleArea = computed(() => sumFieldToWan(filteredList.value, 'c_xzmj'));
+
+const idleRate = computed(() => formatPercent2(idleArea.value, totalBuildingArea.value));
+
+const idleRateNum = computed(() => {
+  const { rateNum } = computeIdleRateFromSnapshots(filteredList.value);
+  return rateNum;
+});
+
+const certifiedAssetPercent = computed(() => {
+  const sum = filteredList.value.filter((item) => item.c_sfblcz === '是').length;
+  return formatPercent2(sum, filteredList.value.length);
 });
 
+const certifiedRateNum = computed(() => {
+  const { rateNum } = computeCertifiedRateFromSnapshots(filteredList.value);
+  return rateNum;
+});
+
+const buildingAreaYoY = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  const previous = sumSnapshotsToWan(yoySnapshots.value, 'c_sjjzmj');
+  return formatRelativeChange(totalBuildingArea.value, previous);
+});
+
+const idleRateMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  const { rateNum: previous } = computeIdleRateFromSnapshots(momSnapshots.value);
+  return formatRatePointChange(idleRateNum.value, previous);
+});
+
+const certifiedRateMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  const { rateNum: previous } = computeCertifiedRateFromSnapshots(momSnapshots.value);
+  return formatRatePointChange(certifiedRateNum.value, previous);
+});
+
+const metricCardList = computed(() => [
+  {
+    id: 1,
+    title: '建筑总面积',
+    text: '同比去年',
+    percent: buildingAreaYoY.value.text,
+    trend: buildingAreaYoY.value.trend,
+    value: totalBuildingArea.value,
+    showTrendIcon: true,
+    showUnit: true,
+  },
+  {
+    id: 2,
+    title: '已出租/在用建筑面积',
+    text: '占总建筑面积',
+    showTrendIcon: false,
+    percent: rentPercent.value,
+    value: totalRentedArea.value,
+    showUnit: true,
+  },
+  {
+    id: 3,
+    title: '闲置率',
+    text: '环比上月',
+    trend: idleRateMoM.value.trend,
+    percent: idleRateMoM.value.text,
+    value: idleRate.value,
+    showTrendIcon: true,
+    showUnit: false,
+  },
+  {
+    id: 4,
+    title: '已办证资产占比',
+    text: '环比上月',
+    trend: certifiedRateMoM.value.trend,
+    percent: certifiedRateMoM.value.text,
+    value: certifiedAssetPercent.value,
+    showTrendIcon: true,
+    showUnit: false,
+  },
+]);
+
+onMounted(async () => {
+  await enterpriseStore.fetchStatisticsList();
+});
 </script>
 
 <style scoped>

+ 1 - 1
src/pages/Topic1Page.vue

@@ -130,7 +130,7 @@ const createDefaultFilters = () => ({
 const listFilters = ref(createDefaultFilters());
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
 );
 
 const OTHER_LABEL = '其他';

+ 27 - 3
src/pages/Topic2Page.vue

@@ -107,6 +107,13 @@ import { useEnterpriseStore } from '@/store/enterprise';
 import { useFilterStore } from '@/store/filter';
 import { GROUP_ORDER, GROUP_DISPLAY_NAMES, normalizeGroupName } from '@/utils/aggregateReportRecords';
 import { formatPercent2 } from '@/utils/calculatePercentage';
+import {
+  buildHistoricalSnapshots,
+  computeCertifiedRateFromSnapshots,
+  emptyComparison,
+  formatRatePointChange,
+  getPreviousMonthPeriod,
+} from '@/utils/statsComparison';
 import {
   CERT_UI_OPTIONS,
   getCertUi,
@@ -131,7 +138,13 @@ const groupOptions = [
 ];
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
+
+const previousMonth = computed(() => getPreviousMonthPeriod(filterStore.selectDate));
+
+const momSnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousMonth.value)
 );
 
 const certifiedCount = computed(() =>
@@ -150,14 +163,25 @@ const certifiedPercent = computed(() =>
   formatPercent2(certifiedCount.value, filteredList.value.length)
 );
 
+const certifiedRateNum = computed(() => {
+  const { rateNum } = computeCertifiedRateFromSnapshots(filteredList.value);
+  return rateNum;
+});
+
+const certifiedRateMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  const { rateNum: previous } = computeCertifiedRateFromSnapshots(momSnapshots.value);
+  return formatRatePointChange(certifiedRateNum.value, previous);
+});
+
 const kpiCards = computed(() => [
   {
     id: 1,
     hero: true,
     title: '已办证资产占比',
     text: '环比上月',
-    trend: 'up',
-    percent: '+0.5%',
+    trend: certifiedRateMoM.value.trend,
+    percent: certifiedRateMoM.value.text,
     value: certifiedPercent.value,
     showUnit: false,
     showTrendIcon: true,

+ 51 - 10
src/pages/Topic3Page.vue

@@ -192,6 +192,17 @@ import { useFilterStore } from '@/store/filter';
 import { GROUP_ORDER, GROUP_DISPLAY_NAMES } from '@/utils/aggregateReportRecords';
 import { sumFieldToWan, toWanSquareMeters } from '@/utils/areaMath';
 import { formatPercent2 } from '@/utils/calculatePercentage';
+import {
+  buildHistoricalSnapshots,
+  computeIdleRateFromSnapshots,
+  countIdleSnapshots,
+  emptyComparison,
+  formatCountChange,
+  formatRatePointChange,
+  formatRelativeChange,
+  getPreviousMonthPeriod,
+  sumSnapshotsToWan,
+} from '@/utils/statsComparison';
 import {
   IDLE_MONTHS_BUCKETS,
   IDLE_MONTHS_OPTIONS,
@@ -234,7 +245,13 @@ const groupOptions = [
 ];
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
+
+const previousMonth = computed(() => getPreviousMonthPeriod(filterStore.selectDate));
+
+const momSnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousMonth.value)
 );
 
 const idleAssetList = computed(() =>
@@ -248,14 +265,38 @@ const idleRateValue = computed(() =>
   formatPercent2(totalIdleArea.value, totalBuildingArea.value)
 );
 
+const idleRateNum = computed(() => {
+  const { rateNum } = computeIdleRateFromSnapshots(filteredList.value);
+  return rateNum;
+});
+
+const momIdleArea = computed(() => sumSnapshotsToWan(momSnapshots.value, 'c_xzmj'));
+const momIdleCount = computed(() => countIdleSnapshots(momSnapshots.value));
+const momIdleRateNum = computed(() => computeIdleRateFromSnapshots(momSnapshots.value).rateNum);
+
+const idleAreaMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  return formatRelativeChange(totalIdleArea.value, momIdleArea.value);
+});
+
+const idleCountMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  return formatCountChange(idleAssetCount.value, momIdleCount.value);
+});
+
+const idleRateMoM = computed(() => {
+  if (!filterStore.selectDate) return emptyComparison();
+  return formatRatePointChange(idleRateNum.value, momIdleRateNum.value);
+});
+
 const kpiCards = computed(() => [
   {
     id: 1,
     hero: true,
     title: '闲置总面积',
-    text: '环比',
-    trend: 'down',
-    percent: '-2.1%',
+    text: '环比上月',
+    trend: idleAreaMoM.value.trend,
+    percent: idleAreaMoM.value.text,
     value: totalIdleArea.value,
     unit: '万㎡',
     showUnit: true,
@@ -264,9 +305,9 @@ const kpiCards = computed(() => [
   {
     id: 2,
     title: '闲置资产数量',
-    text: '环比',
-    trend: 'up',
-    percent: '+3 项',
+    text: '环比上月',
+    trend: idleCountMoM.value.trend,
+    percent: idleCountMoM.value.text,
     value: idleAssetCount.value,
     unit: '项',
     showUnit: true,
@@ -275,9 +316,9 @@ const kpiCards = computed(() => [
   {
     id: 3,
     title: '全区闲置率',
-    text: '按建筑面积口径',
-    trend: 'up',
-    percent: '+0.2%',
+    text: '环比上月',
+    trend: idleRateMoM.value.trend,
+    percent: idleRateMoM.value.text,
     value: idleRateValue.value,
     showUnit: false,
     showTrendIcon: true,

+ 9 - 3
src/store/enterprise.js

@@ -6,9 +6,10 @@ import {
   submitAssetEdit as submitAssetEditApi,
 } from '@/api/enterpriseList';
 import { buildContentPayload, resolveAssetRecord } from '@/utils/assetDetailFields';
-import { normalizeGroupName } from '@/utils/aggregateReportRecords';
+import { normalizeGroupName, matchesReportPeriod } from '@/utils/aggregateReportRecords';
 import { useUserStore } from '@/store/user';
 import { COLUMN_ID, MODEL_ID } from '@/config';
+import { useNotificationStore } from '@/store/notification';
 
 /** 月报 / 企业列表 states 枚举 */
 export const REPORT_STATE = {
@@ -79,8 +80,11 @@ export const useEnterpriseStore = defineStore('enterprise', {
     },
 
     filteredByGroup: (state) => {
-      return (selectGroup) => {
-        const list = state.listsByState[REPORT_STATE.APPROVED];
+      return (selectGroup, selectPeriod = null) => {
+        let list = state.listsByState[REPORT_STATE.APPROVED];
+        if (selectPeriod) {
+          list = list.filter((item) => matchesReportPeriod(item, selectPeriod));
+        }
         if (selectGroup === '全部') return list;
         const group = selectGroup.trim();
         return list.filter((item) => {
@@ -151,6 +155,7 @@ export const useEnterpriseStore = defineStore('enterprise', {
       const auditTime = Date.now();
       await Promise.all(items.map((item) => this.approveItem(item, auditorName, auditTime)));
       await this.fetchReportProgress({ force: true });
+      await useNotificationStore().syncAfterReportChange();
     },
 
     async rejectItem(item, { auditorName, auditorComment }, auditTime = Date.now()) {
@@ -183,6 +188,7 @@ export const useEnterpriseStore = defineStore('enterprise', {
         items.map((item) => this.rejectItem(item, { auditorName, auditorComment }, auditTime))
       );
       await this.fetchReportProgress({ force: true });
+      await useNotificationStore().syncAfterReportChange();
     },
 
     updateApprovedItem(id, patch) {

+ 1 - 2
src/store/filter.js

@@ -3,7 +3,7 @@ import { DEFAULT_MANAGE_GROUP } from '@/utils/manageContext'
 
 export const useFilterStore = defineStore('filter', {
   state: () => ({
-    selectDate: '2026-01',
+    selectDate: '',
     selectGroup: '全部',
     selectManageGroup: DEFAULT_MANAGE_GROUP,
   }),
@@ -18,7 +18,6 @@ export const useFilterStore = defineStore('filter', {
       this.selectManageGroup = val
     },
     resetFilter() {
-      this.selectDate = '2026-01'
       this.selectGroup = '全部'
     }
   },

+ 22 - 0
src/utils/aggregateReportRecords.js

@@ -87,6 +87,28 @@ export const extractPeriod = (item, fallbackPeriod = '2026-01') => {
   return fallbackPeriod;
 };
 
+/** 资产所属统计周期(优先 fill_time,否则按填报/更新时间推算) */
+export const getReportPeriod = (item) =>
+  extractFillMonth(item) || extractPeriod(item, '');
+
+/** 是否属于指定统计周期 */
+export const matchesReportPeriod = (item, period) => {
+  if (!period) return true;
+  const fillMonth = extractFillMonth(item);
+  if (fillMonth) return fillMonth === period;
+  return extractPeriod(item, period) === period;
+};
+
+/** 从资产列表提取可用统计周期,按时间倒序 */
+export const collectReportPeriods = (items) => {
+  const months = new Set();
+  (items || []).forEach((item) => {
+    const period = getReportPeriod(item);
+    if (period) months.add(period);
+  });
+  return [...months].sort().reverse();
+};
+
 export const getReporter = (item) =>
   item.update_user ||
   item.create_user ||

+ 101 - 0
src/utils/assetHistoryRecord.js

@@ -0,0 +1,101 @@
+import {
+  ASSET_DETAIL_FIELDS,
+  getFieldStorageKey,
+  resolveAssetRecord,
+  resolveFieldByColumnKey,
+} from '@/utils/assetDetailFields';
+
+/** 详情修改需记录历史的字段 */
+export const DETAIL_HISTORY_FIELD_KEYS = [
+  'c_sjjzmj',
+  'c_zymj',
+  'c_cjmj',
+  'c_czmj',
+  'c_xzmj',
+  'c_sfblcz',
+];
+
+const fieldByKey = (key) => ASSET_DETAIL_FIELDS.find((field) => field.key === key);
+
+const formatHistoryValue = (val) => {
+  if (val == null || val === '') return '';
+  return String(val);
+};
+
+const valuesEqual = (left, right) =>
+  formatHistoryValue(left) === formatHistoryValue(right);
+
+export const parseAssetHistory = (raw) => {
+  if (raw == null || raw === '') return [];
+  if (Array.isArray(raw)) return raw.slice();
+  if (typeof raw === 'string') {
+    const text = raw.trim();
+    if (!text) return [];
+    try {
+      const parsed = JSON.parse(text);
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+  return [];
+};
+
+const getAssetFieldRaw = (asset, fieldKey) => {
+  const record = resolveAssetRecord(asset);
+  return record?.[fieldKey];
+};
+
+/** 批量修改:记录修改时间、字段、原值、修改人 */
+export const buildBatchHistoryEntry = (asset, columnKey, newValue, operator) => {
+  const field = resolveFieldByColumnKey(columnKey);
+  if (!field?.key) return null;
+
+  const storageKey = getFieldStorageKey(field);
+  const oldValue = getAssetFieldRaw(asset, storageKey);
+  if (valuesEqual(oldValue, newValue)) return null;
+
+  return {
+    time: Date.now(),
+    operator: operator || '—',
+    source: 'batch',
+    field: storageKey,
+    fieldLabel: field.label,
+    oldValue: formatHistoryValue(oldValue),
+  };
+};
+
+/** 详情修改:每次保存均记录全部指定字段的字段名与原值 */
+export const buildDetailHistoryEntry = (asset, _patch, operator) => {
+  const changes = DETAIL_HISTORY_FIELD_KEYS.map((fieldKey) => {
+    const field = fieldByKey(fieldKey);
+    const oldValue = getAssetFieldRaw(asset, fieldKey);
+
+    return {
+      field: fieldKey,
+      fieldLabel: field?.label || fieldKey,
+      oldValue: formatHistoryValue(oldValue),
+    };
+  });
+
+  return {
+    time: Date.now(),
+    operator: operator || '—',
+    source: 'detail',
+    changes,
+  };
+};
+
+/** 将历史条目追加到 patch.c_lsjl */
+export const appendHistoryToPatch = (asset, patch, entry) => {
+  if (!entry) return patch;
+  const history = parseAssetHistory(asset?.c_lsjl);
+  history.push(entry);
+  return {
+    ...patch,
+    c_lsjl: JSON.stringify(history),
+  };
+};
+
+export const resolveOperatorName = (userInfo) =>
+  userInfo?.username || userInfo?.name || '—';

+ 168 - 0
src/utils/statsComparison.js

@@ -0,0 +1,168 @@
+import { resolveAssetRecord } from '@/utils/assetDetailFields';
+import { parseAssetHistory } from '@/utils/assetHistoryRecord';
+import { toWanSquareMeters } from '@/utils/areaMath';
+import { formatPercent2 } from '@/utils/calculatePercentage';
+
+const pad2 = (n) => String(n).padStart(2, '0');
+
+const STATS_SNAPSHOT_FIELDS = [
+  'c_sjjzmj',
+  'c_zymj',
+  'c_cjmj',
+  'c_czmj',
+  'c_xzmj',
+  'c_sfblcz',
+];
+
+export const getPreviousMonthPeriod = (period) => {
+  if (!period || !/^\d{4}-\d{2}$/.test(period)) return '';
+  const [year, month] = period.split('-').map(Number);
+  const prevMonth = month === 1 ? 12 : month - 1;
+  const prevYear = month === 1 ? year - 1 : year;
+  return `${prevYear}-${pad2(prevMonth)}`;
+};
+
+export const getYearAgoPeriod = (period) => {
+  if (!period || !/^\d{4}-\d{2}$/.test(period)) return '';
+  const [year, month] = period.split('-');
+  return `${Number(year) - 1}-${month}`;
+};
+
+const formatEntryPeriod = (time) => {
+  const d = new Date(time);
+  if (Number.isNaN(d.getTime())) return '';
+  return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
+};
+
+const buildCurrentSnapshot = (asset) => {
+  const record = resolveAssetRecord(asset);
+  return STATS_SNAPSHOT_FIELDS.reduce((acc, key) => {
+    acc[key] = record?.[key];
+    return acc;
+  }, {});
+};
+
+const applyHistoryEntry = (snapshot, entry) => {
+  if (!entry) return snapshot;
+
+  if (entry.source === 'detail' && Array.isArray(entry.changes)) {
+    entry.changes.forEach(({ field, oldValue }) => {
+      if (field && STATS_SNAPSHOT_FIELDS.includes(field)) {
+        snapshot[field] = oldValue;
+      }
+    });
+    return snapshot;
+  }
+
+  if (entry.source === 'batch' && entry.field && STATS_SNAPSHOT_FIELDS.includes(entry.field)) {
+    snapshot[entry.field] = entry.oldValue;
+  }
+
+  return snapshot;
+};
+
+export const getLastHistoryEntryInPeriod = (asset, targetPeriod) => {
+  if (!targetPeriod) return null;
+
+  const entries = parseAssetHistory(asset?.c_lsjl)
+    .filter((entry) => entry?.time && formatEntryPeriod(entry.time) === targetPeriod)
+    .sort((a, b) => Number(a.time) - Number(b.time));
+
+  return entries.length ? entries[entries.length - 1] : null;
+};
+
+/** 指定周期快照:有 c_lsjl 记录则取该月最后一条,否则使用当前资产数据 */
+export const getAssetSnapshotForPeriod = (asset, targetPeriod) => {
+  const snapshot = buildCurrentSnapshot(asset);
+  const entry = getLastHistoryEntryInPeriod(asset, targetPeriod);
+  if (!entry) return snapshot;
+  return applyHistoryEntry({ ...snapshot }, entry);
+};
+
+export const buildHistoricalSnapshots = (assets, targetPeriod) =>
+  (assets || []).map((asset) => getAssetSnapshotForPeriod(asset, targetPeriod));
+
+const sumSnapshotSqm = (snapshots, field) =>
+  (snapshots || []).reduce((total, item) => total + Number(item?.[field] || 0), 0);
+
+export const sumSnapshotsToWan = (snapshots, field) =>
+  toWanSquareMeters(sumSnapshotSqm(snapshots, field));
+
+export const sumSnapshotFieldsToWan = (snapshots, fields) => {
+  const sumSqm = (snapshots || []).reduce((total, item) => {
+    return total + fields.reduce((sum, field) => sum + Number(item?.[field] || 0), 0);
+  }, 0);
+  return toWanSquareMeters(sumSqm);
+};
+
+export const countCertifiedSnapshots = (snapshots) =>
+  (snapshots || []).filter((item) => String(item?.c_sfblcz ?? '').trim() === '是').length;
+
+export const countIdleSnapshots = (snapshots) =>
+  (snapshots || []).filter((item) => Number(item?.c_xzmj || 0) > 0).length;
+
+export const computeIdleRateFromSnapshots = (snapshots) => {
+  const idleSqm = sumSnapshotSqm(snapshots, 'c_xzmj');
+  const totalSqm = sumSnapshotSqm(snapshots, 'c_sjjzmj');
+  if (!totalSqm) return { rateText: '0%', rateNum: 0 };
+  const rateNum = (idleSqm / totalSqm) * 100;
+  return {
+    rateText: formatPercent2(idleSqm, totalSqm),
+    rateNum,
+  };
+};
+
+export const computeCertifiedRateFromSnapshots = (snapshots) => {
+  const total = snapshots?.length || 0;
+  if (!total) return { rateText: '0%', rateNum: 0, count: 0 };
+  const count = countCertifiedSnapshots(snapshots);
+  return {
+    rateText: formatPercent2(count, total),
+    rateNum: (count / total) * 100,
+    count,
+  };
+};
+
+export const formatRelativeChange = (current, previous, decimals = 1) => {
+  const cur = Number(current);
+  const prev = Number(previous);
+  if (!Number.isFinite(cur) || !Number.isFinite(prev)) {
+    return { text: '—', trend: 'up' };
+  }
+  if (prev === 0) {
+    if (cur === 0) return { text: '0%', trend: 'up' };
+    return { text: '—', trend: 'up' };
+  }
+  const rate = ((cur - prev) / Math.abs(prev)) * 100;
+  const sign = rate > 0 ? '+' : '';
+  return {
+    text: `${sign}${rate.toFixed(decimals)}%`,
+    trend: rate >= 0 ? 'up' : 'down',
+  };
+};
+
+export const formatRatePointChange = (currentRateNum, previousRateNum, decimals = 2) => {
+  const cur = Number(currentRateNum);
+  const prev = Number(previousRateNum);
+  if (!Number.isFinite(cur) || !Number.isFinite(prev)) {
+    return { text: '—', trend: 'up' };
+  }
+  const diff = cur - prev;
+  const sign = diff > 0 ? '+' : '';
+  return {
+    text: `${sign}${diff.toFixed(decimals)}%`,
+    trend: diff >= 0 ? 'up' : 'down',
+  };
+};
+
+export const formatCountChange = (current, previous) => {
+  const diff = Math.round(Number(current) - Number(previous));
+  if (diff === 0) return { text: '0 项', trend: 'up' };
+  const sign = diff > 0 ? '+' : '';
+  return {
+    text: `${sign}${diff} 项`,
+    trend: diff >= 0 ? 'up' : 'down',
+  };
+};
+
+export const emptyComparison = () => ({ text: '—', trend: 'up' });