From 6fd23303d200403f15e86a5defcf59ea87dbf8a2 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 6 Aug 2026 11:59:52 +0800 Subject: [PATCH] docs(oa): add CC unread badge implementation plan. Co-authored-by: Cursor --- .../plans/2026-08-06-oa-cc-unread-badge.md | 817 ++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-oa-cc-unread-badge.md diff --git a/docs/superpowers/plans/2026-08-06-oa-cc-unread-badge.md b/docs/superpowers/plans/2026-08-06-oa-cc-unread-badge.md new file mode 100644 index 00000000..e5b94476 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-oa-cc-unread-badge.md @@ -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>({}) + const loadedMenuCodes = ref([]) + + 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() + 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([]) + +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。