gongtianxiao il y a 1 mois
Parent
commit
dad130ef3d

+ 9 - 11
src/components/layout/NotificationModal.vue

@@ -86,11 +86,13 @@
               </span>
               <span :style="{ width: notifyColumns[5].width }" @click.stop>
                 <BaseButton
+                  v-if="!item.ephemeral"
                   text="删除"
                   size="sm"
                   variant="danger"
                   @click="handleDeleteOne(item.id)"
                 />
+                <span v-else class="muted">—</span>
               </span>
             </div>
           </div>
@@ -149,9 +151,9 @@ const TYPE_CLASS = {
 /** 当前管理集团 */
 const currentGroup = computed(() => filterStore.selectManageGroup);
 
-/** 从持久化 store 读取,按当前集团过滤 */
+/** 从持久化 store 读取,按当前集团过滤(含前端填报提醒) */
 const displayList = computed(() =>
-  notificationStore.itemsByGroup(currentGroup.value).map((item) => ({
+  notificationStore.displayItemsByGroup(currentGroup.value).map((item) => ({
     ...item,
     typeClass: TYPE_CLASS[item.type] || 'gray',
   }))
@@ -166,8 +168,7 @@ watch(unreadCount, (count) => {
   emit('unread-change', count);
 }, { immediate: true });
 
-/** 打开弹窗:优先展示缓存,后台轻量同步(不强制拉全量资产) */
-const syncOnOpen = async () => {
+const refreshNotifications = async () => {
   try {
     await notificationStore.refresh();
   } catch (e) {
@@ -175,14 +176,11 @@ const syncOnOpen = async () => {
   }
 };
 
+/** 打开弹窗:优先展示缓存,后台轻量同步(不强制拉全量资产) */
+const syncOnOpen = refreshNotifications;
+
 /** 手动刷新:强制拉全量资产并同步 */
-const refreshFull = async () => {
-  try {
-    await notificationStore.refresh();
-  } catch (e) {
-    showError(e?.message || e?.msg || '通知加载失败,请稍后重试');
-  }
-};
+const refreshFull = refreshNotifications;
 
 const close = () => {
   emit('update:visible', false);

+ 31 - 4
src/components/manage/asset/FullAssetListPanel.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <div class="full-asset-list" :class="{ 'full-asset-list--embedded': embedded }">
     <div class="panel">
       <SectionHeader title="时间维度(列表筛选)" />
@@ -132,7 +132,9 @@
 import { showWarning } from '@/utils/message';
 import { exportTableExcel } from '@/utils/exportExcel';
 import { computed, ref, onMounted, watch, defineProps, defineEmits, defineExpose } from 'vue';
-import { useRouter } from 'vue-router';
+import { useRoute, useRouter } from 'vue-router';
+import { parseManageRoute } from '@/utils/manageRoutes';
+import { GROUP_ORDER, normalizeGroupName } from '@/utils/aggregateReportRecords';
 import SectionHeader from '@/components/base/SectionHeader.vue';
 import BaseSelect from '@/components/base/BaseSelect.vue';
 import BaseButton from '@/components/base/BaseButton.vue';
@@ -180,6 +182,7 @@ const props = defineProps({
 const emit = defineEmits(['update:selectedIds']);
 
 const enterpriseStore = useEnterpriseStore();
+const route = useRoute();
 const router = useRouter();
 
 const keyword = ref('');
@@ -330,12 +333,36 @@ const exportFilteredAssets = () =>
 
 defineExpose({ exportFilteredAssets, filteredAssets, selectedIds: () => props.selectedIds });
 
-const loadListData = () => enterpriseStore.fetchByState(REPORT_STATE.APPROVED);
+const loadListData = () => enterpriseStore.ensureState(REPORT_STATE.APPROVED);
+
+const applyRouteFilters = () => {
+  const { tab, group, period } = parseManageRoute(route);
+  if (tab !== 'assets') return;
+
+  const hasRouteFilter = Boolean(route.query.group || route.query.period);
+  if (!hasRouteFilter) return;
+
+  filterGroup.value = group && GROUP_ORDER.includes(normalizeGroupName(group))
+    ? normalizeGroupName(group)
+    : '全部';
+  filterFillMonth.value = period ? String(period) : '全部';
+};
 
 onMounted(() => {
-  loadListData();
+  if (parseManageRoute(route).tab === 'assets') {
+    loadListData();
+  }
 });
 
+watch(
+  () => parseManageRoute(route).tab,
+  (tab) => {
+    if (tab === 'assets') loadListData();
+  },
+);
+
+watch(() => route.fullPath, applyRouteFilters, { immediate: true });
+
 watch(totalPages, (pages) => {
   if (currentPage.value > pages) currentPage.value = pages;
 });

+ 34 - 8
src/components/manage/pages/ManageBasicArchive.vue

@@ -154,6 +154,7 @@
             <span>填报人</span>
             <input v-model="histReporter" type="text" placeholder="姓名" />
           </label>
+          <BaseButton text="重置" variant="outline" @click="resetHistoryFilters" />
           <BaseButton text="导出往期上报报表" variant="outline" @click="handleHistExport" />
         </div>
       </div>
@@ -202,7 +203,7 @@
 <script setup>
 import { showAlert, showConfirm, showError, showSuccess, showWarning } from '@/utils/message';
 import { exportTableExcel } from '@/utils/exportExcel';
-import { computed, ref, onMounted, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import SectionHeader from '@/components/base/SectionHeader.vue';
 import BaseSelect from '@/components/base/BaseSelect.vue';
@@ -212,6 +213,7 @@ import MonthlyEditModal from '@/components/manage/report/MonthlyEditModal.vue';
 import HistDetailModal from '@/components/manage/audit/HistDetailModal.vue';
 import HistAuditTrailModal from '@/components/manage/audit/HistAuditTrailModal.vue';
 import { useEnterpriseStore, REPORT_STATE } from '@/store/enterprise';
+import { useNotificationStore } from '@/store/notification';
 import { useUserStore } from '@/store/user';
 import { parseManageRoute, buildManageRoute } from '@/utils/manageRoutes';
 import { resolveManageEnterprise, assetBelongsToManageScope } from '@/utils/manageContext';
@@ -233,6 +235,7 @@ import { updateAudit, submitAssetEdit, updateContent } from '@/api/enterpriseLis
 import { buildContentPayload, resolveAssetRecord } from '@/utils/assetDetailFields';
 import { COLUMN_ID, MODEL_ID } from '@/config';
 const enterpriseStore = useEnterpriseStore();
+const notificationStore = useNotificationStore();
 const userStore = useUserStore();
 const filterStore = useFilterStore();
 const route = useRoute();
@@ -910,10 +913,12 @@ const handleSubmitReport = async () => {
     await Promise.all(monthlyQueue.value.map((item) => submitQueueItem(item, fillTime)));
 
     selectedIds.value = [];
-    await enterpriseStore.fetchReportProgress({ force: true });
+    await enterpriseStore.refreshReportProgress({ skipSubmitted: true });
+    await enterpriseStore.refreshStates([REPORT_STATE.SUBMITTED]);
     mergeQueueFromBackend();
     syncQueueAuditState();
     persistQueue();
+    await notificationStore.syncAfterReportChange();
     showSuccess('提交成功,进入审核');
   } catch (e) {
     console.log('提交上报异常:', e);
@@ -959,13 +964,24 @@ const refreshMonthlyQueueState = () => {
   persistQueue();
 };
 
-onMounted(() => {
-  loadReportingState();
-});
+watch(
+  () => parseManageRoute(route).tab,
+  (tab) => {
+    if (tab !== 'archive') return;
+    if (monthlySub.value === 'history') loadHistoryData();
+    else loadReportingState();
+  },
+  { immediate: true },
+);
 
 const loadReportingState = async ({ resetQueue = false } = {}) => {
-  await enterpriseStore.fetchReportProgress({ force: true });
-  await enterpriseStore.fetchList(true);
+  if (resetQueue) {
+    await enterpriseStore.refreshReportProgress({ skipSubmitted: true });
+    await enterpriseStore.refreshStates([REPORT_STATE.SUBMITTED]);
+  } else {
+    await enterpriseStore.ensureReportProgress({ skipSubmitted: true });
+    await enterpriseStore.ensureState(REPORT_STATE.SUBMITTED);
+  }
 
   if (resetQueue) {
     monthlyQueue.value = [];
@@ -1123,7 +1139,7 @@ const filteredHistoryRows = computed(() => {
   });
 });
 
-const loadHistoryData = () => enterpriseStore.fetchReportProgress({ force: true });
+const loadHistoryData = () => enterpriseStore.ensureReportProgress({ skipSubmitted: true });
 
 const formatReportMonthForExport = (period) => {
   const match = String(period ?? '').match(/^(\d{4})-(\d{2})$/);
@@ -1159,7 +1175,17 @@ const handleHistAudit = (row) => {
   showHistAudit.value = true;
 };
 
+const resetHistoryFilters = () => {
+  histPeriod.value = '全部';
+  histStatus.value = '全部';
+  histReporter.value = '';
+  if (route.query.period || route.query.status) {
+    router.replace(buildManageRoute({ tab: 'archive', sub: 'history' }));
+  }
+};
+
 watch(monthlySub, (tab) => {
+  if (parseManageRoute(route).tab !== 'archive') return;
   if (tab === 'history') {
     loadHistoryData();
   } else {

+ 21 - 38
src/components/manage/pages/ManageBasicReport.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <section class="manage-page">
     <p class="breadcrumb">数据管理 / <span>首页 · 上报进度与审核总览</span></p>
     <h2 class="page-title">八大集团上报进度与审核总览</h2>
@@ -81,29 +81,22 @@
           </span>
           <span :style="{ width: dashColumns[4].width }">
             <button
-              v-if="row.status === 'unsubmitted'"
+              v-if="row.status === 'passed' || row.status === 'rejected'"
               type="button"
               class="link-btn"
-              @click="handleGoReport(row)"
+              @click="handleGoAudit(row, row.status)"
             >
-              去上报
+              查看
             </button>
             <button
               v-else-if="row.status === 'pending'"
               type="button"
               class="link-btn"
-              @click="handleGoAudit(row)"
+              @click="handleGoAudit(row, 'pending')"
             >
               审核
             </button>
-            <button
-              v-else-if="row.status === 'passed' || row.status === 'rejected'"
-              type="button"
-              class="link-btn"
-              @click="handleGoHistory(row)"
-            >
-              历史
-            </button>
+            <template v-else>—</template>
           </span>
         </div>
       </div>
@@ -112,8 +105,8 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from 'vue';
-import { useRouter } from 'vue-router';
+import { computed, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
 import SectionHeader from '@/components/base/SectionHeader.vue';
 import BaseButton from '@/components/base/BaseButton.vue';
 import { useEnterpriseStore } from '@/store/enterprise';
@@ -126,12 +119,13 @@ import {
   GROUP_DISPLAY_NAMES,
   normalizeGroupName,
 } from '@/utils/aggregateReportRecords';
-import { buildManageRoute } from '@/utils/manageRoutes';
+import { parseManageRoute, buildManageRoute } from '@/utils/manageRoutes';
 
 const DETAIL_STATUSES = ['pending', 'passed', 'rejected'];
 
 const enterpriseStore = useEnterpriseStore();
 const filterStore = useFilterStore();
+const route = useRoute();
 const router = useRouter();
 
 const dashFilter = ref('all');
@@ -287,34 +281,23 @@ const rowRouteQuery = (row) => ({
   ...(row.period && row.period !== '—' ? { period: row.period } : {}),
 });
 
-const handleGoHistory = (row) => {
-  const statusMap = { passed: '已通过', rejected: '已驳回' };
-  router.push(buildManageRoute({
-    tab: 'archive',
-    sub: 'history',
-    ...rowRouteQuery(row),
-    status: statusMap[row.status],
-  }));
-};
-
-const handleGoAudit = (row) => {
+const handleGoAudit = (row, sub = 'pending') => {
   router.push(buildManageRoute({
     tab: 'audit',
-    sub: 'pending',
-    ...rowRouteQuery(row),
-  }));
-};
-
-const handleGoReport = (row) => {
-  router.push(buildManageRoute({
-    tab: 'archive',
+    sub,
     ...rowRouteQuery(row),
   }));
 };
 
-onMounted(() => {
-  enterpriseStore.fetchReportProgress();
-});
+watch(
+  () => parseManageRoute(route).tab,
+  (tab) => {
+    if (tab === 'dashboard') {
+      enterpriseStore.ensureReportProgress({ skipSubmitted: true }).catch(() => {});
+    }
+  },
+  { immediate: true },
+);
 </script>
 
 <style scoped>

+ 46 - 17
src/components/manage/pages/ManageFinanceReport.vue

@@ -17,6 +17,7 @@
       <div v-if="auditTab === 'pending'" class="filter-bar">
         <BaseSelect text="集团" :selectedText="filterGroup" :options="groupOptions" @selected="filterGroup = $event" />
         <BaseSelect text="填报期" :selectedText="filterPeriod" :options="periodOptions" @selected="filterPeriod = $event" />
+        <BaseButton text="重置" variant="outline" @click="resetFilters" />
       </div>
       <div v-else-if="auditTab === 'passed'" class="filter-bar">
         <BaseSelect text="集团" :selectedText="filterGroup" :options="groupOptions" @selected="filterGroup = $event" />
@@ -28,11 +29,13 @@
           :options="auditMonthOptions"
           @selected="filterAuditMonth = $event"
         />
+        <BaseButton text="重置" variant="outline" @click="resetFilters" />
         <BaseButton text="导出已通过清单" variant="outline" @click="handleExportPassed" />
       </div>
       <div v-else class="filter-bar">
         <BaseSelect text="集团" :selectedText="filterGroup" :options="groupOptions" @selected="filterGroup = $event" />
         <BaseSelect text="填报期" :selectedText="filterPeriod" :options="periodOptions" @selected="filterPeriod = $event" />
+        <BaseButton text="重置" variant="outline" @click="resetFilters" />
       </div>
 
       <div v-if="auditTab === 'pending'" class="panel-toolbar">
@@ -54,6 +57,7 @@
       </div>
 
       <div class="data-table">
+        <p v-if="listRefreshing" class="sync-hint">数据刷新中…</p>
         <div class="data-table__header">
           <span v-for="col in activeColumns" :key="col.key" :style="{ width: col.width }">
             <input
@@ -69,7 +73,8 @@
         <div v-if="listLoading" class="empty-row">加载中…</div>
         <div v-else-if="activeRows.length === 0" class="empty-row">暂无符合条件的数据</div>
 
-        <div v-for="row in activeRows" v-else :key="row.id" class="data-table__row">
+        <template v-else>
+        <div v-for="row in activeRows" :key="row.id" class="data-table__row">
           <span v-for="col in activeColumns" :key="col.key" :style="{ width: col.width }">
             <input
               v-if="col.key === 'chk'"
@@ -115,6 +120,7 @@
             </template>
           </span>
         </div>
+        </template>
       </div>
     </div>
 
@@ -349,6 +355,19 @@ const periodOptions = computed(() => {
 });
 
 const listLoading = computed(() => {
+  const hasSourceData = currentSourceList.value.length > 0;
+  if (auditTab.value === 'passed') {
+    return enterpriseStore.approvedLoading && !hasSourceData;
+  }
+  if (auditTab.value === 'rejected') {
+    return enterpriseStore.rejectedLoading && !hasSourceData;
+  }
+  return enterpriseStore.pendingLoading && !hasSourceData;
+});
+
+const listRefreshing = computed(() => {
+  const hasSourceData = currentSourceList.value.length > 0;
+  if (!hasSourceData) return false;
   if (auditTab.value === 'passed') return enterpriseStore.approvedLoading;
   if (auditTab.value === 'rejected') return enterpriseStore.rejectedLoading;
   return enterpriseStore.pendingLoading;
@@ -386,12 +405,12 @@ const requireViewed = (row) => {
 
 const loadTabData = () => {
   if (auditTab.value === 'passed') {
-    return enterpriseStore.fetchByState(REPORT_STATE.APPROVED);
+    return enterpriseStore.ensureState(REPORT_STATE.APPROVED);
   }
   if (auditTab.value === 'rejected') {
-    return enterpriseStore.fetchByState(REPORT_STATE.REJECTED);
+    return enterpriseStore.ensureState(REPORT_STATE.REJECTED);
   }
-  return enterpriseStore.fetchByState(REPORT_STATE.PENDING);
+  return enterpriseStore.ensureState(REPORT_STATE.PENDING);
 };
 
 const handleView = (row) => {
@@ -541,11 +560,21 @@ const handleExportPassed = () => {
   showSuccess(`已导出 ${result.count} 条记录(${result.filename})`);
 };
 
+const resetFilters = () => {
+  filterGroup.value = '全部';
+  filterPeriod.value = '全部';
+  filterAuditMonth.value = '全部';
+  if (route.query.group || route.query.period || route.query.status) {
+    router.replace(buildManageRoute({ tab: 'audit', sub: auditTab.value }));
+  }
+};
+
 const applyRouteSubNav = () => {
   const { tab, sub, group, period } = parseManageRoute(route);
   if (tab !== 'audit') return;
   if (!sub || !['pending', 'passed', 'rejected'].includes(sub)) return;
 
+  const subChanged = auditTab.value !== sub;
   auditTab.value = sub;
 
   const nextGroup = group && GROUP_ORDER.includes(normalizeGroupName(group))
@@ -554,6 +583,12 @@ const applyRouteSubNav = () => {
   const nextPeriod = period ? String(period) : '全部';
   filterGroup.value = nextGroup;
   filterPeriod.value = nextPeriod;
+
+  if (subChanged) {
+    selectedIds.value = [];
+    filterAuditMonth.value = '全部';
+  }
+
   loadTabData();
 };
 
@@ -563,19 +598,6 @@ const switchAuditSub = (sub) => {
 };
 
 watch(() => route.fullPath, applyRouteSubNav, { immediate: true });
-
-watch(auditTab, () => {
-  selectedIds.value = [];
-  const parsed = parseManageRoute(route);
-  const hasRouteFilter =
-    parsed.tab === 'audit' && (route.query.group || route.query.period);
-  if (!hasRouteFilter) {
-    filterGroup.value = '全部';
-    filterPeriod.value = '全部';
-  }
-  filterAuditMonth.value = '全部';
-  loadTabData();
-}, { immediate: true });
 </script>
 
 <style scoped>
@@ -715,4 +737,11 @@ watch(auditTab, () => {
   font-size: 14px;
   color: var(--color-text-secondary);
 }
+
+.sync-hint {
+  margin: 0 0 8px;
+  font-size: 12px;
+  color: var(--color-text-secondary);
+  text-align: right;
+}
 </style>

+ 5 - 6
src/components/manage/pages/ManageHistoryQuery.vue

@@ -1,4 +1,4 @@
-<template>
+<template>
   <section class="manage-page">
     <p class="breadcrumb">数据管理 / <span>全量资产数据管理</span></p>
     <h2 class="page-title">全量资产数据管理</h2>
@@ -187,8 +187,8 @@ const handleBatchUploadConfirm = async ({ rows }) => {
     }
 
     if (successCount > 0) {
-      await enterpriseStore.fetchReportProgress({ force: true });
-      await enterpriseStore.fetchByState(REPORT_STATE.APPROVED, { force: true });
+      await enterpriseStore.refreshReportProgress({ skipSubmitted: true });
+      await enterpriseStore.refreshStates([REPORT_STATE.APPROVED, REPORT_STATE.PENDING]);
     }
 
     if (failCount === 0) {
@@ -258,8 +258,8 @@ const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
     }
 
     if (successCount > 0) {
-      await enterpriseStore.fetchReportProgress({ force: true });
-      await enterpriseStore.fetchByState(REPORT_STATE.APPROVED, { force: true });
+      await enterpriseStore.refreshReportProgress({ skipSubmitted: true });
+      await enterpriseStore.refreshStates([REPORT_STATE.APPROVED, REPORT_STATE.PENDING]);
       await syncNotificationsAfterChange();
     }
 
@@ -285,7 +285,6 @@ const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
 
 onMounted(() => {
   loadAssetFormDraft();
-  enterpriseStore.fetchReportProgress();
 });
 </script>
 

+ 1 - 1
src/components/stats/MonthlyReport.vue

@@ -41,7 +41,7 @@ const progressStatList = computed(() => {
 });
 
 onMounted(() => {
-  enterpriseStore.fetchReportProgress({ skipSubmitted: true });
+  enterpriseStore.ensureReportProgress({ skipSubmitted: true });
 });
 </script>
 

+ 2 - 2
src/pages/AssetDetailPage.vue

@@ -121,7 +121,7 @@ const handleSaveEdit = async (patch) => {
     await submitAssetEdit(asset.value, patchWithHistory);
     enterpriseStore.clearAssetEditDraft(assetId.value);
     showEditModal.value = false;
-    await enterpriseStore.fetchReportProgress({ force: true });
+    await enterpriseStore.refreshReportProgress({ skipSubmitted: true });
     await notificationStore.syncAfterReportChange();
     showSuccess('保存成功,已提交待审核。请前往「管理端 · 审核管理」查看;审核通过后全量列表才会更新。');
     router.push(buildManageRoute({ tab: 'audit', sub: 'pending' }));
@@ -160,7 +160,7 @@ const handleExportPdf = () => {
 };
 
 onMounted(() => {
-  enterpriseStore.fetchReportProgress();
+  enterpriseStore.ensureReportProgress({ skipSubmitted: true });
 });
 </script>
 

+ 23 - 5
src/pages/ManagePage.vue

@@ -19,14 +19,14 @@
 </template>
 
 <script setup>
-import { onMounted, ref, watch } from 'vue';
+import { ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import PageHeader from '@/components/layout/PageHeader.vue';
 import ManageBasicReport from '@/components/manage/pages/ManageBasicReport.vue';
 import ManageBasicArchive from '@/components/manage/pages/ManageBasicArchive.vue';
 import ManageFinanceReport from '@/components/manage/pages/ManageFinanceReport.vue';
 import ManageHistoryQuery from '@/components/manage/pages/ManageHistoryQuery.vue';
-import { useEnterpriseStore } from '@/store/enterprise';
+import { useEnterpriseStore, REPORT_STATE } from '@/store/enterprise';
 import BaseButton from '@/components/base/BaseButton.vue';
 import {
   MANAGE_TAB_LIST,
@@ -41,6 +41,24 @@ const enterpriseStore = useEnterpriseStore();
 
 const activeTab = ref('dashboard');
 
+const preloadForTab = (tabId) => {
+  switch (tabId) {
+    case 'dashboard':
+      return enterpriseStore.ensureReportProgress({ skipSubmitted: true });
+    case 'audit':
+      return enterpriseStore.ensureState(REPORT_STATE.PENDING);
+    case 'archive':
+      return Promise.all([
+        enterpriseStore.ensureState(REPORT_STATE.SUBMITTED),
+        enterpriseStore.ensureReportProgress({ skipSubmitted: true }),
+      ]);
+    case 'assets':
+      return enterpriseStore.ensureState(REPORT_STATE.APPROVED);
+    default:
+      return Promise.resolve();
+  }
+};
+
 const syncTabFromRoute = () => {
   const { tab } = parseManageRoute(route);
   activeTab.value = isManageTabId(tab) ? tab : 'dashboard';
@@ -60,9 +78,9 @@ const switchTab = (tabId) => {
 
 watch(() => route.fullPath, syncTabFromRoute, { immediate: true });
 
-onMounted(() => {
-  enterpriseStore.fetchList();
-});
+watch(activeTab, (tab) => {
+  preloadForTab(tab).catch(() => {});
+}, { immediate: true });
 </script>
 
 <style scoped>

+ 17 - 0
src/services/reportData.js

@@ -0,0 +1,17 @@
+/** 报表列表缓存有效期(毫秒) */
+export const REPORT_DATA_TTL_MS = 60_000;
+
+/** 缓存是否仍可用(未过期且未标记 stale) */
+export const isStateCacheFresh = (meta, ttl = REPORT_DATA_TTL_MS) => {
+  if (!meta || meta.stale) return false;
+  if (!meta.fetchedAt) return false;
+  return Date.now() - meta.fetchedAt < ttl;
+};
+
+/** 是否应发起请求(force 或缓存不可用) */
+export const shouldFetchState = (loadStatus, meta, { force = false, ttl = REPORT_DATA_TTL_MS } = {}) => {
+  if (force) return true;
+  if (loadStatus === 'loading') return false;
+  if (loadStatus !== 'loaded') return true;
+  return !isStateCacheFresh(meta, ttl);
+};

+ 75 - 14
src/store/enterprise.js

@@ -7,6 +7,7 @@ import {
 } from '@/api/enterpriseList';
 import { buildContentPayload, resolveAssetRecord } from '@/utils/assetDetailFields';
 import { normalizeGroupName, matchesReportPeriod } from '@/utils/aggregateReportRecords';
+import { shouldFetchState } from '@/services/reportData';
 import { useUserStore } from '@/store/user';
 import { COLUMN_ID, MODEL_ID } from '@/config';
 import { useNotificationStore } from '@/store/notification';
@@ -34,6 +35,9 @@ const BASE_QUERY = {
   page: 0,
 };
 
+/** 同一 state 并发请求合并为一次 */
+const inflightByState = new Map();
+
 const createInitialListsByState = () =>
   REPORT_STATES.reduce((acc, state) => {
     acc[state] = [];
@@ -46,10 +50,17 @@ const createInitialLoadStatus = () =>
     return acc;
   }, {});
 
+const createInitialMetaByState = () =>
+  REPORT_STATES.reduce((acc, state) => {
+    acc[state] = { stale: false, fetchedAt: 0 };
+    return acc;
+  }, {});
+
 export const useEnterpriseStore = defineStore('enterprise', {
   state: () => ({
     listsByState: createInitialListsByState(),
     loadStatus: createInitialLoadStatus(),
+    metaByState: createInitialMetaByState(),
     /** 全量资产修改弹窗草稿,key 为资产 id */
     assetEditDrafts: {},
   }),
@@ -60,7 +71,7 @@ export const useEnterpriseStore = defineStore('enterprise', {
     approvedList: (state) => state.listsByState[REPORT_STATE.APPROVED],
     rejectedList: (state) => state.listsByState[REPORT_STATE.REJECTED],
 
-    /** 各状态加载中 */
+    /** 各状态加载中(含后台静默刷新) */
     submittedLoading: (state) => state.loadStatus[REPORT_STATE.SUBMITTED] === 'loading',
     pendingLoading: (state) => state.loadStatus[REPORT_STATE.PENDING] === 'loading',
     approvedLoading: (state) => state.loadStatus[REPORT_STATE.APPROVED] === 'loading',
@@ -95,19 +106,67 @@ export const useEnterpriseStore = defineStore('enterprise', {
     },
   },
   actions: {
+    /** @param {boolean} force 忽略 TTL 与已加载缓存 */
     async fetchByState(state, { force = false } = {}) {
-      if (this.loadStatus[state] === 'loaded' && !force) return;
+      const inflight = inflightByState.get(state);
+      if (inflight) return inflight;
+
+      const meta = this.metaByState[state];
+      const loadStatus = this.loadStatus[state];
+      if (!shouldFetchState(loadStatus, meta, { force })) return;
 
+      const hasCache = this.listsByState[state].length > 0;
       this.loadStatus[state] = 'loading';
-      try {
-        const res = await getEnterprise({ ...BASE_QUERY, states: state });
-        const rows = res.content?.data ?? [];
-        this.listsByState[state] = rows.map((item) => resolveAssetRecord(item));
-        this.loadStatus[state] = 'loaded';
-      } catch (e) {
-        this.listsByState[state] = [];
-        this.loadStatus[state] = 'error';
-      }
+
+      const task = (async () => {
+        try {
+          const res = await getEnterprise({ ...BASE_QUERY, states: state });
+          const rows = res.content?.data ?? [];
+          this.listsByState[state] = rows.map((item) => resolveAssetRecord(item));
+          this.loadStatus[state] = 'loaded';
+          this.metaByState[state] = { stale: false, fetchedAt: Date.now() };
+        } catch (e) {
+          if (hasCache) {
+            this.loadStatus[state] = 'loaded';
+            this.metaByState[state] = {
+              ...this.metaByState[state],
+              stale: true,
+            };
+          } else {
+            this.loadStatus[state] = 'error';
+          }
+          throw e;
+        } finally {
+          inflightByState.delete(state);
+        }
+      })();
+
+      inflightByState.set(state, task);
+      return task;
+    },
+
+    /** 按需加载(尊重 TTL,60s 内不重复请求) */
+    ensureState(state) {
+      return this.fetchByState(state);
+    },
+
+    async ensureReportProgress({ skipSubmitted = false } = {}) {
+      const states = skipSubmitted
+        ? [REPORT_STATE.PENDING, REPORT_STATE.APPROVED, REPORT_STATE.REJECTED]
+        : REPORT_STATES;
+
+      await Promise.all(states.map((state) => this.ensureState(state)));
+    },
+
+    /** 写操作后强制刷新指定 state */
+    async refreshStates(states) {
+      if (!states?.length) return;
+      await Promise.all(states.map((state) => this.fetchByState(state, { force: true })));
+    },
+
+    /** 写操作后强制刷新进度相关列表 */
+    async refreshReportProgress({ skipSubmitted = false } = {}) {
+      return this.fetchReportProgress({ skipSubmitted, force: true });
     },
 
     async fetchReportProgress({ force = false, skipSubmitted = false } = {}) {
@@ -124,7 +183,9 @@ export const useEnterpriseStore = defineStore('enterprise', {
 
     /** 数据统计模块:加载已通过审核的资产 */
     async fetchStatisticsList(force = false) {
-      return this.fetchByState(REPORT_STATE.APPROVED, { force });
+      return force
+        ? this.fetchByState(REPORT_STATE.APPROVED, { force: true })
+        : this.ensureState(REPORT_STATE.APPROVED);
     },
 
     async approveItem(item, auditorName, auditTime = Date.now()) {
@@ -154,7 +215,7 @@ export const useEnterpriseStore = defineStore('enterprise', {
       const auditorName = userStore.userInfo?.username || '';
       const auditTime = Date.now();
       await Promise.all(items.map((item) => this.approveItem(item, auditorName, auditTime)));
-      await this.fetchReportProgress({ force: true });
+      await this.refreshStates([REPORT_STATE.PENDING, REPORT_STATE.APPROVED]);
       await useNotificationStore().syncAfterReportChange();
     },
 
@@ -187,7 +248,7 @@ export const useEnterpriseStore = defineStore('enterprise', {
       await Promise.all(
         items.map((item) => this.rejectItem(item, { auditorName, auditorComment }, auditTime))
       );
-      await this.fetchReportProgress({ force: true });
+      await this.refreshStates([REPORT_STATE.PENDING, REPORT_STATE.REJECTED]);
       await useNotificationStore().syncAfterReportChange();
     },
 

+ 119 - 62
src/store/notification.js

@@ -6,10 +6,9 @@ import {
   updateNotificationResync,
 } from '@/api/notification';
 import { useEnterpriseStore } from '@/store/enterprise';
-import { buildNotifications } from '@/utils/buildNotifications';
+import { buildEphemeralReminders, buildNotifications } from '@/utils/buildNotifications';
 import {
   findNotificationItem,
-  isAggregateNotificationYwid,
   isPendingNotificationYwid,
   parseNotificationItem,
 } from '@/utils/notificationFields';
@@ -38,17 +37,47 @@ const getEnterpriseLists = () => {
   };
 };
 
-const loadNotificationItems = async () => {
+const loadNotificationRows = async () => {
   const res = await getNotificationList();
-  const rows = res.content?.data ?? [];
-  return rows
-    .map(parseNotificationItem)
-    .filter((item) => item.id != null && !item.deleted);
+  return (res.content?.data ?? []).map(parseNotificationItem).filter((item) => item.id != null);
+};
+
+/** 展示用:不含已软删 */
+const loadNotificationItems = async () => {
+  const rows = await loadNotificationRows();
+  return rows.filter((item) => !item.deleted);
+};
+
+/** 同步用:含已软删,按 c_ywid 去重时优先保留未删记录 */
+const indexExistingByYwid = (items) => {
+  const map = new Map();
+  items.forEach((item) => {
+    if (!item.ywid) return;
+    const prev = map.get(item.ywid);
+    if (!prev) {
+      map.set(item.ywid, item);
+      return;
+    }
+    if (prev.deleted && !item.deleted) {
+      map.set(item.ywid, item);
+      return;
+    }
+    if (!prev.deleted && item.deleted) return;
+    if ((item.timestamp || 0) >= (prev.timestamp || 0)) {
+      map.set(item.ywid, item);
+    }
+  });
+  return map;
 };
 
 export const useNotificationStore = defineStore('notification', {
   state: () => ({
     items: [],
+    /** 填报期提醒:仅前端展示,不入库 */
+    ephemeralReminders: [],
+    /** 本会话已读/已关闭的 ephemeral id */
+    ephemeralReadIds: [],
+    ephemeralDismissedIds: [],
     /** 无缓存时阻塞展示 */
     loading: false,
     /** 有缓存时后台同步 */
@@ -56,42 +85,54 @@ export const useNotificationStore = defineStore('notification', {
     _refreshPromise: null,
   }),
   getters: {
-    itemsByGroup(state) {
+    displayItemsByGroup(state) {
       return (group) => {
-        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)
+        const persisted = state.items
+          .filter((item) => !item.deleted)
+          .filter((item) => !group || !item.group || item.group === group);
+        const ephemeral = state.ephemeralReminders
+          .filter((item) => !state.ephemeralDismissedIds.includes(String(item.id)))
+          .filter((item) => !group || !item.group || item.group === group);
+        return [...ephemeral, ...persisted]
           .sort((a, b) => b.timestamp - a.timestamp);
       };
     },
     unreadCountByGroup(state) {
       return (group) => {
+        const ephemeralUnread = state.ephemeralReminders.filter((item) => {
+          if (state.ephemeralDismissedIds.includes(String(item.id))) return false;
+          if (state.ephemeralReadIds.includes(String(item.id))) return false;
+          if (group && item.group && item.group !== group) return false;
+          return true;
+        }).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;
+        const persistedUnread = !group
+          ? all.length
+          : all.filter((item) => !item.group || item.group === group).length;
+        return ephemeralUnread + persistedUnread;
       };
     },
   },
   actions: {
+    refreshEphemeralReminders(lists) {
+      this.ephemeralReminders = buildEphemeralReminders(lists);
+    },
+
     async fetchNotifications() {
       this.items = await loadNotificationItems();
     },
 
+    /**
+     * 持久消息同步:仅新增缺失、更新已有;不自动软删。
+     * 用户手动删除(c_sfsc=1)的记录保留删除状态,且不再重复 add。
+     */
     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 existingByYwid = indexExistingByYwid(sourceItems);
 
       const toAdd = [];
       const toUpdate = [];
-      const toRemove = [];
 
       generated.forEach((item) => {
         const ywid = item.ywid || item.id;
@@ -103,41 +144,51 @@ export const useNotificationStore = defineStore('notification', {
           return;
         }
 
-        const resetRead = bumpPending && isPendingNotificationYwid(ywid);
+        // 用户已删或历史软删:不再重建,避免登录/刷新重复入库
+        if (existing.deleted) return;
+
         const pendingBump = bumpPending && isPendingNotificationYwid(ywid);
         const contentChanged =
           existing.title !== item.title || existing.timestamp !== item.timestamp;
 
         if (pendingBump || contentChanged) {
-          toUpdate.push({ existing, item, resetRead });
+          toUpdate.push({ existing, item, resetRead: pendingBump });
         }
       });
 
-      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;
+      if (!toAdd.length && !toUpdate.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 _syncFromStore({ bumpPending = false } = {}) {
+      const enterpriseStore = useEnterpriseStore();
+      const needsReportData =
+        !enterpriseStore.pendingList.length
+        && !enterpriseStore.approvedList.length
+        && !enterpriseStore.rejectedList.length;
+
+      if (needsReportData) {
+        await enterpriseStore.ensureReportProgress({ skipSubmitted: true });
+      }
+
+      const existingItems = await loadNotificationRows();
+      const lists = getEnterpriseLists();
+      await this.syncNotifications(lists, {
+        bumpPending,
+        existingItems,
+      });
+      this.refreshEphemeralReminders(lists);
+      await this.fetchNotifications();
+    },
+
     async _runRefresh({ bumpPending = false } = {}) {
       const blocking = this.items.length === 0;
       if (blocking) {
@@ -147,18 +198,7 @@ export const useNotificationStore = defineStore('notification', {
       }
 
       try {
-        const enterpriseStore = useEnterpriseStore();
-        await enterpriseStore.fetchReportProgress({
-          force: true,
-          skipSubmitted: true,
-        });
-
-        const existingItems = await loadNotificationItems();
-        await this.syncNotifications(getEnterpriseLists(), {
-          bumpPending,
-          existingItems,
-        });
-        await this.fetchNotifications();
+        await this._syncFromStore({ bumpPending });
       } finally {
         this.loading = false;
         this.syncing = false;
@@ -175,7 +215,7 @@ export const useNotificationStore = defineStore('notification', {
       return this._refreshPromise;
     },
 
-    /** 报表/审核数据变更后立即同步通知(如批量修改送审) */
+    /** 报表/审核数据变更后同步通知(调用方须已刷新 enterpriseStore) */
     async syncAfterReportChange() {
       if (this._refreshPromise) {
         return this._refreshPromise;
@@ -183,17 +223,7 @@ export const useNotificationStore = defineStore('notification', {
       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();
+          await this._syncFromStore({ bumpPending: true });
         } finally {
           this.syncing = false;
         }
@@ -213,6 +243,13 @@ export const useNotificationStore = defineStore('notification', {
     },
 
     async markRead(id) {
+      const key = String(id);
+      if (key.startsWith('ephemeral:')) {
+        if (!this.ephemeralReadIds.includes(key)) {
+          this.ephemeralReadIds.push(key);
+        }
+        return;
+      }
       const item = findNotificationItem(this.items, id);
       if (!item || item.read) return;
       await this._updateItem(id, { read: true });
@@ -220,6 +257,13 @@ export const useNotificationStore = defineStore('notification', {
 
     async markAllRead(ids) {
       if (!ids?.length) return;
+      const ephemeralKeys = ids.filter((id) => String(id).startsWith('ephemeral:'));
+      ephemeralKeys.forEach((id) => {
+        const key = String(id);
+        if (!this.ephemeralReadIds.includes(key)) {
+          this.ephemeralReadIds.push(key);
+        }
+      });
       const targets = this.items.filter(
         (item) => ids.some((id) => String(id) === String(item.id)) && !item.read
       );
@@ -229,6 +273,13 @@ export const useNotificationStore = defineStore('notification', {
     },
 
     async deleteItem(id) {
+      const key = String(id);
+      if (key.startsWith('ephemeral:')) {
+        if (!this.ephemeralDismissedIds.includes(key)) {
+          this.ephemeralDismissedIds.push(key);
+        }
+        return;
+      }
       const item = findNotificationItem(this.items, id);
       if (!item) return;
       await this._updateItem(id, { deleted: true });
@@ -236,6 +287,12 @@ export const useNotificationStore = defineStore('notification', {
     },
 
     async deleteAll() {
+      this.ephemeralReminders.forEach((item) => {
+        const key = String(item.id);
+        if (!this.ephemeralDismissedIds.includes(key)) {
+          this.ephemeralDismissedIds.push(key);
+        }
+      });
       const targets = [...this.items];
       for (const item of targets) {
         await this._updateItem(item.id, { deleted: true });

+ 103 - 167
src/utils/buildNotifications.js

@@ -1,13 +1,12 @@
 import {
   GROUP_ORDER,
-  aggregateReportRecords,
-  extractFillMonth,
   extractPeriod,
   formatDateTime,
   getAuditTime,
   getFillTime,
   getRejectReason,
   normalizeGroupName,
+  matchesReportPeriod,
 } from '@/utils/aggregateReportRecords';
 import { NOTIFY_TZLX } from '@/utils/notificationFields';
 import { buildManageRoute } from '@/utils/manageRoutes';
@@ -42,112 +41,101 @@ const toTimestamp = (val) => {
   return Number.isNaN(d.getTime()) ? 0 : d.getTime();
 };
 
-const matchesGroupPeriod = (item, group, period) => {
-  const g = normalizeGroupName(item.c_ssqpjt);
-  if (g !== group) return false;
-  const fillMonth = extractFillMonth(item);
-  if (fillMonth) return fillMonth === period;
-  return extractPeriod(item, period) === period;
-};
-
-const groupHasAnySubmission = (lists, group, period) =>
-  lists.some((list) => list.some((item) => matchesGroupPeriod(item, group, period)));
+const matchesGroupPeriod = (item, group, period) =>
+  normalizeGroupName(item.c_ssqpjt) === group && matchesReportPeriod(item, period);
 
 const groupHasApproved = (approvedList, group, period) =>
   approvedList.some((item) => matchesGroupPeriod(item, group, period));
 
-const groupHasRejected = (rejectedList, group, period) =>
-  rejectedList.some((item) => matchesGroupPeriod(item, group, period));
-
 const buildRoute = (tab, query = {}) => buildManageRoute({ tab, ...query });
 
-const notifyYwid = (prefix, period, group, suffix = '') =>
-  suffix ? `${prefix}:${period}:${group}:${suffix}` : `${prefix}:${period}:${group}`;
+const notifyYwid = (prefix, period, group) => `${prefix}:${period}:${group}`;
 
-/** 月报聚合记录唯一 ywid(账期 + 青浦集团 + 所属集团,避免同集团多条记录互相覆盖) */
-const notifyAggregateYwid = (prefix, record) => {
-  const qingpuGroup = (record.qingpuGroup ?? '').trim() || 'unknown';
-  const groupName = (record.group ?? '').trim() || qingpuGroup;
-  return `${prefix}:${record.period}:${qingpuGroup}:${groupName}`;
-};
+/** 按账期 + 青浦集团整合(同一集团一条) */
+const groupItemsByQingpuGroup = (items, { fallbackPeriod = '2026-01' } = {}) => {
+  const map = new Map();
 
-/** 通知列表过滤、展示用青浦集团 */
-const notifyDisplayGroup = (record) => record.qingpuGroup || record.group;
+  items.forEach((item) => {
+    const period = extractPeriod(item, fallbackPeriod);
+    const qingpuGroup = normalizeGroupName((item.c_ssqpjt ?? '').trim());
+    if (!qingpuGroup) return;
+    const key = `${period}|${qingpuGroup}`;
 
-/** 截止前剩余天数 → 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;
-};
+    if (!map.has(key)) {
+      map.set(key, { period, qingpuGroup, items: [] });
+    }
+    map.get(key).items.push(item);
+  });
 
-const reminderTitle = (period, group, daysLeft) => {
-  const label = daysLeft <= 1 ? '1 天' : `${daysLeft} 天`;
-  return `${period} 月报截止倒计时 ${label}(${group})`;
+  return [...map.values()]
+    .map((batch) => {
+      const latestFill = batch.items.reduce((best, item) => {
+        const t = toTimestamp(getFillTime(item));
+        return t >= toTimestamp(getFillTime(best)) ? item : best;
+      }, batch.items[0]);
+
+      const latestAudit = batch.items.reduce((best, item) => {
+        const t = toTimestamp(getAuditTime(item));
+        return t >= toTimestamp(getAuditTime(best)) ? item : best;
+      }, batch.items[0]);
+
+      return {
+        ...batch,
+        group: batch.qingpuGroup,
+        count: batch.items.length,
+        fillTime: getFillTime(latestFill),
+        submittedAt: formatDateTime(getFillTime(latestFill)),
+        latestAuditItem: latestAudit,
+      };
+    })
+    .sort((a, b) => toTimestamp(b.fillTime) - toTimestamp(a.fillTime));
 };
 
 /**
- * 生成当前时刻所有适用的通知(幂等,同 c_ywid 不会重复入库)
+ * 持久化通知:待审核 / 审核结果 / 逾期催办(不含填报期提醒)
+ * 同一账期 + 青浦集团整合为一条;再次提交/审核时更新条数与时间
  */
 export const buildNotifications = ({
   pendingList = [],
   approvedList = [],
   rejectedList = [],
-} = {}, {
-  currentGroup = '',
 } = {}) => {
   const notifications = [];
   const now = Date.now();
   const currentPeriod = getCurrentPeriod();
-  const deadline = getPeriodDeadlineDate(currentPeriod);
-  const daysLeft = Math.ceil((deadline.getTime() - now) / (86400000));
-  const reminderTbtx = getReminderTbtx(daysLeft);
-
-  // ── 待审核 ────────────────────────────────────────────────
-  aggregateReportRecords(pendingList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    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} 条)待审核`;
+  // ── 待审核:同一账期 + 青浦集团一条(管理端)──────────────
+  groupItemsByQingpuGroup(pendingList, { fallbackPeriod: currentPeriod }).forEach((batch) => {
+    const group = batch.qingpuGroup;
+    const title = `${group} ${batch.period} 资产月报(${batch.count} 条)待审核`;
 
     notifications.push({
-      ywid: notifyAggregateYwid('pending', record),
-      time: record.submittedAt,
+      ywid: notifyYwid('pending', batch.period, group),
+      time: batch.submittedAt,
       type: '待审核',
       title,
       content: title,
-      timestamp: toTimestamp(getFillTime(latest)),
+      timestamp: toTimestamp(batch.fillTime),
       group,
       tzlx: NOTIFY_TZLX.PENDING,
       tbtx: 0,
       shjg: 0,
       route: buildRoute('audit', {
         sub: 'pending',
-        group: record.qingpuGroup || group,
-        period: record.period,
+        group,
+        period: batch.period,
       }),
     });
   });
 
-  // ── 已通过 ────────────────────────────────────────────────
-  aggregateReportRecords(approvedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    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} 条)`;
+  // ── 已通过:同一账期 + 青浦集团一条(企业端)──────────────
+  groupItemsByQingpuGroup(approvedList, { fallbackPeriod: currentPeriod }).forEach((batch) => {
+    const group = batch.qingpuGroup;
+    const latest = batch.latestAuditItem;
+    const title = `${batch.period} 月报已通过(${group},${batch.count} 条)`;
 
     notifications.push({
-      ywid: notifyAggregateYwid('approved', record),
+      ywid: notifyYwid('approved', batch.period, group),
       time: formatDateTime(getAuditTime(latest)),
       type: '审核结果',
       title,
@@ -159,27 +147,22 @@ export const buildNotifications = ({
       shjg: 1,
       route: buildRoute('archive', {
         sub: 'history',
-        group: record.qingpuGroup || group,
-        period: record.period,
+        group,
+        period: batch.period,
         status: '已通过',
       }),
     });
   });
 
-  // ── 已驳回 ────────────────────────────────────────────────
-  aggregateReportRecords(rejectedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    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]);
+  // ── 已驳回:同一账期 + 青浦集团一条(企业端)──────────────
+  groupItemsByQingpuGroup(rejectedList, { fallbackPeriod: currentPeriod }).forEach((batch) => {
+    const group = batch.qingpuGroup;
+    const latest = batch.latestAuditItem;
     const reason = getRejectReason(latest);
-    const title = `${record.period} 月报已被驳回(${record.group})${reason ? `:${reason}` : ''}`;
+    const title = `${batch.period} 月报已被驳回(${group},${batch.count} 条)${reason ? `:${reason}` : ''}`;
 
     notifications.push({
-      ywid: notifyAggregateYwid('rejected', record),
+      ywid: notifyYwid('rejected', batch.period, group),
       time: formatDateTime(getAuditTime(latest)),
       type: '审核结果',
       title,
@@ -191,116 +174,69 @@ export const buildNotifications = ({
       shjg: 0,
       route: buildRoute('archive', {
         sub: 'history',
-        group: record.qingpuGroup || group,
-        period: record.period,
+        group,
+        period: batch.period,
         status: '已驳回',
       }),
     });
   });
 
-  // ── 填报提醒(截止前 7/3/1 天,尚未提交的集团)────────────
-  if (reminderTbtx > 0) {
-    const allLists = [pendingList, approvedList, rejectedList];
+  // ── 逾期催办:上月无已通过则一条(企业端,持久化)──────────
+  const prevPeriod = getPreviousPeriod(currentPeriod);
+  const prevDeadline = getPeriodDeadlineDate(prevPeriod);
+  if (now > prevDeadline.getTime()) {
     GROUP_ORDER.forEach((group) => {
-      if (currentGroup && group !== currentGroup) return;
-      if (groupHasAnySubmission(allLists, group, currentPeriod)) return;
+      if (groupHasApproved(approvedList, group, prevPeriod)) return;
 
-      const title = reminderTitle(currentPeriod, group, daysLeft);
+      const title = `${prevPeriod} 月报已逾期,请补报(${group})`;
       notifications.push({
-        ywid: notifyYwid('reminder', currentPeriod, group, String(reminderTbtx)),
-        time: formatDateTime(now),
-        type: '填报提醒',
+        ywid: notifyYwid('overdue', prevPeriod, group),
+        time: formatDateTime(prevDeadline),
+        type: '逾期催办',
         title,
         content: title,
-        timestamp: now,
+        timestamp: prevDeadline.getTime(),
         group,
-        tzlx: NOTIFY_TZLX.REMINDER,
-        tbtx: reminderTbtx,
+        tzlx: NOTIFY_TZLX.OVERDUE,
+        tbtx: 0,
         shjg: 0,
         route: buildRoute('archive'),
       });
     });
   }
 
-  // ── 逾期催办 ──────────────────────────────────────────────
-  const prevPeriod = getPreviousPeriod(currentPeriod);
-  const prevDeadline = getPeriodDeadlineDate(prevPeriod);
-  if (now > prevDeadline.getTime()) {
-    GROUP_ORDER.forEach((group) => {
-      if (currentGroup && group !== currentGroup) return;
-
-      if (groupHasApproved(approvedList, group, prevPeriod)) return;
-
-      const rejected = groupHasRejected(rejectedList, group, prevPeriod);
-      const hasAny = groupHasAnySubmission(
-        [pendingList, approvedList, rejectedList],
-        group,
-        prevPeriod
-      );
-
-      if (!hasAny) {
-        const title = `${prevPeriod} 月报已逾期,请补报(${group})`;
-        notifications.push({
-          ywid: notifyYwid('overdue', prevPeriod, group),
-          time: formatDateTime(prevDeadline),
-          type: '逾期催办',
-          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({
-          ywid: notifyYwid('overdue-reject', prevPeriod, group),
-          time: formatDateTime(now),
-          type: '逾期催办',
-          title,
-          content: title,
-          timestamp: now,
-          group,
-          tzlx: NOTIFY_TZLX.OVERDUE,
-          tbtx: 0,
-          shjg: 0,
-          route: buildRoute('archive', { sub: 'history', group, period: prevPeriod, status: '已驳回' }),
-        });
-      }
-    });
-  }
+  return notifications.sort((a, b) => b.timestamp - a.timestamp);
+};
 
-  // ── 租约到期/预警 ─────────────────────────────────────────
-  const expiryItems = approvedList
-    .filter((item) => item.status === 'red' || item.status === 'orange')
-    .slice(0, 8);
+/**
+ * 填报期提醒:仅前端展示,不入库;本月无已通过则提醒,通过后自动消失
+ */
+export const buildEphemeralReminders = ({
+  approvedList = [],
+} = {}) => {
+  const currentPeriod = getCurrentPeriod();
+  const now = Date.now();
 
-  expiryItems.forEach((item) => {
-    const label = item.c_bh || item.c_qymc || '资产';
-    const withinOneMonth = item.status === 'red';
-    const group = normalizeGroupName(item.c_ssqpjt) || '未知';
-    if (currentGroup && group !== currentGroup) return;
+  return GROUP_ORDER.flatMap((group) => {
+    if (groupHasApproved(approvedList, group, currentPeriod)) return [];
 
-    const title = `${label} 租约${withinOneMonth ? '将于一个月内到期' : '将于三个月内到期'},请关注`;
-    notifications.push({
-      ywid: `expiry:${item.id}`,
-      time: formatDateTime(item.update_time || now),
-      type: withinOneMonth ? '租约到期' : '租约预警',
+    const title = `${currentPeriod} 月报尚未通过审核,请尽快填报(${group})`;
+    return [{
+      id: `ephemeral:reminder:${currentPeriod}:${group}`,
+      ywid: `ephemeral:reminder:${currentPeriod}:${group}`,
+      time: formatDateTime(now),
+      timestamp: now,
+      type: '填报提醒',
       title,
       content: title,
-      timestamp: toTimestamp(item.update_time || now),
+      read: false,
+      deleted: false,
+      ephemeral: true,
       group,
-      tzlx: withinOneMonth ? NOTIFY_TZLX.LEASE_EXPIRY : NOTIFY_TZLX.LEASE_WARNING,
+      tzlx: NOTIFY_TZLX.REMINDER,
       tbtx: 0,
       shjg: 0,
-      route: buildRoute('assets'),
-    });
+      route: buildRoute('archive'),
+    }];
   });
-
-  return notifications.sort((a, b) => b.timestamp - a.timestamp);
 };

+ 0 - 6
src/utils/notificationFields.js

@@ -191,7 +191,6 @@ export const buildNotificationResyncPayload = (existing, generated, patch = {})
       ywid: generated.ywid,
     },
     {
-      deleted: false,
       pushed: true,
       read: patch.read !== undefined ? patch.read : existing.read,
       ...patch,
@@ -201,10 +200,5 @@ export const buildNotificationResyncPayload = (existing, generated, 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));