Parcourir la source

docs: design WeChat notify menu entry consolidating follow and bind

Capture the approved UX for a single「开启微信通知」menu after 抄送我的, with step 1/2 on one page and removal of login/home header entry points.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu il y a 2 mois
Parent
commit
26fe6182df

+ 201 - 0
docs/superpowers/specs/2026-08-14-wechat-notify-menu-entry-design.md

@@ -0,0 +1,201 @@
+# 开启微信通知 · 菜单聚合入口设计
+
+> 日期:2026-08-14  
+> 工程:`mobile-h5`(主)、菜单 SQL;绑定链路复用既有 `mobile-oa-h5` / `mp-bind`  
+> 相关:`docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md`  
+> Demo:`mobile-ui/zkxw-mobile-wechat-notify-menu-demo.html`  
+> 图标:`mobile-h5/static/menu-icons/oa-wechat-notify.png`
+
+---
+
+## 1. 背景与目标
+
+### 1.1 现状问题
+
+微信审批通知需要两步:
+
+1. **关注服务号**(微信侧)
+2. **绑定当前账号**(写入 `wx_mp_openid`)
+
+当前入口分散:
+
+- 登录页:「关注服务号,接收审批通知」(仅关注)
+- 首页顶栏:「开启微信审批通知」(仅绑定)
+
+用户容易只做一步,且不清楚两者关系。
+
+### 1.2 目标
+
+1. 在首页「抄送我的」后新增菜单 **「开启微信通知」**,将两步按序号 1 / 2 放在同一页。
+2. **移除**登录页关注入口与首页顶栏绑定入口;只保留新菜单。
+3. 不改动服务端推送 / `mp-bind` 授权协议;仅调整小程序引导与入口。
+
+### 1.3 非目标
+
+- 不实现服务号关注状态服务端查询。
+- 不改造模板消息发送与深链逻辑。
+- 不新增 web-admin 配置台。
+
+---
+
+## 2. 已确认决策
+
+| 决策点 | 结论 |
+|--------|------|
+| 入口策略 | 仅保留首页菜单;登录页与首页顶栏入口均去掉 |
+| 菜单文案 | 固定为「开启微信通知」(已开启后也不改名) |
+| 菜单位置 | 「抄送我的」之后 |
+| 菜单图标 | `oa-wechat-notify.png`(由 `开启通知.png` 拷贝并英文化命名) |
+| 页面结构 | 原生步骤页:步骤 1 关注 → 步骤 2 绑定 |
+| 顶部总状态 | **不展示**「已开启 / 未开启」横幅;仅步骤卡片角标表达进度 |
+| 步骤 1 完成判定 | 用户点击「我已关注」自行确认(本地持久化) |
+| 步骤 2 | 跳转既有 `/pages/oa/mp-bind/index`(web-view 授权) |
+| 技术方案 | 独立原生页 + 复用 mp-bind(方案 1) |
+
+---
+
+## 3. 信息架构
+
+```
+首页菜单宫格
+  … → 抄送我的 → 开启微信通知  →  pages/oa/wechat-notify/index
+                                      ├─ 步骤1 关注服务号
+                                      │    openOfficialAccountProfile / 二维码
+                                      │    「我已关注」→ 本地标记
+                                      └─ 步骤2 绑定当前账号
+                                           → pages/oa/mp-bind/index(既有)
+```
+
+删除:
+
+- `pages/login/index.vue`:`login-follow-mp` 区块及相关二维码弹层(若仅服务于该入口)
+- `pages/index/index.vue`:顶栏 `home-mp-notify` 文案与 `onTapMpNotify`
+
+保留:
+
+- `pages/oa/mp-bind/index.vue`、`mobile-oa-h5` `OaMpBind`、后端 `mp-bind` / `mp-bind-status`
+- `MP_GH_USERNAME`、`MP_QRCODE_URL` 配置(改由新步骤页使用)
+
+---
+
+## 4. 页面交互
+
+### 4.1 开启微信通知页(`pages/oa/wechat-notify/index`)
+
+- 导航标题:`开启微信通知`
+- 主体仅两张步骤卡,**无**顶部总状态条。
+
+**步骤 1 · 关注服务号**
+
+| 状态 | UI |
+|------|----|
+| 未确认 | 角标「未完成」;主按钮「去关注」 |
+| 点击去关注 | 优先 `wx.openOfficialAccountProfile`;失败则展开二维码(长按识别) |
+| 二维码展开时 | 显示「我已关注」 |
+| 已确认 | 角标「已完成」;按钮可改为「重新打开关注页」(仍可打开关注/二维码) |
+
+「我已关注」写入本地(建议 key 含 `userId`/`username`,换账号不串数据)。**不**调用后端。
+
+**步骤 2 · 绑定当前账号**
+
+| 状态 | UI |
+|------|----|
+| 步骤 1 未确认 | 主按钮禁用,文案「请先完成步骤 1」 |
+| 步骤 1 已确认且未绑定 | 「去绑定」→ `navigateTo` mp-bind |
+| 已绑定 | 角标「已完成」;按钮「已绑定」禁用(不强制提供解绑) |
+
+绑定状态来源:`GET /mini/auth/mp-bind-status`(`onShow` 刷新)。从 mp-bind 返回后自动刷新。
+
+若接口已 `bound=true`,即使本地未点「我已关注」,也应将步骤 1 视为完成(避免已绑用户被步骤顺序卡住)。规则:
+
+- `bound === true` → 步骤 1、步骤 2 均显示已完成。
+- `bound === false` → 步骤 2 未完成;步骤 1 以本地「我已关注」为准。
+
+### 4.2 首页菜单
+
+- 文案始终「开启微信通知」
+- icon:`oa-wechat-notify`(PNG 自带色,`iconBg: transparent`,与其它 OA 宫格一致)
+- 点击:`navigateTo` `/pages/oa/wechat-notify/index`
+- 不增加角标数字(非待办类)
+
+### 4.3 登录页 / 首页顶栏
+
+- 登录页不渲染关注入口与其二维码弹层(若弹层仅服务于该入口则一并删除相关代码)。
+- 首页顶栏仅保留岗位/部门与「退出登录」,去掉微信通知链接及 `fetchMpBindStatus`(若仅服务于顶栏)。
+
+---
+
+## 5. 数据与菜单
+
+### 5.1 小程序菜单 SQL
+
+新增 `sys_menu`(幂等插入),示例字段:
+
+| 字段 | 值 |
+|------|----|
+| id | `MINI000000000000000000000000OA007` |
+| menu_name | `开启微信通知` |
+| path | `/pages/oa/wechat-notify/index` |
+| perms | `mini:oa:wechat-notify` |
+| order_num | `70`(抄送为 60,紧随其后) |
+| icon(若菜单表/接口支持) | `oa-wechat-notify` |
+
+SQL 落 `docs/sql/`(新建或追加既有 mobile OA 菜单脚本),与 `mobile_menu_oa_task_centers.sql` 风格一致。
+
+### 5.2 前端图标映射
+
+- `mobile-h5/components/van-icon/van-icon.vue`:`PNG_ICONS['oa-wechat-notify']`
+- `mobile-h5/utils/menu-icon-colors.js`:加入 `OA_PNG_ICONS` 与 transparent 默认
+
+### 5.3 本地存储
+
+- Key 建议:`mp_follow_confirmed:{userId}` → `'1'`
+- 登出不清也可;以 userId 隔离即可。绑定成功后可不依赖该 key(见 4.1 规则)。
+
+---
+
+## 6. 实现范围(文件级)
+
+| 动作 | 路径 |
+|------|------|
+| 新增 | `mobile-h5/pages/oa/wechat-notify/index.vue` |
+| 注册 | `mobile-h5/pages.json` |
+| 删除入口 | `mobile-h5/pages/login/index.vue`、`login.scss`(相关样式) |
+| 删除入口 | `mobile-h5/pages/index/index.vue`(顶栏通知) |
+| 图标已有 | `mobile-h5/static/menu-icons/oa-wechat-notify.png` |
+| 映射 | `van-icon.vue`、`menu-icon-colors.js` |
+| SQL | `docs/sql/mobile_menu_oa_wechat_notify.sql`(或等价) |
+| Demo(已有) | `mobile-ui/zkxw-mobile-wechat-notify-menu-demo.html` |
+| 文档修订 | 在 `2026-08-13-oa-wechat-service-notify-design.md` §6.1/6.2 加注:入口已迁移至本设计 |
+
+不改:`backend` 推送与 bind API、`mobile-oa-h5` `OaMpBind` 授权流程。
+
+---
+
+## 7. 错误与边界
+
+| 情况 | 行为 |
+|------|------|
+| 用户谎称已关注 | 步骤 2 仍可绑;未真正关注则后续模板发送失败/skip,与现网一致 |
+| 绑定失败 | mp-bind H5 展示错误;返回设置页后仍为未绑定 |
+| openOfficialAccountProfile 不可用 | 仅二维码路径 |
+| 未登录进设置页 | 沿用全局登录拦截 / forceRelogin |
+| 已绑定用户 | 两步均已完成,无需再点「我已关注」 |
+
+---
+
+## 8. 测试要点
+
+1. 登录页无「关注服务号」文案;首页顶栏无「开启微信审批通知」。
+2. 「抄送我的」后出现「开启微信通知」及铃铛图标。
+3. 步骤 1:去关注 → 我已关注 → 角标已完成;步骤 2 可点。
+4. 步骤 1 未确认时步骤 2 禁用。
+5. 去绑定成功返回后步骤 2 已完成;再次进入保持已完成。
+6. 已绑定账号首次进入该页:两步直接已完成。
+7. 换账号:本地「我已关注」不串号。
+
+---
+
+## 9. 与既有设计关系
+
+本设计**替代** `2026-08-13-oa-wechat-service-notify-design.md` 中 §6.1(登录页关注)与 §6.2(工作台顶栏/个人中心绑定入口)的产品入口描述;推送、绑定 API、深链会话等其余章节仍有效。

BIN
mobile-h5/static/menu-icons/oa-wechat-notify.png


BIN
mobile-ui/oa-wechat-notify.png


+ 772 - 0
mobile-ui/zkxw-mobile-wechat-notify-menu-demo.html

@@ -0,0 +1,772 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
+  <title>中科兴皖 · 微信审批通知菜单 Demo</title>
+  <style>
+    :root {
+      --brand-green: #0a9f6d;
+      --brand-green-dark: #087f57;
+      --brand-green-soft: #e9f9f2;
+      --barley-yellow: #d6a343;
+      --barley-yellow-soft: #fff7e8;
+      --bg-page: #f5f8f7;
+      --surface: #ffffff;
+      --text-primary: #1f2a26;
+      --text-muted: #6b7b74;
+      --border: #dcebe4;
+      --danger: #d14343;
+      --warning: #cf8a19;
+      --success: #189b65;
+      --radius-card: 14px;
+      --radius-lg: 20px;
+      --shadow-soft: 0 4px 16px rgba(8, 127, 87, 0.08);
+    }
+
+    * { box-sizing: border-box; }
+    html, body {
+      margin: 0;
+      padding: 0;
+      min-height: 100%;
+      font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
+      color: var(--text-primary);
+      background:
+        radial-gradient(900px 420px at 10% -10%, rgba(10, 159, 109, 0.14), transparent 55%),
+        radial-gradient(700px 380px at 90% 0%, rgba(214, 163, 67, 0.12), transparent 50%),
+        linear-gradient(180deg, #eef6f2 0%, #f7faf9 40%, #f3f6f5 100%);
+    }
+
+    body {
+      display: flex;
+      justify-content: center;
+      padding: 24px 16px 40px;
+    }
+
+    #app {
+      width: 100%;
+      max-width: 980px;
+      display: grid;
+      grid-template-columns: 280px 1fr;
+      gap: 24px;
+      align-items: start;
+    }
+
+    @media (max-width: 860px) {
+      #app { grid-template-columns: 1fr; }
+      .doc-panel { position: static; }
+    }
+
+    .doc-panel {
+      position: sticky;
+      top: 24px;
+      background: rgba(255, 255, 255, 0.92);
+      border: 1px solid var(--border);
+      border-radius: 18px;
+      padding: 20px 18px;
+      box-shadow: var(--shadow-soft);
+    }
+
+    .doc-panel h1 {
+      margin: 0 0 6px;
+      font-size: 20px;
+      line-height: 1.3;
+      color: var(--brand-green-dark);
+    }
+
+    .doc-panel .sub {
+      margin: 0 0 16px;
+      font-size: 12px;
+      color: var(--text-muted);
+      line-height: 1.5;
+    }
+
+    .section-title {
+      margin: 16px 0 8px;
+      font-size: 12px;
+      font-weight: 600;
+      color: var(--text-muted);
+      letter-spacing: 0.04em;
+      text-transform: uppercase;
+    }
+
+    .screen-nav {
+      display: flex;
+      flex-direction: column;
+      gap: 8px;
+    }
+
+    .screen-nav button {
+      appearance: none;
+      border: 1px solid var(--border);
+      background: #fff;
+      border-radius: 10px;
+      padding: 10px 12px;
+      text-align: left;
+      font-size: 13px;
+      color: var(--text-primary);
+      cursor: pointer;
+    }
+
+    .screen-nav button.is-on {
+      border-color: var(--brand-green);
+      background: var(--brand-green-soft);
+      color: var(--brand-green-dark);
+      font-weight: 600;
+    }
+
+    .note {
+      margin-top: 14px;
+      padding: 10px 12px;
+      border-radius: 10px;
+      background: var(--barley-yellow-soft);
+      border: 1px solid #f0dfb4;
+      font-size: 12px;
+      line-height: 1.55;
+      color: #7a5a12;
+    }
+
+    .stage {
+      display: flex;
+      justify-content: center;
+    }
+
+    .phone {
+      width: 375px;
+      max-width: 100%;
+      background: var(--bg-page);
+      border-radius: 28px;
+      border: 1px solid var(--border);
+      box-shadow: var(--shadow-soft);
+      overflow: hidden;
+      min-height: 720px;
+      display: flex;
+      flex-direction: column;
+    }
+
+    .status-bar {
+      height: 28px;
+      background: linear-gradient(180deg, #0a9f6d 0%, #087f57 100%);
+    }
+
+    .screen { display: none; flex: 1; flex-direction: column; }
+    .screen.is-on { display: flex; }
+
+    /* —— 首页 —— */
+    .home-header {
+      background: linear-gradient(180deg, #0a9f6d 0%, #087f57 100%);
+      color: #fff;
+      padding: 8px 16px 22px;
+      border-radius: 0 0 22px 22px;
+    }
+
+    .brand { font-size: 13px; opacity: 0.9; }
+    .hello { margin-top: 10px; font-size: 22px; font-weight: 700; }
+    .meta {
+      margin-top: 8px;
+      display: flex;
+      justify-content: space-between;
+      align-items: flex-start;
+      gap: 12px;
+      font-size: 12px;
+      opacity: 0.92;
+    }
+    .meta__right {
+      display: flex;
+      flex-direction: column;
+      align-items: flex-end;
+      gap: 6px;
+      flex-shrink: 0;
+    }
+    .home-logout {
+      text-decoration: underline;
+      text-underline-offset: 2px;
+      cursor: pointer;
+      opacity: 0.9;
+    }
+    .home-mp-legacy {
+      font-size: 11px;
+      text-decoration: underline;
+      opacity: 0.75;
+      cursor: pointer;
+    }
+    .home-mp-legacy.is-hidden { display: none; }
+
+    .home-body { padding: 14px 12px 24px; flex: 1; }
+    .panel {
+      background: var(--surface);
+      border-radius: var(--radius-card);
+      border: 1px solid var(--border);
+      padding: 14px 12px 8px;
+    }
+    .panel-hd {
+      display: flex;
+      justify-content: space-between;
+      align-items: baseline;
+      margin-bottom: 12px;
+      padding: 0 4px;
+    }
+    .panel-title { font-size: 15px; font-weight: 700; }
+    .hint { font-size: 12px; color: var(--text-muted); }
+
+    .menu-grid {
+      display: grid;
+      grid-template-columns: repeat(4, 1fr);
+      gap: 10px 4px;
+    }
+
+    .menu-item {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 6px;
+      padding: 8px 4px;
+      border: 0;
+      background: transparent;
+      cursor: pointer;
+      border-radius: 10px;
+    }
+    .menu-item:hover { background: var(--brand-green-soft); }
+    .menu-item.is-new .menu-icon {
+      outline: 2px solid var(--barley-yellow);
+      outline-offset: 2px;
+    }
+
+    .menu-icon {
+      width: 44px;
+      height: 44px;
+      border-radius: 12px;
+      display: grid;
+      place-items: center;
+      font-size: 18px;
+      color: #fff;
+      font-weight: 700;
+      overflow: hidden;
+    }
+    .menu-icon img {
+      width: 44px;
+      height: 44px;
+      object-fit: contain;
+      display: block;
+    }
+    .menu-icon.is-plain {
+      background: transparent !important;
+      border-radius: 0;
+    }
+    .menu-title {
+      font-size: 11px;
+      line-height: 1.3;
+      text-align: center;
+      color: var(--text-primary);
+    }
+
+    .c1 { background: #0a9f6d; }
+    .c2 { background: #2f8f7a; }
+    .c3 { background: #3d8bb8; }
+    .c4 { background: #5b7fbf; }
+    .c5 { background: #7a6bb8; }
+    .c6 { background: #cf8a19; }
+    .c7 { background: transparent; }
+
+    /* —— 通知设置页 —— */
+    .nav {
+      background: linear-gradient(180deg, #0a9f6d 0%, #087f57 100%);
+      color: #fff;
+      padding: 10px 12px 14px;
+      display: flex;
+      align-items: center;
+      gap: 8px;
+    }
+    .nav__back {
+      width: 36px;
+      height: 36px;
+      border: 0;
+      background: rgba(255,255,255,0.16);
+      color: #fff;
+      border-radius: 10px;
+      cursor: pointer;
+      font-size: 18px;
+    }
+    .nav__title { font-size: 17px; font-weight: 700; }
+
+    .notify-body { padding: 14px 12px 28px; flex: 1; }
+
+    .status-banner {
+      display: none;
+    }
+
+    .step-card {
+      background: var(--surface);
+      border: 1px solid var(--border);
+      border-radius: var(--radius-card);
+      padding: 14px;
+      margin-bottom: 10px;
+    }
+    .step-card.is-done {
+      border-color: #b7ebcf;
+      background: linear-gradient(180deg, #f6fffb 0%, #fff 60%);
+    }
+    .step-hd {
+      display: flex;
+      align-items: flex-start;
+      gap: 10px;
+    }
+    .step-num {
+      width: 28px;
+      height: 28px;
+      border-radius: 50%;
+      background: var(--brand-green);
+      color: #fff;
+      display: grid;
+      place-items: center;
+      font-size: 14px;
+      font-weight: 700;
+      flex-shrink: 0;
+    }
+    .step-card.is-done .step-num {
+      background: var(--success);
+    }
+    .step-title {
+      font-size: 15px;
+      font-weight: 700;
+      line-height: 1.35;
+    }
+    .step-desc {
+      margin-top: 4px;
+      font-size: 12px;
+      color: var(--text-muted);
+      line-height: 1.5;
+    }
+    .step-badge {
+      margin-left: auto;
+      font-size: 11px;
+      padding: 2px 8px;
+      border-radius: 999px;
+      background: #eef2f0;
+      color: var(--text-muted);
+      flex-shrink: 0;
+    }
+    .step-badge.is-done {
+      background: var(--brand-green-soft);
+      color: var(--brand-green-dark);
+    }
+
+    .step-actions { margin-top: 12px; padding-left: 38px; }
+    .btn {
+      appearance: none;
+      border: 0;
+      border-radius: 10px;
+      padding: 10px 14px;
+      font-size: 14px;
+      font-weight: 600;
+      cursor: pointer;
+      min-height: 40px;
+    }
+    .btn-primary {
+      background: var(--brand-green);
+      color: #fff;
+      width: 100%;
+    }
+    .btn-primary:disabled {
+      background: #a7d7c4;
+      cursor: default;
+    }
+    .btn-ghost {
+      background: #fff;
+      color: var(--brand-green-dark);
+      border: 1px solid var(--border);
+      width: 100%;
+    }
+
+    .qr-box {
+      margin-top: 10px;
+      display: none;
+      padding: 12px;
+      border-radius: 12px;
+      border: 1px dashed #a7f3d0;
+      background: #f8fffb;
+      text-align: center;
+    }
+    .qr-box.is-on { display: block; }
+    .qr-fake {
+      width: 140px;
+      height: 140px;
+      margin: 0 auto 8px;
+      border-radius: 8px;
+      background:
+        repeating-linear-gradient(0deg, #1f2a26 0 6px, transparent 6px 12px),
+        repeating-linear-gradient(90deg, #1f2a26 0 6px, transparent 6px 12px);
+      opacity: 0.18;
+    }
+    .qr-tip { font-size: 12px; color: var(--text-muted); }
+
+    /* —— 登录页(对照:入口已移除) —— */
+    .login-page {
+      padding: 36px 20px 28px;
+      flex: 1;
+      background: linear-gradient(180deg, #e9f9f2 0%, #f5f8f7 42%, #f5f8f7 100%);
+    }
+    .login-brand { text-align: center; margin-bottom: 22px; }
+    .login-logo {
+      width: 72px;
+      height: 72px;
+      margin: 0 auto 10px;
+      border-radius: 18px;
+      background: var(--brand-green);
+      color: #fff;
+      display: grid;
+      place-items: center;
+      font-size: 28px;
+      font-weight: 800;
+    }
+    .login-sub { font-size: 13px; color: var(--text-muted); }
+    .login-card {
+      background: #fff;
+      border-radius: 16px;
+      border: 1px solid var(--border);
+      padding: 16px;
+      box-shadow: var(--shadow-soft);
+    }
+    .field { margin-bottom: 12px; }
+    .field label {
+      display: block;
+      font-size: 12px;
+      color: var(--text-muted);
+      margin-bottom: 6px;
+    }
+    .field .box {
+      height: 42px;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      background: #fafcfb;
+      padding: 0 12px;
+      display: flex;
+      align-items: center;
+      color: #9aaba3;
+      font-size: 14px;
+    }
+    .login-submit {
+      margin-top: 8px;
+      height: 44px;
+      border-radius: 10px;
+      background: var(--brand-green);
+      color: #fff;
+      display: grid;
+      place-items: center;
+      font-weight: 700;
+    }
+    .login-follow-removed {
+      margin-top: 16px;
+      text-align: center;
+      font-size: 12px;
+      color: var(--text-muted);
+      text-decoration: line-through;
+      opacity: 0.55;
+    }
+    .login-follow-note {
+      margin-top: 6px;
+      text-align: center;
+      font-size: 11px;
+      color: var(--warning);
+    }
+
+    .toast {
+      position: fixed;
+      left: 50%;
+      bottom: 48px;
+      transform: translateX(-50%);
+      background: rgba(31, 42, 38, 0.88);
+      color: #fff;
+      padding: 10px 16px;
+      border-radius: 10px;
+      font-size: 13px;
+      opacity: 0;
+      pointer-events: none;
+      transition: opacity .2s;
+      z-index: 20;
+    }
+    .toast.is-on { opacity: 1; }
+  </style>
+</head>
+<body>
+  <div id="app">
+    <aside class="doc-panel">
+      <h1>微信审批通知 · 菜单方案</h1>
+      <p class="sub">
+        在「抄送我的」后新增「开启微信通知」菜单(铃铛图标),
+        把「关注服务号」与「账号绑定」合并为步骤 1 / 2。
+        <strong>登录页「关注服务号」与首页顶栏入口均不展示</strong>,只保留新菜单。
+      </p>
+
+      <div class="section-title">页面</div>
+      <div class="screen-nav" id="nav">
+        <button type="button" data-screen="home" class="is-on">① 首页(新菜单)</button>
+        <button type="button" data-screen="notify">② 开启微信通知(1 / 2)</button>
+        <button type="button" data-screen="login">③ 登录页(入口移除示意)</button>
+      </div>
+
+      <div class="section-title">模拟状态</div>
+      <div class="screen-nav">
+        <button type="button" id="resetState">重置为未开启</button>
+        <button type="button" id="markFollowed">仅完成步骤 1</button>
+        <button type="button" id="markBound">完成步骤 1+2</button>
+      </div>
+
+      <div class="note">
+        交互说明:首页点「开启微信通知」进入设置页。<br />
+        图标:mobile-h5/static/menu-icons/oa-wechat-notify.png<br />
+        步骤 1:模拟关注(可弹二维码)。<br />
+        步骤 2:未关注时禁用;关注后可点「去绑定」。
+      </div>
+    </aside>
+
+    <main class="stage">
+      <div class="phone">
+        <div class="status-bar"></div>
+
+        <!-- 首页 -->
+        <section class="screen is-on" data-screen="home">
+          <div class="home-header">
+            <div class="brand">中科兴皖 · 移动工作台</div>
+            <div class="hello">您好,张三</div>
+            <div class="meta">
+              <div>产品经理 · 研发中心</div>
+              <div class="meta__right">
+                <span class="home-mp-legacy is-hidden" id="legacyHomeEntry">开启微信审批通知</span>
+                <span class="home-logout">退出登录</span>
+              </div>
+            </div>
+          </div>
+          <div class="home-body">
+            <div class="panel">
+              <div class="panel-hd">
+                <span class="panel-title">功能菜单</span>
+                <span class="hint" id="menusHint">共 7 项</span>
+              </div>
+              <div class="menu-grid" id="menuGrid"></div>
+            </div>
+          </div>
+        </section>
+
+        <!-- 通知设置 -->
+        <section class="screen" data-screen="notify">
+          <div class="nav">
+            <button type="button" class="nav__back" id="backHome" aria-label="返回">‹</button>
+            <div class="nav__title">开启微信通知</div>
+          </div>
+          <div class="notify-body">
+            <div class="step-card" id="step1Card">
+              <div class="step-hd">
+                <div class="step-num">1</div>
+                <div style="flex:1;min-width:0">
+                  <div class="step-title">关注服务号</div>
+                  <div class="step-desc">关注后才能接收微信模板消息。可跳转服务号主页,或长按识别二维码。</div>
+                </div>
+                <span class="step-badge" id="step1Badge">未完成</span>
+              </div>
+              <div class="step-actions">
+                <button type="button" class="btn btn-primary" id="btnFollow">去关注</button>
+                <div class="qr-box" id="qrBox">
+                  <div class="qr-fake" aria-hidden="true"></div>
+                  <div class="qr-tip">长按识别关注(Demo 占位图)</div>
+                  <button type="button" class="btn btn-ghost" id="btnFollowDone" style="margin-top:10px">我已关注</button>
+                </div>
+              </div>
+            </div>
+
+            <div class="step-card" id="step2Card">
+              <div class="step-hd">
+                <div class="step-num">2</div>
+                <div style="flex:1;min-width:0">
+                  <div class="step-title">绑定当前账号</div>
+                  <div class="step-desc">将服务号身份与系统账号关联,审批待办 / 抄送才能推送到你。</div>
+                </div>
+                <span class="step-badge" id="step2Badge">未完成</span>
+              </div>
+              <div class="step-actions">
+                <button type="button" class="btn btn-primary" id="btnBind">去绑定</button>
+              </div>
+            </div>
+          </div>
+        </section>
+
+        <!-- 登录页对照 -->
+        <section class="screen" data-screen="login">
+          <div class="login-page">
+            <div class="login-brand">
+              <div class="login-logo">皖</div>
+              <div class="login-sub">员工工作台登录</div>
+            </div>
+            <div class="login-card">
+              <div class="field">
+                <label>账号</label>
+                <div class="box">请输入用户名 / 工号</div>
+              </div>
+              <div class="field">
+                <label>密码</label>
+                <div class="box">请输入密码</div>
+              </div>
+              <div class="login-submit">登录</div>
+            </div>
+            <div class="login-follow-note" style="margin-top:16px">登录页不再展示「关注服务号」入口</div>
+          </div>
+        </section>
+      </div>
+    </main>
+  </div>
+
+  <div class="toast" id="toast"></div>
+
+  <script>
+    const MENUS = [
+      { title: '发起审批', cls: 'c1', icon: '发' },
+      { title: '草稿箱', cls: 'c2', icon: '草' },
+      { title: '待办中心', cls: 'c3', icon: '待' },
+      { title: '已办中心', cls: 'c4', icon: '已' },
+      { title: '我发起的', cls: 'c5', icon: '我' },
+      { title: '抄送我的', cls: 'c6', icon: '抄' },
+      {
+        title: '开启微信通知',
+        cls: 'c7',
+        iconImg: './oa-wechat-notify.png',
+        isNew: true,
+        openNotify: true,
+        plain: true
+      }
+    ]
+
+    const state = {
+      followed: false,
+      bound: false,
+      showQr: false
+    }
+
+    const els = {
+      nav: document.getElementById('nav'),
+      screens: document.querySelectorAll('.screen'),
+      menuGrid: document.getElementById('menuGrid'),
+      menusHint: document.getElementById('menusHint'),
+      step1Card: document.getElementById('step1Card'),
+      step2Card: document.getElementById('step2Card'),
+      step1Badge: document.getElementById('step1Badge'),
+      step2Badge: document.getElementById('step2Badge'),
+      btnFollow: document.getElementById('btnFollow'),
+      btnBind: document.getElementById('btnBind'),
+      qrBox: document.getElementById('qrBox'),
+      toast: document.getElementById('toast')
+    }
+
+    function showToast(msg) {
+      els.toast.textContent = msg
+      els.toast.classList.add('is-on')
+      clearTimeout(showToast._t)
+      showToast._t = setTimeout(() => els.toast.classList.remove('is-on'), 1600)
+    }
+
+    function showScreen(name) {
+      els.screens.forEach((s) => s.classList.toggle('is-on', s.dataset.screen === name))
+      els.nav.querySelectorAll('button').forEach((b) => {
+        b.classList.toggle('is-on', b.dataset.screen === name)
+      })
+    }
+
+    function renderMenus() {
+      els.menusHint.textContent = '共 ' + MENUS.length + ' 项'
+      els.menuGrid.innerHTML = MENUS.map((m) => {
+        const extra = m.isNew ? ' is-new' : ''
+        const iconCls = 'menu-icon ' + m.cls + (m.plain ? ' is-plain' : '')
+        const iconInner = m.iconImg
+          ? '<img src="' + m.iconImg + '" alt="" />'
+          : m.icon
+        return (
+          '<button type="button" class="menu-item' + extra + '" data-open="' + (m.openNotify ? '1' : '0') + '">' +
+            '<span class="' + iconCls + '">' + iconInner + '</span>' +
+            '<span class="menu-title">' + m.title + '</span>' +
+          '</button>'
+        )
+      }).join('')
+    }
+
+    function renderNotify() {
+      els.step1Card.classList.toggle('is-done', state.followed)
+      els.step2Card.classList.toggle('is-done', state.bound)
+      els.step1Badge.textContent = state.followed ? '已完成' : '未完成'
+      els.step2Badge.textContent = state.bound ? '已完成' : '未完成'
+      els.step1Badge.classList.toggle('is-done', state.followed)
+      els.step2Badge.classList.toggle('is-done', state.bound)
+
+      els.btnFollow.textContent = state.followed ? '重新打开关注页' : '去关注'
+      els.qrBox.classList.toggle('is-on', state.showQr && !state.followed)
+
+      els.btnBind.disabled = !state.followed || state.bound
+      els.btnBind.textContent = state.bound ? '已绑定' : (state.followed ? '去绑定' : '请先完成步骤 1')
+    }
+
+    els.nav.addEventListener('click', (e) => {
+      const btn = e.target.closest('button[data-screen]')
+      if (!btn) return
+      showScreen(btn.dataset.screen)
+    })
+
+    document.getElementById('backHome').addEventListener('click', () => showScreen('home'))
+
+    els.menuGrid.addEventListener('click', (e) => {
+      const item = e.target.closest('.menu-item')
+      if (!item) return
+      if (item.dataset.open === '1') {
+        showScreen('notify')
+        return
+      }
+      showToast(item.querySelector('.menu-title').textContent)
+    })
+
+    els.btnFollow.addEventListener('click', () => {
+      state.showQr = true
+      renderNotify()
+      showToast('Demo:打开服务号主页 / 二维码')
+    })
+
+    document.getElementById('btnFollowDone').addEventListener('click', () => {
+      state.followed = true
+      state.showQr = false
+      renderNotify()
+      showToast('步骤 1 已完成')
+    })
+
+    els.btnBind.addEventListener('click', () => {
+      if (!state.followed || state.bound) return
+      state.bound = true
+      renderNotify()
+      showToast('步骤 2 已完成 · 通知已开启')
+    })
+
+    document.getElementById('resetState').addEventListener('click', () => {
+      state.followed = false
+      state.bound = false
+      state.showQr = false
+      renderNotify()
+      showScreen('notify')
+      showToast('已重置')
+    })
+
+    document.getElementById('markFollowed').addEventListener('click', () => {
+      state.followed = true
+      state.bound = false
+      state.showQr = false
+      renderNotify()
+      showScreen('notify')
+    })
+
+    document.getElementById('markBound').addEventListener('click', () => {
+      state.followed = true
+      state.bound = true
+      state.showQr = false
+      renderNotify()
+      showScreen('notify')
+    })
+
+    renderMenus()
+    renderNotify()
+  </script>
+</body>
+</html>