feat(notification): 消息中心改 accordion 模式 + 跳转按钮 + 公告颜色 CSS var 自适应

用户反馈三点:
1. 公告里"红字"/"蓝字"工具按钮硬编码颜色 #ff4d4f / #00b8e6 在浅色下糊
2. 消息中心列表里公告直接渲染完整 HTML,行被撑得很大
3. 点行就自动 navigate(link_url),用户希望"只看不跳",看完主动决定要不要跳转

改动:

a) SettingsPage 公告编辑器颜色按钮(2 个):
   - 红字: <span style="color:#ff4d4f"> → var(--color-danger)
   - 蓝字: <span style="color:#00b8e6"> → var(--color-primary)
   - 分割线 border-top 颜色 #333 → var(--color-border-card)
   - 三个都改成 CSS var,自动适配浅/深主题
   - title 文案加"(自适应主题)"提示超管

b) NotificationsPage accordion 模式:
   - 加 expandedId: number | null state,始终最多 1 条展开
   - 折叠态:chip + title + 时间 + 一行剥 HTML 后的纯文本预览(stripAndTruncate, 60 字 '…')
   - 展开态:头部 + 下方完整内容(announcement 用 DOMPurify+HTML / 其他 plain) +
            link_url 非空时显示【前往查看】按钮(蓝底白字,带箭头 icon)
   - chevron icon 旋转 0deg/180deg 视觉指示折叠/展开
   - 同 id 再点 → 收起;不同 id → 切换(前一个自动收起)
   - 切页时自动重置 expandedId 为 null

c) 点击行为:
   - handleRowClick(自动跳) → handleToggle(只 markRead + 切 expandedId)
   - 新加 handleJump(url):用户主动点【前往查看】才触发 navigate / window.open(http url)
   - 展开区域 onClick 加 stopPropagation 防误触收起

d) smoke test 更新:
   - 测试公告内容做长用 EXPANDED-ONLY-MARKER 末尾标记,preview 截断后看不到
   - 7.2.0 折叠态 preview 截断验证(marker 不可见)
   - 7.2 展开后 marker 可见
   - 7.3 再点收起 marker 不再可见
   - 用 chip [公告] 文字作为稳定点击锚点(只在头部出现不在展开内容里)

验证:
- typecheck 0 error
- announcement-integration-smoke 13/13(从 10 项扩到 13,加 accordion 路径)
- v0.20.1-smoke 11/11 + v2-smoke 25/25 + modal-interaction 8/8 全过
- vitest 71 fail / 162 pass 与基线一致

GlobalAnnouncementGate 强弹 modal 行为不变(plan §一 7 — 公告强制阅读语义保留)。
重看路径走 sidebar 大铃铛 → 消息中心 → accordion 展开看全文 → 可点【前往查看】跳。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209 2026-05-15 16:24:49 +08:00
parent e55a6665f2
commit bd3e80fd58
3 changed files with 179 additions and 51 deletions

View File

@ -1,10 +1,19 @@
import { useEffect, type CSSProperties } from 'react';
import { useEffect, useState, type CSSProperties } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import DOMPurify from 'dompurify';
import { Sidebar } from '../components/Sidebar';
import { useNotificationStore } from '../store/notification';
import type { AppNotification, NotificationType } from '../types';
// 剥 HTML 取纯文本前 N 字,用于列表行缩略预览
function stripAndTruncate(html: string, maxChars = 60): string {
// 用 DOMParser 而非正则,防 `<script>` 这种诡异内容
const doc = new DOMParser().parseFromString(html, 'text/html');
const text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length <= maxChars) return text;
return text.slice(0, maxChars) + '…';
}
// 每条通知顶部标签 — 4 色一目了然
const TYPE_CHIP: Record<NotificationType, { text: string; color: string; bg: string }> = {
announcement: { text: '公告', color: 'var(--color-primary)', bg: 'var(--color-primary-bg, rgba(0,184,230,0.12))' },
@ -188,63 +197,137 @@ const styles: Record<string, CSSProperties> = {
interface NotificationRowProps {
item: AppNotification;
isLast: boolean;
onClick: (item: AppNotification) => void;
expanded: boolean;
onToggle: (item: AppNotification) => void;
onJump: (url: string) => void;
}
function NotificationRow({ item, isLast, onClick }: NotificationRowProps) {
/**
* accordion :
*
* - 折叠态:chip + title + + HTML
* - 展开态:折叠态内容 + (announcement HTML , plain) +
* link_url ,(,)
*
* 1 ( expandedId state ),
* ; +
*/
function NotificationRow({ item, isLast, expanded, onToggle, onJump }: NotificationRowProps) {
const rowStyle: CSSProperties = {
...styles.row,
...(isLast ? styles.rowLast : {}),
...(isLast && !expanded ? styles.rowLast : {}),
background: item.is_read ? 'transparent' : 'var(--color-primary-bg, transparent)',
flexDirection: 'column',
alignItems: 'stretch',
cursor: 'pointer',
};
const titleStyle: CSSProperties = {
...styles.rowTitle,
...(item.is_read ? styles.rowTitleRead : {}),
whiteSpace: 'normal', // 展开后允许换行
};
const chip = TYPE_CHIP[item.type] || TYPE_CHIP.system;
const isAnnouncement = item.type === 'announcement';
const previewText = isAnnouncement
? stripAndTruncate(item.content, 60)
: (item.content.length > 60 ? item.content.slice(0, 60) + '…' : item.content);
return (
<div
style={rowStyle}
onClick={() => onClick(item)}
onClick={() => onToggle(item)}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--color-bg-hover)';
if (!expanded) e.currentTarget.style.background = 'var(--color-bg-hover)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = item.is_read
? 'transparent'
: 'var(--color-primary-bg, transparent)';
if (!expanded) {
e.currentTarget.style.background = item.is_read
? 'transparent'
: 'var(--color-primary-bg, transparent)';
}
}}
>
{item.is_read ? (
<div style={styles.dotPlaceholder} />
) : (
<div style={styles.unreadDot} aria-label="未读" />
)}
<div style={styles.rowContent}>
<div style={styles.rowHead}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<span style={{
padding: '1px 7px', borderRadius: 4, fontSize: 11, fontWeight: 500,
color: chip.color, background: chip.bg, flexShrink: 0,
}}>
{chip.text}
</span>
<span style={titleStyle}>{item.title}</span>
</div>
<span style={styles.rowTime} title={new Date(item.created_at).toLocaleString('zh-CN')}>
{formatRelative(item.created_at)}
</span>
</div>
{isAnnouncement ? (
<div
style={styles.rowBody}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.content) }}
/>
{/* 折叠/展开公共头部 */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
{item.is_read ? (
<div style={styles.dotPlaceholder} />
) : (
<div style={styles.rowBody}>{item.content}</div>
<div style={styles.unreadDot} aria-label="未读" />
)}
<div style={styles.rowContent}>
<div style={styles.rowHead}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 }}>
<span style={{
padding: '1px 7px', borderRadius: 4, fontSize: 11, fontWeight: 500,
color: chip.color, background: chip.bg, flexShrink: 0,
}}>
{chip.text}
</span>
<span style={titleStyle}>{item.title}</span>
</div>
<span style={styles.rowTime} title={new Date(item.created_at).toLocaleString('zh-CN')}>
{formatRelative(item.created_at)}
</span>
</div>
{/* 折叠态:一行缩略;展开态:不渲染缩略(改下方完整内容) */}
{!expanded && previewText && (
<div style={{ ...styles.rowBody, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{previewText}
</div>
)}
</div>
{/* 折叠/展开 chevron 视觉指示 */}
<div style={{
flexShrink: 0, color: 'var(--color-text-tertiary)', marginTop: 3,
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.15s',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
{/* 展开态:完整内容 + 【前往查看】按钮 */}
{expanded && (
<div style={{
marginTop: 12, paddingTop: 12, paddingLeft: 20,
borderTop: '1px dashed var(--color-border-modal-soft)',
}} onClick={(e) => e.stopPropagation()}>
{isAnnouncement ? (
<div
style={{
fontSize: 13, color: 'var(--color-text-primary)', lineHeight: 1.7, wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(item.content) }}
/>
) : (
<div style={{
fontSize: 13, color: 'var(--color-text-primary)', lineHeight: 1.7,
wordBreak: 'break-word', whiteSpace: 'pre-wrap',
}}>
{item.content}
</div>
)}
{item.link_url && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onJump(item.link_url); }}
style={{
marginTop: 12, padding: '6px 14px', fontSize: 13,
background: 'var(--color-primary)', color: '#fff',
border: 'none', borderRadius: 6, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 6,
}}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12h14M13 5l7 7-7 7" />
</svg>
</button>
)}
</div>
)}
</div>
);
}
@ -254,6 +337,9 @@ export function NotificationsPage() {
const [searchParams] = useSearchParams();
const unreadOnly = searchParams.get('unread_only') === 'true';
// accordion 模式:始终最多 1 条展开
const [expandedId, setExpandedId] = useState<number | null>(null);
const list = useNotificationStore((s) => s.list);
const total = useNotificationStore((s) => s.total);
const page = useNotificationStore((s) => s.page);
@ -267,6 +353,7 @@ export function NotificationsPage() {
// 首次加载 + URL 切换时拉第一页
useEffect(() => {
fetchList({ page: 1, unread_only: unreadOnly });
setExpandedId(null); // 切页时收起所有
}, [fetchList, unreadOnly]);
const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
@ -276,12 +363,25 @@ export function NotificationsPage() {
fetchList({ page: p, unread_only: unreadOnly });
};
const handleRowClick = async (item: AppNotification) => {
if (!item.is_read) {
markRead(item.id);
// accordion toggle:同 id 再点收起;不同 id 切换到新 id;未读自动标已读
const handleToggle = (item: AppNotification) => {
if (expandedId === item.id) {
setExpandedId(null);
} else {
setExpandedId(item.id);
if (!item.is_read) {
markRead(item.id);
}
}
if (item.link_url) {
navigate(item.link_url);
};
// 跳转按钮 — 用户主动点【前往查看】才触发,不再点行就跳
const handleJump = (url: string) => {
if (!url) return;
if (url.startsWith('http')) {
window.open(url, '_blank', 'noopener,noreferrer');
} else {
navigate(url);
}
};
@ -334,7 +434,9 @@ export function NotificationsPage() {
key={item.id}
item={item}
isLast={idx === list.length - 1}
onClick={handleRowClick}
expanded={expandedId === item.id}
onToggle={handleToggle}
onJump={handleJump}
/>
))}
</div>

View File

@ -262,10 +262,11 @@ export function SettingsPage() {
<div style={{ display: 'flex', gap: 4, marginBottom: 6, flexWrap: 'wrap' }}>
{[
{ label: 'B', tag: 'b', title: '加粗' },
{ label: '红字', wrap: ['<span style="color:#ff4d4f">', '</span>'], title: '红色文字' },
{ label: '蓝字', wrap: ['<span style="color:#00b8e6">', '</span>'], title: '蓝色文字' },
// 用 CSS var 而非硬编码颜色 — 浅色 / 深色主题自动适配,避免浅色下糊
{ label: '红字', wrap: ['<span style="color:var(--color-danger)">', '</span>'], title: '红色文字(自适应主题)' },
{ label: '蓝字', wrap: ['<span style="color:var(--color-primary)">', '</span>'], title: '蓝色文字(自适应主题)' },
{ label: 'H3', wrap: ['<h3 style="margin:8px 0 4px">', '</h3>'], title: '标题' },
{ label: '分割线', insert: '<hr style="border:none;border-top:1px solid #333;margin:12px 0">', title: '分割线' },
{ label: '分割线', insert: '<hr style="border:none;border-top:1px solid var(--color-border-card);margin:12px 0">', title: '分割线' },
{ label: '列表项', insert: '<li>', title: '列表项' },
].map((btn) => (
<button

View File

@ -93,7 +93,11 @@ async function main() {
} catch (e) { fail('1. 空内容', e); }
// ── 2. HTML 发送返回 200 + sent_to ──
const testContent = `<p>smoke 测试公告 ${Date.now()} - <b>请忽略</b></p>`;
// 内容做长一点 — 短公告 preview 不会截断,折叠态/展开态文字完全一样,
// 测试 7.3 收起没法用 text 区分;长内容让 preview '…' 截断,
// 用末尾独有 marker 字串作为"只在展开态才看得到"的探针。
const uniqueMarker = `EXPANDED-ONLY-MARKER-${Date.now()}`;
const testContent = `<p>smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。占位文字继续:用户/团队/超管角色矩阵 → 团队管理/消费记录/系统设置。结尾标记 <b>${uniqueMarker}</b></p>`;
let sentTo = 0;
try {
const r = await fetch(`${API}/api/v1/admin/announcement/publish`, {
@ -161,7 +165,7 @@ async function main() {
else fail('6.1 再次弹出', new Error('已读状态下还在弹'));
} catch (e) { fail('6. 关闭流程', e); }
// ── 7. 消息中心:看到公告条目 + chip + HTML 渲染 ──
// ── 7. 消息中心:accordion 列表 ──
try {
await page.goto(`${BASE}/notifications`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2000);
@ -169,11 +173,32 @@ async function main() {
const chip = await page.locator('text=/^公告$/').first().isVisible({ timeout: 3000 });
if (chip) pass('7. 消息中心显示 [公告] chip');
else fail('7. chip 缺失', new Error('未找到公告 chip'));
// HTML 渲染:smoke 测试公告 文字应可见
const contentVisible = await page.locator('text=smoke 测试公告').first().isVisible({ timeout: 2000 });
if (contentVisible) pass('7.1 公告内容 HTML 渲染正常');
else fail('7.1 HTML 渲染', new Error('看不到公告文字'));
} catch (e) { fail('7. 消息中心', e); }
// 折叠态:公告文字应该在 title 行(标题"系统公告")就能看到
const titleVisible = await page.locator('text=系统公告').first().isVisible({ timeout: 2000 });
if (titleVisible) pass('7.1 折叠态显示公告标题');
else fail('7.1 折叠态', new Error('看不到公告标题'));
// ── 7.2 点击行展开 → 看到末尾独有 marker(只在展开态才能看到,折叠 preview 被 60 字 '…' 截断) ──
// 用 chip [公告] 作为稳定点击锚点(chip 只在头部出现,不会跳到展开内容里)
const chipLocator = page.locator('text=/^公告$/').first();
// 先验证折叠态看不到 marker
const markerInCollapsed = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false);
if (!markerInCollapsed) pass('7.2.0 折叠态 preview 截断,看不到末尾 marker');
else fail('7.2.0 折叠态泄漏', new Error('折叠状态下却看得到 marker,preview 没截断'));
await chipLocator.click({ force: true, timeout: 3000 });
await page.waitForTimeout(800);
const expandedMarker = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 2000 });
if (expandedMarker) pass('7.2 点击行展开后显示完整 HTML 内容(看到末尾 marker)');
else fail('7.2 展开内容', new Error('展开后看不到 marker'));
// ── 7.3 再点 chip → 收起(marker 不再可见) ──
await chipLocator.click({ force: true, timeout: 3000 });
await page.waitForTimeout(800);
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 还可见'));
} catch (e) { fail('7. 消息中心 accordion', e); }
// 清场:把测试造的公告标已读,避免污染下一次 smoke
await fetch(`${API}/api/v1/announcement/read`, {