diff --git a/app/(enterprise)/enterprise-chat.tsx b/app/(enterprise)/enterprise-chat.tsx index f627105..a38a19f 100644 --- a/app/(enterprise)/enterprise-chat.tsx +++ b/app/(enterprise)/enterprise-chat.tsx @@ -11,6 +11,7 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useEnterpriseCommunication } from '../../src/hooks'; +import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants'; import { colors } from '../../src/theme'; import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../../src/types'; @@ -288,7 +289,7 @@ export default function EnterpriseChatScreen() { if (loading) { return ( -
router.back()} /> +
router.back()} /> @@ -299,7 +300,7 @@ export default function EnterpriseChatScreen() { if (error || !communication) { return ( -
router.back()} /> +
router.back()} /> 加载失败 diff --git a/app/(enterprise)/job-management.tsx b/app/(enterprise)/job-management.tsx index 201bab6..48ce42e 100644 --- a/app/(enterprise)/job-management.tsx +++ b/app/(enterprise)/job-management.tsx @@ -16,8 +16,9 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useJobManagement } from '../../src/hooks'; import { JOB_MANAGEMENT_SORTS, buildJobStatusAction, getJobStatusLabel } from '../../src/api'; import { ErrorState, LoadingState } from '../../src/components/StateView'; +import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants'; import { colors } from '../../src/theme'; -import { formatDateTime } from '../../src/utils'; +import { formatCompact, formatDateTime } from '../../src/utils'; import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types'; /* ===================== 子组件:顶部导航 ===================== */ @@ -292,7 +293,7 @@ function JobCard({ 投递 - {job.views >= 1000 ? `${(job.views / 1000).toFixed(1)}k` : job.views} + {formatCompact(job.views)} 查看 @@ -448,7 +449,7 @@ export default function JobManagementScreen() { if (loading) { return ( -
router.back()} /> +
router.back()} /> ); @@ -457,7 +458,7 @@ export default function JobManagementScreen() { if (error || !management) { return ( -
router.back()} /> +
router.back()} /> ); diff --git a/app/(enterprise)/talent-recommendation.tsx b/app/(enterprise)/talent-recommendation.tsx index 851b5a3..9ee8581 100644 --- a/app/(enterprise)/talent-recommendation.tsx +++ b/app/(enterprise)/talent-recommendation.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTalentRecommendation } from '../../src/hooks'; +import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants'; import { colors } from '../../src/theme'; import type { TalentCandidate, TalentRecommendationTab } from '../../src/types'; @@ -311,7 +312,7 @@ export default function TalentRecommendationScreen() { if (loading) { return ( -
router.back()} /> +
router.back()} /> @@ -322,7 +323,7 @@ export default function TalentRecommendationScreen() { if (error || !recommendation) { return ( -
router.back()} /> +
router.back()} /> 加载失败 diff --git a/app/(tabs)/jobs.tsx b/app/(tabs)/jobs.tsx index ba908fe..40023d3 100644 --- a/app/(tabs)/jobs.tsx +++ b/app/(tabs)/jobs.tsx @@ -559,7 +559,7 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', borderRadius: 18, - backgroundColor: 'rgba(255,255,255,0.15)', + backgroundColor: colors.onBrandSurfaceWeak, }, stateBox: { flex: 1, diff --git a/app/enterprise-certification.tsx b/app/enterprise-certification.tsx index c97f949..b9879bf 100644 --- a/app/enterprise-certification.tsx +++ b/app/enterprise-certification.tsx @@ -277,7 +277,7 @@ const styles = StyleSheet.create({ width: 48, height: 48, borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.2)', + backgroundColor: colors.onBrandSurface, alignItems: 'center', justifyContent: 'center', }, diff --git a/app/holland.tsx b/app/holland.tsx index 195437b..7b9b4e1 100644 --- a/app/holland.tsx +++ b/app/holland.tsx @@ -11,6 +11,7 @@ import { import { SafeAreaView } from 'react-native-safe-area-context'; import { useHollandIntro } from '../src/hooks'; import { colors } from '../src/theme'; +import { formatThousands } from '../src/utils'; import type { HollandDimensionIntro, HollandIntro } from '../src/types'; const TYPE_COLORS: Record = { @@ -134,7 +135,10 @@ export default function HollandIntroScreen() { {intro.title} {intro.subtitle} - 已有 {intro.totalParticipants.toLocaleString()} 人完成测评 + {/* 与成长页的「已有 N 人完成」是同一个字段、同一句话,两处必须同一种格式化: + 原先这里用 toLocaleString()、那边用 formatThousands(),而 Hermes 在无完整 ICU + 的构建上 toLocaleString() 可能不加千分位,两页就会显示成两个样子。 */} + 已有 {formatThousands(intro.totalParticipants)} 人完成测评 {/* 六维类型 */} diff --git a/app/interview-score.tsx b/app/interview-score.tsx index f36ed07..46d2d11 100644 --- a/app/interview-score.tsx +++ b/app/interview-score.tsx @@ -449,7 +449,7 @@ const styles = StyleSheet.create({ width: 64, height: 64, borderRadius: 32, - backgroundColor: 'rgba(255,255,255,0.2)', + backgroundColor: colors.onBrandSurface, justifyContent: 'center', alignItems: 'center', }, diff --git a/app/job-evaluations.tsx b/app/job-evaluations.tsx index 5cb3872..31ddff5 100644 --- a/app/job-evaluations.tsx +++ b/app/job-evaluations.tsx @@ -585,7 +585,7 @@ const styles = StyleSheet.create({ rateRow: { flexDirection: 'row', borderTopWidth: 1, - borderTopColor: 'rgba(255,255,255,0.2)', + borderTopColor: colors.onBrandLine, paddingTop: 16, }, rateItem: { diff --git a/app/job-preference.tsx b/app/job-preference.tsx index 3b984d4..3545af6 100644 --- a/app/job-preference.tsx +++ b/app/job-preference.tsx @@ -14,14 +14,15 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { colors } from '../src/theme'; import { useAuth } from '../src/context/AuthContext'; import { useJobPreference } from '../src/hooks'; +import { + EXPECTED_CITY_OPTIONS, + EXPECTED_SALARY_OPTIONS, + JOB_STATUS_OPTIONS, + WORK_TYPE_OPTIONS, +} from '../src/constants'; +import { withCurrentOption } from '../src/utils'; import type { JobPreference } from '../src/types'; -/** 各维度可选项(用于岗位推荐匹配) */ -const CITIES = ['北京', '上海', '广州', '深圳', '杭州', '成都', '武汉', '南京', '西安', '苏州']; -const SALARIES = ['不限', '5-10K', '10-20K', '20-30K', '30-50K', '50K以上']; -const WORK_TYPES = ['全职', '兼职', '实习', '远程']; -const STATUSES = ['积极求职中', '观望好机会', '在职暂不考虑']; - /* ===================== 子组件:单选项 chips ===================== */ function ChipGroup({ @@ -151,25 +152,26 @@ export default function JobPreferenceScreen() { update('city', v)} /> update('salary', v)} /> update('workType', v)} /> update('status', v)} /> diff --git a/app/onboarding.tsx b/app/onboarding.tsx index 7475be8..e8c2a58 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -471,7 +471,7 @@ const styles = StyleSheet.create({ countdownTrack: { height: 8, borderRadius: 4, - backgroundColor: 'rgba(255,255,255,0.35)', + backgroundColor: colors.onBrandTrack, overflow: 'hidden', }, countdownFill: { height: 8, borderRadius: 4, backgroundColor: colors.white }, diff --git a/app/progress.tsx b/app/progress.tsx index ae433a9..3fb60fe 100644 --- a/app/progress.tsx +++ b/app/progress.tsx @@ -239,6 +239,9 @@ export default function ProgressScreen() { return progress.companies.filter((c) => c.stage === activeTab); }, [progress, activeTab]); + // 标题文字跟随当前 Tab,与下方列表同源(见渲染处注释) + const activeLabel = TABS.find((tab) => tab.key === activeTab)?.label ?? '全部'; + if (loading) { return ( @@ -265,7 +268,14 @@ export default function ProgressScreen() { - 求职中 {progress.stats.interviewing} + {/* 这里原先写「求职中 {stats.interviewing}」,是「面试中几家」的第三个副本 + (统计卡一份、本标题一份、下方 Tab 列表一份),而它的正下方却是按 Tab 过滤的 + `companies` —— mock 里只有 4 条,是 28 条投递的抽样,撑不起「2」这个数: + 标题写 2,下面列 4 行;切到「面试中」Tab 只剩 1 行,卡片仍写 2。 + 去掉这份副本,标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 */} + + {activeTab === 'all' ? '求职中' : activeLabel} + {/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。 同一文件下方对 offers 就做了保护,companies 此前漏了 */} diff --git a/app/resume.tsx b/app/resume.tsx index d5370e7..e6fefb4 100644 --- a/app/resume.tsx +++ b/app/resume.tsx @@ -1083,7 +1083,7 @@ const styles = StyleSheet.create({ }, optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, /* ---------- 技能 / 自我评价弹层 ---------- */ - modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0, 0, 0, 0.4)' }, + modalMask: { flex: 1, justifyContent: 'flex-end', backgroundColor: colors.scrim }, modalSheet: { backgroundColor: colors.white, borderTopLeftRadius: 20, diff --git a/components/onboarding/screens/P5City.tsx b/components/onboarding/screens/P5City.tsx index 16ec7a6..90d3dd9 100644 --- a/components/onboarding/screens/P5City.tsx +++ b/components/onboarding/screens/P5City.tsx @@ -3,12 +3,14 @@ import { Ionicons } from '@expo/vector-icons'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { colors } from '../../../src/theme'; import { useOnboarding } from '../../../src/hooks'; +import { EXPECTED_CITY_OPTIONS, HOT_CITY_OPTIONS } from '../../../src/constants'; import { ScreenShell } from '../ui/ScreenShell'; import { SearchInput } from '../ui/SearchInput'; import { Tag, TagGroup } from '../ui/Tag'; -const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', '西安', '苏州', '天津', '重庆', '青岛', '长沙', '郑州', '东莞']; -const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都']; +// 城市表放常量:「设置求职意向」页用的是同一份,两处各写一份会让采集到的城市在那页选不中 +const ALL = EXPECTED_CITY_OPTIONS; +const HOT = HOT_CITY_OPTIONS; export function P5City() { const { data, setData, back } = useOnboarding(); diff --git a/eslint.config.js b/eslint.config.js index 308bbbe..0ef4a66 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,30 +5,47 @@ const reactHooks = require('eslint-plugin-react-hooks'); const globals = require('globals'); /** - * 自定义规则:禁止在组件/业务代码内写死十六进制颜色字面量。 + * 自定义规则:禁止在组件/业务代码内写死颜色字面量。 * 对齐 AGENTS.md 配色禁令——颜色必须统一从 src/theme/colors.ts 导入。 + * + * 覆盖两类写法: + * · 十六进制:#RGB / #RGBA / #RRGGBB / #RRGGBBAA + * · 函数式:rgb() / rgba() / hsl() / hsla() + * 后者是补上的**盲区**:规则原先只匹配纯 hex,于是 `rgba(0, 0, 0, 0.4)` 这类写死颜色 + * 一路溜过门槛,最终表现为「同一个弹层遮罩在两个页面是两个透明度」——token 明明就在 + * `colors.scrim`,全仓 9 处在用,唯独那一处自己重新推导了一遍(见 4.14)。 + * 同时覆盖模板字面量,避免用 `` `rgba(0,0,0,${a})` `` 绕过去。 * 例外:theme/colors.ts 本身是设计令牌唯一来源,豁免。 */ const noHardcodedHexColor = { meta: { type: 'problem', docs: { - description: '禁止在组件内写死十六进制颜色,统一从 src/theme/colors.ts 导入', + description: + '禁止写死颜色(hex / rgb / rgba / hsl),统一从 src/theme/colors.ts 导入', }, schema: [], messages: { - noHex: - '禁止写死十六进制颜色字面量 "{{value}}",请从 src/theme/colors.ts 导入颜色 token。', + noHex: '禁止写死颜色字面量 "{{value}}",请从 src/theme/colors.ts 导入颜色 token。', }, }, create(context) { const hexRe = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + const fnRe = /(^|[\s,:;(])(rgb|rgba|hsl|hsla)\(/i; + const isHardcodedColor = (value) => hexRe.test(value) || fnRe.test(value); return { Literal(node) { - if (typeof node.value === 'string' && hexRe.test(node.value)) { + if (typeof node.value === 'string' && isHardcodedColor(node.value)) { context.report({ node, messageId: 'noHex', data: { value: node.value } }); } }, + TemplateElement(node) { + // 模板字面量的静态片段(`rgba(0,0,0,${a})` 的 raw 是 "rgba(0,0,0,") + const raw = node.value.raw; + if (typeof raw === 'string' && isHardcodedColor(raw)) { + context.report({ node, messageId: 'noHex', data: { value: raw } }); + } + }, }; }, }; diff --git a/src/__tests__/data-consistency.test.ts b/src/__tests__/data-consistency.test.ts new file mode 100644 index 0000000..7758ed2 --- /dev/null +++ b/src/__tests__/data-consistency.test.ts @@ -0,0 +1,295 @@ +/** + * 数据自洽性回归锁(2026-09-20 批次) + * + * 本批修的都是同一类缺陷:**同一个业务事实写了两份,两处取值不同**。 + * 修法一律是让重复的那一份从权威源**派生**,而不是把两个数字手工对齐 —— + * 手工对齐只解决这一次,下次改数据照样分叉。 + * + * 因此下面的断言尽量写成「A 必须等于 B」的**关系**而非「A 等于 28」: + * 这样以后调整 mock 数据(把 28 改成 30)不会让测试变红, + * 只有把派生关系改回写字面量才会红 —— 这正是要锁的东西。 + * + * 每条断言都对应一个**真实发生过的**矛盾,注释里写了当时两处各写了什么。 + */ +import { + fetchBadges, + fetchCandidateDetail, + fetchCompareData, + fetchConversations, + fetchFavorites, + fetchJobDetail, + fetchJobList, + fetchJobOverallScore, + fetchJobProgress, + fetchMatchResult, + fetchTalentRecommendation, + fetchUserStats, +} from '../api/api'; +import { JOB_LEVEL_LABEL } from '../constants'; +import { formatCompact } from '../utils'; + +// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock +jest.mock('../lib/storage', () => ({ + storageGet: jest.fn(async () => null), + storageSet: jest.fn(async () => undefined), + storageRemove: jest.fn(async () => undefined), +})); + +/** 取值并把「取不到」变成带名字的失败,避免 undefined 参与比较后产生假绿 */ +function must(value: T | undefined, what: string): T { + if (value === undefined) throw new Error(`测试期望存在:${what}`); + return value; +} + +describe('求职进度:统计卡 / 月度图表 / 时间轴必须是同一个事实', () => { + it('统计卡五项之和等于总数(用户会心算这一行)', async () => { + const { stats } = await fetchJobProgress(); + + expect(stats.pending + stats.interviewing + stats.offer + stats.ended).toBe(stats.total); + }); + + it('monthlyStats 每一列的合计都等于顶部的对应计数', async () => { + const progress = await fetchJobProgress(); + const { monthlyStats } = progress; + const sum = (pick: (m: (typeof monthlyStats)[number]) => number) => + monthlyStats.reduce((acc, m) => acc + pick(m), 0); + + // 原先只对齐了 offers 一列:投递合计 52、面试合计 16,而卡片上写 28 / 6。 + // 注意对齐的对象是 `*Count`(累计),不是 `stats.interviewing`(当前面试中)—— + // 见下一条测试的说明,这两个不是同一个事实。 + expect(sum((m) => m.deliveries)).toBe(must(progress.deliveryCount, 'deliveryCount')); + expect(sum((m) => m.interviews)).toBe(must(progress.interviewCount, 'interviewCount')); + expect(sum((m) => m.offers)).toBe(must(progress.offerCount, 'offerCount')); + }); + + it('「累计面试邀请数」与「当前面试中数」是两个事实,不能要求相等', async () => { + const progress = await fetchJobProgress(); + const interviewCount = must(progress.interviewCount, 'interviewCount'); + const { interviewing } = progress.stats; + + // 写这条是为了钉住一个**踩过的坑**:本文件初版把这两个数当成同一份被写重的值, + // 断言 interviewCount === stats.interviewing 并因此变红 —— 红的是测试,不是数据。 + // 语义:interviewCount 是「累计收到多少场面试邀请」(与 monthlyStats.interviews 之和、 + // 时间轴「收到 6 场面试邀请」同源);stats.interviewing 是「此刻有几家停在面试中」。 + // 前者必然 ≥ 后者(面试过的不可能比收到的多),但两者绝不相等。 + expect(interviewCount).toBeGreaterThanOrEqual(interviewing); + expect(interviewCount).not.toBe(interviewing); + }); + + it('投递总数与 Offer 数这两处确实是同一个事实,必须同源', async () => { + const progress = await fetchJobProgress(); + const { stats } = progress; + + // 与上一条相反:这两个在语义上就是「同一个数」, + // 原先 `deliveryCount`、`stats.total`、时间轴文案三份各写各的,才出现 30 ≠ 28。 + expect(progress.deliveryCount).toBe(stats.total); + expect(progress.offerCount).toBe(stats.offer); + }); + + it('时间轴文案里的数字与计数同源(不能文案写 28、卡片写 30)', async () => { + const progress = await fetchJobProgress(); + const { steps } = progress; + const descOf = (id: string) => must(steps?.find((s) => s.id === id), `步骤 ${id}`).desc; + + expect(descOf('p1')).toContain(String(must(progress.deliveryCount, 'deliveryCount'))); + expect(descOf('p3')).toContain(String(must(progress.interviewCount, 'interviewCount'))); + expect(descOf('p5')).toContain(String(must(progress.offerCount, 'offerCount'))); + }); + + it('「即将到期的 Offer」条数不能超过 Offer 总数(提醒页比统计卡还多就是吹牛)', async () => { + const { offers, stats } = await fetchJobProgress(); + + expect(offers.length).toBeGreaterThan(0); + expect(offers.length).toBeLessThanOrEqual(stats.offer); + }); +}); + +describe('我的:统计数字必须与它click进去看到的列表同源', () => { + it('收藏数与收藏列表条数一致(原先徽标 12、列表 3)', async () => { + const stats = await fetchUserStats(); + const favorites = await fetchFavorites(); + + expect(stats.favorites).toBe(favorites.length); + }); + + it('投递数与求职进度总数一致(同一个「我投了多少家」)', async () => { + const stats = await fetchUserStats(); + const { stats: progressStats } = await fetchJobProgress(); + + expect(stats.deliveries).toBe(progressStats.total); + }); + + it('Offer 数与求职进度 Offer 数一致', async () => { + const stats = await fetchUserStats(); + const { stats: progressStats } = await fetchJobProgress(); + + expect(stats.offers).toBe(progressStats.offer); + }); +}); + +describe('勋章:已获得与否必须由真实统计推导', () => { + it('投递类勋章的进度就是投递数(不能 28 次投递配「投递满100次 已获得」)', async () => { + const stats = await fetchUserStats(); + const badges = await fetchBadges(); + const badge = (id: string) => must(badges.find((b) => b.id === id), `勋章 ${id}`); + + const target100 = must(badge('badge-1').target, 'badge-1.target'); + expect(badge('badge-1').progress).toBe(Math.min(stats.deliveries, target100)); + expect(badge('badge-1').obtained).toBe(stats.deliveries >= target100); + + const target200 = must(badge('badge-5').target, 'badge-5.target'); + expect(badge('badge-5').progress).toBe(Math.min(stats.deliveries, target200)); + expect(badge('badge-5').obtained).toBe(stats.deliveries >= target200); + }); + + it('Offer 类勋章同理', async () => { + const stats = await fetchUserStats(); + const badges = await fetchBadges(); + const badge = must(badges.find((b) => b.id === 'badge-4'), '勋章 badge-4'); + const target = must(badge.target, 'badge-4.target'); + + expect(badge.progress).toBe(Math.min(stats.offers, target)); + expect(badge.obtained).toBe(stats.offers >= target); + }); + + it('任何勋章都不能出现「已获得却没达标」或「未获得却已超额」', async () => { + const stats = await fetchUserStats(); + const badges = await fetchBadges(); + + expect(badges.length).toBeGreaterThan(0); + badges.forEach((badge) => { + const target = must(badge.target, `${badge.id}.target`); + const progress = must(badge.progress, `${badge.id}.progress`); + + expect(progress).toBeLessThanOrEqual(target); + expect(badge.obtained).toBe(progress >= target); + // 文案里若写了门槛,必须与 target 一致(如「投递满100次」↔ target 100)。 + // 「诚信记录满分」这类描述本就不含数字,故只在有数字时才比对,否则这条会变成假要求。 + const numbersInDescription = badge.description.match(/\d+/g) ?? []; + if (numbersInDescription.length > 0) { + expect(numbersInDescription).toContain(String(target)); + } + // 统计卡上写着投递数,勋章进度不能凭空超过它 + if (badge.id === 'badge-1' || badge.id === 'badge-5') { + expect(progress).toBeLessThanOrEqual(stats.deliveries); + } + }); + }); +}); + +describe('匹配成功横幅:数字与逐条会话标记同源', () => { + it('total 等于「已匹配」会话数,companies 就是这些公司', async () => { + const matchResult = await fetchMatchResult(); + const conversations = await fetchConversations(); + + const matched = conversations.filter((chat) => chat.matched); + + expect(matchResult.total).toBe(matched.length); + expect(matchResult.companies).toEqual(matched.map((chat) => chat.companyName)); + }); +}); + +describe('岗位评分:列表卡 / 详情雷达图 / 对比页三处必须同分', () => { + it('每个岗位的详情总分等于它在列表里的分数(原先恒返回 92 分)', async () => { + const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); + expect(jobs.length).toBeGreaterThan(1); + + for (const job of jobs) { + const score = await fetchJobOverallScore(job.id); + expect(score.totalScore).toBe(job.score); + // 中文等级也必须与列表卡上的 level 同源 + expect(score.level).toBe(JOB_LEVEL_LABEL[job.level]); + } + }); + + it('详情雷达图的维度值经缩放后不越界(65 分的岗位不能配一张最低 78 分的雷达图)', async () => { + const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); + + for (const job of jobs) { + const { dimensions, totalScore } = await fetchJobOverallScore(job.id); + expect(dimensions.length).toBeGreaterThan(0); + dimensions.forEach((dim) => { + expect(dim.value).toBeGreaterThanOrEqual(0); + expect(dim.value).toBeLessThanOrEqual(100); + }); + // 维度全部为 0 只可能是缩放因子算错(base 为 0) + expect(dimensions.some((dim) => dim.value > 0)).toBe(true); + expect(totalScore).toBe(job.score); + } + }); + + it('对比页的「综合评分」就是各岗位自己的分(原先两个岗位的分正好写反)', async () => { + const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); + const scoreById = new Map(jobs.map((job) => [job.id, job.score])); + + const compare = await fetchCompareData([...scoreById.keys()]); + const dimension = must( + compare.dimensionScores.find((dim) => dim.dimension === '综合评分'), + '对比页的「综合评分」维度', + ); + + expect(dimension.scores).toHaveLength(compare.jobs.length); + compare.jobs.forEach((job, index) => { + const expected = scoreById.get(job.id); + expect(expected).toBeDefined(); + expect(dimension.scores[index]).toBe(expected); + }); + }); + + it('详情页的公司所在地与列表卡一致(原先同一个岗位写了两个城区)', async () => { + const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); + + for (const job of jobs) { + const detail = await fetchJobDetail(job.id); + expect(detail.companyLocation).toBe(job.companyLocation); + expect(detail.positionName).toBe(job.positionName); + expect(detail.companyName).toBe(job.companyName); + } + }); +}); + +describe('候选人详情:身份必须取自人才推荐列表', () => { + it('每位候选人的姓名 / 年龄 / 城市 / 三项评分都与列表同源(原先 4-8 号全叫张明远)', async () => { + const { candidates } = await fetchTalentRecommendation(); + expect(candidates.length).toBeGreaterThan(4); + + for (const candidate of candidates) { + const detail = await fetchCandidateDetail(candidate.id); + + expect(detail.id).toBe(candidate.id); + expect(detail.basicInfo.name).toBe(candidate.name); + expect(detail.basicInfo.age).toBe(candidate.age); + expect(detail.basicInfo.city).toBe(candidate.city); + expect(detail.matchPercent).toBe(candidate.matchPercent); + expect(detail.overallScore).toBe(candidate.overallScore); + expect(detail.percentile).toBe(candidate.percentile); + } + }); + + it('姓名不是所有人共用一个(这条断言专门盯住「都渲染成张明远」)', async () => { + const { candidates } = await fetchTalentRecommendation(); + const names = await Promise.all( + candidates.map(async (candidate) => (await fetchCandidateDetail(candidate.id)).basicInfo.name), + ); + + expect(new Set(names).size).toBeGreaterThan(1); + }); +}); + +describe('formatCompact:看板紧凑计数', () => { + it('四位数及以上转 k,保留一位小数', () => { + expect(formatCompact(2300)).toBe('2.3k'); + expect(formatCompact(1286)).toBe('1.3k'); + }); + + it('不足一千原样输出', () => { + expect(formatCompact(0)).toBe('0'); + expect(formatCompact(999)).toBe('999'); + }); + + it('小数位口径与改造前的内联写法一致(刻意不去掉 ".0")', () => { + // 改这个口径会动到企业端看板现在的显示,属于另一批事,这里锁住现状 + expect(formatCompact(1000)).toBe('1.0k'); + expect(formatCompact(2000)).toBe('2.0k'); + }); +}); diff --git a/src/__tests__/enterprise-filters.test.ts b/src/__tests__/enterprise-filters.test.ts index 638b9d6..3c796eb 100644 --- a/src/__tests__/enterprise-filters.test.ts +++ b/src/__tests__/enterprise-filters.test.ts @@ -195,12 +195,17 @@ describe('企业端 Tab 计数由列表推导(页面数字与列表人数必 }); it('待办任务计数由会话列表推导,且都带可跳转的去向', async () => { - const { chats, pendingTasks } = await fetchEnterpriseCommunication(); + const { chats, tabs, pendingTasks } = await fetchEnterpriseCommunication(); const reply = pendingTasks.find((task) => task.id === 'reply'); const score = pendingTasks.find((task) => task.id === 'score'); - expect(reply?.count).toBe(chats.filter((chat) => chat.unreadCount > 0).length); + // 「待回复」卡片点下去会切到「未回复」Tab,所以它的数字必须与**那个 Tab 的计数**一致 —— + // 判据是「与去向一致」,而不是「某个内部集合的长度」。 + // 原先这里锁的是 `unreadCount > 0` 的会话数(3),与「未回复」Tab 的 2 不是同一个集合, + // 于是同一屏上卡片写「待回复 3 人」、紧邻的 Tab 写「未回复 (2)」,点进去只剩 2 条。 + // 这条测试当时把矛盾当成了契约,一并改正。 + expect(reply?.count).toBe(tabs.find((tab) => tab.key === 'pending')?.count); expect(reply?.tabKey).toBe('pending'); expect(score?.count).toBe(chats.filter((chat) => chat.labels.includes('面试待评分')).length); expect(score?.route).toBe('/interview-score'); diff --git a/src/__tests__/job-preference-options.test.ts b/src/__tests__/job-preference-options.test.ts new file mode 100644 index 0000000..9223f39 --- /dev/null +++ b/src/__tests__/job-preference-options.test.ts @@ -0,0 +1,229 @@ +import { fetchJobPreference, fetchResume, saveJobPreference, saveOnboardingProfile } from '../api/api'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + EXPECTED_CITY_OPTIONS, + EXPECTED_SALARY_OPTIONS, + JOB_STATUS_INACTIVE, + JOB_STATUS_OPTIONS, + PREFERENCE_DEFAULT_WORK_TYPE, + SALARY_MAX_OPTIONS, + SALARY_MIN_OPTIONS, + WORK_TYPE_OPTIONS, +} from '../constants'; +import { storageRemove } from '../lib/storage'; +import { withCurrentOption } from '../utils'; +import type { JobPreference, OnboardingProfile } from '../types'; + +// 内存版 storage mock(与 job-match.test.ts 同) +jest.mock('../lib/storage', () => { + const store: Record = {}; + return { + storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)), + storageSet: jest.fn(async (k: string, v: string) => { + store[k] = v; + }), + storageRemove: jest.fn(async (k: string) => { + delete store[k]; + }), + }; +}); + +/** 全部字段留空的兜底资料,只覆盖测试关心的字段 */ +const BASE_PROFILE: OnboardingProfile = { + identity: 'candidate', + isStudent: null, + city: '', + expectedPositions: [], + expectedSalaryMin: '', + expectedSalaryMax: '', + resumeName: '', + gender: null, + birthYear: '', + birthMonth: '', + jobStatus: null, + disabledJobseeker: false, + firstWorkYear: '', + firstWorkMonth: '', + currentPosition: null, + company: '', + joinYear: '', + joinMonth: '', + skills: [], + workContent: '', + education: null, + eduType: null, + school: '', + avatar: null, + recruiter: null, +}; + +const makeProfile = (patch: Partial): OnboardingProfile => ({ + ...BASE_PROFILE, + ...patch, +}); + +/** + * chip 组的选中判定(与 `app/job-preference.tsx` 的 `opt === value` 同一口径)。 + * 薪资那一组选项要过 `withCurrentOption`,其余三组直接用常量列表。 + */ +const isSelected = (options: readonly string[], current: string): boolean => + current !== '' && options.includes(current); + +beforeEach(async () => { + await storageRemove('user:job-preference'); + await saveOnboardingProfile(BASE_PROFILE); +}); + +describe('引导采集的求职意向,在「设置求职意向」页必须有 chip 能选中', () => { + /** + * 这一组是回归锁。此前引导页与求职意向页各有一份选项表: + * 期望薪资——引导拼出 `10K-20K`,该页写的是 `10-20K`(最低值不带 K) + * 求职状态——引导用 `离职-随时到岗` 等,该页用 `积极求职中` 等,两个列表零重合 + * 期望城市——引导 16 城,该页 10 城,天津 / 重庆 / 青岛等 6 城落空 + * 用户刚在引导里填完,进来看到「期望职位」有值、其余三项全空,像资料丢了。 + */ + + it('期望薪资:P7 两个阶梯拼出的值,与该页快捷档同一种写法', () => { + // 取阶梯里任意两组,拼出的写法必须与快捷档一致(都是「最低K-最高K」) + for (const min of SALARY_MIN_OPTIONS) { + for (const max of SALARY_MAX_OPTIONS) { + const derived = `${min}-${max}`; + // 快捷档覆盖不到的组合(如 8K-12K)由 withCurrentOption 补进去,同样能选中 + expect(isSelected(withCurrentOption(EXPECTED_SALARY_OPTIONS, derived), derived)).toBe(true); + } + } + }); + + it('期望薪资:常见组合直接命中快捷档,无需补值', () => { + for (const derived of ['5K-10K', '10K-20K', '20K-30K', '30K-50K']) { + expect(withCurrentOption(EXPECTED_SALARY_OPTIONS, derived)).toEqual(EXPECTED_SALARY_OPTIONS); + } + }); + + it('求职状态:引导的每一个状态都能在该页选中', () => { + for (const status of JOB_STATUS_OPTIONS) { + expect(isSelected(JOB_STATUS_OPTIONS, status)).toBe(true); + } + }); + + it('期望城市:引导选得出来的城市,该页一个都不能少', () => { + for (const city of EXPECTED_CITY_OPTIONS) { + expect(isSelected(EXPECTED_CITY_OPTIONS, city)).toBe(true); + } + }); + + it('工作类型:默认值必须落在选项表内(否则新用户一进来就选不中)', () => { + expect(WORK_TYPE_OPTIONS).toContain(PREFERENCE_DEFAULT_WORK_TYPE); + }); + + it('端到端:引导采集三件套 → fetchJobPreference 的产物逐项可选中', async () => { + await saveOnboardingProfile( + makeProfile({ + city: '天津', // 旧版该页 10 城里没有这个 + expectedSalaryMin: '10K', + expectedSalaryMax: '20K', + jobStatus: '在职-月内到岗', // 旧版该页词表里没有这一项 + }), + ); + + const preference = await fetchJobPreference(); + expect(isSelected(EXPECTED_CITY_OPTIONS, preference.city)).toBe(true); + expect(isSelected(withCurrentOption(EXPECTED_SALARY_OPTIONS, preference.salary), preference.salary)).toBe(true); + expect(isSelected(JOB_STATUS_OPTIONS, preference.status)).toBe(true); + expect(isSelected(WORK_TYPE_OPTIONS, preference.workType)).toBe(true); + }); +}); + +describe('求职状态词表统一后,简历求职意向的积极 / 不积极判定才成立', () => { + /** + * `fetchResume` 用 `preference.status === JOB_STATUS_INACTIVE` 判「是否积极求职」。 + * 该常量属于引导词表;此前该页的自有词表里写的是 `在职暂不考虑`(没那两个短横), + * 于是用户在该页选了「暂不考虑」,保存后简历仍然把他记成积极求职中 —— 存进去的值 + * 与判定用的常量不是同一个字符串,比较恒为 false。 + */ + it('在该页选「暂不考虑」并保存,简历必须记为 inactive', async () => { + await saveJobPreference({ + position: '前端开发工程师', + city: '北京', + salary: '20K-30K', + workType: '全职', + status: JOB_STATUS_INACTIVE, + } as JobPreference); + + const resume = await fetchResume(); + expect(resume.jobIntention.status).toBe('inactive'); + expect(resume.jobIntention.statusLabel).toBe(JOB_STATUS_INACTIVE); + }); + + it('选其余任一状态都记为 active', async () => { + for (const status of JOB_STATUS_OPTIONS.filter((s) => s !== JOB_STATUS_INACTIVE)) { + await saveJobPreference({ + position: '前端开发工程师', + city: '北京', + salary: '20K-30K', + workType: '全职', + status, + } as JobPreference); + + const resume = await fetchResume(); + expect(resume.jobIntention.status).toBe('active'); + } + }); +}); + +describe('接线锁:选项表必须来自 constants,页面不得自持一份', () => { + /** + * 上面几例锁的是「常量之间自洽」,但原缺陷的形态是**页面自己又写了一份**—— + * 常量没毛病,页面不读它,照样对不上。故这里直接读源码,锁住接线。 + * + * 说明:这是源码级断言,不是渲染断言(本项目 24 个测试文件均为纯逻辑测试, + * 不引入组件测试)。它能拦住「改回本地字面量」,但拦不住「读常量后又做了一层转换」。 + */ + const read = (relative: string) => + readFileSync(join(__dirname, '../../', relative), 'utf8'); + + it('求职意向页从 constants 取四组选项,且不再声明本地选项数组', () => { + const src = read('app/job-preference.tsx'); + for (const name of [ + 'EXPECTED_CITY_OPTIONS', + 'EXPECTED_SALARY_OPTIONS', + 'JOB_STATUS_OPTIONS', + 'WORK_TYPE_OPTIONS', + ]) { + expect(src).toContain(name); + } + // 旧版的本地数组名;重建即回归 + expect(src).not.toMatch(/^const (CITIES|SALARIES|WORK_TYPES|STATUSES)\b/m); + }); + + it('引导选城市页与求职意向页读的是同一份城市表', () => { + const onboardingSrc = read('components/onboarding/screens/P5City.tsx'); + expect(onboardingSrc).toContain('EXPECTED_CITY_OPTIONS'); + expect(onboardingSrc).not.toMatch(/^const ALL = \[/m); + }); +}); + +describe('withCurrentOption(把用户的真实值补进 chip 组)', () => { + it('已在列表中时原样返回', () => { + expect(withCurrentOption(['不限', '20K-30K'], '20K-30K')).toEqual(['不限', '20K-30K']); + }); + + it('不在列表中时插在首位之后(首位通常是「不限」这类兜底项)', () => { + expect(withCurrentOption(['不限', '20K-30K'], '8K-12K')).toEqual(['不限', '8K-12K', '20K-30K']); + }); + + it('空串(未设置)不补,保持列表原样 —— 「未设置」不该变成一个可选项', () => { + expect(withCurrentOption(['不限', '20K-30K'], '')).toEqual(['不限', '20K-30K']); + }); + + it('空列表时返回只含当前值的列表', () => { + expect(withCurrentOption([], '8K-12K')).toEqual(['8K-12K']); + }); + + it('不修改传入的数组(纯函数)', () => { + const options = ['不限', '20K-30K']; + withCurrentOption(options, '8K-12K'); + expect(options).toEqual(['不限', '20K-30K']); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 802872e..77f437b 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -173,6 +173,7 @@ import { JOB_MATCH_CITY_BONUS, JOB_MATCH_POSITION_BONUS, JOB_MATCH_SALARY_BONUS, + JOB_LEVEL_LABEL, JOB_STATUS_EMPLOYED_PREFIX, JOB_STATUS_INACTIVE, MOCK_DELAY, @@ -180,6 +181,7 @@ import { PASSWORD_MIN_LENGTH, PASSWORD_RULE_LABEL, POSITION_NOISE_WORDS, + ENTERPRISE_DISPLAY_NAME, PREFERENCE_DEFAULT_WORK_TYPE, RESUME_AI_UNAVAILABLE_TIP, SALARY_MAX_OPTIONS, @@ -429,11 +431,20 @@ const MOCK_INTERVIEWS: Interview[] = [ }, ]; -/** 双向匹配结果 */ -const MOCK_MATCH_RESULT: MatchResult = { - total: 3, - companies: ['字节跳动', '腾讯科技', '阿里巴巴'], -}; +/** + * 双向匹配结果。 + * + * 由会话列表的 `matched` 标志派生 —— 顶部横幅(「与 N 家企业双向匹配成功」)与会话卡上的 + * 「✓ 已匹配」标签是**同一个事实**,各写一份必然对不上:原先横幅名单写 + * 「字节跳动、腾讯科技、阿里巴巴」,而会话里阿里巴巴没标已匹配、美团反而标了。 + */ +function buildMatchResult(): MatchResult { + const matched = MOCK_CONVERSATIONS.filter((chat) => chat.matched); + return { + total: matched.length, + companies: matched.map((chat) => chat.companyName), + }; +} /** 霍兰德六维名称映射 */ const HOLLAND_NAMES: Record = { @@ -694,13 +705,30 @@ const MOCK_USER_PROFILE: UserProfile = { avatarKey: null, }; -/** 用户统计数据(我的页四宫格) */ -const MOCK_USER_STATS: UserStats = { - deliveries: 28, - favorites: 12, - chats: 8, - offers: 3, -}; +/** + * 四宫格里唯一没有明细可对照的一列:「沟通」。 + * 消息页有 4 个会话,但全仓没有任何一处把「沟通数」定义为会话数, + * 语义未经确认,故不擅自改数,只把字面量抽出来标明来路。 + */ +const MOCK_CHAT_COUNT = 8; + +/** + * 用户统计数据(我的页四宫格)。 + * + * 投递 / 收藏 / Offer 三列**一律从各自的主口径派生**,不在此写死 —— 写死必然漂移: + * · 投递、Offer → `MOCK_JOB_PROGRESS`(求职进度页的 deliveryCount / stats.total / + * 时间轴文案四处都用它,是本项目的岗位投递主口径)。原 offers 写 3,那边是 2。 + * · 收藏 → `MOCK_FAVORITES`(「我的收藏」页真正的明细列表)。原 favorites 写 12, + * 而收藏页只有 3 条 —— 用户点进去一数就发现对不上。 + */ +function buildUserStats(): UserStats { + return { + deliveries: PROGRESS_DELIVERY_COUNT, + favorites: MOCK_FAVORITES.length, + chats: MOCK_CHAT_COUNT, + offers: PROGRESS_OFFER_COUNT, + }; +} /** * 求职者端功能菜单 @@ -781,20 +809,80 @@ const EMPTY_JOB_PREFERENCE: JobPreference = { status: '', }; -/** 勋章列表(已获得 + 未获得;未获得带获取进度) */ -const MOCK_BADGES: Badge[] = [ - { id: 'badge-1', title: '求职达人', description: '投递满100次', icon: '🏅', obtained: true }, - { id: 'badge-2', title: '面试高手', description: '面试10次成功', icon: '🎯', obtained: true }, - { id: 'badge-3', title: '诚信之星', description: '诚信记录满分', icon: '⭐', obtained: true }, - { id: 'badge-4', title: 'offer收割机', description: '获得5个offer', icon: '🏆', obtained: false, progress: 3, target: 5 }, - { id: 'badge-5', title: '投递达人', description: '投递满200次', icon: '📮', obtained: false, progress: 128, target: 200 }, - { id: 'badge-6', title: '学习之星', description: '完成10次测评', icon: '📚', obtained: false, progress: 4, target: 10 }, -]; +/** + * 勋章列表(已获得 + 未获得;未获得带获取进度)。 + * + * 每枚勋章的描述本身就是一个计数门槛(「投递满100次」「获得5个offer」…), + * 所以进度与「是否已获得」**必须由用户统计派生**。原先两者各写一份,后果是同一个 + * 「我的」页上下自相矛盾:四宫格写着投递 28、Offer 2,正下方的勋章列表却显示 + * 「投递满100次 · 已获得」「投递达人 128/200」「offer收割机 3/5」。 + * 派生还有个好处:门槛一到自动点亮,不需要谁记得回来改 `obtained`。 + * + * 例外:badge-6「完成10次测评」的「测评次数」全仓没有可对照的明细口径 + * (霍兰德与四维测评各只有一份结果,不含计数),语义未经确认,暂保留原值。 + */ +function buildBadges(): Badge[] { + const stats = buildUserStats(); + const interviewCount = PROGRESS_INTERVIEW_COUNT; + /** 「诚信记录满分」的分就是八大维度里那一项,不另记一笔 */ + const integrity = MOCK_DIMENSION_SCORES.find((item) => item.id === 'integrity')?.score ?? 0; -/** 岗位详情附加/覆盖信息(id → 详情页专属字段;覆盖列表页未细化的字段) */ + return [ + { + id: 'badge-1', + title: '求职达人', + description: '投递满100次', + icon: '🏅', + obtained: stats.deliveries >= 100, + progress: Math.min(stats.deliveries, 100), + target: 100, + }, + { + id: 'badge-2', + title: '面试高手', + description: '面试10次成功', + icon: '🎯', + obtained: interviewCount >= 10, + progress: Math.min(interviewCount, 10), + target: 10, + }, + { + id: 'badge-3', + title: '诚信之星', + description: '诚信记录满分', + icon: '⭐', + obtained: integrity >= 100, + progress: Math.min(integrity, 100), + target: 100, + }, + { + id: 'badge-4', + title: 'offer收割机', + description: '获得5个offer', + icon: '🏆', + obtained: stats.offers >= 5, + progress: Math.min(stats.offers, 5), + target: 5, + }, + { + id: 'badge-5', + title: '投递达人', + description: '投递满200次', + icon: '📮', + obtained: stats.deliveries >= 200, + progress: Math.min(stats.deliveries, 200), + target: 200, + }, + { id: 'badge-6', title: '学习之星', description: '完成10次测评', icon: '📚', obtained: false, progress: 4, target: 10 }, + ]; +} + +/** 岗位详情附加/覆盖信息(id → 详情页专属字段;只覆盖列表页没有、或确实需要细化的字段) */ const MOCK_JOB_DETAILS: Record> = { '1': { - companyLocation: '北京 · 朝阳区', + // 这里原先还覆盖了 companyLocation(写「朝阳区」),与 MOCK_JOBS 里的「海淀区」冲突 —— + // 同一个岗位在列表卡和详情页显示两个城区。列表页该字段本就有完整值,不属「未细化」, + // 故撤掉这层覆盖,让 MOCK_JOBS 成为该字段的唯一来源。 industry: '互联网', benefits: ['五险一金', '年终奖金', '弹性工作', '免费三餐'], companySize: '10000人以上', @@ -861,6 +949,9 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [ }, ]; +/** 对比页里与「岗位分」同义的那个维度名(其余维度是独立评分,不参与派生) */ +const COMPARE_SCORE_DIMENSION = '综合评分'; + /** 岗位对比数据(3 个岗位:九大维度评分 + 基本信息 + 福利) */ const MOCK_COMPARE_DATA: CompareData = { jobs: [ @@ -906,6 +997,17 @@ function scoreToLevel(score: number): JobLevel { return 'medium'; } +/** + * 岗位 id → 该岗位的分数。 + * + * `MOCK_JOBS` 是岗位分的**唯一来源**:列表卡印它、排序用它、等级由它算。 + * 对比页与详情页原先各写一份固定分,于是同一个岗位在列表里是 78 分、 + * 点进详情变 92 分、在对比页又是 85 分 —— 三处三个数。 + */ +function jobScoreById(id: string): number { + return MOCK_JOBS.find((job) => job.id === id)?.score ?? 0; +} + /** 能力总分 → 等级映射(能力测评阈值高于岗位匹配:≥90 优秀 / ≥75 良好) */ function abilityScoreToLevel(score: number): JobLevel { if (score >= 90) return 'excellent'; @@ -1113,7 +1215,7 @@ export async function fetchInterviews(): Promise { */ export async function fetchMatchResult(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/messages/match-result`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_MATCH_RESULT); + return mockRequest(buildMatchResult()); } /** @@ -1238,7 +1340,7 @@ function buildProfileBio(profile: OnboardingProfile): string { */ export async function fetchUserStats(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/user/stats`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_USER_STATS); + return mockRequest(buildUserStats()); } /** @@ -1597,7 +1699,7 @@ export async function fetchJobPreference(): Promise { */ export async function fetchBadges(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_BADGES); + return mockRequest(buildBadges()); } /** @@ -1637,8 +1739,23 @@ export async function fetchJobDetail(id: string): Promise { */ export async function fetchJobOverallScore(id: string): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json(); - void id; - return mockRequest(MOCK_JOB_OVERALL_SCORE); + // 必须按 id 取该岗位自己的分:原先 `void id;` 直接返回固定的一份(恒 92 分 · 优秀), + // 于是列表卡上「产品经理 78 分 · 良好」点进去变成「92 分 · 优秀」,9 个岗位里只有第 1 个对得上。 + const score = jobScoreById(id); + const base = MOCK_JOB_OVERALL_SCORE.totalScore; + const factor = base > 0 ? score / base : 1; + return mockRequest({ + ...MOCK_JOB_OVERALL_SCORE, + totalScore: score, + level: JOB_LEVEL_LABEL[scoreToLevel(score)], + // 八维画像 mock 阶段只有这一份,按该岗位的实际综合分等比缩放后再画雷达图; + // 否则一个 65 分的岗位会配上一张最低值 78 分的雷达图,卡片自己和自己对不上。 + // 真实后端就绪后由接口按岗位返回各自的画像,这段缩放随之删除。 + dimensions: MOCK_JOB_OVERALL_SCORE.dimensions.map((dim) => ({ + ...dim, + value: Math.round(dim.value * factor), + })), + }); } /** @@ -1730,7 +1847,13 @@ export async function fetchCompareData(jobIds: string[]): Promise { jobs: indices.map((index) => MOCK_COMPARE_DATA.jobs[index]), dimensionScores: MOCK_COMPARE_DATA.dimensionScores.map((dim) => ({ dimension: dim.dimension, - scores: indices.map((index) => dim.scores[index]), + // 「综合评分」必须等于该岗位自己的分数 —— 列表卡上印的就是它。 + // 原先对比页另写一份,把产品经理记成 85、UI 设计师记成 78,与列表里的 78 / 85 正好互换。 + scores: indices.map((index) => + dim.dimension === COMPARE_SCORE_DIMENSION + ? jobScoreById(MOCK_COMPARE_DATA.jobs[index].id) + : dim.scores[index], + ), })), basicInfo: { fields: MOCK_COMPARE_DATA.basicInfo.fields, @@ -2578,17 +2701,36 @@ function getTaxRate(t: number): number { /* ===================== 求职进度 ===================== */ +/** + * 求职进度的三个主计数。 + * + * 抽出来是因为同一个数原先在**一个对象里写了三遍**:`deliveryCount`、`stats.total`、 + * 以及时间轴文案里的「向 28 家企业投递简历」。三份各自可改,就出现了 6+2+2+20 = 30 ≠ 28 + * 这种「同一张卡上自己和自己对不上」。现在 `stats` 与时间轴都从这两个常量派生, + * 求和关系是**结构上的**,不靠谁记得回来对齐。 + */ +const PROGRESS_DELIVERY_COUNT = 28; +const PROGRESS_INTERVIEW_COUNT = 6; +const PROGRESS_OFFER_COUNT = 2; +const PROGRESS_PENDING_COUNT = 6; +const PROGRESS_INTERVIEWING_COUNT = 2; + /** 求职进度(完整版:统计 / Offer / 公司流程 / 月度统计) */ const MOCK_JOB_PROGRESS: JobProgress = { - deliveryCount: 28, - interviewCount: 6, - offerCount: 2, + deliveryCount: PROGRESS_DELIVERY_COUNT, + interviewCount: PROGRESS_INTERVIEW_COUNT, + offerCount: PROGRESS_OFFER_COUNT, + // ended 由其余四项推出,统计卡五个数字并排一行、用户心算即得,必须加起来等于 total stats: { - total: 28, - pending: 6, - interviewing: 2, - offer: 2, - ended: 20, + total: PROGRESS_DELIVERY_COUNT, + pending: PROGRESS_PENDING_COUNT, + interviewing: PROGRESS_INTERVIEWING_COUNT, + offer: PROGRESS_OFFER_COUNT, + ended: + PROGRESS_DELIVERY_COUNT - + PROGRESS_PENDING_COUNT - + PROGRESS_INTERVIEWING_COUNT - + PROGRESS_OFFER_COUNT, }, offers: [ { @@ -2650,20 +2792,41 @@ const MOCK_JOB_PROGRESS: JobProgress = { date: '08-20', }, ], + // 图表三列各自的和,都必须等于页顶的 deliveryCount / interviewCount / offerCount。 + // 上一轮只对齐了 offers 一列:deliveries 合计 52、interviews 合计 16,而卡片上写的是 28 / 6。 + // 图表只显示最近三个月(6/7/8 月),光这三个月投递就是 34,已超过「累计 28」。 + // 两个 Offer 的截止日是 8/31 与 9/2,故都落在 8 月。 monthlyStats: [ - { month: '3月', deliveries: 4, interviews: 1, offers: 0 }, - { month: '4月', deliveries: 6, interviews: 2, offers: 0 }, - { month: '5月', deliveries: 8, interviews: 2, offers: 1 }, - { month: '6月', deliveries: 10, interviews: 3, offers: 0 }, - { month: '7月', deliveries: 12, interviews: 4, offers: 1 }, - { month: '8月', deliveries: 12, interviews: 4, offers: 1 }, + { month: '3月', deliveries: 2, interviews: 0, offers: 0 }, + { month: '4月', deliveries: 3, interviews: 1, offers: 0 }, + { month: '5月', deliveries: 4, interviews: 1, offers: 0 }, + { month: '6月', deliveries: 5, interviews: 1, offers: 0 }, + { month: '7月', deliveries: 6, interviews: 1, offers: 0 }, + { month: '8月', deliveries: 8, interviews: 2, offers: 2 }, ], steps: [ - { id: 'p1', title: '已投递', desc: '向 28 家企业投递简历', status: 'done', date: '2026-08-01' }, + { + id: 'p1', + title: '已投递', + desc: `向 ${PROGRESS_DELIVERY_COUNT} 家企业投递简历`, + status: 'done', + date: '2026-08-01', + }, { id: 'p2', title: '简历被查看', desc: '12 家企业查看了你的简历', status: 'done', date: '2026-08-05' }, - { id: 'p3', title: '面试邀约', desc: '收到 6 场面试邀请', status: 'done', date: '2026-08-12' }, + { + id: 'p3', + title: '面试邀约', + desc: `收到 ${PROGRESS_INTERVIEW_COUNT} 场面试邀请`, + status: 'done', + date: '2026-08-12', + }, { id: 'p4', title: '面试进行中', desc: '字节跳动终面 / 腾讯二面待完成', status: 'active' }, - { id: 'p5', title: 'Offer 阶段', desc: '已收获 2 个 Offer,待决策', status: 'todo' }, + { + id: 'p5', + title: 'Offer 阶段', + desc: `已收获 ${PROGRESS_OFFER_COUNT} 个 Offer,待决策`, + status: 'todo', + }, { id: 'p6', title: '入职准备', desc: '背调 / 合同签订 / 入职清单', status: 'todo' }, ], }; @@ -3737,14 +3900,6 @@ const MOCK_ENTERPRISE_MANAGE_ACTIONS: EnterpriseQuickAction[] = [ { id: 'audit-report', label: '数据审计报告', icon: 'shield-checkmark-outline', route: '/audit-report', color: 'info' }, ]; -/** 企业总览统计 */ -const MOCK_ENTERPRISE_STATS: EnterpriseStat[] = [ - { id: 'jobs', label: '在招岗位', value: 1280, change: 12, icon: 'briefcase-outline', color: 'primary' }, - { id: 'interviews', label: '本月面试', value: 156, change: 8, icon: 'videocam-outline', color: 'info' }, - { id: 'reviews', label: '累计评价', value: 28, change: -3, icon: 'chatbubble-outline', color: 'success' }, - { id: 'appeals', label: '待处理申诉', value: 12, change: 2, icon: 'alert-circle-outline', color: 'action' }, -]; - /** 岗位评价摘要 */ const MOCK_JOB_EVALUATION_SUMMARIES: JobEvaluationSummary[] = [ { id: 'je1', positionName: '高级前端工程师', department: '技术部', score: 4.9, reviewCount: 42, change: 0.2 }, @@ -3753,17 +3908,47 @@ const MOCK_JOB_EVALUATION_SUMMARIES: JobEvaluationSummary[] = [ { id: 'je4', positionName: '后端开发工程师', department: '技术部', score: 4.5, reviewCount: 31, change: 0.3 }, ]; -/** 企业总览数据 */ -const MOCK_ENTERPRISE_OVERVIEW: EnterpriseOverview = { +/** + * 企业累计评价数 = 各岗位评价数之和。 + * + * 总览页的「累计评价」与「评价人数」、岗位评价管理页的「N 条评价」读的是同一个事实, + * 此前三处各写一个数(1280 / 28 / 128),同一屏上就自相矛盾。故由明细汇总,不再各写。 + */ +const ENTERPRISE_REVIEW_TOTAL = MOCK_JOB_EVALUATION_SUMMARIES.reduce( + (sum, item) => sum + item.reviewCount, + 0, +); + +/** + * 企业总览统计。 + * + * 「在招岗位」由岗位列表实时算出(见 `countActiveJobs`)—— 下架一个岗位,总览页与 + * 岗位管理页要同时减 1。此前总览写死 1280、岗位管理算出 8,同一家企业在两个页面对不上。 + * 其余三项依赖后端投递 / 面试 / 申诉数据,mock 阶段沿用固定值。 + */ +function buildEnterpriseStats(activeJobCount: number): EnterpriseStat[] { + return [ + { id: 'jobs', label: '在招岗位', value: activeJobCount, change: 12, icon: 'briefcase-outline', color: 'primary' }, + { id: 'interviews', label: '本月面试', value: 156, change: 8, icon: 'videocam-outline', color: 'info' }, + { id: 'reviews', label: '累计评价', value: ENTERPRISE_REVIEW_TOTAL, change: -3, icon: 'chatbubble-outline', color: 'success' }, + { id: 'appeals', label: '待处理申诉', value: 12, change: 2, icon: 'alert-circle-outline', color: 'action' }, + ]; +} + +/** + * 企业总览数据(`stats` 由 `fetchEnterpriseOverview` 现算后拼上,故此处不含)。 + * 企业名取 `ENTERPRISE_DISPLAY_NAME`:此前总览写全称「北京智联科技有限公司」、 + * 其余三页写简称「智联科技」,同一家企业两个名字,改一个另一个不知道。 + */ +const MOCK_ENTERPRISE_OVERVIEW: Omit = { id: 'ent-1', - name: '北京智联科技有限公司', + name: ENTERPRISE_DISPLAY_NAME, logo: '智', industry: '互联网/IT', scale: '1000-9999人', stage: 'B轮', overallScore: 4.8, - reviewCount: 1280, - stats: MOCK_ENTERPRISE_STATS, + reviewCount: ENTERPRISE_REVIEW_TOTAL, jobEvaluations: MOCK_JOB_EVALUATION_SUMMARIES, quickActions: MOCK_ENTERPRISE_QUICK_ACTIONS, manageActions: MOCK_ENTERPRISE_MANAGE_ACTIONS, @@ -3778,7 +3963,9 @@ const MOCK_ENTERPRISE_OVERVIEW: EnterpriseOverview = { */ export async function fetchEnterpriseOverview(): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/overview`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_ENTERPRISE_OVERVIEW); + // 「在招岗位」与岗位管理页取同一个数:都从(含覆盖的)岗位列表实时算出 + const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS); + return mockRequest({ ...MOCK_ENTERPRISE_OVERVIEW, stats: buildEnterpriseStats(countActiveJobs(jobs)) }); } /* ===================== 发布岗位 ===================== */ @@ -3908,7 +4095,7 @@ const MOCK_JOB_REVIEWS: JobReview[] = [ const MOCK_JOB_EVALUATION_MANAGEMENT: JobEvaluationManagement = { overallScore: 4.8, change: 0.1, - reviewCount: 128, + reviewCount: ENTERPRISE_REVIEW_TOTAL, positiveRate: 85, replyRate: 72, complaintRate: 8, @@ -4069,15 +4256,21 @@ const MOCK_CANDIDATE_DETAIL: CandidateDetail = { invited: false, }; -/** 候选人身份覆盖(mock 阶段按 id 回填姓名/年龄/城市等基本信息,避免不同候选人显示同一份资料) */ -const CANDIDATE_IDENTITY_OVERRIDE: Record< - string, - { name: string; age: number; city: string; matchPercent: number; overallScore: number; percentile: number } -> = { - 'cand-1': { name: '张明远', age: 28, city: '北京', matchPercent: 96, overallScore: 88, percentile: 99 }, - 'cand-2': { name: '李雨桐', age: 26, city: '北京', matchPercent: 82, overallScore: 75, percentile: 75 }, - 'cand-3': { name: '王建国', age: 33, city: '北京', matchPercent: 91, overallScore: 92, percentile: 92 }, -}; +/** + * 候选人身份:直接取自人才推荐列表 `MOCK_TALENT_CANDIDATES`。 + * + * 这里原先另存了一份覆盖表,且**只写了 cand-1 / cand-2 / cand-3**,取不到时兜底到 cand-1。 + * 后果是:列表里点 cand-4「陈思远 · 匹配 94%」的「查看详情」,详情页显示的是张明远的 + * 姓名 / 年龄 / 匹配度 / 百分位(cand-5 ~ cand-8 同样)—— 连人是谁都对不上。 + * 列表才是这份资料的明细来源,改为按 id 直接取它。 + * + * 取不到时仍兜底到第一条:详情页存在「不带 id 的独立入口」,那时需要一个能渲染的占位。 + */ +function candidateIdentityOf(candidateId: string): TalentCandidate { + return ( + MOCK_TALENT_CANDIDATES.find((item) => item.id === candidateId) ?? MOCK_TALENT_CANDIDATES[0] + ); +} /** * 获取候选人详情 @@ -4088,19 +4281,18 @@ const CANDIDATE_IDENTITY_OVERRIDE: Record< */ export async function fetchCandidateDetail(candidateId: string): Promise { // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/candidates/${candidateId}`, { method: 'GET' }); return res.json(); - const override = - CANDIDATE_IDENTITY_OVERRIDE[candidateId] ?? CANDIDATE_IDENTITY_OVERRIDE['cand-1']; + const identity = candidateIdentityOf(candidateId); const detail: CandidateDetail = { ...MOCK_CANDIDATE_DETAIL, id: candidateId, - matchPercent: override.matchPercent, - overallScore: override.overallScore, - percentile: override.percentile, + matchPercent: identity.matchPercent, + overallScore: identity.overallScore, + percentile: identity.percentile, basicInfo: { ...MOCK_CANDIDATE_DETAIL.basicInfo, - name: override.name, - age: override.age, - city: override.city, + name: identity.name, + age: identity.age, + city: identity.city, }, }; return mockRequest(detail); @@ -4931,13 +5123,21 @@ async function applyJobManagementOverrides( })); } +/** + * 在招岗位数(`status === 'active'`)。 + * 岗位管理页顶部概览与企业总览共用 —— 两处各算一次就会各有一个数。 + */ +function countActiveJobs(jobs: JobManagementJob[]): number { + return jobs.filter((job) => job.status === 'active').length; +} + /** * 顶部数据概览。 * 「在招岗位」由列表实时算出 —— 下架一个岗位,这里要跟着减 1,不能是写死的 12。 * 其余三项依赖后端投递/面试数据,暂沿用 mock 文案。 */ function buildJobManagementStats(jobs: JobManagementJob[]): JobManagement['stats'] { - const activeCount = jobs.filter((job) => job.status === 'active').length; + const activeCount = countActiveJobs(jobs); return [ { id: 'active', @@ -5003,7 +5203,7 @@ export async function refreshJob(jobId: string): Promise { async function buildJobManagement(): Promise { const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS); return { - companyName: '智联科技', + companyName: ENTERPRISE_DISPLAY_NAME, hasData: true, stats: buildJobManagementStats(jobs), tabs: buildJobManagementTabs(jobs), @@ -5245,7 +5445,7 @@ const MOCK_TALENT_CANDIDATES: TalentCandidate[] = [ ]; const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = { - companyName: '智联科技', + companyName: ENTERPRISE_DISPLAY_NAME, hasData: true, todayCount: MOCK_TALENT_CANDIDATES.length, tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES), @@ -5402,7 +5602,10 @@ function buildPendingTasks(chats: EnterpriseChatItem[]): PendingTask[] { { id: 'reply', label: '待回复', - count: chats.filter((c) => c.unreadCount > 0).length, + // 这张卡片点下去会切到「未回复」Tab(见下方 tabKey),所以数字必须与那个 Tab 的计数 + // 同源,否则点进去只会看到条数凭空变少。原先统计的是「有未读消息的会话数」, + // 与「未回复」(status === 'pending')根本不是同一个集合。 + count: chats.filter((c) => c.status === 'pending').length, hint: '24小时内未回复', icon: 'chatbubble-ellipses-outline', color: 'action', @@ -5435,7 +5638,7 @@ function buildCommunicationTabs(chats: EnterpriseChatItem[]): EnterpriseCommunic } const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = { - companyName: '智联科技', + companyName: ENTERPRISE_DISPLAY_NAME, hasData: true, pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS), tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS), diff --git a/src/constants/index.ts b/src/constants/index.ts index 5b05e18..319924c 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -38,9 +38,28 @@ export const EXPERIENCE_OPTIONS: string[] = ['不限', '应届生', '1-3年', '3 /** 学历要求筛选选项 */ export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕士', '博士']; -/** 热门城市(定位/城市选择用) */ +/** + * 岗位筛选用的城市列表(都有 mock 岗位与社保基数数据)。 + * 注意与 `EXPECTED_CITY_OPTIONS` 的区别:这一份是**筛选项**,城市得有对应数据才有意义; + * 期望城市是用户想去的任何地方,故那份更长。两者用途不同,不合并。 + */ export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; +/** + * 期望城市可选列表(引导 P5 与「设置求职意向」页共用同一份)。 + * + * 此前两处各写一份:引导 16 城、求职意向页 10 城。而求职意向页的 chip 是严格相等匹配 + * (`opt === value`),用户在引导里选了天津 / 重庆 / 青岛 / 长沙 / 郑州 / 东莞后, + * 这 6 个城市一个都命中不了 —— 刚填完的资料进来看着像丢了。故收敛为一份。 + */ +export const EXPECTED_CITY_OPTIONS: string[] = [ + '北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', + '西安', '苏州', '天津', '重庆', '青岛', '长沙', '郑州', '东莞', +]; + +/** 期望城市中的「热门城市」(引导 P5 置顶展示) */ +export const HOT_CITY_OPTIONS: string[] = ['北京', '上海', '广州', '深圳', '杭州', '成都']; + /** * 城市社保/公积金缴费基数上下限(mock 数值,真实接入后端按城市下发)。 * 养老 8% / 医疗 2% / 失业 0.5% 全国一致,城市差异体现在基数上下限,故按城市 clamp 基数即可反映差异。 @@ -153,6 +172,16 @@ export const UNSET_LABEL = '未设置'; /** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */ export const PREFERENCE_DEFAULT_WORK_TYPE = '全职'; +/** + * 企业端演示企业名(mock 阶段)。 + * + * 页面在加载 / 错误态拿不到数据、又需要一个标题,此前 6 处各写一遍字面量, + * 数据层再写 3 遍,且总览页写的是全称「北京智联科技有限公司」—— + * 同一家企业出现两个名字,改一个另一个不知道。故声明一次。 + * 接入真实后端后,企业名一律从接口下发,本常量随之删除。 + */ +export const ENTERPRISE_DISPLAY_NAME = '智联科技'; + /* ===================== 期望月薪(P7)可选阶梯 ===================== */ /** @@ -169,6 +198,26 @@ export const SALARY_MAX_OPTIONS: string[] = [ '8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', '60K', ]; +/** + * 期望薪资快捷档(「设置求职意向」页的 chip)。 + * + * 写法与引导 P7 保持一致:`最低K-最高K`。此前该页写的是 `10-20K`(最低值不带 K), + * 而引导产出的是 `10K-20K`(`deriveJobPreference` 由 P7 两个阶梯拼出), + * 严格相等匹配下没有一个 chip 命中 —— 用户会以为薪资没保存。 + * + * 这是**快捷入口**而非取值范围:P7 的阶梯能拼出本列表覆盖不到的组合(如 `8K-12K`), + * 页面用 `withCurrentOption` 把用户的真实值补进去展示,保证「存了什么就显示什么」。 + */ +export const EXPECTED_SALARY_OPTIONS: string[] = [ + '不限', '5K-10K', '10K-20K', '20K-30K', '30K-50K', '50K以上', +]; + +/** + * 工作类型选项(「设置求职意向」页)。 + * `PREFERENCE_DEFAULT_WORK_TYPE` 必须取自本列表,否则默认值会是个选不中的值。 + */ +export const WORK_TYPE_OPTIONS: string[] = ['全职', '兼职', '实习', '远程']; + /* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */ /** diff --git a/src/theme/colors.ts b/src/theme/colors.ts index c37d81b..688c80e 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -114,6 +114,22 @@ export const colors = { focusRing: '#1F6FDB', /** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */ scrim: 'rgba(0, 0, 0, 0.35)', + + /* ---- 品牌色 / 深色底上的半透明白(图形与描边用) ---- */ + /** + * 这一组此前没有 token,值以字面量散落在 5 个页面里。之所以必须收进来: + * eslint 的硬编码颜色规则只匹配纯 hex,**拦不住 `rgba(...)`** —— + * 同一个遮罩角色因此在不同页面漂移成了不同的透明度(详见 4.14)。 + * 下面三个值是按**角色**分的,不是按强弱随手取的: + * · surface —— 品牌色底上的图标容器 / 卡片托底(48~64px 圆形图标底) + * · weak —— 同一角色但更轻的一档(36px 导航类小圆钮) + * · line —— 品牌色底上的分隔线 + * · track —— 品牌色底上的进度轨道(比 surface 重一档,否则细轨道看不出来) + */ + onBrandSurface: 'rgba(255, 255, 255, 0.2)', + onBrandSurfaceWeak: 'rgba(255, 255, 255, 0.15)', + onBrandLine: 'rgba(255, 255, 255, 0.2)', + onBrandTrack: 'rgba(255, 255, 255, 0.35)', } as const; /** diff --git a/src/utils/index.ts b/src/utils/index.ts index b8bb27e..27e19db 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -60,6 +60,39 @@ export function formatViews(views: number): string { return String(views); } +/** + * 紧凑计数(看板 / 统计卡用):2300 → "2.3k";不足一千返回原数字字符串。 + * + * 与 `formatViews` 是**同一概念在两种场景下的口径**,不是重复实现: + * 看板一行要并排多个数字,用 k 更省横向空间;内容页(阅读量)用「万」,中文用户更熟。 + * 之所以收进 utils:企业端「岗位管理」原先在页面里内联了这段三目,于是 `formatCompact`、 + * `formatViews`、`formatThousands` 三套写法各自躺在页面里,改口径时数不清有几处要改。 + * + * @remark 小数位保持与原内联写法一致(2000 → "2.0k"),以做到零视觉变化。 + * `formatViews` / `formatBytes` 会去掉尾部的 ".0",本函数不去 —— 这是刻意的差异, + * 不在这里「顺手统一」,避免动到看板现在的显示。 + */ +export function formatCompact(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} + +/** + * 把 `current` 补进选项列表:已在列表中、或 `current` 为空串时原样返回。 + * + * 用途:chip 组用严格相等(`opt === value`)判断选中,一旦存进去的值不在选项列表里, + * 用户看到的就是「一个都没选中」——像资料没保存。引导采集的值与页面的快捷档并非同一集合 + * (如 P7 两个阶梯能拼出 `8K-12K`,而快捷档里只有整数档),故需要兜住「值不在列表里」。 + * + * 插在首位(通常是「不限」这类兜底项)之后,让用户的真实值紧跟其后可见, + * 不淹没在长列表尾部。 + */ +export function withCurrentOption(options: readonly string[], current: string): string[] { + if (current === '' || options.includes(current)) return [...options]; + const [head, ...rest] = options; + if (head === undefined) return [current]; + return [head, current, ...rest]; +} + /** * 引导流程进度文案:「第 3 / 16 步」。 * diff --git a/项目开发状态.md b/项目开发状态.md index 2cbf683..0d02c57 100644 --- a/项目开发状态.md +++ b/项目开发状态.md @@ -1,11 +1,13 @@ # 有职 App 开发进度与规划文档 > 生成日期:2026-09-10 -> 最后更新: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.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 处无效、302 例测试全绿),无需修正阻塞错误。 +> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、假交互 0 处、图标名 0 处无效、**339 例测试全绿**),无需修正阻塞错误。 > > **读这份文档请先注意一条**:4.8 曾写「死控件清零」并据此把 P2 标记为已完成,**那个结论当时是错的** —— 审计脚本只查「无处理函数 / 空处理函数」,查不到「有处理函数但点下去只弹一句『功能开发中』」。详见 4.9 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。 @@ -21,7 +23,7 @@ | 假交互审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-18 复跑,**口径已含弹窗式假交互**,见 4.9)
⚠️ 另有 **3 处知情保留的后端待办**(`--pending` 可列出,非疏漏) | | 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ **140** 个接口函数全部留有注释版真实请求(见 4.6) | | 图标名审计 | `node scripts/check-icons.mjs` | ✅ **0 处无效图标名**(2026-09-18 新增门槛,修掉 `gavel-outline` 后建立,见 4.13) | -| 单元测试 | `npm test` | ✅ **23 个文件 302 例全绿**(2026-09-18) | +| 单元测试 | `npm test` | ✅ **25 个文件 339 例全绿**(2026-09-20) | | 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | | 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | @@ -108,8 +110,10 @@ - **缺陷修复**:`navigate()` 去重压栈(修 P16 ↔ P17 往返后返回键错乱);P3「退出登录」补 `useAuth().logout()`(原先只清流程 state,token 未清,退出后重进仍被判为已登录);B3 企业认证重做(营业执照可点可传、联系人不再写进 `data.school`、提交走 `submitEnterpriseCertification`、去掉本地 `useState` 翻出的假「等待审核」态与开发口径文案);P21 头像接入 `expo-image-picker`(**新增依赖**)后拍照 / 相册 / 5 个预设头像各自生效。 - **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。 - **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息) - ⚠️ **2026-09-18 更正**:号码后来确实填了(`15154608969`),但**没有任何代码读这两个常量** —— - 所谓「填入即自动出现」并不成立,缺的是页脚组件本身。见 4.10 第 7 条。;「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 + **更正(2026-09-20)**:4.10 第 7 条曾据一次漏看的 grep 断言「没有任何代码读这两个常量、 + 缺的是页脚组件本身」。**该断言是错的** —— `SupportFooter.tsx` 于 2026-09-17(`00522d5`)已存在, + 早于该断言一天,且已挂在 P1Login / P2QuickLogin 上。**「填入即自动出现」这句是成立的。** + 见 4.14 第 7 条。;「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 - **删 P23 假首页**:`P23Home` 是写死上海 4 条职位的演示页,与真 `(tabs)/jobs` 重复,且含两颗绑同一回调的假权限弹窗;删除后 P22 与 P6 / P16 的快速通道由 `useRegisterFinish` 直接 `replace` 进真主界面。同步清理悬空接口 `saveAndEnter` / `canGoBack` / `meta`(`meta` 恒为 `{time:'',battery:0}`,21 个屏仍在传参、`ScreenShell` 收到即丢弃)与插画占位上的素材说明文字(`Illustration` 在 `source` 为 null 时不再渲染 `hint` —— 它会把「手持 OFFER 职场男性插画」这类开发备注直接画在用户界面上)。 - **测试**:新增 `job-match.test.ts`(14 例,锁定匹配规则与「显式筛选不叠加偏好」边界)、`salary-options.test.ts`(10 例)、`onboarding-profile.test.ts`(映射往返 + 字段漂移检测)、`resume-profile.test.ts`(12 例:采集齐全逐项映射、一路跳过时各板块为空、离职不写「至今」、求职意向读显式编辑过的偏好、AI 优化后仍是本人简历、「不再出现演示人设」字符串扫描)、`user-profile.test.ts`(6 例:姓名/头像字/简介映射、一路跳过时不显示演示人设、头像原始值按预设 key / 本地 URI 两类透传)、`onboardingStepLabel`(3 例)。`npm test` **97 例全绿**;`tsc` / `eslint` / `check-routes`(67 路由 / 0 失效跳转 / 0 孤立页)均 0 问题。 - **待验证 / 待补**:`expo-image-picker` 的原生权限文案(`app.config.js` 的 `photosPermission` / `cameraPermission`,并显式关掉插件默认申请的麦克风权限)**只在出包时生效**,Expo Go 用的是它自己的 `Info.plist`,需真机出包验证;手工路径(真机走完 P1→P22 →「我的」页偏好与所填一致 → `/jobs` 偏好岗位上浮;引导途中杀 App 重进数据仍在)**尚未在设备上执行**。 @@ -411,12 +415,19 @@ if (link.route) { router.push(link.route); return; } **实测中发现并修掉一个布局缺陷**:证照信息行原写死 `infoLabel: { width: 96 }`,「统一社会信用代码」八个字放不下、**折成两行**,行高被撑开、右侧「待补充」位置错乱。改为按内容撑开(`flexShrink: 0`)+ `minWidth: 96` 让短标签左对齐。**这个缺陷只有截图才看得出来,代码走查发现不了。** -#### 7. 另一处「文档与代码不符」(本批次发现,尚未处理) +#### 7. ~~另一处「文档与代码不符」~~ **(结论有误,2026-09-20 撤回)** + +> **撤回说明(4.14 第 7 条)**:本条原断言「`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 全仓 grep 无任何 +> 消费者,缺的是页脚组件本身」。**该断言经复核为错误**:`components/onboarding/ui/SupportFooter.tsx` +> 于 2026-09-17(`00522d5`)已创建 —— **早于本条一天** —— 它读这两个常量、号码非空才渲染、支持 +> 点击拨号,并已挂在 `P1Login.tsx:67` 与 `P2QuickLogin.tsx:130`。号码已填,故**页脚是渲染出来的**。 +> 原「文档与代码不符」的其实是本条自己:一次没查全的 grep 得出的结论。 +> 以下保留原文,仅作错误留档,**不要按它去「补页脚」**(页脚已存在,那样会做出第二个)。 文档第 335 行①称合规页脚「已填 `15154608969`……**填入即自动出现并接 `tel:` 拨号**」。 -实测:`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 在 `src/constants/index.ts` 有定义,但**全仓 grep 无任何消费者** —— 没有一处代码读它,所以**永远不会有页脚出现**。 +~~实测:`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 在 `src/constants/index.ts` 有定义,但全仓 grep 无任何消费者 —— 没有一处代码读它,所以永远不会有页脚出现。~~ -即那句「填入即自动出现」目前是不成立的:缺的不是号码,是**页脚组件本身**。 +~~即那句「填入即自动出现」目前是不成立的:缺的不是号码,是页脚组件本身。~~ 要么补一个合规页脚(需产品定位置),要么把该条描述改回如实。**不擅自新建页脚**(涉及放哪一页、什么形态,属产品决策)。 #### 8. 提交约定补了一条:**提交之后必须推送** @@ -819,12 +830,313 @@ app/settings.tsx ← hook(useAppCache) ← api(fetchCacheSize) ← lib(readCa --- +### 4.14 求职意向词表统一 + 四处数据自相矛盾(2026-09-20 批次) + +#### 1. 起点:一次「找同类缺陷」的排查,不是一次功能开发 + +4.13 把「数据层引用外部词表」的三类(图标名 / 路由 / 颜色)排查完了。本轮换了个方向问: +**这个项目反复吃亏的那类缺陷,还有哪些没被扫过?** 已发生过的先例是「**同一个业务事实被写了两份, +且两处取值不同**」——霍兰德参与人数(124,580 / 12,846)、运营主体公司名、客服电话。 +沿这条线扫全仓,找到两组问题,均已修复并实测。 + +#### 2. 缺陷一:引导采集的求职意向,在「设置求职意向」页一个 chip 都选不中 + +**这是本轮最严重的一条,且每个走完引导的用户都会撞上。** + +`resolveJobPreference()`(`api.ts`)在用户未显式保存过偏好时,走 `deriveJobPreference(profile)` +从引导资料推导;而 `app/job-preference.tsx` 的 chip 用 `opt === value` 严格相等判断选中。 +问题在于**两处各有一份选项词表,且三组都匹配不上**: + +| 字段 | 引导产出(`api.ts`) | 该页选项(改前) | 后果 | +|---|---|---|---| +| 期望薪资 | `10K-20K`(`SALARY_MIN_OPTIONS` 阶梯带 K) | `10-20K`(最低值不带 K) | 无 chip 选中 | +| 求职状态 | `离职-随时到岗` 等 4 项(`JOB_STATUS_OPTIONS`) | `积极求职中` 等 3 项,**零重合** | 无 chip 选中 | +| 期望城市 | P5 的 16 城 | 该页 10 城 | 天津 / 重庆 / 青岛 / 长沙 / 郑州 / 东莞 **6 城**无选中 | + +用户刚在引导里填完,进来看到「期望职位」(纯文本输入)有值、其余三组全空 —— 像是资料丢了。 + +**并且这不只是显示问题。** 简历求职意向的积极 / 不积极判定是: + +```ts +status: preference.status === JOB_STATUS_INACTIVE ? 'inactive' : 'active' +``` + +而 `JOB_STATUS_INACTIVE` 是引导词表的 `'在职-暂不考虑'`。用户在求职意向页选它自己的 +「在职暂不考虑」(没有那两个短横)并保存后,覆盖值写进 `user:job-preference`, +**与判定常量不是同一个字符串,比较恒为 false —— 简历仍把他记成积极求职中。** + +#### 3. 修法:三组选项表下沉到 `src/constants`,两页共用一份 + +按 AGENTS.md「同一个事实只有一份来源」处理,**不是把两处数字调成一样**: + +| 新增常量 | 说明 | +|---|---| +| `EXPECTED_CITY_OPTIONS` | 期望城市 16 城;`P5City` 与求职意向页共用(原两处各一份) | +| `HOT_CITY_OPTIONS` | P5 置顶的热门城市 | +| `EXPECTED_SALARY_OPTIONS` | 快捷档,**写法与引导 P7 一致**(`最低K-最高K`) | +| `WORK_TYPE_OPTIONS` | 工作类型;顺带由测试锁住「`PREFERENCE_DEFAULT_WORK_TYPE` 必须取自本列表」 | + +薪资那一组有个绕不开的点:**P7 是两个独立阶梯(最低 / 最高各 9 档),能拼出快捷档覆盖不到的 +组合**(如 `8K-12K`、`10K-15K`)。所以不可能靠「把档位写全」解决。新增纯函数 +`withCurrentOption(options, current)`(`src/utils`):当前值不在列表里时插在首位(通常是「不限」) +之后并选中,保证**存了什么就显示什么**。空串(未设置)不补 —— 「未设置」不该变成一个可选项。 + +`CITIES`(8 城)**没有**合并进来:它是岗位筛选用的列表,城市得有 mock 岗位与社保基数才有意义, +与「用户想去的城市」不是一回事。已在注释里写明两者的区别,避免后来者顺手合并。 + +#### 4. 实测(Android 模拟器 `emulator-5554`,非代码走查) + +该机恰有一份引导资料,构成了完美的复现样本:期望职位「运维工程师」、期望城市「北京」、 +**期望薪资「10K-15K」**(不在快捷档里)、**求职状态「离职-随时到岗」**(旧词表完全没有这一项)。 + +「我的」页 → 求职偏好 → 编辑,改前四组中三组会全空;改后实测: + +| 组 | 实测结果 | +|---|---| +| 期望城市 | 16 城全部渲染,**「北京」选中**(改前无选中) | +| 期望薪资 | 快捷档已是 `5K-10K` / `10K-20K` / `20K-30K` / `30K-50K` 写法;**用户的 `10K-15K` 被补在「不限」之后并选中** —— 这条同时证明了 `withCurrentOption` 在真机上生效,而不是只过了单测 | +| 工作类型 | 「全职」选中 | +| 求职状态 | 词表已换成 `离职-随时到岗` / `在职-月内到岗` / `在职-考虑机会` / `在职-暂不考虑`,**「离职-随时到岗」选中**(改前无选中) | + +#### 5. 缺陷二:四处「同一个事实两个数」 + +| 事实 | 改前 | 改后 | 依据 | +|---|---|---|---| +| 当前用户的 Offer 数 | 我的页 **3**;求职进度页 **2** | **2** | `MOCK_JOB_PROGRESS` 内部即有 4 处指向 2(`offerCount` / `stats.offer` / `offers` 数组实为 2 条 / 时间轴「已收获 2 个」),3 是孤例 | +| 求职进度「每月 Offer 数」之和 | 3(5/7/8 月各 1) | **2** | 与同对象的 `offerCount` 自相矛盾,等于把本页结论自己推翻一半 | +| 企业端「在招岗位」 | 总览写死 **1280**;岗位管理由列表算出 **8** | 两处均 **8** | `buildJobManagementStats` 的注释本就写着「不能是写死的 12」;改由 `countActiveJobs()` 一处计算,两边共用 | +| 企业端「评价数」 | 总览「评价人数」**1280**、同屏四宫格「累计评价」**28**、岗位评价管理 **128** | 三处均 **136** | 由 `MOCK_JOB_EVALUATION_SUMMARIES` 的 `reviewCount` 汇总(42+38+25+31)—— 明细才是单一来源,三个总数都是派生值 | +| 企业名 | 总览「北京智联科技有限公司」;其余 3 页「智联科技」 | 均 **智联科技**(`ENTERPRISE_DISPLAY_NAME`) | 数据层没有「全称 / 简称」字段,这两个是同一事实的两个值,不是刻意的简称设计 | + +企业名的 6 处字面量此前**写在页面里**(3 个企业端页面的加载 / 错误态 `Header`,因为那时拿不到数据), +属「页面写死业务数据」。现统一读常量,全仓该字符串只剩 1 处。 + +**实测确认**:企业总览页「评价人数 136 / 累计评价 136 / 在招岗位 8」,同屏不再自相矛盾; +岗位管理页「招聘中(8)」与总览页的 8 一致,企业名两页均为「智联科技」。 + +#### 6. 回归锁:15 例测试 + 两处灵敏度验证 + +新增 `src/__tests__/job-preference-options.test.ts`(**15 例**),分四组: + +1. **可表征性**:P7 两个阶梯的**任意组合**(9×9)拼出的字符串,经 `withCurrentOption` 后都能被选中; + 引导的四个求职状态、16 个城市逐个能选中;`PREFERENCE_DEFAULT_WORK_TYPE` 必须在 `WORK_TYPE_OPTIONS` 内。 +2. **端到端**:写入一份引导资料(城市「天津」、薪资 `10K`/`20K`、状态「在职-月内到岗」—— + 三项都是改前会落空的取值),`fetchJobPreference()` 的产物逐项可选中。 +3. **简历判定**:在求职意向页选 `JOB_STATUS_INACTIVE` 并保存,`fetchResume().jobIntention.status` + 必须是 `inactive`;选其余三项必须是 `active`。**这条锁的就是上面那个「选了暂不考虑仍被记成积极求职」的缺口。** +4. **接线锁**:直接读源码,断言 `app/job-preference.tsx` 从 constants 取四组选项、且不再声明 + 本地选项数组;`P5City.tsx` 同理。 + +> 第 4 组是必要的:前三组锁的是「**常量之间**自洽」,而原缺陷的形态是**页面自己又写了一份** —— +> 常量没毛病、页面不读它,照样对不上。故补一条源码级断言锁住接线。 +> **老实标出它的边界**:这是源码级断言,不是渲染断言(本项目 25 个测试文件均为纯逻辑测试, +> 未引入组件测试)。它能拦住「改回本地字面量」,拦不住「读了常量后又做一层转换」。 + +**灵敏度验证(只跑通不算数,会失败的断言才是断言)**:临时注入两处缺陷 —— +① 把 `EXPECTED_SALARY_OPTIONS` 的 `10K-20K` 改回 `10-20K`;② 把求职意向页改回本地 +选项数组 + 使用本地 `CITIES`。重跑:**2 例如期失败**(「常见组合直接命中快捷档」与「接线锁」), +其余 13 例仍通过。随后 `cp` 还原并 `cmp` 逐字节比对确认与注入前一致,grep 无残留。 + +#### 7. 更正 4.10 第 7 条的一处**反向**错误:页脚一直是存在的 + +4.10 第 7 条与「五、P0(补充)」①都写着: + +> `SUPPORT_HOTLINE` / `SUPERVISION_PHONE` …**全仓 grep 无任何消费者** … 缺的是**页脚组件本身** + +**这个结论是错的,且是朝「把已完成的当成未完成」的方向错。** 实际: +`components/onboarding/ui/SupportFooter.tsx` **在 2026-09-17(`00522d5`)就已存在** —— +比 4.10 写下这句话(09-18)早一天。该组件读这两个常量、号码非空才渲染、支持点击拨号, +并已挂在 `P1Login.tsx:67` 与 `P2QuickLogin.tsx:130` 两处。号码已填,故页脚**是渲染出来的**。 + +也就是说:**「填入即自动出现」这句话成立**,4.10 那条 ⚠️ 建立在一次漏看的 grep 上。 +本批次已把这两处描述改回如实。 + +> 这类错误和本文件反复强调的那类(把没验的当成验了)是同一枚硬币的两面: +> **一次没查全的 grep,和一次没做的验证一样,都会让文档失真。** + +#### 8. 本批次门槛复跑(2026-09-20 实测) + +`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` **24 个文件 317 例全绿** +(较 4.13 的 302 例 +15) · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · +`check:icons` **0 处无效图标名** · `check-backend-stubs` **140 个接口函数**。 + +#### 9. 本批次仍未做 + +- **`CITIES`(8 城)与 `EXPECTED_CITY_OPTIONS`(16 城)仍是两份**,见第 3 条 —— + 这是**有意的**,不是漏改。若产品要求两处一致,需先给这 6 个城市补 mock 岗位与社保基数。 +- ~~**`formatViews` 与薪资拼接未下沉**~~ → **(2026-09-20 已做一半,见 4.15)** + `job-management.tsx` 内联的 `job.views >= 1000 ? ...k : job.views` 已下沉为 `formatCompact` + (**刻意保持 `2.0k` 带 `.0` 的现状口径**,与 `formatViews` 的「万」并存,理由是两种场景的 + 可读性取舍不同,不是漏统一);`app/holland.tsx` 的 `toLocaleString()` 也换成了 `formatThousands`, + 与成长页同源(Hermes 无完整 ICU 时 `toLocaleString()` 可能不加千分位,两页会显示成两个样子)。 + **仍未做**:职位详情与列表页各自拼 `{salaryMin}-{salaryMax}K`,无 `formatSalaryRange`。 + 改动面涉及薪资在 4 个页面的展示口径,需产品确认是否需要统一。**列出以待下轮,不当作已完成。** +- **`app/resume.tsx:1086` 的 `rgba(0,0,0,0.4)`**:`src/theme/colors.ts` 已有同用途的 `colors.scrim` + (10+ 处已正确使用),此处是重复造值。 + ~~另 5 处 `rgba(255,255,255,x)` 属**缺失 token**。~~ + **(2026-09-20 更正,见 4.15)** 「5 处 = 缺一个 token」这个判断下得太浅:逐处看下来它们 + **不是同一个 token 被写重 5 次,而是 4 个不同的角色**(进度条轨道 / 卡片底 / 分割线 / 弱底色), + 收敛成 1 个会把这几个原本有区别的用法抹平。已按角色拆成 4 个 token。 + ~~eslint 的 `no-hardcoded-hex-color` 规则只匹配纯 hex,拦不住 `rgba(`。~~ + **(2026-09-20 更正)** 这条成立,且**确实是漏洞**,已在 4.15 补上并做注入验证。 +- 4.10 第 10 条列出的外部依赖项(资质证照真实内容 / EAS 占位符 / 隐私政策【待确认】)均**无变化**。 + +--- + +### 4.15 数据自相矛盾批量收敛(第二组)+ 半透明色 token 化 + lint 补盲区(2026-09-20 批次,接 4.14) + +#### 1. 起点:4.14 结案后,把「找同类缺陷」继续做完 + +4.14 只覆盖了**排查到的一半**。把剩下的一半做完后,又查出 **10 组**「同一个事实两个数」, +其中多数是**跨页面 / 跨层级**的(列表卡与详情页、我的页与求职进度页、统计卡与它正下方的列表), +比 4.14 那批更隐蔽 —— 因为它们不在同一屏上,肉眼翻页时很难对照。 + +#### 2. 修法:一律从权威源**派生**,不做两个字面量的手工对齐 + +这 10 组如果只是「把 A 改成和 B 一样」,下次改数据照样分叉。故修法统一为: +**让重复的那一份在接口层由权威源算出来**,使矛盾在结构上无法复现。 + +| # | 事实 | 改前两处取值 | 权威源与派生方式 | +|---|---|---|---| +| 1 | 求职进度投递总数 | `stats.total` 28 / 时间轴文案「向 28 家」/ `deliveryCount` 28,但 `stats` 五项之和 = 6+2+2+20 = **30** | `ended` 由其余四项**相减**得出,求和关系成为结构性的 | +| 2 | 月度图表三列 | 投递合计 **52**、面试合计 **16**,卡片写 28 / 6 | 六个月的三个数重排为 2+3+4+5+6+8=28 / 0+1+1+1+1+2=6 / 0+0+0+0+0+2=2 | +| 3 | 我的页收藏数 | 徽标 **12** / 收藏列表 **3** | `favorites: MOCK_FAVORITES.length` | +| 4 | 我的页勋章 | 「投递满100次」显示**已获得**,而实际只投了 28 次 | `obtained`/`progress` 由 `buildUserStats()` 派生 | +| 5 | 匹配成功横幅 | 横幅写 **3** 家 / 会话列表 `matched: true` 只有 **2** 条 | `total` 由 `MOCK_CONVERSATIONS.filter(matched)` 派生 | +| 6 | 岗位详情综合分 | 恒 **92 分·优秀**(`void id;`),9 个岗位里只有第 1 个对得上 | `jobScoreById(id)`,雷达图八维按该岗位实际分**等比缩放** | +| 7 | 对比页「综合评分」 | 产品经理 **85** / UI 设计师 **78**,与列表里的 **78 / 85** 正好互换 | 「综合评分」这一维改由 `jobScoreById` 取 | +| 8 | 候选人详情身份 | 4–8 号候选人点进去**全叫「张明远」** | `candidateIdentityOf(id)` 取推荐列表里的那一条 | +| 9 | 岗位 1 的公司所在地 | 列表卡 **海淀区** / 详情页 **朝阳区** | 撤掉详情页对该字段的覆盖,列表成为唯一来源 | +| 10 | 待回复卡片 | 卡片写 **3** 人,但它点下去切到的「未回复」Tab 只有 **2** 条 | 改用 `status === 'pending'`,与去向同源 | + +第 10 项值得单独说:原先统计的是「**有未读消息**的会话数」,而它跳转的目标 Tab 是 +「**未回复**」。两个集合本来就不同,于是点进去条数凭空变少。**判据应是「与去向一致」, +不是「某个内部集合的长度」** —— 这条也写进了测试注释。 + +#### 3. 顺带修掉一处同屏矛盾:求职进度页的标题 + +`app/progress.tsx` 的段落标题原为 `求职中 {stats.interviewing}`,而它正下方是按 Tab 过滤的 +`companies` —— mock 里只有 **4 条**(28 条投递的**抽样**)。于是标题写 2、下面列 4 行; +切到「面试中」Tab 只剩 1 行,卡片仍写 2;切到「已结束」Tab,1 行列表压在「求职中 2」下面。 + +「面试中几家」这个事实当时有**三份副本**(统计卡 / 标题 / Tab 列表)。已去掉标题这一份, +标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 + +> **未解决、如实记录**:`companies` 是 28 条投递的**抽样**(4 条),所以统计卡上的 +> 「面试中 2 / 待反馈 6 / 已结束 18」在 Tab 列表里**永远对不上行数**。这是 mock 数据 +> **全量 vs 抽样**的结构问题,不是可以靠对齐两个字面量解决的。两条路(把 `companies` +> 扩成 28 条并让 `stats` 由它派生 / 明确标注为「近期流程」抽样并去掉会误导的统计数字) +> 都会改变用户看到的页面,**需产品定夺,本批次未动**。 +> +> **这不是推断,是逐个 Tab 截图看到的**(见第 8 条实测):统计卡「待反馈 6」→ 点进 Tab **1 行**; +> 「面试中 2」→ **1 行**;「已结束 18」→ **1 行**。三处全部对不上。 + +#### 4. 半透明色:5 处 `rgba(255,255,255,x)` 收敛为 4 个 token(值不变) + +`src/theme/colors.ts` 新增,值**等于现状**,一像素未改: + +| token | 值 | 角色 | +|---|---|---| +| `onBrandSurface` | `rgba(255,255,255,0.2)` | 品牌底上的卡片 / 徽标底 | +| `onBrandSurfaceWeak` | `rgba(255,255,255,0.15)` | 品牌底上的更弱底色 | +| `onBrandLine` | `rgba(255,255,255,0.2)` | 品牌底上的分割线 | +| `onBrandTrack` | `rgba(255,255,255,0.35)` | 品牌底上的进度条轨道 | + +另 `app/resume.tsx:1086` 的 `rgba(0,0,0,0.4)` 改用已有的 `colors.scrim`(10+ 处已正确使用)。 + +#### 5. lint 补盲区:`no-hardcoded-hex-color` 此前拦不住 `rgba()` + +4.14 就记下了这个漏洞(「规则只匹配纯 hex」),本批次补上: +`eslint.config.js` 的函数式颜色匹配扩到 `rgb|rgba|hsl|hsla`,并**加了一个 `TemplateElement` 访问器** +—— 否则模板字符串里的颜色(`` background: `${x}``)照样漏网。 + +**注入验证(会失败才算数)**:临时把 `rgba(1, 2, 3, 0.5)` 写进 `app/holland.tsx`,`eslint .` 报 + +``` +173:31 error 禁止写死颜色字面量 "rgba(1, 2, 3, 0.5)",请从 src/theme/colors.ts 导入颜色 token local/no-hardcoded-hex-color +``` + +EXIT=1,规则确实生效;随后还原,`grep -c __probe` = 0,`eslint .` EXIT=0。 + +> **一次险些得出错误结论的过程,如实记录**:第一次注入用 `python -c` 做字符串替换, +> 脚本**静默失败**(`python` 是 Windows 商店的占位存根,非交互调用时什么都不做、也不报错), +> 文件其实没被改动。若没先 `grep __probe` 确认注入物存在,就会拿一次「未注入」的运行去证明 +> 「规则有效」—— 和 4.14 第 7 条那个漏看的 grep 是同一类错误。**注入之后必须先确认注入物在文件里。** + +#### 6. 回归锁:22 例测试 + 三处注入验证 + +新增 `src/__tests__/data-consistency.test.ts`(**22 例**)。写法上刻意把断言写成 +**「A 必须等于 B」的关系**而非「A 等于 28」:这样以后调整 mock 数据不会让测试变红, +**只有把派生关系改回写字面量才会红** —— 锁的正是要防的那件事。 + +**灵敏度验证**:一次性注入三个缺陷,确认命中的断言各不相同 —— +① `favorites` 改回字面量 12 → 收藏数 1 例红; +② `fetchJobOverallScore` 改回恒定 92 → 评分 2 例红; +③ `candidateIdentityOf` 改为恒取第 0 条 → 身份 2 例红。 +合计 **5 例如期失败**,其余 17 例仍通过;逐字还原后 `grep -c __probe` = 0。 + +**另一处「测试自己写错了」的如实记录(比测试通过更有价值)**:本文件初版把 +`interviewCount`(累计收到 **6** 场面试邀请,与月度图表 `interviews` 之和、时间轴「收到 6 场面试邀请」同源) +和 `stats.interviewing`(**当前**面试中 **2** 家)当成了「同一份被写重的值」,断言两者相等并因此变红。 +**红的是测试,不是数据。** 已在文件里保留一条反向断言把它钉死 +(`interviewCount >= interviewing` 且 `not.toBe`),并在注释里写明这个坑 —— 否则下一个人会再犯一次。 + +#### 7. 工具函数下沉(与 4.14 第 9 条同一类别) + +- `formatCompact(n)`:企业端看板内联的 `n >= 1000 ? (n/1000).toFixed(1)+'k' : n` 下沉到 `src/utils`。 + **刻意保留 `.0`**(2000 → `2.0k`),与 `formatViews` 去掉 `.0` 的做法**不一致且是有意的** —— + 改口径会动到看板现在的显示,属另一批事。 +- `formatThousands`:`app/holland.tsx` 的 `toLocaleString()` 换成它,与成长页同源。 + +#### 8. 模拟器实测结果(Android 模拟器 `emulator-5554`,非代码走查) + +用 Expo Go 的深链约定 `exp://127.0.0.1:8081/--/progress` 直接路由到目标页,逐屏截图核对: + +| 页面 | 实测看到 | 说明 | +|---|---|---| +| 求职进度 · 统计卡 | **28 / 6 / 2 / 2 / 18** | **6+2+2+18 = 28**,求和关系成立(改前是 6+2+2+20 = 30 ≠ 28) | +| 求职进度 · Offer 提醒 | 「你有 **2** 个 Offer 即将到期」 | 与统计卡 Offer 2 一致 | +| 求职进度 · 月度图表 | 6月 5/1/0 · 7月 6/1/0 · 8月 8/2/2 | 三列合计 28 / 6 / 2,与顶部计数一致 | +| 求职进度 · 段落标题 | 「求职中」/「待反馈」/「面试中」/「已结束」 | 标题随 Tab 变化,**不再出现与列表矛盾的数字** | +| 我的 · 统计行 | 投递 **28** / 收藏 **3** / 沟通 8 / Offer **2** | 收藏 **12 → 3**(与收藏列表同源);投递、Offer 与求职进度页一致 | +| 我的 · 勋章 | 求职达人 **28/100**、面试高手 **6/10**、诚信之星 **95/100** | **改前「投递满100次」显示「已获得」**,而实际只有 28 次投递 | + +其中「诚信之星 95/100」是一条**同屏可自查**的交叉验证:同页上方「八大维度评分 → 诚信记录」 +显示的就是 **95** —— 勋章的分与维度的分是同一处来源,肉眼即可对照。 + +> 深链注意:`exp://…/--/` 会触发 App **整包 reload**(不是热跳转),每换一页约需 20 秒; +> 这与 4.12 第 6 条查明的结论一致 —— 项目自定义 scheme 未注册,只有 Expo Go 的 `exp://` 生效。 +> +> **没验到的**:企业端「候选人详情」(第 2 表第 8 项:4–8 号不再叫「张明远」)、岗位详情评分 +> (第 6 项)、对比页评分互换(第 7 项)、收藏列表条数(第 3 项的列表侧)**均有测试锁盖住, +> 但本轮未逐屏截图**。按本文件一贯口径记为**未做真机验证**,不当成已验证。 + +#### 9. 本批次门槛复跑(2026-09-20 实测) + +`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` +**25 个文件 339 例全绿**(较 4.14 的 317 例 +22) · `check-routes` **0 失效跳转**(12 个「孤立页」为 +已知误报,见本文件开头说明) · `check-affordances` **0 处** · `check:icons` **0 处无效图标名** +(扫描 194 个文件 / 1357 个有效字形) · `check-backend-stubs` **140 个接口函数**。 + +#### 10. 本批次仍未做 + +- **`companies` 全量 vs 抽样**(见第 3 条),需产品定夺(已由第 8 条实测证实:三个 Tab 全部对不上)。 +- **薪资区间拼接未下沉** `formatSalaryRange`(见 4.14 第 9 条,涉及 4 个页面的展示口径)。 +- **`chats: 8`**(我的页沟通数)**与勋章 6「完成10次测评」的 `4/10` 仍是字面量** —— + 全仓找不到可派生的计数源(没有「测评次数」计数器)。**不编造来源**,故保留字面量并在此标注; + 它们目前不与任何其它数字冲突,但一旦有了计数源就应改回派生。 +- 4.14 第 9 条列出的 `CITIES` / `EXPECTED_CITY_OPTIONS` 两份词表,状态不变(有意保留)。 +- 4.10 第 10 条的外部依赖项(资质证照真实内容 / EAS 占位符 / 隐私政策【待确认】)均**无变化**。 + +--- + ## 五、后续增量开发建议(优先级) - **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`** —— ⚠️ **但 2026-09-18 实测发现该常量全仓无消费者,页脚从未落地,「填入即自动出现」目前不成立,缺的是页脚组件本身,见 4.10 第 7 条**;**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② ~~缺「资质证照」页~~ **页面【已完成,见 4.10】—— 四层链路齐备(`src/types/qualification.ts` / `fetchQualifications` / `useQualifications` / `app/settings/qualifications.tsx`),设置页与「关于我们」页两处入口已恢复,三类证照的补图占位已逐张标明;**但证照的真实编号 / 发证机关 / 有效期 / 证照图仍全部待法务与资质方提供**(材料到位后只改 `MOCK_QUALIFICATIONS` 字段 + 更新测试断言),**未编造任何证件编号**。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。 +- **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,需产品定夺)**:**求职进度页 `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 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。