Files
crawler-plugin/frontend-vue/src/shared/responsive-shot-plan.ts
T

96 lines
3.1 KiB
TypeScript

/**
* 响应式截图计划(Task 94)。
*
* 为多页应用生成移动端/桌面端响应式验收截图清单:入口 × 视口 的笛卡尔积
* (先页面后视口,顺序稳定),每个 shot 有可预期且唯一的文件名
* `<entry>-<label>.png`。同一入口/视口重复登记自动去重;maxShots 限制
* 截图总数(超出部分截断并计数 dropped)。
*
* 校验失败 fail-fast(入口/视口非数组、label 为空、宽高非正数、maxShots
* 非正数),零状态变更;视口列表读取抛错时调用失败,不产生部分计划,
* 依赖恢复后同一输入可重新计算。输入对象永不修改。
*/
export interface ShotViewport {
/** 视口标识(也用于文件名),不能为空 */
label: string
width: number
height: number
}
export interface ShotEntry {
entry: string
viewport: ShotViewport
filename: string
}
export interface ResponsiveShotPlanOptions {
/** 页面入口列表 */
entries: string[]
/** 视口列表(重复 label 去重) */
viewports: ShotViewport[]
/** 截图总数上限,必须为正数,默认 1000 */
maxShots?: number
}
export interface ResponsiveShotPlan {
/** 有序截图清单 */
shots: ShotEntry[]
shotCount: number
/** 因 maxShots 截断而丢弃的数量 */
droppedCount: number
/** 每个视口 label 的截图数量统计 */
viewportStats: () => Record<string, number>
}
export function createResponsiveShotPlan(options: ResponsiveShotPlanOptions): ResponsiveShotPlan {
if (!Array.isArray(options.entries)) {
throw new Error('entries 必须是数组')
}
if (!Array.isArray(options.viewports)) {
throw new Error('viewports 必须是数组')
}
const maxShots = options.maxShots ?? 1000
if (!(maxShots > 0)) {
throw new Error('maxShots 必须为正数: ' + maxShots)
}
const entries = Array.from(new Set(options.entries.filter((entry) => typeof entry === 'string' && entry.length > 0)))
const viewports: ShotViewport[] = []
for (const viewport of options.viewports) {
if (typeof viewport.label !== 'string' || viewport.label.length === 0) {
throw new Error('label 不能为空')
}
if (!(viewport.width > 0)) {
throw new Error('width 必须为正数: ' + viewport.width)
}
if (!(viewport.height > 0)) {
throw new Error('height 必须为正数: ' + viewport.height)
}
if (!viewports.some((item) => item.label === viewport.label)) {
viewports.push({ label: viewport.label, width: viewport.width, height: viewport.height })
}
}
const shots: ShotEntry[] = []
let droppedCount = 0
for (const entry of entries) {
for (const viewport of viewports) {
if (shots.length >= maxShots) {
droppedCount += 1
continue
}
shots.push({ entry, viewport, filename: `${entry}-${viewport.label}.png` })
}
}
function viewportStats(): Record<string, number> {
const stats: Record<string, number> = {}
for (const shot of shots) {
stats[shot.viewport.label] = (stats[shot.viewport.label] ?? 0) + 1
}
return stats
}
return { shots, shotCount: shots.length, droppedCount, viewportStats }
}