From a6d73b83a7a60ca358e334d96bfcc2909b5c290c Mon Sep 17 00:00:00 2001 From: sligter <1771322848@qq.com> Date: Wed, 29 Jul 2026 19:14:18 +0800 Subject: [PATCH] Harden AI optimize modals with safer DOM and a11y --- .../projectSlidesEditor.quickEdit.css | 1 + .../projectSlidesEditor.aiOptimize.js | 117 ++++++++---- .../components/project/detail/extra_js_1.html | 174 +++++++++++------- .../project/todo_board/extra_js_1.html | 138 +++++++++----- tests/test_project_workflow_regressions.py | 29 +++ 5 files changed, 302 insertions(+), 157 deletions(-) diff --git a/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.quickEdit.css b/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.quickEdit.css index b261d2d..6547d1c 100644 --- a/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.quickEdit.css +++ b/src/landppt/web/static/css/pages/project/slides_editor/projectSlidesEditor.quickEdit.css @@ -1279,6 +1279,7 @@ font-size: 0.8rem; color: var(--text-primary); cursor: pointer; + font-family: inherit; transition: transform 0.2s ease, background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } diff --git a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js index 8441541..cd44032 100644 --- a/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js +++ b/src/landppt/web/static/js/pages/project/slides_editor/projectSlidesEditor.aiOptimize.js @@ -12,10 +12,13 @@ // 创建美化的AI优化需求输入弹窗 function showAIOptimizeModal(config) { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { // 创建模态框遮罩 const modal = document.createElement('div'); modal.className = 'ai-optimize-modal'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', String(config.title || 'AI优化')); // 创建弹窗内容 const content = document.createElement('div'); @@ -35,11 +38,11 @@

- ${config.title} +

-

${config.subtitle}

+

-
@@ -49,15 +52,15 @@
当前内容
- ${config.currentInfo} +
-
@@ -5528,9 +5541,7 @@ -
- ${suggestions.map(s => `${s}`).join('')} -
+
@@ -5540,58 +5551,85 @@ AI将根据您的需求智能优化内容
- -
`; - modal.appendChild(content); - document.body.appendChild(modal); + const previousFocus = document.activeElement; + const input = content.querySelector('.ai-optimize-input'); + const confirmBtn = content.querySelector('.ai-optimize-confirm'); + const currentInfoElement = content.querySelector('.ai-optimize-current-info'); + content.querySelector('.ai-optimize-title').textContent = String(config.title || ''); + content.querySelector('.ai-optimize-subtitle').textContent = String(config.subtitle || ''); + currentInfoElement.textContent = String(config.currentInfo || ''); - // 聚焦输入框 - setTimeout(() => { - const input = document.getElementById('aiOptimizeInput'); - if (input) input.focus(); - }, 100); - - // 点击背景关闭 - modal.addEventListener('click', (e) => { - if (e.target === modal) { - modal.remove(); - reject('用户取消'); - } + const suggestionList = content.querySelector('.ai-optimize-suggestion-list'); + suggestions.forEach((suggestion) => { + const suggestionButton = document.createElement('button'); + suggestionButton.type = 'button'; + suggestionButton.className = 'suggestion-tag'; + suggestionButton.textContent = String(suggestion); + suggestionButton.addEventListener('click', () => { + input.value = String(suggestion); + input.focus(); + }); + suggestionList.appendChild(suggestionButton); }); - // 确认按钮 - const confirmBtn = document.getElementById('confirmOptimizeBtn'); - confirmBtn.onclick = () => { - const input = document.getElementById('aiOptimizeInput'); - const value = input?.value.trim(); - if (!value) { - // 输入框抖动动画 - input.style.animation = 'shake 0.5s'; - setTimeout(() => { input.style.animation = ''; }, 500); + let closed = false; + function closeModal(value = null) { + if (closed) return; + closed = true; + document.removeEventListener('keydown', handleKeydown); + modal.remove(); + previousFocus?.focus?.(); + resolve(value); + } + + function handleKeydown(event) { + if (event.key === 'Escape') { + event.preventDefault(); + closeModal(); return; } - modal.remove(); - resolve(value); - }; - - // 添加shake动画 - const style = document.createElement('style'); - style.textContent = ` - @keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-10px); } - 75% { transform: translateX(10px); } + if (event.key !== 'Tab') return; + const focusable = content.querySelectorAll('button:not([disabled]), textarea:not([disabled])'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } } - `; - document.head.appendChild(style); + + content.querySelector('.ai-optimize-modal__close').addEventListener('click', () => closeModal()); + content.querySelector('.ai-optimize-cancel').addEventListener('click', () => closeModal()); + modal.addEventListener('click', (event) => { + if (event.target === modal) closeModal(); + }); + confirmBtn.addEventListener('click', () => { + const value = input.value.trim(); + if (!value) { + input.classList.add('shake'); + setTimeout(() => input.classList.remove('shake'), 500); + return; + } + closeModal(value); + }); + + modal.appendChild(content); + document.body.appendChild(modal); + document.addEventListener('keydown', handleKeydown); + requestAnimationFrame(() => input.focus()); }); } @@ -5633,7 +5671,7 @@ userRequest = await showAIOptimizeModal({ title: `AI优化 - 第${slideIndex + 1}页`, subtitle: '让AI帮助您优化这一页的内容', - currentInfo: `标题:${escapeHtml(title)}
${subtitle ? `副标题:${escapeHtml(subtitle)}
` : ''}类型:${escapeHtml(slideType)}
内容要点:${contentPoints.length}个`, + currentInfo: `标题:${title}\n${subtitle ? `副标题:${subtitle}\n` : ''}类型:${slideType}\n内容要点:${contentPoints.length}个`, suggestions: [ '增加更多技术细节和实例', '简化内容,突出核心要点', @@ -5792,7 +5830,7 @@ userRequest = await showAIOptimizeModal({ title: '优化大纲', subtitle: '让AI帮助您优化整个PPT大纲结构和内容', - currentInfo: `大纲标题:${escapeHtml(parsedOutline.title)}
幻灯片数量:${parsedOutline.slides ? parsedOutline.slides.length : 0} 页
场景:${escapeHtml(parsedOutline.metadata?.scenario) || '通用'}`, + currentInfo: `大纲标题:${parsedOutline.title || ''}\n幻灯片数量:${parsedOutline.slides ? parsedOutline.slides.length : 0} 页\n场景:${parsedOutline.metadata?.scenario || '通用'}`, suggestions: [ '增加更多文字说明', '重新组织结构,优化逻辑流程', diff --git a/tests/test_project_workflow_regressions.py b/tests/test_project_workflow_regressions.py index ca9f576..fe13627 100644 --- a/tests/test_project_workflow_regressions.py +++ b/tests/test_project_workflow_regressions.py @@ -335,3 +335,32 @@ def test_editor_sidebar_thumbnail_refresh_recalculates_scale_without_overriding_ assert "aspect-ratio: 16 / 9;" in css assert "height: 95px" not in css assert "scale(0.1875)" in css + + +def test_ai_optimize_modals_settle_safely_and_use_scoped_dom(): + sources = [ + _read("src/landppt/web/templates/components/project/todo_board/extra_js_1.html"), + _read("src/landppt/web/templates/components/project/detail/extra_js_1.html"), + _read( + "src/landppt/web/static/js/pages/project/slides_editor/" + "projectSlidesEditor.aiOptimize.js" + ), + ] + + for source in sources: + assert "function closeModal(value = null)" in source + assert "content.querySelector('.ai-optimize-input')" in source + assert "suggestionButton.textContent = String(suggestion)" in source + assert "currentInfoElement.textContent = String(config.currentInfo || '')" in source + assert "document.addEventListener('keydown', handleKeydown)" in source + assert ".ai-optimize-modal__close').addEventListener('click', () => closeModal())" in source + assert ".ai-optimize-cancel').addEventListener('click', () => closeModal())" in source + assert "if (event.target === modal) closeModal()" in source + assert 'id="aiOptimizeInput"' not in source + assert 'id="confirmOptimizeBtn"' not in source + assert "${suggestions.map" not in source + assert "${config.currentInfo}" not in source + assert "onclick=\"this.closest('.ai-optimize-modal').remove()\"" not in source + + assert "document.head.appendChild(style)" not in sources[0] + assert "document.head.appendChild(style)" not in sources[1]