chore: v1.5.0 - 提交测试代码、测试报告,更新 tasks.md 状态
This commit is contained in:
880
docs/modification-assessment-v1.5.md
Normal file
880
docs/modification-assessment-v1.5.md
Normal file
@@ -0,0 +1,880 @@
|
||||
# PinMAP ↔ PinList 双向转换器 — v1.5.0 修改需求评估
|
||||
|
||||
> **版本**: v1.5.0
|
||||
> **日期**: 2026-06-06
|
||||
> **评估人**: 脚本架构师 (Script Architect)
|
||||
> **状态**: 待审批
|
||||
> **变更**: 1 个 P0 Bug 修复 + 3 个 P1 模板分离功能(F009~F012)
|
||||
|
||||
---
|
||||
|
||||
## 1. 修改需求总览
|
||||
|
||||
| 编号 | 类型 | 标题 | 优先级 | 复杂度 | 依赖 |
|
||||
|------|------|------|--------|--------|------|
|
||||
| F012 | Bug修复 | 修复 PinMAP 生成中上/下边 PinName 位置 | **P0** | 低 | 无 |
|
||||
| F009 | 功能 | MAP→List 使用 balllist 模板 | P1 | 中 | 无 |
|
||||
| F010 | 功能 | List→MAP 使用 ballmap 模板 | P1 | 中 | 无 |
|
||||
| F011 | 功能 | 模板格式提取式应用 | P1 | 中 | F009, F010 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前代码状态分析
|
||||
|
||||
### 2.1 代码库结构(v1.4.x 基线)
|
||||
|
||||
```
|
||||
pinmap-to-pinlist/
|
||||
├── run.bat
|
||||
├── Code/
|
||||
│ ├── src/
|
||||
│ │ ├── main.py # ✏️ 需修改 (F009/F010/F011)
|
||||
│ │ ├── file_selector.py # (不变)
|
||||
│ │ ├── models.py # (不变)
|
||||
│ │ ├── pinlist_generator.py # (不变)
|
||||
│ │ ├── pinlist_parser.py # (不变)
|
||||
│ │ ├── pinlist_validator.py # (不变)
|
||||
│ │ ├── pinmap_generator.py # (不变/需确认 F012 影响)
|
||||
│ │ ├── pinmap_layout.py # ✏️ 可能需修改 (F012)
|
||||
│ │ ├── pinmap_parser.py # (不变)
|
||||
│ │ ├── template_reader.py # ✏️ 可能需修改 (F011)
|
||||
│ │ ├── utils.py # (不变)
|
||||
│ │ ├── validator.py # (不变)
|
||||
│ │ ├── xls_reader.py # (不变)
|
||||
│ │ ├── xlsx_reader.py # (不变)
|
||||
│ │ ├── xlsx_writer.py # (不变/需确认 F011 影响)
|
||||
│ │ └── test_pinmap.py # ✏️ 需更新 (F012)
|
||||
│ └── docs/
|
||||
│ ├── architecture-design.md
|
||||
│ ├── modification-assessment.md
|
||||
│ ├── QUICKSTART.md
|
||||
│ ├── README.md
|
||||
│ ├── RELEASE.md
|
||||
│ └── team.md
|
||||
├── docs/
|
||||
│ ├── bugs.md
|
||||
│ ├── features.md
|
||||
│ ├── modification-assessment-v1.3.md
|
||||
│ ├── requirements.md
|
||||
│ └── tasks.md
|
||||
├── Test/
|
||||
│ ├── fixtures/
|
||||
│ │ ├── sample_4x4.xlsx
|
||||
│ │ ├── sample_rect.xlsx
|
||||
│ │ ├── error_*.xlsx
|
||||
│ │ └── warning_missing.xlsx
|
||||
│ ├── run_tests.py
|
||||
│ └── test_report.md
|
||||
└── Releases/
|
||||
```
|
||||
|
||||
### 2.2 现有模板机制
|
||||
|
||||
当前代码(v1.4.x)使用**单一模板文件** `PinMAP-Template.xlsx`(位于项目根目录):
|
||||
|
||||
```python
|
||||
# main.py → _find_template_path()
|
||||
# 查找根目录下的 PinMAP-Template.xlsx
|
||||
def _find_template_path() -> str | None:
|
||||
src_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(src_dir))
|
||||
template_path = os.path.join(root_dir, "PinMAP-Template.xlsx")
|
||||
...
|
||||
```
|
||||
|
||||
**当前行为**:
|
||||
- `run_map_to_list()`(MAP→List)和 `run_list_to_map()`(List→MAP)都查找同一个 `PinMAP-Template.xlsx`
|
||||
- 两个方向共用同一个模板文件
|
||||
- v1.5.0 需求:将两个方向的模板分离
|
||||
|
||||
### 2.3 各模块当前实现要点
|
||||
|
||||
#### `main.py` — 入口流程编排
|
||||
|
||||
```python
|
||||
# 模板查找:单一模板
|
||||
def _find_template_path() -> str | None:
|
||||
"""查找 PinMAP-Template.xlsx"""
|
||||
|
||||
# run_map_to_list(): MAP→List 流程
|
||||
# 使用 _find_template_path() 读取模板 → 应用到 PinList 输出
|
||||
|
||||
# run_list_to_map(): List→MAP 流程
|
||||
# 使用 _find_template_path() 读取模板 → 应用到 PinMAP 输出
|
||||
```
|
||||
|
||||
**关键观察**:两个方向都通过 `_find_template_path()` 查找同一模板。v1.5.0 需要分离。
|
||||
|
||||
#### `pinmap_layout.py` — PinMAP 布局计算 + Name 坐标
|
||||
|
||||
```python
|
||||
# 单元格坐标体系(0-based):
|
||||
# 左边: 序号在 (r, 0), Name 在 (r, c+1) = (r, 1)
|
||||
# 下边: 序号在 (rows, c), Name 在 (r-1, c) = (rows-1, c)
|
||||
# 右边: 序号在 (r, cols), Name 在 (r, c-1) = (r, cols-1)
|
||||
# 上边: 序号在 (1, c), Name 在 (r+1, c) = (2, c)
|
||||
|
||||
def get_name_cell(num_cell, edge_name):
|
||||
if edge_name == "left": return (r, c+1) # Name 右侧
|
||||
elif edge_name == "bottom": return (r-1, c) # Name 上方
|
||||
elif edge_name == "right": return (r, c-1) # Name 左侧
|
||||
elif edge_name == "top": return (r+1, c) # Name 下方
|
||||
```
|
||||
|
||||
**关于 F012 的关键分析**(详见第 3.1 节):
|
||||
|
||||
| 边 | 当前 Name 位置 | 相对序号 | F012 声称"当前" | F012 声称"应改为" |
|
||||
|----|---------------|---------|----------------|-----------------|
|
||||
| bottom | `(r-1, c)` = max_row-1 | Name 在序号**上方** | min_row+1 | max_row-1 |
|
||||
| top | `(r+1, c)` = min_row+1 | Name 在序号**下方** | max_row-1 | min_row+1 |
|
||||
|
||||
**结论**:当前代码实际行为已经符合 F012 的"应改为"目标(bottom Name 在 max_row-1,top Name 在 min_row+1)。F012 描述中的"当前"状态可能指向旧版本代码或使用不同坐标基准的描述。详见 3.1 节分析。
|
||||
|
||||
#### `pinmap_generator.py` — PinMAP 单元格数据构建
|
||||
|
||||
```python
|
||||
def generate_pinmap(entries, rows, cols, package_info,
|
||||
template_style=None, output_path=None):
|
||||
# 1. 计算布局 → layout (dict[str, EdgePins])
|
||||
# 2. 先写入 PinName 单元格
|
||||
# 3. 再写入序号单元格(后可覆盖同名单元格)
|
||||
# 4. 写入文件(模板样式或默认样式)
|
||||
```
|
||||
|
||||
#### `template_reader.py` — 模板样式提取
|
||||
|
||||
```python
|
||||
# 关键能力:
|
||||
# - 解析 xl/styles.xml → fonts, fills, borders, cellXfs
|
||||
# - 解析 sheet1.xml → column_widths, row_heights
|
||||
# - 优雅降级:模板不存在/解析失败 → 返回 None
|
||||
|
||||
@dataclass
|
||||
class TemplateStyle:
|
||||
fonts: list[FontStyle]
|
||||
borders: list[BorderStyle]
|
||||
fills: list[FillStyle]
|
||||
cell_xfs: list[dict] # xf index → {fontId, borderId, fillId, alignment}
|
||||
column_widths: dict[int, float]
|
||||
row_heights: dict[int, float]
|
||||
```
|
||||
|
||||
#### `xlsx_writer.py` — XLSX 输出(含样式)
|
||||
|
||||
```python
|
||||
# StyledXLSXWriter — 生成含 styles.xml 的 xlsx
|
||||
# _styles_xml(): 读取模板字体/填充/边框 → 构建 styles.xml
|
||||
# 当前实现:使用硬编码样式(模板字体仅作参考,主体样式内置)
|
||||
# _sheet_xml(): 应用列宽/行高 + 单元格样式索引
|
||||
# style_idx: 0=default, 1=centered+border, 2=bold(A1), 3=fill
|
||||
```
|
||||
|
||||
**关键观察**:`StyledXLSXWriter._styles_xml()` 目前是**硬编码样式**(内置字体、边框定义),仅从模板读取字体名称/大小作为参考。列宽和行高从模板的**实际行列**读取,但会按输出数据的实际行列扩展。
|
||||
|
||||
---
|
||||
|
||||
## 3. 逐项修改方案
|
||||
|
||||
---
|
||||
|
||||
### 3.1 F012: 修复 PinMAP 生成中上/下边 PinName 位置
|
||||
|
||||
#### 3.1.1 需求分析
|
||||
|
||||
**F012 描述原文**:
|
||||
> 修复 PinMAP 生成中上/下边 PinName 位置
|
||||
> - 当前:下边 Name 在 min_row+1,上边 Name 在 max_row-1
|
||||
> - 应改为:下边 Name 在 max_row-1,上边 Name 在 min_row+1
|
||||
|
||||
**4×4 参考示例**:
|
||||
```
|
||||
A4:1, A5:2, B4:Pin1, B5:Pin2 (上边)
|
||||
C7:3, D7:4, C6:Pin3, D6:Pin4 (右边)
|
||||
F5:5, F4:6, E5:Pin5, E4:Pin6 (下边)
|
||||
D2:7, C2:8, D3:Pin7, C3:Pin8 (左边)
|
||||
```
|
||||
|
||||
#### 3.1.2 代码现状追踪
|
||||
|
||||
**当前 `get_name_cell` 实现**:
|
||||
|
||||
```python
|
||||
def get_name_cell(num_cell, edge_name):
|
||||
r, c = num_cell
|
||||
if edge_name == "left": return (r, c+1) # Name 在序号右侧
|
||||
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方 (max_row-1)
|
||||
elif edge_name == "right": return (r, c-1) # Name 在序号左侧
|
||||
elif edge_name == "top": return (r+1, c) # Name 在序号下方 (min_row+1)
|
||||
```
|
||||
|
||||
**当前 `calculate_layout` 单元格坐标**:
|
||||
|
||||
```python
|
||||
# 下边:序号在 (rows, c),即 max_row
|
||||
# → get_name_cell 返回 (rows-1, c) = max_row-1 ← 上方
|
||||
#
|
||||
# 上边:序号在 (1, c),即 min_row
|
||||
# → get_name_cell 返回 (2, c) = min_row+1 ← 下方
|
||||
```
|
||||
|
||||
#### 3.1.3 关键发现:代码可能已经正确
|
||||
|
||||
将 F012 的"应改为"目标与当前代码对比:
|
||||
|
||||
| 边 | F012"应改为"目标 | 当前代码实际位置 | 是否一致 |
|
||||
|----|-----------------|-----------------|---------|
|
||||
| 下边 (bottom) | max_row-1 | `r-1` = rows-1 = max_row-1 | ✅ 一致 |
|
||||
| 上边 (top) | min_row+1 | `r+1` = 1+1 = min_row+1 | ✅ 一致 |
|
||||
|
||||
**`test_pinmap.py` 中的 4×4 测试数据验证**:
|
||||
|
||||
```python
|
||||
# Test data (0-based):
|
||||
# bottom edge: numbers at (6,2)=3, (6,3)=4; names at (5,2)=Pin3, (5,3)=Pin4
|
||||
# → max_row=6, names at row 5 = max_row-1 ✓
|
||||
# top edge: numbers at (1,3)=7, (1,2)=8; names at (2,3)=Pin7, (2,2)=Pin8
|
||||
# → min_row=1, names at row 2 = min_row+1 ✓
|
||||
```
|
||||
|
||||
**该测试在当前代码中已通过**,表明当前 `get_name_cell` 返回值与测试数据一致。
|
||||
|
||||
#### 3.1.4 可能的问题场景
|
||||
|
||||
如果确实存在问题,以下场景需排查:
|
||||
|
||||
1. **PinMAP 解析方向**:`pinmap_parser.py` 解析 PinMAP 时,底边 Name 读自 `max_row-1`,顶边 Name 读自 `min_row+1`。这与生成方向一致。但如果解析时使用了错误的位置假设,则来回转换会导致 Name 错位。
|
||||
|
||||
2. **边名称语义混淆**:F012 描述中的 `(上边)` `(右边)` `(下边)` `(左边)` 标签可能使用了不同的约定(例如,该标签可能对应的是"Name 边的位置"而非"序号所在边")。
|
||||
|
||||
3. **网格区域偏移**:如果 `A1` 被保留为封装信息,网格区域从第 2 行开始(1-based → 0-based row=1),则 `min_row` 和 `max_row` 的起始值需要重新校准。
|
||||
|
||||
#### 3.1.5 修改方案
|
||||
|
||||
**方案 A:确认代码已正确,仅添加回归测试**
|
||||
|
||||
如果代码已正确,则:
|
||||
1. 不修改 `pinmap_layout.py`
|
||||
2. 在 `test_pinmap.py` 中增加显式的上/下边 Name 位置验证测试
|
||||
3. 更新测试覆盖率
|
||||
|
||||
**方案 B:按 F012 描述修改(如果确定存在 Bug)**
|
||||
|
||||
如果确实需要交换,修改 `get_name_cell`:
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
elif edge_name == "bottom": return (r-1, c) # Name 在序号上方
|
||||
elif edge_name == "top": return (r+1, c) # Name 在序号下方
|
||||
|
||||
# 修改后(交换 bottom/top 的 Name 位置):
|
||||
elif edge_name == "bottom": return (r+1, c) # Name 移到序号下方
|
||||
elif edge_name == "top": return (r-1, c) # Name 移到序号上方
|
||||
```
|
||||
|
||||
**但此修改会导致现有 `test_4x4_parse` 测试失败**,因为测试数据中 bottom Name 在 `max_row-1`。
|
||||
|
||||
**方案 C:仅修改 `pinmap_parser.py` 的 Name 读取位置**
|
||||
|
||||
如果问题是解析方向(MAP→List)使用的位置不正确:
|
||||
修改 `pinmap_parser.py` 中 bottom/top 的 Name 查找行号。
|
||||
|
||||
#### 3.1.6 建议
|
||||
|
||||
**强烈建议在实施前与需求方确认具体的 Bug 表现**。基于代码分析:
|
||||
|
||||
| 事实 | 结论 |
|
||||
|------|------|
|
||||
| `get_name_cell` 中 bottom Name 在 max_row-1 | 符合 F012 的"应改为" |
|
||||
| `get_name_cell` 中 top Name 在 min_row+1 | 符合 F012 的"应改为" |
|
||||
| 4×4 测试已通过 | 代码内部一致 |
|
||||
| `pinmap_parser.py` 使用相同约定 | 读写一致 |
|
||||
| F012 描述中的"当前"与代码不符 | 可能存在描述误差 |
|
||||
|
||||
**实施步骤**(按优先级):
|
||||
|
||||
1. **先确认问题**:使用实际的 4×4 PinMAP 输入文件,运行完整的 PinList→PinMAP 转换,检查输出
|
||||
2. **定位差异**:对比输出与预期,定位是 `get_name_cell` 还是 `pinmap_parser` 的差异
|
||||
3. **修改代码**:根据确认结果修改对应的函数
|
||||
4. **更新测试**:同步更新 `test_pinmap.py` 中的测试数据和断言
|
||||
|
||||
**影响范围**:
|
||||
|
||||
| 文件 | 修改内容 | 可能性 |
|
||||
|------|---------|--------|
|
||||
| `pinmap_layout.py` | 修改 `get_name_cell` 的 bottom/top 逻辑 | 低(可能需要确认) |
|
||||
| `test_pinmap.py` | 更新测试数据/断言 | 中 |
|
||||
| `pinmap_parser.py` | 修改 Name 读取行号 | 低 |
|
||||
|
||||
**风险评估**:
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 修改后破坏往返转换一致性 | **高** | 中 | 运行完整的 MAP→List→MAP 往返测试 |
|
||||
| 修改后破坏现有用户输出 | 中 | 低 | 确认用户实际使用场景 |
|
||||
| 与需求方沟通不足导致反复修改 | 中 | 中 | **先确认再改** |
|
||||
|
||||
**工作量**:0.5–1 小时(含需求确认)
|
||||
|
||||
---
|
||||
|
||||
### 3.2 F009: MAP→List 使用 balllist 模板
|
||||
|
||||
#### 3.2.1 需求
|
||||
|
||||
> PinMAP→PinList 转换方向查找并使用 `BallList-Template.xlsx`,不再共用 PinMAP 模板
|
||||
|
||||
**变更前**:`run_map_to_list()` 使用 `_find_template_path()` → `PinMAP-Template.xlsx`
|
||||
**变更后**:`run_map_to_list()` 查找并使用 `BallList-Template.xlsx`
|
||||
|
||||
#### 3.2.2 影响范围
|
||||
|
||||
仅在 `main.py` 中修改模板查找逻辑,核心转换代码不受影响。
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 文件 | 修改级别 | 说明 |
|
||||
|------|---------|------|
|
||||
| `main.py` | ✏️ 修改 | 新增 `_find_balllist_template_path()` 函数;修改 `run_map_to_list()` 中的模板查找调用 |
|
||||
|
||||
**不涉及**:`pinmap_parser.py`, `pinlist_generator.py`, `xlsx_writer.py`, `template_reader.py`(这些模块只接收 `TemplateStyle` 对象,不关心模板文件名)
|
||||
|
||||
#### 3.2.3 具体修改方案
|
||||
|
||||
**3.2.3.1 新增 `_find_balllist_template_path()` 函数**:
|
||||
|
||||
```python
|
||||
def _find_balllist_template_path() -> str | None:
|
||||
"""查找根目录下的 BallList-Template.xlsx。
|
||||
|
||||
搜索顺序:
|
||||
1. 与 run.bat 同级的根目录
|
||||
2. 当前工作目录
|
||||
"""
|
||||
src_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
|
||||
template_path = os.path.join(root_dir, "BallList-Template.xlsx")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
return template_path
|
||||
|
||||
cwd_template = os.path.join(os.getcwd(), "BallList-Template.xlsx")
|
||||
if os.path.exists(cwd_template):
|
||||
return cwd_template
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
**3.2.3.2 修改 `run_map_to_list()` 中的模板查找**:
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
template_path = _find_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
|
||||
# 修改后:
|
||||
template_path = _find_balllist_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载 BallList 模板样式: {template_path}")
|
||||
else:
|
||||
print("[WARN] BallList 模板文件存在但解析失败,使用默认样式")
|
||||
else:
|
||||
print("[INFO] 未检测到 BallList-Template.xlsx,使用默认样式")
|
||||
```
|
||||
|
||||
**3.2.3.3 输出路径保持不变**:
|
||||
|
||||
PinList 输出路径仍为 `{input_base}_PinList.xlsx`,不受模板变更影响。
|
||||
|
||||
#### 3.2.4 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 用户未放置 BallList-Template.xlsx | 低 | 高 | 模板不存在 → 优雅降级使用默认样式(已实现) |
|
||||
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
|
||||
| BallList-Template.xlsx 解析失败 | 低 | 低 | template_reader 已有 try-except 降级 |
|
||||
|
||||
**工作量**:15 分钟
|
||||
|
||||
---
|
||||
|
||||
### 3.3 F010: List→MAP 使用 ballmap 模板
|
||||
|
||||
#### 3.3.1 需求
|
||||
|
||||
> PinList→PinMAP 转换方向查找并使用 `BallMAP-Template.xlsx`,不再共用 PinMAP 模板
|
||||
|
||||
**变更前**:`run_list_to_map()` 使用 `_find_template_path()` → `PinMAP-Template.xlsx`
|
||||
**变更后**:`run_list_to_map()` 查找并使用 `BallMAP-Template.xlsx`
|
||||
|
||||
#### 3.3.2 影响范围
|
||||
|
||||
仅在 `main.py` 中修改模板查找逻辑。
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 文件 | 修改级别 | 说明 |
|
||||
|------|---------|------|
|
||||
| `main.py` | ✏️ 修改 | 新增 `_find_ballmap_template_path()` 函数;修改 `run_list_to_map()` 中的模板查找调用 |
|
||||
|
||||
#### 3.3.3 具体修改方案
|
||||
|
||||
**3.3.3.1 新增 `_find_ballmap_template_path()` 函数**:
|
||||
|
||||
```python
|
||||
def _find_ballmap_template_path() -> str | None:
|
||||
"""查找根目录下的 BallMAP-Template.xlsx。
|
||||
|
||||
搜索顺序:
|
||||
1. 与 run.bat 同级的根目录
|
||||
2. 当前工作目录
|
||||
"""
|
||||
src_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
root_dir = os.path.dirname(os.path.dirname(src_dir)) # pinmap-to-pinlist/
|
||||
template_path = os.path.join(root_dir, "BallMAP-Template.xlsx")
|
||||
|
||||
if os.path.exists(template_path):
|
||||
return template_path
|
||||
|
||||
cwd_template = os.path.join(os.getcwd(), "BallMAP-Template.xlsx")
|
||||
if os.path.exists(cwd_template):
|
||||
return cwd_template
|
||||
|
||||
return None
|
||||
```
|
||||
|
||||
**3.3.3.2 修改 `run_list_to_map()` 中的模板查找**:
|
||||
|
||||
```python
|
||||
# 修改前:
|
||||
template_path = _find_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
...
|
||||
|
||||
# 修改后:
|
||||
template_path = _find_ballmap_template_path()
|
||||
template_style = None
|
||||
if template_path:
|
||||
template_style = read_template_styles(template_path)
|
||||
if template_style:
|
||||
print(f"[INFO] 已加载 BallMAP 模板样式: {template_path}")
|
||||
else:
|
||||
print("[WARN] BallMAP 模板文件存在但解析失败,使用默认样式")
|
||||
else:
|
||||
print("[INFO] 未检测到 BallMAP-Template.xlsx,使用默认样式")
|
||||
```
|
||||
|
||||
#### 3.3.4 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 用户未放置 BallMAP-Template.xlsx | 低 | 高 | 优雅降级使用默认样式 |
|
||||
| 新旧模板共存产生混淆 | 低 | 低 | 日志明确输出使用的模板文件名 |
|
||||
|
||||
**工作量**:15 分钟
|
||||
|
||||
---
|
||||
|
||||
### 3.4 F011: 模板格式提取式应用
|
||||
|
||||
#### 3.4.1 需求
|
||||
|
||||
> 从模板仅提取格式信息(字体、边框、对齐、列宽、行高),输出文件行列数由实际 Pin 数量决定,不复制模板行列结构。
|
||||
|
||||
**依赖**:F009(BallList-Template.xlsx)+ F010(BallMAP-Template.xlsx)
|
||||
|
||||
#### 3.4.2 当前实现分析
|
||||
|
||||
当前模板应用于 `StyledXLSXWriter`(`xlsx_writer.py`),其行为:
|
||||
|
||||
**`_sheet_xml()` 中的列宽/行高处理**:
|
||||
```python
|
||||
# 当前逻辑:
|
||||
# 1. 遍历模板 style.column_widths,按最大 col 扩展
|
||||
# 2. 对于模板有定义的列使用模板宽度,否则默认 8.0
|
||||
# 3. 遍历 style.row_heights,有定义的行使用模板行高
|
||||
col_widths_xml = ''
|
||||
if self._style and self._style.column_widths:
|
||||
max_width_col = max(self._style.column_widths.keys())
|
||||
max_width_col = max(max_width_col, max_col)
|
||||
for c in range(max_width_col + 1):
|
||||
width = self._style.column_widths.get(c, 8.0)
|
||||
...
|
||||
```
|
||||
|
||||
**`_styles_xml()` 中的样式处理**:
|
||||
```python
|
||||
# 当前逻辑:硬编码 4 种 xf 样式
|
||||
# - xf 0: default
|
||||
# - xf 1: centered + thin border(pin cells)
|
||||
# - xf 2: bold + centered(A1)
|
||||
# - xf 3: centered + border + light fill(header)
|
||||
#
|
||||
# 仅从模板提取:
|
||||
# - font[0] 的 name/size/color(用于 xf 0, 1, 3)
|
||||
# - font[0] 被复制为 font[1](bold 版,用于 xf 2 = A1)
|
||||
```
|
||||
|
||||
#### 3.4.3 关键分析:当前实现是否已满足 F011 要求
|
||||
|
||||
| F011 要求 | 当前实现 | 是否满足 |
|
||||
|-----------|---------|---------|
|
||||
| 仅提取格式信息 | ✅ 模板仅用于样式 | ✅ 已满足 |
|
||||
| 字体 | ✅ 从模板 font[0] 提取 | ✅ 已满足 |
|
||||
| 边框 | ❌ 硬编码 thin border | ⚠️ 部分满足 |
|
||||
| 对齐 | ✅ 硬编码 center/center | ⚠️ 部分满足 |
|
||||
| 列宽 | ✅ 从模板提取 | ✅ 已满足 |
|
||||
| 行高 | ✅ 从模板提取(有则不覆盖) | ✅ 已满足 |
|
||||
| 输出行列由实际 Pin 决定 | ✅ 不复制模板行列结构 | ✅ 已满足 |
|
||||
| 不复制模板行列结构 | ✅ dim 由数据决定 | ✅ 已满足 |
|
||||
|
||||
**核心差距**:当前 `_styles_xml()` 中边框和对齐是硬编码的,未从模板的 `cellXfs` 中提取。F011 要求**完全**从模板提取格式。
|
||||
|
||||
#### 3.4.4 修改方案
|
||||
|
||||
**目标**:将 `_styles_xml()` 改造为从模板的 `cellXfs`、`fonts`、`borders`、`fills` 中提取并原样输出样式,而非硬编码。
|
||||
|
||||
**涉及文件**:
|
||||
|
||||
| 文件 | 修改级别 | 说明 |
|
||||
|------|---------|------|
|
||||
| `template_reader.py` | ✏️ 修改 | 增强样式提取能力:提取 cellXfs 的完整信息(含 numFmtId, xfId 等) |
|
||||
| `xlsx_writer.py` | ✏️ 修改 | 重写 `_styles_xml()` 使用模板原始样式定义 |
|
||||
|
||||
**3.4.4.1 增强 `template_reader.py`**:
|
||||
|
||||
当前 `TemplateReader._parse_styles_xml()` 已能提取:
|
||||
- ✅ fonts(FontStyle: name, size, bold, italic, color)
|
||||
- ✅ fills(FillStyle: pattern_type, fg_color)
|
||||
- ✅ borders(BorderStyle: top, bottom, left, right, color)
|
||||
- ✅ cell_xfs(list[dict]: numFmtId, fontId, fillId, borderId, alignment)
|
||||
|
||||
**需要增加**:
|
||||
- `cell_xfs` 中的 `xfId` 属性(用于样式继承)
|
||||
- `cellXfs` 的 `count` 属性
|
||||
|
||||
当前已提取的信息足以满足 F011。**不需要大幅修改** `template_reader.py`。
|
||||
|
||||
**3.4.4.2 重写 `xlsx_writer.py` 中的 `_styles_xml()`**:
|
||||
|
||||
```python
|
||||
def _styles_xml(self) -> str:
|
||||
"""Build xl/styles.xml from template styles or defaults."""
|
||||
s = self._style
|
||||
|
||||
# ── Fonts ────────────────────────────────────────────────
|
||||
if s and s.fonts:
|
||||
fonts_xml = self._build_fonts_xml(s.fonts)
|
||||
else:
|
||||
fonts_xml = self._default_fonts_xml()
|
||||
|
||||
# ── Fills ────────────────────────────────────────────────
|
||||
if s and s.fills:
|
||||
fills_xml = self._build_fills_xml(s.fills)
|
||||
else:
|
||||
fills_xml = self._default_fills_xml()
|
||||
|
||||
# ── Borders ──────────────────────────────────────────────
|
||||
if s and s.borders:
|
||||
borders_xml = self._build_borders_xml(s.borders)
|
||||
else:
|
||||
borders_xml = self._default_borders_xml()
|
||||
|
||||
# ── Cell XFs ─────────────────────────────────────────────
|
||||
if s and s.cell_xfs:
|
||||
cell_xfs_xml = self._build_cell_xfs_xml(s.cell_xfs)
|
||||
else:
|
||||
cell_xfs_xml = self._default_cell_xfs_xml()
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n'
|
||||
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">\n'
|
||||
+ fonts_xml + '\n'
|
||||
+ fills_xml + '\n'
|
||||
+ borders_xml + '\n'
|
||||
+ cell_xfs_xml + '\n'
|
||||
+ '</styleSheet>'
|
||||
)
|
||||
```
|
||||
|
||||
**关键设计决策**:
|
||||
|
||||
| 决策点 | 方案 | 理由 |
|
||||
|--------|------|------|
|
||||
| 是否完全复制模板 cellXfs | ✅ 是 | 模板定义什么样式就用什么样式 |
|
||||
| 是否需要额外样式(如 A1 bold) | ⚠️ 在模板中应有对应的 xf | 鼓励用户在模板中定义 A1 的样式 |
|
||||
| 无模板时的默认样式 | 保持现有硬编码 | 向后兼容 |
|
||||
| 列宽/行高处理 | 保持现有逻辑 | 已满足 F011 |
|
||||
|
||||
**3.4.4.3 cellXfs 映射策略**:
|
||||
|
||||
模板的 cellXfs 索引需要映射到输出单元格:
|
||||
- 模板中可能有多种 xf(如 xf 0=default, xf 1=边框, xf 2=粗体...)
|
||||
- 输出时需决定每个单元格使用哪个 xf
|
||||
|
||||
**推荐策略**:
|
||||
1. 分析模板 cellXfs,找到最合适的 "pin cell" 样式(通常是带边框+居中的 xf)
|
||||
2. A1 使用模板中带 bold 的 xf(如果存在),否则用 pin cell 样式
|
||||
3. 普通 pin cell 使用模板中的 "pin cell" xf
|
||||
4. 如果模板只有 1 个 xf(default),使用默认样式作为补充
|
||||
|
||||
**简化方案**(推荐首版):
|
||||
- 保留现有的 4 个样式槽位(xf 0~3)
|
||||
- 但从模板读取实际的字体、边框、填充定义填充到对应槽位
|
||||
- 而非硬编码 "thin" 边框和 "center" 对齐
|
||||
|
||||
**实现优先级**:
|
||||
1. **字体信息**:已从模板读取 ✅
|
||||
2. **边框样式**:从模板 borders 读取 → 替换硬编码的 "thin"
|
||||
3. **填充样式**:从模板 fills 读取 → 替换硬编码的 "FFF0F0F0"
|
||||
4. **对齐方式**:从模板 alignment 读取 → 替换硬编码的 "center"
|
||||
5. **列宽/行高**:已从模板读取 ✅
|
||||
|
||||
#### 3.4.5 风险评估
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 |
|
||||
|------|------|------|---------|
|
||||
| 模板 cellXfs 结构与输出不匹配 | 中 | 中 | 提供合理的 fallback(至少 xf 0 和 xf 1) |
|
||||
| 模板无边框定义导致输出无网格线 | 中 | 低 | 模板无边框时保留默认 thin border |
|
||||
| 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 使用 Python 标准库 XML 构建,避免硬编码 namespace 错误 |
|
||||
| 已有用户无模板场景不受影响 | 低 | - | 无模板时完全回退到现有硬编码样式 |
|
||||
|
||||
**工作量**:2–3 小时
|
||||
|
||||
---
|
||||
|
||||
## 4. 模板文件命名规范建议
|
||||
|
||||
### 4.1 v1.5.0 新模板命名
|
||||
|
||||
| 用途 | 文件名 | 位置 | 格式 |
|
||||
|------|--------|------|------|
|
||||
| MAP→List 输出样式 | `BallList-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
|
||||
| List→MAP 输出样式 | `BallMAP-Template.xlsx` | 项目根目录(与 run.bat 同级) | `.xlsx` |
|
||||
|
||||
### 4.2 向后兼容
|
||||
|
||||
| 旧文件名 | 状态 | 说明 |
|
||||
|---------|------|------|
|
||||
| `PinMAP-Template.xlsx` | ⚠️ 废弃(不再自动查找) | v1.5.0 后不再被代码引用 |
|
||||
|
||||
### 4.3 命名规范说明
|
||||
|
||||
```
|
||||
模板文件名格式:
|
||||
{输出格式简称}-Template.xlsx
|
||||
|
||||
其中:
|
||||
- BallList → PinMAP→PinList 转换的输出格式(PinList)
|
||||
- BallMAP → PinList→PinMAP 转换的输出格式(PinMAP)
|
||||
|
||||
命名原则:
|
||||
1. 以输出格式命名,而非输入格式
|
||||
2. 使用 "Ball" 前缀避免与 "Pin" 名混淆
|
||||
3. 保持 PascalCase 风格
|
||||
4. 使用 "-Template" 后缀明确表示模板文件
|
||||
```
|
||||
|
||||
### 4.4 搜索路径优先级
|
||||
|
||||
```
|
||||
1. {project_root}/BallList-Template.xlsx (与 run.bat 同级)
|
||||
2. {cwd}/BallList-Template.xlsx (当前工作目录)
|
||||
3. 无 → 使用默认样式(硬编码 Calibri 11pt + thin border + center align)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 修改影响矩阵
|
||||
|
||||
| 文件 | F012 | F009 | F010 | F011 | 总改动量 | 备注 |
|
||||
|------|------|------|------|------|---------|------|
|
||||
| `main.py` | — | ✏️ 模板查找 | ✏️ 模板查找 | — | ~30行 | 新增2个函数+修改2处调用 |
|
||||
| `pinmap_layout.py` | ✏️ 可能 | — | — | — | ~5行 | 仅在确认问题后修改 |
|
||||
| `template_reader.py` | — | — | — | ✏️ 增强 | ~20行 | 增加 xfId 提取 |
|
||||
| `xlsx_writer.py` | — | — | — | ✏️ 重写 | ~100行 | 重写 _styles_xml() |
|
||||
| `test_pinmap.py` | ✏️ 更新 | — | — | — | ~10行 | 增加 F012 回归测试 |
|
||||
| **合计** | **2 文件** | **1 文件** | **1 文件** | **2 文件** | **4-5 文件** | |
|
||||
|
||||
---
|
||||
|
||||
## 6. 优先级排序
|
||||
|
||||
| 优先级 | 编号 | 原因 | 推荐执行顺序 |
|
||||
|--------|------|------|------------|
|
||||
| **P0** | F012 | 核心布局 Bug(需先确认),影响所有 List→MAP 转换 | 第1(先确认,再决定是否修改) |
|
||||
| **P1-1** | F009 | MAP→List 模板分离,独立于其他改动 | 第2(可与 F010 并行) |
|
||||
| **P1-2** | F010 | List→MAP 模板分离,独立于其他改动 | 第2(可与 F009 并行) |
|
||||
| **P1-3** | F011 | 依赖 F009+F010 的模板文件存在后才能测试 | 第3 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 测试要点
|
||||
|
||||
### 7.1 F012 测试(P0)
|
||||
|
||||
#### 核心验证
|
||||
|
||||
| 测试项 | 输入 | 预期 | 方法 |
|
||||
|--------|------|------|------|
|
||||
| 4×4 PinMAP 往返一致性 | PinList(rows=4, cols=4, 8 Pins) → PinMAP → PinList | 往返后 PinList 数据不变 | 自动化 |
|
||||
| 下边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 下边 PinName 在序号行上方一行(max_row-1) | 检查输出 xlsx |
|
||||
| 上边 Name 位置 | rows=4, cols=4 的 PinMAP 输出 | 上边 PinName 在序号行下方一行(min_row+1) | 检查输出 xlsx |
|
||||
| 与 parser 一致性 | 生成的 PinMAP 再用 parser 解析 | 解析出的 Pin 数据和原始一致 | 自动化 |
|
||||
| 非方形网格 | rows=3, cols=5 的 PinMAP | 上/下边 Name 位置正确 | 检查输出 |
|
||||
|
||||
#### 边界条件
|
||||
|
||||
| 测试项 | 输入 | 预期 |
|
||||
|--------|------|------|
|
||||
| 最小网格 2×2 | PinList(rows=2, cols=2, 8 Pins) | 四条边各 2 Pin,角点正确 |
|
||||
| 大网格 15×15 | PinList(rows=15, cols=15, 60 Pins) | 60 Pin 全部正确分配到四条边 |
|
||||
| max_row-1 与 min_row+1 重合 | rows=2(min_row=1, max_row=2 → max_row-1=1=min_row) | Name 不与序号重叠 |
|
||||
|
||||
#### 现有测试回归
|
||||
|
||||
- `test_4x4_parse()` — 确认不受影响或同步更新
|
||||
- `test_4x4_validate()` — 确认不受影响
|
||||
- `test_12pin_square()` — 确认不受影响
|
||||
|
||||
### 7.2 F009 测试(P1)
|
||||
|
||||
| 测试项 | 方法 | 预期 |
|
||||
|--------|------|------|
|
||||
| BallList-Template.xlsx 存在时加载 | 放置模板 → MAP→List 转换 | 日志显示"已加载 BallList 模板样式" |
|
||||
| BallList-Template.xlsx 不存在时降级 | 删除模板 → MAP→List 转换 | 日志显示"未检测到 BallList-Template.xlsx",输出使用默认样式 |
|
||||
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → MAP→List 转换 | 不加载旧模板,使用默认样式 |
|
||||
| 模板样式应用到 PinList 输出 | 放置带自定义字体/边框的模板 → 转换 | 输出 PinList 使用模板字体和边框 |
|
||||
|
||||
### 7.3 F010 测试(P1)
|
||||
|
||||
| 测试项 | 方法 | 预期 |
|
||||
|--------|------|------|
|
||||
| BallMAP-Template.xlsx 存在时加载 | 放置模板 → List→MAP 转换 | 日志显示"已加载 BallMAP 模板样式" |
|
||||
| BallMAP-Template.xlsx 不存在时降级 | 删除模板 → List→MAP 转换 | 日志显示"未检测到 BallMAP-Template.xlsx",输出使用默认样式 |
|
||||
| 不加载 PinMAP-Template.xlsx | 仅放置 PinMAP-Template.xlsx → List→MAP 转换 | 不加载旧模板,使用默认样式 |
|
||||
|
||||
### 7.4 F011 测试(P1)
|
||||
|
||||
| 测试项 | 方法 | 预期 |
|
||||
|--------|------|------|
|
||||
| 模板字体正确应用 | 模板字体=微软雅黑 12pt → 输出 | 输出文件字体为微软雅黑 12pt |
|
||||
| 模板边框正确应用 | 模板边框=medium → 输出 | 输出文件边框为 medium 而非 thin |
|
||||
| 模板填充正确应用 | 模板背景色=黄色 → 输出 | 输出对应单元格有黄色背景 |
|
||||
| 模板对齐正确应用 | 模板左对齐 → 输出 | 输出文件单元格左对齐 |
|
||||
| 列宽与模板一致 | 模板 A 列宽=20 → 输出 | 输出对应列宽=20 |
|
||||
| 行高与模板一致 | 模板行高=25 → 输出 | 输出对应行高=25 |
|
||||
| 输出行列由实际 Pin 决定 | rows=5, cols=5 → 输出 | 输出为 5×5 网格,不含模板的额外行列 |
|
||||
| 无模板时使用默认样式 | 无模板文件 → 输出 | 使用 Calibri 11pt + thin border + center align |
|
||||
|
||||
### 7.5 集成测试
|
||||
|
||||
| 测试项 | 输入 | 预期 |
|
||||
|--------|------|------|
|
||||
| 往返转换 (MAP→List→MAP) | sample_4x4.xlsx → PinList → PinMAP | 与原始 PinMAP 一致 |
|
||||
| 往返转换 (List→MAP→List) | PinList 文件 → PinMAP → PinList | 与原始 PinList 一致 |
|
||||
| 两个方向使用不同模板 | BallList-Template 和 BallMAP-Template 同时存在 | MAP→List 用 BallList,List→MAP 用 BallMAP |
|
||||
| 一个大模板一个小模板 | BallList 字体=12pt, BallMAP 字体=10pt | 各方向使用各自的字体 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 开发顺序建议
|
||||
|
||||
```
|
||||
阶段 1: F012 需求确认(30 分钟)
|
||||
├─ 用实际 PinMAP 输入文件测试当前代码的 PinName 位置
|
||||
├─ 确认 Bug 具体表现
|
||||
└─ 决定修改方案(A/B/C)
|
||||
|
||||
阶段 2: F009 + F010 模板分离(30 分钟,可并行)
|
||||
├─ main.py: 新增 _find_balllist_template_path()
|
||||
├─ main.py: 新增 _find_ballmap_template_path()
|
||||
├─ main.py: 修改 run_map_to_list() 模板查找
|
||||
├─ main.py: 修改 run_list_to_map() 模板查找
|
||||
└─ 废弃 _find_template_path()(可选,保留无调用不影响)
|
||||
|
||||
阶段 3: F012 修复(如需要,30–60 分钟)
|
||||
├─ pinmap_layout.py: 修改 get_name_cell()
|
||||
├─ test_pinmap.py: 更新测试数据和断言
|
||||
└─ 运行完整测试套件
|
||||
|
||||
阶段 4: F011 格式提取(2–3 小时)
|
||||
├─ template_reader.py: 增加提取项
|
||||
├─ xlsx_writer.py: 重写 _styles_xml()
|
||||
├─ xlsx_writer.py: 新增 _build_*_xml() 辅助函数
|
||||
└─ 模板+无模板场景测试
|
||||
|
||||
阶段 5: 收尾(30 分钟)
|
||||
├─ 更新 CHANGELOG.md
|
||||
├─ 更新 features.md 状态
|
||||
├─ 更新 VERSION 文件
|
||||
└─ 运行完整回归测试
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险评估汇总
|
||||
|
||||
| 风险 | 影响 | 概率 | 缓解措施 | 相关功能 |
|
||||
|------|------|------|---------|---------|
|
||||
| F012 需求描述与代码行为不一致,修改方向错误 | **高** | 中 | **先确认再改**,使用实际文件测试 | F012 |
|
||||
| F012 修改破坏往返转换一致性 | **高** | 中 | 运行完整 MAP→List→MAP 往返测试 | F012 |
|
||||
| 模板 cellXfs 结构与输出不兼容 | 中 | 中 | 提供 fallback,模板解析失败 = 默认样式 | F011 |
|
||||
| 用户未放置新模板文件 | 低 | 高 | 优雅降级,日志明确提示缺失的模板名 | F009/F010 |
|
||||
| 旧 PinMAP-Template.xlsx 被意外加载 | 低 | 低 | 显式删除旧模板查找调用 | F009/F010 |
|
||||
| F011 样式重写引入 OOXML 兼容性问题 | 低 | 低 | 严格 XML 构建,测试 Excel/WPS 打开 | F011 |
|
||||
| 两个模板同时存在造成混淆 | 低 | 低 | 日志明确标注每个方向使用的模板文件名 | F009/F010 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 工作量估算
|
||||
|
||||
| 阶段 | 任务 | 预估时间 | 依赖 |
|
||||
|------|------|---------|------|
|
||||
| 确认 | F012 需求确认(测试+分析) | 30 分钟 | 无 |
|
||||
| 开发 | F009 MAP→List 模板分离 | 15 分钟 | 无 |
|
||||
| 开发 | F010 List→MAP 模板分离 | 15 分钟 | 无 |
|
||||
| 开发 | F012 修复(如需要) | 30–60 分钟 | 需求确认 |
|
||||
| 开发 | F011 模板格式提取式应用 | 2–3 小时 | F009+F010 |
|
||||
| 测试 | 单元测试更新 | 30 分钟 | 所有开发 |
|
||||
| 测试 | 集成/回归测试 | 30 分钟 | 所有开发 |
|
||||
| 文档 | CHANGELOG + features.md 更新 | 15 分钟 | 所有开发 |
|
||||
| **总计** | | **4.5–6 小时** | |
|
||||
|
||||
---
|
||||
|
||||
## 11. 总结
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 修改文件数 | 4–5 个(main.py, pinmap_layout.py, template_reader.py, xlsx_writer.py, test_pinmap.py) |
|
||||
| 新增模板文件 | 2 个(BallList-Template.xlsx, BallMAP-Template.xlsx) |
|
||||
| 影响核心模块 | 是(xlsx_writer.py 样式生成逻辑重写) |
|
||||
| 技术难度 | 中(F011 样式重写需理解 OOXML styles.xml 结构) |
|
||||
| 预估工作量 | 4.5–6 小时 |
|
||||
| 推荐 Agent | Python 编码 Agent |
|
||||
| 风险等级 | 中(F012 需先确认,F011 样式重写有复杂度) |
|
||||
|
||||
**关键结论**:
|
||||
|
||||
1. **F012(P0)**:代码当前行为可能已经正确(bottom Name 在 max_row-1,top Name 在 min_row+1 已与 F012 "应改为"目标一致)。**强烈建议在实施前用实际文件验证**,避免过度修改。如果确认无误,仅需增加回归测试。
|
||||
|
||||
2. **F009+F010(P1)**:改动量小(约 30 行),完全独立,可快速完成。核心是新增两个模板查找函数并替换调用点。
|
||||
|
||||
3. **F011(P1)**:改动量最大(约 120 行),需重写 `xlsx_writer.py` 的 `_styles_xml()` 方法。当前代码已部分满足 F011(字体、列宽、行高从模板提取),主要差距在边框和对齐的硬编码。建议采用"从模板读取实际值填充现有样式槽位"的渐进方案。
|
||||
|
||||
4. **向后兼容**:无模板时所有功能完全回退到现有默认样式,不影响已有用户。
|
||||
|
||||
5. **推荐开发顺序**:确认 F012 → 实现 F009+F010 → 修复 F012(如需要) → 实现 F011 → 测试收尾。
|
||||
|
||||
---
|
||||
|
||||
*文档结束 — 请审批后进入编码阶段*
|
||||
|
||||
##
|
||||
Reference in New Issue
Block a user