dsh-design-studio 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +13 -7
- package/README.md +33 -11
- package/cordis.patch.yml +1 -1
- package/lib/browser.js +251 -0
- package/lib/bundle.js +228 -0
- package/lib/client.js +1074 -41
- package/lib/core.js +233 -21
- package/lib/doc.js +356 -0
- package/lib/index.js +428 -21
- package/lib/look.js +550 -0
- package/lib/picker.js +1118 -24
- package/lib/preset.js +20 -3
- package/lib/serve.js +23 -7
- package/lib/tool.js +6 -3
- package/locale/tool/en.json +2 -2
- package/locale/tool/zh.json +2 -2
- package/package.json +1 -1
- package/skills/ui-design/SKILL.md +40 -2
- package/skills/ui-design/references/app.md +1 -1
package/lib/look.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check_design / look_design 在无头浏览器里干的活(浏览器见 browser.js):
|
|
3
|
+
* 量哪几屏、按哪几种尺寸,每屏开一个那么大的页面量问题 / 截图,最后拼成给模型的一段字。
|
|
4
|
+
*
|
|
5
|
+
* 量的是什么都写清楚(哪屏、多宽多高、页面多高、固定栏离内容多远),不只回一句「没问题」:
|
|
6
|
+
* 模型拿到的是测量结果,不用再自己搭检查页、开浏览器截图去核实。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
export const KIND_SIZE = { web: [1280, 800], app: [390, 844], tablet: [820, 1180], poster: [1080, 1350], slides: [1280, 720], email: [640, 960] }
|
|
13
|
+
const KIND_LIST = Object.keys(KIND_SIZE)
|
|
14
|
+
const META_ALIAS = { phone: 'app', mobile: 'app', ios: 'app', android: 'app', pad: 'tablet', ipad: 'tablet', social: 'poster', image: 'poster', slide: 'slides', deck: 'slides', ppt: 'slides', mail: 'email' }
|
|
15
|
+
// 只给宽度时的高:常见真机的屏幕高;别的宽度按这一类默认的宽高比
|
|
16
|
+
const PHONE_H = { 320: 568, 360: 780, 375: 667, 390: 844, 393: 852, 402: 874, 412: 915, 414: 896, 428: 926, 430: 932, 440: 956 }
|
|
17
|
+
const TABLET_H = { 744: 1133, 768: 1024, 810: 1080, 820: 1180, 834: 1194, 1024: 1366, 1032: 1376 }
|
|
18
|
+
|
|
19
|
+
export const MAX_SCREENS = 12 // check_design 一次最多量几屏
|
|
20
|
+
export const MAX_SIZES = 4 // 最多几种尺寸
|
|
21
|
+
export const MAX_SHOTS = 8 // look_design 一次最多几张图
|
|
22
|
+
export const MAX_CROPS = 4 // check_design 一次最多附几张问题的局部截图
|
|
23
|
+
|
|
24
|
+
/** 页面里 <meta name="dsh-design" content="app"> / "poster 1080x1920" → { kind, size }(和右侧栏 client.js 的 parseMeta 一样) */
|
|
25
|
+
export function parseMeta(content) {
|
|
26
|
+
const out = { kind: '', size: '' }
|
|
27
|
+
for (const w of String(content || '').toLowerCase().split(/[\s,;]+/)) {
|
|
28
|
+
if (!out.kind && (KIND_LIST.includes(w) || META_ALIAS[w])) out.kind = KIND_LIST.includes(w) ? w : META_ALIAS[w]
|
|
29
|
+
const m = /^(\d{2,5})[x×*](\d{2,5})$/.exec(w)
|
|
30
|
+
if (!out.size && m) out.size = `${m[1]}x${m[2]}`
|
|
31
|
+
}
|
|
32
|
+
return out
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 'WxH' / 宽度数字 → [w, h];不认识的给 null */
|
|
36
|
+
export function sizeOf(v, kind) {
|
|
37
|
+
const s = String(v == null ? '' : v).trim().toLowerCase()
|
|
38
|
+
let m = /^(\d{2,5})\s*[x×*]\s*(\d{2,5})$/.exec(s)
|
|
39
|
+
if (m) {
|
|
40
|
+
const w = +m[1], h = +m[2]
|
|
41
|
+
return w >= 200 && h >= 200 && w <= 4000 && h <= 6000 ? [w, h] : null
|
|
42
|
+
}
|
|
43
|
+
m = /^(\d{2,5})(px)?$/.exec(s)
|
|
44
|
+
if (!m) return null
|
|
45
|
+
const w = +m[1]
|
|
46
|
+
if (w < 200 || w > 4000) return null
|
|
47
|
+
const [dw, dh] = KIND_SIZE[kind] || KIND_SIZE.web
|
|
48
|
+
if (kind === 'app' && PHONE_H[w]) return [w, PHONE_H[w]]
|
|
49
|
+
if (kind === 'tablet' && TABLET_H[w]) return [w, TABLET_H[w]]
|
|
50
|
+
if (kind === 'app' || kind === 'tablet' || kind === 'poster' || kind === 'slides') return [w, Math.round((w * dh) / dw)]
|
|
51
|
+
return [w, dh]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 设计稿是哪类、多大:preview_page 给的 → 页面里的 <meta name="dsh-design"> → 默认网页 */
|
|
55
|
+
export function designOf(snap, readHead) {
|
|
56
|
+
let meta = { kind: '', size: '' }
|
|
57
|
+
if (!snap.kind || !snap.size) {
|
|
58
|
+
try { meta = parseMeta(readHead() || '') } catch { /* 读不到就按默认 */ }
|
|
59
|
+
}
|
|
60
|
+
const kind = KIND_LIST.includes(snap.kind) ? snap.kind : meta.kind || 'web'
|
|
61
|
+
const size = sizeOf(snap.size, kind) || sizeOf(meta.size, kind) || KIND_SIZE[kind]
|
|
62
|
+
return { kind, size }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 本地 HTML 文件里 dsh-design 那个 meta 的内容(只读开头一段) */
|
|
66
|
+
export function metaOfFile(file) {
|
|
67
|
+
let fd
|
|
68
|
+
try {
|
|
69
|
+
fd = fs.openSync(file, 'r')
|
|
70
|
+
const buf = Buffer.alloc(16384)
|
|
71
|
+
const n = fs.readSync(fd, buf, 0, buf.length, 0)
|
|
72
|
+
const head = buf.subarray(0, n).toString('utf8')
|
|
73
|
+
const tag = /<meta\b[^>]*\bname\s*=\s*["']?dsh-design["']?[^>]*>/i.exec(head)
|
|
74
|
+
const c = tag && /\bcontent\s*=\s*(["'])([^"']*)\1/i.exec(tag[0])
|
|
75
|
+
return c ? c[2] : ''
|
|
76
|
+
} catch {
|
|
77
|
+
return ''
|
|
78
|
+
} finally {
|
|
79
|
+
if (fd != null) try { fs.closeSync(fd) } catch { /* 关过了 */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 要量 / 截的几屏:names(文件名、路由、去掉 .html 的名字都认)挑出来,不给就全部;单页预览就是那一页 */
|
|
84
|
+
export function pickScreens(snap, names, max) {
|
|
85
|
+
const all = snap.screens && snap.screens.length ? snap.screens : [{ key: snap.focus || '', url: snap.url, label: snap.label || '' }]
|
|
86
|
+
if (!Array.isArray(names) || !names.length) return { list: all.slice(0, max), missing: [], more: Math.max(0, all.length - max) }
|
|
87
|
+
const norm = (x) => String(x || '').trim().replace(/^\.?\//, '').replace(/\.html?$/i, '').toLowerCase()
|
|
88
|
+
const list = []
|
|
89
|
+
const missing = []
|
|
90
|
+
for (const n of names.slice(0, 40)) {
|
|
91
|
+
const hit = all.find((s) => norm(s.key) === norm(n) || norm(s.label) === norm(n))
|
|
92
|
+
if (!hit) missing.push(String(n).slice(0, 60))
|
|
93
|
+
else if (!list.includes(hit)) list.push(hit)
|
|
94
|
+
}
|
|
95
|
+
return { list: list.slice(0, max), missing, more: Math.max(0, list.length - max) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 截图的缩放:长边放大到 1568 像素左右(模型看图的常用上限),最多 2 倍 */
|
|
99
|
+
export const scaleFor = (w, h) => Math.max(1, Math.min(2, Math.round((1568 / Math.max(w, h)) * 100) / 100))
|
|
100
|
+
|
|
101
|
+
const READY = 'new Promise((r) => { const t = Date.now(); (function w() { if (window.__dshDesign || Date.now() - t > 4000) r(!!window.__dshDesign); else setTimeout(w, 50) })() })'
|
|
102
|
+
|
|
103
|
+
/** 开好一屏:等 picker 装上、字体图片载完;要深色就切过去 */
|
|
104
|
+
async function prepare(page, dark) {
|
|
105
|
+
if (!(await page.eval(READY))) throw new Error('页面没装上设计台脚本(不是 HTML,或者载入出错)')
|
|
106
|
+
await page.eval('window.__dshDesign.settle()')
|
|
107
|
+
let darkOk = true
|
|
108
|
+
if (dark) darkOk = await page.eval('window.__dshDesign.dark(true)')
|
|
109
|
+
return { info: await page.eval('window.__dshDesign.info()'), darkOk }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 量:每屏 × 每种尺寸开一个页面跑体检。
|
|
114
|
+
* @returns [{ key, label, w, h, issues, metrics, error }]
|
|
115
|
+
*/
|
|
116
|
+
// 这几类问题由元素自己的哪项样式决定:量完顺手问一下是哪条规则,再找回文件和行号(模型不用再 grep)
|
|
117
|
+
const RULE_PROP = { contrast: 'color', tiny: 'font-size' }
|
|
118
|
+
const MAX_RULES = 12
|
|
119
|
+
|
|
120
|
+
export async function measureAll(browser, { screens, sizes, kind, dark, fetcher = globalThis.fetch, locate = null }) {
|
|
121
|
+
const jobs = []
|
|
122
|
+
for (const sc of screens) for (const [w, h] of sizes) jobs.push({ sc, w, h })
|
|
123
|
+
const gone = new Map() // 地址 → Promise<是不是 404>(几屏、几种尺寸指着同一页只问一次)
|
|
124
|
+
const isGone = (url) => {
|
|
125
|
+
if (!gone.has(url)) {
|
|
126
|
+
gone.set(url, (async () => {
|
|
127
|
+
try {
|
|
128
|
+
const r = await fetcher(url, { signal: AbortSignal.timeout(5000) })
|
|
129
|
+
try { await r.body?.cancel() } catch { /* 不要内容 */ }
|
|
130
|
+
return r.status === 404
|
|
131
|
+
} catch { return false }
|
|
132
|
+
})())
|
|
133
|
+
}
|
|
134
|
+
return gone.get(url)
|
|
135
|
+
}
|
|
136
|
+
const rows = await Promise.all(jobs.map(async ({ sc, w, h }, i) => {
|
|
137
|
+
const row = { key: sc.key, label: sc.label || sc.key, w, h, issues: [], metrics: null, error: '' }
|
|
138
|
+
try {
|
|
139
|
+
await browser.withPage({ url: sc.url, w, h, dark }, async (page) => {
|
|
140
|
+
const { info, darkOk } = await prepare(page, dark)
|
|
141
|
+
if (info && info.title) row.title = String(info.title).slice(0, 40)
|
|
142
|
+
if (dark && !darkOk) row.noDark = true
|
|
143
|
+
const r = await page.eval(`window.__dshDesign.check(${JSON.stringify(kind)})`)
|
|
144
|
+
row.issues = Array.isArray(r && r.issues) ? r.issues : []
|
|
145
|
+
row.metrics = (r && r.metrics) || null
|
|
146
|
+
if (locate) {
|
|
147
|
+
// 对比度、字号问元素自己;叠字问找到的原因(哪个元素的 margin / position)
|
|
148
|
+
const ask = (it) => (it.cause && it.cause.sel && it.cause.prop ? [it.cause.sel, it.cause.prop] : RULE_PROP[it.type] && it.sel ? [it.sel, RULE_PROP[it.type]] : null)
|
|
149
|
+
for (const it of row.issues.filter(ask).slice(0, MAX_RULES)) {
|
|
150
|
+
const [sel, prop] = ask(it)
|
|
151
|
+
const rule = await page.eval(`window.__dshDesign.ruleOf ? window.__dshDesign.ruleOf(${JSON.stringify(sel)}, ${JSON.stringify(prop)}) : null`).catch(() => null)
|
|
152
|
+
if (rule && rule.inline) it.rule = { prop, inline: true }
|
|
153
|
+
else if (rule && rule.sel) it.rule = { prop, sel: rule.sel, href: rule.href, n: rule.n, ...(rule.maybe ? { maybe: true } : {}) }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// 跳转:每屏只在第一种尺寸上收一次(几种尺寸下链接一样)
|
|
157
|
+
if (i % sizes.length === 0 && fetcher) row.links = await page.eval('window.__dshDesign.links ? window.__dshDesign.links() : []').catch(() => [])
|
|
158
|
+
})
|
|
159
|
+
} catch (e) {
|
|
160
|
+
row.error = String((e && e.message) || e).slice(0, 160)
|
|
161
|
+
}
|
|
162
|
+
return row
|
|
163
|
+
}))
|
|
164
|
+
// 规则 → 文件和行号(一次问完)
|
|
165
|
+
if (locate) {
|
|
166
|
+
const withRule = rows.flatMap((r) => r.issues.filter((it) => it.rule && it.rule.sel))
|
|
167
|
+
if (withRule.length) {
|
|
168
|
+
let found = []
|
|
169
|
+
try { found = await locate(withRule.map((it) => it.rule)) } catch { found = [] }
|
|
170
|
+
withRule.forEach((it, i) => { if (found[i] && found[i].file) Object.assign(it.rule, { file: found[i].file, line: found[i].line || 0 }) })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 跳到不存在的页面(404):算在这一屏每种尺寸上(不是某个尺寸才有的问题)
|
|
174
|
+
for (const sc of screens) {
|
|
175
|
+
const first = rows.find((r) => r.key === sc.key && Array.isArray(r.links))
|
|
176
|
+
if (!first) continue
|
|
177
|
+
const dead = []
|
|
178
|
+
const seen = new Set()
|
|
179
|
+
for (const l of first.links.slice(0, 40)) {
|
|
180
|
+
if (!l || typeof l.path !== 'string' || seen.has(l.path)) continue
|
|
181
|
+
seen.add(l.path)
|
|
182
|
+
let url
|
|
183
|
+
try { url = new URL(l.path.split('/').map(encodeURIComponent).join('/'), sc.url).href } catch { continue }
|
|
184
|
+
if (await isGone(url)) dead.push({ type: 'deadlink', sel: '', text: String(l.text || '').slice(0, 24), src: l.path.replace(/^\//, '') })
|
|
185
|
+
}
|
|
186
|
+
for (const r of rows) if (r.key === sc.key && !r.error) r.issues.push(...dead)
|
|
187
|
+
first.linkCount = seen.size // 查了几个跳转(没问题也让模型知道查过)
|
|
188
|
+
}
|
|
189
|
+
for (const r of rows) delete r.links
|
|
190
|
+
return rows
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* 截图:每屏一张(at = top 顶上一屏 / bottom 滚到底 / full 从上到下几屏),selector 只截那个元素。
|
|
195
|
+
* @returns [{ key, label, w, h, at, part, of, jpg, note }]
|
|
196
|
+
*/
|
|
197
|
+
export async function shootAll(browser, { screens, size, at, selector, dark, max = MAX_SHOTS }) {
|
|
198
|
+
const [w, h] = size
|
|
199
|
+
const scale = scaleFor(w, h)
|
|
200
|
+
const out = []
|
|
201
|
+
const perScreen = at === 'full' ? Math.max(1, Math.floor(max / Math.max(1, screens.length))) : 1
|
|
202
|
+
await Promise.all(screens.map(async (sc, si) => {
|
|
203
|
+
const shots = []
|
|
204
|
+
const base = { key: sc.key, label: sc.label || sc.key, w, h }
|
|
205
|
+
try {
|
|
206
|
+
await browser.withPage({ url: sc.url, w, h, scale, dark }, async (page) => {
|
|
207
|
+
const { info, darkOk } = await prepare(page, dark)
|
|
208
|
+
const title = info && info.title ? String(info.title).slice(0, 40) : ''
|
|
209
|
+
const note = { page: info ? info.page : 0, title, noDark: dark && !darkOk }
|
|
210
|
+
if (selector) {
|
|
211
|
+
const r = await page.eval(`window.__dshDesign.rect(${JSON.stringify(String(selector).slice(0, 200))})`)
|
|
212
|
+
if (!r || r.w < 1 || r.h < 1) { shots.push({ ...base, ...note, at: 'selector', error: `没找到「${selector}」或它没有大小` }); return }
|
|
213
|
+
// 四周留 8px,但不伸出视口(元素比视口大就只截视口里那块)
|
|
214
|
+
const pad = 8
|
|
215
|
+
const vx = r.vx || 0, vy = r.vy || 0, vw = r.vw || w, vh = r.vh || h
|
|
216
|
+
const x0 = Math.max(vx, r.x - pad), y0 = Math.max(vy, r.y - pad)
|
|
217
|
+
const x1 = Math.min(vx + vw, r.x + r.w + pad), y1 = Math.min(vy + vh, r.y + r.h + pad)
|
|
218
|
+
if (x1 - x0 < 1 || y1 - y0 < 1) { shots.push({ ...base, ...note, at: 'selector', error: `「${selector}」不在屏幕里` }); return }
|
|
219
|
+
const clip = { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }
|
|
220
|
+
shots.push({ ...base, ...note, at: 'selector', jpg: await page.shot({ clip }) })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (at === 'bottom') {
|
|
224
|
+
await page.eval("window.__dshDesign.scroll('end')")
|
|
225
|
+
shots.push({ ...base, ...note, at, jpg: await page.shot() })
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
if (at === 'full') {
|
|
229
|
+
// 段数超过能截的张数:截前面几段 + 最后一段(底栏压没压住内容在最后一段看),说明里写明跳过了哪些
|
|
230
|
+
const cap = Math.min(perScreen, 4)
|
|
231
|
+
const s0 = await page.eval('window.__dshDesign.scroll(0)')
|
|
232
|
+
const of = (s0 && s0.of) || 1
|
|
233
|
+
const parts = of <= cap ? [...Array(of).keys()] : [...[...Array(cap - 1).keys()], of - 1]
|
|
234
|
+
for (const i of parts) {
|
|
235
|
+
if (i) await page.eval(`window.__dshDesign.scroll(${i})`)
|
|
236
|
+
shots.push({ ...base, ...note, at, part: i + 1, of, jpg: await page.shot() })
|
|
237
|
+
}
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
shots.push({ ...base, ...note, at: 'top', jpg: await page.shot() })
|
|
241
|
+
})
|
|
242
|
+
} catch (e) {
|
|
243
|
+
shots.push({ ...base, at, error: String((e && e.message) || e).slice(0, 160) })
|
|
244
|
+
}
|
|
245
|
+
out[si] = shots
|
|
246
|
+
}))
|
|
247
|
+
return out.flat().filter(Boolean)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 值得附图的问题(严重的、看图比看数字明白),越靠前越先截
|
|
251
|
+
const CROP_ORDER = ['covered', 'overlap', 'clipped', 'overflow', 'hscroll', 'img', 'contrast']
|
|
252
|
+
|
|
253
|
+
/** 两条问题挨得近(同一屏同一尺寸,元素上下差不到 48px、左右差不到 160px):截一张图就都看得到 */
|
|
254
|
+
function nearby(a, b) {
|
|
255
|
+
if (a.row !== b.row) return false
|
|
256
|
+
const p = a.it.rect, q = b.it.rect
|
|
257
|
+
if (!p || !q) return a.it.sel === b.it.sel
|
|
258
|
+
const gy = Math.max(p.y, q.y) - Math.min(p.y + p.h, q.y + q.h)
|
|
259
|
+
const gx = Math.max(p.x, q.x) - Math.min(p.x + p.w, q.x + q.w)
|
|
260
|
+
return gy < 48 && gx < 160
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 挑要附图的问题:严重的、有元素的。同一个问题几种尺寸都有只截第一种;
|
|
265
|
+
* 和已经挑了的挨得近的不另截,记在那张图上一起圈(一行字被底栏压住常常报好几条);
|
|
266
|
+
* 先每屏每类各挑一条,再按顺序补满
|
|
267
|
+
*/
|
|
268
|
+
export function pickCrops(rows, max = MAX_CROPS) {
|
|
269
|
+
const cands = []
|
|
270
|
+
const seen = new Set()
|
|
271
|
+
rows.forEach((r, ri) => {
|
|
272
|
+
if (r.error) return
|
|
273
|
+
for (const it of r.issues) {
|
|
274
|
+
const p = CROP_ORDER.indexOf(it.type)
|
|
275
|
+
if (p < 0 || !(it.sev >= 2) || !it.sel) continue
|
|
276
|
+
const k = `${r.key}|${issueKey(it)}`
|
|
277
|
+
if (seen.has(k)) continue
|
|
278
|
+
seen.add(k)
|
|
279
|
+
cands.push({ row: r, it, k, p, ri })
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
// 同一类里先截最重的(压住 / 切掉得多的、对比度差得多的)
|
|
283
|
+
const worse = (it) => (it.type === 'contrast' ? (it.need || 0) - (it.ratio || 0) : it.px || 0)
|
|
284
|
+
cands.sort((a, b) => a.p - b.p || worse(b.it) - worse(a.it) || a.ri - b.ri)
|
|
285
|
+
const out = []
|
|
286
|
+
const used = new Set()
|
|
287
|
+
const kinds = new Set()
|
|
288
|
+
for (const first of [true, false]) {
|
|
289
|
+
for (const c of cands) {
|
|
290
|
+
if (used.has(c)) continue
|
|
291
|
+
// 同一个元素别的尺寸上的另一个问题:不另截(那张图上看不到这个尺寸的样子)
|
|
292
|
+
if (out.some((p) => p.row !== c.row && p.row.key === c.row.key && p.it.sel === c.it.sel)) { used.add(c); continue }
|
|
293
|
+
const host = out.find((p) => nearby(p, c))
|
|
294
|
+
if (host) { host.also.push(c); used.add(c); continue }
|
|
295
|
+
if (out.length >= max || (first && kinds.has(`${c.row.key}|${c.it.type}`))) continue
|
|
296
|
+
out.push({ ...c, also: [] })
|
|
297
|
+
used.add(c)
|
|
298
|
+
kinds.add(`${c.row.key}|${c.it.type}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** 标在红框上的数字(图上就看得出差多少) */
|
|
305
|
+
function labelOf(it) {
|
|
306
|
+
switch (it.type) {
|
|
307
|
+
case 'covered': return `压住 ${it.px}px`
|
|
308
|
+
case 'overlap': return it.px ? `叠了 ${it.px}px` : ''
|
|
309
|
+
case 'clipped': return `切掉 ${it.px}px`
|
|
310
|
+
case 'overflow': return `伸出 ${it.px}px`
|
|
311
|
+
case 'hscroll': return `伸出屏幕 ${it.px}px`
|
|
312
|
+
case 'contrast': return `${it.ratio}:1`
|
|
313
|
+
default: return ''
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* 严重问题的局部截图:按发现时的尺寸重开那一屏,把元素摆回出问题的样子,红框圈出来截那一块。
|
|
319
|
+
* 截不成的(元素没了、页面变了)就不附。
|
|
320
|
+
* @returns [{ n, k, ks, key, w, h, type, jpg }](n 从 1 起,按挑的顺序;ks 是图里看得到的几条问题)
|
|
321
|
+
*/
|
|
322
|
+
export async function cropIssues(browser, rows, { screens, dark, max = MAX_CROPS }) {
|
|
323
|
+
const picks = pickCrops(rows, max)
|
|
324
|
+
const urlOf = new Map(screens.map((s) => [s.key, s.url]))
|
|
325
|
+
const groups = new Map()
|
|
326
|
+
for (const c of picks) {
|
|
327
|
+
const g = `${c.row.key}|${c.row.w}x${c.row.h}`
|
|
328
|
+
if (!groups.has(g)) groups.set(g, [])
|
|
329
|
+
groups.get(g).push(c)
|
|
330
|
+
}
|
|
331
|
+
await Promise.all([...groups.values()].map(async (list) => {
|
|
332
|
+
const { row } = list[0]
|
|
333
|
+
if (!urlOf.has(row.key)) return
|
|
334
|
+
try {
|
|
335
|
+
await browser.withPage({ url: urlOf.get(row.key), w: row.w, h: row.h, scale: scaleFor(row.w, row.h), dark }, async (page) => {
|
|
336
|
+
await prepare(page, dark)
|
|
337
|
+
for (const c of list) {
|
|
338
|
+
const it = c.it
|
|
339
|
+
const how = { sel: it.sel, other: it.type === 'overlap' ? it.otherSel || '' : '', edge: it.type === 'covered' ? it.edge || '' : '', hscroll: it.type === 'hscroll', also: c.also.map((x) => x.it.sel), label: labelOf(it) }
|
|
340
|
+
const at = await page.eval(`window.__dshDesign.crop ? window.__dshDesign.crop(${JSON.stringify(how)}) : null`).catch(() => null)
|
|
341
|
+
if (!at || !(at.w >= 1 && at.h >= 1)) continue
|
|
342
|
+
c.jpg = await page.shot({ clip: { x: at.x, y: at.y, width: at.w, height: at.h } })
|
|
343
|
+
// 挨着的那几条:真在图里才算「看图几」
|
|
344
|
+
c.ks = [c.k, ...c.also.filter((_, i) => at.inside && at.inside[i]).map((x) => x.k)]
|
|
345
|
+
c.upper = at.upper || ''
|
|
346
|
+
}
|
|
347
|
+
})
|
|
348
|
+
} catch { /* 截不成就不附,清单照样给 */ }
|
|
349
|
+
}))
|
|
350
|
+
return picks.filter((c) => c.jpg).map((c, i) => ({ n: i + 1, k: c.k, ks: c.ks || [c.k], key: c.row.key, w: c.row.w, h: c.row.h, type: c.it.type, upper: c.upper || '', jpg: c.jpg }))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ── 给模型的字 ────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
const TYPE = {
|
|
356
|
+
hscroll: '超出屏幕', clipped: '文字被截断', overflow: '文字超出框', overlap: '文字叠在一起', contrast: '对比度不够', tap: '点击区太小',
|
|
357
|
+
tiny: '字太小', img: '图片没加载出来', fonts: '字号太杂', distort: '图片被拉变形', long: '一行太长', wrap: '文字被挤断行',
|
|
358
|
+
covered: '被固定栏压住', tight: '离固定栏太近', jserr: '页面脚本报错', missing: '文件没载出来', deadlink: '跳到的页面不存在',
|
|
359
|
+
}
|
|
360
|
+
const EDGE = { top: '顶栏', bottom: '底栏' }
|
|
361
|
+
const WHAT = { css: '样式表', script: '脚本', font: '字体', link: '文件', video: '视频', audio: '音频', source: '媒体', iframe: '内嵌页' }
|
|
362
|
+
|
|
363
|
+
const PROP = { color: '文字颜色', 'font-size': '字号', position: '定位' }
|
|
364
|
+
/** 问题由哪条样式规则决定、在哪个文件第几行(找得到才写) */
|
|
365
|
+
function whereRule(r) {
|
|
366
|
+
if (!r) return ''
|
|
367
|
+
const name = PROP[r.prop] || `${r.prop} `
|
|
368
|
+
if (r.inline) return `;${name}写在元素的行内 style 上`
|
|
369
|
+
if (!r.sel) return ''
|
|
370
|
+
const at = r.file ? `,${r.file}${r.line ? ` 第 ${r.line} 行` : ''}` : ''
|
|
371
|
+
return `;${name}${r.maybe ? '大概' : ''}由 ${r.sel} 决定${at}`
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 叠字可能的原因:{ sel, prop, value } → 「(可能是 X 的 margin-top: -26px)」 */
|
|
375
|
+
function causeText(c) {
|
|
376
|
+
if (!c || !c.sel) return ''
|
|
377
|
+
if (c.prop === 'position') return `(可能是 ${c.sel} 用了绝对定位 position: ${c.value})`
|
|
378
|
+
if (c.prop === 'transform') return `(可能是 ${c.sel} 用 transform 挪了位置)`
|
|
379
|
+
return `(可能是 ${c.sel} 的 ${c.prop}: ${c.value})`
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 一条问题的具体数字 */
|
|
383
|
+
export function detailOf(it) {
|
|
384
|
+
switch (it.type) {
|
|
385
|
+
case 'hscroll': return `伸出屏幕 ${it.px}px`
|
|
386
|
+
case 'clipped': return `被切掉 ${it.px}px`
|
|
387
|
+
case 'overflow': return `伸出框 ${it.px}px`
|
|
388
|
+
case 'overlap': return `和 ${it.other}「${String(it.otherText || '').slice(0, 20)}」叠在一起${causeText(it.cause)}`
|
|
389
|
+
case 'deadlink': return `指向 ${it.src},这个文件不存在(404):补上这一屏,或改成已有的屏`
|
|
390
|
+
case 'contrast': return `${it.fg} 配 ${it.bg},${it.ratio}:1(要 ≥ ${it.need}:1)`
|
|
391
|
+
case 'tap': return `${it.w}×${it.h}(至少 ${it.min}×${it.min})`
|
|
392
|
+
case 'tiny': return `${it.size}px(至少 ${it.min}px)`
|
|
393
|
+
case 'img': return String(it.src || '')
|
|
394
|
+
case 'fonts': return `一屏用了 ${(it.sizes || []).length} 种字号:${(it.sizes || []).join(' / ')}`
|
|
395
|
+
case 'distort': return `原图 ${it.nw}×${it.nh},显示成 ${it.w}×${it.h}(用 object-fit: cover 或改尺寸)`
|
|
396
|
+
case 'long': return `一行约 ${it.chars} 字(最好 ≤ ${it.max})`
|
|
397
|
+
case 'wrap': return `折成了 ${it.lines} 行(给它更多宽度、white-space: nowrap 或缩短文字)`
|
|
398
|
+
case 'covered': return `${it.edge === 'top' ? '没滚时' : '滚到底还'}被${EDGE[it.edge] || ''} ${it.bar} 压住 ${it.px}px(给内容留出栏的高度:${it.edge === 'top' ? '顶上' : '底部'}至少再留 ${it.px + 16}px,用 ${it.edge === 'top' ? 'padding-top' : 'padding-bottom'},栏是 fixed 时也可以用 scroll-padding)`
|
|
399
|
+
case 'tight': return `离${EDGE[it.edge] || ''} ${it.bar} 只有 ${it.px}px(留 16px 以上)`
|
|
400
|
+
case 'jserr': return `${it.msg}${it.src ? `(${it.src}${it.line ? `:${it.line}` : ''})` : ''}`
|
|
401
|
+
case 'missing': return `${WHAT[it.what] || '文件'} ${it.src} 载不出来(路径写错了,或者文件不在)`
|
|
402
|
+
default: return ''
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const sizeText = (w, h) => `${w}×${h}`
|
|
407
|
+
const nameOf = (r) => `${r.key}${r.title && r.title !== r.key ? `「${r.title}」` : ''}`
|
|
408
|
+
|
|
409
|
+
/** 一种尺寸下这一屏量出来的样子:页面多高、固定栏多高、离内容多远 */
|
|
410
|
+
function metricsLine(m) {
|
|
411
|
+
if (!m) return ''
|
|
412
|
+
const parts = [m.page > m.h + 1 ? `页面高 ${m.page}(比屏幕高,要滚动,正常)` : `页面高 ${m.page}(一屏放得下)`]
|
|
413
|
+
if (m.areas) parts.push(`里面有 ${m.areas} 块能单独滚的区域`)
|
|
414
|
+
for (const b of m.bars || []) {
|
|
415
|
+
const gap = b.gap == null ? '' : b.gap < 0 ? `,压住内容 ${-b.gap}px` : `,${b.edge === 'bottom' ? '滚到底时' : ''}离内容 ${b.gap}px`
|
|
416
|
+
parts.push(`${EDGE[b.edge] || '栏'} ${b.name} 高 ${b.h}${gap}`)
|
|
417
|
+
}
|
|
418
|
+
return parts.join(';')
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 体检结果 → 给模型的字。同一屏几种尺寸都有的问题只列一次,只在部分尺寸出现的标上尺寸
|
|
423
|
+
* @param rows measureAll 的结果
|
|
424
|
+
*/
|
|
425
|
+
/** 一条问题认得出是同一个(复查时比上次修好了哪些、新出了哪些) */
|
|
426
|
+
const issueKey = (it) => `${it.type}|${it.sel}|${it.text || ''}|${it.msg || ''}|${it.src || ''}`
|
|
427
|
+
const rowKey = (r, dark) => `${r.key}|${r.w}x${r.h}${dark ? '|dark' : ''}`
|
|
428
|
+
|
|
429
|
+
/** 这次量出来的(每屏每种尺寸一组问题),下次复查拿来比 */
|
|
430
|
+
export function issueSnapshot(rows, dark = false) {
|
|
431
|
+
const out = new Map()
|
|
432
|
+
for (const r of rows) if (!r.error) out.set(rowKey(r, dark), new Set(r.issues.map(issueKey)))
|
|
433
|
+
return out
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function formatMeasure(rows, { sizes, where, missing = [], more = 0, dark = false, prev = null, crops = [] }) {
|
|
437
|
+
const pics = new Map(crops.flatMap((c) => (c.ks || [c.k]).map((k) => [k, c])))
|
|
438
|
+
const keys = [...new Set(rows.map((r) => r.key))]
|
|
439
|
+
// 同一屏里几处叠字是同一个原因(同一个元素的同一项样式):并成一条(改一处就都好了)
|
|
440
|
+
const sameCause = new Map() // 屏|问题 → 并到的那条
|
|
441
|
+
for (const key of keys) {
|
|
442
|
+
const first = new Map()
|
|
443
|
+
for (const r of rows.filter((x) => x.key === key)) {
|
|
444
|
+
for (const it of r.issues) {
|
|
445
|
+
if (it.type !== 'overlap' || !it.cause || !it.cause.sel) continue
|
|
446
|
+
const ck = `${it.cause.sel}|${it.cause.prop}`
|
|
447
|
+
const k = `${key}|${issueKey(it)}`
|
|
448
|
+
if (!first.has(ck)) first.set(ck, k)
|
|
449
|
+
else if (first.get(ck) !== k) sameCause.set(k, first.get(ck))
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
const n = new Set(rows.flatMap((r) => r.issues.map((it) => `${r.key}|${issueKey(it)}`)).filter((k) => !sameCause.has(k))).size
|
|
454
|
+
const failed = rows.filter((r) => r.error).length
|
|
455
|
+
// 和上次同样的屏、同样的尺寸比:修好了几个、新出了几个(新的在清单里标出来)
|
|
456
|
+
const fresh = new Set()
|
|
457
|
+
let fixed = 0
|
|
458
|
+
let compared = 0
|
|
459
|
+
if (prev) {
|
|
460
|
+
for (const r of rows) {
|
|
461
|
+
const before = r.error ? null : prev.get(rowKey(r, dark))
|
|
462
|
+
if (!before) continue
|
|
463
|
+
compared++
|
|
464
|
+
const now = new Set(r.issues.map(issueKey))
|
|
465
|
+
for (const k of before) if (!now.has(k)) fixed++
|
|
466
|
+
for (const k of now) if (!before.has(k)) fresh.add(`${r.key}|${k}`)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const sz = sizes.map(([w, h]) => sizeText(w, h)).join('、')
|
|
470
|
+
const status = failed === rows.length
|
|
471
|
+
? '一次都没量成,这次检查没完成(原因见下)。'
|
|
472
|
+
: n
|
|
473
|
+
? `发现 ${n} 个问题,逐个改源文件,只动有问题的地方;颜色、字号能改设计变量就改设计变量${failed ? `(另有 ${failed} 次没量成,见下,检查不完整)` : ''}:`
|
|
474
|
+
: failed
|
|
475
|
+
? `量成的都没问题,但有 ${failed} 次没量成(见下),这次检查不完整。`
|
|
476
|
+
: '没发现问题:下面每屏每种尺寸都量过、都通过了(要滚动的屏看「滚到底时离内容」,是正数就没被压住)。'
|
|
477
|
+
const lines = [`用无头浏览器按真实尺寸量了 ${keys.length} 屏 × ${sizes.length} 种尺寸(${sz}${dark ? ',深色' : ''})${where ? `,设计目录 ${where}` : ''}。${status}`]
|
|
478
|
+
if (compared) lines.push(`比上次(同样的屏和尺寸):修好 ${fixed} 个,新出 ${fresh.size} 个${fresh.size ? '(清单里标了「新」)' : ''}。`)
|
|
479
|
+
if (crops.length) lines.push(`严重的问题附了 ${crops.length} 张局部截图(图 1${crops.length > 1 ? `–${crops.length}` : ''},真实渲染,按发现时的尺寸和滚动位置截的;红框是出问题的元素,框上标了差多少;叠在一起的另一个是橙色虚线框${crops.some((c) => c.type === 'covered') ? ';被栏压住的那几张把栏调成了半透明,透出下面被压住的字' : ''};要看整屏用 look_design)。`)
|
|
480
|
+
if (missing.length) lines.push(`没找到这几屏:${missing.join('、')}`)
|
|
481
|
+
if (more) lines.push(`还有 ${more} 屏这次没量(一次最多 ${MAX_SCREENS} 屏,用 screens 挑着量)`)
|
|
482
|
+
for (const key of keys) {
|
|
483
|
+
const mine = rows.filter((r) => r.key === key)
|
|
484
|
+
lines.push('', nameOf(mine.find((r) => r.title) || mine[0]))
|
|
485
|
+
for (const r of mine) {
|
|
486
|
+
if (r.error) lines.push(` ${sizeText(r.w, r.h)}:没量成(${r.error})`)
|
|
487
|
+
else lines.push(` ${sizeText(r.w, r.h)}:${metricsLine(r.metrics)}${r.noDark ? ';这一屏没有深色样式,量的是浅色' : ''}`)
|
|
488
|
+
}
|
|
489
|
+
const seen = new Map()
|
|
490
|
+
for (const r of mine) {
|
|
491
|
+
for (const it of r.issues) {
|
|
492
|
+
const k = issueKey(it)
|
|
493
|
+
if (!seen.has(k)) seen.set(k, { it, at: [] })
|
|
494
|
+
seen.get(k).at.push(sizeText(r.w, r.h))
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const ok = mine.filter((r) => !r.error).length
|
|
498
|
+
const merged = new Map() // 并进来的叠字:并到的那条 → [问题]
|
|
499
|
+
for (const [k, { it }] of seen) {
|
|
500
|
+
const to = sameCause.get(`${key}|${k}`)
|
|
501
|
+
if (to) merged.set(to, [...(merged.get(to) || []), it])
|
|
502
|
+
}
|
|
503
|
+
let i = 0
|
|
504
|
+
for (const [k, { it, at }] of seen) {
|
|
505
|
+
if (sameCause.has(`${key}|${k}`)) continue
|
|
506
|
+
const tag = (fresh.has(`${key}|${k}`) ? '[新] ' : '') + (at.length < ok && mine.length > 1 ? `[${at.join('、')}] ` : '')
|
|
507
|
+
const what = [it.sel, it.text ? `「${it.text}」` : ''].filter(Boolean).join('')
|
|
508
|
+
const pic = pics.get(`${key}|${k}`)
|
|
509
|
+
const upper = pic && pic.k === `${key}|${k}` && pic.upper ? `;${pic.upper === 'self' ? `「${String(it.text || '').slice(0, 12)}」` : `「${String(it.otherText || '').slice(0, 12)}」`}画在上面` : ''
|
|
510
|
+
const see = pic ? `(看图 ${pic.n}${at.length > 1 ? `,${sizeText(pic.w, pic.h)}` : ''}${upper})` : ''
|
|
511
|
+
const more = merged.get(`${key}|${k}`) || []
|
|
512
|
+
const detail = more.length
|
|
513
|
+
? `和 ${it.other}「${String(it.otherText || '').slice(0, 20)}」叠在一起;${more.map((x) => `${x.sel}「${String(x.text || '').slice(0, 20)}」和 ${x.other}「${String(x.otherText || '').slice(0, 20)}」`).join(';')}也叠着,同一个原因${causeText(it.cause)}`
|
|
514
|
+
: detailOf(it)
|
|
515
|
+
lines.push(` ${++i}. ${tag}${TYPE[it.type] || it.type}${what ? `:${what}` : ''} — ${detail}${whereRule(it.rule)}${see}`)
|
|
516
|
+
}
|
|
517
|
+
const lc = mine.find((r) => typeof r.linkCount === 'number')
|
|
518
|
+
if (lc && lc.linkCount) {
|
|
519
|
+
const dead = [...seen.values()].filter(({ it }) => it.type === 'deadlink').length
|
|
520
|
+
lines.push(` 跳转:查了 ${lc.linkCount} 个,${dead ? `${dead} 个打不开(见上)` : '都能打开'}`)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return lines.join('\n') + '\n'
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** 截图的说明:第几张是哪屏、什么尺寸、哪一段 */
|
|
527
|
+
export function formatShots(shots, { where, missing = [], more = 0 }) {
|
|
528
|
+
const AT = { top: '顶上一屏', bottom: '滚到底', selector: '元素' }
|
|
529
|
+
const lines = [`无头浏览器按真实尺寸截的图(真实渲染的像素)${where ? `,设计目录 ${where}` : ''}:`]
|
|
530
|
+
let n = 0
|
|
531
|
+
for (const s of shots) {
|
|
532
|
+
const what = s.at === 'full' ? `第 ${s.part}/${s.of} 屏` : AT[s.at] || ''
|
|
533
|
+
if (s.error) { lines.push(`- ${nameOf(s)} ${sizeText(s.w, s.h)} ${what}:没截成(${s.error})`); continue }
|
|
534
|
+
lines.push(`- 图 ${++n}:${nameOf(s)} ${sizeText(s.w, s.h)} ${what}${s.page ? `,页面高 ${s.page}` : ''}${s.noDark ? ',没有深色样式(截的是浅色)' : ''}`)
|
|
535
|
+
}
|
|
536
|
+
for (const key of new Set(shots.filter((s) => s.at === 'full' && s.of > 1 && !s.error).map((s) => s.key))) {
|
|
537
|
+
const mine = shots.filter((s) => s.key === key && s.at === 'full' && !s.error)
|
|
538
|
+
const skipped = mine[0].of - mine.length
|
|
539
|
+
if (skipped > 0) lines.push(`${key} 一共 ${mine[0].of} 段,中间 ${skipped} 段没截(要看就单截这一屏,或用 selector 截那一块)`)
|
|
540
|
+
}
|
|
541
|
+
if (missing.length) lines.push(`没找到这几屏:${missing.join('、')}`)
|
|
542
|
+
if (more) lines.push(`还有 ${more} 屏没截(一次最多 ${MAX_SHOTS} 张,用 screens 挑着截)`)
|
|
543
|
+
return lines.join('\n')
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/** 设计目录(给模型看文件在哪):本地文件预览是那个目录,开发服务器是地址 */
|
|
547
|
+
export function whereOf(snap) {
|
|
548
|
+
if (snap.target && snap.target.kind === 'file') return path.dirname(snap.target.file)
|
|
549
|
+
return ''
|
|
550
|
+
}
|