docs: add OA app icon ep pastel implementation plan.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 12:58:58 +08:00
1 parent 0853777fdf
commit 9193908400
1 file changed
+887
@@ -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<string, string>
const ICON_SET = new Set<string>(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
<template>
<span class="oa-app-icon" :class="`oa-app-icon--${size}`" :style="boxStyle">
<el-icon v-if="resolved.kind === 'ep'" :style="{ color: resolved.fg }">
<component :is="epComponent" />
</el-icon>
<img v-else :src="resolved.src" alt="" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { Component } from 'vue'
import * as EpIcons from '@element-plus/icons-vue'
import { OA_APP_DEFAULT_EP_ICON, resolveOaAppIcon } from '@/utils/oa-app-icons'
const props = withDefaults(
defineProps<{
iconUrl?: string | null
/** sm=32 card, md=56 dialog */
size?: 'sm' | 'md'
}>(),
{ size: 'sm' }
)
const resolved = computed(() => resolveOaAppIcon(props.iconUrl))
const epComponent = computed<Component>(() => {
if (resolved.value.kind !== 'ep') return (EpIcons as any)[OA_APP_DEFAULT_EP_ICON]
const name = resolved.value.icon
return (EpIcons as any)[name] || (EpIcons as any)[OA_APP_DEFAULT_EP_ICON]
})
const boxStyle = computed(() => {
if (resolved.value.kind === 'ep') {
return { background: resolved.value.bg, color: resolved.value.fg }
}
return {}
})
</script>
<style scoped lang="scss">
.oa-app-icon {
display: grid;
flex-shrink: 0;
place-items: center;
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}
.oa-app-icon--sm {
width: 32px;
height: 32px;
border-radius: 8px;
.el-icon {
font-size: 16px;
}
}
.oa-app-icon--md {
width: 56px;
height: 56px;
border-radius: 12px;
box-shadow: 0 1px 3px rgb(15 23 42 / 8%);
.el-icon {
font-size: 26px;
}
}
</style>
```
- [ ] **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
<template>
<div class="oa-icon-picker">
<OaAppIcon :icon-url="modelValue" size="md" />
<div class="oa-icon-picker__actions">
<el-button class="btn-ghost" type="button" @click="panelOpen = !panelOpen">
<el-icon><Grid /></el-icon>
从图标库选择
</el-button>
<el-button class="btn-ghost" type="button" @click="onRecolor">
<el-icon><Brush /></el-icon>
换底色
</el-button>
<el-button class="btn-ghost" type="button" @click="onRandom">
<el-icon><Refresh /></el-icon>
随机图标+底色
</el-button>
<el-upload
:show-file-list="false"
accept="image/*"
:http-request="uploadIcon"
:disabled="uploading"
>
<el-button class="btn-ghost" :loading="uploading">
<el-icon><Upload /></el-icon>
上传图片
</el-button>
</el-upload>
<p class="oa-icon-picker__tip">
新增时随机淡色底 + EP 图标(同色系深色);可展开选图标/换底色,或上传自定义图
</p>
</div>
<div v-show="panelOpen" class="oa-icon-picker__panel">
<div class="oa-icon-picker__panel-head">
<strong>Element Plus 图标(精选)</strong>
<el-button class="btn-ghost" style="height: 24px" @click="panelOpen = false">收起</el-button>
</div>
<div class="oa-icon-picker__grid">
<button
v-for="name in OA_APP_EP_ICON_NAMES"
:key="name"
type="button"
class="oa-icon-picker__cell"
:class="{ active: currentEp.icon === name && isEp }"
@click="onPickIcon(name)"
>
<el-icon :style="{ color: currentEp.fg }"><component :is="epMap[name]" /></el-icon>
<span>{{ name }}</span>
</button>
</div>
<div class="form-label" style="margin-top: 10px; font-size: 12px; color: var(--mint-text-muted, #94a3b8)">
底色(8 组彩色,无灰色)
</div>
<div class="oa-icon-picker__colors">
<button
v-for="pair in OA_APP_ICON_COLOR_PAIRS"
:key="pair.bg"
type="button"
class="oa-icon-picker__dot"
:class="{ active: currentEp.bg === pair.bg && isEp }"
:style="{ background: pair.bg }"
:title="pair.bg"
@click="onPickBg(pair.bg)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { UploadRequestOptions } from 'element-plus'
import { Brush, Grid, Refresh, Upload } from '@element-plus/icons-vue'
import * as EpIcons from '@element-plus/icons-vue'
import { fetchUploadAttachment } from '@/api/business'
import OaAppIcon from '@/components/oa-approval/OaAppIcon.vue'
import {
OA_APP_EP_ICON_NAMES,
OA_APP_ICON_COLOR_PAIRS,
buildEpIconUrl,
isEpIconUrl,
parseEpIconUrl,
pickRandomOaAppIconUrl,
recolorOaAppIconUrl
} from '@/utils/oa-app-icons'
const props = defineProps<{ modelValue?: string | null }>()
const emit = defineEmits<{ 'update:modelValue': [string] }>()
const panelOpen = ref(false)
const uploading = ref(false)
const isEp = computed(() => isEpIconUrl(props.modelValue))
const currentEp = computed(() => parseEpIconUrl(props.modelValue))
const epMap = Object.fromEntries(
OA_APP_EP_ICON_NAMES.map((n) => [n, (EpIcons as any)[n]])
) as Record<string, any>
function setUrl(v: string) {
emit('update:modelValue', v)
}
function onPickIcon(name: string) {
setUrl(buildEpIconUrl(name, currentEp.value.bg))
}
function onPickBg(bg: string) {
setUrl(recolorOaAppIconUrl(props.modelValue, bg))
}
function onRecolor() {
const list = OA_APP_ICON_COLOR_PAIRS
const cur = currentEp.value.bg
let next = list[Math.floor(Math.random() * list.length)].bg
if (list.length > 1) {
let guard = 0
while (next === cur && guard++ < 8) {
next = list[Math.floor(Math.random() * list.length)].bg
}
}
setUrl(recolorOaAppIconUrl(props.modelValue, next))
}
function onRandom() {
setUrl(pickRandomOaAppIconUrl())
}
async function uploadIcon(options: UploadRequestOptions) {
const file = options.file as File
if (!file?.type?.startsWith('image/')) {
ElMessage.warning('请上传图片文件')
return
}
uploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res: any = await fetchUploadAttachment(fd)
const url = res?.fileUrl || res?.data?.fileUrl
if (!url) {
ElMessage.error('上传失败,未返回地址')
return
}
setUrl(url)
options.onSuccess?.(res)
} catch (e) {
options.onError?.(e as any)
} finally {
uploading.value = false
}
}
</script>
<style scoped lang="scss">
.oa-icon-picker {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
width: 100%;
}
.oa-icon-picker__actions {
display: flex;
flex: 1;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 200px;
}
.oa-icon-picker__tip {
flex-basis: 100%;
margin: 0;
color: var(--mint-text-muted, #94a3b8);
font-size: 12px;
}
.oa-icon-picker__panel {
flex-basis: 100%;
padding: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.oa-icon-picker__panel-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
strong {
font-size: 13px;
}
}
.oa-icon-picker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 8px;
max-height: 220px;
overflow: auto;
}
.oa-icon-picker__cell {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
justify-content: center;
height: 56px;
padding: 4px;
color: #475569;
font-size: 10px;
background: #fff;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
&:hover {
color: var(--primary-dark, #059669);
border-color: #a7f3d0;
}
&.active {
color: var(--primary-dark, #059669);
background: var(--primary-bg, #ecfdf5);
border-color: var(--primary, #10b981);
}
}
.oa-icon-picker__colors {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.oa-icon-picker__dot {
width: 28px;
height: 28px;
border: 2px solid transparent;
border-radius: 8px;
box-shadow: inset 0 0 0 1px rgb(15 23 42 / 6%);
cursor: pointer;
&.active {
border-color: var(--primary-dark, #059669);
box-shadow: 0 0 0 2px rgb(16 185 129 / 25%);
}
}
</style>
```
- [ ] **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
<span class="oa-app-card__icon">
<img :src="resolveOaAppIconUrl(row.iconUrl)" alt="" />
</span>
```
with:
```vue
<OaAppIcon :icon-url="row.iconUrl" size="sm" />
```
- [ ] **Step 2: Template — dialog icon field**
Replace the entire `oa-icon-picker` block inside「应用图标」`el-form-item` with:
```vue
<el-form-item label="应用图标" class="form-col-span-2">
<OaAppIconPicker v-model="formData.iconUrl" />
</el-form-item>
```
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
<OaAppIcon :icon-url="row.iconUrl" size="sm" />
```
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
<el-form-item label="应用图标" class="form-col-span-2">
<OaAppIconPicker v-model="basic.iconUrl" />
</el-form-item>
```
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 |