# 验证清单(详细版)
本清单用于每次交付前和部署后。每一项都要实测,没测的标「未实测」,不能写「通过」。
结果写进 `screenshots/<版本>/verification.json`,结构为 `checks / browser_errors / console_errors / external_requests / screenshots`。
## A. 内容与事实
- [ ] 每条事实都能在 README 的「内容依据」里找到来源,未知项都是【待填】,并且列在待填清单里。
- [ ] 没有虚构的客户 logo、评价、案例数、认证徽章、整齐的假数字、Lorem 或 Acme。
- [ ] 用 grep 查禁用词。项目自己维护一张禁用词表,例如未确认的规格(轴数)、未授权的品牌名、AI 套话(赋能、颠覆、无缝、一站式)。查的范围包括 HTML 文本、alt、title、meta、aria-label,以及 SVG 和 JSON-LD。
- [ ] 联系方式(主号、备用号、各渠道)在页脚、快捷栏、询价页、表单成功提示和 JSON-LD 里保持一致。
- [ ] 只属于某种语言的内容(例如开票信息)只出现在那个语言的页面上。
## B. SEO 与结构
- [ ] 每页都有唯一的 `
` 和 `meta description`。
- [ ] `canonical` 和站点的实际 URL 形式一致。上 Cloudflare Pages 时注意 `.html` 会被 308 重定向。
- [ ] `hreflang` 包含每种语言和 `x-default`,并且互相指向对应的同名页。
- [ ] OG 标签齐全:og:title / og:description / og:url / og:image / og:image:alt,og:url 等于 canonical。
- [ ] 每页一个 Organization JSON-LD,JSON 能解析,只写确认过的字段。
- [ ] 每页一个 h1,标题层级不跳级;语义标签齐全(header / nav / main / footer)。
- [ ] `sitemap.xml` 覆盖所有页面,`robots.txt` 指向 sitemap;有自定义 404,有隐私和条款页。
## C. 链接、资源与性能
- [ ] 所有本地 `href` / `src` / `srcset` 指向的文件都存在,本地服务器返回 200,锚点存在。
- [ ] 语言切换链接到同名页,并带 `lang` 属性。
- [ ] **外部请求 = 0**(mailto、tel、wa.me 这类由用户点击触发的除外);页面错误 = 0;控制台错误 = 0。
- [ ] 每张图片都有 `width` / `height`。首屏图 `fetchpriority="high"`,首屏以下 `loading="lazy" decoding="async"`。
- [ ] 内容图的 alt 非空。装饰氛围图只有放在 `picture.atmo-bg[aria-hidden=true]` 里时才允许 `alt=""`。
- [ ] 首页总重 ≤ 1.5MB,CLS = 0。LCP 的本地数值只作参考,要注明"本机静态服务器,非线上 p75"。
- [ ] CSS / JS 的引用都带新的 `?v=` 版本号,全站统一。
## D. 响应式与手机
- [ ] 在 320 / 360 / 375 / 390 / 430 / 768 / 1024 / 1280 / 1440 / 1920 下都**没有横向溢出**。判断条件是 `scrollWidth <= clientWidth`,而且不能靠 `overflow: hidden` 把溢出藏起来。
- [ ] 640px 宽度(相当于 1280 的 200% 缩放)下布局正常。
- [ ] 390×620 首屏:H1 的底边、主 CTA 的底边、关键承诺都在 620px 以内(每种语言都要测)。
- [ ] 1280×800 首屏:标题、一句说明、主动作都不用滚动就能看到。
- [ ] 汉堡按钮在 320–430 宽度下:高 44–48px,宽 ≥ 44px,`scrollHeight <= height + 1`,也就是不换行。
- [ ] 所有可见的交互元素都 ≥ 44×44px;主 CTA ≥ 48px;手机上 CTA 全宽。
- [ ] 底部快捷栏只在 ≤ 768px 时显示;菜单打开时隐藏;不遮挡表单提交按钮。
- [ ] 用微信的 UA(iPhone 加 `MicroMessenger`)截图:390×664 和 390×620。
## E. 交互与键盘
- [ ] Tab 第一下落到"跳到正文"链接;整页 Tab 走一遍,焦点始终可见,顺序合理,不会被困住。
- [ ] 菜单:`aria-expanded` 切换;Escape 能关闭并把焦点还给按钮;Tab 在菜单内循环;打开时背景是 `inert`;点击链接后关闭。
- [ ] 弹窗:能打开,能关闭(按钮、Escape、点遮罩),关闭后焦点回到触发按钮。
- [ ] 表单:必填项、格式、文件类型和大小、去重、移除都校验到;错误就地显示,加 `aria-invalid`,焦点移到第一个错误;成功提示如实说明(演示版就写"未上传、未保存");**不发出任何请求,不写 localStorage**(演示表单的情况)。
## F. 降级与动效
- [ ] **无 JS**(`java_script_enabled=False`):H1、正文、图片的 opacity 都是 1;计数显示最终值;导航可见;`html` 上没有 `.js`;表单不会因原生提交跳页。
- [ ] **没有 IntersectionObserver**(用 init script 把它设成 `undefined`)时,`.reveal.is-pending` 的数量为 0,数字都是最终值。
- [ ] **reduced-motion**:所有内容立即可见,没有过渡。运行时用 `emulate_media` 切换成 reduce 后,立刻恢复终态。
- [ ] **正常动效**(独立上下文,`reduced_motion='no-preference'`):只有一个观察器;元素显现后不再被观察;只播一次;计数的 `.sr-only` 一直是最终值,动画副本带 `aria-hidden`;图片悬停缩放只在 `(hover: hover)` 下生效;页头超过 24px 才出现阴影,且高度不变。
- [ ] `?static` 截图模式下全部内容定格可见(如果实现了这个模式)。
## G. 无障碍数值
- [ ] 对比度用 WCAG 公式算,并写进 DESIGN.md:每一层表面上的主文字、正文、辅助文字、链接、按钮文字都 ≥ 4.5:1;大字、控件边界、焦点环 ≥ 3:1。
- [ ] 压在图片上的文字,按遮罩加上底图的黑白极值做保守计算。
- [ ] 不只靠颜色传达信息:错误要同时有文字和边框。
## H. 截图清单(启用 reduce,等待字体和图片解码完成)
- [ ] 每种语言的首页:1440 首屏、1440 全页、390 首屏、390 全页。
- [ ] 手机菜单展开、弹窗(如微信)、询价页桌面和手机、重点内页。
- [ ] 微信 UA 下的首屏截图。
- [ ] 截图前要断言 `.reveal.is-pending` 的数量为 0。
## I. 部署后
- [ ] 线上每个 URL 都用 curl 查过:返回 200,或者预期的 308 并最终 200;HTTPS 证书有效;根路径的语言选择页正常。
- [ ] 线上 HTML 里引用的 `?v=` 是新版本号,抽查一个改过的文本是新版。
- [ ] 部署在服务器上时:其他站点(其他 conf 和端口)的访问没有受影响;`nginx -t` 通过;备份目录存在。
---
## Playwright 骨架(Python,示例)
```python
# 运行:python3 -m http.server 8000 & 然后 python verify.py
import asyncio, json
from urllib.parse import urlparse
from playwright.async_api import async_playwright
BASE = 'http://127.0.0.1:8000'
LANGS = ['zh', 'en', 'ja']
PAGES = ['index', 'about', 'contact'] # 按项目改
WIDTHS = [320, 360, 375, 390, 430, 768, 1024, 1280, 1440, 1920]
WECHAT_UA = ('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 '
'(KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.50(0x18003231) NetType/WIFI Language/zh_CN')
report = {'checks': [], 'browser_errors': [], 'console_errors': [], 'external_requests': [], 'screenshots': []}
async def no_overflow(page, ctx):
w = await page.evaluate('[document.documentElement.scrollWidth, document.documentElement.clientWidth]')
assert w[0] <= w[1], (ctx, 'horizontal overflow', w)
async def settle(page):
await page.evaluate('document.fonts.ready')
await page.evaluate('Promise.all([...document.images].map(i => i.decode().catch(() => {})))')
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch()
ctx = await browser.new_context(reduced_motion='reduce')
page = await ctx.new_page()
page.on('pageerror', lambda e: report['browser_errors'].append(str(e)))
page.on('console', lambda m: report['console_errors'].append(m.text) if m.type == 'error' else None)
page.on('request', lambda r: report['external_requests'].append(r.url)
if urlparse(r.url).netloc not in ('', urlparse(BASE).netloc) and not r.url.startswith('data:') else None)
for lang in LANGS:
for name in PAGES:
url = f'{BASE}/{lang}/{name}.html'
for w in WIDTHS:
await page.set_viewport_size({'width': w, 'height': 900})
await page.goto(url)
await no_overflow(page, f'{url}@{w}')
assert await page.locator('h1').count() == 1, url
assert await page.locator('link[rel=canonical]').count() == 1, url
hreflangs = await page.locator('link[hreflang]').evaluate_all('els => els.map(e => e.hreflang)')
assert set(hreflangs) >= {'x-default', *[{'zh': 'zh-CN'}.get(l, l) for l in LANGS]}, url
# 390×620 首屏断言
await page.set_viewport_size({'width': 390, 'height': 620})
await page.goto(f'{BASE}/{lang}/index.html'); await settle(page)
for sel in ['h1', '.hero-actions .button >> nth=0']:
box = await page.locator(sel).bounding_box()
assert box['y'] + box['height'] <= 620, (lang, sel, box)
t = await page.locator('.menu-toggle').evaluate('e => { const r = e.getBoundingClientRect(); return [r.width, r.height, e.scrollHeight]; }')
assert t[0] >= 44 and 44 <= t[1] <= 48 and t[2] <= t[1] + 1, (lang, 'menu toggle', t)
await page.keyboard.press('Tab') # 键盘:跳到正文
assert await page.locator('.skip-link').evaluate('e => e === document.activeElement'), lang
assert await page.locator('.reveal.is-pending').count() == 0
await page.screenshot(path=f'screenshots/{lang}-index-390.png', full_page=True)
# 无 JS
nojs = await browser.new_context(java_script_enabled=False, viewport={'width': 390, 'height': 844})
s = await nojs.new_page(); await s.goto(f'{BASE}/{LANGS[0]}/index.html')
assert await s.locator('h1').is_visible() and await s.locator('#navigation').is_visible()
await nojs.close()
# 无 IntersectionObserver
fb = await browser.new_context(reduced_motion='no-preference')
await fb.add_init_script('window.IntersectionObserver = undefined')
b = await fb.new_page(); await b.goto(f'{BASE}/{LANGS[0]}/index.html')
assert await b.locator('.reveal.is-pending').count() == 0
await fb.close()
# 微信 UA 首屏
wx = await browser.new_context(user_agent=WECHAT_UA, viewport={'width': 390, 'height': 620}, reduced_motion='reduce')
wp = await wx.new_page(); await wp.goto(f'{BASE}/{LANGS[0]}/index.html'); await settle(wp)
await wp.screenshot(path='screenshots/wechat-index-390x620.png'); await wx.close()
assert not report['browser_errors'] and not report['console_errors'] and not report['external_requests'], report
await browser.close()
open('screenshots/verification.json', 'w').write(json.dumps(report, ensure_ascii=False, indent=2))
asyncio.run(main())
```
## 部署后的 curl 检查(示例)
```sh
BASE=https://<项目>.pages.dev
for p in / /zh/ /en/ /zh/about /en/about; do
printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' -L "$BASE$p"
done
curl -sI "$BASE/zh/about.html" | head -3 # 预期 308 → /zh/about
curl -s "$BASE/zh/" | grep -o 'style.css?v=[^"]*' # 确认是新版本号
```