diff --git a/document/development/通知中心状态持久化/CONCEPT.md b/document/development/通知中心状态持久化/CONCEPT.md new file mode 100644 index 0000000..9ffb242 --- /dev/null +++ b/document/development/通知中心状态持久化/CONCEPT.md @@ -0,0 +1,111 @@ +# 通知中心状态持久化概念文档 + +## 功能一句话 + +为首页小铃铛通知中心补齐服务端状态接口,让同一用户在不同电脑登录时看到一致的已读、清空和隐藏状态,并优化笔记本等小屏幕下的通知弹窗可读性。 + +## 背景与问题 + +当前小铃铛通知由前端从单据中心、个人工作台摘要等数据源即时生成,但已读与清空状态主要写入浏览器 `localStorage`。这会导致同一账号在 A 电脑清空通知后,换到 B 电脑仍然看到通知。 + +同时,通知条数较多或屏幕高度较小时,列表内容容易挤压头部操作区,通知标题与描述也容易在窄宽度下互相挤压。 + +## 目标与非目标 + +目标: + +- 提供当前用户维度的通知状态接口。 +- 支持批量同步通知状态,至少覆盖已读与隐藏。 +- 前端优先使用服务端状态,接口不可用时保留本地降级能力。 +- 优化小屏幕通知弹窗,列表多时使用内部滚动,标题、描述与操作按钮不互相挤压。 + +非目标: + +- 不做独立消息投递系统。 +- 不新增推送、WebSocket 或邮件通知能力。 +- 不改变通知来源生成逻辑,当前仍由单据中心和工作台摘要生成。 + +## 用户与场景 + +- 普通员工:在个人工作台查看待办、单据新消息,跨电脑登录后已读状态一致。 +- 审批人:处理待审批单据后,通知中心不因换电脑重新显示已清空内容。 +- 管理员:仍可看到系统内已有通知入口,但 admin 是否展示工作台由现有逻辑决定。 + +## 功能能力 + +- `GET /notification-states`:读取当前登录用户的通知状态集合。 +- `POST /notification-states`:批量保存当前登录用户的通知状态。 +- 状态字段: + - `notification_id`:前端生成的稳定通知 ID。 + - `read_at`:已读时间。 + - `hidden_at`:隐藏或清空时间。 + - `context_json`:保留通知来源、类型等低风险上下文,便于排查。 +- 前端能力: + - 打开工作台或弹窗时读取服务端状态。 + - 点击通知写入已读。 + - 清空通知写入隐藏。 + - 接口失败时仍写入本地缓存,避免用户操作失效。 + +## 方案设计 + +后端: + +- 新增 `NotificationState` SQLAlchemy 模型。 +- 新增 `NotificationStateService`,负责按 `CurrentUserContext.username` 读写状态。 +- 新增 `notification_states` endpoint,并挂到 API v1 router。 +- 服务初始化时使用项目现有 `Base.metadata.create_all(..., tables=[...])` 模式确保表存在。 + +前端: + +- 新增 `web/src/services/notificationStates.js` 封装接口。 +- `TopBar.vue` 将 `localStorage` 状态作为初始兜底,服务端状态返回后合并覆盖。 +- `markNotificationRead` 与 `clearAllNotifications` 做乐观更新,再异步同步服务端。 +- 对单据通知仍调用现有 `markDocumentInboxRowRead`,同时写入通知状态接口,保证跨设备一致。 + +小屏幕布局: + +- 弹窗宽度使用 `clamp` 与 `100vw` 约束。 +- 弹窗最大高度使用 `min(..., calc(100vh - ...))`。 +- 列表作为唯一滚动区域,头部和 tab 固定在弹窗网格内。 +- 通知描述允许两行截断,避免窄屏时横向挤压。 + +## 算法与公式 + +当前功能不涉及显式数学公式。状态合并规则为: + +$$ +visible = notification\_id \notin hiddenIds +$$ + +$$ +unread = sourceUnread \land notification\_id \notin readIds \land notification\_id \notin hiddenIds +$$ + +服务端状态优先,前端本地状态仅作为接口失败或首次加载前的兜底。 + +## 测试方案 + +- 后端单元测试: + - 当前用户只能读取自己的通知状态。 + - 批量 upsert 后可读取 `read_at`、`hidden_at`。 + - 清空通知写入 hidden 状态。 +- 前端静态测试: + - `TopBar` 引用通知状态服务。 + - 已读、清空操作会同步服务端。 + - 小屏 CSS 使用弹窗 max-height、内部滚动和移动端约束。 +- 构建验证: + - 运行前端构建确认 Vue 与服务导入无误。 + - 在容器内运行后端定向 pytest。 + +## 指标与验收 + +- 同一用户跨电脑登录后,已读和清空状态由服务端保持一致。 +- 接口失败时用户仍可本地清空,不阻断主要流程。 +- 通知弹窗在笔记本高度下不会挤压头部按钮,列表内部滚动。 +- 通知标题、描述、时间在窄屏下不横向溢出。 + +## 风险与开放问题 + +- 当前通知本身仍由前端即时生成,服务端只保存状态,不保存完整通知正文。 +- 通知 ID 需要保持稳定,否则服务端状态无法命中;本次沿用现有 `document:` 和 `workbench:` 前缀。 +- 历史 localStorage 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。 diff --git a/document/development/通知中心状态持久化/TODO.md b/document/development/通知中心状态持久化/TODO.md new file mode 100644 index 0000000..53c9568 --- /dev/null +++ b/document/development/通知中心状态持久化/TODO.md @@ -0,0 +1,10 @@ +# 通知中心状态持久化 TODO + +- [x] 调研现有小铃铛通知来源、localStorage 键和单据中心已读逻辑。[CONCEPT: 背景与问题] 证据:`TopBar.vue`、`useDocumentCenterInbox.js`、`documentCenterNewState.js` 已确认。 +- [x] 新增后端通知状态模型、Schema、Service 与 API endpoint。[CONCEPT: 方案设计] 证据:`notification_states` 支持按用户保存已读与隐藏状态。 +- [x] 将通知状态接口挂载到 API v1 router,并保持当前用户隔离。[CONCEPT: 功能能力] 证据:`GET /notification-states` 与 `POST /notification-states` 已接入。 +- [x] 新增前端 `notificationStates` 服务封装读取与批量保存。[CONCEPT: 前端] 证据:服务层统一请求 `/notification-states`。 +- [x] 改造 `TopBar` 已读、清空逻辑,优先同步服务端,保留本地降级。[CONCEPT: 前端] 证据:小铃铛点击已读、清空通知都会写入状态接口。 +- [x] 优化通知弹窗笔记本与窄屏布局,避免条数多时挤压。[CONCEPT: 小屏幕布局] 证据:弹窗限制视口高度,列表滚动,描述两行截断,420px 下隐藏行箭头。 +- [x] 补充后端和前端回归测试。[CONCEPT: 测试方案] 证据:`server/tests/test_notification_states.py`、`web/tests/sidebar-document-unread-dot.test.mjs`。 +- [x] 运行容器后端定向 pytest、前端 Node 测试与前端 build。[CONCEPT: 指标与验收] 证据:pytest 2 passed;Node 4 passed;Vite build passed。 diff --git a/server/src/app/api/v1/endpoints/notification_states.py b/server/src/app/api/v1/endpoints/notification_states.py new file mode 100644 index 0000000..e6b6c90 --- /dev/null +++ b/server/src/app/api/v1/endpoints/notification_states.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext, get_current_user, get_db +from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStateListRead +from app.services.notification_states import NotificationStateService + +router = APIRouter(prefix="/notification-states") +DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] + + +@router.get( + "", + response_model=NotificationStateListRead, + summary="读取当前用户通知状态", + description="读取当前登录用户的小铃铛通知已读和隐藏状态,用于跨设备保持一致。", +) +def list_notification_states(db: DbSession, current_user: CurrentUser) -> NotificationStateListRead: + return NotificationStateService(db).list_states(current_user) + + +@router.post( + "", + response_model=NotificationStateListRead, + summary="批量保存当前用户通知状态", + description="批量保存当前登录用户的小铃铛通知已读和隐藏状态。", +) +def patch_notification_states( + payload: NotificationStateBatchPatch, + db: DbSession, + current_user: CurrentUser, +) -> NotificationStateListRead: + return NotificationStateService(db).patch_states(payload, current_user) diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index df33fb9..056b455 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -14,6 +14,7 @@ from app.api.v1.endpoints.employees import router as employees_router from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.knowledge import router as knowledge_router +from app.api.v1.endpoints.notification_states import router as notification_states_router from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.orchestrator import router as orchestrator_router @@ -36,6 +37,7 @@ router.include_router(agent_traces_router, tags=["agent-traces"]) router.include_router(analytics_router, tags=["analytics"]) router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(knowledge_router, tags=["knowledge"]) +router.include_router(notification_states_router, tags=["notification-states"]) router.include_router(ocr_router, tags=["ocr"]) router.include_router(ontology_router, tags=["ontology"]) router.include_router(orchestrator_router, tags=["orchestrator"]) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index b81d3ce..b9ee3c7 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -23,6 +23,7 @@ from app.models.financial_record import ( ) from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_report import HermesRiskReport +from app.models.notification_state import NotificationState from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.risk_observation import RiskObservation, RiskObservationFeedback @@ -60,6 +61,7 @@ __all__ = [ "HermesTaskConfig", "HermesTaskExecutionLog", "HermesRiskReport", + "NotificationState", "OrganizationUnit", "ReimbursementRequest", "RiskObservation", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index ba3153f..b3549eb 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -16,6 +16,7 @@ from app.models.financial_record import ( ) from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_report import HermesRiskReport +from app.models.notification_state import NotificationState from app.models.organization import OrganizationUnit from app.models.reimbursement import ReimbursementRequest from app.models.risk_observation import RiskObservation, RiskObservationFeedback @@ -51,6 +52,7 @@ __all__ = [ "HermesTaskConfig", "HermesTaskExecutionLog", "HermesRiskReport", + "NotificationState", "OrganizationUnit", "ReimbursementRequest", "RiskObservation", diff --git a/server/src/app/models/notification_state.py b/server/src/app/models/notification_state.py new file mode 100644 index 0000000..c0b37fa --- /dev/null +++ b/server/src/app/models/notification_state.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import DateTime, Index, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.db.base_class import Base + + +class NotificationState(Base): + __tablename__ = "notification_states" + __table_args__ = ( + UniqueConstraint("user_id", "notification_id", name="uq_notification_states_user_notification"), + Index("ix_notification_states_user_updated", "user_id", "updated_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column(String(100), index=True) + notification_id: Mapped[str] = mapped_column(String(180), index=True) + read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) diff --git a/server/src/app/schemas/notification_state.py b/server/src/app/schemas/notification_state.py new file mode 100644 index 0000000..41a01c2 --- /dev/null +++ b/server/src/app/schemas/notification_state.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def _normalize_text(value: Any) -> str: + return str(value or "").strip() + + +class NotificationStatePatch(BaseModel): + notification_id: str = Field(min_length=1, max_length=180) + read: bool = False + hidden: bool = False + context_json: dict[str, Any] = Field(default_factory=dict) + + @field_validator("notification_id", mode="before") + @classmethod + def normalize_notification_id(cls, value: Any) -> str: + return _normalize_text(value) + + @field_validator("context_json", mode="before") + @classmethod + def normalize_context(cls, value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +class NotificationStateBatchPatch(BaseModel): + states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100) + + +class NotificationStateRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + notification_id: str + read_at: datetime | None + hidden_at: datetime | None + context_json: dict[str, Any] + updated_at: datetime + + +class NotificationStateListRead(BaseModel): + states: list[NotificationStateRead] = Field(default_factory=list) diff --git a/server/src/app/services/notification_states.py b/server/src/app/services/notification_states.py new file mode 100644 index 0000000..8487f88 --- /dev/null +++ b/server/src/app/services/notification_states.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.db.base import Base +from app.models.notification_state import NotificationState +from app.schemas.notification_state import ( + NotificationStateBatchPatch, + NotificationStateListRead, + NotificationStateRead, +) + + +class NotificationStateService: + def __init__(self, db: Session) -> None: + self.db = db + + def ensure_storage_ready(self) -> None: + Base.metadata.create_all(bind=self.db.get_bind(), tables=[NotificationState.__table__]) + + def list_states(self, current_user: CurrentUserContext) -> NotificationStateListRead: + self.ensure_storage_ready() + stmt = ( + select(NotificationState) + .where(NotificationState.user_id == self._user_key(current_user)) + .order_by(NotificationState.updated_at.desc()) + ) + states = list(self.db.scalars(stmt).all()) + return NotificationStateListRead( + states=[NotificationStateRead.model_validate(item) for item in states] + ) + + def patch_states( + self, + payload: NotificationStateBatchPatch, + current_user: CurrentUserContext, + ) -> NotificationStateListRead: + self.ensure_storage_ready() + user_id = self._user_key(current_user) + patches = [item for item in payload.states if item.notification_id] + if not patches: + return self.list_states(current_user) + + ids = {item.notification_id for item in patches} + existing_rows = list( + self.db.scalars( + select(NotificationState).where( + NotificationState.user_id == user_id, + NotificationState.notification_id.in_(ids), + ) + ).all() + ) + existing_by_id = {item.notification_id: item for item in existing_rows} + now = datetime.now(UTC) + + for patch in patches: + row = existing_by_id.get(patch.notification_id) + if row is None: + row = NotificationState( + user_id=user_id, + notification_id=patch.notification_id, + context_json={}, + ) + self.db.add(row) + existing_by_id[patch.notification_id] = row + + if patch.read and row.read_at is None: + row.read_at = now + if patch.hidden and row.hidden_at is None: + row.hidden_at = now + if patch.context_json: + row.context_json = self._merge_context(row.context_json, patch.context_json) + + self.db.commit() + return self.list_states(current_user) + + @staticmethod + def _user_key(current_user: CurrentUserContext) -> str: + return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous" + + @staticmethod + def _merge_context(current: dict | None, patch: dict) -> dict: + base = current if isinstance(current, dict) else {} + return {**base, **patch} diff --git a/server/tests/test_notification_states.py b/server/tests/test_notification_states.py new file mode 100644 index 0000000..ab63864 --- /dev/null +++ b/server/tests/test_notification_states.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from collections.abc import Generator + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.api.deps import CurrentUserContext, get_db +from app.db.base import Base +from app.main import create_app +from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStatePatch +from app.services.notification_states import NotificationStateService + + +def build_session() -> Session: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + return session_factory() + + +def build_client() -> TestClient: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = create_app() + + def override_db() -> Generator[Session, None, None]: + db = session_factory() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_db + return TestClient(app) + + +def test_notification_state_service_persists_user_scoped_read_and_hidden_state() -> None: + with build_session() as db: + service = NotificationStateService(db) + user = CurrentUserContext(username="alice", name="Alice", role_codes=[], is_admin=False) + other_user = CurrentUserContext(username="bob", name="Bob", role_codes=[], is_admin=False) + + saved = service.patch_states( + NotificationStateBatchPatch( + states=[ + NotificationStatePatch( + notification_id="document:owned:EXP-001", + read=True, + hidden=True, + context_json={"kind": "document"}, + ) + ] + ), + user, + ) + other_saved = service.patch_states( + NotificationStateBatchPatch( + states=[ + NotificationStatePatch( + notification_id="document:owned:EXP-001", + read=True, + ) + ] + ), + other_user, + ) + + assert len(saved.states) == 1 + assert saved.states[0].notification_id == "document:owned:EXP-001" + assert saved.states[0].read_at is not None + assert saved.states[0].hidden_at is not None + assert saved.states[0].context_json["kind"] == "document" + assert other_saved.states[0].hidden_at is None + + +def test_notification_state_endpoint_reads_and_updates_current_user_state() -> None: + client = build_client() + headers = {"x-auth-username": "alice", "x-auth-name": "Alice"} + + post_response = client.post( + "/api/v1/notification-states", + json={ + "states": [ + { + "notification_id": "workbench:todo:EXP-002", + "read": True, + "hidden": False, + "context_json": {"kind": "workbench"}, + } + ] + }, + headers=headers, + ) + get_response = client.get("/api/v1/notification-states", headers=headers) + other_response = client.get( + "/api/v1/notification-states", + headers={"x-auth-username": "bob", "x-auth-name": "Bob"}, + ) + + assert post_response.status_code == 200 + assert get_response.status_code == 200 + payload = get_response.json() + assert payload["states"][0]["notification_id"] == "workbench:todo:EXP-002" + assert payload["states"][0]["read_at"] is not None + assert payload["states"][0]["hidden_at"] is None + assert payload["states"][0]["context_json"]["kind"] == "workbench" + assert other_response.json()["states"] == [] diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index cf1ba55..4527bde 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -475,7 +475,9 @@ top: calc(100% + 8px); right: 0; z-index: 60; - width: min(380px, calc(100vw - 32px)); + width: clamp(340px, 34vw, 420px); + max-width: calc(100vw - 24px); + max-height: min(520px, calc(100vh - 96px)); display: grid; grid-template-rows: auto auto minmax(0, 1fr); gap: 0; @@ -509,6 +511,7 @@ .notification-head-brand { min-width: 0; + flex: 1 1 auto; display: flex; align-items: center; gap: 10px; @@ -658,7 +661,8 @@ .notification-list { display: flex; flex-direction: column; - max-height: 280px; + min-height: 0; + max-height: min(336px, calc(100vh - 226px)); overflow-x: hidden; overflow-y: auto; padding: 6px 0; @@ -681,9 +685,9 @@ .notification-row { display: grid; - grid-template-columns: 36px minmax(0, 1fr) 14px; + grid-template-columns: 34px minmax(0, 1fr) 16px; align-items: center; - gap: 10px; + gap: 9px; min-height: 0; padding: 10px 14px; border: 0; @@ -714,8 +718,8 @@ } .notification-type-icon { - width: 36px; - height: 36px; + width: 34px; + height: 34px; display: grid; place-items: center; border: 1px solid var(--theme-primary-light-6); @@ -755,8 +759,7 @@ gap: 4px; } -.notification-copy strong, -.notification-copy small { +.notification-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -793,9 +796,14 @@ } .notification-copy small { + display: -webkit-box; + overflow: hidden; color: #475569; font-size: 12px; line-height: 1.4; + white-space: normal; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } .notification-meta { @@ -808,10 +816,22 @@ .notification-meta em, .notification-meta time { + min-width: 0; + overflow: hidden; color: #94a3b8; font-size: 11px; font-style: normal; font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.notification-meta em { + flex: 1 1 auto; +} + +.notification-meta time { + flex: 0 0 auto; } .notification-row-arrow { @@ -1127,6 +1147,37 @@ gap: 10px; } + .notification-popover { + position: fixed; + top: 72px; + right: 12px; + left: 12px; + width: auto; + max-width: none; + max-height: calc(100vh - 92px); + } + + .notification-head { + align-items: flex-start; + padding: 11px 12px 9px; + } + + .notification-head-actions { + gap: 2px; + } + + .notification-clear-btn { + padding: 0 8px; + } + + .notification-list { + max-height: calc(100vh - 218px); + } + + .notification-row { + padding: 9px 12px; + } + .company-switcher { flex: 1 1 auto; max-width: none; @@ -1171,6 +1222,41 @@ } @media (max-width: 420px) { + .notification-popover { + top: 64px; + right: 8px; + left: 8px; + max-height: calc(100vh - 78px); + } + + .notification-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + } + + .notification-head-brand { + gap: 8px; + } + + .notification-head-icon, + .notification-type-icon { + width: 30px; + height: 30px; + } + + .notification-tabs { + padding: 0 10px; + } + + .notification-row { + grid-template-columns: 30px minmax(0, 1fr); + gap: 8px; + } + + .notification-row-arrow { + display: none; + } + .topbar.detail-mode { gap: 6px; padding: 8px 12px; diff --git a/web/src/components/layout/TopBar.vue b/web/src/components/layout/TopBar.vue index bc11ac5..fc7e913 100644 --- a/web/src/components/layout/TopBar.vue +++ b/web/src/components/layout/TopBar.vue @@ -316,6 +316,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js' +import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js' import EnterpriseSelect from '../shared/EnterpriseSelect.vue' const props = defineProps({ @@ -400,8 +401,6 @@ const eyebrowLabel = computed(() => ( || (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations') )) const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司') -const NOTIFICATION_READ_STORAGE_KEY = 'x-financial.topbar.notifications.read' -const NOTIFICATION_HIDDEN_STORAGE_KEY = 'x-financial.topbar.notifications.hidden' const MAX_NOTIFICATION_ITEMS = 30 const { markDocumentInboxRowRead, @@ -412,46 +411,21 @@ const { stopDocumentInboxPolling } = useDocumentCenterInbox() let documentInboxInitialRefreshTimer = null -const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY)) -const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY)) const notificationOpen = ref(false) +const { + readNotificationIds, + hideNotificationStates, + isNotificationHidden, + isNotificationRead, + loadNotificationStates, + markNotificationStateRead +} = useTopBarNotificationStates() const notificationTab = ref('unread') -function readNotificationIdSet(storageKey) { - if (typeof window === 'undefined') { - return new Set() - } - - try { - const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]') - return new Set(Array.isArray(parsed) ? parsed.map((item) => String(item || '').trim()).filter(Boolean) : []) - } catch { - return new Set() - } -} - -function writeNotificationIdSet(storageKey, values) { - if (typeof window === 'undefined') { - return - } - - window.localStorage.setItem(storageKey, JSON.stringify(Array.from(values).filter(Boolean))) -} - -function updateNotificationIdSet(targetRef, storageKey, updater) { - const next = updater(new Set(targetRef.value)) - targetRef.value = next - writeNotificationIdSet(storageKey, next) -} - function normalizeNotificationId(value) { return String(value || '').trim() } -function isNotificationHidden(id) { - return hiddenNotificationIds.value.has(normalizeNotificationId(id)) -} - function formatNotificationTime(value) { const date = new Date(value) if (!Number.isFinite(date.getTime())) { @@ -492,6 +466,7 @@ const documentNotificationItems = computed(() => if (!id || isNotificationHidden(id)) { return null } + const unread = Boolean(row.isUnread) && !isNotificationRead(id) return { id, @@ -500,10 +475,10 @@ const documentNotificationItems = computed(() => description: resolveDocumentNotificationDescription(row), time: formatNotificationTime(row.updatedAt || row.createdAt), category: row.sourceLabel || '单据中心', - tone: resolveDocumentNotificationTone(row), - unread: Boolean(row.isUnread), + tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }), + unread, icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline', - badge: row.isUnread ? '新' : '', + badge: unread ? '新' : '', target: { type: 'document', id: row.claimId, @@ -593,13 +568,9 @@ function markNotificationRead(item) { if (item.kind === 'document' && item.documentRow) { markDocumentInboxRowRead(item.documentRow) - return } - updateNotificationIdSet(readNotificationIds, NOTIFICATION_READ_STORAGE_KEY, (next) => { - next.add(item.id) - return next - }) + void markNotificationStateRead(item) } function clearAllNotifications() { @@ -616,10 +587,7 @@ function clearAllNotifications() { markDocumentInboxRowsRead(documentRows) } - updateNotificationIdSet(hiddenNotificationIds, NOTIFICATION_HIDDEN_STORAGE_KEY, (next) => { - currentItems.forEach((item) => next.add(item.id)) - return next - }) + void hideNotificationStates(currentItems) notificationTab.value = 'unread' } @@ -827,12 +795,20 @@ watch( (activeView, previousView) => { if (activeView === 'workbench' && previousView !== 'workbench') { clearDocumentInboxInitialRefreshTimer() + void loadNotificationStates() void refreshDocumentInbox({ force: true }) } } ) +watch(notificationOpen, (open) => { + if (open) { + void loadNotificationStates() + } +}) + onMounted(() => { + void loadNotificationStates() scheduleDocumentInboxInitialRefresh() startDocumentInboxPolling() }) diff --git a/web/src/composables/useTopBarNotificationStates.js b/web/src/composables/useTopBarNotificationStates.js new file mode 100644 index 0000000..2dff26b --- /dev/null +++ b/web/src/composables/useTopBarNotificationStates.js @@ -0,0 +1,163 @@ +import { ref } from 'vue' + +import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js' + +const NOTIFICATION_READ_STORAGE_KEY = 'x-financial.topbar.notifications.read' +const NOTIFICATION_HIDDEN_STORAGE_KEY = 'x-financial.topbar.notifications.hidden' + +function normalizeNotificationId(value) { + return String(value || '').trim() +} + +function readNotificationIdSet(storageKey) { + if (typeof window === 'undefined') { + return new Set() + } + + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]') + return new Set(Array.isArray(parsed) ? parsed.map(normalizeNotificationId).filter(Boolean) : []) + } catch { + return new Set() + } +} + +function writeNotificationIdSet(storageKey, values) { + if (typeof window === 'undefined') { + return + } + + window.localStorage.setItem(storageKey, JSON.stringify(Array.from(values).filter(Boolean))) +} + +function mergeRemoteStateIntoSets(states, readIds, hiddenIds) { + for (const item of Array.isArray(states) ? states : []) { + const id = normalizeNotificationId(item?.notification_id || item?.notificationId) + if (!id) { + continue + } + if (item.read_at || item.readAt) { + readIds.add(id) + } + if (item.hidden_at || item.hiddenAt) { + hiddenIds.add(id) + } + } +} + +function buildContext(item) { + const target = item?.target || {} + return { + kind: String(item?.kind || '').trim(), + category: String(item?.category || '').trim(), + target_type: String(target?.type || '').trim() + } +} + +function buildPatch(item, flags) { + const notificationId = normalizeNotificationId(item?.id || item?.notification_id || item?.notificationId) + if (!notificationId) { + return null + } + + return { + notification_id: notificationId, + read: Boolean(flags?.read), + hidden: Boolean(flags?.hidden), + context_json: buildContext(item) + } +} + +export function useTopBarNotificationStates() { + const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY)) + const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY)) + const notificationStateSyncing = ref(false) + const notificationStateError = ref('') + + function persistLocalSets() { + writeNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY, readNotificationIds.value) + writeNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY, hiddenNotificationIds.value) + } + + function applyLocalPatch(patch) { + if (!patch?.notification_id) { + return + } + if (patch.read) { + readNotificationIds.value.add(patch.notification_id) + } + if (patch.hidden) { + hiddenNotificationIds.value.add(patch.notification_id) + } + readNotificationIds.value = new Set(readNotificationIds.value) + hiddenNotificationIds.value = new Set(hiddenNotificationIds.value) + persistLocalSets() + } + + function applyRemoteStates(states) { + const nextReadIds = new Set(readNotificationIds.value) + const nextHiddenIds = new Set(hiddenNotificationIds.value) + mergeRemoteStateIntoSets(states, nextReadIds, nextHiddenIds) + readNotificationIds.value = nextReadIds + hiddenNotificationIds.value = nextHiddenIds + persistLocalSets() + } + + async function loadNotificationStates() { + notificationStateSyncing.value = true + notificationStateError.value = '' + try { + applyRemoteStates(await fetchNotificationStates()) + } catch (error) { + notificationStateError.value = error?.message || '通知状态同步失败' + } finally { + notificationStateSyncing.value = false + } + } + + async function syncNotificationPatches(patches) { + const normalizedPatches = (Array.isArray(patches) ? patches : []).filter(Boolean) + if (!normalizedPatches.length) { + return + } + + normalizedPatches.forEach(applyLocalPatch) + notificationStateError.value = '' + try { + applyRemoteStates(await patchNotificationStates(normalizedPatches)) + } catch (error) { + notificationStateError.value = error?.message || '通知状态同步失败' + } + } + + function isNotificationHidden(id) { + return hiddenNotificationIds.value.has(normalizeNotificationId(id)) + } + + function isNotificationRead(id) { + return readNotificationIds.value.has(normalizeNotificationId(id)) + } + + function markNotificationStateRead(item) { + return syncNotificationPatches([buildPatch(item, { read: true })]) + } + + function hideNotificationStates(items) { + const patches = (Array.isArray(items) ? items : []) + .map((item) => buildPatch(item, { read: true, hidden: true })) + .filter(Boolean) + return syncNotificationPatches(patches) + } + + return { + hiddenNotificationIds, + readNotificationIds, + notificationStateSyncing, + notificationStateError, + hideNotificationStates, + isNotificationHidden, + isNotificationRead, + loadNotificationStates, + markNotificationStateRead + } +} diff --git a/web/src/services/notificationStates.js b/web/src/services/notificationStates.js new file mode 100644 index 0000000..878cb77 --- /dev/null +++ b/web/src/services/notificationStates.js @@ -0,0 +1,38 @@ +import { apiRequest } from './api.js' + +function normalizeStateItem(item) { + const notificationId = String(item?.notification_id || item?.notificationId || '').trim() + if (!notificationId) { + return null + } + + return { + notification_id: notificationId, + read: Boolean(item?.read), + hidden: Boolean(item?.hidden), + context_json: item?.context_json && typeof item.context_json === 'object' + ? item.context_json + : {} + } +} + +export async function fetchNotificationStates() { + const payload = await apiRequest('/notification-states') + return Array.isArray(payload?.states) ? payload.states : [] +} + +export async function patchNotificationStates(states = []) { + const normalizedStates = (Array.isArray(states) ? states : []) + .map(normalizeStateItem) + .filter(Boolean) + + if (!normalizedStates.length) { + return [] + } + + const payload = await apiRequest('/notification-states', { + method: 'POST', + body: JSON.stringify({ states: normalizedStates }) + }) + return Array.isArray(payload?.states) ? payload.states : [] +} diff --git a/web/tests/sidebar-document-unread-dot.test.mjs b/web/tests/sidebar-document-unread-dot.test.mjs index a9358ee..7c5510c 100644 --- a/web/tests/sidebar-document-unread-dot.test.mjs +++ b/web/tests/sidebar-document-unread-dot.test.mjs @@ -38,6 +38,16 @@ const documentNewState = readFileSync( 'utf8' ) +const notificationStatesService = readFileSync( + fileURLToPath(new URL('../src/services/notificationStates.js', import.meta.url)), + 'utf8' +) + +const topbarNotificationStates = readFileSync( + fileURLToPath(new URL('../src/composables/useTopBarNotificationStates.js', import.meta.url)), + 'utf8' +) + test('sidebar no longer renders document center unread indicators', () => { assert.doesNotMatch(sidebar, /useDocumentCenterInbox/) assert.doesNotMatch(sidebar, /hasUnread: documentInboxHasUnread/) @@ -52,12 +62,17 @@ test('sidebar no longer renders document center unread indicators', () => { test('topbar bell owns document center unread notifications', () => { assert.match(topbar, /useDocumentCenterInbox/) + assert.match(topbar, /useTopBarNotificationStates/) assert.match(topbar, /notificationRows: documentInboxNotificationRows/) assert.match(topbar, /const documentNotificationItems = computed/) assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/) assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/) + assert.match(topbar, /const unread = Boolean\(row\.isUnread\) && !isNotificationRead\(id\)/) assert.match(topbar, /markDocumentInboxRowRead\(item\.documentRow\)/) + assert.match(topbar, /markNotificationStateRead\(item\)/) assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/) + assert.match(topbar, /hideNotificationStates\(currentItems\)/) + assert.match(topbar, /loadNotificationStates\(\)/) assert.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/) assert.doesNotMatch(topbar, /document-center-unread/) assert.doesNotMatch(topbar, /target: \{ type: 'documents-center' \}/) @@ -71,14 +86,27 @@ test('topbar bell owns document center unread notifications', () => { assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/) assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/) assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/) - assert.match(topbarStyles, /\.notification-close-btn span::before\s*\{[\s\S]*rotate\(45deg\);/) - assert.match(topbarStyles, /\.notification-close-btn span::after\s*\{[\s\S]*rotate\(-45deg\);/) - assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*244px;[\s\S]*overflow-y:\s*auto;/) - assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*999px;/) + assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/) + assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/) + assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/) + assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/) + assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/) assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/) assert.doesNotMatch(topbarStyles, /\.notification-dot/) }) +test('topbar notification state is persisted through backend API with local fallback', () => { + assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/) + assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/) + assert.match(topbarNotificationStates, /fetchNotificationStates/) + assert.match(topbarNotificationStates, /patchNotificationStates/) + assert.match(topbarNotificationStates, /NOTIFICATION_READ_STORAGE_KEY/) + assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/) + assert.match(topbarNotificationStates, /applyRemoteStates/) + assert.match(topbarNotificationStates, /markNotificationStateRead/) + assert.match(topbarNotificationStates, /hideNotificationStates/) +}) + test('document inbox reuses document center viewed-key state', () => { assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/) assert.match(documentInbox, /readViewedDocumentKeys/)