2 Commits 4aea1a6b7e ... 2a4ed2b045

Auteur SHA1 Message Date
  gongtianxiao 2a4ed2b045 完善数据统计模块的同比/环比逻辑 il y a 1 mois
  gongtianxiao e59dfd9621 完善消息通知功能 il y a 1 mois

+ 2 - 0
public/config.js

@@ -4,4 +4,6 @@ window.__APP_CONFIG__ = {
   LOGIN_URL: 'http://121.43.55.7:10086',
   MODEL_ID: 1818,
   COLUMN_ID: 1764,
+  NOTIFICATION_MODEL_ID: 1913,
+  NOTIFICATION_COLUMN_ID: 1815,
 };

+ 68 - 0
src/api/notification.js

@@ -0,0 +1,68 @@
+import { service } from '@/utils/request';
+import { NOTIFICATION_COLUMN_ID, NOTIFICATION_MODEL_ID } from '@/config';
+import {
+  buildNotificationContentPayload,
+  buildNotificationResyncPayload,
+  buildNotificationUpdatePayload,
+} from '@/utils/notificationFields';
+
+const wrapContentPayload = (data) => {
+  const payload = { ...data };
+  if (payload.content != null && typeof payload.content !== 'string') {
+    payload.content = JSON.stringify(payload.content);
+  }
+  return payload;
+};
+
+export const getNotificationList = () =>
+  service({
+    url: '/content/selectContentList',
+    method: 'POST',
+    data: {
+      columnId: NOTIFICATION_COLUMN_ID,
+      orderBy: [{ field: 'update_time', orderByType: 2 }],
+      pageSize: 999,
+      page: 0,
+    },
+  });
+
+export const addNotification = (item) =>
+  service({
+    url: '/content/addContent',
+    method: 'POST',
+    data: wrapContentPayload({
+      content: buildNotificationContentPayload(item),
+      columnId: NOTIFICATION_COLUMN_ID,
+      modelId: NOTIFICATION_MODEL_ID,
+    }),
+  });
+
+export const updateNotificationContent = (item, patch) => {
+  const contentBody = buildNotificationUpdatePayload(item, patch);
+  return service({
+    url: '/content/updateContent',
+    method: 'POST',
+    data: wrapContentPayload({
+      id: contentBody.id,
+      content: contentBody,
+      columnId: NOTIFICATION_COLUMN_ID,
+      modelId: NOTIFICATION_MODEL_ID,
+    }),
+  });
+};
+
+export const updateNotificationResync = (existing, generated, { resetRead = false } = {}) => {
+  const contentBody = buildNotificationResyncPayload(existing, generated, {
+    read: resetRead ? false : existing.read,
+  });
+  return service({
+    url: '/content/updateContent',
+    method: 'POST',
+    data: wrapContentPayload({
+      id: contentBody.id,
+      content: contentBody,
+      columnId: NOTIFICATION_COLUMN_ID,
+      modelId: NOTIFICATION_MODEL_ID,
+    }),
+  });
+};

+ 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) =>

+ 62 - 30
src/components/layout/NotificationModal.vue

@@ -27,14 +27,23 @@
               variant="danger"
               @click="handleDeleteAll"
             />
-            <BaseButton text="刷新" size="sm" variant="outline" :disabled="loading" @click="refresh" />
+            <BaseButton
+              :text="syncing ? '同步中…' : '刷新'"
+              size="sm"
+              variant="outline"
+              :disabled="loading || syncing"
+              @click="refreshFull"
+            />
           </div>
         </div>
 
         <div v-if="loading && displayList.length === 0" class="notify-empty">加载中…</div>
-        <div v-else-if="displayList.length === 0" class="notify-empty">暂无消息</div>
+        <div v-else-if="displayList.length === 0" class="notify-empty">
+          {{ syncing ? '同步中…' : '暂无消息' }}
+        </div>
 
         <div v-else class="notify-table-wrap">
+          <p v-if="syncing" class="notify-sync-hint">正在后台同步…</p>
           <div class="data-table">
             <div class="data-table__header">
               <span v-for="col in notifyColumns" :key="col.key" :style="{ width: col.width }">
@@ -96,14 +105,12 @@
 </template>
 
 <script setup>
-import { showConfirm } from '@/utils/message';
-import { computed, ref, watch,defineProps,defineEmits } from 'vue';
+import { showConfirm, showError } from '@/utils/message';
+import { computed, watch, defineProps, defineEmits } from 'vue';
 import { useRouter } from 'vue-router';
 import BaseButton from '@/components/base/BaseButton.vue';
-import { useEnterpriseStore } from '@/store/enterprise';
 import { useFilterStore } from '@/store/filter';
 import { useNotificationStore } from '@/store/notification';
-import { buildNotifications } from '@/utils/buildNotifications';
 
 const props = defineProps({
   visible: {
@@ -115,11 +122,11 @@ const props = defineProps({
 const emit = defineEmits(['update:visible', 'unread-change']);
 
 const router = useRouter();
-const enterpriseStore = useEnterpriseStore();
 const filterStore = useFilterStore();
 const notificationStore = useNotificationStore();
 
-const loading = ref(false);
+const loading = computed(() => notificationStore.loading);
+const syncing = computed(() => notificationStore.syncing);
 
 const notifyColumns = [
   { key: 'time', label: '时间', width: '150px' },
@@ -159,19 +166,21 @@ watch(unreadCount, (count) => {
   emit('unread-change', count);
 }, { immediate: true });
 
-/** 拉取最新数据并生成通知(不对集团做过滤,保证全集团通知都持久化) */
-const refresh = async () => {
-  loading.value = true;
+/** 打开弹窗:优先展示缓存,后台轻量同步(不强制拉全量资产) */
+const syncOnOpen = async () => {
   try {
-    await enterpriseStore.fetchReportProgress({ force: true, skipSubmitted: true });
-    const newItems = buildNotifications({
-      pendingList: enterpriseStore.pendingList,
-      approvedList: enterpriseStore.approvedList,
-      rejectedList: enterpriseStore.rejectedList,
-    });
-    notificationStore.mergeNotifications(newItems);
-  } finally {
-    loading.value = false;
+    await notificationStore.refresh({ forceAssets: false });
+  } catch (e) {
+    showError(e?.message || e?.msg || '通知加载失败,请稍后重试');
+  }
+};
+
+/** 手动刷新:强制拉全量资产并同步 */
+const refreshFull = async () => {
+  try {
+    await notificationStore.refresh({ forceAssets: true });
+  } catch (e) {
+    showError(e?.message || e?.msg || '通知加载失败,请稍后重试');
   }
 };
 
@@ -179,23 +188,39 @@ const close = () => {
   emit('update:visible', false);
 };
 
-const markRead = (id) => {
-  notificationStore.markRead(id);
+const markRead = async (id) => {
+  try {
+    await notificationStore.markRead(id);
+  } catch (e) {
+    showError(e?.message || e?.msg || '标记已读失败');
+  }
 };
 
-const markAllRead = () => {
-  const ids = displayList.value.map((item) => item.id);
-  notificationStore.markAllRead(ids);
+const markAllRead = async () => {
+  try {
+    const ids = displayList.value.map((item) => item.id);
+    await notificationStore.markAllRead(ids);
+  } catch (e) {
+    showError(e?.message || e?.msg || '标记已读失败');
+  }
 };
 
 const handleDeleteOne = async (id) => {
   if (!(await showConfirm('确认删除该通知?'))) return;
-  notificationStore.deleteItem(id);
+  try {
+    await notificationStore.deleteItem(id);
+  } catch (e) {
+    showError(e?.message || e?.msg || '删除失败');
+  }
 };
 
 const handleDeleteAll = async () => {
   if (!(await showConfirm('确认删除全部通知?'))) return;
-  notificationStore.deleteAll();
+  try {
+    await notificationStore.deleteAll();
+  } catch (e) {
+    showError(e?.message || e?.msg || '删除失败');
+  }
 };
 
 const goTo = (item) => {
@@ -204,9 +229,9 @@ const goTo = (item) => {
   router.push(item.route);
 };
 
-const handleRowClick = (item) => {
+const handleRowClick = async (item) => {
   if (!item.read) {
-    markRead(item.id);
+    await markRead(item.id);
   }
   if (item.route) {
     goTo(item);
@@ -216,7 +241,7 @@ const handleRowClick = (item) => {
 watch(
   () => props.visible,
   (open) => {
-    if (open) refresh();
+    if (open) syncOnOpen();
   }
 );
 </script>
@@ -290,6 +315,13 @@ watch(
   font-size: 14px;
 }
 
+.notify-sync-hint {
+  margin: 0 0 8px;
+  font-size: 12px;
+  color: var(--color-text-secondary);
+  text-align: right;
+}
+
 .notify-table-wrap {
   overflow: auto;
   max-height: min(60vh, 420px);

+ 2 - 13
src/components/layout/PageHeader.vue

@@ -49,16 +49,13 @@ import { useRoute, useRouter } from 'vue-router';
 import BaseButton from '@/components/base/BaseButton.vue';
 import BaseSelect from '@/components/base/BaseSelect.vue';
 import NotificationModal from '@/components/layout/NotificationModal.vue';
-import { useEnterpriseStore } from '@/store/enterprise';
 import { useFilterStore } from '@/store/filter';
 import { useNotificationStore } from '@/store/notification';
 import { useUserStore } from '@/store/user';
 import { GROUP_ORDER } from '@/utils/aggregateReportRecords';
-import { buildNotifications } from '@/utils/buildNotifications';
 
 const route = useRoute();
 const router = useRouter();
-const enterpriseStore = useEnterpriseStore();
 const filterStore = useFilterStore();
 const notificationStore = useNotificationStore();
 const userStore = useUserStore();
@@ -92,16 +89,8 @@ const goManage = () => {
   router.push('/manage/dashboard');
 };
 
-onMounted(async () => {
-  await enterpriseStore.fetchReportProgress({ skipSubmitted: true });
-  // 首次加载后生成通知并入库
-  notificationStore.mergeNotifications(
-    buildNotifications({
-      pendingList: enterpriseStore.pendingList,
-      approvedList: enterpriseStore.approvedList,
-      rejectedList: enterpriseStore.rejectedList,
-    })
-  );
+onMounted(() => {
+  notificationStore.refresh({ forceAssets: false }).catch(() => {});
 });
 </script>
 

+ 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>

+ 2 - 0
src/config.js

@@ -8,3 +8,5 @@ export const SERVER_URL = cfg.SERVER_URL || '';
 export const LOGIN_URL = cfg.LOGIN_URL || '';
 export const MODEL_ID = cfg.MODEL_ID || 1818;
 export const COLUMN_ID = cfg.COLUMN_ID || 1764;
+export const NOTIFICATION_MODEL_ID = cfg.NOTIFICATION_MODEL_ID || 1913;
+export const NOTIFICATION_COLUMN_ID = cfg.NOTIFICATION_COLUMN_ID || 1815;

+ 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 = '全部'
     }
   },

+ 203 - 75
src/store/notification.js

@@ -1,118 +1,246 @@
 import { defineStore } from 'pinia';
-import { useUserStore } from '@/store/user';
+import {
+  addNotification,
+  getNotificationList,
+  updateNotificationContent,
+  updateNotificationResync,
+} from '@/api/notification';
+import { useEnterpriseStore } from '@/store/enterprise';
+import { buildNotifications } from '@/utils/buildNotifications';
+import {
+  findNotificationItem,
+  isAggregateNotificationYwid,
+  isPendingNotificationYwid,
+  parseNotificationItem,
+} from '@/utils/notificationFields';
 
-const guestKey = '_guest_';
-
-const resolveUserKey = () => {
-  const userStore = useUserStore();
-  const info = userStore.userInfo;
-  if (info) {
-    return info.username || guestKey;
+const applyPatchToItem = (item, patch) => {
+  if (patch.read !== undefined) {
+    item.read = patch.read;
+    if (item._record) item._record.c_sfyd = patch.read ? 1 : 0;
+  }
+  if (patch.deleted !== undefined) {
+    item.deleted = patch.deleted;
+    if (item._record) item._record.c_sfsc = patch.deleted ? 1 : 0;
   }
-  if (userStore.token) {
-    return `token:${String(userStore.token).slice(-16)}`;
+  if (patch.pushed !== undefined) {
+    item.pushed = patch.pushed;
+    if (item._record) item._record.c_sfts = patch.pushed ? 1 : 0;
   }
-  return guestKey;
+};
+
+const getEnterpriseLists = () => {
+  const enterpriseStore = useEnterpriseStore();
+  return {
+    pendingList: enterpriseStore.pendingList,
+    approvedList: enterpriseStore.approvedList,
+    rejectedList: enterpriseStore.rejectedList,
+  };
+};
+
+const loadNotificationItems = async () => {
+  const res = await getNotificationList();
+  const rows = res.content?.data ?? [];
+  return rows
+    .map(parseNotificationItem)
+    .filter((item) => item.id != null && !item.deleted);
 };
 
 export const useNotificationStore = defineStore('notification', {
   state: () => ({
-    /** { [userKey]: notification[] } */
-    itemsByUser: {},
+    items: [],
+    /** 无缓存时阻塞展示 */
+    loading: false,
+    /** 有缓存时后台同步 */
+    syncing: false,
+    _refreshPromise: null,
   }),
   getters: {
-    /** 当前用户的所有通知(按时间倒序) */
-    items(state) {
-      const key = resolveUserKey();
-      return (state.itemsByUser[key] || []).slice().sort((a, b) => b.timestamp - a.timestamp);
-    },
-    /** 按集团过滤 */
     itemsByGroup(state) {
       return (group) => {
-        const all = (state.itemsByUser[resolveUserKey()] || []).slice();
-        if (!group) return all.sort((a, b) => b.timestamp - a.timestamp);
+        const all = state.items.filter((item) => !item.deleted);
+        if (!group) {
+          return all.slice().sort((a, b) => b.timestamp - a.timestamp);
+        }
         return all
           .filter((item) => !item.group || item.group === group)
           .sort((a, b) => b.timestamp - a.timestamp);
       };
     },
-    /** 按集团的未读数 */
     unreadCountByGroup(state) {
       return (group) => {
-        const all = state.itemsByUser[resolveUserKey()] || [];
-        const filtered = group
-          ? all.filter((item) => (!item.group || item.group === group) && !item.read)
-          : all.filter((item) => !item.read);
-        return filtered.length;
+        const all = state.items.filter((item) => !item.deleted && !item.read);
+        if (!group) return all.length;
+        return all.filter((item) => !item.group || item.group === group).length;
       };
     },
   },
   actions: {
-    _getItems() {
-      const key = resolveUserKey();
-      if (!this.itemsByUser[key]) {
-        this.itemsByUser = { ...this.itemsByUser, [key]: [] };
+    async fetchNotifications() {
+      this.items = await loadNotificationItems();
+    },
+
+    async syncNotifications(lists, { bumpPending = false, existingItems } = {}) {
+      const generated = buildNotifications(lists);
+      const sourceItems = existingItems ?? this.items;
+      const existingByYwid = new Map(
+        sourceItems
+          .filter((item) => item.ywid)
+          .map((item) => [item.ywid, item])
+      );
+
+      const toAdd = [];
+      const toUpdate = [];
+      const toRemove = [];
+
+      generated.forEach((item) => {
+        const ywid = item.ywid || item.id;
+        if (!ywid) return;
+
+        const existing = existingByYwid.get(ywid);
+        if (!existing) {
+          toAdd.push(item);
+          return;
+        }
+
+        const resetRead = bumpPending && isPendingNotificationYwid(ywid);
+        const pendingBump = bumpPending && isPendingNotificationYwid(ywid);
+        const contentChanged =
+          existing.title !== item.title || existing.timestamp !== item.timestamp;
+
+        if (pendingBump || contentChanged) {
+          toUpdate.push({ existing, item, resetRead });
+        }
+      });
+
+      const generatedYwids = new Set(
+        generated.map((item) => item.ywid).filter(Boolean)
+      );
+      sourceItems.forEach((existing) => {
+        if (!existing.ywid || existing.deleted) return;
+        if (!isAggregateNotificationYwid(existing.ywid)) return;
+        if (generatedYwids.has(existing.ywid)) return;
+        toRemove.push(existing);
+      });
+
+      if (!toAdd.length && !toUpdate.length && !toRemove.length) return false;
+
+      await Promise.all([
+        ...toAdd.map((item) => addNotification(item)),
+        ...toUpdate.map(({ existing, item, resetRead }) =>
+          updateNotificationResync(existing, item, { resetRead })
+        ),
+        ...toRemove.map((existing) =>
+          updateNotificationContent(existing, { deleted: true })
+        ),
+      ]);
+
+      return true;
+    },
+
+    async _runRefresh({ bumpPending = false } = {}) {
+      const blocking = this.items.length === 0;
+      if (blocking) {
+        this.loading = true;
+      } else {
+        this.syncing = true;
+      }
+
+      try {
+        const enterpriseStore = useEnterpriseStore();
+        await enterpriseStore.fetchReportProgress({
+          force: true,
+          skipSubmitted: true,
+        });
+
+        const existingItems = await loadNotificationItems();
+        await this.syncNotifications(getEnterpriseLists(), {
+          bumpPending,
+          existingItems,
+        });
+        await this.fetchNotifications();
+      } finally {
+        this.loading = false;
+        this.syncing = false;
       }
-      return this.itemsByUser[key];
     },
-    _setItems(arr) {
-      const key = resolveUserKey();
-      this.itemsByUser = { ...this.itemsByUser, [key]: arr };
+
+    async refresh(options = {}) {
+      if (this._refreshPromise) {
+        return this._refreshPromise;
+      }
+      this._refreshPromise = this._runRefresh(options).finally(() => {
+        this._refreshPromise = null;
+      });
+      return this._refreshPromise;
     },
 
-    /**
-     * 合并新生成的通知到持久化存储
-     * - 已存在的(同 id)保留原样(保留已读/已删除状态)
-     * - 新增的追加到列表
-     */
-    mergeNotifications(newItems) {
-      if (!newItems?.length) return;
-      const items = [...this._getItems()];
-      const existingIds = new Set(items.map((i) => i.id));
-      let added = 0;
-      newItems.forEach((item) => {
-        if (!existingIds.has(item.id)) {
-          items.push(item);
-          existingIds.add(item.id);
-          added++;
+    /** 报表/审核数据变更后立即同步通知(如批量修改送审) */
+    async syncAfterReportChange() {
+      if (this._refreshPromise) {
+        return this._refreshPromise;
+      }
+      this._refreshPromise = (async () => {
+        this.syncing = true;
+        try {
+          const enterpriseStore = useEnterpriseStore();
+          await enterpriseStore.fetchReportProgress({
+            force: true,
+            skipSubmitted: true,
+          });
+          const existingItems = await loadNotificationItems();
+          await this.syncNotifications(getEnterpriseLists(), {
+            bumpPending: true,
+            existingItems,
+          });
+          await this.fetchNotifications();
+        } finally {
+          this.syncing = false;
         }
+      })().finally(() => {
+        this._refreshPromise = null;
       });
-      if (added > 0) {
-        this._setItems(items);
+      return this._refreshPromise;
+    },
+
+    async _updateItem(id, patch) {
+      const item = findNotificationItem(this.items, id);
+      if (!item?.id) {
+        throw new Error('通知不存在');
       }
+      await updateNotificationContent(item, patch);
+      applyPatchToItem(item, patch);
     },
 
-    markRead(id) {
-      const items = this._getItems();
-      const index = items.findIndex((i) => i.id === id);
-      if (index < 0) return;
-      const updated = [...items];
-      updated[index] = { ...updated[index], read: true };
-      this._setItems(updated);
+    async markRead(id) {
+      const item = findNotificationItem(this.items, id);
+      if (!item || item.read) return;
+      await this._updateItem(id, { read: true });
     },
 
-    markAllRead(ids) {
+    async markAllRead(ids) {
       if (!ids?.length) return;
-      const idSet = new Set(ids);
-      const updated = this._getItems().map((item) =>
-        idSet.has(item.id) ? { ...item, read: true } : item
+      const targets = this.items.filter(
+        (item) => ids.some((id) => String(id) === String(item.id)) && !item.read
       );
-      this._setItems(updated);
+      for (const item of targets) {
+        await this._updateItem(item.id, { read: true });
+      }
     },
 
-    /** 删除单条通知 */
-    deleteItem(id) {
-      const items = this._getItems().filter((i) => i.id !== id);
-      this._setItems(items);
+    async deleteItem(id) {
+      const item = findNotificationItem(this.items, id);
+      if (!item) return;
+      await this._updateItem(id, { deleted: true });
+      this.items = this.items.filter((i) => String(i.id) !== String(id));
     },
 
-    /** 删除当前用户全部通知 */
-    deleteAll() {
-      this._setItems([]);
+    async deleteAll() {
+      const targets = [...this.items];
+      for (const item of targets) {
+        await this._updateItem(item.id, { deleted: true });
+      }
+      this.items = [];
     },
   },
-  persist: {
-    key: 'guoziwei-notification',
-    pick: ['itemsByUser'],
-  },
 });

+ 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 || '—';

+ 92 - 34
src/utils/buildNotifications.js

@@ -9,6 +9,7 @@ import {
   getRejectReason,
   normalizeGroupName,
 } from '@/utils/aggregateReportRecords';
+import { NOTIFY_TZLX } from '@/utils/notificationFields';
 import { buildManageRoute } from '@/utils/manageRoutes';
 
 const pad2 = (n) => String(n).padStart(2, '0');
@@ -25,12 +26,12 @@ const getPreviousPeriod = (period) => {
   return `${prevYear}-${pad2(prevMonth)}`;
 };
 
-/** 账期截止:次月 5 日 23:59:59 */
+/** 账期截止:次月 1 日 23:59:59 */
 const getPeriodDeadlineDate = (period) => {
   const [year, month] = period.split('-').map(Number);
   const nextMonth = month === 12 ? 1 : month + 1;
   const nextYear = month === 12 ? year + 1 : year;
-  return new Date(nextYear, nextMonth - 1, 5, 23, 59, 59);
+  return new Date(nextYear, nextMonth - 1, 1, 23, 59, 59);
 };
 
 const toTimestamp = (val) => {
@@ -60,18 +61,35 @@ const groupHasRejected = (rejectedList, group, period) =>
 
 const buildRoute = (tab, query = {}) => buildManageRoute({ tab, ...query });
 
-/** 通知 id 仅依赖类型 + 账期 + 集团,保证同一条通知不会重复入库 */
-const notifyRecordId = (type, record) => {
+const notifyYwid = (prefix, period, group, suffix = '') =>
+  suffix ? `${prefix}:${period}:${group}:${suffix}` : `${prefix}:${period}:${group}`;
+
+/** 月报聚合记录唯一 ywid(账期 + 青浦集团 + 所属集团,避免同集团多条记录互相覆盖) */
+const notifyAggregateYwid = (prefix, record) => {
   const qingpuGroup = (record.qingpuGroup ?? '').trim() || 'unknown';
-  const group = (record.group ?? '').trim() || 'unknown';
-  return `${type}:${record.period}:${qingpuGroup}:${group}`;
+  const groupName = (record.group ?? '').trim() || qingpuGroup;
+  return `${prefix}:${record.period}:${qingpuGroup}:${groupName}`;
+};
+
+/** 通知列表过滤、展示用青浦集团 */
+const notifyDisplayGroup = (record) => record.qingpuGroup || record.group;
+
+/** 截止前剩余天数 → c_tbtx(1=7天,2=3天,3=1天) */
+const getReminderTbtx = (daysLeft) => {
+  if (daysLeft <= 0) return 0;
+  if (daysLeft <= 1) return 3;
+  if (daysLeft <= 3) return 2;
+  if (daysLeft <= 7) return 1;
+  return 0;
+};
+
+const reminderTitle = (period, group, daysLeft) => {
+  const label = daysLeft <= 1 ? '1 天' : `${daysLeft} 天`;
+  return `${period} 月报截止倒计时 ${label}(${group})`;
 };
 
 /**
- * 生成当前时刻所有适用的通知(幂等,同 id 不会重复入库)
- * @param {{ pendingList: object[], approvedList: object[], rejectedList: object[] }} lists
- * @param {{ currentGroup?: string }} options - 可选,限定集团范围
- * @returns {Array<{ id: string, time: string, type: string, title: string, timestamp: number, route?: object, group: string }>}
+ * 生成当前时刻所有适用的通知(幂等,同 c_ywid 不会重复入库)
  */
 export const buildNotifications = ({
   pendingList = [],
@@ -85,23 +103,30 @@ export const buildNotifications = ({
   const currentPeriod = getCurrentPeriod();
   const deadline = getPeriodDeadlineDate(currentPeriod);
   const daysLeft = Math.ceil((deadline.getTime() - now) / (86400000));
+  const reminderTbtx = getReminderTbtx(daysLeft);
 
-  // ── 待审核(来自 pendingList)─────────────────────────────
+  // ── 待审核 ────────────────────────────────────────────────
   aggregateReportRecords(pendingList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
+    const group = notifyDisplayGroup(record);
     if (currentGroup && group !== currentGroup) return;
 
     const latest = record.items?.reduce((best, item) => {
       const t = toTimestamp(getFillTime(item));
       return t >= toTimestamp(getFillTime(best)) ? item : best;
     }, record.items?.[0]);
+    const title = `${group} ${record.period} 资产月报(${record.count} 条)待审核`;
+
     notifications.push({
-      id: notifyRecordId('pending', record),
+      ywid: notifyAggregateYwid('pending', record),
       time: record.submittedAt,
       type: '待审核',
-      title: `${group} ${record.period} 资产月报(${record.count} 条)待审核`,
+      title,
+      content: title,
       timestamp: toTimestamp(getFillTime(latest)),
       group,
+      tzlx: NOTIFY_TZLX.PENDING,
+      tbtx: 0,
+      shjg: 0,
       route: buildRoute('audit', {
         sub: 'pending',
         group: record.qingpuGroup || group,
@@ -110,22 +135,28 @@ export const buildNotifications = ({
     });
   });
 
-  // ── 已通过(来自 approvedList)────────────────────────────
+  // ── 已通过 ────────────────────────────────────────────────
   aggregateReportRecords(approvedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
+    const group = notifyDisplayGroup(record);
     if (currentGroup && group !== currentGroup) return;
 
     const latest = record.items?.reduce((best, item) => {
       const t = toTimestamp(getAuditTime(item));
       return t >= toTimestamp(getAuditTime(best)) ? item : best;
     }, record.items?.[0]);
+    const title = `${record.period} 月报已通过(${record.group},${record.count} 条)`;
+
     notifications.push({
-      id: notifyRecordId('approved', record),
+      ywid: notifyAggregateYwid('approved', record),
       time: formatDateTime(getAuditTime(latest)),
       type: '审核结果',
-      title: `${record.period} 月报已通过(${group},${record.count} 条)`,
+      title,
+      content: title,
       timestamp: toTimestamp(getAuditTime(latest)),
       group,
+      tzlx: NOTIFY_TZLX.AUDIT_RESULT,
+      tbtx: 0,
+      shjg: 1,
       route: buildRoute('archive', {
         sub: 'history',
         group: record.qingpuGroup || group,
@@ -135,9 +166,9 @@ export const buildNotifications = ({
     });
   });
 
-  // ── 已驳回(来自 rejectedList)────────────────────────────
+  // ── 已驳回 ────────────────────────────────────────────────
   aggregateReportRecords(rejectedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
+    const group = notifyDisplayGroup(record);
     if (currentGroup && group !== currentGroup) return;
 
     const latest = record.items?.reduce((best, item) => {
@@ -145,13 +176,19 @@ export const buildNotifications = ({
       return t >= toTimestamp(getAuditTime(best)) ? item : best;
     }, record.items?.[0]);
     const reason = getRejectReason(latest);
+    const title = `${record.period} 月报已被驳回(${record.group})${reason ? `:${reason}` : ''}`;
+
     notifications.push({
-      id: notifyRecordId('rejected', record),
+      ywid: notifyAggregateYwid('rejected', record),
       time: formatDateTime(getAuditTime(latest)),
       type: '审核结果',
-      title: `${record.period} 月报已被驳回(${group})${reason ? `:${reason}` : ''}`,
+      title,
+      content: title,
       timestamp: toTimestamp(getAuditTime(latest)),
       group,
+      tzlx: NOTIFY_TZLX.AUDIT_RESULT,
+      tbtx: 0,
+      shjg: 0,
       route: buildRoute('archive', {
         sub: 'history',
         group: record.qingpuGroup || group,
@@ -161,25 +198,31 @@ export const buildNotifications = ({
     });
   });
 
-  // ── 填报提醒(当前账期截止前 7 天,尚未提交的集团)────────
-  if (daysLeft > 0 && daysLeft <= 7) {
+  // ── 填报提醒(截止前 7/3/1 天,尚未提交的集团)────────────
+  if (reminderTbtx > 0) {
     const allLists = [pendingList, approvedList, rejectedList];
     GROUP_ORDER.forEach((group) => {
       if (currentGroup && group !== currentGroup) return;
       if (groupHasAnySubmission(allLists, group, currentPeriod)) return;
+
+      const title = reminderTitle(currentPeriod, group, daysLeft);
       notifications.push({
-        id: `reminder:${currentPeriod}:${group}`,
+        ywid: notifyYwid('reminder', currentPeriod, group, String(reminderTbtx)),
         time: formatDateTime(now),
         type: '填报提醒',
-        title: `${currentPeriod} 月报截止倒计时 ${daysLeft} 天(${group})`,
+        title,
+        content: title,
         timestamp: now,
         group,
+        tzlx: NOTIFY_TZLX.REMINDER,
+        tbtx: reminderTbtx,
+        shjg: 0,
         route: buildRoute('archive'),
       });
     });
   }
 
-  // ── 逾期催办(上月截止后未通过/未提交的集团)──────────────
+  // ── 逾期催办 ──────────────────────────────────────────────
   const prevPeriod = getPreviousPeriod(currentPeriod);
   const prevDeadline = getPeriodDeadlineDate(prevPeriod);
   if (now > prevDeadline.getTime()) {
@@ -196,33 +239,43 @@ export const buildNotifications = ({
       );
 
       if (!hasAny) {
+        const title = `${prevPeriod} 月报已逾期,请补报(${group})`;
         notifications.push({
-          id: `overdue:${prevPeriod}:${group}`,
+          ywid: notifyYwid('overdue', prevPeriod, group),
           time: formatDateTime(prevDeadline),
           type: '逾期催办',
-          title: `${prevPeriod} 月报已逾期,请补报(${group})`,
+          title,
+          content: title,
           timestamp: prevDeadline.getTime(),
           group,
+          tzlx: NOTIFY_TZLX.OVERDUE,
+          tbtx: 0,
+          shjg: 0,
           route: buildRoute('archive'),
         });
         return;
       }
 
       if (rejected) {
+        const title = `${prevPeriod} 月报被驳回且未重报,请尽快处理(${group})`;
         notifications.push({
-          id: `overdue-reject:${prevPeriod}:${group}`,
+          ywid: notifyYwid('overdue-reject', prevPeriod, group),
           time: formatDateTime(now),
           type: '逾期催办',
-          title: `${prevPeriod} 月报被驳回且未重报,请尽快处理(${group})`,
+          title,
+          content: title,
           timestamp: now,
           group,
+          tzlx: NOTIFY_TZLX.OVERDUE,
+          tbtx: 0,
+          shjg: 0,
           route: buildRoute('archive', { sub: 'history', group, period: prevPeriod, status: '已驳回' }),
         });
       }
     });
   }
 
-  // ── 租约到期/预警(来自已通过资产的 status 字段)──────────
+  // ── 租约到期/预警 ─────────────────────────────────────────
   const expiryItems = approvedList
     .filter((item) => item.status === 'red' || item.status === 'orange')
     .slice(0, 8);
@@ -233,13 +286,18 @@ export const buildNotifications = ({
     const group = normalizeGroupName(item.c_ssqpjt) || '未知';
     if (currentGroup && group !== currentGroup) return;
 
+    const title = `${label} 租约${withinOneMonth ? '将于一个月内到期' : '将于三个月内到期'},请关注`;
     notifications.push({
-      id: `expiry:${item.id}`,
+      ywid: `expiry:${item.id}`,
       time: formatDateTime(item.update_time || now),
       type: withinOneMonth ? '租约到期' : '租约预警',
-      title: `${label} 租约${withinOneMonth ? '将于一个月内到期' : '将于三个月内到期'},请关注`,
+      title,
+      content: title,
       timestamp: toTimestamp(item.update_time || now),
       group,
+      tzlx: withinOneMonth ? NOTIFY_TZLX.LEASE_EXPIRY : NOTIFY_TZLX.LEASE_WARNING,
+      tbtx: 0,
+      shjg: 0,
       route: buildRoute('assets'),
     });
   });

+ 210 - 0
src/utils/notificationFields.js

@@ -0,0 +1,210 @@
+import { formatDateTime } from '@/utils/aggregateReportRecords';
+
+/** 通知类型 c_tzlx */
+export const NOTIFY_TZLX = {
+  REMINDER: 0,
+  AUDIT_RESULT: 1,
+  OVERDUE: 2,
+  PENDING: 3,
+  LEASE_WARNING: 4,
+  LEASE_EXPIRY: 5,
+};
+
+/** c_tzlx → 展示类型 */
+export const TZLX_TYPE_LABEL = {
+  [NOTIFY_TZLX.REMINDER]: '填报提醒',
+  [NOTIFY_TZLX.AUDIT_RESULT]: '审核结果',
+  [NOTIFY_TZLX.OVERDUE]: '逾期催办',
+  [NOTIFY_TZLX.PENDING]: '待审核',
+  [NOTIFY_TZLX.LEASE_WARNING]: '租约预警',
+  [NOTIFY_TZLX.LEASE_EXPIRY]: '租约到期',
+};
+
+const parseContentJson = (raw) => {
+  let base = {};
+  if (typeof raw === 'string' && raw.trim().startsWith('{')) {
+    try {
+      base = JSON.parse(raw);
+    } catch {
+      base = {};
+    }
+  } else if (raw && typeof raw === 'object') {
+    base = { ...raw };
+  }
+  return base;
+};
+
+const toFlag = (val) => (Number(val) === 1 ? 1 : 0);
+
+export const resolveNotificationRecord = (item) => {
+  if (!item) return {};
+  const parsed = parseContentJson(item.content);
+  const record = { ...item, ...parsed };
+  if (item.id != null) record.id = item.id;
+  return record;
+};
+
+const parseRoute = (val) => {
+  if (!val) return undefined;
+  if (typeof val === 'object') return val;
+  if (typeof val === 'string' && val.trim().startsWith('{')) {
+    try {
+      return JSON.parse(val);
+    } catch {
+      return undefined;
+    }
+  }
+  return undefined;
+};
+
+const serializeRoute = (routeVal, fallbackRoute) => {
+  if (typeof routeVal === 'string' && routeVal.trim()) return routeVal;
+  const route = fallbackRoute || parseRoute(routeVal) || {};
+  return JSON.stringify(route);
+};
+
+const tzlxToTypeLabel = (record) => {
+  const tzlx = Number(record.c_tzlx);
+  if (tzlx === NOTIFY_TZLX.AUDIT_RESULT) return TZLX_TYPE_LABEL[NOTIFY_TZLX.AUDIT_RESULT];
+  return TZLX_TYPE_LABEL[tzlx] || '通知';
+};
+
+/** 后端记录 → 前端列表项 */
+export const parseNotificationItem = (raw) => {
+  const record = resolveNotificationRecord(raw);
+  const timestamp = Number(record.c_tzsj) || 0;
+  const id = record.id ?? record.contentId;
+
+  return {
+    id,
+    ywid: record.c_ywid || '',
+    time: formatDateTime(record.c_tzsj || record.update_time),
+    timestamp,
+    type: tzlxToTypeLabel(record),
+    title: record.c_xxbt || record.title || '',
+    content: record.c_xxnr || record.content || '',
+    read: toFlag(record.c_sfyd) === 1,
+    deleted: toFlag(record.c_sfsc) === 1,
+    pushed: toFlag(record.c_sfts) === 1,
+    group: record.c_ssqpjt || '',
+    route: parseRoute(record.c_route),
+    tzlx: Number(record.c_tzlx),
+    shjg: Number(record.c_shjg),
+    tbtx: Number(record.c_tbtx),
+    _record: record,
+  };
+};
+
+const buildNotificationFields = ({
+  title,
+  body,
+  timestamp,
+  read = 0,
+  deleted = 0,
+  pushed = 1,
+  tzlx = 0,
+  tbtx = 0,
+  shjg = 0,
+  group = '',
+  ywid = '',
+  route = {},
+  id = '',
+} = {}) => ({
+  ...(id ? { id: String(id) } : {}),
+  title,
+  content: body,
+  c_xxbt: title,
+  c_xxnr: body,
+  c_tzsj: timestamp ?? Date.now(),
+  c_sfyd: toFlag(read),
+  c_sfsc: toFlag(deleted),
+  c_sfts: toFlag(pushed),
+  c_tzlx: tzlx,
+  c_tbtx: tbtx,
+  c_shjg: shjg,
+  c_ssqpjt: group,
+  c_route: serializeRoute(route, route),
+  c_ywid: ywid,
+});
+
+/** 前端生成项 → addContent 的 content 对象 */
+export const buildNotificationContentPayload = (item) => {
+  const title = item.title || '';
+  const body = item.content || title;
+
+  return buildNotificationFields({
+    title,
+    body,
+    timestamp: item.timestamp ?? Date.now(),
+    read: 0,
+    deleted: 0,
+    pushed: 1,
+    tzlx: item.tzlx ?? 0,
+    tbtx: item.tbtx ?? 0,
+    shjg: item.shjg ?? 0,
+    group: item.group || '',
+    ywid: item.ywid || item.id || '',
+    route: item.route || {},
+  });
+};
+
+/** 更新通知(已读 / 删除 / 推送 / 文案) */
+export const buildNotificationUpdatePayload = (item, patch = {}) => {
+  const base = item._record || {};
+  const title = patch.title ?? base.c_xxbt ?? item.title ?? '';
+  const body = patch.content ?? base.c_xxnr ?? item.content ?? title;
+
+  const read = patch.read !== undefined ? patch.read : item.read;
+  const deleted = patch.deleted !== undefined ? patch.deleted : item.deleted;
+  const pushed = patch.pushed !== undefined ? patch.pushed : item.pushed;
+
+  return buildNotificationFields({
+    id: item.id ?? base.id,
+    title,
+    body,
+    timestamp: patch.timestamp ?? base.c_tzsj ?? item.timestamp ?? Date.now(),
+    read,
+    deleted,
+    pushed,
+    tzlx: patch.tzlx ?? base.c_tzlx ?? item.tzlx ?? 0,
+    tbtx: patch.tbtx ?? base.c_tbtx ?? item.tbtx ?? 0,
+    shjg: patch.shjg ?? base.c_shjg ?? item.shjg ?? 0,
+    group: patch.group ?? base.c_ssqpjt ?? item.group ?? '',
+    ywid: patch.ywid ?? base.c_ywid ?? item.ywid ?? '',
+    route: patch.route ?? base.c_route ?? item.route ?? {},
+  });
+};
+
+/** 用最新生成的通知内容覆盖已有记录(用于待审核条数变化等) */
+export const buildNotificationResyncPayload = (existing, generated, patch = {}) =>
+  buildNotificationUpdatePayload(
+    {
+      ...existing,
+      title: generated.title,
+      content: generated.content || generated.title,
+      timestamp: generated.timestamp,
+      tzlx: generated.tzlx,
+      tbtx: generated.tbtx,
+      shjg: generated.shjg,
+      group: generated.group,
+      route: generated.route,
+      ywid: generated.ywid,
+    },
+    {
+      deleted: false,
+      pushed: true,
+      read: patch.read !== undefined ? patch.read : existing.read,
+      ...patch,
+    }
+  );
+
+export const isPendingNotificationYwid = (ywid) =>
+  typeof ywid === 'string' && ywid.startsWith('pending:');
+
+const AGGREGATE_YWID_PREFIXES = ['pending:', 'approved:', 'rejected:'];
+
+export const isAggregateNotificationYwid = (ywid) =>
+  typeof ywid === 'string' && AGGREGATE_YWID_PREFIXES.some((prefix) => ywid.startsWith(prefix));
+
+export const findNotificationItem = (items, id) =>
+  items.find((item) => String(item.id) === String(id));

+ 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' });