/* arch.jsx — 架构与技术方案（可读的文档页，不是 PPT） */

function Arch() {
  const [sec, setSec] = React.useState('diagnose');

  const SECTIONS = [
    ['diagnose', '一、现状诊断'],
    ['target', '二、目标架构'],
    ['sync', '三、公众号同步管道'],
    ['login', '四、关注即登录'],
    ['data', '五、数据模型'],
    ['resilience', '六、抗宕与运维'],
    ['migrate', '七、盘点与迁移'],
    ['roadmap', '八、分期路线'],
    ['cost', '九、代价与取舍'],
  ];

  return (
    <div style={{ display: 'grid', gridTemplateColumns: '250px 1fr', minHeight: '100vh', background: 'var(--cloud)' }}>
      <aside style={{ borderRight: '1px solid var(--line)', padding: '30px 0', position: 'sticky', top: 0, height: '100vh', overflowY: 'auto', background: '#fff' }}>
        <div style={{ padding: '0 24px 22px' }}>
          <button onClick={() => window.__go('home')} style={{ border: 0, background: 'none', padding: 0, marginBottom: 18, fontSize: 11.5, color: 'var(--teal-700)' }}>← 返回原型</button>
          <div style={{ fontFamily: 'var(--f-mono)', fontSize: 9.5, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--txt-3)' }}>技术方案</div>
          <h2 style={{ fontSize: 17, marginTop: 8, lineHeight: 1.35 }}>量子催眠中国<br />官网重构</h2>
        </div>
        {SECTIONS.map(([k, t]) => (
          <button key={k} onClick={() => setSec(k)}
            style={{
              display: 'block', width: '100%', textAlign: 'left', padding: '9px 24px', border: 0,
              background: sec === k ? 'rgba(1,153,154,.05)' : 'none', fontSize: 12.5,
              color: sec === k ? 'var(--teal-900)' : 'var(--txt-2)',
              borderLeft: '2px solid ' + (sec === k ? 'var(--teal-500)' : 'transparent')
            }}>{t}</button>
        ))}
        <div style={{ padding: '22px 24px 0' }}>
          <div style={{ fontSize: 10.5, color: 'var(--txt-3)', lineHeight: 1.7 }}>v1.0 · 2026.10<br />配套原型：前台 + 后台</div>
        </div>
      </aside>

      <main style={{ padding: 'clamp(28px,3.4vw,52px) clamp(24px,4vw,64px) 90px', maxWidth: 940 }}>
        {sec === 'diagnose' && <ADiagnose />}
        {sec === 'target' && <ATarget />}
        {sec === 'sync' && <ASync />}
        {sec === 'login' && <ALogin />}
        {sec === 'data' && <AData />}
        {sec === 'resilience' && <AResilience />}
        {sec === 'migrate' && <AMigrate />}
        {sec === 'roadmap' && <ARoadmap />}
        {sec === 'cost' && <ACost />}
      </main>
    </div>
  );
}

function AH({ eyebrow, title, sub }) {
  return (
    <header style={{ marginBottom: 34, paddingBottom: 26, borderBottom: '1px solid var(--line)' }}>
      <Eyebrow>{eyebrow}</Eyebrow>
      <h1 style={{ marginTop: 16, fontSize: 'clamp(26px,2.9vw,38px)' }}>{title}</h1>
      {sub && <p className="lede" style={{ marginTop: 16, fontSize: 16 }}>{sub}</p>}
    </header>
  );
}
function ASec({ t, children }) {
  return (
    <section style={{ marginBottom: 40 }}>
      <h3 style={{ fontSize: 19, marginBottom: 14 }}>{t}</h3>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14, fontSize: 15.5, lineHeight: 1.85, color: 'var(--txt-2)', fontWeight: 300 }}>{children}</div>
    </section>
  );
}
function Code({ children, cap }) {
  return (
    <div style={{ margin: '6px 0' }}>
      <pre style={{
        margin: 0, background: 'var(--ink)', color: '#dbe8e6', padding: '18px 20px', borderRadius: 4,
        fontFamily: 'var(--f-mono)', fontSize: 11.5, lineHeight: 1.75, overflowX: 'auto'
      }}>{children}</pre>
      {cap && <div style={{ fontSize: 11, color: 'var(--txt-3)', marginTop: 7, fontFamily: 'var(--f-mono)' }}>{cap}</div>}
    </div>
  );
}
function Note({ tone = 'gold', children }) {
  const c = tone === 'gold' ? ['rgba(192,154,94,.09)', 'rgba(192,154,94,.25)', 'var(--gold-deep)']
    : tone === 'red' ? ['rgba(180,84,63,.06)', 'rgba(180,84,63,.2)', '#b4543f']
      : ['rgba(1,153,154,.05)', 'rgba(1,153,154,.18)', 'var(--teal-700)'];
  return (
    <div style={{ background: c[0], border: '1px solid ' + c[1], borderRadius: 3, padding: '14px 16px', fontSize: 13.5, lineHeight: 1.75, color: 'var(--txt-2)', fontWeight: 300 }}>
      <span style={{ color: c[2], fontWeight: 500, marginRight: 6 }}>※</span>{children}
    </div>
  );
}

/* ---------- 一、诊断 ---------- */
function ADiagnose() {
  const rows = [
    ['CSS 文件数', '27 个（含 5 套图标字体）', '首屏阻塞、渲染抖动'],
    ['脚本 src', '20 个（含 jQuery、Revolution Slider）', '主线程拥堵、低端机卡死'],
    ['首页内联正文', '约 7,054 tokens 的长文', '首字节体积大、SEO 抓取混乱'],
    ['viewports', 'user-scalable=0', '无障碍不合规'],
    ['H 标签层级', '单页 5 个 H2 下挂 40+ H3', '信息架构崩塌'],
    ['主题 / 插件', 'The7 7.8 + VC 6.0 + RS 5.4 + 微信登录 1.3', '版本极旧，已知漏洞不修'],
    ['内容体量', 'RSS 仅 10 条，最新 2019-05', '多年未产生新内容'],
  ];
  return (
    <>
      <AH eyebrow="一 · 现状" title="老站的问题不是「旧」，是结构性超载"
        sub="把首页拆开看，会发现问题不在一处，而在每一层都叠加了冗余。宕机不是运气不好，是这种结构的必然结果。" />

      <div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 30 }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
          <thead>
            <tr style={{ background: 'var(--cloud-2)', textAlign: 'left', fontFamily: 'var(--f-mono)', fontSize: 9.5, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--txt-3)' }}>
              <th style={{ padding: '11px 16px', fontWeight: 400 }}>维度</th>
              <th style={{ padding: '11px 16px', fontWeight: 400 }}>实测</th>
              <th style={{ padding: '11px 16px', fontWeight: 400 }}>后果</th>
            </tr>
          </thead>
          <tbody>
            {rows.map(([a, b, c]) => (
              <tr key={a} style={{ borderTop: '1px solid var(--line-soft)' }}>
                <td style={{ padding: '12px 16px', color: 'var(--ink)', whiteSpace: 'nowrap' }}>{a}</td>
                <td style={{ padding: '12px 16px', color: 'var(--txt-2)' }}>{b}</td>
                <td style={{ padding: '12px 16px', color: 'var(--txt-3)' }}>{c}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <ASec t="为什么「保留 WordPress」不是最省事的答案">
        <p>表面看，换一套主题最便宜。但老站的技术栈是 2015 年前后的国产生态组合：The7 + Visual Composer + Revolution Slider。这套组合的问题不在于"老"，而在于<strong>它把内容固化在页面构建器的私有数据结构里</strong>，任何形式化重构都要先做一次内容解析。</p>
        <p>更关键的是：WordPress + 20 个插件 + PHP 动态渲染，意味着<strong>每一次访问都是一次数据库查询与模板渲染</strong>。当访问量出现尖峰（比如一条公众号推文带来的流量），PHP-FPM 进程会被打满，这就是"经常宕机"的直接机制。</p>
      </ASec>

      <ASec t="真正的成本结构">
        <p>重做一次前端，等于同时解决四件事，而它们的投入并不相同：</p>
      </ASec>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2,1fr)', gap: 12 }}>
        {[
          ['视觉与体验', '30%', '换主题基本可解'],
          ['信息架构', '25%', '需人工重新分组 40+ 个 H3'],
          ['性能与抗宕', '25%', '动态渲染 → 静态生成'],
          ['功能（同步/登录/报名）', '20%', '必须新增服务端能力'],
        ].map(([a, b, c]) => (
          <div key={a} className="card" style={{ padding: '16px 18px' }}>
            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
              <span style={{ fontSize: 13.5, color: 'var(--ink)' }}>{a}</span>
              <span style={{ fontFamily: 'var(--f-mono)', fontSize: 11, color: 'var(--gold-deep)' }}>{b}</span>
            </div>
            <p style={{ fontSize: 11.5, color: 'var(--txt-3)', marginTop: 5 }}>{c}</p>
          </div>
        ))}
      </div>
      <div style={{ marginTop: 20 }}>
        <Note>老站已备案（京ICP备15015531号-3），这是资产，应保留。迁移时提前 20 天做域名解析与备案主体一致性核对，避免解析切换导致备案被注销。</Note>
      </div>
    </>
  );
}

/* ---------- 二、目标架构 ---------- */
function ATarget() {
  return (
    <>
      <AH eyebrow="二 · 架构" title="静态前台 + 轻后台，把「宕机」变成不可能"
        sub="核心思路：让 99% 的访问不碰数据库。前台是构建产物，后台只在编辑时工作。" />

      <Code cap="整体拓扑（国内云，单区域双可用区）">{`                        ┌──────────────────────────┐
    读者 / 学员  ───────▶ │  CDN + 静态对象存储        │  ← 全站预渲染 HTML
                        │  (OSS/COS + CDN 回源)     │     0 次数据库查询
                        └──────────▲───────────────┘
                                   │ deploy（构建产物上传）
                        ┌──────────┴───────────────┐
                        │  CI/CD：Git push → 构建    │
                        │  Next SSG / Astro         │
                        └──────────▲───────────────┘
                                   │ 读（构建时）
        ┌──────────────────────────┴───────────────────────────┐
        │                    应用层（容器，2 副本）               │
        │  ┌──────────┐  ┌──────────┐  ┌──────────┐            │
        │  │ 内容 API │  │ 微信网关  │  │ 报名/预约 │            │
        │  └────┬─────┘  └────┬─────┘  └────┬─────┘            │
        └───────┼─────────────┼─────────────┼──────────────────┘
                │             │             │
        ┌───────▼──────┐ ┌────▼─────┐ ┌─────▼──────┐
        │ PostgreSQL   │ │ 任务队列  │ │ Redis      │
        │ + 对象存储    │ │ (BullMQ) │ │ 会话/限流   │
        └──────────────┘ └────┬─────┘ └────────────┘
                              │ 定时/事件
                    ┌─────────▼──────────┐
                    │ 公众号同步 Worker   │──▶ 微信服务器 (AppID/AppSecret)
                    └────────────────────┘`}</Code>

      <ASec t="为什么这样切">
        <p><strong>前台静态化</strong>：页面在发布时生成 HTML，推送到 CDN。读者访问的是 CDN 边缘节点，即使应用层完全挂掉，全站仍然可读。这是把"宕机"从概率事件变成架构上不可能的最直接手段。</p>
        <p><strong>应用层只服务写操作与动态查询</strong>：登录态校验、报名提交、预约创建、后台管理。它的 QPS 需求比前台低两个数量级，两台小规格容器足够。</p>
        <p><strong>队列解耦同步任务</strong>：公众号抓取是外部依赖，必须异步。抓取失败不该影响任何人访问站点。</p>
      </ASec>

      <ASec t="技术选型">
        <div style={{ display: 'grid', gap: 10, marginTop: 4 }}>
          {[
            ['前台', 'Next.js（App Router, SSG/ISR）或 Astro', '按需 ISR 可在文章发布后 60 秒内更新单页，无需全站重建'],
            ['后台', 'Next.js API Routes + Postgres', '与前台同仓，类型共享，减少一套技术栈'],
            ['数据库', 'PostgreSQL 15（国内云 RDS）', 'JSONB 存文章块结构，全文检索用 pg_trgm'],
            ['对象存储', 'OSS / COS + CDN', '图片转存后统一走 CDN，禁止直接引用微信图床'],
            ['队列', 'BullMQ + Redis', '抓取、转码、构建触发器'],
            ['搜索', 'pg_trgm / Meilisearch', '初期用 Postgres 足够，量级上来再换'],
          ].map(([a, b, c]) => (
            <div key={a} style={{ display: 'grid', gridTemplateColumns: '80px 1.1fr 1.3fr', gap: 14, padding: '12px 0', borderBottom: '1px solid var(--line-soft)', fontSize: 13 }}>
              <span style={{ fontFamily: 'var(--f-mono)', fontSize: 10.5, color: 'var(--gold-deep)', textTransform: 'uppercase', letterSpacing: '.1em', paddingTop: 2 }}>{a}</span>
              <span style={{ color: 'var(--ink)' }}>{b}</span>
              <span style={{ color: 'var(--txt-3)', fontSize: 12.5 }}>{c}</span>
            </div>
          ))}
        </div>
      </ASec>

      <Note tone="teal">前台与后台同仓但分域部署：`www` 走 CDN 静态，`api` 与 `admin` 走容器。这三个域名的可用性互相独立——后台维护时，读者无感。</Note>
    </>
  );
}

/* ---------- 三、同步管道 ---------- */
function ASync() {
  return (
    <>
      <AH eyebrow="三 · 内容" title="公众号文章自动同步"
        sub="订阅号没有「文章列表」公开接口，这一点必须先说清楚。可行路径有三条，建议以「发布接口 + 后台手工兜底」组合。" />

      <ASec t="三条路径与它们的前提">
        <div style={{ display: 'grid', gap: 12, marginTop: 4 }}>
          {[
            ['① 官方发布接口', '需认证订阅号 + 已开通「发布能力」', '能拿到自己账号的图文素材列表与正文 HTML，最稳', 'teal'],
            ['② 微信服务器事件推送', '需配置服务器地址与 Token 校验', '文章发布不一定触发事件，不能单独作为同步源', 'gold'],
            ['③ 第三方采集 / RSS', '无需开发者权限', '稳定性不可控，可能触发风控，仅作兜底', 'red'],
          ].map(([a, b, c, t]) => (
            <div key={a} className="card" style={{ padding: '15px 18px' }}>
              <div style={{ display: 'flex', gap: 12, alignItems: 'baseline', flexWrap: 'wrap' }}>
                <span style={{ fontSize: 13.5, color: 'var(--ink)', fontWeight: 400 }}>{a}</span>
                <span className="tag" style={{ height: 19, fontSize: 9.5 }}>{b}</span>
              </div>
              <p style={{ fontSize: 12.5, color: 'var(--txt-2)', marginTop: 7, lineHeight: 1.7 }}>{c}</p>
            </div>
          ))}
        </div>
      </ASec>

      <Note tone="gold">
        你确认有 AppID/AppSecret 且是认证订阅号——这让路径 ① 可行。但仍需在公众号后台确认「发布能力」权限已开通（部分账号需申请）。
        建议上线前用一个测试号跑通一次 `freepublish/batchget` 或 `draft/batchget` 拿到真实返回结构，再定字段映射。
      </Note>

      <ASec t="管道阶段">
        <Code cap="Worker 状态机：任一步失败可重入，不阻塞其它任务">{`[cron 每 15 分钟 / 手动触发]
        │
        ▼
1. FETCH   调用微信接口，按 offset 拉取最近 N 篇（增量：以 last_sync_at 去重）
        │   ↓ 幂等键：wx_article_id（微信侧唯一）
        ▼
2. PARSE   正文 HTML → 结构化块（段落/标题/图片/引用/表格）
        │   剔除：微信内联 style、mp-profile 卡片、赞赏/在看按钮、
        │         历史文章推荐、"点击上方蓝字"引导语
        ▼
3. ASSET   图片：下载原图 → 去 EXIF → 转 WebP（质量 82）→ 上 CDN
        │   保留原始尺寸的 2x 用于 retina；禁止外链微信图床（防盗链会裂图）
        ▼
4. CLASSIFY  规则 + 关键词匹配分类（入门/案例/课程/征稿）
        │    命中「医疗」「疗效」「治愈」等词 → 打 requires_review 标记
        ▼
5. REVIEW  命中标记的进人工队列；其余进入 ready 待发布
        ▼
6. PUBLISH 立即发布 or 定时 → 触发前台 ISR 重建单页 → 记录版本快照`}</Code>
      </ASec>

      <ASec t="清洗规则（这是最容易被低估的工作量）">
        <p>微信文章的 HTML 有三个特点：<strong>全内联样式</strong>、<strong>大量结构性冗余</strong>（每段都包了 2–3 层 section 容器）、<strong>图片带防盗链</strong>。直接存下来再渲染，会导致：样式与站点冲突、复制粘贴失效、图片外链在别的域名下裂掉。</p>
      </ASec>
      <Code cap="清洗器的规则集（按命中顺序）">{`strip_inline_styles     移除 style 属性，保留语义标签
unwrap_empty_sections  <section> 无内容时提升子节点
strip_profile_card     class 含 mp_profile / js_profile → 删除
strip_reward           id 含 js_reward / 赞赏 → 删除
strip_follow_cta       "点击上方蓝字"/"长按识别二维码" 段落 → 删除
strip_history_links    class 含 js_article_history → 删除
normalize_images       data-src → 存本地 CDN 地址；补 alt
map_headings           <section><strong> 伪标题 → h2/h3（按字号启发式）
dedupe_paragraphs      连续重复段落（微信编辑器常见）→ 去重`}</Code>
      <Note>原型后台的「文章同步」页就是这个状态机的可视化：每条任务都可以看到走到了哪一步、被清洗掉了什么、为什么进人工队列。</Note>
    </>
  );
}

/* ---------- 四、登录 ---------- */
function ALogin() {
  return (
    <>
      <AH eyebrow="四 · 身份" title="关注即登录：订阅号的正确解法"
        sub="订阅号没有网页授权（OAuth）权限，拿不到用户昵称头像。但这不妨碍做「扫码→关注→登录」，只是状态机要多一步确认。" />

      <ASec t="为什么不能用 OAuth">
        <p>微信的网页授权（`snsapi_base` / `snsapi_userinfo`）<strong>仅对认证服务号开放</strong>。订阅号即使认证，也无法调起授权页。所以"在网页里点一下微信登录，直接拿到 openid"这条路，在订阅号上不存在。</p>
        <p>可行的替代是<strong>带参二维码</strong>：后端生成一个临时二维码，携带一个一次性 ticket；用户扫码后，微信把 `subscribe`（新关注）或 `SCAN`（已关注）事件推给你的服务器，事件里带着 `Ticket` 与 `FromUserName`（即 openid）；服务器把两者绑定，前端轮询发现 ticket 已绑定，即完成登录。</p>
      </ASec>

      <ASec t="完整时序">
        <Code cap="前端只做轮询与状态展示，所有敏感逻辑在服务端">{`前端                API                  微信服务器            用户微信
 │                   │                       │                    │
 │ ① 请求登录         │                       │                    │
 ├──────────────────▶│                       │                    │
 │                   │ ② qrcode/create       │                    │
 │                   │   scene_str=ticket    │                    │
 │                   ├──────────────────────▶│                    │
 │                   │◀── ticket + url ──────┤                    │
 │◀─ {ticket, qr_url,│                       │                    │
 │   expires_in:180} │  ③ 存 redis:          │                    │
 │                   │  qr:{ticket} =        │                    │
 │                   │  {status:'waiting'}   │                    │
 │ ④ 渲染二维码       │                       │                    │
 │                   │                       │  ⑤ 用户扫码         │
 │                   │                       │◀───────────────────┤
 │                   │ ⑥ 事件推送 POST       │                    │
 │                   │   MsgType=event       │                    │
 │                   │   Event=subscribe     │                    │
 │                   │   EventKey=qrscene_…  │                    │
 │                   │◀──────────────────────┤                    │
 │                   │ ⑦ 校验签名 → 写        │                    │
 │                   │  qr:{ticket}={        │                    │
 │                   │   status:'subscribed',│                    │
 │                   │   openid }            │                    │
 │ ⑧ 轮询 GET        │                       │                    │
 │  /auth/qr/:ticket │                       │                    │
 ├──────────────────▶│                       │                    │
 │◀─ {status:        │                       │                    │
 │    'subscribed'}  │                       │                    │
 │ ⑨ 补资料 → POST   │                       │                    │
 │    /auth/complete ├──────────────────────▶│  建 user + session │
 │◀─ Set-Cookie ─────┤                       │                    │
 │ ⑩ 回到原操作       │                       │                    │
 │   （收藏/报名）    │                       │                    │`}</Code>
      </ASec>

      <ASec t="关键实现细节">
        <div style={{ display: 'grid', gap: 10, marginTop: 4 }}>
          {[
            ['签名校验必做', '微信推送需校验 `signature`（sha1 of token+timestamp+nonce）。不做校验的接口等于把账号数据公开。'],
            ['ticket 一次性 + 3 分钟过期', '存 Redis 带 TTL。重复使用同一 ticket 登录必须拒绝，否则可被重放。'],
            ['已关注用户走 SCAN 分支', '老用户扫码不发 `subscribe`，而是 `SCAN`，EventKey 不带 `qrscene_` 前缀。两个分支都要处理。'],
            ['区分"已扫码未关注"', '这是订阅号独有的一步：用户扫了码但还没点关注。此时前端显示"请在微信中点击关注"，而不是失败。'],
            ['轮询而非 WebSocket', '扫码是低频操作，2 秒轮询足够，省掉长连接运维成本。轮询超时 3 分钟自动停。'],
            ['会话用 HttpOnly Cookie', '不用 localStorage 存 token——XSS 可直接窃取。'],
          ].map(([a, b]) => (
            <div key={a} style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 14, padding: '11px 0', borderBottom: '1px solid var(--line-soft)', fontSize: 13 }}>
              <span style={{ color: 'var(--ink)', fontSize: 12.5 }}>{a}</span>
              <span style={{ color: 'var(--txt-2)', fontSize: 12.5, lineHeight: 1.65 }}>{b}</span>
            </div>
          ))}
        </div>
      </ASec>

      <ASec t="门禁策略：只拦功能，不拦阅读">
        <p>你选的是"仅功能性"门禁。这是对的——<strong>内容站用强制登录换关注，几乎必然掉流量与 SEO</strong>；而收藏、报名、预约、下载这些动作本身就需要身份，拦住它们不损失任何东西。</p>
      </ASec>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 10 }}>
        {[['不拦', '文章阅读、课程介绍、FAQ、名录浏览', 'teal'], ['要登录', '收藏、报名、预约、下载资料', 'gold'], ['登录后才有', '我的收藏、我的报名、预约状态', 'gold']].map(([a, b, t]) => (
          <div key={a} className="card" style={{ padding: '14px 16px', borderLeft: '2px solid ' + (t === 'teal' ? 'var(--teal-500)' : 'var(--gold)') }}>
            <div style={{ fontSize: 12.5, color: 'var(--ink)', fontWeight: 400 }}>{a}</div>
            <p style={{ fontSize: 11.5, color: 'var(--txt-3)', marginTop: 6, lineHeight: 1.6 }}>{b}</p>
          </div>
        ))}
      </div>

      <div style={{ marginTop: 22 }}>
        <Note tone="red">
          合规红线：不能把"关注公众号"设计成阅读的前置条件，也不能以"关注才能看全文"作为主要增长手段——这与微信关于诱导关注的规范相冲突，也有账号被处罚的先例。
          功能性门禁 + 文末温和引导，是既安全又长效的做法。
        </Note>
      </div>
    </>
  );
}

/* ---------- 五、数据模型 ---------- */
function AData() {
  return (
    <>
      <AH eyebrow="五 · 数据" title="数据模型"
        sub="只列核心表与关键约束。文章正文用 JSONB 存块结构，便于结构化渲染与后续迁移。" />
      <Code cap="PostgreSQL 15">{`-- 用户（来自公众号关注）
create table users (
  id            bigserial primary key,
  openid        text unique not null,        -- 微信 openid
  unionid       text,                        -- 若绑定开放平台
  nickname      text,                        -- 需用户自行填写（订阅号拿不到）
  contact_time  text,                        -- 方便联系的时间偏好
  source        text,                        -- article / menu / search
  subscribed_at timestamptz default now(),
  unsubscribed_at timestamptz,               -- 取关时间（事件推送）
  tags          text[] default '{}'
);

-- 会话
create table sessions (
  id         text primary key,               -- 随机 32 字节
  user_id    bigint references users(id) on delete cascade,
  expires_at timestamptz not null,
  ip         inet, ua text
);

-- 文章（同步 + 手工）
create table posts (
  id            bigserial primary key,
  wx_article_id text unique,                 -- 幂等键，手工文章为 null
  slug          text unique not null,
  title         text not null,
  excerpt       text,
  cover_url     text,
  body          jsonb not null,              -- [{type:'p',text:…},{type:'h2',…}]
  category      text not null,
  status        text not null default 'draft',  -- draft|review|ready|published|archived
  requires_review boolean default false,
  published_at  timestamptz,
  source        text default 'wechat',       -- wechat | manual
  read_count    int default 0
);
create index on posts using gin (body jsonb_path_ops);
create index on posts (status, published_at desc);

-- 同步任务（可重入）
create table sync_jobs (
  id          bigserial primary key,
  wx_article_id text,
  stage       text not null,                 -- fetch|parse|asset|classify|review|publish
  state       text not null,                 -- queued|running|ok|failed
  error       text,
  attempts    int default 0,
  started_at  timestamptz, finished_at timestamptz
);

-- 版本快照（回滚用）
create table post_versions (
  id         bigserial primary key,
  post_id    bigint references posts(id) on delete cascade,
  snapshot   jsonb not null,
  editor_id  bigint,
  note       text,
  created_at timestamptz default now()
);

-- 课程与报名
create table courses (
  id bigserial primary key, slug text unique, title text, level text,
  form text, language text, price_cents int, seats int,
  starts_at timestamptz, status text default 'draft'
);
create table enrollments (
  id bigserial primary key,
  course_id bigint references courses(id),
  user_id   bigint references users(id),
  name text, city text, phone text,
  payment text default 'pending',            -- pending|paid|refunded
  source text,
  created_at timestamptz default now(),
  unique (course_id, user_id)
);

-- 催眠师名录
create table practitioners (
  id bigserial primary key,
  user_id bigint references users(id),
  display_name text, city text, level text,
  cert_no text unique, cert_verified_at timestamptz,
  specialties text[], bio text,
  status text default 'pending',             -- pending|active|suspended
  approved_by bigint, approved_at timestamptz
);

-- 预约工单
create table bookings (
  id bigserial primary key,
  user_id bigint references users(id),
  practitioner_id bigint references practitioners(id),
  preferred_at timestamptz, note text,
  status text default 'new',                 -- new|contacted|scheduled|done|cancelled
  assigned_to bigint,                        -- 客服
  created_at timestamptz default now()
);

-- 操作日志（写操作全留痕）
create table audit_logs (
  id bigserial primary key, actor_id bigint, action text,
  target_table text, target_id bigint,
  before jsonb, after jsonb, ip inet,
  created_at timestamptz default now()
);`}</Code>
      <Note>文章正文存 JSONB 而非长文本 HTML，是为了让"分层阅读""目录跳转""要点提炼"这些前台能力可以在渲染层做，而不是每次靠正则解析。</Note>
    </>
  );
}

/* ---------- 六、抗宕 ---------- */
function AResilience() {
  return (
    <>
      <AH eyebrow="六 · 可用性" title="抗宕的三道防线"
        sub="目标不是「永不宕机」（不现实），而是「任何单点故障都不影响读者打开页面」。" />

      <div style={{ display: 'grid', gap: 14 }}>
        {[
          ['第一道 · 静态化', '读者访问的是 CDN 边缘节点上的预渲染 HTML。应用层、数据库、后台全部挂掉，全站依然可读。这是最重要的一道，也是老站完全没有的。',
            ['全站 SSG，构建产物版本化', 'CDN 缓存命中率目标 > 95%', '构建失败时保留上一版本继续服务']],
          ['第二道 · 动态部分降级', '登录态、报名、预约是动态的。它们失败时页面不能白屏——前端用乐观 UI + 明确错误提示，退回到"请稍后再试"而不是崩溃。',
            ['API 超时 3 秒，超时即降级', '报名表单本地暂存，恢复后自动重试', '登录弹窗失败不影响继续阅读']],
          ['第三道 · 数据与配置', '数据库每日全量备份 + 异地副本；图片全部在对象存储，不依赖任何单一服务器；配置走环境变量，不写死在代码里。',
            ['RPO 24h（可加密到 1h）', 'RTO < 2h（静态站无需恢复即可服务）', '密钥与 AppSecret 走密钥管理，不进 Git']],
        ].map(([t, d, list]) => (
          <div key={t} className="card" style={{ padding: '20px 22px' }}>
            <h3 style={{ fontSize: 16 }}>{t}</h3>
            <p style={{ fontSize: 13.5, color: 'var(--txt-2)', marginTop: 10, lineHeight: 1.8, fontWeight: 300 }}>{d}</p>
            <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
              {list.map((l) => <span key={l} className="tag ink" style={{ height: 22, fontSize: 10.5 }}>{l}</span>)}
            </div>
          </div>
        ))}
      </div>

      <div style={{ marginTop: 26 }}>
        <ASec t="监控与告警">
          <p>最小可用的监控只需要三件事：<strong>可用性探测</strong>（每 1 分钟请求首页与一个动态接口）、<strong>构建失败告警</strong>（推送到企业微信/邮件）、<strong>同步任务失败告警</strong>（连续 3 次失败才告警，避免噪音）。</p>
          <p>不要一上来就搭完整 APM。先把"挂了会不会有人知道"这个问题解决。</p>
        </ASec>
      </div>

      <Note tone="teal">老站的"全屏 loading 遮罩"（`#load` 覆盖全屏）是典型的放大故障设计：资源加载慢时用户看到的是纯白屏，而不是内容。新站禁止这类全屏加载器。</Note>
    </>
  );
}

/* ---------- 七、迁移 ---------- */
function AMigrate() {
  const steps = [
    ['盘点', '抓取全部 URL 与内容类型', '输出清单：页面 / 文章 / 图片 / 附件 / 死链', '1 天', '你要的"先盘点"就是这一步'],
    ['提取', '解析 VC 短代码与正文', '结构化 JSON（标题 / 段落 / 图片 / 分类）', '2–3 天', '最大不确定性所在'],
    ['映射', '旧 URL → 新 URL', '301 重定向表（CSV）', '1 天', '必须逐条确认，不能靠规则猜'],
    ['重建', '套用新模板', '新站页面 + 缺失内容补齐', '3–5 天', '长文需重新分层'],
    ['校验', '对比新旧', '标题、图片数、段落数、关键数字逐项 diff', '2 天', '自动化脚本 + 抽样人工'],
    ['切换', 'DNS + 提交新 sitemap', '保留旧站只读 30 天', '半天', '切换窗口选低峰期'],
  ];
  return (
    <>
      <AH eyebrow="七 · 迁移" title="先盘点，再决定怎么迁"
        sub="你还不确定内容规模，这是最该先做的一件事。盘点结果直接决定是「全量迁移」还是「择优保留」。" />

      <div className="card" style={{ padding: 0, overflow: 'hidden', marginBottom: 26 }}>
        <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12.5 }}>
          <thead>
            <tr style={{ background: 'var(--cloud-2)', textAlign: 'left', fontFamily: 'var(--f-mono)', fontSize: 9.5, letterSpacing: '.14em', textTransform: 'uppercase', color: 'var(--txt-3)' }}>
              {['阶段', '做什么', '产出', '工期', '备注'].map((h) => <th key={h} style={{ padding: '11px 15px', fontWeight: 400 }}>{h}</th>)}
            </tr>
          </thead>
          <tbody>
            {steps.map((s, i) => (
              <tr key={s[0]} style={{ borderTop: '1px solid var(--line-soft)' }}>
                <td style={{ padding: '12px 15px', whiteSpace: 'nowrap' }}>
                  <span style={{ fontFamily: 'var(--f-mono)', fontSize: 10, color: 'var(--gold-deep)', marginRight: 7 }}>0{i + 1}</span>
                  <span style={{ color: 'var(--ink)' }}>{s[0]}</span>
                </td>
                <td style={{ padding: '12px 15px', color: 'var(--txt-2)' }}>{s[1]}</td>
                <td style={{ padding: '12px 15px', color: 'var(--txt-2)' }}>{s[2]}</td>
                <td style={{ padding: '12px 15px', fontFamily: 'var(--f-mono)', fontSize: 11, color: 'var(--txt-3)', whiteSpace: 'nowrap' }}>{s[3]}</td>
                <td style={{ padding: '12px 15px', color: 'var(--txt-3)', fontSize: 11.5 }}>{s[4]}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      <ASec t="盘点的具体做法">
        <Code cap="不需要额外工具，现有环境就能跑">{`# 1. 从 sitemap / RSS / 站内链接爬全量 URL
curl -s "https://www.qhhtchina.com/?feed=rss2" \\
  | grep -oP '(?<=<link>)[^<]+' | sort -u > urls.txt

# 2. 抓取快照并统计结构（判断提取难度）
while read u; do
  f="snap/$(echo "$u" | md5sum | cut -c1-12).html"
  curl -sL "$u" -o "$f"
  echo "$u | $(wc -c <"$f") | $(grep -c '<img' "$f") | $(grep -o '<h[2-4]' "$f" | wc -l)"
done < urls.txt > inventory.tsv

# 3. 输出分布，判断是否值得全量迁移`}</Code>
        <p>目前已知：RSS 只暴露 10 条，最新一篇 2019-05。这说明<strong>至少有一批内容没进 RSS</strong>（页面 ID 已经到 18183，而 RSS 只有 10 条），必须靠 sitemap 或站内链接爬取才能拿到全量。</p>
      </ASec>

      <Note tone="gold">若盘点结果是「500 篇以上」，建议<strong>不追求 100% 迁移</strong>：按访问量与主题相关度排序，迁 Top 60%，其余保留在 `archive.` 子域只读。这能省掉大量低价值内容的重排成本，也不损失 SEO（301 到归档页即可）。</Note>
    </>
  );
}

/* ---------- 八、路线 ---------- */
function ARoadmap() {
  const phases = [
    ['P0 · 2 周', '骨架与视觉', ['设计系统落地（即本原型）', '首页 + 分层阅读 + FAQ', '静态站构建管道'], 'teal'],
    ['P1 · 3 周', '内容与后台', ['文章同步 Worker（含清洗）', '后台内容库与发布', '目录 / 课程 / 名录页'], 'teal'],
    ['P2 · 2 周', '身份与转化', ['带参二维码登录 + 事件回调', '收藏 / 报名 / 预约', '我的中心'], 'gold'],
    ['P3 · 2 周', '迁移与切换', ['盘点 → 提取 → 301 映射', '双跑校验', 'DNS 切换 + 监控上线'], 'gold'],
  ];
  return (
    <>
      <AH eyebrow="八 · 路线" title="九周，四段交付"
        sub="每一段结束都有可上线的东西，而不是最后一次性切换。" />
      <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
        {phases.map(([p, t, items, tone], i) => (
          <div key={p} style={{ display: 'grid', gridTemplateColumns: '110px 1fr', gap: 24, padding: '22px 0', borderTop: '1px solid var(--line)', position: 'relative' }}>
            <div>
              <div style={{ fontFamily: 'var(--f-mono)', fontSize: 10, letterSpacing: '.12em', color: tone === 'gold' ? 'var(--gold-deep)' : 'var(--teal-700)', textTransform: 'uppercase' }}>{p}</div>
              <div style={{ fontSize: 14, color: 'var(--ink)', marginTop: 6 }}>{t}</div>
            </div>
            <ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
              {items.map((it) => (
                <li key={it} style={{ display: 'flex', gap: 9, fontSize: 13.5, color: 'var(--txt-2)', lineHeight: 1.65 }}>
                  <span style={{ color: 'var(--teal-300)', flex: 'none' }}>—</span>{it}
                </li>
              ))}
            </ul>
          </div>
        ))}
      </div>

      <div style={{ marginTop: 30 }}>
        <ASec t="如果只能做一半">
          <p>预算或时间不足时的优先级：<strong>P0 → P3（迁移）→ P1（同步）→ P2（登录）</strong>。</p>
          <p>理由：先把"能打开、不宕机、内容分层"这三件最影响信任的事做完，再谈增长。同步与登录是效率与转化工具，晚两周上线不影响站的成立。</p>
        </ASec>
      </div>
    </>
  );
}

/* ---------- 九、代价 ---------- */
function ACost() {
  return (
    <>
      <AH eyebrow="九 · 取舍" title="每条路都有代价"
        sub="你选了「静态生成前端 + 轻后台」。下面是我认为你该提前知道的代价，以及它们为什么可以接受。" />

      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {[
          ['发布不是"立刻"可见', '静态站需要触发构建（或 ISR 重验证），通常 30–90 秒。', '对内容站无影响；若确实需要秒级，可对正文走客户端增量拉取。'],
          ['后台功能要自己写', '不像 WordPress 有现成插件市场，报名、预约、名录都得开发。', '这正是老站坏的根源——插件越多越脆弱。写清楚的功能反而更稳。'],
          ['编辑门槛提高了一点点', '没有"可视化拖拽建页"，改用结构化字段 + Markdown 片段。', '老站的编辑体验看似自由，实际导致了 40+ 个无层级 H3。结构化是被迫的改进。'],
          ['迁移有一次性大工作量', '内容提取与 301 映射无法自动化到 100%。', '一次性投入，之后不再有。' ],
        ].map(([t, d, why]) => (
          <div key={t} className="card" style={{ padding: '18px 20px' }}>
            <h3 style={{ fontSize: 14.5, fontFamily: 'var(--f-sans)', fontWeight: 500 }}>{t}</h3>
            <p style={{ fontSize: 13, color: 'var(--txt-2)', marginTop: 8, lineHeight: 1.75, fontWeight: 300 }}>{d}</p>
            <p style={{ fontSize: 12.5, color: 'var(--teal-700)', marginTop: 8, lineHeight: 1.7, display: 'flex', gap: 7 }}>
              <span style={{ flex: 'none' }}>↳</span>{why}
            </p>
          </div>
        ))}
      </div>

      <div style={{ marginTop: 28 }}>
        <Note tone="teal">
          替代方案与它的问题：<strong>保留 WordPress 换主题</strong>——两周能上线，但每周仍要面对插件更新与安全补丁，且同步与登录仍要写插件；<strong>Next.js + 无头 CMS</strong>——能力最全，但多一套 SaaS 依赖与月费，运维复杂度反而上升。静态 + 轻后台是这三者里对"别再宕机"这个诉求最直接的答案。
        </Note>
      </div>
    </>
  );
}

Object.assign(window, { Arch });
