docs(oa): add CC unread badge implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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。
|
||||
Reference in new issue
Block a user