feat: 实现可调整布局系统
- 新增布局管理器支持拖拽调整面板宽度 - 新增状态管理器实现布局持久化 - 重构前端页面结构优化四栏式布局 - 新增 Jest 测试框架和完整测试用例 - 完善项目文档(PRD/FD/TDD/TODO/LOG) - 清理 Python 缓存文件并更新 .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
0eb36f5175
commit
0ab50db85f
65 files changed
+24895
-74
No files matched your search
@@ -26,6 +26,15 @@ data/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Node / Jest
|
||||
node_modules/
|
||||
tests/layout-system/coverage/
|
||||
|
||||
# 本地临时文件/素材(避免误提交)
|
||||
PixPin_*.png
|
||||
exeample/
|
||||
2026-02-26-api-key-credit-monitoring.md
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
|
||||
|
||||
@@ -35,12 +35,11 @@
|
||||
- `app.py` - Flask 应用工厂,负责应用初始化和 Blueprint 注册
|
||||
- `config.py` - 配置管理,环境变量读取
|
||||
- `db.py` - 数据库连接管理,Schema 初始化和升级
|
||||
- `legacy.py` - 旧版实现,正在迁移中 (约 5000 行)
|
||||
- `audit.py` - 审计日志记录
|
||||
- `errors.py` - 错误处理和 trace_id 生成
|
||||
|
||||
#### routes/ 路由层
|
||||
Blueprint 模块化路由,每个文件对应一个功能模块:
|
||||
Blueprint 模块化路由,每个文件对应一个功能模块 (共 11 个):
|
||||
|
||||
- `pages.py` - 页面路由 (首页/登录页)
|
||||
- `accounts.py` - 邮箱账号 CRUD
|
||||
@@ -54,8 +53,23 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
|
||||
- `system.py` - 系统操作 (导出/密码修改)
|
||||
- `audit.py` - 审计日志查询
|
||||
|
||||
#### controllers/ 控制器层
|
||||
请求处理和参数验证,每个文件对应一个路由模块 (共 11 个):
|
||||
|
||||
- `pages.py` - 页面控制器
|
||||
- `accounts.py` - 账号管理控制器
|
||||
- `groups.py` - 分组管理控制器
|
||||
- `tags.py` - 标签控制器
|
||||
- `emails.py` - 邮件操作控制器
|
||||
- `temp_emails.py` - 临时邮箱控制器
|
||||
- `oauth.py` - OAuth 控制器
|
||||
- `settings.py` - 系统设置控制器
|
||||
- `scheduler.py` - 调度器控制器
|
||||
- `system.py` - 系统控制器
|
||||
- `audit.py` - 审计控制器
|
||||
|
||||
#### services/ 服务层
|
||||
业务逻辑实现,封装外部 API 调用:
|
||||
业务逻辑实现,封装外部 API 调用 (共 9 个):
|
||||
|
||||
- `graph.py` - Microsoft Graph API 封装
|
||||
- 获取邮件列表
|
||||
@@ -72,6 +86,9 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
|
||||
- 定时刷新调度
|
||||
- `email_delete.py` - 邮件删除服务
|
||||
- 多 API 回退策略
|
||||
- `scheduler.py` - 定时任务调度器
|
||||
- APScheduler 封装
|
||||
- 任务管理
|
||||
- `gptmail.py` - GPTMail API 封装
|
||||
- `http.py` - HTTP 客户端封装 (支持代理)
|
||||
- `verification_extractor.py` - 验证码提取服务
|
||||
@@ -80,7 +97,7 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
|
||||
- 正则表达式匹配
|
||||
|
||||
#### repositories/ 数据访问层
|
||||
数据库 CRUD 操作封装:
|
||||
数据库 CRUD 操作封装 (共 8 个):
|
||||
|
||||
- `accounts.py` - 邮箱账号数据访问
|
||||
- 增删改查
|
||||
@@ -94,8 +111,18 @@ Blueprint 模块化路由,每个文件对应一个功能模块:
|
||||
- `refresh_runs.py` - 刷新运行记录数据访问
|
||||
- `distributed_locks.py` - 分布式锁实现
|
||||
|
||||
#### middleware/ 中间件
|
||||
横切关注点处理 (共 2 个):
|
||||
|
||||
- `trace.py` - trace_id 中间件
|
||||
- 请求追踪 ID 生成
|
||||
- 日志关联
|
||||
- `error_handler.py` - 错误处理中间件
|
||||
- 统一错误响应
|
||||
- 异常捕获
|
||||
|
||||
#### security/ 安全模块
|
||||
安全相关功能封装:
|
||||
安全相关功能封装 (共 3 个):
|
||||
|
||||
- `auth.py` - 认证授权
|
||||
- 登录验证
|
||||
@@ -287,17 +314,53 @@ Repository 层封装数据访问,业务逻辑与数据库解耦。
|
||||
- Blueprint 可独立部署
|
||||
- 服务层可抽取为独立服务
|
||||
|
||||
## 迁移策略
|
||||
## 前端架构
|
||||
|
||||
### 当前状态
|
||||
- `legacy.py` 包含旧版实现
|
||||
- 新架构逐步替换旧实现
|
||||
- 保持 API 兼容性
|
||||
### 静态资源结构
|
||||
```
|
||||
static/
|
||||
├── css/
|
||||
│ ├── main.css # 主样式
|
||||
│ └── layout.css # 布局系统样式
|
||||
└── js/
|
||||
├── main.js # 主入口
|
||||
├── layout-manager.js # 布局管理器
|
||||
├── layout-bootstrap.js # 布局初始化
|
||||
├── state-manager.js # 状态管理
|
||||
└── features/ # 功能模块
|
||||
├── accounts.js # 账号管理
|
||||
├── emails.js # 邮件操作
|
||||
├── groups.js # 分组管理
|
||||
└── temp_emails.js # 临时邮箱
|
||||
```
|
||||
|
||||
### 迁移步骤
|
||||
### 模板结构
|
||||
```
|
||||
templates/
|
||||
├── index.html # 主页面
|
||||
├── login.html # 登录页
|
||||
└── partials/ # 页面片段
|
||||
├── modals.html # 模态框
|
||||
└── scripts.html # 脚本引用
|
||||
```
|
||||
|
||||
### 布局系统
|
||||
- 四栏式可调整布局 (分组/账号/邮件列表/邮件详情)
|
||||
- 拖拽调整栏宽,实时保存
|
||||
- 支持折叠/展开
|
||||
- 响应式设计,自适应窗口大小
|
||||
|
||||
## 架构演进
|
||||
|
||||
### 已完成的迁移
|
||||
1. ✅ 创建新的分层结构
|
||||
2. ✅ Blueprint 模块化路由
|
||||
3. 🔄 逐步迁移业务逻辑到 Services
|
||||
4. 🔄 数据访问迁移到 Repositories
|
||||
5. ⏳ 移除 legacy.py
|
||||
6. ⏳ 前后端完全分离
|
||||
3. ✅ 业务逻辑迁移到 Services
|
||||
4. ✅ 数据访问迁移到 Repositories
|
||||
5. ✅ 移除 legacy.py
|
||||
6. ✅ 中间件统一处理
|
||||
|
||||
### 下一步计划
|
||||
- ⏳ 前后端完全分离 (RESTful API)
|
||||
- ⏳ 前端框架引入 (Vue.js/React)
|
||||
- ⏳ API 文档完善 (OpenAPI/Swagger)
|
||||
@@ -34,21 +34,29 @@ Web 应用 - Flask 后端 + 传统前端
|
||||
|
||||
```
|
||||
outlookEmail/
|
||||
├── web_outlook_app.py # 应用入口 (兼容层)
|
||||
├── web_outlook_app.py # 应用入口
|
||||
├── outlook_mail_reader.py # 命令行邮件读取工具
|
||||
├── outlook_web/ # 主应用模块
|
||||
│ ├── app.py # Flask 应用工厂
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── db.py # 数据库操作
|
||||
│ ├── legacy.py # 旧版实现 (迁移中)
|
||||
│ ├── audit.py # 审计日志
|
||||
│ ├── errors.py # 错误处理
|
||||
│ ├── routes/ # 路由层 (Blueprint)
|
||||
│ ├── controllers/ # 控制器层
|
||||
│ ├── services/ # 服务层
|
||||
│ ├── repositories/ # 数据访问层
|
||||
│ └── security/ # 安全模块
|
||||
│ ├── security/ # 安全模块
|
||||
│ └── middleware/ # 中间件
|
||||
├── templates/ # HTML 模板
|
||||
│ ├── index.html # 主页面
|
||||
│ ├── login.html # 登录页
|
||||
│ └── partials/ # 页面片段
|
||||
├── static/ # 静态资源
|
||||
│ ├── css/ # 样式文件
|
||||
│ └── js/ # JavaScript 文件
|
||||
│ ├── features/ # 功能模块
|
||||
│ └── layout-*.js # 布局系统
|
||||
├── tests/ # 测试
|
||||
├── docs/ # 文档
|
||||
└── data/ # 数据目录
|
||||
@@ -100,7 +108,14 @@ outlookEmail/
|
||||
- 连续错误自动停止
|
||||
- 可配置轮询间隔/次数
|
||||
|
||||
### 8. 安全特性
|
||||
### 8. 可调整布局系统
|
||||
- 四栏式响应式布局
|
||||
- 拖拽调整栏宽
|
||||
- 布局状态持久化
|
||||
- 支持折叠/展开
|
||||
- 自适应窗口大小
|
||||
|
||||
### 9. 安全特性
|
||||
- XSS 防护 (DOMPurify + iframe 沙箱)
|
||||
- CSRF 防护 (Flask-WTF)
|
||||
- 数据加密 (Fernet 对称加密)
|
||||
@@ -137,10 +152,10 @@ Database (SQLite)
|
||||
### 应用工厂模式
|
||||
使用 `create_app()` 工厂函数创建 Flask 应用实例,便于测试和配置管理。
|
||||
|
||||
### 迁移中架构
|
||||
- `legacy.py` 包含旧版实现 (约 20 万字节)
|
||||
- 正在逐步迁移到新的分层架构
|
||||
- 保持向后兼容性
|
||||
### 完整分层架构
|
||||
- 已完成从单体架构到分层架构的迁移
|
||||
- Routes → Controllers → Services → Repositories 清晰分层
|
||||
- 中间件统一处理横切关注点
|
||||
|
||||
## 数据模型
|
||||
|
||||
@@ -195,13 +210,14 @@ python web_outlook_app.py
|
||||
- ✅ 验证码提取 (Graph → IMAP 回退)
|
||||
- ✅ 自动轮询与通知
|
||||
- ✅ 批量删除邮箱
|
||||
- ✅ 架构迁移完成 (legacy.py 已移除)
|
||||
- ✅ 可调整布局系统
|
||||
|
||||
### 进行中
|
||||
- 🔄 架构迁移 (legacy.py → 分层架构)
|
||||
- 🔄 前后端分离准备
|
||||
- 🔄 单元测试覆盖率提升
|
||||
|
||||
### 待优化
|
||||
- ⏳ 完全移除 legacy.py
|
||||
- ⏳ 前后端完全分离
|
||||
- ⏳ API 文档完善
|
||||
- ⏳ 单元测试覆盖率提升
|
||||
- ⏳ 性能优化与监控
|
||||
@@ -0,0 +1,736 @@
|
||||
# AI 执行提示词 - Outlook 邮件管理工具批量操作与自动化增强
|
||||
|
||||
## 项目背景
|
||||
|
||||
你正在为一个 Outlook 邮件管理工具添加三个核心功能:
|
||||
|
||||
1. **全选功能**:在分组面板的排序按钮右边添加全选复选框,支持一键选中当前分组下的所有邮箱账号,用于批量操作(移动分组、打标签)
|
||||
2. **邮件信息快速复制功能**:在每个邮箱账号名称右边添加复制按钮,点击后自动提取最新邮件中的验证码和链接并复制到剪贴板
|
||||
3. **自动轮询与通知功能**:支持定时轮询选中邮箱的新邮件,检测到新邮件时显示页面通知和视觉提示(红点)
|
||||
|
||||
## 核心技术要求
|
||||
|
||||
### 验证码提取算法
|
||||
- **智能识别**:基于关键词("验证码"、"code"、"OTP"等)搜索上下文(前后50字符),提取4-8位数字/字母组合
|
||||
- **保底提取**:使用正则表达式提取所有4-8位数字/字母组合,过滤日期(1900-2100)和时间(00:00-23:59)格式
|
||||
- **链接提取**:使用正则提取所有HTTP/HTTPS链接并去重
|
||||
- **输出格式**:`验证码 链接1 链接2`(空格分隔)
|
||||
|
||||
### 性能指标
|
||||
- 全选1000个邮箱:< 100ms
|
||||
- 验证码提取API响应:< 2秒
|
||||
- 轮询不影响页面交互
|
||||
|
||||
### 轮询机制
|
||||
- 客户端定时器(setInterval)
|
||||
- 默认配置:10秒间隔,5次轮询
|
||||
- 静默轮询,仅在检测到新邮件时显示通知
|
||||
- 切换邮箱或达到次数后自动停止
|
||||
|
||||
## 参考文档
|
||||
|
||||
在开始实现前,请仔细阅读以下文档:
|
||||
|
||||
1. **PRD(产品需求文档)**:`docs/PRD/Outlook邮件管理工具-批量操作与自动化增强PRD.md`
|
||||
- 了解业务目标、用户故事、功能需求
|
||||
|
||||
2. **FD(功能设计文档)**:`docs/FD/Outlook邮件管理工具-批量操作与自动化增强FD.md`
|
||||
- 了解需要实现的功能清单(27项功能验收 + 5项非功能验收 + 5项回归验收)
|
||||
|
||||
3. **TDD(技术设计文档)**:`docs/TDD/Outlook邮件管理工具-批量操作与自动化增强TDD.md`
|
||||
- 了解详细的技术实现方案、算法设计、API规范、数据结构
|
||||
|
||||
4. **TODO(任务清单)**:`docs/TODO/Outlook邮件管理工具-批量操作与自动化增强TODO.md`
|
||||
- 这是你的主要执行指南,包含详细的任务分解和验收标准
|
||||
|
||||
5. **测试文档**:`docs/TEST/` 目录下的测试文档
|
||||
- 了解测试用例和验收标准
|
||||
|
||||
6. **测试代码**:`tests/` 目录下的测试文件
|
||||
- 参考已准备好的测试用例结构
|
||||
|
||||
## 执行计划
|
||||
|
||||
请严格按照以下阶段顺序执行,每个阶段完成后进行自测:
|
||||
|
||||
### 阶段 0:准备工作(必须先完成)
|
||||
|
||||
```bash
|
||||
# 1. 确认现有测试通过
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 2. 准备测试邮件样本
|
||||
# 创建至少10封测试邮件,覆盖不同验证码格式(参考测试文档中的15个邮件样本)
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有现有单元测试通过
|
||||
- [ ] 测试邮件样本已准备(至少10封)
|
||||
- [ ] 已仔细阅读所有参考文档
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:后端基础(2-3天)
|
||||
|
||||
**目标**:实现验证码提取服务和API接口
|
||||
|
||||
#### 1.1 创建验证码提取服务
|
||||
|
||||
**文件**:`outlook_web/services/verification_extractor.py`
|
||||
|
||||
**核心函数**:
|
||||
```python
|
||||
def smart_extract_verification_code(email_content: str) -> Optional[str]:
|
||||
"""智能识别验证码(基于关键词)"""
|
||||
pass
|
||||
|
||||
def fallback_extract_verification_code(email_content: str) -> Optional[str]:
|
||||
"""保底提取验证码(正则 + 过滤)"""
|
||||
pass
|
||||
|
||||
def extract_links(email_content: str) -> List[str]:
|
||||
"""提取所有HTTP/HTTPS链接"""
|
||||
pass
|
||||
|
||||
def extract_email_text(email: dict) -> str:
|
||||
"""从邮件对象提取纯文本(HTML转文本)"""
|
||||
pass
|
||||
|
||||
def extract_verification_info(email: dict) -> dict:
|
||||
"""完整提取流程(验证码 + 链接 + 格式化)"""
|
||||
pass
|
||||
```
|
||||
|
||||
**实现要点**:
|
||||
- 关键词列表:`["验证码", "code", "验证", "verification", "OTP", "动态码", "校验码", "verify code"]`
|
||||
- 验证码正则:`r'\b[A-Z0-9]{4,8}\b'`
|
||||
- 链接正则:`r'https?://[^\s<>"{}|\\^`\[\]]+'`
|
||||
- 日期过滤:`1900 <= year <= 2100`
|
||||
- 时间过滤:`00:00 <= time <= 23:59`
|
||||
|
||||
#### 1.2 创建API接口
|
||||
|
||||
**文件**:`outlook_web/routes/emails.py` 和 `outlook_web/legacy.py`
|
||||
|
||||
**路由**:`GET /api/emails/<email>/extract-verification`
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"verification_code": "123456",
|
||||
"links": ["https://example.com/verify"],
|
||||
"formatted": "123456 https://example.com/verify"
|
||||
},
|
||||
"trace_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
**错误处理**:
|
||||
- 邮箱不存在 → 404
|
||||
- 未找到邮件 → 404
|
||||
- 未找到验证信息 → 404
|
||||
- Token过期 → 401
|
||||
- 服务器错误 → 500
|
||||
|
||||
#### 1.3 编写单元测试
|
||||
|
||||
**文件**:`tests/test_verification_extractor.py`(已创建,需要实现TODO部分)
|
||||
|
||||
**测试覆盖**:
|
||||
- 智能识别:中文关键词、英文关键词、OTP关键词
|
||||
- 保底提取:纯数字、数字+字母、过滤日期/时间
|
||||
- 链接提取:单个、多个、去重
|
||||
- 边界情况:空邮件、未找到、HTML转文本
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有单元测试通过(18个测试用例)
|
||||
- [ ] 验证码提取成功率 > 90%(基于测试邮件样本)
|
||||
- [ ] API响应时间 < 2秒
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:后端配置(1天)
|
||||
|
||||
**目标**:添加轮询配置到数据库和设置API
|
||||
|
||||
#### 2.1 数据库变更
|
||||
|
||||
**文件**:`outlook_web/db.py`
|
||||
|
||||
**修改位置**:`init_db()` 函数
|
||||
|
||||
**添加配置**:
|
||||
```python
|
||||
# 在 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"))
|
||||
```
|
||||
|
||||
#### 2.2 更新设置API
|
||||
|
||||
**文件**:`outlook_web/legacy.py`
|
||||
|
||||
**修改接口**:
|
||||
- `GET /api/settings`:添加返回 `enable_auto_polling`、`polling_interval`、`polling_count`
|
||||
- `PUT /api/settings`:添加处理这三个字段,验证范围(interval: 5-300, count: 1-100)
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 数据库初始化时自动创建配置项
|
||||
- [ ] GET /api/settings 返回轮询配置
|
||||
- [ ] PUT /api/settings 可以更新轮询配置
|
||||
- [ ] 参数验证正确(超出范围返回400错误)
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:前端基础(2-3天)
|
||||
|
||||
**目标**:实现全选功能和复制按钮
|
||||
|
||||
#### 3.1 全选功能
|
||||
|
||||
**文件**:`static/js/features/accounts.js` 或 `static/js/main.js`
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
// 全选状态枚举
|
||||
const SelectAllState = {
|
||||
NONE: 'none', // 未选中
|
||||
PARTIAL: 'partial', // 部分选中
|
||||
ALL: 'all' // 全选
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
function toggleSelectAll() {
|
||||
const state = getSelectAllState();
|
||||
if (state === SelectAllState.ALL) {
|
||||
unselectAllAccounts();
|
||||
} else {
|
||||
selectAllAccounts();
|
||||
}
|
||||
}
|
||||
|
||||
// 选中所有邮箱
|
||||
function selectAllAccounts() {
|
||||
// 使用 Set 数据结构(O(1)查找)
|
||||
// 使用 requestAnimationFrame 优化渲染
|
||||
}
|
||||
|
||||
// 更新全选复选框状态
|
||||
function updateSelectAllCheckbox() {
|
||||
const checkbox = document.getElementById('selectAllAccounts');
|
||||
const state = getSelectAllState();
|
||||
|
||||
checkbox.checked = (state === SelectAllState.ALL);
|
||||
checkbox.indeterminate = (state === SelectAllState.PARTIAL);
|
||||
}
|
||||
```
|
||||
|
||||
**HTML结构**(在排序按钮右边添加):
|
||||
```html
|
||||
<label class="select-all-container">
|
||||
<input type="checkbox" id="selectAllAccounts" />
|
||||
<span>全选</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
**CSS样式**:
|
||||
```css
|
||||
.select-all-container {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:indeterminate {
|
||||
/* 半选状态样式 */
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 复制按钮
|
||||
|
||||
**文件**:`static/js/features/accounts.js`
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
async function copyVerificationInfo(email) {
|
||||
const btn = event.target;
|
||||
|
||||
// 1. 设置加载状态
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
|
||||
try {
|
||||
// 2. 调用API提取验证信息
|
||||
const response = await fetch(`/api/emails/${email}/extract-verification`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '提取失败');
|
||||
}
|
||||
|
||||
// 3. 复制到剪贴板
|
||||
await copyToClipboard(data.data.formatted);
|
||||
|
||||
// 4. 显示成功状态
|
||||
showNotification('已复制');
|
||||
btn.classList.add('success');
|
||||
setTimeout(() => btn.classList.remove('success'), 1000);
|
||||
|
||||
} catch (error) {
|
||||
// 5. 处理错误
|
||||
if (error.message.includes('未找到')) {
|
||||
showNotification('未找到验证信息', 'error');
|
||||
} else {
|
||||
showNotification('复制失败', 'error');
|
||||
}
|
||||
} finally {
|
||||
// 6. 恢复按钮状态
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
// 优先使用 Clipboard API
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// 降级方案:execCommand
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**HTML结构**(在账号名称右边添加):
|
||||
```html
|
||||
<button class="copy-verification-btn" onclick="copyVerificationInfo('${account.email}')">
|
||||
<i class="icon-copy"></i>
|
||||
</button>
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 全选复选框显示在排序按钮右边
|
||||
- [ ] 点击全选可以选中/取消选中所有邮箱
|
||||
- [ ] 半选状态正确显示
|
||||
- [ ] 切换分组时全选状态重置
|
||||
- [ ] 复制按钮显示在账号名称右边
|
||||
- [ ] 点击复制按钮成功复制验证码和链接
|
||||
- [ ] 显示"已复制"通知
|
||||
- [ ] 按钮有加载/成功/失败状态的视觉反馈
|
||||
- [ ] 全选1000个邮箱耗时 < 100ms
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:前端轮询与通知(2-3天)
|
||||
|
||||
**目标**:实现自动轮询和新邮件通知
|
||||
|
||||
#### 4.1 设置界面
|
||||
|
||||
**文件**:`templates/index.html` 或设置模态框
|
||||
|
||||
**HTML结构**:
|
||||
```html
|
||||
<div class="setting-item">
|
||||
<label>
|
||||
<input type="checkbox" id="enableAutoPolling" />
|
||||
启用自动轮询
|
||||
</label>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label>轮询间隔(秒):</label>
|
||||
<input type="number" id="pollingInterval" min="5" max="300" value="10" />
|
||||
<span class="hint">建议:10-30秒</span>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label>轮询次数:</label>
|
||||
<input type="number" id="pollingCount" min="1" max="100" value="5" />
|
||||
<span class="hint">建议:3-10次</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 4.2 轮询逻辑
|
||||
|
||||
**文件**:`static/js/features/polling.js` 或 `static/js/main.js`
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
// 全局变量
|
||||
let pollingTimer = null;
|
||||
let pollingCount = 0;
|
||||
let pollingMaxCount = 5;
|
||||
let pollingInterval = 10000; // 毫秒
|
||||
let isPollingEnabled = false;
|
||||
let lastEmailIds = new Set();
|
||||
|
||||
// 启动轮询
|
||||
function startPolling(email) {
|
||||
if (!isPollingEnabled) return;
|
||||
|
||||
stopPolling(); // 先停止之前的轮询
|
||||
|
||||
pollingCount = 0;
|
||||
lastEmailIds = new Set(getCurrentEmails().map(e => e.id));
|
||||
|
||||
console.log(`开始轮询邮箱: ${email}, 间隔: ${pollingInterval}ms, 次数: ${pollingMaxCount}`);
|
||||
|
||||
pollingTimer = setInterval(() => {
|
||||
pollForNewEmails(email);
|
||||
}, pollingInterval);
|
||||
}
|
||||
|
||||
// 停止轮询
|
||||
function stopPolling() {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
console.log('停止轮询');
|
||||
}
|
||||
}
|
||||
|
||||
// 轮询检查新邮件
|
||||
async function pollForNewEmails(email) {
|
||||
pollingCount++;
|
||||
console.log(`轮询第 ${pollingCount}/${pollingMaxCount} 次`);
|
||||
|
||||
try {
|
||||
// 1. 获取最新邮件列表
|
||||
const response = await fetch(`/api/emails/${email}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取邮件失败');
|
||||
}
|
||||
|
||||
// 2. 检测新邮件
|
||||
const newEmails = detectNewEmails(lastEmailIds, data.emails);
|
||||
|
||||
// 3. 如果有新邮件,显示通知
|
||||
if (newEmails.length > 0) {
|
||||
showNewEmailNotification(email, newEmails);
|
||||
showNewEmailBadge(email);
|
||||
}
|
||||
|
||||
// 4. 更新邮件ID集合
|
||||
lastEmailIds = new Set(data.emails.map(e => e.id));
|
||||
|
||||
// 5. 达到次数后停止
|
||||
if (pollingCount >= pollingMaxCount) {
|
||||
stopPolling();
|
||||
console.log('轮询已完成');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('轮询失败:', error);
|
||||
stopPolling();
|
||||
showNotification('轮询失败,已自动停止', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 检测新邮件
|
||||
function detectNewEmails(oldEmailIds, newEmails) {
|
||||
return newEmails.filter(email => !oldEmailIds.has(email.id));
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3 通知组件
|
||||
|
||||
**文件**:`static/js/features/notification.js`
|
||||
|
||||
**核心函数**:
|
||||
```javascript
|
||||
function showNewEmailNotification(email, newEmails) {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = 'email-notification';
|
||||
notification.innerHTML = `
|
||||
<div class="notification-header">
|
||||
<span>${email}</span>
|
||||
<button class="close-btn" onclick="this.parentElement.parentElement.remove()">×</button>
|
||||
</div>
|
||||
<div class="notification-body">
|
||||
收到新邮件:${newEmails[0].subject}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// 5秒后自动消失
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function showNewEmailBadge(email) {
|
||||
const accountItem = document.querySelector(`[data-email="${email}"]`);
|
||||
if (accountItem) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'new-email-badge';
|
||||
accountItem.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
function hideNewEmailBadge(email) {
|
||||
const badge = document.querySelector(`[data-email="${email}"] .new-email-badge`);
|
||||
if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CSS样式**:
|
||||
```css
|
||||
.email-notification {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
padding: 16px;
|
||||
min-width: 300px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.new-email-badge {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff4444;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
```
|
||||
|
||||
**集成到现有代码**:
|
||||
```javascript
|
||||
// 在 selectAccount() 函数中添加
|
||||
function selectAccount(email) {
|
||||
// ... 现有代码 ...
|
||||
|
||||
// 启动轮询
|
||||
startPolling(email);
|
||||
|
||||
// 清除红点
|
||||
hideNewEmailBadge(email);
|
||||
}
|
||||
|
||||
// 在 loadAccountsByGroup() 函数中添加
|
||||
function loadAccountsByGroup(groupId) {
|
||||
// 停止轮询
|
||||
stopPolling();
|
||||
|
||||
// ... 现有代码 ...
|
||||
}
|
||||
|
||||
// 在页面卸载时停止轮询
|
||||
window.addEventListener('beforeunload', () => {
|
||||
stopPolling();
|
||||
});
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 设置界面显示轮询配置项
|
||||
- [ ] 启用轮询后选中邮箱时自动开始轮询
|
||||
- [ ] 轮询到达次数后自动停止
|
||||
- [ ] 检测到新邮件时显示通知
|
||||
- [ ] 邮箱列表显示红点提示
|
||||
- [ ] 轮询过程不影响页面操作
|
||||
- [ ] 切换邮箱时停止当前轮询
|
||||
- [ ] 通知5秒后自动消失
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5:优化与完善(1-2天)
|
||||
|
||||
**目标**:性能优化、用户体验优化、测试完善
|
||||
|
||||
#### 5.1 性能优化
|
||||
|
||||
**全选性能优化**:
|
||||
```javascript
|
||||
function selectAllAccounts() {
|
||||
const accounts = Array.from(document.querySelectorAll('.account-item'));
|
||||
|
||||
// 使用 DocumentFragment 减少重排重绘
|
||||
requestAnimationFrame(() => {
|
||||
accounts.forEach(account => {
|
||||
const checkbox = account.querySelector('.account-checkbox');
|
||||
checkbox.checked = true;
|
||||
selectedAccounts.add(account.dataset.email);
|
||||
});
|
||||
updateBatchActionBar();
|
||||
updateSelectAllCheckbox();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**验证码提取性能优化**:
|
||||
- 优化正则表达式(避免回溯)
|
||||
- 缓存邮件内容(避免重复提取)
|
||||
|
||||
**轮询性能优化**:
|
||||
- 使用 `async/await` 避免阻塞
|
||||
- 使用 `AbortController` 支持取消请求
|
||||
|
||||
#### 5.2 用户体验优化
|
||||
|
||||
- 复制按钮添加 tooltip 提示
|
||||
- 通知组件支持多条通知队列
|
||||
- 设置界面添加配置说明和建议值
|
||||
|
||||
#### 5.3 测试完善
|
||||
|
||||
**运行所有测试**:
|
||||
```bash
|
||||
# 单元测试
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 手动测试(参考 tests/test_frontend_manual.py)
|
||||
# 在浏览器中执行测试用例
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- [ ] 所有单元测试通过(18个)
|
||||
- [ ] 所有API测试通过(9个)
|
||||
- [ ] 所有前端测试通过(21个)
|
||||
- [ ] 所有性能测试通过(3个)
|
||||
- [ ] 所有回归测试通过(5个)
|
||||
|
||||
---
|
||||
|
||||
## 最终验收清单(必须全部通过)
|
||||
|
||||
### 功能验收(27项)
|
||||
|
||||
#### 全选功能(7项)
|
||||
- [ ] 全选复选框显示在排序按钮右边
|
||||
- [ ] 点击全选复选框可以选中/取消选中当前分组下的所有邮箱
|
||||
- [ ] 全选状态与邮箱列表选中状态实时同步(全选/半选/未选)
|
||||
- [ ] 全选后可以执行批量移动分组操作
|
||||
- [ ] 全选后可以执行批量打标签/去标签操作
|
||||
- [ ] 切换分组时全选状态重置
|
||||
- [ ] 搜索或筛选后,全选只影响当前显示的邮箱
|
||||
|
||||
#### 复制功能(9项)
|
||||
- [ ] 复制按钮显示在邮箱名称右边
|
||||
- [ ] 点击复制按钮可以自动提取验证码和链接
|
||||
- [ ] 验证码提取成功率 > 90%(基于测试邮件样本)
|
||||
- [ ] 链接提取能正确提取所有 HTTP/HTTPS 链接
|
||||
- [ ] 复制成功后显示"已复制"通知
|
||||
- [ ] 未找到验证信息时显示"未找到验证信息"提示
|
||||
- [ ] 邮箱无数据时自动触发获取邮件操作
|
||||
- [ ] 复制按钮有加载状态、成功状态、失败状态的视觉反馈
|
||||
- [ ] 后端 API `/api/emails/<email>/extract-verification` 正常工作
|
||||
|
||||
#### 轮询功能(11项)
|
||||
- [ ] 设置中心有"自动轮询"配置项(开关、间隔、次数)
|
||||
- [ ] 启用轮询后,选中邮箱时自动开始轮询
|
||||
- [ ] 轮询到达设置次数后自动停止
|
||||
- [ ] 检测到新邮件时显示页面通知(邮箱地址 + 邮件主题)
|
||||
- [ ] 邮箱列表显示新邮件视觉提示(红点/角标)
|
||||
- [ ] 轮询过程静默进行,不干扰用户操作
|
||||
- [ ] 轮询失败时停止并显示错误提示
|
||||
- [ ] 切换邮箱时停止当前轮询
|
||||
- [ ] 设置保存后下次选中邮箱时生效
|
||||
- [ ] 数据库 settings 表包含轮询配置项
|
||||
- [ ] 通知 5 秒后自动消失或点击关闭
|
||||
|
||||
### 非功能验收(5项)
|
||||
- [ ] 全选操作在 100ms 内完成(1000 个邮箱)
|
||||
- [ ] 验证码提取 API 响应时间 < 2 秒
|
||||
- [ ] 轮询不影响页面正常使用
|
||||
- [ ] 所有操作都有明确的成功/失败反馈
|
||||
- [ ] 错误提示清晰易懂
|
||||
|
||||
### 回归验收(5项)
|
||||
- [ ] 现有批量操作功能正常(移动分组、打标签)
|
||||
- [ ] 现有邮件获取功能正常
|
||||
- [ ] 现有设置功能正常
|
||||
- [ ] 单元测试通过:`python -m unittest discover -s tests -v`
|
||||
- [ ] 前端页面关键路径抽样回归(登录、分组/账号、邮件查看)
|
||||
|
||||
---
|
||||
|
||||
## 重要提示
|
||||
|
||||
### 开发原则
|
||||
1. **小步快跑**:每完成一个小功能就测试,不要一次性写太多代码
|
||||
2. **测试驱动**:先写测试用例,再实现功能
|
||||
3. **代码复用**:优先使用现有的函数和组件,避免重复造轮子
|
||||
4. **错误处理**:所有异步操作都要有 try-catch,所有API调用都要处理错误
|
||||
5. **性能优先**:注意性能指标,使用性能分析工具验证
|
||||
|
||||
### 常见陷阱
|
||||
1. **验证码识别**:不要过度依赖智能识别,保底规则很重要
|
||||
2. **浏览器兼容性**:Clipboard API 需要降级方案
|
||||
3. **轮询性能**:高频轮询会影响性能,使用异步请求
|
||||
4. **内存泄漏**:记得在页面卸载时清理定时器
|
||||
5. **状态同步**:全选状态要与邮箱列表实时同步
|
||||
|
||||
### 调试技巧
|
||||
```javascript
|
||||
// 开启调试日志
|
||||
const DEBUG = true;
|
||||
function log(...args) {
|
||||
if (DEBUG) console.log('[DEBUG]', ...args);
|
||||
}
|
||||
|
||||
// 性能测试
|
||||
console.time('全选性能');
|
||||
selectAllAccounts();
|
||||
console.timeEnd('全选性能');
|
||||
|
||||
// 网络请求监控
|
||||
// 打开浏览器开发者工具 -> Network 标签
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开始执行
|
||||
|
||||
现在请按照以下步骤开始执行:
|
||||
|
||||
1. **阅读所有参考文档**(PRD、FD、TDD、TODO、测试文档)
|
||||
2. **完成阶段0的准备工作**(确认测试通过、准备测试数据)
|
||||
3. **从阶段1开始逐步实现**(后端 → 前端 → 优化)
|
||||
4. **每个阶段完成后进行自测**(单元测试 + 手动测试)
|
||||
5. **最终完成所有验收清单**(37项验收标准)
|
||||
|
||||
**预计总时间**:8-12天(P0必须完成)
|
||||
|
||||
**里程碑**:
|
||||
- 里程碑1:后端API完成,单元测试通过(3天)
|
||||
- 里程碑2:前端基础功能完成,手动测试通过(6天)
|
||||
- 里程碑3:轮询与通知功能完成,集成测试通过(9天)
|
||||
- 里程碑4:所有验收清单通过,文档更新完成(12天)
|
||||
|
||||
祝你开发顺利!如有疑问,请参考技术设计文档(TDD)中的详细说明。
|
||||
@@ -0,0 +1,524 @@
|
||||
# AI 工作提示词:Legacy 迁移到 Controllers 层
|
||||
|
||||
## 角色定义
|
||||
|
||||
你是一名高级 Python/Flask 开发工程师,负责将 Outlook 邮件管理工具的 legacy.py(5757 行)迁移到清晰的四层架构(routes → controllers → services → repositories)。
|
||||
|
||||
你的核心职责是:
|
||||
- 按照既定的技术方案执行代码迁移
|
||||
- 确保所有功能和 API 契约保持不变
|
||||
- 编写和运行测试验证迁移正确性
|
||||
- 保持代码质量和可维护性
|
||||
|
||||
---
|
||||
|
||||
## 项目背景
|
||||
|
||||
### 项目简介
|
||||
|
||||
**项目名称:** Outlook 邮件管理工具
|
||||
|
||||
**技术栈:**
|
||||
- 后端:Python 3.8+, Flask 3.0+, SQLite
|
||||
- 前端:原生 JavaScript, HTML, CSS(零构建)
|
||||
- 测试:unittest, unittest.mock, coverage
|
||||
|
||||
**当前问题:**
|
||||
- `outlook_web/legacy.py` 文件达到 5757 行
|
||||
- 包含所有路由处理逻辑(54 个 API 路由)
|
||||
- 职责混乱:路由处理 + 数据访问 + 业务逻辑混在一起
|
||||
- 难以维护、测试和扩展
|
||||
|
||||
**迁移目标:**
|
||||
```
|
||||
当前架构:
|
||||
routes/ (Blueprint) → legacy.py (5757行) → services/ → repositories/
|
||||
|
||||
目标架构:
|
||||
routes/ (Blueprint) → controllers/ (新增) → services/ → repositories/
|
||||
```
|
||||
|
||||
### 已完成的工作
|
||||
|
||||
✅ **文档准备完成:**
|
||||
1. PRD(产品需求文档)- `docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md`
|
||||
2. FD(功能设计文档)- `docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md`
|
||||
3. TDD(技术设计文档)- `docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md`
|
||||
4. TODO(待办清单)- `docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md`
|
||||
5. TEST(测试文档)- `docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md`
|
||||
|
||||
✅ **架构准备完成:**
|
||||
- routes/ 目录已存在(11 个 Blueprint 模块)
|
||||
- services/ 目录已存在(7 个业务逻辑模块)
|
||||
- repositories/ 目录已存在(8 个数据访问模块)
|
||||
- 所有 Blueprint 当前使用 `impl=legacy` 模式
|
||||
|
||||
---
|
||||
|
||||
## 任务目标
|
||||
|
||||
### 总体目标
|
||||
|
||||
将 legacy.py 中的 54 个路由处理函数迁移到新的 controllers/ 层,分 4 个阶段完成:
|
||||
|
||||
**阶段 1:基础模块(19 个路由,预计 2-3 天)**
|
||||
- groups (6), tags (4), settings (3), system (3), audit (1), pages (3)
|
||||
|
||||
**阶段 2:独立功能模块(6 个路由,预计 1-2 天)**
|
||||
- temp_emails (3), oauth (2), scheduler (1)
|
||||
|
||||
**阶段 3:核心复杂模块(24 个路由,预计 3-4 天)**
|
||||
- emails (4), accounts (20)
|
||||
|
||||
**阶段 4:清理和优化(预计 1 天)**
|
||||
- 删除 legacy.py,迁移工具函数和中间件
|
||||
|
||||
### 核心要求
|
||||
|
||||
**必须保持不变:**
|
||||
- ✅ 所有 API 的 URL 路径
|
||||
- ✅ 所有 API 的 HTTP 方法
|
||||
- ✅ 所有 API 的请求参数
|
||||
- ✅ 所有 API 的响应格式
|
||||
- ✅ 所有 API 的错误格式
|
||||
- ✅ trace_id 机制
|
||||
- ✅ 数据脱敏机制
|
||||
- ✅ 鉴权机制(@login_required)
|
||||
|
||||
**必须提升:**
|
||||
- ✅ 代码结构清晰
|
||||
- ✅ 职责分离明确
|
||||
- ✅ 易于测试和维护
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
### 单个模块迁移流程(以 groups 为例)
|
||||
|
||||
#### 步骤 1:创建 Controller 文件
|
||||
|
||||
```bash
|
||||
# 创建 controllers 目录(如果不存在)
|
||||
mkdir -p outlook_web/controllers
|
||||
|
||||
# 创建 groups controller
|
||||
touch outlook_web/controllers/groups.py
|
||||
```
|
||||
|
||||
#### 步骤 2:从 legacy.py 提取函数
|
||||
|
||||
打开 `outlook_web/legacy.py`,找到以下函数:
|
||||
- `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)`
|
||||
|
||||
复制这些函数到 `outlook_web/controllers/groups.py`
|
||||
|
||||
#### 步骤 3:调整导入语句
|
||||
|
||||
```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)
|
||||
- 保持错误处理逻辑
|
||||
- 保持响应格式(jsonify)
|
||||
|
||||
#### 步骤 4:更新 routes/groups.py
|
||||
|
||||
```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/<int:group_id>",
|
||||
view_func=groups_controller.api_get_group,
|
||||
methods=["GET"])
|
||||
|
||||
# ... 其他路由
|
||||
|
||||
return bp
|
||||
```
|
||||
|
||||
**关键点:**
|
||||
- 移除 `impl` 参数
|
||||
- 直接导入 controller 模块
|
||||
- 保持 URL 路径和 HTTP 方法不变
|
||||
|
||||
#### 步骤 5:更新 app.py
|
||||
|
||||
```python
|
||||
# outlook_web/app.py
|
||||
# 更新前
|
||||
app.register_blueprint(groups.create_blueprint(impl=legacy))
|
||||
|
||||
# 更新后
|
||||
app.register_blueprint(groups.create_blueprint())
|
||||
```
|
||||
|
||||
#### 步骤 6:运行测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 运行特定测试
|
||||
python -m unittest tests.test_smoke_contract -v
|
||||
```
|
||||
|
||||
**验证:**
|
||||
- 所有测试通过
|
||||
- 手动测试所有 6 个 API
|
||||
- 验证响应格式正确
|
||||
- 验证错误处理正确
|
||||
|
||||
#### 步骤 7:提交 Git
|
||||
|
||||
```bash
|
||||
git add outlook_web/controllers/groups.py outlook_web/routes/groups.py outlook_web/app.py
|
||||
git commit -m "feat: 迁移 groups 模块到 controllers 层"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术规范
|
||||
|
||||
### Controller 标准模式
|
||||
|
||||
```python
|
||||
# outlook_web/controllers/xxx.py
|
||||
from flask import request, jsonify
|
||||
from outlook_web.security.auth import login_required
|
||||
from outlook_web.repositories import xxx as xxx_repo
|
||||
from outlook_web.errors import build_error_payload
|
||||
|
||||
@login_required
|
||||
def api_xxx():
|
||||
"""函数说明"""
|
||||
try:
|
||||
# 1. 参数解析
|
||||
param = request.args.get('param')
|
||||
|
||||
# 2. 参数验证
|
||||
if not param:
|
||||
return jsonify(build_error_payload('参数错误')), 400
|
||||
|
||||
# 3. 调用 service/repository
|
||||
result = xxx_repo.do_something(param)
|
||||
|
||||
# 4. 返回响应
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
# 5. 错误处理
|
||||
return jsonify(build_error_payload(str(e))), 500
|
||||
```
|
||||
|
||||
### Controller 职责边界
|
||||
|
||||
**必须做:**
|
||||
- ✅ 参数解析(从 request 中提取)
|
||||
- ✅ 参数验证(基本验证)
|
||||
- ✅ 鉴权检查(@login_required)
|
||||
- ✅ 调用 services/repositories
|
||||
- ✅ 响应封装(jsonify)
|
||||
- ✅ 错误处理(try-except)
|
||||
|
||||
**不应该做:**
|
||||
- ❌ 直接操作数据库
|
||||
- ❌ 复杂的业务逻辑
|
||||
- ❌ 直接调用第三方 API
|
||||
- ❌ 数据加密/解密
|
||||
|
||||
### 依赖方向规则
|
||||
|
||||
```
|
||||
routes → controllers → services → repositories → db
|
||||
```
|
||||
|
||||
**禁止反向依赖:**
|
||||
- ❌ repositories → services
|
||||
- ❌ services → controllers
|
||||
- ❌ controllers → routes
|
||||
|
||||
---
|
||||
|
||||
## 测试策略
|
||||
|
||||
### 测试类型
|
||||
|
||||
**1. 回归测试(最重要)**
|
||||
```bash
|
||||
# 每次迁移后都要运行
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
**2. 手动测试**
|
||||
- 使用 curl 或 Postman 测试每个 API
|
||||
- 验证响应格式正确
|
||||
- 验证错误处理正确
|
||||
|
||||
**3. 性能测试(可选)**
|
||||
```python
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
response = client.get('/api/groups')
|
||||
end = time.time()
|
||||
|
||||
print(f"响应时间: {(end - start) * 1000:.2f}ms")
|
||||
```
|
||||
|
||||
### 测试检查清单
|
||||
|
||||
每个模块迁移后:
|
||||
- [ ] 所有现有测试通过
|
||||
- [ ] 手动测试所有 API
|
||||
- [ ] 验证响应格式不变
|
||||
- [ ] 验证错误格式不变
|
||||
- [ ] 验证性能无明显下降
|
||||
- [ ] 提交 Git
|
||||
|
||||
---
|
||||
|
||||
## 参考文档
|
||||
|
||||
### 必读文档
|
||||
|
||||
1. **TODO 文档**(最重要)
|
||||
- 位置:`docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md`
|
||||
- 内容:详细的任务列表和执行步骤
|
||||
|
||||
2. **TDD 文档**
|
||||
- 位置:`docs/TDD/Outlook邮件管理工具-Legacy迁移到Controllers层TDD.md`
|
||||
- 内容:技术设计细节和代码示例
|
||||
|
||||
3. **TEST 文档**
|
||||
- 位置:`docs/TEST/Outlook邮件管理工具-Legacy迁移到Controllers层-测试文档.md`
|
||||
- 内容:测试策略和测试用例
|
||||
|
||||
### 可选文档
|
||||
|
||||
4. **PRD 文档**
|
||||
- 位置:`docs/PRD/PRD-00003-Legacy代码拆分到Controllers层.md`
|
||||
- 内容:需求背景和目标架构
|
||||
|
||||
5. **FD 文档**
|
||||
- 位置:`docs/FD/Outlook邮件管理工具-Legacy迁移到Controllers层FD.md`
|
||||
- 内容:功能清单和验收标准
|
||||
|
||||
6. **开发者指南**
|
||||
- 位置:`docs/DEV/00002-前后端拆分-开发者指南.md`
|
||||
- 内容:项目架构和开发规范
|
||||
|
||||
7. **CLAUDE.md**
|
||||
- 位置:`CLAUDE.md`
|
||||
- 内容:项目概述和常用命令
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 阶段验收
|
||||
|
||||
**每个阶段完成后:**
|
||||
- [ ] 所有测试通过
|
||||
- [ ] 所有 API 手动测试通过
|
||||
- [ ] 代码审查通过
|
||||
- [ ] Git 提交完成
|
||||
|
||||
### 最终验收
|
||||
|
||||
**所有阶段完成后:**
|
||||
- [ ] 所有 54 个路由已迁移
|
||||
- [ ] legacy.py 已删除
|
||||
- [ ] 所有测试通过
|
||||
- [ ] 性能测试通过(响应时间 < 迁移前 110%)
|
||||
- [ ] 文档已更新
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
### 关键原则
|
||||
|
||||
1. **保持兼容性**
|
||||
- 不改变任何 API 的行为
|
||||
- 不改变任何响应格式
|
||||
- 不改变任何错误格式
|
||||
|
||||
2. **分阶段迁移**
|
||||
- 先迁移简单模块,后迁移复杂模块
|
||||
- 每个模块迁移后立即测试
|
||||
- 每个阶段完成后提交 Git
|
||||
|
||||
3. **充分测试**
|
||||
- 每次迁移后运行所有测试
|
||||
- 手动测试所有 API
|
||||
- 验证性能无下降
|
||||
|
||||
4. **保留 legacy.py**
|
||||
- 直到所有模块迁移完成
|
||||
- 作为参考和回滚依据
|
||||
|
||||
5. **及时提交**
|
||||
- 每个模块迁移后提交 Git
|
||||
- 便于回滚和代码审查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Q1:如果测试失败怎么办?**
|
||||
A1:
|
||||
1. 检查函数签名是否一致
|
||||
2. 检查导入语句是否正确
|
||||
3. 检查装饰器是否保留
|
||||
4. 检查错误处理是否一致
|
||||
5. 如果无法解决,回滚到上一个提交
|
||||
|
||||
**Q2:如何处理循环依赖?**
|
||||
A2:
|
||||
1. 确保依赖方向正确(routes → controllers → services → repositories)
|
||||
2. 不要在 repositories 中导入 services
|
||||
3. 不要在 services 中导入 controllers
|
||||
|
||||
**Q3:如何验证 API 契约不变?**
|
||||
A3:
|
||||
1. 运行现有的契约测试
|
||||
2. 手动测试 API,对比响应格式
|
||||
3. 使用 curl 或 Postman 测试
|
||||
|
||||
**Q4:性能下降怎么办?**
|
||||
A4:
|
||||
1. 检查是否有不必要的数据库查询
|
||||
2. 检查是否有重复的函数调用
|
||||
3. 使用 profiler 工具分析瓶颈
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 第一步:环境准备
|
||||
|
||||
```bash
|
||||
# 1. 确认环境
|
||||
python --version # 应该是 3.8+
|
||||
pip list | grep Flask # 应该是 3.0+
|
||||
|
||||
# 2. 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 3. 运行测试(建立基线)
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 4. 创建分支
|
||||
git checkout -b feature/migrate-to-controllers
|
||||
```
|
||||
|
||||
### 第二步:开始迁移
|
||||
|
||||
```bash
|
||||
# 1. 阅读 TODO 文档
|
||||
cat docs/TODO/Outlook邮件管理工具-Legacy迁移到Controllers层TODO.md
|
||||
|
||||
# 2. 创建 controllers 目录
|
||||
mkdir -p outlook_web/controllers
|
||||
touch outlook_web/controllers/__init__.py
|
||||
|
||||
# 3. 开始迁移第一个模块(groups)
|
||||
# 按照上面的"单个模块迁移流程"执行
|
||||
```
|
||||
|
||||
### 第三步:验证和提交
|
||||
|
||||
```bash
|
||||
# 1. 运行测试
|
||||
python -m unittest discover -s tests -v
|
||||
|
||||
# 2. 手动测试
|
||||
curl -X GET http://localhost:5000/api/groups
|
||||
|
||||
# 3. 提交
|
||||
git add .
|
||||
git commit -m "feat: 迁移 groups 模块到 controllers 层"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作建议
|
||||
|
||||
### 推荐工作顺序
|
||||
|
||||
1. **先读文档**(30 分钟)
|
||||
- 阅读 TODO 文档,了解任务列表
|
||||
- 阅读 TDD 文档,了解技术细节
|
||||
- 阅读 TEST 文档,了解测试策略
|
||||
|
||||
2. **建立基线**(15 分钟)
|
||||
- 运行所有测试,记录结果
|
||||
- 手动测试几个关键 API
|
||||
- 记录性能基线
|
||||
|
||||
3. **迁移第一个模块**(1-2 小时)
|
||||
- 选择最简单的模块(groups 或 tags)
|
||||
- 严格按照流程执行
|
||||
- 充分测试验证
|
||||
|
||||
4. **迁移其他模块**(重复)
|
||||
- 按照 TODO 文档的顺序
|
||||
- 每个模块迁移后立即测试
|
||||
- 每个阶段完成后提交
|
||||
|
||||
5. **最终清理**(1 天)
|
||||
- 删除 legacy.py
|
||||
- 迁移工具函数
|
||||
- 更新文档
|
||||
|
||||
### 时间估算
|
||||
|
||||
- 阶段 1(基础模块):2-3 天
|
||||
- 阶段 2(独立模块):1-2 天
|
||||
- 阶段 3(复杂模块):3-4 天
|
||||
- 阶段 4(清理优化):1 天
|
||||
- **总计:7-10 天**
|
||||
|
||||
---
|
||||
|
||||
## 联系和支持
|
||||
|
||||
如果遇到问题:
|
||||
1. 查看文档中的"常见问题"部分
|
||||
2. 检查 Git 提交历史,查看之前的实现
|
||||
3. 运行测试,查看错误信息
|
||||
4. 如果无法解决,回滚到上一个提交
|
||||
|
||||
---
|
||||
|
||||
**祝你工作顺利!记住:保持兼容性、充分测试、及时提交。**
|
||||
+1000
@@ -0,0 +1,1000 @@
|
||||
# PRD - 可调整布局系统
|
||||
|
||||
## 文档信息
|
||||
|
||||
- **文档编号**: PRD-00001
|
||||
- **创建日期**: 2026-02-26
|
||||
- **版本**: v1.1
|
||||
- **状态**: 已审查
|
||||
- **负责人**: 开发团队
|
||||
- **审查日期**: 2026-02-26
|
||||
- **审查结果**: 通过(已根据审查意见修订)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
### 1.1 当前问题
|
||||
|
||||
Outlook 邮件管理工具当前采用固定宽度的四栏布局:
|
||||
- 分组面板(200px)
|
||||
- 账号面板(260px)
|
||||
- 邮件列表面板(380px)
|
||||
- 邮件详情面板(自适应)
|
||||
|
||||
存在以下问题:
|
||||
1. **缺乏灵活性**:用户无法根据使用习惯调整面板宽度
|
||||
2. **空间利用率低**:不同屏幕尺寸下,固定宽度导致空间浪费或拥挤
|
||||
3. **无法聚焦**:无法临时折叠不需要的面板,专注于当前任务
|
||||
4. **用户体验落后**:现代编辑器(VS Code、EnsoAI)普遍支持可调整布局
|
||||
|
||||
### 1.2 需求来源
|
||||
|
||||
参考 EnsoAI 编辑器的 UI 设计理念,提升用户体验,使布局更加灵活和现代化。
|
||||
|
||||
### 1.3 目标用户
|
||||
|
||||
- **主要用户**:桌面浏览器用户(Chrome、Edge、Firefox、Safari)
|
||||
- **使用场景**:
|
||||
- 需要同时查看多个邮箱账号的用户
|
||||
- 需要在不同工作模式间切换的用户(浏览模式 vs 专注阅读模式)
|
||||
- 使用不同屏幕尺寸的用户(笔记本 vs 外接显示器)
|
||||
|
||||
## 二、产品目标
|
||||
|
||||
### 2.1 核心目标
|
||||
|
||||
1. **提升灵活性**:用户可以自由调整每个面板的宽度
|
||||
2. **支持折叠**:用户可以临时折叠不需要的面板
|
||||
3. **状态持久化**:记住用户的布局偏好,刷新后自动恢复
|
||||
4. **平滑体验**:所有交互都有流畅的动画过渡
|
||||
|
||||
### 2.2 成功指标
|
||||
|
||||
- 用户可以在 3 秒内完成面板宽度调整
|
||||
- 用户可以在 1 秒内完成面板折叠/展开
|
||||
- 布局状态在页面刷新后 100% 恢复
|
||||
- 拖动调整时帧率保持在 60fps
|
||||
|
||||
## 三、功能需求
|
||||
|
||||
### 3.1 可拖动调整宽度
|
||||
|
||||
#### 3.1.1 功能描述
|
||||
|
||||
用户可以通过拖动面板边缘的分隔条来调整面板宽度。
|
||||
|
||||
#### 3.1.2 交互细节
|
||||
|
||||
**拖动区域**:
|
||||
- 每个面板右侧边缘有 4px 宽的拖动区域(resizer)
|
||||
- 鼠标悬停时显示视觉反馈:
|
||||
- 光标变为 `col-resize`(双向箭头)
|
||||
- 显示 2px 的高亮边框指示器
|
||||
- 背景色变为半透明黑色(rgba(26, 26, 26, 0.1))
|
||||
|
||||
**拖动行为**:
|
||||
- 鼠标按下(mousedown)时开始拖动
|
||||
- 鼠标移动(mousemove)时实时更新面板宽度
|
||||
- 鼠标释放(mouseup)时结束拖动,保存宽度到 localStorage
|
||||
|
||||
**宽度限制**:
|
||||
- **最小宽度限制**:
|
||||
- 分组面板:150px(确保分组名称和图标可见)
|
||||
- 账号面板:180px(确保邮箱地址可读)
|
||||
- 邮件列表面板:280px(确保邮件预览可读)
|
||||
- 邮件详情面板:400px(确保邮件内容可读)
|
||||
- **最大宽度限制**:
|
||||
- 分组面板:400px
|
||||
- 账号面板:500px
|
||||
- 邮件列表面板:600px
|
||||
- 邮件详情面板:无限制(自动填充)
|
||||
- **窗口尺寸自适应**:
|
||||
- 当浏览器窗口宽度 < 1200px 时,自动折叠分组面板
|
||||
- 当浏览器窗口宽度 < 900px 时,自动折叠账号面板
|
||||
- 当浏览器窗口宽度 < 700px 时,自动折叠邮件列表面板
|
||||
- 确保邮件详情面板始终保持至少 400px 可见宽度
|
||||
|
||||
**视觉反馈**:
|
||||
- 拖动时整个页面光标变为 `col-resize`
|
||||
- 拖动时添加 `user-select: none` 防止选中文本
|
||||
- 使用 CSS transitions 实现平滑过渡(200ms ease)
|
||||
|
||||
#### 3.1.3 技术实现
|
||||
|
||||
- 使用 CSS Grid 布局
|
||||
- 使用 CSS 变量存储面板宽度:
|
||||
- `--group-panel-width`
|
||||
- `--account-panel-width`
|
||||
- `--email-list-panel-width`
|
||||
- 使用原生 JavaScript 实现拖动逻辑
|
||||
- 使用 requestAnimationFrame 优化性能
|
||||
|
||||
### 3.2 面板折叠/展开
|
||||
|
||||
#### 3.2.1 功能描述
|
||||
|
||||
用户可以点击折叠按钮来隐藏/显示面板,专注于当前任务。
|
||||
|
||||
#### 3.2.2 交互细节
|
||||
|
||||
**折叠按钮位置**:
|
||||
- 分组面板:右上角
|
||||
- 账号面板:右上角
|
||||
- 邮件列表面板:右上角
|
||||
|
||||
**按钮样式**:
|
||||
- 图标:左箭头(←)表示折叠,右箭头(→)表示展开
|
||||
- 尺寸:24x24px
|
||||
- 悬停效果:背景色变为 #f5f5f5
|
||||
|
||||
**折叠动画**:
|
||||
- 使用 CSS transition 实现平滑动画(200ms ease)
|
||||
- 折叠时宽度变为 0,同时 opacity 从 1 到 0
|
||||
- 展开时恢复到折叠前的宽度,opacity 从 0 到 1
|
||||
|
||||
**折叠状态指示**:
|
||||
- 折叠后在主容器边缘显示一个细条(48px 宽)
|
||||
- 细条上显示面板名称(竖排文字)
|
||||
- 点击细条可以快速展开面板
|
||||
|
||||
#### 3.2.3 折叠规则
|
||||
|
||||
- 分组面板、账号面板、邮件列表面板可以独立折叠
|
||||
- 邮件详情面板不可折叠(始终可见)
|
||||
- 至少保持一个侧边栏面板展开(不能全部折叠)
|
||||
- **折叠时的拖动行为**:
|
||||
- 当面板处于折叠状态时,该面板的 resizer(拖动区域)自动隐藏
|
||||
- 用户必须先点击折叠指示条展开面板后,才能拖动调整宽度
|
||||
- 展开时恢复到折叠前的宽度(从 localStorage 读取)
|
||||
|
||||
### 3.3 恢复默认布局
|
||||
|
||||
#### 3.3.1 功能描述
|
||||
|
||||
用户可以一键恢复到默认布局,清除所有自定义调整。
|
||||
|
||||
#### 3.3.2 交互细节
|
||||
|
||||
**触发位置**:
|
||||
- 在顶部导航栏的右侧添加"恢复默认布局"按钮
|
||||
- 图标:重置图标(↻)
|
||||
- 文字:仅在悬停时显示 tooltip
|
||||
|
||||
**确认机制**:
|
||||
- 点击后弹出确认对话框:
|
||||
- 标题:"恢复默认布局"
|
||||
- 内容:"确定要恢复到默认布局吗?当前的面板宽度和折叠状态将被重置。"
|
||||
- 按钮:"取消" / "确定"
|
||||
|
||||
**默认布局参数**:
|
||||
- 分组面板:200px,展开
|
||||
- 账号面板:260px,展开
|
||||
- 邮件列表面板:380px,展开
|
||||
- 邮件详情面板:自适应
|
||||
|
||||
**重置动画**:
|
||||
- 使用 CSS transition 平滑过渡到默认宽度(300ms ease)
|
||||
|
||||
### 3.4 状态持久化
|
||||
|
||||
#### 3.4.1 功能描述
|
||||
|
||||
自动保存用户的布局偏好,页面刷新后恢复。
|
||||
|
||||
#### 3.4.2 保存内容
|
||||
|
||||
**面板宽度**:
|
||||
- 分组面板宽度
|
||||
- 账号面板宽度
|
||||
- 邮件列表面板宽度
|
||||
|
||||
**折叠状态**:
|
||||
- 分组面板是否折叠
|
||||
- 账号面板是否折叠
|
||||
- 邮件列表面板是否折叠
|
||||
|
||||
#### 3.4.3 存储方式
|
||||
|
||||
- 使用 localStorage 存储
|
||||
- **存储键名**:`outlook_layout_state_${userId}`(支持多用户隔离)
|
||||
- 如果用户未登录,使用 `outlook_layout_state_guest`
|
||||
- 如果系统有用户 ID,使用 `outlook_layout_state_${userId}`
|
||||
- 数据格式:
|
||||
```json
|
||||
{
|
||||
"version": "1.1",
|
||||
"userId": "user123",
|
||||
"timestamp": 1709020800000,
|
||||
"panels": {
|
||||
"groups": {
|
||||
"width": "200px",
|
||||
"collapsed": false
|
||||
},
|
||||
"accounts": {
|
||||
"width": "260px",
|
||||
"collapsed": false
|
||||
},
|
||||
"emails": {
|
||||
"width": "380px",
|
||||
"collapsed": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.4.4 保存时机
|
||||
|
||||
- 拖动调整宽度后,延迟 500ms 保存(防抖)
|
||||
- 折叠/展开面板后,立即保存
|
||||
- 恢复默认布局后,清除保存的状态
|
||||
- **页面卸载时强制保存**:
|
||||
- 监听 `beforeunload` 事件
|
||||
- 如果有未保存的防抖任务,立即执行保存
|
||||
- 确保用户的最后一次调整不会丢失
|
||||
|
||||
#### 3.4.5 加载时机
|
||||
|
||||
- 页面加载完成后,从 localStorage 读取状态
|
||||
- 如果存在保存的状态,应用到 CSS 变量
|
||||
- 如果不存在,使用默认布局
|
||||
|
||||
## 四、非功能需求
|
||||
|
||||
### 4.1 性能要求
|
||||
|
||||
- **拖动流畅度**:拖动时帧率保持在 60fps
|
||||
- **动画性能**:使用 CSS transform 和 opacity,避免触发 reflow
|
||||
- **内存占用**:localStorage 数据不超过 1KB
|
||||
|
||||
### 4.2 兼容性要求
|
||||
|
||||
- **浏览器支持**:
|
||||
- Chrome 90+
|
||||
- Edge 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- **CSS 特性**:
|
||||
- CSS Grid
|
||||
- CSS 变量
|
||||
- CSS transitions
|
||||
- **JavaScript 特性**:
|
||||
- ES6+
|
||||
- localStorage API
|
||||
- requestAnimationFrame
|
||||
|
||||
### 4.3 可访问性要求
|
||||
|
||||
- **折叠按钮**:
|
||||
- 支持键盘操作(Tab 聚焦 + Enter 触发)
|
||||
- 有合适的 aria-label(如"折叠分组面板")
|
||||
- **拖动区域(Resizer)**:
|
||||
- 支持键盘操作:
|
||||
- Tab 键聚焦到 resizer
|
||||
- 左箭头键(←):减小面板宽度(每次 10px)
|
||||
- 右箭头键(→):增大面板宽度(每次 10px)
|
||||
- Shift + 左/右箭头:快速调整(每次 50px)
|
||||
- 添加 ARIA 属性:
|
||||
- `role="separator"`
|
||||
- `aria-orientation="vertical"`
|
||||
- `aria-label="调整[面板名称]宽度"`
|
||||
- `aria-valuenow`:当前宽度值
|
||||
- `aria-valuemin`:最小宽度
|
||||
- `aria-valuemax`:最大宽度
|
||||
- 聚焦时有明显的视觉反馈(outline 或高亮边框)
|
||||
- **所有交互元素**:
|
||||
- 有明确的视觉反馈
|
||||
- 支持屏幕阅读器
|
||||
|
||||
### 4.4 安全性要求
|
||||
|
||||
- localStorage 数据仅存储布局状态,不包含敏感信息
|
||||
- 防止 XSS 攻击:不直接将用户输入插入 DOM
|
||||
|
||||
## 五、UI 设计规范
|
||||
|
||||
### 5.1 布局结构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 顶部导航栏(固定高度 56px) │
|
||||
├──────┬──────┬──────┬────────────────────────────────────┤
|
||||
│ 分组 │ 账号 │ 邮件 │ 邮件详情 │
|
||||
│ 面板 │ 面板 │ 列表 │ 面板 │
|
||||
│ │ │ 面板 │ (自适应) │
|
||||
│ [▼] │ [▼] │ [▼] │ │
|
||||
│ ├─┤ │ ├─┤ │ ├─┤ │ │
|
||||
└──────┴──────┴──────┴────────────────────────────────────┘
|
||||
200px 260px 380px flex: 1
|
||||
|
||||
[▼] = 折叠按钮
|
||||
├─┤ = 拖动分隔条
|
||||
```
|
||||
|
||||
### 5.2 颜色规范
|
||||
|
||||
- **拖动区域背景(hover)**: rgba(26, 26, 26, 0.1)
|
||||
- **拖动指示器**: #1a1a1a
|
||||
- **折叠按钮背景(hover)**: #f5f5f5
|
||||
- **折叠按钮图标**: #666
|
||||
|
||||
### 5.3 尺寸规范
|
||||
|
||||
- **拖动区域宽度**: 4px
|
||||
- **拖动指示器宽度**: 2px
|
||||
- **折叠按钮尺寸**: 24x24px
|
||||
- **折叠后细条宽度**: 48px
|
||||
|
||||
### 5.4 动画规范
|
||||
|
||||
- **拖动过渡**: 无(实时响应)
|
||||
- **折叠/展开**: 200ms ease
|
||||
- **恢复默认布局**: 300ms ease
|
||||
|
||||
## 六、技术实现方案
|
||||
|
||||
### 6.1 技术栈
|
||||
|
||||
- **前端框架**: 无(原生 JavaScript)
|
||||
- **CSS 预处理器**: 无(原生 CSS)
|
||||
- **布局系统**: CSS Gr- **状态管理**: localStorage
|
||||
|
||||
### 6.2 核心模块
|
||||
|
||||
#### 6.2.1 布局管理器(LayoutManager)
|
||||
|
||||
**职责**:
|
||||
- 管理面板宽度和折叠状态
|
||||
- 处理拖动事件
|
||||
- 处理折叠/展开事件
|
||||
- 保存/加载布局状态
|
||||
|
||||
**主要方法**:
|
||||
```javascript
|
||||
class LayoutManager {
|
||||
constructor()
|
||||
init()
|
||||
startResize(e)
|
||||
resize(e)
|
||||
stopResize()
|
||||
togglePanel(panelName)
|
||||
resetLayout()
|
||||
saveState()
|
||||
loadState()
|
||||
updatePanelWidth(panel, width)
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2.2 状态持久化(StateManager)
|
||||
|
||||
**职责**:
|
||||
- 读写 localStorage
|
||||
- 数据格式验证
|
||||
- 版本兼容性处理
|
||||
|
||||
**主要方法**:
|
||||
```javascript
|
||||
class StateManager {
|
||||
save(state)
|
||||
load()
|
||||
clear()
|
||||
validate(state)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 文件结构
|
||||
|
||||
```
|
||||
static/
|
||||
├── css/
|
||||
│ ├── main.css # 现有样式(需修改)
|
||||
│ └── layout.css # 新增:布局系统样式
|
||||
├── js/
|
||||
│ ├── main.js # 现有脚本(需修改)
|
||||
│ ├── layout-manager.js # 新增:布局管理器
|
||||
│ └── state-manager.js # 新增:状态管理器
|
||||
templates/
|
||||
└── index.html # 主页面(需修改)
|
||||
```
|
||||
|
||||
### 6.4 关键代码片段
|
||||
|
||||
#### CSS 变量定义
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* 面板初始宽度 */
|
||||
--group-panel-width: 200px;
|
||||
--account-panel-width: 260px;
|
||||
--email-list-panel-width: 380px;
|
||||
|
||||
/* 最小/最大宽度限制 */
|
||||
--group-panel-min-width: 150px;
|
||||
--group-panel-max-width: 400px;
|
||||
--account-panel-min-width: 180px;
|
||||
--account-panel-max-width: 500px;
|
||||
--email-list-panel-min-width: 280px;
|
||||
--email-list-panel-max-width: 600px;
|
||||
--email-detail-panel-min-width: 400px;
|
||||
|
||||
/* 折叠状态宽度 */
|
||||
--panel-collapsed-width: 0px;
|
||||
--panel-collapsed-indicator-width: 48px;
|
||||
|
||||
/* 拖动区域 */
|
||||
--resizer-width: 4px;
|
||||
|
||||
/* 动画时长 */
|
||||
--panel-transition-duration: 200ms;
|
||||
}
|
||||
```
|
||||
|
||||
#### Grid 布局
|
||||
|
||||
```css
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(var(--group-panel-min-width), var(--group-panel-width))
|
||||
minmax(var(--account-panel-min-width), var(--account-panel-width))
|
||||
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
|
||||
minmax(var(--email-detail-panel-min-width), 1fr);
|
||||
height: calc(100vh - 56px);
|
||||
transition: grid-template-columns var(--panel-transition-duration) ease;
|
||||
}
|
||||
|
||||
/* 窗口尺寸自适应 */
|
||||
@media (max-width: 1200px) {
|
||||
.main-container {
|
||||
grid-template-columns:
|
||||
0px
|
||||
minmax(var(--account-panel-min-width), var(--account-panel-width))
|
||||
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
|
||||
minmax(var(--email-detail-panel-min-width), 1fr);
|
||||
}
|
||||
.group-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.main-container {
|
||||
grid-template-columns:
|
||||
0px
|
||||
0px
|
||||
minmax(var(--email-list-panel-min-width), var(--email-list-panel-width))
|
||||
minmax(var(--email-detail-panel-min-width), 1fr);
|
||||
}
|
||||
.group-panel,
|
||||
.account-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.main-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.group-panel,
|
||||
.account-panel,
|
||||
.email-list-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 拖动区域样式
|
||||
|
||||
```css
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--resizer-width);
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
transition: background-color 0.2s;
|
||||
/* 可访问性:支持键盘聚焦 */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.resizer:hover,
|
||||
.resizer:focus {
|
||||
background-color: rgba(26, 26, 26, 0.1);
|
||||
}
|
||||
|
||||
.resizer:focus {
|
||||
outline: 2px solid #1a1a1a;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.resizer::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: transparent;
|
||||
transform: translateX(-50%);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.resizer:hover::after,
|
||||
.resizer:focus::after {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 折叠状态下隐藏 resizer */
|
||||
.resizable-panel.collapsed .resizer {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
#### Resizer HTML 结构(支持可访问性)
|
||||
|
||||
```html
|
||||
<div class="resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整分组面板宽度"
|
||||
aria-valuenow="200"
|
||||
aria-valuemin="150"
|
||||
aria-valuemax="400"
|
||||
tabindex="0"
|
||||
data-resizer="groups">
|
||||
</div>
|
||||
```
|
||||
|
||||
#### JavaScript 核心逻辑(含所有改进)
|
||||
|
||||
```javascript
|
||||
class LayoutManager {
|
||||
constructor() {
|
||||
this.isResizing = false;
|
||||
this.currentPanel = null;
|
||||
this.startX = 0;
|
||||
this.startWidth = 0;
|
||||
this.pendingSave = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// 鼠标拖动
|
||||
document.querySelectorAll('.resizer').forEach(resizer => {
|
||||
resizer.addEventListener('mousedown', this.startResize.bind(this));
|
||||
// 键盘支持
|
||||
resizer.addEventListener('keydown', this.handleKeyboard.bind(this));
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', this.resize.bind(this));
|
||||
document.addEventListener('mouseup', this.stopResize.bind(this));
|
||||
|
||||
// 窗口尺寸变化监听
|
||||
window.addEventListener('resize', this.handleWindowResize.bind(this));
|
||||
|
||||
// 页面卸载时强制保存
|
||||
window.addEventListener('beforeunload', this.saveStateNow.bind(this));
|
||||
}
|
||||
|
||||
startResize(e) {
|
||||
this.isResizing = true;
|
||||
const resizerType = e.target.dataset.resizer;
|
||||
this.currentPanel = document.querySelector(`[data-panel="${resizerType}"]`);
|
||||
this.startX = e.clientX;
|
||||
this.startWidth = this.currentPanel.offsetWidth;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
|
||||
resize(e) {
|
||||
if (!this.isResizing) return;
|
||||
|
||||
const delta = e.clientX - this.startX;
|
||||
let newWidth = this.startWidth + delta;
|
||||
|
||||
// 应用最小/最大宽度限制
|
||||
const panelType = this.currentPanel.dataset.panel;
|
||||
const minWidth = this.getMinWidth(panelType);
|
||||
const maxWidth = this.getMaxWidth(panelType);
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
|
||||
// 确保邮件详情面板至少 400px
|
||||
const containerWidth = document.querySelector('.main-container').offsetWidth;
|
||||
const otherPanelsWidth = this.calculateOtherPanelsWidth(panelType);
|
||||
const maxAllowedWidth = containerWidth - otherPanelsWidth - 400;
|
||||
newWidth = Math.min(newWidth, maxAllowedWidth);
|
||||
|
||||
this.updatePanelWidth(this.currentPanel, newWidth);
|
||||
}
|
||||
|
||||
stopResize() {
|
||||
if (this.isResizing) {
|
||||
this.isResizing = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
this.saveStateDebounced();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyboard(e) {
|
||||
const resizer = e.target;
|
||||
const panelType = resizer.dataset.resizer;
|
||||
const panel = document.querySelector(`[data-panel="${panelType}"]`);
|
||||
const currentWidth = panel.offsetWidth;
|
||||
|
||||
let delta = 0;
|
||||
if (e.key === 'ArrowLeft') {
|
||||
delta = e.shiftKey ? -50 : -10;
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
delta = e.shiftKey ? 50 : 10;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let newWidth = currentWidth + delta;
|
||||
const minWidth = this.getMinWidth(panelType);
|
||||
const maxWidth = this.getMaxWidth(panelType);
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
|
||||
this.updatePanelWidth(panel, newWidth);
|
||||
this.updateAriaValue(resizer, newWidth);
|
||||
this.saveStateDebounced();
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
// 自动折叠逻辑
|
||||
if (windowWidth < 1200) {
|
||||
this.collapsePanel('groups', true);
|
||||
}
|
||||
if (windowWidth < 900) {
|
||||
this.collapsePanel('accounts', true);
|
||||
}
|
||||
if (windowWidth < 700) {
|
||||
this.collapsePanel('emails', true);
|
||||
}
|
||||
}
|
||||
|
||||
updatePanelWidth(panel, width) {
|
||||
const panelType = panel.dataset.panel;
|
||||
const varName = `--${panelType}-panel-width`;
|
||||
document.documentElement.style.setProperty(varName, `${width}px`);
|
||||
}
|
||||
|
||||
updateAriaValue(resizer, value) {
|
||||
resizer.setAttribute('aria-valuenow', Math.round(value));
|
||||
}
|
||||
|
||||
getMinWidth(panelType) {
|
||||
const minWidths = {
|
||||
'groups': 150,
|
||||
'accounts': 180,
|
||||
'emails': 280
|
||||
};
|
||||
return minWidths[panelType] || 150;
|
||||
}
|
||||
|
||||
getMaxWidth(panelType) {
|
||||
const maxWidths = {
|
||||
'groups': 400,
|
||||
'accounts': 500,
|
||||
'emails': 600
|
||||
};
|
||||
return maxWidths[panelType] || 600;
|
||||
}
|
||||
|
||||
calculateOtherPanelsWidth(excludePanel) {
|
||||
const panels = ['groups', 'accounts', 'emails'];
|
||||
let total = 0;
|
||||
panels.forEach(panel => {
|
||||
if (panel !== excludePanel) {
|
||||
const el = document.querySelector(`[data-panel="${panel}"]`);
|
||||
if (el && !el.classList.contains('collapsed')) {
|
||||
total += el.offsetWidth;
|
||||
}
|
||||
}
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
saveStateDebounced() {
|
||||
if (this.pendingSave) {
|
||||
clearTimeout(this.pendingSave);
|
||||
}
|
||||
this.pendingSave = setTimeout(() => {
|
||||
this.saveStateNow();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
saveStateNow() {
|
||||
if (this.pendingSave) {
|
||||
clearTimeout(this.pendingSave);
|
||||
this.pendingSave = null;
|
||||
}
|
||||
|
||||
const userId = this.getCurrentUserId() || 'guest';
|
||||
const state = {
|
||||
version: '1.1',
|
||||
userId: userId,
|
||||
timestamp: Date.now(),
|
||||
panels: {
|
||||
groups: {
|
||||
width: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--groups-panel-width'),
|
||||
collapsed: document.querySelector('[data-panel="groups"]')
|
||||
.classList.contains('collapsed')
|
||||
},
|
||||
accounts: {
|
||||
width: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--accounts-panel-width'),
|
||||
collapsed: document.querySelector('[data-panel="accounts"]')
|
||||
.classList.contains('collapsed')
|
||||
},
|
||||
emails: {
|
||||
width: getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--emails-panel-width'),
|
||||
collapsed: document.querySelector('[data-panel="emails"]')
|
||||
.classList.contains('collapsed')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
localStorage.setItem(`outlook_layout_state_${userId}`, JSON.stringify(state));
|
||||
}
|
||||
|
||||
getCurrentUserId() {
|
||||
// 从页面或全局变量获取用户 ID
|
||||
return window.currentUserId || null;
|
||||
}
|
||||
|
||||
collapsePanel(panelType, auto = false) {
|
||||
const panel = document.querySelector(`[data-panel="${panelType}"]`);
|
||||
if (!panel) return;
|
||||
|
||||
panel.classList.add('collapsed');
|
||||
if (!auto) {
|
||||
this.saveStateNow();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 七、实施计划
|
||||
|
||||
### 7.1 开发阶段
|
||||
|
||||
#### 阶段一:基础布局改造(2-3 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 修改 HTML 结构,添加 resizer 元素
|
||||
- [ ] 修改 CSS,实现 Grid 布局
|
||||
- [ ] 添加 CSS 变量系统
|
||||
- [ ] 确保现有功能不受影响
|
||||
|
||||
**验收标准**:
|
||||
- 页面布局正常显示
|
||||
- 现有功能(邮件列表、详情查看)正常工作
|
||||
|
||||
#### 阶段二:拖动调整功能(2-3 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 实现 LayoutManager 类
|
||||
- [ ] 实现拖动事件处理
|
||||
- [ ] 添加视觉反馈(hover 效果)
|
||||
- [ ] 优化拖动性能(requestAnimationFrame)
|
||||
|
||||
**验收标准**:
|
||||
- 可以拖动调整每个面板宽度
|
||||
- 拖动流畅,帧率 60fps
|
||||
- 有明确的视觉反馈
|
||||
|
||||
#### 阶段三:折叠功能(1-2 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 添加折叠按钮
|
||||
- [ ] 实现折叠/展开逻辑
|
||||
- [ ] 添加折叠动画
|
||||
- [ ] 实现折叠后的细条指示器
|
||||
|
||||
**验收标准**:
|
||||
- 可以折叠/展开每个面板
|
||||
- 动画平滑自然
|
||||
- 折叠后有明确的视觉指示
|
||||
|
||||
#### 阶段四:状态持久化(1 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 实现 StateManager 类
|
||||
- [ ] 实现保存/加载逻辑
|
||||
- [ ] 添加防抖优化
|
||||
- [ ] 处理异常情况(localStorage 不可用)
|
||||
|
||||
**验收标准**:
|
||||
- 刷新页面后布局状态恢复
|
||||
- localStorage 数据格式正确
|
||||
- 异常情况有降级处理
|
||||
|
||||
#### 阶段五:恢复默认布局(1 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 添加重置按钮
|
||||
- [ ] 实现确认对话框
|
||||
- [ ] 实现重置逻辑
|
||||
- [ ] 添加重置动画
|
||||
|
||||
**验收标准**:
|
||||
- 可以一键恢复默认布局
|
||||
- 有确认机制防止误操作
|
||||
- 重置动画平滑
|
||||
|
||||
#### 阶段六:测试与优化(2-3 天)
|
||||
|
||||
**任务**:
|
||||
- [ ] 浏览器兼容性测试
|
||||
- [ ] 性能测试(帧率、内存)
|
||||
- [ ] 边界情况测试
|
||||
- [ ] 用户体验优化
|
||||
|
||||
**验收标准**:
|
||||
- 所有主流浏览器正常工作
|
||||
- 性能指标达标
|
||||
- 无明显 bug
|
||||
|
||||
### 7.2 总体时间估算
|
||||
|
||||
- **开发时间**: 9-12 天
|
||||
- **测试时间**: 2-3 天
|
||||
- **总计**: 11-15 天
|
||||
|
||||
### 7.3 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 应对措施 |
|
||||
|------|------|------|----------|
|
||||
| CSS Grid 兼容性问题 | 高 | 低 | 提前测试目标浏览器,准备降级方案 |
|
||||
| 拖动性能不佳 | 中 | 中 | 使用 requestAnimationFrame 优化 |
|
||||
| 现有功能受影响 | 高 | 中 | 充分测试,保持向后兼容 |
|
||||
| localStorage 不可用 | 低 | 低 | 降级到默认布局,不影响核心功能 |
|
||||
|
||||
## 八、验收标准
|
||||
|
||||
### 8.1 功能验收
|
||||
|
||||
- [ ] 可以拖动调整分组面板宽度
|
||||
- [ ] 可以拖动调整账号面板宽度
|
||||
- [ ] 可以拖动调整邮件列表面板宽度
|
||||
- [ ] 可以折叠/展开分组面板
|
||||
- [ ] 可以折叠/展开账号面板
|
||||
- [ ] 可以折叠/展开邮件列表面板
|
||||
- [ ] 可以一键恢复默认布局
|
||||
- [ ] 刷新页面后布局状态恢复
|
||||
- [ ] 拖动时有视觉反馈
|
||||
- [ ] 折叠/展开有平滑动画
|
||||
|
||||
### 8.2 性能验收
|
||||
|
||||
- [ ] 拖动时帧率 ≥ 60fps
|
||||
- [ ] 折叠/展开动画流畅
|
||||
- [ ] localStorage 数据 ≤ 1KB
|
||||
- [ ] 页面加载时间增加 ≤ 50ms
|
||||
|
||||
### 8.3 兼容性验收
|
||||
|
||||
- [ ] Chrome 90+ 正常工作
|
||||
- [ ] Edge 90+ 正常工作
|
||||
- [ ] Firefox 88+ 正常工作
|
||||
- [ ] Safari 14+ 正常工作
|
||||
|
||||
### 8.4 用户体验验收
|
||||
|
||||
- [ ] 拖动操作直观易懂
|
||||
- [ ] 折叠按钮位置合理
|
||||
- [ ] 视觉反馈清晰明确
|
||||
- [ ] 无明显的交互延迟
|
||||
|
||||
## 九、后续优化方向
|
||||
|
||||
### 9.1 短期优化(1-2 个月内)
|
||||
|
||||
1. **键盘快捷键支持**
|
||||
- `Ctrl/Cmd + B` 切换左侧面板
|
||||
- `Ctrl/Cmd + \` 切换邮件列表
|
||||
|
||||
2. **预设布局模板**
|
||||
- 紧凑模式(适合小屏幕)
|
||||
- 标准模式(默认)
|
||||
- 宽松模式(适合大屏幕)
|
||||
|
||||
3. **拖动排序**
|
||||
- 支持拖动调整面板顺序
|
||||
|
||||
### 9.2 中期优化(3-6 个月内)
|
||||
|
||||
1. **移动端适配**
|
||||
- 抽屉式侧边栏
|
||||
- 触摸手势支持
|
||||
|
||||
2. **多套布局方案**
|
||||
- 用户可以保存多套布局配置
|
||||
- 快速切换不同工作场景
|
||||
|
||||
3. **布局分享**
|
||||
- 导出布局配置
|
||||
- 导入他人的布局配置
|
||||
|
||||
## 十、附录
|
||||
|
||||
### 10.1 参考资料
|
||||
|
||||
- EnsoAI 项目:`E:\hushaokang\Data-code\outlookEmail\exeample\EnsoAI`
|
||||
- shadcn/ui Sidebar 组件文档
|
||||
- CSS Grid 布局指南
|
||||
- localStorage API 文档
|
||||
|
||||
### 10.2 术语表
|
||||
|
||||
| 术语 | 说明 |
|
||||
|------|------|
|
||||
| 面板 | 指分组面板、账号面板、邮件列表面板、邮件详情面板 |
|
||||
| Resizer | 拖动分隔条,用于调整面板宽度 |
|
||||
| 折叠 | 隐藏面板内容,宽度变为 0 或显示细条 |
|
||||
| 展开 | 显示面板内容,恢复到折叠前的宽度 |
|
||||
| 布局状态 | 包括面板宽度和折叠状态 |
|
||||
| 默认布局 | 初始的面板宽度和折叠状态 |
|
||||
|
||||
### 10.3 变更记录
|
||||
|
||||
| 版本 | 日期 | 变更内容 | 变更人 |
|
||||
|------|------|----------|--------|
|
||||
| v1.0 | 2026-02-26 | 初始版本 | 开发团队 |
|
||||
| v1.1 | 2026-02-26 | 根据审查报告修订:<br>1. 添加最小/最大宽度限制<br>2. 添加窗口尺寸自适应策略<br>3. 支持多用户布局隔离<br>4. 添加页面卸载时强制保存<br>5. 明确折叠时的拖动行为<br>6. 增强键盘可访问性支持 | 开发团队 |
|
||||
|
||||
### 10.4 审查报告摘要
|
||||
|
||||
**审查日期**: 2026-02-26
|
||||
**审查结果**: 通过(已修订)
|
||||
**文档质量**: 优秀 (A)
|
||||
|
||||
#### 已解决的问题
|
||||
|
||||
🔴 **严重问题**:
|
||||
1. ✅ **缺乏面板拖动的临界值约束** - 已添加明确的最小/最大宽度限制
|
||||
2. ✅ **浏览器窗口尺寸变化的响应策略不明确** - 已添加媒体查询和自动折叠逻辑
|
||||
|
||||
🟡 **中等问题**:
|
||||
3. ✅ **状态持久化缺乏用户隔离** - 存储键名改为 `outlook_layout_state_${userId}`
|
||||
4. ✅ **状态保存可能丢失** - 添加 `beforeunload` 事件监听,强制保存
|
||||
5. ✅ **折叠状态与拖动的逻辑冲突** - 明确折叠时隐藏 resizer
|
||||
|
||||
🟢 **低优先级问题**:
|
||||
6. ✅ **无障碍键盘控制不足** - 添加完整的键盘支持(方向键调整宽度)
|
||||
|
||||
#### 改进内容
|
||||
|
||||
1. **宽度限制系统**:
|
||||
- 分组面板:150px - 400px
|
||||
- 账号面板:180px - 500px
|
||||
- 邮件列表:280px - 600px
|
||||
- 邮件详情:最小 400px
|
||||
|
||||
2. **响应式断点**:
|
||||
- < 1200px:自动折叠分组面板
|
||||
- < 900px:自动折叠账号面板
|
||||
- < 700px:自动折叠邮件列表
|
||||
|
||||
3. **可访问性增强**:
|
||||
- Resizer 支持 Tab 聚焦
|
||||
- 支持方向键调整宽度(← / →)
|
||||
- 支持 Shift + 方向键快速调整
|
||||
- 完整的 ARIA 属性支持
|
||||
|
||||
4. **状态管理优化**:
|
||||
- 多用户隔离存储
|
||||
- 页面卸载时强制保存
|
||||
- 防抖机制优化
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
Binary file not shown.
File diff suppressed because it is too large.
Load diff
@@ -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 个提交
|
||||
```
|
||||
@@ -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,并依赖 **折叠指示器** 提供“展开入口”。
|
||||
- 当历史规则把面板整体移出屏幕时,折叠指示器作为面板子元素也一并移出,造成不可展开。
|
||||
|
||||
#### 修复方案
|
||||
|
||||
- 在布局系统初始化时,为 `<html>` 添加标记类:`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`(滚动条消失),折叠指示器高度与主容器一致
|
||||
@@ -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<br>2. 按下鼠标左键<br>3. 向右拖动 50px<br>4. 释放鼠标 |
|
||||
| **预期结果** | 1. resizer 显示 hover 效果(背景色变化)<br>2. 光标变为 col-resize<br>3. 面板宽度实时更新为 250px<br>4. 拖动流畅,无卡顿 |
|
||||
| **关联需求** | F1.1, F1.3 |
|
||||
|
||||
#### TC-F1-002-B: 拖动分组面板到最小宽度
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载,分组面板宽度为默认 200px |
|
||||
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 向左拖动 100px(超过最小宽度限制)<br>3. 释放鼠标 |
|
||||
| **预期结果** | 面板宽度停止在最小宽度 150px,不会继续缩小 |
|
||||
| **关联需求** | F1.2 |
|
||||
|
||||
#### TC-F1-003-B: 拖动分组面板到最大宽度
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载,分组面板宽度为默认 200px |
|
||||
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 向右拖动 300px(超过最大宽度限制)<br>3. 释放鼠标 |
|
||||
| **预期结果** | 面板宽度停止在最大宽度 400px,不会继续增大 |
|
||||
| **关联需求** | F1.2 |
|
||||
|
||||
#### TC-F1-004-B: 拖动时确保邮件详情面板至少 400px
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 窗口宽度 1200px,所有面板展开 |
|
||||
| **测试步骤** | 1. 拖动分组面板到最大宽度 400px<br>2. 拖动账号面板到最大宽度 500px<br>3. 尝试拖动邮件列表面板到最大宽度 600px |
|
||||
| **预期结果** | 邮件列表面板宽度被限制,确保邮件详情面板至少保持 400px |
|
||||
| **关联需求** | F1.2 |
|
||||
|
||||
#### TC-F1-005-N: 快速拖动导致鼠标移出窗口
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 鼠标按下 resizer<br>2. 快速向右拖动,鼠标移出浏览器窗口<br>3. 在窗口外释放鼠标<br>4. 鼠标移回窗口 |
|
||||
| **预期结果** | 1. 拖动正常结束<br>2. 光标恢复正常<br>3. 状态已保存 |
|
||||
| **关联需求** | F1.1 |
|
||||
|
||||
#### TC-F1-006-PF: 拖动帧率测试
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 使用 Performance API 监听帧率<br>2. 连续拖动 1 秒<br>3. 计算平均帧率 |
|
||||
| **预期结果** | 平均帧率 ≥ 60fps(允许 5fps 误差) |
|
||||
| **关联需求** | F1.4 |
|
||||
|
||||
### 4.3 F2 - 面板折叠/展开测试用例
|
||||
|
||||
#### TC-F2-001-P: 点击折叠按钮折叠面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 分组面板处于展开状态 |
|
||||
| **测试步骤** | 1. 点击分组面板右上角的折叠按钮 |
|
||||
| **预期结果** | 1. 面板宽度变为 0<br>2. 面板内容不可见<br>3. 显示折叠指示条(48px 宽)<br>4. 折叠动画流畅(200ms)<br>5. 按钮图标变为 →<br>6. aria-expanded 更新为 false |
|
||||
| **关联需求** | F2.1, F2.2, F2.3, F2.5 |
|
||||
|
||||
#### TC-F2-002-P: 点击折叠指示条展开面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 分组面板处于折叠状态 |
|
||||
| **测试步骤** | 1. 点击折叠指示条 |
|
||||
| **预期结果** | 1. 面板恢复到折叠前的宽度<br>2. 面板内容可见<br>3. 折叠指示条消失<br>4. 展开动画流畅(200ms)<br>5. 按钮图标变为 ←<br>6. aria-expanded 更新为 true |
|
||||
| **关联需求** | F2.1, F2.2, F2.3, F2.5 |
|
||||
|
||||
#### TC-F2-003-N: 尝试折叠所有面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 只有分组面板展开,其他面板已折叠 |
|
||||
| **测试步骤** | 1. 点击分组面板的折叠按钮 |
|
||||
| **预期结果** | 1. 显示提示信息:"至少需要保持一个面板展开"<br>2. 分组面板保持展开状态 |
|
||||
| **关联需求** | F2.4 |
|
||||
|
||||
#### TC-F2-004-P: 折叠后 resizer 不可见
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 分组面板处于展开状态 |
|
||||
| **测试步骤** | 1. 折叠分组面板<br>2. 检查 resizer 元素 |
|
||||
| **预期结果** | 1. resizer 不可见(display: none)<br>2. resizer 不可交互(pointer-events: none) |
|
||||
| **关联需求** | F2.6 |
|
||||
|
||||
#### TC-F2-005-N: 折叠状态下尝试拖动
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 分组面板处于折叠状态 |
|
||||
| **测试步骤** | 1. 尝试在折叠指示条位置拖动 |
|
||||
| **预期结果** | 无法拖动,必须先展开面板 |
|
||||
| **关联需求** | F2.6 |
|
||||
|
||||
### 4.4 F3 - 恢复默认布局测试用例
|
||||
|
||||
#### TC-F3-001-P: 恢复默认布局
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 用户已调整布局(宽度和折叠状态) |
|
||||
| **测试步骤** | 1. 点击顶部导航栏的"恢复默认布局"按钮<br>2. 在确认对话框中点击"确定" |
|
||||
| **预期结果** | 1. 分组面板宽度恢复为 200px<br>2. 账号面板宽度恢复为 260px<br>3. 邮件列表面板宽度恢复为 380px<br>4. 所有面板展开<br>5. localStorage 中的状态被清除<br>6. 恢复动画流畅(300ms) |
|
||||
| **关联需求** | F3.1, F3.2, F3.3, F3.4 |
|
||||
|
||||
#### TC-F3-002-N: 取消恢复默认布局
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 用户已调整布局 |
|
||||
| **测试步骤** | 1. 点击"恢复默认布局"按钮<br>2. 在确认对话框中点击"取消" |
|
||||
| **预期结果** | 1. 对话框关闭<br>2. 布局保持当前状态不变 |
|
||||
| **关联需求** | F3.2 |
|
||||
|
||||
### 4.5 F4 - 状态持久化测试用例
|
||||
|
||||
#### TC-F4-001-P: 拖动后保存状态
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 拖动分组面板到 250px<br>2. 等待 500ms(防抖时间)<br>3. 检查 localStorage |
|
||||
| **预期结果** | localStorage 中保值 "250px" |
|
||||
| **关联需求** | F4.1 |
|
||||
|
||||
#### TC-F4-002-P: 折叠后立即保存状态
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 折叠分组面板<br>2. 立即检查 localStorage(无需等待) |
|
||||
| **预期结果** | localStorage 中保存了折叠状态 collapsed: true |
|
||||
| **关联需求** | F4.2 |
|
||||
|
||||
#### TC-F4-003-P: 页面刷新后恢复状态
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 用户已调整布局并保存 |
|
||||
| **测试步骤** | 1. 刷新页面(F5)<br>2. 等待页面加载完成 |
|
||||
| **预期结果** | 1. 面板宽度恢复到调整后的值<br>2. 折叠状态恢复<br>3. 恢复过程无闪烁 |
|
||||
| **关联需求** | F4.3 |
|
||||
|
||||
#### TC-F4-004-P: 多用户布局隔离
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 系统支持多用户 |
|
||||
| **测试步骤** | 1. 用户 A 登录,调整布局<br>2. 用户 A 退出<br>3. 用户 B 登录<br>4. 检查布局状态 |
|
||||
| **预期结果** | 用户 B 看到的是默认布局,不受用户 A 的影响 |
|
||||
| **关联需求** | F4.4 |
|
||||
|
||||
#### TC-F4-005-P: 页面卸载时强制保存
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P0 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 拖动面板<br>2. 立即关闭浏览器标签页(不等待防抖)<br>3. 重新打开页面 |
|
||||
| **预期结果** | 布局状态已保存,最后一次调整未丢失 |
|
||||
| **关联需求** | F4.5 |
|
||||
|
||||
#### TC-F4-006-N: localStorage 不可用
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 浏览器禁用 localStorage 或隐私模式 |
|
||||
| **测试步骤** | 1. 加载页面<br>2. 调整布局<br>3. 刷新页面 |
|
||||
| **预期结果** | 1. 页面正常加载,使用默认布局<br>2. 调整布局功能正常<br>3. 刷新后恢复默认布局(无法持久化)<br>4. 控制台显示警告n| **关联需求** | F4.3 |
|
||||
|
||||
#### TC-F4-007-N: localStorage 数据损坏
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | localStorage 中存在损坏的数据 |
|
||||
| **测试步骤** | 1. 手动修改 localStorage 数据为无效 JSON<br>2. 刷新页面 |
|
||||
| **预期结果** | 1. 页面正常加载<br>2. 使用默认布局<br>3. 损坏的数据被清除<br>4. 控制台显示错误信息 |
|
||||
| **关联需求** | F4.3 |
|
||||
|
||||
### 4.6 F5 - 窗口尺寸适配测试用例
|
||||
|
||||
#### TC-F5-001-P: 窗口宽度 < 1200px 自动折叠分组面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 窗口宽度 > 1200px,所有面板展开 |
|
||||
| **测试步骤** | 1. 调整浏览器窗口宽度到 1100px<br>2. 等待 200ms(防抖时间) |
|
||||
| **预期结果** | 1. 分组面板自动折叠<br>2. 面板标记为 autoCollapsed<br>3. 其他面板保持展开 |
|
||||
| **关联需求** | F5.1 |
|
||||
|
||||
#### TC-F5-002-P: 窗口宽度 < 900px 自动折叠账号面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 窗口宽度 > 900px |
|
||||
| **测试步骤** | 1. 调整浏览器窗口宽度到 800px<br>2. 等待 200ms |
|
||||
| **预期结果** | 1. 分组面板和账号面板自动折叠<br>2. 邮件列表和详情面板保持展开 |
|
||||
| **关联需求** | F5.1 |
|
||||
|
||||
#### TC-F5-003-P: 窗口宽度 < 700px 自动折叠邮件列表面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 窗口宽度 > 700px |
|
||||
| **测试步骤** | 1. 调整浏览器窗口宽度到 600px<br>2. 等待 200ms |
|
||||
| **预期结果** | 1. 所有侧边栏面板自动折叠<br>2. 只显示邮件详情面板 |
|
||||
| **关联需求** | F5.1 |
|
||||
|
||||
#### TC-F5-004-P: 窗口放大后自动恢复面板
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 窗口宽度 < 1200px,分组面板自动折叠 |
|
||||
| **测试步骤** | 1. 调整浏览器窗口宽度到 1300px<br>2. 等待 200ms |
|
||||
| **预期结果** | 1. 分组面板自动展开<br>2. autoCd 标记被移除 |
|
||||
| **关联需求** | F5.2 |
|
||||
|
||||
#### TC-F5-005-N: 用户手动折叠的面板不自动恢复
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 窗口宽度 > 1200px |
|
||||
| **测试步骤** | 1. 用户手动折叠分组面板<br>2. 调整窗口宽度到 1100px 再恢复到 1300px |
|
||||
| **预期结果** | 分组面板保持折叠状态(因为是用户手动折叠) |
|
||||
| **关联需求** | F5.2 |
|
||||
|
||||
### 4.7 F6 - 键盘可访问性测试用例
|
||||
|
||||
#### TC-F6-001-P: Tab 键聚焦到 resizer
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | 页面已加载 |
|
||||
| **测试步骤** | 1. 按 Tab 键多次,直到聚焦到分组面板的 resizer |
|
||||
| **预期结果** | 1. resizer 获得焦点<br>2. 显示明显的视觉反馈(outline 或高亮边框) |
|
||||
| **关联需求** | F6.1 |
|
||||
|
||||
#### TC-F6-002-P: 方向键调整宽度
|
||||
|
||||
| 项目 | 内容 |
|
||||
|---------|
|
||||
| **优先级** | P1 |
|
||||
| **前置条件** | resizer 已聚焦,面板宽度 200px |
|
||||
| **测试步骤** | 1. 按右箭头键 5 次<br>2. 按左箭头键 3 次 |
|
||||
| **预期结果** | 1. 每次按右箭头,宽度增加 10px(最终 250px)<br>2. 每次按左箭头,宽度减少 10px(最终 220px)<br>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"<br>2. aria-orientation="vertical"<br>3. aria-label="调整[面板名称]宽度"<br>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%
|
||||
|
||||
---
|
||||
|
||||
**文档结束**
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Generated
+5002
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "outlook-email-layout-system",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest --config tests/layout-system/jest.config.js",
|
||||
"test:watch": "jest --config tests/layout-system/jest.config.js --watch",
|
||||
"test:coverage": "jest --config tests/layout-system/jest.config.js --coverage",
|
||||
"test:unit": "jest --config tests/layout-system/jest.config.js tests/layout-system/unit",
|
||||
"test:integration": "jest --config tests/layout-system/jest.config.js tests/layout-system/integration --collectCoverage=false"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.1.3",
|
||||
"jest-environment-jsdom": "^30.1.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 可调整布局系统(基础样式)
|
||||
*
|
||||
* 说明:
|
||||
* - 使用 CSS Grid + CSS 变量控制三列侧边栏宽度(分组/账号/邮件列表)与详情区域自适应。
|
||||
* - resizer / 折叠相关样式在此文件集中管理,避免污染现有 main.css。
|
||||
* - 该文件将被在 main.css 之后引入,以覆盖旧的 flex 固定宽度布局。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ===================== 面板默认宽度 ===================== */
|
||||
--group-panel-width: 200px;
|
||||
--account-panel-width: 260px;
|
||||
--email-list-panel-width: 380px;
|
||||
|
||||
/* ===================== 折叠相关宽度 ===================== */
|
||||
--panel-collapsed-width: 0px;
|
||||
--panel-collapsed-indicator-width: 48px;
|
||||
|
||||
/* ===================== 宽度限制(用于 Grid 与 JS 计算) ===================== */
|
||||
--group-panel-min-width: 150px;
|
||||
--group-panel-max-width: 400px;
|
||||
--account-panel-min-width: 180px;
|
||||
--account-panel-max-width: 500px;
|
||||
--email-list-panel-min-width: 280px;
|
||||
--email-list-panel-max-width: 600px;
|
||||
--email-detail-panel-min-width: 400px;
|
||||
|
||||
/* ===================== Grid 生效宽度(折叠时可被置为 0,保存宽度不丢失) ===================== */
|
||||
--group-panel-layout-width: var(--group-panel-width);
|
||||
--account-panel-layout-width: var(--account-panel-width);
|
||||
--email-list-panel-layout-width: var(--email-list-panel-width);
|
||||
--group-panel-layout-min-width: var(--group-panel-min-width);
|
||||
--account-panel-layout-min-width: var(--account-panel-min-width);
|
||||
--email-list-panel-layout-min-width: var(--email-list-panel-min-width);
|
||||
|
||||
/* ===================== 交互与动画 ===================== */
|
||||
--resizer-width: 4px;
|
||||
--panel-transition-duration: 200ms;
|
||||
--panel-transition-timing-function: ease;
|
||||
--collapsed-indicator-width: var(--panel-collapsed-indicator-width);
|
||||
}
|
||||
|
||||
/* ===================== Grid 布局(覆盖 main.css) ===================== */
|
||||
.main-container {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(var(--group-panel-layout-min-width), var(--group-panel-layout-width))
|
||||
minmax(var(--account-panel-layout-min-width), var(--account-panel-layout-width))
|
||||
minmax(var(--email-list-panel-layout-min-width), var(--email-list-panel-layout-width))
|
||||
minmax(var(--email-detail-panel-min-width), 1fr);
|
||||
grid-template-rows: 1fr;
|
||||
/* 防止折叠时面板内容溢出把 body 撑出滚动条(会导致“指示器高度异常/过长”观感) */
|
||||
overflow: hidden;
|
||||
transition: grid-template-columns var(--panel-transition-duration)
|
||||
var(--panel-transition-timing-function);
|
||||
will-change: grid-template-columns;
|
||||
}
|
||||
|
||||
/* ===================== 状态恢复时禁用动画(避免闪烁) ===================== */
|
||||
.layout-no-transition .main-container {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.layout-no-transition .resizable-panel > :not(.collapsed-indicator) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 让 Grid 子项允许收缩,避免内容撑破布局 */
|
||||
.main-container > * {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ===================== Resizable 面板基础样式 ===================== */
|
||||
.resizable-panel {
|
||||
position: relative;
|
||||
/* 默认层级:保持低于 modal 等全局浮层 */
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ===================== Resizer 样式 ===================== */
|
||||
.resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: var(--resizer-width);
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 20;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resizer:hover,
|
||||
.resizer:focus,
|
||||
.resizer.active {
|
||||
background-color: rgba(26, 26, 26, 0.1);
|
||||
box-shadow: inset 0 0 0 2px #1a1a1a;
|
||||
}
|
||||
|
||||
.resizer:focus-visible {
|
||||
outline: 2px solid #1a1a1a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===================== 折叠状态(基础) ===================== */
|
||||
.resizable-panel {
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* 折叠时仅隐藏面板内容,指示器仍需可见 */
|
||||
.resizable-panel > :not(.collapsed-indicator) {
|
||||
transition: opacity var(--panel-transition-duration)
|
||||
var(--panel-transition-timing-function);
|
||||
}
|
||||
|
||||
.resizable-panel.collapsed > :not(.collapsed-indicator) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
/*
|
||||
* 关键修复:
|
||||
* - 折叠时列宽为 0,若仅用 opacity 隐藏,面板内文本/控件会在 0 宽下强制换行,
|
||||
* 仍参与布局计算并可能把页面撑出滚动条,导致指示器“看起来过长”。
|
||||
* - 直接 display:none 让隐藏内容不参与布局与滚动高度计算。
|
||||
*/
|
||||
display: none;
|
||||
}
|
||||
|
||||
.resizable-panel.collapsed {
|
||||
overflow: visible;
|
||||
/*
|
||||
* 关键修复:
|
||||
* - 折叠后 Grid 列宽会变为 0,指示器需要“溢出”到右侧可见区域。
|
||||
* - 由于 Grid 子项的绘制顺序受 DOM 影响(后面的面板会盖住前面的面板),
|
||||
* 当多面板折叠时,指示器可能被邮件详情面板覆盖导致“消失且无法展开”。
|
||||
* - 提升折叠面板的 z-index,并强制其宽度为 0,确保指示器可见且不挡住右侧交互。
|
||||
*/
|
||||
z-index: 200;
|
||||
width: 0 !important;
|
||||
min-width: 0 !important;
|
||||
max-width: 0 !important;
|
||||
}
|
||||
|
||||
.resizable-panel.collapsed .resizer {
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===================== 折叠指示器(48px,竖排文字) ===================== */
|
||||
.collapsed-indicator {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: var(--collapsed-indicator-width);
|
||||
height: 100%;
|
||||
background-color: #fafafa;
|
||||
border: 1px solid #e5e5e5;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
z-index: 30;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: translateX(var(--collapsed-indicator-offset, 0px));
|
||||
}
|
||||
|
||||
.resizable-panel.collapsed .collapsed-indicator {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.collapsed-indicator:hover,
|
||||
.collapsed-indicator:focus {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.collapsed-indicator:focus-visible {
|
||||
outline: 2px solid #1a1a1a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.collapsed-indicator .indicator-text {
|
||||
writing-mode: vertical-rl;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 折叠按钮:先复用 main.css 的 .panel-action-btn 基础样式,仅补充可访问性样式 */
|
||||
.collapse-btn:focus-visible {
|
||||
outline: 2px solid #1a1a1a;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ===================== 拖动中全局反馈 ===================== */
|
||||
.layout-resizing,
|
||||
.layout-resizing * {
|
||||
cursor: col-resize !important;
|
||||
}
|
||||
|
||||
.layout-resizing {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ===================== F3:恢复默认布局(确认对话框细节) ===================== */
|
||||
#resetLayoutModal .text-muted {
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ===================== F3:恢复默认布局(按钮) ===================== */
|
||||
.reset-layout-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reset-layout-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 重置动画:临时将布局过渡时长提升到 300ms(FD F3.4) */
|
||||
.layout-resetting {
|
||||
--panel-transition-duration: 300ms;
|
||||
}
|
||||
+19
-35
@@ -129,7 +129,7 @@
|
||||
|
||||
/* 分组面板 */
|
||||
.group-panel {
|
||||
width: 200px;
|
||||
width: var(--group-panel-width, 200px);
|
||||
background-color: #fafafa;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
@@ -259,7 +259,7 @@
|
||||
|
||||
/* 邮箱账号面板 */
|
||||
.account-panel {
|
||||
width: 260px;
|
||||
width: var(--account-panel-width, 260px);
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
@@ -500,7 +500,7 @@
|
||||
|
||||
/* 邮件列表面板 */
|
||||
.email-list-panel {
|
||||
width: 380px;
|
||||
width: var(--email-list-panel-width, 380px);
|
||||
background-color: #ffffff;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
display: flex;
|
||||
@@ -1249,39 +1249,23 @@
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 1200px) {
|
||||
.group-panel {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.email-list-panel {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.group-panel {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.account-panel {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.email-list-panel {
|
||||
width: 280px;
|
||||
}
|
||||
}
|
||||
/* 响应式
|
||||
说明:可调整布局系统的断点折叠逻辑由 LayoutManager(JS)负责,
|
||||
此处移除旧的“固定宽度缩放”规则,避免与用户拖动/持久化状态冲突。 */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
.group-panel,
|
||||
.account-panel {
|
||||
/*
|
||||
* 移动端历史样式(侧栏滑入/滑出):
|
||||
* - 旧逻辑依赖 `.show` 类控制 left。
|
||||
* - 当前版本的可调整布局系统由 LayoutManager(JS)负责折叠/展开与指示器渲染;
|
||||
* 若继续在窄屏下把面板强制 `left:-280px`,会导致折叠指示器也被移出屏幕,
|
||||
* 出现“全部折叠后无法再展开”的严重交互问题。
|
||||
* - 因此仅在布局系统未启用时保留该历史规则,避免影响可调整布局系统。
|
||||
*/
|
||||
|
||||
html:not(.layout-system-enabled) .group-panel,
|
||||
html:not(.layout-system-enabled) .account-panel {
|
||||
position: fixed;
|
||||
left: -280px;
|
||||
top: 56px;
|
||||
@@ -1290,8 +1274,8 @@
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
.group-panel.show,
|
||||
.account-panel.show {
|
||||
html:not(.layout-system-enabled) .group-panel.show,
|
||||
html:not(.layout-system-enabled) .account-panel.show {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -462,18 +462,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 同步“邮件列表显示/隐藏”与可调整布局系统(Grid 列宽)
|
||||
function syncEmailListVisibility(visible) {
|
||||
const panel = document.getElementById('emailListPanel');
|
||||
if (!panel) return;
|
||||
|
||||
try {
|
||||
const lm = window.layoutManager;
|
||||
if (lm && typeof lm.applyCollapsedLayoutVars === 'function' && typeof lm.applyExpandedLayoutVars === 'function') {
|
||||
if (visible) {
|
||||
panel.classList.remove('hidden');
|
||||
|
||||
// 若面板处于折叠状态,先自动展开(不抢焦点/不保存),保持“移动端临时显示”语义
|
||||
if (panel.classList.contains('collapsed') && typeof lm.expandPanel === 'function') {
|
||||
lm.expandPanel('emails', true);
|
||||
}
|
||||
|
||||
lm.applyExpandedLayoutVars('emails');
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
lm.applyCollapsedLayoutVars('emails');
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
// 降级:忽略布局系统异常,不影响主业务
|
||||
console.warn('syncEmailListVisibility failed:', e);
|
||||
}
|
||||
|
||||
// 无布局系统时保持旧逻辑
|
||||
if (visible) {
|
||||
panel.classList.remove('hidden');
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换邮件列表显示
|
||||
function toggleEmailList() {
|
||||
const panel = document.getElementById('emailListPanel');
|
||||
const toggleText = document.getElementById('toggleListText');
|
||||
|
||||
isListVisible = !isListVisible;
|
||||
|
||||
if (isListVisible) {
|
||||
panel.classList.remove('hidden');
|
||||
syncEmailListVisibility(true);
|
||||
toggleText.textContent = '隐藏列表';
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
syncEmailListVisibility(false);
|
||||
toggleText.textContent = '显示列表';
|
||||
}
|
||||
}
|
||||
@@ -621,7 +656,7 @@
|
||||
|
||||
// 显示邮件列表(移动端)
|
||||
function showEmailList() {
|
||||
document.getElementById('emailListPanel').classList.remove('hidden');
|
||||
syncEmailListVisibility(true);
|
||||
isListVisible = true;
|
||||
document.getElementById('toggleListText').textContent = '隐藏列表';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 可调整布局系统 - 页面初始化入口
|
||||
*
|
||||
* 约束:
|
||||
* - 不能在 HTML 中内联 <script>(符合 tests/test_smoke_contract.py 的零构建约束)。
|
||||
* - 仅在浏览器环境运行;Jest 单测直接 require layout-manager.js,不会加载本文件。
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ==================== F3:恢复默认布局(UI 绑定) ====================
|
||||
// 说明:即使布局系统初始化失败,也要保证 onclick 不抛错(仅降级为关闭/无操作)。
|
||||
window.showResetLayoutModal = function showResetLayoutModal() {
|
||||
const modal = document.getElementById('resetLayoutModal');
|
||||
if (!modal) return;
|
||||
modal.classList.add('show');
|
||||
};
|
||||
|
||||
window.hideResetLayoutModal = function hideResetLayoutModal() {
|
||||
const modal = document.getElementById('resetLayoutModal');
|
||||
if (!modal) return;
|
||||
modal.classList.remove('show');
|
||||
};
|
||||
|
||||
window.confirmResetLayout = function confirmResetLayout() {
|
||||
try {
|
||||
if (window.layoutManager && typeof window.layoutManager.resetLayout === 'function') {
|
||||
window.layoutManager.resetLayout();
|
||||
}
|
||||
} finally {
|
||||
window.hideResetLayoutModal();
|
||||
}
|
||||
};
|
||||
|
||||
// ESC 关闭确认框(不影响主业务)
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e || e.key !== 'Escape') return;
|
||||
const modal = document.getElementById('resetLayoutModal');
|
||||
if (!modal || !modal.classList.contains('show')) return;
|
||||
window.hideResetLayoutModal();
|
||||
});
|
||||
|
||||
try {
|
||||
if (typeof window.LayoutManager !== 'function') return;
|
||||
|
||||
const layoutManager = new window.LayoutManager();
|
||||
|
||||
// 标记布局系统已启用:用于禁用 main.css 中的移动端历史侧栏滑入样式(避免折叠指示器被移出屏幕)
|
||||
// 放在 init() 之前,减少窄屏下的闪烁/错位窗口期。
|
||||
try {
|
||||
if (document && document.documentElement && document.documentElement.classList) {
|
||||
document.documentElement.classList.add('layout-system-enabled');
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
layoutManager.init();
|
||||
|
||||
// 暴露到全局,便于调试(后续状态管理/折叠等也复用同一实例)
|
||||
window.layoutManager = layoutManager;
|
||||
} catch (e) {
|
||||
// 初始化失败不应影响主业务功能
|
||||
console.warn('[LayoutManager] init failed:', e);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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 {
|
||||
if (!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();
|
||||
if (!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();
|
||||
if (!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();
|
||||
if (!storage) return;
|
||||
|
||||
const keys = [];
|
||||
try {
|
||||
for (let i = 0; i < storage.length; i += 1) {
|
||||
const k = storage.key(i);
|
||||
if (!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);
|
||||
if (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];
|
||||
if (!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) {
|
||||
if (!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 环境导出
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = StateManager;
|
||||
}
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
+40
-9
@@ -8,6 +8,8 @@
|
||||
<!-- DOMPurify for XSS protection -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.8/dist/purify.min.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
|
||||
<!-- 可调整布局系统:需在 main.css 之后引入以覆盖旧布局样式 -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
@@ -37,6 +39,14 @@
|
||||
<button class="navbar-btn" onclick="showRefreshModal()">全量刷新Token</button>
|
||||
<button class="navbar-btn" onclick="showExportModal()">导出邮箱</button>
|
||||
<button class="navbar-btn" onclick="showSettingsModal()">⚙️ 设置</button>
|
||||
<button class="navbar-btn reset-layout-btn" onclick="showResetLayoutModal()"
|
||||
aria-label="恢复默认布局" title="恢复默认布局">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="1 4 1 10 7 10"></polyline>
|
||||
<path d="M3.51 15a9 9 0 102.13-9.36L1 10"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="navbar-btn" onclick="logout()" style="color: #dc3545;">退出登录</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -44,18 +54,27 @@
|
||||
<!-- 主容器 -->
|
||||
<div class="main-container">
|
||||
<!-- 分组面板 -->
|
||||
<div class="group-panel" id="groupPanel">
|
||||
<div class="group-panel resizable-panel" id="groupPanel" data-panel="groups">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">分组</span>
|
||||
<div style="display: flex; align-items: center; gap: 4px;">
|
||||
<button class="panel-action-btn" onclick="showAddGroupModal()" title="添加分组">+</button>
|
||||
<button class="panel-action-btn collapse-btn" data-panel="groups" aria-label="折叠分组面板"
|
||||
aria-expanded="true" title="折叠分组面板">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-list" id="groupList">
|
||||
<!-- 分组列表将通过 JS 动态加载 -->
|
||||
</div>
|
||||
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整分组面板宽度"
|
||||
aria-valuenow="200" aria-valuemin="150" aria-valuemax="400" tabindex="0" data-resizer="groups">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱账号面板 -->
|
||||
<div class="account-panel" id="accountPanel">
|
||||
<div class="account-panel resizable-panel" id="accountPanel" data-panel="accounts">
|
||||
<div class="account-panel-header">
|
||||
<div class="account-panel-title" id="accountPanelTitle">
|
||||
<span class="group-color" id="currentGroupColor" style="background-color: #666"></span>
|
||||
@@ -71,6 +90,10 @@
|
||||
d="M8 2a.75.75 0 01.75.75v4.5h4.5a.75.75 0 010 1.5h-4.5v4.5a.75.75 0 01-1.5 0v-4.5h-4.5a.75.75 0 010-1.5h4.5v-4.5A.75.75 0 018 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="panel-action-btn collapse-btn" data-panel="accounts" aria-label="折叠账号面板"
|
||||
aria-expanded="true" title="折叠账号面板">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-container">
|
||||
@@ -120,15 +143,15 @@
|
||||
<div class="account-panel-footer">
|
||||
<button class="add-account-btn" onclick="showAddAccountModal()">+ 导入邮箱</button>
|
||||
</div>
|
||||
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整账号面板宽度"
|
||||
aria-valuenow="260" aria-valuemin="180" aria-valuemax="500" tabindex="0" data-resizer="accounts">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="content-area">
|
||||
<!-- 邮件列表面板 -->
|
||||
<div class="email-list-panel" id="emailListPanel">
|
||||
<div class="email-list-panel resizable-panel" id="emailListPanel" data-panel="emails">
|
||||
<div class="email-list-header">
|
||||
<div
|
||||
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px;">
|
||||
<div class="folder-tabs" id="folderTabs" style="display: none;">
|
||||
<button class="folder-tab active" data-folder="inbox" onclick="switchFolder('inbox')">
|
||||
📨 收件箱
|
||||
@@ -137,7 +160,13 @@
|
||||
⚠️ 垃圾邮件
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<button class="refresh-btn" onclick="refreshEmails()">获取邮件</button>
|
||||
<button class="panel-action-btn collapse-btn" data-panel="emails" aria-label="折叠邮件列表面板"
|
||||
aria-expanded="true" title="折叠邮件列表面板">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<h2 style="margin: 0; font-size: 16px; font-weight: 600;">
|
||||
@@ -162,9 +191,12 @@
|
||||
🗑️ 删除
|
||||
</button>
|
||||
</div>
|
||||
<div class="resizer" role="separator" aria-orientation="vertical" aria-label="调整邮件列表面板宽度"
|
||||
aria-valuenow="380" aria-valuemin="280" aria-valuemax="600" tabindex="0" data-resizer="emails">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件详情面板 -->
|
||||
<!-- 邮件详情面板(不可折叠) -->
|
||||
<div class="email-detail-panel">
|
||||
<div class="email-detail-toolbar" id="emailDetailToolbar" style="display:none;">
|
||||
<button class="back-btn" onclick="showEmailList()">← 返回</button>
|
||||
@@ -191,7 +223,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'partials/modals.html' %}
|
||||
|
||||
|
||||
@@ -667,6 +667,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 恢复默认布局确认模态框(可调整布局系统:F3) -->
|
||||
<div class="modal" id="resetLayoutModal" onclick="if(event.target === this) hideResetLayoutModal()">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>恢复默认布局</h3>
|
||||
<button class="modal-close" onclick="hideResetLayoutModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>确定要恢复到默认布局吗?</p>
|
||||
<p class="text-muted">当前的面板宽度和折叠状态将被重置。</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="hideResetLayoutModal()">取消</button>
|
||||
<button class="btn btn-primary" onclick="confirmResetLayout()">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 消息提示 -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
@@ -3,3 +3,10 @@
|
||||
<script src="{{ url_for('static', filename='js/features/temp_emails.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/features/accounts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/features/emails.js') }}"></script>
|
||||
|
||||
<!-- 可调整布局系统(阶段四:状态持久化) -->
|
||||
<script src="{{ url_for('static', filename='js/state-manager.js') }}"></script>
|
||||
|
||||
<!-- 可调整布局系统(阶段二/三:拖动调整 + 折叠) -->
|
||||
<script src="{{ url_for('static', filename='js/layout-manager.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/layout-bootstrap.js') }}"></script>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,289 @@
|
||||
# 布局系统测试运行指南
|
||||
|
||||
## 测试概述
|
||||
|
||||
本目录包含可调整布局系统的完整测试套件,包括单元测试、集成测试、性能测试和可访问性测试。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
tests/layout-system/
|
||||
├── jest.config.js # Jest 配置文件
|
||||
├── setup.js # 测试环境设置
|
||||
├── test-utils.js # 测试辅助工具
|
||||
├── README.md # 本文件
|
||||
├── unit/ # 单元测试
|
||||
│ ├── layout-manager.test.js
|
||||
│ └── state-manager.test.js
|
||||
├── integration/ # 集成测试
|
||||
│ ├── drag-resize.test.js
|
||||
│ ├── panel-collapse.test.js
|
||||
│ ├── reset-layout.test.js
|
||||
│ ├── state-error-handling.test.js
|
||||
│ ├── state-load.test.js
|
||||
│ ├── state-multi-user.test.js
|
||||
│ ├── state-save.test.js
|
||||
│ ├── window-adapt.test.js
|
||||
│ └── keyboard-resizer.test.js
|
||||
├── performance/ # 性能测试
|
||||
│ └── (待添加)
|
||||
└── accessibility/ # 可访问性测试
|
||||
└── (待添加)
|
||||
```
|
||||
|
||||
## 前置要求
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
# 安装 Jest 和相关依赖
|
||||
npm install --save-dev jest @jest/globals jest-environment-jsdom
|
||||
|
||||
# 或使用 yarn
|
||||
yarn add --dev jest @jest/globals jest-environment-jsdom
|
||||
```
|
||||
|
||||
### 配置 package.json
|
||||
|
||||
在项目根目录的 `package.json` 中添加测试脚本:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest --config tests/layout-system/jest.config.js",
|
||||
"test:watch": "jest --config tests/layout-system/jest.config.js --watch",
|
||||
"test:coverage": "jest --config tests/layout-system/jest.config.js --coverage",
|
||||
"test:unit": "jest --config tests/layout-system/jest.config.js tests/layout-system/unit",
|
||||
"test:integration": "jest --config tests/layout-system/jest.config.js tests/layout-system/integration"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 运行所有测试
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### 运行单元测试
|
||||
|
||||
```bash
|
||||
npm run test:unit
|
||||
```
|
||||
|
||||
### 运行集成测试
|
||||
|
||||
```bash
|
||||
npm run test:integration
|
||||
```
|
||||
|
||||
### 运行测试并生成覆盖率报告
|
||||
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
覆盖率报告将生成在 `tests/layout-system/coverage/` 目录。
|
||||
|
||||
### 监听模式(开发时使用)
|
||||
|
||||
```bash
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
## 测试用例说明
|
||||
|
||||
### 单元测试
|
||||
|
||||
#### StateManager 测试 (`unit/state-manager.test.js`)
|
||||
|
||||
测试状态持久化功能:
|
||||
|
||||
- ✅ TC-F4-001-P: 保存有效状态
|
||||
- ✅ TC-F4-003-P: 加载有效状态
|
||||
- ✅ TC-F4-004-P: 多用户布局隔离
|
||||
- ✅ TC-F4-006-N: localStorage 不可用
|
||||
- ✅ TC-F4-007-N: localStorage 数据损坏
|
||||
- ✅ 版本迁移(v1.0 → v1.1)
|
||||
- ✅ 数据验证
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx jest tests/layout-system/unit/state-manager.test.js
|
||||
```
|
||||
|
||||
#### LayoutManager 测试 (`unit/layout-manager.test.js`)
|
||||
|
||||
测试布局管理核心功能:
|
||||
|
||||
- ✅ TC-F1-001-P: 计算拖动后的宽度
|
||||
- ✅ TC-F1-002-B: 限制在最小宽度
|
||||
- ✅ TC-F1-003-B: 限制在最大宽度
|
||||
- ✅ TC-F2-001-P: 折叠面板
|
||||
- ✅ TC-F2-002-P: 展开面板
|
||||
- ✅ 更新 CSS 变量
|
||||
- ✅ 切换面板状态
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx jest tests/layout-system/unit/layout-manager.test.js
|
||||
```
|
||||
|
||||
### 集成测试
|
||||
|
||||
#### 拖动调整宽度测试 (`integration/drag-resize.test.js`)
|
||||
|
||||
测试完整的拖动流程:
|
||||
|
||||
- ✅ TC-F1-001-P: 完整的拖动流程
|
||||
- ✅ TC-F1- 确保邮件详情面板至少 400px
|
||||
- ✅ TC-F1-005-N: 快速拖动导致鼠标移出窗口
|
||||
- ✅ TC-F1-006-PF: 拖动帧率测试(≥ 55fps)
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx jest tests/layout-system/integration/drag-resize.test.js
|
||||
```
|
||||
|
||||
#### 面板折叠测试 (`integration/panel-collapse.test.js`)
|
||||
|
||||
测试完整的折叠/展开流程:
|
||||
|
||||
- ✅ TC-F2-001-P: 点击折叠按钮折叠面板
|
||||
- ✅ TC-F2-002-P: 点击折叠指示条展开面板
|
||||
- ✅ TC-F2-003-N: 尝试折叠所有面板
|
||||
- ✅ TC-F2-004-P: 折叠后 resizer 不可见
|
||||
- ✅ TC-F2-005-N: 折叠状态下尝试拖动
|
||||
- ✅ 折叠动画性能测试(200ms ± 50ms)
|
||||
|
||||
**运行单个测试文件:**
|
||||
|
||||
```bash
|
||||
npx jest tests/layout-system/integration/panel-collapse.test.js
|
||||
```
|
||||
|
||||
## 测试覆盖率目标
|
||||
|
||||
根据 TD 文档的要求:
|
||||
|
||||
- **代码覆盖率**: > 80%
|
||||
- 分支覆盖率: > 80%
|
||||
- 函数覆盖率: > 80%
|
||||
- 行覆盖率: > 80%
|
||||
- 语句覆盖率: > 80%
|
||||
|
||||
- **功能覆盖率**: 100%
|
||||
- F1 - 拖动调整宽度: 100%
|
||||
- F2 - 面板折叠展开: 100%
|
||||
- F3 - 恢复默认布局: 100%
|
||||
- F4 - 状态持久化: 100%
|
||||
- F5 - 窗口尺寸适配: 100%
|
||||
- F6 - 键盘可访问性: 100%
|
||||
|
||||
## 测试数据
|
||||
|
||||
测试使用的数据定义在 `test-utils.js` 中:
|
||||
|
||||
- **用户 ID**: `test-user`, `user-a`, `user-b`, `guest`
|
||||
- **面板宽度**:
|
||||
- 分组面板: 150px (最小) ~ 400px (最大)
|
||||
- 账号面板: 180px (最小) ~ 500px (最大)
|
||||
- 邮件列表: 280px (最小) ~ 600px (最大)
|
||||
- **窗口尺寸**: 1200px, 900px, 700px (断点)
|
||||
|
||||
## 调试测试
|
||||
|
||||
### 运行单个测试用例
|
||||
|
||||
```bash
|
||||
npx jest -t "TC-F1-001-P"
|
||||
```
|
||||
|
||||
### 查看详细输出
|
||||
|
||||
```bash
|
||||
npx jest --verbose
|
||||
```
|
||||
|
||||
### 调试模式
|
||||
|
||||
```bash
|
||||
node --inspect-brk node_modules/.bin/jest --runInBand
|
||||
```
|
||||
|
||||
然后在 Chrome 中打开 `chrome://inspect` 进行调试。
|
||||
|
||||
## 持续集成
|
||||
|
||||
### GitHub Actions 配置示例
|
||||
|
||||
```yaml
|
||||
name: 布局系统测试
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
files: ./tests/layout-system/coverage/lcov.info
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 测试失败:localStorage is not defined
|
||||
|
||||
**A**: 确保 `setup.js` 文件正确配置了 localStorage mock。
|
||||
|
||||
### Q: 测试失败:requestAnimationFrame is not defined
|
||||
|
||||
**A**: 确保 `setup.js` 文件正确配置了 requestAnimationFrame mock。
|
||||
|
||||
### Q: 覆盖率不足 80%
|
||||
|
||||
**A**: 检查是否有未测试的代码分支,添加更多的边界测试用例。
|
||||
|
||||
### Q: 测试运行很慢
|
||||
|
||||
**A**: 使用 `--maxWorkers=4` 参数限制并发数,或使用 `--runInBand` 串行运行。
|
||||
|
||||
## 下一步计划
|
||||
|
||||
- [ ] 添加性能测试(帧率、内存占用)
|
||||
- [ ] 添加可访问性测试(axe-core)
|
||||
- [ ] 添加 E2E 测试(Playwright)
|
||||
- [ ] 添加视觉回归测试
|
||||
- [ ] 集成到 CI/CD 流程
|
||||
|
||||
## 参考文档
|
||||
|
||||
- [TD - 可调整布局系统](../../docs/TD/TD-可调整布局系统.md)
|
||||
- [TDD - 可调整布局系统](.././TDD-可调整布局系统.md)
|
||||
- [Jest 文档](https://jestjs.io/docs/getting-started)
|
||||
- [Testing Library](https://testing-library.com/)
|
||||
|
||||
## 联系方式
|
||||
|
||||
如有问题,请联系测试团队或在 GitHub Issues 中提出。
|
||||
@@ -0,0 +1,656 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<coverage generated="1772174032201" clover="3.2.0">
|
||||
<project timestamp="1772174032201" name="All files">
|
||||
<metrics statements="644" coveredstatements="620" conditionals="539" coveredconditionals="445" methods="88" coveredmethods="88" elements="1271" coveredelements="1153" complexity="0" loc="644" ncloc="644" packages="1" files="2" classes="2"/>
|
||||
<file name="layout-manager.js" path="E:\hushaokang\Data-code\outlookEmail\static\js\layout-manager.js">
|
||||
<metrics statements="527" coveredstatements="509" conditionals="449" coveredconditionals="368" methods="75" coveredmethods="75"/>
|
||||
<line num="17" count="10" type="stmt"/>
|
||||
<line num="21" count="10" type="stmt"/>
|
||||
<line num="23" count="10" type="stmt"/>
|
||||
<line num="29" count="10" type="stmt"/>
|
||||
<line num="43" count="10" type="stmt"/>
|
||||
<line num="52" count="10" type="stmt"/>
|
||||
<line num="64" count="80" type="stmt"/>
|
||||
<line num="67" count="80" type="stmt"/>
|
||||
<line num="70" count="80" type="stmt"/>
|
||||
<line num="73" count="80" type="stmt"/>
|
||||
<line num="76" count="80" type="stmt"/>
|
||||
<line num="79" count="80" type="stmt"/>
|
||||
<line num="82" count="80" type="stmt"/>
|
||||
<line num="85" count="80" type="stmt"/>
|
||||
<line num="88" count="80" type="stmt"/>
|
||||
<line num="91" count="80" type="stmt"/>
|
||||
<line num="94" count="80" type="stmt"/>
|
||||
<line num="97" count="80" type="stmt"/>
|
||||
<line num="100" count="80" type="stmt"/>
|
||||
<line num="103" count="80" type="stmt"/>
|
||||
<line num="106" count="80" type="stmt"/>
|
||||
<line num="107" count="80" type="stmt"/>
|
||||
<line num="108" count="80" type="stmt"/>
|
||||
<line num="109" count="80" type="stmt"/>
|
||||
<line num="110" count="80" type="stmt"/>
|
||||
<line num="111" count="80" type="stmt"/>
|
||||
<line num="112" count="80" type="stmt"/>
|
||||
<line num="113" count="80" type="stmt"/>
|
||||
<line num="114" count="80" type="stmt"/>
|
||||
<line num="117" count="80" type="stmt"/>
|
||||
<line num="119" count="80" type="stmt"/>
|
||||
<line num="121" count="80" type="stmt"/>
|
||||
<line num="129" count="80" type="stmt"/>
|
||||
<line num="132" count="80" type="stmt"/>
|
||||
<line num="133" count="80" type="stmt"/>
|
||||
<line num="136" count="240" type="stmt"/>
|
||||
<line num="140" count="80" type="stmt"/>
|
||||
<line num="141" count="80" type="stmt"/>
|
||||
<line num="142" count="240" type="stmt"/>
|
||||
<line num="146" count="80" type="stmt"/>
|
||||
<line num="147" count="240" type="stmt"/>
|
||||
<line num="148" count="240" type="stmt"/>
|
||||
<line num="151" count="240" type="stmt"/>
|
||||
<line num="152" count="240" type="stmt"/>
|
||||
<line num="156" count="80" type="stmt"/>
|
||||
<line num="157" count="240" type="stmt"/>
|
||||
<line num="158" count="240" type="stmt"/>
|
||||
<line num="162" count="80" type="stmt"/>
|
||||
<line num="165" count="80" type="stmt"/>
|
||||
<line num="166" count="80" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="168" count="79" type="stmt"/>
|
||||
<line num="171" count="79" type="stmt"/>
|
||||
<line num="175" count="80" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="176" count="80" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="177" count="70" type="stmt"/>
|
||||
<line num="179" count="80" type="stmt"/>
|
||||
<line num="180" count="80" type="stmt"/>
|
||||
<line num="184" count="80" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="185" count="80" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="186" count="70" type="stmt"/>
|
||||
<line num="188" count="80" type="stmt"/>
|
||||
<line num="189" count="80" type="stmt"/>
|
||||
<line num="193" count="80" type="stmt"/>
|
||||
<line num="194" count="80" type="stmt"/>
|
||||
<line num="197" count="80" type="stmt"/>
|
||||
<line num="205" count="80" type="stmt"/>
|
||||
<line num="206" count="80" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="207" count="70" type="stmt"/>
|
||||
<line num="211" count="10" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="213" count="10" type="stmt"/>
|
||||
<line num="214" count="10" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="215" count="10" type="stmt"/>
|
||||
<line num="219" count="0" type="stmt"/>
|
||||
<line num="221" count="0" type="stmt"/>
|
||||
<line num="226" count="0" type="stmt"/>
|
||||
<line num="236" count="160" type="stmt"/>
|
||||
<line num="238" count="160" type="stmt"/>
|
||||
<line num="240" count="25" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="241" count="25" type="stmt"/>
|
||||
<line num="242" count="13" type="stmt"/>
|
||||
<line num="243" count="13" type="stmt"/>
|
||||
<line num="247" count="26" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="248" count="1" type="stmt"/>
|
||||
<line num="249" count="1" type="stmt"/>
|
||||
<line num="251" count="26" type="stmt"/>
|
||||
<line num="254" count="1" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="255" count="1" type="stmt"/>
|
||||
<line num="256" count="1" type="stmt"/>
|
||||
<line num="268" count="30" type="stmt"/>
|
||||
<line num="269" count="30" type="stmt"/>
|
||||
<line num="270" count="90" type="stmt"/>
|
||||
<line num="273" count="90" type="stmt"/>
|
||||
<line num="275" count="90" type="cond" truecount="2" falsecount="1"/>
|
||||
<line num="278" count="90" type="stmt"/>
|
||||
<line num="281" count="90" type="stmt"/>
|
||||
<line num="282" count="90" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="286" count="90" type="cond" truecount="3" falsecount="0"/>
|
||||
<line num="290" count="90" type="stmt"/>
|
||||
<line num="293" count="30" type="stmt"/>
|
||||
<line num="316" count="111" type="stmt"/>
|
||||
<line num="317" count="111" type="stmt"/>
|
||||
<line num="318" count="444" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="319" count="3" type="stmt"/>
|
||||
<line num="320" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="324" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="326" count="111" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="327" count="111" type="stmt"/>
|
||||
<line num="328" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="331" count="111" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="334" count="111" type="stmt"/>
|
||||
<line num="335" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="339" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="342" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="343" count="111" type="stmt"/>
|
||||
<line num="344" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="348" count="111" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="349" count="111" type="stmt"/>
|
||||
<line num="350" count="111" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="355" count="108" type="stmt"/>
|
||||
<line num="363" count="30" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="365" count="30" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="366" count="30" type="stmt"/>
|
||||
<line num="367" count="30" type="stmt"/>
|
||||
<line num="375" count="26" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="378" count="26" type="stmt"/>
|
||||
<line num="391" count="80" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="393" count="80" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="394" count="80" type="stmt"/>
|
||||
<line num="395" count="80" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="397" count="1" type="stmt"/>
|
||||
<line num="398" count="1" type="stmt"/>
|
||||
<line num="401" count="1" type="stmt"/>
|
||||
<line num="402" count="1" type="stmt"/>
|
||||
<line num="405" count="1" type="stmt"/>
|
||||
<line num="414" count="81" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="417" count="81" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="418" count="0" type="stmt"/>
|
||||
<line num="419" count="0" type="stmt"/>
|
||||
<line num="422" count="81" type="stmt"/>
|
||||
<line num="423" count="81" type="stmt"/>
|
||||
<line num="424" count="81" type="stmt"/>
|
||||
<line num="426" count="81" type="stmt"/>
|
||||
<line num="427" count="67" type="stmt"/>
|
||||
<line num="428" count="67" type="stmt"/>
|
||||
<line num="434" count="81" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="435" count="81" type="stmt"/>
|
||||
<line num="437" count="0" type="stmt"/>
|
||||
<line num="448" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="451" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="452" count="0" type="stmt"/>
|
||||
<line num="453" count="0" type="stmt"/>
|
||||
<line num="456" count="1" type="stmt"/>
|
||||
<line num="457" count="1" type="stmt"/>
|
||||
<line num="458" count="1" type="stmt"/>
|
||||
<line num="461" count="1" type="stmt"/>
|
||||
<line num="462" count="1" type="stmt"/>
|
||||
<line num="463" count="1" type="stmt"/>
|
||||
<line num="483" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="484" count="1" type="stmt"/>
|
||||
<line num="487" count="1" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="488" count="1" type="stmt"/>
|
||||
<line num="489" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="490" count="1" type="stmt"/>
|
||||
<line num="496" count="1" type="stmt"/>
|
||||
<line num="498" count="1" type="stmt"/>
|
||||
<line num="499" count="1" type="stmt"/>
|
||||
<line num="500" count="3" type="stmt"/>
|
||||
<line num="501" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="504" count="3" type="stmt"/>
|
||||
<line num="505" count="3" type="stmt"/>
|
||||
<line num="508" count="3" type="stmt"/>
|
||||
<line num="511" count="3" type="stmt"/>
|
||||
<line num="515" count="1" type="stmt"/>
|
||||
<line num="516" count="1" type="stmt"/>
|
||||
<line num="519" count="1" type="stmt"/>
|
||||
<line num="527" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="528" count="1" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="530" count="1" type="stmt"/>
|
||||
<line num="531" count="3" type="stmt"/>
|
||||
<line num="532" count="3" type="stmt"/>
|
||||
<line num="533" count="3" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="536" count="3" type="stmt"/>
|
||||
<line num="537" count="3" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="538" count="3" type="stmt"/>
|
||||
<line num="539" count="3" type="stmt"/>
|
||||
<line num="540" count="3" type="stmt"/>
|
||||
<line num="541" count="3" type="stmt"/>
|
||||
<line num="545" count="3" type="stmt"/>
|
||||
<line num="548" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="549" count="1" type="stmt"/>
|
||||
<line num="550" count="1" type="stmt"/>
|
||||
<line num="552" count="2" type="stmt"/>
|
||||
<line num="553" count="2" type="stmt"/>
|
||||
<line num="564" count="6" type="stmt"/>
|
||||
<line num="565" count="6" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="567" count="6" type="stmt"/>
|
||||
<line num="568" count="6" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="570" count="6" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="571" count="1" type="stmt"/>
|
||||
<line num="572" count="1" type="stmt"/>
|
||||
<line num="573" count="1" type="stmt"/>
|
||||
<line num="574" count="1" type="stmt"/>
|
||||
<line num="575" count="1" type="stmt"/>
|
||||
<line num="578" count="5" type="stmt"/>
|
||||
<line num="579" count="5" type="stmt"/>
|
||||
<line num="580" count="5" type="stmt"/>
|
||||
<line num="581" count="5" type="stmt"/>
|
||||
<line num="590" count="847" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="592" count="322" type="stmt"/>
|
||||
<line num="594" count="267" type="stmt"/>
|
||||
<line num="596" count="257" type="stmt"/>
|
||||
<line num="598" count="1" type="stmt"/>
|
||||
<line num="607" count="8" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="608" count="8" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="609" count="8" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="611" count="6" type="stmt"/>
|
||||
<line num="612" count="6" type="stmt"/>
|
||||
<line num="620" count="81" type="stmt"/>
|
||||
<line num="622" count="81" type="stmt"/>
|
||||
<line num="623" count="243" type="stmt"/>
|
||||
<line num="624" count="243" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="626" count="243" type="stmt"/>
|
||||
<line num="629" count="243" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="630" count="240" type="stmt"/>
|
||||
<line num="631" count="240" type="stmt"/>
|
||||
<line num="632" count="240" type="stmt"/>
|
||||
<line num="633" count="240" type="stmt"/>
|
||||
<line num="634" count="240" type="stmt"/>
|
||||
<line num="635" count="240" type="stmt"/>
|
||||
<line num="639" count="240" type="stmt"/>
|
||||
<line num="644" count="240" type="stmt"/>
|
||||
<line num="645" count="240" type="stmt"/>
|
||||
<line num="646" count="240" type="stmt"/>
|
||||
<line num="647" count="240" type="stmt"/>
|
||||
<line num="649" count="240" type="stmt"/>
|
||||
<line num="652" count="243" type="stmt"/>
|
||||
<line num="653" count="243" type="stmt"/>
|
||||
<line num="654" count="243" type="stmt"/>
|
||||
<line num="655" count="243" type="stmt"/>
|
||||
<line num="657" count="243" type="stmt"/>
|
||||
<line num="666" count="5" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="667" count="5" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="668" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="670" count="3" type="stmt"/>
|
||||
<line num="671" count="3" type="stmt"/>
|
||||
<line num="679" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="680" count="2" type="stmt"/>
|
||||
<line num="681" count="2" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="683" count="1" type="stmt"/>
|
||||
<line num="684" count="1" type="stmt"/>
|
||||
<line num="695" count="138" type="stmt"/>
|
||||
<line num="696" count="138" type="stmt"/>
|
||||
<line num="698" count="138" type="stmt"/>
|
||||
<line num="699" count="414" type="stmt"/>
|
||||
<line num="700" count="414" type="stmt"/>
|
||||
<line num="701" count="414" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="703" count="414" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="704" count="63" type="stmt"/>
|
||||
<line num="705" count="63" type="stmt"/>
|
||||
<line num="707" count="351" type="stmt"/>
|
||||
<line num="717" count="138" type="stmt"/>
|
||||
<line num="720" count="138" type="stmt"/>
|
||||
<line num="721" count="138" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="730" count="311" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="732" count="139" type="stmt"/>
|
||||
<line num="734" count="86" type="stmt"/>
|
||||
<line num="736" count="85" type="stmt"/>
|
||||
<line num="738" count="1" type="stmt"/>
|
||||
<line num="748" count="53" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="750" count="48" type="stmt"/>
|
||||
<line num="752" count="2" type="stmt"/>
|
||||
<line num="754" count="2" type="stmt"/>
|
||||
<line num="756" count="1" type="stmt"/>
|
||||
<line num="766" count="55" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="767" count="53" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="769" count="52" type="stmt"/>
|
||||
<line num="770" count="52" type="stmt"/>
|
||||
<line num="771" count="52" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="773" count="51" type="stmt"/>
|
||||
<line num="776" count="51" type="stmt"/>
|
||||
<line num="777" count="51" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="778" count="50" type="stmt"/>
|
||||
<line num="782" count="51" type="stmt"/>
|
||||
<line num="783" count="51" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="784" count="51" type="stmt"/>
|
||||
<line num="796" count="82" type="stmt"/>
|
||||
<line num="797" count="246" type="stmt"/>
|
||||
<line num="798" count="246" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="800" count="246" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="801" count="2" type="stmt"/>
|
||||
<line num="803" count="244" type="stmt"/>
|
||||
<line num="814" count="258" type="stmt"/>
|
||||
<line num="815" count="258" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="817" count="258" type="stmt"/>
|
||||
<line num="818" count="258" type="stmt"/>
|
||||
<line num="819" count="258" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="820" count="258" type="stmt"/>
|
||||
<line num="828" count="258" type="stmt"/>
|
||||
<line num="829" count="258" type="stmt"/>
|
||||
<line num="830" count="258" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="832" count="258" type="stmt"/>
|
||||
<line num="833" count="258" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="834" count="258" type="stmt"/>
|
||||
<line num="836" count="258" type="stmt"/>
|
||||
<line num="847" count="44" type="stmt"/>
|
||||
<line num="848" count="44" type="stmt"/>
|
||||
<line num="849" count="44" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="851" count="44" type="stmt"/>
|
||||
<line num="852" count="44" type="stmt"/>
|
||||
<line num="862" count="114" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="868" count="1" type="stmt"/>
|
||||
<line num="872" count="113" type="stmt"/>
|
||||
<line num="885" count="46" type="stmt"/>
|
||||
<line num="886" count="46" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="889" count="44" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="890" count="18" type="stmt"/>
|
||||
<line num="891" count="18" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="892" count="2" type="stmt"/>
|
||||
<line num="893" count="2" type="stmt"/>
|
||||
<line num="898" count="42" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="899" count="26" type="stmt"/>
|
||||
<line num="901" count="16" type="stmt"/>
|
||||
<line num="904" count="42" type="stmt"/>
|
||||
<line num="905" count="42" type="stmt"/>
|
||||
<line num="906" count="42" type="stmt"/>
|
||||
<line num="908" count="42" type="stmt"/>
|
||||
<line num="909" count="42" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="910" count="42" type="stmt"/>
|
||||
<line num="911" count="42" type="stmt"/>
|
||||
<line num="915" count="42" type="stmt"/>
|
||||
<line num="919" count="42" type="stmt"/>
|
||||
<line num="923" count="42" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="924" count="16" type="stmt"/>
|
||||
<line num="925" count="16" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="926" count="16" type="stmt"/>
|
||||
<line num="931" count="42" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="932" count="16" type="stmt"/>
|
||||
<line num="941" count="16" type="stmt"/>
|
||||
<line num="942" count="16" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="945" count="14" type="stmt"/>
|
||||
<line num="947" count="14" type="stmt"/>
|
||||
<line num="948" count="14" type="stmt"/>
|
||||
<line num="949" count="14" type="stmt"/>
|
||||
<line num="951" count="14" type="stmt"/>
|
||||
<line num="952" count="14" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="953" count="14" type="stmt"/>
|
||||
<line num="954" count="14" type="stmt"/>
|
||||
<line num="958" count="14" type="stmt"/>
|
||||
<line num="962" count="14" type="stmt"/>
|
||||
<line num="964" count="14" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="965" count="9" type="stmt"/>
|
||||
<line num="970" count="14" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="971" count="9" type="stmt"/>
|
||||
<line num="981" count="21" type="stmt"/>
|
||||
<line num="982" count="21" type="stmt"/>
|
||||
<line num="983" count="63" type="stmt"/>
|
||||
<line num="984" count="63" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="985" count="63" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="986" count="56" type="stmt"/>
|
||||
<line num="989" count="21" type="stmt"/>
|
||||
<line num="998" count="3" type="stmt"/>
|
||||
<line num="999" count="3" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1000" count="3" type="stmt"/>
|
||||
<line num="1001" count="3" type="stmt"/>
|
||||
<line num="1007" count="0" type="stmt"/>
|
||||
<line num="1008" count="0" type="cond" truecount="0" falsecount="4"/>
|
||||
<line num="1009" count="0" type="stmt"/>
|
||||
<line num="1021" count="12" type="stmt"/>
|
||||
<line num="1022" count="12" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1024" count="10" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1025" count="2" type="stmt"/>
|
||||
<line num="1027" count="8" type="stmt"/>
|
||||
<line num="1035" count="9" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1036" count="0" type="stmt"/>
|
||||
<line num="1037" count="0" type="stmt"/>
|
||||
<line num="1039" count="9" type="stmt"/>
|
||||
<line num="1052" count="89" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1054" count="89" type="stmt"/>
|
||||
<line num="1055" count="89" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1057" count="89" type="stmt"/>
|
||||
<line num="1058" count="89" type="stmt"/>
|
||||
<line num="1059" count="89" type="stmt"/>
|
||||
<line num="1061" count="89" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1062" count="6" type="stmt"/>
|
||||
<line num="1064" count="83" type="stmt"/>
|
||||
<line num="1067" count="89" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1068" count="4" type="stmt"/>
|
||||
<line num="1070" count="85" type="stmt"/>
|
||||
<line num="1073" count="89" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1074" count="3" type="stmt"/>
|
||||
<line num="1076" count="86" type="stmt"/>
|
||||
<line num="1085" count="13" type="stmt"/>
|
||||
<line num="1086" count="13" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1089" count="13" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1092" count="12" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1094" count="11" type="stmt"/>
|
||||
<line num="1102" count="254" type="stmt"/>
|
||||
<line num="1103" count="254" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1105" count="254" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1107" count="253" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1108" count="253" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1110" count="5" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1111" count="5" type="stmt"/>
|
||||
<line num="1120" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1122" count="4" type="stmt"/>
|
||||
<line num="1123" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1125" count="4" type="stmt"/>
|
||||
<line num="1126" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1127" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1129" count="4" type="stmt"/>
|
||||
<line num="1130" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1131" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1133" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1134" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1136" count="4" type="stmt"/>
|
||||
<line num="1138" count="4" type="stmt"/>
|
||||
<line num="1139" count="4" type="stmt"/>
|
||||
<line num="1140" count="4" type="stmt"/>
|
||||
<line num="1143" count="4" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1144" count="4" type="stmt"/>
|
||||
<line num="1154" count="16" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="1156" count="15" type="stmt"/>
|
||||
<line num="1157" count="15" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1158" count="15" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1160" count="14" type="stmt"/>
|
||||
<line num="1161" count="14" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1163" count="13" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1165" count="11" type="stmt"/>
|
||||
<line num="1168" count="11" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1169" count="11" type="stmt"/>
|
||||
<line num="1172" count="11" type="stmt"/>
|
||||
<line num="1173" count="11" type="stmt"/>
|
||||
<line num="1174" count="11" type="stmt"/>
|
||||
<line num="1175" count="11" type="stmt"/>
|
||||
<line num="1176" count="11" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1177" count="11" type="stmt"/>
|
||||
<line num="1179" count="11" type="stmt"/>
|
||||
<line num="1180" count="11" type="stmt"/>
|
||||
<line num="1181" count="11" type="stmt"/>
|
||||
<line num="1183" count="11" type="stmt"/>
|
||||
<line num="1184" count="11" type="stmt"/>
|
||||
<line num="1186" count="11" type="stmt"/>
|
||||
<line num="1187" count="11" type="stmt"/>
|
||||
<line num="1195" count="40" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="1197" count="39" type="stmt"/>
|
||||
<line num="1199" count="39" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1200" count="38" type="stmt"/>
|
||||
<line num="1201" count="30" type="stmt"/>
|
||||
<line num="1202" count="30" type="cond" truecount="4" falsecount="1"/>
|
||||
<line num="1204" count="30" type="stmt"/>
|
||||
<line num="1205" count="30" type="stmt"/>
|
||||
<line num="1211" count="30" type="stmt"/>
|
||||
<line num="1214" count="0" type="stmt"/>
|
||||
<line num="1216" count="0" type="stmt"/>
|
||||
<line num="1220" count="0" type="stmt"/>
|
||||
<line num="1229" count="12" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1232" count="11" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="1233" count="8" type="stmt"/>
|
||||
<line num="1234" count="8" type="stmt"/>
|
||||
<line num="1236" count="8" type="stmt"/>
|
||||
<line num="1237" count="8" type="stmt"/>
|
||||
<line num="1243" count="8" type="stmt"/>
|
||||
<line num="1245" count="0" type="stmt"/>
|
||||
<line num="1247" count="0" type="stmt"/>
|
||||
<line num="1254" count="11" type="stmt"/>
|
||||
<line num="1255" count="11" type="stmt"/>
|
||||
<line num="1256" count="11" type="stmt"/>
|
||||
<line num="1258" count="11" type="stmt"/>
|
||||
<line num="1259" count="11" type="stmt"/>
|
||||
<line num="1260" count="11" type="stmt"/>
|
||||
<line num="1261" count="11" type="stmt"/>
|
||||
<line num="1264" count="11" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1265" count="11" type="stmt"/>
|
||||
<line num="1267" count="11" type="stmt"/>
|
||||
<line num="1268" count="11" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1269" count="11" type="stmt"/>
|
||||
<line num="1273" count="11" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1274" count="11" type="stmt"/>
|
||||
<line num="1287" count="46" type="stmt"/>
|
||||
<line num="1288" count="46" type="stmt"/>
|
||||
<line num="1290" count="46" type="stmt"/>
|
||||
<line num="1291" count="46" type="stmt"/>
|
||||
<line num="1294" count="46" type="stmt"/>
|
||||
<line num="1297" count="46" type="stmt"/>
|
||||
<line num="1298" count="46" type="stmt"/>
|
||||
<line num="1299" count="46" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1300" count="46" type="stmt"/>
|
||||
<line num="1301" count="46" type="stmt"/>
|
||||
<line num="1302" count="46" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1303" count="46" type="stmt"/>
|
||||
<line num="1304" count="46" type="stmt"/>
|
||||
<line num="1307" count="46" type="stmt"/>
|
||||
<line num="1316" count="49" type="stmt"/>
|
||||
<line num="1317" count="49" type="stmt"/>
|
||||
<line num="1318" count="147" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1319" count="98" type="stmt"/>
|
||||
<line num="1320" count="98" type="stmt"/>
|
||||
<line num="1322" count="49" type="stmt"/>
|
||||
<line num="1330" count="48" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1331" count="47" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1334" count="47" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="1335" count="1" type="stmt"/>
|
||||
<line num="1338" count="47" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1339" count="46" type="stmt"/>
|
||||
<line num="1341" count="1" type="stmt"/>
|
||||
<line num="1349" count="46" type="stmt"/>
|
||||
<line num="1352" count="46" type="stmt"/>
|
||||
<line num="1353" count="46" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1363" count="116" type="stmt"/>
|
||||
<line num="1365" count="116" type="cond" truecount="3" falsecount="0"/>
|
||||
<line num="1368" count="116" type="stmt"/>
|
||||
<line num="1369" count="116" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1371" count="3" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1372" count="2" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="1375" count="2" type="cond" truecount="5" falsecount="0"/>
|
||||
<line num="1376" count="1" type="stmt"/>
|
||||
<line num="1378" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1379" count="1" type="stmt"/>
|
||||
<line num="1383" count="1" type="stmt"/>
|
||||
<line num="1392" count="657" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="1393" count="470" type="stmt"/>
|
||||
<line num="1394" count="470" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1395" count="468" type="stmt"/>
|
||||
<line num="1396" count="468" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="1401" count="10" type="stmt"/>
|
||||
<line num="1404" count="10" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="1405" count="10" type="stmt"/>
|
||||
</file>
|
||||
<file name="state-manager.js" path="E:\hushaokang\Data-code\outlookEmail\static\js\state-manager.js">
|
||||
<metrics statements="117" coveredstatements="111" conditionals="90" coveredconditionals="77" methods="13" coveredmethods="13"/>
|
||||
<line num="14" count="11" type="stmt"/>
|
||||
<line num="17" count="11" type="stmt"/>
|
||||
<line num="18" count="11" type="stmt"/>
|
||||
<line num="26" count="127" type="stmt"/>
|
||||
<line num="27" count="127" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="29" count="127" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="31" count="1" type="stmt"/>
|
||||
<line num="41" count="132" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="42" count="132" type="stmt"/>
|
||||
<line num="52" count="35" type="stmt"/>
|
||||
<line num="53" count="35" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="54" count="1" type="stmt"/>
|
||||
<line num="56" count="1" type="stmt"/>
|
||||
<line num="60" count="1" type="stmt"/>
|
||||
<line num="63" count="34" type="stmt"/>
|
||||
<line num="70" count="34" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="71" count="1" type="stmt"/>
|
||||
<line num="73" count="1" type="stmt"/>
|
||||
<line num="77" count="1" type="stmt"/>
|
||||
<line num="80" count="33" type="stmt"/>
|
||||
<line num="81" count="33" type="stmt"/>
|
||||
<line num="83" count="33" type="stmt"/>
|
||||
<line num="84" count="33" type="stmt"/>
|
||||
<line num="85" count="31" type="stmt"/>
|
||||
<line num="88" count="2" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="89" count="1" type="stmt"/>
|
||||
<line num="91" count="1" type="stmt"/>
|
||||
<line num="95" count="1" type="stmt"/>
|
||||
<line num="96" count="1" type="stmt"/>
|
||||
<line num="97" count="1" type="stmt"/>
|
||||
<line num="98" count="1" type="stmt"/>
|
||||
<line num="100" count="0" type="stmt"/>
|
||||
<line num="102" count="0" type="stmt"/>
|
||||
<line num="106" count="0" type="stmt"/>
|
||||
<line num="111" count="1" type="stmt"/>
|
||||
<line num="113" count="1" type="stmt"/>
|
||||
<line num="117" count="1" type="stmt"/>
|
||||
<line num="127" count="86" type="stmt"/>
|
||||
<line num="128" count="86" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="129" count="0" type="stmt"/>
|
||||
<line num="131" count="0" type="stmt"/>
|
||||
<line num="135" count="0" type="stmt"/>
|
||||
<line num="138" count="86" type="stmt"/>
|
||||
<line num="140" count="86" type="stmt"/>
|
||||
<line num="141" count="86" type="stmt"/>
|
||||
<line num="142" count="86" type="stmt"/>
|
||||
<line num="144" count="3" type="stmt"/>
|
||||
<line num="146" count="3" type="stmt"/>
|
||||
<line num="150" count="3" type="stmt"/>
|
||||
<line num="153" count="83" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="155" count="6" type="stmt"/>
|
||||
<line num="156" count="6" type="stmt"/>
|
||||
<line num="157" count="6" type="stmt"/>
|
||||
<line num="160" count="2" type="stmt"/>
|
||||
<line num="161" count="2" type="stmt"/>
|
||||
<line num="165" count="2" type="stmt"/>
|
||||
<line num="167" count="2" type="stmt"/>
|
||||
<line num="171" count="2" type="stmt"/>
|
||||
<line num="174" count="4" type="stmt"/>
|
||||
<line num="175" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="176" count="1" type="stmt"/>
|
||||
<line num="177" count="1" type="stmt"/>
|
||||
<line num="181" count="1" type="stmt"/>
|
||||
<line num="183" count="1" type="stmt"/>
|
||||
<line num="187" count="1" type="stmt"/>
|
||||
<line num="190" count="3" type="stmt"/>
|
||||
<line num="199" count="5" type="stmt"/>
|
||||
<line num="200" count="5" type="stmt"/>
|
||||
<line num="201" count="5" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="202" count="5" type="stmt"/>
|
||||
<line num="203" count="5" type="stmt"/>
|
||||
<line num="215" count="8" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="216" count="7" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="217" count="7" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="218" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="219" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="221" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="230" count="1" type="stmt"/>
|
||||
<line num="231" count="1" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="233" count="1" type="stmt"/>
|
||||
<line num="234" count="1" type="stmt"/>
|
||||
<line num="235" count="1" type="stmt"/>
|
||||
<line num="236" count="4" type="stmt"/>
|
||||
<line num="237" count="4" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="238" count="4" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="239" count="3" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="240" count="2" type="stmt"/>
|
||||
<line num="247" count="1" type="stmt"/>
|
||||
<line num="248" count="2" type="stmt"/>
|
||||
<line num="249" count="2" type="stmt"/>
|
||||
<line num="250" count="2" type="stmt"/>
|
||||
<line num="251" count="2" type="cond" truecount="1" falsecount="1"/>
|
||||
<line num="252" count="2" type="stmt"/>
|
||||
<line num="253" count="1" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="256" count="1" type="stmt"/>
|
||||
<line num="258" count="2" type="stmt"/>
|
||||
<line num="261" count="1" type="stmt"/>
|
||||
<line num="263" count="1" type="stmt"/>
|
||||
<line num="264" count="2" type="stmt"/>
|
||||
<line num="265" count="2" type="stmt"/>
|
||||
<line num="278" count="43" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="279" count="42" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="281" count="41" type="stmt"/>
|
||||
<line num="282" count="41" type="stmt"/>
|
||||
<line num="283" count="115" type="stmt"/>
|
||||
<line num="284" count="115" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="285" count="115" type="cond" truecount="4" falsecount="0"/>
|
||||
<line num="286" count="112" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="289" count="37" type="stmt"/>
|
||||
<line num="298" count="6" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="301" count="6" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="302" count="1" type="stmt"/>
|
||||
<line num="306" count="5" type="cond" truecount="2" falsecount="0"/>
|
||||
<line num="309" count="1" type="stmt"/>
|
||||
<line num="314" count="11" type="stmt"/>
|
||||
<line num="317" count="11" type="cond" truecount="3" falsecount="1"/>
|
||||
<line num="318" count="11" type="stmt"/>
|
||||
</file>
|
||||
</project>
|
||||
</coverage>
|
||||
@@ -0,0 +1,3 @@
|
||||
{"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\layout-manager.js": {"path":"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\layout-manager.js","statementMap":{"0":{"start":{"line":17,"column":0},"end":{"line":1407,"column":56}},"1":{"start":{"line":21,"column":35},"end":{"line":21,"column":39}},"2":{"start":{"line":23,"column":35},"end":{"line":23,"column":39}},"3":{"start":{"line":29,"column":30},"end":{"line":33,"column":3}},"4":{"start":{"line":43,"column":37},"end":{"line":47,"column":3}},"5":{"start":{"line":52,"column":41},"end":{"line":56,"column":3}},"6":{"start":{"line":64,"column":6},"end":{"line":64,"column":30}},"7":{"start":{"line":67,"column":6},"end":{"line":67,"column":28}},"8":{"start":{"line":70,"column":6},"end":{"line":70,"column":30}},"9":{"start":{"line":73,"column":6},"end":{"line":73,"column":32}},"10":{"start":{"line":76,"column":6},"end":{"line":76,"column":34}},"11":{"start":{"line":79,"column":6},"end":{"line":79,"column":31}},"12":{"start":{"line":82,"column":6},"end":{"line":82,"column":32}},"13":{"start":{"line":85,"column":6},"end":{"line":85,"column":31}},"14":{"start":{"line":88,"column":6},"end":{"line":88,"column":33}},"15":{"start":{"line":91,"column":6},"end":{"line":91,"column":35}},"16":{"start":{"line":94,"column":6},"end":{"line":94,"column":22}},"17":{"start":{"line":97,"column":6},"end":{"line":97,"column":26}},"18":{"start":{"line":100,"column":6},"end":{"line":100,"column":21}},"19":{"start":{"line":103,"column":6},"end":{"line":103,"column":30}},"20":{"start":{"line":106,"column":6},"end":{"line":106,"column":56}},"21":{"start":{"line":107,"column":6},"end":{"line":107,"column":46}},"22":{"start":{"line":108,"column":6},"end":{"line":108,"column":54}},"23":{"start":{"line":109,"column":6},"end":{"line":109,"column":68}},"24":{"start":{"line":110,"column":6},"end":{"line":110,"column":64}},"25":{"start":{"line":111,"column":6},"end":{"line":111,"column":68}},"26":{"start":{"line":112,"column":6},"end":{"line":112,"column":58}},"27":{"start":{"line":113,"column":6},"end":{"line":113,"column":64}},"28":{"start":{"line":114,"column":6},"end":{"line":114,"column":60}},"29":{"start":{"line":117,"column":6},"end":{"line":117,"column":52}},"30":{"start":{"line":119,"column":6},"end":{"line":119,"column":70}},"31":{"start":{"line":119,"column":47},"end":{"line":119,"column":63}},"32":{"start":{"line":121,"column":6},"end":{"line":121,"column":87}},"33":{"start":{"line":121,"column":55},"end":{"line":121,"column":80}},"34":{"start":{"line":129,"column":6},"end":{"line":129,"column":65}},"35":{"start":{"line":132,"column":6},"end":{"line":132,"column":26}},"36":{"start":{"line":133,"column":6},"end":{"line":137,"column":11}},"37":{"start":{"line":136,"column":10},"end":{"line":136,"column":54}},"38":{"start":{"line":140,"column":6},"end":{"line":140,"column":28}},"39":{"start":{"line":141,"column":6},"end":{"line":143,"column":9}},"40":{"start":{"line":142,"column":8},"end":{"line":142,"column":60}},"41":{"start":{"line":146,"column":6},"end":{"line":153,"column":9}},"42":{"start":{"line":147,"column":8},"end":{"line":147,"column":70}},"43":{"start":{"line":148,"column":8},"end":{"line":148,"column":67}},"44":{"start":{"line":151,"column":8},"end":{"line":151,"column":71}},"45":{"start":{"line":152,"column":8},"end":{"line":152,"column":68}},"46":{"start":{"line":156,"column":6},"end":{"line":159,"column":9}},"47":{"start":{"line":157,"column":8},"end":{"line":157,"column":64}},"48":{"start":{"line":158,"column":8},"end":{"line":158,"column":61}},"49":{"start":{"line":162,"column":6},"end":{"line":162,"column":39}},"50":{"start":{"line":165,"column":21},"end":{"line":165,"column":37}},"51":{"start":{"line":166,"column":6},"end":{"line":172,"column":7}},"52":{"start":{"line":168,"column":8},"end":{"line":168,"column":39}},"53":{"start":{"line":171,"column":8},"end":{"line":171,"column":47}},"54":{"start":{"line":175,"column":6},"end":{"line":181,"column":7}},"55":{"start":{"line":176,"column":8},"end":{"line":178,"column":9}},"56":{"start":{"line":177,"column":10},"end":{"line":177,"column":81}},"57":{"start":{"line":179,"column":8},"end":{"line":179,"column":58}},"58":{"start":{"line":180,"column":8},"end":{"line":180,"column":76}},"59":{"start":{"line":184,"column":6},"end":{"line":190,"column":7}},"60":{"start":{"line":185,"column":8},"end":{"line":187,"column":9}},"61":{"start":{"line":186,"column":10},"end":{"line":186,"column":75}},"62":{"start":{"line":188,"column":8},"end":{"line":188,"column":58}},"63":{"start":{"line":189,"column":8},"end":{"line":189,"column":70}},"64":{"start":{"line":193,"column":6},"end":{"line":195,"column":9}},"65":{"start":{"line":194,"column":8},"end":{"line":194,"column":34}},"66":{"start":{"line":197,"column":6},"end":{"line":197,"column":18}},"67":{"start":{"line":205,"column":6},"end":{"line":225,"column":7}},"68":{"start":{"line":206,"column":8},"end":{"line":208,"column":9}},"69":{"start":{"line":207,"column":10},"end"Line truncated
|
||||
,"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\state-manager.js": {"path":"E:\\hushaokang\\Data-code\\outlookEmail\\static\\js\\state-manager.js","statementMap":{"0":{"start":{"line":14,"column":0},"end":{"line":320,"column":56}},"1":{"start":{"line":17,"column":26},"end":{"line":17,"column":31}},"2":{"start":{"line":18,"column":29},"end":{"line":18,"column":52}},"3":{"start":{"line":26,"column":6},"end":{"line":32,"column":7}},"4":{"start":{"line":27,"column":8},"end":{"line":27,"column":33}},"5":{"start":{"line":27,"column":21},"end":{"line":27,"column":33}},"6":{"start":{"line":29,"column":8},"end":{"line":29,"column":43}},"7":{"start":{"line":31,"column":8},"end":{"line":31,"column":20}},"8":{"start":{"line":41,"column":25},"end":{"line":41,"column":90}},"9":{"start":{"line":42,"column":6},"end":{"line":42,"column":50}},"10":{"start":{"line":52,"column":22},"end":{"line":52,"column":39}},"11":{"start":{"line":53,"column":6},"end":{"line":61,"column":7}},"12":{"start":{"line":54,"column":8},"end":{"line":59,"column":9}},"13":{"start":{"line":56,"column":10},"end":{"line":56,"column":90}},"14":{"start":{"line":60,"column":8},"end":{"line":60,"column":21}},"15":{"start":{"line":63,"column":22},"end":{"line":68,"column":7}},"16":{"start":{"line":70,"column":6},"end":{"line":78,"column":7}},"17":{"start":{"line":71,"column":8},"end":{"line":76,"column":9}},"18":{"start":{"line":73,"column":10},"end":{"line":73,"column":68}},"19":{"start":{"line":77,"column":8},"end":{"line":77,"column":21}},"20":{"start":{"line":80,"column":18},"end":{"line":80,"column":44}},"21":{"start":{"line":81,"column":19},"end":{"line":81,"column":42}},"22":{"start":{"line":83,"column":6},"end":{"line":118,"column":7}},"23":{"start":{"line":84,"column":8},"end":{"line":84,"column":35}},"24":{"start":{"line":85,"column":8},"end":{"line":85,"column":20}},"25":{"start":{"line":88,"column":8},"end":{"line":108,"column":9}},"26":{"start":{"line":89,"column":10},"end":{"line":94,"column":11}},"27":{"start":{"line":91,"column":12},"end":{"line":91,"column":92}},"28":{"start":{"line":95,"column":10},"end":{"line":107,"column":11}},"29":{"start":{"line":96,"column":12},"end":{"line":96,"column":37}},"30":{"start":{"line":97,"column":12},"end":{"line":97,"column":39}},"31":{"start":{"line":98,"column":12},"end":{"line":98,"column":24}},"32":{"start":{"line":100,"column":12},"end":{"line":105,"column":13}},"33":{"start":{"line":102,"column":14},"end":{"line":102,"column":68}},"34":{"start":{"line":106,"column":12},"end":{"line":106,"column":25}},"35":{"start":{"line":111,"column":8},"end":{"line":116,"column":9}},"36":{"start":{"line":113,"column":10},"end":{"line":113,"column":57}},"37":{"start":{"line":117,"column":8},"end":{"line":117,"column":21}},"38":{"start":{"line":127,"column":22},"end":{"line":127,"column":39}},"39":{"start":{"line":128,"column":6},"end":{"line":136,"column":7}},"40":{"start":{"line":129,"column":8},"end":{"line":134,"column":9}},"41":{"start":{"line":131,"column":10},"end":{"line":131,"column":90}},"42":{"start":{"line":135,"column":8},"end":{"line":135,"column":20}},"43":{"start":{"line":138,"column":18},"end":{"line":138,"column":44}},"44":{"start":{"line":140,"column":16},"end":{"line":140,"column":20}},"45":{"start":{"line":141,"column":6},"end":{"line":151,"column":7}},"46":{"start":{"line":142,"column":8},"end":{"line":142,"column":35}},"47":{"start":{"line":144,"column":8},"end":{"line":149,"column":9}},"48":{"start":{"line":146,"column":10},"end":{"line":146,"column":73}},"49":{"start":{"line":150,"column":8},"end":{"line":150,"column":20}},"50":{"start":{"line":153,"column":6},"end":{"line":153,"column":28}},"51":{"start":{"line":153,"column":16},"end":{"line":153,"column":28}},"52":{"start":{"line":155,"column":19},"end":{"line":155,"column":23}},"53":{"start":{"line":156,"column":6},"end":{"line":172,"column":7}},"54":{"start":{"line":157,"column":8},"end":{"line":157,"column":33}},"55":{"start":{"line":160,"column":8},"end":{"line":164,"column":9}},"56":{"start":{"line":161,"column":10},"end":{"line":161,"column":29}},"57":{"start":{"line":165,"column":8},"end":{"line":170,"column":9}},"58":{"start":{"line":167,"column":10},"end":{"line":167,"column":73}},"59":{"start":{"line":171,"column":8},"end":{"line":171,"column":20}},"60":{"start":{"line":174,"column":23},"end":{"line":174,"column":43}},"61":{"start":{"line":175,"column":6},"end":{"line":188,"column":7}},"62":{"start":{"line":176,"column":8},"end":{"line":180,"column":9}},"63":{"start":{"line":177,"column":10},"end":{"line":177,"column":29}},"64":{"start":{"line":181,"column":8},"end":{"line":186,"column":9}},"65":{"start":{"line":183,"column":10},"end":{"line":183,"column":88}},"66":{"start":{"line":187,"column":8},"end":{"line":187,"column":20}},"67":{"start":{"line":190,"column":6},"end":{"line":190,"column":22}},"68":{"start":{"line":199,"column":6},"end":{"line":206,"column":7}},"69":{"start":{"line":200,"column":24},"end":{"line":200,Line truncated
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
body, html {
|
||||
margin:0; padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica Neue, Helvetica, Arial;
|
||||
font-size: 14px;
|
||||
color:#333;
|
||||
}
|
||||
.small { font-size: 12px; }
|
||||
*, *:after, *:before {
|
||||
-webkit-box-sizing:border-box;
|
||||
-moz-box-sizing:border-box;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 0;}
|
||||
h2 { font-size: 14px; }
|
||||
pre {
|
||||
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-moz-tab-size: 2;
|
||||
-o-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
a { color:#0074D9; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.strong { font-weight: bold; }
|
||||
.space-top1 { padding: 10px 0 0 0; }
|
||||
.pad2y { padding: 20px 0; }
|
||||
.pad1y { padding: 10px 0; }
|
||||
.pad2x { padding: 0 20px; }
|
||||
.pad2 { padding: 20px; }
|
||||
.pad1 { padding: 10px; }
|
||||
.space-left2 { padding-left:55px; }
|
||||
.space-right2 { padding-right:20px; }
|
||||
.center { text-align:center; }
|
||||
.clearfix { display:block; }
|
||||
.clearfix:after {
|
||||
content:'';
|
||||
display:block;
|
||||
height:0;
|
||||
clear:both;
|
||||
visibility:hidden;
|
||||
}
|
||||
.fl { float: left; }
|
||||
@media only screen and (max-width:640px) {
|
||||
.col3 { width:100%; max-width:100%; }
|
||||
.hide-mobile { display:none!important; }
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: #7f7f7f;
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
.quiet a { opacity: 0.7; }
|
||||
|
||||
.fraction {
|
||||
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #E8E8E8;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.path a:link, div.path a:visited { color: #333; }
|
||||
table.coverage {
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.coverage td {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.coverage td.line-count {
|
||||
text-align: right;
|
||||
padding: 0 5px 0 20px;
|
||||
}
|
||||
table.coverage td.line-coverage {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
min-width:20px;
|
||||
}
|
||||
|
||||
table.coverage td span.cline-any {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.missing-if-branch {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #333;
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.skip-if-branch {
|
||||
display: none;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #ccc;
|
||||
color: white;
|
||||
}
|
||||
.missing-if-branch .typ, .skip-if-branch .typ {
|
||||
color: inherit !important;
|
||||
}
|
||||
.coverage-summary {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.coverage-summary tr { border-bottom: 1px solid #bbb; }
|
||||
.keyline-all { border: 1px solid #ddd; }
|
||||
.coverage-summary td, .coverage-summary th { padding: 10px; }
|
||||
.coverage-summary tbody { border: 1px solid #bbb; }
|
||||
.coverage-summary td { border-right: 1px solid #bbb; }
|
||||
.coverage-summary td:last-child { border-right: none; }
|
||||
.coverage-summary th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage-summary th.file { border-right: none !important; }
|
||||
.coverage-summary th.pct { }
|
||||
.coverage-summary th.pic,
|
||||
.coverage-summary th.abs,
|
||||
.coverage-summary td.pct,
|
||||
.coverage-summary td.abs { text-align: right; }
|
||||
.coverage-summary td.file { white-space: nowrap; }
|
||||
.coverage-summary td.pic { min-width: 120px !important; }
|
||||
.coverage-summary tfoot td { }
|
||||
|
||||
.coverage-summary .sorter {
|
||||
height: 10px;
|
||||
width: 7px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
|
||||
}
|
||||
.coverage-summary .sorted .sorter {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
.coverage-summary .sorted-desc .sorter {
|
||||
background-position: 0 -10px;
|
||||
}
|
||||
.status-line { height: 10px; }
|
||||
/* yellow */
|
||||
.cbranch-no { background: yellow !important; color: #111; }
|
||||
/* dark red */
|
||||
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
|
||||
.low .chart { border:1px solid #C21F39 }
|
||||
.highlighted,
|
||||
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
|
||||
background: #C21F39 !important;
|
||||
}
|
||||
/* medium red */
|
||||
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
|
||||
/* light red */
|
||||
.low, .cline-no { background:#FCE1E5 }
|
||||
/* light green */
|
||||
.high, .cline-yes { background:rgb(230,245,208) }
|
||||
/* medium green */
|
||||
.cstat-yes { background:rgb(161,215,106) }
|
||||
/* dark green */
|
||||
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
|
||||
.high .chart { border:1px solid rgb(77,146,33) }
|
||||
/* dark yellow (gold) */
|
||||
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
|
||||
.medium .chart { border:1px solid #f9cd0b; }
|
||||
/* light yellow */
|
||||
.medium { background: #fff4c2; }
|
||||
|
||||
.cstat-skip { background: #ddd; color: #111; }
|
||||
.fstat-skip { background: #ddd; color: #111 !important; }
|
||||
.cbranch-skip { background: #ddd !important; color: #111; }
|
||||
|
||||
span.cline-neutral { background: #eaeaea; }
|
||||
|
||||
.coverage-summary td.empty {
|
||||
opacity: .5;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cover-fill, .cover-empty {
|
||||
display:inline-block;
|
||||
height: 12px;
|
||||
}
|
||||
.chart {
|
||||
line-height: 0;
|
||||
}
|
||||
.cover-empty {
|
||||
background: white;
|
||||
}
|
||||
.cover-full {
|
||||
border-right: none !important;
|
||||
}
|
||||
pre.prettyprint {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.com { color: #999 !important; }
|
||||
.ignore-none { color: #999; font-weight: normal; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -48px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 48px;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
var jumpToCode = (function init() {
|
||||
// Classes of code we would like to highlight in the file view
|
||||
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
|
||||
|
||||
// Elements to highlight in the file listing view
|
||||
var fileListingElements = ['td.pct.low'];
|
||||
|
||||
// We don't want to select elements that are direct descendants of another match
|
||||
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
|
||||
|
||||
// Selector that finds elements on the page to which we can jump
|
||||
var selector =
|
||||
fileListingElements.join(', ') +
|
||||
', ' +
|
||||
notSelector +
|
||||
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
|
||||
|
||||
// The NodeList of matching elements
|
||||
var missingCoverageElements = document.querySelectorAll(selector);
|
||||
|
||||
var currentIndex;
|
||||
|
||||
function toggleClass(index) {
|
||||
missingCoverageElements
|
||||
.item(currentIndex)
|
||||
.classList.remove('highlighted');
|
||||
missingCoverageElements.item(index).classList.add('highlighted');
|
||||
}
|
||||
|
||||
function makeCurrent(index) {
|
||||
toggleClass(index);
|
||||
currentIndex = index;
|
||||
missingCoverageElements.item(index).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
var nextIndex = 0;
|
||||
if (typeof currentIndex !== 'number' || currentIndex === 0) {
|
||||
nextIndex = missingCoverageElements.length - 1;
|
||||
} else if (missingCoverageElements.length > 1) {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
var nextIndex = 0;
|
||||
|
||||
if (
|
||||
typeof currentIndex === 'number' &&
|
||||
currentIndex < missingCoverageElements.length - 1
|
||||
) {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
return function jump(event) {
|
||||
if (
|
||||
document.getElementById('fileSearch') === document.activeElement &&
|
||||
document.activeElement != null
|
||||
) {
|
||||
// if we're currently focused on the search input, we don't want to navigate
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 78: // n
|
||||
case 74: // j
|
||||
goToNext();
|
||||
break;
|
||||
case 66: // b
|
||||
case 75: // k
|
||||
case 80: // p
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
};
|
||||
})();
|
||||
window.addEventListener('keydown', jumpToCode);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,131 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for All files</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1>All files</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">91.83% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>664/723</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">82.56% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>445/539</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>88/88</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">96.27% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>620/644</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file high" data-value="layout-manager.js"><a href="layout-manager.js.html">layout-manager.js</a></td>
|
||||
<td data-value="91.99" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
|
||||
</td>
|
||||
<td data-value="91.99" class="pct high">91.99%</td>
|
||||
<td data-value="587" class="abs high">540/587</td>
|
||||
<td data-value="81.95" class="pct high">81.95%</td>
|
||||
<td data-value="449" class="abs high">368/449</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="75" class="abs high">75/75</td>
|
||||
<td data-value="96.58" class="pct high">96.58%</td>
|
||||
<td data-value="527" class="abs high">509/527</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file high" data-value="state-manager.js"><a href="state-manager.js.html">state-manager.js</a></td>
|
||||
<td data-value="91.17" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
|
||||
</td>
|
||||
<td data-value="91.17" class="pct high">91.17%</td>
|
||||
<td data-value="136" class="abs high">124/136</td>
|
||||
<td data-value="85.55" class="pct high">85.55%</td>
|
||||
<td data-value="90" class="abs high">77/90</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="13" class="abs high">13/13</td>
|
||||
<td data-value="94.87" class="pct high">94.87%</td>
|
||||
<td data-value="117" class="abs high">111/117</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-27T06:33:52.071Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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}
|
||||
@@ -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;V<U;++V){var ae=Z[V];if(ae.ignoreCase){ac=true}else{if(/[a-z]/i.test(ae.source.replace(/\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|\\[^ux]/gi,""))){S=true;ac=false;break}}}var Y={b:8,t:9,n:10,v:11,f:12,r:13};function ab(ah){var ag=ah.charCodeAt(0);if(ag!==92){return ag}var af=ah.charAt(1);ag=Y[af];if(ag){return ag}else{if("0"<=af&&af<="7"){return parseInt(ah.substring(1),8)}else{if(af==="u"||af==="x"){return parseInt(ah.substring(2),16)}else{return ah.charCodeAt(1)}}}}function T(af){if(af<32){return(af<16?"\\x0":"\\x")+af.toString(16)}var ag=String.fromCharCode(af);if(ag==="\\"||ag==="-"||ag==="["||ag==="]"){ag="\\"+ag}return ag}function X(am){var aq=am.substring(1,am.length-1).match(new RegExp("\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|\\\\[0-3][0-7]{0,2}|\\\\[0-7]{1,2}|\\\\[\\s\\S]|-|[^-\\\\]","g"));var ak=[];var af=[];var ao=aq[0]==="^";for(var ar=ao?1:0,aj=aq.length;ar<aj;++ar){var ah=aq[ar];if(/\\[bdsw]/i.test(ah)){ak.push(ah)}else{var ag=ab(ah);var al;if(ar+2<aj&&"-"===aq[ar+1]){al=ab(aq[ar+2]);ar+=2}else{al=ag}af.push([ag,al]);if(!(al<65||ag>122)){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;ar<af.length;++ar){var at=af[ar];if(at[0]<=ap[1]+1){ap[1]=Math.max(ap[1],at[1])}else{ai.push(ap=at)}}var an=["["];if(ao){an.push("^")}an.push.apply(an,ak);for(var ar=0;ar<ai.length;++ar){var at=ai[ar];an.push(T(at[0]));if(at[1]>at[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<ah;++ak){var ag=aj[ak];if(ag==="("){++am}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){an[af]=-1}}}}for(var ak=1;ak<an.length;++ak){if(-1===an[ak]){an[ak]=++ad}}for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am;if(an[am]===undefined){aj[ak]="(?:"}}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){aj[ak]="\\"+an[am]}}}}for(var ak=0,am=0;ak<ah;++ak){if("^"===aj[ak]&&"^"!==aj[ak+1]){aj[ak]=""}}if(al.ignoreCase&&S){for(var ak=0;ak<ah;++ak){var ag=aj[ak];var ai=ag.charAt(0);Line truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
@@ -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 + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
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);
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Jest 配置文件 - 布局系统测试
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// 测试环境
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
// 根目录
|
||||
rootDir: '../../',
|
||||
|
||||
// 测试文件匹配模式
|
||||
testMatch: [
|
||||
'**/tests/layout-system/unit/**/*.test.js',
|
||||
'**/tests/layout-system/integration/**/*.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: ['<rootDir>/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 = '';
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* 测试辅助工具(布局系统)
|
||||
*
|
||||
* 说明:
|
||||
* - 本文件用于 Jest(jsdom) 环境下快速搭建 DOM、模拟事件、读取 CSS 变量。
|
||||
* - 测试用 DOM 结构尽量与 TDD 第八章的集成结构一致,便于后续集成/回归测试复用。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 创建完整的布局 DOM 结构
|
||||
*/
|
||||
function createLayoutDOM() {
|
||||
const html = `
|
||||
<div class="main-container">
|
||||
<!-- 分组面板 -->
|
||||
<div class="resizable-panel" data-panel="groups">
|
||||
<div class="panel-header">
|
||||
<h3>分组</h3>
|
||||
<button class="collapse-btn"
|
||||
data-panel="groups"
|
||||
aria-label="折叠分组面板"
|
||||
aria-expanded="true">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>分组内容</p>
|
||||
</div>
|
||||
<div class="resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整分组面板宽度"
|
||||
aria-valuenow="200"
|
||||
aria-valuemin="150"
|
||||
aria-valuemax="400"
|
||||
tabindex="0"
|
||||
data-resizer="groups">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号面板 -->
|
||||
<div class="resizable-panel" data-panel="accounts">
|
||||
<div class="panel-header">
|
||||
<h3>账号</h3>
|
||||
<button class="collapse-btn"
|
||||
data-panel="accounts"
|
||||
aria-label="折叠账号面板"
|
||||
aria-expanded="true">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>账号内容</p>
|
||||
</div>
|
||||
<div class="resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整账号面板宽度"
|
||||
aria-valuenow="260"
|
||||
aria-valuemin="180"
|
||||
aria-valuemax="500"
|
||||
tabindex="0"
|
||||
data-resizer="accounts">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件列表面板 -->
|
||||
<div class="resizable-panel" data-panel="emails">
|
||||
<div class="panel-header">
|
||||
<h3>邮件列表</h3>
|
||||
<button class="collapse-btn"
|
||||
data-panel="emails"
|
||||
aria-label="折叠邮件列表面板"
|
||||
aria-expanded="true">
|
||||
←
|
||||
</button>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>邮件列表内容</p>
|
||||
</div>
|
||||
<div class="resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整邮件列表面板宽度"
|
||||
aria-valuenow="380"
|
||||
aria-valuemin="280"
|
||||
aria-valuemax="600"
|
||||
tabindex="0"
|
||||
data-resizer="emails">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邮件详情面板(不可折叠) -->
|
||||
<div class="email-detail-panel">
|
||||
<div class="panel-header">
|
||||
<h3>邮件详情</h3>
|
||||
</div>
|
||||
<div class="panel-content">
|
||||
<p>邮件详情内容</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="main-container">
|
||||
<div class="resizable-panel" data-panel="groups">
|
||||
<div class="resizer" data-resizer="groups"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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<void>}
|
||||
*/
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待过渡动画完成(监听 transitionend)
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} timeout
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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<string, any>} 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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user