diff --git a/web/src/App.tsx b/web/src/App.tsx index 8fca6e4d..0f182bd5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4126,6 +4126,7 @@ export default function App() { )}
void; onNavigateToAnchor?: (messageId: number) => void; selectedCategory?: MessageCategory | 'all'; + /** 会话切换时重置滚动基线,避免旧会话消息数挡住新会话自动滚底 */ + sessionId?: string; /** P3 / Step 5: 未读消息 ID 集合 */ unreadMessageIds?: number[]; onSaveToPrivate?: (message: ChatMessage) => void; @@ -43,6 +45,7 @@ export const MessageStream: React.FC = ({ onQuote, onNavigateToAnchor, selectedCategory = 'all', + sessionId, unreadMessageIds = [], onSaveToPrivate, onExportDocx, @@ -154,6 +157,11 @@ export const MessageStream: React.FC = ({ } }, [totalMessagesCount, handle]); + useEffect(() => { + prevMsgCountRef.current = 0; + isInitialMountRef.current = true; + }, [sessionId]); + return (
= ({ fileRaw, fileUrl }) => const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [sheetNames, setSheetNames] = useState([]); - const [activeSheetIndex, setActiveSheetIndex] = useState(0); const [sheetData, setSheetData] = useState>({}); + const [activeSheetIndex, setActiveSheetIndex] = useState(0); + const [truncatedSheets, setTruncatedSheets] = useState>({}); useEffect(() => { let isMounted = true; @@ -40,6 +41,8 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => } const parsedSheets: Record = {}; + const truncated: Record = {}; + const previewRowLimit = 1000; for (const name of workbook.SheetNames) { const ws = workbook.Sheets[name]; if (ws) { @@ -48,7 +51,8 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => defval: '', blankrows: false, }); - parsedSheets[name] = rawRows; + truncated[name] = rawRows.length > previewRowLimit; + parsedSheets[name] = rawRows.slice(0, previewRowLimit); } else { parsedSheets[name] = []; } @@ -57,6 +61,7 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => if (isMounted) { setSheetNames(workbook.SheetNames); setSheetData(parsedSheets); + setTruncatedSheets(truncated); setActiveSheetIndex(0); setLoading(false); } @@ -173,6 +178,9 @@ export const ExcelViewer: React.FC = ({ fileRaw, fileUrl }) => 工作表:{currentSheetName} · {rows.length} 行 × {maxCols} 列 + {truncatedSheets[currentSheetName] && ( + · 仅预览前 1000 行 + )}
共 {sheetNames.length} 个工作表 diff --git a/web/src/engine/renderMarkdown.test.ts b/web/src/engine/renderMarkdown.test.ts index 267029d2..565a2ac7 100644 --- a/web/src/engine/renderMarkdown.test.ts +++ b/web/src/engine/renderMarkdown.test.ts @@ -17,6 +17,10 @@ test('renderInlineMarkdown: escapes raw HTML tags to prevent XSS', () => { assert.ok(!scriptHtml.includes('alert(1)'); + assert.ok(!forged.includes(''); assert.ok(!/]/i.test(imgHtml), 'must not emit executable img tag'); assert.ok(imgHtml.includes('<img')); diff --git a/web/src/engine/renderMarkdown.ts b/web/src/engine/renderMarkdown.ts index 47c7e705..ab21a58d 100644 --- a/web/src/engine/renderMarkdown.ts +++ b/web/src/engine/renderMarkdown.ts @@ -28,7 +28,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt // 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤 // 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符 const mathPlaceholders: string[] = []; - const placeholderPrefix = 'XMATHTOKENX'; + // 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML + const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase(); + const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`; // 1.1 块级独立公式 $$ ... $$ text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { @@ -42,7 +44,9 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt return `${placeholderPrefix}${idx}XENDX`; } catch { const idx = mathPlaceholders.length; - mathPlaceholders.push(`$$${math}$$`); + mathPlaceholders.push( + `$$${escapeHtml(math)}$$`, + ); return `${placeholderPrefix}${idx}XENDX`; } }); @@ -59,14 +63,14 @@ export function renderInlineMarkdown(content: string, options: RenderMarkdownOpt return `${placeholderPrefix}${idx}XENDX`; } catch { const idx = mathPlaceholders.length; - mathPlaceholders.push(`$${math}$`); + mathPlaceholders.push(`$${escapeHtml(math)}$`); return `${placeholderPrefix}${idx}XENDX`; } }); // 2. 行内代码保护 `code` const codePlaceholders: string[] = []; - const codePlaceholderPrefix = 'XCODETOKENX'; + const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`; text = text.replace(/`([^`\n]+)`/g, (_, code) => { const rendered = `${escapeHtml(code)}`; const idx = codePlaceholders.length; diff --git a/web/src/hooks/useUnreadMarks.ts b/web/src/hooks/useUnreadMarks.ts index e244c684..b73ae5ce 100644 --- a/web/src/hooks/useUnreadMarks.ts +++ b/web/src/hooks/useUnreadMarks.ts @@ -185,6 +185,8 @@ export function useUnreadMarks( // 0. 本地持久化记录已读 saveLocalReadId(sessionId, targetId); + const marksSnapshot = unreadMarks; + // 1. 乐观更新未读集合 const remainingIds = unreadMessageIds.filter((id) => id !== targetId); setUnreadMessageIds(remainingIds); @@ -217,7 +219,7 @@ export function useUnreadMarks( // 2. 异步上报后端已读位点 try { - await fetch('/api/v1/chat/read', { + const res = await fetch('/api/v1/chat/read', { method: 'POST', headers: getBearerAuthHeaders('application/json'), body: JSON.stringify({ @@ -226,12 +228,20 @@ export function useUnreadMarks( read_message_ids: [targetId], }), }); + if (!res.ok) { + throw new Error(`mark read failed: ${res.status}`); + } } catch { - // 错误时不强行重滚,下次拉取自愈 + // 上报失败回滚乐观更新,避免前后端未读数长期分叉 + setUnreadMessageIds(unreadMessageIds); + setNextMessageId(nextMessageId); + setUnreadTotal(unreadTotal); + setUnreadMarks(marksSnapshot); + setIsAllReadCompleted(false); } return targetId; - }, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal]); + }, [sessionId, userId, nextMessageId, unreadMessageIds, unreadTotal, unreadMarks]); // 批量标记已读 (如滚动进入视口阅读或一键已读) const markMessagesRead = useCallback(