From 91939084003281a3fc11906cc72807208d0f27ac Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 6 Aug 2026 12:58:58 +0800 Subject: [PATCH] docs: add OA app icon ep pastel implementation plan. Co-authored-by: Cursor --- .../plans/2026-08-06-oa-app-icon-ep-pastel.md | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-06-oa-app-icon-ep-pastel.md diff --git a/docs/superpowers/plans/2026-08-06-oa-app-icon-ep-pastel.md b/docs/superpowers/plans/2026-08-06-oa-app-icon-ep-pastel.md new file mode 100644 index 00000000..559dbff9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-oa-app-icon-ep-pastel.md @@ -0,0 +1,887 @@ +# OA App Icon Ep Pastel 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:** 审批应用图标改为「8 色淡色底 + 精选 Element Plus 图标(同色系深色描边)」,`iconUrl` 用 `ep:Icon|#bg` 约定串(上传仍为图片路径),卡片 32×32。 + +**Architecture:** 纯函数工具解析/构建 `ep:` 串与色盘;`OaAppIcon` 负责展示;`OaAppIconPicker` 负责新增/编辑交互;审批管理 / 发起 / 设计器共用。后端无变更。 + +**Tech Stack:** Vue 3、Element Plus Icons、TypeScript、`tsx --test`(纯函数单测) + +**Spec:** `docs/superpowers/specs/2026-08-06-oa-app-icon-ep-pastel-design.md` +**Demo:** `docs/demos/oa-app-icon-picker-demo.html` + +--- + +## File map + +| File | Responsibility | +|------|----------------| +| `web-admin/src/utils/oa-app-icons.ts` | 色盘、精选图标名、build/parse/resolve/random;保留图片 `resolveOaAppIconUrl` | +| `web-admin/src/utils/oa-app-icons.test.ts` | 纯函数单测(tsx) | +| `web-admin/src/components/oa-approval/OaAppIcon.vue` | 展示 ep 组合或 img | +| `web-admin/src/components/oa-approval/OaAppIconPicker.vue` | 预览 + 展开选图标/换色/随机/上传 | +| `web-admin/src/views/oa-approval/apps/index.vue` | 卡片 32×32 + 弹窗用 Picker | +| `web-admin/src/views/oa-approval/start/index.vue` | 卡片用 `OaAppIcon` | +| `web-admin/src/views/oa-approval/designer/index.vue` | 基础设置用 Picker(或至少 OaAppIcon + 同等能力) | + +--- + +### Task 1: `oa-app-icons` 纯函数 + 单测 + +**Files:** +- Create: `web-admin/src/utils/oa-app-icons.test.ts` +- Modify: `web-admin/src/utils/oa-app-icons.ts` + +- [ ] **Step 1: Write the failing test** + +Create `web-admin/src/utils/oa-app-icons.test.ts`: + +```ts +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + OA_APP_ICON_COLOR_PAIRS, + OA_APP_EP_ICON_NAMES, + buildEpIconUrl, + parseEpIconUrl, + resolveOaAppIcon, + pickRandomOaAppIconUrl, + iconFgFromBg, + isEpIconUrl +} from './oa-app-icons' + +describe('oa-app-icons palette', () => { + it('has exactly 8 pairs and no gray/slate', () => { + assert.equal(OA_APP_ICON_COLOR_PAIRS.length, 8) + for (const p of OA_APP_ICON_COLOR_PAIRS) { + assert.notEqual(p.bg.toLowerCase(), '#e2e8f0') + assert.notEqual(p.fg.toLowerCase(), '#475569') + } + }) +}) + +describe('build/parse ep url', () => { + it('round-trips', () => { + const raw = buildEpIconUrl('Calendar', '#dbeafe') + assert.equal(raw, 'ep:Calendar|#dbeafe') + const parsed = parseEpIconUrl(raw) + assert.deepEqual(parsed, { icon: 'Calendar', bg: '#dbeafe', fg: '#2563eb' }) + }) + + it('falls back on unknown icon and illegal bg', () => { + const a = parseEpIconUrl('ep:NotARealIcon|#d1fae5') + assert.equal(a.icon, 'Document') + const b = parseEpIconUrl('ep:Document|#ffffff') + assert.equal(b.bg, '#d1fae5') + assert.equal(b.fg, '#059669') + }) + + it('falls back on malformed ep string', () => { + const p = parseEpIconUrl('ep:broken') + assert.equal(p.icon, 'Document') + assert.equal(p.bg, '#d1fae5') + }) +}) + +describe('resolveOaAppIcon', () => { + it('resolves ep kind', () => { + const r = resolveOaAppIcon('ep:Document|#d1fae5') + assert.equal(r.kind, 'ep') + if (r.kind === 'ep') { + assert.equal(r.icon, 'Document') + assert.equal(r.bg, '#d1fae5') + assert.equal(r.fg, '#059669') + } + }) + + it('resolves image kind for legacy path', () => { + const r = resolveOaAppIcon('/oa-app-icons/doc-orange.svg') + assert.equal(r.kind, 'image') + if (r.kind === 'image') { + assert.equal(r.src, '/oa-app-icons/doc-orange.svg') + } + }) + + it('empty becomes fallback ep', () => { + const r = resolveOaAppIcon('') + assert.equal(r.kind, 'ep') + }) +}) + +describe('random', () => { + it('pickRandomOaAppIconUrl returns valid ep in palette and icon list', () => { + for (let i = 0; i < 30; i++) { + const u = pickRandomOaAppIconUrl() + assert.equal(isEpIconUrl(u), true) + const p = parseEpIconUrl(u) + assert.ok(OA_APP_EP_ICON_NAMES.includes(p.icon as (typeof OA_APP_EP_ICON_NAMES)[number])) + assert.ok(OA_APP_ICON_COLOR_PAIRS.some((c) => c.bg === p.bg)) + assert.equal(iconFgFromBg(p.bg), p.fg) + } + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: + +```bash +cd web-admin && pnpm exec tsx --test src/utils/oa-app-icons.test.ts +``` + +Expected: FAIL(exports missing / old API) + +- [ ] **Step 3: Implement `oa-app-icons.ts`** + +Replace `web-admin/src/utils/oa-app-icons.ts` with: + +```ts +/** OA 审批应用图标:ep:Icon|#bg 组合串 + 图片 URL 兼容 */ + +export type OaAppIconColorPair = { bg: string; fg: string } + +/** Spec 锁定 8 组彩色,禁止灰 */ +export const OA_APP_ICON_COLOR_PAIRS: readonly OaAppIconColorPair[] = [ + { 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' } +] as const + +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 + +/** 精选 Element Plus 图标名(PascalCase,与 @element-plus/icons-vue 导出一致) */ +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' +] as const + +export type OaAppEpIconName = (typeof OA_APP_EP_ICON_NAMES)[number] + +export type OaAppIconEpResolved = { + kind: 'ep' + icon: OaAppEpIconName | string + bg: string + fg: string +} + +export type OaAppIconImageResolved = { + kind: 'image' + src: string +} + +export type OaAppIconResolved = OaAppIconEpResolved | OaAppIconImageResolved + +/** 旧静态默认图(仅作图片路径兼容兜底,新随机不再使用) */ +export const OA_APP_DEFAULT_ICON_URL = '/oa-app-icons/doc-orange.svg' + +const minioBaseUrl = String(import.meta.env?.VITE_MINIO_BASE_URL || '').replace(/\/$/, '') + +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]) +) as Record +const ICON_SET = new Set(OA_APP_EP_ICON_NAMES) + +export function isEpIconUrl(iconUrl?: string | null): boolean { + return String(iconUrl || '').trim().startsWith('ep:') +} + +export function iconFgFromBg(bg?: string | null): string { + const key = String(bg || '').trim().toLowerCase() + return FG_BY_BG[key] || OA_APP_DEFAULT_FG +} + +export function buildEpIconUrl(icon: string, bg: string): string { + const name = ICON_SET.has(icon) ? icon : OA_APP_DEFAULT_EP_ICON + const color = BG_SET.has(bg.toLowerCase()) ? bg.toLowerCase() : OA_APP_DEFAULT_BG + return `ep:${name}|${color}` +} + +export function parseEpIconUrl(iconUrl?: string | null): { + icon: string + bg: string + fg: string +} { + 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) } +} + +/** + * 展示用图片完整地址(仅 kind=image 时使用)。 + * ep: 串不要传入此函数拼 MinIO。 + */ +export function resolveOaAppIconUrl(iconUrl?: string | null): string { + const u = (iconUrl || '').trim() + if (!u || isEpIconUrl(u)) return OA_APP_DEFAULT_ICON_URL + if (/^https?:\/\//i.test(u)) return u + if (u.startsWith('/')) return u + return minioBaseUrl ? `${minioBaseUrl}/${u.replace(/^\//, '')}` : u +} + +export function resolveOaAppIcon(iconUrl?: string | null): OaAppIconResolved { + const u = (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 } + } + return { kind: 'image', src: resolveOaAppIconUrl(u) } +} + +export function pickRandomOaAppIconUrl(): string { + const icon = OA_APP_EP_ICON_NAMES[Math.floor(Math.random() * OA_APP_EP_ICON_NAMES.length)] + const pair = OA_APP_ICON_COLOR_PAIRS[Math.floor(Math.random() * OA_APP_ICON_COLOR_PAIRS.length)] + return buildEpIconUrl(icon, pair.bg) +} + +/** 换底色:保留 icon;若当前非 ep,则用 Document */ +export function recolorOaAppIconUrl(iconUrl: string | null | undefined, bg: string): string { + const cur = String(iconUrl || '').trim() + const icon = isEpIconUrl(cur) ? parseEpIconUrl(cur).icon : OA_APP_DEFAULT_EP_ICON + return buildEpIconUrl(icon, bg) +} + +export function getDefaultOaAppIconUrl(): string { + return buildEpIconUrl(OA_APP_DEFAULT_EP_ICON, OA_APP_DEFAULT_BG) +} +``` + +Note: `import.meta.env` in Node test — if tsx fails on `import.meta.env`, guard with: + +```ts +const env = typeof import.meta !== 'undefined' && (import.meta as any).env +const minioBaseUrl = String(env?.VITE_MINIO_BASE_URL || '').replace(/\/$/, '') +``` + +If any name in `OA_APP_EP_ICON_NAMES` is missing from `@element-plus/icons-vue`, remove it in Task 2 when wiring the component map(单测只要求名单自洽). + +- [ ] **Step 4: Run tests and make sure they pass** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/oa-app-icons.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/utils/oa-app-icons.ts web-admin/src/utils/oa-app-icons.test.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add ep: pastel OA app icon URL helpers. + +EOF +)" +``` + +--- + +### Task 2: `OaAppIcon` 展示组件 + +**Files:** +- Create: `web-admin/src/components/oa-approval/OaAppIcon.vue` + +- [ ] **Step 1: Create component** + +```vue + + + + + +``` + +- [ ] **Step 2: Smoke-check icon names exist** + +Run in `web-admin`: + +```bash +pnpm exec tsx -e "import * as I from '@element-plus/icons-vue'; import { OA_APP_EP_ICON_NAMES } from './src/utils/oa-app-icons.ts'; const miss=OA_APP_EP_ICON_NAMES.filter(n=>!(I as any)[n]); if(miss.length) { console.error(miss); process.exit(1)}; console.log('ok', OA_APP_EP_ICON_NAMES.length)" +``` + +Expected: `ok 20`(若有 miss,从 `OA_APP_EP_ICON_NAMES` 删除缺失名并重跑 Task 1 测试) + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/components/oa-approval/OaAppIcon.vue web-admin/src/utils/oa-app-icons.ts +git commit -m "$(cat <<'EOF' +feat(web-admin): add OaAppIcon display for ep and image URLs. + +EOF +)" +``` + +--- + +### Task 3: `OaAppIconPicker` 选择器 + +**Files:** +- Create: `web-admin/src/components/oa-approval/OaAppIconPicker.vue` + +- [ ] **Step 1: Create picker** + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web-admin/src/components/oa-approval/OaAppIconPicker.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): add OaAppIconPicker for ep palette and upload. + +EOF +)" +``` + +--- + +### Task 4: 接入审批管理 `apps/index.vue` + +**Files:** +- Modify: `web-admin/src/views/oa-approval/apps/index.vue` + +- [ ] **Step 1: Template — card icon** + +Replace: + +```vue + + + +``` + +with: + +```vue + +``` + +- [ ] **Step 2: Template — dialog icon field** + +Replace the entire `oa-icon-picker` block inside「应用图标」`el-form-item` with: + +```vue + + + +``` + +Remove local upload/randomize UI buttons that Picker already provides. + +- [ ] **Step 3: Script** + +1. Import: + +```ts +import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue' +import OaAppIconPicker from '@/components/oa-approval/OaAppIconPicker.vue' +import { pickRandomOaAppIconUrl } from '@/utils/oa-app-icons' +``` + +2. Remove unused: `resolveOaAppIconUrl`, `fetchUploadAttachment`(若仅图标用)、`Upload`/`Refresh` 若不再需要、`randomizeIcon`、`uploadIcon`、`iconUploading`。 + +3. Keep `formData.iconUrl = pickRandomOaAppIconUrl()` in `resetForm` / edit fallback / submit fallback(函数已返回 `ep:` 串). + +- [ ] **Step 4: Styles** + +Update `.oa-app-card__icon` block: either delete (component owns size) or leave unused rules removed. Ensure card layout still gaps correctly — `OaAppIcon` is 32×32. + +Delete obsolete `.oa-icon-picker*` scoped styles from this file (moved into component). + +- [ ] **Step 5: Manual smoke** + +```bash +cd web-admin && pnpm dev +``` + +Open `/#/…/oa-approval/apps`:新增应见随机 ep 图标;展开选图标;换底色;保存后卡片 32×32。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/views/oa-approval/apps/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): wire OA apps page to ep pastel icon picker. + +EOF +)" +``` + +--- + +### Task 5: 接入发起页与设计器 + +**Files:** +- Modify: `web-admin/src/views/oa-approval/start/index.vue` +- Modify: `web-admin/src/views/oa-approval/designer/index.vue` + +- [ ] **Step 1: `start/index.vue` card icon** + +Replace img icon with: + +```vue + +``` + +Import `OaAppIcon`. Remove `resolveOaAppIconUrl` if unused. Shrink/remove local `.oa-app-card__icon` img rules that conflict(保留布局 gap). + +- [ ] **Step 2: `designer/index.vue` basic icon** + +Replace icon row preview + upload-only with: + +```vue + + + +``` + +Import picker;删除仅用于图标的 `uploadBasicIcon` / `iconUploading` / `resolveOaAppIconUrl`(若无其它用途);保存逻辑仍提交 `basic.iconUrl`。 + +若设计器打开时 `iconUrl` 为空,在 load basic 后: + +```ts +if (!basic.iconUrl) basic.iconUrl = pickRandomOaAppIconUrl() +``` + +- [ ] **Step 3: Manual smoke** + +- 发起页:旧 SVG 应用仍显示图片;新 ep 应用显示组合图标 32×32 +- 设计器:可换 ep / 上传并保存 + +- [ ] **Step 4: Commit** + +```bash +git add web-admin/src/views/oa-approval/start/index.vue web-admin/src/views/oa-approval/designer/index.vue +git commit -m "$(cat <<'EOF' +feat(web-admin): show ep pastel icons on start and designer. + +EOF +)" +``` + +--- + +### Task 6: Graphify + 最终校验 + +**Files:** +- Run: `./scripts/update-graph.sh frontend` + +- [ ] **Step 1: Re-run unit tests** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/oa-app-icons.test.ts +``` + +Expected: PASS + +- [ ] **Step 2: Update graph** + +```bash +./scripts/update-graph.sh frontend +``` + +- [ ] **Step 3: Commit graph if changed** + +```bash +git add web-admin/graphify-out && git commit -m "$(cat <<'EOF' +chore: update graphify after OA app icon ep pastel. + +EOF +)" +``` + +(无变更则跳过 commit) + +- [ ] **Step 4: Acceptance checklist(对照 spec)** + +1. 新增预填 `ep:`,底色 ∈ 8 色,图标 ∈ 精选列表 +2. 卡片 32×32,弹窗预览 56×56 +3. 展开面板可选图标/底色;上传与 ep 可互相覆盖 +4. 旧 `/oa-app-icons/*` 仍显示 +5. 发起页、设计器与审批管理一致 + +--- + +## Spec coverage (self-review) + +| Spec 要求 | Task | +|-----------|------| +| `ep:Icon\|#bg` 编码 + 兜底 | Task 1 | +| 8 色盘无灰、同色系 fg | Task 1 | +| 随机图标+底色 | Task 1 / 3 / 4 | +| OaAppIcon 展示 | Task 2 | +| OaAppIconPicker 展开面板/换色/上传 | Task 3 | +| apps 接入 + 32×32 | Task 4 | +| start + designer | Task 5 | +| 旧图片兼容、无后端变更 | Task 1 resolve image + 全任务 | +| 纯函数测试 | Task 1 | +| graphify | Task 6 |