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