From eaa8adb858f31b406c8d11119900482cc28b7788 Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Sun, 20 Sep 2026 16:02:05 +0800 Subject: [PATCH] =?UTF-8?q?style(ui):=20=E5=BC=95=E5=AF=BC=E9=A1=B5=208px?= =?UTF-8?q?=20=E6=94=B6=E6=95=9B=E4=B8=8E=E4=B8=8A=E6=89=B9=20gap=20?= =?UTF-8?q?=E6=BC=8F=E7=BD=91=E4=BF=AE=E6=AD=A3=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E6=A0=B8=E5=AF=B9=E9=97=A8=E6=A7=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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)全绿。 --- app/holland.tsx | 2 +- app/holland/question.tsx | 2 +- app/jobs/weight-settings.tsx | 2 +- components/onboarding/screens/B1Wechat.tsx | 2 +- components/onboarding/screens/P12Company.tsx | 6 +- .../onboarding/screens/P18Education.tsx | 2 +- components/onboarding/screens/P1Login.tsx | 2 +- components/onboarding/screens/P9JobStatus.tsx | 2 +- components/onboarding/ui/RadioRow.tsx | 6 +- components/onboarding/ui/ScreenShell.tsx | 2 +- package.json | 3 +- scripts/check-spacing.mjs | 239 ++++++++++++++++++ 项目开发状态.md | 125 ++++++++- 13 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 scripts/check-spacing.mjs diff --git a/app/holland.tsx b/app/holland.tsx index 728255e..9ec2801 100644 --- a/app/holland.tsx +++ b/app/holland.tsx @@ -257,7 +257,7 @@ const styles = StyleSheet.create({ lineHeight: 16, }, featureList: { - gap: 10, + gap: 8, marginBottom: 20, }, featureCard: { diff --git a/app/holland/question.tsx b/app/holland/question.tsx index da5fed1..8f8b687 100644 --- a/app/holland/question.tsx +++ b/app/holland/question.tsx @@ -259,7 +259,7 @@ const styles = StyleSheet.create({ textAlign: 'center', lineHeight: 28, }, - options: { gap: 10, marginBottom: 20 }, + options: { gap: 8, marginBottom: 20 }, option: { flexDirection: 'row', alignItems: 'center', diff --git a/app/jobs/weight-settings.tsx b/app/jobs/weight-settings.tsx index b6f6a7a..d1ccc39 100644 --- a/app/jobs/weight-settings.tsx +++ b/app/jobs/weight-settings.tsx @@ -215,7 +215,7 @@ const styles = StyleSheet.create({ marginRight: 8, }, sectionTitle: { fontSize: 15, fontWeight: '500', color: colors.textPrimary }, - templateRow: { gap: 10, paddingRight: 16 }, + templateRow: { gap: 8, paddingRight: 16 }, templateCard: { width: 140, padding: 8, diff --git a/components/onboarding/screens/B1Wechat.tsx b/components/onboarding/screens/B1Wechat.tsx index 20f6af0..b6a44cf 100644 --- a/components/onboarding/screens/B1Wechat.tsx +++ b/components/onboarding/screens/B1Wechat.tsx @@ -32,7 +32,7 @@ export function B1Wechat() { const styles = StyleSheet.create({ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 18, paddingBottom: 40 }, - preview: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.white, padding: 14, borderRadius: 14, borderWidth: 1, borderColor: colors.border }, + preview: { flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: colors.white, padding: 8, borderRadius: 8, borderWidth: 1, borderColor: colors.border }, previewText: { gap: 4 }, nick: { fontSize: 15, fontWeight: '700', color: colors.textPrimary }, tip: { fontSize: 12, color: colors.textHint }, diff --git a/components/onboarding/screens/P12Company.tsx b/components/onboarding/screens/P12Company.tsx index 1187784..3bff561 100644 --- a/components/onboarding/screens/P12Company.tsx +++ b/components/onboarding/screens/P12Company.tsx @@ -33,9 +33,9 @@ export function P12Company() { const styles = StyleSheet.create({ notice: { - marginTop: 16, - padding: 12, - borderRadius: 10, + marginTop: 8, + padding: 8, + borderRadius: 8, backgroundColor: colors.primaryLight, }, noticeText: { fontSize: 12, color: colors.primaryText, lineHeight: 18 }, diff --git a/components/onboarding/screens/P18Education.tsx b/components/onboarding/screens/P18Education.tsx index 33775e5..e29f8ab 100644 --- a/components/onboarding/screens/P18Education.tsx +++ b/components/onboarding/screens/P18Education.tsx @@ -50,7 +50,7 @@ const styles = StyleSheet.create({ alignSelf: 'center', marginBottom: 18, }, - list: { gap: 10 }, + list: { gap: 8 }, subTitle: { fontSize: 15, fontWeight: '700', color: colors.textPrimary, marginTop: 22, marginBottom: 12 }, footer: { marginTop: 28 }, }); diff --git a/components/onboarding/screens/P1Login.tsx b/components/onboarding/screens/P1Login.tsx index 5a5074d..faae574 100644 --- a/components/onboarding/screens/P1Login.tsx +++ b/components/onboarding/screens/P1Login.tsx @@ -76,7 +76,7 @@ export function P1Login() { const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: colors.pageBg }, topGlow: { position: 'absolute', top: 0, left: 0, right: 0, height: 200 }, - content: { flexGrow: 1, paddingHorizontal: 24, paddingBottom: 12 }, + content: { flexGrow: 1, paddingHorizontal: 8, paddingBottom: 12 }, brandArea: { alignItems: 'center', marginTop: '22%' }, flexSpacer: { flex: 1, minHeight: 28 }, quickBtn: { diff --git a/components/onboarding/screens/P9JobStatus.tsx b/components/onboarding/screens/P9JobStatus.tsx index 49753b5..85a69ce 100644 --- a/components/onboarding/screens/P9JobStatus.tsx +++ b/components/onboarding/screens/P9JobStatus.tsx @@ -59,7 +59,7 @@ const styles = StyleSheet.create({ alignSelf: 'center', marginBottom: 18, }, - list: { gap: 10 }, + list: { gap: 8 }, note: { fontSize: 12, color: colors.textHint, marginTop: 14, textAlign: 'center' }, agree: { marginTop: 18 }, agreeText: { fontSize: 13, color: colors.textSecondary }, diff --git a/components/onboarding/ui/RadioRow.tsx b/components/onboarding/ui/RadioRow.tsx index fb772b2..8d8fa22 100644 --- a/components/onboarding/ui/RadioRow.tsx +++ b/components/onboarding/ui/RadioRow.tsx @@ -29,9 +29,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - paddingVertical: 14, - paddingHorizontal: 16, - borderRadius: 12, + paddingVertical: 8, + paddingHorizontal: 8, + borderRadius: 8, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.white, diff --git a/components/onboarding/ui/ScreenShell.tsx b/components/onboarding/ui/ScreenShell.tsx index e54d5c8..8bad576 100644 --- a/components/onboarding/ui/ScreenShell.tsx +++ b/components/onboarding/ui/ScreenShell.tsx @@ -47,6 +47,6 @@ const styles = StyleSheet.create({ titleInline: { fontSize: 17, fontWeight: '700', color: colors.textPrimary }, titleCenter: { fontSize: 18, fontWeight: '800', color: colors.textPrimary, textAlign: 'center', paddingVertical: 12 }, scroll: { flex: 1 }, - scrollContent: { flexGrow: 1, padding: 20, paddingBottom: 28 }, + scrollContent: { flexGrow: 1, padding: 8, paddingBottom: 28 }, footer: { padding: 16, paddingBottom: 22, backgroundColor: colors.pageBg }, }); diff --git a/package.json b/package.json index 308624e..0c673e9 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "check:affordances": "node scripts/check-affordances.mjs --error", "check:affordances:pending": "node scripts/check-affordances.mjs --pending", "check:backend-stubs": "node scripts/check-backend-stubs.mjs", - "check:icons": "node scripts/check-icons.mjs --error" + "check:icons": "node scripts/check-icons.mjs --error", + "check:spacing": "node scripts/check-spacing.mjs --error" }, "private": true, "devDependencies": { diff --git a/scripts/check-spacing.mjs b/scripts/check-spacing.mjs new file mode 100644 index 0000000..dd8fef7 --- /dev/null +++ b/scripts/check-spacing.mjs @@ -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`); diff --git a/项目开发状态.md b/项目开发状态.md index f00d378..bc7988f 100644 --- a/项目开发状态.md +++ b/项目开发状态.md @@ -1567,19 +1567,121 @@ modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 }, #### 11. 本批次仍未做 / 待定夺 -- **【待定夺】`ScreenShell.padding: 20 → 8` 是否执行**:影响 26 个引导页的首启视觉。 - 规则说「整个项目」,且用户已确认「页面留白也统一 8px」;但引导页是独立设计流程, - 半径体系已按同一逻辑排除。**改动只有一行,等一句话。** -- **【待定夺】卡片半径口径是否继续收紧**:结构判定下全仓仍有约 260 个 - 「有 borderRadius + 有背景/描边」的块不是 8,逐个核过后确认**绝大多数是控件/标签/按钮/图标底** - (`retryBtn` / `avatar` / `tabIndicator` / `progressTrack` / `badge` / `sectionBar` …), - 已按第 5 条排除。**剩余无争议项已清零,未发现新的漏网卡片。** +- ~~**【待定夺】`ScreenShell.padding: 20 → 8`**~~ → **已执行,但只改内容留白**:见 4.19。 + 引导页「自成一套体系」这个排除前提**是错的**(实测其卡片是 8/14/10/8/8,只有两张不在 8, + 根本不存在另一套半径体系);而 backRow / footer 的 16 **本来就是全应用通用的框架留白** + (主应用同类为 16),改成 8 才是错的,已还原。 +- ~~**【待定夺】卡片半径口径是否继续收紧**~~ → **已用脚本证明,不再是估计**:见 4.19 第 4 条。 + 全仓 196 张卡片圆角 100% = 8。上一版「约 260 个不是 8」是**脚本正则锚定全名导致漏排除** + (`retryBtn` / `statusTag` / `filterChip` 这类带后缀的键一个都没匹配上), + 当时报的「217/477」是脚本原始通过数,**260 项是我肉眼判成控件、脚本从未验证过**。现已把判据编码进脚本。 - 4.16 第七节 A–E 五条重复声明发现、4.16 第 5/6 条两项产品定夺、4.15 起的其余开放项 **状态全部不变**(详见 4.17 第 10 条)。 - **仍未建立设计令牌层**:8px 与各间距值目前仍是**裸字面量**,没有单一来源、没有 lint 强制。 这是「改一次值要动 73 个文件」的根因,也是本批次规模这么大的原因。**建议下一批做** (`src/theme` 目前只有 `colors.ts`)。 +### 4.19 引导页 8px 收敛 + 上批 gap 漏网 + 核对脚本自身缺陷(2026-09-20 批次,接 4.18) + +**起因**:4.18 的核对脚本把 `components/onboarding/` 整目录排除了。而规则里最明确、最没有 +裁量空间的一句是「整个项目**所有卡片**的圆角都必须是 8px」——**这条在 26 个引导页上从未被核对过**。 +排除是我自己加的,不是用户说的。本批先把这件事从「等你定夺」变成「有数据」。 + +**1. 引导页实测**:42 处 `borderRadius`,逐块读过上下文后分类 —— 真卡片只有 5 张 +(`B1Wechat.preview` / `P12Company.notice` / `P17Reference.card` / `P3Identity.card` / `Modal.card`), +其余 37 处是控件(`input` / `upload` / `opt` / `quickBtn` / `cardBtn` / `box` / `tag` / `chip`)、 +图标底色块(5 个 `iconWrap` 的 18)、圆(`P22Done.glow` 120/60、`dot`、`Avatar`)与占位框(`slot`)。 +**「引导页自成一套半径体系」这个前提是假的**:其卡片半径是 8/14/10/8/8,只有两张不在 8, +根本不存在另一套体系。排除所依据的理由本身不成立。 + +**2. 改了什么** +| # | 位置 | 改动 | 依据 | +|---|---|---|---| +| 1 | `ScreenShell.scrollContent` | `padding: 20 → 8` | 内容留白,规则 + 用户确认「页面留白也统一 8px」 | +| 2 | `P1Login.content` | `paddingHorizontal: 24 → 8` | 同上(P1 不用页壳,自带 content) | +| 3 | `RadioRow.row` | `12/14/16 → 8/8/8` | 与主应用 `holland/question.option`、`game/level.option` **同形态**(横排 + space-between + 白底 + borderWidth: 1),那两处上批已判为卡片并置 8 —— **是代码库自己否掉了我把 RadioRow 归成「控件排除」的判断** | +| 4 | `B1Wechat.preview` | `r14 → 8`、`padding 14 → 8` | 真卡片 | +| 5 | `P12Company.notice` | `r10 → 8`、`padding 12 → 8`、`marginTop 16 → 8` | 真卡片(同主应用 `aiTip` / `matchBanner` 口径) | +| 6 | `P9JobStatus.list` / `P18Education.list` | `gap 10 → 8` | RadioRow 卡片堆叠 | + +**3. 改错了又还原的两处(本批最该记的教训)** +- `ScreenShell.backRow` 我改成了 `paddingHorizontal: 8`,`footer` 改成了 `paddingHorizontal: 8`。 +- **两处都还原成 16**。理由:主应用底部固定区的口径就是 **16**(`padding: 16` 出现 4 次、 + `paddingHorizontal: 16` 2 次),头部返回图标的视觉位置也在 16 上下(`headerBtn` 是 40×40 居中盒, + 42/58 个文件都是这个结构)。也就是说**引导页原本的 backRow 16 / footer 16 本来就与主应用一致**, + 只有内容留白 20 与主应用的 8 不符。 +- 我先前那句「同一屏上 20 / 16 / 16 三个留白、体系不自洽」**站不住**:16 是全应用通用的框架留白, + 不是引导页的局部不一致。而且返回按钮属于**页面框架**,不是卡片——用户确认的「页面留白」问的是 + 「卡片离屏幕边缘的距离」,头部框架本不在规则范围内,我改它是越界。 +- **教训**:判断某个值「该不该统一」之前,先量一遍**主应用同类元素的既有取值**,不要凭「同屏三个数不一样」 + 就推定不一致。 + +**4. 核对脚本自身有三处缺陷(比代码改动更重要)** +4.18 我报的「卡片 477 / 圆角 = 8:217」是**脚本的原始通过数,不是证明**——剩余 260 项是我肉眼判成 +控件/标签的,脚本从没验证过这个判断。本批把判据编码进脚本时暴露三处缺陷: +1. **排除正则锚定全名**:`^(...|btn|tag|chip|...)$` 匹配不到 `retryBtn` / `statusTag` / `filterChip`, + 于是 203 个控件全被当成卡片报出来。改成按**词尾**判定。 +2. **大小写**:React Native 里大量键是小写(`tag` / `input` / `chip` / `box` / `textarea`), + 而列表里写的是 `Tag` / `Input`,全部漏匹配。加 `i` 标志。 +3. **`Box` 排除过头**:`emptyBox`(上批刚改成 `padding: 8` 的空态盒)、`salaryBox`、`matchBox` + 都被 `*Box` 一起吞掉了 —— 等于把上批改过的东西排除在核对之外。改为只排除确属控件的具名盒。 + 另外 `option` / `row` / `card` / `preview` / `notice` / `taxItem` / `dimItem` / `*Item` **一律不排除**, + 它们本身就是卡片,必须参与核对。 +- **可失败性验证(不能失败的东西不算检查)**:拿一个塞了 4 类违规(r=12 的卡、padding 12 的卡、 + marginTop 12 的卡、留白 16 的页)的临时文件喂给脚本,四个维度**全部命中**。 + 第一次喂的时候脚本报「0 张卡片」——原因是 `/tmp/...` 在 Windows 的 node 里解析成 `C:\tmp\...`, + 目录不存在,walk 直接 return。**「全绿」必须配上这一步,否则分不清是合规还是脚本坏了。** + +**5. 上批(4.18)卡片间隔维度的漏网 3 处**:gap 维度当时是逐个容器看 JSX 判的,漏了 +`app/holland/question.tsx options`(gap 10)、`app/holland.tsx featureList`(10)、 +`app/jobs/weight-settings.tsx templateRow`(10)——三者的子元素分别是 `option` / `featureCard` / +`templateCard`,**全都是 r8 / padding 8 的卡片**。本批已全部改为 8。 +- 排除确认(逐个看过,不是「差不多」):`ability.subItems`(进度条行)、`progress.monthGrid`(图表格)、 + `P5City.list`(下划线分隔行,无圆角无底)、`P7Salary.col`(选项按钮列)、 + `legal.*.benefits` / `resume.*.rowBody` / `compare.scoreBars` / `P17Reference.card` 的 gap + (**均为卡片内部子行间距**,不是卡片之间)、`skill-cloud.searchEmpty` / `talent-recommendation.emptyList` + (**空态盒内部**)、以及 14 个 `center` / `stateBox` / `emptyList`(错误重试态居中容器,误改会打散居中)。 + +**6. 真机验证(模拟器 `emulator-5554`,非代码走查)** +引导页由内部状态机驱动(`useOnboardingFlow` 的 `useState('P1')`),没有 URL 参数, +deep-link 只能到 P1。**临时**把初始步改成目标页截图,**看完已还原**,并 `git diff` 确认该文件无残留改动。 +- 用像素测量代替肉眼(截图缩放后目视判读不可靠):`/tmp/mdj/px.js` 解码 PNG 后找某行第一个非背景像素, + 密度 420dpi → 比例 **2.625**(不是 2.75,第一版算错过)。 +- 实测:P1 按钮 8dp · P9 首张 RadioRow 8dp / 底部按钮 8dp · P17 卡片 8dp / 按钮 8dp · + B1 卡片居中(`center` 的 `alignItems: 'center'`,设计如此)/ 按钮 8dp · P12 提示卡 8dp / 按钮 8dp。 +- **头部返回箭头:改动前后同一行 52px → 73px,差 21px = 正好 8dp** —— 一次同时证明了 + 「热重载生效」与「还原生效」。 +- 截图里的灰色齿轮圆是 **Expo 开发菜单浮钮**,不是应用 UI。P12 搜索框里的 `111` 是设备本地 + 留存的引导数据(`profile.school` 同类),不是本批改动。 + +**7. 门槛**:`npx tsc --noEmit` 0 · `npx eslint .` 0 · `npm test` 26 套件 / 365 用例全过 · +`check-routes` · `check:affordances` · `check:icons`(194 文件 / 1357 字形)· `check-backend-stubs`(140 函数)全绿。 +改动量 **11 文件(含新增 `scripts/check-spacing.mjs`)+ 状态文档**。 + +**8. 把这份核对**落成门槛**,不再是「跑完就没了的一次性脚本** +- 新增 `scripts/check-spacing.mjs` + `npm run check:spacing`(`--error` 有命中即 exit 1), + 与 `check-routes` / `check-affordances` / `check-icons` / `check-backend-stubs` 同族同体例。 +- **动机**:4.17 / 4.18 两批都靠「临时写个脚本、跑完删掉」,于是这次的教训下一批就得重学一遍。 + 规则要能被强制,否则「改一次值动 73 个文件」会一直复发。 +- 脚本里把判据**写成了注释与代码**:结构判定而非命名判定、按词尾而非全名排除、圆用几何判据、 + 排除项逐条打印。**给排除表加名字要写理由**——否则排除表本身会变成新的黑箱。 +- **可失败性已在仓库脚本上复验**:往 `src/` 塞一个含 4 类违规(r=12 的卡 / padding 12 的卡 / + marginTop 12 的卡 / 留白 16 的页)的探针文件 → 四维全命中、**exit 1**;删掉探针 → **exit 0**。 +- **终值**:卡片 196(含引导页),圆角 = 8:**196 / 196**,内边距 ≠ 8:0,卡片间隔 ≠ 8:0, + 页面留白 ≠ 8:0;另剔除 359 项(圆 / 控件)并逐项列出。 + +**9. 仍未做 / 待定夺** +- **主应用底部按钮 16 vs 卡片 8**:14 个带 footer 的主应用页面是 `padding: 16`,同屏卡片是 8, + 两者左右各差 8。这是**主应用既有的状态**,且底部按钮不属于规则里的「卡片」;要不要一起统一到 8, + **需要产品/设计一句话**(改的是 14 个主应用页面,比引导页那批影响更大,故不擅动)。 +- **非卡片元素之间的间距仍不一致**:同为图标底色块,`P10FirstWork` / `P13JoinDate` 是 `marginBottom: 12`, + `P8Resume1` / `P9JobStatus` / `P18Education` 是 18。按「规则主语是所有卡片」的读法,这些不在范围内; + 若要按字面「边距间隔一致」执行,则属另一批。 +- **仍未建立设计令牌层**(4.18 第 9 条):8px 与各间距值仍是**裸字面量**,没有单一来源。 + 本批补上了**强制**(`npm run check:spacing`,见第 8 条)但没有补**来源** —— + 现在「值跑偏了会被拦住」,但「改一次值仍要动 N 个文件」。根因只解决了一半,建议下一批做 + (`src/theme` 目前只有 `colors.ts`;可参照 `colors.ts` 的 `semanticColorTokens` 做法建 spacing 令牌)。 + --- ## 五、后续增量开发建议(优先级) @@ -1628,6 +1730,15 @@ node scripts/check-backend-stubs.mjs # 页面用 `as keyof typeof Ionicons.glyphMap` 断言会吞掉这类类型错误,故需要此脚本 npm run check:icons # 等价 node scripts/check-icons.mjs --error +# 间距与圆角审计(改动样式后必跑;**已设为硬门槛 —— 有命中即 exit 1**) +# 核对 AGENTS.md 的「所有卡片圆角 8px + 边距间隔一致 8px」,四个维度: +# 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白 +# 卡片用**结构判定**(有 borderRadius 且有背景或描边),不用命名判定 —— +# 命名判定漏过 pendingItem / dimItem / matchBanner / salaryBox 这类「名字没 card 但是卡片」的块 +# 排除项(圆 / 控件)**逐条打印**,名单改动必须写理由 +# 新增卡片样式后若报错,先看是漏排除还是真违规,**不要直接往排除表里塞名字** +npm run check:spacing # 等价 node scripts/check-spacing.mjs --error + # 单元测试(src/__tests__ 下纯逻辑) npm test