Browse Source

refactor: 收敛 14 处重复声明的映射表与业务规则

接 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<string, string>,代价是数据层写错色名会静默落到 ?? 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
master
doublex001 1 month ago
parent
commit
65f138bae2
  1. 16
      app/(enterprise)/enterprise-overview.tsx
  2. 22
      app/(tabs)/growth.tsx
  3. 11
      app/(tabs)/jobs.tsx
  4. 10
      app/ability.tsx
  5. 23
      app/article/[id].tsx
  6. 10
      app/assessment.tsx
  7. 13
      app/holland-result.tsx
  8. 15
      app/holland.tsx
  9. 13
      app/holland/question.tsx
  10. 11
      app/hr-evaluation.tsx
  11. 12
      app/interview-score.tsx
  12. 16
      app/interview.tsx
  13. 30
      app/job-publish.tsx
  14. 30
      app/tools/salary-calculator.tsx
  15. 12
      components/onboarding/screens/P2PhoneCode.tsx
  16. 259
      src/__tests__/shared-color-maps.test.ts
  17. 27
      src/api/api.ts
  18. 1
      src/api/index.ts
  19. 30
      src/constants/index.ts
  20. 81
      src/theme/colors.ts
  21. 5
      src/theme/index.ts
  22. 13
      src/utils/index.ts
  23. 207
      项目开发状态.md

16
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<string, string> = {
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[] }) {
<View style={styles.card}>
<View style={styles.statGrid}>
{stats.map((stat) => {
const color = COLOR_TOKENS[stat.color] ?? colors.primary;
const color = semanticColorTokens[stat.color] ?? colors.primary;
const up = stat.change >= 0;
return (
<View key={stat.id} style={styles.statItem}>
@ -109,7 +101,7 @@ function QuickActions({ actions }: { actions: EnterpriseQuickAction[] }) {
<Text style={styles.cardTitle}>快捷入口</Text>
<View style={styles.quickGrid}>
{actions.map((action) => {
const color = COLOR_TOKENS[action.color] ?? colors.primary;
const color = semanticColorTokens[action.color] ?? colors.primary;
return (
<Pressable
key={action.id}
@ -174,7 +166,7 @@ function ManageList({ actions }: { actions: EnterpriseQuickAction[] }) {
<View style={styles.card}>
<Text style={styles.standaloneTitle}>管理后台</Text>
{actions.map((action) => {
const color = COLOR_TOKENS[action.color] ?? colors.primary;
const color = semanticColorTokens[action.color] ?? colors.primary;
return (
<Pressable
key={action.id}

22
app/(tabs)/growth.tsx

@ -14,10 +14,10 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useGrowthData } from '../../src/hooks';
import { colors, headerGradient } from '../../src/theme';
import { articleThumbnailColors, colors, headerGradient, jobLevelTextColors } from '../../src/theme';
import { formatThousands, formatViews } from '../../src/utils';
import { JOB_LEVEL_LABEL } from '../../src/constants';
import type { AbilityDimension, JobLevel } from '../../src/types';
import type { AbilityDimension } from '../../src/types';
type IconName = ComponentProps<typeof Ionicons>['name'];
@ -100,13 +100,6 @@ function RadarChart({ dimensions }: { dimensions: AbilityDimension[] }) {
/* ===================== 配色映射 ===================== */
/** 能力等级 → 文字颜色(与岗位评分标签一致) */
const LEVEL_TEXT_COLOR: Record<JobLevel, string> = {
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() {
<Text style={styles.scoreNumber}>{ability.overallScore}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={styles.levelBadge}>
<Text style={[styles.levelBadgeText, { color: LEVEL_TEXT_COLOR[ability.level] }]}>
<Text style={[styles.levelBadgeText, { color: jobLevelTextColors[ability.level] }]}>
{JOB_LEVEL_LABEL[ability.level]}
</Text>
</View>
@ -296,7 +282,7 @@ export default function GrowthScreen() {
</View>
)}
{articles.map((article) => {
const thumb = THUMBNAIL_STYLE[article.thumbnailColor];
const thumb = articleThumbnailColors[article.thumbnailColor];
return (
<Pressable
key={article.id}

11
app/(tabs)/jobs.tsx

@ -23,7 +23,7 @@ import {
SALARY_RANGES,
} from '../../src/constants';
import type { Job, SortType } from '../../src/types';
import { colors, headerGradient } from '../../src/theme';
import { colors, headerGradient, jobLevelTextColors } from '../../src/theme';
/** 筛选项(下拉占位) */
const FILTERS = ['全部行业', '薪资', '经验', '更多筛选'];
@ -35,13 +35,6 @@ const SORTS: { key: SortType; label: string }[] = [
{ key: 'salary', label: '薪资最高' },
];
/** 等级 → 文字颜色 */
const LEVEL_COLOR = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
} as const;
/** 更多筛选的三组字段 */
type FilterKey = 'salary' | 'experience' | 'education';
@ -121,7 +114,7 @@ function JobCard({ job }: { job: Job }) {
{/* 评分标签 + 查看详情 */}
<View style={styles.scoreRow}>
<Text style={[styles.scoreText, { color: LEVEL_COLOR[job.level] }]}>
<Text style={[styles.scoreText, { color: jobLevelTextColors[job.level] }]}>
{job.score}分 · {JOB_LEVEL_LABEL[job.level]}
</Text>
<Text style={styles.detailLink}>查看详情 &gt;</Text>

10
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<JobLevel, string> = {
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({
<Text style={styles.scoreValue}>{score}</Text>
<Text style={styles.scoreUnit}>分</Text>
<View style={[styles.levelTag, { backgroundColor: colors.primaryLight }]}>
<Text style={[styles.levelText, { color: LEVEL_TEXT_COLOR[level] }]}>
<Text style={[styles.levelText, { color: jobLevelTextColors[level] }]}>
{JOB_LEVEL_LABEL[level]}
</Text>
</View>

23
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<typeof Ionicons>['name'];
const THUMBNAIL_BG: Record<Article['thumbnailColor'], string> = {
violet: colors.infoLight,
orange: colors.actionLight,
green: colors.successLight,
};
const THUMBNAIL_COLOR: Record<Article['thumbnailColor'], string> = {
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}
>
<View style={[styles.thumb, { backgroundColor: THUMBNAIL_BG[article.thumbnailColor] }]}>
<View
style={[
styles.thumb,
{ backgroundColor: articleThumbnailColors[article.thumbnailColor].bg },
]}
>
<Ionicons
name={article.icon as IconName}
size={40}
color={THUMBNAIL_COLOR[article.thumbnailColor]}
color={articleThumbnailColors[article.thumbnailColor].color}
/>
</View>
<Text style={styles.title}>{article.title}</Text>

10
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<JobLevel, string> = {
excellent: colors.primaryDark,
good: colors.primary,
medium: colors.textSecondary,
};
const ENTRIES = [
{
id: 'holland',
@ -88,7 +82,7 @@ function AbilityCard({
<Text style={styles.abilityScore}>{score}</Text>
<Text style={styles.abilityUnit}>分</Text>
<View style={[styles.levelBadge, { backgroundColor: colors.primaryLight }]}>
<Text style={[styles.levelText, { color: LEVEL_TEXT_COLOR[level] }]}>
<Text style={[styles.levelText, { color: jobLevelTextColors[level] }]}>
{JOB_LEVEL_LABEL[level]}
</Text>
</View>

13
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<string, string> = {
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) => (
<Circle key={`dot-${i}`} cx={p.x} cy={p.y} r={3} fill={TYPE_COLORS[dimensions[i].type]} />
<Circle key={`dot-${i}`} cx={p.x} cy={p.y} r={3} fill={hollandTypeColors[dimensions[i].type]} />
))}
</Svg>
);

15
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<string, string> = {
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[] })
<View style={styles.dimGrid}>
{dimensions.map((d) => (
<View key={d.type} style={styles.dimItem}>
<View style={[styles.dimBadge, { backgroundColor: TYPE_COLORS[d.type] + '15' }]}>
<Text style={[styles.dimType, { color: TYPE_COLORS[d.type] }]}>{d.type}</Text>
<View style={[styles.dimBadge, { backgroundColor: hollandTypeColors[d.type] + '15' }]}>
<Text style={[styles.dimType, { color: hollandTypeColors[d.type] }]}>{d.type}</Text>
</View>
<Text style={styles.dimName}>{d.name}</Text>
<Text style={styles.dimDesc}>{d.desc}</Text>

13
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<string, string> = {
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[] }) {
<View
style={[
styles.dimFill,
{ width: `${d.percent}%`, backgroundColor: TYPE_COLORS[d.type] },
{ width: `${d.percent}%`, backgroundColor: hollandTypeColors[d.type] },
]}
/>
</View>

11
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<string, string> = {
优秀: colors.primaryDark,
良好: colors.primary,
中等: colors.textSecondary,
};
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onAppeal }: { onBack: () => void; onAppeal: () => void }) {
@ -160,7 +153,7 @@ function DimensionCard({ dimensions }: { dimensions: EvaluationDimension[] }) {
<Text
style={[
styles.dimTagText,
{ color: LEVEL_TEXT_COLOR[dim.level] ?? colors.textSecondary },
{ color: levelLabelTextColors[dim.level] ?? colors.textSecondary },
]}
>
{dim.level}

12
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<string, string> = {
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
<Text style={styles.cardTitle}>面试评价标签</Text>
<View style={styles.tagRow}>
{tags.map((tag) => {
const color = COLOR_TOKENS[tag.color] ?? colors.primary;
const color = semanticColorTokens[tag.color] ?? colors.primary;
return (
<Pressable
key={tag.id}

16
app/interview.tsx

@ -13,7 +13,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useAIInterview } from '../src/hooks';
import { buildInterviewConfigText } from '../src/api';
import { shareText } from '../src/lib/shareFile';
import { colors } from '../src/theme';
import { colors, semanticColorTokens } from '../src/theme';
import type {
InterviewAbilityImprovement,
InterviewMode,
@ -24,14 +24,6 @@ import type {
InterviewSpecial,
} from '../src/types';
const COLOR_TOKENS: Record<string, string> = {
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({
<View style={styles.tagRow}>
{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 (
<Pressable
key={t.id}
@ -203,7 +195,7 @@ function ImprovementCard({
<View style={styles.card}>
<Text style={styles.cardTitle}>本次预估可提升能力</Text>
{improvements.map((imp) => {
const color = COLOR_TOKENS[imp.color] ?? colors.primary;
const color = semanticColorTokens[imp.color] ?? colors.primary;
return (
<View key={imp.id} style={styles.improvementRow}>
<Text style={styles.improvementLabel}>{imp.label}</Text>
@ -233,7 +225,7 @@ function RecentCard({ recent }: { recent: InterviewRecent[] }) {
<Text style={styles.cardTitle}>最近练习</Text>
</View>
{recent.map((item) => {
const color = COLOR_TOKENS[item.resultColor] ?? colors.primary;
const color = semanticColorTokens[item.resultColor] ?? colors.primary;
return (
<View key={item.id} style={styles.recentRow}>
<Text style={styles.recentDate}>{item.date}</Text>

30
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<T extends string>({
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;
}) {

30
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)))}
/>
<Text style={styles.baseHint}>
基数范围 ¥{formatCurrency(cityProfile.socialBaseLower, 0)} – ¥
@ -549,7 +531,7 @@ function HistoryModal({
{record.note || `${record.input.city} · 税前${formatCurrency(record.input.monthlyGross, 0)}`}
</Text>
<Text style={styles.historySub}>
到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatRecordTime(record.createdAt)}
到手 {formatCurrency(record.result.monthlyNet)}/月 · {formatDateTime(record.createdAt)}
</Text>
</Pressable>
<Pressable

12
components/onboarding/screens/P2PhoneCode.tsx

@ -2,6 +2,7 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons';
import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors } from '../../../src/theme';
import { maskPhone } from '../../../src/api';
import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants';
import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks';
import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils';
@ -79,8 +80,15 @@ export function P2PhoneCode() {
navigate('P3');
};
/** 掩码手机号:138****1234 */
const maskedPhone = phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
/**
* 掩码手机号:138****1234。
*
* 走接口层的 `maskPhone` 而不是本页自己切字符串 —— 页面原先内联的那段与之逐字节相同,
* 但「脱敏规则」只有一处说了算:`carrier-login.test.ts` 锁的就是这条规则,
* 而它测的是接口层那份;页面自持一份等于让测试锁不住真实渲染出来的值。
* 《运营商一键登录接入方案.md》也写明「页面不做脱敏」。
*/
const maskedPhone = maskPhone(phone);
/** 「接收不到短信」:原先是点了没反应的纯文本,改为给出可执行的下一步 */
const showSmsHelp = () => Alert.alert(SMS_HELP_TITLE, SMS_HELP_BODY);

259
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("'不限'");
});
});

27
src/api/api.ts

@ -2527,6 +2527,25 @@ export async function fetchSalaryBrackets(): Promise<SalaryBracket[]> {
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;

1
src/api/index.ts

@ -119,6 +119,7 @@ export {
loginByCarrier,
fetchCarrierPhone,
maskPhone,
clampSocialBase,
fetchCacheSize,
clearAppCache,
sendSmsCode,

30
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);
/* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */

81
src/theme/colors.ts

@ -145,6 +145,87 @@ export const templateAccentColors: Record<string, string> = {
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<string, string> = {
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<string, string>`:这里取值方拿到的就是 `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<string, string> = {
优秀: 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 = {

5
src/theme/index.ts

@ -8,4 +8,9 @@ export {
headerGradientShort,
headerGradientSolid,
templateAccentColors,
semanticColorTokens,
jobLevelTextColors,
levelLabelTextColors,
hollandTypeColors,
articleThumbnailColors,
} from './colors';

13
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())}`;

207
项目开发状态.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<string, string> = {
优秀: 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<string, string>`,代价是数据层写错色名会**静默**落到调用方的 `?? 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 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。

Loading…
Cancel
Save