From aa1a70121a45460346ed9f656d95cbba88e41bbb Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Fri, 15 May 2026 16:48:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E9=A2=9C=E8=89=B2=E8=87=AA=E9=80=82=E5=BA=94=20=E2=80=94=20?= =?UTF-8?q?=E7=AE=97=E6=B3=95=20strip=20=E6=9A=97=E8=89=B2/=E6=B5=85?= =?UTF-8?q?=E8=89=B2=E4=B8=93=E7=94=A8=E7=81=B0=E5=BA=A6=E8=89=B2,?= =?UTF-8?q?=E5=BD=A9=E8=89=B2=E4=BF=9D=E7=95=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈:公告里手写的
在浅色背景下糊; 工具栏只改了"红字""蓝字"按钮(自动适配),自由手写硬编码颜色还是会踩坑。 方案:写 adaptAnnouncementColors helper,sanitize 前预处理 HTML,用算法识别 "灰度系 + 极端亮度(>200 或 <80)"的颜色 → strip 整条声明,让继承主题色; 彩色(三通道差 ≥ 30)一律保留,因为它们通常双主题都可读。 判断细节: - 用 canvas.fillStyle 解析任意 CSS 颜色值(支持 hex/rgb/rgba/hsl/命名色) - 灰度判断:max(r,g,b) - min(r,g,b) < 30(允许微偏色) - 亮度判断:(r+g+b)/3 > 200(浅) 或 < 80(深) 双向 strip - CSS var / currentColor / inherit / transparent 一律保留(用户已经主题适配过) - 不止 color,background-color / border-* / outline-color 都覆盖 实际验证(用户给的 HTML 例子): - div color: #e0e0e0 (224,224,224) → 灰度+亮 → strip ✓ - h2 color: #a78bfa (167,139,250) → 紫色 → 保留 ✓ - span color: #34d399 (52,211,153) → 绿色 → 保留 ✓ - hr border #374151 (55,65,81) → 灰度+暗 → strip ✓ 实现: - 新建 web/src/lib/adaptAnnouncementColors.ts(~110 行,纯 DOMParser+canvas,无依赖) - AnnouncementModal:sanitize 前调用 adaptAnnouncementColors - NotificationsPage 展开公告:同上 - SSR 安全:document 不存在时原样返回 smoke 验证: - 测试公告同时含 #e0e0e0/#374151(灰度,应 strip)+ #a78bfa/#34d399(彩,应留) - 展开后 page.evaluate 扫 inline style 验证 4 项颜色去留 — 4/4 全过 - announcement-integration-smoke 17/17 (从 13 加 4 项颜色检查) - v0.20.1-smoke 11/11 + modal-interaction 8/8 + v2-smoke 25/25 + vitest 71/162 UX 影响: - 超管手写 HTML 用 #e0e0e0 类暗色专用默认色 → 自动 strip,浅深都清晰 - 超管手写 #ff5e5e/#34d399 类彩色 → 保留,两个主题都看得见 - "我自定义了颜色就保留,没自定义按系统主题"语义达成 Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/components/AnnouncementModal.tsx | 4 +- web/src/lib/adaptAnnouncementColors.ts | 163 ++++++++++++++++++++ web/src/pages/NotificationsPage.tsx | 3 +- web/test/announcement-integration-smoke.mjs | 47 +++++- 4 files changed, 208 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/adaptAnnouncementColors.ts diff --git a/web/src/components/AnnouncementModal.tsx b/web/src/components/AnnouncementModal.tsx index e37234e..c5092a8 100644 --- a/web/src/components/AnnouncementModal.tsx +++ b/web/src/components/AnnouncementModal.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import DOMPurify from 'dompurify'; +import { adaptAnnouncementColors } from '../lib/adaptAnnouncementColors'; import styles from './AnnouncementModal.module.css'; interface Props { @@ -23,7 +24,8 @@ export function AnnouncementModal({ content, onClose }: Props) { onClose(); }, [onClose]); - const safeHtml = DOMPurify.sanitize(content); + // 公告颜色自适应:strip 暗色/浅色专用灰度色,让继承主题文字色;彩色保留 + const safeHtml = DOMPurify.sanitize(adaptAnnouncementColors(content)); return (
= 3) { + return [parseInt(m[0], 10), parseInt(m[1], 10), parseInt(m[2], 10)]; + } + } catch { + // ignore — 不可解析的丢回 null + } + return null; +} + +/** + * 判断颜色是否是"主题感知应被 strip 的灰度色": + * - 三通道差 < 30 = 灰度系 + * - 亮度 > 200 = 浅色(暗主题专用) + * - 亮度 < 80 = 深色(浅主题专用) + * 双向都 strip,让继承主题色。 + * + * 不 strip 的情况:CSS var、currentColor、inherit、transparent、彩色。 + */ +export function shouldStripColor(value: string): boolean { + const v = value.trim(); + if (!v) return false; + // CSS var:超管已经主题适配过了,保留 + if (v.includes('var(')) return false; + // 关键字:本来就是继承/重置,保留 + if (/^(currentColor|inherit|initial|unset|transparent|none)$/i.test(v)) return false; + const rgb = parseColorToRgb(v); + if (!rgb) return false; + const [r, g, b] = rgb; + const grayDiff = Math.max(r, g, b) - Math.min(r, g, b); + const brightness = (r + g + b) / 3; + return grayDiff < 30 && (brightness > 200 || brightness < 80); +} + +/** 含可能 color token 的 CSS 属性名集合。border 系列也算 — 因为 `border: 1px solid #xxx`。 */ +const COLOR_BEARING_PROPS = new Set([ + 'color', + 'background', + 'background-color', + 'border', + 'border-top', + 'border-right', + 'border-bottom', + 'border-left', + 'border-color', + 'border-top-color', + 'border-right-color', + 'border-bottom-color', + 'border-left-color', + 'outline', + 'outline-color', +]); + +/** + * 判断 CSS declaration 是否应整条 strip。 + * 简化规则:把 value 拆成 token,任意 token 是"需要 strip 的颜色"就 strip 整条声明。 + * (border: 1px solid #e0e0e0 → strip 整条,让继承主题 border) + */ +function declHasStripColor(prop: string, value: string): boolean { + if (!COLOR_BEARING_PROPS.has(prop)) return false; + // 把 rgb(...)/rgba(...)/hsl(...) 整段当一个 token,避免被空格切碎 + const protectedValue = value.replace(/(rgba?|hsla?)\s*\([^)]+\)/gi, (m) => m.replace(/\s/g, '_')); + for (const rawTok of protectedValue.split(/\s+/)) { + const tok = rawTok.replace(/_/g, ' ').replace(/[,;]+$/, '').trim(); + if (!tok) continue; + if (shouldStripColor(tok)) return true; + } + return false; +} + +/** + * 对公告 HTML 做颜色自适应处理,返回处理过的 HTML 字符串。 + * 不做 sanitize,调用方需要再 DOMPurify.sanitize 一次。 + */ +export function adaptAnnouncementColors(html: string): string { + if (!html || typeof document === 'undefined') return html; + try { + const doc = new DOMParser().parseFromString(html, 'text/html'); + const styledEls = doc.body.querySelectorAll('[style]'); + styledEls.forEach((el) => { + const style = el.getAttribute('style') || ''; + if (!style.trim()) return; + const kept: string[] = []; + for (const declaration of style.split(';')) { + const trimmed = declaration.trim(); + if (!trimmed) continue; + const colonIdx = trimmed.indexOf(':'); + if (colonIdx < 0) { kept.push(trimmed); continue; } + const prop = trimmed.slice(0, colonIdx).trim().toLowerCase(); + const value = trimmed.slice(colonIdx + 1).trim(); + if (declHasStripColor(prop, value)) { + // strip 整条,让该元素该属性继承主题色 + continue; + } + kept.push(`${prop}: ${value}`); + } + const newStyle = kept.join('; '); + if (newStyle) el.setAttribute('style', newStyle); + else el.removeAttribute('style'); + }); + return doc.body.innerHTML; + } catch { + // 解析失败原样返回 — 公告至少不会"消失" + return html; + } +} diff --git a/web/src/pages/NotificationsPage.tsx b/web/src/pages/NotificationsPage.tsx index 6cf90ff..25d2d98 100644 --- a/web/src/pages/NotificationsPage.tsx +++ b/web/src/pages/NotificationsPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import DOMPurify from 'dompurify'; import { Sidebar } from '../components/Sidebar'; import { useNotificationStore } from '../store/notification'; +import { adaptAnnouncementColors } from '../lib/adaptAnnouncementColors'; import type { AppNotification, NotificationType } from '../types'; // 剥 HTML 取纯文本前 N 字,用于列表行缩略预览 @@ -299,7 +300,7 @@ function NotificationRow({ item, isLast, expanded, onToggle, onJump }: Notificat style={{ fontSize: 13, color: 'var(--color-text-primary)', lineHeight: 1.7, wordBreak: 'break-word', }} - dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.content) }} + dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(adaptAnnouncementColors(item.content)) }} /> ) : (
smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。占位文字继续:用户/团队/超管角色矩阵 → 团队管理/消费记录/系统设置。结尾标记 ${uniqueMarker}

`; + const testContent = `

smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。

紫色标题应保留

这里有个 绿色字 应保留,默认色 #e0e0e0 应被 strip。


结尾标记 ${uniqueMarker}

`; let sentTo = 0; try { const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { @@ -135,7 +138,7 @@ async function main() { } catch (e) { fail('4. tudou GET /announcement', e); } // ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ── - const tudouLogin = await login(page, 'tudou', 'tudoupass123'); + const tudouLogin = await login(page, 'tudou', 'seaislee'); await setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user }); await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染 @@ -198,6 +201,36 @@ async function main() { const stillExpanded = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false); if (!stillExpanded) pass('7.3 再点同一行收起 (accordion,marker 不再可见)'); else fail('7.3 收起', new Error('再点没收起,marker 还可见')); + + // ── 8. 颜色自适应:展开公告后检查渲染的 HTML inline color ── + // #e0e0e0 / #374151 (灰度暗色专用) → 应被 strip + // #a78bfa / #34d399 (彩色) → 应保留 + await chipLocator.click({ force: true, timeout: 3000 }); // 再次展开做颜色检查 + await page.waitForTimeout(800); + const colors = await page.evaluate(() => { + // 在 NotificationsPage 找展开区域的渲染 HTML(用 marker 锚定) + const all = document.querySelectorAll('[style]'); + const result = { hasE0: false, hasA7: false, has34: false, has37: false }; + for (const el of all) { + const s = el.getAttribute('style') || ''; + // 只看公告展开渲染区内的样式(排除非公告元素 — 例如带 marker 父链下) + const inAnnouncement = el.closest('div') && (el.textContent || '').length < 300; + if (s.includes('#e0e0e0') || s.includes('rgb(224')) result.hasE0 = true; + if (s.includes('#a78bfa') || s.includes('rgb(167')) result.hasA7 = true; + if (s.includes('#34d399') || s.includes('rgb(52')) result.has34 = true; + if (s.includes('#374151') || s.includes('rgb(55')) result.has37 = true; + // (inAnnouncement 用于将来如有需要可过滤,目前所有匹配都看) + } + return result; + }); + if (!colors.hasE0) pass('8.1 #e0e0e0 (灰白) 已被 strip'); + else fail('8.1 #e0e0e0 未 strip', new Error('期望 strip 实际还在')); + if (!colors.has37) pass('8.2 #374151 (深灰) 已被 strip'); + else fail('8.2 #374151 未 strip', new Error('期望 strip 实际还在')); + if (colors.hasA7) pass('8.3 #a78bfa (紫色) 已保留'); + else fail('8.3 #a78bfa 被误 strip', new Error('彩色不该被 strip')); + if (colors.has34) pass('8.4 #34d399 (绿色) 已保留'); + else fail('8.4 #34d399 被误 strip', new Error('彩色不该被 strip')); } catch (e) { fail('7. 消息中心 accordion', e); } // 清场:把测试造的公告标已读,避免污染下一次 smoke