feat(workbench): persist topbar notification state
This commit is contained in:
111
document/development/通知中心状态持久化/CONCEPT.md
Normal file
111
document/development/通知中心状态持久化/CONCEPT.md
Normal file
@@ -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 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。
|
||||||
10
document/development/通知中心状态持久化/TODO.md
Normal file
10
document/development/通知中心状态持久化/TODO.md
Normal file
@@ -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。
|
||||||
38
server/src/app/api/v1/endpoints/notification_states.py
Normal file
38
server/src/app/api/v1/endpoints/notification_states.py
Normal file
@@ -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)
|
||||||
@@ -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.employee_profiles import router as employee_profiles_router
|
||||||
from app.api.v1.endpoints.health import router as health_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.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.ocr import router as ocr_router
|
||||||
from app.api.v1.endpoints.ontology import router as ontology_router
|
from app.api.v1.endpoints.ontology import router as ontology_router
|
||||||
from app.api.v1.endpoints.orchestrator import router as orchestrator_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(analytics_router, tags=["analytics"])
|
||||||
router.include_router(audit_logs_router, tags=["audit-logs"])
|
router.include_router(audit_logs_router, tags=["audit-logs"])
|
||||||
router.include_router(knowledge_router, tags=["knowledge"])
|
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(ocr_router, tags=["ocr"])
|
||||||
router.include_router(ontology_router, tags=["ontology"])
|
router.include_router(ontology_router, tags=["ontology"])
|
||||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from app.models.financial_record import (
|
|||||||
)
|
)
|
||||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
from app.models.hermes_report import HermesRiskReport
|
from app.models.hermes_report import HermesRiskReport
|
||||||
|
from app.models.notification_state import NotificationState
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
@@ -60,6 +61,7 @@ __all__ = [
|
|||||||
"HermesTaskConfig",
|
"HermesTaskConfig",
|
||||||
"HermesTaskExecutionLog",
|
"HermesTaskExecutionLog",
|
||||||
"HermesRiskReport",
|
"HermesRiskReport",
|
||||||
|
"NotificationState",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"RiskObservation",
|
"RiskObservation",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from app.models.financial_record import (
|
|||||||
)
|
)
|
||||||
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
|
||||||
from app.models.hermes_report import HermesRiskReport
|
from app.models.hermes_report import HermesRiskReport
|
||||||
|
from app.models.notification_state import NotificationState
|
||||||
from app.models.organization import OrganizationUnit
|
from app.models.organization import OrganizationUnit
|
||||||
from app.models.reimbursement import ReimbursementRequest
|
from app.models.reimbursement import ReimbursementRequest
|
||||||
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
|
||||||
@@ -51,6 +52,7 @@ __all__ = [
|
|||||||
"HermesTaskConfig",
|
"HermesTaskConfig",
|
||||||
"HermesTaskExecutionLog",
|
"HermesTaskExecutionLog",
|
||||||
"HermesRiskReport",
|
"HermesRiskReport",
|
||||||
|
"NotificationState",
|
||||||
"OrganizationUnit",
|
"OrganizationUnit",
|
||||||
"ReimbursementRequest",
|
"ReimbursementRequest",
|
||||||
"RiskObservation",
|
"RiskObservation",
|
||||||
|
|||||||
32
server/src/app/models/notification_state.py
Normal file
32
server/src/app/models/notification_state.py
Normal file
@@ -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(),
|
||||||
|
)
|
||||||
45
server/src/app/schemas/notification_state.py
Normal file
45
server/src/app/schemas/notification_state.py
Normal file
@@ -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)
|
||||||
88
server/src/app/services/notification_states.py
Normal file
88
server/src/app/services/notification_states.py
Normal file
@@ -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}
|
||||||
119
server/tests/test_notification_states.py
Normal file
119
server/tests/test_notification_states.py
Normal file
@@ -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"] == []
|
||||||
@@ -475,7 +475,9 @@
|
|||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 60;
|
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;
|
display: grid;
|
||||||
grid-template-rows: auto auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -509,6 +511,7 @@
|
|||||||
|
|
||||||
.notification-head-brand {
|
.notification-head-brand {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -658,7 +661,8 @@
|
|||||||
.notification-list {
|
.notification-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 280px;
|
min-height: 0;
|
||||||
|
max-height: min(336px, calc(100vh - 226px));
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
@@ -681,9 +685,9 @@
|
|||||||
|
|
||||||
.notification-row {
|
.notification-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 36px minmax(0, 1fr) 14px;
|
grid-template-columns: 34px minmax(0, 1fr) 16px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 9px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -714,8 +718,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-type-icon {
|
.notification-type-icon {
|
||||||
width: 36px;
|
width: 34px;
|
||||||
height: 36px;
|
height: 34px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid var(--theme-primary-light-6);
|
border: 1px solid var(--theme-primary-light-6);
|
||||||
@@ -755,8 +759,7 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-copy strong,
|
.notification-copy strong {
|
||||||
.notification-copy small {
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -793,9 +796,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-copy small {
|
.notification-copy small {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
color: #475569;
|
color: #475569;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-meta {
|
.notification-meta {
|
||||||
@@ -808,10 +816,22 @@
|
|||||||
|
|
||||||
.notification-meta em,
|
.notification-meta em,
|
||||||
.notification-meta time {
|
.notification-meta time {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
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 {
|
.notification-row-arrow {
|
||||||
@@ -1127,6 +1147,37 @@
|
|||||||
gap: 10px;
|
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 {
|
.company-switcher {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1171,6 +1222,41 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@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 {
|
.topbar.detail-mode {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -316,6 +316,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
|
||||||
|
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
|
||||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -400,8 +401,6 @@ const eyebrowLabel = computed(() => (
|
|||||||
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
|
||||||
))
|
))
|
||||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
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 MAX_NOTIFICATION_ITEMS = 30
|
||||||
const {
|
const {
|
||||||
markDocumentInboxRowRead,
|
markDocumentInboxRowRead,
|
||||||
@@ -412,46 +411,21 @@ const {
|
|||||||
stopDocumentInboxPolling
|
stopDocumentInboxPolling
|
||||||
} = useDocumentCenterInbox()
|
} = useDocumentCenterInbox()
|
||||||
let documentInboxInitialRefreshTimer = null
|
let documentInboxInitialRefreshTimer = null
|
||||||
const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY))
|
|
||||||
const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY))
|
|
||||||
const notificationOpen = ref(false)
|
const notificationOpen = ref(false)
|
||||||
|
const {
|
||||||
|
readNotificationIds,
|
||||||
|
hideNotificationStates,
|
||||||
|
isNotificationHidden,
|
||||||
|
isNotificationRead,
|
||||||
|
loadNotificationStates,
|
||||||
|
markNotificationStateRead
|
||||||
|
} = useTopBarNotificationStates()
|
||||||
const notificationTab = ref('unread')
|
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) {
|
function normalizeNotificationId(value) {
|
||||||
return String(value || '').trim()
|
return String(value || '').trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isNotificationHidden(id) {
|
|
||||||
return hiddenNotificationIds.value.has(normalizeNotificationId(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNotificationTime(value) {
|
function formatNotificationTime(value) {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (!Number.isFinite(date.getTime())) {
|
if (!Number.isFinite(date.getTime())) {
|
||||||
@@ -492,6 +466,7 @@ const documentNotificationItems = computed(() =>
|
|||||||
if (!id || isNotificationHidden(id)) {
|
if (!id || isNotificationHidden(id)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
const unread = Boolean(row.isUnread) && !isNotificationRead(id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -500,10 +475,10 @@ const documentNotificationItems = computed(() =>
|
|||||||
description: resolveDocumentNotificationDescription(row),
|
description: resolveDocumentNotificationDescription(row),
|
||||||
time: formatNotificationTime(row.updatedAt || row.createdAt),
|
time: formatNotificationTime(row.updatedAt || row.createdAt),
|
||||||
category: row.sourceLabel || '单据中心',
|
category: row.sourceLabel || '单据中心',
|
||||||
tone: resolveDocumentNotificationTone(row),
|
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
|
||||||
unread: Boolean(row.isUnread),
|
unread,
|
||||||
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
|
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
|
||||||
badge: row.isUnread ? '新' : '',
|
badge: unread ? '新' : '',
|
||||||
target: {
|
target: {
|
||||||
type: 'document',
|
type: 'document',
|
||||||
id: row.claimId,
|
id: row.claimId,
|
||||||
@@ -593,13 +568,9 @@ function markNotificationRead(item) {
|
|||||||
|
|
||||||
if (item.kind === 'document' && item.documentRow) {
|
if (item.kind === 'document' && item.documentRow) {
|
||||||
markDocumentInboxRowRead(item.documentRow)
|
markDocumentInboxRowRead(item.documentRow)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNotificationIdSet(readNotificationIds, NOTIFICATION_READ_STORAGE_KEY, (next) => {
|
void markNotificationStateRead(item)
|
||||||
next.add(item.id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllNotifications() {
|
function clearAllNotifications() {
|
||||||
@@ -616,10 +587,7 @@ function clearAllNotifications() {
|
|||||||
markDocumentInboxRowsRead(documentRows)
|
markDocumentInboxRowsRead(documentRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateNotificationIdSet(hiddenNotificationIds, NOTIFICATION_HIDDEN_STORAGE_KEY, (next) => {
|
void hideNotificationStates(currentItems)
|
||||||
currentItems.forEach((item) => next.add(item.id))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
notificationTab.value = 'unread'
|
notificationTab.value = 'unread'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,12 +795,20 @@ watch(
|
|||||||
(activeView, previousView) => {
|
(activeView, previousView) => {
|
||||||
if (activeView === 'workbench' && previousView !== 'workbench') {
|
if (activeView === 'workbench' && previousView !== 'workbench') {
|
||||||
clearDocumentInboxInitialRefreshTimer()
|
clearDocumentInboxInitialRefreshTimer()
|
||||||
|
void loadNotificationStates()
|
||||||
void refreshDocumentInbox({ force: true })
|
void refreshDocumentInbox({ force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(notificationOpen, (open) => {
|
||||||
|
if (open) {
|
||||||
|
void loadNotificationStates()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void loadNotificationStates()
|
||||||
scheduleDocumentInboxInitialRefresh()
|
scheduleDocumentInboxInitialRefresh()
|
||||||
startDocumentInboxPolling()
|
startDocumentInboxPolling()
|
||||||
})
|
})
|
||||||
|
|||||||
163
web/src/composables/useTopBarNotificationStates.js
Normal file
163
web/src/composables/useTopBarNotificationStates.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
38
web/src/services/notificationStates.js
Normal file
38
web/src/services/notificationStates.js
Normal file
@@ -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 : []
|
||||||
|
}
|
||||||
@@ -38,6 +38,16 @@ const documentNewState = readFileSync(
|
|||||||
'utf8'
|
'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', () => {
|
test('sidebar no longer renders document center unread indicators', () => {
|
||||||
assert.doesNotMatch(sidebar, /useDocumentCenterInbox/)
|
assert.doesNotMatch(sidebar, /useDocumentCenterInbox/)
|
||||||
assert.doesNotMatch(sidebar, /hasUnread: documentInboxHasUnread/)
|
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', () => {
|
test('topbar bell owns document center unread notifications', () => {
|
||||||
assert.match(topbar, /useDocumentCenterInbox/)
|
assert.match(topbar, /useDocumentCenterInbox/)
|
||||||
|
assert.match(topbar, /useTopBarNotificationStates/)
|
||||||
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
|
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
|
||||||
assert.match(topbar, /const documentNotificationItems = computed/)
|
assert.match(topbar, /const documentNotificationItems = computed/)
|
||||||
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
|
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
|
||||||
assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/)
|
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, /markDocumentInboxRowRead\(item\.documentRow\)/)
|
||||||
|
assert.match(topbar, /markNotificationStateRead\(item\)/)
|
||||||
assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/)
|
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.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/)
|
||||||
assert.doesNotMatch(topbar, /document-center-unread/)
|
assert.doesNotMatch(topbar, /document-center-unread/)
|
||||||
assert.doesNotMatch(topbar, /target: \{ type: 'documents-center' \}/)
|
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(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-copy\s*\{[\s\S]*display:\s*grid;/)
|
||||||
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
|
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-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
|
||||||
assert.match(topbarStyles, /\.notification-close-btn span::after\s*\{[\s\S]*rotate\(-45deg\);/)
|
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-list\s*\{[\s\S]*max-height:\s*244px;[\s\S]*overflow-y:\s*auto;/)
|
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
|
||||||
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*999px;/)
|
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.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
|
||||||
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
|
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', () => {
|
test('document inbox reuses document center viewed-key state', () => {
|
||||||
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
|
||||||
assert.match(documentInbox, /readViewedDocumentKeys/)
|
assert.match(documentInbox, /readViewedDocumentKeys/)
|
||||||
|
|||||||
Reference in New Issue
Block a user