Browse Source
4.18 的核对脚本把 components/onboarding/ 整目录排除了,于是规则里最明确的 「整个项目所有卡片的圆角都必须是 8px」在 26 个引导页上从未被核对过。排除是 脚本作者自己加的,不是用户说的。本批先把它从「待定夺」变成「有数据」。 实测引导页 42 处 borderRadius:真卡片只有 5 张,其余是控件、图标底色块、圆与 占位框。「引导页自成一套半径体系」这个排除前提是假的 —— 其卡片半径是 8/14/10/8/8,只有两张不在 8,不存在另一套体系。 改动(10 个文件 +14/−14): - ScreenShell.scrollContent 内容留白 20 → 8;P1Login.content 24 → 8 (P1 不用页壳,自带 content) - RadioRow.row 12/14/16 → 8/8/8 —— 它与主应用 holland/question.option、 game/level.option 同形态(横排 + space-between + 白底 + borderWidth: 1), 那两处上批已判为卡片并置 8,是代码库自己否掉了「RadioRow 属控件」的判断 - B1Wechat.preview 圆角 14 → 8、内边距 14 → 8 - P12Company.notice 圆角 10 → 8、内边距 12 → 8、卡片间隔 16 → 8 - P9JobStatus.list / P18Education.list 的卡片间隔 gap 10 → 8 改错又还原的两处(本批最该记的教训): ScreenShell.backRow 与 footer 我改成了 8,两处都还原为 16。主应用底部固定区 的口径就是 16(padding: 16 出现 4 次),头部返回图标的视觉位置也在 16 上下 (headerBtn 是 40×40 居中盒,42/58 个文件如此)。即引导页原本的 16 本就与 主应用一致,只有内容留白 20 与主应用的 8 不符。我先前「同屏三个留白、体系 不自洽」的判断站不住:16 是全应用通用的框架留白,且返回按钮属页面框架、 不是卡片,不在「页面留白」范围内。教训是:判断某个值该不该统一之前, 先量一遍主应用同类元素的既有取值。 补上 4.18 卡片间隔维度的 3 处漏网:holland/question.options gap 10、 holland.featureList gap 10、jobs/weight-settings.templateRow gap 10, 三者子元素分别是 option / featureCard / templateCard,全是 r8/padding 8 的卡片。 纠正 4.18 的不实结论:当时报「卡片 477 / 圆角 = 8:217」并称剩余非 8 的只有 若干控件。实际那个脚本的排除正则锚定全名(^(...|btn|tag|chip|...)$), retryBtn / statusTag / filterChip / progressTrack 这类带后缀的键一个都没匹配上, 260 个控件全被当成卡片报出来。217/477 是脚本的原始通过数,剩余 260 项是肉眼 判成控件的,脚本从未验证过这个判断。另有大小写漏匹配与 *Box 排除过头 (emptyBox / salaryBox / matchBox 被一并吞掉,等于把上批改过的东西排除在核对之外)。 新增 scripts/check-spacing.mjs + npm run check:spacing(--error 有命中即 exit 1), 与 check-routes / check-affordances / check-icons / check-backend-stubs 同族体例。 把判据编码进脚本而不是留在人脑:结构判定而非命名判定、按词尾而非全名排除、 圆用几何判据(width === height 且 borderRadius ≈ width/2)、排除项逐条打印供复核, 给排除表加名字要写理由。可失败性已在仓库脚本上复验:塞入含 4 类违规的探针文件 四维全命中且 exit 1,删掉后 exit 0。 真机验证(emulator-5554,非代码走查):引导页由内部状态机驱动、无 URL 参数, 临时改初始步截图,看完已还原并 git diff 确认无残留。用像素测量代替目视 (/tmp/mdj/px.js 解码 PNG 取某行首个非背景像素;密度 420dpi → 比例 2.625)。 P1 按钮 8dp、P9 卡片 8dp/按钮 8dp、P17 卡片 8dp/按钮 8dp、B1 卡片居中(设计如此) /按钮 8dp、P12 提示卡 8dp/按钮 8dp。头部返回箭头改动前后同行 52px → 73px, 差 21px 正好 8dp,一次同时证明热重载与还原都生效。 门槛:tsc 0 / eslint 0 / 26 套件 365 用例全过 / check-routes / check:affordances / check:icons(194 文件 1357 字形)/ check-backend-stubs(140 函数)/ check:spacing(卡片 196,圆角 = 8:196/196,另三维度 0)全绿。master
13 changed files with 373 additions and 22 deletions
@ -0,0 +1,239 @@ |
|||
/** |
|||
* 间距与圆角校验:核对 `AGENTS.md` 的 |
|||
* 「整个项目所有卡片的圆角都必须是8px,无阴影,边距间隔一致,为8px。」 |
|||
* |
|||
* 背景(2026-09-18 ~ 09-20 的真实教训): |
|||
* 这条规则先后做过两批(4.17 圆角 / 4.18 边距),两批**都报过不实的结论**: |
|||
* |
|||
* ① 4.17 用「命名判定」(键名含 card)找卡片 → **漏网**。`pendingItem` / `dimItem` / |
|||
* `suggestionRow` / `matchBanner` / `salaryBox` / `taxItem` 这些名字里没有 card、 |
|||
* 结构上却是卡片的块,一个都没被查到。改成结构判定后才浮出来。 |
|||
* ② 4.18 我报「卡片 477 / 圆角 = 8:217」,并说「剩余非 8 的只有若干控件」。 |
|||
* 实际上那个脚本的排除正则是 `^(...|btn|tag|chip|...)$` —— **锚定全名**, |
|||
* 于是 `retryBtn` / `statusTag` / `filterChip` / `progressTrack` 这类 |
|||
* **带后缀的键一个都匹配不上**,260 个控件全被当成卡片报出来。 |
|||
* 「217/477」是脚本的原始通过数,剩下 260 项是我**肉眼判成控件**的, |
|||
* **脚本从未验证过这个判断**。这就是「推断冒充验证」。 |
|||
* |
|||
* 所以本脚本把判据**编码进去**,而不是留在人脑里: |
|||
* |
|||
* 卡片 = 有 borderRadius 且(有 backgroundColor 或 borderWidth),且不属于下列形态 |
|||
* |
|||
* ① 圆:`width === height` 且 `borderRadius ≈ width / 2`。 |
|||
* 圆不是卡片 —— 把 120×120 / r60 的装饰圆改成 8 会把它变成圆角方块,属误改。 |
|||
* 用**几何判据**而不是名字清单,是为了以后新增的圆自动被认出来。 |
|||
* ② 控件 / 标签 / 按钮 / 图标底:按**词尾**判定(不是全名!见上文缺陷)。 |
|||
* 排除项一律**逐条打印供复核**,不静默吞掉 —— 否则排除表本身会变成新的黑箱。 |
|||
* |
|||
* **不排除**:`option` / `row` / `card` / `preview` / `notice` / `taxItem` / `dimItem` / 任何 `*Item`。 |
|||
* 它们本身就是卡片,必须参与核对。(`RadioRow.row`、`holland/question.option` 同形态, |
|||
* 都是卡片;把 RadioRow 归成「控件排除」是本脚本要防的那类错误。) |
|||
* |
|||
* 三个维度(对应规则里的「边距间隔一致」): |
|||
* 页面留白 —— 主 ScrollView 的 contentContainerStyle 的 padding / paddingHorizontal |
|||
* 卡片内边距 —— 卡片的 padding(无 padding 时看 paddingHorizontal / paddingVertical) |
|||
* 卡片间隔 —— 卡片的 marginTop / marginBottom |
|||
* |
|||
* **不在范围内**(规则主语是「所有卡片」,用户确认的「页面留白」问的是「卡片离屏幕边缘的距离」): |
|||
* - 卡片**内部**子元素之间的间距(如卡内若干行文字的 gap) |
|||
* - 页面框架(头部返回箭头 backRow / 底部固定区 footer)—— 主应用同类取值为 16,非 8 |
|||
* - 控件自身的 padding(输入框、chip、标签) |
|||
* |
|||
* 可失败性:本脚本已用塞了 4 类违规(r=12 的卡 / padding 12 的卡 / marginTop 12 的卡 / |
|||
* 留白 16 的页)的临时文件验证过,四个维度全部命中。**「全绿」必须配上这一步, |
|||
* 否则分不清是合规还是脚本坏了。** |
|||
* |
|||
* 用法:node scripts/check-spacing.mjs # 列出全部 + 排除项 |
|||
* node scripts/check-spacing.mjs --error # 有命中则 exit 1(可用于 CI) |
|||
*/ |
|||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
|
|||
const STRICT = process.argv.includes('--error'); |
|||
|
|||
const ROOTS = ['app', 'components', 'src']; |
|||
const TARGET = 8; |
|||
|
|||
/** 要核对的间距维度 */ |
|||
const CARD_GAP_PROPS = ['marginTop', 'marginBottom']; |
|||
|
|||
/** |
|||
* 控件 / 标签 的词尾判据。 |
|||
* **必须按词尾匹配**:锚定全名会漏掉 `retryBtn` / `statusTag` / `filterChip` 这类带后缀的键 |
|||
* (4.18 的原始缺陷)。大小写不敏感:RN 里 `tag` / `input` / `chip` 大量是小写键。 |
|||
*/ |
|||
const CONTROL_SUFFIX = |
|||
/(Btn|Button|Tag|Chip|Badge|Input|Indicator|TextArea|Upload|Checkbox|Puzzle|Wrap|Tab|Track|Fill|Bar|Dot|Circle|Knob|Slider|Radio|Thumb|Handle|Logo|Icon|Toast|Cancel|Confirm|Pie|Line|opt|placeholder|paper|slot|block|progress|counter|glow|Submit)$/i; |
|||
|
|||
/** |
|||
* 具名例外(卡片形态的除外项,逐个看过,不是「差不多」)。 |
|||
* 加这一条要写理由 —— 名单是判据的一部分,不是把输出压到 0 的手段。 |
|||
*/ |
|||
const EXPLICIT_CONTROL = new Set([ |
|||
'filterItem', // skill-cloud 行业筛选胶囊
|
|||
'emptyTabItem', // job-evaluations 空态里的 Tab 项
|
|||
'tipAction', // jobs/rank 的「查看全部」文字链
|
|||
'templateLineShort', // resume/export 模板缩略图里的模拟文字行
|
|||
'box', // onboarding/SearchInput、AgreementCheckbox 的控件盒
|
|||
'inputBox', // salary-calculator 输入位
|
|||
'searchBox', // jobs 搜索框
|
|||
'logoBox', // enterprise-overview Logo 底
|
|||
'avatarBox', // candidate-detail 头像位
|
|||
'uploadBox', // appeal 上传位
|
|||
'puzzleArea', // SliderCaptcha 拼图区
|
|||
]); |
|||
|
|||
/* ---------------- 解析 ---------------- */ |
|||
|
|||
function walk(dir, out = []) { |
|||
if (!existsSync(dir)) return out; |
|||
for (const entry of readdirSync(dir)) { |
|||
const full = join(dir, entry); |
|||
if (statSync(full).isDirectory()) walk(full, out); |
|||
else if (/\.tsx$/.test(entry)) out.push(full.split(/[\\/]/).join('/')); |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
/** 取 StyleSheet.create 里的顶层样式块(两空格缩进),单行块与多行块都要支持 */ |
|||
function styleBlocks(src) { |
|||
const out = []; |
|||
const lines = src.split('\n'); |
|||
for (let i = 0; i < lines.length; i += 1) { |
|||
const head = lines[i].match(/^(\s{2})([A-Za-z_$][\w$]*)\s*:\s*\{/); |
|||
if (!head) continue; |
|||
const body = [lines[i]]; |
|||
if (!/\}\s*,?\s*$/.test(lines[i])) { |
|||
for (let j = i + 1; j < lines.length; j += 1) { |
|||
if (new RegExp(`^${head[1]}\\}`).test(lines[j])) break; |
|||
body.push(lines[j]); |
|||
} |
|||
} |
|||
out.push({ key: head[2], text: body.join('\n') }); |
|||
} |
|||
return out; |
|||
} |
|||
|
|||
/** |
|||
* 单行样式块 `a: { gap: 12 },` 会让 `^\s*prop:` 这类行首锚点全部失效, |
|||
* 所以先把 `{ } ,` 换成换行再匹配。 |
|||
*/ |
|||
const flatten = (text) => text.replace(/[{},]/g, '\n'); |
|||
|
|||
function readNumber(text, prop) { |
|||
const m = flatten(text).match(new RegExp(`^[ \\t]*${prop}:[ \\t]*(-?[0-9.]+)`, 'm')); |
|||
return m ? Number(m[1]) : null; |
|||
} |
|||
|
|||
const hasProp = (text, prop) => new RegExp(`^[ \\t]*${prop}:`, 'm').test(flatten(text)); |
|||
|
|||
const isCircle = ({ text }) => { |
|||
const w = readNumber(text, 'width'); |
|||
const h = readNumber(text, 'height'); |
|||
const r = readNumber(text, 'borderRadius'); |
|||
return w !== null && h !== null && w === h && Math.abs(r - w / 2) < 0.51; |
|||
}; |
|||
|
|||
/* ---------------- 核对 ---------------- */ |
|||
|
|||
const files = ROOTS.flatMap((root) => walk(root)); |
|||
const violations = { radius: [], padding: [], gap: [], gutter: [] }; |
|||
const excluded = []; |
|||
let cardCount = 0; |
|||
let radiusOk = 0; |
|||
|
|||
for (const file of files) { |
|||
const src = readFileSync(file, 'utf8'); |
|||
if (!src.includes('StyleSheet.create(')) continue; |
|||
|
|||
for (const block of styleBlocks(src)) { |
|||
if (readNumber(block.text, 'borderRadius') === null) continue; |
|||
if (!hasProp(block.text, 'backgroundColor') && !hasProp(block.text, 'borderWidth')) continue; |
|||
if (isCircle(block)) { |
|||
excluded.push(`${file} | ${block.key} | 圆 ${readNumber(block.text, 'width')}`); |
|||
continue; |
|||
} |
|||
if (CONTROL_SUFFIX.test(block.key) || EXPLICIT_CONTROL.has(block.key)) { |
|||
excluded.push(`${file} | ${block.key} | 控件/标签`); |
|||
continue; |
|||
} |
|||
|
|||
cardCount += 1; |
|||
const radius = readNumber(block.text, 'borderRadius'); |
|||
if (radius !== TARGET) { |
|||
violations.radius.push(`${file} | ${block.key} | borderRadius ${radius}`); |
|||
continue; |
|||
} |
|||
radiusOk += 1; |
|||
|
|||
const padding = readNumber(block.text, 'padding'); |
|||
if (padding !== null) { |
|||
if (padding !== TARGET) violations.padding.push(`${file} | ${block.key} | padding ${padding}`); |
|||
} else { |
|||
for (const prop of ['paddingHorizontal', 'paddingVertical']) { |
|||
const value = readNumber(block.text, prop); |
|||
if (value !== null && value !== TARGET) { |
|||
violations.padding.push(`${file} | ${block.key} | ${prop} ${value}`); |
|||
} |
|||
} |
|||
} |
|||
|
|||
for (const prop of CARD_GAP_PROPS) { |
|||
const value = readNumber(block.text, prop); |
|||
if (value !== null && value !== TARGET) { |
|||
violations.gap.push(`${file} | ${block.key} | ${prop} ${value}`); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 页面留白:取主内容 ScrollView。 |
|||
* 一页可能有多个 ScrollView(空态 / 错误态 + 主内容),**主内容在后面**,故取最后一个匹配; |
|||
* 没有 contentContainerStyle 的文件(纯 View 布局)用候选键名兜底。 |
|||
*/ |
|||
const GUTTER_FALLBACK_KEYS = ['content', 'body', 'scrollContent', 'listContent', 'group', 'container']; |
|||
|
|||
for (const file of files) { |
|||
const src = readFileSync(file, 'utf8'); |
|||
if (!src.includes('contentContainerStyle')) continue; |
|||
|
|||
const referenced = [...src.matchAll(/contentContainerStyle=\{styles\.([\w$]+)/g)].map((m) => m[1]); |
|||
const candidates = referenced.length ? [referenced[referenced.length - 1]] : []; |
|||
for (const key of GUTTER_FALLBACK_KEYS) if (!candidates.includes(key)) candidates.push(key); |
|||
|
|||
const blocks = styleBlocks(src); |
|||
for (const key of candidates) { |
|||
const block = blocks.find((b) => b.key === key); |
|||
if (!block) continue; |
|||
const padding = readNumber(block.text, 'padding'); |
|||
const horizontal = readNumber(block.text, 'paddingHorizontal'); |
|||
if (padding === null && horizontal === null) continue; |
|||
const value = padding !== null ? padding : horizontal; |
|||
if (value !== TARGET) violations.gutter.push(`${file} | ${key} | ${value}`); |
|||
break; |
|||
} |
|||
} |
|||
|
|||
/* ---------------- 输出 ---------------- */ |
|||
|
|||
const total = violations.radius.length + violations.padding.length |
|||
+ violations.gap.length + violations.gutter.length; |
|||
|
|||
console.log(`卡片(结构判定):${cardCount},圆角 = ${TARGET}:${radiusOk} / ${cardCount}`); |
|||
console.log(` 圆角 ≠ ${TARGET}:${violations.radius.length}`); |
|||
violations.radius.forEach((line) => console.log(` ${line}`)); |
|||
console.log(` 内边距 ≠ ${TARGET}:${violations.padding.length}`); |
|||
violations.padding.forEach((line) => console.log(` ${line}`)); |
|||
console.log(` 卡片间隔 ≠ ${TARGET}:${violations.gap.length}`); |
|||
violations.gap.forEach((line) => console.log(` ${line}`)); |
|||
console.log(` 页面留白 ≠ ${TARGET}:${violations.gutter.length}`); |
|||
violations.gutter.forEach((line) => console.log(` ${line}`)); |
|||
console.log(`--- 已剔除 ${excluded.length} 项(圆 / 控件),逐条列出供复核 ---`); |
|||
excluded.forEach((line) => console.log(` ${line}`)); |
|||
|
|||
if (STRICT && total > 0) { |
|||
console.error(`\n❌ 发现 ${total} 处间距/圆角不符合 AGENTS.md 的 ${TARGET}px 规则`); |
|||
process.exit(1); |
|||
} |
|||
if (total === 0) console.log(`\n✅ 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白 全部为 ${TARGET}px`); |
|||
Loading…
Reference in new issue