在Cypress中,抽象到什么程度才合适?用还是不用页面对象模型(POM)?

2025-03-27   出处: dev.to  作/译者:Camille/小窝

深入探讨在你的 Cypress 测试套件中使用页面对象模型(POM)的优点和缺点。发现这种抽象工具可能增强或阻碍你的自动化策略的一些场景。

1、引言

众所周知,在Cypress生态系统中,普遍认为页面对象模型(POM)并不真正适用于此。许多人认为它由从Selenium转型过来的测试者引入的。即使是经验丰富的Cypress专业人士和Cypress官方博客也常常反对在设置测试套件时使用POM方法。

如何在Cypress中使用POM,有一些比较好的文章,可以阅读BrowserStack的"Understanding Page Object Model in Cypress"和LambdaTest的"How to Implement Cypress Page Object Model (POM)"。如果你是POM的支持者,并希望在Cypress中使用它,我推荐这两篇文章。

然而,Cypress社区中部分人有不同的看法,他们倾向于直接在测试代码中使用Cypress命令和断言,并选择应用程序操作(Application Actions)而不是POM。有兴趣深入了解应用程序操作的细节吗?我推荐Gleb Bahmutov的"Application Actions: Use Them Instead of Page Objects" 和Filip Hric的"Page objects vs. App actions in Cypress" 这两篇文章,分别在Cypress官方博客和Applitools的博客上。

我相信应用程序操作对于设置测试的初始状态非常棒,它可以加快测试执行速度。但也存在一个问题:你需要非常了解应用程序的事件,这在黑盒测试中可能是比较有挑战性的。在特定情况下它是一个可靠的工具,但是要注意,使用应用程序操作并不意味着在一些测试框架中需要排除POM方法。

但是,应用程序操作并不是本文的重点,所以我将回到本文的主要问题:

我是否应该大胆的在我的Cypress测试中使用页面对象模型,还是我疯了?

好吧,事情是这样的,请耐心听我讲完这篇文章。我不是来规定你是否应该在Cypress测试框架中使用POM的。相反,我想分享一些我发现POM不必要的场景,以及一些“类似”POM 方法非常有用的场景。所以,让我们深入探讨一下,在什么时候使用 POM 是合适的,而在什么时候不使用 POM 更好!

2、对比

让我们探讨一下如何就是否适用POM设计模式做出合理的决策过程。

在这个演示中,我们将使用 LambdaTest Playground 页面作为一个实际示例,并修改"How to Implement Cypress Page Object Model (POM)"这篇文章中描述的示例。

我们的演示将由两个测试套件组成,在cypress.config.js 文件中设置 baseUrl 为:

baseUrl: "https://ecommerce-playground.lambdatest.io/",

第一个测试套件

第一个测试文件将检查主页:

页面标题

页脚版权声明

搜索功能​

如果我们使用典型的教科书式的页面对象模型(POM)方法来实现这些测试,可能如下所示:

// `Home.js` (POM file)

class Home {
    visit() {
        cy.visit('/')
    }

    getPageTitle() {
        return cy.title()
    }

    getCopyright() {
        return cy.get(
            '.footer p'
        )
    }

    searchInput(text) {
        return cy.get(
            'input[name="search"]'
        ).first().type(text)
    }

    getSearchButton() {
        return cy.get(
            '#search > div.search-button > button'
        ).first()
    }
}
module.exports = Home


// `test-home-page-pom.cy.js` (Test file to verify the Home page)

import Home from '../support/pages/Home'

const home = new Home()

describe('testing home page', () => {
    beforeEach(() => {
        home.visit()
    })

    it('should visit home page and validate footer', () => {
        home.getPageTitle().should('eq', 'Your Store')
        home.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')
    })

    it('should search for a product', () => {
        home.searchInput('iphone')
        home.getSearchButton().click()
    })
})

完美、优雅、整洁,不是吗?

然而,让我们花点时间更仔细检查代码。

在 test-home-page-pom.cy.js 文件的 beforeEach() 钩子中,调用了 home.visit() 来导航到主页。

  beforeEach(() => {

        home.visit()

    })

而visit()函数是在Home.js 这个页面对象模型(POM)文件中创建的。

class Home {

    visit() {

        cy.visit('/')

    }

    // ...

}

但Cypress已经有了一个cy.visit()命令可以精确地完成此操作,这是在Home.js POM 的visit()函数中实际调用的唯一内容。

所以我们真的需要那个函数吗?

让我们继续沿着这个思路思考。

在test-home-page-pom.cy.js的第一个测试中,我们通过分别调用Home.js POM的函数home.getPageTitle()和home.getCopyright()来获取标题和页脚版权,以便检查它们。

 it('should visit home page and validate footer', () => {

        home.getPageTitle().should('eq', 'Your Store')

        home.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')

    })

而这些函数是在Home.js POM文件中以下列方式创建的:

// ...

    getPageTitle() {

        return cy.title()

    }

    getCopyright() {

        return cy.get(

            '.footer p'

        )

    }

    // ...

等等,我在这里看到了一个重复的模式……Cypress 已经有一个 cy.title() 命令来获取页面标题,还有一个cy.get()命令来检索与选择器匹配的 DOM 元素。

让我再问一次,我们真的需要在Home.js POM 中定义这些函数,并且必须创建类的实例来调用这些函数么,为什么?

我敢肯定,你脑海中立即浮现的答案可能是:“朋友!为了在测试页面时保持抽象。如果你不相信我,看看那个套件中的第二个测试!”

有道理!让我们看看test-home-page-pom.cy.js文件中的第二个测试。

it('should search for a product', () => {

        home.searchInput('iphone')

        home.getSearchButton().click()

    })

这个测试输入 “iphone” 作为要搜索的产品,然后点击搜索按钮。

但是这次调用的Home.js POM 函数稍微有点复杂:

  // ...

    searchInput(text) {

        return cy.get(

            'input[name="search"]'

        ).first().type(text)

    }

    getSearchButton() {

        return cy.get(

            '#search > div.search-button > button'

        ).first()

    }

    // ...

似乎你还需要调用 Cypress 命令cy.first(),因为同一页面上有多个 DOM 元素与选择器匹配。

这是事实,但也许它们并不是真的必需的。如果我们找到一个更好的选择器,你根本不需要它们呢?记住,在房地产中,一切都关乎位置、位置、位置。测试也不例外:一切都关乎选择器、选择器、选择器(毕竟,有些人也把它们称为定位器)

通过更深入地研究页面设计,你可以发现,input[name="search"]你可以使用#main-header input[name="search"]代替,#​search > div.search-button > button,你可以使用#main-header #search > div.search-button > button代替。在这两种情况下,它都返回单个元素而不是多个!

我们的Home.js POM 函数可以简化为如下所示:

 // ...

    searchInput(text) {

        return cy.get(

            '#main-header input[name="search"]'

        ).type(text)

    }

    getSearchButton() {

        return cy.get(

            '#main-header #search > div.search-button > button'

        )

    }

    // ...

searchInput(text)只是先执行了一个cy.get(),然后cy.type(),而函数getSearchButton()执行一个cy.get(),本质上是复制了现有的 Cypress 命令。

我们可以很容易地将cy.type()命令从Home.js POM 移到测试文件test-home-page-pom.cy.js中。毕竟,我们在测试文件中正在执行像.click()这样的 UI 操作,所以为了保持一致性,这是有意义的,而且它不会真的使测试代码变得更臃肿,对吧?

 it('should search for a product', () => {

        home.getSearchInput().type('iphone')

        home.getSearchButton().click()

    })

我相信将cy.type()与cy.click()一起放在测试中,会帮助测试人员确切的了解该测试的作用。

如果几周或几个月后,测试人员重新review代码,看到类似home.searchInput('iphone')这样的内容,他们可能会想知道它到底做了什么,或者觉得需要双重检查。这可能会导致需要花费一些时间来拼凑所有内容,特别是如果他们对应用程序或为该页面实现的页面对象模型不太熟悉的话。

如果不需要Home.js POM 这个文件,因为它仅仅是包装了现有的 Cypress 自定义命令,为什么还要保留它呢?测试可以简单地写在一个文件test-home-page-pom.cy.js中,如下所示:

describe('testing home page', () => {

    beforeEach(() => {

        cy.visit('/')

    })

    it('should visit home page', () => {

        cy.title().should('eq', 'Your Store')

        cy.get('.footer p').should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should search for a product', () => {

        cy.get('#main-header input[name="search"]').type('iphone')

        cy.get('#main-header #search > div.search-button > button').click()

    })

})

足够简单,不是吗?

这时你可能会插话:“哇,哇,哇,朋友!还记得吗?抽象!如果在应用程序的生命周期中选择器发生变化怎么办?把所有选择器放在一个地方不是更好吗,这样你就不必在所有测试中四处寻找来更改选择器了”

你是对的,我很高兴你问了这个问题!那么,我们为什么不两全其美呢?

如果我们创建一个Home.js 文件,它类似于 POM 文件,但只包含选择器(从现在起我们将其称为类 POM 文件)。我知道这可能听起来不寻常,但请耐心听我说。你必须承认,这听起来有点有趣。

这意味着:

一个页面的所有选择器都在一个地方(而不是分散在测试文件中)。

我们不必维护那些复制现有 Cypress 命令的不必要函数。

当我们将来review测试用例时,我们不需要在所有这些页面对象模型文件中跳来跳去来弄清楚每个函数的作用,非常节省我们宝贵的时间。

在这种情况下,我们的代码可能如下所示:

// `Home.js` (POM-ish file)

class Home {

    static Copyright = '.footer p'

    static SearchInput = '#main-header input[name="search"]'

    static SearchButton = '#main-header #search > div.search-button > button'

}

export default Home

// `test-home-page-pomish.cy.js`

import Home from '../support/pages/Home'

describe('testing home page', () => {

    beforeEach(() => {

        cy.visit('/')

    })

    it('should visit home page', () => {

        cy.title().should('eq', 'Your Store')

        cy.get(Home.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should search for a product', () => {

        cy.get(Home.SearchInput).type('iphone')

        cy.get(Home.SearchButton).click()

    })

})

我们把所有选择器紧凑地打包在一个文件中,代表正在测试的页面。它就像一个将选择器名称与 DOM 元素及其 CSS 选择器链接起来的速查表。不费吹灰之力,也不混乱!

这个测试文件仍然非常整洁且易于阅读,你一眼就能确切知道它做了什么(没有意外)。此外,它保持了我们在测试不断研发的应用程序时可能需要的那种抽象级别。

第二个测试套件

第二个测试文件将检查博客页面:

页面标题

页脚版权,必须与主页的页脚版权匹配

可用类别​

在经典的 POM 方法中,代码可能如下所示:

// `Blog.js` (Page object file)

class Blog {

    constructor() {

        this.url = '/index.php?route=extension/maza/blog/home'

    }

    visit() {

        cy.visit(this.url)

    }

    getPageTitle() {

        return cy.title()

    }

    getCopyright() {

        return cy.get(

            '.footer p'

        )

    }

    getFirstCategoryButton() {

        return cy.get('#entry_210963 > div > a:nth-child(1)')

    }

    getSecondCategoryButton() {

        return cy.get('#entry_210963 > div > a:nth-child(2)')

    }

    getThirdCategoryButton() {

        return cy.get('#entry_210963 > div > a:nth-child(3)')

    }

    getFourthCategoryButton() {

        return cy.get('#entry_210963 > div > a:nth-child(4)')

    }

}

module.exports = Blog

// `test-blog-page-pom.cy.js` (Test file to verify the Blog page)

import Blog from '../support/pages/Blog'

const blog = new Blog()

describe('testing blog page', () => {

    beforeEach(() => {

        blog.visit()

    })

    it('should visit the blog page and validate footer', () => {

        blog.getPageTitle().should('eq', 'Blog - Poco theme')

        blog.getCopyright().should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should have correct category names', () => {

        blog.getFirstCategoryButton().should('contain.text', 'Business')

        blog.getSecondCategoryButton().should('contain.text', 'Electronics')

        blog.getThirdCategoryButton().should('contain.text', 'Technology')

        blog.getFourthCategoryButton().should('contain.text', 'Fashion')

    })

})

我必须承认,我不喜欢在页面对象模型文件中存储应用程序的 URL 路径。相反,我更愿意将博客页面的 URL'/index.php?route=extension/maza/blog/home'放在cypress.config.js文件中。这样,我可以在测试项目的任何地方使用Cypress.config()来访问它。

urlBlogPage: '/index.php?route=extension/maza/blog/home',

如果我们将用于第一个测试套件的相同标准应用于该第二个测试套件(我们称之为类 POM 方法),代码将如下所示:

// `Blog.js`

class Blog {

    static Copyright = '.footer p'

    static FirstCategoryButton = '#entry_210963 > div > a:nth-child(1)'

    static SecondCategoryButton = '#entry_210963 > div > a:nth-child(2)'

    static ThirdCategoryButton = '#entry_210963 > div > a:nth-child(3)'

    static FourthCategoryButton = '#entry_210963 > div > a:nth-child(4)'

}

export default Blog

// `test-blog-page-pomish.cy.js`

import Blog from "../support/pages/Blog"

describe("testing blog page", () => {

    beforeEach(() => {

        cy.visit(Cypress.config('urlBlogPage'))

    })

    it('should visit the blog page and validate footer', () => {

        cy.title().should('eq', 'Blog - Poco theme')

        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should have correct category names', () => {

        cy.get(Blog.FirstCategoryButton).should('contain.text', 'Business')

        cy.get(Blog.SecondCategoryButton).should('contain.text', 'Electronics')

        cy.get(Blog.ThirdCategoryButton).should('contain.text', 'Technology')

        cy.get(Blog.FourthCategoryButton).should('contain.text', 'Fashion')

    })

})

但我必须再次坦白…… ,我欺骗了你。

我之前提到在类 POM 文件中只包含选择器,不包括函数。

难道所有这些定位器不是遵循一个非常明显的通用模式吗?static FirstCategoryButton = '#entry_210963 > div > a:nth-child(1)' 、static SecondCategoryButton = '#​entry_210963 > div > a:nth-child(2)' 等等。

通过在Blog.js文件中使用箭头函数动态生成类别按钮的选择器,可以大大简化这个过程。尽管它是一个函数,但仍然保持了直观且易于理解的映射模式:

// `Home.js`

class Blog {

    static Copyright = '.footer p'

    static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`

}

export default Blog

// `test-blog-page-pomish.cy.js`

import Blog from "../support/pages/Blog"

describe("testing blog page", () => {

    beforeEach(() => {

        cy.visit(Cypress.config('urlBlogPage'))

    })

    it('should visit the blog page and validate footer', () => {

        cy.title().should('eq', 'Blog - Poco theme')

        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should have correct category names', () => {

        cy.get(Blog.CategoryButton(1)).should('contain.text', 'Business')

        cy.get(Blog.CategoryButton(2)).should('contain.text', 'Electronics')

        cy.get(Blog.CategoryButton(3)).should('contain.text', 'Technology')

        cy.get(Blog.CategoryButton(4)).should('contain.text', 'Fashion')

    })

})

这看起来更整洁了。你得承认这一点吧!

你可以通过将第二个测试中的断言包装在一个循环中来使代码更加紧凑但仍然清晰。然而,这是另一篇博客文章的主题: Dynamic Tests in Cypress: To Loop or Not To Loop

通过 UI 组件而不是页面重构类 POM 文件

我相信你已经注意到,在第一个和第二个测试套件中,初始测试都检查主页和博客页面的页脚版权信息是否相同。

甚至,很可能页脚是我们应用程序中大多数页面(如果不是全部)共享的通用 UI 组件。然而,我们仍然需要在每个页面上验证版权信息是否存在且一致。

由于每个页面的选择器都在每个页面的类 POM 文件中,我们被迫在两个文件中都包含版权 DOM 元素的选择器:

// `Home.js`

class Home {

    static Copyright = '.footer p'

    // ...

}

// `Blog.js`

class Blog {

    static Copyright = '.footer p'

    // ...

}

如果…… 我们重构类 POM 文件,以对应 UI 组件而不是整个页面会怎样呢?

以下可能是我们的案例 UI 组件:

搜索

页脚

类别

重构后,我们的两个测试套件将如下所示:

// `Search.js`

class Search{

    static SearchInput = '#main-header input[name="search"]'

    static SearchButton = '#main-header #search > div.search-button > button'

}

export default Search

// `Footer.js`

class Footer{

    static Copyright = '.footer p'

}

export default Footer

// `Categories.js`

class Categories{

     static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`

}

export default Categories

// `test-home-page-pomish.cy.js`

import Search from '../support/pages/Search'

import Footer from '../support/pages/Footer'

describe('testing home page', () => {

    beforeEach(() => {

        cy.visit('/')

    })

    it('should visit home page', () => {

        cy.title().should('eq', 'Your Store')

        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should search for a product', () => {

        cy.get(Search.SearchInput).type('iphone')

        cy.get(Search.SearchButton).click()

    })

})

// `test-blog-page-pomish.cy.js`

import Categories from "../support/pages/Categories"

import Footer from "../support/pages/Footer"

describe("testing blog page", () => {

    beforeEach(() => {

        cy.visit(Cypress.config('urlBlogPage'))

    })

    it('should visit the blog page and validate footer', () => {

        cy.title().should('eq', 'Blog - Poco theme')

        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    })

    it('should have correct category names', () => {

        cy.get(Categories.CategoryButton(1)).should('contain.text', 'Business')

        cy.get(Categories.CategoryButton(2)).should('contain.text', 'Electronics')

        cy.get(Categories.CategoryButton(3)).should('contain.text', 'Technology')

        cy.get(Categories.CategoryButton(4)).should('contain.text', 'Fashion')

    })

})

如果你问我,我真的认为最终的代码更易于理解,并且绝对更易于维护。但是,嘿,别忘了,这只是我的观点!

额外提示:更进一步 —— 在有帮助时使用自定义命令或实用函数

我相信我们还可以再优化一点,最后一次完善这些测试套件。

在这种情况下,多个测试套件需要验证页面标题和页脚版权,我们可以在commands.js文件中创建一个自定义命令来包含这些断言。自定义命令对于捆绑在不同测试套件中共享的常见操作特别有用。如果可以,尽可能保持 DRY,特别是如果它有帮助的话。

// `support/commands.js`

import Blog from './pages/Blog'

Cypress.Commands.add(

    'checkTitleAndFooter',

    (title) => {

        cy.title().should('eq', title)

        cy.get(Blog.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    }

);

现在我必须再次承认我骗了你,但我保证这是最后一次。

我认为我们应该在第二个测试套件中创建一个 JavaScript 函数,使用循环来断言所有类别。这个函数将接受一个包含要按顺序检查的类别列表的数组。由于这个检查仅在博客页面上进行,因此将代码保留在测试套件中,会比在commands.js文件中创建自定义命令更高效和简洁。

我们新的实用函数如下:

const checkCategoryButtons = (categories) => {

        categories.forEach((category, i) => {

            cy.get(Blog.CategoryButton(i + 1)).should('contain.text', category)

        });

    }

所以,我们最终的类 POM 文件和测试套件如下:

// `support/commands.js`

import Footer from './pages/Footer'

Cypress.Commands.add(

    'checkTitleAndFooter',

    (title) => {

        cy.title().should('eq', title)

        cy.get(Footer.Copyright).should('have.text', '© LambdaTest - Powered by OpenCart')

    }

);

// `Search.js`

class Search {

    static SearchInput = '#main-header input[name="search"]'

    static SearchButton = '#main-header #search > div.search-button > button'

}

export default Search

// `Footer.js`

class Footer {

    static Copyright = '.footer p'

}

export default Footer

// `Categories.js`

class Categories {

     static CategoryButton = (n) => `#entry_210963 > div > a:nth-child(${n})`

}

export default Categories

// `test-home-page-pomish.cy.js`

import Search from '../support/pages/Search'

describe('testing home page', () => {

    beforeEach(() => {

        cy.visit('/')

    })

    it('should visit home page', () => {

        cy.checkTitleAndFooter('Your Store')

    })

    it('should search for a product', () => {

        cy.get(Search.SearchInput).type('iphone')

        cy.get(Search.SearchButton).click()

    })

})

// `test-blog-page-pomish.cy.js`

import Categories from "../support/pages/Categories"

describe("testing blog page", () => {

    const checkCategoryButtons = (categories) => {

        categories.forEach((category, i) => {

            cy.get(Categories.CategoryButton(i + 1)).should('contain.text', category)

        });

    }

    beforeEach(() => {

        cy.visit(Cypress.config('urlBlogPage'))

    })

    it('should visit the blog page and validate footer', () => {

        cy.checkTitleAndFooter('Blog - Poco theme')

    })

    it('should have correct category names', () => {

        checkCategoryButtons(['Business', 'Electronics', 'Technology', 'Fashion'])

    })

})

现在我们已经完成了重构!

3、总结

我理解 POM 方法的粉丝可能会认为使用 “类 POM” 这个术语是一种暴行或亵渎。然而,我冒昧地使用这个术语来区分真正的 POM 和这种 “伪 POM” 模式。

如果你是那些忠实的 POM 粉丝之一,并且在我介绍 “类 POM” 这个术语后仍然继续阅读这篇博客文章,我真诚地感谢你的努力,并将永远感激!

基于我的个人经验以及从比我更聪明的人撰写的精彩文章中获得的见解,以下是我关于在 Cypress 框架中使用页面对象模型的一些看法:

1、在 Cypress 中,典型的、教科书式的 POM 方法可能有些过头了。它很容易变得繁琐、难以维护,并且可能变成它原本要克服的那种麻烦。

2、将相关选择器分组在一个地方,不是按照测试的网页,而是按照 UI 功能或 UI 组件(我们的类 POM 文件)。这些 UI 组件可能会在应用程序的许多页面中被重用。

3、在这些类 POM 文件中,只包含选择器,避免使用函数,特别是那些在 Cypress 环境中没有真正优势的函数(特别是那些仅仅复制现有 Cypress 命令的函数)。

如果需要更改选择器,你只需要在整个测试框架中的一个地方更新它。这在开发中的应用程序中特别有用,因为 UI 组件和选择器的结构可能在设计和开发阶段发生变化。

保持简单 —— 那个 UI 组件的类 POM 文件可以仅仅是一个你导出的类,选择器定义为静态常量。如果需要,你也可以包含一些静态函数来创建索引选择器。

4、如果你想实现 DRY(不要重复)代码:

对于仅在单个测试文件中使用的代码,只需要在该测试文件中创建 JavaScript 实用函数。

对于跨多个测试文件或频繁使用的代码,请考虑在 Cypress support文件夹中创建 Cypress 自定义命令、自定义查询、实用函数,甚至创建一个跨项目共享的外部 Cypress 插件。

当选择方法时,要考虑应该同步运行还是异步运行。关于哪种工具可能适合每种情况的更多信息,请查看我的文章 "And the nominees for “Best Cypress Helper” are: Utility Function, Custom Command, Custom Query, Task, and External Plugin"。

如果你想学习如何从头开始创建自己的 Cypress 外部插件,从构思到在 npm 上发布,查看我的文章 "The Quirky Guide to Crafting and Publishing Your Cypress npm Plugin"。

5、如果在阅读完整篇文章后,你仍然不认同(哪怕只是一点点)我关于是否使用 POM 的观点,那也没关系!我仍然相信你是一名优秀的 QA 工程师,并且我相信你会为你的项目做出正确的选择!


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
/62 人阅读/0 条评论 发表评论

登录 后发表评论
最新文章