feat(web-admin): add ep: pastel OA app icon URL helpers.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
9193908400
commit
bfb495ee77
2 files changed
+216
-28
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user