|
|
@@ -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.
|