2026-08-06-oa-cc-unread-badge.md 23 KB

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

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

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 后插入:

    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
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: 写失败测试

"""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: 跑测确认失败
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):

        "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)
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" 分支,过滤后:

        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(...) 时增加:

            is_read=0,
            read_time=None,
  • Step 7: 跑测通过
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_cc_read.py -v

Expected: PASS

  • Step 8: Commit
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 区块顶部)

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

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

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
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 中,于质检/生产判断之后(或之前)增加:

  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
import { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
// 与质检/生产并列:
void refreshOaMenuBadges()
  • Step 5: Commit
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 关键逻辑
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
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: 跑增量图谱
./scripts/update-graph.sh all
  • Step 2: 若有变更则 commit
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。