feat(web-admin): add ep: pastel OA app icon URL helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-06 13:03:46 +08:00
1 parent 9193908400
commit bfb495ee77
2 files changed
+216 -28

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
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)
}
})
})
+133 -28
View File
@@ -1,44 +1,149 @@
/** OA 审批应用默认图标(public/oa-app-icons,中间 icon 固定,底色随机) */
/** OA 审批应用图标:ep:Icon|#bg 组合串 + 图片 URL 兼容 */
export const OA_APP_DEFAULT_ICON_URL = '/oa-app-icons/doc-orange.svg'
export type OaAppIconColorPair = { bg: string; fg: string }
/** 同形中间 icon、不同底色 */
export const OA_APP_DEFAULT_ICON_URLS: readonly string[] = [
'/oa-app-icons/doc-orange.svg',
'/oa-app-icons/leave-mint.svg',
'/oa-app-icons/clock-blue.svg',
'/oa-app-icons/mail-violet.svg',
'/oa-app-icons/cart-rose.svg',
'/oa-app-icons/yen-amber.svg',
'/oa-app-icons/tag-cyan.svg',
'/oa-app-icons/check-teal.svg'
]
/** 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
const minioBaseUrl = String(import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
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 function getDefaultOaAppIconUrl(): string {
return OA_APP_DEFAULT_ICON_URL
/** 精选 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
}
/** 打开「新增」弹窗时调用:随机一种底色(中间 icon 相同) */
export function pickRandomOaAppIconUrl(): string {
const list = OA_APP_DEFAULT_ICON_URLS
const idx = Math.floor(Math.random() * list.length)
return list[idx] || list[0]
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 env = typeof import.meta !== 'undefined' && (import.meta as any).env
const minioBaseUrl = String(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) }
}
/**
* 展示用完整地址:
* - 空 → 默认图标
* - http(s) / 站内绝对路径(如 /oa-app-icons/*)→ 原样
* - 上传返回的对象存储相对路径(如 202607/15/xxx.png)→ 拼接 VITE_MINIO_BASE_URL
* 展示用图片完整地址(仅 kind=image 时使用)。
* ep: 串不要传入此函数拼 MinIO。
*/
export function resolveOaAppIconUrl(iconUrl?: string | null): string {
const u = (iconUrl || '').trim()
if (!u) return OA_APP_DEFAULT_ICON_URL
if (!u || isEpIconUrl(u)) return OA_APP_DEFAULT_ICON_URL
if (/^https?:\/\//i.test(u)) return u
// public 静态资源或其它站内绝对路径,勿拼 MinIO
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)
}