From bfb495ee7732fb3a565882b53b12643a3b62ccc4 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 6 Aug 2026 13:03:46 +0800 Subject: [PATCH] feat(web-admin): add ep: pastel OA app icon URL helpers. Co-authored-by: Cursor --- web-admin/src/utils/oa-app-icons.test.ts | 83 ++++++++++++ web-admin/src/utils/oa-app-icons.ts | 161 +++++++++++++++++++---- 2 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 web-admin/src/utils/oa-app-icons.test.ts diff --git a/web-admin/src/utils/oa-app-icons.test.ts b/web-admin/src/utils/oa-app-icons.test.ts new file mode 100644 index 00000000..5897c7d6 --- /dev/null +++ b/web-admin/src/utils/oa-app-icons.test.ts @@ -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) + } + }) +}) diff --git a/web-admin/src/utils/oa-app-icons.ts b/web-admin/src/utils/oa-app-icons.ts index 1e705413..59c79b08 100644 --- a/web-admin/src/utils/oa-app-icons.ts +++ b/web-admin/src/utils/oa-app-icons.ts @@ -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 +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) } } /** - * 展示用完整地址: - * - 空 → 默认图标 - * - 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) +}