Procházet zdrojové kódy

docs: add mobile OA start card-grid implementation plan

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu před 2 měsíci
rodič
revize
484b2471f5

+ 615 - 0
docs/superpowers/plans/2026-08-11-mobile-oa-start-card-grid.md

@@ -0,0 +1,615 @@
+# Mobile OA Start Card Grid 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:** 将 `mobile-h5`「发起审批」列表改为与 Web 同构的分类标题行 + 2 列卡片网格,并解析 `ep:Icon|#bg` 使 logo 背景色/图标色与 Web 一致。
+
+**Architecture:** 从 Web `oa-app-icons.ts` 拷贝色对与解析规则到 `mobile-h5/utils/oa-app-icons.js`;用内联 SVG data-URI + `<image>` 渲染 ep 图标(兼容微信小程序);仅改 `pages/oa/start/index.vue` 模板与样式,保留现有 `displayGroups` 与 Tab/搜索逻辑。
+
+**Tech Stack:** uni-app Vue2/3(现有 Options API)· 纯 JS utils · 无新增 npm 依赖
+
+**Spec:** `docs/superpowers/specs/2026-08-11-mobile-oa-start-card-grid-design.md`  
+**Demo:** `mobile-ui/zkxw-mobile-oa-start-cards-demo.html`
+
+---
+
+## File map
+
+| File | Responsibility |
+|------|----------------|
+| `mobile-h5/utils/oa-app-icons.js` | 色对、`parseEpIconUrl` / `resolveOaAppIcon`、SVG data-URI 生成 |
+| `mobile-h5/scripts/check-oa-app-icons.js` | Node 断言脚本(无 vitest 时的校验) |
+| `mobile-h5/components/OaAppIcon.vue` | 展示 ep / 图片图标 |
+| `mobile-h5/pages/oa/start/index.vue` | 卡片网格 UI(保留 Tab + 搜索) |
+
+---
+
+### Task 1: `oa-app-icons.js` 色对与解析
+
+**Files:**
+- Create: `mobile-h5/utils/oa-app-icons.js`
+- Create: `mobile-h5/scripts/check-oa-app-icons.js`
+
+- [ ] **Step 1: Write the check script (failing until util exists)**
+
+Create `mobile-h5/scripts/check-oa-app-icons.js`:
+
+```js
+const assert = require('assert')
+const path = require('path')
+
+let mod
+try {
+  mod = require(path.join(__dirname, '../utils/oa-app-icons.js'))
+} catch (e) {
+  console.error('FAIL: cannot load oa-app-icons.js', e.message)
+  process.exit(1)
+}
+
+const {
+  OA_APP_ICON_COLOR_PAIRS,
+  resolveOaAppIcon,
+  parseEpIconUrl,
+  buildEpIconUrl,
+  isEpIconUrl
+} = mod
+
+assert.strictEqual(OA_APP_ICON_COLOR_PAIRS.length, 8)
+assert.strictEqual(OA_APP_ICON_COLOR_PAIRS[0].bg, '#d1fae5')
+assert.strictEqual(OA_APP_ICON_COLOR_PAIRS[0].fg, '#059669')
+assert.strictEqual(OA_APP_ICON_COLOR_PAIRS[2].bg, '#dbeafe')
+assert.strictEqual(OA_APP_ICON_COLOR_PAIRS[2].fg, '#2563eb')
+
+assert.strictEqual(isEpIconUrl('ep:Calendar|#dbeafe'), true)
+assert.strictEqual(isEpIconUrl('https://x/a.png'), false)
+
+const p = parseEpIconUrl('ep:Calendar|#dbeafe')
+assert.strictEqual(p.icon, 'Calendar')
+assert.strictEqual(p.bg, '#dbeafe')
+assert.strictEqual(p.fg, '#2563eb')
+
+const bad = parseEpIconUrl('ep:NoSuch|#zzzzzz')
+assert.strictEqual(bad.icon, 'Document')
+assert.strictEqual(bad.bg, '#d1fae5')
+assert.strictEqual(bad.fg, '#059669')
+
+const empty = resolveOaAppIcon('')
+assert.strictEqual(empty.kind, 'ep')
+assert.strictEqual(empty.icon, 'Document')
+assert.strictEqual(empty.bg, '#d1fae5')
+
+const img = resolveOaAppIcon('https://cdn.example/a.png')
+assert.strictEqual(img.kind, 'image')
+assert.strictEqual(img.src, 'https://cdn.example/a.png')
+
+const built = buildEpIconUrl('Wallet', '#fef3c7')
+assert.strictEqual(built, 'ep:Wallet|#fef3c7')
+const r = resolveOaAppIcon(built)
+assert.strictEqual(r.kind, 'ep')
+assert.strictEqual(r.fg, '#d97706')
+
+console.log('ok: oa-app-icons checks passed')
+```
+
+- [ ] **Step 2: Run check — expect FAIL (module missing)**
+
+Run: `node mobile-h5/scripts/check-oa-app-icons.js`  
+Expected: exit 1, cannot load module
+
+- [ ] **Step 3: Implement `mobile-h5/utils/oa-app-icons.js`**
+
+Copy color pairs and parse/resolve logic from `web-admin/src/utils/oa-app-icons.ts` into CommonJS-friendly ESM `export` (uni-app uses ES modules via bundler). Use:
+
+```js
+/** OA 审批应用图标:与 web-admin/src/utils/oa-app-icons.ts 色对/解析对齐 */
+
+export const OA_APP_ICON_COLOR_PAIRS = [
+  { bg: '#d1fae5', fg: '#059669' },
+  { bg: '#cffafe', fg: '#0891b2' },
+  { bg: '#dbeafe', fg: '#2563eb' },
+  { bg: '#e0e7ff', fg: '#4f46e5' },
+  { bg: '#ede9fe', fg: '#7c3aed' },
+  { bg: '#fce7f3', fg: '#db2777' },
+  { bg: '#ffedd5', fg: '#ea580c' },
+  { bg: '#fef3c7', fg: '#d97706' }
+]
+
+export const OA_APP_DEFAULT_EP_ICON = 'Document'
+export const OA_APP_DEFAULT_BG = OA_APP_ICON_COLOR_PAIRS[0].bg
+export const OA_APP_DEFAULT_FG = OA_APP_ICON_COLOR_PAIRS[0].fg
+
+export const OA_APP_EP_ICON_NAMES = [
+  'Document', 'Calendar', 'Clock', 'Wallet', 'User', 'Tickets', 'Briefcase',
+  'Money', 'Van', 'OfficeBuilding', 'Message', 'Setting', 'CollectionTag',
+  'Checked', 'Present', 'Stamp', 'CreditCard', 'Notebook', 'Bell', 'Location'
+]
+
+const BG_SET = new Set(OA_APP_ICON_COLOR_PAIRS.map((p) => p.bg.toLowerCase()))
+const FG_BY_BG = Object.fromEntries(
+  OA_APP_ICON_COLOR_PAIRS.map((p) => [p.bg.toLowerCase(), p.fg])
+)
+const ICON_SET = new Set(OA_APP_EP_ICON_NAMES)
+
+export function isEpIconUrl(iconUrl) {
+  return String(iconUrl || '').trim().startsWith('ep:')
+}
+
+export function iconFgFromBg(bg) {
+  const key = String(bg || '').trim().toLowerCase()
+  return FG_BY_BG[key] || OA_APP_DEFAULT_FG
+}
+
+export function buildEpIconUrl(icon, bg) {
+  const name = ICON_SET.has(icon) ? icon : OA_APP_DEFAULT_EP_ICON
+  const color = BG_SET.has(String(bg).toLowerCase())
+    ? String(bg).toLowerCase()
+    : OA_APP_DEFAULT_BG
+  return `ep:${name}|${color}`
+}
+
+export function parseEpIconUrl(iconUrl) {
+  const raw = String(iconUrl || '').trim()
+  if (!raw.startsWith('ep:')) {
+    return { icon: OA_APP_DEFAULT_EP_ICON, bg: OA_APP_DEFAULT_BG, fg: OA_APP_DEFAULT_FG }
+  }
+  const body = raw.slice(3)
+  const pipe = body.indexOf('|')
+  if (pipe < 0) {
+    return { icon: OA_APP_DEFAULT_EP_ICON, bg: OA_APP_DEFAULT_BG, fg: OA_APP_DEFAULT_FG }
+  }
+  let icon = body.slice(0, pipe).trim()
+  let bg = body.slice(pipe + 1).trim().toLowerCase()
+  if (!ICON_SET.has(icon)) icon = OA_APP_DEFAULT_EP_ICON
+  if (!BG_SET.has(bg)) bg = OA_APP_DEFAULT_BG
+  return { icon, bg, fg: iconFgFromBg(bg) }
+}
+
+export function resolveOaAppIcon(iconUrl) {
+  const u = String(iconUrl || '').trim()
+  if (!u) {
+    const p = parseEpIconUrl(buildEpIconUrl(OA_APP_DEFAULT_EP_ICON, OA_APP_DEFAULT_BG))
+    return { kind: 'ep', icon: p.icon, bg: p.bg, fg: p.fg }
+  }
+  if (isEpIconUrl(u)) {
+    const p = parseEpIconUrl(u)
+    return { kind: 'ep', icon: p.icon, bg: p.bg, fg: p.fg }
+  }
+  if (/^https?:\/\//i.test(u) || u.startsWith('/')) {
+    return { kind: 'image', src: u }
+  }
+  return { kind: 'image', src: u }
+}
+
+/** Stroke path snippets (viewBox 0 0 24 24). Unknown → Document */
+const EP_SVG_PATHS = {
+  Document:
+    'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6 M8 13h8 M8 17h8',
+  Calendar:
+    'M3 4h18v18H3z M16 2v4 M8 2v4 M3 10h18',
+  Clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 7v5l3 2',
+  Wallet: 'M2 7h20v12H2z M16 12h4 M2 7l2-3h12l2 3',
+  User: 'M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2 M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z',
+  Tickets: 'M3 7h18v10H3z M3 11h18',
+  Briefcase: 'M2 7h20v14H2z M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2 M2 12h20',
+  Money: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z M12 7v10',
+  Van: 'M3 17h1a3 3 0 0 0 6 0h4a3 3 0 0 0 6 0h1 M3 17V8a2 2 0 0 1 2-2h8l6 4v7',
+  OfficeBuilding: 'M4 21V5a1 1 0 0 1 1-1h9v17 M14 9h6v12 M8 9h2 M8 13h2 M8 17h2',
+  Message: 'M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 21l1.9-5.1A8.5 8.5 0 1 1 21 11.5z',
+  Setting:
+    'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z',
+  CollectionTag: 'M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8z M7.5 7.5h.01',
+  Checked: 'M22 11.1V12a10 10 0 1 1-5.9-9.1 M22 4L12 14.01l-3-3',
+  Present: 'M20 12v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-8 M12 22V8 M2 8h20v4H2z M12 8H7.5a2.5 2.5 0 0 1 0-5C11 3 12 8 12 8z M12 8h4.5a2.5 2.5 0 0 0 0-5C13 3 12 8 12 8z',
+  Stamp: 'M5 21h14 M12 3a4 4 0 0 0-4 4c0 2 1.5 3 4 5 2.5-2 4-3 4-5a4 4 0 0 0-4-4z M6 15h12v3H6z',
+  CreditCard: 'M2 7h20v12H2z M2 11h20',
+  Notebook: 'M4 4h12a2 2 0 0 1 2 2v14H6a2 2 0 0 0-2 2V4z M8 8h6 M8 12h6',
+  Bell: 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9 M13.7 21a2 2 0 0 1-3.4 0',
+  Location: 'M12 21s7-4.5 7-11a7 7 0 1 0-14 0c0 6.5 7 11 7 11z M12 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'
+}
+
+/**
+ * Build data-URI SVG for mini-program <image>.
+ * Paths above are space-separated path `d` hints — implement as multiple <path> or single polyline set.
+ * Prefer a compact implementation: for each icon, a full SVG string template with stroke=fg.
+ */
+export function epIconDataUri(icon, fg) {
+  const name = ICON_SET.has(icon) ? icon : OA_APP_DEFAULT_EP_ICON
+  const color = encodeURIComponent(fg || OA_APP_DEFAULT_FG)
+  // Minimal reliable glyphs (full path d attributes)
+  const dMap = {
+    Document:
+      '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/>',
+    Calendar:
+      '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
+    Clock:
+      '<circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/>',
+    Wallet:
+      '<path d="M20 7H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/><path d="M16 12h4"/><path d="M2 7l2-3h12l2 3"/>',
+    User:
+      '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
+    Tickets:
+      '<rect x="3" y="7" width="18" height="10" rx="2"/><line x1="3" y1="11" x2="21" y2="11"/>',
+    Briefcase:
+      '<rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="2" y1="12" x2="22" y2="12"/>',
+    Money:
+      '<circle cx="12" cy="12" r="9"/><path d="M12 7v10"/><path d="M15 9.5c0-1.1-1.3-2-3-2s-3 .9-3 2 1.3 2 3 2 3 .9 3 2-1.3 2-3 2-3-.9-3-2"/>',
+    Van:
+      '<path d="M3 17h1a3 3 0 0 0 6 0h4a3 3 0 0 0 6 0h1"/><path d="M3 17V8a2 2 0 0 1 2-2h8l6 4v7"/><circle cx="7.5" cy="17" r="1.5"/><circle cx="16.5" cy="17" r="1.5"/>',
+    OfficeBuilding:
+      '<path d="M4 21V5a1 1 0 0 1 1-1h9v17"/><path d="M14 9h6v12"/><line x1="8" y1="9" x2="10" y2="9"/><line x1="8" y1="13" x2="10" y2="13"/><line x1="8" y1="17" x2="10" y2="17"/>',
+    Message:
+      '<path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 21l1.9-5.1A8.5 8.5 0 1 1 21 11.5z"/>',
+    Setting:
+      '<circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/>',
+    CollectionTag:
+      '<path d="M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8z"/><circle cx="7.5" cy="7.5" r="1"/>',
+    Checked:
+      '<path d="M22 11.1V12a10 10 0 1 1-5.9-9.1"/><polyline points="22 4 12 14.01 9 11"/>',
+    Present:
+      '<polyline points="20 12 20 22 4 22 4 12"/><rect x="2" y="7" width="20" height="5"/><line x1="12" y1="22" x2="12" y2="7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/>',
+    Stamp:
+      '<path d="M5 21h14"/><path d="M12 3a4 4 0 0 0-4 4c0 2 1.5 3 4 5 2.5-2 4-3 4-5a4 4 0 0 0-4-4z"/><path d="M6 15h12v3H6z"/>',
+    CreditCard:
+      '<rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/>',
+    Notebook:
+      '<path d="M4 4h12a2 2 0 0 1 2 2v14H6a2 2 0 0 0-2 2V4z"/><line x1="8" y1="8" x2="14" y2="8"/><line x1="8" y1="12" x2="14" y2="12"/>',
+    Bell:
+      '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
+    Location:
+      '<path d="M12 21s7-4.5 7-11a7 7 0 1 0-14 0c0 6.5 7 11 7 11z"/><circle cx="12" cy="10" r="2"/>'
+  }
+  const inner = dMap[name] || dMap.Document
+  const svg =
+    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="${fg}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`
+  return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
+}
+
+// Also export for Node check script (optional CommonJS bridge at bottom only if needed)
+```
+
+**Note for implementer:** Uni-app may load ESM; the check script uses `require`. Prefer dual export OR change the check script to dynamic `import()`:
+
+```js
+// at top of check-oa-app-icons.js — use async import instead of require
+;(async () => {
+  const mod = await import('../utils/oa-app-icons.js')
+  // ...asserts...
+})().catch((e) => { console.error(e); process.exit(1) })
+```
+
+Also assert `epIconDataUri('Calendar', '#2563eb').startsWith('data:image/svg+xml')`.
+
+Remove unused `EP_SVG_PATHS` if you only ship `dMap` — keep one map only (YAGNI).
+
+- [ ] **Step 4: Run check — expect PASS**
+
+Run: `node mobile-h5/scripts/check-oa-app-icons.js`  
+Expected: `ok: oa-app-icons checks passed`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mobile-h5/utils/oa-app-icons.js mobile-h5/scripts/check-oa-app-icons.js
+git commit -m "feat(mobile-h5): add OA app icon parse and ep SVG data-URI helpers"
+```
+
+---
+
+### Task 2: `OaAppIcon.vue` 组件
+
+**Files:**
+- Create: `mobile-h5/components/OaAppIcon.vue`
+
+- [ ] **Step 1: Create component**
+
+```vue
+<template>
+  <view class="oa-app-icon" :class="'oa-app-icon--' + size" :style="boxStyle">
+    <image
+      v-if="resolved.kind === 'ep'"
+      class="oa-app-icon__img"
+      :src="epSrc"
+      mode="aspectFit"
+    />
+    <image
+      v-else
+      class="oa-app-icon__img"
+      :src="resolved.src"
+      mode="aspectFill"
+    />
+  </view>
+</template>
+
+<script>
+import { resolveOaAppIcon, epIconDataUri } from '../utils/oa-app-icons.js'
+
+export default {
+  name: 'OaAppIcon',
+  props: {
+    iconUrl: { type: String, default: '' },
+    size: { type: String, default: 'sm' } // sm=32, md=56
+  },
+  computed: {
+    resolved() {
+      return resolveOaAppIcon(this.iconUrl)
+    },
+    boxStyle() {
+      if (this.resolved.kind === 'ep') {
+        return { background: this.resolved.bg }
+      }
+      return {}
+    },
+    epSrc() {
+      if (this.resolved.kind !== 'ep') return ''
+      return epIconDataUri(this.resolved.icon, this.resolved.fg)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.oa-app-icon {
+  flex-shrink: 0;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.oa-app-icon--sm {
+  width: 32px;
+  height: 32px;
+  border-radius: 8px;
+}
+.oa-app-icon--md {
+  width: 56px;
+  height: 56px;
+  border-radius: 12px;
+}
+.oa-app-icon__img {
+  width: 50%;
+  height: 50%;
+}
+.oa-app-icon--sm .oa-app-icon__img {
+  width: 16px;
+  height: 16px;
+}
+.oa-app-icon--md .oa-app-icon__img {
+  width: 26px;
+  height: 26px;
+}
+/* image kind fills the box */
+.oa-app-icon--image-fill .oa-app-icon__img,
+.oa-app-icon:not([style*='background']) .oa-app-icon__img {
+  /* for image kind use full size — handle via class */
+}
+</style>
+```
+
+**Fix for image kind:** when `kind === 'image'`, set img to 100%×100%:
+
+```vue
+<image
+  v-if="resolved.kind === 'ep'"
+  class="oa-app-icon__glyph"
+  :src="epSrc"
+  mode="aspectFit"
+/>
+<image
+  v-else
+  class="oa-app-icon__photo"
+  :src="resolved.src"
+  mode="aspectFill"
+/>
+```
+
+```css
+.oa-app-icon__glyph { width: 16px; height: 16px; }
+.oa-app-icon--md .oa-app-icon__glyph { width: 26px; height: 26px; }
+.oa-app-icon__photo { width: 100%; height: 100%; }
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add mobile-h5/components/OaAppIcon.vue
+git commit -m "feat(mobile-h5): add OaAppIcon component for ep and image logos"
+```
+
+---
+
+### Task 3: 改造 `pages/oa/start/index.vue` 为卡片网格
+
+**Files:**
+- Modify: `mobile-h5/pages/oa/start/index.vue`
+
+- [ ] **Step 1: Replace list-row cards with section-head + 2-col grid**
+
+Keep script (`displayGroups`, Tab, search, `load`, `onPick`) unchanged. Replace the `v-else class="body"` template block and styles to match Demo / Web:
+
+Template body (keep search + tabs + hint above):
+
+```vue
+    <view v-if="loading" class="state">加载中…</view>
+    <view v-else-if="!displayGroups.length" class="state">
+      暂无可发起的审批模板
+    </view>
+    <view v-else class="body">
+      <view v-for="sec in displayGroups" :key="sec.id" class="section">
+        <view class="section-hd">
+          <text class="section-name">{{ sec.name }}</text>
+          <view class="section-dot" />
+          <text class="section-meta">{{ sec.apps.length }} 个表单</text>
+        </view>
+        <view class="card-row">
+          <view
+            v-for="row in sec.apps"
+            :key="row.id"
+            class="oa-app-card"
+            @click="onPick(row)"
+          >
+            <view class="oa-app-card__main">
+              <OaAppIcon :icon-url="row.iconUrl" size="sm" />
+              <view class="oa-app-card__body">
+                <text class="oa-app-card__name">{{ row.appName }}</text>
+                <text class="oa-app-card__desc">{{
+                  row.description || '点击发起审批'
+                }}</text>
+              </view>
+            </view>
+            <view class="oa-app-card__ops">
+              <view class="oa-app-card__btn" @click.stop="onPick(row)">发起</view>
+            </view>
+          </view>
+        </view>
+      </view>
+    </view>
+```
+
+Register component in script:
+
+```js
+import OaAppIcon from '../../../components/OaAppIcon.vue'
+// in export default:
+components: { OaAppIcon },
+```
+
+Replace card-related styles with (rpx for spacing; px for 32px icon box already in component):
+
+```css
+.section { margin-bottom: 28rpx; }
+.section-hd {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  margin-bottom: 16rpx;
+  min-height: 40rpx;
+}
+.section-name {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #334155;
+}
+.section-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  background: #f87171;
+  flex-shrink: 0;
+}
+.section-meta {
+  margin-left: auto;
+  font-size: 22rpx;
+  color: #94a3b8;
+}
+.card-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+}
+.oa-app-card {
+  width: calc((100% - 16rpx) / 2);
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+  min-height: 180rpx;
+  padding: 20rpx 20rpx 14rpx;
+  border-radius: 20rpx;
+  background: #fff;
+  border: 1px solid #d8e3de;
+  box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.06);
+  box-sizing: border-box;
+}
+.oa-app-card__main {
+  display: flex;
+  gap: 16rpx;
+  align-items: flex-start;
+  min-width: 0;
+}
+.oa-app-card__body {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4rpx;
+}
+.oa-app-card__name {
+  font-size: 26rpx;
+  font-weight: 600;
+  color: #0f172a;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.oa-app-card__desc {
+  font-size: 22rpx;
+  color: #94a3b8;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.oa-app-card__ops {
+  display: flex;
+  justify-content: flex-end;
+  padding-top: 12rpx;
+  border-top: 1px solid #f1f5f9;
+}
+.oa-app-card__btn {
+  height: 44rpx;
+  line-height: 44rpx;
+  padding: 0 16rpx;
+  border-radius: 8rpx;
+  background: #0a9f6d;
+  color: #fff;
+  font-size: 22rpx;
+  font-weight: 600;
+}
+```
+
+Remove obsolete `.card` / `.card-main` / `.card-icon*` / `.card-btn` list-row styles.
+
+- [ ] **Step 2: Manual verify checklist**
+
+1. HBuilderX / 微信开发者工具打开 `#/pages/oa/start/index`
+2. 「全部」:每组标题独立一行;组内 2 列卡片
+3. Tab 切换与搜索仍可用
+4. 与 Web 同 `iconUrl` 的模板:背景色/图标色一致
+5. 点「发起」仍进 webview
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add mobile-h5/pages/oa/start/index.vue
+git commit -m "feat(mobile-h5): show OA start templates as grouped card grid"
+```
+
+---
+
+### Task 4: 验收对照 Demo
+
+**Files:** none (verify only)
+
+- [ ] **Step 1:** 并排打开 `mobile-ui/zkxw-mobile-oa-start-cards-demo.html` 与小程序页,确认:搜索 → Tab → hint → section-head → 2 列卡片。
+- [ ] **Step 2:** 再跑 `node mobile-h5/scripts/check-oa-app-icons.js`,确认仍 PASS。
+- [ ] **Step 3:** 若发现 SVG 在微信里不显示:改为 `data:image/svg+xml;base64,` + `btoa(unescape(encodeURIComponent(svg)))`(小程序对 encodeURIComponent 形式偶发不兼容)。验证通过后单独 commit:
+
+```bash
+git add mobile-h5/utils/oa-app-icons.js
+git commit -m "fix(mobile-h5): use base64 SVG data-URI for WeChat image icons"
+```
+
+---
+
+## Spec coverage (self-review)
+
+| Spec requirement | Task |
+|------------------|------|
+| 2 列卡片网格 | Task 3 |
+| 分类标题独立一行 | Task 3 |
+| 保留搜索下 Tab | Task 3(不删 tabs) |
+| Logo 色对对齐 Web | Task 1 |
+| `ep:` + 图片 URL | Task 1–2 |
+| 三墙主按钮色 | Task 3 `oa-app-card__btn` |
+| 不改 webview/API | 无任务触碰 |
+| Demo 对照 | Task 4 |
+
+No TBD placeholders. Types/names: `resolveOaAppIcon` / `epIconDataUri` / `OaAppIcon` consistent across tasks.