docs(oa): add CC unread badge implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 11:59:52 +08:00
1 parent 48de4be893
commit 6fd23303d2
1 file changed
+817
@@ -0,0 +1,817 @@
# OA「抄送我的」未读角标 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 侧栏「抄送我的」展示未读抄送数;列表支持阅读状态筛选(默认未读)、单条/批量已读,以及查看即已读。
**Architecture:** 在 `oa_task` 增加 `is_read` / `read_time`;引擎层提供未读计数、筛选列表、标记已读;前端复用质检菜单角标模式(Pinia store + `refreshOaMenuBadges`),改造 `cc/index.vue`。
**Tech Stack:** FastAPI + SQLAlchemy + MySQL 5.7;Vue 3 + Element Plus + Pinia;pytest / unittest。
**Spec:** `docs/superpowers/specs/2026-08-06-oa-cc-unread-badge-design.md`
**UI Demo:** `docs/demos/oa-cc-unread-badge-demo.html`
---
## File map
| 文件 | 职责 |
|------|------|
| `docs/sql/oa_task_cc_is_read.sql` | ALTER `oa_task` 增加已读字段 |
| `backend/app/models/models.py` | `OaTask.is_read` / `read_time` |
| `backend/app/services/oa_engine.py` | `_task_to_dict`、建任务默认未读、`list_tasks(read_status)`、`count_cc_unread`、`mark_cc_task_read`、`mark_cc_tasks_read_batch` |
| `backend/app/api/oa_approval.py` | 未读数 / 已读 / 批量已读路由;列表 query |
| `backend/tests/test_oa_cc_read.py` | 序列化、筛选、已读幂等、批量跳过非法 id |
| `web-admin/src/api/oaApproval.ts` | API 封装 |
| `web-admin/src/store/modules/oa-menu-badge.ts` | 角标 store |
| `web-admin/src/utils/oa-menu-badge.ts` | resolve + refresh |
| `web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue` | 接入 OA 角标 |
| `web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue` | 同上 |
| `web-admin/src/router/guards/beforeEach.ts` | 登录后刷新 OA 角标 |
| `web-admin/src/views/oa-approval/cc/index.vue` | 筛选 / 批量已读 / 操作列 |
| `待同步到生产的sql脚本` | 追加本 SQL 引用(若团队习惯) |
---
### Task 1: SQL 增量
**Files:**
- Create: `docs/sql/oa_task_cc_is_read.sql`
- Modify: `待同步到生产的sql脚本`(追加一行说明)
- [ ] **Step 1: 编写 SQL**
```sql
-- OA 抄送任务已读标记(oa_task)
-- MySQL 5.7 / utf8mb4
-- 历史数据全部视为未读(DEFAULT 0)
ALTER TABLE `oa_task`
ADD COLUMN `is_read` TINYINT NOT NULL DEFAULT 0 COMMENT '抄送是否已读 0未读 1已读' AFTER `round_no`,
ADD COLUMN `read_time` DATETIME NULL DEFAULT NULL COMMENT '抄送首次已读时间' AFTER `is_read`;
```
- [ ] **Step 2: 在目标库执行**(本地/测试环境手动执行;本步可不自动化)
- [ ] **Step 3: Commit**
```bash
git add docs/sql/oa_task_cc_is_read.sql 待同步到生产的sql脚本
git commit -m "$(cat <<'EOF'
docs(sql): add oa_task is_read columns for CC unread badge.
EOF
)"
```
---
### Task 2: SQLAlchemy 模型
**Files:**
- Modify: `backend/app/models/models.py`(`OaTask` 类,约 `round_no` 之后)
- [ ] **Step 1: 增加字段**
在 `OaTask` 中 `round_no` 后插入:
```python
is_read = Column(SmallInteger, nullable=False, default=0, comment="抄送是否已读 0未读 1已读")
read_time = Column(DateTime, nullable=True, comment="抄送首次已读时间")
```
确认文件顶部已 `from sqlalchemy import ... SmallInteger, DateTime`(现有已有则勿重复)。
- [ ] **Step 2: Commit**
```bash
git add backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(backend): add is_read fields on OaTask model.
EOF
)"
```
---
### Task 3: 引擎层已读逻辑(TDD)
**Files:**
- Create: `backend/tests/test_oa_cc_read.py`
- Modify: `backend/app/services/oa_engine.py`
- [ ] **Step 1: 写失败测试**
```python
"""OA 抄送已读:序列化、筛选语义、标记幂等"""
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.services import oa_engine as eng
class TestTaskToDictIsRead(unittest.TestCase):
def test_includes_is_read_and_read_time(self):
t = SimpleNamespace(
id="t1",
instance_id="i1",
node_key="n1",
node_name="抄送",
task_type="cc",
assignee_user_id="u1",
status="done",
comment_text=None,
resolve_source=None,
sign_mode=None,
is_read=1,
read_time=datetime(2026, 8, 6, 12, 0, 0),
create_time=datetime(2026, 8, 6, 10, 0, 0),
update_time=datetime(2026, 8, 6, 11, 0, 0),
)
d = eng._task_to_dict(t)
self.assertEqual(d["isRead"], True)
self.assertEqual(d["readTime"], "2026-08-06 12:00:00")
def test_unread_defaults(self):
t = SimpleNamespace(
id="t2",
instance_id="i1",
node_key="n1",
node_name="抄送",
task_type="cc",
assignee_user_id="u1",
status="done",
comment_text=None,
resolve_source=None,
sign_mode=None,
is_read=0,
read_time=None,
create_time=None,
update_time=None,
)
d = eng._task_to_dict(t)
self.assertEqual(d["isRead"], False)
self.assertEqual(d["readTime"], "")
class TestMarkCcReadHelpers(unittest.TestCase):
def test_normalize_read_status(self):
self.assertEqual(eng._normalize_cc_read_status(None), "unread")
self.assertEqual(eng._normalize_cc_read_status(""), "unread")
self.assertEqual(eng._normalize_cc_read_status("all"), "all")
self.assertEqual(eng._normalize_cc_read_status("read"), "read")
with self.assertRaises(eng.OaEngineError):
eng._normalize_cc_read_status("weird")
def test_mark_idempotent_keeps_read_time(self):
first = datetime(2026, 8, 1, 9, 0, 0)
task = SimpleNamespace(
id="t1",
task_type="cc",
assignee_user_id="u1",
del_flag=0,
is_read=1,
read_time=first,
update_by=None,
update_time=None,
)
db = MagicMock()
out = eng._apply_cc_task_read(db, task=task, user_id="u1", now=datetime(2026, 8, 6, 12, 0, 0))
self.assertFalse(out) # 未发生更新
self.assertEqual(task.read_time, first)
self.assertEqual(task.is_read, 1)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_cc_read.py -v
```
Expected: FAIL(缺 `_normalize_cc_read_status` / `_apply_cc_task_read` 或 `_task_to_dict` 缺字段)
- [ ] **Step 3: 实现 `_task_to_dict` 扩展**
在 `_task_to_dict` 返回值中增加(兼容旧 ORM 无属性时用 `getattr`):
```python
"isRead": bool(int(getattr(t, "is_read", 0) or 0)),
"readTime": (
t.read_time.strftime("%Y-%m-%d %H:%M:%S")
if getattr(t, "read_time", None)
else ""
),
```
- [ ] **Step 4: 实现辅助函数与公开 API**(放在 `oa_engine.py`,靠近 `list_tasks`)
```python
def _normalize_cc_read_status(raw: Optional[str]) -> str:
v = (raw or "").strip().lower() or "unread"
if v not in ("unread", "read", "all"):
raise OaEngineError("readStatus 仅支持 unread、read、all")
return v
def _apply_cc_task_read(
db: Session,
*,
task: OaTask,
user_id: str,
now: datetime,
) -> bool:
"""将抄送任务标为已读。返回是否发生了写入。幂等时返回 False。"""
if int(getattr(task, "is_read", 0) or 0) == 1:
return False
task.is_read = 1
task.read_time = now
task.update_by = user_id
task.update_time = now
db.add(task)
return True
def count_cc_unread(db: Session, *, tenant_no: str, user_id: str) -> int:
return (
db.query(OaTask)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "cc",
OaTask.is_read == 0,
)
.count()
)
def mark_cc_task_read(
db: Session, *, tenant_no: str, user_id: str, task_id: str
) -> Dict[str, Any]:
task = (
db.query(OaTask)
.filter(
OaTask.id == task_id,
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
)
.first()
)
if not task or task.task_type != "cc" or task.assignee_user_id != user_id:
raise OaEngineError("抄送任务不存在或无权操作")
now = datetime.now()
_apply_cc_task_read(db, task=task, user_id=user_id, now=now)
db.commit()
db.refresh(task)
return _task_to_dict(task)
def mark_cc_tasks_read_batch(
db: Session, *, tenant_no: str, user_id: str, ids: List[str]
) -> Dict[str, Any]:
id_list = [x.strip() for x in (ids or []) if (x or "").strip()]
if not id_list:
return {"updated": 0}
rows = (
db.query(OaTask)
.filter(
OaTask.tenant_no == tenant_no,
OaTask.del_flag == 0,
OaTask.assignee_user_id == user_id,
OaTask.task_type == "cc",
OaTask.id.in_(id_list),
)
.all()
)
now = datetime.now()
updated = 0
for t in rows:
if _apply_cc_task_read(db, task=t, user_id=user_id, now=now):
updated += 1
db.commit()
return {"updated": updated}
```
- [ ] **Step 5: 扩展 `list_tasks`**
签名增加 `read_status: Optional[str] = None`。在 `kind == "cc"` 分支,过滤后:
```python
rs = _normalize_cc_read_status(read_status)
if rs == "unread":
q = q.filter(OaTask.is_read == 0)
elif rs == "read":
q = q.filter(OaTask.is_read == 1)
# all: 不加 is_read 条件
```
其他 `kind` 忽略 `read_status`。
- [ ] **Step 6: 创建抄送任务时设未读**
在 `_create_tasks_for_node` 构造 `OaTask(...)` 时增加:
```python
is_read=0,
read_time=None,
```
- [ ] **Step 7: 跑测通过**
```bash
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_cc_read.py -v
```
Expected: PASS
- [ ] **Step 8: Commit**
```bash
git add backend/app/services/oa_engine.py backend/tests/test_oa_cc_read.py
git commit -m "$(cat <<'EOF'
feat(backend): CC task read status in oa_engine.
EOF
)"
```
---
### Task 4: API 路由
**Files:**
- Modify: `backend/app/api/oa_approval.py`
- [ ] **Step 1: 在 `list_oa_tasks` 之前注册更具体路由**(避免被 `{kind}` 误伤;`/tasks/cc/unread-count` 为三段路径,本就不匹配两段 `{kind}`,仍建议集中放在 tasks 区块顶部)
```python
class OaCcReadBatchBody(BaseModel):
model_config = ConfigDict(populate_by_name=True)
ids: List[str] = Field(default_factory=list)
@router.get("/tasks/cc/unread-count")
async def oa_cc_unread_count(
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError, count_cc_unread
try:
tenant_no = _get_tenant_no(db, user)
n = count_cc_unread(
db, tenant_no=tenant_no, user_id=user.get("user_id") or ""
)
return success_response({"count": int(n)})
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/tasks/cc/read-batch")
async def oa_cc_read_batch(
body: OaCcReadBatchBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError, mark_cc_tasks_read_batch
try:
tenant_no = _get_tenant_no(db, user)
data = mark_cc_tasks_read_batch(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
ids=body.ids or [],
)
return success_response(data, message="已标记已读")
except OaEngineError as e:
return error_response(400, str(e))
@router.post("/tasks/{task_id}/read")
async def oa_task_mark_read(
task_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError, mark_cc_task_read
try:
tenant_no = _get_tenant_no(db, user)
data = mark_cc_task_read(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
task_id=task_id,
)
return success_response(data, message="已标记已读")
except OaEngineError as e:
return error_response(400, str(e))
```
- [ ] **Step 2: 扩展 `list_oa_tasks`**
```python
@router.get("/tasks/{kind}")
async def list_oa_tasks(
kind: str,
readStatus: Optional[str] = Query(None, alias="readStatus"),
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
from app.services.oa_engine import OaEngineError, list_tasks
try:
tenant_no = _get_tenant_no(db, user)
items = list_tasks(
db,
tenant_no=tenant_no,
user_id=user.get("user_id") or "",
kind=kind,
read_status=readStatus,
)
return success_response({"list": items})
except OaEngineError as e:
return error_response(400, str(e))
```
- [ ] **Step 3: Commit**
```bash
git add backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(api): expose CC unread count and mark-read endpoints.
EOF
)"
```
---
### Task 5: 前端 API
**Files:**
- Modify: `web-admin/src/api/oaApproval.ts`(`fetchOaTasks` 附近)
- [ ] **Step 1: 替换/扩展任务相关 API**
```typescript
export function fetchOaTasks(
kind: 'pending' | 'done' | 'mine' | 'cc',
params?: { readStatus?: 'unread' | 'read' | 'all' }
) {
return request.get<{ list: any[] }>({
url: `${BASE}/tasks/${kind}`,
params: kind === 'cc' ? { readStatus: params?.readStatus ?? 'unread' } : undefined
})
}
export function fetchOaCcUnreadCount() {
return request.get<{ count: number }>({ url: `${BASE}/tasks/cc/unread-count` })
}
export function fetchMarkOaCcTaskRead(taskId: string) {
return request.post({
url: `${BASE}/tasks/${taskId}/read`,
showSuccessMessage: false
})
}
export function fetchBatchMarkOaCcTasksRead(ids: string[]) {
return request.post({
url: `${BASE}/tasks/cc/read-batch`,
data: { ids },
showSuccessMessage: true
})
}
export function formatOaCcUnreadBadgeCount(count: number): string {
return count > 99 ? '99+' : String(count)
}
```
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): OA CC read/unread API helpers.
EOF
)"
```
---
### Task 6: 侧栏角标 store + 菜单接入
**Files:**
- Create: `web-admin/src/store/modules/oa-menu-badge.ts`
- Create: `web-admin/src/utils/oa-menu-badge.ts`
- Modify: `web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue`
- Modify: `web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue`
- Modify: `web-admin/src/router/guards/beforeEach.ts`
- [ ] **Step 1: Store**(对齐 `qc-menu-badge.ts`)
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { formatOaCcUnreadBadgeCount } from '@/api/oaApproval'
export const OA_MENU_CODE_CC = 'oa:cc'
export const useOaMenuBadgeStore = defineStore('oaMenuBadgeStore', () => {
const badgeByMenuCode = ref<Record<string, number>>({})
const loadedMenuCodes = ref<string[]>([])
function setBadge(menuCode: string, count: number) {
badgeByMenuCode.value = {
...badgeByMenuCode.value,
[menuCode]: Math.max(0, Number(count) || 0)
}
if (!loadedMenuCodes.value.includes(menuCode)) {
loadedMenuCodes.value = [...loadedMenuCodes.value, menuCode]
}
}
function isLoaded(menuCode?: string): boolean {
if (!menuCode) return false
return loadedMenuCodes.value.includes(menuCode)
}
function getCount(menuCode?: string): number {
if (!menuCode || !isLoaded(menuCode)) return 0
return badgeByMenuCode.value[menuCode] ?? 0
}
function getBadgeText(menuCode?: string): string {
if (!menuCode || !isLoaded(menuCode)) return ''
const n = getCount(menuCode)
if (n <= 0) return ''
return formatOaCcUnreadBadgeCount(n)
}
function clearBadges() {
badgeByMenuCode.value = {}
loadedMenuCodes.value = []
}
return {
badgeByMenuCode,
loadedMenuCodes,
setBadge,
isLoaded,
getCount,
getBadgeText,
clearBadges
}
})
```
- [ ] **Step 2: `utils/oa-menu-badge.ts`**
```typescript
import { fetchOaCcUnreadCount } from '@/api/oaApproval'
import { OA_MENU_CODE_CC, useOaMenuBadgeStore } from '@/store/modules/oa-menu-badge'
import { useMenuStore } from '@/store/modules/menu'
import type { AppRouteRecord } from '@/types/router'
export function resolveOaMenuBadgeCode(item: AppRouteRecord): string | undefined {
const code = (item.meta.menuCode || '').trim()
if (code === OA_MENU_CODE_CC) return code
if (String(item.name || '') === 'OaApprovalCc') return OA_MENU_CODE_CC
const path = String(item.path || '')
if (path.endsWith('/cc') || path === 'cc') return OA_MENU_CODE_CC
return undefined
}
export function collectVisibleOaMenuBadgeCodes(menuList: AppRouteRecord[]): string[] {
const codes = new Set<string>()
const walk = (items: AppRouteRecord[]) => {
for (const item of items) {
const code = resolveOaMenuBadgeCode(item)
if (code) codes.add(code)
if (item.children?.length) walk(item.children)
}
}
walk(menuList || [])
return [...codes]
}
export async function refreshOaMenuBadges() {
const menuStore = useMenuStore()
const badgeStore = useOaMenuBadgeStore()
const visibleCodes = collectVisibleOaMenuBadgeCodes(menuStore.menuList)
if (!visibleCodes.includes(OA_MENU_CODE_CC)) return
try {
const data = await fetchOaCcUnreadCount()
badgeStore.setBadge(OA_MENU_CODE_CC, data?.count ?? 0)
} catch {
badgeStore.setBadge(OA_MENU_CODE_CC, 0)
}
}
```
- [ ] **Step 3: SidebarSubmenu / HorizontalSubmenu**
在 `resolveMenuTextBadge` 中,于质检/生产判断之后(或之前)增加:
```typescript
import { useOaMenuBadgeStore } from '@/store/modules/oa-menu-badge'
import { resolveOaMenuBadgeCode } from '@/utils/oa-menu-badge'
const oaMenuBadgeStore = useOaMenuBadgeStore()
const { loadedMenuCodes: oaLoadedMenuCodes } = storeToRefs(oaMenuBadgeStore)
// resolveMenuTextBadge 内:
const oaCode = resolveOaMenuBadgeCode(item)
if (oaCode && oaLoadedMenuCodes.value.includes(oaCode)) {
return oaMenuBadgeStore.getBadgeText(oaCode)
}
```
`menuTextBadgeClass`:OA 角标无需 `--zero`(0 时已返回空串)。
- [ ] **Step 4: `beforeEach.ts`**
```typescript
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
// 与质检/生产并列:
void refreshOaMenuBadges()
```
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/store/modules/oa-menu-badge.ts \
web-admin/src/utils/oa-menu-badge.ts \
web-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue \
web-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue \
web-admin/src/router/guards/beforeEach.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): sidebar badge for OA CC unread count.
EOF
)"
```
---
### Task 7: 「抄送我的」列表页
**Files:**
- Modify: `web-admin/src/views/oa-approval/cc/index.vue`
对齐模板:`web-admin/src/views/system/unit/index.vue`(`filter-block` + `list-toolbar` + selection)。
- [ ] **Step 1: 模板结构**
要点(完整实现时写入同一文件):
1. `panel-top` 保留「抄送我的」。
2. `filter-block`:`el-select` 绑定 `readStatus`,选项:
- `{ label: '未读', value: 'unread' }`
- `{ label: '已读', value: 'read' }`
- `{ label: '全部', value: 'all' }`
- 默认 `unread`;重置回到 `unread`;搜索触发 `load`。
3. `list-toolbar`:已选 N 项 +「批量已读」按钮(`Check` 图标,非 `type="primary"`);`selectedRows` 中无未读时 `disabled`。
4. `el-table`:`row-key="id"`、`@selection-change`;增加 `type="selection"`。
5. 「阅读状态」列:`el-tag` `danger`/`info`。
6. 流程名称:未读时名前红点(可用 inline span,颜色 `#fd4e4e`)。
7. 操作列:查看 / 已读 / 打印;已读行禁用「已读」。
- [ ] **Step 2: script 关键逻辑**
```typescript
import {
fetchOaInstanceDetail,
fetchOaTasks,
fetchMarkOaCcTaskRead,
fetchBatchMarkOaCcTasksRead
} from '@/api/oaApproval'
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
import { Check, Printer, RefreshLeft, Search, View } from '@element-plus/icons-vue'
const readStatus = ref<'unread' | 'read' | 'all'>('unread')
const selectedRows = ref<any[]>([])
async function load() {
loading.value = true
try {
const res = await fetchOaTasks('cc', { readStatus: readStatus.value })
rows.value = res?.list || []
} catch {
rows.value = []
} finally {
loading.value = false
selectedRows.value = []
void refreshOaMenuBadges()
}
}
async function markOneRead(row: any) {
const id = String(row?.id || '').trim()
if (!id || row.isRead) return
await fetchMarkOaCcTaskRead(id)
await load()
}
async function openDetail(row: any) {
const id = row.instanceId
const taskId = String(row?.id || '').trim()
if (!id) return
if (taskId && !row.isRead) {
try {
await fetchMarkOaCcTaskRead(taskId)
} catch {
/* 仍允许进入详情 */
}
void refreshOaMenuBadges()
}
router.push({ path: `/system-manage/oa-approval/instances/${id}` })
}
async function handleBatchRead() {
const ids = selectedRows.value
.filter((r) => !r.isRead)
.map((r) => String(r.id || '').trim())
.filter(Boolean)
if (!ids.length) return
await fetchBatchMarkOaCcTasksRead(ids)
await load()
}
```
确认:`App.vue` 的 `panelTitleBarTextRules` 若尚无「抄送我的」则补上(竖条标题)。
- [ ] **Step 3: 手工验收**(登录后)
1. 侧栏角标 = 未读数。
2. 默认列表仅未读;改筛选可见已读/全部。
3. 已读 / 批量已读 / 查看后角标与列表同步。
4. 打印不改变已读。
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/views/oa-approval/cc/index.vue web-admin/src/App.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): CC list read filter, mark-read, and batch read.
EOF
)"
```
---
### Task 8: 图谱更新
**Files:** 由脚本更新 `backend/graphify-out/*`、`web-admin/graphify-out/*`
- [ ] **Step 1: 跑增量图谱**
```bash
./scripts/update-graph.sh all
```
- [ ] **Step 2: 若有变更则 commit**
```bash
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after OA CC unread badge.
EOF
)"
```
---
## Spec coverage checklist
| Spec 项 | Task |
|---------|------|
| `is_read` / `read_time` + 历史默认未读 | 1, 2, 3 |
| 未读数 API | 3, 4 |
| 列表 `readStatus` 默认 unread | 3, 4, 5, 7 |
| 单条 / 批量已读 | 3, 4, 5, 7 |
| 查看即已读、打印不读 | 7 |
| 侧栏角标、0 隐藏、99+ | 6 |
| 筛选下拉无 Tab | 7 |
| 操作列 + 批量已读 | 7 |
---
## Self-review notes
- 无 TBD;函数名前后一致(`count_cc_unread` / `mark_cc_task_read` / `mark_cc_tasks_read_batch`)。
- FastAPI:`GET /tasks/cc/unread-count` 为三路径段,不与 `GET /tasks/{kind}` 冲突。
- 前端 `isRead` 以 API 布尔为准;批量仅提交未读 id。