提交 580949a1 authored 作者: 陈泽健's avatar 陈泽健

feat: 初始化自动化测试平台项目

- 后端 FastAPI + SQLAlchemy + Playwright,31 个文件 ~5300 行
  - 模块管理、用例管理、执行管理、录制器、统计分析、报告管理 API
  - Playwright 测试执行引擎(11 种动作 + 8 种断言)
  - WebSocket 实时推送
- 前端 Vue3 + Element Plus + TypeScript + ECharts,23 个文件 ~5000 行
  - 模块管理、用例管理、用例录制器、执行中心、报告中心、仪表盘
  - 步骤详情面板组件
- Excel 用例导入脚本
- PRD 需求文档、执行计划文档、开发进度报告
- 4 个 Claude Code 技能(CreateCMD、GitCommit、prd-code、prd-plan)
Co-Authored-By: 's avatarClaude <noreply@anthropic.com>
上级 4cc16d1b
此差异已折叠。
---
name: CreateCMD
description: 在当前目录打开指定数量的CMD窗口
---
在当前工作目录下打开指定数量的 CMD 命令提示符窗口。
## Usage
/CreateCMD [数量]
## Description
根据用户输入的数量,在当前工作目录下打开对应数量的 CMD 窗口。
**功能:**
- 询问用户需要打开的 CMD 窗口数量
- 在当前工作目录下打开指定数量的 CMD 窗口
- 每个窗口独立运行,互不影响
**示例:**
```
/CreateCMD 3 # 在当前目录打开 3 个 CMD 窗口
/CreateCMD # 会询问需要打开的数量,然后执行
```
**执行步骤:**
1. 如果用户未提供数量参数,使用 AskUserQuestion 询问用户需要打开几个 CMD 窗口(提供 1、2、3、4 作为常见选项,也可输入其他数量)
2. 获取当前工作目录路径(Windows 形式,如 `E:\GithubData\ubains-module-test`
3. **定位 claude 的完整路径**:在 bash 中运行 `which claude`,得到形如 `/e/nodejs/claude` 的路径,转换成 Windows 形式并补 `.cmd`,即 `E:\nodejs\claude.cmd`。原因是 `E:\nodejs` 不在 Windows PATH 里,新开的 cmd 窗口直接敲 `claude` 会报"不是内部或外部命令",必须用完整路径。
4. **生成一个临时批处理文件**(如 `E:\GithubData\ubains-module-test\.claude\skills\CreateCMD\_run_claude.bat`),内容分两行,避免 `&&` 复合命令在引号传递时被截断:
```bat
@echo off
cd /d <当前目录>
<claude 完整路径> --permission-mode bypassPermissions
```
5. **用真正的 Windows cmd `start` 打开 N 个窗口运行该 bat**。在 bash 中通过 `cmd.exe /c start` 调用(注意:bash 里直接写 `start` 会走 `/usr/bin/start` 这个 MSYS 封装脚本,它会把 `"cd ... && claude"` 这种复合命令的引号传坏,导致 claude 不执行):
```bash
for i in $(seq 1 N); do
MSYS_NO_PATHCONV=1 cmd.exe /c start "Claude $i" cmd /k "<bat 完整路径>"
done
```
6. 确认并告知用户已成功打开的窗口数量
**注意事项(两个坑,务必遵守):**
- **不要用裸 `start`**:bash 里的 `start` 解析为 `/usr/bin/start`(MSYS 封装),会损坏 `&&` 复合命令的引号,表现为窗口打开、cd 成功但 claude 不执行。必须用 `cmd.exe /c start` 走 Windows 原生 `start`,并加 `MSYS_NO_PATHCONV=1` 防止 MSYS 路径转换。
- **不要用裸 `claude`**`E:\nodejs` 不在 Windows PATH,cmd 窗口里找不到。必须用 `which claude` 推导出的 `claude.cmd` 完整路径。
- **用 .bat 文件承载命令序列**,而不是 `"cd ... && claude ..."` 单行,可彻底规避复合命令引号丢失问题。
- `/k` 参数确保窗口执行完命令后保持打开;工作目录设为 Claude Code 的当前工作目录。
- 数量上限建议不超过 10 个,避免系统资源占用过高。
@echo off
cd /d E:\GithubDatains-module-test\develop
E:
odejs\claude.cmd --permission-mode bypassPermissions
---
name: git-commit
description: 代码提交辅助 - 审查待提交文件、自动生成 Conventional Commits 信息、执行 git add/commit
---
代码提交辅助工具。审查当前工作区所有改动文件,过滤无关/临时文件,与用户确认提交范围后自动生成符合 Conventional Commits 规范的提交信息,执行提交。
## Usage
```
/git-commit # 审查所有改动并提交
/git-commit 仅审查 # 仅审查文件,不执行提交
```
## 执行流程
### 阶段 1:扫描与分类
1. **收集改动**:执行 `git status --porcelain``git diff --stat`,获取所有 staged 和 unstaged 文件
2. **获取变更摘要**:对每个改动文件,执行 `git diff` / `git diff --cached` 获取具体变更内容摘要
3. **分类标记**
| 分类 | 判定标准 | 标记 |
|------|----------|------|
| ✅ 应提交 | 源码/配置/文档/脚本/技能文件,有实质改动 | `✅` |
| ⚠️ 可疑 | 临时脚本(poll_*/ssh*/run_*.sh)、调试日志(.log)、一次性辅助脚本、重复文件(如 phase9_v2 与 phase9_robust 功能重复) | `⚠️` |
| ❌ 不应提交 | 编译产物、.pyc、node_modules、IDE 配置、系统文件(.DS_Store)、本地临时文件 | `❌` |
**关键判断规则:**
- 新 skill 目录(`.claude/skills/<name>/`)→ 如果是完整的功能 skill,✅;如果是空的或测试的,⚠️
- `AuxiliaryTool/ScriptTool/` 下的脚本 → 如果是已有脚本的重复/调试版本(如 `diag_*``check_*``fix_*`),⚠️ 提醒用户
- `AuxiliaryTool/ScriptTool/` 下的新目录 → 如果是独立功能模块,✅
- `.log` 文件 → ❌
-`_` 开头的文件 → 检查内容,可能是临时文件
- 中文路径文件 → 正常判断,不作为排除依据
### 阶段 2:展示与确认
以结构化表格展示所有文件及其分类:
```
====== 📋 待提交文件审查 ======
✅ 应提交(N 个):
| # | 文件 | 状态 | 变更概要 |
|---|------|------|----------|
| 1 | .gitignore | M | 新增 xxx 忽略规则 |
| 2 | ... | A | ... |
⚠️ 可疑(M 个):
| # | 文件 | 状态 | 原因 |
|---|------|------|------|
| 1 | phase9_fix_output.log | ?? | 调试日志,通常不提交 |
| 2 | ... | ?? | 临时诊断脚本 |
❌ 不应提交(K 个):
| # | 文件 | 原因 |
|---|------|------|
💡 建议排除的可疑文件:
- xxx(原因)
- yyy(原因)
是否按建议范围提交?(是/否/自定义)
```
使用 `AskUserQuestion` 让用户确认:
1. 是否按建议范围提交?
2. 如果有可疑文件,是否要包含某些可疑文件?
### 阶段 3:生成 Commit Message
根据用户确认的文件列表和变更内容,自动生成符合 [Conventional Commits](https://www.conventionalcommits.org/) 规范的提交信息。
**类型判定规则:**
| 类型 | 适用场景 |
|------|----------|
| `feat` | 新增功能、新脚本、新 skill、新工具 |
| `fix` | Bug 修复、脚本错误修正 |
| `docs` | 纯文档改动(PRD、README、注释) |
| `refactor` | 代码重构,不改变功能 |
| `chore` | 配置变更、依赖更新、构建脚本 |
| `test` | 测试相关 |
| `style` | 格式调整(不影响逻辑) |
| `perf` | 性能优化 |
**范围(scope)自动推断:**
| 范围 | 匹配规则 |
|------|----------|
| `deploy` | 文件在 `AuxiliaryTool/ScriptTool/RemoteDeploy/``自动化部署脚本/` |
| `docker` | 文件在 DM8-Docker 相关目录 |
| `monitor` | 文件在 `AuxiliaryTool/ScriptTool/ServiceMonitor/` 或定时脚本 |
| `skills` | 文件在 `.claude/skills/` |
| `middleware` | 文件在 `MiddlewareVerify/` |
| `config` | `.gitignore``config.json` 等配置文件 |
| `docs` | `Docs/` 目录 |
| `test` | 测试脚本 |
**生成格式:**
```
<type>(<scope>): <简短中文描述>
<详细说明(可选,列出每个主要变更)>
Co-Authored-By: Claude <noreply@anthropic.com>
```
**多类型混合**:如果改动涉及多种类型,使用主体类型 + 详细说明中列出其他改动。
**示例:**
```
feat(skills): 新增 git-commit 代码提交辅助技能
新增 .claude/skills/GitCommit/SKILL.md,提供文件审查、commit message 自动生成、交互式提交流程
Co-Authored-By: Claude <noreply@anthropic.com>
```
### 阶段 4:执行提交(🚨 双重确认门控,不可跳过)
**两道确认门,任何一道未通过都不执行 git 操作:**
- **门 1(范围确认)**:阶段 2 已确认提交哪些文件 —— 用户明确点头"按此范围提交"才进入下一步
- **门 2(message 确认)**:展示完整 commit message,**必须用 `AskUserQuestion` 让用户选择**
- ✅ 确认提交
- ✏️ 修改 message(用户给出修改意见后重新生成,再确认)
- ❌ 取消提交
**门 2 用户选"确认提交"后,才执行:**
1. `git add <确认的文件列表>`**逐文件 add,不使用 `git add -A` 或 `git add .`**
2. `git commit -m "<生成的 message>"`
3. **不要自动 push**:提交成功后告知用户,并询问是否需要 `git push`(push 也需用户明确同意才执行)
**禁止行为:**
- ❌ 跳过任一确认门直接 add/commit
- ❌ 把"未明确拒绝"当作"同意"——必须用户主动确认才执行
- ❌ 一次性把"提交 + push"打包执行,两者必须分别确认
## 注意事项
- **🚨 双重确认门控(最高优先级)**:范围确认(门1)+ message 确认(门2)都必须用户主动点头,未明确同意绝不执行 add/commit/push
- **绝不使用 `git add .` 或 `git add -A`**:必须逐文件 add,只添加用户确认的文件
- **不自动 push**:push 需用户明确指令
- **不修改 .gitignore**:如需添加忽略规则,提醒用户自行添加
- **commit message 末尾必须有 `Co-Authored-By: Claude <noreply@anthropic.com>`**
- **中文描述优先**:commit 摘要使用中文,与仓库已有提交风格保持一致
- **避免宽泛描述**:如"更新代码"、"修复bug",应具体到"修复 phase9 权限绑定后菜单不刷新问题"
- **审查优先于提交**:可疑文件宁可多确认,不可误提交
---
name: prd-code
description: 解析执行计划文档,生成或更新代码
---
Parse an execution plan document and generate or update code.
## Usage
/prd-code <执行计划文档路径>
## Description
解析执行计划文档,生成或更新代码。
**功能:**
- 读取指定的执行计划文档
- 分析任务分解和实施步骤
- 根据计划生成或更新对应的代码文件
**示例:**
```
/prd-code "Docs/PRD/服务自检/_PRD_服务自检需求文档_计划执行.md"
```
**执行步骤:**
1. 读取执行计划文档
2. 分析实施计划(任务分解、脚本文件、代码位置、配置参数、验收标准)
3. 检查现有代码
4. 生成或更新代码(新增功能、修改功能、遵循代码规范、添加注释)
5. 验证并输出修改摘要
**安全规则:**
- 不修改文件路径以外的任何文件
- 遵循现有代码风格和命名规范
- 添加必要的错误处理
- 不引入安全漏洞
---
name: prd-plan
description: 解析 PRD 需求文档,生成执行计划文档
---
Parse a PRD document and generate an execution plan.
## Usage
/prd-plan <需求文档路径>
## Description
解析 PRD 需求文档,生成执行计划文档。
**功能:**
- 读取指定的 PRD 需求文档
- 分析需求内容,提取关键任务和验收标准
- 生成对应的执行计划文档
**输出:**
- 执行计划文档命名为:`<需求文档名>_计划执行.md`
- 保存位置:与需求文档相同目录
**示例:**
```
/prd-plan "Docs/PRD/服务自检/_PRD_服务自检需求文档.md"
```
**执行步骤:**
1. 读取需求文档
2. 分析需求内容(项目背景、执行目标、任务模块、配置要求、验收标准)
3. 生成执行计划文档(包含:执行概述、任务分解与实施计划、验收标准、测试计划、风险评估、实施记录、后续工作、附录)
4. 显示生成的执行计划文档路径和摘要
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:__init__.py
模块描述:后端应用包初始化
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
"""
__version__ = "1.0.0"
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:config.py
模块描述:应用配置管理
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
"""
import os
from typing import Optional
class Settings:
"""
应用配置类
Attributes:
APP_NAME (str): 应用名称
APP_VERSION (str): 应用版本
DEBUG (bool): 是否调试模式
DATABASE_URL (str): 数据库连接 URL
CORS_ORIGINS (list): 允许的跨域来源
PLAYWRIGHT_HEADLESS (bool): Playwright 是否无头模式
SCREENSHOT_DIR (str): 截图存储目录
"""
# 基础配置
APP_NAME: str = "平台自动化测试系统"
APP_VERSION: str = "1.0.0"
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
# 数据库配置
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"sqlite+aiosqlite:///./data/test_platform.db"
)
# CORS 配置
CORS_ORIGINS: list = ["*"] # 生产环境应限制来源
# Playwright 配置
PLAYWRIGHT_HEADLESS: bool = os.getenv(
"PLAYWRIGHT_HEADLESS",
"true"
).lower() == "true"
PLAYWRIGHT_TIMEOUT: int = int(os.getenv("PLAYWRIGHT_TIMEOUT", "30000"))
# 文件存储配置
SCREENSHOT_DIR: str = os.getenv("SCREENSHOT_DIR", "./data/screenshots")
REPORT_DIR: str = os.getenv("REPORT_DIR", "./data/reports")
# 日志配置
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
class Config:
"""Pydantic 配置"""
env_file = ".env"
# 全局配置实例
settings = Settings()
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:database.py
模块描述:数据库连接配置和会话管理
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
"""
import logging
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
create_async_engine,
async_sessionmaker
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# 配置日志
logger = logging.getLogger(__name__)
# 创建异步引擎(SQLite 需要特殊配置避免锁定)
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
future=True,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
pool_pre_ping=True,
)
# 创建异步会话工厂
async_session_maker = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False
)
class Base(DeclarativeBase):
"""
数据库模型基类
所有数据库模型都应继承此类。
Example:
>>> class Module(Base):
>>> __tablename__ = "modules"
>>> id: Mapped[str] = mapped_column(String(32), primary_key=True)
"""
pass
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""
获取数据库会话(依赖注入)
用于 FastAPI 路由的依赖注入,自动管理会话生命周期。
Yields:
AsyncSession: 数据库会话对象
Example:
>>> from fastapi import Depends
>>>
>>> @app.get("/modules")
>>> async def get_modules(db: AsyncSession = Depends(get_db)):
>>> result = await db.execute(select(Module))
>>> return result.scalars().all()
"""
async with async_session_maker() as session:
try:
yield session
await session.commit()
except Exception as e:
await session.rollback()
logger.error(f"数据库操作失败: {str(e)}")
raise
finally:
await session.close()
async def init_db() -> None:
"""
初始化数据库
创建所有表结构。在应用启动时调用。
Example:
>>> import asyncio
>>> asyncio.run(init_db())
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("数据库表结构初始化完成")
async def drop_db() -> None:
"""
删除所有数据库表
用于测试环境清理数据库。
"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
logger.info("数据库表结构已删除")
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:__init__.py
模块描述:执行器包初始化
作者:czj
创建日期:2026-07-09
"""
__all__ = []
\ No newline at end of file
此差异已折叠。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:main.py
模块描述:FastAPI 应用入口,负责启动 Web 服务和注册路由
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
"""
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.config import settings
from app.database import init_db
from app.routers import modules, cases, executions, recorder, stats, reports
# 配置日志
logging.basicConfig(
level=logging.INFO if not settings.DEBUG else logging.DEBUG,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
应用生命周期管理
在应用启动时初始化数据库,在关闭时清理资源。
Yields:
None
"""
# 启动时
logger.info(f"正在启动 {settings.APP_NAME} v{settings.APP_VERSION}")
# 确保必要的目录存在
Path(settings.SCREENSHOT_DIR).mkdir(parents=True, exist_ok=True)
Path(settings.REPORT_DIR).mkdir(parents=True, exist_ok=True)
logger.info("目录初始化完成")
await init_db()
logger.info("数据库初始化完成")
yield
# 关闭时
logger.info("应用正在关闭...")
# 创建 FastAPI 应用实例
app = FastAPI(
title=settings.APP_NAME,
description="Web 可视化自动化测试平台 API - 支持用例录制、执行、结果分析",
version=settings.APP_VERSION,
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# 配置 CORS 中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ==================== 注册路由 ====================
app.include_router(
modules.router,
prefix="/api/modules",
tags=["模块管理"]
)
app.include_router(
cases.router,
prefix="/api/cases",
tags=["用例管理"]
)
app.include_router(
executions.router,
prefix="/api/executions",
tags=["执行管理"]
)
app.include_router(
recorder.router,
prefix="/api/recorder",
tags=["用例录制"]
)
app.include_router(
stats.router,
prefix="/api/stats",
tags=["统计分析"]
)
app.include_router(
reports.router,
prefix="/api/reports",
tags=["报告管理"]
)
# ==================== 根路径 ====================
@app.get("/", tags=["健康检查"])
async def root():
"""
根路径健康检查
返回服务状态信息,用于确认服务正常运行。
Returns:
dict: 服务状态信息
"""
return {
"status": "ok",
"message": f"{settings.APP_NAME} API 服务运行中",
"version": settings.APP_VERSION
}
@app.get("/health", tags=["健康检查"])
async def health_check():
"""
详细健康检查
返回服务和数据库连接状态。
Returns:
dict: 详细健康状态
"""
return {
"status": "healthy",
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"debug": settings.DEBUG
}
# ==================== 文件服务 ====================
@app.get("/api/files/{file_path:path}", tags=["文件服务"])
async def serve_file(file_path: str):
"""
提供截图等静态文件访问
支持路径格式:自定义相对路径
Args:
file_path (str): 文件相对路径
Returns:
FileResponse: 文件响应
"""
# 支持多种可能的路径
possible_paths = [
Path(file_path), # 直接路径
Path("data") / file_path, # data/ 前缀
Path(settings.SCREENSHOT_DIR) / Path(file_path).name, # 截图目录
]
for p in possible_paths:
resolved = p.resolve()
if resolved.exists() and resolved.is_file():
return FileResponse(str(resolved))
raise HTTPException(
status_code=404,
detail=f"文件不存在: {file_path}"
)
\ No newline at end of file
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:__init__.py
模块描述:数据库模型包初始化
作者:czj
创建日期:2026-07-09
"""
from app.models.module import Module
from app.models.test_case import TestCase
from app.models.execution import Execution
from app.models.case_result import CaseResult
__all__ = [
"Module",
"TestCase",
"Execution",
"CaseResult",
]
\ No newline at end of file
此差异已折叠。
此差异已折叠。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:module.py
模块描述:测试模块数据库模型定义
作者:czj
创建日期:2026-07-09
最后修改:2026-07-09
"""
from datetime import datetime
from sqlalchemy import String, Text, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Module(Base):
"""
测试模块数据库模型
用于管理测试用例的分类,每个模块包含一组相关的测试用例。
Attributes:
id (str): 模块唯一标识
name (str): 模块名称,最大长度100字符
description (str): 模块描述
icon (str): 模块图标标识
order (int): 排序序号
created_at (datetime): 创建时间
updated_at (datetime): 更新时间
Example:
>>> module = Module(
... id="module_abc123",
... name="登录模块",
... description="用户登录相关功能测试"
... )
"""
__tablename__ = "modules"
id: Mapped[str] = mapped_column(String(32), primary_key=True, comment="模块ID")
name: Mapped[str] = mapped_column(String(100), nullable=False, comment="模块名称")
description: Mapped[str] = mapped_column(Text, default="", comment="模块描述")
icon: Mapped[str] = mapped_column(String(50), default="folder", comment="模块图标")
order: Mapped[int] = mapped_column(Integer, default=0, comment="排序序号")
created_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
comment="创建时间"
)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=datetime.utcnow,
onupdate=datetime.utcnow,
comment="更新时间"
)
# 关联用例(一对多)
cases: Mapped[list["TestCase"]] = relationship(
"TestCase",
back_populates="module",
cascade="all, delete-orphan"
)
def __repr__(self) -> str:
"""字符串表示"""
return f"<Module(id={self.id}, name={self.name})>"
def to_dict(self) -> dict:
"""
转换为字典
Returns:
dict: 模块数据字典
"""
return {
"id": self.id,
"name": self.name,
"description": self.description,
"icon": self.icon,
"order": self.order,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
模块名称:__init__.py
模块描述:业务服务包初始化
作者:czj
创建日期:2026-07-09
"""
__all__ = []
\ No newline at end of file
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
此差异已折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论