Browse Source

fix: 收敛「同一事实两个数」类数据矛盾并补颜色 lint 盲区

合并 2026-09-20 当天的 4.14、4.15 两个批次。范围仅前端:后端(Java + SpringBoot)
由其他成员实现,本次未写后端代码,接口仍按约定在 api.ts 中预留注释版 fetch。

主线索是一类反复出现的缺陷:**同一个业务事实被写了两份,两处取值不同**。
修法一律是让重复的那一份在接口层从权威源**派生**,而不是把两个字面量手工对齐 ——
手工对齐只解决这一次,下次改数据照样分叉。

4.14 求职意向词表统一 + 四处数据自相矛盾
- 引导采集的期望薪资 / 求职状态 / 期望城市三组选项,引导页与「设置求职意向」页
  各有一份词表,三组全部匹配不上:走完引导进来的用户看到三组 chip 一个都没选中,
  像资料丢了。更严重的是在那里选「暂不考虑」保存后,简历仍把他记成积极求职中
  (判定常量与页面词表不是同一个字符串)。三组选项下沉到 src/constants 单一来源,
  新增 withCurrentOption 处理引导阶梯能拼出、快捷档覆盖不到的薪资组合。
- 顺带收敛四处数据矛盾:当前用户 Offer 数、求职进度每月 Offer 之和、企业端在招岗位、
  企业端评价数、企业名(全称 / 简称两个值)。
- 撤回 4.10 第 7 条的错误结论:该条断言「常量无消费者、缺页脚组件」,实际
  components/onboarding/ui/SupportFooter.tsx 早一天就已存在并挂在 P1Login / P2QuickLogin
  两页上,页脚一直渲染。该结论朝「把已完成的当成未完成」的方向错,已改为如实。

4.15 数据自相矛盾批量收敛 + 半透明色 token 化 + lint 补盲区
- 又查出并修掉 10 组同源不同值,多为跨页面 / 跨层级,翻页时肉眼不易对照:
  · 求职进度 stats 五项之和 30 ≠ 总数 28(ended 改为由其余四项相减得出,求和成为结构性的)
  · 月度图表投递合计 52 / 面试合计 16,而卡片写 28 / 6(六个月三列重排为 28 / 6 / 2)
  · 我的页收藏徽标 12 与收藏列表 3
  · 勋章「投递满100次」显示已获得,而实际只有 28 次投递(obtained / progress 改由
    buildUserStats() 派生)
  · 匹配成功横幅写 3 家,而会话列表里 matched 为 true 的只有 2 条
  · 岗位详情综合分恒为 92 分·优秀(fetchJobOverallScore 里是 `void id;`),9 个岗位里
    只有第 1 个对得上;雷达图八维改按该岗位实际分等比缩放,否则 65 分的岗位会配一张
    最低 78 分的图
  · 对比页把产品经理与 UI 设计师的综合评分正好写反
  · 候选人 4–8 号点进去全叫「张明远」(身份改取自人才推荐列表)
  · 岗位 1 的公司所在地在列表卡写海淀区、详情页写朝阳区
  · 待回复卡片写 3 人,而它跳转的「未回复」Tab 只有 2 条 —— 两者不是同一集合
    (有未读消息 vs 未回复)。判据应是「与去向一致」,不是「某个内部集合的长度」
- 顺带修掉一处同屏矛盾:求职进度页标题「求职中 {stats.interviewing}」压在按 Tab 过滤的
  companies 上(4 条),标题写 2、下面列 4 行,切到「面试中」Tab 只剩 1 行。该事实当时有
  三份副本(统计卡 / 标题 / Tab 列表),已去掉标题这一份。
  **未解决并如实记录**:companies 是 28 条投递的抽样,故统计卡的分阶段数字在 Tab 列表里
  永远对不上行数 —— 属全量 vs 抽样的结构问题,两条修法都会改变用户看到的页面,需产品定夺。
- 5 处 rgba(255,255,255,x) 逐处看下来不是同一个 token 被写重 5 次,而是 4 个不同角色
  (卡片底 / 弱底色 / 分割线 / 进度条轨道),故按角色拆成 4 个 token,值等于现状、一像素未改;
  resume.tsx 的 rgba(0,0,0,0.4) 改用已有 colors.scrim。
- 补 lint 盲区:no-hardcoded-hex-color 只匹配纯 hex,拦不住 rgba()/hsl()。已扩到函数式颜色,
  并加 TemplateElement 访问器,否则模板字符串里的颜色照样漏网。
- formatCompact(企业端看板内联的 .toFixed(1)k)与 formatThousands 下沉到 src/utils。
  formatCompact 刻意保留尾随 .0(2000 → 2.0k),与 formatViews 去掉 .0 的做法不一致且是有意的,
  改口径会动到看板现状显示。

测试
- 新增 data-consistency.test.ts(22 例),断言刻意写成「A 必须等于 B」的关系而非「A 等于 28」:
  以后调整 mock 数据不会让测试变红,只有把派生关系改回写字面量才会红。
- 新增 job-preference-options.test.ts(15 例),含源码级接线锁。
- 修正 enterprise-filters.test.ts 一条把矛盾当契约的断言:原先锁的是「有未读消息的会话数」,
  与卡片跳转过去的「未回复」Tab 不是同一集合,等于把缺陷固化成了期望。现锁「与去向一致」。
- 灵敏度验证:注入缺陷确认对应断言确实变红(含一次一次性注入 3 个缺陷、5 例如期失败),
  随后逐字还原并 grep 确认无残留。
- 两处如实记录:一次用 python 做注入时脚本静默失败(本机 python 是 Windows 商店占位存根,
  非交互调用不执行也不报错),文件其实没被改动 —— 若没先 grep 确认注入物存在,就会拿一次
  「未注入」的运行去证明「规则有效」;以及 data-consistency.test.ts 初版把「累计面试邀请数」
  与「当前面试中数」当成同一个事实而断言相等,红的是测试不是数据,已改为反向断言并写明注释。

实测(Android 模拟器 emulator-5554,截图核对,非代码走查)
- 求职进度统计卡 28 / 6 / 2 / 2 / 18,6+2+2+18 = 28 成立;月度图表三列合计 28 / 6 / 2;
  段落标题随 Tab 变化,不再出现与列表矛盾的数字。
- 我的页 投递 28 / 收藏 3 / 沟通 8 / Offer 2(收藏 12 → 3);勋章 28/100、6/10、95/100,
  其中诚信之星 95 与同屏「八大维度 → 诚信记录 95」同源,肉眼可自查。
- 未验到的:候选人详情 4–8 号姓名、岗位详情评分、对比页评分互换、收藏列表条数 ——
  均有测试锁盖住,但本轮未逐屏截图,按「未做真机验证」记录,不当成已验证。

门槛:tsc 0 / eslint 0 / 25 文件 339 例全绿 / check:routes 0 / check:affordances 0 /
check:icons 0(194 文件、1357 字形)/ check:backend-stubs 140
master
doublex001 1 month ago
parent
commit
aff9fc8b19
  1. 5
      app/(enterprise)/enterprise-chat.tsx
  2. 9
      app/(enterprise)/job-management.tsx
  3. 5
      app/(enterprise)/talent-recommendation.tsx
  4. 2
      app/(tabs)/jobs.tsx
  5. 2
      app/enterprise-certification.tsx
  6. 6
      app/holland.tsx
  7. 2
      app/interview-score.tsx
  8. 2
      app/job-evaluations.tsx
  9. 22
      app/job-preference.tsx
  10. 2
      app/onboarding.tsx
  11. 12
      app/progress.tsx
  12. 2
      app/resume.tsx
  13. 6
      components/onboarding/screens/P5City.tsx
  14. 27
      eslint.config.js
  15. 295
      src/__tests__/data-consistency.test.ts
  16. 9
      src/__tests__/enterprise-filters.test.ts
  17. 229
      src/__tests__/job-preference-options.test.ts
  18. 369
      src/api/api.ts
  19. 51
      src/constants/index.ts
  20. 16
      src/theme/colors.ts
  21. 33
      src/utils/index.ts
  22. 334
      项目开发状态.md

5
app/(enterprise)/enterprise-chat.tsx

@ -11,6 +11,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseCommunication } from '../../src/hooks'; import { useEnterpriseCommunication } from '../../src/hooks';
import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../../src/types'; import type { EnterpriseChatItem, EnterpriseCommunicationTab, PendingTask } from '../../src/types';
@ -288,7 +289,7 @@ export default function EnterpriseChatScreen() {
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}> <View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
@ -299,7 +300,7 @@ export default function EnterpriseChatScreen() {
if (error || !communication) { if (error || !communication) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}> <View style={styles.center}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} /> <Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.tip}>加载失败</Text> <Text style={styles.tip}>加载失败</Text>

9
app/(enterprise)/job-management.tsx

@ -16,8 +16,9 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobManagement } from '../../src/hooks'; import { useJobManagement } from '../../src/hooks';
import { JOB_MANAGEMENT_SORTS, buildJobStatusAction, getJobStatusLabel } from '../../src/api'; import { JOB_MANAGEMENT_SORTS, buildJobStatusAction, getJobStatusLabel } from '../../src/api';
import { ErrorState, LoadingState } from '../../src/components/StateView'; import { ErrorState, LoadingState } from '../../src/components/StateView';
import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { formatDateTime } from '../../src/utils'; import { formatCompact, formatDateTime } from '../../src/utils';
import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types'; import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
@ -292,7 +293,7 @@ function JobCard({
<Text style={styles.jobStatLabel}>投递</Text> <Text style={styles.jobStatLabel}>投递</Text>
</View> </View>
<View style={styles.jobStat}> <View style={styles.jobStat}>
<Text style={styles.jobStatValue}>{job.views >= 1000 ? `${(job.views / 1000).toFixed(1)}k` : job.views}</Text> <Text style={styles.jobStatValue}>{formatCompact(job.views)}</Text>
<Text style={styles.jobStatLabel}>查看</Text> <Text style={styles.jobStatLabel}>查看</Text>
</View> </View>
<View style={styles.jobStat}> <View style={styles.jobStat}>
@ -448,7 +449,7 @@ export default function JobManagementScreen() {
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<LoadingState /> <LoadingState />
</SafeAreaView> </SafeAreaView>
); );
@ -457,7 +458,7 @@ export default function JobManagementScreen() {
if (error || !management) { if (error || !management) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<ErrorState onRetry={refresh} /> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );

5
app/(enterprise)/talent-recommendation.tsx

@ -12,6 +12,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useTalentRecommendation } from '../../src/hooks'; import { useTalentRecommendation } from '../../src/hooks';
import { ENTERPRISE_DISPLAY_NAME } from '../../src/constants';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { TalentCandidate, TalentRecommendationTab } from '../../src/types'; import type { TalentCandidate, TalentRecommendationTab } from '../../src/types';
@ -311,7 +312,7 @@ export default function TalentRecommendationScreen() {
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}> <View style={styles.center}>
<ActivityIndicator size="large" color={colors.primary} /> <ActivityIndicator size="large" color={colors.primary} />
</View> </View>
@ -322,7 +323,7 @@ export default function TalentRecommendationScreen() {
if (error || !recommendation) { if (error || !recommendation) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName={ENTERPRISE_DISPLAY_NAME} onBack={() => router.back()} />
<View style={styles.center}> <View style={styles.center}>
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} /> <Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.tip}>加载失败</Text> <Text style={styles.tip}>加载失败</Text>

2
app/(tabs)/jobs.tsx

@ -559,7 +559,7 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderRadius: 18, borderRadius: 18,
backgroundColor: 'rgba(255,255,255,0.15)', backgroundColor: colors.onBrandSurfaceWeak,
}, },
stateBox: { stateBox: {
flex: 1, flex: 1,

2
app/enterprise-certification.tsx

@ -277,7 +277,7 @@ const styles = StyleSheet.create({
width: 48, width: 48,
height: 48, height: 48,
borderRadius: 24, borderRadius: 24,
backgroundColor: 'rgba(255,255,255,0.2)', backgroundColor: colors.onBrandSurface,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },

6
app/holland.tsx

@ -11,6 +11,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useHollandIntro } from '../src/hooks'; import { useHollandIntro } from '../src/hooks';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { formatThousands } from '../src/utils';
import type { HollandDimensionIntro, HollandIntro } from '../src/types'; import type { HollandDimensionIntro, HollandIntro } from '../src/types';
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
@ -134,7 +135,10 @@ export default function HollandIntroScreen() {
</View> </View>
<Text style={styles.heroTitle}>{intro.title}</Text> <Text style={styles.heroTitle}>{intro.title}</Text>
<Text style={styles.heroSubtitle}>{intro.subtitle}</Text> <Text style={styles.heroSubtitle}>{intro.subtitle}</Text>
<Text style={styles.heroParticipants}>已有 {intro.totalParticipants.toLocaleString()} 人完成测评</Text> {/* 与成长页的「已有 N 人完成」是同一个字段、同一句话,两处必须同一种格式化:
原先这里用 toLocaleString()、那边用 formatThousands(),而 Hermes 在无完整 ICU
的构建上 toLocaleString() 可能不加千分位,两页就会显示成两个样子。 */}
<Text style={styles.heroParticipants}>已有 {formatThousands(intro.totalParticipants)} 人完成测评</Text>
</View> </View>
{/* 六维类型 */} {/* 六维类型 */}

2
app/interview-score.tsx

@ -449,7 +449,7 @@ const styles = StyleSheet.create({
width: 64, width: 64,
height: 64, height: 64,
borderRadius: 32, borderRadius: 32,
backgroundColor: 'rgba(255,255,255,0.2)', backgroundColor: colors.onBrandSurface,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },

2
app/job-evaluations.tsx

@ -585,7 +585,7 @@ const styles = StyleSheet.create({
rateRow: { rateRow: {
flexDirection: 'row', flexDirection: 'row',
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: 'rgba(255,255,255,0.2)', borderTopColor: colors.onBrandLine,
paddingTop: 16, paddingTop: 16,
}, },
rateItem: { rateItem: {

22
app/job-preference.tsx

@ -14,14 +14,15 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { useAuth } from '../src/context/AuthContext'; import { useAuth } from '../src/context/AuthContext';
import { useJobPreference } from '../src/hooks'; 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'; 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 ===================== */ /* ===================== 子组件:单选项 chips ===================== */
function ChipGroup({ function ChipGroup({
@ -151,25 +152,26 @@ export default function JobPreferenceScreen() {
<ChipGroup <ChipGroup
title="期望城市" title="期望城市"
options={CITIES} options={EXPECTED_CITY_OPTIONS}
value={form.city} value={form.city}
onSelect={(v) => update('city', v)} onSelect={(v) => update('city', v)}
/> />
<ChipGroup <ChipGroup
title="期望薪资" title="期望薪资"
options={SALARIES} // 引导 P7 的两个阶梯能拼出快捷档覆盖不到的组合,补进当前值避免「一个都没选中」
options={withCurrentOption(EXPECTED_SALARY_OPTIONS, form.salary)}
value={form.salary} value={form.salary}
onSelect={(v) => update('salary', v)} onSelect={(v) => update('salary', v)}
/> />
<ChipGroup <ChipGroup
title="工作类型" title="工作类型"
options={WORK_TYPES} options={WORK_TYPE_OPTIONS}
value={form.workType} value={form.workType}
onSelect={(v) => update('workType', v)} onSelect={(v) => update('workType', v)}
/> />
<ChipGroup <ChipGroup
title="求职状态" title="求职状态"
options={STATUSES} options={JOB_STATUS_OPTIONS}
value={form.status} value={form.status}
onSelect={(v) => update('status', v)} onSelect={(v) => update('status', v)}
/> />

2
app/onboarding.tsx

@ -471,7 +471,7 @@ const styles = StyleSheet.create({
countdownTrack: { countdownTrack: {
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.35)', backgroundColor: colors.onBrandTrack,
overflow: 'hidden', overflow: 'hidden',
}, },
countdownFill: { height: 8, borderRadius: 4, backgroundColor: colors.white }, countdownFill: { height: 8, borderRadius: 4, backgroundColor: colors.white },

12
app/progress.tsx

@ -239,6 +239,9 @@ export default function ProgressScreen() {
return progress.companies.filter((c) => c.stage === activeTab); return progress.companies.filter((c) => c.stage === activeTab);
}, [progress, activeTab]); }, [progress, activeTab]);
// 标题文字跟随当前 Tab,与下方列表同源(见渲染处注释)
const activeLabel = TABS.find((tab) => tab.key === activeTab)?.label ?? '全部';
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@ -265,7 +268,14 @@ export default function ProgressScreen() {
<OfferAlert count={progress.offers.length} /> <OfferAlert count={progress.offers.length} />
<FilterTabs active={activeTab} onChange={setActiveTab} /> <FilterTabs active={activeTab} onChange={setActiveTab} />
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>求职中 {progress.stats.interviewing}</Text> {/* 这里原先写「求职中 {stats.interviewing}」,是「面试中几家」的第三个副本
(统计卡一份、本标题一份、下方 Tab 列表一份),而它的正下方却是按 Tab 过滤的
`companies` —— mock 里只有 4 条,是 28 条投递的抽样,撑不起「2」这个数:
标题写 2,下面列 4 行;切到「面试中」Tab 只剩 1 行,卡片仍写 2。
去掉这份副本,标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 */}
<Text style={styles.sectionTitle}>
{activeTab === 'all' ? '求职中' : activeLabel}
</Text>
</View> </View>
{/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。 {/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。
同一文件下方对 offers 就做了保护,companies 此前漏了 */} 同一文件下方对 offers 就做了保护,companies 此前漏了 */}

2
app/resume.tsx

@ -1083,7 +1083,7 @@ const styles = StyleSheet.create({
}, },
optimizeFooterBtnText: { fontSize: 15, fontWeight: '700', color: colors.white }, 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: { modalSheet: {
backgroundColor: colors.white, backgroundColor: colors.white,
borderTopLeftRadius: 20, borderTopLeftRadius: 20,

6
components/onboarding/screens/P5City.tsx

@ -3,12 +3,14 @@ import { Ionicons } from '@expo/vector-icons';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { colors } from '../../../src/theme'; import { colors } from '../../../src/theme';
import { useOnboarding } from '../../../src/hooks'; import { useOnboarding } from '../../../src/hooks';
import { EXPECTED_CITY_OPTIONS, HOT_CITY_OPTIONS } from '../../../src/constants';
import { ScreenShell } from '../ui/ScreenShell'; import { ScreenShell } from '../ui/ScreenShell';
import { SearchInput } from '../ui/SearchInput'; import { SearchInput } from '../ui/SearchInput';
import { Tag, TagGroup } from '../ui/Tag'; import { Tag, TagGroup } from '../ui/Tag';
const ALL = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉', '西安', '苏州', '天津', '重庆', '青岛', '长沙', '郑州', '东莞']; // 城市表放常量:「设置求职意向」页用的是同一份,两处各写一份会让采集到的城市在那页选不中
const HOT = ['北京', '上海', '广州', '深圳', '杭州', '成都']; const ALL = EXPECTED_CITY_OPTIONS;
const HOT = HOT_CITY_OPTIONS;
export function P5City() { export function P5City() {
const { data, setData, back } = useOnboarding(); const { data, setData, back } = useOnboarding();

27
eslint.config.js

@ -5,30 +5,47 @@ const reactHooks = require('eslint-plugin-react-hooks');
const globals = require('globals'); const globals = require('globals');
/** /**
* 自定义规则:禁止在组件/业务代码内写死十六进制颜色字面量。 * 自定义规则:禁止在组件/业务代码内写死颜色字面量。
* 对齐 AGENTS.md 配色禁令——颜色必须统一从 src/theme/colors.ts 导入。 * 对齐 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 本身是设计令牌唯一来源,豁免。 * 例外:theme/colors.ts 本身是设计令牌唯一来源,豁免。
*/ */
const noHardcodedHexColor = { const noHardcodedHexColor = {
meta: { meta: {
type: 'problem', type: 'problem',
docs: { docs: {
description: '禁止在组件内写死十六进制颜色,统一从 src/theme/colors.ts 导入', description:
'禁止写死颜色(hex / rgb / rgba / hsl),统一从 src/theme/colors.ts 导入',
}, },
schema: [], schema: [],
messages: { messages: {
noHex: noHex: '禁止写死颜色字面量 "{{value}}",请从 src/theme/colors.ts 导入颜色 token。',
'禁止写死十六进制颜色字面量 "{{value}}",请从 src/theme/colors.ts 导入颜色 token。',
}, },
}, },
create(context) { 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 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 { return {
Literal(node) { 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 } }); 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 } });
}
},
}; };
}, },
}; };

295
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<T>(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');
});
});

9
src/__tests__/enterprise-filters.test.ts

@ -195,12 +195,17 @@ describe('企业端 Tab 计数由列表推导(页面数字与列表人数必
}); });
it('待办任务计数由会话列表推导,且都带可跳转的去向', async () => { it('待办任务计数由会话列表推导,且都带可跳转的去向', async () => {
const { chats, pendingTasks } = await fetchEnterpriseCommunication(); const { chats, tabs, pendingTasks } = await fetchEnterpriseCommunication();
const reply = pendingTasks.find((task) => task.id === 'reply'); const reply = pendingTasks.find((task) => task.id === 'reply');
const score = pendingTasks.find((task) => task.id === 'score'); 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(reply?.tabKey).toBe('pending');
expect(score?.count).toBe(chats.filter((chat) => chat.labels.includes('面试待评分')).length); expect(score?.count).toBe(chats.filter((chat) => chat.labels.includes('面试待评分')).length);
expect(score?.route).toBe('/interview-score'); expect(score?.route).toBe('/interview-score');

229
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<string, string> = {};
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>): 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']);
});
});

369
src/api/api.ts

@ -173,6 +173,7 @@ import {
JOB_MATCH_CITY_BONUS, JOB_MATCH_CITY_BONUS,
JOB_MATCH_POSITION_BONUS, JOB_MATCH_POSITION_BONUS,
JOB_MATCH_SALARY_BONUS, JOB_MATCH_SALARY_BONUS,
JOB_LEVEL_LABEL,
JOB_STATUS_EMPLOYED_PREFIX, JOB_STATUS_EMPLOYED_PREFIX,
JOB_STATUS_INACTIVE, JOB_STATUS_INACTIVE,
MOCK_DELAY, MOCK_DELAY,
@ -180,6 +181,7 @@ import {
PASSWORD_MIN_LENGTH, PASSWORD_MIN_LENGTH,
PASSWORD_RULE_LABEL, PASSWORD_RULE_LABEL,
POSITION_NOISE_WORDS, POSITION_NOISE_WORDS,
ENTERPRISE_DISPLAY_NAME,
PREFERENCE_DEFAULT_WORK_TYPE, PREFERENCE_DEFAULT_WORK_TYPE,
RESUME_AI_UNAVAILABLE_TIP, RESUME_AI_UNAVAILABLE_TIP,
SALARY_MAX_OPTIONS, 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<HollandType, string> = { const HOLLAND_NAMES: Record<HollandType, string> = {
@ -694,13 +705,30 @@ const MOCK_USER_PROFILE: UserProfile = {
avatarKey: null, avatarKey: null,
}; };
/** 用户统计数据(我的页四宫格) */ /**
const MOCK_USER_STATS: UserStats = { * 四宫格里唯一没有明细可对照的一列:「沟通」。
deliveries: 28, * 消息页有 4 个会话,但全仓没有任何一处把「沟通数」定义为会话数,
favorites: 12, * 语义未经确认,故不擅自改数,只把字面量抽出来标明来路。
chats: 8, */
offers: 3, 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: '', status: '',
}; };
/** 勋章列表(已获得 + 未获得;未获得带获取进度) */ /**
const MOCK_BADGES: Badge[] = [ * 勋章列表(已获得 + 未获得;未获得带获取进度)。
{ id: 'badge-1', title: '求职达人', description: '投递满100次', icon: '🏅', obtained: true }, *
{ id: 'badge-2', title: '面试高手', description: '面试10次成功', icon: '🎯', obtained: true }, * 每枚勋章的描述本身就是一个计数门槛(「投递满100次」「获得5个offer」…),
{ id: 'badge-3', title: '诚信之星', description: '诚信记录满分', icon: '⭐', obtained: true }, * 所以进度与「是否已获得」**必须由用户统计派生**。原先两者各写一份,后果是同一个
{ id: 'badge-4', title: 'offer收割机', description: '获得5个offer', icon: '🏆', obtained: false, progress: 3, target: 5 }, * 「我的」页上下自相矛盾:四宫格写着投递 28、Offer 2,正下方的勋章列表却显示
{ id: 'badge-5', title: '投递达人', description: '投递满200次', icon: '📮', obtained: false, progress: 128, target: 200 }, * 「投递满100次 · 已获得」「投递达人 128/200」「offer收割机 3/5」。
{ id: 'badge-6', title: '学习之星', description: '完成10次测评', icon: '📚', obtained: false, progress: 4, target: 10 }, * 派生还有个好处:门槛一到自动点亮,不需要谁记得回来改 `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<string, Partial<JobDetail>> = { const MOCK_JOB_DETAILS: Record<string, Partial<JobDetail>> = {
'1': { '1': {
companyLocation: '北京 · 朝阳区', // 这里原先还覆盖了 companyLocation(写「朝阳区」),与 MOCK_JOBS 里的「海淀区」冲突 ——
// 同一个岗位在列表卡和详情页显示两个城区。列表页该字段本就有完整值,不属「未细化」,
// 故撤掉这层覆盖,让 MOCK_JOBS 成为该字段的唯一来源。
industry: '互联网', industry: '互联网',
benefits: ['五险一金', '年终奖金', '弹性工作', '免费三餐'], benefits: ['五险一金', '年终奖金', '弹性工作', '免费三餐'],
companySize: '10000人以上', companySize: '10000人以上',
@ -861,6 +949,9 @@ const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
}, },
]; ];
/** 对比页里与「岗位分」同义的那个维度名(其余维度是独立评分,不参与派生) */
const COMPARE_SCORE_DIMENSION = '综合评分';
/** 岗位对比数据(3 个岗位:九大维度评分 + 基本信息 + 福利) */ /** 岗位对比数据(3 个岗位:九大维度评分 + 基本信息 + 福利) */
const MOCK_COMPARE_DATA: CompareData = { const MOCK_COMPARE_DATA: CompareData = {
jobs: [ jobs: [
@ -906,6 +997,17 @@ function scoreToLevel(score: number): JobLevel {
return 'medium'; 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 良好) */ /** 能力总分 → 等级映射(能力测评阈值高于岗位匹配:≥90 优秀 / ≥75 良好) */
function abilityScoreToLevel(score: number): JobLevel { function abilityScoreToLevel(score: number): JobLevel {
if (score >= 90) return 'excellent'; if (score >= 90) return 'excellent';
@ -1113,7 +1215,7 @@ export async function fetchInterviews(): Promise<Interview[]> {
*/ */
export async function fetchMatchResult(): Promise<MatchResult> { export async function fetchMatchResult(): Promise<MatchResult> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/match-result`, { method: 'GET' }); return res.json(); // 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<UserStats> { export async function fetchUserStats(): Promise<UserStats> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/stats`, { method: 'GET' }); return res.json(); // 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<JobPreference> {
*/ */
export async function fetchBadges(): Promise<Badge[]> { export async function fetchBadges(): Promise<Badge[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json(); // 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<JobDetail> {
*/ */
export async function fetchJobOverallScore(id: string): Promise<JobOverallScore> { export async function fetchJobOverallScore(id: string): Promise<JobOverallScore> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json(); // TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json();
void id; // 必须按 id 取该岗位自己的分:原先 `void id;` 直接返回固定的一份(恒 92 分 · 优秀),
return mockRequest(MOCK_JOB_OVERALL_SCORE); // 于是列表卡上「产品经理 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<CompareData> {
jobs: indices.map((index) => MOCK_COMPARE_DATA.jobs[index]), jobs: indices.map((index) => MOCK_COMPARE_DATA.jobs[index]),
dimensionScores: MOCK_COMPARE_DATA.dimensionScores.map((dim) => ({ dimensionScores: MOCK_COMPARE_DATA.dimensionScores.map((dim) => ({
dimension: dim.dimension, 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: { basicInfo: {
fields: MOCK_COMPARE_DATA.basicInfo.fields, 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 / 公司流程 / 月度统计) */ /** 求职进度(完整版:统计 / Offer / 公司流程 / 月度统计) */
const MOCK_JOB_PROGRESS: JobProgress = { const MOCK_JOB_PROGRESS: JobProgress = {
deliveryCount: 28, deliveryCount: PROGRESS_DELIVERY_COUNT,
interviewCount: 6, interviewCount: PROGRESS_INTERVIEW_COUNT,
offerCount: 2, offerCount: PROGRESS_OFFER_COUNT,
// ended 由其余四项推出,统计卡五个数字并排一行、用户心算即得,必须加起来等于 total
stats: { stats: {
total: 28, total: PROGRESS_DELIVERY_COUNT,
pending: 6, pending: PROGRESS_PENDING_COUNT,
interviewing: 2, interviewing: PROGRESS_INTERVIEWING_COUNT,
offer: 2, offer: PROGRESS_OFFER_COUNT,
ended: 20, ended:
PROGRESS_DELIVERY_COUNT -
PROGRESS_PENDING_COUNT -
PROGRESS_INTERVIEWING_COUNT -
PROGRESS_OFFER_COUNT,
}, },
offers: [ offers: [
{ {
@ -2650,20 +2792,41 @@ const MOCK_JOB_PROGRESS: JobProgress = {
date: '08-20', 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: [ monthlyStats: [
{ month: '3月', deliveries: 4, interviews: 1, offers: 0 }, { month: '3月', deliveries: 2, interviews: 0, offers: 0 },
{ month: '4月', deliveries: 6, interviews: 2, offers: 0 }, { month: '4月', deliveries: 3, interviews: 1, offers: 0 },
{ month: '5月', deliveries: 8, interviews: 2, offers: 1 }, { month: '5月', deliveries: 4, interviews: 1, offers: 0 },
{ month: '6月', deliveries: 10, interviews: 3, offers: 0 }, { month: '6月', deliveries: 5, interviews: 1, offers: 0 },
{ month: '7月', deliveries: 12, interviews: 4, offers: 1 }, { month: '7月', deliveries: 6, interviews: 1, offers: 0 },
{ month: '8月', deliveries: 12, interviews: 4, offers: 1 }, { month: '8月', deliveries: 8, interviews: 2, offers: 2 },
], ],
steps: [ 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: '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: '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' }, { 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' }, { 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[] = [ const MOCK_JOB_EVALUATION_SUMMARIES: JobEvaluationSummary[] = [
{ id: 'je1', positionName: '高级前端工程师', department: '技术部', score: 4.9, reviewCount: 42, change: 0.2 }, { 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 }, { 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<EnterpriseOverview, 'stats'> = {
id: 'ent-1', id: 'ent-1',
name: '北京智联科技有限公司', name: ENTERPRISE_DISPLAY_NAME,
logo: '智', logo: '智',
industry: '互联网/IT', industry: '互联网/IT',
scale: '1000-9999人', scale: '1000-9999人',
stage: 'B轮', stage: 'B轮',
overallScore: 4.8, overallScore: 4.8,
reviewCount: 1280, reviewCount: ENTERPRISE_REVIEW_TOTAL,
stats: MOCK_ENTERPRISE_STATS,
jobEvaluations: MOCK_JOB_EVALUATION_SUMMARIES, jobEvaluations: MOCK_JOB_EVALUATION_SUMMARIES,
quickActions: MOCK_ENTERPRISE_QUICK_ACTIONS, quickActions: MOCK_ENTERPRISE_QUICK_ACTIONS,
manageActions: MOCK_ENTERPRISE_MANAGE_ACTIONS, manageActions: MOCK_ENTERPRISE_MANAGE_ACTIONS,
@ -3778,7 +3963,9 @@ const MOCK_ENTERPRISE_OVERVIEW: EnterpriseOverview = {
*/ */
export async function fetchEnterpriseOverview(): Promise<EnterpriseOverview> { export async function fetchEnterpriseOverview(): Promise<EnterpriseOverview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/overview`, { method: 'GET' }); return res.json(); // 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 = { const MOCK_JOB_EVALUATION_MANAGEMENT: JobEvaluationManagement = {
overallScore: 4.8, overallScore: 4.8,
change: 0.1, change: 0.1,
reviewCount: 128, reviewCount: ENTERPRISE_REVIEW_TOTAL,
positiveRate: 85, positiveRate: 85,
replyRate: 72, replyRate: 72,
complaintRate: 8, complaintRate: 8,
@ -4069,15 +4256,21 @@ const MOCK_CANDIDATE_DETAIL: CandidateDetail = {
invited: false, invited: false,
}; };
/** 候选人身份覆盖(mock 阶段按 id 回填姓名/年龄/城市等基本信息,避免不同候选人显示同一份资料) */ /**
const CANDIDATE_IDENTITY_OVERRIDE: Record< * 候选人身份:直接取自人才推荐列表 `MOCK_TALENT_CANDIDATES`。
string, *
{ name: string; age: number; city: string; matchPercent: number; overallScore: number; percentile: number } * 这里原先另存了一份覆盖表,且**只写了 cand-1 / cand-2 / cand-3**,取不到时兜底到 cand-1。
> = { * 后果是:列表里点 cand-4「陈思远 · 匹配 94%」的「查看详情」,详情页显示的是张明远的
'cand-1': { name: '张明远', age: 28, city: '北京', matchPercent: 96, overallScore: 88, percentile: 99 }, * 姓名 / 年龄 / 匹配度 / 百分位(cand-5 ~ cand-8 同样)—— 连人是谁都对不上。
'cand-2': { name: '李雨桐', age: 26, city: '北京', matchPercent: 82, overallScore: 75, percentile: 75 }, * 列表才是这份资料的明细来源,改为按 id 直接取它。
'cand-3': { name: '王建国', age: 33, city: '北京', matchPercent: 91, overallScore: 92, percentile: 92 }, *
}; * 取不到时仍兜底到第一条:详情页存在「不带 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<CandidateDetail> { export async function fetchCandidateDetail(candidateId: string): Promise<CandidateDetail> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/candidates/${candidateId}`, { method: 'GET' }); return res.json(); // TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/candidates/${candidateId}`, { method: 'GET' }); return res.json();
const override = const identity = candidateIdentityOf(candidateId);
CANDIDATE_IDENTITY_OVERRIDE[candidateId] ?? CANDIDATE_IDENTITY_OVERRIDE['cand-1'];
const detail: CandidateDetail = { const detail: CandidateDetail = {
...MOCK_CANDIDATE_DETAIL, ...MOCK_CANDIDATE_DETAIL,
id: candidateId, id: candidateId,
matchPercent: override.matchPercent, matchPercent: identity.matchPercent,
overallScore: override.overallScore, overallScore: identity.overallScore,
percentile: override.percentile, percentile: identity.percentile,
basicInfo: { basicInfo: {
...MOCK_CANDIDATE_DETAIL.basicInfo, ...MOCK_CANDIDATE_DETAIL.basicInfo,
name: override.name, name: identity.name,
age: override.age, age: identity.age,
city: override.city, city: identity.city,
}, },
}; };
return mockRequest(detail); 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。 * 「在招岗位」由列表实时算出 —— 下架一个岗位,这里要跟着减 1,不能是写死的 12。
* 其余三项依赖后端投递/面试数据,暂沿用 mock 文案。 * 其余三项依赖后端投递/面试数据,暂沿用 mock 文案。
*/ */
function buildJobManagementStats(jobs: JobManagementJob[]): JobManagement['stats'] { function buildJobManagementStats(jobs: JobManagementJob[]): JobManagement['stats'] {
const activeCount = jobs.filter((job) => job.status === 'active').length; const activeCount = countActiveJobs(jobs);
return [ return [
{ {
id: 'active', id: 'active',
@ -5003,7 +5203,7 @@ export async function refreshJob(jobId: string): Promise<JobManagement> {
async function buildJobManagement(): Promise<JobManagement> { async function buildJobManagement(): Promise<JobManagement> {
const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS); const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS);
return { return {
companyName: '智联科技', companyName: ENTERPRISE_DISPLAY_NAME,
hasData: true, hasData: true,
stats: buildJobManagementStats(jobs), stats: buildJobManagementStats(jobs),
tabs: buildJobManagementTabs(jobs), tabs: buildJobManagementTabs(jobs),
@ -5245,7 +5445,7 @@ const MOCK_TALENT_CANDIDATES: TalentCandidate[] = [
]; ];
const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = { const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = {
companyName: '智联科技', companyName: ENTERPRISE_DISPLAY_NAME,
hasData: true, hasData: true,
todayCount: MOCK_TALENT_CANDIDATES.length, todayCount: MOCK_TALENT_CANDIDATES.length,
tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES), tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES),
@ -5402,7 +5602,10 @@ function buildPendingTasks(chats: EnterpriseChatItem[]): PendingTask[] {
{ {
id: 'reply', id: 'reply',
label: '待回复', label: '待回复',
count: chats.filter((c) => c.unreadCount > 0).length, // 这张卡片点下去会切到「未回复」Tab(见下方 tabKey),所以数字必须与那个 Tab 的计数
// 同源,否则点进去只会看到条数凭空变少。原先统计的是「有未读消息的会话数」,
// 与「未回复」(status === 'pending')根本不是同一个集合。
count: chats.filter((c) => c.status === 'pending').length,
hint: '24小时内未回复', hint: '24小时内未回复',
icon: 'chatbubble-ellipses-outline', icon: 'chatbubble-ellipses-outline',
color: 'action', color: 'action',
@ -5435,7 +5638,7 @@ function buildCommunicationTabs(chats: EnterpriseChatItem[]): EnterpriseCommunic
} }
const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = { const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = {
companyName: '智联科技', companyName: ENTERPRISE_DISPLAY_NAME,
hasData: true, hasData: true,
pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS), pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS),
tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS), tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS),

51
src/constants/index.ts

@ -38,9 +38,28 @@ export const EXPERIENCE_OPTIONS: string[] = ['不限', '应届生', '1-3年', '3
/** 学历要求筛选选项 */ /** 学历要求筛选选项 */
export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕士', '博士']; export const EDUCATION_OPTIONS: string[] = ['不限', '大专', '本科', '硕士', '博士'];
/** 热门城市(定位/城市选择用) */ /**
* 岗位筛选用的城市列表(都有 mock 岗位与社保基数数据)。
* 注意与 `EXPECTED_CITY_OPTIONS` 的区别:这一份是**筛选项**,城市得有对应数据才有意义;
* 期望城市是用户想去的任何地方,故那份更长。两者用途不同,不合并。
*/
export const CITIES: string[] = ['北京', '上海', '深圳', '杭州', '广州', '成都', '南京', '武汉']; 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 数值,真实接入后端按城市下发)。 * 城市社保/公积金缴费基数上下限(mock 数值,真实接入后端按城市下发)。
* 养老 8% / 医疗 2% / 失业 0.5% 全国一致,城市差异体现在基数上下限,故按城市 clamp 基数即可反映差异。 * 养老 8% / 医疗 2% / 失业 0.5% 全国一致,城市差异体现在基数上下限,故按城市 clamp 基数即可反映差异。
@ -153,6 +172,16 @@ export const UNSET_LABEL = '未设置';
/** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */ /** 工作类型默认值:引导流程未采集该项,映射时补默认「全职」 */
export const PREFERENCE_DEFAULT_WORK_TYPE = '全职'; export const PREFERENCE_DEFAULT_WORK_TYPE = '全职';
/**
* 企业端演示企业名(mock 阶段)。
*
* 页面在加载 / 错误态拿不到数据、又需要一个标题,此前 6 处各写一遍字面量,
* 数据层再写 3 遍,且总览页写的是全称「北京智联科技有限公司」——
* 同一家企业出现两个名字,改一个另一个不知道。故声明一次。
* 接入真实后端后,企业名一律从接口下发,本常量随之删除。
*/
export const ENTERPRISE_DISPLAY_NAME = '智联科技';
/* ===================== 期望月薪(P7)可选阶梯 ===================== */ /* ===================== 期望月薪(P7)可选阶梯 ===================== */
/** /**
@ -169,6 +198,26 @@ export const SALARY_MAX_OPTIONS: string[] = [
'8K', '10K', '12K', '15K', '20K', '25K', '30K', '40K', '60K', '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[] = ['全职', '兼职', '实习', '远程'];
/* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */ /* ===================== 引导流程步骤(进度文案的唯一依据) ===================== */
/** /**

16
src/theme/colors.ts

@ -114,6 +114,22 @@ export const colors = {
focusRing: '#1F6FDB', focusRing: '#1F6FDB',
/** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */ /** 弹层遮罩 · 半透明黑(Modal / 底部弹层背景) */
scrim: 'rgba(0, 0, 0, 0.35)', 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; } as const;
/** /**

33
src/utils/index.ts

@ -60,6 +60,39 @@ export function formatViews(views: number): string {
return String(views); 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 步」。 * 引导流程进度文案:「第 3 / 16 步」。
* *

334
项目开发状态.md

@ -1,11 +1,13 @@
# 有职 App 开发进度与规划文档 # 有职 App 开发进度与规划文档
> 生成日期:2026-09-10 > 生成日期: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-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-17(全仓死控件清零:9 处 → 0 处,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) > 上一版: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-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) > 上上版: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 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。 > **读这份文档请先注意一条**:4.8 曾写「死控件清零」并据此把 P2 标记为已完成,**那个结论当时是错的** —— 审计脚本只查「无处理函数 / 空处理函数」,查不到「有处理函数但点下去只弹一句『功能开发中』」。详见 4.9 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。
@ -21,7 +23,7 @@
| 假交互审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-18 复跑,**口径已含弹窗式假交互**,见 4.9)<br>⚠️ 另有 **3 处知情保留的后端待办**(`--pending` 可列出,非疏漏) | | 假交互审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-18 复跑,**口径已含弹窗式假交互**,见 4.9)<br>⚠️ 另有 **3 处知情保留的后端待办**(`--pending` 可列出,非疏漏) |
| 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ **140** 个接口函数全部留有注释版真实请求(见 4.6) | | 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ **140** 个接口函数全部留有注释版真实请求(见 4.6) |
| 图标名审计 | `node scripts/check-icons.mjs` | ✅ **0 处无效图标名**(2026-09-18 新增门槛,修掉 `gavel-outline` 后建立,见 4.13) | | 图标名审计 | `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/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 |
| 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | | 应用配置 | `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 个预设头像各自生效。 - **缺陷修复**:`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 条款链接按下条处理。 - **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。
- **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息) - **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息)
⚠️ **2026-09-18 更正**:号码后来确实填了(`15154608969`),但**没有任何代码读这两个常量** —— **更正(2026-09-20)**:4.10 第 7 条曾据一次漏看的 grep 断言「没有任何代码读这两个常量、
所谓「填入即自动出现」并不成立,缺的是页脚组件本身。见 4.10 第 7 条。;「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 缺的是页脚组件本身」。**该断言是错的** —— `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 职场男性插画」这类开发备注直接画在用户界面上)。 - **删 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 问题。 - **测试**:新增 `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 重进数据仍在)**尚未在设备上执行**。 - **待验证 / 待补**:`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` 让短标签左对齐。**这个缺陷只有截图才看得出来,代码走查发现不了。** **实测中发现并修掉一个布局缺陷**:证照信息行原写死 `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:` 拨号**」。 文档第 335 行①称合规页脚「已填 `15154608969`……**填入即自动出现并接 `tel:` 拨号**」。
实测:`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 在 `src/constants/index.ts` 有定义,但**全仓 grep 无任何消费者** —— 没有一处代码读它,所以**永远不会有页脚出现**。 ~~实测:`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 在 `src/constants/index.ts` 有定义,但全仓 grep 无任何消费者 —— 没有一处代码读它,所以永远不会有页脚出现。~~
即那句「填入即自动出现」目前是不成立的:缺的不是号码,是**页脚组件本身**。 ~~即那句「填入即自动出现」目前是不成立的:缺的不是号码,是页脚组件本身。~~
要么补一个合规页脚(需产品定位置),要么把该条描述改回如实。**不擅自新建页脚**(涉及放哪一页、什么形态,属产品决策)。 要么补一个合规页脚(需产品定位置),要么把该条描述改回如实。**不擅自新建页脚**(涉及放哪一页、什么形态,属产品决策)。
#### 8. 提交约定补了一条:**提交之后必须推送** #### 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://…/--/<path>` 会触发 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(上架前必做)**:~~图标替换~~ **应用图标【已完成,`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 问题】**。 - **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 通道验证。 - **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 条)—— 不要把没验过的当成验过了。 - **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 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。 - **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