Pārlūkot izejas kodu

Merge branch 'master' of http://47.103.92.60:3003/skyversation/guoziwei_ui.git

# Conflicts:
#	public/config.js
wdq 2 mēneši atpakaļ
vecāks
revīzija
fd1465a51d
45 mainītis faili ar 2142 papildinājumiem un 770 dzēšanām
  1. 1 0
      jsconfig.json
  2. 11 1
      public/config.js
  3. 1 5
      src/api/enterpriseList.js
  4. 68 0
      src/api/notification.js
  5. 7 1
      src/components/base/BaseButton.vue
  6. 93 47
      src/components/base/BaseSelect.vue
  7. 1 1
      src/components/chart/GroupAreaCompareBarChart.vue
  8. 2 2
      src/components/chart/PieChart.vue
  9. 64 41
      src/components/layout/NotificationModal.vue
  10. 3 18
      src/components/layout/PageHeader.vue
  11. 7 0
      src/components/manage/asset/BatchEditModal.vue
  12. 1 1
      src/components/manage/asset/BatchUploadModal.vue
  13. 83 43
      src/components/manage/asset/FullAssetListPanel.vue
  14. 34 17
      src/components/manage/asset/PickAssetModal.vue
  15. 42 29
      src/components/manage/pages/ManageBasicArchive.vue
  16. 21 38
      src/components/manage/pages/ManageBasicReport.vue
  17. 49 39
      src/components/manage/pages/ManageFinanceReport.vue
  18. 28 7
      src/components/manage/pages/ManageHistoryQuery.vue
  19. 21 7
      src/components/stats/ChartSpecial.vue
  20. 98 32
      src/components/stats/ComplianceAssetTable.vue
  21. 8 1
      src/components/stats/FullDataList.vue
  22. 1 1
      src/components/stats/MonthlyReport.vue
  23. 53 14
      src/components/stats/QueryFilter.vue
  24. 38 36
      src/components/stats/TopicFullDataListSection.vue
  25. 2 0
      src/config.js
  26. 0 2
      src/main.js
  27. 16 3
      src/pages/AssetDetailPage.vue
  28. 120 50
      src/pages/HomePage.vue
  29. 23 5
      src/pages/ManagePage.vue
  30. 32 25
      src/pages/Topic1Page.vue
  31. 47 12
      src/pages/Topic2Page.vue
  32. 82 19
      src/pages/Topic3Page.vue
  33. 17 0
      src/services/reportData.js
  34. 84 30
      src/store/enterprise.js
  35. 1 2
      src/store/filter.js
  36. 262 77
      src/store/notification.js
  37. 22 0
      src/utils/aggregateReportRecords.js
  38. 15 1
      src/utils/assetDetailFields.js
  39. 101 0
      src/utils/assetHistoryRecord.js
  40. 135 141
      src/utils/buildNotifications.js
  41. 27 1
      src/utils/fullAssetList.js
  42. 2 21
      src/utils/manageContext.js
  43. 204 0
      src/utils/notificationFields.js
  44. 161 0
      src/utils/statsComparison.js
  45. 54 0
      src/utils/topic1ChartNavigation.js

+ 1 - 0
jsconfig.json

@@ -3,6 +3,7 @@
     "target": "es5",
     "module": "esnext",
     "baseUrl": "./",
+    "ignoreDeprecations":"6.0",
     "moduleResolution": "node",
     "paths": {
       "@/*": [

+ 11 - 1
public/config.js

@@ -1,7 +1,17 @@
 window.__APP_CONFIG__ = {
+  // aly
+  // SERVER_URL: 'http://10.235.245.174:10081/dms',
+  // LOGIN_URL: 'http://121.43.55.7:10086',
+  // MODEL_ID: 1818,
+  // COLUMN_ID: 1764,
+  // NOTIFICATION_MODEL_ID: 1913,
+  // NOTIFICATION_COLUMN_ID: 1815,
+
+  // zww
   SERVER_URL: 'http://10.235.245.174:10081/dms',
-  // SERVER_URL: 'http://121.43.55.7:2101',
   LOGIN_URL: 'http://10.235.245.174:8888',
   MODEL_ID: 2289,
   COLUMN_ID: 3022,
+  NOTIFICATION_MODEL_ID: 2291,
+  NOTIFICATION_COLUMN_ID: 3023
 };

+ 1 - 5
src/api/enterpriseList.js

@@ -4,7 +4,6 @@ import { COLUMN_ID, MODEL_ID } from '@/config';
 
 export const getEnterprise = (FormData) => {
   return service({
-    // url: '/proxy_dms/content/selectContentList',
     url: '/content/selectContentList',
     method: 'POST',
     data: FormData
@@ -13,7 +12,6 @@ export const getEnterprise = (FormData) => {
 
 export const updateAudit = (data) => {
   return service({
-    // url: '/proxy_dms/content/updateAudit',
     url: '/content/updateAudit',
     method: 'POST',
     data,
@@ -26,7 +24,6 @@ export const updateContent = (data) => {
     payload.content = JSON.stringify(payload.content);
   }
   return service({
-    // url: '/proxy_dms/content/updateContent',
     url: '/content/updateContent',
     method: 'POST',
     data: payload,
@@ -39,7 +36,6 @@ const addContent = (data) => {
     payload.content = JSON.stringify(payload.content);
   }
   return service({
-    // url: '/proxy_dms/content/addContent',
     url: '/content/addContent',
     method: 'POST',
     data: payload,
@@ -85,4 +81,4 @@ export async function submitAssetEdit(item, patch, { auditorComment = '修改送
   });
 
   return contentBody;
-}
+}

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

+ 7 - 1
src/components/base/BaseButton.vue

@@ -22,7 +22,7 @@ const props = defineProps({
   variant: {
     type: String,
     default: 'default',
-    validator: (v) => ['default', 'primary', 'warning', 'outline'].includes(v),
+    validator: (v) => ['default', 'primary', 'warning', 'outline', 'danger'].includes(v),
   },
   size: {
     type: String,
@@ -79,6 +79,12 @@ const rootClass = computed(() => [
   border: 1px solid rgba(255, 204, 51, 0.6);
 }
 
+.nav-tab-item--danger {
+  background-color: #fff;
+  color: rgba(220, 50, 30, 1);
+  border-color: rgba(220, 50, 30, 0.4);
+}
+
 .nav-tab-item--outline {
   background: var(--color-surface);
   border: 1px solid var(--color-input-border);

+ 93 - 47
src/components/base/BaseSelect.vue

@@ -1,49 +1,78 @@
 <template>
   <div class="filter-group">
-    <label class="filter-label">{{ text }}</label>
-    <div class="filter-select" @click="handleSelect">
-      <span class="filter-select-text">{{ selectedText }}</span>
-      <span class="filter-select-arrow"><img src="@/assets/images/Vector.png"></span>
-    </div>
+    <label v-if="text" class="filter-label">{{ text }}</label>
+    <div class="filter-select-wrap" :style="selectWrapStyle">
+      <div class="filter-select" @click="handleSelect">
+        <span class="filter-select-text" :title="displayText">{{ displayText }}</span>
+        <span class="filter-select-arrow"><img src="@/assets/images/Vector.png" alt=""></span>
+      </div>
 
-    <div
-      class="filter-select-options"
-      v-if="showOptions"
-      :style="optionsPanelStyle"
-      @mouseleave="handleOptionMouseLeave"
-    >
-      <div class="filter-select-option" v-for="option in options" :key="option.value" @click="handleOptionClick(option)">
-        <span class="filter-select-option-text">{{ option.text }}</span>
+      <div
+        class="filter-select-options"
+        v-if="showOptions"
+        :style="optionsPanelStyle"
+        @mouseleave="handleOptionMouseLeave"
+      >
+        <div
+          class="filter-select-option"
+          v-for="option in options"
+          :key="option.value"
+          :title="option.text"
+          @click="handleOptionClick(option)"
+        >
+          <span class="filter-select-option-text">{{ option.text }}</span>
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { defineProps, ref, defineEmits, watch, computed } from 'vue'
-const emit = defineEmits(['selected'])
-const showOptions = ref(false)
+import { defineProps, ref, defineEmits, watch, computed } from 'vue';
+
+const emit = defineEmits(['selected']);
+const showOptions = ref(false);
 
 const props = defineProps({
   text: {
     type: String,
+    default: '',
   },
   selectedText: {
     type: String,
+    default: '',
   },
   options: {
     type: Array,
     default: () => [],
   },
+  /** 下拉框固定宽度,数字为 px */
+  selectWidth: {
+    type: [Number, String],
+    default: 128,
+  },
   /** 下拉选项区最大高度,超出滚动 */
   maxOptionsHeight: {
     type: [Number, String],
     default: 0,
   },
-})
+});
 
-const selectedText = ref(props.selectedText)
-const options = ref(props.options)
+const selectedText = ref(props.selectedText);
+const options = ref(props.options);
+
+const resolveWidth = (val) => (typeof val === 'number' ? `${val}px` : val);
+
+const selectWrapStyle = computed(() => {
+  const width = resolveWidth(props.selectWidth);
+  return {
+    width,
+    minWidth: width,
+    maxWidth: width,
+  };
+});
+
+const displayText = computed(() => props.selectedText || selectedText.value || '');
 
 const optionsPanelStyle = computed(() => {
   if (!props.maxOptionsHeight) return undefined;
@@ -51,21 +80,21 @@ const optionsPanelStyle = computed(() => {
     ? `${props.maxOptionsHeight}px`
     : props.maxOptionsHeight;
   return { maxHeight: height };
-})
+});
 
 const handleSelect = () => {
-  showOptions.value = !showOptions.value
-}
+  showOptions.value = !showOptions.value;
+};
 
 const handleOptionMouseLeave = () => {
-  showOptions.value = false
-}
+  showOptions.value = false;
+};
 
 const handleOptionClick = (option) => {
-  selectedText.value = option.text
-  showOptions.value = false
-  emit('selected', option.value)
-}
+  selectedText.value = option.text;
+  showOptions.value = false;
+  emit('selected', option.value);
+};
 
 watch(() => props.selectedText, (newVal) => {
   selectedText.value = newVal;
@@ -79,14 +108,14 @@ watch(() => props.options, (newVal) => {
 <style scoped>
 .filter-group {
   height: 36px;
-  position: relative; 
+  position: relative;
   display: flex;
   align-items: center;
-  gap: 8.01px;
+  gap: 8px;
+  flex-shrink: 0;
 }
 
 .filter-label {
-  /* width: 59px; */
   height: 16px;
   font-family: var(--font-regular);
   font-weight: 400;
@@ -96,58 +125,70 @@ watch(() => props.options, (newVal) => {
   vertical-align: middle;
   color: var(--color-text-primary);
   white-space: nowrap;
+  flex-shrink: 0;
+}
+
+.filter-select-wrap {
+  position: relative;
+  flex-shrink: 0;
 }
 
 .filter-select {
+  width: 100%;
   height: 36px;
   border-radius: var(--radius-sm);
-  border-width: 1px;
   padding: 8px 12px;
   gap: 8px;
   background: var(--color-surface);
   border: 1px solid var(--color-input-border);
   display: flex;
   align-items: center;
-  justify-content: center;
+  justify-content: space-between;
+  box-sizing: border-box;
   cursor: pointer;
 }
 
-.filter-select-text{
+.filter-select-text {
+  flex: 1;
+  min-width: 0;
   height: 16px;
   font-family: var(--font-regular);
   font-weight: 400;
   font-size: 14px;
   line-height: 16px;
-  letter-spacing: 0px;
-  vertical-align: middle;
+  letter-spacing: 0;
   color: var(--color-text-body);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
-.filter-select-arrow{
+.filter-select-arrow {
   width: 18px;
   height: 18px;
   display: flex;
   align-items: center;
-  justify-content: center; 
+  justify-content: center;
+  flex-shrink: 0;
 }
 
-.filter-select-options{
+.filter-select-options {
   position: absolute;
   top: calc(100% + 4px);
   left: 0;
-  min-width: 100%;
-  width: max-content;
-  max-width: 280px;
+  width: 100%;
+  box-sizing: border-box;
   background: var(--color-surface);
   border: 1px solid var(--color-input-border);
   border-radius: var(--radius-sm);
   padding: 4px 0;
   z-index: 1000;
   overflow-y: auto;
+  overflow-x: hidden;
   box-shadow: var(--shadow-dropdown);
 }
 
-.filter-select-option{
+.filter-select-option {
   min-height: 36px;
   border-radius: 0;
   padding: 8px 12px;
@@ -156,6 +197,7 @@ watch(() => props.options, (newVal) => {
   display: flex;
   align-items: center;
   cursor: pointer;
+  min-width: 0;
 }
 
 .filter-select-option:last-child {
@@ -166,14 +208,18 @@ watch(() => props.options, (newVal) => {
   background: var(--color-accent-hover);
 }
 
-.filter-select-option-text{
+.filter-select-option-text {
+  width: 100%;
+  min-width: 0;
   height: 16px;
   font-family: var(--font-regular);
   font-weight: 400;
   font-size: 14px;
   line-height: 16px;
-  letter-spacing: 0px;
-  vertical-align: middle;
+  letter-spacing: 0;
   color: var(--color-text-body);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
-</style>
+</style>

+ 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.selectDate)
 );
 
 const getUsedAreaSqm = (item) =>

+ 2 - 2
src/components/chart/PieChart.vue

@@ -128,7 +128,7 @@ const buildTopic1LegendItems = (all) => {
     })),
     {
       key: '__legend_other__',
-      name: '其他',
+      name: '更多',
       legendKind: 'other',
       color: LEGEND_OTHER_COLOR,
       percent: tailPercent,
@@ -165,7 +165,7 @@ const buildIndexLegendFromHeadCount = (all, headCount) => {
     })),
     {
       key: '__legend_other__',
-      name: '其他',
+      name: '更多',
       legendKind: 'other',
       color: LEGEND_OTHER_COLOR,
       percent: tailPercent,

+ 64 - 41
src/components/layout/NotificationModal.vue

@@ -24,18 +24,26 @@
               v-if="displayList.length > 0"
               text="全部删除"
               size="sm"
-              variant="outline"
-              class="notify-btn--danger"
+              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 }">
@@ -78,12 +86,13 @@
               </span>
               <span :style="{ width: notifyColumns[5].width }" @click.stop>
                 <BaseButton
+                  v-if="!item.ephemeral"
                   text="删除"
                   size="sm"
-                  variant="outline"
-                  class="notify-btn--danger"
+                  variant="danger"
                   @click="handleDeleteOne(item.id)"
                 />
+                <span v-else class="muted">—</span>
               </span>
             </div>
           </div>
@@ -98,14 +107,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: {
@@ -117,11 +124,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' },
@@ -144,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',
   }))
@@ -161,43 +168,57 @@ watch(unreadCount, (count) => {
   emit('unread-change', count);
 }, { immediate: true });
 
-/** 拉取最新数据并生成通知(不对集团做过滤,保证全集团通知都持久化) */
-const refresh = async () => {
-  loading.value = true;
+const refreshNotifications = 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();
+  } catch (e) {
+    showError(e?.message || e?.msg || '通知加载失败,请稍后重试');
   }
 };
 
+/** 打开弹窗:优先展示缓存,后台轻量同步(不强制拉全量资产) */
+const syncOnOpen = refreshNotifications;
+
+/** 手动刷新:强制拉全量资产并同步 */
+const refreshFull = refreshNotifications;
+
 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) => {
@@ -206,9 +227,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);
@@ -218,7 +239,7 @@ const handleRowClick = (item) => {
 watch(
   () => props.visible,
   (open) => {
-    if (open) refresh();
+    if (open) syncOnOpen();
   }
 );
 </script>
@@ -283,11 +304,6 @@ watch(
   flex-shrink: 0;
 }
 
-.notify-btn--danger {
-  color: rgba(220, 50, 30, 1);
-  border-color: rgba(220, 50, 30, 0.4);
-}
-
 .notify-empty {
   min-height: 120px;
   display: flex;
@@ -297,6 +313,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);

+ 3 - 18
src/components/layout/PageHeader.vue

@@ -8,6 +8,7 @@
           text="当前集团"
           :selected-text="filterStore.selectManageGroup"
           :options="manageGroupOptions"
+          :select-width="128"
           :max-options-height="320"
           @selected="filterStore.setManageGroup"
         />
@@ -49,16 +50,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 +90,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().catch(() => {});
 });
 </script>
 
@@ -155,7 +145,6 @@ onMounted(async () => {
 }
 
 .manage-context__group :deep(.filter-select) {
-  min-width: 120px;
   background: var(--color-accent-soft);
   border-color: var(--color-accent-border-strong);
 }
@@ -217,9 +206,5 @@ onMounted(async () => {
   .manage-context {
     gap: 10px;
   }
-
-  .manage-context__group :deep(.filter-select) {
-    min-width: 100px;
-  }
 }
 </style>

+ 7 - 0
src/components/manage/asset/BatchEditModal.vue

@@ -10,6 +10,7 @@
             <BaseSelect
               :selected-text="selectedFieldLabel"
               :options="fieldSelectOptions"
+              select-width="100%"
               :max-options-height="280"
               @selected="selectedFieldKey = $event"
             />
@@ -183,6 +184,12 @@ const handleSubmit = () => {
   display: none;
 }
 
+.batch-edit-form__field--select :deep(.filter-select-wrap) {
+  width: 100%;
+  min-width: 0;
+  max-width: none;
+}
+
 .batch-edit-form__field--select :deep(.filter-select) {
   width: 100%;
   justify-content: space-between;

+ 1 - 1
src/components/manage/asset/BatchUploadModal.vue

@@ -4,7 +4,7 @@
       <div class="batch-upload-modal" role="dialog" aria-modal="true" aria-labelledby="batch-upload-title">
         <h3 id="batch-upload-title" class="batch-upload-modal__title">上传批量档案</h3>
         <p class="batch-upload-modal__desc">
-          请使用「下载档案批量模板」填写的 Excel 文件,表头须与模板一致。
+          请使用「下载档案批量模板」填写的 Excel 文件,表头须与模板一致。已有资产编码将不会重复添加。
         </p>
 
         <div class="batch-upload-form">

+ 83 - 43
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="时间维度(列表筛选)" />
@@ -10,7 +10,6 @@
           :options="fillMonthOptions"
           @selected="filterFillMonth = $event"
         />
-        <BaseButton text="查询" @click="handleQuery" />
       </div>
     </div>
 
@@ -37,6 +36,7 @@
           text="房屋用途"
           :selectedText="filterUsage"
           :options="fwUsageOptions"
+          :select-width="168"
           :max-options-height="240"
           @selected="filterUsage = $event"
         />
@@ -46,7 +46,6 @@
           :options="CERT_FILTER_OPTIONS"
           @selected="filterCert = $event"
         />
-        <BaseButton text="查询" @click="handleQuery" />
         <BaseButton text="重置" variant="outline" @click="resetFilters" />
       </div>
     </div>
@@ -58,7 +57,7 @@
 
       <div v-if="listLoading" class="pin-table__empty">加载中…</div>
       <div v-else class="pin-table-wrap">
-        <div class="pin-table-scroll" :class="{ 'pin-table-scroll--visible-scrollbar': showScrollbar }">
+        <div ref="headScrollRef" class="pin-table-head-scroll">
           <div class="pin-table">
             <div class="pin-table__head">
               <div
@@ -84,6 +83,14 @@
                 {{ col.label }}
               </div>
             </div>
+          </div>
+        </div>
+        <div
+          class="pin-table-scroll pin-table-scroll--body"
+          :class="{ 'pin-table-scroll--visible-scrollbar': showScrollbar }"
+          @scroll="syncTableScroll"
+        >
+          <div class="pin-table">
             <div v-for="item in pagedAssets" :key="item.id" class="pin-table__row">
               <div
                 v-if="selectable"
@@ -133,7 +140,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';
@@ -181,6 +190,7 @@ const props = defineProps({
 const emit = defineEmits(['update:selectedIds']);
 
 const enterpriseStore = useEnterpriseStore();
+const route = useRoute();
 const router = useRouter();
 
 const keyword = ref('');
@@ -189,14 +199,17 @@ const filterState = ref('全部');
 const filterUsage = ref('全部');
 const filterCert = ref('全部');
 const filterFillMonth = ref('全部');
-const appliedKeyword = ref('');
-const appliedGroup = ref('全部');
-const appliedState = ref('全部');
-const appliedUsage = ref('全部');
-const appliedCert = ref('全部');
-const appliedFillMonth = ref('全部');
 const currentPage = ref(1);
 const selectAllRef = ref(null);
+const headScrollRef = ref(null);
+
+const syncTableScroll = (event) => {
+  const head = headScrollRef.value;
+  const scrollLeft = event.target.scrollLeft;
+  if (head && head.scrollLeft !== scrollLeft) {
+    head.scrollLeft = scrollLeft;
+  }
+};
 
 const allAssets = computed(() => enterpriseStore.approvedList.map(normalizeAsset));
 
@@ -221,12 +234,12 @@ const listLoading = computed(() => enterpriseStore.approvedLoading);
 
 const filteredAssets = computed(() =>
   filterAssets(allAssets.value, {
-    keyword: appliedKeyword.value,
-    group: appliedGroup.value,
-    state: appliedState.value,
-    usage: appliedUsage.value,
-    cert: appliedCert.value,
-    fillMonth: appliedFillMonth.value,
+    keyword: keyword.value,
+    group: filterGroup.value,
+    state: filterState.value,
+    usage: filterUsage.value,
+    cert: filterCert.value,
+    fillMonth: filterFillMonth.value,
     extractFillMonth,
   })
 );
@@ -283,16 +296,6 @@ watch([allPageSelected, somePageSelected], () => {
   }
 });
 
-const handleQuery = () => {
-  appliedKeyword.value = keyword.value;
-  appliedGroup.value = filterGroup.value;
-  appliedState.value = filterState.value;
-  appliedUsage.value = filterUsage.value;
-  appliedCert.value = filterCert.value;
-  appliedFillMonth.value = filterFillMonth.value;
-  currentPage.value = 1;
-};
-
 const resetFilters = () => {
   keyword.value = '';
   filterGroup.value = '全部';
@@ -300,15 +303,15 @@ const resetFilters = () => {
   filterUsage.value = '全部';
   filterCert.value = '全部';
   filterFillMonth.value = '全部';
-  appliedKeyword.value = '';
-  appliedGroup.value = '全部';
-  appliedState.value = '全部';
-  appliedUsage.value = '全部';
-  appliedCert.value = '全部';
-  appliedFillMonth.value = '全部';
   currentPage.value = 1;
 };
 
+// 筛选条件变化时回到第 1 页,避免当前页超出范围
+watch(
+  () => [keyword.value, filterGroup.value, filterState.value, filterUsage.value, filterCert.value, filterFillMonth.value],
+  () => { currentPage.value = 1; }
+);
+
 const prevPage = () => {
   if (currentPage.value > 1) currentPage.value -= 1;
 };
@@ -347,12 +350,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;
 });
@@ -423,12 +450,25 @@ watch(totalPages, (pages) => {
   width: 100%;
   border: 1px solid var(--color-divider);
   border-radius: var(--radius-sm);
+  display: flex;
+  flex-direction: column;
+}
+
+.pin-table-head-scroll {
+  width: 100%;
   overflow: hidden;
+  flex-shrink: 0;
+  border-bottom: 1px solid var(--color-divider);
+  /* 与 body 区 scrollbar-gutter 对齐,避免表头/数据列错位 */
+  padding-right: 8px;
+  box-sizing: border-box;
 }
 
-.pin-table-scroll {
+.pin-table-scroll--body {
   width: 100%;
-  overflow-x: auto;
+  max-height: min(720px, calc(100vh - 280px));
+  overflow: auto;
+  scrollbar-gutter: stable;
 }
 
 .pin-table-scroll--visible-scrollbar {
@@ -461,10 +501,6 @@ watch(totalPages, (pages) => {
   padding: 8px 6px;
 }
 
-.pin-table__cell--head.pin-table__cell--select {
-  z-index: 6;
-}
-
 .pin-table__row .pin-table__cell--select {
   z-index: 4;
 }
@@ -522,11 +558,11 @@ watch(totalPages, (pages) => {
 .pin-table__cell--head {
   align-items: center;
   white-space: nowrap;
-  overflow: hidden;
+  overflow: visible;
   text-overflow: ellipsis;
   min-height: 42px;
+  height: 42px;
   background: var(--color-table-head);
-  z-index: 2;
 }
 
 .pin-table__row .pin-table__cell:not(.pin-table__cell--head) {
@@ -551,6 +587,10 @@ watch(totalPages, (pages) => {
   z-index: 5;
 }
 
+.pin-table__cell--head.pin-table__cell--select {
+  z-index: 6;
+}
+
 .pin-table__cell--left-last {
   box-shadow: 6px 0 8px -6px rgba(17, 17, 17, 0.12);
 }

+ 34 - 17
src/components/manage/asset/PickAssetModal.vue

@@ -10,7 +10,7 @@
             <input
               v-model="keyword"
               type="search"
-              placeholder="企业 / 编号"
+              placeholder="编号 / 地址"
             />
           </label>
         </div>
@@ -28,7 +28,8 @@
               <span style="width: 140px">编号</span>
               <span style="width: 120px">集团</span>
               <span style="width: 180px">名称</span>
-              <span style="width: 140px">建筑面积㎡</span>
+              <span style="width: 140px">坐落具体地址</span>
+              <span style="width: 140px">操作</span>
             </div>
             <div v-if="loading" class="pick-modal__empty">加载中…</div>
             <div v-else-if="displayRows.length === 0" class="pick-modal__empty">暂无匹配资产</div>
@@ -49,7 +50,10 @@
               <span style="width: 140px">{{ row.code }}</span>
               <span style="width: 120px">{{ row.group }}</span>
               <span style="width: 180px">{{ row.name }}</span>
-              <span style="width: 140px">{{ row.buildArea }}</span>
+              <span style="width: 140px">{{ row.address }}</span>
+              <span style="width: 140px">
+                <BaseButton text="删除" variant="danger" @click="handleDelete(row)" />
+              </span>
             </div>
           </div>
         </div>
@@ -64,12 +68,13 @@
 </template>
 
 <script setup>
-import { showWarning } from '@/utils/message';
-import { computed, ref, watch } from 'vue';
+import { showConfirm, showError, showSuccess, showWarning } from '@/utils/message';
+import { computed, ref, watch, defineProps, defineEmits } from 'vue';
 import BaseButton from '@/components/base/BaseButton.vue';
 import { useEnterpriseStore, REPORT_STATE } from '@/store/enterprise';
 import { assetBelongsToManageScope } from '@/utils/manageContext';
-import { defineProps, defineEmits } from 'vue';
+import { updateAudit } from '@/api/enterpriseList';
+import { COLUMN_ID } from '@/config';
 
 const props = defineProps({
   visible: {
@@ -81,11 +86,6 @@ const props = defineProps({
     type: Array,
     default: () => [],
   },
-  /** 顶栏当前企业,用于限定可选资产范围 */
-  currentEnterprise: {
-    type: String,
-    default: '',
-  },
 });
 
 const emit = defineEmits(['update:visible', 'confirm']);
@@ -113,13 +113,11 @@ const pickableList = computed(() => {
   return list;
 });
 
-const formatArea = (val) => Number(val || 0).toLocaleString('zh-CN');
-
 const normalizeCode = (raw) => String(raw ?? '').trim().toLowerCase();
 
 const matchKeyword = (item, kw) => {
   const code = normalizeCode(item.c_bh);
-  const enterprise = String(item.c_qymc ?? item.c_ssjt ?? '').trim().toLowerCase();
+  const address = String(item.c_zljtdz ?? '').trim().toLowerCase();
 
   if (/^\d+$/.test(kw)) {
     if (kw.length === 6) {
@@ -128,12 +126,12 @@ const matchKeyword = (item, kw) => {
     return code.startsWith(kw);
   }
 
-  return code.includes(kw) || enterprise.includes(kw);
+  return code.includes(kw) || address.includes(kw);
 };
 
 const scopedList = computed(() =>
   pickableList.value.filter((item) =>
-    assetBelongsToManageScope(item, props.currentEnterprise)
+    assetBelongsToManageScope(item)
   )
 );
 
@@ -152,7 +150,7 @@ const displayRows = computed(() => {
         code: item.c_bh || '暂无',
         group: (item.c_ssjt ?? '').trim() || '暂无',
         name: item.c_qymc || '暂无',
-        buildArea: formatArea(item.c_sjjzmj),
+        address: item.c_zljtdz || '暂无',
         raw: item,
         inQueue: props.excludeIds.includes(rowKey),
       };
@@ -182,6 +180,25 @@ const toggleSelectAll = (event) => {
   }
 };
 
+const handleDelete = async (row) => {
+  const confirmed = await showConfirm('确定删除该资产吗?');
+  if (!confirmed) return;
+  try {
+    await updateAudit({
+      id: row.raw.id,
+      columnId: COLUMN_ID,
+      state: 4,
+    });
+    showSuccess('资产已删除');
+    await Promise.all([
+      enterpriseStore.fetchList(true),
+      enterpriseStore.fetchByState(REPORT_STATE.REJECTED, { force: true }),
+    ]);
+  } catch (e) {
+    showError(e?.message || e?.msg || '删除失败,请稍后重试');
+  }
+};
+
 const handleCancel = () => {
   emit('update:visible', false);
 };

+ 42 - 29
src/components/manage/pages/ManageBasicArchive.vue

@@ -154,7 +154,7 @@
             <span>填报人</span>
             <input v-model="histReporter" type="text" placeholder="姓名" />
           </label>
-          <BaseButton text="查询" @click="handleHistQuery" />
+          <BaseButton text="重置" variant="outline" @click="resetHistoryFilters" />
           <BaseButton text="导出往期上报报表" variant="outline" @click="handleHistExport" />
         </div>
       </div>
@@ -188,7 +188,6 @@
     <PickAssetModal
       v-model:visible="showPickModal"
       :exclude-ids="queueIds"
-      :current-enterprise="currentEnterprise"
       @confirm="handlePickConfirm"
     />
     <MonthlyEditModal
@@ -204,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';
@@ -214,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';
@@ -235,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();
@@ -254,13 +255,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);
@@ -620,9 +621,6 @@ const statusTagType = computed(() => {
 const histPeriod = ref('全部');
 const histStatus = ref('全部');
 const histReporter = ref('');
-const appliedHistPeriod = ref('全部');
-const appliedHistStatus = ref('全部');
-const appliedHistReporter = ref('');
 
 const historyLoading = computed(
   () =>
@@ -706,7 +704,7 @@ const handlePickConfirm = (pickedItems) => {
   pickedItems.forEach((item) => {
     const id = String(item.id);
     if (!id || !belongsToMonthlyQueue(item)) return;
-    if (!assetBelongsToManageScope(item, currentEnterprise.value)) return;
+    if (!assetBelongsToManageScope(item)) return;
     if (monthlyQueue.value.some((r) => String(r.id) === id)) return;
     monthlyQueue.value.push(item);
     added++;
@@ -915,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);
@@ -964,13 +964,14 @@ const refreshMonthlyQueueState = () => {
   persistQueue();
 };
 
-onMounted(() => {
-  loadReportingState();
-});
-
 const loadReportingState = async ({ resetQueue = false } = {}) => {
-  await enterpriseStore.fetchReportProgress({ force: true });
-  await enterpriseStore.fetchList({ force: 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 = [];
@@ -984,6 +985,18 @@ const loadReportingState = async ({ resetQueue = false } = {}) => {
   refreshMonthlyQueueState();
 };
 
+const loadHistoryData = () => enterpriseStore.ensureReportProgress({ skipSubmitted: true });
+
+watch(
+  () => parseManageRoute(route).tab,
+  (tab) => {
+    if (tab !== 'archive') return;
+    if (monthlySub.value === 'history') loadHistoryData();
+    else loadReportingState();
+  },
+  { immediate: true },
+);
+
 const mapHistoryRecord = (record, status, statusType, auditStatus) => ({
   ...record,
   status,
@@ -1119,23 +1132,15 @@ const HISTORY_EXPORT_COLUMNS = [
 ];
 
 const filteredHistoryRows = computed(() => {
-  const kw = appliedHistReporter.value.trim();
+  const kw = histReporter.value.trim();
   return historySourceRows.value.filter((row) => {
-    if (appliedHistPeriod.value !== '全部' && row.period !== appliedHistPeriod.value) return false;
-    if (appliedHistStatus.value !== '全部' && row.status !== appliedHistStatus.value) return false;
+    if (histPeriod.value !== '全部' && row.period !== histPeriod.value) return false;
+    if (histStatus.value !== '全部' && row.status !== histStatus.value) return false;
     if (kw && !row.reporter.includes(kw)) return false;
     return true;
   });
 });
 
-const loadHistoryData = () => enterpriseStore.fetchReportProgress({ force: true });
-
-const handleHistQuery = () => {
-  appliedHistPeriod.value = histPeriod.value;
-  appliedHistStatus.value = histStatus.value;
-  appliedHistReporter.value = histReporter.value;
-};
-
 const formatReportMonthForExport = (period) => {
   const match = String(period ?? '').match(/^(\d{4})-(\d{2})$/);
   if (!match) return period ?? '';
@@ -1170,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 {
@@ -1193,9 +1208,7 @@ const applyRouteSubNav = () => {
     status && ['审核中', '已通过', '已驳回'].includes(String(status)) ? String(status) : '全部';
 
   histPeriod.value = nextPeriod;
-  appliedHistPeriod.value = nextPeriod;
   histStatus.value = nextStatus;
-  appliedHistStatus.value = nextStatus;
   loadHistoryData();
 };
 

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

+ 49 - 39
src/components/manage/pages/ManageFinanceReport.vue

@@ -17,7 +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="查询" @click="handleQuery" />
+        <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" />
@@ -29,13 +29,13 @@
           :options="auditMonthOptions"
           @selected="filterAuditMonth = $event"
         />
-        <BaseButton text="查询" @click="handleQuery" />
+        <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="查询" @click="handleQuery" />
+        <BaseButton text="重置" variant="outline" @click="resetFilters" />
       </div>
 
       <div v-if="auditTab === 'pending'" class="panel-toolbar">
@@ -57,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
@@ -72,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'"
@@ -118,6 +120,7 @@
             </template>
           </span>
         </div>
+        </template>
       </div>
     </div>
 
@@ -171,9 +174,6 @@ const auditTab = ref('pending');
 const filterGroup = ref('全部');
 const filterPeriod = ref('全部');
 const filterAuditMonth = ref('全部');
-const appliedGroup = ref('全部');
-const appliedPeriod = ref('全部');
-const appliedAuditMonth = ref('全部');
 
 const selectedIds = ref([]);
 const viewedIds = ref([]);
@@ -286,14 +286,14 @@ const pendingSourceRows = computed(() =>
 );
 
 const matchFilter = (row) => {
-  if (appliedGroup.value !== '全部' && normalizeGroupName(row.qingpuGroup) !== appliedGroup.value) return false;
-  if (appliedPeriod.value !== '全部' && row.period !== appliedPeriod.value) return false;
+  if (filterGroup.value !== '全部' && normalizeGroupName(row.qingpuGroup) !== filterGroup.value) return false;
+  if (filterPeriod.value !== '全部' && row.period !== filterPeriod.value) return false;
   return true;
 };
 
 const matchPassedFilter = (row) => {
   if (!matchFilter(row)) return false;
-  if (appliedAuditMonth.value !== '全部' && row.auditMonth !== appliedAuditMonth.value) return false;
+  if (filterAuditMonth.value !== '全部' && row.auditMonth !== filterAuditMonth.value) return false;
   return true;
 };
 
@@ -355,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;
@@ -392,20 +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.fetchByState(REPORT_STATE.PENDING);
-};
-
-const handleQuery = () => {
-  appliedGroup.value = filterGroup.value;
-  appliedPeriod.value = filterPeriod.value;
-  if (auditTab.value === 'passed') {
-    appliedAuditMonth.value = filterAuditMonth.value;
+    return enterpriseStore.ensureState(REPORT_STATE.REJECTED);
   }
+  return enterpriseStore.ensureState(REPORT_STATE.PENDING);
 };
 
 const handleView = (row) => {
@@ -555,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))
@@ -567,9 +582,13 @@ const applyRouteSubNav = () => {
     : '全部';
   const nextPeriod = period ? String(period) : '全部';
   filterGroup.value = nextGroup;
-  appliedGroup.value = nextGroup;
   filterPeriod.value = nextPeriod;
-  appliedPeriod.value = nextPeriod;
+
+  if (subChanged) {
+    selectedIds.value = [];
+    filterAuditMonth.value = '全部';
+  }
+
   loadTabData();
 };
 
@@ -579,22 +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 = '全部';
-    appliedGroup.value = '全部';
-    appliedPeriod.value = '全部';
-  }
-  filterAuditMonth.value = '全部';
-  appliedAuditMonth.value = '全部';
-  loadTabData();
-}, { immediate: true });
 </script>
 
 <style scoped>
@@ -734,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>

+ 28 - 7
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>
@@ -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);
@@ -178,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) {
@@ -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;
@@ -237,8 +258,9 @@ 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();
     }
 
     if (failCount === 0) {
@@ -263,7 +285,6 @@ const handleBatchEditConfirm = async ({ fieldKey, fieldLabel, value }) => {
 
 onMounted(() => {
   loadAssetFormDraft();
-  enterpriseStore.fetchReportProgress();
 });
 </script>
 

+ 21 - 7
src/components/stats/ChartSpecial.vue

@@ -21,16 +21,20 @@
           <SectionHeader :title="item.text" />
           <BarChart
             v-if="isSmallScreen"
+            clickable
             :xAxisData="pieToBar(item.data).xAxisData"
             :seriesData="pieToBar(item.data).seriesData"
             :style="smallBarStyle"
+            @bar-click="item.onSliceClick"
           />
           <PieChart
             v-else
+            clickable
             :data="item.data"
             :legend-head-count="item.legendHeadCount"
             :legend-grid-no-padding-right="item.legendGridNoPaddingRight"
             value-unit="万m²"
+            @slice-click="item.onSliceClick"
           />
         </div>
       </div>
@@ -54,15 +58,14 @@ 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';
+import { buildTopic1ChartQuery } from '@/utils/topic1ChartNavigation';
 
 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 +91,19 @@ const onMainBarClick = (groupName) => {
   router.push('/topic1');
 };
 
-const filteredList = computed(() => {
-  return enterpriseStore.filteredByGroup(filterStore.selectGroup)
-})
+const navigateToTopic1FromPie = (chartKey, sliceName) => {
+  const query = buildTopic1ChartQuery(chartKey, sliceName);
+  router.push(Object.keys(query).length ? { path: '/topic1', query } : '/topic1');
+};
+
+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
@@ -242,19 +251,25 @@ const pieChartList = computed(() => [
   {
     name: '图表1',
     text: '土地类型面积',
+    chartKey: 'landUsage',
     data: isSmallScreen.value ? purposeListCompact.value : purposeListFull.value,
     legendHeadCount: isSmallScreen.value ? 0 : PURPOSE_TOP_N,
+    onSliceClick: (name) => navigateToTopic1FromPie('landUsage', name),
   },
   {
     name: '图表2',
     text: '房屋使用状态',
+    chartKey: 'util',
     data: usageStatusList.value,
+    onSliceClick: (name) => navigateToTopic1FromPie('util', name),
   },
   {
     name: '图表3',
     text: '房龄分布',
+    chartKey: 'age',
     data: ageDistributionList.value,
     legendGridNoPaddingRight: true,
+    onSliceClick: (name) => navigateToTopic1FromPie('age', name),
   },
 ]);
 
@@ -266,7 +281,6 @@ const topicCardList = ref([
 
 onMounted(() => {
   mq.addEventListener('change', onBreakpointChange);
-  enterpriseStore.fetchStatisticsList();
 });
 
 onUnmounted(() => {

+ 98 - 32
src/components/stats/ComplianceAssetTable.vue

@@ -1,42 +1,55 @@
 <template>
   <div class="compliance-table" :class="{ 'compliance-table--fluid': fluid }">
-    <div class="compliance-table__header">
-      <div
-        v-for="column in columns"
-        :key="column.key"
-        class="compliance-table__cell compliance-table__cell--header"
-        :style="cellStyle(column)"
-      >
-        {{ column.label }}
+    <div :class="{ 'compliance-table__sticky-chrome': stickyChrome }">
+      <div v-if="stickyChrome" class="compliance-table__chrome-content">
+        <slot name="chrome" />
       </div>
-    </div>
-    <div class="compliance-table__body">
-      <template v-if="paginatedRows.length">
-        <div
-          v-for="(row, rowIndex) in paginatedRows"
-          :key="row.id ?? rowIndex"
-          class="compliance-table__row"
-        >
+      <div ref="headScrollRef" class="compliance-table-head-scroll">
+        <div class="compliance-table__header">
           <div
             v-for="column in columns"
             :key="column.key"
-            class="compliance-table__cell"
-            :class="{ 'compliance-table__cell--action': column.type === 'action' }"
+            class="compliance-table__cell compliance-table__cell--header"
             :style="cellStyle(column)"
           >
-            <button
-              v-if="column.type === 'action'"
-              type="button"
-              class="compliance-table__link"
-              @click="$emit('row-action', row)"
-            >
-              {{ column.actionLabel || '查看' }}
-            </button>
-            <template v-else>{{ row[column.key] ?? '暂无' }}</template>
+            {{ column.label }}
           </div>
         </div>
-      </template>
-      <div v-else class="compliance-table__empty">暂无符合条件的数据</div>
+      </div>
+    </div>
+    <div
+      class="compliance-table-body-scroll"
+      :class="{ 'compliance-table-body-scroll--sticky-chrome': stickyChrome }"
+      @scroll="syncTableScroll"
+    >
+      <div class="compliance-table__body">
+        <template v-if="paginatedRows.length">
+          <div
+            v-for="(row, rowIndex) in paginatedRows"
+            :key="row.id ?? rowIndex"
+            class="compliance-table__row"
+          >
+            <div
+              v-for="column in columns"
+              :key="column.key"
+              class="compliance-table__cell"
+              :class="{ 'compliance-table__cell--action': column.type === 'action' }"
+              :style="cellStyle(column)"
+            >
+              <button
+                v-if="column.type === 'action'"
+                type="button"
+                class="compliance-table__link"
+                @click="$emit('row-action', row)"
+              >
+                {{ column.actionLabel || '查看' }}
+              </button>
+              <template v-else>{{ row[column.key] ?? '暂无' }}</template>
+            </div>
+          </div>
+        </template>
+        <div v-else class="compliance-table__empty">暂无符合条件的数据</div>
+      </div>
     </div>
     <div class="compliance-table__pagination">
       <button type="button" :disabled="currentPage === 1" @click="prevPage">
@@ -51,7 +64,7 @@
 </template>
 
 <script setup>
-import { ref, computed, watch, defineProps, defineEmits} from 'vue';
+import { ref, computed, watch, defineProps, defineEmits } from 'vue';
 
 const props = defineProps({
   columns: {
@@ -70,10 +83,24 @@ const props = defineProps({
     type: Boolean,
     default: false,
   },
+  stickyChrome: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 defineEmits(['row-action']);
 
+const headScrollRef = ref(null);
+
+const syncTableScroll = (event) => {
+  const head = headScrollRef.value;
+  const scrollLeft = event.target.scrollLeft;
+  if (head && head.scrollLeft !== scrollLeft) {
+    head.scrollLeft = scrollLeft;
+  }
+};
+
 const cellStyle = (column) => {
   if (props.fluid) {
     if (column.type === 'action') {
@@ -137,6 +164,46 @@ watch(totalPages, (pages) => {
   gap: 4px;
 }
 
+/* 与 PageHeader 高度一致(layout/PageHeader.vue) */
+.compliance-table__sticky-chrome {
+  position: sticky;
+  top: 64px;
+  z-index: 50;
+  background: var(--color-surface);
+  display: flex;
+  flex-direction: column;
+  gap: var(--section-gap);
+  margin: 0 calc(-1 * var(--card-padding));
+  padding: 0 var(--card-padding);
+  box-shadow: 0 4px 12px rgba(17, 17, 17, 0.06);
+}
+
+.compliance-table__chrome-content {
+  display: flex;
+  flex-direction: column;
+  gap: var(--section-gap);
+}
+
+.compliance-table-head-scroll {
+  width: 100%;
+  overflow: hidden;
+  flex-shrink: 0;
+  border-bottom: 1px solid var(--color-divider);
+  padding-right: 8px;
+  box-sizing: border-box;
+}
+
+.compliance-table-body-scroll {
+  width: 100%;
+  max-height: min(720px, calc(100vh - 280px));
+  overflow: auto;
+  scrollbar-gutter: stable;
+}
+
+.compliance-table-body-scroll--sticky-chrome {
+  max-height: min(640px, calc(100vh - 380px));
+}
+
 .compliance-table__header,
 .compliance-table__row {
   display: flex;
@@ -168,7 +235,7 @@ watch(totalPages, (pages) => {
 
 .compliance-table--fluid .compliance-table__cell--header {
   white-space: nowrap;
-  overflow: hidden;
+  overflow: visible;
   text-overflow: ellipsis;
   height: 42px;
   line-height: 42px;
@@ -286,5 +353,4 @@ watch(totalPages, (pages) => {
   font-size: 14px;
   padding: 0;
 }
-
 </style>

+ 8 - 1
src/components/stats/FullDataList.vue

@@ -24,6 +24,7 @@
           text="房屋用途"
           :selected-text="fwUsageLabel"
           :options="fwUsageOptions"
+          :select-width="168"
           :max-options-height="240"
           @selected="patchFilters({ fwUsage: $event })"
         />
@@ -49,10 +50,16 @@
           text="土地类型"
           :selected-text="landTypeLabel"
           :options="landTypeOptions"
+          :select-width="168"
           :max-options-height="240"
           @selected="patchFilters({ landUsage: $event })"
         />
-        <BaseButton text="清除筛选" variant="outline" size="sm" @click="$emit('reset')" />
+        <BaseButton
+          class="filter-bar__reset"
+          text="重置"
+          variant="outline"
+          @click="$emit('reset')"
+        />
       </div>
     </template>
 

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

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

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

+ 38 - 36
src/components/stats/TopicFullDataListSection.vue

@@ -1,35 +1,36 @@
 <template>
   <div ref="rootRef" class="topic-full-list">
-    <div class="topic-full-list__head">
-      <SectionHeader :title="title" />
-      <button
-        v-if="showExport"
-        type="button"
-        class="export-btn"
-        @click="$emit('export')"
-      >
-        {{ exportLabel }}
-      </button>
-    </div>
-
-    <slot name="filters" />
-
-    <slot name="hint">
-      <p v-if="filterHint" class="filter-hint">
-        当前筛选:<strong>{{ filterHint }}</strong>
-        <template v-if="total != null"> · 共 {{ total }} 条</template>
-      </p>
-    </slot>
-
-    <div class="table-scroll">
-      <ComplianceAssetTable
-        fluid
-        :columns="columns"
-        :rows="rows"
-        :page-size="pageSize"
-        @row-action="$emit('row-action', $event)"
-      />
-    </div>
+    <ComplianceAssetTable
+      fluid
+      sticky-chrome
+      :columns="columns"
+      :rows="rows"
+      :page-size="pageSize"
+      @row-action="$emit('row-action', $event)"
+    >
+      <template #chrome>
+        <div class="topic-full-list__head">
+          <SectionHeader :title="title" />
+          <button
+            v-if="showExport"
+            type="button"
+            class="export-btn"
+            @click="$emit('export')"
+          >
+            {{ exportLabel }}
+          </button>
+        </div>
+
+        <slot name="filters" />
+
+        <slot name="hint">
+          <p v-if="filterHint" class="filter-hint">
+            当前筛选:<strong>{{ filterHint }}</strong>
+            <template v-if="total != null"> · 共 {{ total }} 条</template>
+          </p>
+        </slot>
+      </template>
+    </ComplianceAssetTable>
   </div>
 </template>
 
@@ -91,7 +92,6 @@ defineExpose({
   padding: var(--card-padding);
   display: flex;
   flex-direction: column;
-  gap: var(--section-gap);
   background: var(--color-surface);
   border: 1px solid var(--color-accent-border);
 }
@@ -104,11 +104,6 @@ defineExpose({
   flex-wrap: wrap;
 }
 
-.table-scroll {
-  width: 100%;
-  overflow-x: auto;
-}
-
 .export-btn {
   height: 38px;
   padding: 0 20px;
@@ -154,6 +149,13 @@ defineExpose({
   min-width: 240px;
 }
 
+.topic-full-list .filter-bar__reset {
+  height: 36px;
+  padding: 0 16px;
+  flex-shrink: 0;
+  align-self: center;
+}
+
 .topic-full-list .filter-hint {
   margin: 0;
   font-family: var(--font-regular);

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

+ 0 - 2
src/main.js

@@ -4,13 +4,11 @@ import router from './router'
 import { createPinia } from 'pinia'
 import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
 import '@/assets/common.css'
-import * as echarts from 'echarts'
 
 const pinia = createPinia()
 pinia.use(piniaPluginPersistedstate)
 
 const app = createApp(App)
-app.config.globalProperties.$echarts = echarts
 app.use(pinia)
 app.use(router)
 app.mount('#app')

+ 16 - 3
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 enterpriseStore.refreshReportProgress({ skipSubmitted: true });
+    await notificationStore.syncAfterReportChange();
     showSuccess('保存成功,已提交待审核。请前往「管理端 · 审核管理」查看;审核通过后全量列表才会更新。');
     router.push(buildManageRoute({ tab: 'audit', sub: 'pending' }));
   } catch (e) {
@@ -147,7 +160,7 @@ const handleExportPdf = () => {
 };
 
 onMounted(() => {
-  enterpriseStore.fetchReportProgress();
+  enterpriseStore.ensureReportProgress({ skipSubmitted: true });
 });
 </script>
 

+ 120 - 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,129 @@ 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 } 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,
+  },
+]);
+
 </script>
 
 <style scoped>

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

+ 32 - 25
src/pages/Topic1Page.vue

@@ -74,8 +74,8 @@
 
 <script setup>
 import { showWarning } from '@/utils/message';
-import { ref, onMounted, computed, watch } from 'vue';
-import { useRouter } from 'vue-router';
+import { ref, computed, watch, nextTick } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
 import PageHeader from '@/components/layout/PageHeader.vue';
 import TopicPageHeader from '@/components/stats/TopicPageHeader.vue';
 import QueryFilter from '@/components/stats/QueryFilter.vue';
@@ -93,21 +93,25 @@ import {
   mapTopic1Row,
 } from '@/utils/topic1AssetList';
 import { GROUP_DISPLAY_NAMES } from '@/utils/aggregateReportRecords';
+import {
+  AGE_SLICE_TO_KEY,
+  createDefaultTopic1ListFilters,
+  hasTopic1ChartQuery,
+  parseTopic1ListFiltersFromQuery,
+} from '@/utils/topic1ChartNavigation';
 
 const enterpriseStore = useEnterpriseStore();
 const filterStore = useFilterStore();
 const router = useRouter();
+const route = useRoute();
 const fullListRef = ref(null);
 
 const DISPLAY_TO_GROUP = Object.fromEntries(
   Object.entries(GROUP_DISPLAY_NAMES).map(([group, short]) => [short, group])
 );
 
-const AGE_SLICE_TO_KEY = {
-  '0-10年': '0-10',
-  '10-20年': '10-20',
-  '20年以上': '20+',
-};
+const createDefaultFilters = (group = filterStore.selectGroup) =>
+  createDefaultTopic1ListFilters(group);
 
 const USAGE_PIE_CONFIG = [
   { name: '出租', field: 'c_czmj', color: 'rgba(1, 118, 255, 1)' },
@@ -116,21 +120,10 @@ const USAGE_PIE_CONFIG = [
   { name: '闲置', field: 'c_xzmj', color: 'rgba(255, 232, 119, 1)' },
 ];
 
-const createDefaultFilters = () => ({
-  keyword: '',
-  purpose: '全部',
-  group: filterStore.selectGroup,
-  age: '全部',
-  util: '全部',
-  fwUsage: '',
-  landType: '',
-  landUsage: '',
-});
-
 const listFilters = ref(createDefaultFilters());
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
 );
 
 const OTHER_LABEL = '其他';
@@ -267,10 +260,27 @@ const applyListFilters = (patch) => {
 };
 
 const resetListFilters = () => {
-  listFilters.value = createDefaultFilters();
+  filterStore.setGroup('全部');
+  listFilters.value = createDefaultFilters('全部');
 };
 
-watch(() => filterStore.selectGroup, resetListFilters);
+watch(() => filterStore.selectGroup, (group) => {
+  if (hasTopic1ChartQuery(route.query)) {
+    listFilters.value = parseTopic1ListFiltersFromQuery(route.query, group);
+    return;
+  }
+  listFilters.value = createDefaultFilters(group);
+});
+
+watch(
+  () => route.fullPath,
+  () => {
+    if (!hasTopic1ChartQuery(route.query)) return;
+    listFilters.value = parseTopic1ListFiltersFromQuery(route.query, filterStore.selectGroup);
+    nextTick(() => scrollToFullList());
+  },
+  { immediate: true },
+);
 
 watch(
   () => listFilters.value.group,
@@ -379,9 +389,6 @@ const topPieChartsList = computed(() => [
   },
 ]);
 
-onMounted(() => {
-  enterpriseStore.fetchStatisticsList();
-});
 </script>
 
 <style scoped>
@@ -389,7 +396,7 @@ onMounted(() => {
   width: 100%;
   max-width: var(--page-max-width);
   padding: 24px;
-  padding-bottom: 32px;
+  padding-bottom: 0;
   display: flex;
   flex-direction: column;
   gap: 12px;

+ 47 - 12
src/pages/Topic2Page.vue

@@ -56,7 +56,7 @@
         title="权属穿透全量数据列表"
         :columns="TOPIC2_FULL_COLUMNS"
         :rows="fullRows"
-        :page-size="7"
+        :page-size="10"
         :filter-hint="filterHint"
         @export="handleExport"
         @row-action="openAssetDetail"
@@ -79,7 +79,12 @@
               :options="CERT_UI_OPTIONS"
               @selected="filterCert = $event"
             />
-            <BaseButton text="清除筛选" variant="outline" size="sm" @click="resetLocalFilters" />
+            <BaseButton
+              class="filter-bar__reset"
+              text="重置"
+              variant="outline"
+              @click="resetLocalFilters"
+            />
           </div>
         </template>
       </TopicFullDataListSection>
@@ -90,7 +95,7 @@
 <script setup>
 import { showSuccess, showWarning } from '@/utils/message';
 import { exportTableExcel } from '@/utils/exportExcel';
-import { computed, onMounted, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
 import PageHeader from '@/components/layout/PageHeader.vue';
 import TopicPageHeader from '@/components/stats/TopicPageHeader.vue';
@@ -107,6 +112,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 +143,18 @@ const groupOptions = [
 ];
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
+
+/** 各集团办证率对比:仅受统计周期影响,不受所属集团筛选影响 */
+const periodAllGroupsList = computed(() =>
+  enterpriseStore.filteredByGroup('全部', filterStore.selectDate)
+);
+
+const previousMonth = computed(() => getPreviousMonthPeriod(filterStore.selectDate));
+
+const momSnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousMonth.value)
 );
 
 const certifiedCount = computed(() =>
@@ -150,14 +173,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,
@@ -199,7 +233,7 @@ const ownershipPieData = computed(() => {
 
 const certRateChartData = computed(() =>
   GROUP_ORDER.map((group) => {
-    const items = filteredList.value.filter(
+    const items = periodAllGroupsList.value.filter(
       (item) => normalizeGroupName(item.c_ssqpjt) === group
     );
     const total = items.length;
@@ -296,8 +330,9 @@ const onGroupBarClick = (shortName) => {
 };
 
 const resetLocalFilters = () => {
+  filterStore.setGroup('全部');
   filterKeyword.value = '';
-  filterGroup.value = filterStore.selectGroup;
+  filterGroup.value = '全部';
   filterCert.value = '全部';
 };
 
@@ -326,10 +361,10 @@ const handleExport = () => {
   showSuccess(`已导出 ${result.count} 条记录(${result.filename})`);
 };
 
-watch(() => filterStore.selectGroup, resetLocalFilters);
-
-onMounted(() => {
-  enterpriseStore.fetchStatisticsList();
+watch(() => filterStore.selectGroup, (group) => {
+  filterKeyword.value = '';
+  filterGroup.value = group;
+  filterCert.value = '全部';
 });
 </script>
 

+ 82 - 19
src/pages/Topic3Page.vue

@@ -120,7 +120,7 @@
         title="闲置资产全量数据列表"
         :columns="TOPIC3_FULL_COLUMNS"
         :rows="fullRows"
-        :page-size="7"
+        :page-size="10"
         :filter-hint="filterHint"
         :total="fullRows.length"
         @export="handleExport"
@@ -142,6 +142,7 @@
               text="房屋用途"
               :selected-text="filterPurpose"
               :options="purposeOptions"
+              :select-width="168"
               :max-options-height="240"
               @selected="filterPurpose = $event"
             />
@@ -149,12 +150,14 @@
               text="闲置时长"
               :selected-text="filterIdleMonthsLabel"
               :options="IDLE_MONTHS_OPTIONS"
+              :select-width="148"
               @selected="onIdleMonthsSelect"
             />
             <BaseSelect
               text="闲置率区间"
               :selected-text="filterIdleBucketLabel"
               :options="IDLE_RATE_OPTIONS"
+              :select-width="148"
               @selected="onIdleBucketSelect"
             />
             <BaseSelect
@@ -163,7 +166,12 @@
               :options="SCOPE_OPTIONS"
               @selected="filterScope = $event"
             />
-            <BaseButton text="清除筛选" variant="outline" size="sm" @click="resetLocalFilters" />
+            <BaseButton
+              class="filter-bar__reset"
+              text="重置"
+              variant="outline"
+              @click="resetLocalFilters"
+            />
           </div>
         </template>
       </TopicFullDataListSection>
@@ -174,7 +182,7 @@
 <script setup>
 import { showSuccess, showWarning } from '@/utils/message';
 import { exportTableExcel } from '@/utils/exportExcel';
-import { computed, onMounted, ref, watch } from 'vue';
+import { computed, ref, watch } from 'vue';
 import { useRouter } from 'vue-router';
 import PageHeader from '@/components/layout/PageHeader.vue';
 import TopicPageHeader from '@/components/stats/TopicPageHeader.vue';
@@ -192,6 +200,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 +253,22 @@ const groupOptions = [
 ];
 
 const filteredList = computed(() =>
-  enterpriseStore.filteredByGroup(filterStore.selectGroup)
+  enterpriseStore.filteredByGroup(filterStore.selectGroup, filterStore.selectDate)
+);
+
+/** 闲置面积 TOP5(集团):仅受统计周期影响,不受所属集团筛选影响 */
+const periodAllGroupsList = computed(() =>
+  enterpriseStore.filteredByGroup('全部', filterStore.selectDate)
+);
+
+const periodIdleAssetList = computed(() =>
+  periodAllGroupsList.value.filter((item) => getIdleArea(item) > 0)
+);
+
+const previousMonth = computed(() => getPreviousMonthPeriod(filterStore.selectDate));
+
+const momSnapshots = computed(() =>
+  buildHistoricalSnapshots(filteredList.value, previousMonth.value)
 );
 
 const idleAssetList = computed(() =>
@@ -248,14 +282,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 +322,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 +333,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,
@@ -332,7 +390,7 @@ const durationPieData = computed(() =>
 );
 
 const idleTop5GroupChartData = computed(() => {
-  const groupMap = idleAssetList.value.reduce((acc, item) => {
+  const groupMap = periodIdleAssetList.value.reduce((acc, item) => {
     const group = (item.c_ssqpjt ?? '其他').trim() || '其他';
     acc[group] = (acc[group] ?? 0) + getIdleArea(item);
     return acc;
@@ -530,8 +588,9 @@ const onIdleBucketSelect = (value) => {
 };
 
 const resetLocalFilters = () => {
+  filterStore.setGroup('全部');
   filterKeyword.value = '';
-  filterGroup.value = filterStore.selectGroup;
+  filterGroup.value = '全部';
   filterPurpose.value = '全部';
   filterIdleMonths.value = '全部';
   filterIdleBucket.value = '全部';
@@ -564,10 +623,14 @@ const handleExport = () => {
   showSuccess(`已导出 ${result.count} 条记录(${result.filename})`);
 };
 
-watch(() => filterStore.selectGroup, resetLocalFilters);
-
-onMounted(() => {
-  enterpriseStore.fetchStatisticsList();
+watch(() => filterStore.selectGroup, (group) => {
+  filterKeyword.value = '';
+  filterGroup.value = group;
+  filterPurpose.value = '全部';
+  filterIdleMonths.value = '全部';
+  filterIdleBucket.value = '全部';
+  filterScope.value = 'idle';
+  filterEnterprise.value = '';
 });
 </script>
 

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

+ 84 - 30
src/store/enterprise.js

@@ -6,9 +6,11 @@ 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 { shouldFetchState } from '@/services/reportData';
 import { useUserStore } from '@/store/user';
 import { COLUMN_ID, MODEL_ID } from '@/config';
+import { useNotificationStore } from '@/store/notification';
 
 /** 月报 / 企业列表 states 枚举 */
 export const REPORT_STATE = {
@@ -33,6 +35,9 @@ const BASE_QUERY = {
   page: 0,
 };
 
+/** 同一 state 并发请求合并为一次 */
+const inflightByState = new Map();
+
 const createInitialListsByState = () =>
   REPORT_STATES.reduce((acc, state) => {
     acc[state] = [];
@@ -45,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: {},
   }),
@@ -59,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',
@@ -79,8 +91,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) => {
@@ -91,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 } = {}) {
@@ -120,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()) {
@@ -150,7 +215,8 @@ 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();
     },
 
     async rejectItem(item, { auditorName, auditorComment }, auditTime = Date.now()) {
@@ -182,20 +248,8 @@ export const useEnterpriseStore = defineStore('enterprise', {
       await Promise.all(
         items.map((item) => this.rejectItem(item, { auditorName, auditorComment }, auditTime))
       );
-      await this.fetchReportProgress({ force: true });
-    },
-
-    updateApprovedItem(id, patch) {
-      const list = this.listsByState[REPORT_STATE.APPROVED];
-      const index = list.findIndex(
-        (item) => String(item.id) === String(id) || String(item.c_bh || '') === String(id)
-      );
-      if (index < 0) return false;
-      this.listsByState[REPORT_STATE.APPROVED][index] = {
-        ...list[index],
-        ...patch,
-      };
-      return true;
+      await this.refreshStates([REPORT_STATE.PENDING, REPORT_STATE.REJECTED]);
+      await useNotificationStore().syncAfterReportChange();
     },
 
     getAssetEditDraft(assetId) {

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

+ 262 - 77
src/store/notification.js

@@ -1,118 +1,303 @@
 import { defineStore } from 'pinia';
-import { useUserStore } from '@/store/user';
+import {
+  addNotification,
+  getNotificationList,
+  updateNotificationContent,
+  updateNotificationResync,
+} from '@/api/notification';
+import { useEnterpriseStore } from '@/store/enterprise';
+import { buildEphemeralReminders, buildNotifications } from '@/utils/buildNotifications';
+import {
+  findNotificationItem,
+  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 loadNotificationRows = async () => {
+  const res = await getNotificationList();
+  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: () => ({
-    /** { [userKey]: notification[] } */
-    itemsByUser: {},
+    items: [],
+    /** 填报期提醒:仅前端展示,不入库 */
+    ephemeralReminders: [],
+    /** 本会话已读/已关闭的 ephemeral id */
+    ephemeralReadIds: [],
+    ephemeralDismissedIds: [],
+    /** 无缓存时阻塞展示 */
+    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) {
+    displayItemsByGroup(state) {
       return (group) => {
-        const all = (state.itemsByUser[resolveUserKey()] || []).slice();
-        if (!group) return all.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 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 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);
+        const persistedUnread = !group
+          ? all.length
+          : all.filter((item) => !item.group || item.group === group).length;
+        return ephemeralUnread + persistedUnread;
       };
     },
   },
   actions: {
-    _getItems() {
-      const key = resolveUserKey();
-      if (!this.itemsByUser[key]) {
-        this.itemsByUser = { ...this.itemsByUser, [key]: [] };
-      }
-      return this.itemsByUser[key];
+    refreshEphemeralReminders(lists) {
+      this.ephemeralReminders = buildEphemeralReminders(lists);
     },
-    _setItems(arr) {
-      const key = resolveUserKey();
-      this.itemsByUser = { ...this.itemsByUser, [key]: arr };
+
+    async fetchNotifications() {
+      this.items = await loadNotificationItems();
     },
 
     /**
-     * 合并新生成的通知到持久化存储
-     * - 已存在的(同 id)保留原样(保留已读/已删除状态)
-     * - 新增的追加到列表
+     * 持久消息同步:仅新增缺失、更新已有;不自动软删。
+     * 用户手动删除(c_sfsc=1)的记录保留删除状态,且不再重复 add。
      */
-    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 syncNotifications(lists, { bumpPending = false, existingItems } = {}) {
+      const generated = buildNotifications(lists);
+      const sourceItems = existingItems ?? this.items;
+      const existingByYwid = indexExistingByYwid(sourceItems);
+
+      const toAdd = [];
+      const toUpdate = [];
+
+      generated.forEach((item) => {
+        const ywid = item.ywid || item.id;
+        if (!ywid) return;
+
+        const existing = existingByYwid.get(ywid);
+        if (!existing) {
+          toAdd.push(item);
+          return;
+        }
+
+        // 用户已删或历史软删:不再重建,避免登录/刷新重复入库
+        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: pendingBump });
+        }
+      });
+
+      if (!toAdd.length && !toUpdate.length) return false;
+
+      await Promise.all([
+        ...toAdd.map((item) => addNotification(item)),
+        ...toUpdate.map(({ existing, item, resetRead }) =>
+          updateNotificationResync(existing, item, { resetRead })
+        ),
+      ]);
+
+      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) {
+        this.loading = true;
+      } else {
+        this.syncing = true;
+      }
+
+      try {
+        await this._syncFromStore({ bumpPending });
+      } finally {
+        this.loading = false;
+        this.syncing = false;
+      }
+    },
+
+    async refresh(options = {}) {
+      if (this._refreshPromise) {
+        return this._refreshPromise;
+      }
+      this._refreshPromise = this._runRefresh(options).finally(() => {
+        this._refreshPromise = null;
+      });
+      return this._refreshPromise;
+    },
+
+    /** 报表/审核数据变更后同步通知(调用方须已刷新 enterpriseStore) */
+    async syncAfterReportChange() {
+      if (this._refreshPromise) {
+        return this._refreshPromise;
+      }
+      this._refreshPromise = (async () => {
+        this.syncing = true;
+        try {
+          await this._syncFromStore({ bumpPending: true });
+        } 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 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 });
     },
 
-    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 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
       );
-      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 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 });
+      this.items = this.items.filter((i) => String(i.id) !== String(id));
     },
 
-    /** 删除当前用户全部通知 */
-    deleteAll() {
-      this._setItems([]);
+    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 });
+      }
+      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 ||

+ 15 - 1
src/utils/assetDetailFields.js

@@ -185,6 +185,17 @@ const formatAssetFieldValue = (val) => {
   return String(val);
 };
 
+const formatCertAcquisitionDateDisplay = (val) => {
+  if (val == null || val === '') return '—';
+  const text = String(val).trim();
+  const cnMatch = text.match(/^(\d{4})年(\d{1,2})月$/);
+  if (cnMatch) return `${cnMatch[1]}年${Number(cnMatch[2])}月`;
+  const dateInput = excelSerialToDateInput(val);
+  const match = String(dateInput).match(/^(\d{4})-(\d{2})-\d{2}$/);
+  if (!match) return formatAssetFieldValue(val);
+  return `${match[1]}年${Number(match[2])}月`;
+};
+
 export const getAssetFieldValue = (item, field) => {
   if (!item) return '—';
   const record = resolveAssetRecord(item);
@@ -195,7 +206,10 @@ export const getAssetFieldValue = (item, field) => {
   ];
   for (const key of keys) {
     const val = record[key];
-    if (val != null && val !== '') return formatAssetFieldValue(val);
+    if (val != null && val !== '') {
+      if (field.key === 'c_czqdrq') return formatCertAcquisitionDateDisplay(val);
+      return formatAssetFieldValue(val);
+    }
   }
   return '—';
 };

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

+ 135 - 141
src/utils/buildNotifications.js

@@ -1,14 +1,14 @@
 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';
 
 const pad2 = (n) => String(n).padStart(2, '0');
@@ -25,12 +25,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) => {
@@ -41,208 +41,202 @@ 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 });
 
-/** 通知 id 仅依赖类型 + 账期 + 集团,保证同一条通知不会重复入库 */
-const notifyRecordId = (type, record) => {
-  const qingpuGroup = (record.qingpuGroup ?? '').trim() || 'unknown';
-  const group = (record.group ?? '').trim() || 'unknown';
-  return `${type}:${record.period}:${qingpuGroup}:${group}`;
+const notifyYwid = (prefix, period, group) => `${prefix}:${period}:${group}`;
+
+/** 按账期 + 青浦集团整合(同一集团一条) */
+const groupItemsByQingpuGroup = (items, { fallbackPeriod = '2026-01' } = {}) => {
+  const map = new Map();
+
+  items.forEach((item) => {
+    const period = extractPeriod(item, fallbackPeriod);
+    const qingpuGroup = normalizeGroupName((item.c_ssqpjt ?? '').trim());
+    if (!qingpuGroup) return;
+    const key = `${period}|${qingpuGroup}`;
+
+    if (!map.has(key)) {
+      map.set(key, { period, qingpuGroup, items: [] });
+    }
+    map.get(key).items.push(item);
+  });
+
+  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));
 };
 
 /**
- * 生成当前时刻所有适用的通知(幂等,同 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 }>}
+ * 持久化通知:待审核 / 审核结果 / 逾期催办(不含填报期提醒)
+ * 同一账期 + 青浦集团整合为一条;再次提交/审核时更新条数与时间
  */
 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));
-
-  // ── 待审核(来自 pendingList)─────────────────────────────
-  aggregateReportRecords(pendingList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
-    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]);
+
+  // ── 待审核:同一账期 + 青浦集团一条(管理端)──────────────
+  groupItemsByQingpuGroup(pendingList, { fallbackPeriod: currentPeriod }).forEach((batch) => {
+    const group = batch.qingpuGroup;
+    const title = `${group} ${batch.period} 资产月报(${batch.count} 条)待审核`;
+
     notifications.push({
-      id: notifyRecordId('pending', record),
-      time: record.submittedAt,
+      ywid: notifyYwid('pending', batch.period, group),
+      time: batch.submittedAt,
       type: '待审核',
-      title: `${group} ${record.period} 资产月报(${record.count} 条)待审核`,
-      timestamp: toTimestamp(getFillTime(latest)),
+      title,
+      content: title,
+      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,
       }),
     });
   });
 
-  // ── 已通过(来自 approvedList)────────────────────────────
-  aggregateReportRecords(approvedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
-    if (currentGroup && group !== currentGroup) return;
+  // ── 已通过:同一账期 + 青浦集团一条(企业端)──────────────
+  groupItemsByQingpuGroup(approvedList, { fallbackPeriod: currentPeriod }).forEach((batch) => {
+    const group = batch.qingpuGroup;
+    const latest = batch.latestAuditItem;
+    const title = `${batch.period} 月报已通过(${group},${batch.count} 条)`;
 
-    const latest = record.items?.reduce((best, item) => {
-      const t = toTimestamp(getAuditTime(item));
-      return t >= toTimestamp(getAuditTime(best)) ? item : best;
-    }, record.items?.[0]);
     notifications.push({
-      id: notifyRecordId('approved', record),
+      ywid: notifyYwid('approved', batch.period, group),
       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,
-        period: record.period,
+        group,
+        period: batch.period,
         status: '已通过',
       }),
     });
   });
 
-  // ── 已驳回(来自 rejectedList)────────────────────────────
-  aggregateReportRecords(rejectedList, { fallbackPeriod: currentPeriod }).forEach((record) => {
-    const group = record.qingpuGroup || record.group;
-    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 = `${batch.period} 月报已被驳回(${group},${batch.count} 条)${reason ? `:${reason}` : ''}`;
+
     notifications.push({
-      id: notifyRecordId('rejected', record),
+      ywid: notifyYwid('rejected', batch.period, group),
       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,
-        period: record.period,
+        group,
+        period: batch.period,
         status: '已驳回',
       }),
     });
   });
 
-  // ── 填报提醒(当前账期截止前 7 天,尚未提交的集团)────────
-  if (daysLeft > 0 && daysLeft <= 7) {
-    const allLists = [pendingList, approvedList, rejectedList];
-    GROUP_ORDER.forEach((group) => {
-      if (currentGroup && group !== currentGroup) return;
-      if (groupHasAnySubmission(allLists, group, currentPeriod)) return;
-      notifications.push({
-        id: `reminder:${currentPeriod}:${group}`,
-        time: formatDateTime(now),
-        type: '填报提醒',
-        title: `${currentPeriod} 月报截止倒计时 ${daysLeft} 天(${group})`,
-        timestamp: now,
-        group,
-        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],
+      const title = `${prevPeriod} 月报已逾期,请补报(${group})`;
+      notifications.push({
+        ywid: notifyYwid('overdue', prevPeriod, group),
+        time: formatDateTime(prevDeadline),
+        type: '逾期催办',
+        title,
+        content: title,
+        timestamp: prevDeadline.getTime(),
         group,
-        prevPeriod
-      );
-
-      if (!hasAny) {
-        notifications.push({
-          id: `overdue:${prevPeriod}:${group}`,
-          time: formatDateTime(prevDeadline),
-          type: '逾期催办',
-          title: `${prevPeriod} 月报已逾期,请补报(${group})`,
-          timestamp: prevDeadline.getTime(),
-          group,
-          route: buildRoute('archive'),
-        });
-        return;
-      }
-
-      if (rejected) {
-        notifications.push({
-          id: `overdue-reject:${prevPeriod}:${group}`,
-          time: formatDateTime(now),
-          type: '逾期催办',
-          title: `${prevPeriod} 月报被驳回且未重报,请尽快处理(${group})`,
-          timestamp: now,
-          group,
-          route: buildRoute('archive', { sub: 'history', group, period: prevPeriod, status: '已驳回' }),
-        });
-      }
+        tzlx: NOTIFY_TZLX.OVERDUE,
+        tbtx: 0,
+        shjg: 0,
+        route: buildRoute('archive'),
+      });
     });
   }
 
-  // ── 租约到期/预警(来自已通过资产的 status 字段)──────────
-  const expiryItems = approvedList
-    .filter((item) => item.status === 'red' || item.status === 'orange')
-    .slice(0, 8);
+  return notifications.sort((a, b) => b.timestamp - a.timestamp);
+};
 
-  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;
+/**
+ * 填报期提醒:仅前端展示,不入库;本月无已通过则提醒,通过后自动消失
+ */
+export const buildEphemeralReminders = ({
+  approvedList = [],
+} = {}) => {
+  const currentPeriod = getCurrentPeriod();
+  const now = Date.now();
 
-    notifications.push({
-      id: `expiry:${item.id}`,
-      time: formatDateTime(item.update_time || now),
-      type: withinOneMonth ? '租约到期' : '租约预警',
-      title: `${label} 租约${withinOneMonth ? '将于一个月内到期' : '将于三个月内到期'},请关注`,
-      timestamp: toTimestamp(item.update_time || now),
+  return GROUP_ORDER.flatMap((group) => {
+    if (groupHasApproved(approvedList, group, currentPeriod)) return [];
+
+    const title = `${currentPeriod} 月报尚未通过审核,请尽快填报(${group})`;
+    return [{
+      id: `ephemeral:reminder:${currentPeriod}:${group}`,
+      ywid: `ephemeral:reminder:${currentPeriod}:${group}`,
+      time: formatDateTime(now),
+      timestamp: now,
+      type: '填报提醒',
+      title,
+      content: title,
+      read: false,
+      deleted: false,
+      ephemeral: true,
       group,
-      route: buildRoute('assets'),
-    });
+      tzlx: NOTIFY_TZLX.REMINDER,
+      tbtx: 0,
+      shjg: 0,
+      route: buildRoute('archive'),
+    }];
   });
-
-  return notifications.sort((a, b) => b.timestamp - a.timestamp);
 };

+ 27 - 1
src/utils/fullAssetList.js

@@ -93,6 +93,29 @@ const formatDisplay = (val) => {
   return String(val);
 };
 
+/** 产证取得日期:Excel 序列号 / yyyy-MM-dd → YYYY年M月 */
+const formatCertAcquisitionDate = (val) => {
+  if (val == null || val === '') return '—';
+  const text = String(val).trim();
+  const cnMatch = text.match(/^(\d{4})年(\d{1,2})月$/);
+  if (cnMatch) return `${cnMatch[1]}年${Number(cnMatch[2])}月`;
+  let year;
+  let month;
+  if (/^\d{4}-\d{2}-\d{2}/.test(text)) {
+    [, year, month] = text.slice(0, 10).match(/^(\d{4})-(\d{2})-\d{2}$/) ?? [];
+  } else if (/^\d+$/.test(text)) {
+    const serial = Number(text);
+    if (!Number.isFinite(serial)) return text;
+    const date = new Date(Date.UTC(1899, 11, 30) + serial * 86400000);
+    year = date.getUTCFullYear();
+    month = date.getUTCMonth() + 1;
+  } else {
+    return text;
+  }
+  if (!year || !month) return text;
+  return `${year}年${Number(month)}月`;
+};
+
 const getFieldKeys = (col) => {
   if (Array.isArray(col.fieldKeys)) return col.fieldKeys;
   if (typeof col.fieldKeys === 'string') return [col.fieldKeys];
@@ -103,7 +126,10 @@ const getFieldKeys = (col) => {
 export const getCellValue = (item, col) => {
   for (const fieldKey of getFieldKeys(col)) {
     const val = item[fieldKey];
-    if (val != null && val !== '') return formatDisplay(val);
+    if (val != null && val !== '') {
+      if (fieldKey === 'c_czqdrq') return formatCertAcquisitionDate(val);
+      return formatDisplay(val);
+    }
   }
   return '—';
 };

+ 2 - 21
src/utils/manageContext.js

@@ -11,28 +11,9 @@ export const resolveManageEnterprise = (userInfo) =>
   || userInfo?.orgName
   || DEFAULT_MANAGE_ENTERPRISE;
 
-/** 资产是否属于当前企业/集团管辖范围(新增上报资产弹窗用) */
+/** 资产是否属于当前管理集团管辖范围 */
 export const assetBelongsToManageScope = (item) => {
-  // 优先以所属青浦集团(c_ssqpjt)匹配当前管理集团
   const filterStore = useFilterStore();
   const qpGroup = String(item?.c_ssqpjt ?? '').trim();
-  if (qpGroup && qpGroup === filterStore.selectManageGroup) {
-    return true;
-  }
-
-  // // 原逻辑(注释保留)
-  // const target = String(enterprise ?? '').trim();
-  // if (!target) return true;
-  //
-  // const qymc = String(item.c_qymc ?? '').trim();
-  // const ssjt = String(item.c_ssjt ?? '').trim();
-  //
-  // if (qymc === target || ssjt === target) return true;
-  // if (qymc.includes(target) || target.includes(qymc)) return true;
-  //
-  // if (target === DEFAULT_MANAGE_ENTERPRISE && qpGroup === DEFAULT_MANAGE_GROUP) {
-  //   return true;
-  // }
-
-  return false;
+  return Boolean(qpGroup && qpGroup === filterStore.selectManageGroup);
 };

+ 204 - 0
src/utils/notificationFields.js

@@ -0,0 +1,204 @@
+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,
+    },
+    {
+      pushed: true,
+      read: patch.read !== undefined ? patch.read : existing.read,
+      ...patch,
+    }
+  );
+
+export const isPendingNotificationYwid = (ywid) =>
+  typeof ywid === 'string' && ywid.startsWith('pending:');
+
+export const findNotificationItem = (items, id) =>
+  items.find((item) => String(item.id) === String(id));

+ 161 - 0
src/utils/statsComparison.js

@@ -0,0 +1,161 @@
+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 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' });

+ 54 - 0
src/utils/topic1ChartNavigation.js

@@ -0,0 +1,54 @@
+/** 房龄环形图扇区名称 → 列表筛选项 key */
+export const AGE_SLICE_TO_KEY = {
+  '0-10年': '0-10',
+  '10-20年': '10-20',
+  '20年以上': '20+',
+};
+
+export const TOPIC1_HOME_LAND_OTHER = '其他';
+
+export const createDefaultTopic1ListFilters = (group = '全部') => ({
+  keyword: '',
+  purpose: '全部',
+  group,
+  age: '全部',
+  util: '全部',
+  fwUsage: '',
+  landType: '',
+  landUsage: '',
+});
+
+export const hasTopic1ChartQuery = (query = {}) =>
+  Boolean(query.util || query.landUsage || query.age || query.fwUsage);
+
+/** 首页 / 外链跳转:环形图扇区 → 专题1路由 query */
+export const buildTopic1ChartQuery = (chartKey, sliceName) => {
+  const query = {};
+  if (chartKey === 'util' && sliceName) {
+    query.util = sliceName;
+    return query;
+  }
+  if (chartKey === 'landUsage' && sliceName && sliceName !== TOPIC1_HOME_LAND_OTHER) {
+    query.landUsage = sliceName;
+    return query;
+  }
+  if (chartKey === 'age') {
+    const ageKey = AGE_SLICE_TO_KEY[sliceName];
+    if (ageKey) query.age = ageKey;
+    return query;
+  }
+  if (chartKey === 'fwUsage' && sliceName) {
+    query.fwUsage = sliceName;
+    return query;
+  }
+  return query;
+};
+
+export const parseTopic1ListFiltersFromQuery = (query = {}, group = '全部') => {
+  const filters = createDefaultTopic1ListFilters(group);
+  if (typeof query.util === 'string' && query.util) filters.util = query.util;
+  if (typeof query.landUsage === 'string' && query.landUsage) filters.landUsage = query.landUsage;
+  if (typeof query.age === 'string' && query.age) filters.age = query.age;
+  if (typeof query.fwUsage === 'string' && query.fwUsage) filters.fwUsage = query.fwUsage;
+  return filters;
+};