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
| 文件 | 职责 |
|---|---|
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 引用(若团队习惯) |
Files:
docs/sql/oa_task_cc_is_read.sqlModify: 待同步到生产的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
)"
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(现有已有则勿重复)。
git add backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(backend): add is_read fields on OaTask model.
EOF
)"
Files:
backend/tests/test_oa_cc_read.pyModify: 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()
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 缺字段)
_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 ""
),
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}
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。
在 _create_tasks_for_node 构造 OaTask(...) 时增加:
is_read=0,
read_time=None,
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_cc_read.py -v
Expected: PASS
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
)"
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))
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))
git add backend/app/api/oa_approval.py
git commit -m "$(cat <<'EOF'
feat(api): expose CC unread count and mark-read endpoints.
EOF
)"
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)
}
git add web-admin/src/api/oaApproval.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): OA CC read/unread API helpers.
EOF
)"
Files:
web-admin/src/store/modules/oa-menu-badge.tsweb-admin/src/utils/oa-menu-badge.tsweb-admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vueweb-admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vueModify: 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
}
})
utils/oa-menu-badge.tsimport { 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)
}
}
在 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 时已返回空串)。
beforeEach.tsimport { refreshOaMenuBadges } from '@/utils/oa-menu-badge'
// 与质检/生产并列:
void refreshOaMenuBadges()
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
)"
Files:
web-admin/src/views/oa-approval/cc/index.vue对齐模板:web-admin/src/views/system/unit/index.vue(filter-block + list-toolbar + selection)。
要点(完整实现时写入同一文件):
panel-top 保留「抄送我的」。filter-block:el-select 绑定 readStatus,选项:
{ label: '未读', value: 'unread' }{ label: '已读', value: 'read' }{ label: '全部', value: 'all' }unread;重置回到 unread;搜索触发 load。list-toolbar:已选 N 项 +「批量已读」按钮(Check 图标,非 type="primary");selectedRows 中无未读时 disabled。el-table:row-key="id"、@selection-change;增加 type="selection"。el-tag danger/info。#fd4e4e)。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 若尚无「抄送我的」则补上(竖条标题)。
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
)"
Files: 由脚本更新 backend/graphify-out/*、web-admin/graphify-out/*
./scripts/update-graph.sh all
git add backend/graphify-out web-admin/graphify-out
git commit -m "$(cat <<'EOF'
chore: update graphify after OA CC unread badge.
EOF
)"
| 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 |
count_cc_unread / mark_cc_task_read / mark_cc_tasks_read_batch)。GET /tasks/cc/unread-count 为三路径段,不与 GET /tasks/{kind} 冲突。isRead 以 API 布尔为准;批量仅提交未读 id。