feat(notification): 公告颜色自适应 — 算法 strip 暗色/浅色专用灰度色,彩色保留
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m47s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 4m47s
用户反馈:公告里手写的 <div style="color:#e0e0e0"> 在浅色背景下糊; 工具栏只改了"红字""蓝字"按钮(自动适配),自由手写硬编码颜色还是会踩坑。 方案:写 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) <noreply@anthropic.com>
This commit is contained in:
parent
bd3e80fd58
commit
aa1a70121a
@ -1,5 +1,6 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import { adaptAnnouncementColors } from '../lib/adaptAnnouncementColors';
|
||||||
import styles from './AnnouncementModal.module.css';
|
import styles from './AnnouncementModal.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -23,7 +24,8 @@ export function AnnouncementModal({ content, onClose }: Props) {
|
|||||||
onClose();
|
onClose();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const safeHtml = DOMPurify.sanitize(content);
|
// 公告颜色自适应:strip 暗色/浅色专用灰度色,让继承主题文字色;彩色保留
|
||||||
|
const safeHtml = DOMPurify.sanitize(adaptAnnouncementColors(content));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
163
web/src/lib/adaptAnnouncementColors.ts
Normal file
163
web/src/lib/adaptAnnouncementColors.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* 公告 HTML 颜色自适应预处理。
|
||||||
|
*
|
||||||
|
* 超管写公告时常用暗色主题专用色(`#e0e0e0` 白字 / `#d1d5db` 浅灰 / `#fff` / `white` 等),
|
||||||
|
* 这些在浅色背景下糊得看不清;反过来用 `#333` / `#374151` 类深灰文字在深色下也糊。
|
||||||
|
*
|
||||||
|
* 本函数遍历所有 inline `style`,用算法识别 "灰度系 + 极端亮度"(暗色 OR 浅色专用)
|
||||||
|
* → strip 掉对应声明 → 让内容继承容器的主题色(`var(--color-text-primary)`,浅深自适应)。
|
||||||
|
*
|
||||||
|
* 真彩色(红/绿/蓝/紫/橙/青…,三通道差 ≥ 30)一律保留,因为它们通常双主题都可读。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* const safe = DOMPurify.sanitize(adaptAnnouncementColors(rawHtml));
|
||||||
|
*
|
||||||
|
* 在 NotificationsPage 展开公告 + AnnouncementModal 强弹两处调用。
|
||||||
|
*
|
||||||
|
* 实现说明:
|
||||||
|
* - 用 DOMParser 解析 HTML(浏览器原生,不依赖 DOMPurify hook)
|
||||||
|
* - 用 canvas getContext 解析任意 CSS 颜色值(支持 #hex / rgb / rgba / hsl / 命名色)
|
||||||
|
* - SSR 安全:如果 document 不存在,原样返回不处理
|
||||||
|
* - 对 color / background-color / border / border-color / border-top 等所有可能含颜色的属性都做识别
|
||||||
|
*/
|
||||||
|
|
||||||
|
let __canvasCtx: CanvasRenderingContext2D | null | undefined = undefined;
|
||||||
|
function getCanvasCtx(): CanvasRenderingContext2D | null {
|
||||||
|
if (__canvasCtx !== undefined) return __canvasCtx;
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
__canvasCtx = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
__canvasCtx = document.createElement('canvas').getContext('2d');
|
||||||
|
return __canvasCtx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把任意 CSS color 值解析成 RGB 三通道(0-255)。失败返回 null。 */
|
||||||
|
function parseColorToRgb(value: string): [number, number, number] | null {
|
||||||
|
const ctx = getCanvasCtx();
|
||||||
|
if (!ctx) return null;
|
||||||
|
const v = value.trim();
|
||||||
|
if (!v) return null;
|
||||||
|
try {
|
||||||
|
// 先设个已知值,如果解析失败 fillStyle 会保留原值我们能比对
|
||||||
|
ctx.fillStyle = '#000000';
|
||||||
|
ctx.fillStyle = v;
|
||||||
|
const computed = ctx.fillStyle as string;
|
||||||
|
// canvas 输出格式:#rrggbb 或 rgba(r,g,b,a)
|
||||||
|
if (computed.startsWith('#') && (computed.length === 7 || computed.length === 4)) {
|
||||||
|
// 标准化 #abc → #aabbcc
|
||||||
|
const hex = computed.length === 4
|
||||||
|
? '#' + computed[1] + computed[1] + computed[2] + computed[2] + computed[3] + computed[3]
|
||||||
|
: computed;
|
||||||
|
return [
|
||||||
|
parseInt(hex.slice(1, 3), 16),
|
||||||
|
parseInt(hex.slice(3, 5), 16),
|
||||||
|
parseInt(hex.slice(5, 7), 16),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const m = computed.match(/\d+/g);
|
||||||
|
if (m && m.length >= 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<HTMLElement>('[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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { Sidebar } from '../components/Sidebar';
|
import { Sidebar } from '../components/Sidebar';
|
||||||
import { useNotificationStore } from '../store/notification';
|
import { useNotificationStore } from '../store/notification';
|
||||||
|
import { adaptAnnouncementColors } from '../lib/adaptAnnouncementColors';
|
||||||
import type { AppNotification, NotificationType } from '../types';
|
import type { AppNotification, NotificationType } from '../types';
|
||||||
|
|
||||||
// 剥 HTML 取纯文本前 N 字,用于列表行缩略预览
|
// 剥 HTML 取纯文本前 N 字,用于列表行缩略预览
|
||||||
@ -299,7 +300,7 @@ function NotificationRow({ item, isLast, expanded, onToggle, onJump }: Notificat
|
|||||||
style={{
|
style={{
|
||||||
fontSize: 13, color: 'var(--color-text-primary)', lineHeight: 1.7, wordBreak: 'break-word',
|
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)) }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
* 6. 关闭 modal → POST /announcement/read → 再开页面不弹
|
* 6. 关闭 modal → POST /announcement/read → 再开页面不弹
|
||||||
* 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染)
|
* 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染)
|
||||||
*
|
*
|
||||||
* 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/tudoupass123 可登录。
|
* 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/seaislee 可登录。
|
||||||
* 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。
|
* 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。
|
||||||
*/
|
*/
|
||||||
import { chromium } from '@playwright/test';
|
import { chromium } from '@playwright/test';
|
||||||
@ -68,7 +68,7 @@ async function main() {
|
|||||||
let adminTok, tudouTok;
|
let adminTok, tudouTok;
|
||||||
try {
|
try {
|
||||||
const a = await login(page, 'admin', 'admin123');
|
const a = await login(page, 'admin', 'admin123');
|
||||||
const t = await login(page, 'tudou', 'tudoupass123');
|
const t = await login(page, 'tudou', 'seaislee');
|
||||||
adminTok = a.token; tudouTok = t.token;
|
adminTok = a.token; tudouTok = t.token;
|
||||||
if (!adminTok || !tudouTok) throw new Error('token 空');
|
if (!adminTok || !tudouTok) throw new Error('token 空');
|
||||||
} catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; }
|
} catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; }
|
||||||
@ -93,11 +93,14 @@ async function main() {
|
|||||||
} catch (e) { fail('1. 空内容', e); }
|
} catch (e) { fail('1. 空内容', e); }
|
||||||
|
|
||||||
// ── 2. HTML 发送返回 200 + sent_to ──
|
// ── 2. HTML 发送返回 200 + sent_to ──
|
||||||
// 内容做长一点 — 短公告 preview 不会截断,折叠态/展开态文字完全一样,
|
// 内容包含多种颜色 — 既测 preview 截断 + accordion,也测颜色自适应:
|
||||||
// 测试 7.3 收起没法用 text 区分;长内容让 preview '…' 截断,
|
// - 外层 div color:#e0e0e0 → 暗色专用灰白,应被 strip
|
||||||
// 用末尾独有 marker 字串作为"只在展开态才看得到"的探针。
|
// - h2 color:#a78bfa → 紫色,应保留
|
||||||
|
// - span color:#34d399 → 绿色,应保留
|
||||||
|
// - hr border-top color:#374151 → 深灰,应被 strip
|
||||||
|
// 用末尾独有 marker 字串作为"只在展开态才看得到"的探针(preview 60 字截断 '…')。
|
||||||
const uniqueMarker = `EXPANDED-ONLY-MARKER-${Date.now()}`;
|
const uniqueMarker = `EXPANDED-ONLY-MARKER-${Date.now()}`;
|
||||||
const testContent = `<p>smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。占位文字继续:用户/团队/超管角色矩阵 → 团队管理/消费记录/系统设置。结尾标记 <b>${uniqueMarker}</b></p>`;
|
const testContent = `<div style="color:#e0e0e0;line-height:1.8"><p>smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。</p><h2 style="color:#a78bfa">紫色标题应保留</h2><p>这里有个 <span style="color:#34d399">绿色字</span> 应保留,默认色 #e0e0e0 应被 strip。</p><hr style="border:none;border-top:1px solid #374151;margin:12px 0"><p>结尾标记 <b>${uniqueMarker}</b></p></div>`;
|
||||||
let sentTo = 0;
|
let sentTo = 0;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`${API}/api/v1/admin/announcement/publish`, {
|
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); }
|
} catch (e) { fail('4. tudou GET /announcement', e); }
|
||||||
|
|
||||||
// ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ──
|
// ── 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 setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user });
|
||||||
await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染
|
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);
|
const stillExpanded = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false);
|
||||||
if (!stillExpanded) pass('7.3 再点同一行收起 (accordion,marker 不再可见)');
|
if (!stillExpanded) pass('7.3 再点同一行收起 (accordion,marker 不再可见)');
|
||||||
else fail('7.3 收起', new Error('再点没收起,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); }
|
} catch (e) { fail('7. 消息中心 accordion', e); }
|
||||||
|
|
||||||
// 清场:把测试造的公告标已读,避免污染下一次 smoke
|
// 清场:把测试造的公告标已读,避免污染下一次 smoke
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user