From 65f138bae2c4e2c025ec6024c26925b81a21563a Mon Sep 17 00:00:00 2001 From: doublex001 <16157008+doublex001@user.noreply.gitee.com> Date: Sun, 20 Sep 2026 14:06:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9B=2014=20=E5=A4=84?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=A3=B0=E6=98=8E=E7=9A=84=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E4=B8=9A=E5=8A=A1=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接 4.14 / 4.15 的同一类缺陷排查。那两批治的是「同一个事实两处取值不同」, 本批治它的另一半:**取值当时相同,但同一张映射表 / 同一段业务规则被抄了多份**。 后者平时看不出来,代价有两个:改一处漏一处;以及测试锁的是**没人渲染的那份**。 本批不改任何取值,属纯重构 —— 已逐条核对 git diff 的删除行,无一色值被改。 修法:14 处本地声明 → 6 张单一来源的表 - 评级 → 文字色 **5 份 → 1**:growth / jobs / ability / assessment 四处同名同值 (三处叫 LEVEL_TEXT_COLOR、一处叫 LEVEL_COLOR),hr-evaluation 另有一份中文键版。 - 霍兰德六型 → 强调色 3 份 → 1(介绍 / 答题 / 结果三页各一份 TYPE_COLORS)。 - 语义色名 → 色值 3 份 → 1(企业看板 / 面试评分 / AI 面试各一份 COLOR_TOKENS)。 - 文章缩略图 2 种写法 → 1:成长页是 {violet:{bg,color}} 对象表,详情页则拆成 THUMBNAIL_BG + THUMBNAIL_COLOR 两份平行 Record。 - 社保基数 clamp **3 份 → 1**:权威那份在接口层 computeSalaryResult 里,另两份在 计算器页(一个同名 helper + handleSelectCity 内联)。提取时把 city 提成**显式参数** —— 页面内联那份的成因正是 helper 读的是 inputs.city(旧城市),而切城市要用新城市的上限, 所以「简化成调用 helper」会静默算错城市。 - 时间戳 → 「月-日 时:分」2 份 → 1:utils.formatDateTime 收 ISO 串、页面 formatRecordTime 收时间戳,格式逐字节相同。formatDateTime 改为接受 string | number。 副作用(正向的):时间戳版本原先不判 NaN,坏值会渲染出「NaN-NaN NaN:NaN」,现返回空串。 - 手机号脱敏 2 份 → 1:登录页内联的那段与 api.maskPhone 逐字节相同,而 carrier-login.test.ts 里那条 describe 的名字就是「脱敏在接口层,页面不自己切字符串」, 《运营商一键登录接入方案.md》也写明「页面不做脱敏」—— 页面违反了它自己的约定。 - 企业端发布页自持的学历 / 工作类型选项表接回 src/constants(工作类型的编码与标签 成对定义,标签列表由它派生,避免「有编码没标签」的悬空项)。 放置位置按职责定,不是就近塞:颜色映射进 theme(该文件自己写着「色值是本文件独占的职责」, 且已有 templateAccentColors 先例);clamp 进 api 层(AGENTS.md 规定业务规则在接口层, 权威那份本来就在那里);时间格式化进 utils;选项词表进 constants。 一处是**派生**而不是搬家 hr-evaluation 的键是中文评级('优秀'),不是 JobLevel —— 根因是数据层 EvaluationDimension.level 声明为 string 且直接下发中文评级。这是「同一事实、两个键域」, 把字面量搬个地方等于把「两份同值」换成「两份同值、只是搬了家」。故 levelLabelTextColors 按引用派生自 jobLevelTextColors,将来换色只改一处。断言锁的是关系(同键同值),不是字面量。 刻意**不**合并的两处 - 经验要求:constants 的 EXPERIENCE_OPTIONS 是**筛选**项(含「不限」、不含「1年以内」), 发布页那份是**必填要求**(含「1年以内」、无「不限」),不是同一个事实。已加反向锁 把差异写死,防止后来者按名字想当然地合并。 - DIMENSION_LEVEL(profile.tsx)不在收敛范围:键的是 DimensionScore['level'](只有两档), 且「良好」用 textSecondary 而非另外五处的 primary。两处对同一档取色确实不同, 属需要产品定夺的配色分歧,**未擅自统一**,已记入《项目开发状态.md》4.16。 测试 - 新增 shared-color-maps.test.ts(26 例)。断言分两类:**关系**(中文评级表与 jobLevelTextColors 同键同值、各表内部色值互不相同、计算器养老金额可由 clampSocialBase 反推、formatDateTime 两种入参格式一致)与**接线锁**(读源码断言 14 处旧声明名不再出现、 页面必须引用新表名)—— 值对了但页面不读表,缺陷照旧。 - 灵敏度验证:一次性注入 6 个缺陷(重建旧声明 + input.city 换成 '北京' + levelLabelTextColors 改回字面量),**6 例如期全红**,逐字还原后 grep 确认 0 残留。 - 一处如实记录:clamp 那条接线锁**第一版是写错的** —— 断言「页面不得出现 socialBaseUpper」, 而页面本来就该读它展示「基数范围 ¥x – ¥y(城市)」。错的是断言不是代码,已改为精确锁 「上下限字段与 Math.min/Math.max 同现」(那才是旧版内联 clamp 的形状)。 另有一次 edit 吃掉了 `const DIM_COLORS =` 后的空格,已复原到与 HEAD 逐字节一致。 如实记录:一份子代理报告的三条说法未通过核对 - 称 DIMENSION_LEVEL 重复声明 —— 不成立,全仓只有 profile.tsx 一处。 - 称 THUMBNAIL_* 重复「×2」—— 不精确,实为一个对象表 + 两份平行 Record(两种写法)。 - 给的 clamp 行号不准,且**没提权威那份在 api.ts**。 与既有一贯口径一致:代理报告是模型输出、不是证据,每条都独立 grep + 逐行读过才动手。 实测(Android 模拟器 emulator-5554,截图核对,非代码走查) - 本批取值未变,真机的价值在「没崩、没落到兜底」:成长页三个缩略图色系全部解析; HR 评价「优秀」「良好」都有色、无一落兜底灰;企业看板统计四宫格与快捷入口 **五个语义色各不相同**(若有键写错会静默全变蓝,多色并存即证明未落兜底); 发布页学历 5 项 / 经验 6 项 / 工作类型 4 项且选中态正确;薪资格算器切城市后 提示与区间正确更新。 - 未验到的:clamp 本身未在真机制造出越界值 —— 社保基数输入框**逐键 clamp**, 手工输入停不到目标值(既有交互,非本批改动),故 clamp 语义以单测 + 注入为准; 岗位列表 / 能力 / 测评的评级色、霍兰德三页六型色、面试两页语义色、登录页掩码手机号、 薪资历史记录时间格式 —— 均有类型约束或测试锁盖住,但本轮未逐屏截图。 仍未做(需产品定夺,未自行选择) - 「良好」一档在个人页与其余五处取色不同(灰 vs 蓝),统一只改一行。 - JobExperienceRequirement 的「1年以内」是死值(全仓仅见于类型声明,mock 数据没用过、 筛选表也没这项),留还是删。 - 数据层 color: string 未收紧成联合类型,故 semanticColorTokens 的键只能保持 Record,代价是数据层写错色名会静默落到 ?? colors.primary。已在注释与 测试里把「这是刻意的」写死,根治属另一批。 门槛:tsc 0 / eslint 0 / 26 文件 365 例全绿(较 4.15 的 339 例 +26)/ check-routes 0 / check:affordances 0 / check:icons 0(194 文件、1357 字形)/ check-backend-stubs 140 --- app/(enterprise)/enterprise-overview.tsx | 16 +- app/(tabs)/growth.tsx | 22 +- app/(tabs)/jobs.tsx | 11 +- app/ability.tsx | 10 +- app/article/[id].tsx | 23 +- app/assessment.tsx | 10 +- app/holland-result.tsx | 13 +- app/holland.tsx | 15 +- app/holland/question.tsx | 13 +- app/hr-evaluation.tsx | 11 +- app/interview-score.tsx | 12 +- app/interview.tsx | 16 +- app/job-publish.tsx | 30 +- app/tools/salary-calculator.tsx | 30 +- components/onboarding/screens/P2PhoneCode.tsx | 12 +- src/__tests__/shared-color-maps.test.ts | 259 ++++++++++++++++++ src/api/api.ts | 27 +- src/api/index.ts | 1 + src/constants/index.ts | 30 +- src/theme/colors.ts | 81 ++++++ src/theme/index.ts | 5 + src/utils/index.ts | 13 +- 项目开发状态.md | 207 +++++++++++++- 23 files changed, 678 insertions(+), 189 deletions(-) create mode 100644 src/__tests__/shared-color-maps.test.ts diff --git a/app/(enterprise)/enterprise-overview.tsx b/app/(enterprise)/enterprise-overview.tsx index a359110..455e1fa 100644 --- a/app/(enterprise)/enterprise-overview.tsx +++ b/app/(enterprise)/enterprise-overview.tsx @@ -12,17 +12,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useEnterpriseOverview } from '../../src/hooks'; import { LoadingState, ErrorState } from '../../src/components/StateView'; import { useRole } from '../../src/context/RoleContext'; -import { colors, headerGradient } from '../../src/theme'; +import { colors, headerGradient, semanticColorTokens } from '../../src/theme'; import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types'; -const COLOR_TOKENS: Record = { - primary: colors.primary, - success: colors.success, - action: colors.action, - info: colors.info, - danger: colors.danger, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ onSettings }: { onSettings: () => void }) { @@ -80,7 +72,7 @@ function StatGrid({ stats }: { stats: EnterpriseStat[] }) { {stats.map((stat) => { - const color = COLOR_TOKENS[stat.color] ?? colors.primary; + const color = semanticColorTokens[stat.color] ?? colors.primary; const up = stat.change >= 0; return ( @@ -109,7 +101,7 @@ function QuickActions({ actions }: { actions: EnterpriseQuickAction[] }) { 快捷入口 {actions.map((action) => { - const color = COLOR_TOKENS[action.color] ?? colors.primary; + const color = semanticColorTokens[action.color] ?? colors.primary; return ( 管理后台 {actions.map((action) => { - const color = COLOR_TOKENS[action.color] ?? colors.primary; + const color = semanticColorTokens[action.color] ?? colors.primary; return ( ['name']; @@ -100,13 +100,6 @@ function RadarChart({ dimensions }: { dimensions: AbilityDimension[] }) { /* ===================== 配色映射 ===================== */ -/** 能力等级 → 文字颜色(与岗位评分标签一致) */ -const LEVEL_TEXT_COLOR: Record = { - excellent: colors.primaryDark, - good: colors.primary, - medium: colors.textSecondary, -}; - /** AI 工具卡片色调映射 */ const TOOL_STYLE = { primary: { @@ -123,13 +116,6 @@ const TOOL_STYLE = { }, } as const; -/** 文章缩略图配色映射(复用信息/行动/成功三组语义色) */ -const THUMBNAIL_STYLE = { - violet: { bg: colors.infoLight, color: colors.info }, - orange: { bg: colors.actionLight, color: colors.action }, - green: { bg: colors.successLight, color: colors.success }, -} as const; - /* ===================== 页面 ===================== */ /** @@ -225,7 +211,7 @@ export default function GrowthScreen() { {ability.overallScore} 分 - + {JOB_LEVEL_LABEL[ability.level]} @@ -296,7 +282,7 @@ export default function GrowthScreen() { )} {articles.map((article) => { - const thumb = THUMBNAIL_STYLE[article.thumbnailColor]; + const thumb = articleThumbnailColors[article.thumbnailColor]; return ( - + {job.score}分 · {JOB_LEVEL_LABEL[job.level]} 查看详情 > diff --git a/app/ability.tsx b/app/ability.tsx index 406d068..150291e 100644 --- a/app/ability.tsx +++ b/app/ability.tsx @@ -11,16 +11,10 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; import { useAbilityAssessment } from '../src/hooks'; -import { colors } from '../src/theme'; +import { colors, jobLevelTextColors } from '../src/theme'; import { JOB_LEVEL_LABEL } from '../src/constants'; import type { AbilityDimension, AbilityTrendPoint, JobLevel } from '../src/types'; -const LEVEL_TEXT_COLOR: Record = { - excellent: colors.primaryDark, - good: colors.primary, - medium: colors.textSecondary, -}; - const DIM_COLORS = [colors.primary, colors.info, colors.success, colors.action]; const DIM_LABELS = ['专业能力', '项目经验', '技能广度', '成长潜力']; @@ -59,7 +53,7 @@ function ScoreCard({ {score} 分 - + {JOB_LEVEL_LABEL[level]} diff --git a/app/article/[id].tsx b/app/article/[id].tsx index 5ab660c..d0cfb8c 100644 --- a/app/article/[id].tsx +++ b/app/article/[id].tsx @@ -5,23 +5,11 @@ import type { ComponentProps } from 'react'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useGrowthData } from '../../src/hooks'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; -import { colors } from '../../src/theme'; +import { articleThumbnailColors, colors } from '../../src/theme'; import { formatViews } from '../../src/utils'; -import type { Article } from '../../src/types'; type IconName = ComponentProps['name']; -const THUMBNAIL_BG: Record = { - violet: colors.infoLight, - orange: colors.actionLight, - green: colors.successLight, -}; -const THUMBNAIL_COLOR: Record = { - violet: colors.info, - orange: colors.action, - green: colors.success, -}; - /* ===================== 顶部导航 ===================== */ function Header({ onBack }: { onBack: () => void }) { @@ -60,11 +48,16 @@ export default function ArticleScreen() { contentContainerStyle={styles.content} showsVerticalScrollIndicator={false} > - + {article.title} diff --git a/app/assessment.tsx b/app/assessment.tsx index 33c1f9f..ec15aff 100644 --- a/app/assessment.tsx +++ b/app/assessment.tsx @@ -10,16 +10,10 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useGrowthData } from '../src/hooks'; -import { colors } from '../src/theme'; +import { colors, jobLevelTextColors } from '../src/theme'; import { JOB_LEVEL_LABEL } from '../src/constants'; import type { JobLevel } from '../src/types'; -const LEVEL_TEXT_COLOR: Record = { - excellent: colors.primaryDark, - good: colors.primary, - medium: colors.textSecondary, -}; - const ENTRIES = [ { id: 'holland', @@ -88,7 +82,7 @@ function AbilityCard({ {score} 分 - + {JOB_LEVEL_LABEL[level]} diff --git a/app/holland-result.tsx b/app/holland-result.tsx index b62dbcf..58da1c5 100644 --- a/app/holland-result.tsx +++ b/app/holland-result.tsx @@ -15,22 +15,13 @@ import Svg, { Circle, Line, Polygon } from 'react-native-svg'; import { useHollandAssessment } from '../src/hooks'; import { buildHollandReportText } from '../src/api'; import { shareText } from '../src/lib/shareFile'; -import { colors, headerGradientShort } from '../src/theme'; +import { colors, headerGradientShort, hollandTypeColors } from '../src/theme'; import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types'; const RADAR_SIZE = 160; const RADAR_CENTER = RADAR_SIZE / 2; const RADAR_MAX_RADIUS = RADAR_SIZE / 2 - 8; -const TYPE_COLORS: Record = { - R: colors.primary, - I: colors.info, - A: colors.action, - S: colors.success, - E: colors.compareOrange, - C: colors.textHint, -}; - function radarPoint(index: number, total: number, radius: number) { const angle = (Math.PI * 2 * index) / total - Math.PI / 2; return { @@ -79,7 +70,7 @@ function RadarChart({ dimensions }: { dimensions: HollandDimension[] }) { strokeWidth={2} /> {dataVertices.map((p, i) => ( - + ))} ); diff --git a/app/holland.tsx b/app/holland.tsx index 7b9b4e1..4c687ee 100644 --- a/app/holland.tsx +++ b/app/holland.tsx @@ -10,19 +10,10 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useHollandIntro } from '../src/hooks'; -import { colors } from '../src/theme'; +import { colors, hollandTypeColors } from '../src/theme'; import { formatThousands } from '../src/utils'; import type { HollandDimensionIntro, HollandIntro } from '../src/types'; -const TYPE_COLORS: Record = { - R: colors.primary, - I: colors.info, - A: colors.action, - S: colors.success, - E: colors.compareOrange, - C: colors.textHint, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack }: { onBack: () => void }) { @@ -45,8 +36,8 @@ function DimensionGrid({ dimensions }: { dimensions: HollandDimensionIntro[] }) {dimensions.map((d) => ( - - {d.type} + + {d.type} {d.name} {d.desc} diff --git a/app/holland/question.tsx b/app/holland/question.tsx index 3216938..6efd523 100644 --- a/app/holland/question.tsx +++ b/app/holland/question.tsx @@ -11,18 +11,9 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useHollandAssessment } from '../../src/hooks'; -import { colors } from '../../src/theme'; +import { colors, hollandTypeColors } from '../../src/theme'; import type { HollandDimension, HollandOption } from '../../src/types'; -const TYPE_COLORS: Record = { - R: colors.primary, - I: colors.info, - A: colors.action, - S: colors.success, - E: colors.compareOrange, - C: colors.textHint, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ progress, onBack }: { progress: string; onBack: () => void }) { @@ -55,7 +46,7 @@ function DimensionBars({ dimensions }: { dimensions: HollandDimension[] }) { diff --git a/app/hr-evaluation.tsx b/app/hr-evaluation.tsx index 8e212e3..fd0398f 100644 --- a/app/hr-evaluation.tsx +++ b/app/hr-evaluation.tsx @@ -11,16 +11,9 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; import { useHREvaluation } from '../src/hooks'; -import { colors } from '../src/theme'; +import { colors, levelLabelTextColors } from '../src/theme'; import type { EvaluationDimension, EvaluationReview, EvaluationTrendPoint } from '../src/types'; -/** 评级 → 文字颜色(优秀=品牌蓝,良好=主色,其他=次要文字) */ -const LEVEL_TEXT_COLOR: Record = { - 优秀: colors.primaryDark, - 良好: colors.primary, - 中等: colors.textSecondary, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack, onAppeal }: { onBack: () => void; onAppeal: () => void }) { @@ -160,7 +153,7 @@ function DimensionCard({ dimensions }: { dimensions: EvaluationDimension[] }) { {dim.level} diff --git a/app/interview-score.tsx b/app/interview-score.tsx index 46d2d11..cae5a6e 100644 --- a/app/interview-score.tsx +++ b/app/interview-score.tsx @@ -13,18 +13,10 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg'; import { useInterviewScore } from '../src/hooks'; import { LoadingState, ErrorState } from '../src/components/StateView'; -import { colors } from '../src/theme'; +import { colors, semanticColorTokens } from '../src/theme'; import { formatDateTime } from '../src/utils'; import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types'; -const COLOR_TOKENS: Record = { - primary: colors.primary, - success: colors.success, - action: colors.action, - info: colors.info, - danger: colors.danger, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack }: { onBack: () => void }) { @@ -165,7 +157,7 @@ function TagGroup({ tags, onToggle }: { tags: InterviewScoreTag[]; onToggle: (id 面试评价标签 {tags.map((tag) => { - const color = COLOR_TOKENS[tag.color] ?? colors.primary; + const color = semanticColorTokens[tag.color] ?? colors.primary; return ( = { - primary: colors.primary, - success: colors.success, - action: colors.action, - info: colors.info, - danger: colors.danger, -}; - /* ===================== 子组件:顶部导航 ===================== */ function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) { @@ -138,7 +130,7 @@ function QuestionTypeTags({ {types.map((t) => { const selected = active.includes(t.id); - const color = COLOR_TOKENS[t.color] ?? colors.primary; + const color = semanticColorTokens[t.color] ?? colors.primary; return ( 本次预估可提升能力 {improvements.map((imp) => { - const color = COLOR_TOKENS[imp.color] ?? colors.primary; + const color = semanticColorTokens[imp.color] ?? colors.primary; return ( {imp.label} @@ -233,7 +225,7 @@ function RecentCard({ recent }: { recent: InterviewRecent[] }) { 最近练习 {recent.map((item) => { - const color = COLOR_TOKENS[item.resultColor] ?? colors.primary; + const color = semanticColorTokens[item.resultColor] ?? colors.primary; return ( {item.date} diff --git a/app/job-publish.tsx b/app/job-publish.tsx index 602e14b..439c6b9 100644 --- a/app/job-publish.tsx +++ b/app/job-publish.tsx @@ -14,17 +14,26 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useJobPublish } from '../src/hooks'; import { LoadingState, ErrorState } from '../src/components/StateView'; import { colors } from '../src/theme'; +import { EDUCATION_OPTIONS, WORK_TYPE_LABELS } from '../src/constants'; import type { JobPublishFormData } from '../src/types'; -const WORK_TYPE_OPTIONS: { label: string; value: JobPublishFormData['workType'] }[] = [ - { label: '全职', value: 'full-time' }, - { label: '兼职', value: 'part-time' }, - { label: '实习', value: 'intern' }, - { label: '远程', value: 'remote' }, -]; - -const EDUCATION_OPTIONS: JobPublishFormData['education'][] = ['不限', '大专', '本科', '硕士', '博士']; - +/** 工作类型选项(标签与编码的对照表来自 constants,此处只是换个形状消费) */ +const WORK_TYPE_OPTIONS = WORK_TYPE_LABELS; + +/** + * 经验要求选项 —— **刻意不从 constants 的 `EXPERIENCE_OPTIONS` 取**。 + * + * 两张表不是同一个事实,名字像而已: + * · constants 的 `EXPERIENCE_OPTIONS` 是**筛选项**,含「不限」(表示不加限制), + * 且不含「1年以内」; + * · 本表是**企业发布岗位时的必填要求**,没有「不限」(必须明确要求), + * 且含「1年以内」—— 与 `JobExperienceRequirement` 的成员逐一对应。 + * 强行合并会让发布页出现「不限」这个不该有的选项。 + * + * 已知遗留(非本批处理):`JobExperienceRequirement` 的「1年以内」在 mock 岗位数据里 + * **一次都没用到**,而筛选项表里也没有它 —— 即这个取值目前既筛不到、也没有样本。 + * 属数据/类型层面的取舍,需产品定夺,已记入《项目开发状态.md》4.16。 + */ const EXPERIENCE_OPTIONS: JobPublishFormData['experience'][] = [ '应届生', '1年以内', @@ -111,7 +120,8 @@ function RadioGroup({ value, onChange, }: { - options: T[] | { label: string; value: T }[]; + /** 只读:选项表现在有一份来自 constants 的共享常量(`WORK_TYPE_LABELS`),不该被就地改写 */ + options: readonly T[] | ReadonlyArray<{ label: string; value: T }>; value: T; onChange: (value: T) => void; }) { diff --git a/app/tools/salary-calculator.tsx b/app/tools/salary-calculator.tsx index 73920b8..1179173 100644 --- a/app/tools/salary-calculator.tsx +++ b/app/tools/salary-calculator.tsx @@ -14,6 +14,7 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useSalaryCalculator, useSalaryRecords, useSalaryToolLinks } from '../../src/hooks'; +import { clampSocialBase } from '../../src/api'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView'; import { colors, headerGradientShort } from '../../src/theme'; import { @@ -22,15 +23,7 @@ import { SOCIAL_SECURITY_CITY_PROFILES, } from '../../src/constants'; import type { SalaryCalcInput, SalaryCalcRecord, SalaryToolLink } from '../../src/types'; -import { formatCurrency } from '../../src/utils'; - -/** 记录时间戳 → 简短可读文本(月-日 时:分) */ -function formatRecordTime(ts: number): string { - const d = new Date(ts); - const pad = (n: number) => String(n).padStart(2, '0'); - return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; -} - +import { formatCurrency, formatDateTime } from '../../src/utils'; /* ===================== 子组件:顶部导航 ===================== */ @@ -290,20 +283,9 @@ export default function SalaryCalculatorScreen() { setInputs({ ...inputs, [key]: value }); }; - /** 将社保基数 clamp 到当前城市上下限内 */ - const clampSocialBase = (v: number) => { - const profile = SOCIAL_SECURITY_CITY_PROFILES[inputs.city] ?? DEFAULT_SOCIAL_PROFILE; - return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, v)); - }; - - /** 选城市:切换城市并把社保基数 clamp 到新城市上下限 */ + /** 选城市:切换城市并把社保基数 clamp 到**新城市**的上下限(规则在接口层) */ const handleSelectCity = (city: string) => { - const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; - const clamped = Math.min( - profile.socialBaseUpper, - Math.max(profile.socialBaseLower, inputs.socialBase), - ); - setInputs({ ...inputs, city, socialBase: clamped }); + setInputs({ ...inputs, city, socialBase: clampSocialBase(city, inputs.socialBase) }); setCityPickerVisible(false); }; @@ -325,7 +307,7 @@ export default function SalaryCalculatorScreen() { label="社保基数" value={inputs.socialBase} suffix="元" - onChange={(v) => update('socialBase', clampSocialBase(Math.max(0, v)))} + onChange={(v) => update('socialBase', clampSocialBase(inputs.city, Math.max(0, v)))} /> 基数范围 ¥{formatCurrency(cityProfile.socialBaseLower, 0)} – ¥ @@ -549,7 +531,7 @@ function HistoryModal({ {record.note || `${record.input.city} · 税前${formatCurrency(record.input.monthlyGross, 0)}`} - 到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatRecordTime(record.createdAt)} + 到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatDateTime(record.createdAt)} Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY); diff --git a/src/__tests__/shared-color-maps.test.ts b/src/__tests__/shared-color-maps.test.ts new file mode 100644 index 0000000..0ce2276 --- /dev/null +++ b/src/__tests__/shared-color-maps.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { clampSocialBase, computeSalaryResult, maskPhone } from '../api/api'; +import { SOCIAL_SECURITY_CITY_PROFILES, JOB_LEVEL_LABEL, EDUCATION_OPTIONS, WORK_TYPE_LABELS, WORK_TYPE_OPTIONS } from '../constants'; +import { + articleThumbnailColors, + colors, + hollandTypeColors, + jobLevelTextColors, + levelLabelTextColors, + semanticColorTokens, + templateAccentColors, +} from '../theme'; +import { formatDateTime } from '../utils'; +import type { JobLevel } from '../types'; + +// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 carrier-login.test.ts 同) +jest.mock('../lib/storage', () => ({ + storageGet: jest.fn(async () => null), + storageSet: jest.fn(async () => undefined), + storageRemove: jest.fn(async () => undefined), +})); + +/** + * 本批要收敛的缺陷形态是**同一张映射表在多个页面各写一份**。 + * + * 断言刻意写成两类: + * · 值的关系(「A 必须等于 B」),而不是「A 等于 #175AB0」—— 以后调色不会让测试变红, + * 只有把派生关系改回并列复写才会红; + * · 源码级接线锁(读源码断言页面**不再自持一份**)—— 值对了但页面不读常量,缺陷照旧。 + * 这是本项目既有的做法(见 job-preference-options.test.ts)。 + * + * 说明:源码级断言读的是字符串,拦得住「改回本地字面量」,拦不住「读了常量又转换一层」。 + */ +const read = (relative: string) => readFileSync(join(__dirname, '../../', relative), 'utf8'); + +const JOB_LEVELS: JobLevel[] = ['excellent', 'good', 'medium']; + +describe('评级 → 文字颜色:五处收敛成 theme 里的一张表', () => { + it('AGENTS.md 第九节:正向评级「优秀」必须用 primaryDark', () => { + expect(jobLevelTextColors.excellent).toBe(colors.primaryDark); + }); + + it('三档取值互不相同(否则「等级」这个区分在页面上看不出来)', () => { + const values = JOB_LEVELS.map((level) => jobLevelTextColors[level]); + expect(new Set(values).size).toBe(JOB_LEVELS.length); + }); + + it('中文评级表由 jobLevelTextColors 派生:每档都与它同键的那档同值', () => { + // 这张表存在的原因是**键的域不同**(数据层有的字段下发中文评级而不是 JobLevel), + // 不是取值不同。两者若分叉,就是「优秀」与 excellent 在同一个页面上显示出两种蓝。 + for (const level of JOB_LEVELS) { + expect(levelLabelTextColors[JOB_LEVEL_LABEL[level]]).toBe(jobLevelTextColors[level]); + } + }); + + it('接线锁:四个「等级 → 文字色」的页面读 theme,不再各自声明本地映射', () => { + for (const file of [ + 'app/(tabs)/growth.tsx', + 'app/(tabs)/jobs.tsx', + 'app/ability.tsx', + 'app/assessment.tsx', + ]) { + const src = read(file); + expect(src).toContain('jobLevelTextColors'); + // 旧版本地映射的两种名字;重建即回归 + expect(src).not.toMatch(/^const (LEVEL_TEXT_COLOR|LEVEL_COLOR)\b/m); + } + }); + + it('接线锁:HR 评价页读中文评级表,不再自持一份', () => { + const src = read('app/hr-evaluation.tsx'); + expect(src).toContain('levelLabelTextColors'); + expect(src).not.toMatch(/^const LEVEL_TEXT_COLOR\b/m); + // 中文键不该再出现在页面里(那是表的内容,不是页面的内容) + expect(src).not.toContain("'优秀'"); + }); +}); + +describe('霍兰德六型 → 强调色:三处收敛成一张表', () => { + it('六型取值互不相同(否则雷达图六个顶点会撞色)', () => { + const values = Object.values(hollandTypeColors); + expect(values).toHaveLength(6); + expect(new Set(values).size).toBe(6); + }); + + it('接线锁:介绍 / 答题 / 结果三页读 theme,不再各自声明 TYPE_COLORS', () => { + for (const file of ['app/holland.tsx', 'app/holland/question.tsx', 'app/holland-result.tsx']) { + const src = read(file); + expect(src).toContain('hollandTypeColors'); + expect(src).not.toMatch(/^const TYPE_COLORS\b/m); + } + }); +}); + +describe('语义色名 → 色值:三处收敛成一张表', () => { + /** + * 这张表的键刻意保持 `string` 而非字面量联合:数据层这些字段声明的是 `color: string`, + * 收紧会让页面按 `string` 取值编译不过。这条断言把「刻意」写死,防止后来者顺手收紧后又放开。 + */ + it('五个色名都能取到值,且都是合法十六进制', () => { + for (const key of ['primary', 'success', 'action', 'info', 'danger']) { + expect(semanticColorTokens[key]).toMatch(/^#[0-9A-Fa-f]{6}$/); + } + }); + + it('接线锁:企业看板 / 面试评分 / AI 面试三页读 theme,不再各自声明 COLOR_TOKENS', () => { + for (const file of [ + 'app/(enterprise)/enterprise-overview.tsx', + 'app/interview-score.tsx', + 'app/interview.tsx', + ]) { + const src = read(file); + expect(src).toContain('semanticColorTokens'); + expect(src).not.toMatch(/^const COLOR_TOKENS\b/m); + } + }); +}); + +describe('文章缩略图:底色与图标色成对定义一处', () => { + it('三组底色互不相同、三组图标色互不相同', () => { + const bgs = Object.values(articleThumbnailColors).map((t) => t.bg); + const fgs = Object.values(articleThumbnailColors).map((t) => t.color); + expect(new Set(bgs).size).toBe(3); + expect(new Set(fgs).size).toBe(3); + }); + + it('接线锁:成长页与文章详情页读同一张表', () => { + for (const file of ['app/(tabs)/growth.tsx', 'app/article/[id].tsx']) { + const src = read(file); + expect(src).toContain('articleThumbnailColors'); + // 旧版:成长页写成一个对象表,详情页拆成两份平行 Record —— 两种写法都不该回来 + expect(src).not.toMatch(/^const (THUMBNAIL_STYLE|THUMBNAIL_BG|THUMBNAIL_COLOR)\b/m); + } + }); + + it('模板强调色与语义色表都从 colors 取值,页面不写字面量', () => { + for (const value of [...Object.values(templateAccentColors), ...Object.values(semanticColorTokens)]) { + expect(value).toMatch(/^#[0-9A-Fa-f]{6}$/); + } + }); +}); + +describe('社保基数 clamp:三份副本收敛成接口层一条规则', () => { + it('切城市时按**新城市**的上下限 clamp(沿用旧城市的档案就是原来的 bug)', () => { + const shenzhenUpper = SOCIAL_SECURITY_CITY_PROFILES['深圳'].socialBaseUpper; + const beijingUpper = SOCIAL_SECURITY_CITY_PROFILES['北京'].socialBaseUpper; + // 前提校验:两城上限确实不同,否则这一例什么也证明不了 + expect(shenzhenUpper).toBeGreaterThan(beijingUpper); + + // 用户在深圳把基数拉到上限,再切到北京:必须被压到北京上限 + const inShenzhen = clampSocialBase('深圳', shenzhenUpper); + expect(inShenzhen).toBe(shenzhenUpper); + expect(clampSocialBase('北京', inShenzhen)).toBe(beijingUpper); + }); + + it('上下限之外的值被压回区间,区间内的值原样返回', () => { + const profile = SOCIAL_SECURITY_CITY_PROFILES['北京']; + expect(clampSocialBase('北京', 0)).toBe(profile.socialBaseLower); + expect(clampSocialBase('北京', 99_999_999)).toBe(profile.socialBaseUpper); + const inside = Math.round((profile.socialBaseLower + profile.socialBaseUpper) / 2); + expect(clampSocialBase('北京', inside)).toBe(inside); + }); + + it('无档案的城市落到默认档案(不静默返回原值)', () => { + expect(clampSocialBase('不存在的城市', 1)).toBe(SOCIAL_SECURITY_CITY_PROFILES['北京'].socialBaseLower); + }); + + it('计算器与页面走的是同一条 clamp 规则', () => { + // 养老保险个人缴纳 8%,反推社保基数 —— 若 computeSalaryResult 自带一份 clamp,这里会对不上 + const result = computeSalaryResult({ + city: '深圳', + monthlyGross: 50_000, + bonusMonths: 3, + socialBase: 99_999_999, // 故意超上限 + providentFundRate: 0.07, + specialDeduction: 0, + }); + const pension = result.insuranceItems.find((item) => item.label === '养老保险'); + expect(pension?.amount).toBe(Math.round(clampSocialBase('深圳', 99_999_999) * 0.08)); + }); + + it('接线锁:计算器页面不再自己 clamp', () => { + const src = read('app/tools/salary-calculator.tsx'); + expect(src).toContain('clampSocialBase'); + /* + * 页面**会**读 `cityProfile.socialBaseLower/Upper`,那是正当用途 —— + * 在社保基数输入框下方显示「基数范围 ¥x – ¥y(城市)」。 + * 故不能简单地禁掉这两个字段(第一版就这么写错了,断言本身是错的)。 + * 真正的分叉信号是**它们与 Math.min/Math.max 同现** —— 旧版那份内联 clamp 正是这个形状。 + */ + expect(src).not.toMatch(/Math\.(min|max)\([^)]*socialBase(Upper|Lower)/); + }); +}); + +describe('脱敏与时间格式化:页面不再自持一份', () => { + it('接线锁:登录页调用接口层的 maskPhone,不再自己切字符串', () => { + const src = read('components/onboarding/screens/P2PhoneCode.tsx'); + expect(src).toContain('maskPhone(phone)'); + // 旧版内联写法;重建即回归 + expect(src).not.toContain("****${phone.slice"); + }); + + it('maskPhone 对 11 位手机号保留前 3 后 4,非 11 位原样返回', () => { + expect(maskPhone('15000009367')).toBe('150****9367'); + expect(maskPhone('1500000')).toBe('1500000'); + }); + + it('formatDateTime 同时接受 ISO 串与时间戳,两种入参格式一致', () => { + const iso = '2026-09-15T06:20:00.000Z'; + expect(formatDateTime(new Date(iso).getTime())).toBe(formatDateTime(iso)); + expect(formatDateTime(iso)).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + it('坏值一律返回空串(原先时间戳那份不判 NaN,会渲染出 NaN-NaN NaN:NaN)', () => { + expect(formatDateTime(Number.NaN)).toBe(''); + expect(formatDateTime('')).toBe(''); + expect(formatDateTime('不是时间')).toBe(''); + }); + + it('接线锁:薪资格算器读 utils 的 formatDateTime,不再自带 formatRecordTime', () => { + const src = read('app/tools/salary-calculator.tsx'); + expect(src).toContain('formatDateTime'); + expect(src).not.toMatch(/^function formatRecordTime\b/m); + }); +}); + +describe('选项词表:企业端发布页与 constants 的关系', () => { + it('学历选项与 JobEducationRequirement 的成员集合一致(两处是同一个事实)', () => { + expect([...EDUCATION_OPTIONS].sort()).toEqual(['不限', '大专', '本科', '硕士', '博士'].sort()); + }); + + it('工作类型的编码与标签成对定义,标签列表由它派生', () => { + expect(WORK_TYPE_OPTIONS).toEqual(WORK_TYPE_LABELS.map((item) => item.label)); + // 编码域与标签域都要齐全,避免「有编码没标签」的悬空项 + expect(WORK_TYPE_LABELS.every((item) => item.value !== '' && item.label !== '')).toBe(true); + }); + + it('接线锁:发布页的学历与工作类型都取自 constants', () => { + const src = read('app/job-publish.tsx'); + expect(src).toContain('EDUCATION_OPTIONS'); + expect(src).toContain('WORK_TYPE_LABELS'); + // 旧版本地字面量;重建即回归 + expect(src).not.toContain("'不限', '大专'"); + expect(src).not.toMatch(/label: '全职'/); + }); + + /** + * 经验要求的两张表**不是**同一个事实,本批刻意不合并(合并会让发布页多出一个 + * 不该有的「不限」选项)。这例把差异锁住,防止后来者按名字想当然地合并。 + */ + it('发布页的经验要求与筛选项表本就该不同:前者含「1年以内」不含「不限」,后者反之', () => { + const src = read('app/job-publish.tsx'); + expect(src).toContain("'1年以内'"); + // 发布页那张表里不该出现「不限」这个筛选项专用的兜底值 + const localExperience = src.slice(src.indexOf('const EXPERIENCE_OPTIONS')); + expect(localExperience.slice(0, 400)).not.toContain("'不限'"); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 77f437b..09ac869 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -2527,6 +2527,25 @@ export async function fetchSalaryBrackets(): Promise { return mockRequest(MOCK_SALARY_BRACKETS); } +/** + * 把社保基数 clamp 到**指定城市**的上下限内(业务规则在本层)。 + * + * `city` 是显式参数而不是从输入对象里读 —— 这条正是原先三份副本各写一遍的根因: + * 页面切城市时要按**新城市**的上下限 clamp,而读 `inputs.city` 的 helper 只会拿到**旧城市**, + * 于是页面只好把同一段 `Math.min/Math.max` 内联重写一遍。城市成为参数后, + * 「按哪个城市的上下限」由调用方说清楚,特殊情况不再需要特殊写法。 + * + * 养老/医疗/失业的比例全国一致,城市差异只体现在基数上下限;无该城市档案时用默认档案。 + * + * @param city 城市名(与 `SOCIAL_SECURITY_CITY_PROFILES` 的键一致) + * @param value 待 clamp 的社保基数(元) + * @returns 落在 [lower, upper] 内的基数 + */ +export function clampSocialBase(city: string, value: number): number { + const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; + return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, value)); +} + /** * 薪资计算(业务规则:五险一金 + 个税估算,接口层纯函数)。 * 计算器为实时交互,故同步计算,避免 300ms mock 延迟影响输入体验。 @@ -2538,12 +2557,8 @@ export function computeSalaryResult(input: SalaryCalcInput): SalaryCalcResult { // 年终奖摊入月度,得到月度税前合计 const monthlyTotal = monthlyGross * (1 + input.bonusMonths / 12); - // 按所在城市 clamp 社保基数到该城市上下限(养老/医疗/失业比例全国一致,城市差异在基数上下限) - const socialProfile = SOCIAL_SECURITY_CITY_PROFILES[input.city] ?? DEFAULT_SOCIAL_PROFILE; - const socialBase = Math.min( - socialProfile.socialBaseUpper, - Math.max(socialProfile.socialBaseLower, input.socialBase), - ); + // 按所在城市 clamp 社保基数到该城市上下限(规则见 clampSocialBase) + const socialBase = clampSocialBase(input.city, input.socialBase); // 社保个人缴纳比例:养老 8% + 医疗 2% + 失业 0.5% const pension = socialBase * 0.08; diff --git a/src/api/index.ts b/src/api/index.ts index a5144da..750d690 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -119,6 +119,7 @@ export { loginByCarrier, fetchCarrierPhone, maskPhone, + clampSocialBase, fetchCacheSize, clearAppCache, sendSmsCode, diff --git a/src/constants/index.ts b/src/constants/index.ts index 319924c..7a960bc 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -4,7 +4,9 @@ import type { CarrierName, CitySocialProfile, + JobEducationRequirement, JobLevel, + JobWorkType, PasswordRuleError, ResumeTemplate, } from '../types'; @@ -35,8 +37,15 @@ export const SALARY_RANGES: ReadonlyArray<{ label: string; min: number; max: num /** 经验要求筛选选项 */ export const EXPERIENCE_OPTIONS: string[] = ['不限', '应届生', '1-3年', '3-5年', '5-10年', '10年以上']; -/** 学历要求筛选选项 */ -export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕士', '博士']; +/** + * 学历要求选项(岗位筛选 **与** 企业端发布岗位共用同一份)。 + * + * 类型取 `JobEducationRequirement[]` 而不是 `string[]`:这五个值恰好就是 + * `JobEducationRequirement` 联合类型的全部成员,发布页此前把它重写了一遍 + * 并标成 `JobPublishFormData['education'][]`(同一个联合类型的另一种写法)。 + * 收紧成字面量后,联合类型改名或增删时这一份会编译报错,而不是静默对不上。 + */ +export const EDUCATION_OPTIONS: JobEducationRequirement[] = ['不限', '大专', '本科', '硕士', '博士']; /** * 岗位筛选用的城市列表(都有 mock 岗位与社保基数数据)。 @@ -212,11 +221,26 @@ export const EXPECTED_SALARY_OPTIONS: string[] = [ '不限', '5K-10K', '10K-20K', '20K-30K', '30K-50K', '50K以上', ]; +/** + * 工作类型:编码 ↔ 中文标签的唯一一份对照表。 + * + * 编码是数据层(`JobWorkType`)的表示,标签是给人看的 —— 两者必须成对定义, + * 否则就会出现「改了一个忘了另一个」。此前这四个中文标签写着两遍:一份是 + * 求职意向页用的纯标签数组,一份是企业端发布页用的 `{label, value}` 数组, + * 同一组字符串、两种形状、两个文件。现由本表派生。 + */ +export const WORK_TYPE_LABELS: ReadonlyArray<{ value: JobWorkType; label: string }> = [ + { value: 'full-time', label: '全职' }, + { value: 'part-time', label: '兼职' }, + { value: 'intern', label: '实习' }, + { value: 'remote', label: '远程' }, +]; + /** * 工作类型选项(「设置求职意向」页)。 * `PREFERENCE_DEFAULT_WORK_TYPE` 必须取自本列表,否则默认值会是个选不中的值。 */ -export const WORK_TYPE_OPTIONS: string[] = ['全职', '兼职', '实习', '远程']; +export const WORK_TYPE_OPTIONS: string[] = WORK_TYPE_LABELS.map((item) => item.label); /* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */ diff --git a/src/theme/colors.ts b/src/theme/colors.ts index 688c80e..cf68323 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -145,6 +145,87 @@ export const templateAccentColors: Record = { success: colors.success, }; +/** + * 语义色名 → 色值。数据层只下发**色名**(如 `stat.color: 'success'`),页面按名取色。 + * + * 原先这份映射在 enterprise-overview / interview-score / interview **三个页面各写一份**, + * 取值相同 —— 但「success 是哪个绿」本该只有一处说了算。放在 theme 的理由与 + * templateAccentColors 相同:色值是本文件独占的职责。 + * + * 键保持 `string`(不收紧成字面量联合)是**刻意的**:数据层这些字段声明的是 `color: string` + * 而非联合类型(src/types/enterprise.ts 等 6 处),收紧键类型会让页面按 `string` 取值直接编译不过。 + * 代价是数据层写错色名时静默落到调用方的 `?? colors.primary` 兜底 —— 要根治得先把 + * 数据层的 `color: string` 收紧成联合类型(涉及 6 个类型字段与全部 mock 数据),属另一批事。 + */ +export const semanticColorTokens: Record = { + primary: colors.primary, + success: colors.success, + action: colors.action, + info: colors.info, + danger: colors.danger, +}; + +/** + * 岗位 / 能力评级 → 文字颜色。 + * + * AGENTS.md 第九节已明文规定「正向评级(如「优秀」)文字使用 primaryDark」,而这段映射原先在 + * growth / jobs / ability / assessment **四个页面各写一份**(三处叫 LEVEL_TEXT_COLOR、 + * 一处叫 LEVEL_COLOR,取值完全相同)—— 评级该用什么蓝,本该只有一处定义。 + * + * 键写成字面量联合而不是 `Record`:这里取值方拿到的就是 `JobLevel`, + * 能让新增等级时编译报错。写成字面量而非 import JobLevel,是为保持本文件零 import。 + * + * @remark `DIMENSION_LEVEL`(app/(tabs)/profile.tsx)**不在**本次收敛范围内:它键的是 + * `DimensionScore['level']`(只有两档),且「良好」用的是 textSecondary 而非这里的 primary。 + * 两处对「良好」的取色确实不同,属需要产品定夺的配色分歧,已记入《项目开发状态.md》4.16。 + */ +export const jobLevelTextColors: Record<'excellent' | 'good' | 'medium', string> = { + excellent: colors.primaryDark, + good: colors.primary, + medium: colors.textSecondary, +}; + +/** + * 中文评级 → 文字颜色。**由 jobLevelTextColors 按引用派生,不重复写字面量。** + * + * 存在这一份是因为键的**域**不同而非取值不同:数据层的 `DimensionScore.level` 是 + * `JobLevel` 联合类型,而 `EvaluationDimension.level` / `OverallScore.level` 字段声明为 + * `string` 且直接下发中文评级('优秀')。hr-evaluation 按中文评级取色,无法直接用上面那张表。 + * + * 派生而非并列复写:如果哪天「良好」要换色,改 jobLevelTextColors 一处即可, + * 这张表自动跟随 —— 两张表写着同一个色值才是分叉的起点。 + */ +export const levelLabelTextColors: Record = { + 优秀: jobLevelTextColors.excellent, + 良好: jobLevelTextColors.good, + 中等: jobLevelTextColors.medium, +}; + +/** 霍兰德六型 → 强调色(介绍页 / 答题页 / 结果页三处原先各写一份,取值相同) */ +export const hollandTypeColors: Record<'R' | 'I' | 'A' | 'S' | 'E' | 'C', string> = { + R: colors.primary, + I: colors.info, + A: colors.action, + S: colors.success, + E: colors.compareOrange, + C: colors.textHint, +}; + +/** + * 文章缩略图色系 → 底色 / 图标色。 + * + * 成长页原先写成 `THUMBNAIL_STYLE = { violet: { bg, color } }`,文章详情页则拆成 + * `THUMBNAIL_BG` + `THUMBNAIL_COLOR` 两份平行的 `Record` —— 同一组色值、两种写法。 + */ +export const articleThumbnailColors: Record< + 'violet' | 'orange' | 'green', + { bg: string; color: string } +> = { + violet: { bg: colors.infoLight, color: colors.info }, + orange: { bg: colors.actionLight, color: colors.action }, + green: { bg: colors.successLight, color: colors.success }, +}; + /* ===================== 暗色模式(亮 → 暗映射) ===================== */ export const darkColors = { diff --git a/src/theme/index.ts b/src/theme/index.ts index 12d3fb0..118e6c8 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -8,4 +8,9 @@ export { headerGradientShort, headerGradientSolid, templateAccentColors, + semanticColorTokens, + jobLevelTextColors, + levelLabelTextColors, + hollandTypeColors, + articleThumbnailColors, } from './colors'; diff --git a/src/utils/index.ts b/src/utils/index.ts index 27e19db..ed5d5e1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -109,10 +109,17 @@ export function onboardingStepLabel(step: string): string | null { /** * 时间点展示:「2026-09-15T06:20:00.000Z」→「09-15 14:20」(本地时区)。 * 空串或无法解析时返回空串,调用方据此不渲染该行(而不是显示 Invalid Date)。 + * + * 入参同时接受 ISO 字符串与 `Date.now()` 时间戳:薪资格算器的历史记录存的是时间戳, + * 页面原先为此自带了一个 `formatRecordTime`,逐字节重写了同一个格式。两种入参合流到这里, + * 格式只有一处说了算。 + * + * 副作用(正向的):时间戳版本原先不判 `NaN`,坏值会渲染出「NaN-NaN NaN:NaN」; + * 现在与字符串版本一样返回空串。 */ -export function formatDateTime(iso: string): string { - if (iso === '') return ''; - const date = new Date(iso); +export function formatDateTime(value: string | number): string { + if (value === '') return ''; + const date = new Date(value); if (Number.isNaN(date.getTime())) return ''; const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; diff --git a/项目开发状态.md b/项目开发状态.md index 0d02c57..5ad58e6 100644 --- a/项目开发状态.md +++ b/项目开发状态.md @@ -1,13 +1,14 @@ # 有职 App 开发进度与规划文档 > 生成日期:2026-09-10 -> 最后更新:2026-09-20 · 第二批(**把同一类缺陷「同一个事实被写了两份、两处取值不同」排查完剩下的另一半**,见 4.15。① 又查出并修掉 **10 组**同源不同值的数据 —— 求职进度统计卡五项之和 30 ≠ 总数 28、月度图表投递合计 52 / 面试合计 16 而卡片写 28 / 6、我的页收藏徽标 12 与列表 3、勋章「投递满100次 **已获得**」而实际只投了 28 次、匹配横幅 3 家而 `matched` 标记只有 2 条、岗位详情综合分**恒为 92**(9 个岗位只有第 1 个对得上)、对比页把产品经理与 UI 设计师的分**正好写反**、候选人 4–8 号点进去**全叫「张明远」**、岗位 1 的公司所在地在列表与详情各写一个城区、待回复卡片写 3 人而它跳转的 Tab 只有 2 条;**修法一律是从权威源派生**而非手工对齐两个字面量,使矛盾在结构上无法复现,新增 **22 例回归锁**并逐条注入验证;② 顺带修掉求职进度页标题「求职中 {stats.interviewing}」压在 4 行列表上、切到「面试中」Tab 只剩 1 行的**同屏**自相矛盾;③ 5 处 `rgba(255,255,255,x)` 按**实际角色**(不是同一个 token)收敛为 4 个语义 token,**值等于现状、一像素未改**;④ 补上 lint 盲区 —— `no-hardcoded-hex-color` 此前**拦不住 `rgba()`/`hsl()`**,已扩到函数式颜色并加模板字符串访问器,注 `rgba(1,2,3,0.5)` 验证确实报错;⑤ 企业端看板内联的 `.toFixed(1)k` 与霍兰德参与人数的 `toLocaleString()` 下沉到 `src/utils`。**本批次两处如实记录**:一次 `python` 静默失败导致「注入其实没发生」,以及一条**测试自己写错了**的断言(把「累计面试邀请」与「当前面试中」当成同一个事实),均已订正并写进文档**) -> 上一版:2026-09-20(**修掉一类「同一个事实被写了两份、两处取值不同」的缺陷**:① 引导采集的求职意向——期望薪资 / 求职状态 / 期望城市三组选项,引导页与「设置求职意向」页各有一份词表,**三组全部匹配不上**,走完引导进来的用户看到三组 chip 一个都没选中,像资料丢了;更严重的是在那里选「暂不考虑」保存后,简历**仍把他记成积极求职中**(判定常量与页面词表不是同一个字符串)。三组选项已下沉到 `src/constants` 单一来源,新增 `withCurrentOption` 处理 P7 阶梯能拼出、快捷档覆盖不到的薪资组合,**真机实测四组 chip 全部正确选中**;② 顺带查出四处数据自相矛盾:当前用户 Offer 数(我的页 3 / 求职进度页 2)、求职进度每月 Offer 之和(3 / `offerCount` 为 2)、企业端在招岗位(总览写死 1280 / 岗位管理算出 8)、企业端评价数(1280 / 28 / 128 三个数)、企业名(全称 / 简称两个值),均已收敛为单一来源并实测;③ **撤回 4.10 第 7 条关于合规页脚的错误结论** —— 4.10 断言「常量无消费者、缺页脚组件」,实际该组件早一天就已存在并挂在两页上,页脚一直渲染。见 4.14**) -> 上上版:2026-09-18(**修正 4.8「死控件清零」的错误结论** —— 该结论只覆盖了审计脚本的检测口径,另有 **37 处「点了只弹功能开发中」的假交互**未被发现,本轮全部处理;补上审计脚本盲区,修复 18 处四态缺口,企业端岗位加「下架 / 刷新」写通道,见 4.9。**另有:新建「资质证照」页并恢复设置 / 关于我们两处入口,修掉「关于我们」页两条点下去毫无反应的死链,见 4.10**。**再有:修掉「草稿」岗位被写成「重新上架」的状态文案缺陷(截图复看才发现),状态映射下沉到 API 层并加测试锁,见 4.11**。**再有:4.9 遗留的 6 处导出 / 分享全部真机验证通过(靠系统分享面板的文本预览核内容),并顺带查出霍兰德参与人数在介绍页与成长页是两个数(124,580 / 12,846),已收敛为单一常量 + 测试锁,见 4.12**。**再有:4.9 遗留的最后 6 处空态用「注入空数据 + 逐个截图」实测通过(关掉 4.9 第 8 条最后一项);过程中查出文书模板 `gavel-outline` 不是合法 Ionicons 字形名(全库 1357 个字形里没有 gavel,图标渲染为空白,被页面的 `as keyof typeof Ionicons.glyphMap` 断言吞掉了类型错误),已修并新增 `npm run check:icons` 门槛防复发;同时更正 4.12 对深链不路由的错误归因(不是 dev-client,是 Expo Go 只注册 `exp://`);并把 4.9 遗留的最后一项「`settings` 缓存读取失败态」结案 —— **结论不是「验不了」,而是 `readCacheBytes()` 把所有异常吞成 0、该分支根本不可达**,已加 12 例测试锁住这条契约;另补上第七处分享路径(入职清单导出)的真机验证,以及 4.9 同批 5 个分享拼装函数一直缺的 28 例测试锁(含把 4.12 那个真机证伪实验固化成断言);并顺带把「数据层引用外部词表」三类(图标名 / 路由 / 颜色)系统排查完,见 4.13**) -> 上上上版:2026-09-17(全仓死控件清零:9 处 → 0 处,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) -> 上上上上版:2026-09-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) +> 最后更新:2026-09-20 · 第三批(**把同一类缺陷的另一半「同一张映射表 / 同一段业务规则在多个页面各写一份」收敛掉**,见 4.16。共 **14 处**本地重复声明压成 **6 张单一来源的表**:评级→文字色 **5 份 → 1**(growth / jobs / ability / assessment 四处同名同值,hr-evaluation 因数据层在那里下发的是**中文评级**而非 `JobLevel`、键的域不同而另立一份,故第六处是**派生**而非并列复写)、霍兰德六型色 3 → 1、语义色名→色值 3 → 1、文章缩略图 2 种写法(对象表 vs 两份平行 `Record`)→ 1、社保基数 clamp 3 → 1(**下游那份在接口层,才是业务规则该在的层**;页面内联的那份是因为它要用**新城市**的上下限而 helper 读的是旧城市,故提取时把 `city` 变成显式参数,特殊情况随之消失)、`formatRecordTime` 与登录页内联脱敏 → 复用 `formatDateTime` / `maskPhone`;另把企业端发布页自持的学历 / 工作类型选项表接回 `src/constants`。**取值一律等于现状、一像素未改**,属纯重构。新增 **26 例回归锁**并做**6 处注入验证**(6 例如期变红、逐字还原、grep 确认无残留)。**两条如实记录**:① 子代理报来的 `DIMENSION_LEVEL`「重复声明」经查**不成立**(全仓只有一处),其 `THUMBNAIL_*` 与 clamp 的行号描述亦不精确 —— 与「代理报告不是证据」这一贯口径一致,**每条都独立核对过**;② 查出 `profile.tsx` 的「良好」用 `textSecondary`、而另外五处用 `primary` 的**配色分歧,未擅自统一**,留给产品定夺) +> 上一版:2026-09-20 · 第二批(**把同一类缺陷「同一个事实被写了两份、两处取值不同」排查完剩下的另一半**,见 4.15。① 又查出并修掉 **10 组**同源不同值的数据 —— 求职进度统计卡五项之和 30 ≠ 总数 28、月度图表投递合计 52 / 面试合计 16 而卡片写 28 / 6、我的页收藏徽标 12 与列表 3、勋章「投递满100次 **已获得**」而实际只投了 28 次、匹配横幅 3 家而 `matched` 标记只有 2 条、岗位详情综合分**恒为 92**(9 个岗位只有第 1 个对得上)、对比页把产品经理与 UI 设计师的分**正好写反**、候选人 4–8 号点进去**全叫「张明远」**、岗位 1 的公司所在地在列表与详情各写一个城区、待回复卡片写 3 人而它跳转的 Tab 只有 2 条;**修法一律是从权威源派生**而非手工对齐两个字面量,使矛盾在结构上无法复现,新增 **22 例回归锁**并逐条注入验证;② 顺带修掉求职进度页标题「求职中 {stats.interviewing}」压在 4 行列表上、切到「面试中」Tab 只剩 1 行的**同屏**自相矛盾;③ 5 处 `rgba(255,255,255,x)` 按**实际角色**(不是同一个 token)收敛为 4 个语义 token,**值等于现状、一像素未改**;④ 补上 lint 盲区 —— `no-hardcoded-hex-color` 此前**拦不住 `rgba()`/`hsl()`**,已扩到函数式颜色并加模板字符串访问器,注 `rgba(1,2,3,0.5)` 验证确实报错;⑤ 企业端看板内联的 `.toFixed(1)k` 与霍兰德参与人数的 `toLocaleString()` 下沉到 `src/utils`。**本批次两处如实记录**:一次 `python` 静默失败导致「注入其实没发生」,以及一条**测试自己写错了**的断言(把「累计面试邀请」与「当前面试中」当成同一个事实),均已订正并写进文档**) +> 上上版:2026-09-20(**修掉一类「同一个事实被写了两份、两处取值不同」的缺陷**:① 引导采集的求职意向——期望薪资 / 求职状态 / 期望城市三组选项,引导页与「设置求职意向」页各有一份词表,**三组全部匹配不上**,走完引导进来的用户看到三组 chip 一个都没选中,像资料丢了;更严重的是在那里选「暂不考虑」保存后,简历**仍把他记成积极求职中**(判定常量与页面词表不是同一个字符串)。三组选项已下沉到 `src/constants` 单一来源,新增 `withCurrentOption` 处理 P7 阶梯能拼出、快捷档覆盖不到的薪资组合,**真机实测四组 chip 全部正确选中**;② 顺带查出四处数据自相矛盾:当前用户 Offer 数(我的页 3 / 求职进度页 2)、求职进度每月 Offer 之和(3 / `offerCount` 为 2)、企业端在招岗位(总览写死 1280 / 岗位管理算出 8)、企业端评价数(1280 / 28 / 128 三个数)、企业名(全称 / 简称两个值),均已收敛为单一来源并实测;③ **撤回 4.10 第 7 条关于合规页脚的错误结论** —— 4.10 断言「常量无消费者、缺页脚组件」,实际该组件早一天就已存在并挂在两页上,页脚一直渲染。见 4.14**) +> 上上上版:2026-09-18(**修正 4.8「死控件清零」的错误结论** —— 该结论只覆盖了审计脚本的检测口径,另有 **37 处「点了只弹功能开发中」的假交互**未被发现,本轮全部处理;补上审计脚本盲区,修复 18 处四态缺口,企业端岗位加「下架 / 刷新」写通道,见 4.9。**另有:新建「资质证照」页并恢复设置 / 关于我们两处入口,修掉「关于我们」页两条点下去毫无反应的死链,见 4.10**。**再有:修掉「草稿」岗位被写成「重新上架」的状态文案缺陷(截图复看才发现),状态映射下沉到 API 层并加测试锁,见 4.11**。**再有:4.9 遗留的 6 处导出 / 分享全部真机验证通过(靠系统分享面板的文本预览核内容),并顺带查出霍兰德参与人数在介绍页与成长页是两个数(124,580 / 12,846),已收敛为单一常量 + 测试锁,见 4.12**。**再有:4.9 遗留的最后 6 处空态用「注入空数据 + 逐个截图」实测通过(关掉 4.9 第 8 条最后一项);过程中查出文书模板 `gavel-outline` 不是合法 Ionicons 字形名(全库 1357 个字形里没有 gavel,图标渲染为空白,被页面的 `as keyof typeof Ionicons.glyphMap` 断言吞掉了类型错误),已修并新增 `npm run check:icons` 门槛防复发;同时更正 4.12 对深链不路由的错误归因(不是 dev-client,是 Expo Go 只注册 `exp://`);并把 4.9 遗留的最后一项「`settings` 缓存读取失败态」结案 —— **结论不是「验不了」,而是 `readCacheBytes()` 把所有异常吞成 0、该分支根本不可达**,已加 12 例测试锁住这条契约;另补上第七处分享路径(入职清单导出)的真机验证,以及 4.9 同批 5 个分享拼装函数一直缺的 28 例测试锁(含把 4.12 那个真机证伪实验固化成断言);并顺带把「数据层引用外部词表」三类(图标名 / 路由 / 颜色)系统排查完,见 4.13**) +> 上上上上版:2026-09-17(全仓死控件清零:9 处 → 0 处,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) +> 上上上上上版:2026-09-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) > 范围:项目健康审查 + 已开发 / 待开发清单 -> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、假交互 0 处、图标名 0 处无效、**339 例测试全绿**),无需修正阻塞错误。 +> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、假交互 0 处、图标名 0 处无效、**365 例测试全绿**),无需修正阻塞错误。 > > **读这份文档请先注意一条**:4.8 曾写「死控件清零」并据此把 P2 标记为已完成,**那个结论当时是错的** —— 审计脚本只查「无处理函数 / 空处理函数」,查不到「有处理函数但点下去只弹一句『功能开发中』」。详见 4.9 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。 @@ -1130,12 +1131,206 @@ EXIT=1,规则确实生效;随后还原,`grep -c __probe` = 0,`eslint .` --- +### 4.16 重复声明批量收敛(同一张表 / 同一段规则在多个页面各写一份)(2026-09-20 批次,接 4.15) + +#### 1. 起点:4.14 / 4.15 治的是「取值不同」,本批治的是「写法重复」 + +前两批处理的形态是**同一个事实两处取值不同**(数字对不上、词表匹配不上)。 +本批是它的另一半:**取值当时相同,但同一张映射表被抄了多份**。 +这类缺陷平时看不出来,代价有两个: +- **改一处漏一处**:产品说「良好」换个蓝,页面里躺着五份同值映射,改哪份全凭记性; +- **测试锁不住**:`jobLevelTextColors` 有测试,但页面不读它,测试锁的是**没人渲染的那份**。 + +本批**不改任何取值**(改前后逐值对照过),是纯重构。判据也从「两个数是否相等」 +换成「**这段定义是否只有一处**」,故新增的锁以**源码级接线锁**为主。 + +#### 2. 修法表:14 处本地声明 → 6 张单一来源的表 + +| # | 事实 | 原先几份 / 在哪 | 现在 | +|---|---|---|---| +| 1 | 评级 → 文字色 | **5 份**:growth 的 `LEVEL_TEXT_COLOR`、jobs 的 `LEVEL_COLOR`、ability 与 assessment 各一份同名 `LEVEL_TEXT_COLOR`、hr-evaluation 的中文键版 | `theme` 的 `jobLevelTextColors`(+ 派生的 `levelLabelTextColors`) | +| 2 | 霍兰德六型 → 强调色 | **3 份**:`holland.tsx` / `holland/question.tsx` / `holland-result.tsx` 各一份 `TYPE_COLORS` | `theme` 的 `hollandTypeColors` | +| 3 | 语义色名 → 色值 | **3 份**:`enterprise-overview` / `interview-score` / `interview` 各一份 `COLOR_TOKENS` | `theme` 的 `semanticColorTokens` | +| 4 | 文章缩略图 底色 / 图标色 | **2 种写法**:成长页一个 `{violet:{bg,color}}` 对象表;详情页拆成 `THUMBNAIL_BG` + `THUMBNAIL_COLOR` 两份平行 `Record` | `theme` 的 `articleThumbnailColors` | +| 5 | 社保基数 clamp 到城市上下限 | **3 份**:`api.ts:computeSalaryResult`(权威)、计算器页 `clampSocialBase`、计算器页 `handleSelectCity` 内联 | 接口层 `clampSocialBase(city, value)`,两处调用 | +| 6 | 时间戳 → 「月-日 时:分」 | **2 份**:`utils.formatDateTime`(收 ISO 串)与计算器页 `formatRecordTime`(收时间戳) | `formatDateTime(value: string \| number)` | +| 7 | 手机号脱敏 | **2 份**:`api.maskPhone` 与登录页**逐字节相同**的内联表达式 | 页面调用 `maskPhone` | +| 8 | 学历 / 工作类型选项 | 发布页自持两份:`EDUCATION_OPTIONS`(与 constants 逐字相同)、`WORK_TYPE_LABELS`(同一组中文标签的另一种形状) | 均取自 `src/constants` | + +放在哪个文件是**按职责**定的,不是就近塞: +- 颜色映射进 `src/theme/colors.ts` —— 该文件自己就写着「**色值是本文件独占的职责**」, + 且已有 `templateAccentColors` 这个先例; +- clamp 进 `src/api/api.ts` —— AGENTS.md 规定业务规则在接口层,而**权威的那一份本来就在那里**; +- 时间格式化进 `src/utils` —— 日期格式化是 utils 的既有职责(该文件已有 `formatDateTime`); +- 选项词表进 `src/constants` —— 4.14 定的单一来源。 + +#### 3. 一处必须**派生**而不是并列复写:`levelLabelTextColors` + +hr-evaluation 那份的键是**中文评级**(`'优秀'`),不是 `JobLevel`。 +根因在数据层:`DimensionScore.level` 是 `JobLevel` 联合类型,而 `EvaluationDimension.level` +声明为 `string` 且**直接下发中文评级**。所以这**不是**「同一张表抄两遍」,而是**同一个事实、 +两个键域**。若只是把字面量搬个地方,等于把「两份同值」换成「两份同值、只是搬了家」。 + +故写成**按引用派生**: + +```ts +export const levelLabelTextColors: Record = { + 优秀: jobLevelTextColors.excellent, + 良好: jobLevelTextColors.good, + 中等: jobLevelTextColors.medium, +}; +``` + +将来「良好」换色只改 `jobLevelTextColors` 一处,这张表自动跟随 —— 两张表各自写着同一个色值 +才是分叉的起点。回归锁断言的是**关系**:「每一档与它同键的那档同值」, +而不是「优秀等于 #175AB0」。 + +#### 4. clamp 提取时的一个坑:`city` 必须变成显式参数 + +三段 clamp 看着一模一样,直接「简化成调用 helper」会**静默算错城市**: + +```ts +// 页面上那个 helper 读的是 inputs.city —— 也就是**旧城市** +const clampSocialBase = (v: number) => { /* 用 inputs.city 的上下限 */ }; +// 而选城市时它必须用**新城市**的上下限,于是页面只好把同一段 Math.min/Math.max 内联重写 +const handleSelectCity = (city: string) => { /* 用 city 的上下限,另写一遍 */ }; +``` + +把 `city` 提成显式参数后,两处调用都说得清「按哪个城市」: + +```ts +export function clampSocialBase(city: string, value: number): number { + const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; + return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, value)); +} +``` + +这不是「顺手美化」,而是**重复的成因**:正因为 helper 拿不到目标城市,才被迫复制一份。 +回归锁用一个**前提校验**保证不空转 —— 先断言深圳上限 > 北京上限(否则这例证明不了什么), +再断言「在深圳拉到上限后切北京,结果必须落到北京上限」。 + +#### 5. 一条**未被统一**的配色分歧(需产品定夺,未擅自改) + +`app/(tabs)/profile.tsx` 的 `DIMENSION_LEVEL` 里,`good.text = colors.textSecondary`(灰); +而上面第 1 项那五处对「良好」一律用 `colors.primary`(蓝)。 + +**两处对同一个档位取不同的色,是一个真实存在的分歧**,但本批**没有动它**: +- 它键的是 `DimensionScore['level']`(只有两档),与岗位评级的三档表**不是同一张表**; +- 「哪个对」是产品判断(个人页的维度条要不要跟岗位标签同一个蓝),不是重构能决定的; +- 后果有限:两处不在同一屏,用户不会同屏看到矛盾(与 4.15 那些「同屏两个数」不同)。 + +若产品确认应统一,改法是让 `DIMENSION_LEVEL` 的 `good.text` 取 +`jobLevelTextColors.good`,一行即可。**在此之前不要擅自统一。** + +#### 6. 顺带查出的一条死值:`JobExperienceRequirement` 的「1年以内」 + +`'1年以内'` 全仓**只出现在类型声明里一次**:mock 岗位数据一次都没用到它, +岗位筛选的 `EXPERIENCE_OPTIONS`(constants)里也没有它 —— 即这个取值 +**既筛不到、也没有样本**,只有发布页能选出来。 + +本批**刻意不合并**两张经验表,因为它们**不是同一个事实**,名字像而已: + +| 表 | 用途 | 含「不限」? | 含「1年以内」? | +|---|---|---|---| +| `constants.EXPERIENCE_OPTIONS` | 岗位**筛选**项 | ✅(表示不加限制) | ❌ | +| `job-publish` 的本地 `EXPERIENCE_OPTIONS` | 企业发布岗位的**必填要求** | ❌(必须明确要求) | ✅(与联合类型逐项对应) | + +强行合并会让发布页多出一个不该有的「不限」选项。已加**反向锁**把这个差异写死, +防止后来者按名字想当然地合并。至于「1年以内」这个死值该留还是该删,属数据取舍,需产品定夺。 + +#### 7. 如实记录:一条被**证伪**的子代理结论 + +本轮开工前有一份子代理报告,列了若干「重复声明」。逐条核对的结果: + +| 报告的说法 | 核验结果 | +|---|---| +| `DIMENSION_LEVEL` 重复声明 | **不成立**。全仓只有 `profile.tsx` 一处 | +| `THUMBNAIL_*` 重复「×2」 | 描述不精确。不是两份同名表,是**一个对象表 + 两份平行 `Record`**(两种写法) | +| clamp 在 `:302-305` 与 `:294-296` | 行号不准。实际三份,第三份在 `api.ts` 里(报告没提,而那才是权威那份) | + +这与本文件一贯口径一致:**代理报告是模型输出,不是证据**。 +每一条都独立 `grep` + 逐行读过才动手,**没有一条是照报告直接改的**。 + +#### 8. 回归锁与注入验证 + +新增 `src/__tests__/shared-color-maps.test.ts`(**26 例**)。断言分两类: +- **关系**(「A 必须等于 B」):中文评级表与 `jobLevelTextColors` 同键同值、 + 六型 / 语义色 / 缩略图各色**互不相同**(撞色会让雷达图六顶点或四宫格看不出区分)、 + 计算器的养老金额可由 `clampSocialBase` 反推(证明两处走同一条规则)、 + `formatDateTime` 两种入参格式一致; +- **接线锁**(读源码):14 处旧声明名一律 `not.toMatch`,且页面必须 `toContain` 新表名 —— + 值对了但页面不读表,缺陷照旧。 + +**注入验证(本批 6 处,一次性注入、6 例如期全红、逐字还原)**: + +| 注入 | 期望变红的锁 | 结果 | +|---|---|---| +| `ability.tsx` 加回 `const LEVEL_TEXT_COLOR` | 「四个页面读 theme」 | ✅ 红 | +| `levelLabelTextColors['良好']` 改回字面量 | 「中文评级表由 jobLevelTextColors 派生」 | ✅ 红 | +| `api.ts` 把 `input.city` 换成 `'北京'` | 「计算器与页面走同一条 clamp 规则」 | ✅ 红 | +| 计算器页内联 `Math.min(cityProfile.socialBaseUpper, …)` | 「计算器页面不再自己 clamp」 | ✅ 红 | +| 登录页恢复内联脱敏 | 「登录页调用 maskPhone」 | ✅ 红 | +| 发布页加 `label: '全职'` 字面量 | 「发布页取自 constants」 | ✅ 红 | + +**一处如实记录**:本文件里那条 clamp 的接线锁**第一版是写错的** —— 断言写成 +「页面不得出现 `socialBaseUpper`」,而页面**本来就该**读它(用于展示 +「基数范围 ¥6,821 – ¥33,891」这行提示)。断言本身错了,不是代码错了; +已改为精确锁「上下限字段与 `Math.min/Math.max` **同现**」(那才是旧版内联 clamp 的形状)。 + +#### 9. 真机实测(Android 模拟器 emulator-5554,截图核对,非代码走查) + +本批**取值未变**,故真机的价值不在「颜色对不对」,而在**没崩、没落到兜底**: + +| 页面 | 核对内容 | 结果 | +|---|---|---| +| 成长中心 | 能力等级色 + **三个缩略图色系全部解析**(紫 / 橙 / 绿各一组) | ✅ 三者都渲染出各自的色,无 `undefined` | +| HR 评价详情 | 中文评级表:六维里「优秀」「良好」都有色 | ✅ 无一落到兜底灰 | +| 企业端总览 | 语义色名表:统计四宫格与快捷入口 | ✅ **五个色各不相同**(蓝/紫/绿/红/橙)—— 若有键写错会静默全变蓝,此处多色并存即证明未落兜底 | +| 发布岗位 | 学历 5 项(含「不限」,「本科」选中)+ 经验 6 项(含「1年以内」,无「不限」)+ 工作类型 4 项(「全职」选中) | ✅ 与文档所述一致,值↔标签配对未被打乱 | +| 薪资格算器 | 切城市:北京 → 深圳,提示更新为「¥4,492 – ¥43,659(深圳)」,基数 25000 在区间内保持不变 | ✅ `handleSelectCity` 生效 | + +> **没验到的(按一贯口径记为「未做真机验证」,不当成已验证)**: +> ① **clamp 本身未在真机上制造出越界值** —— 社保基数输入框是**逐键 clamp** 的 +> (`onChange` 每次都过 clamp),手工输入根本无法停留在目标值上,属既有交互、非本批改动; +> 故 clamp 的语义**以单测 + 注入为准**(注入 `'北京'` 那例即为该语义的灵敏性证明)。 +> ② 岗位列表 / 能力 / 测评三页的**评级色**,霍兰德三页的**六型色**, +> 面试评分 / AI 面试两页的**语义色**,以及登录页的**掩码手机号**与**薪资格算器历史记录的时间格式** —— +> 均有类型约束(键是字面量联合,写错即编译不过)或测试锁盖住,但**本轮未逐屏截图**。 + +#### 10. 本批次门槛复跑(2026-09-20 实测) + +`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` +**26 个文件 365 例全绿**(较 4.15 的 339 例 +26) · `check-routes` **0 失效跳转** · +`check-affordances` **0 处** · `check:icons` **0 处无效图标名**(194 文件 / 1357 字形) · +`check-backend-stubs` **140 个接口函数**。六处注入物已 `grep` 确认为 **0 残留**。 + +#### 11. 本批次仍未做 + +- 第 5 条的「良好」配色分歧(需产品定夺)。 +- 第 6 条的「1年以内」死值该留该删(需产品定夺)。 +- **数据层 `color: string` 未收紧成联合类型**:`semanticColorTokens` 的键因此只能保持 + `Record`,代价是数据层写错色名会**静默**落到调用方的 `?? colors.primary`。 + 根治要把 6 个类型字段(`EnterpriseStat.color` 等)收紧成联合类型并同步全部 mock 数据, + 属另一批。**本批已在代码注释与测试里把「这是刻意的」写死**,防止后来者顺手收紧又放开。 +- 4.15 第 10 条列的各项(`companies` 全量 vs 抽样、`formatSalaryRange` 未下沉、 + `chats: 8` 与测评勋章仍是字面量、`CITIES` / `EXPECTED_CITY_OPTIONS` 两份词表有意保留)**状态不变**。 +- 早前批次提到的 `WORK_TYPE_OPTIONS` 标签重复**已在本批修掉**(见第 2 表第 8 项)。 + +--- + ## 五、后续增量开发建议(优先级) - **P0(上架前必做)**:~~图标替换~~ **应用图标【已完成,`878710c`,1024×1024「有职」logo】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.5】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。 - **P0(补充 · 2026-09-15 新发现)**:① ~~页脚服务热线 / 举报监督电话待填~~ **已填 `15154608969`**,且 **`SupportFooter` 已挂在 P1Login / P2QuickLogin 两页上,页脚实际渲染**(2026-09-20 复核;4.10 第 7 条那句「无消费者 / 缺页脚组件」已撤回,见 4.14 第 7 条);**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② ~~缺「资质证照」页~~ **页面【已完成,见 4.10】—— 四层链路齐备(`src/types/qualification.ts` / `fetchQualifications` / `useQualifications` / `app/settings/qualifications.tsx`),设置页与「关于我们」页两处入口已恢复,三类证照的补图占位已逐张标明;**但证照的真实编号 / 发证机关 / 有效期 / 证照图仍全部待法务与资质方提供**(材料到位后只改 `MOCK_QUALIFICATIONS` 字段 + 更新测试断言),**未编造任何证件编号**。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。 - **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 - **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · ~~死控件清零~~ **死控件已清零(22 → 0,2026-09-17,见 4.8)** · ~~假交互清零~~ **假交互已清零(37 → 0,2026-09-18,**但 4.8 当时的「已完成」结论是错的,见 4.9 首条**)** · 真实后端对接 · OTA 通道验证。 +- **P1(补充 · 2026-09-20 第三批,需产品定夺)**:① **「良好」这一档在个人页与其余五处取色不同** —— + `app/(tabs)/profile.tsx` 的 `DIMENSION_LEVEL.good.text` 是 `textSecondary`(灰), + 而岗位评级那五处一律 `primary`(蓝)。两处不同屏、后果有限,故**未擅自统一**; + 若确认要统一,让 `DIMENSION_LEVEL.good.text` 取 `jobLevelTextColors.good` 即可,一行。详见 4.16 第 5 条。 + ② **`JobExperienceRequirement` 的「1年以内」是死值** —— 全仓仅见于类型声明, + mock 岗位数据没用过、筛选表也没这一项(既筛不到也无样本),只有发布页能选出来。 + 留还是删需产品定夺,详见 4.16 第 6 条。 - **P1(补充 · 2026-09-20,需产品定夺)**:**求职进度页 `companies` 是「全量」还是「抽样」** —— mock 里只有 4 条,而统计卡写「投递 28 / 待反馈 6 / 面试中 2 / Offer 2 / 已结束 18」,于是任何 Tab 点进去行数都对不上卡片。**这不是能靠对齐字面量解决的问题**,两条路(① 把 `companies` 扩到 28 条、`stats` 由它派生;② 明确标注为「近期流程」抽样、并撤掉会误导的统计数字)**都会改变用户看到的页面**,故本轮只去掉了标题里那份矛盾的副本(见 4.15 第 3 条),主体未动。**不要在没有产品结论的情况下自行选一条。** - **P2(补充 · 2026-09-18)**:**3 处知情保留的后端待办**(聊天附件四图标 / 接受 Offer / 导出文件下载),后端接口就绪后按 `npm run check:affordances:pending` 列出的清单逐条兑现;另**已做的真机验证与仍未验证的项**见 4.9 第 8 条(已由 4.11 / 4.12 / 4.13 逐项关闭:`settings` 缓存失败态的最终结论是**不可达死代码**,见 4.13 第 8 条)—— 不要把没验过的当成验过了。 - **P1(补充 · 2026-09-17)**:① **运营商一键登录取号**需运营商号码认证服务 + EAS 自定义客户端,接入路线图见 `运营商一键登录接入方案.md`(端上换 1 处 SDK 调用 + 后端换 1 个接口,页面/hook/类型零改动);② **引导页 4 张配图**待提供素材(`assets.ts` 的 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,换图只改 `source` 一行);③ **`assets.ts` 内 16 个素材位 `source` 仍为 `null`**(营业执照示例图、5 张预设头像、3 张场景插画等)—— 未补时按约定降级:引导页那 4 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。