Explorar el Código

docs(oa): add CC unread badge design and demo

Capture the approved design for sidebar unread counts and read/batch-read flows on 抄送我的, with an interactive HTML demo for UI reference.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 3 meses
padre
commit
48de4be893

+ 583 - 0
docs/demos/oa-cc-unread-badge-demo.html

@@ -0,0 +1,583 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title>OA 抄送我的 · 未读角标 Demo</title>
+  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --text: #1e293b;
+      --text-muted: #64748b;
+      --border: #d9e8e2;
+      --bg-page: #f8fafc;
+      --surface: #ffffff;
+      --table-head: #f8fafc;
+      --row-alt: #fafafa;
+      --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
+      --radius: 10px;
+      --warn: #d97706;
+      --warn-bg: #fffbeb;
+      --danger: #fd4e4e;
+      --badge-zero: #94a3b8;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
+      font-size: 14px;
+      line-height: 1.5;
+      color: var(--text);
+      background: #eef2f0;
+      padding: 24px;
+    }
+    .page-head {
+      max-width: 1180px;
+      margin: 0 auto 16px;
+    }
+    .page-head h1 {
+      margin: 0 0 6px;
+      font-size: 22px;
+      font-weight: 700;
+    }
+    .page-head p {
+      margin: 0;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+    .shell-wrap {
+      max-width: 1180px;
+      margin: 0 auto;
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      box-shadow: var(--shadow);
+      overflow: hidden;
+    }
+    .shell { display: flex; min-height: 620px; }
+    .sidebar {
+      width: 220px;
+      flex-shrink: 0;
+      border-right: 1px solid var(--border);
+      background: #fff;
+      padding: 16px 12px;
+    }
+    .logo {
+      font-weight: 700;
+      padding: 4px 12px 16px;
+      border-bottom: 1px solid var(--border);
+      margin-bottom: 12px;
+    }
+    .logo span { color: var(--primary); }
+    .nav-group {
+      font-size: 12px;
+      font-weight: 700;
+      color: var(--text-muted);
+      padding: 8px 12px;
+      letter-spacing: 0.02em;
+    }
+    .nav-item {
+      position: relative;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 9px 12px;
+      border-radius: 8px;
+      color: var(--text);
+      text-decoration: none;
+      font-size: 13px;
+      margin-bottom: 2px;
+      cursor: pointer;
+      border: none;
+      background: transparent;
+      width: 100%;
+      text-align: left;
+      font-family: inherit;
+    }
+    .nav-item:hover { background: #f1f5f9; }
+    .nav-item.active {
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      font-weight: 600;
+    }
+    .nav-item .menu-name { flex: 1; min-width: 0; }
+    /* 对齐 web-admin .art-text-badge */
+    .art-text-badge {
+      min-width: 20px;
+      height: 18px;
+      line-height: 17px;
+      padding: 0 5px;
+      font-size: 10px;
+      font-weight: 700;
+      color: #fff;
+      text-align: center;
+      background: var(--danger);
+      border-radius: 4px;
+      flex-shrink: 0;
+    }
+    .art-text-badge.is-zero {
+      background: var(--badge-zero);
+    }
+    .nav-item.parent { font-weight: 600; }
+    .nav-children { padding-left: 8px; margin-bottom: 8px; }
+    .main { flex: 1; background: var(--bg-page); padding: 20px; min-width: 0; }
+    .content-card {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: var(--radius);
+      overflow: hidden;
+    }
+    .panel-top {
+      padding: 16px 20px;
+      border-bottom: 1px solid var(--border);
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+    .panel-top h2 {
+      margin: 0;
+      font-size: 16px;
+      font-weight: 700;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .panel-top h2::before {
+      content: '';
+      width: 3px;
+      height: 16px;
+      background: var(--primary);
+      border-radius: 2px;
+    }
+    .panel-meta {
+      margin-left: auto;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+    .panel-meta strong { color: var(--danger); }
+    .list-body { padding: 0; }
+    table { width: 100%; border-collapse: collapse; }
+    th, td {
+      padding: 12px 14px;
+      text-align: center;
+      border-bottom: 1px solid #eef2f0;
+      font-size: 13px;
+    }
+    th {
+      background: var(--table-head);
+      color: var(--text-muted);
+      font-weight: 600;
+      font-size: 12px;
+    }
+    tr:nth-child(even) td { background: var(--row-alt); }
+    tr.unread td:first-child .title-cell {
+      font-weight: 700;
+    }
+    .title-cell {
+      display: inline-flex;
+      align-items: center;
+      gap: 6px;
+      justify-content: center;
+    }
+    .dot-unread {
+      width: 7px;
+      height: 7px;
+      border-radius: 50%;
+      background: var(--danger);
+      flex-shrink: 0;
+    }
+    .tag {
+      display: inline-block;
+      padding: 2px 8px;
+      border-radius: 4px;
+      font-size: 12px;
+      font-weight: 600;
+    }
+    .tag-running { background: var(--primary-bg); color: var(--primary-dark); }
+    .tag-done { background: #f1f5f9; color: var(--text-muted); }
+    .tag-unread { background: #fef2f2; color: #dc2626; }
+    .tag-read { background: #f1f5f9; color: var(--text-muted); }
+    .ops { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; }
+    .btn {
+      display: inline-flex;
+      align-items: center;
+      gap: 5px;
+      height: 30px;
+      padding: 0 10px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 12px;
+      font-weight: 500;
+      cursor: pointer;
+      font-family: inherit;
+    }
+    .btn:hover { background: #f8fafc; }
+    .btn-primary {
+      color: var(--primary-dark);
+      border-color: #a7f3d0;
+      background: var(--primary-bg);
+    }
+    .btn-primary:hover { background: #d1fae5; }
+    .btn:disabled {
+      opacity: 0.45;
+      cursor: not-allowed;
+    }
+    .toast {
+      position: fixed;
+      right: 28px;
+      bottom: 28px;
+      background: #0f172a;
+      color: #fff;
+      padding: 10px 14px;
+      border-radius: 8px;
+      font-size: 13px;
+      opacity: 0;
+      transform: translateY(8px);
+      transition: 0.2s ease;
+      pointer-events: none;
+      z-index: 20;
+    }
+    .toast.show { opacity: 1; transform: translateY(0); }
+    .note {
+      max-width: 1180px;
+      margin: 14px auto 0;
+      padding: 12px 14px;
+      background: #fff;
+      border: 1px dashed #a7f3d0;
+      border-radius: 8px;
+      color: var(--text-muted);
+      font-size: 13px;
+    }
+    .note strong { color: var(--primary-dark); }
+    .note ul { margin: 8px 0 0; padding-left: 18px; }
+    .ref-row {
+      max-width: 1180px;
+      margin: 16px auto 0;
+      display: grid;
+      grid-template-columns: 1fr 1fr;
+      gap: 12px;
+    }
+    .ref-card {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      padding: 14px 16px;
+    }
+    .ref-card h3 {
+      margin: 0 0 10px;
+      font-size: 13px;
+      font-weight: 700;
+    }
+    .ref-menu {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 10px 12px;
+      border-radius: 8px;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      font-weight: 600;
+      font-size: 13px;
+    }
+    .ref-menu .spacer { flex: 1; }
+    .legend {
+      margin-top: 8px;
+      font-size: 12px;
+      color: var(--text-muted);
+    }
+    /* 简易详情抽屉示意 */
+    .drawer-mask {
+      display: none;
+      position: fixed;
+      inset: 0;
+      background: rgba(15, 23, 42, 0.35);
+      z-index: 30;
+      align-items: stretch;
+      justify-content: flex-end;
+    }
+    .drawer-mask.show { display: flex; }
+    .drawer {
+      width: min(420px, 100%);
+      background: #fff;
+      box-shadow: -8px 0 30px rgba(0, 0, 0, 0.12);
+      display: flex;
+      flex-direction: column;
+    }
+    .drawer-head {
+      padding: 16px 18px;
+      border-bottom: 1px solid var(--border);
+      display: flex;
+      align-items: center;
+      gap: 10px;
+    }
+    .drawer-head h3 { margin: 0; font-size: 15px; flex: 1; }
+    .drawer-body { padding: 18px; flex: 1; color: var(--text-muted); font-size: 13px; }
+    .drawer-body dl { margin: 0; display: grid; gap: 10px; }
+    .drawer-body dt { font-size: 11px; color: var(--text-muted); }
+    .drawer-body dd { margin: 2px 0 0; color: var(--text); font-weight: 600; }
+    .drawer-foot {
+      padding: 12px 18px;
+      border-top: 1px solid var(--border);
+      display: flex;
+      justify-content: flex-end;
+      gap: 8px;
+    }
+    .btn-lg { height: 36px; padding: 0 14px; font-size: 13px; }
+    .btn-main {
+      background: var(--primary);
+      border-color: var(--primary);
+      color: #fff;
+    }
+    .btn-main:hover { background: var(--primary-dark); }
+  </style>
+</head>
+<body>
+  <div class="page-head">
+    <h1>OA「抄送我的」未读角标 · Demo</h1>
+    <p>侧栏数字对齐「质检工单」红色文字角标;列表支持「已读」,点「查看」默认标记已读。</p>
+  </div>
+
+  <div class="shell-wrap">
+    <div class="shell">
+      <aside class="sidebar">
+        <div class="logo">陇耘<span>鲜达</span></div>
+        <div class="nav-group">质量检测(参照)</div>
+        <div class="nav-item parent">
+          <span class="menu-name">质检工单</span>
+          <span class="art-text-badge">12</span>
+        </div>
+        <div class="nav-group">OA审批</div>
+        <div class="nav-children">
+          <button class="nav-item" type="button">待处理</button>
+          <button class="nav-item" type="button">已处理</button>
+          <button class="nav-item" type="button">我发起的</button>
+          <button class="nav-item active" type="button" id="nav-cc">
+            <span class="menu-name">抄送我的</span>
+            <span class="art-text-badge" id="menu-badge">3</span>
+          </button>
+          <button class="nav-item" type="button">草稿箱</button>
+        </div>
+      </aside>
+
+      <main class="main">
+        <section class="content-card">
+          <div class="panel-top">
+            <h2>抄送我的</h2>
+            <div class="panel-meta">未读 <strong id="unread-count">3</strong> 条 · 可点操作栏按钮体验</div>
+          </div>
+          <div class="list-body">
+            <table>
+              <thead>
+                <tr>
+                  <th style="min-width:68px">序号</th>
+                  <th style="min-width:160px">流程名称</th>
+                  <th style="min-width:100px">申请人</th>
+                  <th style="min-width:150px">申请时间</th>
+                  <th style="min-width:110px">当前节点</th>
+                  <th style="min-width:100px">流程状态</th>
+                  <th style="min-width:90px">阅读状态</th>
+                  <th style="min-width:280px">操作</th>
+                </tr>
+              </thead>
+              <tbody id="tbody"></tbody>
+            </table>
+          </div>
+        </section>
+      </main>
+    </div>
+  </div>
+
+  <div class="ref-row">
+    <div class="ref-card">
+      <h3>参照:质检工单菜单角标</h3>
+      <div class="ref-menu">
+        <span>质检工单</span>
+        <span class="spacer"></span>
+        <span class="art-text-badge">12</span>
+      </div>
+      <p class="legend">红色小块数字 · 0 时灰底(本 Demo 未读为 0 时隐藏角标,也可改成灰「0」)</p>
+    </div>
+    <div class="ref-card">
+      <h3>本需求:抄送我的</h3>
+      <div class="ref-menu">
+        <span>抄送我的</span>
+        <span class="spacer"></span>
+        <span class="art-text-badge" id="ref-badge">3</span>
+      </div>
+      <p class="legend">统计「抄送给我且尚未阅读」的记录数;列表操作可消减</p>
+    </div>
+  </div>
+
+  <div class="note">
+    <strong>交互约定(Demo 可点):</strong>
+    <ul>
+      <li><strong>已读</strong>:仅标记已读,不打开详情;按钮在已读后禁用。</li>
+      <li><strong>查看</strong>:打开详情示意抽屉,同时默认标记为已读,侧栏数字同步减少。</li>
+      <li>未读行:流程名称前有红色圆点;阅读状态列显示「未读 / 已读」Tag。</li>
+    </ul>
+  </div>
+
+  <div class="drawer-mask" id="drawer">
+    <div class="drawer">
+      <div class="drawer-head">
+        <h3 id="drawer-title">审批详情</h3>
+        <button class="btn btn-lg" type="button" id="drawer-close">关闭</button>
+      </div>
+      <div class="drawer-body">
+        <dl>
+          <div>
+            <dt>流程名称</dt>
+            <dd id="d-name">-</dd>
+          </div>
+          <div>
+            <dt>申请人</dt>
+            <dd id="d-user">-</dd>
+          </div>
+          <div>
+            <dt>申请时间</dt>
+            <dd id="d-time">-</dd>
+          </div>
+          <div>
+            <dt>说明</dt>
+            <dd style="font-weight:500;color:var(--text-muted)">打开详情即视为已读(与需求一致)。</dd>
+          </div>
+        </dl>
+      </div>
+      <div class="drawer-foot">
+        <button class="btn btn-lg btn-main" type="button" id="drawer-ok">知道了</button>
+      </div>
+    </div>
+  </div>
+
+  <div class="toast" id="toast"></div>
+
+  <script>
+    const rows = [
+      { id: '1', name: '请假申请', user: '张三', time: '2026-08-05 09:12', node: '部门负责人', status: 'running', read: false },
+      { id: '2', name: '费用报销', user: '李四', time: '2026-08-04 16:40', node: '财务审核', status: 'running', read: false },
+      { id: '3', name: '出差申请', user: '王五', time: '2026-08-03 11:05', node: '结束', status: 'done', read: false },
+      { id: '4', name: '用章申请', user: '赵六', time: '2026-08-01 14:22', node: '结束', status: 'done', read: true },
+      { id: '5', name: '采购申请', user: '钱七', time: '2026-07-28 10:08', node: '总经理', status: 'running', read: true }
+    ]
+
+    const tbody = document.getElementById('tbody')
+    const menuBadge = document.getElementById('menu-badge')
+    const refBadge = document.getElementById('ref-badge')
+    const unreadCountEl = document.getElementById('unread-count')
+    const toastEl = document.getElementById('toast')
+    const drawer = document.getElementById('drawer')
+
+    function unreadCount() {
+      return rows.filter((r) => !r.read).length
+    }
+
+    function formatBadge(n) {
+      if (n <= 0) return ''
+      return n > 99 ? '99+' : String(n)
+    }
+
+    function syncBadge() {
+      const n = unreadCount()
+      const text = formatBadge(n)
+      unreadCountEl.textContent = String(n)
+      ;[menuBadge, refBadge].forEach((el) => {
+        if (!text) {
+          el.style.display = 'none'
+          el.textContent = ''
+        } else {
+          el.style.display = ''
+          el.textContent = text
+          el.classList.toggle('is-zero', false)
+        }
+      })
+    }
+
+    function toast(msg) {
+      toastEl.textContent = msg
+      toastEl.classList.add('show')
+      clearTimeout(toast._t)
+      toast._t = setTimeout(() => toastEl.classList.remove('show'), 1600)
+    }
+
+    function markRead(id, source) {
+      const row = rows.find((r) => r.id === id)
+      if (!row || row.read) return false
+      row.read = true
+      render()
+      toast(source === 'view' ? '已查看,标记为已读' : '已标记为已读')
+      return true
+    }
+
+    function openDetail(id) {
+      const row = rows.find((r) => r.id === id)
+      if (!row) return
+      markRead(id, 'view')
+      document.getElementById('drawer-title').textContent = '抄送详情'
+      document.getElementById('d-name').textContent = row.name
+      document.getElementById('d-user').textContent = row.user
+      document.getElementById('d-time').textContent = row.time
+      drawer.classList.add('show')
+    }
+
+    function closeDrawer() {
+      drawer.classList.remove('show')
+    }
+
+    function render() {
+      tbody.innerHTML = rows
+        .map((r, i) => {
+          const statusTag =
+            r.status === 'running'
+              ? '<span class="tag tag-running">进行中</span>'
+              : '<span class="tag tag-done">已完成</span>'
+          const readTag = r.read
+            ? '<span class="tag tag-read">已读</span>'
+            : '<span class="tag tag-unread">未读</span>'
+          const title = r.read
+            ? `<span class="title-cell">${r.name}</span>`
+            : `<span class="title-cell"><span class="dot-unread"></span>${r.name}</span>`
+          return `<tr class="${r.read ? '' : 'unread'}" data-id="${r.id}">
+            <td>${i + 1}</td>
+            <td>${title}</td>
+            <td>${r.user}</td>
+            <td>${r.time}</td>
+            <td>${r.node}</td>
+            <td>${statusTag}</td>
+            <td>${readTag}</td>
+            <td>
+              <div class="ops">
+                <button class="btn btn-primary" data-act="view" data-id="${r.id}" type="button">👁 查看</button>
+                <button class="btn" data-act="read" data-id="${r.id}" type="button" ${r.read ? 'disabled' : ''}>✓ 已读</button>
+                <button class="btn" type="button" disabled>🖨 打印</button>
+              </div>
+            </td>
+          </tr>`
+        })
+        .join('')
+      syncBadge()
+    }
+
+    tbody.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-act]')
+      if (!btn) return
+      const id = btn.getAttribute('data-id')
+      const act = btn.getAttribute('data-act')
+      if (act === 'read') markRead(id, 'button')
+      if (act === 'view') openDetail(id)
+    })
+
+    document.getElementById('drawer-close').addEventListener('click', closeDrawer)
+    document.getElementById('drawer-ok').addEventListener('click', closeDrawer)
+    drawer.addEventListener('click', (e) => {
+      if (e.target === drawer) closeDrawer()
+    })
+
+    render()
+  </script>
+</body>
+</html>

+ 127 - 0
docs/superpowers/specs/2026-08-06-oa-cc-unread-badge-design.md

@@ -0,0 +1,127 @@
+# OA「抄送我的」未读角标 Design
+
+| 项 | 内容 |
+|----|------|
+| 日期 | 2026-08-06 |
+| 状态 | 已定稿(UI 参考 `docs/demos/oa-cc-unread-badge-demo.html`) |
+| 范围 | 侧栏「抄送我的」未读数角标 + 列表阅读状态筛选 / 已读 / 批量已读 / 查看即已读 |
+
+## 背景
+
+「抄送我的」目前仅列出抄送任务,无法区分是否已读,侧栏也无未读提示。产品要求对齐「质检工单」菜单上的红色数字角标,展示**抄送给我且尚未阅读**的数量;列表支持标记已读,点击「查看」默认已读。
+
+现有 `oa_task` 抄送任务创建时 `status=done`(不阻断流程),**没有**阅读态字段,需扩展。
+
+## 目标与非目标
+
+**目标**
+
+- 侧栏「抄送我的」(`menuCode: oa:cc`)展示未读抄送数;样式对齐质检 `.art-text-badge`;未读为 0 时**隐藏**角标。
+- 列表:筛选下拉「阅读状态」(未读 / 已读 / 全部),**默认仅未读**;无 Tab。
+- 操作列「已读」+ 工具栏「批量已读」;「查看」进入详情前自动标记已读。
+- 历史抄送记录上线后全部视为**未读**。
+
+**非目标**
+
+- 不改造小程序端。
+- 「打印」不自动已读。
+- 不引入独立已读表或前端 localStorage 持久化。
+- 不把阅读态与审批 `status`(pending/done)混用。
+
+## 方案对比与结论
+
+| 方案 | 做法 | 优点 | 缺点 |
+|------|------|------|------|
+| 1(选定) | `oa_task` 增加 `is_read` / `read_time` | 改动小;列表与角标查询简单 | 审批任务行也会多两列(可忽略) |
+| 2 | 独立 `oa_cc_read` 表 | 不改 `oa_task` | 多 join;实现更绕 |
+| 3 | localStorage | 无后端 | 换设备丢失;多端不一致 |
+
+**选定方案 1。**
+
+## 数据模型
+
+表:`oa_task` 增量字段:
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `is_read` | TINYINT NOT NULL DEFAULT 0 | `0` 未读 / `1` 已读;仅 `task_type=cc` 有业务意义 |
+| `read_time` | DATETIME NULL | 首次标记已读时间;重复标记不覆盖 |
+
+行为:
+
+- 新建抄送任务:显式 `is_read=0`,`read_time=NULL`。
+- 历史数据迁移:全部 `is_read=0`(产品确认)。
+- 标记已读:本人 + `task_type=cc` + 未删除;已读则幂等成功。
+- 审批任务(`task_type=approval`)不读写这两字段的业务逻辑。
+
+SQL:`docs/sql/oa_task_cc_is_read.sql`(ALTER + 注释;迁移默认未读即靠 DEFAULT / 显式 UPDATE)。
+
+## API(`/api/admin/oa-approval`)
+
+| 方法 | 路径 | 说明 |
+|------|------|------|
+| GET | `/tasks/cc/unread-count` | 当前用户未读抄送数 `{ count: number }` |
+| GET | `/tasks/cc` | 扩展:query `readStatus=unread\|read\|all`,**缺省 `unread`**;列表项含 `isRead`、`readTime`(及现有字段) |
+| POST | `/tasks/{task_id}/read` | 单条已读;仅本人抄送任务;幂等 |
+| POST | `/tasks/cc/read-batch` | body `{ ids: string[] }`;仅处理本人、抄送、未删任务;返回 `{ updated: number }` |
+
+约束(全部 SHALL):
+
+- 租户隔离:`tenant_no` = 当前用户租户。
+- 归属:`assignee_user_id` = 当前用户。
+- 类型:`task_type=cc`,`del_flag=0`。
+- 非本人 / 非抄送 / 不存在:单条 400;批量跳过非法 id,只更新合法未读行。
+
+> 说明:若现网 `GET /tasks/{kind}` 且 `kind=cc` 已存在,则在该路径上增加 `readStatus`,**不要**为列表另起冲突路径;`unread-count` 与 `read-batch` 使用更具体路径,避免与 `{kind}` 路由抢配(实现时注意 FastAPI 路由注册顺序:更具体路径先于 `{kind}`)。
+
+## 前端
+
+### 「抄送我的」页(`web-admin/src/views/oa-approval/cc/index.vue`)
+
+- **筛选** `filter-block`:`el-select`「阅读状态」选项未读 / 已读 / 全部;默认 `unread`;重置回到未读。
+- **工具栏**:已选 N 项 →「批量已读」(中性描边,非主色);无选中或所选均已读时禁用。
+- **表格**:`selection`;「阅读状态」列用 `el-tag`(未读 `danger`,已读 `info`);未读行流程名可选红点。
+- **操作列**:查看 | 已读 | 打印;已读行禁用「已读」;查看:先 `POST .../read` 再跳转实例详情;打印不改已读。
+- 成功标记后:刷新列表 + `refreshOaMenuBadges()`;`onActivated` 同样刷新。
+
+对齐列表模板:`/#/demo/unit`(筛选 + 工具栏 + 勾选 + 批量)。
+
+### 侧栏角标
+
+- 新增 store(如 `oa-menu-badge`)+ `refreshOaMenuBadges()`,模式对齐 `qc-menu-badge`。
+- 解析 `menuCode === 'oa:cc'`(及路由名 `OaApprovalCc` 兜底)。
+- `SidebarSubmenu` / 横向菜单接入;数字格式与质检一致(`>99` → `99+`);`count===0` 不渲染角标。
+- 登录后路由守卫中与质检角标一并刷新。
+
+UI Demo:`docs/demos/oa-cc-unread-badge-demo.html`(交互示意;实现时补筛选与批量勾选,以本 spec 为准)。
+
+## 错误与边界
+
+| 场景 | 行为 |
+|------|------|
+| 重复已读 | 成功,不改 `read_time` |
+| 批量含已读 / 非法 id | 跳过;合法未读照常更新;`updated` 为实际更新数 |
+| 默认筛选未读且全部已读 | 列表空;角标隐藏;用户可改筛选看已读/全部 |
+| 角标接口失败 | 角标视为未加载或 0(不阻塞菜单) |
+
+## 测试要点
+
+1. 新抄送产生后角标 +1;列表默认能看到该未读行。
+2. 「已读」/「批量已读」后角标与列表同步。
+3. 「查看」后该条变为已读,角标减少。
+4. 「打印」后仍为未读。
+5. 筛选已读 / 全部行为正确;重置回到未读。
+6. 历史数据上线后均为未读。
+7. 非抄送任务调 `/read` 应失败。
+
+## 决策记录
+
+| 决策 | 结论 |
+|------|------|
+| 未读为 0 | 隐藏角标 |
+| 已读入口 | 操作列 + 工具栏批量已读 |
+| 历史数据 | 全部未读 |
+| 列表筛选 | 下拉「阅读状态」,无 Tab,默认未读 |
+| 查看 | 默认已读 |
+| 打印 | 不自动已读 |
+| 存储 | `oa_task.is_read` / `read_time` |