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

+ 2 - 0
public/config.js

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

+ 68 - 0
src/api/notification.js

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

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

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

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

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

+ 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 LOGIN_URL = cfg.LOGIN_URL || '';
 export const MODEL_ID = cfg.MODEL_ID || 1818;
 export const MODEL_ID = cfg.MODEL_ID || 1818;
 export const COLUMN_ID = cfg.COLUMN_ID || 1764;
 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;

+ 203 - 75
src/store/notification.js

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

+ 92 - 34
src/utils/buildNotifications.js

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

+ 210 - 0
src/utils/notificationFields.js

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