docs: add OA app icon ep pastel implementation plan.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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 |
|
||||
Reference in new issue
Block a user