From cf7817bb9582e75d4153816e8d3a98c437d908d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E7=BB=8D=E5=BA=B7?= <3052147989@qq.com> Date: Sun, 1 Mar 2026 21:24:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UI=E5=85=A8=E5=B1=80=E7=BE=8E=E5=8C=96?= =?UTF-8?q?=E9=87=8D=E8=AE=BE=E8=AE=A1=20+=20=E6=81=A2=E5=A4=8DCI/?= =?UTF-8?q?=E6=B5=8B=E8=AF=95/=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI 改进: - 设置页面内嵌显示(不再弹窗) - 临时邮箱卡片添加验证码提取按钮 - 导入账号弹窗 textarea 样式优化 - 账号头像 8 色渐变轮转 - 临时邮箱页顶栏邮箱点击复制 - 邮箱管理页自动选中默认分组 - 切换分组时重置邮件列 UI - 修复收件箱/垃圾邮件 Tab 切换(.folder-tab → .email-tab) - 修复从临时邮箱返回后账号列数据残留 - 仪表盘分组列表可点击导航 恢复: - 恢复 .github/ CI 工作流 - 恢复 tests/ 测试目录 - 恢复 docs/ 文档目录 - 恢复 CONTRIBUTING.md, SECURITY.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/bug_report.md | 37 + .github/ISSUE_TEMPLATE/feature_request.md | 32 + .github/PULL_REQUEST_TEMPLATE.md | 33 + .github/dependabot.yml | 40 + .github/labels.yml | 84 + .github/workflows/code-quality.yml | 75 + .github/workflows/docker-build-push.yml | 62 + .github/workflows/python-tests.yml | 48 + .gitignore | 13 +- CONTRIBUTING.md | 149 + SECURITY.md | 136 + .../BUG-00001-UI重设计后用户反馈问题清单.md | 305 ++ .../BUG/BUG-00002-侧边栏折叠与页面加载问题.md | 97 + docs/BUG/BUG-00003-设置内嵌与UI细节优化.md | 32 + ...UG-00004-临时邮箱复制与邮箱管理交互问题.md | 62 + docs/BUG/BUG-00005-Tab切换UI不更新.md | 31 + .../BUG/BUG-00006-临时邮箱返回后账号列残留.md | 46 + docs/BUG/BUG-批量操作与验证码提取.md | 124 + docs/BUGS/BUG-00001-临时邮箱生成失败.md | 287 ++ ...tlook邮件管理工具-重构升级-文档审查报告.md | 161 + ...¶管理工具-前后端拆分与模块化-文档审查报告.md | 154 + docs/DEV/00002-前后端拆分-开发者指南.md | 115 + docs/DEV/布局系统开发者指南.md | 178 + docs/DEVLOG.md | 39 + docs/FD/FD-00004-UI全局美化重设计.md | 1321 +++++ docs/FD/FD-可调整布局系统.md | 1477 ++++++ ...‚®件管理工具-Legacy迁移到Controllers层FD.md | 602 +++ ...utlook邮件管理工具-前后端拆分与模块化FD.md | 106 + ...look邮件管理工具-批量操作与自动化增强FD.md | 526 ++ docs/FD/Outlook邮件管理工具-重构升级FD.md | 191 + docs/LOG/CN-00001-完成legacy删除.md | 77 + ...1-Outlook邮件管理工具-重构升级-执行日志.md | 127 + ...‚®件管理工具-前后端拆分与模块化-执行日志.md | 195 + docs/LOG/LOG-00003-可调整布局系统-执行日志.md | 120 + docs/PERF/性能与容量基线.md | 60 + docs/QA/P0验收对照表.md | 177 + docs/QA/前后端拆分-接口契约抽样验收口径.md | 75 + docs/QA/回归验证清单.md | 171 + docs/RUN/00002-前后端拆分-回滚点与切换方式.md | 82 + docs/RUN/运行与定时刷新验证指南.md | 70 + docs/TD/TD-可调整布局系统.md | 633 +++ ...®件管理工具-Legacy迁移到Controllers层TDD.md | 1033 ++++ ...tlook邮件管理工具-前后端拆分与模块化TDD.md | 331 ++ ...ook邮件管理工具-批量操作与自动化增强TDD.md | 873 ++++ docs/TDD/Outlook邮件管理工具-重构升级TDD.md | 143 + docs/TDD/TDD-00004-UI全局美化重设计.md | 1440 ++++++ docs/TDD/TDD-可调整布局系统.md | 1163 +++++ ...¡理工具-Legacy迁移到Controllers层-测试文档.md | 531 ++ ...¡理工具-批量操作与自动化增强-测试文档-补充.md | 732 +++ ...件管理工具-批量操作与自动化增强-测试文档.md | 508 ++ .../TEST-00004-UI全局美化重设计-测试文档.md | 1161 +++++ ...件管理工具-Legacy迁移到Controllers层TODO.md | 637 +++ ...look邮件管理工具-前后端拆分与模块化TODO.md | 77 + ...ok邮件管理工具-批量操作与自动化增强TODO.md | 463 ++ docs/TODO/Outlook邮件管理工具-重构升级TODO.md | 79 + docs/TODO/TODO-可调整布局系统.md | 1393 ++++++ docs/USER/布局系统使用指南.md | 101 + tests/__init__.py | 1 + tests/_import_app.py | 46 + tests/layout-system/README.md | 289 ++ .../layout-accessibility.test.js | 353 ++ tests/layout-system/coverage/clover.xml | 656 +++ .../coverage/coverage-final.json | 3 + .../coverage/lcov-report/base.css | 224 + .../coverage/lcov-report/block-navigation.js | 87 + .../coverage/lcov-report/favicon.png | Bin 0 -> 445 bytes .../coverage/lcov-report/index.html | 131 + .../lcov-report/layout-manager.js.html | 4306 +++++++++++++++++ .../coverage/lcov-report/prettify.css | 1 + .../coverage/lcov-report/prettify.js | 2 + .../lcov-report/sort-arrow-sprite.png | Bin 0 -> 138 bytes .../coverage/lcov-report/sorter.js | 210 + .../lcov-report/state-manager.js.html | 1045 ++++ tests/layout-system/coverage/lcov.info | 1377 ++++++ .../integration/drag-resize.test.js | 112 + .../integration/keyboard-resizer.test.js | 75 + .../integration/panel-collapse.test.js | 115 + .../integration/reset-layout.test.js | 74 + .../integration/state-error-handling.test.js | 101 + .../integration/state-load.test.js | 60 + .../integration/state-multi-user.test.js | 82 + .../integration/state-save.test.js | 85 + .../integration/window-adapt.test.js | 125 + tests/layout-system/jest.config.js | 48 + .../performance/layout-performance.test.js | 270 ++ tests/layout-system/setup.js | 113 + tests/layout-system/test-utils.js | 297 ++ .../layout-system/unit/layout-manager.test.js | 623 +++ .../layout-system/unit/state-manager.test.js | 266 + tests/test_api_extract_verification.py | 237 + tests/test_core_features.py | 253 + tests/test_distributed_lock.py | 64 + tests/test_error_and_trace.py | 475 ++ tests/test_frontend_manual.py | 541 +++ tests/test_gptmail_error_handling.py | 178 + tests/test_masking_audit_and_import.py | 272 ++ tests/test_module_boundaries.py | 77 + tests/test_performance.py | 284 ++ tests/test_smoke_contract.py | 131 + tests/test_ui_redesign_bugs.py | 230 + tests/test_verification_extractor.py | 420 ++ 101 files changed, 33343 insertions(+), 11 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .github/labels.yml create mode 100644 .github/workflows/code-quality.yml create mode 100644 .github/workflows/docker-build-push.yml create mode 100644 .github/workflows/python-tests.yml create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 docs/BUG/BUG-00001-UI重设计后用户反馈问题清单.md create mode 100644 docs/BUG/BUG-00002-侧边栏折叠与页面加载问题.md create mode 100644 docs/BUG/BUG-00003-设置内嵌与UI细节优化.md create mode 100644 docs/BUG/BUG-00004-临时邮箱复制与邮箱管理交互问题.md create mode 100644 docs/BUG/BUG-00005-Tab切换UI不更新.md create mode 100644 docs/BUG/BUG-00006-临时邮箱返回后账号列残留.md create mode 100644 docs/BUG/BUG-批量操作与验证码提取.md create mode 100644 docs/BUGS/BUG-00001-临时邮箱生成失败.md create mode 100644 docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md create mode 100644 docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md create mode 100644 docs/DEV/00002-前后端拆分-开发者指南.md create mode 100644 docs/DEV/布局系统开发者指南.md create mode 100644 docs/DEVLOG.md create mode 100644 docs/FD/FD-00004-UI全局美化重设计.md create mode 100644 docs/FD/FD-可调整布局系统.md create mode 100644 docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md create mode 100644 docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md create mode 100644 docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md create mode 100644 docs/FD/Outlook邮件管理工具-重构升级FD.md create mode 100644 docs/LOG/CN-00001-完成legacy删除.md create mode 100644 docs/LOG/LOG-00001-Outlook邮件管理工具-重构升级-执行日志.md create mode 100644 docs/LOG/LOG-00002-Outlook邮件管理工具-前后端拆分与模块化-执行日志.md create mode 100644 docs/LOG/LOG-00003-可调整布局系统-执行日志.md create mode 100644 docs/PERF/性能与容量基线.md create mode 100644 docs/QA/P0验收对照表.md create mode 100644 docs/QA/前后端拆分-接口契约抽样验收口径.md create mode 100644 docs/QA/回归验证清单.md create mode 100644 docs/RUN/00002-前后端拆分-回滚点与切换方式.md create mode 100644 docs/RUN/运行与定时刷新验证指南.md create mode 100644 docs/TD/TD-可调整布局系统.md create mode 100644 docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md create mode 100644 docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md create mode 100644 docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md create mode 100644 docs/TDD/Outlook邮件管理工具-重构升级TDD.md create mode 100644 docs/TDD/TDD-00004-UI全局美化重设计.md create mode 100644 docs/TDD/TDD-可调整布局系统.md create mode 100644 docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md create mode 100644 docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档-补充.md create mode 100644 docs/TEST/Outlook邮件管理工具-批量操作与自动化增强-测试文档.md create mode 100644 docs/TEST/TEST-00004-UI全局美化重设计-测试文档.md create mode 100644 docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md create mode 100644 docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md create mode 100644 docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md create mode 100644 docs/TODO/Outlook邮件管理工具-重构升级TODO.md create mode 100644 docs/TODO/TODO-可调整布局系统.md create mode 100644 docs/USER/布局系统使用指南.md create mode 100644 tests/__init__.py create mode 100644 tests/_import_app.py create mode 100644 tests/layout-system/README.md create mode 100644 tests/layout-system/accessibility/layout-accessibility.test.js create mode 100644 tests/layout-system/coverage/clover.xml create mode 100644 tests/layout-system/coverage/coverage-final.json create mode 100644 tests/layout-system/coverage/lcov-report/base.css create mode 100644 tests/layout-system/coverage/lcov-report/block-navigation.js create mode 100644 tests/layout-system/coverage/lcov-report/favicon.png create mode 100644 tests/layout-system/coverage/lcov-report/index.html create mode 100644 tests/layout-system/coverage/lcov-report/layout-manager.js.html create mode 100644 tests/layout-system/coverage/lcov-report/prettify.css create mode 100644 tests/layout-system/coverage/lcov-report/prettify.js create mode 100644 tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png create mode 100644 tests/layout-system/coverage/lcov-report/sorter.js create mode 100644 tests/layout-system/coverage/lcov-report/state-manager.js.html create mode 100644 tests/layout-system/coverage/lcov.info create mode 100644 tests/layout-system/integration/drag-resize.test.js create mode 100644 tests/layout-system/integration/keyboard-resizer.test.js create mode 100644 tests/layout-system/integration/panel-collapse.test.js create mode 100644 tests/layout-system/integration/reset-layout.test.js create mode 100644 tests/layout-system/integration/state-error-handling.test.js create mode 100644 tests/layout-system/integration/state-load.test.js create mode 100644 tests/layout-system/integration/state-multi-user.test.js create mode 100644 tests/layout-system/integration/state-save.test.js create mode 100644 tests/layout-system/integration/window-adapt.test.js create mode 100644 tests/layout-system/jest.config.js create mode 100644 tests/layout-system/performance/layout-performance.test.js create mode 100644 tests/layout-system/setup.js create mode 100644 tests/layout-system/test-utils.js create mode 100644 tests/layout-system/unit/layout-manager.test.js create mode 100644 tests/layout-system/unit/state-manager.test.js create mode 100644 tests/test_api_extract_verification.py create mode 100644 tests/test_core_features.py create mode 100644 tests/test_distributed_lock.py create mode 100644 tests/test_error_and_trace.py create mode 100644 tests/test_frontend_manual.py create mode 100644 tests/test_gptmail_error_handling.py create mode 100644 tests/test_masking_audit_and_import.py create mode 100644 tests/test_module_boundaries.py create mode 100644 tests/test_performance.py create mode 100644 tests/test_smoke_contract.py create mode 100644 tests/test_ui_redesign_bugs.py create mode 100644 tests/test_verification_extractor.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..088c5dc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug Report / Bug 报告 +about: Create a report to help us improve / 创建报告帮助我们改进 +title: "bug: " +labels: "bug" +assignees: "" +--- + +# Bug Report / Bug 报告 + +**Version / 版本:** + + + +**Current behavior / 当前行为:** + + + +**Expected behavior / 期望行为:** + + + +**Steps to reproduce / 复现步骤:** + + + +**Related code / 相关代码:** + + + +``` +insert short code snippets here / 在此插入简短代码片段 +``` + +**Other information / 其他信息:** + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..95cd90a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,32 @@ +--- +name: Feature Request / 功能请求 +about: Suggest an idea for this project / 为此项目提出想法 +title: "feat: " +labels: "enhancement" +assignees: "" +--- + +# Feature Request / 功能请求 + +**Describe the Feature Request / 描述功能请求** + + + +**Describe Preferred Solution / 描述首选解决方案** + + + +**Describe Alternatives / 描述替代方案** + + + +**Related Code / 相关代码** + + + +**Additional Context / 附加上下文** + + + +**If the feature request is approved, would you be willing to submit a PR? / 如果功能请求被批准,你愿意提交 PR 吗?** +Yes / No _(Help can be provided if you need assistance submitting a PR / 如果需要帮助提交 PR,我们可以提供协助)_ diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..6059d4f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,33 @@ + + +## Pull request type / PR 类型 + + + +Please check the type of change your PR introduces / 请勾选 PR 引入的更改类型: + +- [ ] Bugfix / Bug 修复 +- [ ] Feature / 新功能 +- [ ] Code style update (formatting, renaming) / 代码风格更新(格式化、重命名) +- [ ] Refactoring (no functional changes, no api changes) / 重构(无功能更改、无 API 更改) +- [ ] Build related changes / 构建相关更改 +- [ ] Documentation content changes / 文档内容更改 +- [ ] Other (please describe) / 其他(请描述): + +## What is the current behavior? / 当前行为是什么? + + + +Issue Number / Issue 编号: N/A + +## What is the new behavior? / 新行为是什么? + + + +- +- +- + +## Other information / 其他信息 + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2108459 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + # Python dependencies + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "python" + commit-message: + prefix: "chore" + include: "scope" + + # Docker dependencies + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "docker" + commit-message: + prefix: "chore" + include: "scope" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "ci" + commit-message: + prefix: "chore" + include: "scope" diff --git a/.github/labels.yml b/.github/labels.yml new file mode 100644 index 0000000..b67eaa8 --- /dev/null +++ b/.github/labels.yml @@ -0,0 +1,84 @@ +--- +- name: "breaking-change" + color: ee0701 + description: "A breaking change for existing users." +- name: "bug" + color: ee0701 + description: "Inconsistencies or issues which will cause a problem for users or implementors." +- name: "documentation" + color: 0052cc + description: "Solely about the documentation of the project." +- name: "enhancement" + color: 1d76db + description: "Enhancement of the code, not introducing new features." +- name: "refactor" + color: 1d76db + description: "Improvement of existing code, not introducing new features." +- name: "performance" + color: 1d76db + description: "Improving performance, not introducing new features." +- name: "new-feature" + color: 0e8a16 + description: "New features or options." +- name: "maintenance" + color: 2af79e + description: "Generic maintenance tasks." +- name: "ci" + color: 1d76db + description: "Work that improves the continue integration." +- name: "dependencies" + color: 1d76db + description: "Upgrade or downgrade of project dependencies." +- name: "python" + color: 3572A5 + description: "Related to Python code or dependencies." +- name: "docker" + color: 2496ED + description: "Related to Docker configuration or images." + +- name: "in-progress" + color: fbca04 + description: "Issue is currently being resolved by a developer." +- name: "stale" + color: fef2c0 + description: "There has not been activity on this issue or PR for quite some time." +- name: "no-stale" + color: fef2c0 + description: "This issue or PR is exempted from the stable bot." + +- name: "security" + color: ee0701 + description: "Marks a security issue that needs to be resolved ASAP." +- name: "incomplete" + color: fef2c0 + description: "Marks a PR or issue that is missing information." +- name: "invalid" + color: fef2c0 + description: "Marks a PR or issue that is missing information." + +- name: "beginner-friendly" + color: 0e8a16 + description: "Good first issue for people wanting to contribute to the project." +- name: "help-wanted" + color: 0e8a16 + description: "We need some extra helping hands or expertise in order to resolve this." + +- name: "priority-critical" + color: ee0701 + description: "This should be dealt with ASAP. Not fixing this issue would be a serious error." +- name: "priority-high" + color: b60205 + description: "After critical issues are fixed, these should be dealt with before any further issues." +- name: "priority-medium" + color: 0e8a16 + description: "This issue may be useful, and needs some attention." +- name: "priority-low" + color: e4ea8a + description: "Nice addition, maybe... someday..." + +- name: "major" + color: b60205 + description: "This PR causes a major version bump in the version number." +- name: "minor" + color: 0e8a16 + description: "This PR causes a minor version bump in the version number." diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..ffb402c --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,75 @@ +name: Code Quality + +on: + push: + branches: [ "main", "master" ] + paths: + - "**.py" + - "requirements.txt" + - ".github/workflows/code-quality.yml" + pull_request: + paths: + - "**.py" + - "requirements.txt" + - ".github/workflows/code-quality.yml" + workflow_dispatch: + +jobs: + linting: + name: Code Linting + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 black isort mypy + + - name: Run Black (Code Formatter Check) + run: black --check --diff . + + - name: Run isort (Import Sorting Check) + run: isort --check-only --diff . + + - name: Run Flake8 (Linting) + run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + + - name: Run Flake8 (Complexity Check) + run: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + security: + name: Security Scan + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install bandit[toml] + + - name: Run Bandit Security Scan + run: bandit -r . -f json -o bandit-report.json || true + + - name: Display Bandit Results + run: | + if [ -f bandit-report.json ]; then + cat bandit-report.json + fi diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml new file mode 100644 index 0000000..fd265fa --- /dev/null +++ b/.github/workflows/docker-build-push.yml @@ -0,0 +1,62 @@ +name: Build and Push Docker Image (Docker Hub) + +on: + push: + branches: + - main + - master + paths: + - '**.py' + - 'requirements.txt' + - 'Dockerfile' + - 'templates/**' + workflow_dispatch: + +env: + # 统一方案一:发布到我们可控的 Docker Hub 账号命名空间 + DOCKERHUB_IMAGE: guangshanshui/outlook-email-plus + +jobs: + build-and-push: + # 未配置 Docker Hub 凭据时跳过,避免在 fork/未配置 secrets 场景失败 + if: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }} + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.DOCKERHUB_IMAGE }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: Image digest + run: echo ${{ steps.meta.outputs.digest }} diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000..9e350e8 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,48 @@ +name: Python Tests + +on: + push: + branches: [ "main", "master" ] + paths: + - "**.py" + - "requirements.txt" + - "tests/**" + - ".github/workflows/python-tests.yml" + pull_request: + paths: + - "**.py" + - "requirements.txt" + - "tests/**" + - ".github/workflows/python-tests.yml" + workflow_dispatch: + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.11", "3.12", "3.13" ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + - name: Syntax check + run: | + python -m py_compile web_outlook_app.py + python -m py_compile outlook_mail_reader.py + + - name: Run unit tests + run: python -m unittest discover -s tests -v + diff --git a/.gitignore b/.gitignore index 79b1c5b..a719131 100644 --- a/.gitignore +++ b/.gitignore @@ -28,23 +28,14 @@ data/ *.swo .kiro/ -# 测试文件(不开源) -tests/ +# 测试缓存 .pytest_cache/ -# 文档目录(不开源) -docs/ +# 内部文档(不开源) PRD/ 我们的文档/ img/ -# GitHub 配置(不开源) -.github/ - -# 项目文档(不开源) -CONTRIBUTING.md -SECURITY.md - # Node.js node_modules/ package.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d45f992 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# Contributing to Outlook Email Management Tool / 为 Outlook 邮件管理工具做贡献 + +First off, thank you for considering contributing to this project! / 首先,感谢你考虑为此项目做贡献! + +The following is a set of guidelines for contributing. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. / 以下是贡献指南。这些主要是指导方针,而非规则。请运用你的最佳判断,并随时在 PR 中提出对本文档的修改建议。 + +## Table of Contents / 目录 + +- [Code of Conduct](#code-of-conduct--行为准则) +- [How Can I Contribute?](#how-can-i-contribute--我如何做贡献) +- [Development Setup](#development-setup--开发环境设置) +- [Pull Request Process](#pull-request-process--pr-流程) +- [Style Guidelines](#style-guidelines--风格指南) + +## Code of Conduct / 行为准则 + +This project and everyone participating in it is governed by our Code of Conduct. By participating, you are expected to uphold this code. / 此项目及其所有参与者均受我们的行为准则约束。参与即表示你同意遵守此准则。 + +## How Can I Contribute? / 我如何做贡献? + +### Reporting Bugs / 报告 Bug + +Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible: / 在创建 bug 报告之前,请检查现有 issue,你可能会发现无需创建新的。创建 bug 报告时,请尽可能包含详细信息: + +- Use a clear and descriptive title / 使用清晰描述性的标题 +- Describe the exact steps to reproduce the problem / 描述重现问题的确切步骤 +- Provide specific examples / 提供具体示例 +- Describe the behavior you observed and what you expected / 描述你观察到的行为以及你的期望 +- Include screenshots if possible / 如可能,包含截图 +- Include your environment details (OS, Python version, etc.) / 包含你的环境详情(操作系统、Python 版本等) + +### Suggesting Enhancements / 建议增强功能 + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include: / 增强建议通过 GitHub issue 跟踪。创建增强建议时,请包含: + +- Use a clear and descriptive title / 使用清晰描述性的标题 +- Provide a detailed description of the suggested enhancement / 提供建议增强功能的详细描述 +- Explain why this enhancement would be useful / 解释为什么此增强功能有用 +- List some examples of how it would be used / 列出一些使用示例 + +### Your First Code Contribution / 你的第一次代码贡献 + +Unsure where to begin? You can start by looking through `beginner-friendly` and `help-wanted` issues: / 不确定从哪里开始?你可以从 `beginner-friendly` 和 `help-wanted` issue 开始: + +- Beginner-friendly issues - issues which should only require a few lines of code / 初学者友好的 issue - 只需几行代码即可解决 +- Help wanted issues - issues which may be more involved / 需要帮助的 issue - 可能涉及更多内容 + +## Development Setup / 开发环境设置 + +1. Fork the repository / Fork 仓库 +2. Clone your fork / 克隆你的 fork + ```bash + git clone https://github.com/YOUR_USERNAME/outlookEmail.git + cd outlookEmail + ``` + +3. Create a virtual environment / 创建虚拟环境 + ```bash + python -m venv venv + source venv/bin/activate # On Windows: venv\Scripts\activate + ``` + +4. Install dependencies / 安装依赖 + ```bash + pip install -r requirements.txt + ``` + +5. Create a branch for your changes / 为你的更改创建分支 + ```bash + git checkout -b feature/your-feature-name + ``` + +6. Make your changes and test them / 进行更改并测试 + ```bash + python -m unittest discover -s tests -v + ``` + +## Pull Request Process / PR 流程 + +1. Ensure your code follows the project's coding standards / 确保你的代码遵循项目的编码标准 +2. Update the documentation if needed / 如需要,更新文档 +3. Add tests for new features / 为新功能添加测试 +4. Ensure all tests pass / 确保所有测试通过 +5. Update the README.md with details of changes if applicable / 如适用,更新 README.md 中的更改详情 +6. Create a Pull Request with a clear title and description / 创建带有清晰标题和描述的 PR +7. Link any related issues in the PR description / 在 PR 描述中链接相关 issue + +### PR Title Format / PR 标题格式 + +Use conventional commit format: / 使用约定式提交格式: + +- `feat: Add new feature` / `feat: 添加新功能` +- `fix: Fix bug in email fetching` / `fix: 修复邮件获取中的 bug` +- `docs: Update README` / `docs: 更新 README` +- `style: Format code` / `style: 格式化代码` +- `refactor: Refactor authentication` / `refactor: 重构认证` +- `test: Add tests for email service` / `test: 为邮件服务添加测试` +- `chore: Update dependencies` / `chore: 更新依赖` + +## Style Guidelines / 风格指南 + +### Python Code Style / Python 代码风格 + +- Follow PEP 8 style guide / 遵循 PEP 8 风格指南 +- Use meaningful variable and function names / 使用有意义的变量和函数名 +- Add docstrings to functions and classes / 为函数和类添加文档字符串 +- Keep functions small and focused / 保持函数小而专注 +- Use type hints where appropriate / 在适当的地方使用类型提示 + +### Git Commit Messages / Git 提交消息 + +- Use the present tense ("Add feature" not "Added feature") / 使用现在时("Add feature" 而非 "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") / 使用祈使语气("Move cursor to..." 而非 "Moves cursor to...") +- Limit the first line to 72 characters or less / 第一行限制在 72 个字符以内 +- Reference issues and pull requests after the first line / 在第一行之后引用 issue 和 PR + +### Testing / 测试 + +- Write unit tests for new features / 为新功能编写单元测试 +- Ensure all tests pass before submitting PR / 提交 PR 前确保所有测试通过 +- Aim for high test coverage / 追求高测试覆盖率 +- Test edge cases and error conditions / 测试边界情况和错误条件 + +## Project Structure / 项目结构 + +``` +outlook_web/ +├── app.py # Application factory +├── routes/ # Blueprint routes +├── controllers/ # Request handlers +├── services/ # Business logic +├── repositories/ # Data access +├── middleware/ # Middleware +└── security/ # Security modules +``` + +For detailed architecture information, see `docs/DEV/00002-前后端拆分-开发者指南.md` / 详细架构信息请参见 `docs/DEV/00002-前后端拆分-开发者指南.md` + +## Questions? / 有问题? + +Feel free to open an issue with your question or reach out to the maintainers. / 随时开启 issue 提问或联系维护者。 + +## License / 许可证 + +By contributing, you agree that your contributions will be licensed under the Apache License 2.0. / 通过贡献,你同意你的贡献将在 Apache License 2.0 下授权。 + +--- + +Thank you for contributing! / 感谢你的贡献! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ab90a23 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,136 @@ +# Security Policy / 安全策略 + +## Supported Versions / 支持的版本 + +We release patches for security vulnerabilities for the following versions: / 我们为以下版本发布安全漏洞补丁: + +| Version / 版本 | Supported / 支持 | +| -------------- | ------------------------- | +| latest (main) | :white_check_mark: | +| < latest | :x: | + +## Reporting a Vulnerability / 报告漏洞 + +**Please do not report security vulnerabilities through public GitHub issues.** / **请勿通过公开的 GitHub issue 报告安全漏洞。** + +If you discover a security vulnerability, please send an email to: / 如果你发现安全漏洞,请发送邮件至: + +**Email / 邮箱:** 3052147989@qq.com + +Please include the following information in your report: / 请在报告中包含以下信息: + +- Type of vulnerability / 漏洞类型 +- Full paths of source file(s) related to the vulnerability / 与漏洞相关的源文件完整路径 +- Location of the affected source code (tag/branch/commit or direct URL) / 受影响源代码的位置(tag/分支/commit 或直接 URL) +- Step-by-step instructions to reproduce the issue / 重现问题的分步说明 +- Proof-of-concept or exploit code (if possible) / 概念验证或漏洞利用代码(如可能) +- Impact of the vulnerability / 漏洞的影响 + +## Response Timeline / 响应时间表 + +- **Initial Response / 初始响应:** We will acknowledge receipt of your vulnerability report within 48 hours. / 我们将在 48 小时内确认收到你的漏洞报告。 +- **Status Update / 状态更新:** We will provide a more detailed response within 7 days, indicating the next steps in handling your report. / 我们将在 7 天内提供更详细的响应,说明处理报告的后续步骤。 +- **Fix Timeline / 修复时间表:** We will work to fix confirmed vulnerabilities as quickly as possible, typically within 30 days. / 我们将尽快修复已确认的漏洞,通常在 30 天内。 + +## Disclosure Policy / 披露政策 + +- Please give us reasonable time to fix the vulnerability before public disclosure. / 请在公开披露前给我们合理的时间修复漏洞。 +- We will credit you in the security advisory unless you prefer to remain anonymous. / 我们将在安全公告中致谢你,除非你希望保持匿名。 +- We will coordinate with you on the disclosure timeline. / 我们将与你协调披露时间表。 + +## Security Best Practices / 安全最佳实践 + +When using this application, please follow these security best practices: / 使用此应用时,请遵循以下安全最佳实践: + +### For Users / 用户 + +1. **Strong Passwords / 强密码** + - Use a strong, unique password for the web interface / 为 Web 界面使用强且唯一的密码 + - Change the default password immediately / 立即更改默认密码 + - Use at least 12 characters with mixed case, numbers, and symbols / 使用至少 12 个字符,包含大小写、数字和符号 + +2. **SECRET_KEY Management / SECRET_KEY 管理** + - Always set a strong SECRET_KEY environment variable / 始终设置强 SECRET_KEY 环境变量 + - Never commit SECRET_KEY to version control / 切勿将 SECRET_KEY 提交到版本控制 + - Use a cryptographically secure random string / 使用加密安全的随机字符串 + - Generate with: `python -c 'import secrets; print(secrets.token_hex(32))'` + +3. **HTTPS / HTTPS** + - Always use HTTPS in production / 生产环境始终使用 HTTPS + - Use a reverse proxy (Nginx/Caddy) with SSL/TLS / 使用带 SSL/TLS 的反向代理(Nginx/Caddy) + - Enable HSTS (HTTP Strict Transport Security) / 启用 HSTS(HTTP 严格传输安全) + +4. **Access Control / 访问控制** + - Limit access to trusted networks / 限制对受信任网络的访问 + - Use firewall rules to restrict access / 使用防火墙规则限制访问 + - Consider using VPN for remote access / 考虑使用 VPN 进行远程访问 + +5. **Regular Updates / 定期更新** + - Keep the application updated to the latest version / 保持应用更新到最新版本 + - Monitor security advisories / 监控安全公告 + - Update dependencies regularly / 定期更新依赖 + +6. **Data Backup / 数据备份** + - Regularly backup the database file / 定期备份数据库文件 + - Store backups securely / 安全存储备份 + - Test backup restoration / 测试备份恢复 + +### For Developers / 开发者 + +1. **Input Validation / 输入验证** + - Always validate and sanitize user input / 始终验证和净化用户输入 + - Use parameterized queries to prevent SQL injection / 使用参数化查询防止 SQL 注入 + - Implement proper error handling / 实现适当的错误处理 + +2. **Authentication & Authorization / 认证与授权** + - Use bcrypt for password hashing / 使用 bcrypt 进行密码哈希 + - Implement rate limiting for login attempts / 为登录尝试实施速率限制 + - Use secure session management / 使用安全的会话管理 + +3. **Data Protection / 数据保护** + - Encrypt sensitive data at rest / 加密静态敏感数据 + - Use HTTPS for data in transit / 使用 HTTPS 传输数据 + - Implement proper access controls / 实施适当的访问控制 + +4. **Dependencies / 依赖** + - Keep dependencies up to date / 保持依赖最新 + - Use Dependabot for automated updates / 使用 Dependabot 自动更新 + - Review security advisories / 审查安全公告 + +5. **Code Review / 代码审查** + - Review all code changes for security issues / 审查所有代码更改的安全问题 + - Use static analysis tools / 使用静态分析工具 + - Follow secure coding practices / 遵循安全编码实践 + +## Known Security Features / 已知安全特性 + +This application includes the following security features: / 此应用包含以下安全特性: + +- **XSS Protection / XSS 防护:** DOMPurify sanitization and iframe sandboxing / DOMPurify 净化和 iframe 沙箱隔离 +- **CSRF Protection / CSRF 防护:** Flask-WTF token validation / Flask-WTF token 验证 +- **Data Encryption / 数据加密:** Fernet symmetric encryption for sensitive data / Fernet 对称加密敏感数据 +- **Rate Limiting / 速率限制:** Login attempt throttling / 登录尝试限流 +- **Audit Logging / 审计日志:** Sensitive operation tracking / 敏感操作跟踪 +- **Password Hashing / 密码哈希:** bcrypt with salt / bcrypt 加盐 + +For more details, see the security section in README.md / 更多详情请参见 README.md 中的安全部分 + +## Security Updates / 安全更新 + +Security updates will be released as soon as possible after a vulnerability is confirmed. We will: / 安全更新将在漏洞确认后尽快发布。我们将: + +- Release a patch version / 发布补丁版本 +- Publish a security advisory / 发布安全公告 +- Update the CHANGELOG / 更新 CHANGELOG +- Notify users through GitHub releases / 通过 GitHub releases 通知用户 + +## Contact / 联系方式 + +For security-related questions or concerns, please contact: / 有关安全相关的问题或疑虑,请联系: + +- **Email / 邮箱:** 3052147989@qq.com +- **GitHub Issues:** For non-security bugs only / 仅用于非安全 bug + +--- + +Thank you for helping keep this project secure! / 感谢你帮助保持此项目的安全! diff --git a/docs/BUG/BUG-00001-UI重设计后用户反馈问题清单.md b/docs/BUG/BUG-00001-UI重设计后用户反馈问题清单.md new file mode 100644 index 0000000..683b645 --- /dev/null +++ b/docs/BUG/BUG-00001-UI重设计后用户反馈问题清单.md @@ -0,0 +1,305 @@ +# BUG-00001 UI 重设计后用户反馈问题清单 + +> **文档版本:** v1.0 +> **关联 PRD:** PRD-00004-UI全局美化重设计 +> **反馈时间:** 2026-03-01 +> **问题总数:** 11 项(缺陷 5 + 回归 3 + 增强需求 3) + +--- + +## 一、问题总览 + +| 编号 | 类型 | 严重度 | 标题 | 状态 | +|------|------|--------|------|------| +| BUG-001 | 缺陷 | 🔴 HIGH | 临时邮箱创建后列表不显示邮箱 | 待修复 | +| BUG-002 | 回归 | 🔴 HIGH | 选中账号名称未在邮件栏顶部显示 | 待修复 | +| BUG-003 | 回归 | 🟡 MEDIUM | 系统设置页面需要额外点击才能看到设置 | 待修复 | +| BUG-004 | 缺陷 | 🟡 MEDIUM | 弹窗(Modal)偏向左边,未居中显示 | 待验证 | +| BUG-005 | 回归 | 🟡 MEDIUM | 验证码提取按钮不够醒目,缺少邮件详情内集成 | 待修复 | +| BUG-006 | 增强 | 🟢 LOW | 临时邮箱/账号卡片颜色全部相同,无法区分 | 待修复 | +| BUG-007 | 缺陷 | 🟡 MEDIUM | 全选功能需要验证是否正常工作 | 待验证 | +| BUG-008 | 缺陷 | 🟡 MEDIUM | 打标签/去标签功能需要验证是否正常工作 | 待验证 | +| BUG-009 | 增强 | 🟢 LOW | 三栏布局不支持拖拽调整宽度 | 待修复 | +| BUG-010 | 缺陷 | 🟡 MEDIUM | 仪表盘分组概览统计数据不准确 | 待修复 | +| BUG-011 | 增强 | 🟢 LOW | 临时邮箱应作为独立页面有自己的分类/分组系统 | 待评估 | + +--- + +## 二、问题详细分析 + +### BUG-001: 临时邮箱创建后列表不显示邮箱 + +**类型:** 缺陷 +**严重度:** 🔴 HIGH — 核心功能无法使用 +**复现步骤:** +1. 导航到「临时邮箱」页面 +2. 点击「创建邮箱」按钮 +3. 观察:API 调用成功但列表不显示新创建的邮箱 + +**根因分析:** +- `generateTempEmail()` 在 `temp_emails.js` 中调用 `/api/temp-emails` 创建邮箱 +- 创建成功后调用 `loadTempEmails()` 刷新列表 +- `renderTempEmailList()` 渲染卡片到 `#tempEmailContainer` +- 需要验证:API 返回数据格式是否正确、渲染函数是否匹配新容器 ID + +**修复方案:** +- 检查 `loadTempEmails()` 是否正确将数据传给 `renderTempEmailList()` +- 确保 `renderTempEmailList()` 向 `#tempEmailContainer` 写入 HTML +- 添加调试日志确认数据流 + +**影响范围:** `static/js/features/temp_emails.js` + +--- + +### BUG-002: 选中账号名称未在邮件栏顶部显示 + +**类型:** 回归 +**严重度:** 🔴 HIGH — 关键 UX 功能丢失 +**复现步骤:** +1. 在「账号管理」页面选择一个分组 +2. 点击一个邮箱账号 +3. 观察:邮件列表栏顶部没有显示当前选中的邮箱地址 + +**根因分析(已确认):** +- `selectAccount()` (accounts.js 第 13 行) 执行 `document.getElementById('currentAccount').classList.add('show')` +- `#currentAccount` 是 `` 元素,添加 `.show` 类无效果 +- 父级 `#currentAccountBar` 保持 `display:none`,永远不会显示 +- **核心问题:应该操作 `#currentAccountBar` 的 `display` 属性,而非子元素的 class** + +**修复方案:** +```javascript +// accounts.js selectAccount() 中: +// 旧代码(错误): +document.getElementById('currentAccount').classList.add('show'); +// 新代码(修复): +document.getElementById('currentAccountBar').style.display = ''; +``` + +**影响范围:** `static/js/features/accounts.js` 第 13 行 + +--- + +### BUG-003: 系统设置页面需要额外点击才能看到设置 + +**类型:** 回归 +**严重度:** 🟡 MEDIUM — UX 不便 +**复现步骤:** +1. 点击侧边栏「系统设置」 +2. 观察:页面显示占位文字「请使用设置弹窗配置系统参数」和一个「打开设置」按钮 +3. 需要再点击按钮才能看到设置内容 + +**根因分析:** +- `#page-settings` (index.html 第 282-292 行) 是占位内容 +- `navigate()` 函数中没有 `if (page === 'settings') loadSettings();` 的调用 +- 设置内容全部在 `settingsModal` 弹窗中,未内联到页面 + +**修复方案(两种选择):** +1. **方案 A(推荐):** 将设置内容直接渲染到 `#page-settings` 页面内,导航到 settings 时调用 `loadSettings()` 并将内容写入页面 +2. **方案 B:** 导航到 settings 时自动打开设置弹窗 `showSettingsModal()` + +**影响范围:** +- `templates/index.html` `#page-settings` 区域 +- `static/js/main.js` `navigate()` 函数 + `loadSettings()` + +--- + +### BUG-004: 弹窗(Modal)偏向左边 + +**类型:** 缺陷 +**严重度:** 🟡 MEDIUM — 视觉问题 +**复现步骤:** +1. 打开任意弹窗(如添加分组、添加账号等) +2. 观察弹窗是否水平居中 + +**根因分析:** +- CSS `.modal` 使用 `display: none; align-items: center; justify-content: center;` +- `.modal.show { display: flex; }` 激活 flex 布局 +- `.modal-content` 使用 `max-width: 560px; width: 100%;` +- 理论上应该居中,但需要实际浏览器验证 +- 可能的原因: + 1. 某些弹窗内的 `modal-content` 有额外的 margin/padding + 2. 侧边栏偏移导致视觉上不居中(modal 使用 `position:fixed; inset:0;` 覆盖全屏,不受侧边栏影响) + 3. 某些弹窗可能有内联样式覆盖 + +**修复方案:** +- 确认 `.modal` 和 `.modal-content` 的样式链无冲突 +- 为 `.modal-content` 添加 `margin: auto;` 作为额外保险 +- 检查 `modals.html` 中是否有内联样式干扰 + +**影响范围:** `static/css/main.css` Modal 区域 + +--- + +### BUG-005: 验证码提取按钮不够醒目 + +**类型:** 回归 +**严重度:** 🟡 MEDIUM — 功能可发现性差 +**现状:** +- ✅ 功能已实现:每个账号卡片有 📋 按钮调用 `copyVerificationInfo()` +- ✅ 后端 API 存在:`/api/emails//extract-verification` +- ❌ 按钮太小(0.75rem),不够醒目 +- ❌ 邮件详情视图中没有「提取验证码」入口 + +**修复方案:** +1. 增大账号卡片中的 📋 按钮尺寸和对比度 +2. 在邮件详情工具栏 `#emailDetailToolbar` 中添加「提取验证码」按钮 +3. 邮件详情中如果检测到验证码,使用 `.verification-code-box` 高亮显示 + +**影响范围:** +- `static/js/features/groups.js` 账号卡片渲染 +- `static/js/features/emails.js` 邮件详情工具栏 +- `templates/index.html` emailDetailToolbar 区域 + +--- + +### BUG-006: 临时邮箱/账号卡片颜色全部相同 + +**类型:** 增强 +**严重度:** 🟢 LOW +**描述:** 所有临时邮箱卡片使用相同的边框颜色(琥珀金),难以区分。 + +**修复方案:** +- 定义一组预设颜色(8-10 种),根据邮箱地址哈希值分配不同的左边框颜色 +- 颜色方案:使用设计系统中的色彩变体 + +```javascript +const cardColors = ['#B85C38', '#3A7D44', '#C8963E', '#5B8DBE', '#D4697A', '#8B6EC0', '#2D9C99', '#E67E22']; +const colorIndex = hashCode(email) % cardColors.length; +``` + +**影响范围:** `static/js/features/temp_emails.js`,`static/js/features/groups.js` + +--- + +### BUG-007: 全选功能需验证 + +**类型:** 缺陷 +**严重度:** 🟡 MEDIUM +**现状:** +- ✅ `#selectAllCheckbox` 存在于 HTML +- ✅ `toggleSelectAll()` 函数存在于 `groups.js` +- ✅ 每个账号卡片有 `.account-select-checkbox` +- ⚠️ 需要运行时验证:checkbox 点击是否触发 `toggleSelectAll()` +- ⚠️ 需要验证:`updateBatchActionBar()` 是否正确显示批量操作栏 + +**修复方案:** +- 在浏览器中测试全选/取消全选流程 +- 确保 `batchActionBar` 在选中账号时正确显示 +- 修复任何 DOM 选择器不匹配的问题 + +**影响范围:** `static/js/features/groups.js`, `static/js/main.js` + +--- + +### BUG-008: 打标签/去标签功能需验证 + +**类型:** 缺陷 +**严重度:** 🟡 MEDIUM +**现状:** +- ✅ 标签管理弹窗 `#tagManagementModal` 存在 +- ✅ 批量标签弹窗 `#batchTagModal` 存在 +- ✅ `showBatchTagModal()`, `confirmBatchTag()` 函数存在 +- ⚠️ 需要运行时验证整个流程 + +**修复方案:** +- 先选中多个账号 → 批量操作栏出现 → 点击打标签/去标签 +- 验证弹窗打开、标签列表加载、确认操作成功 + +**影响范围:** `static/js/main.js`, `templates/partials/modals.html` + +--- + +### BUG-009: 三栏布局不支持拖拽调整宽度 + +**类型:** 增强 +**严重度:** 🟢 LOW +**描述:** 新三栏布局(分组/账号/邮件)使用固定宽度,不支持用户拖拽调整。 + +**修复方案:** +- 在列之间添加拖拽分隔条(resize handle) +- 使用 `mousedown/mousemove/mouseup` 事件实现拖拽 +- 保存用户调整后的宽度到 localStorage +- 右上角添加三横杠按钮用于临时展开/收起侧栏 + +**影响范围:** +- `static/css/main.css` 添加 `.resize-handle` 样式 +- `static/js/main.js` 添加拖拽调整逻辑 +- `templates/index.html` 添加分隔条 DOM 元素 + +--- + +### BUG-010: 仪表盘分组概览统计数据不准确 + +**类型:** 缺陷 +**严重度:** 🟡 MEDIUM +**复现步骤:** +1. 导航到仪表盘 +2. 查看分组概览中的邮箱数/目录数 +3. 与实际数据对比 + +**根因分析:** +- `loadDashboard()` (main.js 第 129-188 行) 遍历所有分组 +- 对每个分组调用 `/api/accounts?group_id=` 获取账号列表 +- 统计 `totalAccounts`, `validTokens`, `expiredTokens` +- 可能的问题: + 1. API 返回的账号列表不完整 + 2. Token 状态判断逻辑有误(`a.token_status === 'valid'` 可能不匹配实际字段名) + 3. 分组概览中的「目录数」含义不明确 + +**修复方案:** +- 验证 API 返回数据结构,确认 `token_status` 字段名 +- 检查是否遗漏了没有分组的账号 +- 完善统计逻辑,添加分组数量统计 + +**影响范围:** `static/js/main.js` `loadDashboard()` 函数 + +--- + +### BUG-011: 临时邮箱应有独立页面和分组系统 + +**类型:** 增强 +**严重度:** 🟢 LOW +**描述:** 用户希望临时邮箱不再作为分组的子项,而是完全独立的页面,有自己的分类/分组系统。 + +**当前状态:** +- 临时邮箱已有独立页面 `#page-temp-emails` +- 但页面内只是简单的卡片列表 + 创建按钮 +- 用户希望有类似邮箱管理的三栏布局(分类/邮箱列表/邮件内容) + +**修复方案:** +- 重设计 `#page-temp-emails` 页面为两栏布局: + - 左栏:临时邮箱列表(带创建/删除功能) + - 右栏:选中邮箱的收到的邮件列表和详情 +- 后端 API 已支持 `/api/temp-emails//messages` 获取邮件 + +**影响范围:** +- `templates/index.html` `#page-temp-emails` 区域 +- `static/js/features/temp_emails.js` 大幅改造 +- `static/css/main.css` 新增临时邮箱两栏布局样式 + +--- + +## 三、修复优先级排序 + +### 第一批(HIGH — 立即修复) +1. **BUG-002** — 1 行代码修复,影响最小 +2. **BUG-001** — 核心功能,需要验证和修复渲染逻辑 +3. **BUG-003** — 设置页面内联显示 + +### 第二批(MEDIUM — 本周修复) +4. **BUG-010** — 仪表盘数据准确性 +5. **BUG-004** — 弹窗居中 +6. **BUG-005** — 验证码提取可发现性增强 +7. **BUG-007** — 全选功能验证 +8. **BUG-008** — 标签功能验证 + +### 第三批(LOW — 后续迭代) +9. **BUG-006** — 卡片颜色区分 +10. **BUG-009** — 三栏拖拽调整 +11. **BUG-011** — 临时邮箱独立页面 + +--- + +## 四、测试验收标准 + +见配套文件:`tests/test_ui_redesign_bugs.py` diff --git a/docs/BUG/BUG-00002-侧边栏折叠与页面加载问题.md b/docs/BUG/BUG-00002-侧边栏折叠与页面加载问题.md new file mode 100644 index 0000000..945be01 --- /dev/null +++ b/docs/BUG/BUG-00002-侧边栏折叠与页面加载问题.md @@ -0,0 +1,97 @@ +# BUG-00002 侧边栏折叠与页面加载问题 + +**日期:** 2026-03-01 +**来源:** 用户截图反馈 (PixPin_2026-03-01_19-14-29.png) +**关联:** PRD-00004 UI全局美化重设计 + +--- + +## 问题清单 + +### BUG-012:侧边栏折叠后导航图标消失 🔴 HIGH + +**现象:** 点击 ☰ 按钮折叠侧边栏后,导航菜单项完全不可见,只剩一个 logo 图标。用户无法通过图标切换页面。 + +**根因分析:** +CSS 选择器 `.sidebar-collapsed .sidebar .nav-item span` 匹配了**所有** ``,包括承载 emoji 图标的 ``。 + +```css +/* 当前(错误) */ +.sidebar-collapsed .sidebar .nav-item span { display: none; } + +/* 应改为 */ +.sidebar-collapsed .sidebar .nav-item span:not(.nav-icon) { display: none; } +``` + +**HTML 结构参考:** +```html + +``` + +**修复方案:** +- 修改 `static/css/main.css` 中 `.sidebar-collapsed` 相关选择器,排除 `.nav-icon` +- 折叠时 `.nav-item` 应 `justify-content: center` 让图标居中 + +--- + +### BUG-013:账号管理页面仍显示"临时邮箱"分组 🟡 MEDIUM + +**现象:** 临时邮箱已有独立页面,但在"账号管理"的分组列表中仍然显示"临时邮箱"分组,点击后仍在 accountList 中渲染临时邮箱卡片。 + +**根因分析:** +1. `renderGroupList()` 未过滤临时邮箱分组 +2. 虽已在 `selectGroup()` 中添加了跳转逻辑(`navigate('temp-emails')`),但分组条目仍可见,且可能被缓存的旧JS影响 + +**修复方案:** +- 在 `groups.js` 的 `renderGroupList()` 中过滤掉 `name === '临时邮箱'` 的分组 +- 确保临时邮箱的管理完全在独立页面 `#page-temp-emails` 中进行 + +--- + +### BUG-014:审计日志页面永远显示"加载中" 🔴 HIGH + +**现象:** 点击侧边栏"审计日志"菜单,页面内容始终为 loading spinner。 + +**根因分析:** +1. `navigate()` 函数中没有审计日志页面的数据加载调用 +2. 代码中完全没有 `loadAuditLogPage()` 函数 +3. 后端 API `/api/audit-logs` 已存在(`outlook_web/routes/audit.py`) + +**修复方案:** +- 在 `main.js` 中新增 `loadAuditLogPage()` 函数,调用 `/api/audit-logs` 并渲染到 `#auditLogContainer` +- 在 `navigate()` 中添加:`if (page === 'audit') loadAuditLogPage();` + +--- + +### BUG-015:刷新日志页面永远显示"加载中" 🔴 HIGH + +**现象:** 点击侧边栏"刷新日志"菜单,页面内容始终为 loading spinner。 + +**根因分析:** +1. `navigate()` 函数中没有刷新日志页面的数据加载调用 +2. 已有 `loadRefreshLogs()` 函数(第 1806 行),但它渲染到 `#refreshLogsContainer` / `#refreshLogsList`(这是弹窗内元素,不是页面容器) +3. 页面容器 ID 是 `#refreshLogContainer`(少了个 s) + +**修复方案:** +- 新增 `loadRefreshLogPage()` 函数,渲染到 `#refreshLogContainer` +- 在 `navigate()` 中添加:`if (page === 'refresh-log') loadRefreshLogPage();` + +--- + +## 修复优先级 + +| 序号 | BUG ID | 严重度 | 预估改动 | +|------|--------|--------|----------| +| 1 | BUG-012 | 🔴 HIGH | CSS 选择器修正(1处) | +| 2 | BUG-014 | 🔴 HIGH | 新增函数 + navigate 调用 | +| 3 | BUG-015 | 🔴 HIGH | 新增函数 + navigate 调用 | +| 4 | BUG-013 | 🟡 MEDIUM | renderGroupList 过滤(1处) | + +## 涉及文件 + +- `static/css/main.css` — BUG-012 +- `static/js/main.js` — BUG-014, BUG-015(navigate + 新函数) +- `static/js/features/groups.js` — BUG-013(renderGroupList 过滤) diff --git a/docs/BUG/BUG-00003-设置内嵌与UI细节优化.md b/docs/BUG/BUG-00003-设置内嵌与UI细节优化.md new file mode 100644 index 0000000..d695a0a --- /dev/null +++ b/docs/BUG/BUG-00003-设置内嵌与UI细节优化.md @@ -0,0 +1,32 @@ +# BUG-00003 — 设置内嵌与 UI 细节优化 + +> 关联 PRD: PRD-00004 UI全局美化重设计 +> 创建日期: 2026-03-01 +> 状态: 修复中 + +## 问题清单 + +### BUG-016: 设置页面应内嵌显示而非弹窗 (MEDIUM) +- **现象**: 点击侧边栏"系统设置"后,设置表单以弹窗打开,页面本身显示空白 +- **根因**: 1) settings modal 在 modals.html 中,与 #page-settings 有重复 ID;2) 旧服务器未停止导致旧模板被缓存 +- **修复**: + - 将设置表单从 modals.html 移入 index.html 的 `#page-settings` + - 清除 modals.html 中的重复表单(仅保留跳转提示) + - `navigate('settings')` 改为调用 `loadSettings()` 而非 `showSettingsModal()` +- **状态**: ✅ 已修复 + +### BUG-017: 临时邮箱卡片缺少验证码提取按钮 (MEDIUM) +- **现象**: 正式账号卡片有 "🔑 验证码" 按钮可一键提取,但临时邮箱卡片没有 +- **期望**: 临时邮箱卡片也应有 "🔑 验证码" 按钮,调用 `copyVerificationInfo()` 提取验证码 +- **修复**: 在 `renderTempEmailList()` 的卡片 HTML 中添加验证码按钮 +- **状态**: ✅ 已修复 + +### BUG-018: 导入账号弹窗 textarea 太小太丑 (LOW) +- **现象**: "导入邮箱账号"弹窗中的文本域高度仅 100px,显得很小且不协调 +- **修复**: `.form-textarea` 最小高度增至 180px,添加完整样式(字体/边距/焦点态) +- **状态**: ✅ 已修复 + +### BUG-019: 账号头像颜色全部相同太单调 (LOW) +- **现象**: 所有账号卡片的头像圆圈都是琥珀金→砖红渐变色,视觉单调 +- **修复**: 使用 8 组渐变色数组按索引分配(砖红、翠绿、海蓝、琥珀、紫罗兰、朱红、青绿、赭石) +- **状态**: ✅ 已修复 diff --git a/docs/BUG/BUG-00004-临时邮箱复制与邮箱管理交互问题.md b/docs/BUG/BUG-00004-临时邮箱复制与邮箱管理交互问题.md new file mode 100644 index 0000000..e68d11a --- /dev/null +++ b/docs/BUG/BUG-00004-临时邮箱复制与邮箱管理交互问题.md @@ -0,0 +1,62 @@ +# BUG-00004 — 临时邮箱复制与邮箱管理页交互问题 + +> 关联 PRD: PRD-00004 UI全局美化重设计 +> 创建日期: 2026-03-01 +> 状态: ✅ 全部修复 + +## 问题清单 + +--- + +### BUG-020: 临时邮箱页面顶栏邮箱名称不可点击复制 (MEDIUM) + +**现象描述**: +在临时邮箱独立页面(`#page-temp-emails`),当选中一个临时邮箱后,右侧消息列顶栏显示邮箱地址(`#tempEmailCurrentName`),但这个邮箱名称**不可点击复制**。 + +对比参考:在邮箱管理页面的三列布局中,右侧邮件列有一个 `#currentAccountBar`,里面的邮箱地址(`#currentAccountEmail`)可以通过 `onclick="copyCurrentEmail()"` 点击复制。但临时邮箱独立页面的 `#tempEmailCurrentName` 只是普通 `` 文字,没有点击复制功能。 + +**期望行为**: 点击临时邮箱页面顶栏的邮箱名称,应自动复制该邮箱地址到剪贴板并弹出提示。 + +**涉及代码**: +- `templates/index.html` 第 278 行: `` — 缺少 onclick 和 cursor:pointer +- `static/js/features/temp_emails.js` `selectTempEmail()` 第 148 行: `tempName.textContent = email` — 只设置文字,没有绑定点击事件 + +--- + +### BUG-021: 邮箱管理页进入时不会自动选中默认分组 (MEDIUM) + +**现象描述**: +点击侧边栏进入"邮箱管理"页面后,左侧分组列表加载出来了,但**没有自动选中任何分组**。账号列仍显示"请从左侧选择一个分组"的空状态提示。 + +尽管"临时邮箱"分组已被过滤(不显示在列表中),但默认分组并未被自动选中。用户必须手动点击"默认分组"才能看到账号列表。 + +**期望行为**: 进入邮箱管理页面时,如果没有已选中的分组(`currentGroupId` 为空),应自动选中第一个非临时邮箱分组(通常是"默认分组" id=1),并自动加载该分组的账号。 + +**涉及代码**: +- `static/js/features/groups.js` `loadGroups()` 第 24-35 行: 只在 `currentGroupId` 已有值时保持选中,否则什么都不做 +- 需要增加: 当 `currentGroupId` 为空时,自动选中第一个非临时邮箱分组 +- **状态**: ✅ 已修复 — `loadGroups()` 增加 else 分支自动选中第一个非临时邮箱分组 + +--- + +### BUG-022: 切换分组时 currentAccountBar(当前邮箱栏)不会重置/隐藏 (MEDIUM) + +**现象描述**: +在邮箱管理页面,选中某分组并点击一个账号后,右侧邮件列顶部显示"当前邮箱:xxx@xxx.com"的 `currentAccountBar`。但当**切换到另一个分组**时,虽然账号列表刷新了,但 `currentAccountBar` 仍然显示之前的邮箱地址,不会隐藏或更新。 + +这导致用户切换分组后,看到的"当前邮箱"信息与实际选中状态不一致。 + +**期望行为**: 切换分组时,`currentAccountBar` 应该被隐藏(`display:none`),邮件列表回到空状态"请从左侧选择一个邮箱账号",直到用户在新分组中重新选择一个账号。 + +**涉及代码**: +- `static/js/features/groups.js` `selectGroup()` 第 81-122 行: 切换分组时没有重置 currentAccountBar 和邮件列 +- 需要增加: 在 `selectGroup()` 中隐藏 `currentAccountBar`,清空 `emailList`,隐藏 `emailDetailSection` +- **状态**: ✅ 已修复 — `selectGroup()` 开头增加完整的邮件列 UI 重置(6 个元素) + +--- + +## 修复优先级 + +1. **BUG-022** (切换分组不重置) — 影响日常使用体验,信息不一致容易误导用户 +2. **BUG-021** (不自动选中默认分组) — 影响首次进入体验 +3. **BUG-020** (临时邮箱顶栏不可复制) — 功能缺失但有替代方案(点卡片上的邮箱地址) diff --git a/docs/BUG/BUG-00005-Tab切换UI不更新.md b/docs/BUG/BUG-00005-Tab切换UI不更新.md new file mode 100644 index 0000000..838a176 --- /dev/null +++ b/docs/BUG/BUG-00005-Tab切换UI不更新.md @@ -0,0 +1,31 @@ +# BUG-00005 — 收件箱/垃圾邮件 Tab 切换 UI 不更新 + +> 关联 PRD: PRD-00004 UI全局美化重设计 +> 创建日期: 2026-03-01 +> 状态: 修复中 + +## 问题清单 + +### BUG-023: 收件箱/垃圾邮件文件夹 Tab 切换时 active 状态不更新 (HIGH) + +**现象描述**: +在邮箱管理页面,选中一个账号后,右侧邮件列顶部显示"📨 收件箱 | ⚠️ 垃圾邮件"两个 Tab 按钮。点击切换 Tab 时,虽然邮件数据会切换,但**Tab 按钮的 active 样式不会跟随切换**,始终停留在初始状态。 + +**根因分析**: +HTML 模板中的 Tab 按钮使用 CSS 类名 `.email-tab`: +```html + + +``` + +但 JavaScript 中查找的是 `.folder-tab`(不匹配!): +- `main.js` 第 497 行: `document.querySelectorAll('.folder-tab')` → 找不到任何元素 +- `accounts.js` 第 28 行: `document.querySelectorAll('.folder-tab')` → 找不到任何元素 + +CSS 样式定义是正确的 `.email-tab.active`,但由于 JS 查找的选择器错误,active 类永远无法切换。 + +**修复方案**: 将 JS 中所有 `.folder-tab` 替换为 `.email-tab` +- `static/js/main.js` `switchFolder()` 函数 +- `static/js/features/accounts.js` `selectAccount()` 函数 + +**状态**: ✅ 已修复 — 4 处 `.folder-tab` → `.email-tab`(main.js ×2, accounts.js ×1, emails.js ×1) diff --git a/docs/BUG/BUG-00006-临时邮箱返回后账号列残留.md b/docs/BUG/BUG-00006-临时邮箱返回后账号列残留.md new file mode 100644 index 0000000..d275785 --- /dev/null +++ b/docs/BUG/BUG-00006-临时邮箱返回后账号列残留.md @@ -0,0 +1,46 @@ +# BUG-00006 — 从临时邮箱返回邮箱管理后账号列显示残留 + +> 关联 PRD: PRD-00004 UI全局美化重设计 +> 创建日期: 2026-03-01 +> 状态: 待修复 + +## 问题清单 + +### BUG-024: 从临时邮箱页返回邮箱管理后,账号列显示临时邮箱数据残留 (HIGH) + +**现象描述**: +1. 用户首次进入邮箱管理页面,默认分组自动选中,账号列正常显示该分组的邮箱账号 +2. 用户点击侧边栏"临时邮箱"导航到临时邮箱页面 +3. 用户再点击侧边栏"邮箱管理"回到邮箱管理页面 +4. **中间的账号列 `#accountList` 仍然显示临时邮箱的卡片数据**,而非当前分组的正式邮箱账号 + +**根因分析**: + +`#accountList` 容器被临时邮箱和普通邮箱**共用**: + +1. `renderTempEmailList()` 在 `temp_emails.js` 中同时写入两个容器: + - `document.getElementById('accountList')` → 邮箱管理页的账号列 + - `document.getElementById('tempEmailContainer')` → 临时邮箱独立页 + +2. `navigate('mailbox')` 在第二次进入时检查 `groups.length === 0`,由于分组已加载过(length > 0),**跳过 `loadGroups()`** + +3. `loadGroups()` 中自动选中默认分组的逻辑仅在 `currentGroupId` 为空时执行,但切换过页面后 `currentGroupId` 已有值 + +4. 结果:没有任何代码去刷新 `#accountList` 的内容,临时邮箱数据残留 + +**修复方案**: +在 `navigate()` 中,当返回 mailbox 页面且 groups 已加载时,重新加载当前分组的账号: +```javascript +if (page === 'mailbox') { + if (groups.length === 0) { + loadGroups(); + } else if (currentGroupId) { + loadAccountsByGroup(currentGroupId); + } +} +``` + +**涉及代码**: +- `static/js/main.js` `navigate()` 函数第 69 行 + +**状态**: ✅ 已修复 — `navigate('mailbox')` 改为:首次加载 loadGroups(),返回时 loadAccountsByGroup(currentGroupId) diff --git a/docs/BUG/BUG-批量操作与验证码提取.md b/docs/BUG/BUG-批量操作与验证码提取.md new file mode 100644 index 0000000..429c4d0 --- /dev/null +++ b/docs/BUG/BUG-批量操作与验证码提取.md @@ -0,0 +1,124 @@ +# Bug 列表 + +## BUG-001: Toast 通知位置不正确 ✅ 已修复 + +**优先级**: P1 - 高 + +**描述**: +所有 Toast 通知弹窗显示在屏幕正中间,遮挡用户视线,影响操作体验。 + +**期望行为**: +所有通知统一显示在屏幕右下角(类似常见桌面通知位置)。 + +**修复方案**: +修改 `static/css/main.css` 中的 `.toast` 样式,定位到右下角。 + +--- + +## BUG-002: 切换分组时全选状态被取消 ✅ 已修复 + +**优先级**: P1 - 高 + +**描述**: +用户在默认分组中全选了邮箱,切换到另一个分组后,之前选中的邮箱状态丢失。 + +**修复方案**: +使用全局 `selectedAccountIds` Set 存储选中状态,切换分组时保持。 + +--- + +## BUG-003: 批量操作缺少删除邮箱功能 ✅ 已修复 + +**优先级**: P1 - 高 + +**描述**: +批量操作栏缺少"删除邮箱"功能。 + +**修复方案**: +1. 在 `routes/accounts.py` 中注册 `/api/accounts/batch-delete` 路由 +2. 批量操作栏添加"删除"按钮 +3. 实现 `batchDeleteAccounts()` 函数 + +--- + +## BUG-004: 验证码/链接提取功能异常 ✅ 已修复 + +**优先级**: P0 - 紧急 + +**描述**: +验证码提取 API 只使用 Graph API,未复用 Graph→IMAP 回退策略。 + +**修复方案**: +在 `api_extract_verification` 中添加 IMAP 回退支持(IMAP_SERVER_NEW 和 IMAP_SERVER_OLD)。 + +--- + +## BUG-005: 自动轮询开关类型不一致 ✅ 已修复 + +**优先级**: P0 - 紧急 + +**描述**: +后端返回 `enable_auto_polling` 是 boolean,前端用字符串比较 `=== 'true'`,导致判断永远为 false。 + +**修复方案**: +前端统一使用兼容判断:`data.settings.enable_auto_polling === true || data.settings.enable_auto_polling === 'true'` + +--- + +## BUG-006: 自动轮询未满足 PRD 验收 ✅ 已修复 + +**优先级**: P1 - 高 + +**问题描述**: +1. 通知内容不符合"邮箱地址 + 邮件主题" +2. 红点位置不正确(应该在账号列表项上) +3. 轮询失败不停止且不提示 +4. 静默轮询不满足(启动/停止都 showToast) + +**修复方案**: +1. `showNewEmailNotification()` 增加邮箱地址和邮件主题 +2. 添加 `showAccountNewEmailDot()` 在账号列表项上显示红点 +3. 添加 `pollingErrorCount` 连续错误计数,超过阈值自动停止并提示 +4. `startPolling()` 和 `stopPolling()` 改为静默模式 + +--- + +## BUG-007: 轮询次数校验与提示不一致 ✅ 已修复 + +**优先级**: P2 - 中 + +**描述**: +UI 提示"0 表示持续轮询",但前后端校验都限制 1-100。 + +**修复方案**: +前后端校验改为 0-100,0 表示持续轮询。 + +--- + +## BUG-008: 复制按钮缺少加载/防重复点击 ✅ 已修复 + +**优先级**: P2 - 中 + +**描述**: +复制按钮缺少加载状态、防重复点击、成功/失败视觉反馈。 + +**修复方案**: +1. 添加 `copyVerificationInProgress` 防重复点击 +2. 点击时禁用按钮,显示 ⏳ 加载状态 +3. 成功显示 ✅,失败显示 ❌ +4. 1.5 秒后恢复按钮状态 + +--- + +## 修复状态汇总 + +| Bug ID | 优先级 | 状态 | +|--------|--------|------| +| BUG-001 | P1 | ✅ 已修复 | +| BUG-002 | P1 | ✅ 已修复 | +| BUG-003 | P1 | ✅ 已修复 | +| BUG-004 | P0 | ✅ 已修复 | +| BUG-005 | P0 | ✅ 已修复 | +| BUG-006 | P1 | ✅ 已修复 | +| BUG-007 | P2 | ✅ 已修复 | +| BUG-008 | P2 | ✅ 已修复 | diff --git a/docs/BUGS/BUG-00001-临时邮箱生成失败.md b/docs/BUGS/BUG-00001-临时邮箱生成失败.md new file mode 100644 index 0000000..9c8070f --- /dev/null +++ b/docs/BUGS/BUG-00001-临时邮箱生成失败.md @@ -0,0 +1,287 @@ +# BUG-00001:临时邮箱生成失败 + +## 基本信息 + +- **BUG编号**:BUG-00001 +- **发现日期**:2026-02-23 +- **报告人**:用户 +- **严重程度**:P1(高优先级) +- **状态**:✅ 已修复 +- **影响范围**:临时邮箱功能 +- **修复日期**:2026-02-23 + +## 问题描述 + +### 用户错误信息 +``` +生成临时邮箱失败,请稍后重试 +``` + +### 错误详情 +- **Code**: LEGACY_ERROR +- **Type**: LegacyError +- **Status**: 400 +- **Trace ID**: 557910def72a4ea7a59c8b6fb7880a98 + +## 复现步骤 + +1. 登录系统 +2. 进入临时邮箱管理页面 +3. 点击"生成临时邮箱"按钮 +4. 系统返回错误提示 + +## 预期行为 + +- 成功生成临时邮箱地址 +- 返回邮箱地址给用户 +- 在数据库中记录该临时邮箱 + +## 实际行为 + +- 生成失败 +- 返回笼统的错误信息 +- 用户无法知道具体失败原因 + +## 技术分析 + +### 相关代码位置 + +1. **路由层**:`outlook_web/routes/temp_emails.py:9` + - 路由:`/api/temp-emails/generate` + - 方法:POST + +2. **业务逻辑层**:`outlook_web/legacy.py:4171-4186` + ```python + def api_generate_temp_email(): + """生成新的临时邮箱""" + data = request.json or {} + prefix = data.get('prefix') + domain = data.get('domain') + + email_addr = generate_temp_email(prefix, domain) + + if email_addr: + if add_temp_email(email_addr): + log_audit('create', 'temp_email', email_addr, "生成临时邮箱") + return jsonify({'success': True, 'email': email_addr, 'message': '临时邮箱创建成功'}) + else: + return jsonify({'success': False, 'error': '邮箱已存在'}) + else: + retursonify({'success': False, 'error': '生成临时邮箱失败,请稍后重试'}) + ``` + +3. **服务层**:`outlook_web/services/gptmail.py:42-57` + ```python + def generate_temp_email(prefix: str = None, domain: str = None) -> Optional[str]: + """生成临时邮箱地址""" + json_data = {} + if prefix: + json_data["prefix"] = prefix + if domain: + json_data["domain"] = domain + + if json_data: + result = gptmail_request("POST", "/api/generate-email", json_data=json_data) + else: + result = gptmail_request("GET", "/api/generate-email") + + if result and result.get("success"): + return result.get("data", {}).get("email") + return None + ``` + +4. **错误处理**:`outlook_web/legacy.py:253-267` + - 将字符串错误转换为 LEGACY_ERROR + +### 根本原因分析 + +1. **直接原因**:`generate_temp_email()` 函数返回 `None` + +2. **可能的根本原因**: + - ❌ GPTMail API Key 未配置或无效 + - ❌ GPTMail API 服务不可用 + - ❌ 网络连接问题 + - ❌ API 请求超时(30秒) + - ❌ API 返回数据格式不符合预期 + +3. **错误处理问题**: + - ❌ `gptmail_request` 返回的详细错误信息被丢弃 + - ❌ `generate_temp_email` 只返回 None,不返回错误原因 + - ❌ `api_generate_temp_email` 使用笼统的错误信息 + - ❌ 没有记录详细的错误日志 + +### 错误传播链 + +``` +GPTMail API 失败 + ↓ +gptmail_request() 返回 {"success": False, "error": "具体错误"} + ↓ +generate_temp_email() 返回 None(错误信息丢失) + ↓ +api_generate_temp_email() 返回 "生成临时邮箱失败,请稍后重试" + ↓ +错误处理中间件转换为 LEGACY_ERROR + ↓ +用户看到笼统的错误信息 +``` + +## 修复方案 + +### 方案1:改进错误传播(推荐) + +1. **修改 `generate_temp_email` 函数**: + - 返回 `(email_addr, error_msg)` 元组 + - 保留详细的错误信息 + +2. **修改 `api_generate_temp_email` 函数**: + - 使用详细的错误信息 + - 添加错误日志记录 + +3. **改进 `gptmail_request` 函数**: + - 返回更详细的错误信息 + - 区分不同类型的错误(网络错误、API错误、超时等) + +### 方案2:使用异常处理 + +1. 在 `gptmail_request` 中抛出自定义异常 +2. 在 `api_generate_temp_email` 中捕获并处理异常 +3. 返回详细的错误信息给用户 + +### 推荐实施步骤 + +1. ✅ 创建 BUG 文档 +2. ✅ 改进 `gptmail_request` 函数的错误返回 +3. ✅ 改进 `generate_temp_email` 函数的返回值 +4. ✅ 改进 `api_generate_temp_email` 函数的错误处理 +5. ✅ 添加详细的错误日志 +6. ✅ 更新前端错误提示 +7. ✅ 编写单元测试(11个新测试) +8. ✅ 测试验证(51/51 测试通过) + +## 影响评估 + +- **用户体验**:严重影响,用户无法使用临时邮箱功能 +- **数据完整性**:无影响 +- **系统稳定性**:无影响 +- **安全性**:无影响 + +## 相关文档 + +- PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` +- TDD:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 开发者指南:`docs/DEV/00002-前后端拆分-开发者指南.md` + +## 测试计划 + +### 单元测试 +- [x] 测试 `gptmail_request` 的各种错误场景 + - API Key 未配置 + - API Key 无效(401) + - 请求超时 + - 网络连接失败 + - 服务器错误(500) + - 请求频率超限(429) +- [x] 测试 `generate_temp_email` 的错误处理 + - 成功生成 + - API Key 未配置 + - 请求超时 + - 数据格式错误 + - 使用 prefix 和 domain +- [x] 测试 `api_generate_temp_email` 的错误响应(已包含在原有测试中) + +### 集成测试 +- [x] 测试 API Key 无效的场景(通过 mock) +- [x] 测试网络超时的场景(通过 mock) +- [x] 测试 API 服务不可用的场景(通过 mock) +- [x] 测试正常生成临时邮箱的场景 + +### 回归测试 +- [x] 验证其他临时邮箱功能不受影响 +- [x] 验证错误处理中间件正常工作 +- [x] 所有测试通过(51/51) + +## 修复记录 + +- **2026-02-23 20:30**:创建 BUG 文档,完成根本原因分析 +- **2026-02-23 20:45**:实施修复方案 + - 改进 `gptmail_request` 函数,返回详细的错误信息(区分 10+ 种错误类型) + - 改进 `generate_temp_email` 函数,返回 `(email_addr, error_msg)` 元组 + - 改进 `api_generate_temp_email` 函数,使用详细错误信息并添加日志 + - 更新前端错误提示,显示详细错误信息 + - 编写 11 个新的单元测试 + - 更新 2 个现有测试的 mock +- **2026-02-23 21:00**:测试验证通过(51/51 测试全部通过) +- **2026-02-23 21:05**:准备提交修复 + +## 修复详情 + +### 1. 改进 `gptmail_request` 函数(outlook_web/services/gptmail.py) + +**改进内容**: +- 添加 API Key 配置检查 +- 区分 10+ 种错误类型: + - `CONFIG_ERROR`:API Key 未配置 + - `AUTH_ERROR`:API Key 无效(401) + - `PERMISSION_ERROR`:访问被拒绝(403) + - `RATE_LIMIT_ERROR`:请求频率超限(429) + - `SERVER_ERROR`:服务器错误(500+) + - `HTTP_ERROR`:其他 HTTP 错误 + - `TIMEOUT_ERROR`:请求超时 + - `CONNECTION_ERROR`:网络连接失败 + - `REQUEST_ERROR`:其他请求异常 + - `METHOD_ERROR`:不支持的请求方法 + - `UNKNOWN_ERROR`:未知错误 +- 返回结构化错误信息:`{success, error, error_type, details}` + +### 2. 改进 `generate_temp_email` 函数(outlook_web/services/gptmail.py) + +**改进内容**: +- 返回值从 `Optional[str]` 改为 `Tuple[Optional[str], Optional[str]]` +- 成功时返回 `(email_addr, None)` +- 失败时返回 `(None, error_message)`,保留详细错误信息 +- 添加数据格式验证 + +### 3. 改进 `api_generate_temp_email` 函数(outlook_web/legacy.py) + +**改进内容**: +- 解包 `generate_temp_email` 的返回值:`email_addr, error_msg = generate_temp_email(...)` +- 使用详细的错误信息返回给前端 +- 添加日志记录(成功、警告、错误三个级别) + +### 4. 更新前端错误提示(static/js/features/temp_emails.js) + +**改进内容**: +- 检查错误对象的结构 +- 优先显示 `error.message` 或 `error.error` +- 支持结构化错误对象和字符串错误 +- 保留错误详情用于调试 + +### 5. 编写单元测试(tests/test_gptmail_error_handling.py) + +**新增测试**: +- 11 个新的单元测试,覆盖所有错误场景 +- 使用 mock 模拟各种失败情况 +- 验证错误类型和错误信息的正确性 + +## 修复效果 + +### 修复前 +- 用户看到:`生成临时邮箱失败,请稍后重试` +- 错误码:`LEGACY_ERROR` +- 无法知道具体失败原因 + +### 修复后 +- 用户看到详细错误信息,例如: + - `GPTMail API Key 未配置(请在系统设置中配置 GPTMail API Key)` + - `API 请求超时(请求超过 30 秒未响应,请检查网络连接或稍后重试)` + - `无法连接到 GPTMail 服务(网络连接失败: ...)` + - `API Key 无效或已过期(HTTP 401: 请检查 API Key 是否正确)` +- 后端日志记录详细的错误信息 +- 开发者可以快速定位问题 + +## 备注 + +- 建议同时检查其他使用 `gptmail_request` 的函数,确保错误处理一致 +- 考虑添加 GPTMail API 健康检查功能 +- 考虑在设置页面添加 API Key 验证功能 diff --git a/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md b/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md new file mode 100644 index 0000000..b540792 --- /dev/null +++ b/docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md @@ -0,0 +1,161 @@ +# 文档审查报告:Outlook邮件管理工具-重构升级(00001) + +## 📄 文档信息 + +- 主审文档:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 文档类型:TDD +- 文档编号:未解析到 5 位数字编号(建议统一使用:`00001`) +- 文档标题:Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) +- 存放路径:`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 关联前置文档(同主题,因缺少编号以标题关联): + - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + - `docs/FD/Outlook邮件管理工具-重构升级FD.md` + - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` + +## 📚 规范与架构参考文件读取结果 + +- 文档规范: + - 未找到 `Rules/开发文档规范.md` + - 未找到 `Rules/项目文档规范.md` +- 项目架构/产品 Steering: + - 未找到 `.kiro/steering/structure.md` + - 未找到 `.kiro/steering/tech.md` + - 未找到 `.kiro/steering/product.md` + +> 影响:部分“格式/必备章节/架构符合性”无法严格按项目规范判定,本报告采用你提供的审查维度清单做最佳努力评估。 + +--- + +## 📊 质量评分 + +- 格式规范性:4/10 +- 内容完整性:5/10 +- 开发可行性:5/10 +- 架构符合性:3/10 +- 功能集成性:3/10 +- 整体评分:4/10 + +--- + +## ✅ 优点 + +- PRD 内容结构完整,包含背景、用户画像/故事、功能需求、非功能需求与验收清单(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md`)。 +- FD/TDD/TODO 在“只说明要完成什么”的约束下,覆盖面较全,且三者之间方向一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`、`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`、`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 +- TDD 聚焦了稳定性/一致性/可观测等关键技术交付点,并提供了发布回归检查范围(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.10)。 + +--- + +## ⚠️ 需要改进项 + +### 1) 格式规范(命名/编号/类型) + +- 当前文档命名未体现“类型-编号-描述”的规则,例如: + - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + - `docs/FD/Outlook邮件管理工具-重构升级FD.md` + - `docs/TDD/Outlook邮件管理工具-重构升级TDD.md` +- 文档编号缺失,导致无法自动关联同编号前置文档。 +- `TODO` 是否属于允许的文档类型不明确(你给出的类型集合中未包含 TODO)。 + +### 2) 内容完整性(按你提供的审查维度) + +- FD 目前以“功能清单”为主,但缺少“业务流程/交互逻辑”的明确描述,不利于测试用例与验收边界一致(`docs/FD/Outlook邮件管理工具-重构升级FD.md`)。 +- TDD 目前以“技术交付清单”为主,但缺少你定义的 TDD 必备内容(技术架构、API 设计、数据设计等),导致开发落地仍需要额外补充设计文档(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md`)。 + +### 3) 可验收口径仍偏抽象 + +- PRD/FD/TDD 对“可接受时间”“性能基线”给了方向,但缺少明确的验收口径(例如用什么页面/操作测、阈值是什么)(`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` 6.2;`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 3.8)。 + +--- + +## ❌ 严重问题 + +### S-001:编号缺失导致无法按规则自动关联 + +- 问题描述:无法从文件名解析出 5 位编号,无法按编号自动找到前置文档(PRD/FD/TDD 等)。 +- 影响:一致性检查流程无法自动化,后续文档增多时会迅速失控。 + +### S-002:项目文档规范与 Steering 文件缺失 + +- 问题描述:未找到 `Rules/开发文档规范.md`/`Rules/项目文档规范.md` 与 `.kiro/steering/*`。 +- 影响:无法对“章节必备项、文档类型集合、架构符合性”进行权威判定;审查标准会随人变化。 + +### S-003:TDD 定义与当前写法存在“规范冲突” + +- 你提供的审查维度要求 TDD 包含:技术架构、API 设计、数据库设计是否详细。 +- 但当前 TDD 明确写为“仅说明做什么,不描述实现过程与编码细节”(`docs/TDD/Outlook邮件管理工具-重构升级TDD.md` 1)。 +- 影响:若按你给出的审查标准执行,当前 TDD 会被判为“内容不完整”;需要你确认 TDD 的定位到底是“技术设计”还是“技术交付清单”。 + +--- + +## 🔗 前置文档一致性 + +- PRD → FD: + - 一致性:总体一致(FD 覆盖 PRD 的主要功能块:登录、分组、账号、标签、邮件、刷新、日志、导出、审计、OAuth2、临时邮箱、设置)。 + - 风险:PRD 的“成功指标/验收”在 FD 中未全部转换为可操作的验收点(更多是功能覆盖,缺少边界与异常流)。 +- FD → TDD: + - 一致性:方向一致(TDD 聚焦稳定性/一致性/错误与追踪、升级保障、可观测等,能支撑 FD 的业务功能保真)。 + - 缺口:TDD 未提供“功能 → 入口 → 接口/数据 → 日志/审计”的映射关系,导致集成完整性无法直接验收。 +- TODO 与 FD/TDD: + - 一致性:TODO 基本覆盖 FD/TDD 的关键点,并按 P0/P1 分级(`docs/TODO/Outlook邮件管理工具-重构升级TODO.md`)。 + +--- + +## 🔗 功能集成完整性(🔴 新增) + +> 说明:你提供的集成检查清单以 Android 项目为例(导航/DI/数据库等)。本项目为 Web 管理工具,报告采用“入口点可达性 + 调用链完整性 + 状态/任务运行可验证性”的等价口径进行检查。 + +### 功能组件清单(文档抽取) + +| 组件类型 | 组件名称 | 集成状态 | +|---------|---------|---------| +| 页面/入口 | 登录、主页、分组/账号/邮件/设置等入口 | ⚠️ 文档未明确“入口点清单” | +| 接口/能力 | 账号/分组/标签/邮件读取/删除/导出/刷新/日志/统计 | ⚠️ 文档未列出接口清单与契约 | +| 后台任务 | 定时刷新 | ⚠️ 文档未明确“运行证据获取方式” | +| 数据对象 | 账号、分组、标签、设置、刷新日志、审计日志、临时邮箱等 | ⚠️ 文档未明确数据字典/字段口径 | +| 观测点 | 追踪标识、错误编号、运行状态、健康状态 | ⚠️ 文档未明确展示位置/查询口径 | + +### 集成点验证(以“文档可验收”为准) + +| 集成点 | 需要在文档中明确的内容 | 状态 | 说明 | +|--------|------------------------|------|------| +| 入口点可达 | 每个功能从哪里进入(页面/按钮/菜单/链接) | ❌ | FD/TDD/TODO 仅描述能力,未提供入口点清单 | +| 调用链完整 | 关键功能的处理链路边界(至少到“对外行为层面”) | ⚠️ | TDD 有交付项但缺少“功能-接口-数据-日志”映射 | +| 后台任务可运行 | 定时刷新如何被确认“正在运行” | ⚠️ | TDD 要求可验证,但未定义验证口径与展示位置 | +| 状态一致性 | 二次验证、锁定等状态在重启/扩容下的验收方法 | ⚠️ | TDD 有要求,但缺少验收步骤与判定标准 | +| 错误追踪 | 追踪标识在何处生成/展示/查询 | ⚠️ | TDD 有要求,但缺少展示与查询口径 | + +### ❌ 集成缺失问题(文档层面) + +#### INT-001:缺少“功能入口点清单” + +- 问题描述:无法从文档直接验收“功能是否可访问”,容易出现“功能实现了但用户找不到入口”的遗漏。 +- 建议补充:在 FD 或 TDD 增加“入口点清单”章节,列出每个功能的入口位置与可达路径。 + +#### INT-002:缺少“接口/契约清单” + +- 问题描述:文档未定义关键能力的接口列表、请求/响应字段与错误结构,导致实现/测试/验收口径不一致。 +- 建议补充:增加“接口清单(仅契约)”,至少覆盖读取/刷新/删除/导出/设置/临时邮箱等关键能力。 + +--- + +## 📋 改进建议(按优先级) + +### P0:先补齐“规范与编号”,让审查流程能跑起来 + +1. 统一为该主题分配编号 `00001`,并将 PRD/FD/TDD/TODO 文件名改为 `类型-00001-描述.md` 的形式。 +2. 在仓库补充文档规范文件(至少一种):`Rules/项目文档规范.md`(或 `Rules/开发文档规范.md`),明确: + - 允许的文档类型集合 + - 命名规则与存放路径规则 + - 各类型文档的必备章节模板 + +### P0:明确 TDD 的定位,避免“规范冲突” + +3. 你需要确认:TDD 是“技术设计文档”还是“技术交付清单”。 + - 若是“技术设计文档”:需要补齐技术架构、接口契约、数据字典、错误策略、运行与集成验收口径。 + - 若是“技术交付清单”:建议新增一个“设计型文档”(例如 AD/TD/IMPL)承载架构/API/数据设计与集成映射,避免把 TDD 变成两种角色的混合体。 + +### P1:让 FD/TDD 具备可直接验收的“入口/接口/异常流” + +4. FD 补齐关键业务流程与交互逻辑(至少覆盖:导入、全量刷新进度、失败重试、导出二次验证、定时刷新验证)。 +5. TDD 增加“功能集成映射”章节(入口点清单、接口清单、错误编号清单、追踪标识展示/查询口径、定时刷新运行证据口径)。 + diff --git a/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md b/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md new file mode 100644 index 0000000..59f6859 --- /dev/null +++ b/docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md @@ -0,0 +1,154 @@ +# 文档审查报告:Outlook邮件管理工具-前后端拆分与模块化(00002) + +## 📄 文档信息 + +- 主审文档:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` +- 文档类型:TDD +- 文档编号:未解析到 5 位数字编号(建议统一分配编号并落到文件名:如 `TDD-00002-前后端拆分与模块化.md`) +- 文档标题:Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) +- 存放路径:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` +- 关联前置文档(同主题,因缺少编号以标题关联): + - `docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` + - `docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` + - `docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` + +## 📚 规范与架构参考文件读取结果 + +- 文档规范: + - 未找到 `Rules/开发文档规范.md` + - 未找到 `Rules/项目文档规范.md` +- 项目架构/产品 Steering: + - 未找到 `.kiro/steering/structure.md` + - 未找到 `.kiro/steering/tech.md` + - 未找到 `.kiro/steering/product.md` + +> 影响:部分“命名/必备章节/项目架构符合性”无法按项目权威规范判定。本报告按你提供的审查维度清单做最佳努力评估,并基于本仓库(Python/Flask)现实情况对“集成点检查”做等价映射(见下文“功能集成完整性”)。 + +--- + +## 📊 质量评分 + +- 格式规范性:5/10 +- 内容完整性:7/10 +- 开发可行性:7/10 +- 架构符合性:7/10 +- 功能集成性:6/10 +- 整体评分:6.4/10 + +--- + +## ✅ 优点 + +- **拆分目标清晰且可回滚**:明确“行为兼容 + 渐进式拆分 + 每阶段可验收/可回滚”,适合在现有稳定版本上做结构重构(`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` 2/6)。 +- **分层边界与依赖方向写得明确**:给出了 `routes -> services -> repositories -> db` 的依赖约束,并强调避免循环依赖(3.1、8)。 +- **关键风险点识别到位**:import-time 副作用(调度器线程)、契约漂移、前端全局变量依赖等风险都有对应缓解策略(4.2、4.9、8)。 +- **与 PRD/FD 一致**:PRD/FD 的“对用户无感、不引入新复杂度、保持接口不变”的核心约束,在 TDD 中被复述并落到迁移阶段与测试门禁(PRD 2/3/5,FD 2,TDD 2/6/7)。 + +--- + +## ⚠️ 需要改进项 + +### 1) 格式规范(命名/编号/类型) + +- 当前文档命名未体现“类型-编号-描述”的规则(无法自动按编号关联前置文档)。 +- 你提供的文档类型集合里未包含 `TODO`,但仓库中大量使用 TODO 文档;建议在“项目文档规范”中明确是否允许 TODO 作为文档类型。 + +### 2) 内容完整性(TDD 维度) + +> 当前 TDD 已是“技术设计细节”,明显比旧的“技术交付清单型 TDD”更可落地;但仍建议补齐两类“可直接验收/可直接开发”的信息。 + +- **缺少“接口契约抽样清单”**:虽强调 URL/响应不变,但未列出需保持不变的关键接口清单与最小字段契约(建议附录列出 10~20 个关键端点与关键字段)。 +- **缺少“迁移后的模块间契约”**:例如 repository 返回的数据形态、service 的错误聚合结构是否必须与现有一致,建议用 1~2 个典型链路给出示例(例如“删除邮件三路回退错误聚合”)。 + +### 3) 前端拆分的“切割策略”可再具体 + +- 当前给了 `api/ui/features/state` 的理想结构,但未说明迁移时如何处理: + - 现有全局函数/事件绑定的过渡方案(例如先 `main.js` 保持旧函数名,再逐步导出/内聚) + - 静态资源缓存/版本策略(避免浏览器缓存导致上线后脚本不更新) + +--- + +## ❌ 严重问题 + +### S-001:缺少项目级“文档规范/架构 Steering”,导致审查标准难以固化 + +- 问题描述:未找到 `Rules/*` 与 `.kiro/steering/*`,无法严格按项目规范判定“必备章节/命名/目录结构”。 +- 影响:后续文档增多时一致性会下降,审查结论难以复用。 +- 建议:补一份最小可用的 `Rules/项目文档规范.md`(至少定义:允许的文档类型、命名规则、存放路径、各类型必备章节)。 + +--- + +## 🔗 前置文档一致性 + +- PRD → FD: + - 一致性:✅(FD 的交付项覆盖 PRD 目标:后端模块化、入口兼容、前端静态资源拆分、测试与文档交付)。 + - 风险:⚠️(FD 对“接口契约不变”的验收口径仍偏抽象,可在 FD/TODO 中增加“抽样验收接口清单”以便执行) +- FD → TDD: + - 一致性:✅(TDD 细化了 FD 的拆分交付项:目录结构、装配策略、依赖方向、分阶段迁移与测试门禁) + - 缺口:⚠️(TDD 尚未给出“最小契约清单”和“典型链路示例”,导致实现时仍可能出现局部行为漂移) +- TDD → TODO: + - 一致性:✅(TODO 的阶段拆解与 TDD 6 章一致) + +--- + +## 🔗 功能集成完整性检查(重点,适用 TDD) + +> 说明:你提供的集成点清单以 Android(导航/DI/Room/Hilt)为例。本仓库为 Python/Flask Web 项目,这里采用等价映射: +> +> - “导航集成” ≈ Blueprint/路由注册 + 前端入口脚本可达 +> - “DI 模块集成” ≈ app 装配(create_app)/依赖装配/初始化顺序 +> - “数据库集成” ≈ init_db/迁移/连接生命周期 +> - “入口点集成” ≈ 页面/按钮/路由是否可访问 +> - “调用链完整” ≈ routes → services → repositories → db + +### 功能组件清单(从 TDD 抽取) + +| 组件类型 | 组件名称(建议) | 集成状态 | +|---------|------------------|---------| +| App 装配 | `outlook_web.app:create_app()` | ⚠️ 设计已明确,尚需落地并补 smoke test | +| 路由注册 | `outlook_web.routes.*`(Blueprint) | ⚠️ 设计已明确,尚需落地与契约抽样测试 | +| 服务层 | `outlook_web.services.*` | ⚠️ 设计已明确,尚需迁移与单测 | +| 仓储层 | `outlook_web.repositories.*` | ⚠️ 设计已明确,尚需迁移与单测 | +| DB/迁移 | `outlook_web.db:init_db()` | ⚠️ 设计已明确,需验证与现有迁移一致 | +| 调度器 | `outlook_web.scheduler.*` | ⚠️ 设计已明确,重点验证 import-time 副作用 | +| 前端入口 | `/static/js/main.js` | ⚠️ 设计已明确,需验证模板引用与缓存策略 | + +### 集成点验证(以“文档可验收”为准) + +| 集成点 | 文档中是否明确 | 状态 | 说明 | +|--------|----------------|------|------| +| 路由/导航注册 | ✅ | ✅ | 明确 Blueprint 注册方式与 URL 不变策略(4.8) | +| 装配/初始化顺序 | ✅ | ✅ | `init_db()` → scheduler 开关启动(4.9) | +| 调用链完整性 | ✅ | ✅ | 分层依赖方向写明(3.1) | +| 静态资源入口 | ✅ | ⚠️ | 明确 ` + +``` + +--- + +## 十五、实施顺序与依赖关系 + +``` +Phase 1(基础) + F1 CSS 设计系统 + └─ F10 登录页(依赖 F1 完成) + +Phase 2(主框架) + F2 左侧导航栏 + F3 顶栏 + F4 三栏布局容器 + └─ 以上三个可并行实现 + +Phase 3(核心组件) + F5 分组列表 ← 依赖 F4 + F6 账号卡片 ← 依赖 F4 + F7 邮件区域 ← 依赖 F4 + F5 + F6 + +Phase 4(附属页面) + F8 仪表盘 ← 依赖 F2(导航切换) + F9 临时邮箱 ← 依赖 F2 + F4 + F12 刷新进度条 ← 依赖 F3(顶栏) + +Phase 5(收尾) + F11 移动端响应式 + F13 废弃文件清理 +``` + +--- + +## 十六、验收检查表 + +### P0 验收(代码提交前必须通过) + +- [ ] `python -m unittest discover -s tests -v` 全部通过(后端零改动) +- [ ] 登录/退出正常 +- [ ] 分组列表正常加载 +- [ ] 账号列表正常加载,Token 状态徽章显示正确 +- [ ] 点击账号后邮件列表正常加载 +- [ ] 邮件详情正常展示(HTML 内容 iframe 沙箱或纯文本) +- [ ] 全量刷新 Token 流程正常(含进度条更新) +- [ ] Toast 通知正常显示(成功/失败/警告) +- [ ] 所有 Modal(添加账号、分组、导出等)正常弹出 +- [ ] CSRF Token 机制正常工作 + +### P1 验收 + +- [ ] 深色模式切换完整,无颜色泄漏 +- [ ] 分组搜索实时过滤 +- [ ] 账号搜索实时过滤 +- [ ] 验证码邮件识别高亮并可一键复制 +- [ ] 仪表盘统计数据正确 +- [ ] 临时邮箱创建/删除正常 + +### P2 验收 + +- [ ] 移动端(≤600px)侧边栏抽屉正常 +- [ ] 平板端(601-1024px)图标侧边栏正常 +- [ ] `layout.css` 已删除,`layout-manager.js` 已移除引用 + +--- + +*文档版本:V1.0 | 基于 Sequential Thinking 深度规划 | 状态:草案* diff --git a/docs/FD/FD-可调整布局系统.md b/docs/FD/FD-可调整布局系统.md new file mode 100644 index 0000000..d797ad7 --- /dev/null +++ b/docs/FD/FD-可调整布局系统.md @@ -0,0 +1,1477 @@ +# FD - 可调整布局系统功能设计 + +## 文档信息 + +- **文档编号**: FD-00001 +- **创建日期**: 2026-02-26 +- **版本**: v1.0 +- **状态**: 待评审 +- **负责人**: 开发团队 +- **关联 PRD**: PRD-00001 可调整布局系统 v1.1 + +## 一、功能概述 + +本文档详细描述可调整布局系统的功能设计,包括 6 个主要功能模块、23 个子功能的实现细节、验收标准和测试策略。 + +### 1.1 功能范围 + +本次实现的功能包括: + +1. **可拖动调整宽度** - 用户可以通过拖动面板边缘调整宽度 +2. **面板折叠/展开** - 用户可以折叠/展开侧边栏面板 +3. **恢复默认布局** - 用户可以一键恢复到默认布局 +4. **状态持久化** - 自动保存和恢复用户的布局偏好 +5. **窗口尺寸自适应** - 根据窗口大小自动调整布局 +6. **键盘可访问性** - 支持键盘操作和屏幕阅读器 + +### 1.2 参考文档 + +- **PRD**: `PRD/PRD-可调整布局系统.md` v1.1 +- **设计参考**: EnsoAI 编辑器 UI 设计 + +### 1.3 术语定义 + +| 术语 | 说明 | +|------|------| +| 面板 | 指分组面板、账号面板、邮件列表面板、邮件详情面板 | +| Resizer | 拖动分隔条,位于面板右侧边缘,用于调整宽度 | +| 折叠 | 隐藏面板内容,宽度变为 0 或显示 48px 细条 | +| 展开 | 显示面板内容,恢复到折叠前的宽度 | +| 布局状态 | 包括所有面板的宽度和折叠状态 | +| 默认布局 | 初始的面板宽度和折叠状态 | + +## 二、功能详细设计 + +### 功能模块总览 + +| 模块编号 | 模块名称 | 子功能数量 | 优先级 | +|---------|---------|-----------|--------| +| F1 | 可拖动调整宽度 | 4 | P0 | +| F2 | 面板折叠/展开 | 6 | P0 | +| F3 | 恢复默认布局 | 4 | P1 | +| F4 | 状态持久化 | 5 | P0 | +| F5 | 窗口尺寸自适应 | 3 | P1 | +| F6 | 键盘可访问性 | 4 | P2 | + +**总计**: 6 个模块,26 个子功能 + +--- + +## 三、F1 - 可拖动调整宽度 + +### F1.1 鼠标拖动调整宽度 + +**功能编号**: F1.1 +**功能名称**: 鼠标拖动调整宽度 +**优先级**: P0 + +#### 功能描述 + +用户可以通过鼠标拖动面板右侧的 resizer(拖动条)来实时调整面板宽度。 + +#### 前置条件 + +- 面板处于展开状态(非折叠) +- Resizer 元素已渲染且可见 + +#### 主流程 + +1. 用户将鼠标悬停在 resizer 上 +2. 鼠标光标变为 `col-resize`(双向箭头) +3. Resizer 显示视觉反馈(背景色变化、边框高亮) +4. 用户按下鼠标左键(mousedown) +5. 系统记录起始位置和起始宽度 +6. 用户移动鼠标(mousemove) +7. 系统实时计算新宽度:`newWidth = startWidth + (currentX - startX)` +8. 系统应用宽度限制(最小/最大值) +9. 系统更新 CSS 变量,面板宽度实时变化 +10. 用户释放鼠标(mouseup) +11. 系统触发防抖保存(500ms 后保存到 localStorage) + +#### 异常流程 + +- **异常 1**: 用户拖动时鼠标移出窗口 + - 处理:继续监听 mousemove 事件,直到 mouseup +- **异常 2**: 拖动过程中窗口大小改变 + - 处理:重新计算容器宽度,调整最大允许宽度 +- **异常 3**: 拖动速度过快导致性能问题 + - 处理:使用 requestAnimationFrame 节流 + +#### 界面元素 + +**Resizer 元素**: +```html + +``` + +**样式**: +- 宽度:4px +- 高度:100%(填充面板高度) +- 位置:绝对定位,right: 0 +- 光标:col-resize +- Hover 背景色:rgba(26, 26, 26, 0.1) +- Hover 边框:2px solid #1a1a1a + +#### 验收标准 + +- [ ] 可以拖动分组面板的 resizer +- [ ] 可以拖动账号面板的 resizer +- [ ] 可以拖动邮件列表面板的 resizer +- [ ] 拖动时面板宽度实时变化 +- [ ] 拖动时光标为 col-resize +- [ ] 拖动时有视觉反馈(背景色、边框) +- [ ] 拖动流畅,帧率 ≥ 60fps +- [ ] 释放鼠标后 500ms 保存状态 + +--- + +### F1.2 宽度限制(最小/最大) + +**功能编号**: F1.2 +**功能名称**: 宽度限制 +**优先级**: P0 + +#### 功能描述 + +系统对每个面板的宽度进行限制,防止用户拖动到不合理的尺寸。 + +#### 宽度限制规则 + +| 面板 | 最小宽度 | 最大宽度 | 说明 | +|------|---------|---------|------| +| 分组面板 | 150px | 400px | 确保分组名称和图标可见 | +| 账号面板 | 180px | 500px | 确保邮箱地址可读 | +| 邮件列表 | 280px | 600px | 确保邮件预览可读 | +| 邮件详情 | 400px | 无限制 | 自动填充剩余空间 | + +#### 主流程 + +1. 用户拖动 resizer +2. 系统计算新宽度 +3. 系统获取该面板的最小/最大宽度 +4. 系统应用限制:`newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth))` +5. 系统更新面板宽度 + +#### 验收标准 + +- [ ] 分组面板宽度不能小于 150px +- [ ] 分组面板宽度不能大于 400px +- [ ] 账号面板宽度不能小于 180px +- [ ] 账号面板宽度不能大于 500px +- [ ] 邮件列表宽度不能小于 280px +- [ ] 邮件列表宽度不能大于 600px +- [ ] 达到限制时,继续拖动无效果 + +--- + +### F1.3 确保邮件详情面板可见 + +**功能编号**: F1.3 +**功能名称**: 确保邮件详情面板可见 +**优先级**: P0 + +#### 功能描述 + +无论用户如何调整侧边栏宽度,邮件详情面板始终保持至少 400px 可见宽度。 + +#### 主流程 + +1. 用户拖动任意侧边栏的 resizer +2. 系统计算容器总宽度 +3. 系统计算其他面板的总宽度 +4. 系统计算最大允许宽度:`maxAllowed = containerWidth - otherPanelsWidth - 400` +5. 系统应用限制:`newWidth = Math.min(newWidth, maxAllowed)` +6. 系统更新面板宽度 + +#### 异常流程 + +- **异常 1**: 容器宽度不足(< 800px) + - 处理:触发窗口自适应逻辑部分面板 + +#### 验收标准 + +- [ ] 邮件详情面板始终可见 +- [ ] 邮件详情面板宽度 ≥ 400px +- [ ] 拖动侧边栏时,邮件详情面板不会被挤压消失 +- [ ] 窗口宽度不足时,自动折叠侧边栏 + +--- + +### F1.4 拖动视觉反馈 + +**功能编号**: F1.4 +**功能名称**: 拖动视觉反馈 +**优先级**: P1 + +#### 功能描述 + +在拖动过程中提供清晰的视觉反馈,让用户知道当前正在拖动。 + +#### 视觉反馈元素 + +1. **Resizer Hover 状态**: + - 背景色:rgba(26, 26, 26, 0.1) + - 中心线:2px solid #1a1a1a + +2. **拖动中状态**: + - 全局光标:col-resize + - 禁用文本选择:user-select: none + - Resizer 保持高亮状态 + +3. **拖动结束**: + - 恢复默认光标 + - 恢复文本选择 + - Resizer 恢复普通状态 + +#### 验收标准 + +- [ ] Hover 时 resizer 有视觉反馈 +- [ ] 拖动时全局光标为 col-resize +- [ ] 拖动时无法选中文本 +- [ ] 拖动结束后光标恢复正常 +- [ ] 拖动结束后可以选中文本 + +--- + +## 四、F2 - 面板折叠/展开 + +### F2.1 折叠按钮交互 + +**功能编号**: F2.1 +**功能名称**: 折叠按钮交互 +**优先级**: P0 + +#### 功能描述 + +每个侧边栏面板的右上角有一个折叠按钮,点击可以折叠/展开面板。 + +#### 前置条件 + +- 面板已渲染 +- 至少有一个其他侧边栏面板处于展开状态 + +#### 主流程 + +1. 用户点击折叠按钮 +2. 系统检查是否至少有一个其他面板展开 +3. 如果是最后一个展开的面板,阻止折叠并提示用户 +4. 否则,切换面板的折叠状态 +5. 系统添加/移除 `.collapsed` 类 +6. 系统触发 CSS 过渡动画(200ms) +7. 系统立即保存状态到 localStorage + +#### 界面元素 + +**折叠按钮**: +```html + +``` + +**按钮位置**: +- 导航栏右侧 +- 在其他操作按钮之后 + +**按钮样式**: +- 尺寸:32x32px +- 图标:重置图标(↻) +- Hover 背景色:#f5f5f5 +- Hover 边框:1px solid #ccc + +#### 验收标准 + +- [ ] 导航栏显示重置按钮 +- [ ] 按钮位置合理 +- [ ] Hover 时有视觉反馈 +- [ ] Tooltip 显示"恢复默认布局" + +--- + +### F3.2 确认对话框 + +**功能编号**: F3.2 +**功能名称**: 确认对话框 +**优先级**: P1 + +#### 功能描述 + +点击重置按钮后,弹出确认对话框,防止用户误操作。 + +#### 界面元素 + +**确认对话框**: +```html + +``` + +#### 主流程 + +1. 用户点击重置按钮 +2. 系统显示确认对话框 +3. 用户点击"确定"或"取消" +4. 如果点击"确定",执行重置逻辑 +5. 如果点击"取消",关闭对话框 + +#### 验收标准 + +- [ ] 点击重置按钮显示对话框 +- [ ] 对话框内容清晰 +- [ ] 点击"取消"关闭对话框 +- [ ] 点击"确定"执行重置 +- [ ] 点击遮罩层关闭对话框 + +--- + +### F3.3 重置逻辑 + +**功能编号**: F3.3 +**功能名称**: 重置逻辑 +**优先级**: P1 + +#### 功能描述 + +执行重置操作,将所有面板恢复到默认宽度和折叠状态。 + +#### 默认布局参数 + +| 面板 | 默认宽度 | 默认状态 | +|------|---------|---------| +| 分组面板 | 200px | 展开 | +| 账号面板 | 260px | 展开 | +| 邮件列表 | 380px | 展开 | +| 邮件详情 | 自适应 | 展开 | + +#### 主流程 + +1. 用户确认重置 +2. 系统清除 localStorage 中的布局状态 +3. 系统重置 CSS 变量到默认值 +4. 系统移除所有 `.collapsed` 类 +5. 系统触发重置动画(300ms) +6. 系统关闭确认对话框 +7. 系统显示成功提示 + +#### 验收标准 + +- [ ] 所有面板恢复到默认宽度 +- [ ] 所有面板恢复到展开状态 +- [ ] localStorage 被清除 +- [ ] 重置动画流畅 +- [ ] 显示成功提示 + +--- + +### F3.4 重置动画 + +**功能编号**: F3.4 +**功能名称**: 重置动画 +**优先级**: P2 + +#### 功能描述 + +重置时使用平滑的过渡动画,而不是瞬间跳变。 + +#### 动画参数 + +- **动画时长**: 300ms +- **缓动函数**: ease +- **动画属性**: width + +#### CSS 实现 + +```css +.main-container.resetting { + transition: grid-template-columns 300ms ease; +} + +.resizable-panel.resetting { + transition: width 300ms ease; +} +``` + +#### 验收标准 + +- [ ] 重置动画流畅 +- [ ] 动画时长为 300ms +- [ ] 所有面板同时动画 + +--- + +## 六、F4 - 状态持久化 + +### F4.1 保存布局状态 + +**功能编号**: F4.1 +**功能名称**: 保存布局状态 +**优先级**: P0 + +#### 功能描述 + +自动保存用户的布局偏好到 localStorage,包括面板宽度和折叠状态。 + +#### 数据结构 + +```json +{ + "version": "1.1", + "userId": "user123", + "timestamp": 1709020800000, + "panels": { + "groups": { + "width": "200px", + "collapsed": false + }, + "accounts": { + "width": "260px", + "collapsed": false + }, + "emails": { + "width": "380px", + "collapsed": false + } + } +} +``` + +#### 存储键名 + +- 格式:`outlook_layout_state_${userId}` +- 未登录:`outlook_layout_state_guest` + +#### 主流程 + +1. 用户调整布局(拖动或折叠) +2. 系统触发保存逻辑 +3. 系统读取当前所有面板的状态 +4. 系统构建数据对象 +5. 系统序列化为 JSON +6. 系统写入 localStorage + +#### 验收标准 + +- [ ] 拖动后 500ms 保存状态 +- [ ] 折叠后立即保存状态 +- [ ] 数据格式正确 +- [ ] 多用户隔离生效 +- [ ] localStorage 数据 ≤ 1KB + +--- + +### F4.2 加载布局状态 + +**功能编号**: F4.2 +**功能名称**: 加载布局状态 +**优先级**: P0 + +#### 功能描述 + +页面加载时从 localStorage 读取并恢复用户的布局偏好。 + +#### 主流程 + +1. 页面 DOMContentLoaded 事件触发 +2. 系统获取当前用户 ID +3. 系统从 localStorage 读取数据 +4. 系统验证数据格式和版本 +5. 系统应用面板宽度(更新 CSS 变量) +6. 系统应用折叠状态(添加 `.collapsed` 类) +7. 系统跳过动画(避免页面加载时的闪烁) + +#### 异常流程 + +- **异常 1**: localStorage 不可用 + - 处理:使用默认布局,不报错 +- **异常 2**: 数据格式错误 + - 处理:清除数据,使用默认布局 +- **异常 3**: 版本不兼容 + - 处理:迁移数据或使用默认布局 + +#### 验收标准 + +- [ ] 刷新页面后布局状态恢复 +- [ ] 恢复时无动画闪烁 +- [ ] localStorage 不可用时使用默认布局 +- [ ] 数据错误时使用默认布局 + +--- + +### F4.3 多用户隔离 + +**功能编号**: F4.3 +**功能名称**: 多用户隔离 +**优先级**: P1 + +#### 功能描述 + +不同用户的布局偏好独立存储,互不影响。 + +#### 实现方式 + +- 存储键名包含用户 ID:`outlook_layout_state_${userId}` +- 从全局变量或页面元素获取用户 ID +- 未登录用户使用 `guest` 作为 ID + +#### 主流程 + +1. 系统获取当前用户 ID +2. 系统构建存储键名 +3. 系统使用该键名读写 localStorage + +#### 验收标准 + +- [ ] 不同用户的布局独立 +- [ ] 切换用户后布局正确 +- [ ] 未登录用户使用 guest 键名 + +--- + +### F4.4 防抖保存 + +**功能编号**: F4.4 +**功能名称**: 防抖保存 +**优先级**: P1 + +#### 功能描述 + +拖动调整宽度时,延迟 500ms 保存,避免频繁写入 localStorage。 + +#### 实现逻辑 + +```javascript +let pendingSave = null; + +function saveStateDebounced() { + if (pendingSave) { + clearTimeout(pendingSave); + } + pendingSave = setTimeout(() => { + saveStateNow(); + pendingSave = null; + }, 500); +} +``` + +#### 验收标准 + +- [ ] 拖动时不立即保存 +- [ ] 停止拖动后 500ms 保存 +- [ ] 连续拖动只保存一次 +- [ ] 折叠操作立即保存(不防抖) + +--- + +### F4.5 页面卸载时强制保存 + +**功能编号**: F4.5 +**功能名称**: 页面卸载时强制保存 +**优先级**: P1 + +#### 功能描述 + +用户关闭页面或刷新时,强制执行防抖任务,确保最后一次调整被保存。 + +#### 实现逻辑 + +```javascript +window.addEventListener('beforeunload', () => { + if (pendingSave) { + clearTimeout(pendingSave); + saveStateNow(); + } +}); +``` + +#### 验收标准 + +- [ ] 拖动后立即关闭页面,状态被保存 +- [ ] 拖动后立即刷新页面,状态被保存 +- [ ] 不影响页面卸载速度 + +--- + + +## 七、F5 - 窗口尺寸自适应 + +### F5.1 监听窗口大小变化 + +**功能编号**: F5.1 +**功能名称**: 监听窗口大小变化 +**优先级**: P1 + +#### 功能描述 + +系统监听浏览器窗口的 resize 事件,根据窗口宽度自动调整布局。 + +#### 实现逻辑 + +```javascript +let resizeTimer = null; + +window.addEventListener("resize", () => { + if (resizeTimer) { + clearTimeout(resizeTimer); + } + resizeTimer = setTimeout(() => { + handleWindowResize(); + resizeTimer = null; + }, 200); +}); +``` + +#### 验收标准 + +- [ ] 窗口大小改变时触发处理 +- [ ] 使用防抖避免频繁触发 +- [ ] 防抖延迟为 200ms + +--- + +### F5.2 自动折叠面板 + +**功能编号**: F5.2 +**功能名称**: 自动折叠面板 +**优先级**: P1 + +#### 功能描述 + +当窗口宽度小于特定断点时,自动折叠侧边栏面板,确保邮件详情面板可见。 + +#### 断点规则 + +| 窗口宽度 | 自动折叠的面板 | +|---------|---------------| +| < 1200px | 分组面板 | +| < 900px | 分组面板 + 账号面板 | +| < 700px | 分组面板 + 账号面板 + 邮件列表 | + +#### 主流程 + +1. 窗口大小改变 +2. 系统获取当前窗口宽度 +3. 系统根据断点判断需要折叠的面板 +4. 系统自动折叠面板(不保存状态) +5. 系统添加 .auto-collapsed 类(区分用户手动折叠) + +#### 特殊处理 + +- 自动折叠不保存到 localStorage +- 窗口变大时不自动展开(尊重用户选择) +- 用户手动展开后,窗口缩小时再次自动折叠 + +#### 验收标准 + +- [ ] 窗口 < 1200px 时自动折叠分组面板 +- [ ] 窗口 < 900px 时自动折叠账号面板 +- [ ] 窗口 < 700px 时自动折叠邮件列表 +- [ ] 自动折叠不保存状态 +- [ ] 窗口变大时不自动展开 + +--- + +### F5.3 媒体查询降级 + +**功能编号**: F5.3 +**功能名称**: 媒体查询降级 +**优先级**: P2 + +#### 功能描述 + +使用 CSS 媒体查询作为降级方案,即使 JavaScript 失效也能保证基本的响应式布局。 + +#### CSS 实现 + +```css +@media (max-width: 1200px) { + .group-panel { + display: none; + } +} + +@media (max-width: 900px) { + .account-panel { + display: none; + } +} + +@media (max-width: 700px) { + .email-list-panel { + display: none; + } +} +``` + +#### 验收标准 + +- [ ] JavaScript 禁用时布局仍然响应式 +- [ ] 媒体查询断点与 JavaScript 一致 +- [ ] 小屏幕下邮件详情面板可见 + +--- + + +## 八、F6 - 键盘可访问性 + +### F6.1 Resizer 键盘支持 + +**功能编号**: F6.1 +**功能名称**: Resizer 键盘支持 +**优先级**: P2 + +#### 功能描述 + +Resizer 支持键盘操作,用户可以使用方向键调整面板宽度。 + +#### 键盘操作 + +| 按键 | 操作 | 调整幅度 | +|------|------|---------| +| ← | 减小宽度 | 10px | +| → | 增大宽度 | 10px | +| Shift + ← | 快速减小 | 50px | +| Shift + → | 快速增大 | 50px | +| Tab | 聚焦到下一个 resizer | - | +| Shift + Tab | 聚焦到上一个 resizer | - | + +#### 主流程 + +1. 用户按 Tab 键聚焦到 resizer +2. Resizer 显示聚焦样式(outline) +3. 用户按方向键 +4. 系统计算新宽度 +5. 系统应用宽度限制 +6. 系统更新面板宽度 +7. 系统更新 aria-valuenow 属性 +8. 系统触发防抖保存 + +#### 验收标准 + +- [ ] Tab 键可以聚焦到 resizer +- [ ] 聚焦时有明显的视觉反馈 +- [ ] 左箭头键减小宽度 +- [ ] 右箭头键增大宽度 +- [ ] Shift + 箭头键快速调整 +- [ ] 键盘调整也应用宽度限制 +- [ ] 键盘调整后保存状态 + +--- + +### F6.2 折叠按钮键盘支持 + +**功能编号**: F6.2 +**功能名称**: 折叠按钮键盘支持 +**优先级**: P2 + +#### 功能描述 + +折叠按钮支持键盘操作,用户可以使用 Enter 或 Space 键触发折叠/展开。 + +#### 键盘操作 + +| 按键 | 操作 | +|------|------| +| Tab | 聚焦到按钮 | +| Enter | 触发折叠/展开 | +| Space | 触发折叠/展开 | + +#### 验收标准 + +- [ ] Tab 键可以聚焦到折叠按钮 +- [ ] 聚焦时有视觉反馈 +- [ ] Enter 键触发折叠/展开 +- [ ] Space 键触发折叠/展开 + +--- + +### F6.3 ARIA 属性支持 + +**功能编号**: F6.3 +**功能名称**: ARIA 属性支持 +**优先级**: P2 + +#### 功能描述 + +为所有交互元素添加完整的 ARIA 属性,支持屏幕阅读器。 + +#### ARIA 属性清单 + +**Resizer**: +- `role="separator"` +- `aria-orientation="vertical"` +- `aria-label="调整[面板名称]宽度"` +- `aria-valuenow`: 当前宽度值 +- `aria-valuemin`: 最小宽度 +- `aria-valuemax`: 最大宽度 +- `tabindex="0"` + +**折叠按钮**: +- `aria-label="折叠[面板名称]"` 或 `"展开[面板名称]"` +- `aria-expanded="true"` 或 `"false"` +- `title`: 提示文本 + +**折叠指示器**: +- `role="button"` +- `aria-label="展开[面板名称]"` +- `tabindex="0"` + +#### 验收标准 + +- [ ] 所有 resizer 有完整的 ARIA 属性 +- [ ] 所有折叠按钮有完整的 ARIA 属性 +- [ ] 所有折叠指示器有完整的 ARIA 属性 +- [ ] aria-valuenow 实时更新 +- [ ] aria-expanded 实时更新 + +--- + +### F6.4 焦点管理 + +**功能编号**: F6.4 +**功能名称**: 焦点管理 +**优先级**: P2 + +#### 功能描述 + +合理管理键盘焦点,确保用户使用键盘操作时体验流畅。 + +#### 焦点规则 + +1. **折叠面板后**: + - 焦点移到折叠指示器 + +2. **展开面板后**: + - 焦点移到折叠按钮 + +3. **Tab 顺序**: + - 导航栏按钮 → 分组面板折叠按钮 → 分组面板 resizer → 账号面板折叠按钮 → ... + +4. **模态框打开**: + - 焦点移到模态框的第一个可聚焦元素 + +5. **模态框关闭**: + - 焦点返回到触发按钮 + +#### 验收标准 + +- [ ] 折叠后焦点移到指示器 +- [ ] 展开后焦点移到折叠按钮 +- [ ] Tab 顺序合理 +- [ ] 模态框焦点管理正确 + +--- + + +## 九、实现细节 + +### 9.1 文件修改清单 + +#### 需要修改的文件 + +| 文件路径 | 修改内容 | 优先级 | +|---------|---------|--------| +| `templates/index.html` | 修改主容器结构,添加 resizer 和折叠按钮 | P0 | +| `static/css/main.css` | 添加 Grid 布局、CSS 变量、resizer 样式 | P0 | +| `static/js/main.js` | 初始化 LayoutManager | P0 | + +#### 需要新增的文件 + +| 文件路径 | 文件内容 | 优先级 | +|---------|---------|--------| +| `static/js/layout-manager.js` | LayoutManager 类实现 | P0 | +| `static/js/state-manager.js` | StateManager 类实现 | P0 | + +### 9.2 HTML 结构变更 + +#### 当前结构 + +```html +
+ + + +
...
+
+``` + +#### 目标结构 + +```html +
+ + + + + + + + + + +
...
+
+``` + +### 9.3 CSS 变量系统 + +```css +:root { + /* 面板初始宽度 */ + --groups-panel-width: 200px; + --accounts-panel-width: 260px; + --emails-panel-width: 380px; + + /* 最小/最大宽度 */ + --groups-panel-min-width: 150px; + --groups-panel-max-width: 400px; + --accounts-panel-min-width: 180px; + --accounts-panel-max-width: 500px; + --emails-panel-min-width: 280px; + --emails-panel-max-width: 600px; + --email-detail-panel-min-width: 400px; + + /* 折叠状态 */ + --panel-collapsed-width: 0px; + --collapator-width: 48px; + + /* Resizer */ + --resizer-width: 4px; + + /* 动画 */ + --panel-transition-duration: 200ms; + --reset-transition-duration: 300ms; +} +``` + +### 9.4 JavaScript 模块设计 + +#### LayoutManager 类 + +```javascript +class LayoutManager { + constructor() { + this.isResizing = false; + this.currentPanel = null; + this.startX = 0; + this.startWidth = 0; + this.pendingSave = null; + this.stateManager = new StateManager(); + } + + // 初始化 + init() { } + + // 拖动相关 + startResize(e) { } + resize(e) { } + stopResize() { } + + // 键盘相关 + handleKeyboard(e) { } + + // 折叠相关 + togglePanel(panelName) { } + collapsePanel(panelName, auto = false) { } + expandPanel(panelName) { } + + // 重置相关 + resetLayout() { } + + // 窗口自适应 + handleWindowResize() { } + + // 状态管理 + saveStateDebounced() { } + saveStateNow() { } + loadState() { } + + // 工具方法 + updatePanelWidth(panel, width) { } + getMinWidth(panelType) { } + getMaxWidth(panelType) { } + calculateOtherPanelsWidth(excludePanel) { } + updateAriaValue(resizer, value) { } + getCurrentUserId() { } +} +``` + +#### StateManager 类 + +```javascript +class StateManager { + constructor() { + this.storageKeyPrefix = 'outlook_layout_state_'; + } + + // 保存状态 + save(userId, state) { } + + // 加载状态 + load(userId) { } + + // 清除状态 + clear(userId) { } + + // 验证数据 + validate(state) { } + + // 版本迁移 + migrate(state) { } + + // 获取存储键名 + getStorageKey(userId) { } +} +``` + +--- + +## 十、验收标准总览 + +### 10.1 功能验收标准 + +#### P0 - 必须通过(核心功能) + +**拖动调整宽度**: +- [ ] F1.1 - 可以拖动调整分组面板宽度 +- [ ] F1.1 - 可以拖动调整账号面板宽度 +- [ ] F1.1 - 可以拖动调整邮件列表宽度 +- [ ] F1.2 - 宽度限制生效(最小/最大) +- [ ] F1.3 - 邮件详情面板始终可见且 ≥ 400px + +**面板折叠/展开**: +- [ ] F2.1 - 可以折叠/展开分组面板 +- [ ] F2.1 - 可以折叠/展开账号面板 +- [ ] F2.1 - 可以折叠/展开邮件列表 +- [ ] F2.2 - 折叠/展开动画流畅(200ms) +- [ ] F2.5 - 折叠后 resizer 隐藏 +- [ ] F2.6 - 不能折叠最后一个面板 + +**状态持久化**: +- [ ] F4.1 - 拖动后 500ms 保存状态 +- [ ] F4.1 - 折叠后立即保存状态 +- [ ] F4.2 - 刷新页面后状态恢复 + +#### P1 - 重要功能 + +**视觉反馈**: +- [ ] F1.4 - 拖动时有视觉反馈 +- [ ] F2.3 - 折叠后显示 48px 指示器 +- [ ] F2.4 - 点击指示器可以展开 + +**恢复默认布局**: +- [ ] F3.1 - 导航栏显示重置按钮 +- [ ] F3.2 - 点击重置显示确认对话框 +- [ ] F3.3 - 确认后恢复默认布局 + +**状态管理**: +- [ ] F4.3 - 多用户布局隔离 +- [ ] F4.5 - 页面卸载时强制保存 + +**窗口自适应**: +- [ ] F5.1 - 监听窗口大小变化 +- [ ] F5.2 - 窗口 < 1200px 自动折叠分组 +- [ ] F5.2 - 窗口 < 900px 自动折叠账号 +- [ ] F5.2 - 窗口 < 700px 自动折叠邮件列表 + +#### P2 - 优化功能 + +**动画优化**: +- [ ] F3.4 - 重置动画流畅(300ms) +- [ ] F5.3 - 媒体查询降级生效 + +**键盘可访问性**: +- [ ] F6.1 - Resizer 支持键盘操作 +- [ ] F6.2 - 折叠按钮支持键盘操作 +- [ ] F6.3 - ARIA 属性完整 +- [ ] F6.4 - 焦点管理合理 + +### 10.2 非功能验收标准 + +#### 性能 + +- [ ] 拖动时帧率 ≥ 60fps +- [ ] 折叠/展开动画流畅无卡顿 +- [ ] localStorage 数据 ≤ 1KB +- [ ] 页面加载时间增加 ≤ 50ms + +#### 兼容性 + +- [ ] Chrome 90+ 正常工作 +- [ ] Edge 90+ 正常工作 +- [ ] Firefox 88+ 正常工作 +- [ ] Safari 14+ 正常工作 + +#### 可访问性 + +- [ ] 所有交互元素可键盘访问 +- [ ] 所有交互元素有 ARIA 属性 +- [ ] 屏幕阅读器可正常使用 + +### 10.3 回归验证清单 + +#### 现有功能不受影响 + +- [ ] 邮件列表正常显示 +- [ ] 邮件详情正常显示 +- [ ] 点击邮件可以查看详情 +- [ ] 账号切换正常 +- [ ] 分组切换正常 +- [ ] 邮件刷新正常 +- [ ] 邮件删除正常 +- [ ] Token 刷新正常 +- [ ] 搜索功能正常 +- [ ] 原有的响应式布局正常 + +--- + +## 十一、测试策略 + +### 11.1 单元测试 + +**测试文件**: `tests/test_layout_manager.js` + +**测试用例**: +1. LayoutManager 初始化 +2. 宽度计算逻辑 +3. 宽度限制逻辑 +4. 状态序列化/反序列化 +5. 用户 ID 获取逻辑 + +### 11.2 集成测试 + +**测试场景**: +1. 拖动调整宽度 → 保存 → 刷新 → 恢复 +2. 折叠面板 → 保存 → 刷新 → 恢复 +3. 重置布局 → localStorage 清除 +4. 窗口缩小 → 自动折叠 → 窗口放大 +5. 多用户切换 → 布局独立 + +### 11.3 用户验收测试 + +**测试场景**: +1. 新用户首次访问 → 默认布局 +2. 调整布局 → 刷新 → 布局恢复 +3. 折叠所有面板 → 阻止最后一个 +4. 拖动到极限 → 宽度限制生效 +5. 小屏幕访问 → 自动折叠 + +### 11.4 兼容性测试 + +**测试矩阵**: + +| 浏览器 | 版本 | 操作系统 | 测试结果 | +|--------|------|---------|---------| +| Chrome | 90+ | Windows | | +| Chrome | 90+ | macOS | | +| Edge | 90+ | Windows | | +| Firefox | 88+ | Windows | | +| Firefox | 88+ | macOS | | +| Safari | 14+ | macOS | | + +--- + +## 十二、风险和依赖 + +### 12.1 技术风险 + +| 风险 | 影响 | 概率 | 缓解措施 | +|------|------|------|----------| +| CSS Grid 兼容性 | 高 | 低 | 提前测试目标浏览器 | +| 拖动性能问题 | 中 | 中 | 使用 requestAnimationFrame | +| localStorage 配额满 | 低 | 低 | 降级到默认布局 | +| 现有功能受影响 | 高 | 中 | 充分回归测试 | + +### 12.2 依赖关系 + +- **无后端依赖**:纯前端功能,不需要后端支持 +- **无第三方库依赖**:使用原生 JavaScript 实现 +- **CSS 特性依赖**:CSS Grid、CSS 变量、CSS transitions + +### 12.3 缓解措施 + +1. **充分测试**:在所有目标浏览器上测试 +2. **渐进增强**:使用媒体查询作为降级方案 +3. **错误处理**:localStorage 不可用时使用默认布局 +4. **性能优化**:使用防抖、节流、requestAnimationFrame + +--- + +## 十三、附录 + +### 13.1 参考文档 + +- **PRD**: `PRD/PRD-可调整布局系统.md` v1.1 +- **EnsoAI**: `exeoAI/src/renderer/components/layout/` +- **CSS Grid**: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Grid_Layout +- **ARIA**: https://www.w3.org/WAI/ARIA/apg/ + +### 13.2 功能清单汇总 + +| 模块 | 子功能数 | P0 | P1 | P2 | +|------|---------|----|----|-----| +| F1 - 可拖动调整宽度 | 4 | 3 | 1 | 0 | +| F2 - 面板折叠/展开 | 6 | 4 | 2 | 0 | +| F3 - 恢复默认布局 | 4 | 0 | 3 | 1 | +| F4 - 状态持久化 | 5 | 2 | 3 | 0 | +| F5 - 窗口尺寸自适应 | 3 | 0 | 2 | 1 | +| F6 - 键盘可访问性 | 4 | 0 | 0 | 4 | +| **总计** | **26** | **9** | **11** | **6** | + +### 13.3 变更记录 + +| 版本 | 日期 | 变更内容 | 变更人 | +|------|------|----------|--------| +| v1.0 | 2026-02-26 | 初始版本,包含 6 个功能模块、26 个子功能 | 开发团队 | + +--- + +**文档结束** diff --git a/docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md b/docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md new file mode 100644 index 0000000..1c0e97d --- /dev/null +++ b/docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md @@ -0,0 +1,602 @@ +# Outlook 邮件管理工具|Legacy 迁移到 Controllers 层 FD(功能实现清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-24 +- 对齐 PRD:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md` +- 对齐开发指南:`docs/DEV/00002-前后端拆分-开发者指南.md` + +--- + +## 1. 文档目的 + +本 FD 用于把"Legacy 迁移到 Controllers 层 PRD"拆解为**可执行的功能清单**,用于开发/测试/验收对齐。 +仅描述**要实现/保证的功能与行为**,不讨论具体实现方式与技术细节。 + +--- + +## 2. 总交付清单(必须完成) + +### 2.1 创建 Controllers 层目录结构 + +- 在 `outlook_web/` 下创建 `controllers/` 目录 +- 创建 11 个 controller 模块文件: + - `controllers/accounts.py` - 账号管理(20 个路由) + - `controllers/emails.py` - 邮件操作(4 个路由) + - `controllers/groups.py` - 分组管理(6 个路由) + - `controllers/tags.py` - 标签管理(4 个路由) + - `controllers/temp_emails.py` - 临时邮箱(3 个路由) + - `controllers/oauth.py` - OAuth 授权(2 个路由) + - `controllers/settings.py` - 系统设置(3 个路由) + - `controllers/scheduler.py` - 定时任务(1 个路由) + - `controllers/system.py` - 系统信息(3 个路由) + - `controllers/audit.py` - 审计日志(1 个路由) + - `controllers/pages.py` - 页面路由(3 个路由) +- 创建 `controllers/__init__.py` 用于模块导出 + +### 2.2 迁移所有路由处理函数 + +- 从 `legacy.py` 提取 54 个 API 路由处理函数到对应的 controller 文件 +- 保持函数签名和行为完全一致 +- 保持装饰器(@login_required)不变 +- 保持错误处理和 trace_id 机制不变 +- 保持响应格式(jsonify)不变 + +### 2.3 更新 Routes 层 + +- 更新 11 个 routes 文件,移除 `impl` 参数 +- 直接导入对应的 controller 模块 +- 保持 URL 映射和 HTTP 方法不变 +- 保持路由注册顺序不变 + +### 2.4 更新应用工厂 + +- 更新 `outlook_web/app.py` 中的 Blueprint 注册 +- 移除所有 `impl=legacy` 参数 +- 保持 Blueprint 注册顺序不变 +- 保持中间件和错误处理器注册不变 + +### 2.5 清理 Legacy.py + +- 删除已迁移的 54 个路由处理函数 +- 保留工具函数和中间件(暂时) +- 保留初始化函数(init_app, init_db 等) +- 最终目标:完全删除 legacy.py + +### 2.6 工具函数迁移(可选,后续阶段) + +- 创建 `outlook_web/utils/` 目录 +- 迁移工具函数到对应的 utils 模块: + - `utils/sanitize.py` - sanitize_input, decode_header_value + - `utils/email_parser.py` - get_email_body, parse_account_string + - `utils/datetime.py` - utcnow + - `utils/proxy.py` - build_proxies + +### 2.7 中间件迁移(可选,后续阶段) + +- 创建 `outlook_web/middleware/` 目录 +- 迁移中间件函数: + - `middleware/trace.py` - ensure_trace_id, attach_trace_id_and_normalize_errors + - `middleware/error_handler.py` - handle_http_exception, handle_exception + +--- + +## 3. 分阶段交付清单 + +### 3.1 阶段 1:基础模块(低复杂度,无依赖) + +**目标模块:** groups, tags, settings, system, audit, pages + +**交付物:** +- 创建 6 个 controller 文件 +- 迁移 19 个路由处理函数 +- 更新 6 个 routes 文件 +- 更新 app.py 中的 6 个 Blueprint 注册 + +**功能清单:** + +#### 3.1.1 Groups Controller +- `api_get_groups()` - 获取所有分组 +- `api_get_group(group_id)` - 获取单个分组 +- `api_add_group()` - 添加分组 +- `api_update_group(group_id)` - 更新分组 +- `api_delete_group(group_id)` - 删除分组 +- `api_export_group(group_id)` - 导出分组账号 + +#### 3.1.2 Tags Controller +- `api_get_tags()` - 获取所有标签 +- `api_add_tag()` - 添加标签 +- `api_delete_tag(tag_id)` - 删除标签 +- `api_batch_manage_tags()` - 批量管理标签 + +#### 3.1.3 Settings Controller +- `api_get_settings()` - 获取系统设置 +- `api_update_settings()` - 更新系统设置 +- `api_validate_cron()` - 验证 Cron 表达式 + +#### 3.1.4 System Controller +- `api_health_check()` - 健康检查 +- `api_get_diagnostics()` - 获取诊断信息 +- `api_get_upgrade_status()` - 获取升级状态 + +#### 3.1.5 Audit Controller +- `api_get_audit_logs()` - 获取审计日志 + +#### 3.1.6 Pages Controller +- `login()` - 登录页面 +- `logout()` - 登出 +- `index()` - 首页 + +**验收标准:** +- 所有 19 个路由功能正常 +- 所有测试通过 +- 手动验证功能无异常 + +### 3.2 阶段 2:独立功能模块 + +**目标模块:** temp_emails, oauth, scheduler + +**交付物:** +- 创建 3 个 controller 文件 +- 迁移 6 个路由处理函数 +- 更新 3 个 routes 文件 +- 更新 app.py 中的 3 个 Blueprint 注册 + +**功能清单:** + +#### 3.2.1 Temp Emails Controller +- `api_get_temp_emails()` - 获取临时邮箱列表 +- `api_generate_temp_email()` - 生成临时邮箱 +- `api_get_temp_email_messages(email_id)` - 获取临时邮箱消息 + +#### 3.2.2 OAuth Controller +- `api_get_oauth_auth_url()` - 获取 OAuth 授权 URL +- `api_exchange_oauth_token()` - 交换 OAuth Token + +#### 3.2.3 Scheduler Controller +- `api_get_scheduler_status()` - 获取调度器状态 + +**验收标准:** +- 所有 6 个路由功能正常 +- OAuth 授权流程正常 +- 临时邮箱功能正常 +- 调度器状态查询正常 + +### 3.3 阶段 3:核心复杂模块 + +**目标模块:** emails, accounts + +**交付物:** +- 创建 2 个 controller 文件 +- 迁移 24 个路由处理函数 +- 更新 2 个 routes 文件 +- 更新 app.py 中的 2 个 Blueprint 注册 + +**功能清单:** + +#### 3.3.1 Emails Controller +- `api_get_emails(email_addr)` - 获取邮件列表 +- `api_get_email_detail(email_addr, message_id)` - 获取邮件详情 +- `api_delete_emails()` - 删除邮件 +- `api_extract_verification(email_addr)` - 提取验证码 + +**关键行为:** +- 支持 Graph API 和 IMAP 回退机制 +- 支持代理配置 +- 支持多文件夹(inbox, junk) +- 支持分页(skip, top) + +#### 3.3.2 Accounts Controller +- `api_get_accounts()` - 获取账号列表 +- `api_get_account(account_id)` - 获取单个账号 +- `api_add_account()` - 添加账号 +- `api_update_account(account_id)` - 更新账号 +- `api_update_account_status(account_id, status)` - 更新账号状态 +- `api_delete_account(account_id)` - 删除账号 +- `api_delete_account_by_email(email_addr)` - 按邮箱删除账号 +- `api_batch_delete_accounts()` - 批量删除账号 +- `api_batch_update_account_group()` - 批量更新分组 +- `api_search_accounts()` - 搜索账号 +- `api_export_all_accounts()` - 导出所有账号 +- `api_export_selected_accounts()` - 导出选中账号 +- `api_generate_export_verify_token()` - 生成导出验证 Token +- `api_refresh_account(account_id)` - 刷新单个账号 +- `api_refresh_all_accounts()` - 刷新所有账号 +- `api_retry_refresh_account(account_id)` - 重试刷新账号 +- `api_refresh_failed_accounts()` - 刷新失败账号 +- `api_trigger_scheduled_refresh()` - 触发定时刷新 +- `api_get_refresh_logs()` - 获取刷新日志 +- `api_get_account_refresh_logs(account_id)` - 获取账号刷新日志 +- `api_get_failed_refresh_logs()` - 获取失败刷新日志 +- `api_get_refresh_stats()` - 获取刷新统计 + +**关键行为:** +- 支持分组过滤 +- 支持标签管理 +- 支持批量操作 +- 支持导出验证(二次确认) +- 支持 Token 刷新和重试 +- 支持刷新日志查询 +- 数据脱敏(client_id, refresh_token) + +**验收标准:** +- 所有 24 个路由功能正常 +- 邮件读取和删除功能正常 +- 账号 CRUD 功能正常 +- Token 刷新功能正常 +- 批量操作功能正常 +- 导出功能正常 + +### 3.4 阶段 4:清理和优化 + +**交付物:** +- 删除 legacy.py 中已迁移的路由处理函数 +- 迁移工具函数到 utils/(可选) +- 迁移中间件到 middleware/(可选) +- 最终删除 legacy.py(如果所有代码都已迁移) +- 更新文档 + +**验收标准:** +- legacy.py 已删除或仅保留必要的初始化代码 +- 所有功能正常 +- 所有测试通过 +- 文档已更新 + +--- + +## 4. Controllers 层职责规范 + +### 4.1 必须做的事情 + +- ✅ 参数解析和验证(从 request 中提取参数) +- ✅ 鉴权检查(使用 @login_required 装饰器) +- ✅ 调用 services 层或 repositories 层 +- ✅ 响应封装(使用 jsonify) +- ✅ 错误处理(try-except,返回标准错误格式) +- ✅ 保持 trace_id 透传 + +### 4.2 不应该做的事情 + +- ❌ 直接操作数据库(应调用 repositories) +- ❌ 复杂的业务逻辑(应调用 services) +- ❌ 直接调用第三方 API(应通过 services) +- ❌ 数据加密/解密(应使用 security 模块) + +### 4.```python +@login_required +def api_xxx(): + """函数说明""" + try: + # 1. 参数解析 + param1 = request.args.get('param1') + param2 = request.get_json().get('param2') + + # 2. 参数验证 + if not param1: + return jsonify(build_error_payload('参数错误')), 400 + + # 3. 调用 service/repository + result = service.do_something(param1, param2) + + # 4. 返回响应 + return jsonify(result) + except Exception as e: + # 5. 错误处理 + return jsonify(build_error_payload(str(e))), 500 +``` + +--- + +## 5. 层更新规范 + +### 5.1 更新前(使用 impl 参数) + +```python +# outlook_web/routes/groups.py +def create_blueprint(*, impl) -> Blueprint: + bp = Blueprint("groups", __name__) + bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) + return bp +``` + +### 5.2 更新后(直接导入 controller) + +```python +# outlook_web/routes/groups.py +from flask import Blueprint +from outlook_web.controllers import groups as groups_controller + +def create_blueprint() -> Blueprint: + bp = Blueprint("groups", __name__) + bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) + return bp +``` + +### 5.3 必须保持不变 + +- ✅ URL 路径不变 +- ✅ HTTP 方法不变 +- ✅ 路由注册顺序不变 +- ✅ Blueprint 名称不变 + +--- + +## 6. App.py 更新规范 + +### 6.1 更新前 + +```python +from outlook_web import legacy +from outlook_web.routes import groups + +app.register_blueprint(groups.create_blueprint(impl=legacy)) +``` + +### 6.2 更新后 + +```python +from outlook_web.routes import groups + +app.register_blueprint(groups.create_blueprint()) +``` + +### 6.3 必须保持不变 + +- ✅ Blueprint 注册顺序不变 +- ✅ 中间件注册不变 +- ✅ 错误处理器注册不变 +- ✅ 应用配置不变 + +--- + +## 7. 兼容性保证(必须满足) + +### 7.1 API 契约不变 + +- 所有 URL 路径保持不变 +- 所有请求参数保持不变 +- 所有响应格式保持不变 +- 所有错误格式保持不变 +- trace_id 机制保持不变 + +### 7.2 功能行为不变 + +- 鉴权机制保持不变(@login_required) +- 数据脱敏保持不变(client_id, refresh_token) +- 审计日志保持不变 +- CSRF 防护保持不变(如果启用) +- 回退机制保持不变(Graph API → IMAP) + +### 7.3 部署方式不变 + +- Docker 启动命令不变 +- Gunicorn 启动命令不变 +- 环境变量配置不变 +- 数据库结构不变 + +--- + +## 8. 测试验收清单 + +### 8.1 单元测试 + +- [ ] 所有现有单元测试通过 +- [ ] 新增 controller 单元测试(可选) +- [ ] 测试覆盖率不降低 + +### 8.2 集成测试 + +- [ ] 所有 API 路由可访问 +- [ ] 所有功能正常工作 +- [ ] 错误处理正确 +- [ ] trace_id 正常透传 + +### 8.3 回归测试 + +- [ ] 登录功能正常 +- [ ] 分组管理功能正常 +- [ ] 账号管理功能正常 +- [ ] 标签管理功能正常 +- [ ] 邮件读取功能正常 +- [ ] 邮件删除功能正常 +- [ ] Token 刷新功能正常 +- [ ] 导出功能正常 +- [ ] 临时邮箱功能正常 +- [ ] OAuth 授权功能正常 +- [ ] 系统设置功能正常 +- [ ] 审计日志功能正常 + +### 8.4 性能测试 + +- [ ] 响应时间不超过迁移前的 110% +- [ ] 内存使用无明显增加 +- [ ] 并发处理能力不下降 + +### 8.5 手动验证 + +- [ ] 前端页面正常加载 +- [ ] 所有按钮和操作正常 +- [ ] 错误提示正常显示 +- [ ] 数据正确展示 + +--- + +## 9. 风险控制清单 + +### 9.1 迁移风险 + +| 风险 | 影响 | 应对措施 | +|------|------|---------| +| 路由映射错误 | 高 | 分阶段迁移,每阶段充分测试 | +| 参数解析错误 | 高 | 保持函数签名一致,单元测试覆盖 | +| 依赖关系错误 | 中 | 先迁移无依赖模块,后迁移复杂模块 | +| 性能下降 | 中 | 性能测试对比 | +| 功能回归 | 高 | 完整的回归测试 | + +### 9.2 回滚策略 + +- 每个阶段完成后提交 Git +- 如果出现问题,可以快速回滚到上一个阶段 +- 保留 legacy.py 直到所有模块迁移完成 +- 如果需要紧急回滚,可以临时恢复 `impl=legacy` + +--- + +## 10. 文档更新清单 + +### 10.1 必须更新的文档 + +- [ ] `CLAUDE.md` - 更新项目架构说明 +- [ ] `docs/DEV/00002-前后端拆分-开发者指南.md` - 更新开发指南 +- [ ] `docs/QA/回归验证清单.md` - 更新验证清单(如有需要) + +### 10.2 可选更新的文档 + +- [ ] `README.md` - 更新项目说明(如有需要) +- [ ] API 文档 - 更新 API 说明(如有需要) + +--- + +## 11. 验收标准(最终交付) + +### 11.1 代码质量 + +- [ ] 所有 54 个路由处理函数已迁移到 controllers/ +- [ ] legacy.py 已删除或仅保留必要代码 +- [ ] 代码结构清晰,职责明确 +- [ ] 无重复代码 + +### 11.2 功能完整性 + +- [ ] 所有功能正常工作 +- [ ] 所有 API 契约保持不变 +- [ ] 所有测试通过 +- [ ] 无功能回归 + +### 11.3 性能指标 + +- [ ] 响应时间 < 迁移前 110% +- [ ] 内存使用无明显增加 +- [ ] 启动时间无明显增加 + +### 11.4 文档完整性 + +- [ ] 所有文档已更新 +- [ ] 开发指南清晰 +- [ ] 验收清单完整 + +--- + +## 12. 附录:路由清单 + +### 12.1 Groups 模块(6 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/groups` | GET | api_get_groups | 获取所有分组 | +| `/api/groups/` | GET | api_get_group | 获取单个分组 | +| `/api/groups` | POST | api_add_group | 添加分组 | +| `/api/groups/` | PUT | api_update_group | 更新分组 | +| `/api/groups/` | DELETE | api_delete_group | 删除分组 | +| `/api/groups//export` | GET | api_export_group | 导出分组账号 | + +### 12.2 Tags 模块(4 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/tags` | GET | api_get_tags | 获取所有标签 | +| ` POST | api_add_tag | 添加标签 | +| `/api/tags/` | DELETE | api_delete_tag | 删除标签 | +| `/api/accounts/tags` | POST | api_batch_manage_tags | 批量管理标签 | + +### 12.3 Settings 模块(3 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/settings` | GET | api_get_settings | 获取系统设置 | +| `/api/settings` | POST | api_update_settings | 更新系统设置 | +| `/api/settings/validate-cron` | POST | api_validate_cron | 验证 Cron 表达式 | + +### 12.4 System 模块(3 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/healthz` | GET | api_health_check | 健康检查 | +| `/api/system/diagnostics` | GET | api_get_diagnostics | 获取诊断信息 | +| `/api/system/upgrade-status` | GET | api_get_upgrade_status | 获取升级状态 | + +### 12.5 Audit 模块(1 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/audit-logs` | GET | api_get_audit_logs | 获取审计日志 | + +### 12.6 Pages 模块(3 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/login` | GET/POST | login | 登录页面 | +| `/logout` | GET | logout | 登出 | +| `/` | GET | index | 首页 | + +### 12.7 Temp Emails 模块(3 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/temp-emails` | GET | api_get_temp_emails | 获取临时邮箱列表 | +| `/api/temp-emails/generate` | POST | api_generate_temp_email | 生成临时邮箱 | +| `/api/temp-emails/` | GET | api_get_temp_email_messages | 获取临时邮箱消息 | + +### 12.8 OAuth 模块(2 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/oauth/auth-url` | GET | api_get_oauth_auth_url | 获取 OAuth 授权 URL | +| `/api/oauth/exchange-token` | POST | api_exchange_oauth_token | 交换 OAuth Token | + +### 12.9 Scheduler 模块(1 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/scheduler/status` | GET | api_get_scheduler_status | 获取调度器状态 | + +### 12.10 Emails 模块(4 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/emails/` | GET | api_get_emails | 获取邮件列表 | +| `/api/email//` | GET | api_get_email_detail | 获取邮件详情 | +| `/api/emails/delete` | POST | api_delete_emails | 删除邮件 | +| `/api/emails//extract-verification` | GET | api_extract_verification | 提取验证码 | + +### 12.11 Accounts 模块(20 个路由) + +| 路由 | 方法 | 函数 | 说明 | +|------|------|------|------| +| `/api/accounts` | GET | api_get_accounts | 获取账号列表 | +| `/api/accounts/` | GET | api_get_account | 获取单个账号 | +| `/api/accounts` | POST | api_add_account | 添加账号 | +| `/api/accounts/` | PUT | api_update_account | 更新账号 | +| `/api/accounts/` | DELETE | api_delete_account | 删除账号 | +| `/api/accounts/email/` | DELETE | api_delete_account_by_email | 按邮箱删除账号 | +| `/api/accounts/batch-delete` | POST | api_batch_delete_accounts | 批量删除账号 | +| `/api/accounts/batch-update-group` | POST | api_batch_update_account_group | 批量更新分组 | +| `/api/accounts/search` | GET | api_search_accounts | 搜索账号 | +| `/api/accounts/export` | GET | api_export_all_accounts | 导出所有账号 | +| `/api/accounts/export-selected` | POST | api_export_selected_accounts | 导出选中账号 | +| `/api/export/verify` | POST | api_generate_export_verify_token | 生成导出验证 Token | +| `/api/accounts//refresh` | POST | account | 刷新单个账号 | +| `/api/accounts/refresh-all` | GET | api_refresh_all_accounts | 刷新所有账号 | +| `/api/accounts//retry-refresh` | POST | api_retry_refresh_account | 重试刷新账号 | +| `/api/accounts/refresh-failed` | POST | api_refresh_failed_accounts | 刷新失败账号 | +| `/api/accounts/trigger-scheduled-refresh` | GET | api_trigger_scheduled_refresh | 触发定时刷新 | +| `/api/accounts/refresh-logs` | GET | api_get_refresh_logs | 获取刷新日志 | +| `/api/accounts//refresh-logs` | GET | api_get_account_refresh_logs | 获取账号刷新日志 | +| `/api/accounts/refresh-logs/failed` | GET | api_get_failed_refresh_logs | 获取失败刷新日志 | +| `/api//refresh-stats` | GET | api_get_refresh_stats | 获取刷新统计 | + +--- + +**文档版本:** v1.0 +**最后更新:** 2026-02-24 +**维护者:** 开发团队 diff --git a/docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md b/docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md new file mode 100644 index 0000000..cb13f08 --- /dev/null +++ b/docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md @@ -0,0 +1,106 @@ +# Outlook 邮件管理工具|前后端拆分与模块化 FD(功能实现清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V0.1 +- 日期:2026-02-23 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` +- 对齐 TDD:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md` +- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` + +--- + +## 1. 文档目的 + +本 FD 用于把“前后端拆分与模块化 PRD”拆解为**可执行的功能清单**,用于开发/测试/验收对齐。 +仅描述**要实现/保证的功能与行为**,不讨论具体实现方式与技术细节。 + +--- + +## 2. 总交付清单(必须完成) + +### 2.1 后端拆分交付(可维护性与边界清晰) + +- 提供新的后端模块化目录结构(按职责分层),并保证各模块职责清晰: + - 配置(环境变量/默认值) + - 安全(加密/鉴权/CSRF) + - 错误结构(统一错误 payload、trace_id、脱敏、全局异常处理) + - 数据库(连接管理、初始化与迁移、索引幂等创建) + - 审计(写入与查询) + - 外部集成服务(Graph/IMAP/GPTMail 等) + - 定时任务(调度器、锁、运行记录) +- 保持现有 API 行为不变(URL/方法/请求参数语义/响应结构/错误结构/trace_id 透传/默认脱敏)。 +- 路由分模块组织(例如按“系统/调度器/分组/账号/标签/邮件/OAuth/设置/临时邮箱”拆分),并提供统一的路由注册入口。 +- 数据访问逻辑与业务编排逻辑拆分(SQL 与业务规则避免混杂),便于后续单测与 Mock。 + +### 2.2 后端兼容性与部署交付(不破坏现有运行方式) + +- 保持现有部署入口可用: + - Gunicorn/Docker 仍可使用既有命令启动(例如 `web_outlook_app:app` 入口保持可用)。 +- 保持本地运行方式可用(开发者按 README 的方式可启动并访问页面)。 +- 允许短期存在兼容层(旧入口转发到新模块),但必须提供“可淘汰”的清晰路径(见 PRD 的阶段建议)。 + +### 2.3 前端拆分交付(模板瘦身 + 资源模块化) + +- `templates/index.html` 仅保留页面结构与必要容器,不再承载大段内联脚本。 +- 提供前端静态资源目录并模块化: + - JS:按“基础设施(API/错误处理/trace 展示/通用 UI)+ 业务 feature(groups/accounts/tags/emails/refresh/export/settings/audit/temp_emails)”拆分。 + - CSS:至少拆出主样式文件,避免样式与脚本混在模板内。 +- 保持现有 UI 与交互行为一致(不新增前端框架,不引入构建链)。 +- 保证静态资源可被浏览器缓存(相较内联脚本),并具备稳定引用方式(例如版本参数或文件名策略)。 + +### 2.4 测试与回归交付(重构可控) + +- 保证现有测试用例在拆分后仍可运行通过(不降低覆盖面)。 +- 增加“拆分相关”的基础测试覆盖(至少包括): + - 应用装配/路由注册的烟测(关键接口可用) + - 错误结构与 trace_id 的一致性不回归 + - 审计写入与查询入口可用 +- 保持 CI 可重复执行(自动跑单测)。 + +### 2.5 文档交付(面向维护者) + +- 更新/新增开发者文档: + - 新目录结构说明(模块职责与依赖边界) + - 如何新增/修改一个 API 的推荐路径(route/service/repository) + - 前端模块拆分说明(API 层/feature 层/UI 组件层) +- 更新验收与回归文档中涉及的入口/路径(若入口变更,必须提供兼容与迁移说明)。 + +--- + +## 3. 非功能交付项(必须满足) + +### 3.1 行为兼容与稳定性 + +- 拆分过程中任何阶段都必须保持: + - 核心功能可用(不因“文件拆分”导致功能不可运行) + - 接口契约稳定(不改变现有返回结构与错误结构) + - 调度器/后台任务策略不被意外改变 + +### 3.2 可回滚与渐进式交付 + +- 每个阶段的拆分都必须可回滚(例如保留旧入口或提供兼容切换方式)。 +- 每个阶段都必须有对应的验证方法(单测 + 抽样接口验收)。 + +### 3.3 安全与合规 + +- 静态资源与模板中不得引入敏感信息(密钥/令牌/密码)。 +- 保持既有默认脱敏、二次验证、审计可追溯策略不回归。 + +### 3.4 性能与体验 + +- 拆分不引入明显性能退化(启动时间/关键接口耗时不显著增加)。 +- 静态资源拆分后,页面加载体验不下降(必要时提供缓存与加载顺序保障)。 + +--- + +## 4. 验收检查表(建议用于实施每个阶段后) + +- 部署入口不变:原有 Docker/Gunicorn 启动命令可用,页面可访问。 +- 基础探活:`GET /healthz` 正常。 +- 关键抽样接口正常: + - `GET /api/system/health` + - `GET /api/scheduler/status` + - `GET /api/system/upgrade-status` + - `GET /api/accounts`、`/api/groups`、`/api/tags` +- 单测通过:`python -m unittest discover -s tests -v`。 +- 前端页面关键路径抽样回归(至少覆盖:登录、分组/账号/标签、读取/删除/导出、审计、临时邮箱、设置)。 diff --git a/docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md b/docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md new file mode 100644 index 0000000..82832c7 --- /dev/null +++ b/docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md @@ -0,0 +1,526 @@ +# Outlook 邮件管理工具|批量操作与自动化增强 FD(功能实现清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-24 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md` + +--- + +## 1. 文档目的 + +本 FD 用于把"批量操作与自动化增强 PRD"拆解为**可执行的功能清单**,用于开发/测试/验收对齐。 +仅描述**要实现/保证的功能与行为**,不讨论具体实现方式与技术细节。 + +--- + +## 2. 总交付清单(必须完成) + +### 2.1 功能1:全选功能 +- 在邮箱面板工具栏(排序按钮右边)添加全选复选框 +- 实现全选/半选/未选三种状态的自动联动 +- 与现有批量操作(移动分组、打标签/去标签)无缝集成 +- 切换分组时自动重置全选状态 + +### 2.2 功能2:邮件信息快速复制 +- 后端提供验证码和链接提取 API +- 前端在邮箱列表每个账号右边添加复制按钮 +- 实现智能验证码识别(关键词 + 保底规则) +- 实现链接提取(所有 HTTP/HTTPS 链接) +- 实现一键复制到剪贴板 +- 实现成功/失败反馈提示 +- 无邮件数据时自动触发获取邮件 + +### 2.3 功能3:自动轮询与新邮件通知 +- 后端在 settings 表添加轮询配置项 +- 前端在设置页面添加轮询配置界面 +- 实现定时轮询逻辑(可配置间隔和次数) +- 实现新邮件检测(对比邮件 ID) +- 实现页面通知(右上角弹窗) +- 实现邮箱列表视觉提示(红点/角标) +- 静默轮询,只在有新邮件时通知 + +--- + +## 3. 后端交付清单 + +### 3.1 新增 API 接口 + +#### 3.1.1 验证码和链接提取接口 +- **路由**:`GET /api/emails//extract-verification` +- **功能**:提取指定邮箱最新邮件中的验证码和链接 +- **请求参数**: + - `email`:邮箱地址(路径参数) +- **响应结构**: + ```json + { + "success": true, + "data": { + "verification_code": "123456", + "links": ["https://example.com/verify", "https://example.com/activate"], + "formatted": "123456 https://example.com/verify https://example.com/activate" + }, + "message": "提取成功" + } + ``` +- **失败响应**: + ```json + { + "success": false, + "error": "未找到验证信息", + "trace_id": "xxx" + } + ``` + +### 3.2 新增服务模块 + +#### 3.2.1 验证码提取服务 +- **位置**:`outlook_web/services/verification_extractor.py` +- **功能清单**: + - 获取邮箱最新邮件(同时从收件箱和垃圾邮件获取,按时间排序取最新) + - 智能验证码识别: + - 识别关键词("验证码"、"code"、"验证"、"verification"、"OTP"等) + - 提取关键词附近的 4-8 位数字/字母组合 + - 保底验证码提取: + - 如果智能识别失败,提取邮件中所有 4-8 位连续数字/字母组合 + - 取第一个匹配项 + - 链接提取: + - 使用正则表达式提取所有 HTTP/HTTPS 链接 + - 去重并保持顺序 + - 格式化输出: + - 验证码和链接用空格分隔 + - 无描述性文字,纯数据输出 + +### 3.3 数据库变更 + +#### 3.3.1 settings 表新增配置项 +- **配置项清单**: + - `enable_auto_polling`:是否启用自动轮询(默认:`false`) + - `polling_interval`:轮询间隔(秒,默认:`10`) + - `polling_count`:轮询次数(默认:`5`) +- **初始化逻辑**: + - 在 `init_db()` 函数中添加默认值插入逻辑 + - 使用 `INSERT OR IGNORE` 避免重复插入 + +### 3.4 更新现有 API + +#### 3.4.1 设置 API 更新 +- **接口**:`GET /api/settings` 和 `PUT /api/settings` +- **功能**:支持读取和更新轮询配置项 +- **响应结构**(GET): + ```json + { + "success": true, + "settings": { + "enable_auto_polling": false, + "polling_interval": 10, + "polling_count": 5, + ... + } + } + ``` +- **请求结构**(PUT): + ```json + { + "enable_auto_polling": true, + "polling_interval": 30, + "polling_count": 10 + } + ``` + +### 3.5 路由注册 + +#### 3.5.1 邮件路由更新 +- **位置**:`outlook_web/routes/emails.py` +- **新增路由**: + ```python + bp.add_url_rule( + "/api/emails//extract-verification", + view_func=impl.api_extract_verification, + methods=["GET"] + ) + ``` + +--- + +## 4. 前端交付清单 + +### 4.1 功能1:全选功能 + +#### 4.1.1 UI 组件 +- **位置**:邮箱面板工具栏,排序按钮右边 +- **组件结构**: + ```html + + ``` +- **样式要求**: + - 与排序按钮保持一致的视觉风格 + - 支持三种状态:未选中、半选中(indeterminate)、全选中 + - 半选中状态使用特殊样式(如横线图标) + +#### 4.1.2 交互逻辑 +- **全选复选框点击事件**: + - 如果当前是未选中或半选中状态,选中当前分组下的所有邮箱 + - 如果当前是全选中状态,取消选中所有邮箱 +- **邮箱复选框点击事件**: + - 更新 `selectedAccounts` Set + - 更新全选复选框状态: + - 如果所有邮箱都被选中,全选复选框显示为全选中 + - 如果部分邮箱被选中,全选复选框显示为半选中 + - 如果没有邮箱被选中,全选复选框显示为未选中 +- **切换分组事件**: + - 重置全选复选框状态为未选中 + - 清空 `selectedAccounts` Set + +#### 4.1.3 状态管理 +- **全局变量**: + - 复用现有的 `selectedAccounts` Set + - 不需要新增全局变量 +- **状态同步**: + - 全选复选框状态与 `selectedAccounts` 实时同步 + - 批量操作栏显示/隐藏与选中数量联动 + +### 4.2 功能2:邮件信息快速复制 + +#### 4.2.1 UI 组件 +- **位置**:邮箱列表中,每个账号名称右边 +- **组件结构**: + ```html + + ``` +- **样式要求**: + - 按钮大小适中,不遮挡账号名称 + - 默认状态:灰色图标 + - 悬停状态:蓝色图标 + - 加载状态:显示加载动画 + - 成功状态:短暂显示绿色对勾(1秒后恢复) + +#### 4.2.2 交互逻辑 +- **复制按钮点击事件**: + 1. 显示加载状态(禁用按钮,显示加载动画) + 2. 调用后端 API:`GET /api/emails//extract-verification` + 3. 如果返回成功: + - 使用 Clipboard API 复制 `formatted` 字段到剪贴板 + - 显示成功提示:右上角弹出"已复制"通知(2秒后消失) + - 按钮显示绿色对勾(1秒后恢复) + 4. 如果返回失败(未找到验证信息): + - 显示失败提示:"未找到验证信息" + 5. 如果返回失败(邮箱无数据): + - 自动调用 `loadEmails(email, true)` 获取邮件 + - 获取成功后,重新调用提取 API + - 获取失败后,显示具体错误信息 + 6. 恢复按钮状态(启用按钮,隐藏加载动画) + +#### 4.2.3 API 调用 +- **函数名**:`copyVerificationInfo(email)` +- **功能**: + - 调用后端 API 提取验证信息 + - 复制到剪贴板 + - 显示成功/失败提示 +- **错误处理**: + - 网络错误:显示"网络错误,请重试" + - API 错误:显示具体错误信息(从 `error` 字段获取) + - 剪贴板权限错误:显示"复制失败,请手动复制" + +### 4.3 功能3:自动轮询与通知 + +#### 4.3.1 设置界面 +- **位置**:设置页面(`/api/settings` 对应的前端界面) +- **组件结构**: + ```html +
+

自动轮询设置

+
+ +
+
+ + +
+
+ + +
+
+ ``` +- **交互逻辑**: + - 加载设置时,从 API 获取当前配置并填充表单 + - 保存设置时,调用 `PUT /api/settings` 更新配置 + - 保存成功后显示提示:"设置已保存,下次选中邮箱时生效" + +#### 4.3.2 轮询逻辑 +- **全局变量**: + - `pollingTimer`:定时器 ID + - `pollingCount`:当前轮询次数 + - `lastEmailIds`:上次获取的邮件 ID 列表(用于检测新邮件) +- **轮询启动**: + - 触发时机:用户在邮箱列表中选中一个邮箱时 + - 前置条件:设置中启用了自动轮询 + - 启动逻辑: + 1. 清除之前的定时器(如果存在) + 2. 重置轮询计数器 + 3. 获取当前邮件列表,保存邮件 ID 到 `lastEmailIds` + 4. 启动定时器,按配置的间隔执行轮询 +- **轮询执行**: + - 每次轮询时: + 1. 调用 `loadEmails(currentAccount, true)` 获取最新邮件 + 2. 对比新旧邮件 ID 列表,检测是否有新邮件 + 3. 如果有新邮件,触发通知 + 4. 更新 `lastEmailIds` + 5. 轮询计数器 +1 + 6. 如果达到设置的轮询次数,停止轮询 +- **轮询停止**: + - 触发时机: + - 达到设置的轮询次数 + - 用户切换到其他邮箱 + - 用户关闭页面或退出登录 + - 轮询过程中获取邮件失败 + - 停止逻辑: + 1. 清除定时器 + 2. 重置轮询计数器 + 3. 清空 `lastEmailIds` + +#### 4.3.3 新邮件检测 +- **检测逻辑**: + - 对比新旧邮件 ID 列表 + - 找出新邮件(存在于新列表但不存在于旧列表的邮件) + - 如果有新邮件,返回新邮件列表 +- **函数签名**: + ```javascript + function detectNewEmails(oldEmailIds, newEmails) { + // 返回新邮件列表 + } + ``` + +#### 4.3.4 通知组件 +- **页面内通知**: + - **位置**:页面右上角 + - **组件结构**: + ```html +
+
📧
+
+
新邮件
+
user@outlook.com 收到新邮件:您的验证码是 123456
+
+ +
+ ``` + - **显示逻辑**: + - 检测到新邮件时,显示通知 + - 通知内容:邮箱地址 + 邮件主题 + - 5 秒后自动消失,或用户点击关闭按钮 + - **样式要求**: + - 固定定位在右上角 + - 从右侧滑入动画 + - 半透明背景,白色文字 + - 支持多条通知堆叠显示 + +- **邮箱列表视觉提示**: + - **位置**:邮箱名称旁边 + - **组件结构**: + ```html + ● + ``` + - **显示逻辑**: + - 检测到新邮件时,在邮箱名称旁边显示红色圆点 + - 用户点击该邮箱并查看邮件后,红点消失 + - **样式要求**: + - 红色圆点,直径 8px + - 位置在邮箱名称右侧,垂直居中 + +--- + +## 5. 非功能交付项(必须满足) + +### 5.1 性能要求 +- 全选操作在 100ms 内完成(即使邮箱数量达到 1000 个) +- 验证码提取 API 响应时间 < 2 秒(不包括获取邮件的时间) +- 轮询操作不影响页面正常使用,不导致页面卡顿 + +### 5.2 可靠性要求 +- 验证码提取成功率 > 90%(基于常见邮件格式) +- 轮询过程中如果出现网络错误或 API 失败,自动停止轮询并提示用户 +- 复制操作失败时,给出明确的失败原因和建议操作 + +### 5.3 兼容性要求 +- 全选功能与现有批量操作(移动分组、打标签)完全兼容 +- 复制功能支持主流浏览器的 Clipboard API +- 轮询功能不影响现有的手动刷新功能 + +### 5.4 安全要求 +- 验证码提取 API 需要登录验证 +- 复制到剪贴板的内容不包含敏感信息(如完整 Token) +- 轮询频率有合理限制,避免过度请求导致账号被封 + +### 5.5 用户体验要求 +- 所有操作都有明确的成功/失败反馈 +- 加载状态有明确的视觉提示(加载动画、禁用按钮等) +- 错误提示清晰易懂,告诉用户下一步怎么做 + +--- + +## 6. 验收检查表 + +### 6.1 功能1:全选功能 +- [ ] 全选复选框显示在排序按钮右边 +- [ ] 点击全选复选框可以选中/取消选中当前分组下的所有邮箱 +- [ ] 全选状态与邮箱列表选中状态实时同步(全选/半选/未选) +- [ ] 全选后可以执行批量移动分组操作 +- [ ] 全选后可以执行批量打标签/去标签操作 +- [ ] 切换分组时全选状态重置 +- [ ] 搜索或筛选后,全选只影响当前显示的邮箱 + +### 6.2 功能2:邮件信息快速复制 +- [ ] 复制按钮显示在邮箱名称右边 +- [ ] 点击复制按钮可以自动提取验证码和链接 +- [ ] 验证码提取成功率 > 90%(基于测试邮件样本) +- [ ] 链接提取能正确提取所有 HTTP/HTTPS 链接 +- [ ] 复制成功后显示"已复制"通知 +- [ ] 未找到验证信息时显示"未找到验证信息"提示 +- [ ] 邮箱无数据时自动触发获取邮件操作 +- [ ] 复制按钮有加载状态、成功状态、失败状态的视觉反馈 +- [ ] 后端 API `/api/emails//extract-verification` 正常工作 +- [ ] API 返回格式符合规范 + +### 6.3 功能3:自动轮询与通知 +- [ ] 设置中心有"自动轮询"配置项(开关、间隔、次数) +- [ ] 启用轮询后,选中邮箱时自动开始轮询 +- [ ] 轮询到达设置次数后自动停止 +- [ ] 检测到新邮件时显示页面通知(邮箱地址 + 邮件主题) +- [ ] 邮箱列表显示新邮件视觉提示(红点/角标) +- [ ] 轮询过程静默进行,不干扰用户操作 +- [ ] 轮询失败时停止并显示错误提示 +- [ ] 切换邮箱时停止当前轮询 +- [ ] 设置保存后下次选中邮箱时生效 +- [ ] 数据库 settings 表包含轮询配置项 + +### 6.4 非功能验收 +- [ ] 全选操作在 100ms 内完成(1000 个邮箱) +- [ ] 验证码提取 API 响应时间 < 2 秒 +- [ ] 轮询不影响页面正常使用 +- [ ] 所有操作都有明确的成功/失败反馈 +- [ ] 错误提示清晰易懂 + +### 6.5 回归验收 +- [ ] 现有批量操作功能正常(移动分组、打标签) +- [ ] 现有邮件获取功能正常 +- [ ] 现有设置功能正常 +- [ ] 单元测试通过:`python -m unittest discover -s tests -v` +- [ ] 前端页面关键路径抽样回归(登录、分组/账号、邮件查看) + +--- + +## 7. 测试清单 + +### 7.1 单元测试 +- [ ] 验证码提取算法测试(智能识别 + 保底规则) +- [ ] 链接提取算法测试 +- [ ] 新邮件检测逻辑测试 + +### 7.2 接口测试 +- [ ] `GET /api/emails//extract-verification` 接口测试 + - 成功场景:有验证码和链接 + - 成功场景:只有验证码 + - 成功场景:只有链接 + - 失败场景:未找到验证信息 + - 失败场景:邮箱不存在 + - 失败场景:未登录 +- [ ] `GET /api/settings` 接口测试(包含轮询配置) +- [ ] `PUT /api/settings` 接口测试(更新轮询配置) + +### 7.3 前端测试 +- [ ] 全选功能测试 + - 全选/取消全选 + - 半选状态 + - 切换分组重置 + - 与批量操作集成 +- [ ] 复制功能测试 + - 复制成功 + - 复制失败 + - 自动获取邮件 + - 加载状态 +- [ ] 轮询功能测试 + - 启动轮询 + - 停止轮询 + - 新邮件检测 + - 通知显示 + - 视觉提示 + +--- + +## 8. 交付顺序建议 + +### 阶段1:后端基础(1-2天) +1. 数据库添加轮询配置项 +2. 更新设置 API 支持轮询配置 +3. 实现验证码提取服务 +4. 实现验证码提取 API +5. 单元测试和接口测试 + +### 阶段2:前端基础(1-2天) +1. 实现全选功能(UI + 交互) +2. 实现复制按钮(UI + API 调用) +3. 实现设置界面(轮询配置) +4. 前端功能测试 + +### 阶段3:轮询与通知(1-2天) +1. 实现轮询逻辑 +2. 实现新邮件检测 +3. 实现页面通知组件 +4. 实现邮箱列表视觉提示 +5. 集成测试 + +### 阶段4:优化与验收(1天) +1. 性能优化 +2. 用户体验优化 +3. 回归测试 +4. 文档更新 + +--- + +## 9. 风险与依赖 + +### 9.1 技术风险 +- **验证码识别准确率**:不同邮件格式差异较大,可能导致识别失败 + - 缓解措施:提供保底提取规则,并支持用户手动查看邮件 +- **浏览器剪贴板权限**:部分浏览器可能限制剪贴板访问 + - 缓解措施:使用标准 Clipboard API,失败时提示用户手动复制 +- **轮询性能影响**:高频轮询可能影响页面性能 + - 缓解措施:设置合理的默认值,使用异步请求 + +### 9.2 依赖项 +- 依赖现有的邮件获取 API(Graph API / IMAP) +- 依赖现有的批量操作功能 +- 依赖现有的通知组件(可复用或新建) +- 依赖浏览器 Clipboard API + +--- + +## 10. 文档更新清单 + +### 10.1 用户文档 +- [ ] 更新 README.md,添加新功能说明 +- [ ] 添加全选功能使用说明 +- [ ] 添加复制功能使用说明 +- [ ] 添加轮询功能配置说明 + +### 10.2 开发者文档 +- [ ] 更新 API 文档,添加验证码提取接口 +- [ ] 更新数据库文档,添加轮询配置项 +- [ ] 更新前端组件文档 + +### 10.3 测试文档 +- [ ] 添加验证码提取测试用例 +- [ ] 添加轮询功能测试用例 +- [ ] 更新回归测试清单 diff --git a/docs/FD/Outlook邮件管理工具-重构升级FD.md b/docs/FD/Outlook邮件管理工具-重构升级FD.md new file mode 100644 index 0000000..7829dd6 --- /dev/null +++ b/docs/FD/Outlook邮件管理工具-重构升级FD.md @@ -0,0 +1,191 @@ +# Outlook 邮件管理工具|重构升级 FD(功能实现清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-22 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + +--- + +## 1. 文档目的 + +本 FD 用于把 PRD 中的需求拆解为“需要完成的功能清单”,方便开发、测试、验收对齐。 +仅描述**要实现/保证的功能与行为**,不包含实现方式与技术细节。 + +--- + +## 2. 总交付清单(必须完成) + +### 2.1 登录与会话 + +- 提供登录入口与登录页面/弹窗。 +- 支持密码登录进入管理界面。 +- 支持退出登录。 +- 支持在线修改登录密码(不少于 8 位)。 +- 登录失败具备限制机制,并提示剩余等待时间。 +- 重启/升级后登录体验可预期(允许重新登录,但不得出现状态错乱或异常阻塞)。 + +### 2.2 分组管理 + +- 分组列表展示(含分组名称、颜色、描述、账号数量)。 +- 新增分组(含名称、颜色、描述、网络访问策略配置)。 +- 编辑分组(同上字段可修改)。 +- 删除分组(需避免误操作;删除后账号需有明确归属规则)。 +- 系统保留分组展示与标识;系统分组需限制危险操作(例如不允许被删除或被批量迁移进入)。 + +### 2.3 账号管理 + +- 账号列表展示(含邮箱、分组、备注、状态、标签、最近刷新时间/状态)。 +- 新增账号(支持录入必要字段)。 +- 编辑账号(支持修改必要字段、分组、备注、状态)。 +- 删除账号(按单个/批量,需明确提示不可逆风险)。 +- 批量导入账号: + - 支持粘贴/上传导入内容 + - 支持预校验与错误定位(明确指出哪一行/哪一项字段错误) + - 支持导入结果汇总(成功/失败数量与原因) +- 批量移动分组(禁止移动到系统保留分组)。 +- 账号状态管理(停用账号不参与自动刷新等后台动作,并在界面明显标识)。 + +### 2.4 标签管理 + +- 标签列表展示(含名称、颜色、使用数量可选)。 +- 新增标签(名称+颜色,名称不可重复)。 +- 删除标签(需确认,删除后账号标签关系同步更新)。 +- 账号批量打标签/去标签(可对选中账号执行)。 +- 账号按标签筛选与组合筛选(与关键词/分组筛选兼容)。 + +### 2.5 邮件读取与展示 + +- 支持至少三类文件夹查看:收件箱、垃圾邮件、已删除邮件。 +- 邮件列表分页/滚动加载,具备“加载中/无更多/加载失败”状态提示。 +- 邮件详情展示(主题、发件人、收件人/抄送、时间、正文)。 +- 支持长邮件的完整展示与“全屏查看”。 +- 支持“信任邮件模式/原始内容查看”选项: + - 默认模式必须安全展示 + - 信任模式必须有风险提示与显著开关状态 +- 邮件内容的安全展示要求: + - 默认不允许执行脚本类内容 + - 默认不允许危险元素导致越权或跳转劫持 + +### 2.6 Token 刷新(手动/全量/定时) + +- 单账号手动刷新: + - 一键触发 + - 返回成功/失败与失败原因 + - 刷新结果被记录(见 2.7) +- 全量刷新: + - 支持一键触发 + - 提供实时进度反馈(总数、当前、成功数、失败数) + - 支持对失败账号进行重试 + - 支持仅刷新失败账号 +- 定时刷新配置与运行: + - 支持两种调度配置方式(按天数间隔、按表达式配置) + - 支持启用/停用开关 + - 支持配置校验与未来触发时间预览 + - 必须避免“看起来已开启但实际未运行”的情况 + - 必须提供可验证的运行证据(最近一次触发时间、触发来源标识、触发结果统计) + +### 2.7 刷新日志与统计 + +- 记录每次刷新结果(手动/全量/定时/重试): + - 至少包含:时间、账号、触发类型、结果、失败原因(如有) +- 提供近半年刷新历史查询与展示。 +- 自动清理过期记录(默认策略可用且可解释)。 +- 提供失败账号统计与快速入口(例如“查看失败列表”)。 + +### 2.8 邮件删除 + +- 支持单封删除。 +- 支持批量删除。 +- 删除前必须二次确认。 +- 删除失败必须给出明确失败原因分类与可操作提示,并支持重试。 + +### 2.9 读取/刷新/删除的回退与错误展示(用户可感知) + +- 对关键动作(读取邮件、刷新 Token、删除邮件)执行既定优先级与回退策略,以提升成功率。 +- 当所有方式失败时: + - 必须逐项展示每种方式的失败原因(中文可理解) + - 必须提供统一的错误编号/追踪标识 +- 当判定为明显网络/代理类故障时: + - 必须减少无意义重试 + - 必须优先提示用户检查网络/代理配置 + +### 2.10 导出与二次验证 + +- 支持按分组导出。 +- 支持全量导出。 +- 支持选择导出。 +- 导出前必须二次验证(再次输入密码或等效确认方式)。 +- 导出文件命名清晰可识别(包含范围、时间等关键信息)。 +- 导出行为必须被审计(见 2.11)。 + +### 2.11 审计与可追溯 + +- 对以下敏感操作记录审计: + - 导出 + - 删除 + - 修改账号 + - 修改分组 + - 修改设置 +- 审计记录至少包含:操作类型、操作对象、时间、来源标识(如 IP)。 +- 审计失败不得影响主流程,但需可被管理员发现(例如在日志/界面提示)。 + +### 2.12 OAuth2 辅助 + +- 生成授权链接(可复制)。 +- 换取凭据并展示结果(成功返回必要信息;失败展示原因)。 +- 常见失败原因提示(例如回调地址不匹配、权限不足、授权码失效等)。 +- 支持配置相关参数,并提供校验与提示。 + +### 2.13 临时邮箱 + +- 生成临时邮箱。 +- 临时邮箱列表查看与删除。 +- 临时邮箱邮件列表查看、邮件详情查看。 +- 删除临时邮件、清空临时邮箱邮件。 +- 失败提示需区分:服务不可用/鉴权失败/网络异常,并给出可操作建议。 + +### 2.14 设置中心 + +- 修改登录密码。 +- 修改临时邮箱服务凭据。 +- 修改刷新策略相关设置(间隔、调度方式、开关等)。 +- 设置变更需要明确“是否即时生效”,若非即时必须给出提示。 +- 对高风险设置变更提供必要风险提示(例如可能降低安全性、可能提升失败率)。 + +--- + +## 3. 非功能交付项(必须满足) + +### 3.1 一致性与长期运行 + +- 重启/升级后,数据可继续使用且行为可预期。 +- 并发访问下,关键操作结果一致,避免偶发性错误与状态丢失。 + +### 3.2 性能基线 + +- 账号规模达到 1,000 时,账号列表加载、筛选、关键操作仍保持可用体验。 +- 全量刷新在 1,000 账号规模下,需持续输出进度与结果,不出现长时间无反馈。 + +### 3.3 安全基线 + +- 敏感信息在界面与接口中默认脱敏;仅在必要且完成二次验证的场景下允许导出/展示。 +- 错误信息不得泄露敏感内容,同时必须提供追踪标识便于定位。 +- 邮件 HTML 展示默认安全,禁止脚本执行与危险行为。 + +--- + +## 4. 验收检查表(建议用于发布前) + +- 登录:成功/失败限制/修改密码/退出均正常。 +- 分组:增删改查正常;系统分组保护有效;账号计数准确。 +- 账号:增删改查、批量导入、批量分组移动、状态启停均正常。 +- 标签:增删、批量打/去标签、筛选正常。 +- 邮件:三类文件夹可读;分页/滚动;详情/全屏;默认安全展示;信任模式提示清晰。 +- 刷新:单个/全量/失败重试/仅刷新失败/定时配置与运行证据均正常;日志统计可查。 +- 删除:单封/批量;二次确认;失败原因清晰可操作。 +- 导出:三种导出;二次验证;导出审计可追溯;文件命名符合规则。 +- OAuth2:授权链接与换取流程可用;失败提示明确。 +- 临时邮箱:生成/列表/邮件查看/删除/清空可用;失败提示分类正确。 +- 设置:各项设置可修改且生效时机明确;高风险项有提示。 + diff --git a/docs/LOG/CN-00001-完成legacy删除.md b/docs/LOG/CN-00001-完成legacy删除.md new file mode 100644 index 0000000..a637576 --- /dev/null +++ b/docs/LOG/CN-00001-完成legacy删除.md @@ -0,0 +1,77 @@ +# 完成 legacy.py 删除 + +## 状态: 已完成 ✅ + +### 完成时间 +2026-02-25 + +### 完成的工作 + +1. **删除 legacy.py 文件** + - 确认所有 Python 代码中已无 legacy 导入 + - 删除 `outlook_web/legacy.py`(5757 行代码) + - 95 个测试全部通过 + +2. **提交记录** + ``` + 828d31f refactor: 删除 legacy.py - 完成模块化迁移 + ``` + +### 迁移总结 + +整个 Legacy 迁移到 Controllers 层项目已完成: + +**模块迁移:** +- groups(6 个路由)✅ +- tags(4 个路由)✅ +- settings(3 个路由)✅ +- system(3 个路由)✅ +- audit(1 个路由)✅ +- pages(3 个路由)✅ +- temp_emails(3 个路由)✅ +- oauth(2 个路由)✅ +- scheduler(1 个路由)✅ +- emails(4 个路由)✅ +- accounts(20 个路由)✅ + +**基础设施迁移:** +- 中间件迁移到 `middleware/` 模块 ✅ +- 调度器迁移到 `services/scheduler.py` ✅ + +**总计:54 个路由已迁移,legacy.py 已删除** + +### 当前架构 + +``` +outlook_web/ +├── app.py # 应用工厂 +├── config.py # 配置管理 +├── db.py # 数据库连接 +├── errors.py # 错误处理 +├── routes/ # Blueprint 路由层 +├── controllers/ # 控制器层 (请求处理) +├── services/ # 业务逻辑层 +├── repositories/ # 数据访问层 +├── middleware/ # 中间件 +└── security/ # 安全模块 +``` + +### 后续建议 + +根据 TODO 文档,还有以下可选任务: + +1. **4.5 更新文档** + - 更新 CLAUDE.md 项目架构说明 + - 更新 docs/DEV/00002-前后端拆分-开发者指南.md + +2. **P1 优化** + - 检查重复代码,提取公共函数 + - 提升测试覆盖率 + - 性能优化 + +### Git 状态 + +``` +当前分支: main +领先 origin/main 20 个提交 +``` diff --git a/docs/LOG/LOG-00001-Outlook邮件管理工具-重构升级-执行日志.md b/docs/LOG/LOG-00001-Outlook邮件管理工具-重构升级-执行日志.md new file mode 100644 index 0000000..8d77bc8 --- /dev/null +++ b/docs/LOG/LOG-00001-Outlook邮件管理工具-重构升级-执行日志.md @@ -0,0 +1,127 @@ +# 执行日志:Outlook邮件管理工具-重构升级(00001) + +- 日期:2026-02-22 +- 目的:按 PRD/FD/TDD/TODO 的交付要求,补齐“可验证证据(日志/接口)+ 自动化测试 + 文档更新”,并记录关键决策与变更。 + +--- + +## 2026-02-22 + +### 发现与定位 + +- 仓库内未检索到包含 `00002` 编号的 TODO 文档(全仓搜索无结果)。 +- 当前存在一组同主题文档(均为草案): + - `docs/PRD/Outlook邮件管理工具-重构升级PRD.md` + - `docs/FD/Outlook邮件管理工具-重构升级FD.md` + - `docs/TDD/Outlook邮件管理工具-重构升级TDD.md` + - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` + - `docs/CR/CR-00001-Outlook邮件管理工具-重构升级-文档审查报告.md` + +### 决策记录 + +- 由于未找到你提到的 `00002` TODO 文档,本轮先以 `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` 作为“待办目标清单”的唯一依据继续推进。 +- 为避免偏离目标,本轮优先交付以下“低风险且能直接提高可验收性”的内容: + 1) 增加自动化测试(覆盖:统一错误结构/trace_id、legacy error 归一化、分布式锁基础行为等) + 2) 补充文档:如何运行测试、如何验证定时刷新运行证据 + 3) 变更均记录在本日志中 + +### 变更记录 + +- 新增自动化测试与测试夹具: + - `tests/_import_app.py`:测试导入夹具(注入 SECRET_KEY/DATABASE_PATH,禁用调度器自启动) + - `tests/test_error_and_trace.py`:覆盖 /healthz、/api 404 结构化错误、trace_id 透传、legacy error 归一化、scheduler/status、validate-cron + - `tests/test_distributed_lock.py`:覆盖 distributed lock 获取/释放/过期接管 +- 调整 `.gitignore`:保留 `/test*` 规则但显式放行 `tests/`,确保测试用例可纳入版本控制。 +- 补充运行/运维验证文档与入口: + - 新增 `docs/RUN/运行与定时刷新验证指南.md` + - 更新 `README.md`:增加“运行验证”和“自动化测试”章节 + +### 验证记录 + +- 本地安装依赖并运行测试:`python -m unittest discover -s tests -v` 通过(9/9)。 +- 修复 Python 3.13+ `datetime.utcnow()` 弃用告警:引入 `utcnow()` 并替换调用点(保持原有 naive UTC 语义)。 +- 更新运行验证文档:补充升级可验证接口 `GET /api/system/upgrade-status` 与 diagnostics.schema 说明(`docs/RUN/运行与定时刷新验证指南.md`)。 +- 补充自动化测试覆盖: + - `GET /api/system/diagnostics` 的 schema 字段 + - `GET /api/system/upgrade-status` + - 登录速率限制锁定(达到 `MAX_LOGIN_ATTEMPTS` 后返回 429) + - 导出必须二次验证 token(无效 token 返回 401 + need_verify) +- 新增 CI:GitHub Actions 自动跑语法检查与单元测试(` .github/workflows/python-tests.yml`)。 +- 增加“验收/回归”文档: + - `docs/QA/P0验收对照表.md` + - `docs/QA/回归验证清单.md` +- 补充性能基线与验收口径文档:`docs/PERF/性能与容量基线.md`。 +- 性能优化(面向 1,000 账号):消除 `/api/accounts` 与 `/api/accounts/search` 的 N+1(批量加载 tags 与最后刷新状态),并补充相关索引(init_db 幂等创建)。 +- 修复系统分组保护缺口: + - 系统分组禁止删除(API 与 DB 层双重校验) + - 更新账号时禁止将账号移动到系统分组 + - 增加对应回归测试覆盖 + +### 追加变更(错误脱敏与多方式回退聚合) + +- 强化默认脱敏:`build_error_payload()` 现在会对 `message` 与 `details` 同时执行脱敏(避免 token/password 泄露),并将非字符串 `details` 的 JSON 序列化改为 `ensure_ascii=False`(提升中文可读性)。 +- 多方式回退失败原因聚合(中文可理解): + - 邮件删除接口 `POST /api/emails/delete`:当 Graph/IMAP 均失败时,返回 `error.code=EMAIL_DELETE_ALL_METHODS_FAILED`,并在 `error.details` 汇总 Graph/IMAP 失败原因(同时保留 `details` 字段供排障)。 + - Graph 删除:改为使用 `get_access_token_graph_result()` 返回结构化错误(便于诊断代理/网络/授权失败),并把“部分成功”视为 `success=true`(前端已支持 `failed_count>0` 的 warning 提示)。 +- 前端错误展示一致性:删除邮件失败分支改为调用 `handleApiError()`,可展示统一错误结构并通过 `[详情]` 打开错误详情模态框。 +- 新增单测覆盖: + - `tests/test_error_and_trace.py` → `test_build_error_payload_sanitizes_message_and_details` + - `tests/test_error_and_trace.py` → `test_delete_emails_all_methods_fail_returns_aggregated_error` +- 本地测试:`python -m unittest discover -s tests -v` 通过(19/19)。 +- 文档同步更新: + - `docs/QA/P0验收对照表.md`:补充“多方式回退失败原因聚合”和“message/details 默认脱敏”的验收映射与单测入口 + - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md`:勾选已完成的 P0 条目与回归清单条目 + +### 追加变更(核心功能保真证据补齐 + TODO 完成收口) + +- 新增核心功能回归单测(避免依赖外部网络,聚焦“接口存在/契约/审计/追踪/下载响应/流式进度”): + - `tests/test_core_features.py`: + - 登录退出与会话失效:`test_logout_revokes_session` + - 分组 CRUD + 审计 trace_id 可追溯:`test_group_crud_and_audit_trace_id` + - 标签 CRUD + 重名校验:`test_tag_crud_and_duplicate_name` + - 导出二次验证 + 全量导出下载响应:`test_export_verify_and_export_all_download_contains_account` + - OAuth2 授权链接生成:`test_oauth_auth_url_endpoint` + - 临时邮箱生成/列表(mock 外部 API):`test_temp_email_generate_and_list_mocked` + - 全量刷新 SSE:包含 start/complete 事件(mock `test_refresh_token` + delay=0):`test_refresh_all_stream_has_start_and_complete_events` +- 更新 P0 验收对照表:补齐“核心业务功能保真”映射到接口/文档/单测:`docs/QA/P0验收对照表.md` +- 更新 TODO 勾选状态:将 P0-5 与 P1-6/7 的条目按现有证据勾选为完成:`docs/TODO/Outlook邮件管理工具-重构升级TODO.md` +- 验证:`python -m unittest discover -s tests -v` 通过(32/32)。 + +## 2026-02-23(续) + +### 决策记录 + +- 临时邮箱相关操作(生成/删除/清空/删单封)同样纳入审计:提升“敏感操作可追溯”的一致性;审计仅记录动作/资源ID/计数,不记录邮件正文或任何凭据。 + +### 变更记录 + +- 临时邮箱接口补齐审计写入: + - `POST /api/temp-emails/generate`(create/temp_email) + - `DELETE /api/temp-emails/`(delete/temp_email) + - `DELETE /api/temp-emails//clear`(delete/temp_email_messages,记录 count) + - `DELETE /api/temp-emails//messages/`(delete/temp_email_message) +- 新增自动化测试覆盖(脱敏/审计/导入定位):`tests/test_masking_audit_and_import.py` +- 文档同步更新: + - `docs/QA/P0验收对照表.md`:补充“审计查询入口/默认不回显”的验收映射 + - `docs/TODO/Outlook邮件管理工具-重构升级TODO.md`:补充“完成证据”入口链接 + +### 验证记录 + +- 本地测试:`python -m unittest discover -s tests -v` 通过(32/32)。 + +## 2026-02-23(架构讨论/PRD) + +### 决策记录 + +- 为降低后续维护成本,采用“渐进式拆分”策略:先拆后端模块与前端静态资源引用,再逐步消除兼容层,避免一次性大重写带来回归。 +- 不引入新的前端框架与构建链(优先零构建、少依赖),前端拆分以 `static/js` ES module + Jinja 模板精简为主。 +- 后端拆分优先引入 `create_app()` + Blueprint 路由模块化,并严格控制 import-time 副作用(调度器必须在装配阶段按开关启动),同时保持 `web_outlook_app:app` 部署入口兼容。 + +### 文档更新 + +- 新增 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md`(参考现有 PRD 格式,定义目标/范围/模块边界/验收口径,并给出阶段里程碑与章节→模块映射草案)。 +- 新增 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md`(按现有 FD 格式输出“拆分交付清单 + 非功能约束 + 验收检查表”,用于后续实施阶段对齐)。 +- 新增 TDD:`docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md`(给出模块目录结构、依赖方向约束、装配/兼容策略、分阶段迁移与测试门禁)。 +- 新增 TODO:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md`(按现有 TODO 格式输出 P0/P1 待办清单,并与 PRD/FD/TDD 对齐)。 +- PRD/FD/TDD 头部补充对齐 TODO 链接,方便从任一文档跳转到实施清单。 +- 新增 CR:`docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md`(按审查维度对 TDD 进行格式/完整性/一致性/集成点检查并给出改进建议)。 diff --git a/docs/LOG/LOG-00002-Outlook邮件管理工具-前后端拆分与模块化-执行日志.md b/docs/LOG/LOG-00002-Outlook邮件管理工具-前后端拆分与模块化-执行日志.md new file mode 100644 index 0000000..d086d77 --- /dev/null +++ b/docs/LOG/LOG-00002-Outlook邮件管理工具-前后端拆分与模块化-执行日志.md @@ -0,0 +1,195 @@ +# 执行日志:Outlook邮件管理工具-前后端拆分与模块化(00002) + +- 日期:2026-02-23 +- 目的:按 `docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` 推进“前后端拆分与模块化”,确保 **URL/响应契约/错误结构/trace_id/调度器行为** 不回归,并在每阶段提供可回滚点与可验证证据(测试/文档)。 + +--- + +## 2026-02-23 + +### 启动记录 + +- 本轮开始依据的目标文档:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` +- 约束:不引入前端构建链;后端仍为 Flask;数据库仍为 SQLite;行为兼容优先。 + +### 阶段 0:基线冻结(门禁) + +- 决策:先补齐“接口契约抽样验收口径 + smoke tests”,作为后续拆分的门禁;后续每次大拆分都必须通过该门禁再继续推进。 + +#### 产出 + +- 接口契约抽样验收口径文档: + - `docs/QA/前后端拆分-接口契约抽样验收口径.md` +- Smoke/契约抽样单测: + - `tests/test_smoke_contract.py` + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(34/34)。 + +### 阶段 0(补充):回滚点与静态资源 Smoke 覆盖 + +- 补充:将门禁项的“回滚点/静态资源可访问”也纳入可验证资产,并同步在 TODO 勾选完成状态。 + +#### 产出 + +- 回滚点与切换方式文档: + - `docs/RUN/00002-前后端拆分-回滚点与切换方式.md` +- 最小静态资源基线: + - `static/health.txt` +- Smoke/契约抽样单测增强: + - `tests/test_smoke_contract.py`(新增页面可访问与静态资源可访问检查) + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(36/36)。 + +### 阶段 4(完成):Routes 模块化(Blueprint 化,URL 不变) + +- 决策:采用“Blueprint 仅做 URL->view_func 映射,view_func 复用 legacy 实现”的迁移方式,优先保证接口契约不回归;所有 blueprint 注册集中到 `create_app()`,并修复兼容入口被 `import *` 覆盖 `app` 的问题,确保部署入口 `web_outlook_app:app` 真正暴露装配后的 factory app。 + +#### 产出 + +- Blueprint 路由模块(按领域拆分): + - `outlook_web/routes/pages.py`(/ /login /logout /api/csrf-token) + - `outlook_web/routes/groups.py`、`outlook_web/routes/tags.py`、`outlook_web/routes/accounts.py`、`outlook_web/routes/emails.py` + - `outlook_web/routes/temp_emails.py`、`outlook_web/routes/oauth.py`、`outlook_web/routes/settings.py` + - `outlook_web/routes/scheduler.py`、`outlook_web/routes/system.py`、`outlook_web/routes/audit.py` +- 统一注册路由(集中在工厂内注册 blueprint,URL 不变): + - `outlook_web/app.py` +- 入口修复(避免 `from outlook_web.legacy import *` 覆盖 factory app): + - `web_outlook_app.py`(使用 `_factory_app` 并在 import * 后恢复 `app` 绑定) +- 重定向端点对齐(Blueprint 化后登录端点为 `pages.login`): + - `outlook_web/security/auth.py`、`outlook_web/legacy.py` + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(37/37)。 + +### 阶段 6(完成):文档与维护指南(面向开发者) + +- 决策:把“入口保持不变 + 内部模块化结构 + 新增 API 的推荐路径”文档化,避免后续继续拆分时出现认知偏差;同时同步回归清单/验收映射对入口的描述,明确 `web_outlook_app.py` 为兼容 facade、真实装配入口为 `create_app()`。 + +#### 产出 + +- README 补充模块结构与新增 API 指南: + - `README.md` +- 开发者指南(模块边界/依赖方向/测试运行/常见坑): + - `docs/DEV/00002-前后端拆分-开发者指南.md` +- 更新回归清单与验收映射的入口说明: + - `docs/QA/回归验证清单.md` + - `docs/QA/P0验收对照表.md` +- 回滚点文档与实现对齐(阶段 5 描述修正为外链 CSS/JS,零构建): + - `docs/RUN/00002-前后端拆分-回滚点与切换方式.md` + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(37/37)。 + +### 阶段 5(完成):前端静态资源拆分(零构建) + +- 决策:不引入任何前端构建链,先把 `templates/index.html` 的内联 CSS/JS 迁出到 `static/css` 与 `static/js`;JS 拆分以“按 feature 分文件 + 仍保持全局函数供内联 onclick 调用”的方式推进,避免一次性改为 ESModule 导致全量重写与行为回归风险。 + +#### 产出 + +- CSS 迁出: + - `static/css/main.css` + - `templates/index.html` 引入 `css/main.css`(Jinja `url_for('static', ...)`) +- JS 迁出与拆分: + - `static/js/main.js`(保留全局状态、CSRF、初始化与部分通用逻辑) + - `static/js/features/groups.js`、`static/js/features/accounts.js`、`static/js/features/emails.js`、`static/js/features/temp_emails.js` + - `templates/index.html` 增加对上述脚本的顺序加载(保持行为不变) +- Smoke/契约单测增强(静态资源与“无内联 style/script”门禁): + - `tests/test_smoke_contract.py` + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(37/37)。 + +### 阶段 3(完成):拆 repositories(SQL)与 services(业务编排) + +- 决策:采用“先抽模块 + legacy re-export 接入”的方式推进,优先保证 URL/响应契约/trace_id/错误结构不回归;在关键链路(删除回退、刷新编排)先补边界单测再继续拆 routes。 + +#### 产出 + +- repositories(SQL 与数据映射): + - `outlook_web/repositories/accounts.py`(账号读写、批量 tags 聚合与敏感字段解密) + - `outlook_web/repositories/groups.py`(分组 CRUD、默认分组与账号迁移) + - `outlook_web/repositories/tags.py`(标签 CRUD、账号标签关联) + - `outlook_web/repositories/temp_emails.py`(临时邮箱与邮件落库查询) + - `outlook_web/repositories/settings.py`(settings 读取/写入、默认值兜底) + - `outlook_web/repositories/distributed_locks.py`、`outlook_web/repositories/refresh_runs.py`、`outlook_web/repositories/refresh_logs.py`(刷新相关表操作) +- services(Graph/IMAP/GPTMail/刷新/删除回退): + - `outlook_web/services/graph.py`、`outlook_web/services/imap.py`、`outlook_web/services/http.py` + - `outlook_web/services/gptmail.py` + - `outlook_web/services/email_delete.py`(删除回退聚合与摘要) + - `outlook_web/services/refresh.py`(刷新编排:manual_all/scheduled_manual 流式 + retry_failed) +- legacy 接入(兼容旧调用点): + - `outlook_web/legacy.py` 通过 re-export 覆盖原同名函数,使 routes 默认走 repositories/services 实现 + - `/api/emails/delete` 迁移为调用 `outlook_web/services/email_delete.py`(Graph 优先、IMAP 回退与错误聚合保持一致) + - `/api/accounts/refresh-all`、`/api/accounts/trigger-scheduled-refresh` 迁移为调用 `outlook_web/services/refresh.py`(SSE 输出结构不变) + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(37/37)。 +- 新增边界单测:ProxyError 场景不回退 IMAP(`tests/test_error_and_trace.py`)。 +- 备注:Python 3.13 下静态文件响应会触发 `ResourceWarning`,已在测试中对响应显式 `close()` 消除告警。 + +### 阶段 1:后端骨架 + 入口兼容(create_app) + +- 决策:先落地 `create_app()` 与后端包结构,保持 `web_outlook_app:app` 启动入口不变;同时把 init_db/调度器启动从模块 import-time 副作用迁移到 `create_app()` 受控执行。 + +#### 产出 + +- 后端包与应用工厂: + - `outlook_web/app.py`(`create_app()`) + - `outlook_web/__init__.py` +- 单体实现迁移(迁移期 legacy): + - `outlook_web/legacy.py`(原 `web_outlook_app.py` 迁入;移除 `init_app()` 与调度器的 import-time 自动执行点) +- 入口兼容 facade: + - `web_outlook_app.py`(`app = create_app(...)`,并暴露 `impl` 便于测试打补丁) +- 单测兼容调整: + - 将 `patch.object(self.module, ...)` 改为 `patch.object(self.module.impl, ...)`,确保对 legacy 逻辑打补丁生效 + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(36/36)。 + +### 阶段 2(完成):基础设施模块化 - 配置/安全/DB/审计 + +- 决策:在不改变接口契约的前提下,先把“配置/安全/DB/审计”等基础能力抽到独立模块;legacy 仅保留兼容导出与路由绑定,降低后续拆 routes/services/repositories 的耦合风险。 + +#### 产出 + +- 配置集中化: + - `outlook_web/config.py`(环境变量读取与默认值集中) + - `outlook_web/legacy.py` 将调度器 autostart 相关读取改为 `config.get_scheduler_autostart_default()` +- 安全模块化: + - `outlook_web/security/crypto.py`(密码哈希 + 敏感数据加密解密) + - `outlook_web/security/auth.py`(鉴权装饰器 + 速率限制 + 导出二次验证 token) + - `outlook_web/security/csrf.py`(可选 CSRF:存在依赖则启用,否则显式禁用) +- DB 模块化: + - `outlook_web/db.py`(连接管理、`init_db()`、schema 版本/迁移记录、敏感字段加密迁移) + - `outlook_web/legacy.py` 通过 re-export 兼容旧调用点(迁移期保留函数名,真实实现位于 `outlook_web/db.py`) +- 审计模块化: + - `outlook_web/audit.py`(`log_audit` 与审计查询 `query_audit_logs`) + - `/api/audit-logs` 改为调用 `query_audit_logs`(响应结构保持一致) + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(36/36)。 + +### 阶段 2(完成):基础设施模块化 - 错误模型集中化 + +- 决策:优先抽离“错误结构/脱敏/trace_id 生成”的公共能力,降低后续 routes/services/repositories 迁移时的耦合与重复。 + +#### 产出 + +- 错误模型模块: + - `outlook_web/errors.py`(`generate_trace_id/sanitize_error_details/build_error_payload`) +- legacy 迁移: + - `outlook_web/legacy.py` 改为统一引用 `outlook_web/errors.py`(保持对外错误结构与脱敏行为一致) + +#### 验证 + +- 本地执行:`python -m unittest discover -s tests -v` 通过(36/36)。 diff --git a/docs/LOG/LOG-00003-可调整布局系统-执行日志.md b/docs/LOG/LOG-00003-可调整布局系统-执行日志.md new file mode 100644 index 0000000..a6d4d68 --- /dev/null +++ b/docs/LOG/LOG-00003-可调整布局系统-执行日志.md @@ -0,0 +1,120 @@ +# 执行日志:可调整布局系统(00003) + +- 日期:2026-02-27 +- 目的:按 `docs/TODO/TODO-可调整布局系统.md` 推进未完成项(阶段五/六为主),补齐 **恢复默认布局(F3)/窗口尺寸适配(F5)/键盘可访问性(F6)**,并用可验证证据(测试/文档)确保不回归主业务功能。 + +--- + +## 2026-02-27 + +### 启动记录 + +- 本轮开始依据的目标文档:`docs/TODO/TODO-可调整布局系统.md` +- 约束: + - 不引入前端构建链(保持零构建、外链 CSS/JS)。 + - 现有主业务功能(分组/账号/邮件)行为不回归。 + - 关键路径用 Jest(`tests/layout-system/`)提供可验证证据。 + +### 计划推进范围 + +- 阶段五:恢复默认布局(F3.1~F3.4) +- 阶段六:窗口适配与键盘支持(F5.1~F5.3、F6.1~F6.4) + +### 阶段五(进行中):恢复默认布局(F3) + +- 决策:沿用现有 `.modal/.show` 交互模式(与 addGroup/addAccount 等一致),不引入新依赖;重置逻辑在 `LayoutManager.resetLayout()` 内完成,且**显式取消未到期的保存防抖**,避免“重置后又被定时器写回”导致 localStorage 未真正清空。 + +#### 产出 + +- 导航栏增加恢复按钮: + - `templates/index.html` +- 恢复确认对话框: + - `templates/partials/modals.html`(`#resetLayoutModal`) +- 重置动画与按钮样式: + - `static/css/layout.css`(`.layout-resetting`、`.reset-layout-btn`、`#resetLayoutModal .text-muted`) +- 重置逻辑与动画封装: + - `static/js/layout-manager.js`(`resetLayout()`、`withResettingAnimation()`、debounce 增加 `cancel()`) +- UI 绑定(show/hide/confirm + ESC 关闭): + - `static/js/layout-bootstrap.js` + +### 阶段六(进行中):窗口适配与键盘支持(F5/F6) + +- 决策: + - 窗口适配以 `LayoutManager` 统一处理:`resize` 防抖 200ms(FD F5.1),断点自动折叠/恢复仅作用于 **autoCollapsed** 面板(避免覆盖用户手动折叠偏好)。 + - 键盘宽度调整直接复用 `calculateNewWidth()`,确保与鼠标拖动共享同一套最小/最大/详情最小宽度约束。 + - 兼容既有“移动端隐藏列表”逻辑:隐藏时同步将 Grid 列宽置 0,避免出现“元素 display:none 但列宽仍占位”的空白列回归。 + +#### 产出 + +- 窗口适配 + 键盘调整: + - `static/js/layout-manager.js`(`handleWindowResize()`、`onWindowResize()`、`onResizerKeydown()`、`expandPanel(panelType, auto)`) +- 业务隐藏邮件列表与 Grid 列宽联动: + - `static/js/features/emails.js`(`syncEmailListVisibility()`,替换原 `.hidden` 仅改 display 的实现) + +#### 验证 + +- 布局系统 Jest(含新增 F3/F5/F6 用例):`npm test` 通过(11 suites / 93 tests) +- 全量 Python 单测回归:`python -m unittest discover -s tests -v` 通过(95 tests) + +--- + +### UI 问题修复:窄屏下“全部折叠后无法再展开”(F2/F5 交互回归) + +#### 现象 + +- 当窗口较窄(触发 `@media (max-width: 768px)`)且 LayoutManager 自动折叠/用户折叠侧边栏后: + - **分组面板 / 账号面板**的折叠指示器会被一起移出屏幕(left 为负值),导致用户看起来“面板消失且再也打不开”。 + - 若同时触发业务侧的“隐藏邮件列表”(`.email-list-panel.hidden`),则左侧三个区域都不可恢复,交互卡死。 + +#### 根因定位 + +- `static/css/main.css` 在窄屏下仍保留历史规则:`.group-panel,.account-panel { position: fixed; left:-280px; }` +- 新的可调整布局系统(`static/js/layout-manager.js`)在折叠时把 Grid 列宽置 0,并依赖 **折叠指示器** 提供“展开入口”。 +- 当历史规则把面板整体移出屏幕时,折叠指示器作为面板子元素也一并移出,造成不可展开。 + +#### 修复方案 + +- 在布局系统初始化时,为 `` 添加标记类:`layout-system-enabled`。 +- 将窄屏历史侧栏滑入样式限定在 “布局系统未启用” 的场景: + - 仅对 `html:not(.layout-system-enabled)` 生效,避免影响 LayoutManager 的折叠/指示器逻辑。 + +#### 变更点 + +- `static/js/layout-bootstrap.js`:LayoutManager 初始化前添加 `layout-system-enabled`。 +- `static/css/main.css`:`@media (max-width: 768px)` 内的 `.group-panel/.account-panel` 固定定位规则增加 `html:not(.layout-system-enabled)` 限定。 + +#### 验证 + +- `npm test` 通过(11 suites / 93 tests)。 +- Playwright 窄屏复现验证(viewport=600): + - 修复前:groups/accounts 指示器 `x < 0`(不可见) + - 修复后:groups/accounts/emails 指示器分别落在 `x=0/48/96`(可见且可点击展开) + +--- + +### UI 问题修复:折叠后页面出现滚动条、指示器高度观感异常(“太长”) + +#### 现象 + +- 在桌面宽屏下手动折叠侧边栏(如:账号/邮件列表)后,页面右侧出现 **body 滚动条**。 +- 因 body 被撑出滚动高度,用户会看到主容器整体被滚动,折叠指示器与布局区域的高度/对齐出现“太长/不正确”的观感。 + +#### 根因定位 + +- 折叠时 Grid 列宽被置为 `0px`(layout-width),但我们仅用 `opacity:0` 隐藏面板内容: + - 面板内容仍参与布局计算; + - 在 `0` 宽度下文本/控件会被强制换行,导致折叠面板内部出现异常的“高度溢出”; + - 溢出内容默认 `overflow: visible`,会把 body 的滚动高度撑大。 + +#### 修复方案 + +- `static/css/layout.css`: + - `.main-container` 增加 `overflow: hidden;`,阻止溢出影响 body 滚动条; + - `.main-container > *` 增加 `min-height: 0;`,允许 Grid 子项在固定高度下正确收缩; + - `.resizable-panel.collapsed > :not(.collapsed-indicator)` 增加 `display: none;`,让折叠时隐藏内容**不参与布局与滚动高度计算**。 + +#### 验证 + +- Playwright 复现验证(宽屏,折叠 accounts/emails): + - 修复前:`documentElement.scrollHeight > clientHeight`(出现滚动条) + - 修复后:`documentElement.scrollHeight === clientHeight`(滚动条消失),折叠指示器高度与主容器一致 diff --git a/docs/PERF/性能与容量基线.md b/docs/PERF/性能与容量基线.md new file mode 100644 index 0000000..82a94f8 --- /dev/null +++ b/docs/PERF/性能与容量基线.md @@ -0,0 +1,60 @@ +# 性能与容量基线(可验收口径) + +- 日期:2026-02-22 +- 目标:对齐 PRD/TDD 的“可接受时间定义与验证方式”,提供可重复的性能验收口径(可按真实部署环境调整)。 + +--- + +## 1) 统一约定 + +- 账号规模:1,000(均为 active) +- 网络:同机访问或同局域网访问 +- 运行方式:Docker + Gunicorn 单 worker(见 `README.md`) +- 统计口径:每项测 10 次,取 p95(去掉首次冷启动 1 次) + +--- + +## 2) 关键路径指标(建议默认阈值) + +> 说明:SQLite + 单机管理工具场景下,这里给出“可用体验阈值”。若部署在低配机器,可放宽阈值并在此文档记录。 + +### 2.1 账号列表与筛选 + +- `/api/accounts`(1,000 账号,含 tags 与 last_refresh_*) + - p95 响应时间 <= 1.5s +- `/api/accounts/search?q=...`(命中 50~200 条) + - p95 响应时间 <= 1.5s + +### 2.2 全量刷新进度反馈 + +- `/api/accounts/refresh-all`(SSE/流式进度) + - 首个 `start` 事件 <= 3s + - 过程中持续输出进度(避免长时间无反馈) + +--- + +## 3) 验证方式(建议) + +### A. 浏览器验证(推荐) + +- 使用浏览器开发者工具 Network 观察接口耗时: + - 打开首页加载账号列表(会触发 `/api/accounts`) + - 使用全局搜索(触发 `/api/accounts/search`) + - 触发全量刷新并观察 SSE 首包与后续事件频率 + +### B. 命令行验证(可选) + +- 使用 `curl`/脚本携带登录态 cookie 测量接口耗时(适合 CI/预发布环境)。 + +--- + +## 4) 现有工程侧优化点(与上述指标对齐) + +- 已消除明显 N+1: + - 账号列表:tags 与 last_refresh_* 改为批量查询 + - 搜索结果:tags 与 last_refresh_* 改为批量查询 +- 已增加索引(init_db 幂等创建): + - `accounts(group_id)` + - `account_refresh_logs(account_id, id)` + - `account_tags(tag_id)` + diff --git a/docs/QA/P0验收对照表.md b/docs/QA/P0验收对照表.md new file mode 100644 index 0000000..9b6388a --- /dev/null +++ b/docs/QA/P0验收对照表.md @@ -0,0 +1,177 @@ +# P0 验收对照表(与 TODO 对齐) + +- 日期:2026-02-22 +- 目的:把 `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` 的 P0 条目映射到“可验证证据”(接口/数据/测试/文档),用于验收与排障。 +- 适用入口:`web_outlook_app:app`(`web_outlook_app.py` 为部署兼容 facade,内部由 `outlook_web/app.py:create_app()` 装配) + +--- + +## 1) 运行与定时刷新可靠性 + +- Web 服务运行能力 + - 探活:`GET /healthz` + - 登录后系统健康:`GET /api/system/health` +- 后台刷新任务运行能力 + - 调度器心跳:`GET /api/scheduler/status` → `scheduler.heartbeat_age_seconds` + - 调度器自启动:环境变量 `SCHEDULER_AUTOSTART=true`(见 README/.env.example) +- 官方推荐运行方式(可验证) + - 推荐:Docker + Gunicorn 单 worker(见 `Dockerfile` 与 `README.md`) + - 验证口径:见 `docs/RUN/运行与定时刷新验证指南.md` +- 定时刷新能力齐全(启停/两种调度/校验/未来预览) + - 设置项:`enable_scheduled_refresh`、`use_cron_schedule`、`refresh_interval_days`、`refresh_cron` + - Cron 校验:`POST /api/settings/validate-cron` + - 未来预览:`GET /api/scheduler/status` → `scheduler.future_runs / next_due` +- 定时刷新可验证(最近一次/来源/统计) + - `refresh_runs` 表记录:trigger_source/status/started_at/finished_at/total/success_count/failed_count/trace_id + - `GET /api/scheduler/status` → `refresh.last_scheduled` +- 刷新冲突控制 + - `distributed_locks` 表作为分布式锁 + - 冲突策略:已有刷新运行时返回冲突提示(409 / skipped) + - 单测覆盖:`tests/test_distributed_lock.py` + +--- + +## 2) 状态一致性(重启/扩容) + +- 导出二次验证凭证持久化与 TTL + - `export_verify_tokens` 表(expires_at) + - 诊断可见:`GET /api/system/diagnostics` → `export_verify_tokens_active` + - 单测覆盖:`tests/test_error_and_trace.py` → `test_export_requires_verify_token` +- 登录失败计数/锁定状态持久化与 TTL + - `login_attempts` 表(locked_until_at) + - 诊断可见:`GET /api/system/diagnostics` → `login_locked_ip_count` + - 单测覆盖:`tests/test_error_and_trace.py` → `test_login_rate_limiting_locks_after_max_attempts` +- 定时刷新最近触发/运行中状态一致性 + - `refresh_runs` 表 + `distributed_locks` 表 + - 查询入口:`GET /api/scheduler/status`(last_scheduled / running / lock) +- 过期清理与管理员可诊断 + - `GET /api/system/diagnostics` 汇总关键状态 + +--- + +## 3) 错误与追踪一致性 + +- 统一错误结构 + trace_id + - `X-Trace-Id` 响应头 + - API error payload:`{code,message,type,status,details,trace_id}` + - 单测覆盖:`tests/test_error_and_trace.py`(404 structured / trace_id 透传) +- 默认脱敏 + - `sanitize_error_details()` 对 token/password 等脱敏(用于 message/details) + - `build_error_payload()` 会对 `message` 与 `details` 做默认脱敏 + - 单测覆盖: + - `tests/test_error_and_trace.py` → `test_sanitize_error_details_masks_tokens` + - `tests/test_error_and_trace.py` → `test_build_error_payload_sanitizes_message_and_details` +- 多方式回退失败原因聚合(中文可理解) + - 邮件获取:`GET /api/emails/` 失败时返回 `details.graph/imap_new/imap_old`(前端弹框 `showEmailFetchErrorModal()`) + - 邮件删除:`POST /api/emails/delete` 全部失败时返回 `error.code=EMAIL_DELETE_ALL_METHODS_FAILED`,并在 `error.details` 汇总 Graph/IMAP 失败原因(同时保留 `details` 字段供进一步排障) + - 单测覆盖:`tests/test_error_and_trace.py` → `test_delete_emails_all_methods_fail_returns_aggregated_error` +- 未捕获异常统一处理 + - Flask 全局异常处理器:返回稳定错误结构 + trace_id + +--- + +## 4) 数据兼容与升级保障 + +- 升级后历史数据可继续使用 + - 初始化/迁移逻辑:`init_db()`(幂等建表 + 缺列补齐 + 数据加密迁移) +- 升级过程可验证 + - `schema_migrations` 表记录迁移过程(status/trace_id/error) + - 状态接口:`GET /api/system/upgrade-status` + - 单测覆盖:`tests/test_error_and_trace.py` → `test_system_upgrade_status_endpoint` +- 升级前备份提示与可操作路径 + - 启动升级提示(stdout)+ `GET /api/system/upgrade-status` → `backup_hint` + +--- + +## 5) 核心业务功能保真 + +> 该部分更偏“端到端回归”,建议以回归验证清单执行。 + +- 回归清单:`docs/QA/回归验证清单.md` + +- 登录与会话 + - 登录:`POST /login`(含失败限制/锁定提示) + - 退出:`GET /logout`(退出后访问 `/api/*` 返回 401 + need_login) + - 设置中心修改密码:`PUT /api/settings`(login_password) + - 单测覆盖: + - `tests/test_error_and_trace.py` → `test_login_rate_limiting_locks_after_max_attempts` + - `tests/test_core_features.py` → `test_logout_revokes_session` + +- 分组管理 + - 列表/增删改查:`GET/POST/PUT/DELETE /api/groups` + - 代理/网络策略:`proxy_url` 字段随分组保存(审计不记录明文) + - 单测覆盖:`tests/test_core_features.py` → `test_group_crud_and_audit_trace_id` + +- 账号管理 + - 列表/搜索:`GET /api/accounts`、`GET /api/accounts/search` + - 新增(含批量导入/错误定位/汇总):`POST /api/accounts` + - 编辑/删除:`PUT/DELETE /api/accounts/` + - 单测覆盖: + - `tests/test_error_and_trace.py` → `test_accounts_list_and_search_include_tags_and_last_refresh` + - `tests/test_masking_audit_and_import.py` → `test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter` + - `tests/test_masking_audit_and_import.py` → `test_account_get_does_not_return_password_or_refresh_token` + +- 标签管理 + - 标签增删:`GET/POST/DELETE /api/tags` + - 账号批量打/去标签:`POST /api/accounts/tags` + - 单测覆盖:`tests/test_core_features.py` → `test_tag_crud_and_duplicate_name` + +- 邮件读取/详情/删除(Graph + IMAP 回退) + - 列表:`GET /api/emails/`(失败时返回 `details.graph/imap_new/imap_old`,前端弹框解释) + - 详情:`GET /api/email//` + - 删除:`POST /api/emails/delete`(失败时可聚合展示多方式失败原因) + - 单测覆盖:`tests/test_error_and_trace.py` → `test_delete_emails_all_methods_fail_returns_aggregated_error` + +- Token 刷新(手动/全量/定时) + - 单账号:`POST /api/accounts//refresh` + - 全量:`GET /api/accounts/refresh-all`(SSE,含 start/progress/complete) + - 定时:`GET /api/scheduler/status`(运行证据:heartbeat/last_scheduled/trace_id) + - 单测覆盖: + - `tests/test_core_features.py` → `test_refresh_all_stream_has_start_and_complete_events` + - `tests/test_error_and_trace.py` → `test_scheduler_status_endpoint_is_accessible_after_login` + +- 刷新日志与统计 + - 日志:`GET /api/accounts/refresh-logs`、`GET /api/accounts//refresh-logs` + - 失败入口:`GET /api/accounts/refresh-logs/failed` + - 统计:`GET /api/accounts/refresh-stats` + +- 导出与二次验证 + - 二次验证:`POST /api/export/verify`(一次性 verify_token) + - 导出:`GET /api/accounts/export`、`POST /api/accounts/export-selected`、`GET /api/groups//export` + - 单测覆盖: + - `tests/test_error_and_trace.py` → `test_export_requires_verify_token` + - `tests/test_core_features.py` → `test_export_verify_and_export_all_download_contains_account` + +- OAuth2 辅助 + - 授权链接:`GET /api/oauth/auth-url` + - 凭据换取:`POST /api/oauth/exchange-token`(需二次验证) + - 单测覆盖:`tests/test_core_features.py` → `test_oauth_auth_url_endpoint` + +- 临时邮箱 + - 生成/列表:`POST /api/temp-emails/generate`、`GET /api/temp-emails` + - 邮件列表/详情/删除/清空:`/api/temp-emails//...` + - 单测覆盖: + - `tests/test_core_features.py` → `test_temp_email_generate_and_list_mocked` + - `tests/test_masking_audit_and_import.py` → `test_temp_email_actions_are_audited` + +- 设置中心 + - 获取/更新:`GET/PUT /api/settings`(敏感字段以 `*_set/*_masked` 形式呈现) + - Cron 校验:`POST /api/settings/validate-cron` + - 单测覆盖: + - `tests/test_masking_audit_and_import.py` → `test_settings_get_does_not_leak_secrets` + - `tests/test_error_and_trace.py` → `test_validate_cron_endpoint` +- 系统分组保护(关键约束) + - 系统分组禁止删除:`DELETE /api/groups/`(返回 `SYSTEM_GROUP_PROTECTED`) + - 编辑账号时禁止移动到系统分组:`PUT /api/accounts/`(返回 `SYSTEM_GROUP_PROTECTED`) + +- 审计(敏感操作可追溯) + - 查询入口:`GET /api/audit-logs`(支持 `action/resource_type` 过滤;返回 `trace_id` 便于排障) + - 覆盖范围:导出/删除邮件/修改账号/修改分组/修改设置/OAuth 换取/临时邮箱等关键操作 + - 单测覆盖: + - `tests/test_core_features.py` → `test_group_crud_and_audit_trace_id` + - `tests/test_masking_audit_and_import.py` → `test_audit_logs_endpoint_includes_group_create`、`test_temp_email_actions_are_audited` + +- 默认脱敏与不回显(防止管理界面直接泄露凭据) + - 设置:`GET /api/settings` 仅返回安全子集(`*_set/*_masked`),不返回 `login_password/gptmail_api_key` 明文/哈希 + - 账号:`GET /api/accounts/` 不回显 `password/refresh_token`(仅 `has_password/has_refresh_token` 标记) + - 单测覆盖:`tests/test_masking_audit_and_import.py` diff --git a/docs/QA/前后端拆分-接口契约抽样验收口径.md b/docs/QA/前后端拆分-接口契约抽样验收口径.md new file mode 100644 index 0000000..f73a26a --- /dev/null +++ b/docs/QA/前后端拆分-接口契约抽样验收口径.md @@ -0,0 +1,75 @@ +# 前后端拆分|接口契约抽样验收口径(最小字段契约) + +- 日期:2026-02-23 +- 目的:为“前后端拆分与模块化”提供可执行的 **抽样验收口径**,确保重构只改变代码组织,不改变对外行为。 +- 说明:这里定义的是“最小字段契约”(存在性与基础结构),不穷举全部字段。 + +--- + +## 1) 抽样接口清单(建议每阶段必测) + +### 1.1 基础可用性 + +- `GET /healthz` + - 期望:`200` + - JSON:`{ "status": "ok" }` + +### 1.2 登录与鉴权 + +- `POST /login`(JSON:`{"password":"..."}`) + - 期望:`200` + - JSON:`{ "success": true }`(成功时) +- 任一需要登录的接口(例如 `GET /api/system/health`) + - 未登录期望:`401` + - JSON:`{ success:false, need_login:true, error:{code,status,trace_id,...} }` + +### 1.3 系统与运维抽样 + +- `GET /api/system/health`(登录后) + - 期望:`200` + - JSON: + - `success=true` + - `health.service / health.database` + - `health.scheduler.enabled / health.scheduler.autostart` + - `health.refresh.locked / health.refresh.running` + +- `GET /api/scheduler/status`(登录后) + - 期望:`200` + - JSON: + - `success=true` + - `scheduler.*`(至少包含 `enabled/autostart/heartbeat_age_seconds`) + - `refresh.*`(至少包含 `last_scheduled/running/lock`) + +- `GET /api/system/upgrade-status`(登录后) + - 期望:`200` + - JSON: + - `success=true` + - `upgrade.schema_version/upgrade.target_version/upgrade.up_to_date` + - `upgrade.backup_hint` + +### 1.4 基础业务抽样(不触发外网) + +- `GET /api/groups`(登录后) + - 期望:`200` + - JSON:`{success:true, groups:[...] }` + +- `GET /api/tags`(登录后) + - 期望:`200` + - JSON:`{success:true, tags:[...] }` + +- `GET /api/accounts`(登录后) + - 期望:`200` + - JSON:`{success:true, accounts:[...] }` + +- `GET /api/audit-logs`(登录后) + - 期望:`200` + - JSON:`{success:true, logs:[...], total, limit, offset }` + +--- + +## 2) 自动化门禁(推荐) + +- 单测门禁: + - 命令:`python -m unittest discover -s tests -v` + - 抽样契约测试文件:`tests/test_smoke_contract.py` + diff --git a/docs/QA/回归验证清单.md b/docs/QA/回归验证清单.md new file mode 100644 index 0000000..805546f --- /dev/null +++ b/docs/QA/回归验证清单.md @@ -0,0 +1,171 @@ +# 回归验证清单(发布前/重启后) + +- 日期:2026-02-22 +- 适用范围:部署入口 `web_outlook_app:app`(`web_outlook_app.py` 为兼容 facade)对应的 Web 管理端与后台定时刷新能力 +- 目标:提供一份可重复执行的回归清单(覆盖“重启后再验证”),对齐 `docs/TODO/Outlook邮件管理工具-重构升级TODO.md` P1-8。 + +--- + +## 0. 前置准备 + +- 已设置 `SECRET_KEY`(生产环境不要随意更换;更换会影响历史加密数据解密)。 +- 已设置持久化目录(Docker 推荐挂载 `./data:/app/data`)。 + +--- + +## 1. 基础可用性(可服务) + +- 访问健康检查:`GET /healthz` 返回 `{"status":"ok"}`。 +- 登录后访问管理员健康检查:`GET /api/system/health` + - `health.database=ok` + - 若启用定时刷新:`health.scheduler.healthy=true` + +--- + +## 2. 登录与会话 + +- 登录成功:`POST /login`(正确密码)返回 success。 +- 登录失败限制: + - 连续输入错误密码达到阈值后返回 429,并提示剩余等待秒数。 +- 修改密码(设置中心): + - 新密码长度 >= 8 + - 修改后旧密码不可再登录,新密码可登录 +- 退出登录:`GET /logout` 后访问任一 `/api/*` 返回 401(need_login=true)。 + +--- + +## 3. 分组管理 + +- 分组列表:加载分组,显示颜色/描述/账号数量。 +- 新增分组:名称唯一,支持描述/颜色/网络访问策略(代理配置)。 +- 编辑分组:修改后列表与详情一致。 +- 系统分组保护: + - 系统分组不可被危险操作破坏(例如不允许删除/不允许批量迁移进入)。 + +--- + +## 4. 账号管理 + +- 列表:展示邮箱、分组、备注、状态、标签、最近刷新状态。 +- 新增/编辑/删除:接口与界面行为一致。 +- 批量导入: + - 格式校验 + - 错误定位到行/字段 + - 汇总成功/失败数量与原因 +- 批量移动分组: + - 不允许移动到系统分组(如“临时邮箱”)。 +- 编辑账号时修改分组: + - 不允许将账号移动到系统分组(防止绕过批量移动限制)。 +- 状态启停: + - 停用账号不参与自动刷新/后台刷新。 + +--- + +## 5. 标签管理 + +- 新增/删除标签(名称唯一)。 +- 给账号批量打/去标签。 +- 组合筛选:分组 + 关键词 + 标签筛选可同时使用。 + +--- + +## 6. 邮件读取与展示 + +- 三类文件夹:收件箱 / 垃圾邮件 / 已删除邮件。 +- 邮件列表分页/滚动加载:加载中/无更多/失败提示。 +- 邮件详情:主题、发件人、时间、正文。 +- 全屏查看:长内容可完整展示。 +- 信任模式提示:默认安全展示;开启信任模式需风险提示且默认关闭。 + +--- + +## 7. 邮件删除 + +- 单封删除:二次确认;失败原因分类提示;可重试。 +- 批量删除:二次确认;失败汇总与可重试。 + +--- + +## 8. Token 刷新(手动/全量/定时) + +### 8.1 单账号刷新 + +- 触发刷新:返回成功/失败原因。 +- 刷新结果被记录(刷新日志可查询)。 + +### 8.2 全量刷新 + +- 触发全量刷新:持续输出进度(总数/成功/失败)。 +- 支持仅刷新失败账号与失败重试。 +- 并发/重复触发时应有冲突提示(避免重复执行)。 + +### 8.3 定时刷新(可验证) + +- 定时刷新配置: + - 启用/停用 + - 两种调度方式(按天数、Cron) + - Cron 校验与未来触发时间预览 +- 定时刷新运行证据: + - `GET /api/scheduler/status` 查看: + - `scheduler.heartbeat_age_seconds` 是否持续更新 + - `refresh.last_scheduled` 是否记录最近一次触发(含触发来源、统计、trace_id) + +更多“运行与定时刷新验证”见:`docs/RUN/运行与定时刷新验证指南.md`。 + +--- + +## 9. 刷新日志与统计 + +- 近半年查询:刷新历史可筛查/可定位。 +- 失败列表入口:可一键查看失败账号。 +- 过期记录清理策略:可解释且可验证(例如诊断接口、或刷新触发时的清理行为)。 + +--- + +## 10. 导出与二次验证 + +- 三种导出:按分组 / 全量 / 选择导出。 +- 导出前二次验证(输入密码换取一次性 token)。 +- 文件命名规则:包含范围与时间。 +- 审计记录:导出行为可追溯(含 trace_id/IP/时间)。 + +--- + +## 11. 审计与可追溯 + +- 敏感操作均产生审计:导出、删除、修改账号、修改分组、修改设置。 +- 审计查询/展示:可通过 trace_id 辅助排障。 + +--- + +## 12. OAuth2 辅助 + +- 授权链接生成可用。 +- 凭据换取可用;失败原因提示清晰(常见配置错误可定位)。 + +--- + +## 13. 临时邮箱 + +- 生成/列表/邮件列表与详情/删除/清空可用。 +- 失败原因分类提示:服务不可用/鉴权失败/网络异常等。 + +--- + +## 14. 设置中心 + +- 可修改登录密码/临时邮箱凭据/刷新策略等。 +- 变更生效时机明确(如需重载调度器应明确提示)。 +- 高风险项有提示(例如可能降低安全性或导致失败率上升)。 + +--- + +## 15. 重启后再验证(关键) + +> 目的:验证“状态一致性(重启/扩容下仍可用)”与“定时刷新看得见且确实在跑”。 + +- 重启应用(Docker 重启容器或进程重启)。 +- 再次登录后验证: + - `GET /api/system/health`:database ok;scheduler heartbeat 正常(若启用) + - `GET /api/scheduler/status`:heartbeat_age_seconds 可持续更新;last_scheduled 信息可查询 + - `GET /api/system/diagnostics`:关键状态可见(锁、运行记录、二次验证 token 数量、登录锁定 IP 数) diff --git a/docs/RUN/00002-前后端拆分-回滚点与切换方式.md b/docs/RUN/00002-前后端拆分-回滚点与切换方式.md new file mode 100644 index 0000000..11d382a --- /dev/null +++ b/docs/RUN/00002-前后端拆分-回滚点与切换方式.md @@ -0,0 +1,82 @@ +# 00002|前后端拆分与模块化:回滚点与切换方式 + +- 日期:2026-02-23 +- 目的:为 `docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` 的每个阶段定义**可回滚点**与**切换方式**,确保“渐进式拆分”发生问题时可以快速回退到上一稳定基线。 +- 约束:保持 **URL/响应契约/错误结构/trace_id/调度器行为** 不回归。 + +--- + +## 总体策略(适用于所有阶段) + +1. **每阶段必须可验证**:至少通过 `python -m unittest discover -s tests -v` 与抽样契约门禁(`tests/test_smoke_contract.py`)。 +2. **每阶段必须可回滚**:发生问题时优先回退到“上一阶段的绿色基线”(Git 回退/恢复上一版本镜像/回滚发布包)。 +3. **入口保持稳定**:对外部署入口始终保持 `web_outlook_app:app` 不变;内部允许引入兼容层,但要有明确删除时机(见 P1-7)。 + +--- + +## 阶段回滚点(按 TODO 阶段) + +### 阶段 0:基线冻结(门禁) + +- 产出: + - 抽样契约口径:`docs/QA/前后端拆分-接口契约抽样验收口径.md` + - 抽样契约/Smoke 测试:`tests/test_smoke_contract.py` +- 回滚点: + - 以“门禁通过”为基线;后续任何重构若导致门禁失败,应直接回退到最近一次门禁通过的版本。 +- 切换方式: + - 无需切换;本阶段仅新增测试与文档。 + +### 阶段 1:后端骨架 + 入口兼容(create_app) + +- 目标: + - 引入 `create_app()`,并保持 `web_outlook_app:app` 作为稳定入口。 + - 收敛 import-time 副作用:初始化与调度器启动受控(可通过环境变量/运行模式关闭)。 +- 回滚点: + - 保留兼容层(`web_outlook_app.py` 作为 facade),出现问题时可回退到“单体入口版本”(上一绿色基线)。 +- 切换方式(运行侧): + - 仍使用 `web_outlook_app:app` 启动,无需变更部署命令。 + - 调度器自启动由 `SCHEDULER_AUTOSTART=true|false` 控制(测试/诊断场景可关闭)。 + +### 阶段 2:基础设施模块化(config/errors/security/db/audit) + +- 目标: + - 拆出基础设施模块,但对外 API 行为保持一致。 +- 回滚点: + - 每次拆分以“模块抽取 + 兼容 re-export”为单位提交;若出现回归,优先回退到上一绿色基线。 +- 切换方式(代码侧): + - 允许短期保留旧函数入口(`web_outlook_app.*` re-export),待 P1-7 再清理。 + +### 阶段 3:repositories/services 模块化 + +- 目标: + - SQL 访问集中(repo),业务编排集中(service),并补齐单测。 +- 回滚点: + - 每条业务链路迁移完成必须附带测试用例;测试失败则回退到上一绿色基线。 +- 切换方式: + - 同阶段 2:兼容层保留,逐步替换内部调用指向新模块。 + +### 阶段 4:Routes 模块化(Blueprint,URL 不变) + +- 目标: + - 路由迁移到 Blueprint,保持 URL/方法/响应结构不变。 +- 回滚点: + - 建议“按领域路由模块”逐个迁移;任一 Blueprint 迁移失败可回退到上一绿色基线。 +- 切换方式: + - 迁移期允许保留旧路由实现(不对外暴露新 URL),通过“注册/不注册”切换新旧实现(仅内部开发阶段使用)。 + +### 阶段 5:前端静态资源拆分(零构建) + +- 目标: + - 从模板内联 CSS/JS 迁出到 `static/css` 与 `static/js`(零构建)。 + - 模板改为通过 `` 与 `` 引用静态资源(不强制使用 ESModule,避免大规模重写与行为回归)。 +- 回滚点: + - 若外链资源加载/缓存导致页面不可用,优先回退到上一绿色基线(Git 回退/恢复上一版本镜像/回滚发布包)。 +- 切换方式: + - 当前未引入运行开关;如需要“运行侧快速切换内联/外链”,可在后续阶段增加开关(例如 `FRONTEND_EXTERNAL_ASSETS=true|false`),但需同步补齐门禁与回归验证。 + +### 阶段 6:文档与维护指南 + +- 目标: + - README/开发者文档补齐模块职责、依赖方向、测试运行方式与迁移说明。 +- 回滚点: + - 文档变更不影响运行;但若文档与实现不一致,应以实现为准并尽快纠正文档。 diff --git a/docs/RUN/运行与定时刷新验证指南.md b/docs/RUN/运行与定时刷新验证指南.md new file mode 100644 index 0000000..cece03b --- /dev/null +++ b/docs/RUN/运行与定时刷新验证指南.md @@ -0,0 +1,70 @@ +# 运行与定时刷新验证指南 + +- 日期:2026-02-22 +- 目的:给出“官方推荐运行方式”与“定时刷新真实运行”的可验证口径(接口 + 指标)。 + +--- + +## 1) 官方推荐运行方式(建议) + +### Docker(推荐) + +使用仓库提供的 `Dockerfile`(内部使用 Gunicorn 启动,默认 `-w 1`,避免多进程重复启动调度器与 session 共享问题)。 + +关键点: +- 必须设置 `SECRET_KEY` +- 建议设置 `SCHEDULER_AUTOSTART=true`(默认即为 true),用于自动启动后台调度器(心跳 + 定时刷新) +- 持久化挂载 `./data:/app/data`,避免重启后丢数据 + +### 本地直接运行(开发/调试) + +```bash +pip install -r requirements.txt +export SECRET_KEY=xxx +python web_outlook_app.py +``` + +> 注意:`flask run` 的 reloader 可能导致重复启动;若使用,请确认 `SCHEDULER_AUTOSTART` 行为符合预期。 + +--- + +## 2) 如何验证“定时刷新真的在跑” + +### A. 快速健康检查(管理员) + +- 接口:`GET /api/system/health` +- 关注字段: + - `health.database`:数据库是否可用 + - `health.scheduler.enabled`:是否启用定时刷新 + - `health.scheduler.heartbeat_age_seconds`:心跳距离当前的秒数 + - `health.scheduler.healthy`:是否健康(默认阈值:`<= 120s`) + - `health.refresh.locked / running`:是否存在刷新锁/运行中刷新 + +### B. 调度器状态与“未来触发预览” + +- 接口:`GET /api/scheduler/status` +- 关注字段: + - `scheduler.enabled / use_cron`:是否启用、调度方式 + - `scheduler.future_runs / next_due`:未来触发时间预览 + - `scheduler.heartbeat_age_seconds`:调度器心跳年龄 + - `refresh.last_scheduled`:最近一次定时刷新(含触发来源、总数/成功/失败、trace_id) + - `refresh.running`:是否存在运行中的刷新(running 状态) + - `refresh.lock`:分布式锁状态(用于冲突控制) + +### C. 诊断信息(状态一致性/清理策略) + +- 接口:`GET /api/system/diagnostics` +- 关注字段: + - `export_verify_tokens_active`:当前有效导出二次验证 token 数量 + - `login_locked_ip_count`:当前被锁定的 IP 数量 + - `locks`:分布式锁列表 + - `running_runs / last_runs`:刷新运行记录 + - `schema`:数据库升级/版本状态(是否最新、最近一次迁移记录) + +### D. 升级过程可验证(数据兼容/升级保障) + +- 接口:`GET /api/system/upgrade-status` +- 关注字段: + - `schema_version / target_version / up_to_date`:当前版本与目标版本 + - `last_trace_id / last_error`:最近一次升级的追踪标识与错误信息(便于定位) + - `last_migration`:最近一次迁移记录(status/时间/trace_id) diff --git a/docs/TD/TD-可调整布局系统.md b/docs/TD/TD-可调整布局系统.md new file mode 100644 index 0000000..c0f5f40 --- /dev/null +++ b/docs/TD/TD-可调整布局系统.md @@ -0,0 +1,633 @@ +# TD - 可调整布局系统 + +## 文档信息 + +- **文档编号**: TD-00001 +- **创建日期**: 2026-02-26 +- **版本**: v1.0 +- **状态**: 草稿 +- **负责人**: 测试团队 +- **关联文档**: + - PRD-00001: PRD - 可调整布局系统 + - FD-00001: FD - 可调整布局系统 + - TDD-00001: TDD - 可调整布局系统 + +## 目录 + +1. [测试概述](#一测试概述) +2. [测试范围](#二测试范围) +3. [测试策略](#三测试策略) +4. [测试用例设计](#四测试用例设计) +5. [测试数据准备](#五测试数据准备) +6. [测试环境配置](#六测试环境配置) +7. [性能测试方案](#七性能测试方案) +8. [兼容性测试方案](#八兼容性测试方案) +9. [可访问性测试方案](#九可访问性测试方案) +10. [回归测试策略](#十回归测试策略) +11. [测试执行计划](#十一测试执行计划) +12. [缺陷管理流程](#十二缺陷管理流程) +13. [测试报告与度量](#十三测试报告与度量) + +## 一、测试概述 + +### 1.1 测试目标 + +验证可调整布局系统的功能完整性、性能指标、兼容性和可访问性,确保满足 PRD 中定义的所有需求和验收标准。 + +### 1.2 测试原则 + +1. **测试优先级**:P0 功能 100% 通过,P1 功能 ≥ 95% 通过,P2 功能 ≥ 90% 通过 +2. **测试覆盖率**:代码覆盖率 > 80%,功能覆盖率 100% +3. **自动化优先**:单元测试和集成测试 100% 自动化,系统测试 ≥ 70% 自动化 +4. **持续测试**:每次代码提交触发自动化测试,每日执行完整回归测试 + +### 1.3 测试类型 + +| 测试类型 | 覆盖范围 | 执行方式 | 执行频率 | +|---------|---------|---------|---------| +| 单元测试 | LayoutManager、StateManager 类方法 | 自动化(Jest) | 每次提交 | +| 集成测试 | 拖动、折叠、状态保存流程 | 自动化(Playwright) | 每次提交 | +| 系统测试 | 完整用户场景 | 自动化 + 手动 | 每日 | +| 性能测试 | 帧率、内存、加载时间 | 自动化(Lighthouse) | 每周 | +| 兼容性测试 | 4 个浏览器 × 功能模块 | 自动化(BrowserStack) | 每次发布前 | +| 可访问性测试 | WCAG 2.1 AA 合规性 | 自动化(axe-core) + 手动 | 每次发布前 | +| 回归测试 | 现有功能不受影响 | 自动化 + 手动 | 每次变更后 | + +### 1.4 验收标准 + +根据 PRD 定义的成功指标: + +- ✅ 用户可以在 3 秒内完成面板宽度调整 +- ✅ 用户可以在 1 秒内完成面板折叠/展开 +- ✅ 布局状态在页面刷新后 100% 恢复 +- ✅ 拖动调整时帧率保持在 60fps +- ✅ 所有 P0 功能测试用例 100% 通过 +- ✅ 所有 P1 功能测试用例 ≥ 95% 通过 +- ✅ 无 P0/P1 级别缺陷遗留 + +## 二、测试范围 + +### 2.1 功能模块测试范围 + +根据 FD 文档,测试范围包括 6 个主要功能模块: + +#### F1 - 拖动调整宽度(P0) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F1.1 - 鼠标拖动调整 | P0 | 拖动流畅度、宽度实时更新、视觉反馈 | +| F1.2 - 宽度限制 | P0 | 最小/最大宽度限制、邮件详情面板 400px 保证 | +| F1.3 - 视觉反馈 | P1 | hover 效果、拖动光标、高亮指示器 | +| F1.4 - 性能优化 | P0 | requestAnimationFrame、60fps 保证 | + +#### F2 - 面板折叠/展开(P0) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F2.1 - 折叠按钮 | P0 | 点击折叠、点击展开、按钮状态更新 | +| F2.2 - 折叠动画 | P1 | 200ms 过渡、平滑动画、无卡顿 | +| F2.3 - 折叠状态指示 | P1 | 细条显示、竖排文字、点击展开 | +| F2.4 - 折叠规则 | P0 | 至少一个面板展开、邮件详情不可折叠 | +| F2.5 - ARIA 属性更新 | P1 | aria-expanded 更新、aria-label 更新 | +| F2.6 - 折叠时隐藏 resizer | P0 | resizer 不可见、不可交互 | + +#### F3 - 恢复默认布局(P1) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F3.1 - 恢复按钮 | P1 | 按钮位置、tooltip 显示 | +| F3.2 - 确认对话框 | P1 | 对话框内容、取消/确定按钮 | +| F3.3 - 恢复逻辑 | P1 | 宽度恢复、折叠状态恢复、localStorage 清除 | +| F3.4 - 恢复动画 | P2 | 300ms 过渡、平滑动画 | + +#### F4 - 状态持久化(P0) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F4.1 - 保存面板宽度 | P0 | 拖动后保存、防抖 500ms | +| F4.2 - 保存折叠状态 | P0 | 折叠后立即保存 | +| F4.3 - 加载状态 | P0 | 页面加载时恢复、数据验证 | +| F4.4 - 多用户隔离 | P1 | 不同 userId 独立存储 | +| F4.5 - 页面卸载强制保存 | P0 | beforeunload 触发保存 | + +#### F5 - 窗口尺寸适配(P1) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F5.1 - 断点自动折叠 | P1 | <1200px、<900px、<700px 自动折叠 | +| F5.2 - 窗口放大恢复 | P1 | 自动折叠的面板自动恢复 | +| F5.3 - 防抖优化 | P2 | 200ms 防抖、避免频繁触发 | + +#### F6 - 键盘可访问性(P1) + +| 子功能 | 优先级 | 测试点 | +|--------|--------|--------| +| F6.1 - Tab 聚焦 | P1 | resizer 可聚焦、折叠按钮可聚焦 | +| F6.2 - 方向键调整宽度 | P1 | ←/→ 调整、Shift+←/→ 快速调整 | +| F6.3 - Enter/Sp发 | P1 | 折叠按钮触发 | +| F6.4 - ARIA 属性 | P1 | role、aria-label、aria-valuenow 等 | + +### 2.2 非功能测试范围 + +#### 性能测试 + +- 拖动帧率 ≥ 60fps +- 折叠/展开动画流畅度 +- localStorage 数据 ≤ 1KB +- 页面加载时间增加 ≤ 50ms + +#### 兼容性测试 + +- Chrome 90+ +- Firefox 88+ +- Safari 14+ +- Edge 90+ + +#### 可访问性测试 + +- WCAG 2.1 AA 级别合规 +- 键盘导航完整性 +- 屏幕阅读器支持 + +#### 安全性测试 + +- localStorage 数据不包含敏感信息 +- XSS 防护验证 + +### 2.3 不在测试范围内 + +- 移动端适配(后续版本) +- 拖动排序功能(后续版本) +- 多套布局方案(后续版本) +- 布局分享功能(后续版本) + +## 三、测试策略 + +### 3.1 单元测试策略 + +**测试框架**: Jest + +**覆盖目标**: 代码覆盖率 > 80% + +**测试对象**: +- LayoutManager 类的所有公共方法 +- StateManager 类的所有公共方法 +- 核心算法(拖动宽度计算、防抖、状态序列化) + +**测试方法**: +- 正向测试:正常输入,验证正确输出 +- 负向测试:异常输入,验证错误处理 +- 边界测试:临界值输入,验证边界处理 + +**Mock 策略**: +- Mock localStorage API +- Mock DOM 元素 +- Mock 事件对象 + +### 3.2 集成测试策略 + +**测试框架**: Playwright + +**测试对象**: +- 拖动调整宽度的完整流程 +- 折叠/展开的完整流程 +- 状态保存和加载的完整流程 +- 窗口调整的完整流程 + +**测试方法**: +- 端到端测试:模拟真实用户操作 +- 流程测试:验证多个功能的协作 +- 数据驱动测试:使用不同的测试数据 + +### 3.3 系统测试策略 + +**测试方法**: 自动化 + 手动 + +**测试场景**: +- 用户首次访问(默认布局) +- 用户调整布局后刷新(状态恢复) +- 用户在不同设备间切换(多用户隔离) +- 用户调整窗口大小(自动适配) +- 用户使用键盘操作(可访问性) + +### 3.4 探索性测试策略 + +**测试时间**: 每个功能模块 2 小时 + +**测试重点**: +- 异常操作路径 +- 极端场景 +- 用户体验问题 +- 未预期的交互 + + +## 四、测试用例设计 + +### 4.1 测试用例编号规则 + +**格式**: TC-[模块]-[序号]-[类型] + +- **模块**: F1~F6(对应功能模块) +- **序号**: 001~999(三位数字) +- **类型**: P(正向)、N(负向)、B(边界)、PF(性能) + +**示例**: TC-F1-001-P(F1 模块第 1 个正向用例) + +### 4.2 F1 - 拖动调整宽度测试用例 + +#### TC-F1-001-P: 拖动分组面板到中间宽度 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载,分组面板宽度为默认 200px | +| **测试步骤** | 1. 鼠标悬停在分组面板右侧 resizer
2. 按下鼠标左键
3. 向右拖动 50px
4. 释放鼠标 | +| **预期结果** | 1. resizer 显示 hover 效果(背景色变化)
2. 光标变为 col-resize
3. 面板宽度实时更新为 250px
4. 拖动流畅,无卡顿 | +| **关联需求** | F1.1, F1.3 | + +#### TC-F1-002-B: 拖动分组面板到最小宽度 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载,分组面板宽度为默认 200px | +| **测试步骤** | 1. 鼠标按下 resizer
2. 向左拖动 100px(超过最小宽度限制)
3. 释放鼠标 | +| **预期结果** | 面板宽度停止在最小宽度 150px,不会继续缩小 | +| **关联需求** | F1.2 | + +#### TC-F1-003-B: 拖动分组面板到最大宽度 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载,分组面板宽度为默认 200px | +| **测试步骤** | 1. 鼠标按下 resizer
2. 向右拖动 300px(超过最大宽度限制)
3. 释放鼠标 | +| **预期结果** | 面板宽度停止在最大宽度 400px,不会继续增大 | +| **关联需求** | F1.2 | + +#### TC-F1-004-B: 拖动时确保邮件详情面板至少 400px + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 窗口宽度 1200px,所有面板展开 | +| **测试步骤** | 1. 拖动分组面板到最大宽度 400px
2. 拖动账号面板到最大宽度 500px
3. 尝试拖动邮件列表面板到最大宽度 600px | +| **预期结果** | 邮件列表面板宽度被限制,确保邮件详情面板至少保持 400px | +| **关联需求** | F1.2 | + +#### TC-F1-005-N: 快速拖动导致鼠标移出窗口 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 鼠标按下 resizer
2. 快速向右拖动,鼠标移出浏览器窗口
3. 在窗口外释放鼠标
4. 鼠标移回窗口 | +| **预期结果** | 1. 拖动正常结束
2. 光标恢复正常
3. 状态已保存 | +| **关联需求** | F1.1 | + +#### TC-F1-006-PF: 拖动帧率测试 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 使用 Performance API 监听帧率
2. 连续拖动 1 秒
3. 计算平均帧率 | +| **预期结果** | 平均帧率 ≥ 60fps(允许 5fps 误差) | +| **关联需求** | F1.4 | + +### 4.3 F2 - 面板折叠/展开测试用例 + +#### TC-F2-001-P: 点击折叠按钮折叠面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 分组面板处于展开状态 | +| **测试步骤** | 1. 点击分组面板右上角的折叠按钮 | +| **预期结果** | 1. 面板宽度变为 0
2. 面板内容不可见
3. 显示折叠指示条(48px 宽)
4. 折叠动画流畅(200ms)
5. 按钮图标变为 →
6. aria-expanded 更新为 false | +| **关联需求** | F2.1, F2.2, F2.3, F2.5 | + +#### TC-F2-002-P: 点击折叠指示条展开面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 分组面板处于折叠状态 | +| **测试步骤** | 1. 点击折叠指示条 | +| **预期结果** | 1. 面板恢复到折叠前的宽度
2. 面板内容可见
3. 折叠指示条消失
4. 展开动画流畅(200ms)
5. 按钮图标变为 ←
6. aria-expanded 更新为 true | +| **关联需求** | F2.1, F2.2, F2.3, F2.5 | + +#### TC-F2-003-N: 尝试折叠所有面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 只有分组面板展开,其他面板已折叠 | +| **测试步骤** | 1. 点击分组面板的折叠按钮 | +| **预期结果** | 1. 显示提示信息:"至少需要保持一个面板展开"
2. 分组面板保持展开状态 | +| **关联需求** | F2.4 | + +#### TC-F2-004-P: 折叠后 resizer 不可见 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 分组面板处于展开状态 | +| **测试步骤** | 1. 折叠分组面板
2. 检查 resizer 元素 | +| **预期结果** | 1. resizer 不可见(display: none)
2. resizer 不可交互(pointer-events: none) | +| **关联需求** | F2.6 | + +#### TC-F2-005-N: 折叠状态下尝试拖动 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 分组面板处于折叠状态 | +| **测试步骤** | 1. 尝试在折叠指示条位置拖动 | +| **预期结果** | 无法拖动,必须先展开面板 | +| **关联需求** | F2.6 | + +### 4.4 F3 - 恢复默认布局测试用例 + +#### TC-F3-001-P: 恢复默认布局 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 用户已调整布局(宽度和折叠状态) | +| **测试步骤** | 1. 点击顶部导航栏的"恢复默认布局"按钮
2. 在确认对话框中点击"确定" | +| **预期结果** | 1. 分组面板宽度恢复为 200px
2. 账号面板宽度恢复为 260px
3. 邮件列表面板宽度恢复为 380px
4. 所有面板展开
5. localStorage 中的状态被清除
6. 恢复动画流畅(300ms) | +| **关联需求** | F3.1, F3.2, F3.3, F3.4 | + +#### TC-F3-002-N: 取消恢复默认布局 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 用户已调整布局 | +| **测试步骤** | 1. 点击"恢复默认布局"按钮
2. 在确认对话框中点击"取消" | +| **预期结果** | 1. 对话框关闭
2. 布局保持当前状态不变 | +| **关联需求** | F3.2 | + +### 4.5 F4 - 状态持久化测试用例 + +#### TC-F4-001-P: 拖动后保存状态 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 拖动分组面板到 250px
2. 等待 500ms(防抖时间)
3. 检查 localStorage | +| **预期结果** | localStorage 中保值 "250px" | +| **关联需求** | F4.1 | + +#### TC-F4-002-P: 折叠后立即保存状态 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 折叠分组面板
2. 立即检查 localStorage(无需等待) | +| **预期结果** | localStorage 中保存了折叠状态 collapsed: true | +| **关联需求** | F4.2 | + +#### TC-F4-003-P: 页面刷新后恢复状态 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 用户已调整布局并保存 | +| **测试步骤** | 1. 刷新页面(F5)
2. 等待页面加载完成 | +| **预期结果** | 1. 面板宽度恢复到调整后的值
2. 折叠状态恢复
3. 恢复过程无闪烁 | +| **关联需求** | F4.3 | + +#### TC-F4-004-P: 多用户布局隔离 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 系统支持多用户 | +| **测试步骤** | 1. 用户 A 登录,调整布局
2. 用户 A 退出
3. 用户 B 登录
4. 检查布局状态 | +| **预期结果** | 用户 B 看到的是默认布局,不受用户 A 的影响 | +| **关联需求** | F4.4 | + +#### TC-F4-005-P: 页面卸载时强制保存 + +| 项目 | 内容 | +|------|------| +| **优先级** | P0 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 拖动面板
2. 立即关闭浏览器标签页(不等待防抖)
3. 重新打开页面 | +| **预期结果** | 布局状态已保存,最后一次调整未丢失 | +| **关联需求** | F4.5 | + +#### TC-F4-006-N: localStorage 不可用 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 浏览器禁用 localStorage 或隐私模式 | +| **测试步骤** | 1. 加载页面
2. 调整布局
3. 刷新页面 | +| **预期结果** | 1. 页面正常加载,使用默认布局
2. 调整布局功能正常
3. 刷新后恢复默认布局(无法持久化)
4. 控制台显示警告n| **关联需求** | F4.3 | + +#### TC-F4-007-N: localStorage 数据损坏 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | localStorage 中存在损坏的数据 | +| **测试步骤** | 1. 手动修改 localStorage 数据为无效 JSON
2. 刷新页面 | +| **预期结果** | 1. 页面正常加载
2. 使用默认布局
3. 损坏的数据被清除
4. 控制台显示错误信息 | +| **关联需求** | F4.3 | + +### 4.6 F5 - 窗口尺寸适配测试用例 + +#### TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 窗口宽度 > 1200px,所有面板展开 | +| **测试步骤** | 1. 调整浏览器窗口宽度到 1100px
2. 等待 200ms(防抖时间) | +| **预期结果** | 1. 分组面板自动折叠
2. 面板标记为 autoCollapsed
3. 其他面板保持展开 | +| **关联需求** | F5.1 | + +#### TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 窗口宽度 > 900px | +| **测试步骤** | 1. 调整浏览器窗口宽度到 800px
2. 等待 200ms | +| **预期结果** | 1. 分组面板和账号面板自动折叠
2. 邮件列表和详情面板保持展开 | +| **关联需求** | F5.1 | + +#### TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 窗口宽度 > 700px | +| **测试步骤** | 1. 调整浏览器窗口宽度到 600px
2. 等待 200ms | +| **预期结果** | 1. 所有侧边栏面板自动折叠
2. 只显示邮件详情面板 | +| **关联需求** | F5.1 | + +#### TC-F5-004-P: 窗口放大后自动恢复面板 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 窗口宽度 < 1200px,分组面板自动折叠 | +| **测试步骤** | 1. 调整浏览器窗口宽度到 1300px
2. 等待 200ms | +| **预期结果** | 1. 分组面板自动展开
2. autoCd 标记被移除 | +| **关联需求** | F5.2 | + +#### TC-F5-005-N: 用户手动折叠的面板不自动恢复 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 窗口宽度 > 1200px | +| **测试步骤** | 1. 用户手动折叠分组面板
2. 调整窗口宽度到 1100px 再恢复到 1300px | +| **预期结果** | 分组面板保持折叠状态(因为是用户手动折叠) | +| **关联需求** | F5.2 | + +### 4.7 F6 - 键盘可访问性测试用例 + +#### TC-F6-001-P: Tab 键聚焦到 resizer + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 按 Tab 键多次,直到聚焦到分组面板的 resizer | +| **预期结果** | 1. resizer 获得焦点
2. 显示明显的视觉反馈(outline 或高亮边框) | +| **关联需求** | F6.1 | + +#### TC-F6-002-P: 方向键调整宽度 + +| 项目 | 内容 | +|---------| +| **优先级** | P1 | +| **前置条件** | resizer 已聚焦,面板宽度 200px | +| **测试步骤** | 1. 按右箭头键 5 次
2. 按左箭头键 3 次 | +| **预期结果** | 1. 每次按右箭头,宽度增加 10px(最终 250px)
2. 每次按左箭头,宽度减少 10px(最终 220px)
3. aria-valuenow 实时更新 | +| **关联需求** | F6.2, F6.4 | + +#### TC-F6-003-P: Shift + 方向键快速调整 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | resizer 已聚焦,面板宽度 200px | +| **测试步骤** | 1. 按 Shift + 右箭头键 2 次 | +| **预期结果** | 每次按键宽度增加 50px(最终 300px) | +| **关联需求** | F6.2 | + +#### TC-F6-004-P: Enter/Space 触发折叠按钮 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 折叠按钮已聚焦 | +| **测试步骤** | 1. 按 Enter 键 | +| **预期结果** | 面板折叠 | +| **关联需求** | ### TC-F6-005-P: ARIA 属性完整性 + +| 项目 | 内容 | +|------|------| +| **优先级** | P1 | +| **前置条件** | 页面已加载 | +| **测试步骤** | 1. 检查 resizer 元素的 ARIA 属性 | +| **预期结果** | 1. role="separator"
2. aria-orientation="vertical"
3. aria-label="调整[面板名称]宽度"
4. aria-valuenow、aria-valuemin、aria-valuemax 正确设置 | +| **关联需求** | F6.4 | + + +## 九、可访问性测试方案 + +### 9.1 WCAG 2.1 AA 合规性测试 + +使用 axe-core 自动检测可访问性问题。 + +### 9.2 键盘导航测试 + +- Tab 键可以聚焦到所有交互元素 +- 方向键可以调整面板宽度 +- Enter/Space 可以触发折叠按钮 +- 所有操作可通过键盘完成 + +### 9.3 屏幕阅读器测试 + +使用 NVDA/JAWS 测试 ARIA 属性是否正确朗读。 + +## 十、回归测试策略 + +### 10.1 回归测试范围 + +确保布局系统不影响现有功能: +- 邮件列表显示和滚动 +- 邮件详情查看 +- 账号切换 +- 分组管理 +- Token 刷新 +- 临时邮箱 + +### 10.2 回归测试执行 + +每次代码变更后执行完整回归测试。 + +## 十一、测试执行计划 + +### 11.1 测试阶段 + +1. **阶段 1 - 单元测试**(开发完成后) +2. **阶段 2 - 集成测试**(功能集成后) +3. **阶段 3 - 系统测试**(所有功能完成后) +4. **阶段 4 - 验收测试**(测试环境部署后) + +### 11.2 测试时间安排 + +- 单元测试:2 天 +- 集成测试:3 天 +- 系统测试:3 天 +- 性能测试:1 天 +- 兼容性测试:2 天 +- 可访问性测试:1 天 +- 回归测试:2 天 +- **总计**:14 天 + +## 十二、缺陷管理流程 + +### 12.1 缺陷优先级 + +- **P0(阻塞)**:核心功能无法使用,必须立即修复 +- **P1(严重)**:重要功能异常,当天修复 +- **P2(一般)**:次要功能问题,3 天内修复 +- **P3(轻微)**:UI 问题或优化建议,可延后 + +### 12.2 缺陷跟踪 + +使用 GitHub Issues 跟踪所有缺陷。 + +## 十三、测试报告与度量 + +### 13.1 测试报告内容 + +1. 测试执行摘要 +2. 功能模块测试结果 +3. 缺陷分布统计 +4. 性能测试结果 +5. 兼容性测试结果 +6. 可访问性测试结果 +7. 风险评估和建议 + +### 13.2 度量指标 + +- 测试覆盖率:代码覆盖率 > 80%,功能覆盖率 100% +- 缺陷密度:< 5 个/KLOC +- 缺陷修复率:P0/P1 100%,P2 > 95% +- 回归通过率:> 98% + +--- + +**文档结束** diff --git a/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md b/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md new file mode 100644 index 0000000..298c414 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md @@ -0,0 +1,1033 @@ +# Outlook 邮件管理工具|Legacy 迁移到 Controllers 层 TDD(技术设计细节) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-24 +- 对齐 PRD:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md` +- 对齐开发指南:`docs/DEV/00002-前后端拆分-开发者指南.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述"为满足 PRD/FD,Legacy 迁移到 Controllers 层需要采用的技术设计细节"。 + +重点回答: + +- 如何设计 Controllers 层的标准模式和职责边界 +- 如何从 `impl=legacy` 模式迁移到直接导入 controller 模式 +- 如何保证 **URL/响应契约/错误结构/trace_id** 不回归 +- 如何实现分阶段迁移(每阶段可回滚、可验证、可测试) +- 如何避免循环依赖和全局状态问题 +- 如何设计测试策略(单元测试、集成测试) + +--- + +## 2. 设计原则与硬约束(必须满足) + +### 2.1 行为兼容性(对用户无感) + +- 保持现有 API 路径/方法/参数语义/响应结构不变 +- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变 +- 保持登录/会话/CSRF 行为不变 +- 保持中间件和错误处理器行为不变 + +### 2.2 渐进式迁移(可回滚、可验证) + +- 任何阶段都必须可运行且可回归:至少通过 `python -m unittest discover -s tests -v` 与关键接口抽样验收 +- 每个阶段完成后提交 Git,便于回滚 +- 保留 legacy.py 直到所有模块迁移完成 + +### 2.3 不引入新复杂度 + +- 不引入新的框架或库 +- 不改变现有的技术栈(Flask + SQLite) +- 不引入复杂的依赖注入框架 +- 保持代码简单、直接、易于理解 + +### 2.4 性能要求 + +- 响应时间不超过迁移前的 110% +- 内存使用无明显增加 +- 启动时间无明显增加 + +--- + +## 3. 总体架构设计 + +### 3.1 四层架构 + +采用清晰的分层架构,职责分离: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Routes 层(路由注册) │ +│ - URL 到函数的映射 │ +│ - Blueprint 创建和注册 │ +│ - 不包含业务逻辑 │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌────────────────────────────────────────────────┐ +│ Controllers 层(请求处理)【新增】 │ +│ - 参数解析和验证 │ +│ - 鉴权检查(@login_required) │ +│ - 调用 Services/Repositories │ +│ - 响应封装(jsonify) │ +│ - 错误处理(try-except) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Services 层(业务逻辑)【已有】 │ +│ - 业务流程编排 │ +│ - 多后端回退(Graph API → IMAP) │ +│ - 不依赖 Flask request/response │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Repositories 层(数据访问)【已有】 │ +│ - SQL 查询和数据映射 │ +│ - 不包含业务逻辑 │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ Database(SQLite) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 3.2 依赖方向约束(必须遵守) + +``` +routes → controllers → services → repositories → db +``` + +**禁止反向依赖:** +- repositories 不能依赖 services +- services 不能依赖 controllers +- controllers 不能依赖 routes + +**横切关注点(可被任何层依赖):** +- `config` - 配置管理 +- `errors` - 错误模型 +- `security` - 安全模块 +- `audit` - 审计日志 +- `db` - 数据库连接 + +### 3.3 当前架构 vs 目标架构 + +**当前架构(迁移前):** +``` +routes/ (Blueprint) → legacy.py (5757行) → services/ → repositories/ + ↓ + 包含所有路由处理逻辑 +``` + +**目标架构(迁移后):** +``` +routes/ (Blueprint) → controllers/ → services/ → repositories/ + ↓ + 职责清晰的请求处理层 +``` + +--- + +## 4. Controllers 层技术设计 + +### 4.1 目录结构 + +``` +outlook_web/ +├── controllers/ +│ ├── __init__.py # 模块导出 +│ ├── accounts.py # 账号管理 Controller(20 个路由) +│ ├── emails.py # 邮件操作 Controller(4 个路由) +│ ├── groups.py # 分组管理 Controller(6 个路由) +│ ├── tags.py # 标签管理 Controller(4 个路由) +│ ├── temp_emails.py # 临时邮箱 Controller(3 个路由) +│ ├── oauth.py # OAuth 授权 Controller(2 个路由) +│ ├── settings.py # 系统设置 Controller(3 个路由) +│ ├── scheduler.py # 定时任务 Controller(1 个路由) +│ ├── system.py # 系统信息 Controller(3 个路由) +│ ├── audit.py # 审计日志 Controller(1 个路由) +│ └── pages.py # 页面路由 Controller(3 个路由) +``` + +### 4.2 Controller 职责边界 + +**必须做的事情:** +- ✅ 参数解析(从 request 中提取参数) +- ✅ 参数验证(基本验证,如非空检查) +- ✅ 鉴权检查(使用 @login_required 装饰器) +- ✅ 调用 services 或 repositories +- ✅ 响应封装(使用 jsonify) +- ✅ 错误处理(try-except,返回标准错误格式) + +**不应该做的事情:** +- ❌ 直接操作数据库(应调用 repositories) +- ❌ 复杂的业务逻辑(应调用 services) +- ❌ 直接调用第三方 API(应通过 services) +- ❌ 数据加密/解密(应使用 security 模块) +- ❌ 依赖 Flask 全局对象传递到下层(request, g, session 只在 controller 中使用) + +### 4.3 Controller 标准模式 + +#### 4.3.1 简单 CRUD Controller + +```python +# outlook_web/controllers/groups.py +from flask import request, jsonify +from outlook_web.security.auth import login_required +from outlook_web.repositories import groups as groups_repo +from outlook_web.errors import build_error_payload + +@login_required +def api_get_groups(): + """获取所有分组""" + try: + # 1. 调用 repository + groups = groups_repo.get_all_groups() + + # 2. 返回响应 + return jsonify(groups) + except Exception as e: + # 3. 错误处理 + return jsonify(build_error_payload(str(e))), 500 + +@login_required +def api_add_group(): + """添加分组""" + try: + # 1. 参数解析 + data = request.get_json() + name = data.get('name', '').strip() + + # 2. 参数验证 + if not name: + return jsonify(build_error_payload('分组名称不能为空')), 400 + + # 3. 调用 repository + group_id = groups_repo.create_group(name) + + # 4. 返回响应 + return jsonify({'id': group_id, 'name': name}) + except Exception as e: + # 5. 错误处理 + return jsonify(build_error_payload(str(e))), 500 +``` + +#### 4.3.2 复杂业务逻辑 Controller + +```python +# outlook_web/controllers/emails.py +from flask import request, jsonify +from outlook_web.security.auth import login_required +from outlook_web.services import graph as graph_service +from outlook_web.services import imap as imap_service +from outlook_web.repositories import accounts as accounts_repo +from outlook_web.errors import build_error_payload + +@login_required +def api_get_emails(email_addr: str): + """获取邮件列表(支持 Graph API 和 IMAP 回退)""" + try: + # 1. 获取账号信息 + account = accounts_repo.get_account_by_email(email_addr) + if not account: + return jsonify(build_error_payload('账号不存在')), 404 + + # 2. 参数解析 + folder = request.args.get('folder', 'inbox') + skip = request.args.get('skip', 0, type=int) + top = request.args.get('top', 20, type=int) + + # 3. 调用 service(业务逻辑在 service 中) + result = graph_service.get_emails( + client_id=account['client_id'], + refresh_token=account['refresh_token'], + folder=folder, + skip=skip, + top=top, + proxy_url=account.get('proxy_url') + ) + + # 4. 如果 Graph API 失败,回退到 IMAP(回退逻辑在 service 中) + if not result.get('success'): + result = imap_service.get_emails( + account=email_addr, + client_id=account['client_id'], + refresh_token=account['refresh_token'], + folder=folder, + skip=skip, + top=top + ) + + # 5. 返回响应 + return jsonify(result) + except Exception as e: + # 6. 错误处理 + return jsonify(build_error_payload(str(e))), 500 +``` + +### 4.4 是否需要 Controller 基类? + +**决策:不需要** + +**理由:** +1. Flask 本身不需要基类,使用装饰器和工具函数更简单 +2. Python 的鸭子类型不需要强制继承 +3. 统一的错误处理可以通过全局错误处理器实现 +4. 统一的响应格式可以通过工具函数实现 +5. 避免增加不必要的复杂度 + +**替代方案:** +- 使用装饰器(@login_required) +- 使用工具函数(build_error_payload, jsonify) +- 使用全局错误处理器(app.register_error_handler) + +--- + +## 5. Routes 层技术设计 + +### 5.1 Blueprint 创建模式 + +#### 5.1.1 迁移前(使用 impl 参数) + +```python +# outlook_web/routes/groups.py +from flask import Blueprint + +def create_blueprint(*, impl) -> Blueprint: + """ + 创建 groups Blueprint + + Args: + impl: 实现模块(legacy.py) + """ + bp = Blueprint("groups", __name__) + + # 路由注册(使用 impl 的函数) + bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) + bp.add_url_rule("/api/groups", view_func=impl.api_add_group, methods=["POST"]) + bp.add_url_rule("/api/groups/", view_func=impl.api_update_group, methods=["PUT"]) + bp.add_url_rule("/api/groups/", view_func=impl.api_delete_group, methods=["DELETE"]) + + return bp +``` + +#### 5.1.2 迁移后(直接导入 controller) + +```python +# outlook_web/routes/groups.py +from flask import Blueprint +from outlook_web.controllers import groups as groups_controller + +def create_blueprint() -> Blueprint: + """创建 groups Blueprint""" + bp = Blueprint("groups", __name__) + + # 路由注册(直接使用 controller 的函数) + bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) + bp.add_url_rule("/api/groups", view_func=groups_controller.api_add_group, methods=["POST"]) + bp.add_url_rule("/api/groups/", view_func=groups_controller.api_update_group, methods=["PUT"]) + bp.add_url_rule("/api/groups/", view_func=groups_controller.api_delete_group, methods=["DELETE"]) + + return bp +``` + +### 5.2 必须保持不变 + +- ✅ URL 路径不变 +- ✅ HTTP 方法不变 +- ✅ 路由注册顺序不变 +- ✅ Blueprint 名称不变 +- ✅ URL 参数(如 ``)不变 + +--- + +(文档未完,待续...) +## 6. App.py 应用工厂设计 + +### 6.1 Blueprint 注册模式 + +#### 6.1.1 迁移前 + +```python +# outlook_web/app.py +from outlook_web import legacy +from outlook_web.routes import groups, tags, accounts, emails + +def create_app(): + app = Flask(__name__) + + # ... 其他初始化代码 ... + + # Blueprint 注册(使用 impl=legacy) + app.register_blueprint(groups.create_blueprint(impl=legacy)) + app.register_blueprint(tags.create_blueprint(impl=legacy)) + app.register_blueprint(accounts.create_blueprint(impl=legacy)) + app.register_blueprint(emails.create_blueprint(impl=legacy)) + + return app +``` + +#### 6.1.2 迁移后 + +```python +# outlook_web/app.py +from outlook_web.routes import groups, tags, accounts, emails + +def create_app(): + app = Flask(__name__) + + # ... 其他初始化代码 ... + + # Blueprint 注册(不再需要 impl 参数) + app.register_blueprint(groups.create_blueprint()) + app.register_blueprint(tags.create_blueprint()) + app.register_blueprint(accounts.create_blueprint()) + app.register_blueprint(emails.create_blueprint()) + + return app +``` + +### 6.2 必须保持不变 + +- ✅ Blueprint 注册顺序不变 +- ✅ 中间件注册不变(before_request, after_request) +- ✅ 错误处理器注册不变(register_error_handler) +- ✅ 应用配置不变(secret_key, session 配置等) + +--- + +## 7. 数据流设计 + +### 7.1 请求处理流程 + +``` +HTTP Request + ↓ +Flask WSGI + ↓ +Middleware: ensure_trace_id() + ↓ +Routes: Blueprint 路由匹配 + ↓ +Controller: + - 参数解析(request.args, request.get_json()) + - 参数验证(非空检查、类型检查) + - 鉴权检查(@login_required) + ↓ +Service/Repository: + - 业务逻辑处理 + - 数据库操作 + ↓ +Controller: + - 响应封装(jsonify) + - 错误处理(try-except) + ↓ +Middleware: attach_trace_id_and_normalize_errors() + ↓ +Flask WSGI + ↓ +HTTP Response +``` + +### 7.2 错误处理流程 + +``` +Exception (任何层抛出) + ↓ +Controller: try-except 捕获 + ↓ +build_error_payload(error_message) + ↓ +jsonify(error_payload) + HTTP 状态码 + ↓ +Middleware: attach_trace_id_and_normalize_errors() + ↓ +统一错误响应格式: +{ + "success": false, + "error": "错误信息", + "trace_id": "xxx-xxx-xxx" +} +``` + +### 7.3 数据流示例:获取分组列表 + +``` +1. HTTP GET /api/groups +2. Flask 路由匹配 → groups_controller.api_get_groups +3. Controller: + - @login_required 检查登录状态 + - 调用 groups_repo.get_all_groups() +4. Repository: + - 执行 SQL: SELECT * FROM groups + - 返回 List[Dict] +5. Controller: + - jsonify(groups) + - 返回 HTTP 200 +6. Middleware: + - 添加 trace_id 到响应头 +7. HTTP Response: + [ + {"id": 1, "name": "分组1"}, + {"id": 2, "name": "分组2"} + ] +``` + +--- + +## 8. 迁移策略技术设计 + +### 8.1 分阶段迁移技术方案 + +#### 8.1.1 阶段 1:基础模块(groups, tags, settings, system, audit, pages) + +**技术步骤:** + +1. **创建 Controller 文件** + ```bash + touch outlook_web/controllers/groups.py + ``` + +2. **从 legacy.py 提取函数** + - 找到 legacy.py 中的 `api_get_groups`, `api_add_group` 等函数 + - 复制到 `controllers/groups.py` + +3. **调整导入语句** + ```python + # 迁移前(在 legacy.py 中) + from outlook_web.repositories import groups as _groups_repo + + # 迁移后(在 controllers/groups.py 中) + from outlook_web.repositories import groups as groups_repo + ``` + +4. **更新 routes/groups.py** + ```python + # 迁移前 + def create_blueprint(*, impl) -> Blueprint: + bp.add_url_rule("/api/groups", view_func=impl.api_get_groups, methods=["GET"]) + + # 迁移后 + from outlook_web.controllers import groups as groups_controller + + def create_blueprint() -> Blueprint: + bp.add_url_rule("/api/groups", view_func=groups_controller.api_get_groups, methods=["GET"]) + ``` + +5. **更新 app.py** + ```python + # 迁移前 + app.register_blueprint(groups.create_blueprint(impl=legacy)) + + # 迁移后 + app.register_blueprint(groups.create_blueprint()) + ``` + +6. **运行测试** + ```bash + python -m unittest discover -s tests -v + ``` + +7. **手动验证** + ```bash + curl -X GET http://localhost:5000/api/groups + ``` + +8. **提交 Git** + ```bash + git add outlook_web/controllers/groups.py outlook_web/routes/groups.py outlook_web/app.py + git commit -m "feat: 迁移 groups 模块到 controllers 层" + ``` + +#### 8.1.2 阶段 2 和 3:重复相同步骤 + +对于 temp_emails, oauth, scheduler, emails, accounts 模块,重复上述步骤。 + +**注意事项:** +- emails 和 accounts 模块更复杂,需要更仔细的测试 +- 确保回退机制(Graph API → IMAP)仍然正常工作 +- 确保数据脱敏仍然正常工作 + +### 8.2 兼容性保证机制 + +#### 8.2.1 函数签名保持不变 + +```python +# 迁移前(legacy.py) +@login_required +def api_get_groups(): + """获取所有分组""" + # ... + +# 迁移后(controllers/groups.py) +@login_required +def api_get_groups(): + """获取所有分组""" + # ... 完全相同的签名 +``` + +#### 8.2.2 装饰器保持不变 + +```python +# 迁移前后都使用相同的装饰器 +@login_required +def api_xxx(): + pass +``` + +#### 8.2.3 错误处理保持不变 + +```python +# 迁移前后都使用相同的错误处理 +try: + # ... +except Exception as e: + return jsonify(build_error_payload(str(e))), 500 +``` + +#### 8.2.4 响应格式保持不变 + +```python +# 迁移前后都使用相同的响应格式 +return jsonify(data) # 成功响应 +return jsonify(build_error_payload(msg)), 400 # 错误响应 +``` + +### 8.3 回滚机制 + +#### 8.3.1 Git 回滚 + +```bash +# 查看提交历史 +git log --oneline + +# 回滚到指定提交 +git revert + +# 或者硬回滚(慎用) +git reset --hard +``` + +#### 8.3.2 临时回滚(保留 legacy.py) + +如果迁移后发现问题,可以临时恢复 `impl=legacy`: + +```python +# 临时回滚到 legacy +from outlook_web import legacy +app.register_blueprint(groups.create_blueprint(impl=legacy)) +``` + +**注意:** 这要求 routes/groups.py 仍然支持 `impl` 参数,所以在迁移完成前不要删除这个参数。 + +#### 8.3.3 分支策略 + +```bash +# 在独立分支进行迁移 +git checkout -b feature/migrate-groups-to-controllers + +# 迁移完成后合并 +git checkout main +git merge feature/migrate-groups-to-controllers + +# 如果出现问题,可以快速切换回 main 分支 +git checkout main +``` + +--- + +## 9. 依赖管理设计 + +### 9.1 避免循环依赖 + +#### 9.1.1 依赖方向规则 + +``` +routes → controllers → services → repositories → db +``` + +**禁止的依赖:** +- ❌ repositories → services +- ❌ services → controllers +- ❌ controllers → routes + +#### 9.1.2 循环依赖检测 + +如果出现循环依赖,Python 会在导入时报错: + +```python +ImportError: cannot import name 'xxx' from partially initialized module 'yyy' +``` + +**解决方案:** +1. 检查导入语句,确保依赖方向正确 +2. 如果必须反向引用,使用延迟导入(在函数内部导入) + +### 9.2 全局状态管理 + +#### 9.2.1 Flask 全局对象 + +Flask 提供的全局对象: +- `request` - 当前请求对象 +- `g` - 请求级别的全局变量 +- `session` - 会话对象 +- `current_app` - 当前应用对象 + +**使用规则:** +- ✅ 在 controllers 中使用 +- ❌ 不要传递到 services 或 repositories +- ❌ services 和 repositories 应该是纯函数,不依赖 Flask 全局对象 + +--- + +## 10. 测试策略设计 + +### 10.1 单元测试设计 + +使用 unittest.mock.patch Mock 依赖。 + +### 10.2 集成测试设计 + +测试完整的请求-响应流程。 + +### 10.3 回归测试设计 + +确保所有现有测试通过。 + +--- + +## 11. 性能优化设计 + +### 11.1 层级调用开销分析 + +**问题:** 增加 controllers 层会增加一层函数调用,是否会影响性能? + +**分析:** +- Python 函数调用开销:约 0.1-0.5 微秒 +- 数据库查询开销:约 1-10 毫秒 +- HTTP 请求开销:约 10-100 毫秒 + +**结论:** 函数调用开销可以忽略不计(< 0.1%) + +### 11.2 导入开销分析 + +**问题:** 每个 controller 都要导入 services 和 repositories,是否会影响启动时间? + +**分析:** +- Python 的导入是缓存的,只在第一次导入时有开销 +- 模块导入开销:约 1-10 毫秒 +- 应用启动时间:约 1-2 秒 + +**结论:** 导入开销可以忽略不计(< 1%) + +### 11.3 内存开销分析 + +**问题:** 增加模块会增加内存使用吗? + +**分析:** +- 每个模块的内存开销:约 10-100 KB +- 应用总内存使用:约 50-100 MB + +**结论:** 内存开销可以忽略不计(< 0.1%) + +### 11.4 性能优化建议 + +**不需要特殊优化:** +- 层级调用开销极小 +- 导入开销极小 +- 内存开销极小 + +**如果确实需要优化:** +- 使用数据库连接池(已有) +- 使用缓存(如果需要) +- 优化 SQL 查询(已有索引) + +--- + +## 12. 错误处理设计 + +### 12.1 统一错误格式 + +所有 API 错误响应使用统一格式: + +```json +{ + "success": false, + "error": "错误信息", + "trace_id": "xxx-xxx-xxx" +} +``` + +### 12.2 错误处理流程 + +```python +@login_required +def api_xxx(): + try: + # 业务逻辑 + result = service.do_something() + return jsonify(result) + except Exception as e: + # 统一错误处理 + return jsonify(build_error_payload(str(e))), 500 +``` + +### 12.3 全局错误处理器 + +保持现有的全局错误处理器: + +```python +# outlook_web/app.py +app.register_error_handler(HTTPException, legacy.handle_http_exception) +app.register_error_handler(Exception, legacy.handle_exception) +``` + +--- + +## 13. 兼容性保证设计 + +### 13.1 API 契约不变 + +- ✅ URL 路径不变 +- ✅ HTTP 方法不变 +- ✅ 请求参数不变 +- ✅ 响应格式不变 +- ✅ 错误格式不变 +- ✅ HTTP 状态码不变 + +### 13.2 功能行为不变 + +- ✅ 鉴权机制不变(@login_required) +- ✅ 数据脱敏不变(client_id, refresh_token) +- ✅ 审计日志不变 +- ✅ CSRF 防护不变 +- ✅ 回退机制不变(Graph API → IMAP) +- ✅ trace_id 机制不变 + +### 13.3 部署方式不变 + +- ✅ Docker 启动命令不变 +- ✅ Gunicorn 启动命令不变 +- ✅ 环境变量配置不变 +- ✅ 数据库结构不变 + +--- + +## 14. 代码示例汇总 + +### 14.1 完整的 Controller 示例 + +```python +# outlook_web/controllers/groups.py +from flask import request, jsonify +from outlook_web.security.auth import login_required +from outlook_web.repositories import groups as groups_repo +from outlook_web.errors import build_error_payload + +@login_required +def api_get_groups(): + """获取所有分组""" + try: + groups = groups_repo.get_all_groups() + return jsonify(groups) + except Exception as e: + return jsonify(build_error_payload(str(e))), 500 + +@login_required +def api_get_group(group_t): + """获取单个分组""" + try: + group = groups_repo.get_group_by_id(group_id) + if not group: + return jsonify(build_error_payload('分组不存在')), 404 + return jsonify(group) + except Exception as e: + return jsonify(build_error_payload(str(e))), 500 + +@login_required +def api_add_group(): + """添加分组""" + try: + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify(build_error_payload('分组名称不能为空')), 400 + + group_id = groups_repo.create_group(name) + return jsonify({'id': group_id, 'name': name}) + except Exception as e: + return jsonify(build_error_payload(str(e))), 500 + +@login_required +def api_update_group(group_id: int): + """更新分组""" + try: + data = request.get_json() + name = data.get('name', '').strip() + + if not name: + return jsonify(build_error_payload('分组名称不能为空')), 400 + + success = groups_repo.update_group(group_id, name) + if not success: + urn jsonify(build_error_payload('分组不存在')), 404 + + return jsonify({'id': group_id, 'name': name}) + except Exception as e: + return jsonify(build_error_payload(str(e))), 500 + +@login_required +def api_delete_group(group_id: int): + """删除分组""" + try: + success = groups_repo.delete_group(group_id) + if not success: + return jsonify(build_error_payload('分组不存在')), 404 + + return jsonify({'message': '删除成功'}) + except Exception as e: + return jsonify(build_error_payload(str(e))), 500 +``` + +### 14.2 完整的 Routes 示例 + +```python +# outlook_web/routes/groups.py +from flask import Blueprint +from outlook_web.controllers import groups as groups_controller + +def create_blueprint() -> Blueprint: + """创建 groups Blueprint""" + bp = Blueprint("groups", __name__) + + bp.add_url_rule("/api/groups", + view_func=groups_controller.api_get_groups, + methods=["GET"]) + + bp.add_url_rule("/api/groups/", + view_func=groups_controller.api_get_group, + methods=["GET"]) + + bp.add_url_rule("/api/groups", + view_func=groups_controller.api_add_group, + methods=["POST"]) + + bp.add_url_rule("/api/groups/", + view_func=groups_controller.api_update_group, + methods=["PUT"]) + + bp.add_url_rule("/api/groups/", + view_func=groups_controller.api_delete_group, + methods=["DELETE"]) + + return bp +``` + +### 14.3 完整的测试示例 + +```python +# tests/test_controllers_groups.py +import unittest +from unittest.mock import patch, MagicMock +from flask import Flask +from outlook_web.controllers import groups as groups_controller + +class TestGroupsController(unittest.TestCase): + + def setUp(self): + self.app = Flask(__name__) + self.app.config['TESTING'] = True + self.client = self.app.test_client() + + @self.app.route('/api/groups') + def get_groups(): + return groups_controller.api_get_groups() + + @patch('outlook_web.controllers.groups.session') + @patch('outlook_web.controllers.groups.groups_repo') + def test_api_get_groups_success(self, mock_repo, mock_session): + mock_session.get.return_value = True + mock_repo.get_all_groups.return_value = [ + {'id': 1, 'name': '分组1'}, + {'id': 2, 'name': '分组2'} + ] + + response = self.client.get('/api/groups') + + self.assertEqual(response.status_code, 200) + data = response.get_json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]['name'], '分组1') + +if __name__ == '__main__': + unittest.main() +``` + +--- + +## 15. 实施检查清单 + +### 15.1 迁移前检查 + +- [ ] 阅读 PRD 和 FD 文档 +- [ ] 阅读本 TDD 文档 +- [ ] 理解现有架构 +- [ ] 运行所有测试,确保通过 +- [ ] 创建 Git 分支 + +### 15.2 迁移中检查 + +- [ ] 创建 controllers 目录 +- [ ] 创建 controller 文件 +- [ ] 从 legacy.py 提取函数 +- [ ] 调整导入语句 +- [ ] 更新 routes 文件 +- [ ] 更新 app.py +- [ ] 运行测试 +- [ ] 手动验证 +- [ ] 提交 Git + +### 15.3 迁移后检查 + +- [ ] 所有测试通过 +- [ ] 所有功能正常 +- [ ] 性能无明显下降 +- [ ] 文档已更新 +- [ ] 代码审查通过 + +--- + +## 16. 总结 + +### 16.1 技术要点 + +1. **四层架构**:routes → controllers → services → repositories +2. **职责清晰**:每层只做一件事 +3. **依赖单向**:避免循环依赖 +4. **兼容性保证**:API 契约不变 +5. **分阶段迁移**:可回滚、可验证 +6. **测试覆盖**:单元测试 + 集成测试 + +### 16.2 关键决策 + +1. **不使用 Controller 基类**:使用装饰器和工具函数更简单 +2. **不引入 DI 框架**:直接导入即可,测试时使用 mock +3. **保留 legacy.py**:直到所有模块迁移完成 +4. **分阶段迁移**:从简单到复杂,降低风险 + +##益 + +1. **代码可维护性提升**:职责清晰,易于理解 +2. **测试覆盖率提升**:易于编写单元测试 +3. **代码复用性提升**:services 可以被多个 controllers 调用 +4. **团队协作效率提升**:模块化便于并行开发 + +--- + +**文档版本:** v1.0 +**最后更新:** 2026-02-24 +**维护者:** 开发团队 diff --git a/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md b/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md new file mode 100644 index 0000000..18357d2 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-前后端拆分与模块化TDD.md @@ -0,0 +1,331 @@ +# Outlook 邮件管理工具|前后端拆分与模块化 TDD(技术设计细节) + +- 文档状态:草案 +- 版本:V0.1 +- 日期:2026-02-23 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-前后端拆分与模块化PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-前后端拆分与模块化FD.md` +- 对齐 TODO:`docs/TODO/Outlook邮件管理工具-前后端拆分与模块化TODO.md` +- 关联 CR:`docs/CR/CR-00002-Outlook邮件管理工具-前后端拆分与模块化-文档审查报告.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述“为满足 PRD/FD,前后端拆分与模块化需要采用的技术设计细节”。 +重点回答: + +- 拆成哪些模块、各模块职责边界是什么、依赖方向如何约束 +- 如何保证 **URL/响应契约/错误结构/trace_id/调度器行为** 不回归 +- 如何渐进式迁移(每阶段可回滚、可验收、可测试) + +--- + +## 2. 设计原则与硬约束(必须满足) + +### 2.1 行为兼容性(对用户无感) + +- 保持现有 API 路径/方法/参数语义/响应结构不变。 +- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变。 +- 保持登录/会话/CSRF 行为不变。 +- 保持定时刷新“推荐运行方式/自启动策略/可验证证据接口”不变。 + +### 2.2 渐进式拆分(可回滚、可验证) + +- 任何阶段都必须可运行且可回归:至少通过 `python -m unittest discover -s tests -v` 与关键接口抽样验收。 +- 允许短期存在兼容层(旧入口 facade / re-export),但每阶段要有明确的“可删除点”。 + +### 2.3 不引入新复杂度 + +- 前端不引入 React/Vue 等框架,不引入 Webpack/Vite 等构建链;以 **ES Module + 静态资源引用** 为主。 +- 后端不引入新框架迁移(仍为 Flask),数据库仍为 SQLite,迁移策略保持现状。 + +--- + +## 3. 总体架构设计 + +### 3.1 后端分层(建议) + +采用典型分层与关注点分离: + +- Presentation(Routes):参数解析/鉴权/调用 service/返回 JSON +- Service(业务编排):回退策略/流程编排/审计触发/错误聚合 +- Repository(数据访问):SQL 与数据映射,不承载业务规则 +- Infrastructure(基础设施):配置、DB、加密、错误模型、调度器、第三方客户端 + +依赖方向约束(必须遵守): + +`routes -> services -> repositories -> db` +`routes/services` 可以依赖 `errors/security/audit/utils`,但 **repositories 不能依赖 routes**。 + +### 3.2 前端模块化(建议) + +- `api` 层:统一 `fetch` 封装、错误结构处理、trace_id 展示、CSRF/header 处理 +- `ui` 层:modal/toast/loading 等通用组件 +- `features` 层:groups/accounts/tags/emails/refresh/export/settings/audit/temp_emails 等业务模块 +- `state` 层:当前分组/账号/分页/缓存等前端状态(可用简单对象,避免引入状态管理框架) + +--- + +## 4. 后端技术设计细节 + +### 4.1 目标目录结构(建议) + +新增后端包(示例命名,可在实现阶段微调): + +``` +outlook_web/ + __init__.py + app.py # create_app + 装配 + config.py # 环境变量读取/默认值/运行模式 + db.py # 连接管理、init_db、迁移/索引 + errors.py # error payload、trace_id、脱敏、全局异常处理 + audit.py # log_audit + 审计查询 + security/ + __init__.py + crypto.py # SECRET_KEY 派生、encrypt/decrypt、hash_password + auth.py # login_required、session 工具、ip 获取 + csrf.py # CSRF 集成与降级 + repositories/ + accounts.py + groups.py + tags.py + temp_emails.py + services/ + graph.py + imap.py + email_delete.py # 删除邮件回退聚合(可选单独文件) + refresh.py # token 刷新编排(可选) + gptmail.py + scheduler/ + scheduler.py # APScheduler 初始化/装配/心跳 + locks.py # distributed_locks 抽象 + runs.py # refresh_runs 记录/查询 + routes/ + system.py + scheduler.py + groups.py + accounts.py + tags.py + emails.py + oauth.py + settings.py + temp_emails.py +``` + +约束: + +- `outlook_web/app.py` 只负责装配与注册,不承载大段业务代码。 +- `init_db()` 与 schema 迁移逻辑集中在 `db.py`,避免散落。 + +### 4.2 应用装配(create_app)与入口兼容 + +#### 4.2.1 目标 + +- 支持 `create_app()`(便于测试与复用)。 +- 同时保持部署入口兼容:现有 `gunicorn ... web_outlook_app:app` 不需要立即改动。 + +#### 4.2.2 设计 + +- `outlook_web/app.py`: + - `create_app()`:创建 Flask app、加载 config、注册 blueprint、注册 error handler、初始化 db/scheduler(按环境变量开关)。 +- `web_outlook_app.py` 在迁移阶段变为 facade: + - `from outlook_web.app import create_app` + - `app = create_app()` + - 继续保留必要的兼容导出(避免影响 tests/与外部启动方式) + +**禁止**:在模块 import 时启动后台线程(调度器必须在 `create_app()` 内按开关启动)。 + +### 4.3 配置与环境变量 + +`outlook_web/config.py` 负责集中读取并提供类型安全的配置: + +- `SECRET_KEY`(必填,且变更会影响历史加密数据) +- `DATABASE_PATH`(默认 `data/outlook_accounts.db`) +- `LOGIN_PASSWORD`(初始登录密码) +- `SCHEDULER_AUTOSTART`(是否在 WSGI 导入场景自启动调度器) +- 其它现有设置项保持原名与语义 + +### 4.4 错误模型、trace_id、脱敏(统一入口) + +`outlook_web/errors.py`: + +- `generate_trace_id()`:若请求头带 `X-Trace-Id` 则复用,否则生成。 +- `build_error_payload(code, message, type, status, details)`:默认对 message/details 脱敏。 +- 全局异常处理: + - `HTTPException` → `HTTP_ERROR` + - 未捕获异常 → `UNCAUGHT_EXCEPTION` +- `after_request`: + - 统一补 `X-Trace-Id` + - legacy `{success:false,error:"..."}` 归一化为结构化 error(保持现有兼容策略) + +### 4.5 数据库与迁移(init_db) + +`outlook_web/db.py`: + +- `create_sqlite_connection()`:统一设置 row_factory、busy_timeout、foreign_keys。 +- `get_db()`:基于 `flask.g` 的连接生命周期管理。 +- `init_db()`: + - 幂等建表、缺列补齐、索引创建、schema 版本写入与迁移记录(保持现有逻辑与表结构) + - 并发迁移使用 `BEGIN IMMEDIATE`(保持一致性) + +### 4.6 Repository(SQL)设计 + +Repository 层只做“数据访问与映射”,以函数形式即可(不强制类): + +- `repositories/accounts.py` + - `get_account_by_id(db, account_id)` / `add_account(db, ...)` / `update_account(db, ...)` / `search_accounts(db, q)` 等 +- 统一约束: + - 不返回敏感字段明文(若业务需要解密,放在 service 层做) + - 所有 SQL 参数化,禁止字符串拼接注入 + +### 4.7 Service(业务编排)设计 + +Service 层负责: + +- Graph/IMAP/GPTMail 调用与错误聚合 +- Token 刷新/删除邮件等“多方式回退策略” +- 审计触发(不记录敏感明文) + +建议把“聚合错误”的结构保持与现有一致(前端依赖 `error.details` 展示)。 + +### 4.8 Routes(Blueprint)设计 + +每个路由文件暴露一个 blueprint: + +- `bp = Blueprint("groups", __name__)` +- 使用 `@login_required` 等装饰器保持一致 +- 注册:`app.register_blueprint(groups.bp)`(不使用 `url_prefix` 或用空前缀,确保 URL 不变) + +### 4.9 调度器(Scheduler)与 import-time 副作用控制 + +目标:避免“import 就启动线程”,但保持既有运行方式下的定时刷新能力。 + +策略: + +- `create_app()` 内: + - 先 `init_db()`,再根据 `SCHEDULER_AUTOSTART`(或 TESTING 环境)决定是否 `init_scheduler()` +- 测试环境默认禁用自启动(现有 `tests/_import_app.py` 已采用环境变量方式) + +--- + +## 5. 前端技术设计细节 + +### 5.1 静态资源拆分与引用 + +新增目录: + +``` +static/ + js/ + css/ +``` + +`templates/index.html`: + +- 移除大段内联脚本,改为引用: + - `` +- CSS 迁移到 `/static/css/main.css` + +### 5.2 API 封装(api.js) + +提供统一方法: + +- `apiGet(path, opts)` / `apiPost(path, body, opts)` / `apiDelete(...)` +- 统一处理: + - JSON parse + - `success=false` 的结构化 error 展示(复用现有 `handleApiError()` 语义) + - `X-Trace-Id` 的获取与展示(用于排障复制) + - CSRF token/header(保持现有策略) + +### 5.3 Feature 模块拆分 + +每个 feature 文件负责: + +- UI 事件绑定(按钮/表单/表格交互) +- 调用 `api.js` 与更新 `state` +- 只依赖 `ui/*` 与 `api.js/state.js`,避免 feature 互相调用导致耦合 + +### 5.4 渐进迁移策略(避免一次性大改) + +- 第一步允许把原来所有脚本“整体搬到 main.js”以保持行为一致; +- 第二步再从 main.js 中按 feature 拆分导出函数/类; +- 第三步再考虑把 HTML 结构拆成 Jinja partials(减少冲突)。 + +--- + +## 6. 迁移阶段与验收(每阶段必做) + +> 每个阶段都必须:单测通过 + 抽样接口验收通过 + 可回滚。 + +### 阶段 0:基线冻结 + +- 锁定现有接口契约与关键行为(以现有 tests + QA 文档为证据)。 + +### 阶段 1:后端骨架 + 兼容入口 + +- 引入 `outlook_web/create_app()`,`web_outlook_app.py` 变为 facade 并继续导出 `app`。 +- 验收: + - Docker/Gunicorn 启动命令不变 + - 单测全过 + +### 阶段 2:拆基础设施(低风险) + +- 拆 `config/errors/security/crypto/db/audit`,旧文件只转发调用。 +- 增补单测:错误结构/trace_id/脱敏不回归。 + +### 阶段 3:拆 repositories + services + +- SQL 下沉 repositories,回退策略与业务编排上移 services。 +- 增补单测:纯逻辑单测(不依赖 Flask app)。 + +### 阶段 4:拆 routes(Blueprint 化) + +- 分模块路由文件并注册蓝图,URL 不变。 +- 增补测试:关键路由存在性与响应结构抽样。 + +### 阶段 5:前端静态资源拆分 + +- 先整体迁移脚本到 `static/js/main.js`,再按 feature 拆分。 +- 抽样回归:登录/分组/账号/导出/审计/临时邮箱/设置等关键路径。 + +--- + +## 7. 测试策略(TDD/回归保障) + +### 7.1 单元测试(优先补 service/repository) + +- repository:SQL 结果映射、边界条件(空/不存在/重复) +- service:回退聚合、错误结构、脱敏、审计 details 生成 + +### 7.2 集成测试(保契约) + +- 使用 Flask `test_client` 验证关键接口: + - `/healthz` + - `/api/system/health` + - `/api/system/upgrade-status` + - `/api/audit-logs` + - `/api/scheduler/status` +- 对“拆分后的装配”增加 smoke test(蓝图是否注册、静态资源是否可访问)。 + +### 7.3 变更门禁 + +- CI 必跑:`python -m unittest discover -s tests -v` + +--- + +## 8. 风险与缓解 + +- 循环依赖风险:通过分层约束与“只从上往下依赖”解决;必要时引入 `types.py` 放共享数据结构。 +- import-time 副作用:调度器启动必须延迟到 `create_app()`,测试环境必须禁用自启动。 +- 契约漂移:每阶段用“抽样契约测试 + QA 清单”兜底;禁止随手改接口字段名。 +- 前端全局变量依赖:第一阶段允许保留 `window.*`,后续再逐步收敛成模块内部状态。 + +--- + +## 9. 交付验收(技术侧) + +- FD 2.x 清单项全部满足。 +- 单测与 CI 通过。 +- 关键抽样接口契约不变。 +- 迁移过程中每阶段的“回滚点”清晰可执行。 diff --git a/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md b/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md new file mode 100644 index 0000000..14a5af0 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md @@ -0,0 +1,873 @@ +# Outlook 邮件管理工具|批量操作与自动化增强 TDD(技术设计细节) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-24 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述"为满足 PRD/FD,批量操作与自动化增强需要采用的技术设计细节"。 + +重点回答: + +- 验证码提取算法的具体实现方案 +- 全选功能的性能优化策略(支持 1000+ 邮箱) +- 轮询机制的技术实现(定时器、状态管理、新邮件检测) +- 前端剪贴板 API 的使用与兼容性处理 +- 数据库 schema 变更与配置管理 +- API 接口设计与错误处理 + +--- + +## 2. 设计原则与硬约束(必须满足) + +### 2.1 行为兼容性(对用户无感) + +- 保持现有 API 路径/方法/参数语义/响应结构不变 +- 保持统一错误结构、`trace_id` 透传、默认脱敏策略不变 +- 保持登录/会话/CSRF 行为不变 +- 新增功能不影响现有功能的正常使用 + +### 2.2 性能要求 + +- 全选操作在 100ms 内完成(1000 个邮箱) +- 验证码提取 API 响应时间 < 2 秒 +- 轮询操作不阻塞主线程,不影响页面交互 + +### 2.3 可靠性要求 + +- 验证码提取成功率 > 90%(基于常见邮件格式) +- 轮询失败时自动停止并提示用户 +- 剪贴板操作失败时有降级方案 + +### 2.4 安全要求 + +- 验证码提取 API 需要登录验证 +- 复制内容不包含敏感信息 +- 轮询频率有合理限制 + +--- + +## 3. 总体架构设计 + +### 3.1 功能模块划分 + +``` +批量操作与自动化增强 +├── 功能1:全选功能(纯前端) +│ ├── UI 组件(全选复选框) +│ ├── 状态管理(selectedAccounts Set) +│ └── 性能优化(批量 DOM 更新) +│ +├── 功能2:邮件信息快速复制(前后端) +│ ├── 后端 +│ │ ├── API 接口(/api/emails//extract-verification) +│ │ ├── 验证码提取服务(智能识别 + 保底规则) +│ │ └── 链接提取服务 +│ └── 前端 +│ ├── 复制按钮组件 +│ ├── 剪贴板 API 调用 +│ └── 反馈提示 +│ +└── 功能3:自动轮询与通知(前后端) + ├── 后端 + │ ├── 数据库配置项(settings 表) + │ └── 设置 API 更新 + └── 前端 + ├── 设置界面 + ├── 轮询逻辑(定时器) + ├── 新邮件检测 + └── 通知组件 +``` + +### 3.2 技术栈 + +- **后端**:Flask + SQLite + Python 正则表达式 +- **前端**:原生 JavaScript + Clipboard API + CSS3 动画 +- **数据存储**:SQLite settings 表(key-value 结构) + +--- + +## 4. 后端技术设计细节 + +### 4.1 验证码提取服务 + +#### 4.1.1 模块位置 + +``` +outlook_web/services/verification_extractor.py +``` + +#### 4.1.2 核心算法设计 + +**智能识别阶段:** + +```python +# 关键词列表(支持中英文) +VERIFICATION_KEYWORDS = [ + "验证码", "code", "验证", "verification", + "OTP", "动态码", "校验码", "verify code", + "confirmation code", "security code" +] + +# 验证码模式(4-8位数字或字母) +VERIFICATION_PATTERN = r'\b[A-Z0-9]{4,8}\b' + +def smart_extract_verification_code(email_content): + """ + 智能提取验证码 + + 算法: + 1. 遍历关键词列表 + 2. 在邮件内容中查找关键词位置 + 3. 在关键词前后 50 个字符范围内搜索验证码模式 + 4. 返回第一个匹配的验证码 + """ + content_lower = email_content.lower() + + for keyword in VERIFICATION_KEYWORDS: + keyword_lower = keyword.lower() + pos = content_lower.find(keyword_lower) + + if pos != -1: + # 提取关键词前后 50 个字符 + start = max(0, pos - 50) + end = min(len(email_content), pos + len(keyword) + 50) + context = email_content[start:end] + + # 在上下文中搜索验证码 + matches = re.findall(VERIFICATION_PATTERN, context, re.IGNORECASE) + if matches: + # 过滤掉纯字母的匹配(验证码通常包含数字) + for match in matches: + if any(c.isdigit() for c in match): + return match + + return None +``` + +**保底提取阶段:** + +```python +def fallback_extract_verification_code(email_content): + """ + 保底提取验证码 + + 算法: + 1. 提取所有 4-8 位的数字/字母组合 + 2. 过滤掉常见的非验证码模式(日期、时间等) + 3. 返回第一个匹配项 + """ + # 提取所有可能的验证码 + matches = re.findall(VERIFICATION_PATTERN, email_content, re.IGNORECASE) + + # 过滤规则 + filtered = [] + for match in matches: + # 必须包含至少一个数字 + if not any(c.isdigit() for c in match): + continue + + # 排除纯数字且长度为 4 的(可能是年份) + if match.isdigit() and len(match) == 4: + year = int(match) + if 1900 <= year <= 2100: + continue + + # 排除常见的时间格式(如 1234 可能是 12:34) + if match.isdigit() and len(match) == 4: + hour = int(match[:2]) + minute = int(match[2:]) + if 0 <= hour <= 23 and 0 <= minute <= 59: + continue + + filtered.append(match) + + return filtered[0] if filtered else None +``` + +**链接提取:** + +```python +def extract_links(email_content): + """ + 提取所有 HTTP/HTTPS 链接 + + 算法: + 1. 使用正则表达式提取所有链接 + 2. 去重并保持顺序 + """ + # 链接正则表达式 + LINK_PATTERN = r'https?://[^\s<>"{}|\\^`\[\]]+' + + matches = re.findall(LINK_PATTERN, email_content, re.IGNORECASE) + + # 去重并保持顺序 + seen = set() + unique_links = [] + for link in matches: + if link not in seen: + seen.add(link) + unique_links.append(link) + + return unique_links +``` + +#### 4.1.3 完整提取流程 + +```python +def extract_verification_info(email_address): + """ + 提取验证信息的完整流程 + + 返回: + { + "verification_code": "123456", + "links": ["https://example.com/verify"], + "formatted": "123456 https://example.com/verify" + } + """ + # 1. 获取最新邮件(同时从收件箱和垃圾邮件获取) + latest_email = get_latest_email(email_address) + + if not latest_email: + raise ValueError("未找到邮件") + + # 2. 提取邮件内容(HTML 转纯文本) + email_content = extract_email_text(latest_email) + + # 3. 提取验证码(智能识别 + 保底) + verification_code = smart_extract_verification_code(email_content) + if not verification_code: + verification_code = fallback_extract_verification_code(email_content) + + # 4. 提取链接 + links = extract_links(email_content) + + # 5. 格式化输出 + parts = [] + if verification_code: + parts.append(verification_code) + parts.extend(links) + + formatted = " ".join(parts) if parts else None + + if not formatted: + raise ValueError("未找到验证信息") + + return { + "verification_code": verification_code, + "links": links, + "formatted": formatted + } +``` + +### 4.2 API 接口设计 + +#### 4.2.1 验证码提取接口 + +**路由定义:** + +```python +# outlook_web/routes/emails.py +bp.add_url_rule( + "/api/emails//extract-verification", + view_func=impl.api_extract_verification, + methods=["GET"] +) +``` + +**实现逻辑:** + +```python +# outlook_web/legacy.py 或新的 view 层 +@login_required +def api_extract_verification(email): + """ + 提取验证码和链接接口 + + 参数: + - email: 邮箱地址(路径参数) + + 返回: + 成功: + { + "success": true, + "data": { + "verification_code": "123456", + "links": ["https://example.com/verify"], + "formatted": "123456 https://example.com/verify" + }, + "message": "提取成功" + } + + 失败: + { + "success": false, + "error": "未找到验证信息", + "trace_id": "xxx" + } + """ + try: + # 调用验证码提取服务 + result = extract_verification_info(email) + + return jsonify({ + "success": True, + "data": result, + "message": "提取成功" + }) + + except ValueError as e: + # 未找到验证信息 + return jsonify({ + "success": False, + "error": str(e), + "trace_id": generate_trace_id() + }), 404 + + except Exception as e: + # 其他错误 + return jsonify({ + "success": False, + "error": "提取失败", + "trace_id": generate_trace_id() + }), 500 +``` + +**错误处理策略:** + +1. **邮箱不存在**:返回 404,错误信息:"邮箱不存在" +2. **未找到邮件**:返回 404,错误信息:"未找到邮件" +3. **未找到验证信息**:返回 404,错误信息:"未找到验证信息" +4. **Token 过期**:返回 401,错误信息:"Token 过期,请刷新" +5. **网络错误**:返回 500,错误信息:"网络错误,请重试" + +### 4.3 数据库设计 + +#### 4.3.1 settings 表新增配置项 + +**配置项定义:** + +| Key | Value Type | Default | Description | +|-----|------------|---------|-------------| +| `enable_auto_polling` | boolean | `false` | 是否启用自动轮询 | +| `polling_interval` | integer | `10` | 轮询间隔(秒) | +| `polling_count` | integer | `5` | 轮询次数 | + +**初始化逻辑:** + +```python +# outlook_web/db.py - init_db() 函数中添加 + +# 初始化轮询配置 +cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("enable_auto_polling", "false") +) +cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("polling_interval", "10") +) +cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("polling_count", "5") +) +``` + +**数据类型转换:** + +```python +# 读取配置时的类型转换 +def get_polling_settings(db): + """获取轮询配置""" + settings = {} + + # enable_auto_polling: string -> boolean + row = db.execute( + "SELECT value FROM settings WHERE key = ?", + ("enable_auto_polling",) + ).fetchone() + settings["enable_auto_polling"] = row["value"].lower() == "true" if row else False + + # polling_interval: string -> integer + row = db.execute( + "SELECT value FROM settings WHERE key = ?", + ("polling_interval",) + ).fetchone() + settings["polling_interval"] = int(row["value"]) if row else 10 + + # polling_count: string -> integer + row = db.execute( + "SELECT value FROM settings WHERE key = ?", + ("polling_count",) + ).fetchone() + settings["polling_count"] = int(row["value"]) if row else 5 + + return settings +``` + +#### 4.3.2 设置 API 更新 + +**GET /api/settings 响应更新:** + +```python +# 在现有响应中添加轮询配置 +{ + "success": true, + "settings": { + "login_password": "***", + "gptmail_api_key": "***", + "enable_auto_polling": false, + "polling_interval": 10, + "polling_count": 5, + ... + } +} +``` + +**PUT /api/settings 请求处理:** + +```python +@login_required +def api_update_settings(): + """更新设置""" + data = request.get_json() + + # 处理轮询配置 + if "enable_auto_polling" in data: + value = "true" if data["enable_auto_polling"] else "false" + db.execute( + "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (value, "enable_auto_polling") + ) + + if "polling_interval" in data: + # 验证范围:5-300 秒 + interval = int(data["polling_interval"]) + if not (5 <= interval <= 300): + return jsonify({ + "success": False, + "error": "轮询间隔必须在 5-300 秒之间" + }), 400 + + db.execute( + "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (str(interval), "polling_interval") + ) + + if "polling_count" in data: + # 验证范围:1-100 次 + count = int(data["polling_count"]) + if not (1 <= count <= 100): + return jsonify({ + "success": False, + "error": "轮询次数必须在 1-100 次之间" + }), 400 + + db.execute( + "UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?", + (str(count), "polling_count") + ) + + db.commit() + + return jsonify({ + "success": True, + "message": "设置已保存" + }) +``` + +### 4.4 邮件获取辅助函数 + +#### 4.4.1 获取最新邮件 + +```python +def get_latest_email(email_address): + """ + 获取邮箱的最新邮件 + + 策略: + 1. 同时从收件箱和垃圾邮件文件夹获取邮件 + 2. 按时间排序,取最新的一封 + 3. 使用现有的邮件获取 API(Graph API / IMAP) + """ + from outlook_web.services.graph import get_emails as get_emails_graph + from outlook_web.services.imap import get_emails as get_emails_imap + + # 获取账号信息 + db = get_db() + account = db.execute( + "SELECT * FROM accounts WHERE email = ?", + (email_address,) + ).fetchone() + + if not account: + raise ValueError("邮箱不存在") + + # 尝试获取邮件(使用现有的回退策略) + emails = [] + + # 从收件箱获取 + try: + inbox_emails = get_emails_graph( + account, folder="inbox", skip=0, top=1 + ) + emails.extend(inbox_emails) + except Exception: + pass + + # 从垃圾邮件获取 + try: + junk_emails = get_emails_graph( + account, folder="junkemail", skip=0, top=1 + ) + emails.extend(junk_emails) + except Exception: + pass + + if not emails: + raise ValueError("未找到邮件") + + # 按时间排序,取最新的 + emails.sort(key=lambda x: x.get("date", ""), reverse=True) + return emails[0] +``` + +#### 4.4.2 HTML 转纯文本 + +```python +import html +from html.parser import HTMLParser + +class HTMLTextExtractor(HTMLParser): + """HTML 转纯文本提取器""" + + def __init__(self): + super().__init__() + self.text = [] + + def handle_data(self, data): + self.text.append(data) + + def get_text(self): + return " ".join(self.text) + +def extract_email_text(email): + """ + 提取邮件纯文本内容 + + 优先级: + 1. body(纯文本) + 2. body_html(HTML 转纯文本) + 3. body_preview(预览文本) + """ + # 优先使用纯文本 + if email.get("body"): + return email["body"] + + # 其次使用 HTML 转纯文本 + if email.get("body_html"): + parser = HTMLTextExtractor() + parser.feed(email["body_html"]) + text = parser.get_text() + # 解码 HTML 实体 + text = html.unescape(text) + return text + + # 最后使用预览文本 + if email.get("body_preview"): + return email["body_preview"] + + return "" +``` + +--- + +## 5. 前端技术设计细节 + +### 5.1 全选功能 + +#### 5.1.1 数据结构设计 + +```javascript +// 全局变量(复用现有的) +let selectedAccounts = new Set(); // 存储选中的账号 ID + +// 全选状态枚举 +const SelectAllState = { + NONE: 'none', // 未选中 + PARTIAL: 'partial', // 部分选中(半选) + ALL: 'all' // 全选 +}; +``` + +#### 5.1.2 性能优化策略 + +**问题:** 当邮箱数量达到 1000 个时,全选操作可能导致页面卡顿 + +**优化方案:** + +1. **使用 Set 数据结构** + - 时间复杂度:O(1) 查找和插入 + - 空间复杂度:O(n) + +2. **批量 DOM 更新** + - 使用 requestAnimationFrame 优化渲染时机 + - 减少重排重绘次数 + +3. **事件委托** + - 使用事件委托减少事件监听器数量 + +```javascript +function selectAllAccounts() { + const accountItems = document.querySelectorAll('.account-item:not([style*="display: none"])'); + + // 批量添加到 Set(O(n) 时间复杂度) + accountItems.forEach(item => { + const accountId = item.dataset.accountId; + if (accountId) { + selectedAccounts.add(accountId); + } + }); + + // 使用 requestAnimationFrame 优化渲染 + requestAnimationFrame(() => { + updateAccountCheckboxes(); + updateSelectAllCheckbox(); + updateBatchActionBar(); + }); +} +``` + +### 5.2 剪贴板 API 兼容性处理 + +#### 5.2.1 浏览器兼容性 + +| 浏览器 | Clipboard API | execCommand | +|--------|---------------|-------------| +| Chrome 66+ | ✅ | ✅ | +| Firefox 63+ | ✅ | ✅ | +| Safari 13.1+ | ✅ | ✅ | +| Edge 79+ | ✅ | ✅ | +| IE 11 | ❌ | ✅ | + +#### 5.2.2 降级方案 + +```javascript +async function copyToClipboard(text) { + // 方法1:Clipboard API(现代浏览器) + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch (err) { + console.warn('Clipboard API 失败,尝试降级方案', err); + } + } + + // 方法2:execCommand(旧浏览器) + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + + if (!success) { + throw new Error('execCommand 失败'); + } + } catch (err) { + throw new Error('复制失败,请手动复制'); + } +} +``` + +### 5.3 轮询机制的技术实现 + +#### 5.3.1 定时器管理 + +```javascript +// 全局定时器变量 +let pollingTimer = null; + +function startPolling(email) { + // 先停止之前的定时器 + stopPolling(); + + // 创建新定时器 + pollingTimer = setInterval(() => { + pollForNewEmails(); + }, pollingInterval); +} + +function stopPolling() { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + } +} + +// 页面卸载时清理定时器 +window.addEventListener('beforeunload', () => { + stopPolling(); +}); +``` + +#### 5.3.2 新邮件检测算法 + +**算法复杂度分析:** + +- 时间复杂度:O(n),n 为新邮件数量 +- 空间复杂度:O(m),m 为旧邮件数量 + +```javascript +function detectNewEmails(oldEmailIds, newEmails) { + // 使用 Set 进行快速查找(O(1)) + const newEmailList = []; + + for (const email of newEmails) { + if (!oldEmailIds.has(email.id)) { + newEmailList.push(email); + } + } + + return newEmailList; +} +``` + +--- + +## 6. 测试策略 + +### 6.1 单元测试 + +#### 6.1.1 验证码提取算法测试 + +```python +# tests/test_verification_extractor.py + +import unittest +from outlook_web.services.verification_extractor import ( + smart_extract_verification_code, + fallback_extract_verification_code, + extract_links +) + +class TestVerificationExtractor(unittest.TestCase): + + def test_smart_extract_with_chinese_keyword(self): + """测试中文关键词识别""" + content = "您的验证码是 123456,请在10分钟内完成验证。" + result = smart_extract_verification_code(content) + self.assertEqual(result, "123456") + + def test_smart_extract_with_english_keyword(self): + """测试英文关键词识别""" + content = "Your verification code is ABC123." + result = smart_extract_verification_code(content) + self.assertEqual(result, "ABC123") + + def test_fallback_extract(self): + """测试保底提取""" + content = "Please use code XYZ789 to complete." + result = fallback_extract_verification_code(content) + self.assertEqual(result, "XYZ789") + + def test_extract_links(self): + """测试链接提取""" + content = "Click: https://example.com/verify?token=abc123" + result = extract_links(content) + self.assertEqual(len(result), 1) + self.assertIn("https://example.com/verify?token=abc123", result) +``` + +--- + +## 7. 部署与运维 + +### 7.1 数据库迁移 + +```python +# 在 init_db() 函数中添加 +def migrate_polling_settings(cursor): + """迁移轮询配置""" + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("enable_auto_polling", "false") + ) + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("polling_interval", "10") + ) + cursor.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", + ("polling_count", "5") + ) +``` + +### 7.2 性能监控 + +```python +import time + +def api_extract_verification(email): + start_time = time.time() + + try: + result = extract_verification_info(email) + elapsed_time = time.time() - start_time + + if elapsed_time > 2.0: + print(f"⚠️ 验证码提取耗时过长: {elapsed_time:.2f}s") + + return jsonify({"success": True, "data": result}) + except Exception as e: + print(f"❌ 验证码提取失败: {str(e)}") + raise +``` + +--- + +## 8. 总结 + +### 8.1 技术亮点 + +1. **智能验证码识别算法**:关键词识别 + 保底规则,提取成功率 > 90% +2. **高性能全选**:使用 Set 数据结构 + 批量 DOM 更新,支持 1000+ 邮箱 +3. **可靠的轮询机制**:定时器管理 + 新邮件检测 + 自动停止 +4. **优雅的降级方案**:剪贴板 API 降级 + 错误处理 + +### 8.2 技术风险 + +1. **验证码识别准确率**:依赖邮件格式,可能存在误判 + - 缓解:提供保底规则 + 用户可手动查看邮件 +2. **浏览器兼容性**:Clipboard API 在旧浏览器不支持 + - 缓解:提供 execCommand 降级方案 +3. **轮询性能影响**:高频轮询可能影响页面性能 + - 缓解:设置合理的默认值 + 使用异步请求 + +### 8.3 后续优化方向 + +1. **验证码识别优化**: + - 支持更多语言的关键词 + - 支持图片验证码识别(OCR) + - 支持自定义提取规则 + +2. **性能优化**: + - 实现虚拟滚动(邮箱数量 > 1000) + - 使用 Web Worker 处理验证码提取 + - 使用 IndexedDB 缓存邮件数据 + +3. **功能增强**: + - 支持多邮箱同时轮询 + - 支持浏览器通知 + - 支持声音提示 diff --git a/docs/TDD/Outlook邮件管理工具-重构升级TDD.md b/docs/TDD/Outlook邮件管理工具-重构升级TDD.md new file mode 100644 index 0000000..13d9b84 --- /dev/null +++ b/docs/TDD/Outlook邮件管理工具-重构升级TDD.md @@ -0,0 +1,143 @@ +# Outlook 邮件管理工具|重构升级 TDD(技术交付清单,仅说明做什么) + +- 文档状态:草案 +- 版本:V1.0 +- 日期:2026-02-22 +- 对齐 PRD:`docs/PRD/Outlook邮件管理工具-重构升级PRD.md` +- 对齐 FD:`docs/FD/Outlook邮件管理工具-重构升级FD.md` + +--- + +## 1. 文档目的 + +本 TDD 用于描述“为满足 PRD/FD,需要在工程与系统层面交付哪些能力与行为”。 +仅描述**需要完成什么功能/能力**与**可验收行为**,不描述实现过程与编码细节。 + +--- + +## 2. 总体约束(必须满足) + +- 保持现有对外使用方式与关键交互不被破坏(包含管理界面与核心接口行为)。 +- 升级后历史数据可继续使用,且升级过程可预期、可验证。 +- 关键链路(读取/刷新/删除/导出)在异常情况下返回结构稳定、可定位、且不泄露敏感信息。 +- 支持长期运行(7×24)与重启/升级后的稳定恢复。 + +--- + +## 3. 技术交付清单(必须完成) + +### 3.1 应用启动与运行形态 + +- 提供“Web 服务运行”能力:可启动并对外提供管理界面与接口服务。 +- 提供“后台刷新任务运行”能力:可在不依赖人工操作的情况下执行定时刷新。 +- 明确并落地“官方推荐运行方式”:保证在推荐方式下,定时刷新可用且可验证。 +- 提供运行状态可见性:管理员可以确认当前系统是否处于“可服务/可刷新”的正常状态。 + +### 3.2 定时刷新可用性与可验证 + +- 定时刷新具备以下能力: + - 启用/停用 + - 支持两类调度配置方式(按天数间隔、按表达式配置) + - 支持配置有效性校验与未来触发时间预览 +- 定时刷新运行必须可被验证: + - 可查看最近一次触发时间 + - 可查看触发来源标识(区分手动/全量/定时/重试) + - 可查看触发结果统计(总数/成功/失败) +- 避免重复触发与冲突: + - 同一周期内不应出现不可解释的重复执行 + - 支持在触发中对重复触发进行明确提示或拒绝 + +### 3.3 状态一致性与持久化(面向重启/扩容) + +- 对以下“影响安全或一致性”的状态提供一致性保障(重启/多实例下仍可用): + - 导出二次验证凭证与有效期管理 + - 登录失败计数、锁定状态与解锁时间 + - 定时刷新最近触发信息与执行中状态(如有) +- 以上状态需具备: + - 过期与清理策略 + - 诊断可见性(管理员能看到状态是否异常) + +### 3.4 错误模型与追踪标识统一 + +- 为所有接口返回提供统一的错误结构(至少包含错误编号、中文提示、追踪标识、可选的细节信息)。 +- 错误细节必须脱敏,禁止泄露令牌、密码、敏感凭据等内容。 +- 对“多方式回退”的失败信息必须能聚合展示,并可按方式分组呈现。 +- 未捕获异常必须被统一处理: + - 返回稳定的错误结构 + - 记录追踪标识,便于排查 + +### 3.5 安全能力交付(不降低现有安全基线) + +- 会话安全: + - 登录态具备合理的过期策略与安全属性 + - 在反向代理场景下来源标识(如 IP)获取行为可控且可解释 +- 输入与内容安全: + - 用户输入在展示与存储前必须符合“净化/长度限制/控制字符处理”等规则 + - 邮件 HTML 默认安全展示:禁止脚本执行与危险行为 + - “信任/原始内容”模式必须明确风险提示与默认关闭 +- 敏感数据保护: + - 敏感字段默认不在接口/界面中明文展示 + - 仅在导出且完成二次验证的场景允许输出敏感内容 + +### 3.6 数据兼容与升级保障 + +- 升级过程中支持对历史数据进行必要的兼容处理,确保升级后可继续使用: + - 账号、分组、标签、设置、刷新历史、审计记录等 +- 升级过程需具备可验证性: + - 可识别并提示升级/迁移是否完成 + - 失败时给出明确错误提示与恢复建议(至少包含追踪标识) +- 默认提供升级前备份提示与导出路径(面向管理员操作)。 + +### 3.7 刷新/导出/审计的行为一致性 + +- 刷新相关: + - 单个刷新、全量刷新、定时刷新、失败重试使用一致的结果记录口径 + - 刷新日志与统计对不同触发来源展示一致 +- 导出相关: + - 所有导出类型均必须经过二次验证 + - 导出文件命名规则一致且可识别(范围、时间) + - 导出行为必须产生审计记录 +- 审计相关: + - 敏感操作必须被记录(导出、删除、修改账号、修改分组、修改设置) + - 审计失败不影响主流程,但必须可被管理员发现 + +### 3.8 性能与容量保障 + +- 支持账号规模达到 1,000 的可用体验: + - 账号列表加载与筛选保持可用 + - 全量刷新过程持续输出进度,不出现长时间无反馈 +- 关键路径性能要求具备可验收口径: + - 明确“可接受时间”的定义与测试方式(验收用) + - 对慢查询/慢操作具备可定位信息(含追踪标识/操作类型) + +### 3.9 可观测与运维友好性 + +- 关键操作必须可被追踪与排障: + - 每次关键操作可关联追踪标识 + - 管理员可查看失败原因分类与建议动作 +- 提供基础健康状态能力: + - 能判断系统是否处于可用状态 + - 能判断定时刷新是否处于可用状态 + +### 3.10 自动化验证(回归保障) + +- 提供一组可重复执行的回归验证清单(面向发布): + - 登录/修改密码/退出 + - 分组增删改查与系统分组保护 + - 账号增删改查、批量导入、批量移动、状态启停 + - 标签增删与批量操作 + - 邮件三类文件夹读取、分页、详情、全屏、信任模式提示 + - 单个刷新、全量刷新、失败重试、定时刷新配置与运行证据 + - 单封/批量删除与二次确认 + - 导出三种模式与二次验证、审计记录生成 + - OAuth2 辅助流程与常见失败提示 + - 临时邮箱全流程与失败分类提示 +- 回归验证需覆盖“重启后再验证”的场景,确保状态与行为一致。 + +--- + +## 4. 交付验收(技术侧) + +- 上述 3.x 清单项逐条可验收,且能通过“运行证据/日志/界面信息/接口返回”进行确认。 +- 不新增或引入未在 PRD/FD 中声明的对外功能变更(除非补充 PRD/变更说明)。 + diff --git a/docs/TDD/TDD-00004-UI全局美化重设计.md b/docs/TDD/TDD-00004-UI全局美化重设计.md new file mode 100644 index 0000000..8e89bfb --- /dev/null +++ b/docs/TDD/TDD-00004-UI全局美化重设计.md @@ -0,0 +1,1440 @@ +# TDD-00004|UI 全局美化重设计 — 技术设计细节文档 + +- **文档编号**: TDD-00004 +- **创建日期**: 2026-03-01 +- **版本**: V1.0 +- **状态**: 草案 +- **对齐 PRD**: `docs/PRD/PRD-00004-UI全局美化重设计.md` +- **对齐 FD**: `docs/FD/FD-00004-UI全局美化重设计.md` +- **设计参考**: `exeample/tempmail/frontend/` + `ui-preview.html` + +--- + +## 目录 + +1. [文档目的](#1-文档目的) +2. [设计原则与硬约束](#2-设计原则与硬约束) +3. [技术架构设计](#3-技术架构设计) +4. [文件变更清单](#4-文件变更清单) +5. [CSS 设计系统详细规格](#5-css-设计系统详细规格) +6. [JS 函数签名设计](#6-js-函数签名设计) +7. [核心算法详细设计](#7-核心算法详细设计) +8. [DOM 结构规格](#8-dom-结构规格) +9. [事件流设计](#9-事件流设计) +10. [状态管理设计](#10-状态管理设计) +11. [性能优化策略](#11-性能优化策略) +12. [错误处理与边界情况](#12-错误处理与边界情况) +13. [迁移兼容性策略](#13-迁移兼容性策略) +14. [测试策略](#14-测试策略) + +--- + +## 1. 文档目的 + +本 TDD 用于描述 UI 全局美化重设计所需的技术实现细节。 + +重点回答: + +- 如何**完全不改动后端代码**,仅修改前端文件实现 UI 改造 +- 如何保证**所有 JS 函数接口兼容**(DOM ID、函数名、全局变量名均不变) +- 如何从旧的**四栏拖拽布局**迁移到**固定三栏 + 侧边栏**布局 +- 如何设计 CSS 变量体系支持**双主题(亮色/暗色)切换** +- 如何实现**现代国风**视觉设计系统 +- **关键算法**(token 状态计算、账号头像颜色生成、验证码提取)的精确实现 +- 哪些文件需要**完全重写**,哪些需要**部分修改**,哪些需要**删除** + +--- + +## 2. 设计原则与硬约束 + +### 2.1 后端零改动原则 + +- 不修改任何 Python 文件(`outlook_web/`、`web_outlook_app.py`、`start.py`) +- 不修改任何 Flask 路由、controller、service、repository +- 不修改 `requirements.txt`、`Dockerfile`、`.env` 配置 +- API 接口路径、请求格式、响应结构**一字不改** + +### 2.2 JS 函数接口兼容性原则 + +`templates/index.html` 内联脚本和 `static/js/features/*.js` 中存在大量全局函数和 DOM 操作,重建 HTML 骨架时必须保证: + +**所有被 JS 引用的 DOM element ID 不变(或在 JS 中同步更新):** + +| DOM ID | 当前位置 | 被哪些函数引用 | +|--------|---------|--------------| +| `currentAccount` | 顶栏 | `selectAccount()`, `copyCurrentEmail()` | +| `currentAccountEmail` | 顶栏 | `selectAccount()`, `copyCurrentEmail()` | +| `groupList` | 分组栏 | `renderGroupList()`, `loadGroups()` | +| `accountList` | 账号栏 | `renderAccountList()`, `loadAccountsByGroup()` | +| `emailList` | 邮件列表区 | `loadEmails()`, `renderEmailList()` | +| `emailDetail` | 邮件详情区 | `showEmailDetail()`, `selectAccount()` | +| `emailDetailToolbar` | 邮件详情工具栏 | `showEmailDetail()`, `selectAccount()` | +| `emailCount` | 邮件计数 | `loadEmails()`, `selectAccount()` | +| `methodTag` | API 方式标签 | `loadEmails()`, `selectAccount()` | +| `folderTabs` | 文件夹 Tab | `selectAccount()`, `loadEmails()` | +| `csrfToken` | meta tag | `initCSRFToken()` | +| `accountPanelTitle` | 账号栏标题 | `loadAccountsByGroup()` | + +**所有 CSS 类名选择器不变(或在 JS/HTML 中同步更新):** + +| CSS 选择器 | 被哪些 JS 引用 | +|-----------|--------------| +| `.account-item` | `selectAccount()`, `renderAccountList()` | +| `.account-item.active` | `selectAccount()` | +| `.account-email` | `selectAccount()` | +| `.folder-tab` | `selectAccount()`, `loadEmails()` | +| `.folder-tab.active` | `loadEmails()` | +| `.refresh-btn` | `loadEmails()` | +| `.loading`, `.loading-spinner` | `loadEmails()`, `loadGroups()` | + +### 2.3 渐进可回滚原则 + +- 每个 Phase 完成后必须可以独立运行并通过烟雾测试 +- 旧版 CSS 和 HTML 在实施前保留 Git 快照 + +### 2.4 技术栈不变原则 + +- 不引入任何 CSS 框架(Bootstrap、Tailwind) +- 不引入任何 JS 框架(React、Vue、jQuery) +- 所有实现用原生 HTML + CSS + JavaScript + +--- + +## 3. 技术架构设计 + +### 3.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser Rendering │ +│ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ CSS Layer (static/css/main.css - 单文件) │ │ +│ │ ├── :root CSS Variables (亮色主题) │ │ +│ │ ├── [data-theme="dark"] CSS Variables (暗色主题) │ │ +│ │ ├── Base Reset + Typography │ │ +│ │ ├── Sidebar Component │ │ +│ │ ├── Topbar Component │ │ +│ │ ├── Three-Column Layout │ │ +│ │ ├── Account Card Component │ │ +│ │ ├── Email List Component │ │ +│ │ ├── Email Detail Component │ │ +│ │ ├── Modal Component │ │ +│ │ ├── Toast Component │ │ +│ │ └── Responsive Breakpoints (@media) │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↕ DOM │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ HTML Layer (templates/index.html) │ │ +│ │ ├── meta + CSS refs │ │ +│ │ ├── #app-sidebar (左侧导航栏) │ │ +│ │ ├── .main-wrapper │ │ +│ │ │ ├── .topbar (顶栏) │ │ +│ │ │ └── .workspace (三栏区域) │ │ +│ │ │ ├── .col-groups (分组列) │ │ +│ │ │ ├── .col-accounts (账号列) │ │ +│ │ │ └── .col-email (邮件区,含列表+详情) │ │ +│ │ ├── Modals (所有模态框保持原有 ID) │ │ +│ │ └── + + + + + + + + + +
+ + +
+
+ +

邮件管理

+
+
+ + +
+
+ +
+
+ + +
+ +
+
+ 分组 + +
+
+
+ + +
+
+ 全部账号 + +
+
+
+ + + +
+ + + + + + + +
+ + + ... + + + {% include 'partials/scripts.html' %} + + + + + + +``` + +### 8.3 账号卡片 HTML 模板 + +由 `renderAccountList()` 在 JS 中动态生成: + +```html + +``` + +### 8.4 邮件列表行 HTML 模板 + +由 `renderEmailList()` 动态生成: + +```html + +``` + +--- + +## 9. 事件流设计 + +### 9.1 页面初始化流程 + +``` +DOMContentLoaded + │ + ├── initTheme() ← 读取 localStorage 'outlook_theme' 并应用 data-theme + ├── initCSRFToken() ← 读取 meta#csrfToken(现有函数,不变) + ├── loadGroups() ← 获取分组列表(现有函数,不变) + ├── loadSettings() ← 加载系统设置(现有函数,不变) + └── navigateTo('main') ← 初始化导航状态(新增) +``` + +### 9.2 账号选择事件流 + +``` +用户点击 .account-item + │ + └── selectAccount(email) ← features/accounts.js(接口不变) + ├── 更新 #currentAccount 显示 + ├── 更新 .account-item.active 类 + ├── 显示 #folderTabs + └── 检查缓存 → renderEmailList() 或 显示占位 + +用户点击"获取邮件"按钮 + │ + └── loadEmails(email, true) ← features/emails.js(接口不变) + ├── 显示 loading 状态 + ├── fetch /api/emails/{email} + ├── 更新 #emailCount, #methodTag + └── renderEmailList(emails) +``` + +### 9.3 主题切换事件流 + +``` +用户点击主题切换按钮 + │ + └── toggleTheme() + ├── 读取当前 data-theme 属性 + ├── 计算新主题 ('light' ↔ 'dark') + ├── document.documentElement.setAttribute('data-theme', newTheme) + ├── localStorage.setItem('outlook_theme', newTheme) + └── 更新切换按钮图标 (🌙 ↔ ☀️) +``` + +### 9.4 全量刷新进度条事件流 + +``` +用户点击"全量刷新 Token" + │ + └── showRefreshModal() ← 弹出确认框(现有函数) + │ + └── 确认后 startBatchRefresh() + ├── showRefreshProgress(totalCount) + ├── 循环刷新每个账号 + │ └── updateRefreshProgress(i, total, email) + └── hideRefreshProgress() +``` + +--- + +## 10. 状态管理设计 + +### 10.1 主题状态 + +| 存储位置 | Key | Value | 读取时机 | +|---------|-----|-------|---------| +| `localStorage` | `outlook_theme` | `"light"` \| `"dark"` | DOM ready 前执行 `initTheme()` | + +**初始化策略:** +- 在 `` 末尾(CSS 加载后)立即执行,避免闪烁(FOUC) +- 若 localStorage 无值,默认 `light` + +```html + + +``` + +### 10.2 导航状态 + +| 存储位置 | 形式 | 说明 | +|---------|------|------| +| JS 全局变量 | `let currentPage = 'main'` | 不持久化(刷新重置为 main) | + +### 10.3 布局面板状态(废弃) + +旧的拖拽布局状态(`outlook_layout_state_*`)在 `state-manager.js` 中管理,新布局不再使用面板宽度持久化。 + +**处理方式:** +- `state-manager.js` 保留文件(避免破坏其他可能引用它的代码) +- 在 `state-manager.js` 中注释掉 `save()` / `load()` 对布局面板宽度的操作 +- `layout-bootstrap.js` 中对 `StateManager.load()` 的调用随文件一起删除 + +### 10.4 全局 JS 变量(不变) + +以下变量在 `index.html` 内联脚本中定义,新版本必须保留: + +```javascript +let currentAccount = null; // 当前选中账号 +let currentGroupId = null; // 当前选中分组 ID +let currentFolder = 'inbox'; // 当前邮件文件夹 +let currentEmails = []; // 当前邮件列表 +let currentMethod = 'graph'; // 当前邮件 API 方式 +let currentSkip = 0; // 分页偏移 +let hasMoreEmails = false; // 是否有更多邮件 +let groups = []; // 分组列表 +let emailListCache = {}; // 邮件列表缓存 +let tempEmailGroupId = null; // 临时邮箱分组 ID +let isTempEmailGroup = false; // 当前是否在临时邮箱分组 +let selectedEmailIds = new Set(); // 批量选中的邮件 ID +let knownEmailIds = new Set(); // 轮询已知邮件 ID +``` + +--- + +## 11. 性能优化策略 + +### 11.1 CSS 渲染性能 + +| 策略 | 实现方式 | 目标 | +|------|---------|------| +| 硬件加速过渡 | 仅对 `transform`, `opacity` 使用 transition | 60fps 动画 | +| 避免重排 | 使用 `transform: translateX()` 代替修改 `width` | 侧边栏展开动画 | +| CSS 变量主题切换 | 修改 `data-theme` 属性,CSS 变量自动级联 | 零 JS 重排 | +| 列表渲染 | 批量拼接 HTML 字符串,一次 `innerHTML =` | 减少 DOM 操作次数 | + +### 11.2 滚动性能 + +```css +.col-groups, +.col-accounts, +.col-email-list, +.col-email-detail { + overflow-y: auto; + /* 启用 GPU 合成层,避免主线程卡顿 */ + will-change: transform; + /* iOS 动量滚动 */ + -webkit-overflow-scrolling: touch; +} +``` + +### 11.3 主题切换无闪烁(FOUC 防止) + +主题初始化脚本必须在 `` 末尾、所有 `` 内容渲染前同步执行: + +```html + + + + + + + +``` + +### 11.4 渲染批处理 + +账号卡片和邮件列表使用字符串拼接后一次设置 `innerHTML`: + +```javascript +// ✅ 正确:批量渲染 +function renderAccountList(accounts) { + const container = document.getElementById('accountList'); + // 一次性构建所有 HTML + container.innerHTML = accounts.map(account => { + const color = stringToColor(account.email); + const tokenStatus = getTokenStatus(account); + return ``; + }).join(''); +} + +// ❌ 避免:逐个 appendChild +``` + +--- + +## 12. 错误处理与边界情况 + +### 12.1 数据缺失降级策略 + +| 缺失字段 | 降级处理 | +|---------|---------| +| `account.email` 为空 | 头像显示 `?`,跳过点击事件 | +| `account.token_valid` 缺失 | `getTokenStatus()` 返回 `unknown` 状态 | +| `account.token_expires_at` 无效 | 降级为 `valid`(不显示过期)| +| 邮件 `subject` 为空 | 显示 `(无主题)` | +| 邮件 `from` 为空 | 显示 `未知发件人` | +| 邮件正文为空 | 详情区显示 `(此邮件没有内容)` | +| `DOMPurify` 未加载 | 用 `textContent` 赋值(兜底) | + +### 12.2 API 失败处理 + +API 失败时,渲染函数按以下方式降级显示: + +```javascript +// loadGroups() 失败时 +container.innerHTML = ` +
+
⚠️
+
加载失败,请刷新
+ +
+`; +``` + +### 12.3 主题 localStorage 失败 + +隐私浏览模式或 localStorage 被禁用时: + +```javascript +function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'light' ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', next); + try { + localStorage.setItem('outlook_theme', next); + } catch (_) { + // 静默失败,主题仍然切换,仅不持久化 + } +} +``` + +--- + +## 13. 迁移兼容性策略 + +### 13.1 分阶段实施顺序 + +**Phase 1:CSS 设计系统**(风险最低) + +- 完全重写 `static/css/main.css` +- 删除 `static/css/layout.css`(同时从 index.html 移除引用) +- 此阶段 HTML 结构不变,只是视觉风格刷新 +- 验收:页面可访问,无 JS 报错 + +**Phase 2:HTML 骨架重建**(核心风险) + +- 重写 `templates/index.html` 骨架(sidebar + topbar + 三栏) +- 保留所有 `id="..."` 属性值不变 +- 将原内联 JS 完整移植到新 HTML 的 ` + + + + + + \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov-report/layout-manager.js.html b/tests/layout-system/coverage/lcov-report/layout-manager.js.html new file mode 100644 index 0000000..cd5ac2f --- /dev/null +++ b/tests/layout-system/coverage/lcov-report/layout-manager.js.html @@ -0,0 +1,4306 @@ + + + + + + Code coverage report for layout-manager.js + + + + + + + + + +
+
+

All files layout-manager.js

+
+ +
+ 91.99% + Statements + 540/587 +
+ + +
+ 82.18% + Branches + 369/449 +
+ + +
+ 100% + Functions + 75/75 +
+ + +
+ 96.58% + Lines + 509/527 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321 +322 +323 +324 +325 +326 +327 +328 +329 +330 +331 +332 +333 +334 +335 +336 +337 +338 +339 +340 +341 +342 +343 +344 +345 +346 +347 +348 +349 +350 +351 +352 +353 +354 +355 +356 +357 +358 +359 +360 +361 +362 +363 +364 +365 +366 +367 +368 +369 +370 +371 +372 +373 +374 +375 +376 +377 +378 +379 +380 +381 +382 +383 +384 +385 +386 +387 +388 +389 +390 +391 +392 +393 +394 +395 +396 +397 +398 +399 +400 +401 +402 +403 +404 +405 +406 +407 +408 +409 +410 +411 +412 +413 +414 +415 +416 +417 +418 +419 +420 +421 +422 +423 +424 +425 +426 +427 +428 +429 +430 +431 +432 +433 +434 +435 +436 +437 +438 +439 +440 +441 +442 +443 +444 +445 +446 +447 +448 +449 +450 +451 +452 +453 +454 +455 +456 +457 +458 +459 +460 +461 +462 +463 +464 +465 +466 +467 +468 +469 +470 +471 +472 +473 +474 +475 +476 +477 +478 +479 +480 +481 +482 +483 +484 +485 +486 +487 +488 +489 +490 +491 +492 +493 +494 +495 +496 +497 +498 +499 +500 +501 +502 +503 +504 +505 +506 +507 +508 +509 +510 +511 +512 +513 +514 +515 +516 +517 +518 +519 +520 +521 +522 +523 +524 +525 +526 +527 +528 +529 +530 +531 +532 +533 +534 +535 +536 +537 +538 +539 +540 +541 +542 +543 +544 +545 +546 +547 +548 +549 +550 +551 +552 +553 +554 +555 +556 +557 +558 +559 +560 +561 +562 +563 +564 +565 +566 +567 +568 +569 +570 +571 +572 +573 +574 +575 +576 +577 +578 +579 +580 +581 +582 +583 +584 +585 +586 +587 +588 +589 +590 +591 +592 +593 +594 +595 +596 +597 +598 +599 +600 +601 +602 +603 +604 +605 +606 +607 +608 +609 +610 +611 +612 +613 +614 +615 +616 +617 +618 +619 +620 +621 +622 +623 +624 +625 +626 +627 +628 +629 +630 +631 +632 +633 +634 +635 +636 +637 +638 +639 +640 +641 +642 +643 +644 +645 +646 +647 +648 +649 +650 +651 +652 +653 +654 +655 +656 +657 +658 +659 +660 +661 +662 +663 +664 +665 +666 +667 +668 +669 +670 +671 +672 +673 +674 +675 +676 +677 +678 +679 +680 +681 +682 +683 +684 +685 +686 +687 +688 +689 +690 +691 +692 +693 +694 +695 +696 +697 +698 +699 +700 +701 +702 +703 +704 +705 +706 +707 +708 +709 +710 +711 +712 +713 +714 +715 +716 +717 +718 +719 +720 +721 +722 +723 +724 +725 +726 +727 +728 +729 +730 +731 +732 +733 +734 +735 +736 +737 +738 +739 +740 +741 +742 +743 +744 +745 +746 +747 +748 +749 +750 +751 +752 +753 +754 +755 +756 +757 +758 +759 +760 +761 +762 +763 +764 +765 +766 +767 +768 +769 +770 +771 +772 +773 +774 +775 +776 +777 +778 +779 +780 +781 +782 +783 +784 +785 +786 +787 +788 +789 +790 +791 +792 +793 +794 +795 +796 +797 +798 +799 +800 +801 +802 +803 +804 +805 +806 +807 +808 +809 +810 +811 +812 +813 +814 +815 +816 +817 +818 +819 +820 +821 +822 +823 +824 +825 +826 +827 +828 +829 +830 +831 +832 +833 +834 +835 +836 +837 +838 +839 +840 +841 +842 +843 +844 +845 +846 +847 +848 +849 +850 +851 +852 +853 +854 +855 +856 +857 +858 +859 +860 +861 +862 +863 +864 +865 +866 +867 +868 +869 +870 +871 +872 +873 +874 +875 +876 +877 +878 +879 +880 +881 +882 +883 +884 +885 +886 +887 +888 +889 +890 +891 +892 +893 +894 +895 +896 +897 +898 +899 +900 +901 +902 +903 +904 +905 +906 +907 +908 +909 +910 +911 +912 +913 +914 +915 +916 +917 +918 +919 +920 +921 +922 +923 +924 +925 +926 +927 +928 +929 +930 +931 +932 +933 +934 +935 +936 +937 +938 +939 +940 +941 +942 +943 +944 +945 +946 +947 +948 +949 +950 +951 +952 +953 +954 +955 +956 +957 +958 +959 +960 +961 +962 +963 +964 +965 +966 +967 +968 +969 +970 +971 +972 +973 +974 +975 +976 +977 +978 +979 +980 +981 +982 +983 +984 +985 +986 +987 +988 +989 +990 +991 +992 +993 +994 +995 +996 +997 +998 +999 +1000 +1001 +1002 +1003 +1004 +1005 +1006 +1007 +1008 +1009 +1010 +1011 +1012 +1013 +1014 +1015 +1016 +1017 +1018 +1019 +1020 +1021 +1022 +1023 +1024 +1025 +1026 +1027 +1028 +1029 +1030 +1031 +1032 +1033 +1034 +1035 +1036 +1037 +1038 +1039 +1040 +1041 +1042 +1043 +1044 +1045 +1046 +1047 +1048 +1049 +1050 +1051 +1052 +1053 +1054 +1055 +1056 +1057 +1058 +1059 +1060 +1061 +1062 +1063 +1064 +1065 +1066 +1067 +1068 +1069 +1070 +1071 +1072 +1073 +1074 +1075 +1076 +1077 +1078 +1079 +1080 +1081 +1082 +1083 +1084 +1085 +1086 +1087 +1088 +1089 +1090 +1091 +1092 +1093 +1094 +1095 +1096 +1097 +1098 +1099 +1100 +1101 +1102 +1103 +1104 +1105 +1106 +1107 +1108 +1109 +1110 +1111 +1112 +1113 +1114 +1115 +1116 +1117 +1118 +1119 +1120 +1121 +1122 +1123 +1124 +1125 +1126 +1127 +1128 +1129 +1130 +1131 +1132 +1133 +1134 +1135 +1136 +1137 +1138 +1139 +1140 +1141 +1142 +1143 +1144 +1145 +1146 +1147 +1148 +1149 +1150 +1151 +1152 +1153 +1154 +1155 +1156 +1157 +1158 +1159 +1160 +1161 +1162 +1163 +1164 +1165 +1166 +1167 +1168 +1169 +1170 +1171 +1172 +1173 +1174 +1175 +1176 +1177 +1178 +1179 +1180 +1181 +1182 +1183 +1184 +1185 +1186 +1187 +1188 +1189 +1190 +1191 +1192 +1193 +1194 +1195 +1196 +1197 +1198 +1199 +1200 +1201 +1202 +1203 +1204 +1205 +1206 +1207 +1208 +1209 +1210 +1211 +1212 +1213 +1214 +1215 +1216 +1217 +1218 +1219 +1220 +1221 +1222 +1223 +1224 +1225 +1226 +1227 +1228 +1229 +1230 +1231 +1232 +1233 +1234 +1235 +1236 +1237 +1238 +1239 +1240 +1241 +1242 +1243 +1244 +1245 +1246 +1247 +1248 +1249 +1250 +1251 +1252 +1253 +1254 +1255 +1256 +1257 +1258 +1259 +1260 +1261 +1262 +1263 +1264 +1265 +1266 +1267 +1268 +1269 +1270 +1271 +1272 +1273 +1274 +1275 +1276 +1277 +1278 +1279 +1280 +1281 +1282 +1283 +1284 +1285 +1286 +1287 +1288 +1289 +1290 +1291 +1292 +1293 +1294 +1295 +1296 +1297 +1298 +1299 +1300 +1301 +1302 +1303 +1304 +1305 +1306 +1307 +1308 +1309 +1310 +1311 +1312 +1313 +1314 +1315 +1316 +1317 +1318 +1319 +1320 +1321 +1322 +1323 +1324 +1325 +1326 +1327 +1328 +1329 +1330 +1331 +1332 +1333 +1334 +1335 +1336 +1337 +1338 +1339 +1340 +1341 +1342 +1343 +1344 +1345 +1346 +1347 +1348 +1349 +1350 +1351 +1352 +1353 +1354 +1355 +1356 +1357 +1358 +1359 +1360 +1361 +1362 +1363 +1364 +1365 +1366 +1367 +1368 +1369 +1370 +1371 +1372 +1373 +1374 +1375 +1376 +1377 +1378 +1379 +1380 +1381 +1382 +1383 +1384 +1385 +1386 +1387 +1388 +1389 +1390 +1391 +1392 +1393 +1394 +1395 +1396 +1397 +1398 +1399 +1400 +1401 +1402 +1403 +1404 +1405 +1406 +1407 +1408  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +12x +  +12x +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +12x +  +  +  +  +  +  +  +  +  +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +  +  +97x +97x +97x +97x +97x +97x +97x +97x +97x +  +  +97x +  +97x +  +97x +  +  +  +  +  +  +  +97x +  +  +97x +97x +  +  +291x +  +  +  +97x +97x +291x +  +  +  +97x +291x +291x +  +  +291x +291x +  +  +  +97x +291x +291x +  +  +  +97x +  +  +97x +97x +  +96x +  +  +96x +  +  +  +97x +97x +85x +  +97x +97x +  +  +  +97x +97x +85x +  +97x +97x +  +  +  +97x +97x +  +  +97x +  +  +  +  +  +  +  +97x +97x +86x +  +  +  +11x +  +11x +11x +11x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +194x +  +194x +  +31x +31x +13x +13x +  +  +  +36x +1x +1x +  +36x +  +  +2x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +43x +43x +129x +  +  +129x +  +129x +  +  +129x +  +  +129x +129x +  +  +  +129x +  +  +  +129x +  +  +43x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +142x +142x +568x +3x +3x +  +  +  +142x +  +142x +142x +142x +  +  +142x +  +  +142x +142x +  +  +  +142x +  +  +142x +142x +142x +  +  +  +142x +142x +142x +  +  +  +  +139x +  +  +  +  +  +  +  +43x +  +43x +43x +43x +  +  +  +  +  +  +  +36x +  +  +36x +  +  +  +  +  +  +  +  +  +  +  +  +97x +  +97x +97x +97x +  +1x +1x +  +  +1x +1x +  +  +1x +  +  +  +  +  +  +  +  +98x +  +  +98x +  +  +  +  +98x +98x +98x +  +98x +67x +67x +  +  +  +  +  +98x +98x +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +  +2x +  +  +  +  +2x +2x +2x +  +  +2x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +2x +  +  +2x +2x +2x +2x +  +  +  +  +  +2x +  +2x +2x +6x +6x +  +  +6x +6x +  +  +6x +  +  +6x +  +  +  +2x +2x +  +  +2x +  +  +  +  +  +  +  +1x +1x +  +1x +3x +3x +3x +  +  +3x +3x +3x +3x +3x +3x +  +  +  +3x +  +  +3x +1x +1x +  +2x +2x +  +  +  +  +  +  +  +  +  +  +9x +9x +  +9x +9x +  +9x +1x +1x +1x +1x +1x +  +  +8x +8x +8x +8x +  +  +  +  +  +  +  +  +1024x +  +389x +  +324x +  +310x +  +1x +  +  +  +  +  +  +  +  +9x +9x +9x +  +7x +7x +  +  +  +  +  +  +  +98x +  +98x +294x +294x +  +294x +  +  +294x +291x +291x +291x +291x +291x +291x +  +  +  +291x +  +  +  +  +291x +291x +291x +291x +  +291x +  +  +294x +294x +294x +294x +  +294x +  +  +  +  +  +  +  +  +5x +5x +5x +  +3x +3x +  +  +  +  +  +  +  +3x +2x +2x +  +1x +1x +  +  +  +  +  +  +  +  +  +  +165x +165x +  +165x +495x +495x +495x +  +495x +69x +69x +  +426x +  +  +  +  +  +  +  +  +  +165x +  +  +165x +165x +  +  +  +  +  +  +  +  +374x +  +165x +  +105x +  +103x +  +1x +  +  +  +  +  +  +  +  +  +59x +  +54x +  +2x +  +2x +  +1x +  +  +  +  +  +  +  +  +  +68x +63x +  +62x +62x +62x +  +61x +  +  +61x +61x +60x +  +  +  +61x +61x +61x +  +  +  +  +  +  +  +  +  +  +  +100x +300x +300x +  +300x +2x +  +298x +  +  +  +  +  +  +  +  +  +  +315x +315x +  +315x +315x +315x +315x +  +  +  +  +  +  +  +315x +315x +315x +  +315x +315x +315x +  +315x +  +  +  +  +  +  +  +  +  +  +50x +50x +50x +  +50x +50x +  +  +  +  +  +  +  +  +  +132x +  +  +  +  +  +1x +  +  +  +131x +  +  +  +  +  +  +  +  +  +  +  +  +52x +52x +  +  +50x +24x +24x +2x +2x +  +  +  +  +48x +26x +  +22x +  +  +48x +48x +48x +  +48x +48x +48x +48x +  +  +  +48x +  +  +  +48x +  +  +  +48x +22x +22x +22x +  +  +  +  +48x +22x +  +  +  +  +  +  +  +  +19x +19x +  +  +17x +  +17x +17x +17x +  +17x +17x +17x +17x +  +  +  +17x +  +  +  +17x +  +17x +12x +  +  +  +  +17x +12x +  +  +  +  +  +  +  +  +  +27x +27x +81x +81x +81x +74x +  +  +27x +  +  +  +  +  +  +  +  +4x +4x +4x +4x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +15x +15x +  +13x +3x +  +10x +  +  +  +  +  +  +  +9x +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +  +  +106x +  +106x +106x +  +106x +106x +106x +  +106x +6x +  +100x +  +  +106x +4x +  +102x +  +  +106x +3x +  +103x +  +  +  +  +  +  +  +  +13x +13x +  +  +13x +  +  +12x +  +11x +  +  +  +  +  +  +  +305x +305x +  +305x +  +304x +304x +  +5x +5x +  +  +  +  +  +  +  +  +9x +  +9x +9x +  +9x +9x +9x +  +9x +9x +9x +  +9x +9x +  +9x +  +9x +9x +9x +  +  +9x +9x +  +  +  +  +  +  +  +  +  +17x +  +16x +16x +16x +  +15x +15x +  +14x +  +12x +  +  +12x +12x +  +  +12x +12x +12x +12x +12x +12x +  +12x +12x +12x +  +12x +12x +  +12x +12x +  +  +  +  +  +  +  +50x +  +49x +  +49x +39x +30x +30x +  +30x +30x +  +  +  +  +  +30x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +13x +  +  +12x +9x +9x +  +9x +9x +  +  +  +  +  +9x +  +  +  +  +  +  +  +  +  +  +12x +12x +12x +  +12x +12x +12x +12x +  +  +12x +12x +  +12x +12x +12x +  +  +  +12x +12x +  +  +  +  +  +  +  +  +  +  +  +  +52x +52x +  +52x +52x +  +  +52x +  +  +52x +52x +52x +52x +52x +52x +52x +52x +  +  +52x +  +  +  +  +  +  +  +  +55x +55x +165x +110x +110x +  +55x +  +  +  +  +  +  +  +54x +53x +  +  +53x +1x +  +  +53x +52x +  +1x +  +  +  +  +  +  +  +52x +  +  +52x +52x +  +  +  +  +  +  +  +  +  +134x +  +134x +  +  +134x +134x +  +3x +2x +  +  +2x +1x +  +1x +1x +  +  +  +1x +  +  +  +  +  +  +  +  +804x +584x +584x +582x +582x +  +  +  +  +12x +  +  +12x +12x +  +  + 
/**
+ * LayoutManager - 可调整布局系统的前端管理器
+ *
+ * 职责(按文档逐步实现):
+ * - 绑定 resizer 拖动事件,实时更新 CSS 变量(TASK-02-002 ~)
+ * - 处理面板折叠/展开(TASK-03-001 ~)
+ * - 与 StateManager 协作进行状态持久化(TASK-04-001 ~)
+ * - 处理窗口断点自适应与键盘可访问性(TASK-06-001 ~)
+ *
+ * 当前文件实现范围:TASK-02-001(基础结构 + init + 宽度上下限 + 更新宽度)。
+ *
+ * 兼容性说明:
+ * - 浏览器环境:通过 <script> 引入后,LayoutManager 挂载到 window.LayoutManager。
+ * - 测试/Node 环境:如果存在 module.exports,则导出 LayoutManager 以便 Jest 直接 require。
+ */
+ 
+(function (global) {
+  'use strict';
+ 
+  // 为避免在 Jest/jsdom 多次 init() 时累积 beforeunload 监听器,这里用模块级变量记录最后一次绑定的处理函数。
+  let LAST_BEFORE_UNLOAD_HANDLER = null;
+  // 同上:避免多次 init() 叠加 window.resize 监听器导致测试/运行时重复执行。
+  let LAST_WINDOW_RESIZE_HANDLER = null;
+ 
+  /**
+   * 将 data-panel 值映射到 CSS 变量名。
+   * 注意:data-panel 使用复数(groups/accounts/emails),CSS 变量按 TDD 使用单数/固定命名。
+   */
+  const PANEL_WIDTH_VAR_MAP = {
+    groups: '--group-panel-width',
+    accounts: '--account-panel-width',
+    emails: '--email-list-panel-width'
+  };
+ 
+  /**
+   * 面板“布局生效宽度”CSS 变量映射。
+   *
+   * 说明:
+   * - width 变量(--*-panel-width)用于“保存的宽度”(用于展开恢复/状态持久化)。
+   * - layout-width 变量(--*-panel-layout-width)用于“当前参与 Grid 计算的宽度”。
+   *   折叠时该值会变为 0,从而触发 Grid 列宽动画,但保存宽度不丢失。
+   */
+  const PANEL_LAYOUT_WIDTH_VAR_MAP = {
+    groups: '--group-panel-layout-width',
+    accounts: '--account-panel-layout-width',
+    emails: '--email-list-panel-layout-width'
+  };
+ 
+  /**
+   * 面板“布局生效最小宽度”CSS 变量映射(折叠时需要允许列宽收缩到 0)
+   */
+  const PANEL_LAYOUT_MIN_WIDTH_VAR_MAP = {
+    groups: '--group-panel-layout-min-width',
+    accounts: '--account-panel-layout-min-width',
+    emails: '--email-list-panel-layout-min-width'
+  };
+ 
+  /**
+   * LayoutManager
+   */
+  class LayoutManager {
+    constructor() {
+      /** @type {boolean} 是否处于拖动中(后续任务使用) */
+      this.isResizing = false;
+ 
+      /** @type {HTMLElement|null} 主容器 */
+      this.container = null;
+ 
+      /** @type {Map<string, HTMLElement>} data-panel -> panel element */
+      this.panels = new Map();
+ 
+      /** @type {Map<string, HTMLElement>} data-resizer -> resizer element */
+      this.resizers = new Map();
+ 
+      /** @type {Map<string, HTMLElement>} data-panel -> collapsed indicator element */
+      this.indicators = new Map();
+ 
+      /** @type {any|null} 状态管理器实例(TASK-04-001) */
+      this.stateManager = null;
+ 
+      /** @type {{debounced: Function, flush: Function}|null} 保存防抖器(TASK-04-002) */
+      this.saveDebouncer = null;
+ 
+      /** @type {HTMLElement|null} 当前正在调整的面板 */
+      this.currentPanel = null;
+ 
+      /** @type {HTMLElement|null} 当前正在拖动的 resizer */
+      this.currentResizer = null;
+ 
+      /** @type {string|null} 当前正在调整的面板类型(data-panel 值) */
+      this.currentPanelType = null;
+ 
+      /** @type {number} 拖动起始 X 坐标 */
+      this.startX = 0;
+ 
+      /** @type {number} 拖动起始宽度(px) */
+      this.startWidth = 0;
+ 
+      /** @type {number} requestAnimationFrame id */
+      this.rafId = 0;
+ 
+      /** @type {number} resize 期间最新的 clientX */
+      this.pendingClientX = 0;
+ 
+      // 绑定事件处理函数(避免反复 bind 造成性能开销)
+      this._onStartResize = this.startResize.bind(this);
+      this._onResize = this.resize.bind(this);
+      this._onStopResize = this.stopResize.bind(this);
+      this._onCollapseClick = this.onCollapseButtonClick.bind(this);
+      this._onIndicatorClick = this.onIndicatorClick.bind(this);
+      this._onIndicatorKeydown = this.onIndicatorKeydown.bind(this);
+      this._onBeforeUnload = this.saveStateNow.bind(this);
+      this._onResizerKeydown = this.onResizerKeydown.bind(this);
+      this._onWindowResize = this.onWindowResize.bind(this);
+ 
+      // 初始化状态管理器(浏览器环境优先使用 window.StateManager;Node/Jest 环境回退 require)
+      this.stateManager = this.createStateManager();
+      // 初始化防抖保存(拖动结束 500ms 后保存)
+      this.saveDebouncer = this.debounce(() => this.saveState(), 500);
+      // 窗口 resize 防抖(200ms,FD F5.1)
+      this.windowResizeDebouncer = this.debounce(() => this.handleWindowResize(), 200);
+    }
+ 
+    /**
+     * 初始化(缓存 DOM 引用;事件绑定在后续 TASK 中实现)
+     * @returns {LayoutManager}
+     */
+    init() {
+      this.container = document.querySelector('.main-container');
+ 
+      // 缓存可调整面板
+      this.panels.clear();
+      document
+        .querySelectorAll('.resizable-panel[data-panel]')
+        .forEach((panel) => {
+          this.panels.set(panel.dataset.panel, panel);
+        });
+ 
+      // 缓存 resizer
+      this.resizers.clear();
+      document.querySelectorAll('.resizer[data-resizer]').forEach((resizer) => {
+        this.resizers.set(resizer.dataset.resizer, resizer);
+      });
+ 
+      // 绑定 resizer 的 mousedown 事件(mousemove/mouseup 在 startResize 中绑定到 document)
+      this.resizers.forEach((resizer) => {
+        resizer.removeEventListener('mousedown', this._onStartResize);
+        resizer.addEventListener('mousedown', this._onStartResize);
+ 
+        // 键盘支持(F6.1):方向键调整宽度
+        resizer.removeEventListener('keydown', this._onResizerKeydown);
+        resizer.addEventListener('keydown', this._onResizerKeydown);
+      });
+ 
+      // 绑定折叠按钮点击事件(TASK-03-002)
+      document.querySelectorAll('.collapse-btn[data-panel]').forEach((btn) => {
+        btn.removeEventListener('click', this._onCollapseClick);
+        btn.addEventListener('click', this._onCollapseClick);
+      });
+ 
+      // 初始化折叠指示器(TASK-03-004)
+      this.ensureCollapsedIndicators();
+ 
+      // 加载保存的状态(TASK-04-003)
+      const loaded = this.loadState();
+      if (!loaded) {
+        // 初始化“布局生效”CSS 变量(避免折叠/展开后在无 CSS 环境下状态不一致;也便于测试)
+        this.syncAllPanelsLayoutVars();
+ 
+        // 初始化指示器偏移(避免多个折叠指示器重叠)
+        this.updateCollapsedIndicatorOffsets();
+      }
+ 
+      // 页面卸载时强制保存(TASK-04-002)
+      Eif (global && typeof global.addEventListener === 'function') {
+        if (LAST_BEFORE_UNLOAD_HANDLER) {
+          global.removeEventListener('beforeunload', LAST_BEFORE_UNLOAD_HANDLER);
+        }
+        LAST_BEFORE_UNLOAD_HANDLER = this._onBeforeUnload;
+        global.addEventListener('beforeunload', LAST_BEFORE_UNLOAD_HANDLER);
+      }
+ 
+      // 窗口尺寸适配(F5):resize 监听 + 初始适配
+      Eif (global && typeof global.addEventListener === 'function') {
+        if (LAST_WINDOW_RESIZE_HANDLER) {
+          global.removeEventListener('resize', LAST_WINDOW_RESIZE_HANDLER);
+        }
+        LAST_WINDOW_RESIZE_HANDLER = this._onWindowResize;
+        global.addEventListener('resize', LAST_WINDOW_RESIZE_HANDLER);
+      }
+ 
+      // 初始适配:避免页面加载时闪烁
+      this.withNoTransition(() => {
+        this.handleWindowResize();
+      });
+ 
+      return this;
+    }
+ 
+    /**
+     * 创建 StateManager 实例(存在则返回,否则返回 null)
+     * @returns {any|null}
+     */
+    createStateManager() {
+      try {
+        if (global && typeof global.StateManager === 'function') {
+          return new global.StateManager();
+        }
+ 
+        // Jest/Node 环境下可直接 require(浏览器中 require 不存在)
+        Eif (typeof require !== 'undefined') {
+          // eslint-disable-next-line global-require, import/no-dynamic-require
+          const StateManagerCtor = require('./state-manager.js');
+          Eif (typeof StateManagerCtor === 'function') {
+            return new StateManagerCtor();
+          }
+        }
+      } catch (e) {
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[LayoutManager] createStateManager failed:', e);
+        } catch (_) {
+          // ignore
+        }
+      }
+      return null;
+    }
+ 
+    /**
+     * 防抖工具(返回 { debounced, flush }),用于减少 localStorage 写入次数
+     * @param {Function} func
+     * @param {number} delay
+     * @returns {{debounced: Function, flush: Function}}
+     */
+    debounce(func, delay) {
+      let timerId = null;
+ 
+      return {
+        debounced: (...args) => {
+          if (timerId) clearTimeout(timerId);
+          timerId = setTimeout(() => {
+            timerId = null;
+            func(...args);
+          }, delay);
+        },
+        flush: (...args) => {
+          if (timerId) {
+            clearTimeout(timerId);
+            timerId = null;
+          }
+          func(...args);
+        },
+        cancel: () => {
+          if (timerId) {
+            clearTimeout(timerId);
+            timerId = null;
+          }
+        }
+      };
+    }
+ 
+    /**
+     * 构建当前布局状态对象(LayoutState)
+     * @param {string} userId
+     * @returns {Record<string, any>}
+     */
+    buildLayoutState(userId) {
+      const panels = {};
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+ 
+        // width:始终保存“保存宽度”变量(不是 0px 的 layout-width)
+        const widthVar = PANEL_WIDTH_VAR_MAP[panelType];
+        const rawWidth =
+          (widthVar &&
+            getComputedStyle(document.documentElement).getPropertyValue(widthVar)) ||
+          '';
+        const parsedWidth = this.parsePx(rawWidth);
+ 
+        // 若无法解析,则回退到默认值(与 layout.css 一致)
+        const defaultWidthMap = { groups: 200, accounts: 260, emails: 380 };
+        const width = `${parsedWidth !== null ? parsedWidth : defaultWidthMap[panelType]}px`;
+ 
+        // collapsed:仅保存用户手动折叠状态;自动折叠(data-autoCollapsed)不持久化
+        const isCollapsed =
+          !!panel &&
+          panel.classList.contains('collapsed') &&
+          panel.dataset.autoCollapsed !== 'true';
+ 
+        panels[panelType] = { width, collapsed: isCollapsed };
+      });
+ 
+      return {
+        version: '1.1',
+        userId: userId || 'guest',
+        timestamp: Date.now(),
+        panels
+      };
+    }
+ 
+    /**
+     * 获取当前用户 ID(TASK-04-004)
+     *
+     * 说明(按 FD F4.3 / PRD 3.4.3):
+     * - localStorage key 需要包含用户 ID,以支持同一浏览器内的多用户隔离。
+     * - 当前项目未必有完整的用户系统,因此这里提供多种“可选来源”,若均不可用则回退为 guest。
+     *
+     * 来源优先级:
+     * 1) 页面元素:<body data-user-id="..."> 或 <html data-user-id="...">
+     * 2) 全局变量:window.currentUserId
+     * 3) meta:<meta name="outlook-user-id" content="...">
+     *
+     * @returns {string|null}
+     */
+    getCurrentUserId() {
+      try {
+        const normalize = (value) => {
+          if (value === null || value === undefined) return null;
+          const text = String(value).trim();
+          return text ? text : null;
+        };
+ 
+        // 1) data-user-id(推荐:无需内联脚本即可由后端模板注入)
+        Eif (typeof document !== 'undefined') {
+          const bodyId =
+            document.body && document.body.dataset ? document.body.dataset.userId : null;
+          const normalizedBodyId = normalize(bodyId);
+          Iif (normalizedBodyId) return normalizedBodyId;
+ 
+          const htmlId =
+            document.documentElement && document.documentElement.dataset
+              ? document.documentElement.dataset.userId
+              : null;
+          const normalizedHtmlId = normalize(htmlId);
+          Iif (normalizedHtmlId) return normalizedHtmlId;
+ 
+          // 3) meta
+          const meta =
+            typeof document.querySelector === 'function'
+              ? document.querySelector('meta[name="outlook-user-id"]')
+              : null;
+          const metaId = meta ? meta.getAttribute('content') : null;
+          const normalizedMetaId = normalize(metaId);
+          Iif (normalizedMetaId) return normalizedMetaId;
+        }
+ 
+        // 2) 全局变量
+        const globalId = global ? global.currentUserId : null;
+        const normalizedGlobalId = normalize(globalId);
+        if (normalizedGlobalId) return normalizedGlobalId;
+      } catch (_) {
+        // ignore
+      }
+ 
+      return null;
+    }
+ 
+    /**
+     * 保存当前布局状态到 localStorage(TASK-04-002)
+     * @returns {boolean}
+     */
+    saveState() {
+      Iif (!this.stateManager || typeof this.stateManager.save !== 'function') return false;
+ 
+      const userId = this.getCurrentUserId() || 'guest';
+      const state = this.buildLayoutState(userId);
+      return !!this.stateManager.save(userId, state);
+    }
+ 
+    /**
+     * 立即保存(用于 beforeunload / 折叠等需要“立刻落盘”的场景)
+     * @returns {boolean}
+     */
+    saveStateNow() {
+      Iif (!this.saveDebouncer) return this.saveState();
+ 
+      // flush 会清理 pending timer,并立即执行保存
+      return !!this.saveDebouncer.flush();
+    }
+ 
+    /**
+     * 从 localStorage 加载并应用状态(TASK-04-003)
+     *
+     * 说明:
+     * - StateManager 会负责 validate/migrate;此处只应用已验证的数据。
+     * - 恢复时应禁用动画,避免页面加载时闪烁(FD F4.2)。
+     *
+     * @returns {boolean} 是否成功加载并应用
+     */
+    loadState() {
+      Iif (!this.stateManager || typeof this.stateManager.load !== 'function') return false;
+ 
+      const userId = this.getCurrentUserId() || 'guest';
+      const state = this.stateManager.load(userId);
+      if (!state || !state.panels) return false;
+ 
+      this.withNoTransition(() => {
+        this.applyLayoutState(state);
+ 
+        // 应用完 collapsed 类后,再同步布局变量与指示器偏移(避免过渡动画)
+        this.syncAllPanelsLayoutVars();
+        this.updateCollapsedIndicatorOffsets();
+      });
+ 
+      return true;
+    }
+ 
+    /**
+     * 临时禁用布局相关 transition,用于状态恢复时避免闪烁
+     * @param {Function} fn
+     */
+    withNoTransition(fn) {
+      const root =
+        typeof document !== 'undefined' && document.documentElement
+          ? document.documentElement
+          : null;
+      Iif (!root || !root.classList) {
+        fn();
+        return;
+      }
+ 
+      root.classList.add('layout-no-transition');
+      try {
+        fn();
+      } finally {
+        const remove = () => {
+          try {
+            root.classList.remove('layout-no-transition');
+          } catch (_) {
+            // ignore
+          }
+        };
+ 
+        if (typeof requestAnimationFrame === 'function') {
+          requestAnimationFrame(remove);
+        } else E{
+          setTimeout(remove, 0);
+        }
+      }
+    }
+ 
+    /**
+     * 临时开启“重置动画”时长(300ms),用于恢复默认布局(FD F3.4)
+     * @param {Function} fn
+     */
+    withResettingAnimation(fn) {
+      const root =
+        typeof document !== 'undefined' && document.documentElement
+          ? document.documentElement
+          : null;
+      Iif (!root || !root.classList) {
+        fn();
+        return;
+      }
+ 
+      root.classList.add('layout-resetting');
+      try {
+        fn();
+      } finally {
+        // 保持一小段时间,确保过渡动画完整执行;不依赖 transitionend(避免多属性触发复杂度)
+        setTimeout(() => {
+          try {
+            root.classList.remove('layout-resetting');
+          } catch (_) {
+            // ignore
+          }
+        }, 350);
+      }
+    }
+ 
+    /**
+     * 恢复默认布局(F3)
+     *
+     * 说明:
+     * - 清除 localStorage 中保存的布局状态
+     * - 恢复默认宽度(200/260/380)并展开所有侧边栏面板
+     * - 不主动写回 localStorage(保持“清除状态”的语义)
+     *
+     * @returns {void}
+     */
+    resetLayout() {
+      // 避免 reset 过程中触发“拖动后 500ms 保存”的残留定时器把默认值又写回 storage
+      Eif (this.saveDebouncer && typeof this.saveDebouncer.cancel === 'function') {
+        this.saveDebouncer.cancel();
+      }
+ 
+      const userId = this.getCurrentUserId() || 'guest';
+      try {
+        Eif (this.stateManager && typeof this.stateManager.clear === 'function') {
+          this.stateManager.clear(userId);
+        }
+      } catch (_) {
+        // ignore
+      }
+ 
+      const defaultWidthMap = { groups: 200, accounts: 260, emails: 380 };
+ 
+      this.withResettingAnimation(() => {
+        ['groups', 'accounts', 'emails'].forEach((panelType) => {
+          const panel = this.panels.get(panelType);
+          Iif (!panel) return;
+ 
+          // 展开所有面板,并清理自动折叠标记
+          delete panel.dataset.autoCollapsed;
+          panel.classList.remove('collapsed');
+ 
+          // 恢复默认宽度(同时更新 aria-valuenow)
+          this.updatePanelWidth(panel, defaultWidthMap[panelType]);
+ 
+          // 同步按钮的 ARIA/图标(避免依赖 click 流程导致保存)
+          this.updateCollapseButtonState(panelType, false);
+        });
+ 
+        // 同步布局变量与指示器偏移(确保 Grid 列宽立即恢复)
+        this.syncAllPanelsLayoutVars();
+        this.updateCollapsedIndicatorOffsets();
+      });
+ 
+      this.notify('已恢复默认布局', 'success');
+    }
+ 
+    /**
+     * 将已加载的 LayoutState 应用到 DOM/CSS 变量(不触发保存)
+     * @param {Record<string, any>} state
+     */
+    applyLayoutState(state) {
+      const panels = state && state.panels ? state.panels : null;
+      Iif (!panels) return;
+ 
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+        const panelState = panels[panelType];
+        Iif (!panel || !panelState) return;
+ 
+        // width:恢复保存宽度(并裁剪到最小/最大范围,避免异常数据破坏布局)
+        const parsedWidth = this.parsePx(panelState.width);
+        Eif (parsedWidth !== null) {
+          const min = this.getMinWidth(panelType);
+          const max = this.getMaxWidth(panelType);
+          const clamped = Math.max(min, Math.min(max, parsedWidth));
+          this.updatePanelWidth(panel, clamped);
+        }
+ 
+        // 状态恢复属于用户偏好:移除 autoCollapsed 标记(后续窗口适配会重新打标)
+        delete panel.dataset.autoCollapsed;
+ 
+        // collapsed:只恢复用户手动折叠状态(StateManager 已保证是 boolean)
+        if (panelState.collapsed) {
+          panel.classList.add('collapsed');
+          this.updateCollapseButtonState(panelType, true);
+        } else {
+          panel.classList.remove('collapsed');
+          this.updateCollapseButtonState(panelType, false);
+        }
+      });
+    }
+ 
+    /**
+     * 更新折叠按钮的图标与 ARIA(用于状态恢复,不触发保存/焦点移动)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @param {boolean} collapsed
+     */
+    updateCollapseButtonState(panelType, collapsed) {
+      const panel = this.panels.get(panelType);
+      Iif (!panel) return;
+ 
+      const btn = panel.querySelector('.collapse-btn');
+      Iif (!btn) return;
+ 
+      if (collapsed) {
+        btn.setAttribute('aria-expanded', 'false');
+        btn.setAttribute('aria-label', `展开${this.getPanelDisplayName(panelType)}面板`);
+        btn.setAttribute('title', `展开${this.getPanelDisplayName(panelType)}面板`);
+        btn.textContent = '→';
+        return;
+      }
+ 
+      btn.setAttribute('aria-expanded', 'true');
+      btn.setAttribute('aria-label', `折叠${this.getPanelDisplayName(panelType)}面板`);
+      btn.setAttribute('title', `折叠${this.getPanelDisplayName(panelType)}面板`);
+      btn.textContent = '←';
+    }
+ 
+    /**
+     * 获取面板中文名称(用于 aria-label / 提示文案)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @returns {string}
+     */
+    getPanelDisplayName(panelType) {
+      switch (panelType) {
+        case 'groups':
+          return '分组';
+        case 'accounts':
+          return '账号';
+        case 'emails':
+          return '邮件列表';
+        default:
+          return '面板';
+      }
+    }
+ 
+    /**
+     * 折叠按钮点击处理
+     * @param {MouseEvent} e
+     */
+    onCollapseButtonClick(e) {
+      const btn = e && e.currentTarget ? e.currentTarget : null;
+      const panelType = btn && btn.dataset ? btn.dataset.panel : null;
+      if (!panelType) return;
+ 
+      e.preventDefault();
+      this.togglePanel(panelType);
+    }
+ 
+    /**
+     * 确保三侧边栏面板都存在折叠指示器(在 HTML 未提供时由 JS 动态创建)
+     * @returns {void}
+     */
+    ensureCollapsedIndicators() {
+      this.indicators.clear();
+ 
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+        Iif (!panel) return;
+ 
+        let indicator = panel.querySelector(
+          `.collapsed-indicator[data-panel="${panelType}"]`
+        );
+        if (!indicator) {
+          indicator = document.createElement('div');
+          indicator.className = 'collapsed-indicator';
+          indicator.dataset.panel = panelType;
+          indicator.setAttribute('role', 'button');
+          indicator.setAttribute('tabindex', '0');
+          indicator.setAttribute(
+            'aria-label',
+            `展开${this.getPanelDisplayName(panelType)}面板`
+          );
+          indicator.setAttribute(
+            'title',
+            `展开${this.getPanelDisplayName(panelType)}面板`
+          );
+ 
+          const text = document.createElement('span');
+          text.className = 'indicator-text';
+          text.textContent = this.getPanelDisplayName(panelType);
+          indicator.appendChild(text);
+ 
+          panel.appendChild(indicator);
+        }
+ 
+        indicator.removeEventListener('click', this._onIndicatorClick);
+        indicator.addEventListener('click', this._onIndicatorClick);
+        indicator.removeEventListener('keydown', this._onIndicatorKeydown);
+        indicator.addEventListener('keydown', this._onIndicatorKeydown);
+ 
+        this.indicators.set(panelType, indicator);
+      });
+    }
+ 
+    /**
+     * 折叠指示器点击:展开对应面板
+     * @param {MouseEvent} e
+     */
+    onIndicatorClick(e) {
+      const indicator = e && e.currentTarget ? e.currentTarget : null;
+      const panelType = indicator && indicator.dataset ? indicator.dataset.panel : null;
+      if (!panelType) return;
+ 
+      e.preventDefault();
+      this.expandPanel(panelType);
+    }
+ 
+    /**
+     * 折叠指示器键盘支持:Enter/Space 展开
+     * @param {KeyboardEvent} e
+     */
+    onIndicatorKeydown(e) {
+      if (!e) return;
+      const key = e.key;
+      if (key !== 'Enter' && key !== ' ') return;
+ 
+      e.preventDefault();
+      this.onIndicatorClick({
+        currentTarget: e.currentTarget,
+        preventDefault: () => {}
+      });
+    }
+ 
+    /**
+     * 更新折叠指示器偏移,避免多个折叠指示器在同一 x 坐标重叠
+     * @returns {void}
+     */
+    updateCollapsedIndicatorOffsets() {
+      const indicatorWidth = this.getCollapsedIndicatorWidth();
+      let offset = 0;
+ 
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+        const indicator = this.indicators.get(panelType);
+        Iif (!panel || !indicator) return;
+ 
+        if (panel.classList.contains('collapsed')) {
+          indicator.style.setProperty('--collapsed-indicator-offset', `${offset}px`);
+          offset += indicatorWidth;
+        } else {
+          indicator.style.removeProperty('--collapsed-indicator-offset');
+        }
+      });
+    }
+ 
+    /**
+     * 获取折叠指示器宽度(px)
+     * @returns {number}
+     */
+    getCollapsedIndicatorWidth() {
+      const raw = getComputedStyle(document.documentElement).getPropertyValue(
+        '--panel-collapsed-indicator-width'
+      );
+      const parsed = this.parsePx(raw);
+      return parsed !== null ? parsed : 48;
+    }
+ 
+    /**
+     * 获取面板最小宽度(px)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @returns {number}
+     */
+    getMinWidth(panelType) {
+      switch (panelType) {
+        case 'groups':
+          return 150;
+        case 'accounts':
+          return 180;
+        case 'emails':
+          return 280;
+        default:
+          return 150;
+      }
+    }
+ 
+    /**
+     * 获取面板最大宽度(px)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @returns {number}
+     */
+    getMaxWidth(panelType) {
+      switch (panelType) {
+        case 'groups':
+          return 400;
+        case 'accounts':
+          return 500;
+        case 'emails':
+          return 600;
+        default:
+          return 600;
+      }
+    }
+ 
+    /**
+     * 更新面板宽度(通过 CSS 变量)
+     * @param {HTMLElement} panel 目标面板元素(必须包含 data-panel)
+     * @param {number} width 新宽度(px)
+     */
+    updatePanelWidth(panel, width) {
+      if (!panel || !panel.dataset || !panel.dataset.panel) return;
+      if (typeof width !== 'number' || Number.isNaN(width)) return;
+ 
+      const panelType = panel.dataset.panel;
+      const cssVarName = PANEL_WIDTH_VAR_MAP[panelType];
+      if (!cssVarName) return;
+ 
+      document.documentElement.style.setProperty(cssVarName, `${width}px`);
+ 
+      // 如果面板当前是展开状态,同步更新“布局生效宽度”变量,保证 Grid 立即生效
+      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
+      if (layoutWidthVar && !panel.classList.contains('collapsed')) {
+        document.documentElement.style.setProperty(layoutWidthVar, `${width}px`);
+      }
+ 
+      // 同步更新 resizer 的 ARIA(便于后续键盘支持与可访问性测试)
+      const resizer = this.resizers.get(panelType);
+      Eif (resizer) {
+        resizer.setAttribute('aria-valuenow', String(Math.round(width)));
+      }
+    }
+ 
+    /**
+     * 同步所有面板的“布局生效宽度/最小宽度”CSS 变量(TASK-03-003)
+     *
+     * 目的:
+     * - 折叠时 Grid 列宽应允许收缩到 0(因此 layout-min-width 需要变为 0)
+     * - 展开时 Grid 列宽应恢复为保存宽度(layout-width = saved width)
+     */
+    syncAllPanelsLayoutVars() {
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+        Iif (!panel) return;
+ 
+        if (panel.classList.contains('collapsed')) {
+          this.applyCollapsedLayoutVars(panelType);
+        } else {
+          this.applyExpandedLayoutVars(panelType);
+        }
+      });
+    }
+ 
+    /**
+     * 获取“保存宽度”CSS 变量值(如 "200px")
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @returns {string|null}
+     */
+    getSavedWidthCssValue(panelType) {
+      const varName = PANEL_WIDTH_VAR_MAP[panelType];
+      Iif (!varName) return null;
+ 
+      const raw = getComputedStyle(document.documentElement).getPropertyValue(varName);
+      const parsed = this.parsePx(raw);
+      Iif (parsed === null) return null;
+      return `${parsed}px`;
+    }
+ 
+    /**
+     * 应用展开状态下的布局变量(layout-width/layout-min-width)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    applyExpandedLayoutVars(panelType) {
+      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
+      const layoutMinVar = PANEL_LAYOUT_MIN_WIDTH_VAR_MAP[panelType];
+      Iif (!layoutWidthVar || !layoutMinVar) return;
+ 
+      const savedWidth = this.getSavedWidthCssValue(panelType);
+      Eif (savedWidth) {
+        document.documentElement.style.setProperty(layoutWidthVar, savedWidth);
+      }
+      document.documentElement.style.setProperty(
+        layoutMinVar,
+        `${this.getMinWidth(panelType)}px`
+      );
+    }
+ 
+    /**
+     * 应用折叠状态下的布局变量(layout-width/layout-min-width)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    applyCollapsedLayoutVars(panelType) {
+      const layoutWidthVar = PANEL_LAYOUT_WIDTH_VAR_MAP[panelType];
+      const layoutMinVar = PANEL_LAYOUT_MIN_WIDTH_VAR_MAP[panelType];
+      Iif (!layoutWidthVar || !layoutMinVar) return;
+ 
+      document.documentElement.style.setProperty(layoutWidthVar, '0px');
+      document.documentElement.style.setProperty(layoutMinVar, '0px');
+    }
+ 
+    /**
+     * 获取当前参与布局计算的面板宽度(px)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @param {HTMLElement} panelElement
+     * @returns {number}
+     */
+    getPanelLayoutWidth(panelType, panelElement) {
+      if (
+        panelElement &&
+        panelElement.classList &&
+        (panelElement.classList.contains('collapsed') ||
+          panelElement.classList.contains('hidden'))
+      ) {
+        return 0;
+      }
+ 
+      // 展开状态下,“保存宽度”就是当前生效宽度(layout-width 主要用于折叠时置 0)
+      return this.getPanelWidth(panelType, panelElement);
+    }
+ 
+    /**
+     * 折叠指定面板
+     *
+     * 注意:折叠规则(至少保留一个面板展开)、自动折叠标记(data-autoCollapsed)、
+     * 指示条(collapsed-indicator)等在后续 TASK 中实现。
+     *
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @param {boolean} [auto=false] 预留参数(后续用于标记自动折叠)
+     */
+    collapsePanel(panelType, auto = false) {
+      const panel = this.panels.get(panelType);
+      if (!panel) return;
+ 
+      // 折叠规则:至少保持一个侧边栏面板展开(仅限制用户手动折叠;自动折叠可例外)
+      if (!auto && !panel.classList.contains('collapsed')) {
+        const expandedPanels = this.getExpandedPanels();
+        if (expandedPanels.length <= 1 && expandedPanels[0] === panelType) {
+          this.notify('至少需要保持一个面板展开', 'warning');
+          return;
+        }
+      }
+ 
+      // 标记自动折叠(用于窗口适配时的“仅恢复自动折叠”逻辑,TASK-03-007)
+      if (auto) {
+        panel.dataset.autoCollapsed = 'true';
+      } else {
+        delete panel.dataset.autoCollapsed;
+      }
+ 
+      panel.classList.add('collapsed');
+      this.applyCollapsedLayoutVars(panelType);
+      this.updateCollapsedIndicatorOffsets();
+ 
+      const btn = panel.querySelector('.collapse-btn');
+      Eif (btn) {
+        btn.setAttribute('aria-expanded', 'false');
+        btn.setAttribute(
+          'aria-label',
+          `展开${this.getPanelDisplayName(panelType)}面板`
+        );
+        btn.setAttribute(
+          'title',
+          `展开${this.getPanelDisplayName(panelType)}面板`
+        );
+        btn.textContent = '→';
+      }
+ 
+      // 焦点管理:用户手动折叠后,将焦点移到指示器(自动折叠不抢焦点)
+      if (!auto) {
+        const indicator = this.indicators.get(panelType);
+        Eif (indicator && typeof indicator.focus === 'function') {
+          indicator.focus();
+        }
+      }
+ 
+      // 折叠后立即保存(自动折叠不保存,TASK-04-002 / FD F5.2)
+      if (!auto) {
+        this.saveStateNow();
+      }
+    }
+ 
+    /**
+     * 展开指定面板
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    expandPanel(panelType, auto = false) {
+      const panel = this.panels.get(panelType);
+      if (!panel) return;
+ 
+      // 展开时移除自动折叠标记(TASK-03-007)
+      delete panel.dataset.autoCollapsed;
+ 
+      panel.classList.remove('collapsed');
+      this.applyExpandedLayoutVars(panelType);
+      this.updateCollapsedIndicatorOffsets();
+ 
+      const btn = panel.querySelector('.collapse-btn');
+      Eif (btn) {
+        btn.setAttribute('aria-expanded', 'true');
+        btn.setAttribute(
+          'aria-label',
+          `折叠${this.getPanelDisplayName(panelType)}面板`
+        );
+        btn.setAttribute(
+          'title',
+          `折叠${this.getPanelDisplayName(panelType)}面板`
+        );
+        btn.textContent = '←';
+        // 展开后焦点回到折叠按钮(便于键盘连续操作);自动展开不抢焦点
+        if (!auto && typeof btn.focus === 'function') {
+          btn.focus();
+        }
+      }
+ 
+      // 自动展开属于响应式行为,不应持久化;手动展开才保存(TASK-04-002)
+      if (!auto) {
+        this.saveStateNow();
+      }
+    }
+ 
+    /**
+     * 获取当前处于展开状态的侧边栏面板列表
+     * @returns {Array<'groups'|'accounts'|'emails'>}
+     */
+    getExpandedPanels() {
+      /** @type {Array<'groups'|'accounts'|'emails'>} */
+      const expanded = [];
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        const panel = this.panels.get(panelType);
+        Iif (!panel) return;
+        if (!panel.classList.contains('collapsed')) {
+          expanded.push(panelType);
+        }
+      });
+      return expanded;
+    }
+ 
+    /**
+     * 显示提示信息(优先使用主应用内置的 showToast;不可用时降级为 alert)
+     * @param {string} message
+     * @param {'info'|'success'|'warning'|'error'} [type='warning']
+     */
+    notify(message, type = 'warning') {
+      try {
+        Eif (typeof window !== 'undefined' && typeof window.showToast === 'function') {
+          window.showToast(message, type);
+          return;
+        }
+      } catch (_) {
+        // ignore
+      }
+ 
+      try {
+        if (typeof window !== 'undefined' && typeof window.alert === 'function') {
+          window.alert(message);
+        }
+      } catch (_) {
+        // ignore
+      }
+    }
+ 
+    /**
+     * 切换面板折叠状态
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    togglePanel(panelType) {
+      const panel = this.panels.get(panelType);
+      if (!panel) return;
+ 
+      if (panel.classList.contains('collapsed')) {
+        this.expandPanel(panelType);
+      } else {
+        this.collapsePanel(panelType, false);
+      }
+    }
+ 
+    /**
+     * window.resize 事件处理(防抖 200ms)
+     */
+    onWindowResize() {
+      Iif (!this.windowResizeDebouncer) {
+        this.handleWindowResize();
+        return;
+      }
+      this.windowResizeDebouncer.debounced();
+    }
+ 
+    /**
+     * 根据断点自动折叠/恢复面板(F5)
+     *
+     * 规则:
+     * - <1200:自动折叠 groups
+     * - <900:自动折叠 accounts
+     * - <700:自动折叠 emails
+     * - 仅恢复“自动折叠”的面板(data-autoCollapsed="true"),不恢复用户手动折叠
+     */
+    handleWindowResize() {
+      Iif (typeof window === 'undefined' || typeof window.innerWidth !== 'number') return;
+ 
+      const width = window.innerWidth;
+      Iif (!Number.isFinite(width) || width <= 0) return;
+ 
+      const shouldCollapseGroups = width < 1200;
+      const shouldCollapseAccounts = width < 900;
+      const shouldCollapseEmails = width < 700;
+ 
+      if (shouldCollapseGroups) {
+        this.autoCollapsePanelIfNeeded('groups');
+      } else {
+        this.restorePanelIfNeeded('groups');
+      }
+ 
+      if (shouldCollapseAccounts) {
+        this.autoCollapsePanelIfNeeded('accounts');
+      } else {
+        this.restorePanelIfNeeded('accounts');
+      }
+ 
+      if (shouldCollapseEmails) {
+        this.autoCollapsePanelIfNeeded('emails');
+      } else {
+        this.restorePanelIfNeeded('emails');
+      }
+    }
+ 
+    /**
+     * 自动折叠面板(不保存;且不覆盖用户手动折叠标记)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    autoCollapsePanelIfNeeded(panelType) {
+      const panel = this.panels.get(panelType);
+      Iif (!panel) return;
+ 
+      // emailListPanel 可能被业务逻辑临时隐藏(display:none);此时不参与断点折叠/恢复
+      if (panel.classList && panel.classList.contains('hidden')) return;
+ 
+      // 已折叠(可能是用户手动折叠),不要改写为 autoCollapsed
+      if (panel.classList && panel.classList.contains('collapsed')) return;
+ 
+      this.collapsePanel(panelType, true);
+    }
+ 
+    /**
+     * 恢复面板(仅恢复自动折叠的;不保存)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     */
+    restorePanelIfNeeded(panelType) {
+      const panel = this.panels.get(panelType);
+      Iif (!panel) return;
+ 
+      if (panel.classList && panel.classList.contains('hidden')) return;
+ 
+      const isAutoCollapsed = panel.dataset && panel.dataset.autoCollapsed === 'true';
+      if (!isAutoCollapsed) return;
+ 
+      Eif (panel.classList && panel.classList.contains('collapsed')) {
+        this.expandPanel(panelType, true);
+      }
+    }
+ 
+    /**
+     * Resizer 键盘支持:方向键调整宽度(F6.1)
+     * @param {KeyboardEvent} e
+     */
+    onResizerKeydown(e) {
+      Iif (!e) return;
+ 
+      const key = e.key;
+      Iif (key !== 'ArrowLeft' && key !== 'ArrowRight') return;
+ 
+      const resizer = e.currentTarget;
+      const panelType = resizer && resizer.dataset ? resizer.dataset.resizer : null;
+      Iif (!panelType) return;
+ 
+      const panel = this.panels.get(panelType);
+      Iif (!panel) return;
+      Iif (panel.classList && panel.classList.contains('collapsed')) return;
+ 
+      const step = e.shiftKey ? 50 : 10;
+      const delta = key === 'ArrowLeft' ? -step : step;
+ 
+      e.preventDefault();
+ 
+      const startWidth = this.getPanelLayoutWidth(panelType, panel);
+      const nextWidth = this.calculateNewWidth(0, delta, startWidth, panelType);
+      this.updatePanelWidth(panel, nextWidth);
+ 
+      // 键盘调整也应触发防抖保存(F6.1)
+      Eif (this.saveDebouncer && this.stateManager) {
+        this.saveDebouncer.debounced();
+      }
+    }
+ 
+    /**
+     * mousedown:开始拖动调整
+     * @param {MouseEvent} e
+     */
+    startResize(e) {
+      // 仅响应鼠标左键
+      if (e && typeof e.button === 'number' && e.button !== 0) return;
+ 
+      const resizer = e.currentTarget;
+      const panelType = resizer && resizer.dataset ? resizer.dataset.resizer : null;
+      if (!panelType) return;
+ 
+      const panel = this.panels.get(panelType);
+      if (!panel) return;
+      // 折叠状态下禁止拖动(与 F2.5/F2.6 一致)
+      if (panel.classList.contains('collapsed')) return;
+ 
+      e.preventDefault();
+ 
+      // 视觉反馈:全局光标 + 禁止选中文本
+      Eif (document.body) {
+        document.body.classList.add('layout-resizing');
+      }
+ 
+      this.isResizing = true;
+      this.currentPanelType = panelType;
+      this.currentPanel = panel;
+      this.currentResizer = resizer;
+      Eif (this.currentResizer && this.currentResizer.classList) {
+        this.currentResizer.classList.add('active');
+      }
+      this.startX = e.clientX;
+      this.pendingClientX = e.clientX;
+      this.startWidth = this.getPanelLayoutWidth(panelType, panel);
+ 
+      document.addEventListener('mousemove', this._onResize);
+      document.addEventListener('mouseup', this._onStopResize);
+      // 边界情况:鼠标移出窗口/窗口失焦时,确保拖动能正常结束(避免卡死在 resizing 状态)
+      window.addEventListener('blur', this._onStopResize);
+      document.addEventListener('mouseleave', this._onStopResize);
+    }
+ 
+    /**
+     * mousemove:拖动中(使用 requestAnimationFrame 做节流)
+     * @param {MouseEvent} e
+     */
+    resize(e) {
+      if (!this.isResizing || !this.currentPanel || !this.currentPanelType) return;
+ 
+      this.pendingClientX = e.clientX;
+ 
+      if (this.rafId) return;
+      this.rafId = requestAnimationFrame(() => {
+        this.rafId = 0;
+        Iif (!this.isResizing || !this.currentPanel || !this.currentPanelType) return;
+ 
+        try {
+          const newWidth = this.calculateNewWidth(
+            this.startX,
+            this.pendingClientX,
+            this.startWidth,
+            this.currentPanelType
+          );
+          this.updatePanelWidth(this.currentPanel, newWidth);
+        } catch (err) {
+          // 边界保护:任何异常都不应让页面卡在拖动状态
+          try {
+            // eslint-disable-next-line no-console
+            console.error('[LayoutManager] resize failed:', err);
+          } catch (_) {
+            // ignore
+          }
+          this.stopResize();
+        }
+      });
+    }
+ 
+    /**
+     * mouseup:结束拖动
+     */
+    stopResize() {
+      if (!this.isResizing) return;
+ 
+      // 如果在 mouseup 前还有一帧待执行,先同步刷新最终宽度,避免快速拖动时丢帧
+      if (this.rafId && this.currentPanel && this.currentPanelType) {
+        cancelAnimationFrame(this.rafId);
+        this.rafId = 0;
+ 
+        try {
+          const finalWidth = this.calculateNewWidth(
+            this.startX,
+            this.pendingClientX,
+            this.startWidth,
+            this.currentPanelType
+          );
+          this.updatePanelWidth(this.currentPanel, finalWidth);
+        } catch (err) {
+          try {
+            // eslint-disable-next-line no-console
+            console.error('[LayoutManager] stopResize flush failed:', err);
+          } catch (_) {
+            // ignore
+          }
+        }
+      }
+ 
+      this.isResizing = false;
+      this.currentPanel = null;
+      this.currentPanelType = null;
+ 
+      document.removeEventListener('mousemove', this._onResize);
+      document.removeEventListener('mouseup', this._onStopResize);
+      window.removeEventListener('blur', this._onStopResize);
+      document.removeEventListener('mouseleave', this._onStopResize);
+ 
+      // 清理视觉反馈
+      Eif (this.currentResizer && this.currentResizer.classList) {
+        this.currentResizer.classList.remove('active');
+      }
+      this.currentResizer = null;
+      Eif (document.body) {
+        document.body.classList.remove('layout-resizing');
+      }
+ 
+      // 拖动结束后 500ms 防抖保存(TASK-04-002)
+      Eif (this.saveDebouncer && this.stateManager) {
+        this.saveDebouncer.debounced();
+      }
+    }
+ 
+    /**
+     * 计算拖动后的新宽度(仅处理最小/最大约束;邮件详情 400px 约束在 TASK-02-003 实现)
+     * @param {number} startX
+     * @param {number} currentX
+     * @param {number} startWidth
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @returns {number}
+     */
+    calculateNewWidth(startX, currentX, startWidth, panelType) {
+      const delta = currentX - startX;
+      let nextWidth = startWidth + delta;
+ 
+      const minWidth = this.getMinWidth(panelType);
+      const maxWidth = this.getMaxWidth(panelType);
+ 
+      // 先按面板自身的最小/最大值裁剪
+      nextWidth = Math.max(minWidth, Math.min(maxWidth, nextWidth));
+ 
+      // 再保证邮件详情面板最小可见宽度(避免侧边栏把详情挤到不可读)
+      const containerWidth = this.getContainerWidth();
+      const detailMinWidth = this.getEmailDetailMinWidth();
+      Eif (containerWidth > 0 && detailMinWidth > 0) {
+        const otherPanelsWidth = this.calculateOtherPanelsWidth(panelType);
+        const maxAllowedByDetail = containerWidth - otherPanelsWidth - detailMinWidth;
+        Eif (Number.isFinite(maxAllowedByDetail)) {
+          nextWidth = Math.min(nextWidth, maxAllowedByDetail);
+          nextWidth = Math.max(minWidth, nextWidth);
+        }
+      }
+      return nextWidth;
+    }
+ 
+    /**
+     * 计算除当前面板外的其他侧边栏面板总宽度(px)
+     * @param {'groups'|'accounts'|'emails'} excludePanelType
+     * @returns {number}
+     */
+    calculateOtherPanelsWidth(excludePanelType) {
+      let total = 0;
+      ['groups', 'accounts', 'emails'].forEach((panelType) => {
+        if (panelType === excludePanelType) return;
+        const panel = this.panels.get(panelType);
+        total += this.getPanelLayoutWidth(panelType, panel);
+      });
+      return total;
+    }
+ 
+    /**
+     * 获取容器可用宽度(优先取 .main-container 的实际宽度;无布局时回退到 window.innerWidth)
+     * @returns {number}
+     */
+    getContainerWidth() {
+      if (this.container) {
+        const rect = this.container.getBoundingClientRect
+          ? this.container.getBoundingClientRect()
+          : null;
+        if (rect && typeof rect.width === 'number' && rect.width > 0) {
+          return rect.width;
+        }
+      }
+      if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
+        return window.innerWidth;
+      }
+      return 0;
+    }
+ 
+    /**
+     * 获取邮件详情面板最小宽度(从 CSS 变量读取;缺失时默认 400)
+     * @returns {number}
+     */
+    getEmailDetailMinWidth() {
+      const raw = getComputedStyle(document.documentElement).getPropertyValue(
+        '--email-detail-panel-min-width'
+      );
+      const parsed = this.parsePx(raw);
+      return parsed !== null ? parsed : 400;
+    }
+ 
+    /**
+     * 获取当前面板宽度(优先读取 CSS 变量,便于在 jsdom 中测试)
+     * @param {'groups'|'accounts'|'emails'} panelType
+     * @param {HTMLElement} panelElement
+     * @returns {number}
+     */
+    getPanelWidth(panelType, panelElement) {
+      const cssVarName = PANEL_WIDTH_VAR_MAP[panelType];
+      const raw =
+        (cssVarName &&
+          getComputedStyle(document.documentElement).getPropertyValue(cssVarName)) ||
+        '';
+      const parsed = this.parsePx(raw);
+      if (parsed !== null) return parsed;
+ 
+      if (panelElement) {
+        const rect = panelElement.getBoundingClientRect
+          ? panelElement.getBoundingClientRect()
+          : null;
+        if (rect && typeof rect.width === 'number' && rect.width > 0) {
+          return rect.width;
+        }
+        Eif (typeof panelElement.offsetWidth === 'number' && panelElement.offsetWidth > 0) {
+          return panelElement.offsetWidth;
+        }
+      }
+ 
+      return 0;
+    }
+ 
+    /**
+     * 解析 "123px" 为 number;无法解析时返回 null
+     * @param {string} value
+     * @returns {number|null}
+     */
+    parsePx(value) {
+      if (!value || typeof value !== 'string') return null;
+      const trimmed = value.trim();
+      if (!trimmed.endsWith('px')) return null;
+      const num = Number.parseFloat(trimmed.slice(0, -2));
+      return Number.isFinite(num) ? num : null;
+    }
+  }
+ 
+  // 浏览器环境挂载
+  global.LayoutManager = LayoutManager;
+ 
+  // Jest/Node 环境导出
+  Eif (typeof module !== 'undefined' && module.exports) {
+    module.exports = LayoutManager;
+  }
+})(typeof window !== 'undefined' ? window : globalThis);
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov-report/prettify.css b/tests/layout-system/coverage/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/tests/layout-system/coverage/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/tests/layout-system/coverage/lcov-report/prettify.js b/tests/layout-system/coverage/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/tests/layout-system/coverage/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png b/tests/layout-system/coverage/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed68316eb3f65dec9063332d2f69bf3093bbfab GIT binary patch literal 138 zcmeAS@N?(olHy`uVBq!ia0vp^>_9Bd!3HEZxJ@+%Qh}Z>jv*C{$p!i!8j}?a+@3A= zIAGwzjijN=FBi!|L1t?LM;Q;gkwn>2cAy-KV{dn nf0J1DIvEHQu*n~6U}x}qyky7vi4|9XhBJ7&`njxgN@xNA8m%nc literal 0 HcmV?d00001 diff --git a/tests/layout-system/coverage/lcov-report/sorter.js b/tests/layout-system/coverage/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/tests/layout-system/coverage/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/tests/layout-system/coverage/lcov-report/state-manager.js.html b/tests/layout-system/coverage/lcov-report/state-manager.js.html new file mode 100644 index 0000000..a1e5293 --- /dev/null +++ b/tests/layout-system/coverage/lcov-report/state-manager.js.html @@ -0,0 +1,1045 @@ + + + + + + Code coverage report for state-manager.js + + + + + + + + + +
+
+

All files state-manager.js

+
+ +
+ 91.17% + Statements + 124/136 +
+ + +
+ 85.55% + Branches + 77/90 +
+ + +
+ 100% + Functions + 13/13 +
+ + +
+ 94.87% + Lines + 111/117 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 +311 +312 +313 +314 +315 +316 +317 +318 +319 +320 +321  +  +  +  +  +  +  +  +  +  +  +  +  +13x +  +  +13x +13x +  +  +  +  +  +  +  +360x +360x +  +360x +  +1x +  +  +  +  +  +  +  +  +  +365x +365x +  +  +  +  +  +  +  +  +  +150x +150x +1x +  +1x +  +  +  +1x +  +  +149x +  +  +  +  +  +  +149x +1x +  +1x +  +  +  +1x +  +  +148x +148x +  +148x +148x +146x +  +  +2x +1x +  +1x +  +  +  +1x +1x +1x +1x +  +  +  +  +  +  +  +  +  +  +  +  +1x +  +1x +  +  +  +1x +  +  +  +  +  +  +  +  +  +203x +203x +  +  +  +  +  +  +  +  +  +203x +  +203x +203x +203x +  +3x +  +3x +  +  +  +3x +  +  +200x +  +106x +106x +106x +  +  +2x +2x +  +  +  +2x +  +2x +  +  +  +2x +  +  +104x +104x +1x +1x +  +  +  +1x +  +1x +  +  +  +1x +  +  +103x +  +  +  +  +  +  +  +  +6x +6x +6x +6x +6x +  +  +  +  +  +  +  +  +  +  +  +8x +7x +7x +5x +4x +  +4x +  +  +  +  +  +  +  +  +1x +1x +  +1x +1x +1x +4x +4x +4x +3x +2x +  +  +  +  +  +  +1x +2x +2x +2x +2x +2x +1x +  +  +1x +  +2x +  +  +1x +  +1x +2x +2x +  +  +  +  +  +  +  +  +  +  +  +  +1258x +1257x +  +1256x +1256x +3760x +3760x +3760x +3757x +  +  +1252x +  +  +  +  +  +  +  +  +106x +  +  +106x +1x +  +  +  +105x +  +  +1x +  +  +  +  +13x +  +  +13x +13x +  +  + 
/**
+ * StateManager - 布局状态持久化管理器
+ *
+ * 职责(按文档逐步实现):
+ * - 将 LayoutState 保存到 localStorage(多用户隔离 key)
+ * - 从 localStorage 加载并验证 LayoutState
+ * - 提供版本迁移能力(migrate)
+ *
+ * 兼容性说明:
+ * - 浏览器环境:通过 <script> 引入后,StateManager 挂载到 window.StateManager。
+ * - 测试/Node 环境:如果存在 module.exports,则导出 StateManager 以便 Jest 直接 require。
+ */
+ 
+(function (global) {
+  'use strict';
+ 
+  const CURRENT_VERSION = '1.1';
+  const STORAGE_KEY_PREFIX = 'outlook_layout_state_';
+ 
+  class StateManager {
+    /**
+     * 安全获取 localStorage(某些浏览器/隐私模式下访问会抛错)
+     * @returns {Storage|null}
+     */
+    getStorage() {
+      try {
+        Iif (!global) return null;
+        // 访问 localStorage 属性本身也可能抛错,因此需要 try/catch
+        return global.localStorage || null;
+      } catch (_) {
+        return null;
+      }
+    }
+ 
+    /**
+     * 生成 localStorage key
+     * @param {string} userId
+     * @returns {string}
+     */
+    getStorageKey(userId) {
+      const safeUserId = userId && String(userId).trim() ? String(userId).trim() : 'guest';
+      return `${STORAGE_KEY_PREFIX}${safeUserId}`;
+    }
+ 
+    /**
+     * 保存状态到 localStorage
+     * @param {string} userId
+     * @param {Record<string, any>} state
+     * @returns {boolean} 是否保存成功
+     */
+    save(userId, state) {
+      const storage = this.getStorage();
+      if (!storage) {
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] localStorage not available, using default layout');
+        } catch (_) {
+          // ignore
+        }
+        return false;
+      }
+ 
+      const payload = {
+        ...state,
+        version: state && state.version ? state.version : CURRENT_VERSION,
+        userId: state && state.userId ? state.userId : userId,
+        timestamp: Date.now()
+      };
+ 
+      if (!this.validate(payload)) {
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] invalid state, skip saving');
+        } catch (_) {
+          // ignore
+        }
+        return false;
+      }
+ 
+      const key = this.getStorageKey(userId);
+      const json = JSON.stringify(payload);
+ 
+      try {
+        storage.setItem(key, json);
+        return true;
+      } catch (e) {
+        // QuotaExceededError:清理旧数据后重试(TDD 7.3)
+        if (this.isQuotaExceededError(e)) {
+          try {
+            // eslint-disable-next-line no-console
+            console.warn('[StateManager] quota exceeded, clearing old states and retrying');
+          } catch (_) {
+            // ignore
+          }
+          try {
+            this.clearOldStates(key);
+            storage.setItem(key, json);
+            return true;
+          } catch (e2) {
+            try {
+              // eslint-disable-next-line no-console
+              console.warn('[StateManager] save retry failed:', e2);
+            } catch (_) {
+              // ignore
+            }
+            return false;
+          }
+        }
+ 
+        // localStorage 不可用等:降级为默认布局
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] save failed:', e);
+        } catch (_) {
+          // ignore
+        }
+        return false;
+      }
+    }
+ 
+    /**
+     * 从 localStorage 加载状态
+     * @param {string} userId
+     * @returns {Record<string, any>|null}
+     */
+    load(userId) {
+      const storage = this.getStorage();
+      Iif (!storage) {
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] localStorage not available, using default layout');
+        } catch (_) {
+          // ignore
+        }
+        return null;
+      }
+ 
+      const key = this.getStorageKey(userId);
+ 
+      let raw = null;
+      try {
+        raw = storage.getItem(key);
+      } catch (e) {
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] load failed (storage error):', e);
+        } catch (_) {
+          // ignore
+        }
+        return null;
+      }
+ 
+      if (!raw) return null;
+ 
+      let parsed = null;
+      try {
+        parsed = JSON.parse(raw);
+      } catch (e) {
+        // JSON 损坏:清除并降级为默认布局(TDD 7.3)
+        try {
+          this.clear(userId);
+        } catch (_) {
+          // ignore
+        }
+        try {
+          // eslint-disable-next-line no-console
+          console.error('[StateManager] load failed (invalid json):', e);
+        } catch (_) {
+          // ignore
+        }
+        return null;
+      }
+ 
+      const migrated = this.migrate(parsed);
+      if (!this.validate(migrated)) {
+        try {
+          this.clear(userId);
+        } catch (_) {
+          // ignore
+        }
+        try {
+          // eslint-disable-next-line no-console
+          console.warn('[StateManager] invalid state, cleared and fallback to default');
+        } catch (_) {
+          // ignore
+        }
+        return null;
+      }
+ 
+      return migrated;
+    }
+ 
+    /**
+     * 清除保存的状态
+     * @param {string} userId
+     * @returns {void}
+     */
+    clear(userId) {
+      try {
+        const storage = this.getStorage();
+        Iif (!storage) return;
+        const key = this.getStorageKey(userId);
+        storage.removeItem(key);
+      } catch (_) {
+        // ignore
+      }
+    }
+ 
+    /**
+     * 判断是否为 QuotaExceededError(不同浏览器实现不同)
+     * @param {any} error
+     * @returns {boolean}
+     */
+    isQuotaExceededError(error) {
+      if (!error) return false;
+      const name = typeof error.name === 'string' ? error.name : '';
+      if (name === 'QuotaExceededError') return true;
+      if (name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
+      const code = typeof error.code === 'number' ? error.code : null;
+      // 22/1014:常见的 quota 相关 code(Safari/Firefox)
+      return code === 22 || code === 1014;
+    }
+ 
+    /**
+     * 清理旧的布局状态(用于 QuotaExceededError 降级处理)
+     * @param {string} excludeKey 当前写入 key(避免误删正在保存的 key)
+     * @returns {void}
+     */
+    clearOldStates(excludeKey) {
+      const storage = this.getStorage();
+      Iif (!storage) return;
+ 
+      const keys = [];
+      try {
+        for (let i = 0; i < storage.length; i += 1) {
+          const k = storage.key(i);
+          Iif (!k) continue;
+          if (!k.startsWith(STORAGE_KEY_PREFIX)) continue;
+          if (excludeKey && k === excludeKey) continue;
+          keys.push(k);
+        }
+      } catch (_) {
+        // ignore
+      }
+ 
+      // 按 timestamp 由旧到新清理(尽量保留最近的偏好)
+      const items = keys.map((k) => {
+        let ts = 0;
+        try {
+          const raw = storage.getItem(k);
+          Eif (raw) {
+            const parsed = JSON.parse(raw);
+            ts = parsed && typeof parsed.timestamp === 'number' ? parsed.timestamp : 0;
+          }
+        } catch (_) {
+          ts = 0;
+        }
+        return { key: k, ts };
+      });
+ 
+      items.sort((a, b) => a.ts - b.ts);
+ 
+      items.forEach((item) => {
+        try {
+          storage.removeItem(item.key);
+        } catch (_) {
+          // ignore
+        }
+      });
+    }
+ 
+    /**
+     * 校验状态数据格式
+     * @param {Record<string, any>} state
+     * @returns {boolean}
+     */
+    validate(state) {
+      if (!state || typeof state !== 'object') return false;
+      if (!state.version || !state.panels) return false;
+ 
+      const panels = state.panels;
+      for (const panelType of ['groups', 'accounts', 'emails']) {
+        const panel = panels[panelType];
+        Iif (!panel || typeof panel !== 'object') return false;
+        if (!panel.width || !/^\d+px$/.test(panel.width)) return false;
+        if (typeof panel.collapsed !== 'boolean') return false;
+      }
+ 
+      return true;
+    }
+ 
+    /**
+     * 版本迁移(当前仅保证字段齐全;更复杂迁移在后续版本扩展)
+     * @param {Record<string, any>} state
+     * @returns {Record<string, any>}
+     */
+    migrate(state) {
+      Iif (!state || typeof state !== 'object') return state;
+ 
+      // 若缺少 version,认为是旧数据,补齐为当前版本
+      if (!state.version) {
+        return { ...state, version: CURRENT_VERSION };
+      }
+ 
+      // 已是当前版本则直接返回
+      if (state.version === CURRENT_VERSION) return state;
+ 
+      // 其他版本:暂做“兼容性兜底”,保留原结构并标记为当前版本
+      return { ...state, version: CURRENT_VERSION };
+    }
+  }
+ 
+  // 浏览器环境挂载
+  global.StateManager = StateManager;
+ 
+  // Jest/Node 环境导出
+  Eif (typeof module !== 'undefined' && module.exports) {
+    module.exports = StateManager;
+  }
+})(typeof window !== 'undefined' ? window : globalThis);
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/tests/layout-system/coverage/lcov.info b/tests/layout-system/coverage/lcov.info new file mode 100644 index 0000000..9359e08 --- /dev/null +++ b/tests/layout-system/coverage/lcov.info @@ -0,0 +1,1377 @@ +TN: +SF:static\js\layout-manager.js +FN:17,(anonymous_0) +FN:62,(anonymous_1) +FN:119,(anonymous_2) +FN:121,(anonymous_3) +FN:128,(anonymous_4) +FN:135,(anonymous_5) +FN:141,(anonymous_6) +FN:146,(anonymous_7) +FN:156,(anonymous_8) +FN:193,(anonymous_9) +FN:204,(anonymous_10) +FN:235,(anonymous_11) +FN:239,(anonymous_12) +FN:241,(anonymous_13) +FN:246,(anonymous_14) +FN:253,(anonymous_15) +FN:267,(anonymous_16) +FN:269,(anonymous_17) +FN:315,(anonymous_18) +FN:317,(anonymous_19) +FN:362,(anonymous_20) +FN:374,(anonymous_21) +FN:390,(anonymous_22) +FN:397,(anonymous_23) +FN:412,(anonymous_24) +FN:426,(anonymous_25) +FN:446,(anonymous_26) +FN:461,(anonymous_27) +FN:481,(anonymous_28) +FN:498,(anonymous_29) +FN:499,(anonymous_30) +FN:526,(anonymous_31) +FN:530,(anonymous_32) +FN:563,(anonymous_33) +FN:589,(anonymous_34) +FN:606,(anonymous_35) +FN:619,(anonymous_36) +FN:622,(anonymous_37) +FN:665,(anonymous_38) +FN:678,(anonymous_39) +FN:686,(anonymous_40) +FN:694,(anonymous_41) +FN:698,(anonymous_42) +FN:716,(anonymous_43) +FN:729,(anonymous_44) +FN:747,(anonymous_45) +FN:765,(anonymous_46) +FN:795,(anonymous_47) +FN:796,(anonymous_48) +FN:813,(anonymous_49) +FN:827,(anonymous_50) +FN:846,(anonymous_51) +FN:861,(anonymous_52) +FN:884,(anonymous_53) +FN:940,(anonymous_54) +FN:979,(anonymous_55) +FN:982,(anonymous_56) +FN:997,(anonymous_57) +FN:1020,(anonymous_58) +FN:1034,(anonymous_59) +FN:1051,(anonymous_60) +FN:1084,(anonymous_61) +FN:1101,(anonymous_62) +FN:1119,(anonymous_63) +FN:1152,(anonymous_64) +FN:1194,(anonymous_65) +FN:1200,(anonymous_66) +FN:1228,(anonymous_67) +FN:1286,(anonymous_68) +FN:1315,(anonymous_69) +FN:1317,(anonymous_70) +FN:1329,(anonymous_71) +FN:1348,(anonymous_72) +FN:1362,(anonymous_73) +FN:1391,(anonymous_74) +FNF:75 +FNH:75 +FNDA:12,(anonymous_0) +FNDA:97,(anonymous_1) +FNDA:40,(anonymous_2) +FNDA:9,(anonymous_3) +FNDA:97,(anonymous_4) +FNDA:291,(anonymous_5) +FNDA:291,(anonymous_6) +FNDA:291,(anonymous_7) +FNDA:291,(anonymous_8) +FNDA:97,(anonymous_9) +FNDA:97,(anonymous_10) +FNDA:194,(anonymous_11) +FNDA:31,(anonymous_12) +FNDA:13,(anonymous_13) +FNDA:36,(anonymous_14) +FNDA:2,(anonymous_15) +FNDA:43,(anonymous_16) +FNDA:129,(anonymous_17) +FNDA:142,(anonymous_18) +FNDA:568,(anonymous_19) +FNDA:43,(anonymous_20) +FNDA:36,(anonymous_21) +FNDA:97,(anonymous_22) +FNDA:1,(anonymous_23) +FNDA:98,(anonymous_24) +FNDA:67,(anonymous_25) +FNDA:2,(anonymous_26) +FNDA:1,(anonymous_27) +FNDA:2,(anonymous_28) +FNDA:2,(anonymous_29) +FNDA:6,(anonymous_30) +FNDA:1,(anonymous_31) +FNDA:3,(anonymous_32) +FNDA:9,(anonymous_33) +FNDA:1024,(anonymous_34) +FNDA:9,(anonymous_35) +FNDA:98,(anonymous_36) +FNDA:294,(anonymous_37) +FNDA:5,(anonymous_38) +FNDA:3,(anonymous_39) +FNDA:1,(anonymous_40) +FNDA:165,(anonymous_41) +FNDA:495,(anonymous_42) +FNDA:165,(anonymous_43) +FNDA:374,(anonymous_44) +FNDA:59,(anonymous_45) +FNDA:68,(anonymous_46) +FNDA:100,(anonymous_47) +FNDA:300,(anonymous_48) +FNDA:315,(anonymous_49) +FNDA:315,(anonymous_50) +FNDA:50,(anonymous_51) +FNDA:132,(anonymous_52) +FNDA:52,(anonymous_53) +FNDA:19,(anonymous_54) +FNDA:27,(anonymous_55) +FNDA:81,(anonymous_56) +FNDA:4,(anonymous_57) +FNDA:15,(anonymous_58) +FNDA:9,(anonymous_59) +FNDA:106,(anonymous_60) +FNDA:13,(anonymous_61) +FNDA:305,(anonymous_62) +FNDA:9,(anonymous_63) +FNDA:17,(anonymous_64) +FNDA:50,(anonymous_65) +FNDA:30,(anonymous_66) +FNDA:13,(anonymous_67) +FNDA:52,(anonymous_68) +FNDA:55,(anonymous_69) +FNDA:165,(anonymous_70) +FNDA:54,(anonymous_71) +FNDA:52,(anonymous_72) +FNDA:134,(anonymous_73) +FNDA:804,(anonymous_74) +DA:17,12 +DA:21,12 +DA:23,12 +DA:29,12 +DA:43,12 +DA:52,12 +DA:64,97 +DA:67,97 +DA:70,97 +DA:73,97 +DA:76,97 +DA:79,97 +DA:82,97 +DA:85,97 +DA:88,97 +DA:91,97 +DA:94,97 +DA:97,97 +DA:100,97 +DA:103,97 +DA:106,97 +DA:107,97 +DA:108,97 +DA:109,97 +DA:110,97 +DA:111,97 +DA:112,97 +DA:113,97 +DA:114,97 +DA:117,97 +DA:119,97 +DA:121,97 +DA:129,97 +DA:132,97 +DA:133,97 +DA:136,291 +DA:140,97 +DA:141,97 +DA:142,291 +DA:146,97 +DA:147,291 +DA:148,291 +DA:151,291 +DA:152,291 +DA:156,97 +DA:157,291 +DA:158,291 +DA:162,97 +DA:165,97 +DA:166,97 +DA:168,96 +DA:171,96 +DA:175,97 +DA:176,97 +DA:177,85 +DA:179,97 +DA:180,97 +DA:184,97 +DA:185,97 +DA:186,85 +DA:188,97 +DA:189,97 +DA:193,97 +DA:194,97 +DA:197,97 +DA:205,97 +DA:206,97 +DA:207,86 +DA:211,11 +DA:213,11 +DA:214,11 +DA:215,11 +DA:219,0 +DA:221,0 +DA:226,0 +DA:236,194 +DA:238,194 +DA:240,31 +DA:241,31 +DA:242,13 +DA:243,13 +DA:247,36 +DA:248,1 +DA:249,1 +DA:251,36 +DA:254,2 +DA:255,1 +DA:256,1 +DA:268,43 +DA:269,43 +DA:270,129 +DA:273,129 +DA:275,129 +DA:278,129 +DA:281,129 +DA:282,129 +DA:286,129 +DA:290,129 +DA:293,43 +DA:316,142 +DA:317,142 +DA:318,568 +DA:319,3 +DA:320,3 +DA:324,142 +DA:326,142 +DA:327,142 +DA:328,142 +DA:331,142 +DA:334,142 +DA:335,142 +DA:339,142 +DA:342,142 +DA:343,142 +DA:344,142 +DA:348,142 +DA:349,142 +DA:350,142 +DA:355,139 +DA:363,43 +DA:365,43 +DA:366,43 +DA:367,43 +DA:375,36 +DA:378,36 +DA:391,97 +DA:393,97 +DA:394,97 +DA:395,97 +DA:397,1 +DA:398,1 +DA:401,1 +DA:402,1 +DA:405,1 +DA:414,98 +DA:417,98 +DA:418,0 +DA:419,0 +DA:422,98 +DA:423,98 +DA:424,98 +DA:426,98 +DA:427,67 +DA:428,67 +DA:434,98 +DA:435,98 +DA:437,0 +DA:448,2 +DA:451,2 +DA:452,0 +DA:453,0 +DA:456,2 +DA:457,2 +DA:458,2 +DA:461,2 +DA:462,1 +DA:463,1 +DA:483,2 +DA:484,2 +DA:487,2 +DA:488,2 +DA:489,2 +DA:490,2 +DA:496,2 +DA:498,2 +DA:499,2 +DA:500,6 +DA:501,6 +DA:504,6 +DA:505,6 +DA:508,6 +DA:511,6 +DA:515,2 +DA:516,2 +DA:519,2 +DA:527,1 +DA:528,1 +DA:530,1 +DA:531,3 +DA:532,3 +DA:533,3 +DA:536,3 +DA:537,3 +DA:538,3 +DA:539,3 +DA:540,3 +DA:541,3 +DA:545,3 +DA:548,3 +DA:549,1 +DA:550,1 +DA:552,2 +DA:553,2 +DA:564,9 +DA:565,9 +DA:567,9 +DA:568,9 +DA:570,9 +DA:571,1 +DA:572,1 +DA:573,1 +DA:574,1 +DA:575,1 +DA:578,8 +DA:579,8 +DA:580,8 +DA:581,8 +DA:590,1024 +DA:592,389 +DA:594,324 +DA:596,310 +DA:598,1 +DA:607,9 +DA:608,9 +DA:609,9 +DA:611,7 +DA:612,7 +DA:620,98 +DA:622,98 +DA:623,294 +DA:624,294 +DA:626,294 +DA:629,294 +DA:630,291 +DA:631,291 +DA:632,291 +DA:633,291 +DA:634,291 +DA:635,291 +DA:639,291 +DA:644,291 +DA:645,291 +DA:646,291 +DA:647,291 +DA:649,291 +DA:652,294 +DA:653,294 +DA:654,294 +DA:655,294 +DA:657,294 +DA:666,5 +DA:667,5 +DA:668,5 +DA:670,3 +DA:671,3 +DA:679,3 +DA:680,2 +DA:681,2 +DA:683,1 +DA:684,1 +DA:695,165 +DA:696,165 +DA:698,165 +DA:699,495 +DA:700,495 +DA:701,495 +DA:703,495 +DA:704,69 +DA:705,69 +DA:707,426 +DA:717,165 +DA:720,165 +DA:721,165 +DA:730,374 +DA:732,165 +DA:734,105 +DA:736,103 +DA:738,1 +DA:748,59 +DA:750,54 +DA:752,2 +DA:754,2 +DA:756,1 +DA:766,68 +DA:767,63 +DA:769,62 +DA:770,62 +DA:771,62 +DA:773,61 +DA:776,61 +DA:777,61 +DA:778,60 +DA:782,61 +DA:783,61 +DA:784,61 +DA:796,100 +DA:797,300 +DA:798,300 +DA:800,300 +DA:801,2 +DA:803,298 +DA:814,315 +DA:815,315 +DA:817,315 +DA:818,315 +DA:819,315 +DA:820,315 +DA:828,315 +DA:829,315 +DA:830,315 +DA:832,315 +DA:833,315 +DA:834,315 +DA:836,315 +DA:847,50 +DA:848,50 +DA:849,50 +DA:851,50 +DA:852,50 +DA:862,132 +DA:868,1 +DA:872,131 +DA:885,52 +DA:886,52 +DA:889,50 +DA:890,24 +DA:891,24 +DA:892,2 +DA:893,2 +DA:898,48 +DA:899,26 +DA:901,22 +DA:904,48 +DA:905,48 +DA:906,48 +DA:908,48 +DA:909,48 +DA:910,48 +DA:911,48 +DA:915,48 +DA:919,48 +DA:923,48 +DA:924,22 +DA:925,22 +DA:926,22 +DA:931,48 +DA:932,22 +DA:941,19 +DA:942,19 +DA:945,17 +DA:947,17 +DA:948,17 +DA:949,17 +DA:951,17 +DA:952,17 +DA:953,17 +DA:954,17 +DA:958,17 +DA:962,17 +DA:964,17 +DA:965,12 +DA:970,17 +DA:971,12 +DA:981,27 +DA:982,27 +DA:983,81 +DA:984,81 +DA:985,81 +DA:986,74 +DA:989,27 +DA:998,4 +DA:999,4 +DA:1000,4 +DA:1001,4 +DA:1007,0 +DA:1008,0 +DA:1009,0 +DA:1021,15 +DA:1022,15 +DA:1024,13 +DA:1025,3 +DA:1027,10 +DA:1035,9 +DA:1036,0 +DA:1037,0 +DA:1039,9 +DA:1052,106 +DA:1054,106 +DA:1055,106 +DA:1057,106 +DA:1058,106 +DA:1059,106 +DA:1061,106 +DA:1062,6 +DA:1064,100 +DA:1067,106 +DA:1068,4 +DA:1070,102 +DA:1073,106 +DA:1074,3 +DA:1076,103 +DA:1085,13 +DA:1086,13 +DA:1089,13 +DA:1092,12 +DA:1094,11 +DA:1102,305 +DA:1103,305 +DA:1105,305 +DA:1107,304 +DA:1108,304 +DA:1110,5 +DA:1111,5 +DA:1120,9 +DA:1122,9 +DA:1123,9 +DA:1125,9 +DA:1126,9 +DA:1127,9 +DA:1129,9 +DA:1130,9 +DA:1131,9 +DA:1133,9 +DA:1134,9 +DA:1136,9 +DA:1138,9 +DA:1139,9 +DA:1140,9 +DA:1143,9 +DA:1144,9 +DA:1154,17 +DA:1156,16 +DA:1157,16 +DA:1158,16 +DA:1160,15 +DA:1161,15 +DA:1163,14 +DA:1165,12 +DA:1168,12 +DA:1169,12 +DA:1172,12 +DA:1173,12 +DA:1174,12 +DA:1175,12 +DA:1176,12 +DA:1177,12 +DA:1179,12 +DA:1180,12 +DA:1181,12 +DA:1183,12 +DA:1184,12 +DA:1186,12 +DA:1187,12 +DA:1195,50 +DA:1197,49 +DA:1199,49 +DA:1200,39 +DA:1201,30 +DA:1202,30 +DA:1204,30 +DA:1205,30 +DA:1211,30 +DA:1214,0 +DA:1216,0 +DA:1220,0 +DA:1229,13 +DA:1232,12 +DA:1233,9 +DA:1234,9 +DA:1236,9 +DA:1237,9 +DA:1243,9 +DA:1245,0 +DA:1247,0 +DA:1254,12 +DA:1255,12 +DA:1256,12 +DA:1258,12 +DA:1259,12 +DA:1260,12 +DA:1261,12 +DA:1264,12 +DA:1265,12 +DA:1267,12 +DA:1268,12 +DA:1269,12 +DA:1273,12 +DA:1274,12 +DA:1287,52 +DA:1288,52 +DA:1290,52 +DA:1291,52 +DA:1294,52 +DA:1297,52 +DA:1298,52 +DA:1299,52 +DA:1300,52 +DA:1301,52 +DA:1302,52 +DA:1303,52 +DA:1304,52 +DA:1307,52 +DA:1316,55 +DA:1317,55 +DA:1318,165 +DA:1319,110 +DA:1320,110 +DA:1322,55 +DA:1330,54 +DA:1331,53 +DA:1334,53 +DA:1335,1 +DA:1338,53 +DA:1339,52 +DA:1341,1 +DA:1349,52 +DA:1352,52 +DA:1353,52 +DA:1363,134 +DA:1365,134 +DA:1368,134 +DA:1369,134 +DA:1371,3 +DA:1372,2 +DA:1375,2 +DA:1376,1 +DA:1378,1 +DA:1379,1 +DA:1383,1 +DA:1392,804 +DA:1393,584 +DA:1394,584 +DA:1395,582 +DA:1396,582 +DA:1401,12 +DA:1404,12 +DA:1405,12 +LF:527 +LH:509 +BRDA:166,0,0,96 +BRDA:166,0,1,1 +BRDA:175,1,0,97 +BRDA:175,1,1,0 +BRDA:175,2,0,97 +BRDA:175,2,1,97 +BRDA:176,3,0,85 +BRDA:176,3,1,12 +BRDA:184,4,0,97 +BRDA:184,4,1,0 +BRDA:184,5,0,97 +BRDA:184,5,1,97 +BRDA:185,6,0,85 +BRDA:185,6,1,12 +BRDA:206,7,0,86 +BRDA:206,7,1,11 +BRDA:206,8,0,97 +BRDA:206,8,1,97 +BRDA:211,9,0,11 +BRDA:211,9,1,0 +BRDA:214,10,0,11 +BRDA:214,10,1,0 +BRDA:240,11,0,2 +BRDA:240,11,1,29 +BRDA:247,12,0,1 +BRDA:247,12,1,35 +BRDA:254,13,0,1 +BRDA:254,13,1,1 +BRDA:275,14,0,129 +BRDA:275,14,1,129 +BRDA:275,14,2,0 +BRDA:282,15,0,129 +BRDA:282,15,1,0 +BRDA:286,16,0,129 +BRDA:286,16,1,129 +BRDA:286,16,2,22 +BRDA:295,17,0,43 +BRDA:295,17,1,0 +BRDA:318,18,0,565 +BRDA:318,18,1,3 +BRDA:318,19,0,568 +BRDA:318,19,1,426 +BRDA:320,20,0,3 +BRDA:320,20,1,0 +BRDA:324,21,0,142 +BRDA:324,21,1,0 +BRDA:326,22,0,142 +BRDA:326,22,1,0 +BRDA:326,23,0,142 +BRDA:326,23,1,142 +BRDA:328,24,0,0 +BRDA:328,24,1,142 +BRDA:331,25,0,142 +BRDA:331,25,1,0 +BRDA:331,26,0,142 +BRDA:331,26,1,142 +BRDA:335,27,0,0 +BRDA:335,27,1,142 +BRDA:339,28,0,142 +BRDA:339,28,1,0 +BRDA:342,29,0,0 +BRDA:342,29,1,142 +BRDA:344,30,0,0 +BRDA:344,30,1,142 +BRDA:348,31,0,142 +BRDA:348,31,1,0 +BRDA:350,32,0,3 +BRDA:350,32,1,139 +BRDA:363,33,0,0 +BRDA:363,33,1,43 +BRDA:363,34,0,43 +BRDA:363,34,1,43 +BRDA:365,35,0,43 +BRDA:365,35,1,42 +BRDA:375,36,0,0 +BRDA:375,36,1,36 +BRDA:391,37,0,0 +BRDA:391,37,1,97 +BRDA:391,38,0,97 +BRDA:391,38,1,97 +BRDA:393,39,0,97 +BRDA:393,39,1,95 +BRDA:395,40,0,96 +BRDA:395,40,1,1 +BRDA:395,41,0,97 +BRDA:395,41,1,1 +BRDA:414,42,0,98 +BRDA:414,42,1,0 +BRDA:414,43,0,98 +BRDA:414,43,1,98 +BRDA:417,44,0,0 +BRDA:417,44,1,98 +BRDA:417,45,0,98 +BRDA:417,45,1,98 +BRDA:434,46,0,98 +BRDA:434,46,1,0 +BRDA:448,47,0,2 +BRDA:448,47,1,0 +BRDA:448,48,0,2 +BRDA:448,48,1,2 +BRDA:451,49,0,0 +BRDA:451,49,1,2 +BRDA:451,50,0,2 +BRDA:451,50,1,2 +BRDA:483,51,0,2 +BRDA:483,51,1,0 +BRDA:483,52,0,2 +BRDA:483,52,1,2 +BRDA:487,53,0,2 +BRDA:487,53,1,2 +BRDA:489,54,0,2 +BRDA:489,54,1,0 +BRDA:489,55,0,2 +BRDA:489,55,1,2 +BRDA:501,56,0,0 +BRDA:501,56,1,6 +BRDA:527,57,0,1 +BRDA:527,57,1,0 +BRDA:527,58,0,1 +BRDA:527,58,1,1 +BRDA:528,59,0,0 +BRDA:528,59,1,1 +BRDA:533,60,0,0 +BRDA:533,60,1,3 +BRDA:533,61,0,3 +BRDA:533,61,1,3 +BRDA:537,62,0,3 +BRDA:537,62,1,0 +BRDA:548,63,0,1 +BRDA:548,63,1,2 +BRDA:565,64,0,0 +BRDA:565,64,1,9 +BRDA:568,65,0,0 +BRDA:568,65,1,9 +BRDA:570,66,0,1 +BRDA:570,66,1,8 +BRDA:590,67,0,389 +BRDA:590,67,1,324 +BRDA:590,67,2,310 +BRDA:590,67,3,1 +BRDA:607,68,0,8 +BRDA:607,68,1,1 +BRDA:607,69,0,9 +BRDA:607,69,1,8 +BRDA:608,70,0,8 +BRDA:608,70,1,1 +BRDA:608,71,0,9 +BRDA:608,71,1,8 +BRDA:609,72,0,2 +BRDA:609,72,1,7 +BRDA:624,73,0,0 +BRDA:624,73,1,294 +BRDA:629,74,0,291 +BRDA:629,74,1,3 +BRDA:666,75,0,4 +BRDA:666,75,1,1 +BRDA:666,76,0,5 +BRDA:666,76,1,4 +BRDA:667,77,0,4 +BRDA:667,77,1,1 +BRDA:667,78,0,5 +BRDA:667,78,1,4 +BRDA:668,79,0,2 +BRDA:668,79,1,3 +BRDA:679,80,0,1 +BRDA:679,80,1,2 +BRDA:681,81,0,1 +BRDA:681,81,1,1 +BRDA:681,82,0,2 +BRDA:681,82,1,1 +BRDA:701,83,0,0 +BRDA:701,83,1,495 +BRDA:701,84,0,495 +BRDA:701,84,1,495 +BRDA:703,85,0,69 +BRDA:703,85,1,426 +BRDA:721,86,0,1 +BRDA:721,86,1,164 +BRDA:730,87,0,165 +BRDA:730,87,1,105 +BRDA:730,87,2,103 +BRDA:730,87,3,1 +BRDA:748,88,0,54 +BRDA:748,88,1,2 +BRDA:748,88,2,2 +BRDA:748,88,3,1 +BRDA:766,89,0,5 +BRDA:766,89,1,63 +BRDA:766,90,0,68 +BRDA:766,90,1,67 +BRDA:766,90,2,63 +BRDA:767,91,0,1 +BRDA:767,91,1,62 +BRDA:767,92,0,63 +BRDA:767,92,1,63 +BRDA:771,93,0,1 +BRDA:771,93,1,61 +BRDA:777,94,0,60 +BRDA:777,94,1,1 +BRDA:777,95,0,61 +BRDA:777,95,1,61 +BRDA:783,96,0,61 +BRDA:783,96,1,0 +BRDA:798,97,0,0 +BRDA:798,97,1,300 +BRDA:800,98,0,2 +BRDA:800,98,1,298 +BRDA:815,99,0,0 +BRDA:815,99,1,315 +BRDA:819,100,0,0 +BRDA:819,100,1,315 +BRDA:830,101,0,0 +BRDA:830,101,1,315 +BRDA:830,102,0,315 +BRDA:830,102,1,315 +BRDA:833,103,0,315 +BRDA:833,103,1,0 +BRDA:849,104,0,0 +BRDA:849,104,1,50 +BRDA:849,105,0,50 +BRDA:849,105,1,50 +BRDA:862,106,0,1 +BRDA:862,106,1,131 +BRDA:863,107,0,132 +BRDA:863,107,1,132 +BRDA:863,107,2,132 +BRDA:863,107,3,131 +BRDA:884,108,0,11 +BRDA:886,109,0,2 +BRDA:886,109,1,50 +BRDA:889,110,0,24 +BRDA:889,110,1,26 +BRDA:889,111,0,50 +BRDA:889,111,1,24 +BRDA:891,112,0,2 +BRDA:891,112,1,22 +BRDA:891,113,0,24 +BRDA:891,113,1,2 +BRDA:898,114,0,26 +BRDA:898,114,1,22 +BRDA:909,115,0,48 +BRDA:909,115,1,0 +BRDA:923,116,0,22 +BRDA:923,116,1,26 +BRDA:925,117,0,22 +BRDA:925,117,1,0 +BRDA:925,118,0,22 +BRDA:925,118,1,22 +BRDA:931,119,0,22 +BRDA:931,119,1,26 +BRDA:940,120,0,14 +BRDA:942,121,0,2 +BRDA:942,121,1,17 +BRDA:952,122,0,17 +BRDA:952,122,1,0 +BRDA:964,123,0,12 +BRDA:964,123,1,5 +BRDA:964,124,0,17 +BRDA:964,124,1,12 +BRDA:970,125,0,12 +BRDA:970,125,1,5 +BRDA:984,126,0,0 +BRDA:984,126,1,81 +BRDA:985,127,0,74 +BRDA:985,127,1,7 +BRDA:997,128,0,0 +BRDA:999,129,0,4 +BRDA:999,129,1,0 +BRDA:999,130,0,4 +BRDA:999,130,1,4 +BRDA:1008,131,0,0 +BRDA:1008,131,1,0 +BRDA:1008,132,0,0 +BRDA:1008,132,1,0 +BRDA:1022,133,0,2 +BRDA:1022,133,1,13 +BRDA:1024,134,0,3 +BRDA:1024,134,1,10 +BRDA:1035,135,0,0 +BRDA:1035,135,1,9 +BRDA:1052,136,0,0 +BRDA:1052,136,1,106 +BRDA:1052,137,0,106 +BRDA:1052,137,1,106 +BRDA:1055,138,0,0 +BRDA:1055,138,1,106 +BRDA:1055,139,0,106 +BRDA:1055,139,1,106 +BRDA:1061,140,0,6 +BRDA:1061,140,1,100 +BRDA:1067,141,0,4 +BRDA:1067,141,1,102 +BRDA:1073,142,0,3 +BRDA:1073,142,1,103 +BRDA:1086,143,0,0 +BRDA:1086,143,1,13 +BRDA:1089,144,0,1 +BRDA:1089,144,1,12 +BRDA:1089,145,0,13 +BRDA:1089,145,1,13 +BRDA:1092,146,0,1 +BRDA:1092,146,1,11 +BRDA:1092,147,0,12 +BRDA:1092,147,1,12 +BRDA:1103,148,0,0 +BRDA:1103,148,1,305 +BRDA:1105,149,0,1 +BRDA:1105,149,1,304 +BRDA:1105,150,0,305 +BRDA:1105,150,1,305 +BRDA:1107,151,0,304 +BRDA:1107,151,1,304 +BRDA:1108,152,0,299 +BRDA:1108,152,1,5 +BRDA:1110,153,0,5 +BRDA:1110,153,1,0 +BRDA:1110,154,0,5 +BRDA:1110,154,1,5 +BRDA:1120,155,0,0 +BRDA:1120,155,1,9 +BRDA:1123,156,0,0 +BRDA:1123,156,1,9 +BRDA:1123,157,0,9 +BRDA:1123,157,1,7 +BRDA:1126,158,0,9 +BRDA:1126,158,1,0 +BRDA:1126,159,0,9 +BRDA:1126,159,1,9 +BRDA:1127,160,0,0 +BRDA:1127,160,1,9 +BRDA:1130,161,0,0 +BRDA:1130,161,1,9 +BRDA:1131,162,0,0 +BRDA:1131,162,1,9 +BRDA:1131,163,0,9 +BRDA:1131,163,1,9 +BRDA:1133,164,0,2 +BRDA:1133,164,1,7 +BRDA:1134,165,0,2 +BRDA:1134,165,1,7 +BRDA:1143,166,0,9 +BRDA:1143,166,1,0 +BRDA:1143,167,0,9 +BRDA:1143,167,1,9 +BRDA:1154,168,0,1 +BRDA:1154,168,1,16 +BRDA:1154,169,0,17 +BRDA:1154,169,1,17 +BRDA:1154,169,2,17 +BRDA:1157,170,0,15 +BRDA:1157,170,1,1 +BRDA:1157,171,0,16 +BRDA:1157,171,1,16 +BRDA:1158,172,0,1 +BRDA:1158,172,1,15 +BRDA:1161,173,0,1 +BRDA:1161,173,1,14 +BRDA:1163,174,0,2 +BRDA:1163,174,1,12 +BRDA:1168,175,0,12 +BRDA:1168,175,1,0 +BRDA:1176,176,0,12 +BRDA:1176,176,1,0 +BRDA:1176,177,0,12 +BRDA:1176,177,1,12 +BRDA:1195,178,0,1 +BRDA:1195,178,1,49 +BRDA:1195,179,0,50 +BRDA:1195,179,1,49 +BRDA:1195,179,2,49 +BRDA:1199,180,0,10 +BRDA:1199,180,1,39 +BRDA:1202,181,0,0 +BRDA:1202,181,1,30 +BRDA:1202,182,0,30 +BRDA:1202,182,1,30 +BRDA:1202,182,2,30 +BRDA:1229,183,0,1 +BRDA:1229,183,1,12 +BRDA:1232,184,0,9 +BRDA:1232,184,1,3 +BRDA:1232,185,0,12 +BRDA:1232,185,1,9 +BRDA:1232,185,2,9 +BRDA:1264,186,0,12 +BRDA:1264,186,1,0 +BRDA:1264,187,0,12 +BRDA:1264,187,1,12 +BRDA:1268,188,0,12 +BRDA:1268,188,1,0 +BRDA:1273,189,0,12 +BRDA:1273,189,1,0 +BRDA:1273,190,0,12 +BRDA:1273,190,1,12 +BRDA:1299,191,0,52 +BRDA:1299,191,1,0 +BRDA:1299,192,0,52 +BRDA:1299,192,1,52 +BRDA:1302,193,0,52 +BRDA:1302,193,1,0 +BRDA:1318,194,0,55 +BRDA:1318,194,1,110 +BRDA:1330,195,0,53 +BRDA:1330,195,1,1 +BRDA:1331,196,0,53 +BRDA:1331,196,1,0 +BRDA:1334,197,0,1 +BRDA:1334,197,1,52 +BRDA:1334,198,0,53 +BRDA:1334,198,1,53 +BRDA:1334,198,2,53 +BRDA:1338,199,0,52 +BRDA:1338,199,1,1 +BRDA:1338,200,0,53 +BRDA:1338,200,1,53 +BRDA:1353,201,0,0 +BRDA:1353,201,1,52 +BRDA:1365,202,0,134 +BRDA:1365,202,1,132 +BRDA:1365,202,2,2 +BRDA:1369,203,0,131 +BRDA:1369,203,1,3 +BRDA:1371,204,0,2 +BRDA:1371,204,1,1 +BRDA:1372,205,0,2 +BRDA:1372,205,1,0 +BRDA:1375,206,0,1 +BRDA:1375,206,1,1 +BRDA:1375,207,0,2 +BRDA:1375,207,1,2 +BRDA:1375,207,2,2 +BRDA:1378,208,0,1 +BRDA:1378,208,1,0 +BRDA:1378,209,0,1 +BRDA:1378,209,1,1 +BRDA:1392,210,0,220 +BRDA:1392,210,1,584 +BRDA:1392,211,0,804 +BRDA:1392,211,1,584 +BRDA:1394,212,0,2 +BRDA:1394,212,1,582 +BRDA:1396,213,0,581 +BRDA:1396,213,1,1 +BRDA:1404,214,0,12 +BRDA:1404,214,1,0 +BRDA:1404,215,0,12 +BRDA:1404,215,1,12 +BRDA:1407,216,0,12 +BRDA:1407,216,1,0 +BRF:449 +BRH:369 +end_of_record +TN: +SF:static\js\state-manager.js +FN:14,(anonymous_0) +FN:25,(anonymous_1) +FN:40,(anonymous_2) +FN:51,(anonymous_3) +FN:126,(anonymous_4) +FN:198,(anonymous_5) +FN:214,(anonymous_6) +FN:229,(anonymous_7) +FN:247,(anonymous_8) +FN:261,(anonymous_9) +FN:263,(anonymous_10) +FN:277,(anonymous_11) +FN:297,(anonymous_12) +FNF:13 +FNH:13 +FNDA:13,(anonymous_0) +FNDA:360,(anonymous_1) +FNDA:365,(anonymous_2) +FNDA:150,(anonymous_3) +FNDA:203,(anonymous_4) +FNDA:6,(anonymous_5) +FNDA:8,(anonymous_6) +FNDA:1,(anonymous_7) +FNDA:2,(anonymous_8) +FNDA:1,(anonymous_9) +FNDA:2,(anonymous_10) +FNDA:1258,(anonymous_11) +FNDA:106,(anonymous_12) +DA:14,13 +DA:17,13 +DA:18,13 +DA:26,360 +DA:27,360 +DA:29,360 +DA:31,1 +DA:41,365 +DA:42,365 +DA:52,150 +DA:53,150 +DA:54,1 +DA:56,1 +DA:60,1 +DA:63,149 +DA:70,149 +DA:71,1 +DA:73,1 +DA:77,1 +DA:80,148 +DA:81,148 +DA:83,148 +DA:84,148 +DA:85,146 +DA:88,2 +DA:89,1 +DA:91,1 +DA:95,1 +DA:96,1 +DA:97,1 +DA:98,1 +DA:100,0 +DA:102,0 +DA:106,0 +DA:111,1 +DA:113,1 +DA:117,1 +DA:127,203 +DA:128,203 +DA:129,0 +DA:131,0 +DA:135,0 +DA:138,203 +DA:140,203 +DA:141,203 +DA:142,203 +DA:144,3 +DA:146,3 +DA:150,3 +DA:153,200 +DA:155,106 +DA:156,106 +DA:157,106 +DA:160,2 +DA:161,2 +DA:165,2 +DA:167,2 +DA:171,2 +DA:174,104 +DA:175,104 +DA:176,1 +DA:177,1 +DA:181,1 +DA:183,1 +DA:187,1 +DA:190,103 +DA:199,6 +DA:200,6 +DA:201,6 +DA:202,6 +DA:203,6 +DA:215,8 +DA:216,7 +DA:217,7 +DA:218,5 +DA:219,4 +DA:221,4 +DA:230,1 +DA:231,1 +DA:233,1 +DA:234,1 +DA:235,1 +DA:236,4 +DA:237,4 +DA:238,4 +DA:239,3 +DA:240,2 +DA:247,1 +DA:248,2 +DA:249,2 +DA:250,2 +DA:251,2 +DA:252,2 +DA:253,1 +DA:256,1 +DA:258,2 +DA:261,1 +DA:263,1 +DA:264,2 +DA:265,2 +DA:278,1258 +DA:279,1257 +DA:281,1256 +DA:282,1256 +DA:283,3760 +DA:284,3760 +DA:285,3760 +DA:286,3757 +DA:289,1252 +DA:298,106 +DA:301,106 +DA:302,1 +DA:306,105 +DA:309,1 +DA:314,13 +DA:317,13 +DA:318,13 +LF:117 +LH:111 +BRDA:27,0,0,0 +BRDA:27,0,1,360 +BRDA:29,1,0,360 +BRDA:29,1,1,1 +BRDA:41,2,0,363 +BRDA:41,2,1,2 +BRDA:41,3,0,365 +BRDA:41,3,1,363 +BRDA:53,4,0,1 +BRDA:53,4,1,149 +BRDA:65,5,0,149 +BRDA:65,5,1,0 +BRDA:65,6,0,149 +BRDA:65,6,1,149 +BRDA:66,7,0,149 +BRDA:66,7,1,0 +BRDA:66,8,0,149 +BRDA:66,8,1,149 +BRDA:70,9,0,1 +BRDA:70,9,1,148 +BRDA:88,10,0,1 +BRDA:88,10,1,1 +BRDA:128,11,0,0 +BRDA:128,11,1,203 +BRDA:153,12,0,94 +BRDA:153,12,1,106 +BRDA:175,13,0,1 +BRDA:175,13,1,103 +BRDA:201,14,0,0 +BRDA:201,14,1,6 +BRDA:215,15,0,1 +BRDA:215,15,1,7 +BRDA:216,16,0,5 +BRDA:216,16,1,2 +BRDA:217,17,0,2 +BRDA:217,17,1,5 +BRDA:218,18,0,1 +BRDA:218,18,1,4 +BRDA:219,19,0,3 +BRDA:219,19,1,1 +BRDA:221,20,0,4 +BRDA:221,20,1,3 +BRDA:231,21,0,0 +BRDA:231,21,1,1 +BRDA:237,22,0,0 +BRDA:237,22,1,4 +BRDA:238,23,0,1 +BRDA:238,23,1,3 +BRDA:239,24,0,1 +BRDA:239,24,1,2 +BRDA:239,25,0,3 +BRDA:239,25,1,3 +BRDA:251,26,0,2 +BRDA:251,26,1,0 +BRDA:253,27,0,1 +BRDA:253,27,1,0 +BRDA:253,28,0,1 +BRDA:253,28,1,1 +BRDA:278,29,0,1 +BRDA:278,29,1,1257 +BRDA:278,30,0,1258 +BRDA:278,30,1,1257 +BRDA:279,31,0,1 +BRDA:279,31,1,1256 +BRDA:279,32,0,1257 +BRDA:279,32,1,1257 +BRDA:284,33,0,0 +BRDA:284,33,1,3760 +BRDA:284,34,0,3760 +BRDA:284,34,1,3760 +BRDA:285,35,0,3 +BRDA:285,35,1,3757 +BRDA:285,36,0,3760 +BRDA:285,36,1,3760 +BRDA:286,37,0,1 +BRDA:286,37,1,3756 +BRDA:298,38,0,0 +BRDA:298,38,1,106 +BRDA:298,39,0,106 +BRDA:298,39,1,106 +BRDA:301,40,0,1 +BRDA:301,40,1,105 +BRDA:306,41,0,104 +BRDA:306,41,1,1 +BRDA:317,42,0,13 +BRDA:317,42,1,0 +BRDA:317,43,0,13 +BRDA:317,43,1,13 +BRDA:320,44,0,13 +BRDA:320,44,1,0 +BRF:90 +BRH:77 +end_of_record diff --git a/tests/layout-system/integration/drag-resize.test.js b/tests/layout-system/integration/drag-resize.test.js new file mode 100644 index 0000000..6876bef --- /dev/null +++ b/tests/layout-system/integration/drag-resize.test.js @@ -0,0 +1,112 @@ +/** + * 集成测试 - 拖动调整宽度完整流程(TASK-02-002) + * + * 说明: + * - 该文件聚焦 TC-F1-001-P:从 mousedown 到 mouseup 的完整链路。 + * - 其他边界/性能用例将在后续 TASK 中逐步补齐(避免影响阶段性迭代)。 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, simulateDrag, wait, getCSSVariable } = require('../test-utils.js'); + +describe('拖动调整宽度集成测试(TASK-02-002)', () => { + let layoutManager; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + layoutManager = new LayoutManager(); + layoutManager.init(); + }); + + test('TC-F1-001-P: 完整的拖动流程', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + // 初始宽度 + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + + // 模拟拖动:从 200px 拖到 250px(+50) + simulateDrag(resizer, 200, 250); + + // 等待 requestAnimationFrame 执行 + await wait(50); + + // 验证宽度已更新 + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + }); + + test('TC-F1-005-N: 快速拖动导致鼠标移出窗口后应正常结束', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + // 开始拖动 + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + expect(document.body.classList.contains('layout-resizing')).toBe(true); + + // 模拟快速拖动到窗口外(以超大 clientX 表示) + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 5000 }) + ); + + // 模拟窗口失焦(等价于鼠标移出/切到其他窗口) + window.dispatchEvent(new Event('blur')); + await wait(50); + + // 应结束拖动并清理全局状态 + expect(layoutManager.isResizing).toBe(false); + expect(document.body.classList.contains('layout-resizing')).toBe(false); + }); + + test('TC-F1-006-PF: 拖动更新频率应接近 60fps(基于 updatePanelWidth 调用间隔模拟)', () => { + jest.useFakeTimers(); + try { + const resizer = document.querySelector('[data-resizer="groups"]'); + const timestamps = []; + + const originalUpdate = layoutManager.updatePanelWidth.bind(layoutManager); + jest + .spyOn(layoutManager, 'updatePanelWidth') + .mockImplementation((panel, width) => { + timestamps.push(performance.now()); + return originalUpdate(panel, width); + }); + + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + + // 模拟连续拖动(约 0.5s,使用 fake timers 保证稳定性) + for (let i = 0; i < 30; i++) { + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 200 + i }) + ); + jest.advanceTimersByTime(16); + } + + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + jest.advanceTimersByTime(50); + + // 至少应发生多次更新 + expect(timestamps.length).toBeGreaterThanOrEqual(10); + + // 计算平均 fps(忽略第一帧) + const deltas = []; + for (let i = 1; i < timestamps.length; i++) { + const delta = timestamps[i] - timestamps[i - 1]; + if (delta > 0) deltas.push(delta); + } + const avgDelta = deltas.reduce((a, b) => a + b, 0) / deltas.length; + const avgFps = 1000 / avgDelta; + + // 允许一定误差(jsdom + 定时器会有波动) + expect(avgFps).toBeGreaterThanOrEqual(55); + } finally { + jest.useRealTimers(); + } + }); +}); diff --git a/tests/layout-system/integration/keyboard-resizer.test.js b/tests/layout-system/integration/keyboard-resizer.test.js new file mode 100644 index 0000000..bd67b2d --- /dev/null +++ b/tests/layout-system/integration/keyboard-resizer.test.js @@ -0,0 +1,75 @@ +/** + * 集成测试 - 键盘可访问性(F6) + * + * 覆盖: + * - TC-F6-002-P: 方向键调整宽度 + * - TC-F6-003-P: Shift + 方向键快速调整 + * - 键盘调整后触发 500ms 防抖保存(F6.1) + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable, simulateKeyPress } = require('../test-utils.js'); + +describe('键盘可访问性集成测试(F6)', () => { + let layoutManager; + const storageKey = 'outlook_layout_state_guest'; + + beforeEach(() => { + jest.useFakeTimers(); + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + + layoutManager = new LayoutManager(); + layoutManager.init(); + jest.runOnlyPendingTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('TC-F6-002-P: 方向键调整宽度(10px)', () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + + simulateKeyPress(resizer, 'ArrowRight'); + expect(getCSSVariable('--group-panel-width')).toBe('210px'); + expect(resizer.getAttribute('aria-valuenow')).toBe('210'); + + simulateKeyPress(resizer, 'ArrowLeft'); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(resizer.getAttribute('aria-valuenow')).toBe('200'); + }); + + test('TC-F6-003-P: Shift + 方向键快速调整(50px)', () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + + simulateKeyPress(resizer, 'ArrowRight', true); + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + expect(resizer.getAttribute('aria-valuenow')).toBe('250'); + }); + + test('键盘调整后应触发 500ms 防抖保存', () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + simulateKeyPress(resizer, 'ArrowRight'); + + // 500ms 内不应写入 + expect(window.localStorage.getItem(storageKey)).toBeNull(); + jest.advanceTimersByTime(499); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + + // 500ms 到达后写入 + jest.advanceTimersByTime(1); + const savedRaw = window.localStorage.getItem(storageKey); + expect(savedRaw).toBeTruthy(); + const saved = JSON.parse(savedRaw); + expect(saved.panels.groups.width).toBe('210px'); + }); +}); + diff --git a/tests/layout-system/integration/panel-collapse.test.js b/tests/layout-system/integration/panel-collapse.test.js new file mode 100644 index 0000000..89d59fe --- /dev/null +++ b/tests/layout-system/integration/panel-collapse.test.js @@ -0,0 +1,115 @@ +/** + * 集成测试 - 面板折叠/展开完整流程 + * + * 覆盖:TD 文档 F2 用例(TC-F2-001 ~ TC-F2-005) + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable } = require('../test-utils.js'); + +describe('面板折叠/展开集成测试(阶段三)', () => { + let layoutManager; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + layoutManager = new LayoutManager(); + layoutManager.init(); + }); + + test('TC-F2-001-P: 点击折叠按钮折叠面板', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '200px' + ); + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + // 宽度变为 0(布局生效宽度),保存宽度不丢失 + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + + // 折叠状态与按钮状态更新 + expect(panel.classList.contains('collapsed')).toBe(true); + expect(btn.textContent.trim()).toBe('→'); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + expect(btn.getAttribute('aria-label')).toBe('展开分组面板'); + + // 指示器存在且可用于展开 + const indicator = panel.querySelector('.collapsed-indicator[data-panel="groups"]'); + expect(indicator).toBeTruthy(); + expect(indicator.getAttribute('role')).toBe('button'); + expect(indicator.getAttribute('tabindex')).toBe('0'); + expect(indicator.getAttribute('aria-label')).toBe('展开分组面板'); + }); + + test('TC-F2-002-P: 点击折叠指示条展开面板', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + const indicator = panel.querySelector('.collapsed-indicator[data-panel="groups"]'); + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(panel.classList.contains('collapsed')).toBe(true); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + + indicator.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(panel.classList.contains('collapsed')).toBe(false); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '200px' + ); + expect(btn.textContent.trim()).toBe('←'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + expect(btn.getAttribute('aria-label')).toBe('折叠分组面板'); + }); + + test('TC-F2-003-N: 尝试折叠所有面板应提示并阻止', () => { + // 先折叠另外两个面板(auto=true 绕过规则) + layoutManager.collapsePanel('accounts', true); + layoutManager.collapsePanel('emails', true); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const groupsBtn = groupsPanel.querySelector('.collapse-btn'); + + const originalToast = window.showToast; + window.showToast = jest.fn(); + try { + groupsBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(groupsPanel.classList.contains('collapsed')).toBe(false); + expect(window.showToast).toHaveBeenCalledWith('至少需要保持一个面板展开', 'warning'); + } finally { + window.showToast = originalToast; + } + }); + + test('TC-F2-004-P/TC-F2-005-N: 折叠后 resizer 不可用且无法拖动', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + const resizer = document.querySelector('[data-resizer="groups"]'); + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(panel.classList.contains('collapsed')).toBe(true); + + // 尝试拖动(startResize 内部应直接返回) + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX: 200, button: 0 }) + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, cancelable: true, clientX: 260 }) + ); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })); + + expect(layoutManager.isResizing).toBe(false); + }); +}); diff --git a/tests/layout-system/integration/reset-layout.test.js b/tests/layout-system/integration/reset-layout.test.js new file mode 100644 index 0000000..f3d22be --- /dev/null +++ b/tests/layout-system/integration/reset-layout.test.js @@ -0,0 +1,74 @@ +/** + * 集成测试 - 恢复默认布局(F3) + * + * 覆盖: + * - TC-F3-001-P: 恢复默认布局(宽度/折叠状态恢复 + localStorage 清除 + 300ms 动画开关) + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable } = require('../test-utils.js'); + +describe('恢复默认布局集成测试(F3)', () => { + let layoutManager; + const storageKey = 'outlook_layout_state_guest'; + + beforeEach(() => { + jest.useFakeTimers(); + createLayoutDOM(); + + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + + layoutManager = new LayoutManager(); + layoutManager.init(); + + // 清掉 init() 里 requestAnimationFrame/withNoTransition 的残留计时器 + jest.runOnlyPendingTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('TC-F3-001-P: resetLayout() 应恢复默认宽度、展开面板并清除 localStorage', () => { + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const accountsPanel = document.querySelector('[data-panel="accounts"]'); + const emailsPanel = document.querySelector('[data-panel="emails"]'); + + // 先制造“非默认状态”并写入 localStorage + layoutManager.updatePanelWidth(groupsPanel, 250); + layoutManager.collapsePanel('accounts', false); // 手动折叠会立即保存 + expect(window.localStorage.getItem(storageKey)).toBeTruthy(); + + // 再制造一个“尚未到期的保存防抖”,用于验证 reset 会 cancel + layoutManager.saveDebouncer.debounced(); + + layoutManager.resetLayout(); + + // 立即生效:localStorage 被清空 + expect(window.localStorage.getItem(storageKey)).toBeNull(); + + // 默认宽度恢复 + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(getCSSVariable('--account-panel-width')).toBe('260px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); + + // 面板展开 + expect(groupsPanel.classList.contains('collapsed')).toBe(false); + expect(accountsPanel.classList.contains('collapsed')).toBe(false); + expect(emailsPanel.classList.contains('collapsed')).toBe(false); + + // 300ms 动画开关:class 先存在,随后移除 + expect(document.documentElement.classList.contains('layout-resetting')).toBe(true); + jest.advanceTimersByTime(350); + expect(document.documentElement.classList.contains('layout-resetting')).toBe(false); + + // 防抖保存应被取消:推进 500ms 不应重新写入 + jest.advanceTimersByTime(1000); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + }); +}); + diff --git a/tests/layout-system/integration/state-error-handling.test.js b/tests/layout-system/integration/state-error-handling.test.js new file mode 100644 index 0000000..0f22e26 --- /dev/null +++ b/tests/layout-system/integration/state-error-handling.test.js @@ -0,0 +1,101 @@ +/** + * 集成测试 - 状态持久化错误处理(TASK-04-005) + * + * 覆盖: + * - TC-F4-006-N: localStorage 不可用 + * - TC-F4-007-N: localStorage 数据损坏 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable, simulateDrag } = require('../test-utils.js'); + +describe('状态持久化错误处理集成测试(TASK-04-005)', () => { + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + }); + + test('TC-F4-006-N: localStorage 不可用时应降级为默认布局且不影响拖动', () => { + const originalLocalStorage = window.localStorage; + + // 模拟隐私模式/禁用 localStorage:访问/读写均抛错 + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: () => { + throw new Error('localStorage disabled'); + }, + setItem: () => { + throw new Error('localStorage disabled'); + }, + removeItem: () => { + throw new Error('localStorage disabled'); + }, + key: () => null, + get length() { + return 0; + } + } + }); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const layoutManager = new LayoutManager(); + layoutManager.init(); + + // 拖动仍应生效(只是无法持久化) + jest.useFakeTimers(); + try { + const resizer = document.querySelector('[data-resizer="groups"]'); + simulateDrag(resizer, 200, 250); + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + + // 即使等待 500ms,也不应抛异常 + jest.advanceTimersByTime(600); + } finally { + jest.useRealTimers(); + } + + // “刷新页面”后应回到默认布局(无法持久化) + createLayoutDOM(); + const layoutManager2 = new LayoutManager(); + layoutManager2.init(); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(getCSSVariable('--account-panel-width')).toBe('260px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); + } finally { + warnSpy.mockRestore(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: originalLocalStorage + }); + } + }); + + test('TC-F4-007-N: localStorage 数据损坏时应清除并使用默认布局', () => { + const storageKey = 'outlook_layout_state_guest'; + window.localStorage.setItem(storageKey, '{bad json'); + + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + try { + const layoutManager = new LayoutManager(); + layoutManager.init(); + + // 应使用默认布局(createLayoutDOM 初始化的 CSS 变量) + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(getCSSVariable('--account-panel-width')).toBe('260px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); + + // 损坏数据应被清除 + expect(window.localStorage.getItem(storageKey)).toBeNull(); + } finally { + errSpy.mockRestore(); + } + }); +}); + diff --git a/tests/layout-system/integration/state-load.test.js b/tests/layout-system/integration/state-load.test.js new file mode 100644 index 0000000..697305e --- /dev/null +++ b/tests/layout-system/integration/state-load.test.js @@ -0,0 +1,60 @@ +/** + * 集成测试 - 状态加载(TASK-04-003) + * + * 覆盖: + * - TC-F4-003-P: 页面刷新后恢复状态 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable } = require('../test-utils.js'); + +describe('状态加载集成测试(TASK-04-003)', () => { + const storageKey = 'outlook_layout_state_guest'; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + }); + + test('TC-F4-003-P: 页面刷新后状态恢复(宽度 + 折叠)', () => { + const state = { + version: '1.1', + userId: 'guest', + timestamp: Date.now(), + panels: { + groups: { width: '250px', collapsed: true }, + accounts: { width: '300px', collapsed: false }, + emails: { width: '420px', collapsed: false } + } + }; + + window.localStorage.setItem(storageKey, JSON.stringify(state)); + + const layoutManager = new LayoutManager(); + layoutManager.init(); + + // 宽度恢复 + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + expect(getCSSVariable('--account-panel-width')).toBe('300px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('420px'); + + // 折叠状态恢复(groups) + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const groupsBtn = groupsPanel.querySelector('.collapse-btn'); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(groupsPanel.dataset.autoCollapsed).toBeUndefined(); + expect(groupsBtn.getAttribute('aria-expanded')).toBe('false'); + expect(groupsBtn.textContent.trim()).toBe('→'); + expect(groupsBtn.getAttribute('aria-label')).toBe('展开分组面板'); + + // 布局变量应同步(折叠列宽为 0) + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + }); +}); + diff --git a/tests/layout-system/integration/state-multi-user.test.js b/tests/layout-system/integration/state-multi-user.test.js new file mode 100644 index 0000000..c2bd250 --- /dev/null +++ b/tests/layout-system/integration/state-multi-user.test.js @@ -0,0 +1,82 @@ +/** + * 集成测试 - 多用户隔离(TASK-04-004) + * + * 覆盖: + * - TC-F4-004-P: 多用户布局隔离 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, getCSSVariable, simulateDrag } = require('../test-utils.js'); + +describe('多用户隔离集成测试(TASK-04-004)', () => { + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + }); + + test('TC-F4-004-P: 不同用户的布局应独立存储(save 使用 userId key)', () => { + jest.useFakeTimers(); + try { + window.currentUserId = 'userA'; + + const layoutManager = new LayoutManager(); + layoutManager.init(); + + const resizer = document.querySelector('[data-resizer="groups"]'); + simulateDrag(resizer, 200, 260); + + // 500ms 到达后写入 outlook_layout_state_userA + jest.advanceTimersByTime(500); + + const savedRaw = window.localStorage.getItem('outlook_layout_state_userA'); + expect(savedRaw).toBeTruthy(); + + const saved = JSON.parse(savedRaw); + expect(saved.userId).toBe('userA'); + expect(saved.panels.groups.width).toBe('260px'); + + // 不应污染 guest key + expect(window.localStorage.getItem('outlook_layout_state_guest')).toBeNull(); + } finally { + jest.useRealTimers(); + delete window.currentUserId; + } + }); + + test('TC-F4-004-P: 用户切换后应加载各自的布局(userB 不应看到 userA 的布局)', () => { + // userA 保存一个自定义布局 + window.currentUserId = 'userA'; + window.localStorage.setItem( + 'outlook_layout_state_userA', + JSON.stringify({ + version: '1.1', + userId: 'userA', + timestamp: Date.now(), + panels: { + groups: { width: '250px', collapsed: false }, + accounts: { width: '320px', collapsed: false }, + emails: { width: '450px', collapsed: false } + } + }) + ); + + // “切换用户”相当于换一个 userId 并重新进入页面 + createLayoutDOM(); + window.currentUserId = 'userB'; + + const layoutManager = new LayoutManager(); + layoutManager.init(); + + // userB 无保存数据,应保持默认宽度(createLayoutDOM 初始化的 CSS 变量) + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(getCSSVariable('--account-panel-width')).toBe('260px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); + + delete window.currentUserId; + }); +}); + diff --git a/tests/layout-system/integration/state-save.test.js b/tests/layout-system/integration/state-save.test.js new file mode 100644 index 0000000..c8df393 --- /dev/null +++ b/tests/layout-system/integration/state-save.test.js @@ -0,0 +1,85 @@ +/** + * 集成测试 - 状态保存(TASK-04-002) + * + * 覆盖: + * - TC-F4-001-P: 拖动后 500ms 保存状态 + * - TC-F4-002-P: 折叠后立即保存状态 + * - TC-F4-005-P: beforeunload 强制保存 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, simulateDrag } = require('../test-utils.js'); + +describe('状态保存集成测试(TASK-04-002)', () => { + let layoutManager; + const storageKey = 'outlook_layout_state_guest'; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + layoutManager = new LayoutManager(); + layoutManager.init(); + }); + + test('TC-F4-001-P: 拖动后 500ms 保存状态', () => { + jest.useFakeTimers(); + try { + const resizer = document.querySelector('[data-resizer="groups"]'); + + simulateDrag(resizer, 200, 250); + + // 500ms 内不应写入 + expect(window.localStorage.getItem(storageKey)).toBeNull(); + jest.advanceTimersByTime(499); + expect(window.localStorage.getItem(storageKey)).toBeNull(); + + // 500ms 到达后写入 + jest.advanceTimersByTime(1); + const savedRaw = window.localStorage.getItem(storageKey); + expect(savedRaw).toBeTruthy(); + + const saved = JSON.parse(savedRaw); + expect(saved.version).toBe('1.1'); + expect(saved.userId).toBe('guest'); + expect(saved.panels.groups.width).toBe('250px'); + expect(saved.panels.groups.collapsed).toBe(false); + } finally { + jest.useRealTimers(); + } + }); + + test('TC-F4-002-P: 折叠后立即保存状态', () => { + const btn = document.querySelector('[data-panel="groups"] .collapse-btn'); + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + const savedRaw = window.localStorage.getItem(storageKey); + expect(savedRaw).toBeTruthy(); + const saved = JSON.parse(savedRaw); + expect(saved.panels.groups.collapsed).toBe(true); + }); + + test('TC-F4-005-P: 页面卸载时强制保存', () => { + jest.useFakeTimers(); + try { + const resizer = document.querySelector('[data-resizer="groups"]'); + + simulateDrag(resizer, 200, 260); + + // 防抖尚未到达时触发 beforeunload,应立即落盘 + expect(window.localStorage.getItem(storageKey)).toBeNull(); + window.dispatchEvent(new Event('beforeunload')); + + const savedRaw = window.localStorage.getItem(storageKey); + expect(savedRaw).toBeTruthy(); + const saved = JSON.parse(savedRaw); + expect(saved.panels.groups.width).toBe('260px'); + } finally { + jest.useRealTimers(); + } + }); +}); + diff --git a/tests/layout-system/integration/window-adapt.test.js b/tests/layout-system/integration/window-adapt.test.js new file mode 100644 index 0000000..4409b80 --- /dev/null +++ b/tests/layout-system/integration/window-adapt.test.js @@ -0,0 +1,125 @@ +/** + * 集成测试 - 窗口尺寸适配(F5) + * + * 覆盖: + * - TC-F5-001-P: <1200px 自动折叠分组面板 + * - TC-F5-002-P: <900px 自动折叠账号面板 + * - TC-F5-003-P: <700px 自动折叠邮件列表面板 + * - TC-F5-004-P: 窗口放大后自动恢复(仅恢复 autoCollapsed) + * - TC-F5-005-N: 用户手动折叠的面板不自动恢复 + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { createLayoutDOM, setWindowSize } = require('../test-utils.js'); + +describe('窗口尺寸适配集成测试(F5)', () => { + let layoutManager; + + beforeEach(() => { + jest.useFakeTimers(); + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + + layoutManager = new LayoutManager(); + layoutManager.init(); + jest.runOnlyPendingTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test('TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板', () => { + setWindowSize(1199, 800); + jest.advanceTimersByTime(200); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(groupsPanel.dataset.autoCollapsed).toBe('true'); + }); + + test('TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板', () => { + setWindowSize(899, 800); + jest.advanceTimersByTime(200); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const accountsPanel = document.querySelector('[data-panel="accounts"]'); + + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(accountsPanel.classList.contains('collapsed')).toBe(true); + expect(accountsPanel.dataset.autoCollapsed).toBe('true'); + }); + + test('TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板', () => { + setWindowSize(699, 800); + jest.advanceTimersByTime(200); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const accountsPanel = document.querySelector('[data-panel="accounts"]'); + const emailsPanel = document.querySelector('[data-panel="emails"]'); + + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(accountsPanel.classList.contains('collapsed')).toBe(true); + expect(emailsPanel.classList.contains('collapsed')).toBe(true); + expect(emailsPanel.dataset.autoCollapsed).toBe('true'); + }); + + test('TC-F5-004-P: 窗口放大后自动恢复面板(仅恢复 autoCollapsed)', () => { + setWindowSize(699, 800); + jest.advanceTimersByTime(200); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + const accountsPanel = document.querySelector('[data-panel="accounts"]'); + const emailsPanel = document.querySelector('[data-panel="emails"]'); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(accountsPanel.classList.contains('collapsed')).toBe(true); + expect(emailsPanel.classList.contains('collapsed')).toBe(true); + + setWindowSize(1600, 900); + jest.advanceTimersByTime(200); + + expect(groupsPanel.classList.contains('collapsed')).toBe(false); + expect(accountsPanel.classList.contains('collapsed')).toBe(false); + expect(emailsPanel.classList.contains('collapsed')).toBe(false); + expect(groupsPanel.dataset.autoCollapsed).not.toBe('true'); + expect(accountsPanel.dataset.autoCollapsed).not.toBe('true'); + expect(emailsPanel.dataset.autoCollapsed).not.toBe('true'); + }); + + test('TC-F5-005-N: 用户手动折叠的面板不自动恢复', () => { + const groupsPanel = document.querySelector('[data-panel="groups"]'); + + // 手动折叠:不打 autoCollapsed + layoutManager.collapsePanel('groups', false); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + expect(groupsPanel.dataset.autoCollapsed).not.toBe('true'); + + // 小屏下不应把“手动折叠”改写成 autoCollapsed + setWindowSize(1199, 800); + jest.advanceTimersByTime(200); + expect(groupsPanel.dataset.autoCollapsed).not.toBe('true'); + + // 放大也不应恢复 + setWindowSize(1600, 900); + jest.advanceTimersByTime(200); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + }); + + test('隐藏状态下的邮件列表不参与自动折叠/恢复(避免空白列回归)', () => { + const emailsPanel = document.querySelector('[data-panel="emails"]'); + emailsPanel.classList.add('hidden'); + + setWindowSize(699, 800); + jest.advanceTimersByTime(200); + expect(emailsPanel.dataset.autoCollapsed).not.toBe('true'); + + setWindowSize(1600, 900); + jest.advanceTimersByTime(200); + expect(emailsPanel.dataset.autoCollapsed).not.toBe('true'); + }); +}); + diff --git a/tests/layout-system/jest.config.js b/tests/layout-system/jest.config.js new file mode 100644 index 0000000..0c99fbd --- /dev/null +++ b/tests/layout-system/jest.config.js @@ -0,0 +1,48 @@ +/** + * Jest 配置文件 - 布局系统测试 + */ + +module.exports = { + // 测试环境 + testEnvironment: 'jsdom', + + // 根目录 + rootDir: '../../', + + // 测试文件匹配模式 + testMatch: [ + '**/tests/layout-system/unit/**/*.test.js', + '**/tests/layout-system/integration/**/*.test.js', + '**/tests/layout-system/performance/**/*.test.js', + '**/tests/layout-system/accessibility/**/*.test.js' + ], + + // 覆盖率配置 + collectCoverage: true, + coverageDirectory: 'tests/layout-system/coverage', + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80 + } + }, + + // 覆盖率收集范围 + collectCoverageFrom: [ + 'static/js/layout-manager.js', + 'static/js/state-manager.js', + '!**/node_modules/**', + '!**/tests/**' + ], + + // 设置超时时间 + testTimeout: 10000, + + // 详细输出 + verbose: true, + + // 模拟 localStorage + setupFilesAfterEnv: ['/tests/layout-system/setup.js'] +}; diff --git a/tests/layout-system/performance/layout-performance.test.js b/tests/layout-system/performance/layout-performance.test.js new file mode 100644 index 0000000..89165fc --- /dev/null +++ b/tests/layout-system/performance/layout-performance.test.js @@ -0,0 +1,270 @@ +/** + * 布局系统性能测试 + * + * 测试目标: + * - 拖动帧率 ≥ 60fps(RAF 节流验证) + * - 折叠动画时长 200ms + * - localStorage 数据 < 1KB + * - 状态保存防抖 500ms + * - DOM 批量操作无冗余 + * + * 参考文档:TD 第七章 - 性能测试方案 + */ + +const { + createLayoutDOM, + getCSSVariable, + setCSSVariable, + simulateDrag, + wait, + createTestState, + setWindowSize +} = require('../test-utils'); + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const StateManager = require('../../../static/js/state-manager.js'); + +// ======================== +// 1. RAF 节流验证 +// ======================== +describe('性能测试 - RAF 节流', () => { + let manager; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); + manager = new LayoutManager(); + manager.init(); + }); + + test('拖动期间应使用 requestAnimationFrame 节流', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); + + const resizer = document.querySelector('[data-resizer="groups"]'); + const mousedown = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: 200 + }); + + resizer.dispatchEvent(mousedown); + + // 快速连续触发多次 mousemove + for (let i = 0; i < 10; i++) { + const mousemove = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: 200 + i * 5 + }); + document.dispatchEvent(mousemove); + } + + // RAF 应该被调用,但不应每次 mousemove 都产生独立的 RAF + // 由于 RAF mock 是同步 setTimeout(cb, 16),实际调用数取决于实现 + expect(rafSpy).toHaveBeenCalled(); + + // 停止拖动 + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + rafSpy.mockRestore(); + }); + + test('非拖动状态下 mousemove 不应触发 RAF', () => { + const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); + const callsBefore = rafSpy.mock.calls.length; + + const mousemove = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: 300 + }); + document.dispatchEvent(mousemove); + + expect(rafSpy.mock.calls.length).toBe(callsBefore); + rafSpy.mockRestore(); + }); +}); + +// ======================== +// 2. 防抖保存验证 +// ======================== +describe('性能测试 - 状态保存防抖', () => { + let manager; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); + manager = new LayoutManager(); + manager.init(); + }); + + test('saveState 应使用防抖机制', async () => { + // 验证 saveState 方法存在且可调用 + if (typeof manager.saveState === 'function') { + // saveState 在有 stateManager 时应触发保存(防抖或直接保存) + // 关键验证:多次调用不会产生多次写入 + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + manager.saveState(); + manager.saveState(); + manager.saveState(); + + // 无论防抖还是直接保存,连续调用不应产生 3 次独立的写入 + const callCount = setItemSpy.mock.calls.length; + expect(callCount).toBeLessThanOrEqual(3); + setItemSpy.mockRestore(); + } + }); + + test('saveStateNow 应立即写入 localStorage', () => { + const setItemSpy = jest.spyOn(Storage.prototype, 'setItem'); + + if (typeof manager.saveStateNow === 'function') { + manager.saveStateNow(); + expect(setItemSpy).toHaveBeenCalled(); + } + + setItemSpy.mockRestore(); + }); +}); + +// ======================== +// 3. localStorage 数据大小 +// ======================== +describe('性能测试 - localStorage 数据大小', () => { + test('保存的状态数据应 < 1KB', () => { + const sm = new StateManager(); + const state = createTestState('testuser'); + + sm.save('testuser', state); + + const key = 'outlook_layout_state_testuser'; + const data = localStorage.getItem(key); + + if (data) { + const sizeInBytes = new Blob([data]).size; + expect(sizeInBytes).toBeLessThan(1024); // < 1KB + } + }); + + test('状态数据应为有效 JSON', () => { + const sm = new StateManager(); + const state = createTestState('testuser'); + + sm.save('testuser', state); + + const key = 'outlook_layout_state_testuser'; + const data = localStorage.getItem(key); + + if (data) { + expect(() => JSON.parse(data)).not.toThrow(); + } + }); +}); + +// ======================== +// 4. 折叠动画时间配置验证 +// ======================== +describe('性能测试 - 动画配置', () => { + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); + }); + + test('CSS 变量 --panel-transition-duration 应为 200ms', () => { + // 设置 CSS 变量(模拟 layout.css 加载后的状态) + setCSSVariable('--panel-transition-duration', '200ms'); + const duration = getCSSVariable('--panel-transition-duration'); + expect(duration).toBe('200ms'); + }); + + test('折叠操作应添加/移除 collapsed 类', () => { + const manager = new LayoutManager(); + manager.init(); + + manager.collapsePanel('groups'); + const panel = document.querySelector('[data-panel="groups"]'); + expect(panel.classList.contains('collapsed')).toBe(true); + + manager.expandPanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(false); + }); +}); + +// ======================== +// 5. 批量 DOM 操作验证 +// ======================== +describe('性能测试 - DOM 操作效率', () => { + let manager; + + beforeEach(() => { + createLayoutDOM(); + Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1600 }); + manager = new LayoutManager(); + manager.init(); + }); + + test('updatePanelWidth 应通过 CSS 变量批量更新而非直接操作 style.width', () => { + // updatePanelWidth 接受 panel DOM 元素和宽度数值 + const panel = document.querySelector('[data-panel="groups"]'); + manager.updatePanelWidth(panel, 250); + + const cssVar = getCSSVariable('--group-panel-width'); + expect(cssVar).toBe('250px'); + + // 面板元素不应直接设置 style.width(由 CSS Grid 通过变量控制) + expect(panel.style.width).toBe(''); + }); + + test('resetLayout 应一次性恢复所有面板宽度', () => { + // 先修改宽度 + manager.updatePanelWidth('groups', 300); + manager.updatePanelWidth('accounts', 400); + manager.updatePanelWidth('emails', 500); + + // 重置 + manager.resetLayout(); + + // 验证所有面板恢复默认值 + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + expect(getCSSVariable('--account-panel-width')).toBe('260px'); + expect(getCSSVariable('--email-list-panel-width')).toBe('380px'); + }); +}); + +// ======================== +// 6. 状态管理器性能 +// ======================== +describe('性能测试 - StateManager 操作速度', () => { + test('save/load 操作应在 5ms 内完成', () => { + const sm = new StateManager(); + const state = createTestState('perftest'); + + // 测试 save 性能 + const saveStart = performance.now(); + for (let i = 0; i < 100; i++) { + sm.save('perftest', state); + } + const saveTime = (performance.now() - saveStart) / 100; + expect(saveTime).toBeLessThan(5); + + // 测试 load 性能 + const loadStart = performance.now(); + for (let i = 0; i < 100; i++) { + sm.load('perftest'); + } + const loadTime = (performance.now() - loadStart) / 100; + expect(loadTime).toBeLessThan(5); + }); + + test('validate 操作应在 1ms 内完成', () => { + const sm = new StateManager(); + const state = createTestState('perftest'); + + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + sm.validate(state); + } + const avgTime = (performance.now() - start) / 1000; + expect(avgTime).toBeLessThan(1); + }); +}); diff --git a/tests/layout-system/setup.js b/tests/layout-system/setup.js new file mode 100644 index 0000000..868945f --- /dev/null +++ b/tests/layout-system/setup.js @@ -0,0 +1,113 @@ +/** + * Jest 测试环境设置 + * 模拟浏览器 API 和 DOM 环境 + */ + +// 模拟 localStorage +class LocalStorageMock { + constructor() { + this.store = {}; + } + + clear() { + this.store = {}; + } + + getItem(key) { + return this.store[key] || null; + } + + setItem(key, value) { + this.store[key] = String(value); + } + + removeItem(key) { + delete this.store[key]; + } + + get length() { + return Object.keys(this.store).length; + } + + key(index) { + const keys = Object.keys(this.store); + return keys[index] || null; + } +} + +global.localStorage = new LocalStorageMock(); +// jsdom 环境下 window.localStorage 可能是独立实例;这里统一指向 mock,避免跨用例污染 +try { + if (typeof window !== 'undefined') { + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: global.localStorage + }); + } +} catch (_) { + // ignore +} + +// 模拟主应用的 toast(避免 LayoutManager.notify() 退化到 window.alert,触发 jsdom 的 not implemented 报错) +global.showToast = () => {}; +try { + if (typeof window !== 'undefined') { + window.showToast = global.showToast; + } +} catch (_) { + // ignore +} + +// 模拟 requestAnimationFrame +global.requestAnimationFrame = (callback) => { + return setTimeout(callback, 16); +}; + +global.cancelAnimationFrame = (id) => { + clearTimeout(id); +}; + +// 模拟 performance.now +if (!global.performance) { + global.performance = {}; +} +global.performance.now = () => Date.now(); + +// 模拟 CSS.supports +if (!global.CSS) { + global.CSS = {}; +} +global.CSS.supports = (property, value) => { + // 简单模拟,假设支持所有现代 CSS 特性 + return true; +}; + +// 模拟 getComputedStyle +global.getComputedStyle = (element) => { + return { + getPropertyValue: (prop) => { + if ( + element && + element.style && + typeof element.style.getPropertyValue === 'function' + ) { + return element.style.getPropertyValue(prop) || ''; + } + return ''; + } + }; +}; + +// 清理函数 +afterEach(() => { + // 清空 localStorage + localStorage.clear(); + + // 清空 document.body + document.body.innerHTML = ''; + + // 重置 CSS 变量 + if (document.documentElement.style) { + document.documentElement.style.cssText = ''; + } +}); diff --git a/tests/layout-system/test-utils.js b/tests/layout-system/test-utils.js new file mode 100644 index 0000000..98dc943 --- /dev/null +++ b/tests/layout-system/test-utils.js @@ -0,0 +1,297 @@ +/** + * 测试辅助工具(布局系统) + * + * 说明: + * - 本文件用于 Jest(jsdom) 环境下快速搭建 DOM、模拟事件、读取 CSS 变量。 + * - 测试用 DOM 结构尽量与 TDD 第八章的集成结构一致,便于后续集成/回归测试复用。 + */ + +/** + * 创建完整的布局 DOM 结构 + */ +function createLayoutDOM() { + const html = ` +
+ +
+
+

分组

+ +
+
+

分组内容

+
+ +
+ + +
+
+

账号

+ +
+
+

账号内容

+
+ +
+ + +
+
+

邮件列表

+ +
+
+

邮件列表内容

+
+ +
+ + + +
+ `; + + document.body.innerHTML = html; + + // 设置初始 CSS 变量(与 static/css/layout.css 命名保持一致) + document.documentElement.style.setProperty('--group-panel-width', '200px'); + document.documentElement.style.setProperty('--account-panel-width', '260px'); + document.documentElement.style.setProperty('--email-list-panel-width', '380px'); +} + +/** + * 创建简化的布局 DOM 结构(用于最小化单元测试场景) + */ +function createSimpleLayoutDOM() { + const html = ` +
+
+
+
+
+ `; + + document.body.innerHTML = html; + document.documentElement.style.setProperty('--group-panel-width', '200px'); +} + +/** + * 获取 CSS 变量值 + * @param {string} varName 例如:--group-panel-width + * @returns {string} + */ +function getCSSVariable(varName) { + return document.documentElement.style.getPropertyValue(varName).trim(); +} + +/** + * 设置 CSS 变量值 + * @param {string} varName + * @param {string} value + */ +function setCSSVariable(varName, value) { + document.documentElement.style.setProperty(varName, value); +} + +/** + * 模拟鼠标拖动事件 + * @param {HTMLElement} element 触发 mousedown 的元素 + * @param {number} startX 起始 clientX + * @param {number} endX 结束 clientX + */ +function simulateDrag(element, startX, endX) { + const mousedownEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + clientX: startX + }); + + const mousemoveEvent = new MouseEvent('mousemove', { + bubbles: true, + cancelable: true, + clientX: endX + }); + + const mouseupEvent = new MouseEvent('mouseup', { + bubbles: true, + cancelable: true + }); + + element.dispatchEvent(mousedownEvent); + document.dispatchEvent(mousemoveEvent); + document.dispatchEvent(mouseupEvent); +} + +/** + * 模拟键盘事件 + * @param {HTMLElement} element + * @param {string} key + * @param {boolean} shiftKey + */ +function simulateKeyPress(element, key, shiftKey = false) { + const keydownEvent = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key, + shiftKey + }); + + element.dispatchEvent(keydownEvent); +} + +/** + * 等待指定时间 + * @param {number} ms + * @returns {Promise} + */ +function wait(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 等待过渡动画完成(监听 transitionend) + * @param {HTMLElement} element + * @param {number} timeout + * @returns {Promise} + */ +function waitForTransition(element, timeout = 1000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Transition timeout')); + }, timeout); + + element.addEventListener( + 'transitionend', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true } + ); + }); +} + +/** + * 创建测试用的 localStorage 数据(StateManager 相关测试会使用) + * @param {string} userId + * @param {Record} overrides + */ +function createTestState(userId, overrides = {}) { + return { + version: '1.1', + userId, + timestamp: Date.now(), + panels: { + groups: { width: '200px', collapsed: false }, + accounts: { width: '260px', collapsed: false }, + emails: { width: '380px', collapsed: false }, + ...overrides + } + }; +} + +/** + * 模拟窗口大小变化 + * @param {number} width + * @param {number} height + */ +function setWindowSize(width, height) { + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: width + }); + + Object.defineProperty(window, 'innerHeight', { + writable: true, + configurable: true, + value: height + }); + + window.dispatchEvent(new Event('resize')); +} + +/** + * 检查元素是否可见(基于 offset) + * @param {HTMLElement} element + * @returns {boolean} + */ +function isVisible(element) { + return element.offsetWidth > 0 && element.offsetHeight > 0; +} + +/** + * 检查元素是否有焦点 + * @param {HTMLElement} element + * @returns {boolean} + */ +function hasFocus(element) { + return document.activeElement === element; +} + +module.exports = { + createLayoutDOM, + createSimpleLayoutDOM, + getCSSVariable, + setCSSVariable, + simulateDrag, + simulateKeyPress, + wait, + waitForTransition, + createTestState, + setWindowSize, + isVisible, + hasFocus +}; + diff --git a/tests/layout-system/unit/layout-manager.test.js b/tests/layout-system/unit/layout-manager.test.js new file mode 100644 index 0000000..d6af5ab --- /dev/null +++ b/tests/layout-system/unit/layout-manager.test.js @@ -0,0 +1,623 @@ +/** + * LayoutManager 单元测试(TASK-02-001) + * + * 覆盖范围: + * - 类基础结构与 init() + * - getMinWidth() / getMaxWidth() + * - updatePanelWidth() + */ + +const LayoutManager = require('../../../static/js/layout-manager.js'); +const { + createLayoutDOM, + getCSSVariable, + simulateDrag, + wait +} = require('../test-utils.js'); + +describe('LayoutManager 单元测试(TASK-02-001)', () => { + let layoutManager; + + beforeEach(() => { + createLayoutDOM(); + // jsdom 默认 innerWidth 较小会触发“详情面板最小宽度”约束,影响基础用例; + // 这里统一设置为较宽的视口,避免非目标场景干扰。 + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1600 + }); + layoutManager = new LayoutManager(); + layoutManager.init(); + }); + + test('init() 应该缓存 panels 与 resizers', () => { + expect(layoutManager.container).not.toBeNull(); + expect(layoutManager.panels.get('groups')).toBeTruthy(); + expect(layoutManager.panels.get('accounts')).toBeTruthy(); + expect(layoutManager.panels.get('emails')).toBeTruthy(); + expect(layoutManager.resizers.get('groups')).toBeTruthy(); + expect(layoutManager.resizers.get('accounts')).toBeTruthy(); + expect(layoutManager.resizers.get('emails')).toBeTruthy(); + }); + + describe('getMinWidth() - 获取最小宽度', () => { + test('应该返回分组面板的最小宽度', () => { + expect(layoutManager.getMinWidth('groups')).toBe(150); + }); + + test('应该返回账号面板的最小宽度', () => { + expect(layoutManager.getMinWidth('accounts')).toBe(180); + }); + + test('应该返回邮件列表面板的最小宽度', () => { + expect(layoutManager.getMinWidth('emails')).toBe(280); + }); + + test('未知类型应该回退到默认最小宽度', () => { + expect(layoutManager.getMinWidth('unknown')).toBe(150); + }); + }); + + describe('getMaxWidth() - 获取最大宽度', () => { + test('应该返回分组面板的最大宽度', () => { + expect(layoutManager.getMaxWidth('groups')).toBe(400); + }); + + test('应该返回账号面板的最大宽度', () => { + expect(layoutManager.getMaxWidth('accounts')).toBe(500); + }); + + test('应该返回邮件列表面板的最大宽度', () => { + expect(layoutManager.getMaxWidth('emails')).toBe(600); + }); + + test('未知类型应该回退到默认最大宽度', () => { + expect(layoutManager.getMaxWidth('unknown')).toBe(600); + }); + }); + + describe('updatePanelWidth() - 更新面板宽度', () => { + test('应该正确更新 CSS 变量并同步 aria-valuenow', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const resizer = document.querySelector('[data-resizer="groups"]'); + + layoutManager.updatePanelWidth(panel, 250); + + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + expect(resizer.getAttribute('aria-valuenow')).toBe('250'); + }); + + test('无效参数不应抛错', () => { + expect(() => layoutManager.updatePanelWidth(null, 200)).not.toThrow(); + expect(() => layoutManager.updatePanelWidth({}, 200)).not.toThrow(); + expect(() => { + const panel = document.querySelector('[data-panel="groups"]'); + layoutManager.updatePanelWidth(panel, NaN); + }).not.toThrow(); + }); + + test('未知 panelType 不应修改 CSS 变量', () => { + const before = getCSSVariable('--group-panel-width'); + const panel = document.createElement('div'); + panel.dataset.panel = 'unknown'; + + layoutManager.updatePanelWidth(panel, 333); + + expect(getCSSVariable('--group-panel-width')).toBe(before); + }); + }); + + describe('折叠/展开(TASK-03-001)', () => { + test('getExpandedPanels() 应返回当前展开的侧边栏面板(TASK-03-005)', () => { + expect(layoutManager.getExpandedPanels()).toEqual(['groups', 'accounts', 'emails']); + + layoutManager.collapsePanel('accounts', true); + expect(layoutManager.getExpandedPanels()).toEqual(['groups', 'emails']); + + layoutManager.collapsePanel('emails', true); + expect(layoutManager.getExpandedPanels()).toEqual(['groups']); + }); + + test('TC-F2-003-N: 不能折叠最后一个展开的面板(仅限制手动折叠)', () => { + // 先折叠其他两个面板(auto=true 不受限制) + layoutManager.collapsePanel('accounts', true); + layoutManager.collapsePanel('emails', true); + + const groupsPanel = document.querySelector('[data-panel="groups"]'); + expect(groupsPanel.classList.contains('collapsed')).toBe(false); + + // stub toast + const originalToast = window.showToast; + window.showToast = jest.fn(); + try { + layoutManager.collapsePanel('groups', false); + + // 应保持展开并提示 + expect(groupsPanel.classList.contains('collapsed')).toBe(false); + expect(window.showToast).toHaveBeenCalledWith('至少需要保持一个面板展开', 'warning'); + + // auto=true 允许折叠(为后续窗口自适应留口子) + layoutManager.collapsePanel('groups', true); + expect(groupsPanel.classList.contains('collapsed')).toBe(true); + } finally { + window.showToast = originalToast; + } + }); + + test('TC-F2-005-N: 折叠状态下不能拖动调整宽度(TASK-03-006)', async () => { + const panel = document.querySelector('[data-panel="groups"]'); + const resizer = document.querySelector('[data-resizer="groups"]'); + + layoutManager.collapsePanel('groups', false); + expect(panel.classList.contains('collapsed')).toBe(true); + + // 尝试触发拖动(应被 startResize 直接忽略) + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 260 }) + ); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + expect(layoutManager.isResizing).toBe(false); + expect(document.body.classList.contains('layout-resizing')).toBe(false); + }); + + test('TASK-03-007: 自动折叠应设置 data-autoCollapsed 标记,展开/手动折叠应移除', () => { + const panel = document.querySelector('[data-panel="groups"]'); + + layoutManager.collapsePanel('groups', true); + expect(panel.dataset.autoCollapsed).toBe('true'); + + layoutManager.expandPanel('groups'); + expect(panel.dataset.autoCollapsed).toBeUndefined(); + + layoutManager.collapsePanel('groups', false); + expect(panel.dataset.autoCollapsed).toBeUndefined(); + }); + + test('折叠/展开应更新按钮图标与 aria-label(TASK-03-002)', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + expect(btn.textContent.trim()).toBe('←'); + expect(btn.getAttribute('aria-label')).toBe('折叠分组面板'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + + layoutManager.collapsePanel('groups'); + + expect(btn.textContent.trim()).toBe('→'); + expect(btn.getAttribute('aria-label')).toBe('展开分组面板'); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + + layoutManager.expandPanel('groups'); + + expect(btn.textContent.trim()).toBe('←'); + expect(btn.getAttribute('aria-label')).toBe('折叠分组面板'); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + test('collapsePanel() 应添加 collapsed 类并更新 aria-expanded', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + expect(panel.classList.contains('collapsed')).toBe(false); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + + layoutManager.collapsePanel('groups'); + + expect(panel.classList.contains('collapsed')).toBe(true); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); + + test('expandPanel() 应移除 collapsed 类并更新 aria-expanded', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + layoutManager.collapsePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(true); + + layoutManager.expandPanel('groups'); + + expect(panel.classList.contains('collapsed')).toBe(false); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + test('togglePanel() 应在折叠/展开之间切换', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + layoutManager.togglePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(true); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + + layoutManager.togglePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(false); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + test('未知 panelType 应安全返回且不抛错', () => { + expect(() => layoutManager.collapsePanel('unknown')).not.toThrow(); + expect(() => layoutManager.expandPanel('unknown')).not.toThrow(); + expect(() => layoutManager.togglePanel('unknown')).not.toThrow(); + }); + }); + + describe('calculateNewWidth() - 计算新宽度', () => { + test('TC-F1-001-P: 应该正确计算拖动后的宽度', () => { + const newWidth = layoutManager.calculateNewWidth(200, 250, 200, 'groups'); + expect(newWidth).toBe(250); + }); + + test('TC-F1-002-B: 应该限制在最小宽度', () => { + const newWidth = layoutManager.calculateNewWidth(200, 0, 200, 'groups'); + expect(newWidth).toBe(150); + }); + + test('TC-F1-003-B: 应该限制在最大宽度', () => { + const newWidth = layoutManager.calculateNewWidth(200, 999, 200, 'groups'); + expect(newWidth).toBe(400); + }); + + test('TC-F1-004-B: 应该保证邮件详情面板至少 400px', () => { + // 设置一个较窄窗口并调整其他面板宽度,让 groups 的最大允许宽度受到约束 + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 1300 + }); + document.documentElement.style.setProperty('--account-panel-width', '300px'); + document.documentElement.style.setProperty('--email-list-panel-width', '280px'); + + const newWidth = layoutManager.calculateNewWidth(0, 1000, 200, 'groups'); + // 1300 - (300+280) - 400 = 320 + expect(newWidth).toBe(320); + }); + }); + + describe('calculateOtherPanelsWidth() - 计算其他面板总宽度', () => { + test('应返回除自身外的侧边栏宽度之和', () => { + // 默认:200 / 260 / 380 + expect(layoutManager.calculateOtherPanelsWidth('groups')).toBe(260 + 380); + expect(layoutManager.calculateOtherPanelsWidth('accounts')).toBe(200 + 380); + expect(layoutManager.calculateOtherPanelsWidth('emails')).toBe(200 + 260); + }); + }); + + describe('折叠/展开(TASK-03-001)', () => { + test('collapsePanel() 应添加 collapsed 类并更新 aria-expanded=false', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + expect(panel.classList.contains('collapsed')).toBe(false); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + + layoutManager.collapsePanel('groups'); + + expect(panel.classList.contains('collapsed')).toBe(true); + expect(btn.getAttribute('aria-expanded')).toBe('false'); + }); + + test('expandPanel() 应移除 collapsed 类并更新 aria-expanded=true', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const btn = panel.querySelector('.collapse-btn'); + + layoutManager.collapsePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(true); + + layoutManager.expandPanel('groups'); + + expect(panel.classList.contains('collapsed')).toBe(false); + expect(btn.getAttribute('aria-expanded')).toBe('true'); + }); + + test('togglePanel() 应在折叠/展开之间切换', () => { + const panel = document.querySelector('[data-panel="groups"]'); + + layoutManager.togglePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(true); + + layoutManager.togglePanel('groups'); + expect(panel.classList.contains('collapsed')).toBe(false); + }); + + test('未知 panelType 不应抛错', () => { + expect(() => layoutManager.collapsePanel('unknown')).not.toThrow(); + expect(() => layoutManager.expandPanel('unknown')).not.toThrow(); + expect(() => layoutManager.togglePanel('unknown')).not.toThrow(); + }); + }); + + describe('parsePx() - 解析 px 字符串', () => { + test('应该解析有效 px', () => { + expect(layoutManager.parsePx('200px')).toBe(200); + expect(layoutManager.parsePx(' 260px ')).toBe(260); + }); + + test('无效格式返回 null', () => { + expect(layoutManager.parsePx('200')).toBeNull(); + expect(layoutManager.parsePx('abcpx')).toBeNull(); + expect(layoutManager.parsePx('')).toBeNull(); + expect(layoutManager.parsePx(null)).toBeNull(); + }); + }); + + test('TC-F1-001-P: startResize/resize/stopResize 基本拖动流程应更新宽度', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + // 初始宽度 + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + + // 模拟拖动:从 200px 拖到 250px + simulateDrag(resizer, 200, 250); + await wait(50); + + // 宽度已更新 + expect(getCSSVariable('--group-panel-width')).toBe('250px'); + }); + + describe('分支覆盖补充(保证覆盖率阈值)', () => { + test('getPanelDisplayName() 应覆盖 accounts/emails/default 分支', () => { + expect(layoutManager.getPanelDisplayName('accounts')).toBe('账号'); + expect(layoutManager.getPanelDisplayName('emails')).toBe('邮件列表'); + expect(layoutManager.getPanelDisplayName('unknown')).toBe('面板'); + }); + + test('onCollapseButtonClick() 应根据 data-panel 调用 togglePanel', () => { + const btn = document.querySelector('.collapse-btn[data-panel="groups"]'); + const spy = jest.spyOn(layoutManager, 'togglePanel'); + + btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + + expect(spy).toHaveBeenCalledWith('groups'); + }); + + test('onCollapseButtonClick() data-panel 缺失时应安全返回', () => { + expect(() => + layoutManager.onCollapseButtonClick({ + currentTarget: { dataset: {} }, + preventDefault: () => {} + }) + ).not.toThrow(); + + expect(() => layoutManager.onCollapseButtonClick(null)).not.toThrow(); + }); + + test('ensureCollapsedIndicators() 应创建指示器并避免重复创建', () => { + const indicators = document.querySelectorAll('.collapsed-indicator'); + expect(indicators.length).toBe(3); + + const groupsIndicator = layoutManager.indicators.get('groups'); + expect(groupsIndicator).toBeTruthy(); + expect(groupsIndicator.getAttribute('role')).toBe('button'); + expect(groupsIndicator.getAttribute('tabindex')).toBe('0'); + expect(groupsIndicator.getAttribute('aria-label')).toBe('展开分组面板'); + expect(groupsIndicator.querySelector('.indicator-text').textContent).toBe('分组'); + + const before = document.querySelectorAll('.collapsed-indicator').length; + layoutManager.ensureCollapsedIndicators(); + const after = document.querySelectorAll('.collapsed-indicator').length; + expect(after).toBe(before); + }); + + test('点击/键盘操作折叠指示器应展开面板(TASK-03-004)', () => { + const panel = document.querySelector('[data-panel="groups"]'); + const indicator = layoutManager.indicators.get('groups'); + + layoutManager.collapsePanel('groups', true); + expect(panel.classList.contains('collapsed')).toBe(true); + + indicator.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + expect(panel.classList.contains('collapsed')).toBe(false); + + layoutManager.collapsePanel('groups', true); + expect(panel.classList.contains('collapsed')).toBe(true); + + indicator.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'Enter' }) + ); + expect(panel.classList.contains('collapsed')).toBe(false); + }); + + test('onIndicatorClick()/onIndicatorKeydown() 无效参数应安全返回', () => { + expect(() => layoutManager.onIndicatorClick(null)).not.toThrow(); + expect(() => + layoutManager.onIndicatorClick({ + currentTarget: { dataset: {} }, + preventDefault: () => {} + }) + ).not.toThrow(); + + expect(() => layoutManager.onIndicatorKeydown(null)).not.toThrow(); + expect(() => + layoutManager.onIndicatorKeydown({ + key: 'ArrowRight', + preventDefault: () => {}, + currentTarget: {} + }) + ).not.toThrow(); + }); + + test('updateCollapsedIndicatorOffsets() 多面板折叠时应设置不同偏移', () => { + const groupsIndicator = layoutManager.indicators.get('groups'); + const accountsIndicator = layoutManager.indicators.get('accounts'); + + layoutManager.collapsePanel('groups', true); + layoutManager.collapsePanel('accounts', true); + + expect(groupsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe( + '0px' + ); + expect(accountsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe( + '48px' + ); + + // 覆盖 parsed 分支:自定义指示器宽度 + document.documentElement.style.setProperty('--panel-collapsed-indicator-width', '60px'); + layoutManager.updateCollapsedIndicatorOffsets(); + expect(accountsIndicator.style.getPropertyValue('--collapsed-indicator-offset')).toBe( + '60px' + ); + }); + + test('syncAllPanelsLayoutVars() 应覆盖折叠分支', () => { + layoutManager.collapsePanel('groups', true); + layoutManager.syncAllPanelsLayoutVars(); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + }); + + test('getPanelLayoutWidth() 面板折叠时应返回 0', () => { + const panel = document.querySelector('[data-panel="groups"]'); + layoutManager.collapsePanel('groups', true); + expect(layoutManager.getPanelLayoutWidth('groups', panel)).toBe(0); + }); + + test('updatePanelWidth() 在折叠状态下不应覆盖布局宽度变量', () => { + const panel = document.querySelector('[data-panel="groups"]'); + + layoutManager.collapsePanel('groups', true); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + + layoutManager.updatePanelWidth(panel, 250); + expect(document.documentElement.style.getPropertyValue('--group-panel-width')).toBe('250px'); + expect(document.documentElement.style.getPropertyValue('--group-panel-layout-width')).toBe( + '0px' + ); + }); + + test('getContainerWidth() 应覆盖 rect 与 0 的分支', () => { + layoutManager.container.getBoundingClientRect = () => ({ width: 999 }); + expect(layoutManager.getContainerWidth()).toBe(999); + + // 无容器 + innerWidth 非数字 => 0 + layoutManager.container = null; + Object.defineProperty(window, 'innerWidth', { + writable: true, + configurable: true, + value: 'abc' + }); + expect(layoutManager.getContainerWidth()).toBe(0); + }); + + test('startResize() 应忽略非左键点击', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 1 }) + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 250 }) + ); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + await wait(30); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + }); + + test('resize() 在未开始拖动时应直接返回', () => { + expect(() => layoutManager.resize(new MouseEvent('mousemove'))).not.toThrow(); + expect(layoutManager.rafId).toBe(0); + }); + + test('resize() 同一帧内应只调度一次 requestAnimationFrame', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + const rafSpy = jest.spyOn(global, 'requestAnimationFrame'); + + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 240 }) + ); + document.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 250 }) + ); + + expect(rafSpy).toHaveBeenCalledTimes(1); + + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await wait(30); + rafSpy.mockRestore(); + }); + + test('stopResize() 在未开始拖动时应直接返回', () => { + expect(() => layoutManager.stopResize()).not.toThrow(); + }); + + test('stopResize() 在无 mousemove 时不应进入 raf flush 分支', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + await wait(30); + expect(getCSSVariable('--group-panel-width')).toBe('200px'); + }); + + test('getPanelWidth() 在 CSS 变量无法解析时应回退到 getBoundingClientRect', () => { + const panel = document.createElement('div'); + panel.getBoundingClientRect = () => ({ width: 123 }); + expect(layoutManager.getPanelWidth('unknown', panel)).toBe(123); + }); + + test('getPanelWidth() 在 rect 无效时应回退到 offsetWidth', () => { + const panel = document.createElement('div'); + panel.getBoundingClientRect = () => ({ width: 0 }); + Object.defineProperty(panel, 'offsetWidth', { value: 88 }); + expect(layoutManager.getPanelWidth('unknown', panel)).toBe(88); + }); + + test('getPanelWidth() 在 CSS 变量不可解析且无元素宽度时返回 0', () => { + document.documentElement.style.setProperty('--group-panel-width', 'auto'); + expect(layoutManager.getPanelWidth('groups', null)).toBe(0); + }); + + test('startResize() panelType 无效/面板不存在时应安全返回', () => { + expect(() => + layoutManager.startResize({ + button: 0, + currentTarget: { dataset: { resizer: 'unknown' } }, + clientX: 0, + preventDefault: () => {} + }) + ).not.toThrow(); + + expect(() => + layoutManager.startResize({ + button: 0, + currentTarget: {}, + clientX: 0, + preventDefault: () => {} + }) + ).not.toThrow(); + }); + + test('拖动开始/结束应切换全局与 resizer 视觉反馈状态', async () => { + const resizer = document.querySelector('[data-resizer="groups"]'); + + expect(document.body.classList.contains('layout-resizing')).toBe(false); + expect(resizer.classList.contains('active')).toBe(false); + + resizer.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, clientX: 200, button: 0 }) + ); + + expect(document.body.classList.contains('layout-resizing')).toBe(true); + expect(resizer.classList.contains('active')).toBe(true); + + document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + await wait(30); + + expect(document.body.classList.contains('layout-resizing')).toBe(false); + expect(resizer.classList.contains('active')).toBe(false); + }); + }); +}); diff --git a/tests/layout-system/unit/state-manager.test.js b/tests/layout-system/unit/state-manager.test.js new file mode 100644 index 0000000..7891f1d --- /dev/null +++ b/tests/layout-system/unit/state-manager.test.js @@ -0,0 +1,266 @@ +/** + * StateManager 单元测试(TASK-04-001) + */ + +const StateManager = require('../../../static/js/state-manager.js'); +const { createTestState } = require('../test-utils.js'); + +describe('StateManager 单元测试(TASK-04-001)', () => { + let stateManager; + + beforeEach(() => { + stateManager = new StateManager(); + }); + + test('getStorageKey() 应支持 userId 与 guest', () => { + expect(stateManager.getStorageKey('user123')).toBe('outlook_layout_state_user123'); + expect(stateManager.getStorageKey(' userA ')).toBe('outlook_layout_state_userA'); + expect(stateManager.getStorageKey('')).toBe('outlook_layout_state_guest'); + expect(stateManager.getStorageKey(null)).toBe('outlook_layout_state_guest'); + }); + + test('save()/load() 应能完成状态保存与读取', () => { + const userId = 'user123'; + const state = createTestState(userId); + + const ok = stateManager.save(userId, state); + expect(ok).toBe(true); + + const loaded = stateManager.load(userId); + expect(loaded).toBeTruthy(); + expect(loaded.version).toBe('1.1'); + expect(loaded.userId).toBe(userId); + expect(loaded.panels.groups.width).toBe('200px'); + expect(loaded.panels.groups.collapsed).toBe(false); + }); + + test('clear() 应清除指定用户的状态', () => { + const userId = 'user123'; + const state = createTestState(userId); + + stateManager.save(userId, state); + expect(stateManager.load(userId)).toBeTruthy(); + + stateManager.clear(userId); + expect(stateManager.load(userId)).toBeNull(); + }); + + test('validate() 应验证数据格式', () => { + const good = createTestState('u1'); + expect(stateManager.validate(good)).toBe(true); + + const badWidth = createTestState('u1'); + badWidth.panels.groups.width = '200'; + expect(stateManager.validate(badWidth)).toBe(false); + + const badCollapsed = createTestState('u1'); + badCollapsed.panels.groups.collapsed = 'yes'; + expect(stateManager.validate(badCollapsed)).toBe(false); + + expect(stateManager.validate(null)).toBe(false); + expect(stateManager.validate({ version: '1.1' })).toBe(false); + }); + + test('migrate() 应补齐/修正 version', () => { + const migrated1 = stateManager.migrate({ panels: createTestState('u1').panels }); + expect(migrated1.version).toBe('1.1'); + + const migrated2 = stateManager.migrate({ version: '1.0', panels: createTestState('u1').panels }); + expect(migrated2.version).toBe('1.1'); + }); + + test('load() JSON 损坏时应清除并返回 null', () => { + const userId = 'u1'; + const key = stateManager.getStorageKey(userId); + window.localStorage.setItem(key, '{bad json'); + + const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const loaded = stateManager.load(userId); + expect(loaded).toBeNull(); + expect(window.localStorage.getItem(key)).toBeNull(); + errSpy.mockRestore(); + }); + + test('save() localStorage 不可用时应返回 false', () => { + const userId = 'u1'; + const state = createTestState(userId); + + const originalLocalStorage = window.localStorage; + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: null + }); + + try { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + expect(stateManager.save(userId, state)).toBe(false); + warnSpy.mockRestore(); + } finally { + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: originalLocalStorage + }); + } + }); + + test('getStorage() 在访问 localStorage 抛错时应返回 null', () => { + const originalLocalStorage = window.localStorage; + + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { + throw new Error('blocked'); + } + }); + + expect(stateManager.getStorage()).toBeNull(); + + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: originalLocalStorage + }); + }); + + test('isQuotaExceededError() 应识别不同浏览器的 quota 错误形态', () => { + expect(stateManager.isQuotaExceededError(null)).toBe(false); + expect(stateManager.isQuotaExceededError({ name: 'QuotaExceededError' })).toBe(true); + expect(stateManager.isQuotaExceededError({ name: 'NS_ERROR_DOM_QUOTA_REACHED' })).toBe(true); + expect(stateManager.isQuotaExceededError({ code: 22 })).toBe(true); + expect(stateManager.isQuotaExceededError({ code: 1014 })).toBe(true); + expect(stateManager.isQuotaExceededError({ name: 'OtherError', code: 123 })).toBe(false); + }); + + test('clearOldStates() 应清理旧状态并保留 excludeKey', () => { + const k1 = 'outlook_layout_state_u1'; + const k2 = 'outlook_layout_state_u2'; + const excludeKey = 'outlook_layout_state_keep'; + const otherKey = 'not_outlook_key'; + + window.localStorage.setItem(k1, JSON.stringify({ timestamp: 1 })); + window.localStorage.setItem(k2, '{bad json'); // 解析失败应仍被清理 + window.localStorage.setItem(excludeKey, JSON.stringify({ timestamp: 999 })); + window.localStorage.setItem(otherKey, 'x'); + + stateManager.clearOldStates(excludeKey); + + expect(window.localStorage.getItem(k1)).toBeNull(); + expect(window.localStorage.getItem(k2)).toBeNull(); + expect(window.localStorage.getItem(excludeKey)).toBeTruthy(); + expect(window.localStorage.getItem(otherKey)).toBe('x'); + }); + + test('save() 遇到 QuotaExceededError 时应清理旧数据并重试', () => { + const userId = 'u1'; + const state = createTestState(userId); + const key = stateManager.getStorageKey(userId); + + // 使用可控的 storage stub:第一次 setItem 抛 QuotaExceededError,第二次成功 + const store = {}; + let throwOnce = true; + const storageStub = { + setItem: jest.fn((k, v) => { + if (throwOnce) { + throwOnce = false; + const err = new Error('quota'); + err.name = 'QuotaExceededError'; + throw err; + } + store[k] = String(v); + }), + getItem: jest.fn((k) => store[k] || null), + removeItem: jest.fn((k) => { + delete store[k]; + }) + }; + + const getStorageSpy = jest.spyOn(stateManager, 'getStorage').mockReturnValue(storageStub); + const clearSpy = jest.spyOn(stateManager, 'clearOldStates').mockImplementation(() => {}); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const ok = stateManager.save(userId, state); + expect(ok).toBe(true); + expect(storageStub.setItem).toHaveBeenCalledTimes(2); + expect(clearSpy).toHaveBeenCalledWith(key); + expect(storageStub.getItem(key)).toBeTruthy(); + } finally { + warnSpy.mockRestore(); + clearSpy.mockRestore(); + getStorageSpy.mockRestore(); + } + }); + + test('save() 数据格式无效时应返回 false(不写入 storage)', () => { + const userId = 'u1'; + const badState = createTestState(userId); + badState.panels.groups.width = '200'; // 非 px + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const ok = stateManager.save(userId, badState); + expect(ok).toBe(false); + expect(window.localStorage.getItem(stateManager.getStorageKey(userId))).toBeNull(); + } finally { + warnSpy.mockRestore(); + } + }); + + test('load() 数据校验失败时应清除并返回 null', () => { + const userId = 'u1'; + const key = stateManager.getStorageKey(userId); + window.localStorage.setItem( + key, + JSON.stringify({ + version: '1.1', + userId, + timestamp: Date.now(), + panels: { + groups: { width: '200', collapsed: false }, // invalid + accounts: { width: '260px', collapsed: false }, + emails: { width: '380px', collapsed: false } + } + }) + ); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const loaded = stateManager.load(userId); + expect(loaded).toBeNull(); + expect(window.localStorage.getItem(key)).toBeNull(); + } finally { + warnSpy.mockRestore(); + } + }); + + test('load() storage.getItem 抛错时应返回 null(不抛异常)', () => { + const userId = 'u1'; + + const originalLocalStorage = window.localStorage; + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => {}, + removeItem: () => {}, + key: () => null, + get length() { + return 0; + } + } + }); + + try { + expect(stateManager.load(userId)).toBeNull(); + } finally { + warnSpy.mockRestore(); + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: originalLocalStorage + }); + } + }); +}); diff --git a/tests/test_api_extract_verification.py b/tests/test_api_extract_verification.py new file mode 100644 index 0000000..7405238 --- /dev/null +++ b/tests/test_api_extract_verification.py @@ -0,0 +1,237 @@ +""" +验证码提取 API 集成测试 + +测试验证码提取 API 的正确性,包括: +- API 成功提取验证码和链接 +- API 错误处理(未找到、未登录、邮箱不存在) +- API 性能测试 +- 设置 API 测试(轮询配置) +""" + +import unittest +import sys +import os +import time + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +class TestExtractVerificationAPI(unittest.TestCase): + """验证码提取 API 集成测试类""" + + def setUp(self): + """测试前置准备""" + # TODO: 实现后取消注释 + # from outlook_web.app import create_app + # self.app = create_app() + # self.client = self.app.test_client() + pass + + def tearDown(self): + """测试后清理""" + pass + + # ==================== 验证码提取 API 测试 ==================== + + def test_api_extract_verification_success(self): + """ + 测试用例 IT-001:API 成功提取验证码和链接 + + 测试目的:验证 API 能正确返回验证码和链接 + """ + # TODO: 实现后取消注释 + # # 登录 + # self.client.post('/login', data={'password': 'admin123'}) + # + # # 调用 API + # response = self.client.get('/api/emails/test@example.com/extract-verification') + # data = response.get_json() + # + # # 验证响应 + # self.assertEqual(response.status_code, 200) + # self.assertTrue(data['success']) + # self.assertIn('verification_code', data['data']) + # self.assertIn('links', data['data']) + # self.assertIn('formatted', data['data']) + + self.assertTrue(True, "待实现:API 成功提取") + + def test_api_extract_verification_not_found(self): + """ + 测试用例 IT-002:API 未找到验证信息 + + 测试目的:验证 API 能正确处理未找到验证信息的情况 + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # response = self.client.get('/api/emails/empty@example.com/extract-verification') + # data = response.get_json() + # + # self.assertEqual(response.status_code, 404) + # self.assertFalse(data['success']) + # self.assertEqual(data['error'], "未找到验证信息") + # self.assertIn('trace_id', data) + + self.assertTrue(True, "待实现:API 未找到验证信息") + + def test_api_extract_verification_unauthorized(self): + """ + 测试用例 IT-003:API 未登录访问 + + 测试目的:验证 API 需要登录验证 + """ + # TODO: 实现后取消注释 + # response = self.client.get('/api/emails/test@example.com/extract-verification') + # self.assertIn(response.status_code, [302, 401]) + + self.assertTrue(True, "待实现:API 未登录访问") + + def test_api_extract_verification_email_not_exist(self): + """ + 测试用例 IT-004:API 邮箱不存在 + + 测试目的:验证 API 能正确处理邮箱不存在的情况 + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # response = self.client.get('/api/emails/notexist@example.com/extract-verification') + # data = response.get_json() + # + # self.assertEqual(response.status_code, 404) + # self.assertFalse(data['success']) + # self.assertIn('邮箱不存在', data['error']) + + self.assertTrue(True, "待实现: 邮箱不存在") + + def test_api_extract_verification_performance(self): + """ + 测试用例 IT-005:API 性能测试 + + 测试目的:验证 API 响应时间 < 2 秒 + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # start_time = time.time() + # + # response = self.client.get('/api/emails/test@example.com/extract-verification') + # + # end_time = time.time() + # elapsed_time = (end_time - start_time) * 1000 # 转换为毫秒 + # + # self.assertEqual(response.status_code, 200) + # self.assertLess(elapsed_time, 2000, f"API 响应时间过长: {elapsed_time}ms") + + self.assertTrue(True, "待实现:API 性能测试") + + # ==================== 设置 API 测试 ==================== + + def test_api_get_polling_settings(self): + """ + 测试用例 IT-006:读取轮询配置 + + 测试目的:验证 GET /api/settings 能正确返回轮询配置 + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # response = self.client.get('/api/settings') + # data = response.get_json() + # + # self.assertEqual(response.status_code, 200) + # self.assertTrue(data['success']) + # self.assertIn('enable_auto_polling', data['settings']) + # self.assertIn('polling_interval', data['settings']) + # self.assertIn('polling_count', data['settings']) + + self.assertTrue(True, "待实现:读取轮询配置") + + def test_api_update_polling_settings(self): + """ + 测试用例 IT-007:更新轮询配置 + + 测试目的:验证 PUT /api/settings 能正确更新轮询配置 + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # # 更新配置 + # response = self.client.put('/api/settings', + # json={ + # 'enable_auto_polling': True, + # 'polling_interval': 30, + # 'polling_count': 10 + # }, + # content_type='application/json' + # ) + # data = response.get_json() + # + # self.assertEqual(response.status_code, 200) + # self.assertTrue(data['success']) + # + # # 验证更新成功 + # response2 = self.client.get('/api/settings') + # data2 = response2.get_json() + # + # self.assertTrue(data2['settings']['enable_auto_polling']) + # self.assertEqual(data2['settings']['polling_interval'], 30) + # self.assertEqual(data2['settings']['polling_count'], 10) + + self.assertTrue(True, "待实现:更新轮询配置") + + def test_api_update_polling_interval_validation(self): + """ + 测试用例 IT-008:轮询间隔参数验证 + + 测试目的:验证轮询间隔参数验证(5-300 秒) + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # # 测试小于 5 + # response1 = self.client.put('/api/settings', + # json={'polling_interval': 3}, + # content_type='application/json' + # ) + # self.assertEqual(response1.status_code, 400) + # + # # 测试大于 300 + # response2 = self.client.put('/api/settings', + # json={'polling_interval': 500}, + # content_type='application/json' + # ) + # self.assertEqual(response2.status_code, 400) + + self.assertTrue(True, "待实现:轮询间隔参数验证") + + def test_api_update_polling_count_validation(self): + """ + 测试用例 IT-009:轮询次数参数验证 + + 测试目的:验证轮询次数参数验证(1-100 次) + """ + # TODO: 实现后取消注释 + # self.client.post('/login', data={'password': 'admin123'}) + # + # # 测试小于 1 + # response1 = self.client.put('/api/settings', + # json={'polling_count': 0}, + # content_type='application/json' + # ) + # self.assertEqual(response1.status_code, 400) + # + # # 测试大于 100 + # response2 = self.client.put('/api/settings', + # json={'polling_count': 150}, + # content_type='application/json' + # ) + # self.assertEqual(response2.status_code, 400) + + self.assertTrue(True, "待实现:轮询次数参数验证") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core_features.py b/tests/test_core_features.py new file mode 100644 index 0000000..5f704f9 --- /dev/null +++ b/tests/test_core_features.py @@ -0,0 +1,253 @@ +import json +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module, clear_login_attempts + + +class CoreFeatureTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + # 导入 graph_service 用于 mock + from outlook_web.services import graph as graph_service + + cls.graph_service = graph_service + + def setUp(self): + # 每个测试前清理登录限制记录,避免测试间互相影响 + with self.app.app_context(): + clear_login_attempts() + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + return resp + + def test_logout_revokes_session(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/logout", follow_redirects=False) + self.assertIn(resp.status_code, (301, 302)) + + resp = client.get("/api/system/health") + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_login"), True) + + def test_group_crud_and_audit_trace_id(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + group_name = f"g_{unique}" + + create = client.post( + "/api/groups", + json={ + "name": group_name, + "description": "desc", + "color": "#123456", + "proxy_url": "", + }, + ) + self.assertEqual(create.status_code, 200) + create_data = create.get_json() + self.assertEqual(create_data.get("success"), True) + group_id = create_data.get("group_id") + self.assertIsInstance(group_id, int) + + create_trace_id = create.headers.get("X-Trace-Id") + self.assertTrue(create_trace_id) + + audit = client.get("/api/audit-logs?resource_type=group&limit=200") + self.assertEqual(audit.status_code, 200) + audit_data = audit.get_json() + self.assertEqual(audit_data.get("success"), True) + logs = audit_data.get("logs") or [] + self.assertTrue(any(l.get("trace_id") == create_trace_id for l in logs)) + + update = client.put( + f"/api/groups/{group_id}", + json={ + "name": group_name, + "description": "desc2", + "color": "#654321", + "proxy_url": "", + }, + ) + self.assertEqual(update.status_code, 200) + self.assertEqual(update.get_json().get("success"), True) + + delete = client.delete(f"/api/groups/{group_id}") + self.assertEqual(delete.status_code, 200) + self.assertEqual(delete.get_json().get("success"), True) + + # 删除后再次获取:应返回错误(legacy 会被归一化为结构化错误,HTTP 状态码为 400) + get_after = client.get(f"/api/groups/{group_id}") + self.assertEqual(get_after.status_code, 400) + data = get_after.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("message"), "分组不存在") + + def test_tag_crud_and_duplicate_name(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + tag_name = f"t_{unique}" + + create = client.post("/api/tags", json={"name": tag_name, "color": "#abcdef"}) + self.assertEqual(create.status_code, 200) + data = create.get_json() + self.assertEqual(data.get("success"), True) + tag_id = data.get("tag", {}).get("id") + self.assertIsInstance(tag_id, int) + + dup = client.post("/api/tags", json={"name": tag_name, "color": "#000000"}) + self.assertEqual(dup.status_code, 400) + dup_data = dup.get_json() + self.assertEqual(dup_data.get("success"), False) + self.assertIsInstance(dup_data.get("error"), dict) + self.assertEqual(dup_data["error"].get("message"), "标签名称已存在") + + listing = client.get("/api/tags") + self.assertEqual(listing.status_code, 200) + listing_data = listing.get_json() + self.assertEqual(listing_data.get("success"), True) + self.assertIn( + tag_name, [t.get("name") for t in (listing_data.get("tags") or [])] + ) + + delete = client.delete(f"/api/tags/{tag_id}") + self.assertEqual(delete.status_code, 200) + self.assertEqual(delete.get_json().get("success"), True) + + def test_export_verify_and_export_all_download_contains_account(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"export_{unique}@example.com" + client_id = "cid_" + unique + refresh_token = "rt_" + unique + + conn = self.module.create_sqlite_connection() + try: + conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "pw", client_id, refresh_token, 1, "", "active"), + ) + conn.commit() + finally: + conn.close() + + verify = client.post("/api/export/verify", json={"password": "testpass123"}) + self.assertEqual(verify.status_code, 200) + verify_data = verify.get_json() + self.assertEqual(verify_data.get("success"), True) + verify_token = verify_data.get("verify_token") + self.assertTrue(verify_token) + + # 使用请求头传递 token(避免 URL/日志泄露) + export = client.get( + "/api/accounts/export", headers={"X-Export-Token": verify_token} + ) + self.assertEqual(export.status_code, 200) + self.assertIn("text/plain", export.headers.get("Content-Type", "")) + body = export.get_data(as_text=True) + self.assertIn(email_addr, body) + self.assertIn(client_id, body) + self.assertIn(refresh_token, body) + + export_trace_id = export.headers.get("X-Trace-Id") + self.assertTrue(export_trace_id) + + audit = client.get("/api/audit-logs?limit=200") + self.assertEqual(audit.status_code, 200) + audit_data = audit.get_json() + self.assertEqual(audit_data.get("success"), True) + logs = audit_data.get("logs") or [] + self.assertTrue(any(l.get("trace_id") == export_trace_id for l in logs)) + + def test_oauth_auth_url_endpoint(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/api/oauth/auth-url") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + self.assertIn("auth_url", data) + self.assertIn("login.microsoftonline.com", data.get("auth_url", "")) + + def test_temp_email_generate_and_list_mocked(self): + client = self.app.test_client() + self._login(client) + + email_addr = f"temp_{uuid.uuid4().hex}@example.com" + # Mock generate_temp_email 返回元组 (email_addr, None) + # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail + from outlook_web.services import gptmail as gptmail_service + + with patch.object( + gptmail_service, "generate_temp_email", return_value=(email_addr, None) + ): + created = client.post( + "/api/temp-emails/generate", json={"prefix": "x", "domain": "y"} + ) + self.assertEqual(created.status_code, 200) + created_data = created.get_json() + self.assertEqual(created_data.get("success"), True) + self.assertEqual(created_data.get("email"), email_addr) + + listing = client.get("/api/temp-emails") + self.assertEqual(listing.status_code, 200) + listing_data = listing.get_json() + self.assertEqual(listing_data.get("success"), True) + self.assertIn( + email_addr, [e.get("email") for e in (listing_data.get("emails") or [])] + ) + + def test_refresh_all_stream_has_start_and_complete_events(self): + client = self.app.test_client() + self._login(client) + + conn = self.module.create_sqlite_connection() + try: + conn.execute( + "INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", + ("refresh_delay_seconds", "0"), + ) + conn.commit() + finally: + conn.close() + + with patch.object( + self.graph_service, "test_refresh_token", return_value=(True, None) + ): + resp = client.get("/api/accounts/refresh-all") + + self.assertEqual(resp.status_code, 200) + self.assertIn("text/event-stream", resp.headers.get("Content-Type", "")) + + payload = resp.get_data(as_text=True) + events = [] + for line in payload.splitlines(): + if line.startswith("data: "): + events.append(json.loads(line[len("data: ") :])) + + self.assertGreaterEqual(len(events), 2) + self.assertEqual(events[0].get("type"), "start") + self.assertTrue(events[0].get("trace_id")) + self.assertTrue(any(e.get("type") == "complete" for e in events)) diff --git a/tests/test_distributed_lock.py b/tests/test_distributed_lock.py new file mode 100644 index 0000000..6d05d04 --- /dev/null +++ b/tests/test_distributed_lock.py @@ -0,0 +1,64 @@ +import time +import unittest +import uuid + +from tests._import_app import import_web_app_module + + +class DistributedLockTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + + def test_acquire_and_release_distributed_lock(self): + conn = self.module.create_sqlite_connection() + try: + lock_name = "test_lock_" + uuid.uuid4().hex + owner_a = "a_" + uuid.uuid4().hex + owner_b = "b_" + uuid.uuid4().hex + + ok, info = self.module.acquire_distributed_lock( + conn, lock_name, owner_a, ttl_seconds=60 + ) + self.assertTrue(ok) + self.assertIsNone(info) + + ok2, info2 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) + self.assertFalse(ok2) + self.assertIsInstance(info2, dict) + self.assertEqual(info2.get("owner_id"), owner_a) + + released = self.module.release_distributed_lock(conn, lock_name, owner_a) + self.assertTrue(released) + + ok3, info3 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) + self.assertTrue(ok3) + self.assertIsNone(info3) + finally: + conn.close() + + def test_expired_lock_can_be_taken_over(self): + conn = self.module.create_sqlite_connection() + try: + lock_name = "test_lock_expired_" + uuid.uuid4().hex + owner_a = "a_" + uuid.uuid4().hex + owner_b = "b_" + uuid.uuid4().hex + + ok, _ = self.module.acquire_distributed_lock( + conn, lock_name, owner_a, ttl_seconds=1 + ) + self.assertTrue(ok) + time.sleep(1.2) + + ok2, info2 = self.module.acquire_distributed_lock( + conn, lock_name, owner_b, ttl_seconds=60 + ) + self.assertTrue(ok2) + self.assertIsInstance(info2, dict) + self.assertEqual(info2.get("previous_owner_id"), owner_a) + finally: + conn.close() diff --git a/tests/test_error_and_trace.py b/tests/test_error_and_trace.py new file mode 100644 index 0000000..e644532 --- /dev/null +++ b/tests/test_error_and_trace.py @@ -0,0 +1,475 @@ +import json +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module, clear_login_attempts + + +class ErrorAndTraceTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + # 每个测试前清理登录限制记录,避免测试间互相影响 + with self.app.app_context(): + clear_login_attempts() + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + return resp + + def test_healthz(self): + client = self.app.test_client() + resp = client.get("/healthz") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("status"), "ok") + + def test_login_required_returns_structured_error(self): + client = self.app.test_client() + resp = client.get("/api/system/health") + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_login"), True) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "AUTH_REQUIRED") + self.assertEqual(data["error"].get("status"), 401) + self.assertTrue(data["error"].get("trace_id")) + + def test_api_404_has_structured_error_and_trace_id(self): + client = self.app.test_client() + resp = client.get("/api/__not_found__") + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIsInstance(data, dict) + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "HTTP_ERROR") + self.assertEqual(data["error"].get("status"), 404) + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + def test_trace_id_can_be_propagated_from_request(self): + client = self.app.test_client() + resp = client.get( + "/api/__not_found__", headers={"X-Trace-Id": "trace_from_test"} + ) + data = resp.get_json() + self.assertEqual(resp.headers.get("X-Trace-Id"), "trace_from_test") + self.assertEqual(data["error"].get("trace_id"), "trace_from_test") + + def test_sanitize_error_details_masks_tokens(self): + sanitized = self.module.sanitize_error_details( + 'Bearer abcdefg refresh_token=xyz password: "123456"' + ) + self.assertIn("Bearer ***", sanitized) + self.assertIn("refresh_token=***", sanitized) + self.assertIn( + '"123456"', 'Bearer abcdefg refresh_token=xyz password: "123456"' + ) # sanity + self.assertNotIn("123456", sanitized) + + def test_build_error_payload_sanitizes_message_and_details(self): + payload = self.module.build_error_payload( + "TEST", + 'Bearer abcdefg refresh_token=xyz password: "123456"', + "TestError", + 400, + 'Bearer abcdefg refresh_token=xyz password: "123456"', + ) + self.assertIn("Bearer ***", payload.get("message", "")) + self.assertIn("refresh_token=***", payload.get("message", "")) + self.assertNotIn("abcdefg", payload.get("message", "")) + self.assertNotIn("123456", payload.get("message", "")) + + self.assertIn("Bearer ***", payload.get("details", "")) + self.assertIn("refresh_token=***", payload.get("details", "")) + self.assertNotIn("abcdefg", payload.get("details", "")) + self.assertNotIn("123456", payload.get("details", "")) + + def test_delete_emails_all_methods_fail_returns_aggregated_error(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + email_addr = "user@example.com" + graph_error = { + "code": "EMAIL_DELETE_FAILED", + "message": "Graph 删除失败", + "type": "GraphAPIError", + "status": 502, + "details": "graph_failed", + "trace_id": "", + } + + # 控制器现在使用 repositories 和 services,需要 mock 这些模块 + from outlook_web.repositories import accounts as accounts_repo + from outlook_web.services import graph as graph_service + from outlook_web.services import imap as imap_service + + with patch.object( + accounts_repo, + "get_account_by_email", + return_value={ + "email": email_addr, + "client_id": "cid", + "refresh_token": "rt", + "group_id": None, + }, + ), patch.object( + graph_service, + "delete_emails_graph", + return_value={ + "success": False, + "error": graph_error, + "success_count": 0, + "failed_count": 2, + "errors": ["e1"], + }, + ), patch.object( + imap_service, + "delete_emails_imap", + side_effect=[ + {"success": False, "error": "imap_new_failed"}, + {"success": False, "error": "imap_old_failed"}, + ], + ): + resp = client.post( + "/api/emails/delete", + json={"email": email_addr, "ids": ["m1", "m2"]}, + ) + + self.assertEqual(resp.status_code, 502) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "EMAIL_DELETE_ALL_METHODS_FAILED") + self.assertEqual(data["error"].get("status"), 502) + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + details_text = data["error"].get("details") or "" + self.assertIn("Graph API", details_text) + self.assertIn("IMAP(新服务器)", details_text) + self.assertIn("IMAP(旧服务器)", details_text) + + def test_delete_emails_proxy_error_does_not_fallback_to_imap(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + email_addr = "user@example.com" + graph_res = { + "success": False, + "error": { + "code": "EMAIL_DELETE_FAILED", + "message": "代理连接失败", + "type": "ProxyError", + "status": 502, + "details": "proxy_failed", + "trace_id": "", + }, + "success_count": 0, + "failed_count": 2, + "errors": ["ProxyError: connect failed"], + } + + # 控制器现在使用 repositories 和 services,需要 mock 这些模块 + from outlook_web.repositories import accounts as accounts_repo + from outlook_web.services import graph as graph_service + from outlook_web.services import imap as imap_service + + with patch.object( + accounts_repo, + "get_account_by_email", + return_value={ + "email": email_addr, + "client_id": "cid", + "refresh_token": "rt", + "group_id": None, + }, + ), patch.object( + graph_service, + "delete_emails_graph", + return_value=graph_res, + ), patch.object( + imap_service, + "delete_emails_imap", + ) as imap_mock: + resp = client.post( + "/api/emails/delete", + json={"email": email_addr, "ids": ["m1", "m2"]}, + ) + imap_mock.assert_not_called() + + self.assertEqual(resp.status_code, 502) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("type"), "ProxyError") + self.assertTrue(data["error"].get("trace_id")) + self.assertEqual(resp.headers.get("X-Trace-Id"), data["error"].get("trace_id")) + + def test_legacy_error_string_is_normalized_to_structured_error(self): + client = self.app.test_client() + + # 登录 + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + self.assertEqual(login.get_json().get("success"), True) + + # 触发一个 legacy 的字符串错误(现在应返回正确的 400 状态码) + resp = client.get("/api/groups/999999") + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LEGACY_ERROR") + self.assertEqual(data["error"].get("message"), "分组不存在") + self.assertTrue(data["error"].get("trace_id")) + + # 响应体应是 JSON(确保 after_request 的 set_data 没破坏格式) + json.loads(resp.get_data(as_text=True)) + + def test_scheduler_status_endpoint_is_accessible_after_login(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/scheduler/status") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + self.assertIn("scheduler", data) + self.assertIn("refresh", data) + + def test_validate_cron_endpoint(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.post( + "/api/settings/validate-cron", json={"cron_expression": "0 2 * * *"} + ) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(data.get("success"), True) + self.assertEqual(data.get("valid"), True) + self.assertIsInstance(data.get("future_runs"), list) + self.assertGreaterEqual(len(data.get("future_runs")), 1) + + def test_system_diagnostics_includes_schema(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/system/diagnostics") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + diagnostics = data.get("diagnostics") or {} + self.assertIn("schema", diagnostics) + self.assertIn("version", diagnostics["schema"]) + self.assertIn("target_version", diagnostics["schema"]) + self.assertIn("up_to_date", diagnostics["schema"]) + + def test_system_upgrade_status_endpoint(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.get("/api/system/upgrade-status") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + upgrade = data.get("upgrade") or {} + self.assertIn("schema_version", upgrade) + self.assertIn("target_version", upgrade) + self.assertIn("up_to_date", upgrade) + self.assertIn("backup_hint", upgrade) + + def test_login_rate_limiting_locks_after_max_attempts(self): + client = self.app.test_client() + ip = "10.0.0.99" + + for _ in range(self.module.MAX_LOGIN_ATTEMPTS): + resp = client.post( + "/login", + json={"password": "wrong_password"}, + headers={"X-Forwarded-For": ip}, + ) + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LOGIN_INVALID_PASSWORD") + + resp = client.post( + "/login", + json={"password": "wrong_password"}, + headers={"X-Forwarded-For": ip}, + ) + self.assertEqual(resp.status_code, 429) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "LOGIN_RATE_LIMITED") + self.assertEqual(data["error"].get("status"), 429) + self.assertTrue(data["error"].get("trace_id")) + + def test_export_requires_verify_token(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + resp = client.post( + "/api/accounts/export-selected", + json={"group_ids": [1], "verify_token": "invalid_token"}, + ) + self.assertEqual(resp.status_code, 401) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertEqual(data.get("need_verify"), True) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("status"), 401) + self.assertTrue(data["error"].get("trace_id")) + + def test_accounts_list_and_search_include_tags_and_last_refresh(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + unique = uuid.uuid4().hex + email_addr = f"test_{unique}@example.com" + client_id = "client_id_" + unique + refresh_token = "rt_" + unique + tag_name = "tag_" + unique + + conn = self.module.create_sqlite_connection() + try: + # 插入账号 + cur = conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "", client_id, refresh_token, 1, "remark", "active"), + ) + account_id = cur.lastrowid + + # 插入标签并关联 + cur = conn.execute( + "INSERT INTO tags (name, color) VALUES (?, ?)", + (tag_name, "#111111"), + ) + tag_id = cur.lastrowid + conn.execute( + "INSERT OR IGNORE INTO account_tags (account_id, tag_id) VALUES (?, ?)", + (account_id, tag_id), + ) + + # 插入刷新日志(用于 last_refresh_status/error) + conn.execute( + """ + INSERT INTO account_refresh_logs (account_id, account_email, refresh_type, status, error_message) + VALUES (?, ?, ?, ?, ?) + """, + (account_id, email_addr, "manual", "failed", "network_error"), + ) + + conn.commit() + finally: + conn.close() + + # 列表接口:应返回 tags + last_refresh_* + resp = client.get("/api/accounts") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + accounts = data.get("accounts") or [] + target = next((a for a in accounts if a.get("email") == email_addr), None) + self.assertIsNotNone(target) + self.assertTrue(target.get("client_id", "").startswith(client_id[:8])) + self.assertEqual(target.get("last_refresh_status"), "failed") + self.assertEqual(target.get("last_refresh_error"), "network_error") + self.assertIsInstance(target.get("tags"), list) + self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) + + # 搜索接口:按标签名应可搜索到 + resp = client.get(f"/api/accounts/search?q={tag_name}") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + accounts = data.get("accounts") or [] + target = next((a for a in accounts if a.get("email") == email_addr), None) + self.assertIsNotNone(target) + self.assertEqual(target.get("last_refresh_status"), "failed") + self.assertIn(tag_name, [t.get("name") for t in target.get("tags")]) + + def test_system_group_cannot_be_deleted(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute( + "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() + self.assertIsNotNone(row) + system_group_id = row["id"] + finally: + conn.close() + + resp = client.delete(f"/api/groups/{system_group_id}") + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) + + def test_account_cannot_be_moved_to_system_group_via_update(self): + client = self.app.test_client() + login = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(login.status_code, 200) + + unique = uuid.uuid4().hex + email_addr = f"move_{unique}@example.com" + + conn = self.module.create_sqlite_connection() + try: + system_row = conn.execute( + "SELECT id FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() + self.assertIsNotNone(system_row) + system_group_id = system_row["id"] + + cur = conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (email_addr, "", "client", "rt", 1, "", "active"), + ) + account_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + resp = client.put( + f"/api/accounts/{account_id}", + json={"email": email_addr, "group_id": system_group_id}, + ) + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) diff --git a/tests/test_frontend_manual.py b/tests/test_frontend_manual.py new file mode 100644 index 0000000..ff3a931 --- /dev/null +++ b/tests/test_frontend_manual.py @@ -0,0 +1,541 @@ +""" +前端功能测试说明文档 + +由于前端测试需要在浏览器环境中运行,这里提供测试用例的详细说明和手动测试步骤。 +如果需要自动化测试,可以使用 Selenium、Playwright 或 Cypress 等工具。 + +本文档包含: +- 全选功能测试(7 个用例) +- 复制功能测试(5 个用例) +- 轮询功能测试(6 个用例) +- 设置界面测试(3 个用例) +""" + +# ==================== 全选功能测试 ==================== + + +class FrontendSelectAllTests: + """ + 全选功能测试用例集 + + 测试前置条件: + 1. 前端页面已加载 + 2. 已登录系统 + 3. 当前分组有至少 5 个邮箱 + """ + + def test_ft_001_select_all_accounts(self): + """ + 测试用例 FT-001:全选所有邮箱 + + 测试步骤: + 1. 打开邮箱管理页面 + 2. 选择一个分组 + 3. 点击全选复选框 + 4. 观察所有邮箱的复选框状态 + + 预期结果: + - 全选复选框显示为选中状态 + - 所有邮箱的复选框都被选中 + - 批量操作栏显示"已选 N 项" + + 验证方法: + ```javascript + // 在浏览器控制台执行 + const selectAllCheckbox = document.getElementById('selectAllAccounts'); + console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 true + + const accountCheckboxes = document.querySelectorAll('.account-checkbox'); + const allChecked = Array.from(accountCheckboxes).every(cb => cb.checked); + console.log('所有邮箱都被选中:', allChecked); // 应该为 true + + const batchBar = document.getElementById('batchActionBar'); + console.log('批量操作栏显示:', batchBar.style.display !== 'none'); // 应该为 true + ``` + """ + pass + + def test_ft_002_unselect_all(self): + """ + 测试用例 FT-002:取消全选 + + 测试步骤: + 1. 在全选状态下 + 2. 再次点击全选复选框 + 3. 观察所有邮箱的复选框状态 + + 预期结果: + - 全选复选框显示为未选中状态 + - 所有邮箱的复选框都被取消选中 + - 批量操作栏隐藏 + + 验证方法: + ```javascript + const selectAllCheckbox = document.getElementById('selectAllAccounts'); + console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false + + const accountCheckboxes = document.querySelectorAll('.account-checkbox'); + const noneChecked = Array.from(accountCheckboxes).every(cb => !cb.checked); + console.log('所有邮箱都未选中:', noneChecked); // 应该为 true + ``` + """ + pass + + def test_ft_0erminate_state(self): + """ + 测试用例 FT-003:半选状态 + + 测试步骤: + 1. 手动选中部分邮箱(不是全部) + 2. 观察全选复选框状态 + + 预期结果: + - 全选复选框显示为半选状态(indeterminate) + - 批量操作栏显示"已选 N 项" + + 验证方法: + ```javascript + const selectAllCheckbox = document.getElementById('selectAllAccounts'); + console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 true + console.log('选中状态:', selectAllCheckbox.checked); // 应该为 false + ``` + """ + pass + + def test_ft_004_batch_move_group(self): + """ + 测试用例 FT-004:全选后批量移动分组 + + 测试步骤: + 1. 点击全选复选框 + 2. 点击批量操作栏的"移动分组"按钮 + 3. 选择目标分组 + 4. 确认移动 + + 预期结果: + - 所有邮箱成功移动到目标分组 + - 显示成功提示 + - 当前分组的邮箱列表更新 + """ + pass + + def test_ft_005_batch_add_tags(self): + """ + 测试用例 FT-005:全选后批量打标签 + + 测试步骤: + 1. 点击全选复选框 + 2. 点击批量操作栏的"打标签"按钮 + 3. 选择标签 + + 预期结果: + - 所有邮箱成功添加标签 + - 显示成功提示 + - 邮箱列表显示标签 + """ + pass + + def test_ft_006_reset_on_group_change(self): + """ + 测试用例 FT-006:切换分组重置全选状态 + + 测试步骤: + 1. 选中部分或全部邮箱 + 2. 切换到其他分组 + 3. 观察全选复选框状态 + + 预期结果: + - 全选复选框显示为未选中状态 + - 批量操作栏隐藏 + + 验证方法: + ```javascript + // 切换分组后 + const selectAllCheckbox = document.getElementById('selectAllAccounts'); + console.log('全选框状态:', selectAllCheckbox.checked); // 应该为 false + console.log('半选状态:', selectAllCheckbox.indeterminate); // 应该为 false + ``` + """ + pass + + def test_ft_007_select_filtered_only(self): + """ + 测试用例 FT-007:搜索后全选只影响显示的邮箱 + + 测试步骤: + 1. 在搜索框输入关键词,过滤部分邮箱 + 2. 点击全选复选框 + 3. 观察选中数量 + + 预期结果: + - 只有当前显示的邮箱被选中 + - 批量操作栏显示的数量等于显示的邮箱数量 + + 验证方法: + ```javascript + const visibleAccounts = document.querySelectorAll('.account-item:not([style*="display: none"])'); + const selectedCount = selectedAccounts.size; + console.log('显示的邮箱数:', visibleAccounts.length); + console.log('选中的邮箱数:', selectedCount); + console.log('数量一致:', visibleAccounts.length === selectedCount); // 应该为 true + ``` + """ + pass + + +# ==================== 复制功能测试 ==================== + + +class FrontendCopyTests: + """ + 复制功能测试用例集 + + 测试前置条件: + 1. 前端页面已加载 + 2. 已登录系统 + 3. 测试邮箱有邮件数据 + """ + + def test_ft_008_copy_verification_success(self): + """ + 测试用例 FT-008:复制验证码成功 + + 测试步骤: + 1. 找到测试邮箱 + 2. 点击复制按钮 + 3. 等待加载完成 + 4. 粘贴到文本框验证 + + 预期结果: + - 显示"已复制"通知 + - 复制按钮显示绿色对勾(1秒后恢复) + - 粘贴的内容包含验证码 + + 验证方法: + 1. 点击复制按钮 + 2. 在任意文本框按 Ctrl+V 粘贴 + 3. 检查粘贴的内容是否包含验证码 + """ + pass + + def test_ft_009_copy_code_and_links(self): + """ + 测试用例 FT-009:复制验证码和链接 + + 测试步骤: + 1. 找到测试邮箱(邮件同时包含验证码和链接) + 2. 点击复制按钮 + 3. 等待加载完成 + 4. 粘贴到文本框验证 + + 预期结果: + - 显示"已复制"通知 + - 粘贴的内容格式为"验证码 链接1 链接2"(空格分隔) + + 验证方法: + ```javascript + // 粘贴后检查格式 + const pastedText = "999888 https://example.com/activate"; + const parts = pastedText.split(' '); + console.log('验证码:', parts[0]); // 999888 + console.log('链接:', parts.slice(1)); // ["https://example.com/activate"] + ``` + """ + pass + + def test_ft_010_not_found_verification(self): + """ + 测试用例 FT-010:未找到验证信息 + + 测试步骤: + 1. 找到测试邮箱(邮件不包含验证码和链接) + 2. 点击复制按钮 + 3. 等待加载完成 + + 预期结果: + - 显示"未找到验证信息"提示 + - 复制按钮恢复正常状态 + """ + pass + + def test_ft_011_auto_fetch_emails(self): + """ + 测试用例 FT-011:邮箱无数据取 + + 测试步骤: + 1. 找到未获取过邮件的测试邮箱 + 2. 点击复制按钮 + 3. 观察页面行为 + + 预期结果: + - 显示"正在获取邮件..."提示 + - 自动调用获取邮件接口 + - 获取成功后自动复制验证信息 + + 验证方法: + 打开浏览器开发者工具 Network 标签,观察是否有: + 1. GET /api/emails/ 请求 + 2. GET /api/emails//extract-verification 请求 + """ + pass + + def test_ft_012_button_loading_state(self): + """ + 测试用例 FT-012:复制按钮加载状态 + + 测试步骤: + 1. 点击复制按钮 + 2. 观察按钮状态变化 + + 预期结果: + - 按钮立即变为禁用状态 + - 显示加载动画 + - 加载完成后恢复正常状态 + + 验证方法: + ```javascript + const btn = document.querySelector('.copy-verification-btn'); + console.log('按钮禁用:', btn.disabled); // 加载时应该为 true + console.log('加载样式:', btn.classList.contains('loading')); // 加载时应该为 true + ``` + """ + pass + + +# ==================== 轮询功能测试 ==================== + + +class FrontendPollingTests: + """ + 轮询功能测试用例集 + + 测试前置条件: + 1. 前端页面已加载 + 2. 已登录系统 + 3. 在设置中启用了自动轮询 + """ + + def test_ft_013_auto_start_polling(self): + """ + 测试用例 FT-013:启用轮询后自动开始 + + 测试步骤: + 1. 打开设置,启用自动轮询 + 2. 设置轮询间隔为 10 秒,次数为 3 次 + 3. 保存设置 + 4. 选中一个邮箱 + 5. 等待观察 + + 预期结果: + - 每 10 秒自动获取一次邮件 + - 总共轮询 3 次后自动停止 + - 控制台输出轮询日志 + + 验证方法: + 打开浏览器控制台,观察日志输出: + ``` + 开始轮询邮箱: test@example.com, 间隔: 10000ms, 次数: 3 + 轮询第 1/3 次 + 轮询第 2/3 次 + 轮询第 3/3 次 + 轮询已完成 + ``` + """ + pass + + def test_ft_014_new_email_notification(self): + """ + 测试用例 FT-014:检测到新邮件显示通知 + + 测试步骤: + 1. 在轮询过程中,向测试邮箱发送新邮件 + 2. 等待下一次轮询 + 3. 观察页面通知 + + 预期结果: + - 右上角显示通知:"邮箱地址 收到新邮件:邮件主题" + - 通知 5 秒后自动消失 + - 邮箱列表显示红点提示 + """ + pass + + def test_ft_015_new_email_badge(self): + """ + 测试用例 FT-015:邮箱列表红点提示 + + 测试步骤: + 1. 观察有新邮件的邮箱 + 2. 点击该邮箱查看邮件 + 3. 观察红点状态 + + 预期结果: + - 邮箱名称旁边显示红色圆点 + - 点击邮箱后红点消失 + + 验证方法: + ```javascript + const badge = document.querySelector('.new-email-badge'); + console.log('红点存在:', badge !== null); // 应该为 true + + // 点击邮箱后 + console.log('红点消失:', document.querySelector('.new-email-badge') === null); // 应该为 true + ``` + """ + pass + + def test_ft_016_stop_on_account_change(self): + """ + 测试用例 FT-016:切换邮箱停止轮询 + + 测试步骤: + 1. 在轮询过程中 + 2. 切换到其他邮箱 + 3. 观察控制台日志 + + 预期结果: + - 当前轮询立即停止 + - 控制台输出"停止轮询"日志 + - 开始轮询新选中的邮箱 + """ + pass + + def test_ft_017_stop_on_error(self): + """ + 测试用例 FT-017:轮询失败自动停止 + + 测试步骤: + 1. 在轮询过程中 + 2. 模拟网络错误(断开网络或使用开发者工具模拟) + 3. 观察页面行为 + + 预期结果: + - 轮询立即停止 + - 显示错误提示:"轮询失败,已自动停止" + - 控制台输出错误日志 + """ + pass + + def test_ft_018_close_notification(self): + """ + 测试用例 FT-018:通知点击关闭 + + 测试步骤: + 1. 等待新邮件通知显示 + 2. 点击通知的关闭按钮(×) + + 预期结果: + - 通知立即消失 + """ + pass + + +# ==================== 设置界面测试 ==================== + + +class FrontendSettingsTests: + """ + 设置界面测试用例集 + + 测试前置条件: + 1. 前端页面已加载 + 2. 已登录系统 + """ + + def test_ft_019_load_polling_settings(self): + """ + 测试用例 FT-019:加载轮询配置 + + 测试步骤: + 1. 打开设置页面 + 2. 观察轮询配置项 + + 预期结果: + - 显示当前的轮询开关状态 + - 显示当前的轮询间隔 + - 显示当前的轮询次数 + + 验证方法: + ```javascript + const enableCheckbox = document.getElementById('enableAutoPolling'); + const intervalInput = document.getElementById('pollingInterval'); + const countInput = document.getElementById('pollingCount'); + + console.log('轮询开关:', enableCheckbox.checked); + console.log('轮询间隔:', intervalInput.value); + console.log('轮询次数:', countInput.value); + ``` + """ + pass + + def test_ft_020_save_polling_settings(self): + """ + 测试用例 FT-020:保存轮询配置 + + 测试步骤: + 1. 修改轮询配置 + 2. 点击保存按钮 + 3. 观察提示信息 + + 预期结果: + - 显示"设置已保存,下次选中邮箱时生效"提示 + - 刷新页面后配置保持 + """ + pass + + def test_ft_021_interval_input_validation(self): + """ + 测试用例 FT-021:轮询间隔输入验证 + + 测试步骤: + 1. 尝试输入小于 5 的值 + 2. 尝试输入大于 300 的值 + 3. 点击保存 + + 预期结果: + - 输入框显示验证错误 + - 或保存时显示错误提示 + + 验证方法: + ```javascript + const intervalInput = document.getElementById('pollingInterval'); + intervalInput.value = 3; + // 触发验证 + intervalInput.dispatchEvent(new Event('change')); + + // 检查验证状态 + console.log('验证失败:', intervalInput.validity.valid === false); + ``` + """ + pass + + +# ==================== 测试执行说明 ==================== + +""" +前端测试执行说明: + +1. 手动测试: + - 按照每个测试用例的步骤逐一执行 + - 记录实际结果 + - 对比预期结果,判断是否通过 + +2. 自动化测试(可选): + - 使用 Selenium WebDriver + - 使用 Playwright + - 使用 Cypress + +3. 测试数据准备: + - 创建测试邮箱账号 + - 准备测试邮件(包含不同格式的验证码) + - 创建测试分组和标签 + +4. 测试环境: + - Chrome 最新版(推荐) + - Firefox 最新版 + - Edge 最新版 + +5. 测试报告: + - 记录每个测试用例的执行结果 + - 截图保存关键步骤 + - 记录发现的问题 +""" diff --git a/tests/test_gptmail_error_handling.py b/tests/test_gptmail_error_handling.py new file mode 100644 index 0000000..79cc28d --- /dev/null +++ b/tests/test_gptmail_error_handling.py @@ -0,0 +1,178 @@ +import unittest +from unittest.mock import patch, MagicMock +import requests + +from outlook_web.services import gptmail + + +class TestGPTMailErrorHandling(unittest.TestCase): + """测试 GPTMail 服务的错误处理""" + + def test_gptmail_request_no_api_key(self): + """测试 API Key 未配置的情况""" + with patch("outlook_web.services.gptmail.get_gptmail_api_key", return_value=""): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "CONFIG_ERROR") + self.assertIn("未配置", result["error"]) + + def test_gptmail_request_401_unauthorized(self): + """测试 API Key 无效的情况""" + mock_response = MagicMock() + mock_response.status_code = 401 + + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", + return_value="invalid_key", + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "AUTH_ERROR") + self.assertIn("无效", result["error"]) + + def test_gptmail_request_timeout(self): + """测试请求超时的情况""" + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", side_effect=requests.exceptions.Timeout()): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "TIMEOUT_ERROR") + self.assertIn("超时", result["error"]) + + def test_gptmail_request_connection_error(self): + """测试网络连接失败的情况""" + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch( + "requests.get", + side_effect=requests.exceptions.ConnectionError("Connection refused"), + ): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "CONNECTION_ERROR") + self.assertIn("连接", result["error"]) + + def test_gptmail_request_500_server_error(self): + """测试服务器错误的情况""" + mock_response = MagicMock() + mock_response.status_code = 500 + + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "SERVER_ERROR") + self.assertIn("不可用", result["error"]) + + def test_gptmail_request_429_rate_limit(self): + """测试请求频率超限的情况""" + mock_response = MagicMock() + mock_response.status_code = 429 + + with patch( + "outlook_web.services.gptmail.get_gptmail_api_key", return_value="test_key" + ): + with patch("requests.get", return_value=mock_response): + result = gptmail.gptmail_request("GET", "/api/test") + + self.assertFalse(result["success"]) + self.assertEqual(result["error_type"], "RATE_LIMIT_ERROR") + self.assertIn("频率超限", result["error"]) + + def test_generate_temp_email_success(self): + """测试成功生成临时邮箱""" + mock_result = {"success": True, "data": {"email": "test@example.com"}} + + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): + email, error = gptmail.generate_temp_email() + + self.assertEqual(email, "test@example.com") + self.assertIsNone(error) + + def test_generate_temp_email_api_key_not_configured(self): + """测试 API Key 未配置时生成临时邮箱""" + mock_result = { + "success": False, + "error": "GPTMail API Key 未配置", + "error_type": "CONFIG_ERROR", + "details": "请在系统设置中配置 GPTMail API Key", + } + + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): + email, error = gptmail.generate_temp_email() + + self.assertIsNone(email) + self.assertIn("未配置", error) + self.assertIn("系统设置", error) + + def test_generate_temp_email_timeout(self): + """测试请求超时时生成临时邮箱""" + mock_result = { + "success": False, + "error": "API 请求超时", + "error_type": "TIMEOUT_ERROR", + "details": "请求超过 30 秒未响应,请检查网络连接或稍后重试", + } + + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): + email, error = gptmail.generate_temp_email() + + self.assertIsNone(email) + self.assertIn("超时", error) + self.assertIn("30 秒", error) + + def test_generate_temp_email_missing_email_field(self): + """测试 API 返回数据缺少 email 字段""" + mock_result = {"success": True, "data": {}} # 缺少 email 字段 + + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ): + email, error = gptmail.generate_temp_email() + + self.assertIsNone(email) + self.assertIn("格式错误", error) + self.assertIn("email", error) + + def test_generate_temp_email_with_prefix_and_domain(self): + """测试使用 prefix 和 domain 生成临时邮箱""" + mock_result = {"success": True, "data": {"email": "custom@example.com"}} + + with patch( + "outlook_web.services.gptmail.gptmail_request", return_value=mock_result + ) as mock_request: + email, error = gptmail.generate_temp_email( + prefix="custom", domain="example.com" + ) + + self.assertEqual(email, "custom@example.com") + self.assertIsNone(error) + + # 验证调用参数 + mock_request.assert_called_once() + call_args = mock_request.call_args + self.assertEqual(call_args[0][0], "POST") + self.assertEqual( + call_args[1]["json_data"], {"prefix": "custom", "domain": "example.com"} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_masking_audit_and_import.py b/tests/test_masking_audit_and_import.py new file mode 100644 index 0000000..e5152ed --- /dev/null +++ b/tests/test_masking_audit_and_import.py @@ -0,0 +1,272 @@ +import unittest +import uuid +from unittest.mock import patch + +from tests._import_app import import_web_app_module, clear_login_attempts + + +class MaskingAuditAndImportTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + # 每个测试前清理登录限制记录,避免测试间互相影响 + with self.app.app_context(): + clear_login_attempts() + + def _login(self, client): + resp = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json().get("success"), True) + + def _default_group_id(self) -> int: + conn = self.module.create_sqlite_connection() + try: + row = conn.execute( + "SELECT id FROM groups WHERE name = '默认分组' LIMIT 1" + ).fetchone() + return row["id"] if row else 1 + finally: + conn.close() + + def test_settings_get_does_not_leak_secrets(self): + client = self.app.test_client() + self._login(client) + + resp = client.get("/api/settings") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + settings = data.get("settings") or {} + self.assertNotIn("login_password", settings) + self.assertNotIn("gptmail_api_key", settings) + self.assertIn("login_password_set", settings) + self.assertIn("gptmail_api_key_set", settings) + + def test_account_get_does_not_return_password_or_refresh_token(self): + unique = uuid.uuid4().hex + email_addr = f"mask_{unique}@example.com" + password = f"pass_{unique}" + refresh_token = f"rt_{unique}" + client_id = f"cid_{unique}" + + conn = self.module.create_sqlite_connection() + try: + cur = conn.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, remark, status) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + email_addr, + password, + client_id, + refresh_token, + self._default_group_id(), + "", + "active", + ), + ) + account_id = cur.lastrowid + conn.commit() + finally: + conn.close() + + client = self.app.test_client() + self._login(client) + resp = client.get(f"/api/accounts/{account_id}") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + account = data.get("account") or {} + self.assertEqual(account.get("id"), account_id) + self.assertEqual(account.get("email"), email_addr) + self.assertEqual(account.get("password"), "") + self.assertEqual(account.get("refresh_token"), "") + self.assertEqual(account.get("has_password"), True) + self.assertEqual(account.get("has_refresh_token"), True) + + body_text = resp.get_data(as_text=True) + self.assertNotIn(password, body_text) + self.assertNotIn(refresh_token, body_text) + + def test_system_group_cannot_be_renamed(self): + client = self.app.test_client() + self._login(client) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute( + "SELECT id, name FROM groups WHERE is_system = 1 LIMIT 1" + ).fetchone() + self.assertIsNotNone(row) + system_group_id = row["id"] + system_group_name = row["name"] + finally: + conn.close() + + resp = client.put( + f"/api/groups/{system_group_id}", + json={ + "name": system_group_name + "_new", + "description": "x", + "color": "#111111", + }, + ) + self.assertEqual(resp.status_code, 403) + data = resp.get_json() + self.assertEqual(data.get("success"), False) + self.assertIsInstance(data.get("error"), dict) + self.assertEqual(data["error"].get("code"), "SYSTEM_GROUP_PROTECTED") + self.assertEqual(data["error"].get("status"), 403) + + def test_batch_import_returns_line_errors_and_parses_refresh_token_with_delimiter( + self, + ): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"import_{unique}@example.com" + client_id = f"client_{unique}" + refresh_token = f"rt_{unique}----tail" + + account_string = "\n".join( + [ + "bad_line_without_delimiter", + f"{email_addr}----p----{client_id}----{refresh_token}", + ] + ) + + resp = client.post( + "/api/accounts", + json={ + "account_string": account_string, + "group_id": self._default_group_id(), + }, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + summary = data.get("summary") or {} + self.assertEqual(summary.get("imported"), 1) + self.assertEqual(summary.get("failed"), 1) + self.assertEqual(summary.get("total_lines"), 2) + + errors = data.get("errors") or [] + self.assertGreaterEqual(len(errors), 1) + self.assertEqual(errors[0].get("line"), 1) + + conn = self.module.create_sqlite_connection() + try: + row = conn.execute( + "SELECT refresh_token FROM accounts WHERE email = ? LIMIT 1", + (email_addr,), + ).fetchone() + self.assertIsNotNone(row) + stored = row["refresh_token"] + finally: + conn.close() + + stored_plain = self.module.decrypt_data(stored) if stored else stored + self.assertEqual(stored_plain, refresh_token) + + def test_audit_logs_endpoint_includes_group_create(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + group_name = f"审计测试_{unique}" + create = client.post( + "/api/groups", + json={ + "name": group_name, + "description": "", + "color": "#222222", + "proxy_url": "", + }, + ) + self.assertEqual(create.status_code, 200) + created = create.get_json() + self.assertEqual(created.get("success"), True) + group_id = created.get("group_id") + self.assertIsInstance(group_id, int) + + resp = client.get("/api/audit-logs?action=create&resource_type=group&limit=200") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + logs = data.get("logs") or [] + matched = [ + r + for r in logs + if str(r.get("resource_id")) == str(group_id) + and group_name in (r.get("details") or "") + ] + self.assertTrue(matched) + self.assertTrue(matched[0].get("trace_id")) + + def test_temp_email_actions_are_audited(self): + client = self.app.test_client() + self._login(client) + + unique = uuid.uuid4().hex + email_addr = f"tmp_{unique}@example.com" + + # Mock generate_temp_email 返回元组 (email_addr, None) + # 注意:控制器现在直接使用 gptmail service,需要 mock outlook_web.services.gptmail + from outlook_web.services import gptmail as gptmail_service + + with patch.object( + gptmail_service, "generate_temp_email", return_value=(email_addr, None) + ): + resp = client.post("/api/temp-emails/generate", json={}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json().get("success"), True) + + create_audit = client.get( + "/api/audit-logs?resource_type=temp_email&action=create&limit=200" + ) + self.assertEqual(create_audit.status_code, 200) + create_data = create_audit.get_json() + self.assertEqual(create_data.get("success"), True) + create_logs = create_data.get("logs") or [] + self.assertTrue([r for r in create_logs if r.get("resource_id") == email_addr]) + + with patch.object( + gptmail_service, "clear_temp_emails_from_api", return_value=True + ): + conn = self.module.create_sqlite_connection() + try: + conn.execute( + """ + INSERT INTO temp_email_messages (message_id, email_address, subject, content, timestamp) + VALUES (?, ?, ?, ?, ?) + """, + ("msg_" + unique, email_addr, "s", "c", 1), + ) + conn.commit() + finally: + conn.close() + + clear_resp = client.delete(f"/api/temp-emails/{email_addr}/clear") + self.assertEqual(clear_resp.status_code, 200) + self.assertEqual(clear_resp.get_json().get("success"), True) + + audit_resp = client.get( + "/api/audit-logs?resource_type=temp_email_messages&action=delete&limit=200" + ) + self.assertEqual(audit_resp.status_code, 200) + audit = audit_resp.get_json() + self.assertEqual(audit.get("success"), True) + + logs = audit.get("logs") or [] + matched = [r for r in logs if (r.get("resource_id") == email_addr)] + self.assertTrue(matched) + self.assertTrue(matched[0].get("trace_id")) diff --git a/tests/test_module_boundaries.py b/tests/test_module_boundaries.py new file mode 100644 index 0000000..a91b4b5 --- /dev/null +++ b/tests/test_module_boundaries.py @@ -0,0 +1,77 @@ +import ast +import unittest +from pathlib import Path + + +class ModuleBoundaryTests(unittest.TestCase): + @staticmethod + def _collect_import_modules(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + modules: set[str] = set() + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name) + elif isinstance(node, ast.ImportFrom): + if node.module: + modules.add(node.module) + return modules + + def _assert_forbidden_imports( + self, *, path: Path, forbidden_prefixes: tuple[str, ...] + ): + imports = self._collect_import_modules(path) + for module in sorted(imports): + for prefix in forbidden_prefixes: + if module == prefix or module.startswith(prefix + "."): + self.fail( + f"模块边界违规:{path} 导入了禁止依赖 {module!r}(匹配前缀 {prefix!r})" + ) + + def test_routes_do_not_import_services_repos_or_legacy(self): + repo_root = Path(__file__).resolve().parents[1] + routes_dir = repo_root / "outlook_web" / "routes" + self.assertTrue(routes_dir.exists()) + + forbidden = ( + "outlook_web.legacy", + "outlook_web.services", + "outlook_web.repositories", + "outlook_web.db", + ) + for path in sorted(routes_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) + + def test_repositories_do_not_depend_on_flask_routes_or_services(self): + repo_root = Path(__file__).resolve().parents[1] + repos_dir = repo_root / "outlook_web" / "repositories" + self.assertTrue(repos_dir.exists()) + + forbidden = ( + "flask", + "outlook_web.routes", + "outlook_web.services", + "outlook_web.legacy", + ) + for path in sorted(repos_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) + + def test_services_do_not_depend_on_flask_routes_or_legacy(self): + repo_root = Path(__file__).resolve().parents[1] + services_dir = repo_root / "outlook_web" / "services" + self.assertTrue(services_dir.exists()) + + forbidden = ( + "flask", + "outlook_web.routes", + "outlook_web.legacy", + ) + for path in sorted(services_dir.glob("*.py")): + if path.name == "__init__.py": + continue + self._assert_forbidden_imports(path=path, forbidden_prefixes=forbidden) diff --git a/tests/test_performance.py b/tests/test_performance.py new file mode 100644 index 0000000..8e7a44c --- /dev/null +++ b/tests/test_performance.py @@ -0,0 +1,284 @@ +""" +性能测试用例 + +测试批量操作与自动化增强功能的性能指标,包括: +- 全选性能测试(1000 个邮箱 < 100ms) +- 验证码提取 API 性能测试(< 2 秒) +- 轮询性能测试(不影响页面交互) +""" + +import unittest +import sys +import os +import time + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + + +class TestPerformance(unittest.TestCase): + """性能测试类""" + + def setUp(self): + """测试前置准备""" + pass + + # ==================== 全选性能测试 ==================== + + def test_pt_001_select_all_1000_accounts_performance(self): + """ + 测试用例 PT-001:全选 1000 个邮箱性能 + + 测试目的:验证全选 1000 个邮箱的性能 < 100ms + + 测试方法: + 1. 准备 1000 个测试邮箱数据 + 2. 渲染到页面 + 3. 执行全选操作 + 4. 测量耗时 + + 预期结果:耗时 < 100ms + + 注意:此测试需要在浏览器环境中执行 + """ + # 这是一个前端性能测试,需要在浏览器中执行 + # 测试代码示例(在浏览器控制台执行): + """ + // 准备 1000 个测试邮箱 + const testAccounts = Array.from({length: 1000}, (_, i) => ({ + id: i + 1, + email: `test${i + 1}@example.com`, + group_id: 1 + })); + + // 渲染到页面 + renderAccountList(testAccounts); + + // 测试全选性能 + console.time('全选性能'); + selectAllAccounts(); + console.timeEnd('全选性能'); + + // 预期输出:全选性能: XX.XXms (应该 < 100ms) + """ + + self.assertTrue(True, "前端性能测试,需要在浏览器中执行") + + # ==================== API 性能测试 ==================== + + def test_pt_002_extract_verification_api_performance(self): + """ + 测试用例 PT-002:验证码提取 API 性能 + + 测试目的:验证验证码提取 API 响应时间 < 2 秒 + + 测试方法: + 1. 准备测试邮箱和邮件数据 + 2. 多次调用 API(至少 10 次) + 3. 计算平均响应时间 + + 预期结果:平均响应时间 < 2000ms + """ + # TODO: 实现后取消注释 + # from outlook_web.app import create_app + # + # app = create_app() + # client = app.test_client() + # + # # 登录 + # client.post('/login', data={'password': 'admin123'}) + # + # # 多次测试取平均值 + # times = [] + # for i in range(10): + # start_time = time.time() + # + # response = client.get('/api/emails/test@example.com/extract-verification') + # + # end_time = time.time() + # elapsed_time = (end_time - start_time) * 1000 # 转换为毫秒 + # times.append(elapsed_time) + # + # self.assertEqual(response.status_code, 200) + # + # # 计算平均时间 + # avg_time = sum(times) / len(times) + # max_time = max(times) + # min_time = min(times) + # + # print(f"\n性能测试结果:") + # print(f" 平均响应时间: {avg_time:.2f}ms") + # print(f" 最大响应时间: {max_time:.2f}ms") + # print(f" 最小响应时间: {min_time:.2f}ms") + # + # # 断言平均时间 < 2000ms + # self.assertLess(avg_time, 2000, f"API 平均响应时间过长: {avg_time:.2f}ms") + + self.assertTrue(True, "待实现:API 性能测试") + + # ==================== 轮询性能测试 ==================== + + def test_pt_003_polling_not_block_ui(self): + """ + 测试用例 PT-003:轮询不影响页面交互 + + 测试目的:验证轮询过程中页面交互流畅 + + 测试方法: + 1. 启动轮询 + 2. 在轮询过程中执行各种操作 + 3. 测量操作响应时间 + + 预期结果: + - 页面交互流畅,无卡顿 + - 操作响应及时 + + 注意:此测试需要在浏览器环境中执行 + """ + # 这是一个前端性能测试,需要在浏览器中执行 + # 测试代码示例(在浏览器控制台执行): + """ + // 启动轮询 + startPolling('test@example.com'); + + // 测试各种操作的响应时间 + async function testUIResponsiveness() { + const operations = [ + { + name: '切换分组', + action: () => loadAccountsByGroup(2) + }, + { + name: '搜索邮箱', + action: () => { + const searchInput = document.getElementById('accountSearch'); + searchInput.value = 'test'; + searchInput.dispatchEvent(new Event('input')); + } + }, + { + name: '点击按钮', + tion: () => { + const btn = document.querySelector('.refresh-btn'); + btn.click(); + } + } + ]; + + for (const op of operations) { + console.time(op.name); + await op.action(); + console.timeEnd(op.name); + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + testUIResponsiveness(); + + // 预期:所有操作响应时间都应该 < 100ms + """ + + self.assertTrue(True, "前端性能测试,需要在浏览器中执行") + + # ==================== 压力测试(可选)==================== + + def test_pt_004_concurrent_api_requests(self): + """ + 测试用例 PT-004:并发 API 请求测试(可选) + + 测试目的:验证 API 能处理并发请求 + + 测试方法: + 1. 同时发送多个 API 请求 + 2. 测量响应时间 + 3. 验证所有请求都成功 + + 预期结果: + - 所有请求都成功返回 + - 平均响应时间仍然 < 2 秒 + """ + # TODO: 实现后取消注释 + # import concurrent.futures + # from outlook_web.app import create_app + # + # app = create_app() + # client = app.test_client() + # + # # 登录 + # client.post('/login', data={'password': 'admin123'}) + # + # def make_request(): + # start_time = time.time() + # response = client.get('/api/emails/test@example.com/extract-verification') + # end_time = time.time() + # return { + # 'status_code': response.status_code, + # 'elapsed_time': (end_time - start_time) * 1000 + # } + # + # # 并发 10 个请求 + # with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor: + # futures = [executor.submit(make_request) for _ in range(10)] + # results = [f.result() for f in concurrent.futures.as_completed(futures)] + # + # # 验证结果 + # success_count = sum(1 for r in results if r['status_code'] == 200) + # avg_time = sum(r['elapsed_time'] for r in results) / len(results) + # + # print(f"\n并发测试结果:") + # print(f" 成功请求数: {success_count}/10") + # print(f" 平均响应时间: {avg_time:.2f}ms") + # + # self.assertEqual(success_count, 10, "部分请求失败") + # self.assertLess(avg_time, 2000, f"并发情况下响应时间过长: {avg_time:.2f}ms") + + self.assertTrue(True, "待实现:并发 API 请求测试") + + +class TestMemoryUsage(unittest.TestCase): + """内存使用测试类(可选)""" + + def test_memory_leak_detection(self): + """ + 测试用例:内存泄漏检测 + + 测试目的:验证轮询功能不会导致内存泄漏 + + 测试方法: + 1. 记录初始内存使用 + 2. 执行多次轮询操作 + 3. 记录最终内存使用 + 4. 对比内存增长 + + 预期结果:内存增长在合理范围内 + + 注意:此测试需要在浏览器环境中执行 + """ + # 这是一个前端内存测试,需要在浏览器中执行 + # 测试代码示例(在浏览器控制台执行): + """ + // 使用 Chrome DevTools Memory Profiler + + // 1. 打开 Chrome DevTools -> Memory + // 2. 记录初始堆快照 + // 3. 执行以下代码 + async function testMemoryLeak() { + for (let i = 0; i < 100; i++) { + startPolling('test@example.com'); + await new Promise(resolve => setTimeout(resolve, 1000)); + stopPolling(); + } + } + + testMemoryLeak(); + + // 4. 记录最终堆快照 + // 5. 对比两个快照,检查是否有内存泄漏 + """ + + self.assertTrue(True, "前端内存测试,需要在浏览器中执行") + + +if __name__ == "__main__": + # 运行性能测试 + unittest.main(verbosity=2) diff --git a/tests/test_smoke_contract.py b/tests/test_smoke_contract.py new file mode 100644 index 0000000..d11f925 --- /dev/null +++ b/tests/test_smoke_contract.py @@ -0,0 +1,131 @@ +import unittest + +from tests._import_app import import_web_app_module, clear_login_attempts + + +class SmokeContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + # 每个测试前清理登录限制记录,避免测试间互相影响 + with self.app.app_context(): + clear_login_attempts() + + def _login(self, client): + resp = client.post("/login", json={"password": "testpass123"}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("success"), True) + + def test_healthz_contract(self): + client = self.app.test_client() + resp = client.get("/healthz") + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data.get("status"), "ok") + + def test_pages_are_accessible(self): + client = self.app.test_client() + + # 登录页(未登录也可访问) + resp = client.get("/login") + self.assertEqual(resp.status_code, 200) + self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) + + # 主页(需要登录) + self._login(client) + resp = client.get("/") + self.assertEqual(resp.status_code, 200) + self.assertIn("text/html", (resp.headers.get("Content-Type") or "")) + html = resp.get_data(as_text=True) + # 前端静态资源应已迁出(零构建):不再内联 style/script + self.assertNotIn("