diff --git a/AGENTS.md b/AGENTS.md index 2e7cc07..26c38ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before ======================================== 【核心原则】 -亲爱的,你是react native+expo开发工程师,或许某天我将会离开你,你要加油,永远爱你 +亲爱的,你是react native+expo开发工程师。 后端文档:https://s.apifox.cn/a64fdc2b-3980-4dc1-9b02-b1097417e76e 测试号:手机号:19900009021 密码:ZhilaiTest!2026#A @@ -19,14 +19,6 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before 引导页背景应当是蓝和绿的渐变色 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob 严格要求:请在每周的工作日的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库并推送到远端,每天一次,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。 - -> **提交之后必须推送(2026-09-18 补充)**。原约定只写了「提交」,没写「推送」,实际后果是 -> **2026-09-11 至 09-17 整整一周的 12 个提交从未离开本机**,远端一直停在 09-10: -> ① 这段工作只存在于一台机器上,磁盘故障即丢失; -> ② 后端同事(Java + SpringBoot)看不到前端的接口契约; -> ③ 9:20 的「拉取」形同虚设 —— 本地领先 12、落后 0,拉不到任何东西。 -> 故 17:50 的动作是**两步**:`git commit` → `git push`。次日 9:20 拉取前先看 -> `git rev-list --left-right --count HEAD...@{u}`,正常应为 `0 0`。 业务逻辑分层管理,不是没有,而是被妥善组织: +------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+ | 层级 | 职责 | 业务逻辑示例 | diff --git a/app/(enterprise)/_layout.tsx b/app/(enterprise)/_layout.tsx index d09d196..582e61c 100644 --- a/app/(enterprise)/_layout.tsx +++ b/app/(enterprise)/_layout.tsx @@ -1,11 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Tabs } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs'; -import { useFocusEffect } from 'expo-router'; -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { colors } from '../../src/theme'; -import { useRole } from '../../src/context/RoleContext'; /** 企业端默认落地「岗位」Tab(与求职端首屏一致) */ export const unstable_settings = { @@ -141,16 +139,11 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) { * 绿色 success 强调,与求职端蓝色 primary 形成视觉区分。 */ export default function EnterpriseTabLayout() { - const { setRole } = useRole(); - - // 用 useFocusEffect:进入/返回该端时随焦点写入角色, - // 避免「切换到企业端」后返回时 layout 未重新挂载导致 role 残留 candidate。 - useFocusEffect( - useCallback(() => { - setRole('enterprise'); - }, [setRole]), - ); - + /* + * 这里**不再**写角色:原实现在 useFocusEffect 里无条件 `setRole('enterprise')`, + * 等于「点进企业端 Tab 就变成企业账号」,与服务端事实无关。 + * 角色现在只由 AuthContext 按服务器下发维护。 + */ return ( { + const outcome = await switchTo('STUDENT'); + if (!outcome.ok) { + Alert.alert('无法进入求职端', outcome.message ?? '请稍后重试'); + return; + } + router.replace('/(tabs)/jobs'); + }; + return ( { - setRole('candidate'); - router.replace('/(tabs)/jobs'); - }} + style={[styles.candidateCard, switching && styles.candidateCardBusy]} + onPress={() => void handlePress()} + disabled={switching} hitSlop={4} > @@ -528,6 +537,9 @@ const styles = StyleSheet.create({ fontWeight: '500', color: colors.textPrimary, }, + candidateCardBusy: { + opacity: 0.6, + }, candidateCard: { flexDirection: 'row', alignItems: 'center', diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 7ebc81b..b58ad2b 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -1,11 +1,9 @@ import { Ionicons } from '@expo/vector-icons'; import { Tabs } from 'expo-router/js-tabs'; import type { BottomTabBarProps } from 'expo-router/js-tabs'; -import { useFocusEffect } from 'expo-router'; -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { Animated, Easing, Pressable, StyleSheet, View } from 'react-native'; import { colors } from '../../src/theme'; -import { useRole } from '../../src/context/RoleContext'; /** 无 index 路由,默认进入「岗位」Tab 作为落地页 */ export const unstable_settings = { @@ -163,16 +161,12 @@ function TabBar({ state, descriptors, navigation, insets }: BottomTabBarProps) { * 底部 Tab 导航:4 个 Tab(岗位/沟通/成长/我的),高级感极简风格 */ export default function TabLayout() { - const { setRole } = useRole(); - - // 用 useFocusEffect:进入/返回该端时随焦点写入角色, - // 避免「从企业端返回求职端」时 layout 未重新挂载导致 role 残留 enterprise。 - useFocusEffect( - useCallback(() => { - setRole('candidate'); - }, [setRole]), - ); - + /* + * 这里**不再**写角色。 + * 原实现在 useFocusEffect 里无条件 `setRole('candidate')` —— 只要用户点进求职端 Tab, + * 本地账号角色就变成求职者,哪怕服务端记录的是企业;下次冷启动会被直接放进求职端。 + * 角色现在只由 AuthContext 按服务器下发(登录 / 选身份 / /auth/me)维护。 + */ return ( { + const outcome = await switchTo('COMPANY'); + if (!outcome.ok) { + Alert.alert('无法进入企业端', outcome.message ?? '请稍后重试'); + return; + } + router.replace('/(enterprise)/enterprise-overview'); + }; + return ( { - setRole('enterprise'); - router.replace('/(enterprise)/enterprise-overview'); - }} + style={[styles.enterpriseCard, switching && styles.enterpriseCardBusy]} + onPress={() => void handlePress()} + disabled={switching} hitSlop={4} > @@ -712,6 +727,9 @@ const styles = StyleSheet.create({ statValue: { fontSize: 18, fontWeight: '800', color: colors.textPrimary }, statLabel: { fontSize: 12, color: colors.textSecondary, marginTop: 4 }, /* ---- 切换到企业端 ---- */ + enterpriseCardBusy: { + opacity: 0.6, + }, enterpriseCard: { flexDirection: 'row', alignItems: 'center', diff --git a/app/index.tsx b/app/index.tsx index 43b8b51..de4b841 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,19 +1,58 @@ -import { Redirect } from 'expo-router'; -import { StyleSheet, View } from 'react-native'; +import { Redirect, useRouter } from 'expo-router'; +import { useCallback, useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; import { useAuth } from '../src/context/AuthContext'; -import { useRole } from '../src/context/RoleContext'; -import { colors } from '../src/theme'; +import { colors, spacing } from '../src/theme'; /** * 应用总入口(根路由 /)。 - * 按「引导 → 登录 → 选择身份 → 对应端」漏斗放行,保证每个页面都有合理的前置入口。 + * 按「引导 → 登录 → 与服务端核对身份 → 对应端」漏斗放行。 + * + * **角色不以本地缓存为准**:本地存的 role 只代表上次是什么。冷启动有 Token 时先调 + * `/auth/me` 核对,核对成功才跳业务页;核对失败**不**退回本地角色硬跳 —— + * 那会把已被后台改成企业(或已禁用)的账号继续放进求职端,页面按错误身份渲染并一路报错。 + * 失败时停在本页给出重试入口,把决定权交回用户。 */ export default function Index() { - const { onboardingSeen, isTokenValid, isHydrated: authHydrated } = useAuth(); - const { role, isHydrated: roleHydrated } = useRole(); + const { + onboardingSeen, + isTokenValid, + isHydrated: authHydrated, + role, + isRoleVerified, + verifySession, + } = useAuth(); + + const [verifying, setVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(null); + /** 重试计数:仅用于重新触发核对 effect */ + const [attempt, setAttempt] = useState(0); + const router = useRouter(); + + /** 需要核对的条件:本地存储已就绪 + 看过引导 + 有有效 Token + 尚未核对过 */ + const needVerify = authHydrated && onboardingSeen && isTokenValid && !isRoleVerified; + + useEffect(() => { + if (!needVerify) return; + let active = true; + setVerifying(true); + setVerifyError(null); + verifySession() + .catch((e: unknown) => { + if (active) setVerifyError(e instanceof Error ? e : new Error('身份核对失败')); + }) + .finally(() => { + if (active) setVerifying(false); + }); + return () => { + active = false; + }; + }, [needVerify, verifySession, attempt]); + + const retry = useCallback(() => setAttempt((n) => n + 1), []); // 本地存储读取完成前不做任何跳转决策,否则已登录用户会闪现引导页/注册页 - if (!authHydrated || !roleHydrated) { + if (!authHydrated) { return ; } @@ -23,9 +62,36 @@ export default function Index() { if (!isTokenValid) { return ; } + + // 有 Token 但角色未与服务器核对:核对中或失败都不能放行 + if (!isRoleVerified) { + if (verifying) { + return ( + + + 正在确认登录状态… + + ); + } + return ( + + 无法确认登录状态 + {verifyError?.message ?? '请检查网络后重试'} + + 重试 + + router.replace('/register')}> + 重新登录 + + + ); + } + + // 核对通过但服务器说角色未定(UNASSIGNED)→ 去选身份 if (!role) { return ; } + // 两个路由组均无 index,必须用显式落地路由,否则落地页空白 return ( void }) { export default function SettingsScreen() { const router = useRouter(); const { logout } = useAuth(); - const { role, setRole } = useRole(); + const { role } = useRole(); + const { switchTo } = useIdentitySwitch(); const { label: cacheLabel, loading: cacheLoading, @@ -153,14 +154,20 @@ export default function SettingsScreen() { ]); }; - const handleIdentitySwitch = () => { - if (role === 'enterprise') { - setRole('candidate'); - router.replace('/(tabs)/jobs'); - } else { - setRole('enterprise'); - router.replace('/(enterprise)/enterprise-overview'); + /** + * 身份切换:由服务端裁决(`POST /auth/identity/select`),成功即采用返回的角色与凭证。 + * 本地不再直接改角色 —— 那只是改了界面上的一个字段,账号在服务器上没变。 + */ + const handleIdentitySwitch = async () => { + const target = role === 'enterprise' ? 'STUDENT' : 'COMPANY'; + const outcome = await switchTo(target); + if (!outcome.ok) { + Alert.alert('无法切换身份', outcome.message ?? '请稍后重试'); + return; } + router.replace( + outcome.role === 'enterprise' ? '/(enterprise)/enterprise-overview' : '/(tabs)/jobs', + ); }; const handlePress = (item: SettingsItem) => { diff --git a/assets/android-icon-background.png b/assets/android-icon-background.png deleted file mode 100644 index f12af2c..0000000 Binary files a/assets/android-icon-background.png and /dev/null differ diff --git a/assets/android-icon-foreground.png b/assets/android-icon-foreground.png deleted file mode 100644 index 55e304d..0000000 Binary files a/assets/android-icon-foreground.png and /dev/null differ diff --git a/assets/android-icon-monochrome.png b/assets/android-icon-monochrome.png deleted file mode 100644 index aeb4fa5..0000000 Binary files a/assets/android-icon-monochrome.png and /dev/null differ diff --git a/components/onboarding/screens/B3Recruiter.tsx b/components/onboarding/screens/B3Recruiter.tsx index ffd11c8..7cd20b3 100644 --- a/components/onboarding/screens/B3Recruiter.tsx +++ b/components/onboarding/screens/B3Recruiter.tsx @@ -3,6 +3,7 @@ import { Image, Pressable, StyleSheet, Text, TextInput, View } from 'react-nativ import { colors, spacing } from '../../../src/theme'; import { useEnterpriseCertification, + useIdentitySwitch, useImagePick, useOnboarding, useRegisterFinish, @@ -16,7 +17,10 @@ export function B3Recruiter() { const finish = useRegisterFinish(); const { pick, picking, error: pickError } = useImagePick(); const { submit, submitting } = useEnterpriseCertification(); + const { switchTo, switching } = useIdentitySwitch(); const [submitFailed, setSubmitFailed] = useState(false); + /** 服务端返回的失败原因(身份选择或认证提交),比「提交失败」四个字有用 */ + const [failReason, setFailReason] = useState(null); // 三项都是认证必需,缺一项不放行:没有营业执照的「企业认证」提交不出有意义的结果 const canSubmit = @@ -31,10 +35,30 @@ export function B3Recruiter() { }; const handleSubmit = async () => { - if (!canSubmit || submitting) return; + if (!canSubmit || submitting || switching) return; setSubmitFailed(false); + setFailReason(null); try { - // 提交审核状态由接口返回驱动,不再用本地 useState 假装「已提交」 + /* + * 第一步:先让服务端确认这个账号是企业身份。 + * 契约 POST /auth/identity/select 要求选 COMPANY 时必须带 company 资料; + * 这一步成功后才拿得到企业角色,后面的认证提交才有意义 —— 顺序不能反。 + * 「提交认证」也**不等于**「认证通过」:认证结果由审核决定,本地不做任何置位。 + */ + const identityOutcome = await switchTo('COMPANY', { + name: data.recruiterCompany.trim(), + industry: '', + description: '', + website: '', + address: '', + }); + if (!identityOutcome.ok) { + setSubmitFailed(true); + setFailReason(identityOutcome.message ?? '企业身份创建失败,请稍后重试'); + return; + } + + // 第二步:提交企业认证资料,审核状态由接口返回驱动 const res = await submit({ companyName: data.recruiterCompany.trim(), // B3 不采集统一社会信用代码,由企业端「企业认证」页补全(该页已存在且有入口) @@ -46,13 +70,15 @@ export function B3Recruiter() { }); if (!res.success) { setSubmitFailed(true); + setFailReason('认证提交未通过,请核对资料后重试'); return; } + // 落地前 useRegisterFinish 会 flushSave,把认证资料一起落库 await finish('enterprise'); - } catch { - // 真实后端就绪后这里会接到网络异常 + } catch (e) { setSubmitFailed(true); + setFailReason((e as Error).message || '网络异常,请稍后重试'); } }; @@ -96,13 +122,15 @@ export function B3Recruiter() { placeholderTextColor={colors.textHint} /> - {submitFailed ? 认证提交失败,请稍后重试 : null} + {submitFailed ? ( + {failReason ?? '认证提交失败,请稍后重试'} + ) : null} void handleSubmit()} /> diff --git a/components/onboarding/screens/P2PasswordLogin.tsx b/components/onboarding/screens/P2PasswordLogin.tsx index 79c4ef5..5f5692c 100644 --- a/components/onboarding/screens/P2PasswordLogin.tsx +++ b/components/onboarding/screens/P2PasswordLogin.tsx @@ -1,13 +1,10 @@ import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors, spacing } from '../../../src/theme'; -import { useAgreementGate, useOnboarding, usePasswordLogin } from '../../../src/hooks'; +import { useAgreementGate, useLoginLanding, useOnboarding, usePasswordLogin } from '../../../src/hooks'; import { isValidPhone } from '../../../src/utils'; import { useAuth } from '../../../src/context/AuthContext'; -import { useRole } from '../../../src/context/RoleContext'; -import { fetchCurrentUser } from '../../../src/api'; import { PasswordField } from '../../../src/components/PasswordField'; import { ScreenShell } from '../ui/ScreenShell'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; @@ -24,10 +21,9 @@ export function P2PasswordLogin() { const { data, setData, navigate, back } = useOnboarding(); const { gate, modalProps } = useAgreementGate(); const { login, loading, error } = usePasswordLogin(); - /** 双 Token 写入:换取令牌后必须落库,否则冷启动会被打回登录页 */ - const { setSession } = useAuth(); - const { role, setRole } = useRole(); - const router = useRouter(); + /** 会话写入 + 落地判定(双 Token + 服务器角色) */ + const { land, go } = useLoginLanding(); + const { verifySession } = useAuth(); const [password, setPassword] = useState(''); @@ -38,28 +34,34 @@ export function P2PasswordLogin() { const handleLogin = async () => { if (!canSubmit || loading) return; + let result; try { - const result = await login(phone, password); - // 双 Token 落库(access + refresh),后续请求自动带 Bearer - await setSession(result.accessToken, result.refreshToken, result.expiresAt); + result = await login(phone, password); } catch { // 失败原因由 usePasswordLogin 记录,页面下方展示 return; } - - // 角色以 /auth/me 后端返回为准(规范 §5),不一致时纠正路由 - let landingRole = role; - try { - const me = await fetchCurrentUser(); - setRole(me.role); - landingRole = me.role; - } catch { - // /auth/me 失败时退回到本地已选身份,不阻断登录 + // 登录响应里就带角色:直接按它落地(消费一次,不额外打接口) + const target = land(result); + if (target !== 'identity') { + go(target); + return; } - // 已有身份的老用户直接进对应主界面,新用户才去选身份 - if (landingRole) { - router.replace(landingRole === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); + /* + * 登录响应要求或暗示还需选身份时,再用 /auth/me 复核一次: + * 早先这里在 /auth/me 失败后「退回本地已选身份」并继续跳转 —— 本地角色只是上次的 + * 缓存,服务器此刻可能已经把它改了(例如选了企业、又被后台调整)。拿过期缓存把人 + * 放进业务页面,比停下来更糟。现在失败即提示重试,不进业务页。 + */ + try { + const verified = await verifySession(); + if (verified) { + go(verified === 'enterprise' ? 'enterprise' : 'candidate'); + return; + } + } catch (e) { + Alert.alert('无法确认账号身份', (e as Error).message || '请检查网络后重试'); return; } navigate('P3'); diff --git a/components/onboarding/screens/P2PasswordReset.tsx b/components/onboarding/screens/P2PasswordReset.tsx index 4186f6f..6d74882 100644 --- a/components/onboarding/screens/P2PasswordReset.tsx +++ b/components/onboarding/screens/P2PasswordReset.tsx @@ -1,12 +1,10 @@ import { useState } from 'react'; import { Ionicons } from '@expo/vector-icons'; -import { useRouter } from 'expo-router'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { colors, spacing } from '../../../src/theme'; import { useAgreementGate, useOnboarding, usePasswordReset, useSmsCode } from '../../../src/hooks'; import { isValidPhone, isValidSmsCode, SMS_CODE_MAX_LENGTH } from '../../../src/utils'; import { useAuth } from '../../../src/context/AuthContext'; -import { useRole } from '../../../src/context/RoleContext'; import { PasswordField } from '../../../src/components/PasswordField'; import { ScreenShell } from '../ui/ScreenShell'; import { CountdownButton } from '../ui/CountdownButton'; @@ -29,10 +27,12 @@ export function P2PasswordReset() { const { gate, modalProps } = useAgreementGate(); const { reset, loading, error, checkPassword } = usePasswordReset(); const { send: sendCode, error: smsError } = useSmsCode(); - /** 重置成功即写入登录态,用户不必再用新密码登录一次 */ - const { login: writeAuth } = useAuth(); - const { role } = useRole(); - const router = useRouter(); + /** + * 重置密码**不会**返回登录态:契约 /auth/password/reset 的响应体只有 + * {passwordConfigured, reauthenticationRequired}。所以这里不能写会话, + * 只能清掉可能残留的旧会话(改密后旧凭证可能已被后端撤销)并要求重新登录。 + */ + const { logout } = useAuth(); const [stage, setStage] = useState('verify'); const [code, setCode] = useState(''); @@ -66,18 +66,20 @@ export function P2PasswordReset() { let result; try { result = await reset({ phone, code, password }); - writeAuth(result.token); } catch { // 失败原因由 usePasswordReset 记录,页面下方展示 return; } - // 已有身份的老用户直接进对应主界面,新用户才去选身份 - if (role) { - router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); - return; - } - navigate('P3'); + // 旧会话作废:后端未下发新凭证,带着旧 Token 继续用等于用一个可能已失效的身份 + logout(); + Alert.alert( + '密码已设置', + result.reauthenticationRequired + ? '出于安全考虑,请使用新密码重新登录。' + : '请使用新密码登录。', + [{ text: '去登录', onPress: () => navigate('P2P') }], + ); }; return ( diff --git a/components/onboarding/screens/P2PhoneCode.tsx b/components/onboarding/screens/P2PhoneCode.tsx index a948117..4302f6a 100644 --- a/components/onboarding/screens/P2PhoneCode.tsx +++ b/components/onboarding/screens/P2PhoneCode.tsx @@ -4,9 +4,15 @@ import { Alert, Pressable, StyleSheet, Text, TextInput, View } from 'react-nativ import { colors, spacing } from '../../../src/theme'; import { maskPhone } from '../../../src/api'; import { SMS_HELP_BODY, SMS_HELP_TITLE } from '../../../src/constants'; -import { useAgreementGate, useLogin, useOnboarding, useSmsCode, useWechatBindPhone } from '../../../src/hooks'; +import { + useAgreementGate, + useLogin, + useLoginLanding, + useOnboarding, + useSmsCode, + useWechatBindPhone, +} from '../../../src/hooks'; import { isValidPhone, SMS_CODE_MAX_LENGTH, SMS_CODE_MIN_LENGTH } from '../../../src/utils'; -import { useAuth } from '../../../src/context/AuthContext'; import { ScreenShell } from '../ui/ScreenShell'; import { CountdownButton } from '../ui/CountdownButton'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; @@ -22,8 +28,8 @@ export function P2PhoneCode() { const { gate, modalProps } = useAgreementGate(); const { login, loading: logging, error: loginError } = useLogin(); const { send: sendCode, error: smsError } = useSmsCode(); - /** 登录态写入:此前本页只跳 P3 不写 token,导致登录态始终为空 */ - const { login: writeAuth } = useAuth(); + /** 登录成功后的会话写入与落地判定(双 Token + 服务器角色) */ + const { land, go } = useLoginLanding(); const { bind, loading: binding, error: bindError } = useWechatBindPhone(); const [code, setCode] = useState(''); @@ -69,15 +75,20 @@ export function P2PhoneCode() { } catch { return; } - } else { - try { - const result = await login(phone, code); - writeAuth(result.token); - } catch { - return; - } + // 微信首次绑定:身份尚未确定,去选身份 + navigate('P3'); + return; + } + + let result; + try { + // 真实 /auth/login:验证码错误 / 过期由后端拒绝并抛出,不会走到下面的跳转 + result = await login(phone, code); + } catch { + return; } - navigate('P3'); + // 服务器给了明确角色就直达对应端;UNASSIGNED 才去选身份 + if (!go(land(result))) navigate('P3'); }; /** diff --git a/components/onboarding/screens/P2QuickLogin.tsx b/components/onboarding/screens/P2QuickLogin.tsx index d8e6f96..e9ba756 100644 --- a/components/onboarding/screens/P2QuickLogin.tsx +++ b/components/onboarding/screens/P2QuickLogin.tsx @@ -1,8 +1,13 @@ import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { colors, spacing } from '../../../src/theme'; import { CARRIER_LABEL } from '../../../src/constants'; -import { useAgreementGate, useCarrierPhone, useLogin, useOnboarding } from '../../../src/hooks'; -import { useAuth } from '../../../src/context/AuthContext'; +import { + useAgreementGate, + useCarrierPhone, + useLogin, + useLoginLanding, + useOnboarding, +} from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; import { AgreementCheckbox } from '../ui/AgreementCheckbox'; @@ -27,8 +32,8 @@ export function P2QuickLogin() { const { carrierToken, maskedPhone, carrier, ready, loading: fetching, error: fetchError, refresh } = useCarrierPhone(); const { loginByCarrier, loading: logging, error: loginError } = useLogin(); - /** 登录态写入:此前本页只跳 P3 不写 token */ - const { login: writeAuth } = useAuth(); + /** 登录成功后的会话写入与落地判定 */ + const { land, go } = useLoginLanding(); const agreed = data.agreed; const carrierName = CARRIER_LABEL[carrier]; @@ -40,14 +45,14 @@ export function P2QuickLogin() { const handleLogin = async () => { try { const result = await loginByCarrier(carrierToken); - writeAuth(result.token); // 号码写回引导资料:后续步骤(P2P/P2R 预填、B3 联系人电话)都以这里为准 setData({ phone: result.phone }); + // 服务器给了明确角色就直达对应端;UNASSIGNED 才去选身份 + if (!go(land(result))) navigate('P3'); } catch { // 失败原因由 useLogin 记录,页面下方展示 return; } - navigate('P3'); }; /** 运营商认证服务条款由运营商在认证页内提供,本仓库没有该文档,不冒充能打开它 */ diff --git a/components/onboarding/screens/P3Identity.tsx b/components/onboarding/screens/P3Identity.tsx index 6d7026b..454dd11 100644 --- a/components/onboarding/screens/P3Identity.tsx +++ b/components/onboarding/screens/P3Identity.tsx @@ -1,18 +1,34 @@ +import { useState } from 'react'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native'; import { colors, spacing } from '../../../src/theme'; import { useAuth } from '../../../src/context/AuthContext'; -import { useOnboarding } from '../../../src/hooks'; +import { useIdentitySwitch, useOnboarding } from '../../../src/hooks'; import { ScreenShell } from '../ui/ScreenShell'; import { Illustration } from '../ui/Illustration'; export function P3Identity() { const { setData, navigate, back, reset } = useOnboarding(); const { logout } = useAuth(); + const { switchTo, switching } = useIdentitySwitch(); + const [failed, setFailed] = useState(null); - const chooseCandidate = () => { + /** + * 选求职者:**先由服务端确认身份再往下走**。 + * 契约 POST /auth/identity/select 是账号身份的权威来源(并首次下发凭证); + * 早先这里只写本地 data.identity 就跳转,账号在服务端仍是 UNASSIGNED。 + */ + const chooseCandidate = async () => { + setFailed(null); + const outcome = await switchTo('STUDENT'); + if (!outcome.ok) { + setFailed(outcome.message ?? '身份选择失败,请重试'); + return; + } setData({ identity: 'candidate' }); navigate('P4'); }; + + /** 选招聘者:企业资料在 B3 采集,提交时一并调 selectIdentity(契约要求 COMPANY 带 company) */ const chooseRecruiter = () => { setData({ identity: 'recruiter' }); navigate('B3'); @@ -41,7 +57,11 @@ export function P3Identity() { 选择你的身份 - + void chooseCandidate()} + > @@ -61,6 +81,8 @@ export function P3Identity() { + {failed ? {failed} : null} + 退出登录 @@ -82,6 +104,8 @@ const styles = StyleSheet.create({ }, cardBtnOutline: { backgroundColor: colors.white, borderWidth: 1, borderColor: colors.primary }, cardBtnText: { fontSize: 16, fontWeight: '700', color: colors.textInverse }, + cardBusy: { opacity: 0.6 }, + error: { marginTop: 12, textAlign: 'center', fontSize: 12, color: colors.dangerText }, or: { textAlign: 'center', color: colors.textHint, marginVertical: spacing.block, fontSize: 13 }, logout: { marginTop: spacing.section, alignItems: 'center' }, logoutText: { fontSize: 13, color: colors.textHint }, diff --git a/docs/applications-500-diagnosis.md b/docs/applications-500-diagnosis.md deleted file mode 100644 index fe23ac9..0000000 --- a/docs/applications-500-diagnosis.md +++ /dev/null @@ -1,196 +0,0 @@ -# `/applications` 接口 500 问题诊断报告 - -> 前端联调:2026-09-29 -> 测试账号:`19900009021` / `ZhilaiTest!2026#A` -> 后端地址:`https://job.youthome.com.cn/api/v1` - ---- - -## 问题复现 - -| 请求 | HTTP | code | message | -|---|---|---|---| -| `GET /applications`(带 Bearer token) | **500** | 500 | `internal server error` | -| `GET /applications?page=0&size=20`(带 token) | **500** | 500 | `internal server error` | -| `GET /applications`(不带 token) | 401 | 401 | `not authenticated` | -| `GET /applications/me`(带 token) | 200 | 200 | `ok` ✓ | -| `POST /applications`(带 token,投递) | 404 | 7001 | `job not found` ✓(正确路由,只是 jobId 不存在) | -| `GET /applications/me/timeline`(带 token) | 404 | 8002 | `application not found` ✓(正确路由) | - -**结论:`GET /applications` 带 token 必现 HTTP 500**,是后端 bug。 - ---- - -## 根因分析 - -### 1. OpenAPI 文档中根本没有定义 `GET /applications` - -后端公开的 28 个端点里,applications 模块只有 **5 个**: - -| 方法 | 路径 | 用途 | 状态 | -|---|---|---|---| -| POST | `/applications` | 提交投递 | ✅ 正常(jobId 不存在时返回 404) | -| GET | `/applications/me` | 我的投递列表(分页) | ✅ 正常(返回 200 + 分页结构) | -| GET | `/applications/{id}` | 投递详情 | ✅ 正常(id 不存在返回 404) | -| PATCH | `/applications/{id}/status` | 企业改投递状态 | — 未测 | -| POST | `/applications/{id}/withdraw` | 撤回投递 | — 未测 | -| GET | `/applications/{id}/timeline` | 状态时间线 | ✅ 正常(id 不存在返回 404) | - -**没有 `GET /applications` 这条路由。** - -### 2. 为什么不是 404 而是 500? - -这是 Spring Boot Controller **路由冲突** 导致的。 - -后端 Controller 大概率写了: - -```java -@RestController -@RequestMapping("/api/v1/applications") -public class ApplicationController { - - @GetMapping("/me") // GET /applications/me → 200 ✓ - public Page listMine() { ... } - - @GetMapping("/{id}") // GET /applications/{id} → 404(id 不存在)✓ - public Application getById(@PathVariable Long id) { ... } - - @PostMapping("") // POST /applications → 404(jobId 不存在)✓ - public Application create(@RequestBody ...) { ... } - - // 但没有写 @GetMapping("")! -} -``` - -当请求 `GET /applications` 进来时: -- Spring 路由解析器在候选路由里找匹配 -- `/applications/me` 需要路径是 `/me`,不匹配 -- **`/{id}` 匹配成功** —— `id` 被解析为字符串 `"applications"` -- 但方法签名上 `@PathVariable Long id` 期望 `Long` 类型 -- Spring 尝试把 `"applications"` 转成 `Long` → **类型转换异常** -- 全局异常处理器没兜住这个异常 → 返回 HTTP 500 `internal server error` - -这是 Spring Boot Controller 常见的路由设计陷阱:**一个 Controller 既有 `/{id}` 又有 `/me`,但忘了写根路径 `@GetMapping("")` 或显式拒绝根路径的 GET 请求**。 - ---- - -## 修复方案(后端二选一) - -### 方案 A:显式拦截并返回 404(推荐,防御性编程) - -```java -@GetMapping("") -public ResponseEntity listAll() { - // applications 没有"列出所有投递"这个语义 - return ResponseEntity.notFound().build(); -} -``` - -### 方案 B:确认不需要这个路由,忽略 - -如果后端根本不打算提供 `GET /applications`(全量投递列表——企业端才有意义,而且需要按企业 ID 过滤),那 Spring Boot 默认会 404。但 **当前 500 是路由冲突导致的**,需要清理路由或者统一异常处理。 - -### 方案 C:按角色分发(如果后端真打算提供) - -```java -@GetMapping("") -public ResponseEntity listAll(Authentication auth) { - // STUDENT 应该只查自己的 → 404 引导去 /applications/me - // COMPANY 可以查自己企业下的投递 → 需要 companyId 过滤 - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "code", 404, "message", "这个路由不存在,请使用 /applications/me 或 /applications/{id}" - )); -} -``` - ---- - -## 其他建议 - -### 1. Spring Boot 的 `/{id}` 路由建议加类型约束 - -```java -// 只匹配数字 ID,排除 "/me" 等子路径 -@GetMapping("/{id:\\d+}") -public Application getById(@PathVariable Long id) { ... } -``` - -这样 `GET /applications` 和 `GET /applications/me` 不会被 `/{id}` 吃掉。 - -### 2. 建议加 Controller 测试 - -```java -@WebMvcTest(ApplicationController.class) -class ApplicationControllerTest { - @Test - void get_root_shouldReturn404() throws Exception { - mockMvc.perform(get("/api/v1/applications")) - .andExpect(status().isNotFound()); - } -} -``` - -### 3. 统一异常处理加类型转换兜底 - -```java -@ExceptionHandler(MethodArgumentTypeMismatchException.class) -public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( - "code", 404, - "message", "资源不存在" - )); -} -``` - ---- - -## 给后端同事的话 - -> XX,`GET /applications`(不带 /me)带 token 返回 500 我测出来了,OpenAPI 文档里也没定义这个路由。 -> -> 根因是 Spring 路由冲突:Controller 里有 `GET /applications/{id}` 但没有 `GET /applications`,所以 `"applications"` 这个字符串被当成了 `@PathVariable Long id` 传给方法,类型转换失败抛异常,全局异常处理器没兜住就 500 了。 -> -> 前端这边**不需要这个路由**——学生端调的是 `/applications/me`(正常),投递调的是 `POST /applications`(正常),详情调的是 `GET /applications/{id}`(正常)。所以最简单的修复就是给 Controller 加个 `@GetMapping("")` 返回 404,或者 `/{id}` 加类型约束 `@GetMapping("/{id:\\d+}")` 排除子路径。 -> -> 具体怎么修看你那边代码风格,上面三个方案选一个就行。 - ---- - -## 附:其他需要后端补的端点清单 - -(来自全量联调扫描,前端有调用但后端没端点) - -### 🔴 高优先级 - -| 前端路径 | 用途 | 前端函数 | -|---|---|---| -| `GET /jobs/{id}/score` | 岗位综合评分(8 维度雷达图) | `fetchJobScore` | -| `GET /jobs/{id}/salary` | 薪资分布数据 | `fetchJobSalary` | -| `GET /jobs/{id}/reviews` | 面经/岗位评价列表 | `fetchJobReviews` | -| `GET /jobs/{id}/competition-ranking` | 同岗位竞争力排名 | `fetchRankData` | -| `GET /profile/me/career-progress` | 求职进度时间轴 | `fetchJobProgress` | -| `GET /jobs/favorites` | 我的收藏岗位 | `fetchFavorites` | -| `GET /conversations` | 沟通会话列表 | `fetchConversations` | -| `GET /notifications` | 系统通知 | `fetchNotifications` | - -### 🟡 中优先级 - -| 前端路径 | 用途 | 前端函数 | -|---|---|---| -| `GET /jobs/compare` | 岗位对比 | `fetchJobCompare` | -| `GET /offers/compare` | Offer 对比 | `fetchOfferCompare` | -| `GET /profile/me/preferences` | 求职偏好 | `fetchJobPreference` | -| `PUT /profile/me/preferences` | 保存求职偏好 | 更新 | -| `POST /jobs/{id}/reviews` | 发布评价 | `submitReview` | -| `GET /interviews/me` | 面试列表 | `fetchMyInterviews` | -| `GET /interviews/{id}/score` | 面试评分 | `fetchInterviewScore` | -| `GET /company/candidates/{id}` | 候选人详情 | `fetchCandidateDetail` | - -### 🔴 已对齐但后端仍有问题 - -| 端点 | 问题 | -|---|---| -| `POST /auth/sms/send` | `sms sender unavailable`(短信网关未通,无 mock code 旁路) | -| `GET /jobs/search` / `GET /jobs` / `GET /companies/{id}/jobs` | `items: []`(后端无 job seed 数据) | -| `GET /profile/me` | 返回空对象 `{}`(后端未定义 STUDENT profile 字段) | -| `GET /companies/{id}` | 只有 id=1 有数据(企业 seed 不足) | diff --git a/docs/联调路径校正映射.md b/docs/联调路径校正映射.md deleted file mode 100644 index 6ffee61..0000000 --- a/docs/联调路径校正映射.md +++ /dev/null @@ -1,109 +0,0 @@ -# 联调路径校正映射表(基于 job.youthome.com.cn OpenAPI 实测) - -> 生成日期:2026-09-28 | 数据源:`scripts/openapi.json`(182 条真实路径 / 220 path-methods) -> 审计范围:api.ts 内全部 `request()` 调用(89 个函数) -> 结论:ALIGNED(已对齐)= 6,WRONG→REAL(路径错但有真实等价)= 14,PHANTOM(幽灵域,后端无契约)= 69 - -## 一、已对齐(无需改路径) - -| 函数 | 真实路径 | 原标记 | -|---|---|---| -| `createJobReview` | `POST /jobs/{jobId}/reviews` | 已校正 | -| `fetchCompareData` | `/jobs/compare` | 已校正 | -| `fetchEmployeeReviews` | `GET /jobs/{jobId}/reviews` | 已校正 | -| `fetchJobDetail` | `GET /jobs/{jobId}` | 已校正 | -| `fetchOfferCompareData` | `GET /offers/compare` | 【待确认】 | -| `fetchUserProfile` | `GET /profile/me` | 已校正 | - -## 二、路径错但有真实等价端点(需改路径) - -| 函数 | 当前(错) | 应改为(真实) | 说明 | -|---|---|---|---| -| `fetchCandidateDetail` | `/enterprise/candidates/${candidateId}` | `/company/candidates/{applicationId}` | 前端猜 /enterprise/candidates,真路径在 /company 域 | -| `fetchConversation` | `/chat/conversations/${id}` | `GET /conversations/{id}` | 前端猜 /chat/conversations,真路径 /conversations/{id} | -| `fetchConversations` | `/messages/conversations` | `GET /conversations` | 前端猜 /messages/conversations,真 /conversations | -| `fetchInterviewScore` | `/enterprise/interviews/${interviewId}/score` | `GET /interviews/{interviewId}/score` | 前端猜 /enterprise/interviews,真路径在 /interviews 域 | -| `fetchInterviewScoreDraft` | `/enterprise/interviews/${interviewId}/score/draft` | `PUT /interviews/{interviewId}/score` | 真路径无 /draft 子路径,保存草稿用 PUT /interviews/{id}/score | -| `fetchInterviews` | `/messages/interviews` | `GET /interviews/me` | 前端猜 /messages/interviews,真 /interviews/me(我的面试列表) | -| `fetchJobPreference` | `/profile/me/job-preference` | `GET/PUT /profile/me/preferences` | 前端猜 /profile/me/job-preference,真路径 /profile/me/preferences | -| `fetchJobProgress` | `/tools/job-progress` | `GET /profile/me/career-progress` | 前端猜 /tools/job-progress,真 /profile/me/career-progress(统一求职进度聚合) | -| `fetchMatchResult` | `/messages/match-result` | `GET /applications/{id}/match 或 /jobs/{jobId}/match` | 前端猜 /messages/match-result,真为投递/岗位匹配诊断 | -| `fetchNotifications` | `/messages/notifications` | `GET /notifications` | 前端猜 /messages/notifications,真 /notifications | -| `fetchRankData` | `/jobs/rank` | `GET /jobs/{jobId}/competition-ranking` | 前端猜 /jobs/rank,真为按岗位竞争力排名,语义不同(需 jobId) | -| `fetchResume` | `/resume` | `GET /profile/me/resume` | 前端猜 /resume,真 /profile/me/resume(下载简历) | -| `saveJobPreference` | `/profile/me/job-preference` | `PUT /profile/me/preferences` | 同上 | -| `saveResumeBasicInfo` | `/resume/basic-info` | `POST /profile/me/resume (multipart)` | 前端猜 /resume/basic-info,真为上传简历文件(multipart),结构化字段在 PUT /profile/me | - -## 三、幽灵域(后端 OpenAPI 无契约,必须退回 mock-only) - -| 函数 | 当前(幽灵)路径 | 说明 | -|---|---|---| -| `deleteSalaryRecord` | `/salary/records/${id}` | 无 /salary 域,后端未提供 | -| `fetchAIInterviewData` | `/interview/config` | 未命中 OpenAPI | -| `fetchAITools` | `/growth/ai-tools` | 未命中 OpenAPI | -| `fetchAbilityAssessment` | `/growth/ability` | 未命中 OpenAPI | -| `fetchAboutInfo` | `/settings/about` | 未命中 OpenAPI | -| `fetchAccountCancel` | `/settings/account-cancel` | 未命中 OpenAPI | -| `fetchAccountSecurity` | `/settings/account-security` | 未命中 OpenAPI | -| `fetchAppealForm` | `/evaluation/appeal` | 未命中 OpenAPI | -| `fetchArticles` | `/growth/articles` | 未命中 OpenAPI | -| `fetchAuditReport` | `/operation/audit-report` | 未命中 OpenAPI | -| `fetchBadges` | `/user/badges` | 未命中 OpenAPI | -| `fetchDimensionScores` | `/user/dimension-scores` | 未命中 OpenAPI | -| `fetchDndSetting` | `/settings/privacy/dnd` | 未命中 OpenAPI | -| `fetchEnterpriseCertification` | `/enterprise/certification` | 未命中 OpenAPI | -| `fetchEnterpriseCommunication` | `/enterprise/communication` | 未命中 OpenAPI | -| `fetchEnterpriseEntry` | `/user/enterprise-entry` | 未命中 OpenAPI | -| `fetchEnterpriseOverview` | `/enterprise/overview` | 未命中 OpenAPI | -| `fetchGameLevels` | `/game/levels` | 未命中 OpenAPI | -| `fetchGameProgress` | `/game/progress` | 未命中 OpenAPI | -| `fetchHREvaluation` | `/evaluation/hr` | 未命中 OpenAPI | -| `fetchHollandAssessment` | `/growth/holland` | 未命中 OpenAPI | -| `fetchHollandIntro` | `/growth/holland-intro` | 未命中 OpenAPI | -| `fetchIdentityOptions` | `/tools/identity-options` | 未命中 OpenAPI | -| `fetchJobEvaluationEmptyState` | `/enterprise/job-evaluation-empty` | 未命中 OpenAPI | -| `fetchJobEvaluationManagement` | `/enterprise/job-evaluations` | 未命中 OpenAPI | -| `fetchJobManagement` | `/enterprise/job-management` | 未命中 OpenAPI | -| `fetchJobOverallScore` | `/jobs/${id}/score` | 无 /jobs/{id}/score,综合分见 /jobs/{id}/competition-ranking 或 /profile/me/candidate-scoring | -| `fetchJobPublishForm` | `/enterprise/jobs/publish-form` | 未命中 OpenAPI | -| `fetchKeywordBlock` | `/settings/privacy/keywords` | 未命中 OpenAPI | -| `fetchLegalBrand` | `/legal/brand` | 未命中 OpenAPI | -| `fetchLegalConversation` | `/legal/conversations/default` | 未命中 OpenAPI | -| `fetchLegalDocument` | `/settings/legal-document/${id}` | 无 /legal 域,后端未提供 | -| `fetchLegalFAQs` | `/legal/faqs` | 未命中 OpenAPI | -| `fetchLegalMembership` | `/legal/membership` | 未命中 OpenAPI | -| `fetchLegalOrders` | `/legal/orders` | 未命中 OpenAPI | -| `fetchLegalPlans` | `/legal/plans` | 未命中 OpenAPI | -| `fetchLegalServiceRequests` | `/legal/service-requests` | 未命中 OpenAPI | -| `fetchLegalServices` | `/legal/services` | 未命中 OpenAPI | -| `fetchLegalTemplates` | `/legal/templates` | 未命中 OpenAPI | -| `fetchMenuItems` | `/user/menus` | 未命中 OpenAPI | -| `fetchNotificationSettings` | `/settings/notification` | 未命中 OpenAPI | -| `fetchOperationRules` | `/operation/rules` | 未命中 OpenAPI | -| `fetchOverallScore` | `/user/overall-score` | 未命中 OpenAPI | -| `fetchPendingOfferCount` | `/offers/pending/count` | 无 /offers/pending/count,可用 /offers/statistics 替代(需确认) | -| `fetchPersonalDataExport` | `/settings/personal-data-export` | 未命中 OpenAPI | -| `fetchPrivacyCenter` | `/settings/privacy-center` | 未命中 OpenAPI | -| `fetchPrivacyDetailSetting` | `/settings/privacy/${id}` | 无 /settings 域,后端未提供 | -| `fetchPrivacySettings` | `/settings/privacy` | 未命中 OpenAPI | -| `fetchQualifications` | `/settings/qualifications` | 未命中 OpenAPI | -| `fetchResumeExportSettings` | `/resume/export-settings` | 无 /resume/export-settings,导出设置本地维护 | -| `fetchResumeSavedAt` | `/resume/saved-at` | 无 /resume/saved-at,简历保存时间可由 /profile/me/resume-versions 派生 | -| `fetchSalaryBrackets` | `/tools/salary-brackets` | 未命中 OpenAPI | -| `fetchSalaryData` | `/jobs/${id}/salary` | 无 /jobs/{id}/salary,后端未提供薪资明细端点 | -| `fetchSalaryRecords` | `/salary/records` | 未命中 OpenAPI | -| `fetchSalaryToolLinks` | `/tools/salary-related` | 未命中 OpenAPI | -| `fetchSkillCloud` | `/tools/skill-cloud` | 未命中 OpenAPI | -| `fetchSkillLearningPath` | `/tools/skill-learning-path` | 未命中 OpenAPI | -| `fetchTalentRecommendation` | `/enterprise/talent-recommendation` | 未命中 OpenAPI | -| `fetchUserStats` | `/user/stats` | 未命中 OpenAPI | -| `fetchWeightSettings` | `/jobs/weight-settings` | 无 /jobs/weight-settings,权重见 /profile/me/review-weights、/review-weight-templates | -| `saveResumeEducation` | `/resume/education` | 无 /resume/education,简历整体由 /profile/me/resume + resume-versions 管理 | -| `saveResumeExperience` | `/resume/experience` | 同上 | -| `saveResumeExportSettings` | `/resume/export-settings` | 未命中 OpenAPI | -| `saveResumeProjects` | `/resume/projects` | 同上 | -| `saveResumeSelfEvaluation` | `/resume/self-evaluation` | 同上 | -| `saveResumeSkills` | `/resume/skills` | 同上 | -| `saveResumeSnapshot` | `/resume/save` | 同上 | -| `saveSalaryRecord` | `/salary/records` | 无 /salary/records | -| `saveWeightSettings` | `/weight-settings` | 无 /weight-settings | diff --git a/src/__tests__/http.test.ts b/src/__tests__/http.test.ts index d023902..60a974f 100644 --- a/src/__tests__/http.test.ts +++ b/src/__tests__/http.test.ts @@ -245,8 +245,13 @@ describe('mapBackendRole(规范 §5 角色映射)', () => { expect(mapBackendRole('ADMIN')).toBe('admin'); }); - it('认不出来时降级为 candidate,不臆造身份', () => { - expect(mapBackendRole('UNKNOWN')).toBe('candidate'); - expect(mapBackendRole('')).toBe('candidate'); + it('UNASSIGNED(未选身份)返回 null,不得当成求职者放行', () => { + // 新手机号首次验证码登录即为 UNASSIGNED,此时必须进身份选择页 + expect(mapBackendRole('UNASSIGNED')).toBeNull(); + }); + + it('认不出来的角色返回 null,不臆造身份', () => { + expect(mapBackendRole('UNKNOWN')).toBeNull(); + expect(mapBackendRole('')).toBeNull(); }); }); diff --git a/src/__tests__/password.test.ts b/src/__tests__/password.test.ts index 070d822..62aed9e 100644 --- a/src/__tests__/password.test.ts +++ b/src/__tests__/password.test.ts @@ -208,6 +208,9 @@ describe('sendSmsCode(发送验证码 · 真实后端)', () => { }); describe('resetPassword(重置 / 首次设置密码)', () => { + // 与改密域一致:本域默认跑 mock(真实路径由下面的「真实模式」用例单独覆盖) + beforeAll(() => setMockMode(true)); + it('验证码不合法时拒绝', async () => { await expect( resetPassword({ phone: '13800138000', code: '12', password: 'abc12345' }), @@ -220,14 +223,45 @@ describe('resetPassword(重置 / 首次设置密码)', () => { ).rejects.toThrow(PASSWORD_RULE_LABEL['need-letter-digit']); }); - it('参数合法时直接下发登录态,用户无需再登录一次', async () => { + it('参数合法时返回密码操作结果,**不返回任何 Token**', async () => { const result = await resetPassword({ phone: '13800138000', code: '1234', password: 'abc12345', }); - expect(result.phone).toBe('13800138000'); - expect(result.token).toContain('13800138000'); + // 契约 /auth/password/reset 只回 {passwordConfigured, reauthenticationRequired} + expect(result.passwordConfigured).toBe(true); + expect(result).not.toHaveProperty('token'); + }); + + it('真实模式:POST /auth/password/reset,body 用契约字段 smsCode / newPassword', async () => { + setMockMode(false); + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => + makeResponse(200, { + code: 200, + message: 'ok', + data: { passwordConfigured: true, reauthenticationRequired: true }, + requestId: 'r', + timestamp: 't', + }), + ); + const result = await resetPassword({ + phone: '13800138000', + code: '1234', + password: 'abc12345', + }); + const fetchMock = (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock; + const [calledUrl, calledInit] = fetchMock.mock.calls[0]; + expect(calledUrl).toContain('/auth/password/reset'); + expect(calledInit.method).toBe('POST'); + // 契约字段名是 smsCode / newPassword,发 code / password 会被判为缺失 + expect(JSON.parse(String(calledInit.body))).toEqual({ + phone: '13800138000', + smsCode: '1234', + newPassword: 'abc12345', + }); + expect(result.reauthenticationRequired).toBe(true); + setMockMode(true); }); }); @@ -252,9 +286,41 @@ describe('changePassword(已登录态改密)', () => { ).rejects.toThrow(PASSWORD_RULE_LABEL['same-as-old']); }); - it('参数合法时成功', async () => { - await expect( - changePassword({ oldPassword: 'old12345', newPassword: 'abc12345', confirmPassword: 'abc12345' }), - ).resolves.toBeUndefined(); + it('参数合法时返回密码操作结果', async () => { + const result = await changePassword({ + oldPassword: 'old12345', + newPassword: 'abc12345', + confirmPassword: 'abc12345', + }); + expect(result.passwordConfigured).toBe(true); + }); + + it('真实模式:POST /auth/password/change,原密码字段名是 currentPassword', async () => { + setMockMode(false); + (global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async () => + makeResponse(200, { + code: 200, + message: 'ok', + data: { passwordConfigured: true, reauthenticationRequired: false }, + requestId: 'r', + timestamp: 't', + }), + ); + const result = await changePassword({ + oldPassword: 'old12345', + newPassword: 'abc12345', + confirmPassword: 'abc12345', + }); + const fetchMock = (global as unknown as { fetch: jest.Mock }).fetch as jest.Mock; + const [calledUrl, calledInit] = fetchMock.mock.calls[0]; + expect(calledUrl).toContain('/auth/password/change'); + expect(calledInit.method).toBe('POST'); + // 发 oldPassword 会让后端认为原密码为空,改密必然失败 + expect(JSON.parse(String(calledInit.body))).toEqual({ + currentPassword: 'old12345', + newPassword: 'abc12345', + }); + expect(result.reauthenticationRequired).toBe(false); + setMockMode(true); }); }); diff --git a/src/api/auth.ts b/src/api/auth.ts index 3afc1a9..d9f3748 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,4 +1,4 @@ -import { request, isMockMode } from './http'; +import { request, isMockMode, toExpiresAt } from './http'; import type { AppRole } from '../types'; /* ===================== 鉴权接口(真实后端) ===================== @@ -6,15 +6,23 @@ import type { AppRole } from '../types'; * 客户端不提交 role / userId / verified / passwordHash(后端生成)。 */ -/** 后端下发的角色值(规范 §5) */ -export type BackendRole = 'STUDENT' | 'COMPANY' | 'ADMIN'; +/** + * 后端下发的角色值(规范 §5)。 + * `UNASSIGNED` 是「已注册但还没选身份」的统一账号状态(新手机号首次验证码登录即此态), + * 后端契约 `POST /auth/login` 明写:新手机号创建 UNASSIGNED 账号,此时 + * `identitySelectionRequired=true`,前端必须进入首次身份选择。 + */ +export type BackendRole = 'STUDENT' | 'COMPANY' | 'ADMIN' | 'UNASSIGNED'; /** * 后端角色 → 应用内部角色(§5:以 /auth/me 返回为准)。 * STUDENT→candidate(求职者)、COMPANY→enterprise(企业)、ADMIN→admin(管理员)。 - * 认不出来时降级为 candidate,不臆造身份。 + * + * 认不出来一律返回 **null**,不再降级成 candidate: + * UNASSIGNED 账号被当成求职者,用户会以「还没选身份」的账号直接落到求职端主界面; + * 后端将来新增角色时同样会被静默错认。null 会强制调用方去走身份选择分支。 */ -export function mapBackendRole(role: string): AppRole { +export function mapBackendRole(role: string): AppRole | null { switch (role) { case 'STUDENT': return 'candidate'; @@ -23,7 +31,7 @@ export function mapBackendRole(role: string): AppRole { case 'ADMIN': return 'admin'; default: - return 'candidate'; + return null; } } @@ -43,13 +51,17 @@ export interface AuthLoginData extends AuthTokens { userId: number; phone: string; nickname: string; + /** 后端原始角色值;已由调用方经 mapBackendRole 映射,UNASSIGNED 表示还需选身份 */ role: BackendRole; -} - -/** 后端 expiresIn(秒)→ expiresAt(毫秒,从现在起算) */ -function toExpiresAt(expiresIn?: number): number | undefined { - if (!expiresIn || expiresIn <= 0) return undefined; - return Date.now() + expiresIn * 1000; + /** + * 是否还需完成首次身份选择(后端契约 `identitySelectionRequired`)。 + * 为 true 时必须进身份选择页,不能直接落地任何一端。 + */ + identitySelectionRequired?: boolean; + /** 企业 id(选 COMPANY 或已是企业账号时下发) */ + companyId?: number; + /** 企业入驻状态(onboardingStatus) */ + onboardingStatus?: string; } export interface SmsSendPayload { @@ -105,6 +117,9 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise('/auth/password/login', { method: 'POST', auth: false, body: payload }); return { userId: raw.userId, @@ -114,6 +129,9 @@ export async function loginByPassword(payload: PasswordLoginPayload): Promise { + if (!isValidPhone(payload.phone)) { + throw new Error('手机号格式不正确'); + } if (isMockMode()) { const mock = mockAuthTokens(`sms_${payload.phone}`); return { @@ -144,6 +171,7 @@ export async function loginBySms(payload: SmsLoginPayload): Promise('/auth/login', { method: 'POST', auth: false, body: payload }); + identitySelectionRequired?: boolean; + companyId?: number; + onboardingStatus?: string; + }>('/auth/login', { + method: 'POST', + auth: false, + body: { phone: payload.phone, code: payload.code, purpose: payload.purpose ?? 'login' }, + }); return { userId: raw.userId, phone: raw.phone, @@ -163,6 +198,9 @@ export async function loginBySms(payload: SmsLoginPayload): Promise { verified: boolean; active: boolean; createdAt?: string; - }>('/auth/me', { method: 'GET' }); + }>('/auth/me', { method: 'GET', auth: true }); return { id: String(raw.id), role: mapBackendRole(raw.role), @@ -335,6 +376,7 @@ export async function selectIdentity(payload: IdentitySelectionPayload): Promise const raw = await request<{ userId: number; role: BackendRole; + nickname?: string; identitySelectionRequired: boolean; companyId?: number; onboardingStatus?: string; @@ -345,8 +387,9 @@ export async function selectIdentity(payload: IdentitySelectionPayload): Promise }>('/auth/identity/select', { method: 'POST', auth: true, body: payload }); return { userId: raw.userId, + // 契约未下发 phone / nickname 时留空,不再回填 'Mock 用户' 这种假值 phone: '', - nickname: 'Mock 用户', + nickname: raw.nickname ?? '', role: raw.role, accessToken: raw.accessToken, refreshToken: raw.refreshToken, diff --git a/src/api/http.ts b/src/api/http.ts index 60d9fdd..7046d66 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -97,6 +97,35 @@ export function setUnauthorizedHandler(handler: () => void): void { unauthorizedHandler = handler; } +/** + * 刷新成功回调:由 AuthContext 注册。 + * + * 刷新发生在请求层内部(401 拦截),AuthContext 并不知情 —— 不通知它的话, + * token-store 已换成新 Token,而 AuthContext 里仍是旧 token + 旧 expiresAt, + * 两份状态从此分叉:`isTokenValid` 会用过期时间把已续期的用户判成未登录。 + * 故刷新成功后回调,让 AuthContext 把同一份新凭证写入自己的 state。 + */ +let sessionRefreshedHandler: + | ((accessToken: string, refreshToken: string, expiresAt?: number) => void) + | null = null; +export function setSessionRefreshedHandler( + handler: (accessToken: string, refreshToken: string, expiresAt?: number) => void, +): void { + sessionRefreshedHandler = handler; +} + +/** + * 后端 `expiresIn`(秒级 TTL)→ 本地绝对过期时间戳(毫秒)。 + * + * 契约 `/auth/login`、`/auth/refresh`、`/auth/identity/select` 下发的都是 `expiresIn`, + * 早先请求层读的是并不存在的 `expiresAt`,于是每次刷新后到期时间都丢失。 + * 放在 http 层是因为 auth.ts 依赖本文件(反向 import 会成环),两边共用这一份。 + */ +export function toExpiresAt(expiresIn?: number): number | undefined { + if (!expiresIn || expiresIn <= 0) return undefined; + return Date.now() + expiresIn * 1000; +} + /** 单飞刷新闸门:并发 401 只触发一次刷新(§4 多标签并发互斥的移动端等价物) */ let refreshPromise: Promise | null = null; @@ -144,17 +173,21 @@ async function doRefresh(): Promise { unauthorizedHandler?.(); return false; } + // 契约返回 expiresIn(秒),不是 expiresAt;转换后才是本地到期时间戳 const result = await rawRequest<{ accessToken: string; refreshToken: string; - expiresAt?: number; + expiresIn?: number; }>('/auth/refresh', { method: 'POST', body: { refreshToken: tokens.refreshToken }, auth: false, _isRefresh: true, }); - await setTokens(result.accessToken, result.refreshToken, result.expiresAt); + const expiresAt = toExpiresAt(result.expiresIn); + await setTokens(result.accessToken, result.refreshToken, expiresAt); + // 同步通知 AuthContext,避免「token-store 已续期、Context 还以为过期」的分叉 + sessionRefreshedHandler?.(result.accessToken, result.refreshToken, expiresAt); return true; } catch { await clearTokens(); diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx index f72f531..967ab57 100644 --- a/src/context/AuthContext.tsx +++ b/src/context/AuthContext.tsx @@ -1,63 +1,92 @@ -import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; import { storageGet, storageSet } from '../lib/storage'; import { TOKEN_TTL_MS } from '../constants'; import { clearTokens, setTokens, hydrateTokens } from '../lib/token-store'; -import { setUnauthorizedHandler } from '../api/http'; +import { setUnauthorizedHandler, setSessionRefreshedHandler } from '../api/http'; +import { fetchCurrentUser, mapBackendRole } from '../api/auth'; +import type { AppRole } from '../types'; /* ===================== 鉴权(引导/登录)上下文 ===================== - * 管理「引导页是否看过」「是否已登录」「登录令牌」「分端标记」。 - * 与 RoleContext 配合形成完整漏斗: - * 首次启动 → 引导页 → 登录页 → 选择身份(identity-select) → 分端设置 → 对应端 - * - 求职者:求职意向设置(job-preference) → 求职端 (tabs) - * - 招聘者:企业认证(enterprise-certification) → 企业端 (enterprise) - * 两个分端设置步骤均允许跳过,设置完成后置位对应标记,不再重复强制。 + * 管理「引导页是否看过」「是否已登录」「会话凭证」「服务器下发的角色」。 * - * 持久化:已接入 @react-native-async-storage/async-storage, - * 五字段以 JSON 整体存入 'auth' 键,App 重启后自动恢复引导/登录/身份态。 + * 三条硬规则(对应本轮修复的会话/身份缺陷): + * 1. **只有一个会话写入入口**:`applySession`。它同时写 access + refresh + 到期时间 + + * 角色,并同步 token-store。此前的 `login(token)` 只写 accessToken、不写 refresh、 + * 不碰 token-store —— 页面看起来「登录成功」,真实请求却一个 Bearer 都带不出去。 + * 2. **角色只认服务器**:本地不做任何身份推断,`role` 来自登录/选身份/`/auth/me` 的响应。 + * 后端返回 UNASSIGNED 或认不出的角色 → `role` 为 null → 页面必须进身份选择, + * 不能当求职者放行。 + * 3. **接口核对优先于本地缓存**:`verifySession()`(调 `/auth/me`)失败会抛出, + * 调用方要么重试要么要求重新登录,绝不静默回退本地角色并跳进业务页面。 + * + * 持久化:整体以 JSON 存入 'auth' 键,App 重启后自动恢复。 */ +/** 建立会话所需的全部信息(双 Token + 服务器角色) */ +export interface SessionPayload { + accessToken: string; + refreshToken: string; + /** 到期时间戳(毫秒);不传表示后端未下发 TTL */ + expiresAt?: number; + /** 后端原始角色值(STUDENT / COMPANY / ADMIN / UNASSIGNED) */ + backendRole?: string; + /** 是否还需完成首次身份选择 */ + identitySelectionRequired?: boolean; +} + export interface AuthContextValue { /** 引导页是否已看完(首次启动展示引导页) */ onboardingSeen: boolean; /** 是否已登录 */ isLoggedIn: boolean; - /** 登录令牌(mock 态为占位串) */ + /** 访问令牌 */ token: string | null; - /** 刷新令牌(双 Token 方案;mock 态为 null) */ + /** 刷新令牌 */ refreshToken: string | null; - /** 令牌过期时间戳(毫秒);null 表示无有效令牌 */ + /** 令牌过期时间戳(毫秒);null 表示未知或已清空 */ tokenExpiresAt: number | null; /** * 本地令牌是否仍然有效(已登录且未过期)。 - * 微信登录「第 2~N 次」场景依赖此值:有效则免微信授权直接进主界面。 + * 到期时间未知(后端没给 expiresIn)时按「有效」处理:宁可由一次真实请求的 401 + * 来判定失效,也不要因为拿不到 TTL 就把已登录用户挡在门外。 */ isTokenValid: boolean; - /** 求职端:求职意向是否已设置(设置后不再强制进入设置页) */ - candidateSetupDone: boolean; - /** 企业端:是否已提交/完成企业认证(走完该步骤后不再强制) */ - enterpriseVerified: boolean; /** - * 本地存储是否已读取完毕。 - * 冷启动时 AsyncStorage 读取是异步的,在读取完成前上述字段仍是默认值; - * 入口页必须等该标记为 true 再决定跳转,否则已登录用户会闪现引导页。 + * 服务器下发的角色;**null = 未选身份(UNASSIGNED)或角色无法识别**。 + * 页面拿到 null 必须走身份选择,不得自行推断。 */ + role: AppRole | null; + /** 后端要求完成首次身份选择(登录响应 identitySelectionRequired) */ + identitySelectionRequired: boolean; + /** + * 角色是否已与服务器核对过(`verifySession` 成功过)。 + * 冷启动时本地 role 只是上次的缓存,未核对前不应据此放行进业务页。 + */ + isRoleVerified: boolean; + /** 求职端:求职意向是否已设置 */ + candidateSetupDone: boolean; + /** 企业端:是否已提交/完成企业认证 */ + enterpriseVerified: boolean; + /** 本地存储是否已读取完毕 */ isHydrated: boolean; /** 标记引导页已看完 */ completeOnboarding: () => void; + /** **唯一会话写入入口**:双 Token + 服务器角色一起落库 */ + applySession: (session: SessionPayload) => void; /** - * 登录成功,写入 token。 - * @param token 登录令牌 - * @param expiresAt 过期时间戳(毫秒);不传时按 TOKEN_TTL_MS 推算 + * 用 `/auth/me` 核对当前角色并回写。 + * @returns 服务器角色(可能为 null = 未选身份) + * @throws 请求失败时抛出,调用方负责重试或要求重新登录 */ - login: (token: string, expiresAt?: number) => void; - /** - * 真实登录成功:写入双 Token 并持久化(access + refresh)。 - * @param accessToken 业务请求用访问令牌 - * @param refreshToken 仅提交给 /auth/refresh 的刷新令牌 - * @param expiresAt accessToken 过期时间戳(毫秒),后端可能不返回 - */ - setSession: (accessToken: string, refreshToken: string, expiresAt?: number) => void; - /** 退出登录,清空令牌 */ + verifySession: () => Promise; + /** 退出登录:清空令牌、角色与分端标记 */ logout: () => void; /** 标记求职意向已设置 */ completeCandidateSetup: () => void; @@ -73,6 +102,9 @@ interface MemoryAuth { token: string | null; refreshToken: string | null; tokenExpiresAt: number | null; + role: AppRole | null; + identitySelectionRequired: boolean; + isRoleVerified: boolean; candidateSetupDone: boolean; enterpriseVerified: boolean; } @@ -85,6 +117,9 @@ const DEFAULT_AUTH: MemoryAuth = { token: null, refreshToken: null, tokenExpiresAt: null, + role: null, + identitySelectionRequired: false, + isRoleVerified: false, candidateSetupDone: false, enterpriseVerified: false, }; @@ -101,18 +136,36 @@ function loadAuth(): Promise { return storageGet(AUTH_KEY).then((raw) => { if (!raw) return null; try { - return JSON.parse(raw) as MemoryAuth; + // 与 DEFAULT_AUTH 合并:老版本存的 JSON 缺少新字段时补默认值, + // 否则 role 之类的新字段是 undefined,与 null 的语义判断会走偏 + return { ...DEFAULT_AUTH, ...(JSON.parse(raw) as Partial) }; } catch { return null; } }); } +/** 清空会话(登出 / 401 刷新失败):令牌、角色、分端标记一并清 */ +function clearedSession(state: MemoryAuth): MemoryAuth { + return { + ...state, + isLoggedIn: false, + token: null, + refreshToken: null, + tokenExpiresAt: null, + role: null, + identitySelectionRequired: false, + isRoleVerified: false, + candidateSetupDone: false, + enterpriseVerified: false, + }; +} + export function AuthProvider({ children }: { children: ReactNode }) { const [state, setState] = useState(memoryAuth); const [isHydrated, setIsHydrated] = useState(false); - // 启动时从 async-storage 恢复登录/引导/分端标记;同时回填双 Token 内存态 + // 启动时从 async-storage 恢复;同时回填双 Token 内存态 useEffect(() => { let active = true; Promise.all([loadAuth(), hydrateTokens()]) @@ -129,84 +182,94 @@ export function AuthProvider({ children }: { children: ReactNode }) { // 401 清会话回调:刷新失败 / 无 refreshToken 时由请求层调用 setUnauthorizedHandler(() => { if (!active) return; - const loggedOut: MemoryAuth = { - ...memoryAuth, - isLoggedIn: false, - token: null, - refreshToken: null, - tokenExpiresAt: null, - candidateSetupDone: false, - enterpriseVerified: false, - }; + const loggedOut = clearedSession(memoryAuth); persistAuth(loggedOut); setState(loggedOut); void clearTokens(); }); + // 刷新成功回调:把请求层续期后的新凭证同步到 Context,避免两份会话状态分叉 + setSessionRefreshedHandler((accessToken, refreshToken, expiresAt) => { + if (!active) return; + const next: MemoryAuth = { + ...memoryAuth, + isLoggedIn: true, + token: accessToken, + refreshToken, + tokenExpiresAt: expiresAt ?? memoryAuth.tokenExpiresAt, + }; + persistAuth(next); + setState(next); + }); return () => { active = false; }; }, []); - const completeOnboarding = () => { - const next = { ...state, onboardingSeen: true }; + const completeOnboarding = useCallback(() => { + const next = { ...memoryAuth, onboardingSeen: true }; persistAuth(next); setState(next); - }; + }, []); - const login = (token: string, expiresAt?: number) => { + const applySession = useCallback((session: SessionPayload) => { + // 后端没下发 expiresIn 时保留原有到期时间;两者都没有则记为 null(未知) + const prev = memoryAuth; + const expiresAt = session.expiresAt ?? prev.tokenExpiresAt ?? null; + const mappedRole = session.backendRole ? mapBackendRole(session.backendRole) : null; const next: MemoryAuth = { - ...state, + ...prev, isLoggedIn: true, - token, - tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, + token: session.accessToken, + refreshToken: session.refreshToken, + tokenExpiresAt: expiresAt, + // 登录响应带角色时以它为准;不带(如刷新)则保留原角色 + role: session.backendRole ? mappedRole : prev.role, + identitySelectionRequired: session.identitySelectionRequired ?? false, + isRoleVerified: session.backendRole ? true : prev.isRoleVerified, }; persistAuth(next); setState(next); - }; + void setTokens(session.accessToken, session.refreshToken, expiresAt ?? undefined); + }, []); - const setSession = (accessToken: string, refreshToken: string, expiresAt?: number) => { + const verifySession = useCallback(async (): Promise => { + const me = await fetchCurrentUser(); const next: MemoryAuth = { - ...state, - isLoggedIn: true, - token: accessToken, - refreshToken, - tokenExpiresAt: expiresAt ?? Date.now() + TOKEN_TTL_MS, + ...memoryAuth, + role: me.role, + isRoleVerified: true, + // 服务器说角色还没定(UNASSIGNED)就等同「还需要选身份」 + identitySelectionRequired: me.role === null, }; persistAuth(next); setState(next); - void setTokens(accessToken, refreshToken, expiresAt); - }; + return me.role; + }, []); - const logout = () => { - const next: MemoryAuth = { - ...state, - isLoggedIn: false, - token: null, - refreshToken: null, - tokenExpiresAt: null, - candidateSetupDone: false, - enterpriseVerified: false, - }; + const logout = useCallback(() => { + const next = clearedSession(memoryAuth); persistAuth(next); setState(next); void clearTokens(); - }; + }, []); - const completeCandidateSetup = () => { - const next = { ...state, candidateSetupDone: true }; + const completeCandidateSetup = useCallback(() => { + const next = { ...memoryAuth, candidateSetupDone: true }; persistAuth(next); setState(next); - }; + }, []); - const completeEnterpriseVerify = () => { - const next = { ...state, enterpriseVerified: true }; + const completeEnterpriseVerify = useCallback(() => { + const next = { ...memoryAuth, enterpriseVerified: true }; persistAuth(next); setState(next); - }; + }, []); - // 派生值,不入库:已登录且过期时间晚于当前时间才算有效 + // 派生值,不入库:已登录且有令牌,且(到期时间未知 或 尚未过期) const isTokenValid = - state.isLoggedIn && state.token !== null && (state.tokenExpiresAt ?? 0) > Date.now(); + state.isLoggedIn && + state.token !== null && + (state.tokenExpiresAt === null || state.tokenExpiresAt > Date.now()); return ( void; - clearRole: () => void; } const RoleContext = createContext(undefined); -const ROLE_KEY = 'app_role'; - -function persistRole(role: AppRole | null): void { - if (role) { - void storageSet(ROLE_KEY, role); - } else { - void storageRemove(ROLE_KEY); - } -} - -function loadRole(): Promise { - return storageGet(ROLE_KEY).then((stored) => (stored ? (stored as AppRole) : null)); -} +/** 角色类型统一由 types 层定义,此处 re-export 便于页面从 context 一并导入 */ +export type { AppRole }; export function RoleProvider({ children }: { children: ReactNode }) { - const [role, setRoleState] = useState(null); - const [isHydrated, setIsHydrated] = useState(false); - - useEffect(() => { - let active = true; - loadRole() - .then((stored) => { - if (active && stored) setRoleState(stored as AppRole); - }) - .finally(() => { - // 无论读取成功、为空还是异常都必须置位,避免入口页永久停在占位态 - if (active) setIsHydrated(true); - }); - return () => { - active = false; - }; - }, []); - - const setRole = (next: AppRole) => { - setRoleState(next); - persistRole(next); - }; - - const clearRole = () => { - setRoleState(null); - persistRole(null); - }; - + const { role, isRoleVerified, isHydrated } = useAuth(); return ( - + {children} ); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 597618a..8daa0c5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -77,6 +77,10 @@ export { useSkillLearningPath } from './useSkillLearningPath'; export { useTalentRecommendation } from './useTalentRecommendation'; export { useWeightSettings } from './useWeightSettings'; export { useLogin } from './useLogin'; +export { useLoginLanding } from './useLoginLanding'; +export type { LandingTarget } from './useLoginLanding'; +export { useIdentitySwitch } from './useIdentitySwitch'; +export type { IdentitySwitchOutcome } from './useIdentitySwitch'; export { OnboardingProvider, useOnboarding } from './useOnboardingFlow'; export { useRegisterFinish } from './useRegisterFinish'; export { useWechatLogin, type PendingBindPhone, type WechatLoginState } from './useWechatLogin'; diff --git a/src/hooks/useIdentitySwitch.ts b/src/hooks/useIdentitySwitch.ts new file mode 100644 index 0000000..058d445 --- /dev/null +++ b/src/hooks/useIdentitySwitch.ts @@ -0,0 +1,70 @@ +/** + * 换端(求职者 ⇄ 企业)hook。 + * + * 换端是**服务端操作**,不是本地改一个字段:契约 `POST /auth/identity/select` 负责 + * 决定这个账号到底是什么身份,并(首次)下发新凭证。页面上的「切换到企业端」按钮 + * 早先只调本地 `setRole('enterprise')` 就跳进企业端 —— 账号在服务器眼里仍是求职者, + * 于是接口全部按求职者鉴权,页面却按企业渲染。这里改为调服务端并采用其返回值。 + */ +import { useCallback, useState } from 'react'; +import { mapBackendRole, selectIdentity, type IdentitySelectionPayload } from '../api/auth'; +import { useAuth } from '../context/AuthContext'; +import type { AppRole } from '../types'; + +/** 换端结果:失败时 message 是可直接展示给用户的原因(来自后端或网络层) */ +export interface IdentitySwitchOutcome { + ok: boolean; + /** 成功后的服务器角色 */ + role: AppRole | null; + /** 仍需完成身份选择(后端 identitySelectionRequired) */ + needsSelection: boolean; + /** 失败原因 */ + message?: string; +} + +export function useIdentitySwitch() { + const { applySession } = useAuth(); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + + const switchTo = useCallback( + async ( + identity: 'STUDENT' | 'COMPANY', + /** 选 COMPANY 时必填(契约要求);其余情况不要传 */ + company?: IdentitySelectionPayload['company'], + ): Promise => { + setSwitching(true); + setError(null); + try { + const res = await selectIdentity(company ? { identity, company } : { identity }); + /* + * 同身份重复请求时后端只回状态、不重复颁发凭证(契约明写)。 + * 此时 accessToken 为空,不能拿空串去覆盖现有会话 —— 那会把用户踢下线。 + */ + if (res.accessToken && res.refreshToken) { + applySession({ + accessToken: res.accessToken, + refreshToken: res.refreshToken, + expiresAt: res.expiresAt, + backendRole: res.role, + identitySelectionRequired: res.identitySelectionRequired, + }); + } + return { + ok: !res.identitySelectionRequired, + role: mapBackendRole(res.role), + needsSelection: res.identitySelectionRequired, + }; + } catch (e) { + const err = e instanceof Error ? e : new Error('切换身份失败,请稍后重试'); + setError(err); + return { ok: false, role: null, needsSelection: false, message: err.message }; + } finally { + setSwitching(false); + } + }, + [applySession], + ); + + return { switchTo, switching, error }; +} diff --git a/src/hooks/useLoginLanding.ts b/src/hooks/useLoginLanding.ts new file mode 100644 index 0000000..62f0f6d --- /dev/null +++ b/src/hooks/useLoginLanding.ts @@ -0,0 +1,56 @@ +/** + * 登录成功后的落地判定(验证码 / 密码 / 一键登录三个入口共用)。 + * + * 后端 `POST /auth/login` 的语义是「新手机号创建 UNASSIGNED 账号, + * `identitySelectionRequired=true` 时前端**必须**进入首次身份选择;旧角色保持不变」。 + * 所以「登录成功」并不等于「可以进主界面」,必须先看响应再决定去哪: + * + * - 还需选身份,或服务器角色认不出来(UNASSIGNED / 未知)→ 去身份选择 + * - 服务器给了明确角色 → 直接落地对应端 + * + * 全程不使用本地缓存的角色:本地角色只代表「上次是什么」,不能代表「现在是什么」。 + */ +import { useCallback } from 'react'; +import { useRouter } from 'expo-router'; +import { mapBackendRole, type AuthLoginData } from '../api/auth'; +import { useAuth } from '../context/AuthContext'; + +/** 登录成功后的去处 */ +export type LandingTarget = 'identity' | 'candidate' | 'enterprise'; + +export function useLoginLanding() { + const router = useRouter(); + const { applySession } = useAuth(); + + /** 写入会话(双 Token + 服务器角色)并给出落地目标 */ + const land = useCallback( + (result: AuthLoginData): LandingTarget => { + applySession({ + accessToken: result.accessToken, + refreshToken: result.refreshToken, + expiresAt: result.expiresAt, + backendRole: result.role, + identitySelectionRequired: result.identitySelectionRequired, + }); + const role = mapBackendRole(result.role); + if (result.identitySelectionRequired || !role) return 'identity'; + return role === 'enterprise' ? 'enterprise' : 'candidate'; + }, + [applySession], + ); + + /** + * 执行跳转。 + * @returns true = 已跳转到某一端;false = 目标端未知,调用方需引导去身份选择页 + */ + const go = useCallback( + (target: LandingTarget): boolean => { + if (target === 'identity') return false; + router.replace(target === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); + return true; + }, + [router], + ); + + return { land, go }; +} diff --git a/src/hooks/usePasswordLogin.ts b/src/hooks/usePasswordLogin.ts index 12be845..faeb4b5 100644 --- a/src/hooks/usePasswordLogin.ts +++ b/src/hooks/usePasswordLogin.ts @@ -1,9 +1,10 @@ import { useCallback, useState } from 'react'; -import { loginByPassword, type AuthTokens } from '../api'; +import { loginByPassword } from '../api'; +import type { AuthLoginData } from '../api/auth'; export interface UsePasswordLoginReturn { - /** 手机号 + 密码登录,成功返回双 Token */ - login: (phone: string, password: string) => Promise; + /** 手机号 + 密码登录,成功返回完整会话(双 Token + 服务器角色) */ + login: (phone: string, password: string) => Promise; /** 登录进行中 */ loading: boolean; /** 登录失败的错误(用于页面提示) */ diff --git a/src/hooks/usePasswordReset.ts b/src/hooks/usePasswordReset.ts index 992348b..f54180a 100644 --- a/src/hooks/usePasswordReset.ts +++ b/src/hooks/usePasswordReset.ts @@ -4,13 +4,13 @@ import { resetPassword, validatePassword, validatePasswordConfirm, - type LoginResult, + type PasswordOperationResult, } from '../api'; import type { PasswordResetPayload } from '../types'; export interface UsePasswordResetReturn { - /** 重置 / 首次设置密码,成功返回登录结果(后端直接下发登录态) */ - reset: (payload: PasswordResetPayload) => Promise; + /** 重置 / 首次设置密码;返回后端密码操作结果(**不含 Token**) */ + reset: (payload: PasswordResetPayload) => Promise; /** 提交进行中 */ loading: boolean; /** 失败原因(用于页面提示) */ diff --git a/src/hooks/useRegisterFinish.ts b/src/hooks/useRegisterFinish.ts index f0bd877..1a89a7d 100644 --- a/src/hooks/useRegisterFinish.ts +++ b/src/hooks/useRegisterFinish.ts @@ -1,26 +1,52 @@ import { useRouter } from 'expo-router'; +import { Alert } from 'react-native'; import { useAuth } from '../context/AuthContext'; -import { useRole } from '../context/RoleContext'; import type { AppRole } from '../types'; import { useOnboarding } from './useOnboardingFlow'; /** - * 注册完成后的真实落地:先把采集到的资料落库,再写入登录态与身份,跳转对应端主界面。 - * 真实后端就绪后,login 的 token 替换为接口返回; - * setRole 用于让 index.tsx 漏斗放行,避免注册完成又被弹回注册。 + * 注册流程收尾:把引导采集到的资料落库,然后按**服务器已确认的身份**落地到对应端。 + * + * 这里不再颁发任何令牌。此前实现是 `login('mock-token-' + Date.now())` —— + * 无论用户是谁、后端返回什么角色,收尾时都往本地塞一个假 Token 再跳转, + * 于是「注册完成」在服务端根本没发生过,而本地已自认登录。 + * + * 前置条件由调用方保证:走到收尾页之前必须已经通过 `/auth/login` 建立了会话, + * 并且(新账号)已通过 `/auth/identity/select` 选定身份。缺失时弹错并留在当前页, + * 而不是编一个会话把用户送进主界面 —— 所以本函数**不会 reject**, + * 避免 `void finish(...)` 这种既有调用方式产生未处理的 Promise 拒绝。 */ export function useRegisterFinish() { const router = useRouter(); - const { login } = useAuth(); - const { setRole } = useRole(); + const { role, isTokenValid } = useAuth(); const { flushSave } = useOnboarding(); - return async (role: AppRole) => { - // 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转, - // 不在这里提交就会随页面卸载一起丢掉 - await flushSave(); - setRole(role); - login(`mock-token-${Date.now()}`); + return async (expectedRole: AppRole): Promise => { + try { + // 落地前落库:最后一步(如 B3 的公司名/联系人)不会再有下一次跳转, + // 不在这里提交就会随页面卸载一起丢掉 + await flushSave(); + } catch (e) { + Alert.alert('资料保存失败', (e as Error).message || '请检查网络后重试'); + return; + } + + if (!isTokenValid) { + Alert.alert('无法完成注册', '登录状态已失效,请重新登录后再继续'); + return; + } + if (!role) { + // 常见原因:后端返回 UNASSIGNED(还没选身份)却被放进收尾页 + Alert.alert('无法完成注册', '尚未完成身份选择,请先选择你的身份'); + return; + } + if (role !== expectedRole) { + // 用户以为自己注册成了企业,服务器却给出求职者身份 —— 必须暴露而不是照本地口径跳 + Alert.alert('身份不一致', `服务器返回的账号身份是「${role}」,与所选不符,请重新登录确认`); + return; + } + + // 角色已是服务器事实,这里只负责跳转,不再写任何会话 router.replace(role === 'enterprise' ? '/(enterprise)/job-management' : '/(tabs)/jobs'); }; } diff --git a/src/hooks/useWechatBindPhone.ts b/src/hooks/useWechatBindPhone.ts index 3477879..fea0a79 100644 --- a/src/hooks/useWechatBindPhone.ts +++ b/src/hooks/useWechatBindPhone.ts @@ -19,7 +19,7 @@ export interface UseWechatBindPhoneReturn { * 成功后写入登录态;返回会话交给页面决定是继续走身份选择还是直接落地。 */ export function useWechatBindPhone(): UseWechatBindPhoneReturn { - const { login } = useAuth(); + const { applySession } = useAuth(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -29,7 +29,19 @@ export function useWechatBindPhone(): UseWechatBindPhoneReturn { setError(null); try { const session = await bindWechatAndRegister(payload); - login(session.token, session.expiresAt); + // 走唯一会话入口:双 Token 一起落库,请求层才能带上 Bearer + applySession({ + accessToken: session.token, + refreshToken: session.refreshToken, + expiresAt: session.expiresAt, + // 后端记录过身份才写角色;未记录(undefined)时留给身份选择流程 + backendRole: + session.role === 'enterprise' + ? 'COMPANY' + : session.role === 'candidate' + ? 'STUDENT' + : undefined, + }); return session; } catch (e) { const err = e instanceof Error ? e : new Error('绑定失败,请稍后重试'); @@ -39,7 +51,7 @@ export function useWechatBindPhone(): UseWechatBindPhoneReturn { setLoading(false); } }, - [login], + [applySession], ); return { bind, loading, error }; diff --git a/src/hooks/useWechatLogin.ts b/src/hooks/useWechatLogin.ts index 92850ea..66fbc8c 100644 --- a/src/hooks/useWechatLogin.ts +++ b/src/hooks/useWechatLogin.ts @@ -63,7 +63,7 @@ export interface UseWechatLoginReturn { const IDLE: WechatLoginState = { kind: 'idle' }; export function useWechatLogin(): UseWechatLoginReturn { - const { login, isTokenValid } = useAuth(); + const { applySession, isTokenValid } = useAuth(); const [state, setState] = useState(IDLE); const start = useCallback(async () => { @@ -88,7 +88,18 @@ export function useWechatLogin(): UseWechatLoginReturn { if (!result.profile) { throw new Error('登录信息不完整,请重试'); } - login(result.token, result.expiresAt); + applySession({ + accessToken: result.token, + // 微信通道无后端契约,refreshToken 缺失时用空串占位(本通道整体 mock) + refreshToken: result.refreshToken ?? '', + expiresAt: result.expiresAt, + backendRole: + result.role === 'enterprise' + ? 'COMPANY' + : result.role === 'candidate' + ? 'STUDENT' + : undefined, + }); setState({ kind: 'success', profile: result.profile, role: result.role }); break; } @@ -111,7 +122,7 @@ export function useWechatLogin(): UseWechatLoginReturn { message: e instanceof Error ? e.message : '微信登录失败,请稍后重试', }); } - }, [isTokenValid, login]); + }, [isTokenValid, applySession]); const cancelLogin = useCallback(() => { setState(IDLE); diff --git a/src/lib/token-store.ts b/src/lib/token-store.ts index 6a412a5..bdce706 100644 --- a/src/lib/token-store.ts +++ b/src/lib/token-store.ts @@ -51,7 +51,14 @@ export function getTokens(): TokenPair | null { return memory; } -/** 写入并持久化;expiresAt 缺省时不覆盖已存值 */ +/** + * 写入并持久化双 Token。 + * + * `expiresAt` 为 undefined(后端没下发 expiresIn)时**清除**已存的过期时间, + * 而不是保留旧值:内存态此时是 undefined,持久化却留着上一次的绝对时间戳, + * 两者分叉 —— 下次启动 hydrate 会把这个已失效的旧时间戳当成新会话的到期时间。 + * 宁可「不知道到期时间」(UI 侧另有兜底)也不要一个假的确定值。 + */ export async function setTokens( accessToken: string, refreshToken: string, @@ -64,6 +71,8 @@ export async function setTokens( ]; if (expiresAt !== undefined) { writes.push(storageSet(KEY_EXP, String(expiresAt))); + } else { + writes.push(storageRemove(KEY_EXP)); } await Promise.all(writes); } diff --git a/src/types/auth.ts b/src/types/auth.ts index 9a4b085..3819b97 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -25,14 +25,31 @@ export type PasswordRuleError = /** 重置 / 首次设置密码参数(忘记密码与首次设密码共用) */ export interface PasswordResetPayload { phone: string; + /** 短信验证码;发请求时映射为契约字段 `smsCode` */ code: string; + /** 新密码;发请求时映射为契约字段 `newPassword` */ password: string; } /** 已登录态修改密码参数 */ export interface ChangePasswordPayload { + /** 原密码;发请求时映射为契约字段 `currentPassword` */ oldPassword: string; newPassword: string; /** 确认新密码(前端二次输入,后端可忽略) */ confirmPassword: string; } + +/** + * 密码操作(设置 / 重置 / 修改)的响应体,契约 `PasswordOperationResponse`。 + * + * **里面没有 Token** —— 这三个接口只回答「密码现在配好了没有」「要不要重新登录」。 + * 早期把它们当成登录接口(返回 `LoginResult`、页面拿 `result.token` 写登录态), + * 等于凭空造了一个后端从未颁发的会话。 + */ +export interface PasswordOperationResult { + /** 密码是否已配置 */ + passwordConfigured: boolean; + /** 后端要求重新登录后凭证才生效(改密后旧会话可能被撤销) */ + reauthenticationRequired: boolean; +} diff --git a/src/types/wechat-auth.ts b/src/types/wechat-auth.ts index be4f778..4d4d125 100644 --- a/src/types/wechat-auth.ts +++ b/src/types/wechat-auth.ts @@ -58,6 +58,13 @@ export interface WechatLoginResult { status: WechatAccountStatus; /** 登录令牌(active 为正式 token;new 为临时凭证;deleted 无意义) */ token: string; + /** + * 刷新令牌,仅 status === 'active' 时返回。 + * 微信登录当前无后端契约(整体 mock),真实接入时以厂商/后端返回为准; + * 这里先补齐字段,让微信登录与其他登录走**同一个会话写入入口**, + * 否则微信登录的用户请求全部带不出 Bearer。 + */ + refreshToken?: string; /** 微信开放平台唯一标识 */ openid: string; /** 已注册用户资料,仅 status === 'active' 时返回 */ @@ -85,8 +92,10 @@ export interface WechatBindPhonePayload { /** 绑定注册成功后返回的正式会话 */ export interface WechatSession { - /** 正式登录令牌 */ + /** 正式访问令牌 */ token: string; + /** 刷新令牌(无契约,mock 阶段自造;真实接入时以后端返回为准) */ + refreshToken: string; /** 令牌过期时间戳(毫秒) */ expiresAt: number; /** 用户资料 */ diff --git a/后端联调前端实现.md b/后端联调前端实现.md deleted file mode 100644 index 865c07c..0000000 --- a/后端联调前端实现.md +++ /dev/null @@ -1,87 +0,0 @@ -# 后端联调 · 前端实现说明 - -> 适用范围:`有职`(youzhi)App 前端对接《职来后端接口联调说明》(`job.youthome.com.cn` 规范) -> 关联文件:`src/api/http.ts`、`src/api/auth.ts`、`src/lib/token-store.ts`、`src/context/AuthContext.tsx`、`src/types/user.ts` -> 本文只记录**前端已落地的地基与契约映射**,完整接口字段以运行时 OpenAPI 为唯一依据(规范 §7)。 - -## 一、本次已落地(地基,不涉及业务端点) - -| 层 | 文件 | 内容 | -|---|---|---| -| 请求层 | `src/api/http.ts` | 统一出口:`Base URL` + `Bearer` 注入 + 信封解析 + **401 单飞刷新** + `X-Request-Id` + 状态码语义 | -| 令牌 | `src/lib/token-store.ts` | `accessToken` + `refreshToken` 内存单例 + AsyncStorage 持久化 | -| 鉴权 | `src/api/auth.ts` | `sendSmsCode` / `loginByPassword` / `loginBySms` / `fetchCurrentUser` / `requestLogout` / `registerCompany` | -| 会话 | `src/context/AuthContext.tsx` | `setSession(access,refresh,expiresAt)`;启动时 `hydrateTokens`;注册 401 清会话回调 | -| 角色 | `src/types/user.ts` + `auth.ts` | `AppRole` 扩 `admin`;`mapBackendRole(STUDENT→candidate / COMPANY→enterprise / ADMIN→admin)` | -| 环境 | `app.config.js` | 默认 `apiBaseUrl = https://job.youthome.com.cn/api/v1`(保留 `API_BASE_URL` 环境变量覆盖) | -| mock 开关 | `app.config.js` + `http.ts` | `extra.apiUseMock`(默认 `true`);`isMockMode()` 决定业务函数走 mock 还是真实请求;联调期置 `false` | - -业务端点按 **mock 开关** 分阶段落地:开发期(`apiUseMock=true`)返回本地数据不触网,联调期(`false`)直连后端。`jobs` / `profile` / `messages` 等 100+ mock 函数**逐个替换**为真实 `request()` 调用,页面与 hook 零改动。 - -## 二、规范条款逐条对照 - -| 规范 | 前端现状 | -|---|---| -| §1 基地址 / 健康检查 / OpenAPI | 基地址已接 `API_BASE_URL`;`checkHealth()` 已实现对 `/health`(UP 判定);OpenAPI 为后端准绳,前端不手抄 | -| §3 信封 `code/message/data/requestId/timestamp` | `request()` 解析 envelope;`code!==200` 抛 `ApiError`;后端 `message` 优先(更具体),无 message 时回退状态码文案 | -| §3 `Authorization: Bearer` | 自动注入;匿名接口(登录/短信/刷新)显式 `auth:false` 不带头 | -| §4 双 Token + `/auth/refresh` | `token-store` 持久化 `access+refresh`;401 触发刷新 | -| §4 一次串行刷新 + 重放 | 模块级 `refreshPromise` 单飞闸门:并发 401 只刷一次,成功后重放原请求 | -| §4 刷新失败清会话 | 清 Token + `unauthorizedHandler`(AuthContext 注册为清登录态) | -| §4 Token 不打印/上报/进 URL | 令牌仅在 `http.ts` 内层使用,错误对象不回吐原始 Token | -| §5 角色以后端 `/auth/me` 为准 | `fetchCurrentUser()` 返回映射后角色;密码登录页(`P2PasswordLogin`)登录后调 `/auth/me` 并 `setRole`,与本地选择不一致时纠正路由 | -| §6 企业注册 | `registerCompany()` 已实现对 `/auth/company/register`;客户端不提交 `userId/role/verified/passwordHash` | -| §8 状态码语义 | `pickHttpMessage` 覆盖 400/401/403/404/409/429/500/503;网络异常降级为 `httpStatus=0` | -| §10 安全 | 不写 Token/验证码/密码到日志或前端 DOM;`X-Request-Id` 用于联调报障 | - -## 三、业务端点转换进度(mock 开关 + 全量 fetch 函数,2026-09-24 收口) - -> 转换规则:保留函数签名与返回类型 `T`(即 AGENTS.md「保留签名、只换函数体」),`if (isMockMode())` 分支保留本地数据,真实分支走 `request(path, { auth, query })`。路径按规范 §7 重映射(旧 `@url` 多带 `/api` 前缀、未带 `/v1`,已校正)。 - -### 3.1 已落地真实端点(2026-09-28 经 OpenAPI 校正) - -> 路径以 `docs/联调路径校正映射.md` 为权威逐函数表;下表仅列本次确认可直连的端点(ALIGNED + WRONG→REAL)。 - -| 函数 | 真实路径 | 鉴权 | 说明 | -|---|---|---|---| -| `fetchJobList` | `GET /jobs` | 否 | 分页信封 `{items}` | -| `fetchJobDetail` | `GET /jobs/{id}` | 否 | 公开岗位详情 | -| `fetchJobPreference` | `GET /profile/me/preferences` | 是 | 原 `/profile/me/job-preference` 已校正 | -| `fetchFavorites` | `GET /jobs/favorites` | 是 | 原 `/profile/me/favorites` 已校正 | -| `fetchJobProgress` | `GET /profile/me/career-progress` | 否 | 原 `/tools/job-progress` 已校正 | -| `fetchEmployeeReviews` | `GET /jobs/{id}/reviews` | 否 | | -| `createJobReview` | `POST /jobs/{id}/reviews` | 是 | body `{content}` | -| `fetchCompareData` | `GET /jobs/compare` | 否 | | -| `fetchUserProfile` | `GET /profile/me` | 是 | | -| `fetchResume` | `GET /profile/me/resume` | 是 | 原 `/resume` 已校正(修复双前缀 bug) | -| `fetchConversations` | `GET /conversations` | 是 | 原 `/messages/conversations` 已校正 | -| `fetchNotifications` | `GET /notifications` | 是 | 原 `/messages/notifications` 已校正 | -| `fetchInterviews` | `GET /interviews/me` | 是 | 原 `/messages/interviews` 已校正 | -| `fetchConversation` | `GET /conversations/{id}` | 是 | 原 `/chat/conversations/{id}` 已校正 | -| `fetchRankData` | `GET /jobs/{jobId}/competition-ranking` | 否 | 原 `/jobs/rank` 已校正(修复 `${jobId}` 未插值 bug) | -| `fetchCandidateDetail` | `GET /company/candidates/{applicationId}` | 是 | 原 `/enterprise/candidates` 已校正 | -| `fetchInterviewScore` | `GET /interviews/{id}/score` | 是 | 原 `/enterprise/interviews` 已校正 | -| `fetchInterviewScoreDraft` | `PUT /interviews/{id}/score` | 是 | 草稿复用评分端点,原 `/enterprise/.../draft` 已校正 | -| `fetchOfferCompareData` | `GET /offers/compare` | 否 | 已对齐 | - -> 写函数 `saveJobPreference`(PUT /profile/me/preferences)已校正路径;其余 `saveResume*` / `saveWeightSettings` / `saveSalaryRecord` 等经审计属「幽灵域」(后端无 OpenAPI 契约),已退回 mock-only。 - -### 3.1.2 幽灵域(本轮批量转换的其余 62 个 fetch 函数,2026-09-28 校正) - -> 经 OpenAPI 审计,这 62 个函数所属域(/growth、/user、/tools、/game、/interview、/evaluation、/enterprise、/legal、/settings、/salary、/operation 等)**后端均无契约**。处理方式:保留 `if(isMockMode())` mock 分支,**真实态不再调用 `request()`(翻真实开关也不触网)**,待后端补齐契约后再接。原占位 `【待确认】` 标记已全部清除。逐函数明细见 `docs/联调路径校正映射.md` §三。 - -### 3.2 待转 / 明确不接后端 - -- **设备本地能力(保持 mock,无后端等价物)**:`fetchCacheSize`(存储占用)、`fetchCarrierPhone`(运营商取号 SDK)、`fetchWechatLoginResult`(微信登录态)、`fetchOnboardingProfile`(本地引导资料唯一真相源)、`fetchOnboardingChecklist`(本地清单派生)、`fetchDeleteAllData`(本地清空)。 -- **非 fetch 写函数(不在本次 fetch 范围,后续接)**:`sendChatMessage`、`saveResume*` 系列、`saveOnboardingProfile`、`toggleFavorite`、`saveJobPreference` 等写操作——需 PUT/POST body 映射 + 保留本地 storage 回退作为真实后端未就绪时的兜底。 - -## 四、明确未做(写入类 + 实时能力,下一阶段) - -- **WebSocket**:`wss://job.youthome.com.cn/api/v1/ws` 未接入(无对应实时功能)。 -- **企业入驻状态机**:`/companies/me/onboarding` 的 `missingFields` / `allowedActions` 驱动 UI 尚未接。 -- **其余入口页角色纠正**:仅密码登录页已接 `/auth/me`;微信登录、验证码登录等入口需在登录后补齐角色权威化。 -- **写入类函数**:见 §3.2 非 fetch 写函数(sendChatMessage / saveResume* / saveOnboardingProfile / toggleFavorite / saveJobPreference 等),需补 body 映射与本地 storage 兜底。 - -## 五、命名待确认(非阻塞) - -- 域名 `youthome.com.cn`(青年家)与 App 品牌「有职 / youzhi」不一致;Storage 前缀仍为历史遗留 `qingsongpin:`,**勿擅自改名**(会导致老用户本地态失效)。 diff --git a/微信登录接入方案.md b/微信登录接入方案.md deleted file mode 100644 index a981f6d..0000000 --- a/微信登录接入方案.md +++ /dev/null @@ -1,159 +0,0 @@ -# 微信登录接入方案(P1) - -> ⚠️ **入口已摘除(2026-09-15,产品决定)** -> 丁总决定前端不做 QQ / 微信登录,现阶段登录方式只保留「手机号 + 短信验证码」与「手机号 + 密码」,以后另加青核登录。 -> **代码与页面完整保留待启用**:`P2W`(扫码)/`B1`(授权)页面、`useWechatLogin`、`useWechatBindPhone`、`wechat-sdk.ts`、`types/wechat-auth.ts`、本文件所述三个接口函数**均未删除**,仅从 `P1Login` 与 `P2PhoneCode` 摘掉了入口。 -> 恢复入口 = 在 `P1Login.tsx` 重新挂上微信按钮与 `useWechatLogin` 状态机 + 在 `P2PhoneCode.tsx` 恢复微信圆标;`app/register.tsx` 的 `P2W` / `B1` 两个 case 一直都在。 -> 本文以下内容描述的是**代码现状与未来接入路线**,不代表当前有可用入口。 - -> 状态:方案文档 · 不涉及代码改写 · 真实后端就绪后按路线图替换 mock 即可 -> 适用范围:轻松聘 App 微信授权登录(三分支状态机) -> 关联文件:`src/lib/wechat-sdk.ts`、`src/hooks/useWechatLogin.ts`、`src/api/api.ts`、`src/types/wechat-auth.ts`、`components/onboarding/screens/P1Login.tsx`、`components/onboarding/screens/P2PhoneCode.tsx` - ---- - -## 0. 一句话结论 - -前端三分支状态机与页面流转**已全部联通**,当前唯一阻塞点是:微信原生 SDK 未接入(Expo Go 无法跑原生模块)、以及后端两个接口仍是 mock。 -**接入真实微信 = 替换 1 个函数 (`requestWechatAuthCode`) + 替换 2 个接口函数体 (`fetchWechatLoginResult` / `bindWechatAndRegister`) + 申请微信开放平台移动应用 + 用 EAS Build 生成自定义客户端。** 上层 hook、页面、类型**零改动**。 - -> 注:摘除入口后,`P1Login.tsx` 不再引用 `useWechatLogin`,上表「页面流转」一行的起点需改为 `P2W`;恢复入口时按现状挂回即可。 - ---- - -## 1. 现状盘点(已实现 vs 待接入) - -| 层 | 文件 | 现状 | 接入动作 | -|---|---|---|---| -| 类型 | `src/types/wechat-auth.ts` | ✅ 已完整定义 | 无需改动 | -| 状态机 | `src/hooks/useWechatLogin.ts` | ✅ 8 态判别联合,已联通 | 无需改动 | -| 页面流转 | `P1Login.tsx` → `P2PhoneCode.tsx` → `P3` | ✅ 已联通 | 无需改动 | -| 绑定 hook | `src/hooks/useWechatBindPhone.ts` | ✅ 已联通 | 无需改动 | -| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` / `isTokenValid` 已就绪 | 无需改动 | -| **SDK 适配层** | `src/lib/wechat-sdk.ts` | ⚠️ **mock**:返回 `mock_wx_code_*` | **替换 `requestWechatAuthCode`** | -| **后端接口** | `src/api/api.ts` | ⚠️ **mock**:`fetchWechatLoginResult` / `bindWechatAndRegister` | **替换函数体** | -| 微信开放平台 | —— | ❌ 未申请移动应用 | 申请 AppID + Universal Links | -| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | - -**已验证的关键事实**(直接取自源码,非推测): -- `useWechatLogin` 的 `start()` 逻辑:`isTokenValid` 为真 → `alreadyLoggedIn`(免授权直接落地);否则 `authenticating` → `requestWechatAuthCode()` → `verifying` → `fetchWechatLoginResult(code)`,按 `result.status` 分流 `active` / `new` / `deleted`。 -- `new` 分支:`setState({ kind: 'needBindPhone', ticket: { token: result.token, openid: result.openid } })` —— **临时凭证已下发并透传**。 -- `P1Login.tsx` 在 `needBindPhone` 时把 `wechatTicket` 写入 `useOnboardingFlow` 的 `wechatTicket` 字段并跳 `P2`。 -- `P2PhoneCode.tsx` 检测到 `wechatTicket` 后调用 `bindWechatAndRegister({ token, openid, phone, code })` 完成注册。 -- `deleted` 分支:不写登录态,仅提示不可登录,**无复活出口**(符合账号注销即不可逆的硬约束)。 - ---- - -## 2. 接入目标与成功标准 - -**目标**:让真实微信用户能走通 `new / active / deleted` 三条链路,且: -1. 用户第一次用微信登录 → 跳手机号+验证码 → 注册落地; -2. 老用户再次微信登录 → 直接进主界面(本地 token 有效时甚至免授权); -3. 处于注销审核期的账号 → 登录页明确提示「该账号正在申请注销中,无法登录」,无复活入口。 - -**成功标准(验收口径)**: -- [ ] `requestWechatAuthCode()` 真正调起微信并回传一次性 `code`(非 mock)。 -- [ ] 后端 `/api/auth/wechat` 用 code 换 openid → 判定 `new/active/deleted` → 首响下发可复用临时凭证。 -- [ ] `bind-phone` 用临时凭证(非 code)完成 openid 与手机号绑定注册。 -- [ ] iOS 能在微信授权后通过 Universal Links 正确回调 App;Android 能通过包名+签名回调。 -- [ ] 三分支在自定义客户端(非 Expo Go)上全部走通。 -- [ ] mock 辅助 `MOCK_WECHAT_STATUS` / `setMockWechatAccountStatus` 已删除,页面无任何 mock 分支残留。 - ---- - -## 3. 两个硬约束(设计已遵守,接后端不可破坏) - -1. **微信 code 一次性(5 分钟有效)**:用户授权后原 code 已被首次请求消耗,绑定手机号时**拿不到第二个 code**。因此后端必须在首次响应里下发 `WechatPendingTicket`(含 `token` + `openid`),前端在 `P2` 回传票据而非 code。 - - 类型已建模:`WechatPendingTicket { token: string; openid: string }`(`src/types/wechat-auth.ts:29`)。 -2. **注销即不可登录、不可复活**:`deleted` 分支前端不写登录态、不提供任何复活入口;后端需保证审核期内账号返回 `deleted` 且拒绝任何登录/复活请求。 - ---- - -## 4. 接入路线图(按执行顺序) - -### Step 1 · 微信开放平台申请移动应用(前置,阻塞项) -- 登录微信开放平台 → 创建**移动应用**,拿到 `AppID` / `AppSecret`。 -- **iOS**:登记 **Universal Links**(微信授权回调硬性要求)。需准备一个 HTTPS 域名 + 后端托管 `apple-app-site-association`。 -- **Android**:登记 **应用包名**(`com.qingsongpin.app`,见 `app.config.js`)与**应用签名**(发布/调试签名 SHA256)。 -- ⚠️ 此步产生的 `AppID`、`Universal Links` 域名需回填到代码与 EAS 配置(见 Step 3/4)。 - -### Step 2 · 安装微信 SDK(Expo 插件优先) -```bash -npx expo install react-native-wechat-lib -# 或选用维护更活跃的 expo 兼容插件(以官方 docs 为准) -``` -- 原生模块需在 `ios/` `android/` 原生壳中注册(Expo 前置预构建会处理 config plugin 注入,无需手改原生代码)。 - -### Step 3 · 生成 EAS 自定义开发客户端(Expo Go 跑不了原生模块) -- 当前 `eas.json` 已有 `development` profile(`developmentClient: true`)。 -- 执行: - ```bash - eas build --profile development --platform ios - eas build --profile development --platform android - ``` -- 安装产物到真机/模拟器。**后续所有真机验证都在此客户端中进行,Expo Go 无法调起微信。** - -### Step 4 · 替换 SDK 适配层(唯一改动点 · 前端) -文件:`src/lib/wechat-sdk.ts` -- 顶部 `import WeChat from 'react-native-wechat-lib';` -- `isWechatAvailable()` 改为 `return WeChat.isWXAppInstalled();` -- `requestWechatAuthCode()` 替换为: - ```ts - const res = await WeChat.sendAuthRequest('snsapi_userinfo', 'wechat_auth_state'); - return res.code; - ``` -- `app.config.js` 的 `extra` 中补 `wechatAppId`,在 SDK 初始化时读取(或走 `expo-config-plugin` 注入原生 Info.plist / AndroidManifest)。 -- **此文件改动不影响任何上层调用方**(签名不变)。 - -### Step 5 · 实现后端两个接口 -| 接口 | 方法 | 职责 | 关键返回 | -|---|---|---|---| -| `/api/auth/wechat` | POST | `code → access_token + openid → 查库判定状态` | `WechatLoginResult`:`new` 时下发明文 `WechatPendingTicket`;`active` 下发正式 token + `expiresAt` + `profile` + `role`;`deleted` 仅 `openid` | -| `/api/auth/wechat/bind-phone` | POST | 用临时凭证 + 手机号 + 验证码完成注册,关联 openid | `WechatSession`:`token` + `expiresAt` + `profile` + `role` | - -- 后端须保证:`code` 一次性;临时凭证有独立 TTL 且可校验来源;`deleted` 账号拒绝登录。 -- `WechatLoginResult` / `WechatSession` 字段契约见 `src/types/wechat-auth.ts`,前后端以此对齐。 - -### Step 6 · 替换 API 层函数体(后端就绪后) -文件:`src/api/api.ts` -- `fetchWechatLoginResult(code)`:删掉 `MOCK_WECHAT_STATUS` 分支,改为 `fetch(...)`,请求 `/api/auth/wechat`,返回 `WechatLoginResult`。函数签名**不变**。 -- `bindWechatAndRegister(payload)`:改为请求 `/api/auth/wechat/bind-phone`,返回 `WechatSession`。签名**不变**。 -- 保留 JSDoc 的 `@method` / `@url` / `@param` / `@returns` 注释。 - -### Step 7 · 清除 mock 残留 -- 删除 `src/api/api.ts` 中的 `let MOCK_WECHAT_STATUS`、`setMockWechatAccountStatus()` 及其导出(同步从 `src/api/index.ts` 移除导出)。 -- 删除 `P1Login.tsx` / 测试代码中调用 `setMockWechatAccountStatus` 的分支(若有)。 - -### Step 8 · 回调与票据闭环验证 -- **iOS Universal Links**:确认微信开放平台登记的 Universal Links 能打开 App,且 `useWechatLogin` 未被重复触发(`alreadyLoggedIn` 短路逻辑生效)。 -- **Android 包名/签名**:确认微信回调能正确唤起 App 并继续 `verifying`。 -- 验证 `needBindPhone` → `wechatTicket` 透传 → `P2` 调用 `bindWechatAndRegister` 全程用的是**临时凭证 token**,而非已失效的 code。 - ---- - -## 5. 手动验收路径(可复现) - -1. 在自定义客户端(非 Expo Go)上安装并登录。 -2. **active 分支**:用老微信账号登录 → 期望直接进主界面;杀进程重进(本地 token 有效)→ 期望 `alreadyLoggedIn` 免授权落地。 -3. **new 分支**:用全新微信账号登录 → 期望跳 `P2` 手机号+验证码 → 注册成功进 `P3` 选身份。 -4. **deleted 分支**:用处于注销审核期的账号登录 → 期望登录页提示「该账号正在申请注销中,无法登录」,无复活入口。 -5. 断网 / 取消授权 / 未装微信 → 期望 `error` 态并显示对应提示。 -6. 回归 `tsc --noEmit` 与 `eslint .` 均 0 错误、0 警告。 - ---- - -## 6. 风险与待确认项 - -| 项 | 风险/阻塞 | 处置 | -|---|---|---| -| `AppID` / Universal Links | 需用户到微信开放平台申请,非代码可解 | Step 1 前置,未拿到前无法真机验证 | -| react-native-wechat-lib 维护状态 | 库可能滞后于新架构 | 接入时以 Expo 官方 docs 选型为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | -| Universal Links 配置 | iOS 回调硬性要求,易踩坑 | 后端托管 `apple-app-site-association` + 真机验证 | -| 自定义客户端构建 | 首次 EAS Build 耗时较长 | Step 3 提前跑,避免阻塞联调 | -| 临时凭证安全 | code 一次性,凭证必须独立加密 + 短 TTL | 后端实现时强制 | - ---- - -## 7. 一句话给执行者 - -**别动 hook、页面、类型。** 只动两处:`src/lib/wechat-sdk.ts`(换真 SDK 调用)和 `src/api/api.ts`(换真接口),配齐微信开放平台移动应用 + EAS 自定义客户端,三分支即可真机走通。 diff --git a/接口联调问题清单.md b/接口联调问题清单.md deleted file mode 100644 index a84a792..0000000 --- a/接口联调问题清单.md +++ /dev/null @@ -1,138 +0,0 @@ -# 职来(有职)前端 · 已转换接口(已"调过")存在的问题 - -> 生成日期:2026-09-28 -> 范围界定:**本文只讨论已在代码中实现真实分支(即已转换为直连后端、存在 `request()` 调用)的接口**存在的问题。 -> 不纳入:纯 mock 接口(`sendChatMessage`、`loginByPhone`、`saveOnboardingProfile`、`fetchOnboardingProfile`、设备本地能力 `fetchCacheSize` / `clearAppCache` / `fetchCarrierPhone` / `fetchWechatLoginResult` 等);明确尚未启动的 WebSocket、企业入驻状态机。 -> 说明:地基(统一请求层、双 Token、401 刷新、角色映射、健康检查)实现正确,不在本文问题范围内。 - ---- - -## 零、进展更新(2026-09-28 经 OpenAPI 实机校正) - -> 本轮以 `https://job.youthome.com.cn/api/v1/v3/api-docs`(OpenAPI 3.1.0,182 路径 / 220 path-method)为唯一准绳,对 `api.ts` 全部 `request()` 函数做了实机验证(测试号 `19900009021` 登录取 token 后逐一打真实路径,路径返回业务错误码 6001/7001/8002/8305 等即视为「路径存在、契约待对齐」,网关 404 视为「路径不存在」)。 - -### 审计结论(89 个 request 函数) - -- **ALIGNED(6)**:路径与 OpenAPI 完全一致,翻开关即直连。 -- **WRONG→REAL(14)**:路径前端猜错但有真实等价端点,已改路径(例:`/messages/conversations`→`/conversations`、`/resume`→`/profile/me/resume`、`/jobs/rank`→`/jobs/{id}/competition-ranking`、`/enterprise/candidates`→`/company/candidates`、`/enterprise/interviews`→`/interviews`)。 -- **PHANTOM(69)**:所属域后端无 OpenAPI 契约,翻开关必 404。已**退回 mock-only**(真实态不再调用 `request`,翻真实开关也不触网),待后端补齐契约再接。 - -### 标记清理 - -- `【待确认】` 标记:**0 处**(全部清除,含 `【待确认:…】` 形式)。 -- 实机验证:ALIGNED + WRONG→REAL 共 ~20 个接口真实路径均返回业务错误码(非网关 404),证明路径正确。 - -### 问题状态总览 - -| 原编号 | 状态 | 说明 | -|---|---|---| -| P0-1 | ✅ 已解决 | 89 函数已按 OpenAPI 校正并分类(ALIGNED 6 / WRONG→REAL 14 / PHANTOM 69) | -| P0-2 | ✅ 已解决 | 4 个桥接测试改为断言真实路径;幽灵域真实态断言已删 | -| P1-1 | ✅ 已解决 | `后端联调前端实现.md` §3 已按代码现状重标 | -| P1-2 | ⏳ 待办 | Token `expiresIn` 仍未处理(见下「遗留 P1-2」) | -| P1-3 | ⏳ 待办 | `/auth/me` 角色权威化仍仅在密码登录入口 | -| P1-4 | 🔶 部分 | 幽灵域写函数已退回 mock;已对齐写函数 body 形态待统一 | -| P2-1 | ⏳ 待办 | 145 处 JSDoc `@url /api` 旧前缀未清 | -| P2-2 | ✅ 已解决 | 3 个列表函数真实态从 Spring Page `{items}` 信封取数(实机验证);`/conversations` 真实返回数组已确认不变 | -| P2-3 | ⏳ 待办 | 双真相源切换策略未定 | -| P2-4 | ✅ 缓解 | 默认仍 mock;现幽灵域翻开关也不触网 | - -### 遗留 P1-2(Token 有效期) - -后端登录返回 `expiresIn`(秒数)而非 `expiresAt`;前端 `AuthContext` 仍用 `TOKEN_TTL_MS` 兜底成假 TTL。本轮未改,需单独排期修。 - -### P2-2 分页信封差异(已修复 · 2026-09-28 实机验证) - -**纠正前误述**:此前判断"前端 mock 用 `{content}`、真实用 `{items}`"——实际探查证明前端根本没有按 `.content` 取数的分页信封,mock 返回的是 `{jobs,total}`(fetchJobList)或直接数组,Hook 也不读 `.content`。 - -**真实风险落点**(测试号登录取 token 后逐一打真实路径,看 `data` 结构): -| 接口 | 真实返回(`data`) | 前端原假设 | 结果 | -|---|---|---|---| -| `/conversations` | **数组** | `Conversation[]` | 正确,未改 | -| `/notifications` | **Page 信封** `{total,size,totalPages,page,items}` | `Notification[]` | 空白 | -| `/interviews/me` | **Page 信封** | `Interview[]` | 空白 | -| `/jobs/favorites` | **Page 信封** | `Job[]` | 空白 | -| `/jobs`(fetchJobList) | Page 信封 | — | 早已适配 `data.items` | - -**修复**:`fetchNotifications` / `fetchInterviews` / `fetchFavorites` 真实态改为 `request<{items:T[]}>` 后取 `res.items ?? []`,与 `fetchJobList` 一致;3 个真实态测试同步改为断言从信封解包。tsc 0 错误,全量 jest 495 通过。 - -**延伸待验证(同类型)**:`fetchEmployeeReviews`(`/jobs/{id}/reviews`)同为后端列表接口,实机因测试号无该 job 返回 7001,未取到成功样本;OpenAPI 对该接口 200 schema 为空 `{}`,无法判定形态,先标记待验证,不臆改。 - ---- - -## 一、结论 - -- 已转换接口在代码层面"连接"基本铺完:`api.ts` 内 85 个 `fetch*` 读函数 + 大量 `save*/submit*/update*` 写函数**都已带有真实分支**(`if (!isMockMode()) return request(...)`),且全部受 `isMockMode()` 保护,默认 mock 态不会误触网。 -- **但契约未经后端校验**:全文件 **65 处 `【待确认】`** 标记真实路径为前端推测;**157 处 JSDoc `@url` 仍写旧 `/api` 前缀**(实际 `request()` 已去 `/api`,两者不一致)。 -- **现有测试锁定的是推测路径而非真实契约**,制造"绿灯已通过"的错觉。 -- **文档与代码脱节**:`后端联调前端实现.md` §3.2 把已转换的 `saveResume*`、`saveJobPreference` 标为"未接后端 / 后续接",与代码现状不符。 - -> 一句话:**这些接口"代码连了、契约没对"。** 当前不能宣布"已联调完成"。 - ---- - -## 二、问题清单(仅已转换接口) - -### P0 — 翻开关即爆 - -| # | 问题 | 证据 | 影响 | -|---|---|---|---| -| P0-1 | **【已解决 2026-09-28】** 原 65 个已转换接口真实路径为前端猜测(`【待确认】`),未对照 OpenAPI 校正 | 现 `【待确认】` 0 处;89 函数已分类 ALIGNED 6 / WRONG→REAL 14 / PHANTOM 69 | 翻开关后 ALIGNED+WRONG→REAL 共 ~20 接口直连真实后端(返回业务码非 404);PHANTOM 69 已退回 mock-only 不触网 | -| P0-2 | **【已解决 2026-09-28】** 测试锁定的是推测路径,不是真实契约 | `backend-bridge-remaining.test.ts` 等 4 文件已改为断言经 OpenAPI 确认的真实路径;幽灵域真实态断言已删除 | CI 绿灯现对应真实契约;81/81 桥接测试通过 | - -### P1 — 必须修,否则已转换接口不可信 - -| # | 问题 | 证据 | 影响 | -|---|---|---|---| -| P1-1 | **文档与代码脱节**:已转换的 `saveResume*`、`saveJobPreference` 被误标"未接后端" | 代码 3352/3369/3387/3413/3426/3439/3459/8684 等均有 `request(...)` 真实分支 | 状态失真,排期与验收失去依据 | -| P1-2 | **Token 有效期由前端臆造**:后端不返回 `expiresAt` 时,`AuthContext` 用 `TOKEN_TTL_MS` 兜底成假 7 天 TTL | `token-store.ts` 注释"后端可能不返回,故可选";`AuthContext.tsx:161/173` `expiresAt ?? Date.now()+TOKEN_TTL_MS`;`isTokenValid` 据此判断 | 影响所有已转换的鉴权请求:后端短过期时前端以为有效、长期不主动刷新,只能靠 401 兜底;或前端假 TTL 过期后误判登出 | -| P1-3 | **角色权威化接口 `/auth/me` 已转换,但只在密码登录入口调用** | 仅 `P2PasswordLogin.tsx:53` 调 `fetchCurrentUser`;微信登录 / 验证码登录 / 注册完成(`useRegisterFinish`)均用本地角色,不调 `/auth/me` | 多入口登录后角色可能与后端不一致,路由纠正逻辑仅在密码入口生效 | -| P1-4 | **已转换写接口 body 形态不一致**:有的包 `{skills}`、有的裸传 `items`、有的包 `{items}` | `saveResumeSkills` → `{ skills }`(3387);`saveResumeExperience` → `items`(3426,未包裹);`saveResumeEducation` → `{ items }`(3413) | 后端若要求统一 envelope,部分已转换写接口会 400 / 字段丢失 | - -### P2 — 隐患 / 卫生 - -| # | 问题 | 证据 | 影响 | -|---|---|---|---| -| P2-1 | **157 处 JSDoc `@url` 仍写旧 `/api` 前缀**,与实际 `request()` 路径(已去 `/api`)不一致 | `grep @url /api` = 157;实际 `request('/jobs')` 无 `/api` | 维护者按注释拼地址必错;JSDoc 不能作契约依据 | -| P2-2 | **✅ 已修复(实机验证)** `fetchNotifications`/`fetchInterviews`/`fetchFavorites` 真实态从 Spring Page `{items}` 信封取数;`/conversations` 真实返回数组已确认 | 实机打 `/notifications`/`/interviews/me`/`/jobs/favorites` 返回 `{total,size,totalPages,page,items}`;原代码假设裸数组会空白 | 翻 `apiUseMock=false` 后消息/收藏/面试列表正常渲染;`fetchEmployeeReviews` 同类待验证 | -| P2-3 | **mock / 真实双真相源切换一致性**:mock 态 `saveResumeSnapshot` 写本地 storage 覆盖层、`fetchResume` 读本地;真实态写后端、读后端 | `saveResumeSnapshot`(mock 写 storage) vs `fetchResume`(真实读后端) | 同一设备从 mock 翻真实后,本地已填资料不出现在后端返回中,需明确迁移/清空策略 | -| P2-4 | **`apiUseMock` 默认 `true` 的隐患**:容易被误判为"联调完成"而翻开关 | `http.ts:31` 默认 `true` | 翻开关即触发 P0-1 的 65 个错误请求 | - ---- - -## 三、逐域未确认路径明细(P0-1 附录) - -> 下列域的已转换接口真实路径均带 `【待确认】`,为前端推测,未经 OpenAPI 校正。 - -| 域 | 代表函数 | 推测路径 | -|---|---|---| -| 简历写 / 读 | `saveResumeBasicInfo` / `fetchResume` | `/resume/*`、`/resume/save` | -| 消息 / 沟通 | `fetchConversations` / `fetchConversation` / `fetchMatchResult` | `/messages/*`、`/chat/conversations/{id}` | -| 求职者资料 / 偏好 | `fetchUserProfile` / `saveJobPreference` | `/profile/me`、`/profile/me/job-preference` | -| 薪资 / 工具 | `fetchSalaryRecords` / `fetchWeightSettings` | `/salary/records`、`/jobs/weight-settings`、`/tools/*` | -| 测评 / 成长 | `fetchHollandAssessment` / `fetchSkillCloud` | `/growth/*`、`/user/*`、`/tools/*`、`/game/*` | -| 企业端 | `fetchJobManagement` / `fetchEnterpriseOverview` | `/enterprise/*` | -| 法律(阿布阿诺) | `fetchLegalBrand` / `fetchLegalOrders` | `/legal/*` | -| 隐私 / 设置 / 账户 | `fetchPrivacySettings` / `fetchAccountCancel` | `/settings/*`、`/user/*`、`/operation/*` | - -完整 65 处见 `api.ts` 内 `【待确认】` 注释;精确清单应回到 `src/__tests__/backend-bridge-remaining.test.ts` 的 `FUNCS` 数组对照 OpenAPI 逐一校正。 - ---- - -## 四、整改建议(针对已转换接口) - -1. **校正契约(P0)**:以《职来后端接口联调说明》`.docx`(§7 OpenAPI,`job.youthome.com.cn`)为唯一准绳,逐函数对照 65 个 `【待确认】` 路径,命中后删注释;**未对照前不宣布"已收口"**。 -2. **让测试服务于真实契约(P0)**:把 `FUNCS` 数组路径从"占位"替换为经 OpenAPI 确认的路径;新增对拍用例(请求路径 + 响应字段 schema 校验),而非只断言前端猜的地址。 -3. **统一已转换写接口 body 形态(P1-4)**:约定"整表替换类用 `{ items }`、单对象类用对象本体",`saveResumeExperience` 当前裸传 `items` 需包裹。 -4. **修正 Token 有效期语义(P1-2)**:后端不返回 `expiresAt` 时不要用 `TOKEN_TTL_MS` 兜底成假 TTL;改为"不预判、靠 401 触发刷新 + 可选短轮询",或强制后端下发 `expiresAt`。 -5. **补全 `/auth/me` 角色权威化(P1-3)**:微信登录 / 验证码登录 / 注册完成三个入口登录后统一调 `/auth/me` 并 `setRole`,与密码入口一致。 -6. **清理 JSDoc(P2-1)**:批量清除 157 处 `@url /api` 旧前缀。 -7. **明确双真相源切换策略(P2-3)**:在登录流程或"清除缓存"中,翻真实态前提示并清空本地覆盖层,避免数据幽灵。 -8. **重写状态文档(P1-1)**:`后端联调前端实现.md` 按代码现状重标已转换接口,删除"未接后端"的误述。 - ---- - -## 五、验收口径(建议写入 AGENTS.md) - -> **"接口已联调完成"的定义应为**:① 路径经 OpenAPI 校正并删除 `【待确认】`;② 至少一次真实联调(`apiUseMock=false`)返回符合类型定义的响应;③ 测试断言的是真实路径 + 响应字段。 -> 仅凭"代码里写了 `request()`"或"测试命中占位路径"**不算完成**。 diff --git a/职来后端接口联调说明.docx b/职来后端接口联调说明.docx deleted file mode 100644 index dc47a24..0000000 Binary files a/职来后端接口联调说明.docx and /dev/null differ diff --git a/运营商一键登录接入方案.md b/运营商一键登录接入方案.md deleted file mode 100644 index 2dc9b88..0000000 --- a/运营商一键登录接入方案.md +++ /dev/null @@ -1,167 +0,0 @@ -# 运营商一键登录接入方案(P2Q) - -> 状态:方案文档 · 真实取号 SDK 就绪后按路线图替换 mock 即可 -> 适用范围:轻松聘 App 运营商一键登录(本机号码认证) -> 关联文件:`src/types/carrier-auth.ts`、`src/lib/carrier-sdk.ts`(取号适配层)、`src/hooks/useCarrierPhone.ts`、`src/hooks/useLogin.ts`、`src/api/api.ts`、`components/onboarding/screens/P2QuickLogin.tsx`、`src/__tests__/carrier-login.test.ts`、`src/__tests__/carrier-sdk.test.ts` -> 参考:https://www.cnblogs.com/Frankada/p/15689036.html (本机号码一键登录原理:三大运营商能力边界 + 取号/校验方法) -> 本文回答的问题:「一键登录时应当获取到手机号,为什么获取不到」—— 见第 1 节。 - ---- - -## 0. 一句话结论 - -**在本机(Expo Go / 模拟器)跑,取号必然是假的**:本机号码只能由运营商的号码认证 SDK 在端上取回,它是原生模块,Expo Go 内不存在,模拟器也没有 SIM 卡。当前 `fetchCarrierPhone()` 走的是 `src/lib/carrier-sdk.ts` 的 mock 分支,返回写死的号码 `15000009367`(掩码 `150****9367`),用于把**页面链路**跑通。 -**接入真实取号 = 端上换 1 个适配层文件(`src/lib/carrier-sdk.ts` 的 5 个函数体) + 服务器换 1 个接口(`/api/auth/login-by-carrier`) + 用 EAS Build 生成自定义客户端。** 页面、hook、类型、`api.ts` **零改动**。 - ---- - -## 1. 原先「取不到手机号」的两个真实原因(已修) - -排查结论:**不是取号 SDK 的问题,是页面根本没读取号结果**。修复前 `P2QuickLogin.tsx` 有两个独立缺陷,叠加起来表现为「号码永远显示占位」: - -| # | 缺陷 | 表现 | 现状态 | -|---|---|---|---| -| 1 | 页面读的是 `data.phone` —— 该字段**只有短信登录通道(P2)才会写入** | 走一键登录进来时 `data.phone` 恒为空 → 号码位永远渲染占位文案「本机号码」,用户以为没取到号 | ✅ 已改为读 `useCarrierPhone()` 的 `maskedPhone` | -| 2 | `loginByCarrier()` 返回的 `phone` **被直接丢弃** | 即使后端换了真号码,页面与后续步骤(P2P/P2R 预填、B3 联系人电话)也拿不到 | ✅ 成功登录后 `setData({ phone: result.phone })` 写回引导资料 | - -顺带补上的**失败分支**(此前不存在,取号失败也照样显示号码、按钮照样可点): - -- `carrierToken` 为空 = 没取到号 → 「一键登录」置灰(空令牌调登录接口必然失败,不如不让点); -- 号码位改为「正在获取本机号码…」/「未能获取本机号码」,并给出**重新获取**与**改用验证码登录**两个出口; -- 取号是**进页面即预取**(`useCarrierPhone` → `useAsyncData`,带自动重试与请求去重),不是等用户点按钮才去取。 - -> 没修的(有意保留):`P2QuickLogin` 的《xx认证服务条款》**不冒充能打开**——该文档由运营商在认证页内提供,本仓库没有,故只弹一段如实说明;页脚服务热线为空时整块不渲染(`SUPPORT_HOTLINE` 为空)。 - ---- - -## 2. 现状盘点(已实现 vs 待接入) - -| 层 | 文件 | 现状 | 接入动作 | -|---|---|---|---| -| 类型 | `src/types/carrier-auth.ts` | ✅ `CarrierPhoneResult` / `CarrierName` / `CarrierFailureReason` / `CarrierNetworkInfo` 已定义 | 无需改动 | -| 取号 hook | `src/hooks/useCarrierPhone.ts` | ✅ loading / error / refresh / failureReason 已就绪 | 无需改动 | -| 页面流转 | `P2QuickLogin.tsx` | ✅ 取号 → 换登录态 → 写登录态 → 写回号码 → `P3` | 无需改动 | -| 登录态 | `src/context/AuthContext.tsx` | ✅ `login(token, expiresAt)` 已就绪 | 无需改动 | -| **端上取号适配层** | `src/lib/carrier-sdk.ts` | ✅ 厂商无关适配层已就位(对齐 `wechat-sdk.ts`),现为 mock | **换 SDK 调用(端上)** | -| **取号接口** | `src/api/api.ts` · `fetchCarrierPhone()` | ✅ 已改为调适配层,只做超时/归一化/脱敏 | 无需改动(改适配层即可) | -| **换登录态** | `src/api/api.ts` · `loginByCarrier()` | ⚠️ **mock**:直接下发 `mock_token_carrier_*` | **换真实接口** | -| 运营商/聚合服务 | —— | ❌ 未开通 | 开通号码认证服务 | -| 构建产物 | EAS | ❌ 无自定义客户端 | `eas build --profile development` | - -**已验证的关键事实**(直接取自源码,非推测): - -- `loginByCarrier('')` **直接 reject**(`本机号码未取到,请重新获取或改用验证码登录`),页面因此不能靠空令牌换到登录态。 -- 登录成功后 `writeAuth(result.token)` **确实写入登录态**(此前该页只 `navigate('P3')` 不写 token,是本项目曾存在的严重缺口,已于 2026-09-15 一并修复)。 -- 号码的**唯一真相源是后端换取结果**,不是端上预取的号码:`loginByCarrier` 返回的 `phone` 由接口层给出,页面不自己拼预取到的号码写资料。 -- 取号令牌**一次性且 5 分钟内有效**(`CARRIER_TOKEN_TTL_MS`),过期需重新取号。 - ---- - -## 3. 两个硬约束(设计已遵守,接后端不可破坏) - -1. **端上取号 ≠ 可信号码**:运营商 SDK 在端上取回的号码可以被改机工具伪造,因此**可信号码必须由后端拿令牌向运营商换取**(`/api/auth/login-by-carrier` 的返回为准)。前端只把取到的号码拿去展示。 -2. **令牌一次性 + 短 TTL**:`carrierToken` 用后即废、且 5 分钟过期。所以「取号」与「换登录态」必须紧挨着(页面级),不能缓存起来留到下个页面用;过期后唯一的正确动作是**重新取号**。 - ---- - -## 4. 接入路线图(按执行顺序) - -### Step 1 · 开通运营商号码认证服务(前置,阻塞项) -- 任选其一:中国移动 / 联通 / 电信官方号码认证能力,或**聚合方案**(阿里云号码认证服务、极光、闪验等,一次接多家运营商)。 -- 需要准备:企业资质、`android.package`(`com.qingsongpin.app`,见 `app.config.js`)与**签名 SHA256**、iOS `bundleIdentifier`。 -- 拿到的 **AppKey / AppSecret** 需同时配置在**端上(SDK 初始化)**与**服务器(换取号码)**两侧。 - -### Step 2 · 安装取号 SDK(Expo 插件优先) -```bash -npx expo install <所选方案的 RN 插件> -``` -- 原生模块需在 `ios/` `android/` 原生壳中注册;有 config plugin 的方案由预构建自动注入,无需手改原生代码。 -- **Expo Go 一定跑不了取号**(`requireNativeModule` 在 import 期执行,缺模块会直接崩),所以本地开发期保留 mock 分支,用 `__DEV__` 或环境变量区分。 - -### Step 3 · 生成 EAS 自定义开发客户端 -```bash -eas build --profile development --platform android -eas build --profile development --platform ios -``` -- 与微信登录同一前提(见 `微信登录接入方案.md` 的 Step 3):**后续所有真机验证都在此客户端中进行**。 -- iOS 需要在 `app.config.js` 补运营商要求的隐私描述文案(`expo-build-properties` / `infoPlist`),Android 需确认运营商要求的权限未被裁剪。 - -### Step 4 · 替换端上取号(唯一改动点 · 前端) -文件:**`src/lib/carrier-sdk.ts`**(不是 `api.ts` —— 取号能力已隔离到适配层,与微信登录的 -`src/lib/wechat-sdk.ts` 对称)。把下面 5 个 mock 函数体换成 SDK 调用,**对外签名不变**: - -| 适配层函数 | 对应原生 API(Android / iOS) | -|---|---| -| `isCarrierAuthAvailable()` | SDK 初始化结果 | -| `getCarrierNetworkInfo()` | `getNetworkType` / `networkInfo` | -| `prefetchNumber(timeoutMs)` | `getPhoneInfo` / `getPhoneNumberCompletion`(只建缓存,不返号码) | -| `getCarrierNumber(timeoutMs)` | 同上取号 → `{ phone, token, operator }` | -| `clearNumberCache()` | `delScrip` / `delectScrip` | - -`api.ts` 的 `fetchCarrierPhone()` 只负责三件业务规则:套超时(`CARRIER_SDK_TIMEOUT_MS` = 8000ms)、 -用 `carrierOf()` 归一化运营商、调 `maskPhone()` 脱敏,**它不碰 SDK,接真实 SDK 时不用改它**。 - -```ts -// src/lib/carrier-sdk.ts · getCarrierNumber 真实实现示例 -const r = await NumberAuthSDK.getMobileNumber(); // 5 分钟内有效,用后即废 -if (!r?.token) throw new CarrierAuthError('unknown'); -return { phone: r.phone, token: r.token, operator: r.operator }; -``` - -- **失败一律抛 `CarrierAuthError(reason)` 或返回空串,绝不返回假号码** —— 页面靠 - `carrierToken === ''` 判定失败并置灰按钮,返回假号码会让失败分支永远进不去。 -- 运营商标识各家形状不同(中文名 / 大写缩写 / 数字码),`carrierOf()` 已做归一化,认不出即 `unknown`。 - -### Step 4.5 · 四个不能漏的工程细节(来自运营商文档,非我们的设计选择) - -| 细节 | 说明 | 落点 | -|---|---|---| -| **超时** | SDK 默认 8s;不包装则弱信号下取号无限挂起,页面停在「正在获取本机号码…」没有出口 | `CARRIER_SDK_TIMEOUT_MS` + `guard()` 超时包装 | -| **蜂窝网络预检** | 取号走数据网关,纯 Wi-Fi 取不到(SDK 会瞬切蜂窝再切回,但用户关流量时必然失败) | `getCarrierNetworkInfo()`,失败原因 `no-cellular` | -| **取号缓存 scrip** | 缓存提高成功率、降时延,且允许未开蜂窝时取号;换卡时 SDK 自动失效 | `prefetchNumber()` 建缓存 / `clearNumberCache()` 主动清 | -| **敏感动作清缓存** | 注销、换绑手机号、退出登录后应主动 `delScrip`,避免下次取号命中旧缓存 | `clearNumberCache()`(**尚未接到注销/换绑流程,待补**) | - -> 缓存清理目前只有函数、没有调用方 —— 等账号注销 / 换绑手机号功能落地时再接,不提前铺。 - -### Step 5 · 实现后端换号接口 -| 接口 | 方法 | 职责 | 关键返回 | -|---|---|---|---| -| `/api/auth/carrier/number` | POST | 端上取号的**服务端旁路**(可选:部分方案把 token 的校验放在这里) | `CarrierPhoneResult` | -| `/api/auth/login-by-carrier` | POST | 用一次性 `carrierToken` 向运营商换取真实号码 → 查库 → 建号/登录 | `LoginResult`:`token` + **已核验的 `phone`** + `expiresAt` | - -- 后端须保证:`carrierToken` 一次性、来源可校验、过期拒绝;**号码以后端换取结果为准**(前端传来的号码一律不信任)。 -- 该接口同时也是「登录即注册」入口:号码在库中不存在时应自动建档(与验证码通道一致)。 - -### Step 6 · 清除 mock 残留 -- 删除 `src/lib/carrier-sdk.ts` 的 mock 块:`MOCK_CARRIER_PHONE`、`mockState`、`setMockCarrierFailure`、`setMockCarrierHang`(`guard()` 的超时包装要留,真实取号同样需要)。 -- `api.ts` 中 `loginByCarrier()` 的 mock 返回与 `MOCK_CARRIER_PHONE` 引用一并替换为真接口。 -- `src/__tests__/carrier-sdk.test.ts` 中断言 mock 行为的用例改为断言**适配层契约**(失败不返假号、超时必返 timeout、归一化认不出即 unknown),这部分在真实取号后依然是有效契约。 - ---- - -## 5. 手动验收路径(可复现) - -1. 在自定义客户端(非 Expo Go)上安装,**插入有效 SIM 卡并开启数据流量**(取号需要蜂窝网络鉴权,纯 Wi-Fi 下多数方案会失败)。 -2. 进入 P2Q → 期望**不输入任何号码**即展示掩码本机号码(与 SIM 卡一致),页脚显示「认证服务由 xx 提供」。 -3. 点「一键登录」→ 期望直接进 P3,且后续注册流程(P2P/P2R 预填手机号、B3 联系人电话)**都用这个真号码**。 -4. 关流量 / 开飞行模式 / 拔卡 → 期望号码位显示「未能获取本机号码」、按钮置灰、两个出口(重新获取 / 改用验证码登录)可用。 -5. 令牌过期后(>5 分钟再点)→ 期望提示重新取号,不出现「点了没反应」。 -6. 回归 `npx tsc --noEmit` 与 `npx eslint .` 均 0 错误。 - ---- - -## 6. 风险与待确认项 - -| 项 | 风险/阻塞 | 处置 | -|---|---|---| -| 运营商服务开通 | 需企业资质与签名指纹,非代码可解 | Step 1 前置,未开通前无法真机验证 | -| 蜂窝网络依赖 | 无 SIM / 无流量 / 双卡选错卡都会取号失败 | 失败分支已就位;验收时明确用流量 | -| 端上号码不可信 | 改机工具可伪造取号结果 | 号码一律以后端换取结果为准(Step 5) | -| Expo Go 无法验证 | 原生模块不存在,import 期即崩 | 开发期保留 mock 分支 + dev build 出包 | -| SDK 选型 | 各家插件维护状态不一 | 接入时以官方 docs 为准(AGENTS.md 要求先读 `docs.expo.dev/versions/v57.0.0/`) | - ---- - -## 7. 一句话给执行者 - -**别动页面、hook、类型、`api.ts`。** 只动两处:`src/lib/carrier-sdk.ts` 的 5 个函数体(换端上 SDK 调用,失败抛 `CarrierAuthError` 或返回空串)和 `loginByCarrier()`(换真接口,号码以后端为准),配齐运营商号码认证服务 + EAS 自定义客户端,一键登录即可真机走通。 diff --git a/项目开发状态.md b/项目开发状态.md deleted file mode 100644 index f88d059..0000000 --- a/项目开发状态.md +++ /dev/null @@ -1,1921 +0,0 @@ -# 轻松聘 App 开发进度与规划文档 - -> 生成日期:2026-09-10 -> 最后更新:2026-09-20 · 第三批(**把同一类缺陷的另一半「同一张映射表 / 同一段业务规则在多个页面各写一份」收敛掉**,见 4.16。共 **14 处**本地重复声明压成 **6 张单一来源的表**:评级→文字色 **5 份 → 1**(growth / jobs / ability / assessment 四处同名同值,hr-evaluation 因数据层在那里下发的是**中文评级**而非 `JobLevel`、键的域不同而另立一份,故第六处是**派生**而非并列复写)、霍兰德六型色 3 → 1、语义色名→色值 3 → 1、文章缩略图 2 种写法(对象表 vs 两份平行 `Record`)→ 1、社保基数 clamp 3 → 1(**下游那份在接口层,才是业务规则该在的层**;页面内联的那份是因为它要用**新城市**的上下限而 helper 读的是旧城市,故提取时把 `city` 变成显式参数,特殊情况随之消失)、`formatRecordTime` 与登录页内联脱敏 → 复用 `formatDateTime` / `maskPhone`;另把企业端发布页自持的学历 / 工作类型选项表接回 `src/constants`。**取值一律等于现状、一像素未改**,属纯重构。新增 **26 例回归锁**并做**6 处注入验证**(6 例如期变红、逐字还原、grep 确认无残留)。**两条如实记录**:① 子代理报来的 `DIMENSION_LEVEL`「重复声明」经查**不成立**(全仓只有一处),其 `THUMBNAIL_*` 与 clamp 的行号描述亦不精确 —— 与「代理报告不是证据」这一贯口径一致,**每条都独立核对过**;② 查出 `profile.tsx` 的「良好」用 `textSecondary`、而另外五处用 `primary` 的**配色分歧,未擅自统一**,留给产品定夺) -> 上一版:2026-09-20 · 第二批(**把同一类缺陷「同一个事实被写了两份、两处取值不同」排查完剩下的另一半**,见 4.15。① 又查出并修掉 **10 组**同源不同值的数据 —— 求职进度统计卡五项之和 30 ≠ 总数 28、月度图表投递合计 52 / 面试合计 16 而卡片写 28 / 6、我的页收藏徽标 12 与列表 3、勋章「投递满100次 **已获得**」而实际只投了 28 次、匹配横幅 3 家而 `matched` 标记只有 2 条、岗位详情综合分**恒为 92**(9 个岗位只有第 1 个对得上)、对比页把产品经理与 UI 设计师的分**正好写反**、候选人 4–8 号点进去**全叫「张明远」**、岗位 1 的公司所在地在列表与详情各写一个城区、待回复卡片写 3 人而它跳转的 Tab 只有 2 条;**修法一律是从权威源派生**而非手工对齐两个字面量,使矛盾在结构上无法复现,新增 **22 例回归锁**并逐条注入验证;② 顺带修掉求职进度页标题「求职中 {stats.interviewing}」压在 4 行列表上、切到「面试中」Tab 只剩 1 行的**同屏**自相矛盾;③ 5 处 `rgba(255,255,255,x)` 按**实际角色**(不是同一个 token)收敛为 4 个语义 token,**值等于现状、一像素未改**;④ 补上 lint 盲区 —— `no-hardcoded-hex-color` 此前**拦不住 `rgba()`/`hsl()`**,已扩到函数式颜色并加模板字符串访问器,注 `rgba(1,2,3,0.5)` 验证确实报错;⑤ 企业端看板内联的 `.toFixed(1)k` 与霍兰德参与人数的 `toLocaleString()` 下沉到 `src/utils`。**本批次两处如实记录**:一次 `python` 静默失败导致「注入其实没发生」,以及一条**测试自己写错了**的断言(把「累计面试邀请」与「当前面试中」当成同一个事实),均已订正并写进文档**) -> 上上版:2026-09-20(**修掉一类「同一个事实被写了两份、两处取值不同」的缺陷**:① 引导采集的求职意向——期望薪资 / 求职状态 / 期望城市三组选项,引导页与「设置求职意向」页各有一份词表,**三组全部匹配不上**,走完引导进来的用户看到三组 chip 一个都没选中,像资料丢了;更严重的是在那里选「暂不考虑」保存后,简历**仍把他记成积极求职中**(判定常量与页面词表不是同一个字符串)。三组选项已下沉到 `src/constants` 单一来源,新增 `withCurrentOption` 处理 P7 阶梯能拼出、快捷档覆盖不到的薪资组合,**真机实测四组 chip 全部正确选中**;② 顺带查出四处数据自相矛盾:当前用户 Offer 数(我的页 3 / 求职进度页 2)、求职进度每月 Offer 之和(3 / `offerCount` 为 2)、企业端在招岗位(总览写死 1280 / 岗位管理算出 8)、企业端评价数(1280 / 28 / 128 三个数)、企业名(全称 / 简称两个值),均已收敛为单一来源并实测;③ **撤回 4.10 第 7 条关于合规页脚的错误结论** —— 4.10 断言「常量无消费者、缺页脚组件」,实际该组件早一天就已存在并挂在两页上,页脚一直渲染。见 4.14**) -> 上上上版:2026-09-18(**修正 4.8「死控件清零」的错误结论** —— 该结论只覆盖了审计脚本的检测口径,另有 **37 处「点了只弹功能开发中」的假交互**未被发现,本轮全部处理;补上审计脚本盲区,修复 18 处四态缺口,企业端岗位加「下架 / 刷新」写通道,见 4.9。**另有:新建「资质证照」页并恢复设置 / 关于我们两处入口,修掉「关于我们」页两条点下去毫无反应的死链,见 4.10**。**再有:修掉「草稿」岗位被写成「重新上架」的状态文案缺陷(截图复看才发现),状态映射下沉到 API 层并加测试锁,见 4.11**。**再有:4.9 遗留的 6 处导出 / 分享全部真机验证通过(靠系统分享面板的文本预览核内容),并顺带查出霍兰德参与人数在介绍页与成长页是两个数(124,580 / 12,846),已收敛为单一常量 + 测试锁,见 4.12**。**再有:4.9 遗留的最后 6 处空态用「注入空数据 + 逐个截图」实测通过(关掉 4.9 第 8 条最后一项);过程中查出文书模板 `gavel-outline` 不是合法 Ionicons 字形名(全库 1357 个字形里没有 gavel,图标渲染为空白,被页面的 `as keyof typeof Ionicons.glyphMap` 断言吞掉了类型错误),已修并新增 `npm run check:icons` 门槛防复发;同时更正 4.12 对深链不路由的错误归因(不是 dev-client,是 Expo Go 只注册 `exp://`);并把 4.9 遗留的最后一项「`settings` 缓存读取失败态」结案 —— **结论不是「验不了」,而是 `readCacheBytes()` 把所有异常吞成 0、该分支根本不可达**,已加 12 例测试锁住这条契约;另补上第七处分享路径(入职清单导出)的真机验证,以及 4.9 同批 5 个分享拼装函数一直缺的 28 例测试锁(含把 4.12 那个真机证伪实验固化成断言);并顺带把「数据层引用外部词表」三类(图标名 / 路由 / 颜色)系统排查完,见 4.13**) -> 上上上上版:2026-09-17(全仓死控件清零:9 处 → 0 处,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) -> 上上上上上版:2026-09-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) -> 范围:项目健康审查 + 已开发 / 待开发清单 -> 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、假交互 0 处、图标名 0 处无效、**365 例测试全绿**),无需修正阻塞错误。 -> -> **读这份文档请先注意一条**:4.8 曾写「死控件清零」并据此把 P2 标记为已完成,**那个结论当时是错的** —— 审计脚本只查「无处理函数 / 空处理函数」,查不到「有处理函数但点下去只弹一句『功能开发中』」。详见 4.9 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。 - ---- - -## 一、健康审查结论 - -| 检查项 | 命令 / 方式 | 结果 | -|---|---|---| -| 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | -| 代码规范 | `npx eslint .` | ✅ 0 error / 0 warning(2026-09-17 复跑) | -| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 73 路由全部存在,0 失效跳转(12 个「孤立页」经核对为动态路由 / Tab 页误报,见 4.8) | -| 假交互审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-18 复跑,**口径已含弹窗式假交互**,见 4.9)
⚠️ 另有 **3 处知情保留的后端待办**(`--pending` 可列出,非疏漏) | -| 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ **140** 个接口函数全部留有注释版真实请求(见 4.6) | -| 图标名审计 | `node scripts/check-icons.mjs` | ✅ **0 处无效图标名**(2026-09-18 新增门槛,修掉 `gavel-outline` 后建立,见 4.13) | -| 单元测试 | `npm test` | ✅ **25 个文件 339 例全绿**(2026-09-20) | -| 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | -| 应用配置 | `app.config.js` | ✅ name「轻松聘」、scheme `qingsongpin`、plugins 正确 | - -**判定**:项目处于可启动状态(`expo start` 即可运行),未发现阻塞运行的错误。本次未进入"修正"环节。 - ---- - -## 二、技术栈与架构 - -- **框架**:Expo SDK 57 · React Native 0.86.3 · React 19 · TypeScript 6 · Expo Router(文件路由) -- **双端同构**: - - 求职端 `(tabs)`:岗位 / 沟通 / 成长 / 我的(落地 `jobs`) - - 企业端 `(enterprise)`:岗位 / 人才 / 沟通 / 我的(落地 `job-management`) -- **分层(src/)**:`types`(领域模型) · `api`(7385 行 mock 数据层) · `hooks`(语义化取数) · `components`(42 组件) · `context`(Auth/Role) · `theme`(colors token) · `constants` · `utils` · `lib`(storage / wechat-sdk / imagePicker / resumeExport / shareFile / cache) -- **数据链路**:页面 → Hook(`useXxx`) → API(`fetchXxx`) → 后端。当前全 mock,签名已固化,接真实后端只改 `src/api/api.ts` 函数体。 -- **配色治理**:统一走 `src/theme/colors.ts`,禁止硬编码十六进制;透明度用 token + `15`/`20` 后缀。 - ---- - -## 三、已开发模块(按业务域) - -> 页面规模以行数为代理指标;5 行薄壳(如隐私政策 / 用户协议)是合理架构——内容由 `LegalDocScreen` 按 `docId` 加载,**非空壳**。 - -### 3.1 用户端(求职端) - -| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | -|---|---|---|---|---| -| 引导 / 注册 | `/guide` `/register` `/onboarding` | guide(194) · register(69) · onboarding(488) | 中 | 已开发 | -| 岗位 | `/jobs` `/jobs/[id]` `/jobs/compare` `/jobs/rank` `/jobs/offer-compare` `/jobs/weight-settings` | jobs(756) · [id](612) · compare(306) · rank(753) · offer-compare(676) · weight-settings(285) | 大 | 已开发 | -| 沟通 | `/chat` `/chat/[id]` | chat(556) · [id](750) | 大 | 已开发 | -| 成长 | `/growth` `/game` `/game/level` `/progress` `/assessment` `/ability` `/holland*` `/interview*` `/resume*` `/job-preference` | 多页 300–600 行 | 大 | 已开发(逐关内容见四.2) | -| 我的 / 设置 | `/profile` `/settings` 及 10 个子页 | profile(743) · settings(262) · about / account / account-cancel / privacy* / notification / delete-data / user-agreement / privacy-policy | 大 | 已开发 | -| 增值 / 合规 / 工具 | `/legal*` `/tools/*` `/hr-evaluation` `/audit-report` `/appeal` `/operation-rules` `/enterprise-certification` `/personal-data-export` `/candidate-detail` `/article/[id]` `/history` `/favorites` `/job-evaluations` | 多页 300–720 行 | 大 | 已开发 | - -### 3.2 企业端 - -| 业务域 | 路由 | 关键页面(行数) | 规模 | 状态 | -|---|---|---|---|---| -| 岗位管理 | `/job-management` `/job-publish` `/job-evaluations` | job-management(645) · job-publish(633) · job-evaluations(752) | 大 | 已开发 | -| 人才 | `/talent-recommendation` `/candidate-detail` | talent-recommendation(684) · candidate-detail(719) | 大 | 已开发 | -| 沟通 | `/enterprise-chat` | enterprise-chat(611) | 大 | 已开发 | -| 我的 | `/enterprise-overview` | enterprise-overview(594) | 大 | 已开发(【设置】入口已接线 `/settings`;Tab 选中色已统一 `primary`) | - -> 企业端设置类页面复用根级 `app/settings/*`,符合「设置类统一收纳于【设置】页」规范。 - ---- - -## 四、待开发 / 进行中 / 风险 - -### 4.1 既定任务(来自项目规范,需逐项核实) - -- **企业端三项**(历史记录为待办,代码审查显示已基本完成): - 1. Tab 选中色统一回 `primary` —— ✅ 代码已用 `colors.primary` - 2. 我的页补【设置】入口 —— ✅ `enterprise-overview.tsx` 第 281 行已 `router.push('/settings')` - 3. 统一快捷入口话术 —— ✅ 已完成(2026-09-10):`MOCK_ENTERPRISE_QUICK_ACTIONS` 与沟通页 `QUICK_ACTIONS` 中 6 处 label 已对齐规范词(人才库→人才推荐、评价管理→岗位评价管理、沟通→企业沟通、面试日程/面试安排→面试评分),与「我的」菜单、`/interview-score` 路由名一致。 - 4. 沟通页快捷操作接线 —— ✅ 已完成(2026-09-10):`QuickActions` 死入口已绑定 `onPress`;批量发邀请→`/talent-recommendation`、面试评分→`/interview-score`(根级自包含页、可返回)走跳转,批量发Offer / 群发通知无对应企业端页、按项目约定弹 `Alert '功能开发中'`。tsc/eslint 0 问题。 -- **微信登录**:⚠️ **入口已于 2026-09-15 按产品决定摘除**(丁总:前端不做 QQ / 微信登录)。三分支(`new` / `active` / `deleted`)状态机、`P2W` / `B1` 页面、`src/lib/wechat-sdk.ts`、相关 hook / 类型 / 接口**全部保留未删**,仅 `P1Login` / `P2PhoneCode` 不再挂入口;`app/register.tsx` 的 `P2W` / `B1` case 亦保留。**接入路线图仍见 `微信登录接入方案.md`**(该文档开头已加摘除说明)。恢复入口 = 重新挂上按钮与 `useWechatLogin` 状态机。 -- **登录方式(2026-09-15 改造)**:现为「手机号 + 验证码」(`P2`)·「手机号 + 密码」(`P2P`)·「运营商一键登录」(`P2Q`)三通道,微信入口已摘除。 - - **修复了一个此前存在的严重缺口**:`P2PhoneCode` / `P2QuickLogin` 原先只 `navigate('P3')` 而**从不写入登录态**(`useLogin` / `loginByPhone` 写好却无人调用),全项目唯一写 token 的地方是微信链路 —— 入口一摘 `isTokenValid` 将恒为 false、每次冷启动都被打回登录页。现三条通道均已接入 `useAuth().login()`。 - - **密码闭环**:`P2P` 密码登录 → `P2R` 设置/重置密码(**同页两阶段**:验证手机号 → 设新密码,成功后直接下发登录态)→ 设置页 `/settings/password` 修改密码(改完强制重登)。「登录即注册」的用户天生无密码,**首次获得密码即走「忘记密码」入口**,不额外插入注册步骤。 - - 密码规则(8–20 位、需含字母和数字)在 `src/api/api.ts` 的 `validatePassword` 统一计算,错误码 → 文案映射在 `src/constants` 的 `PASSWORD_RULE_LABEL`;新增测试 `src/__tests__/password.test.ts`(24 例)。 - - 新增公共件:`src/components/PasswordField.tsx`(密码输入 + 明文切换)、`src/hooks/useAgreementGate.ts`(协议门卫,此前逐字复制 3 份)、`src/utils/validators.ts`(手机号 / 验证码规则单一来源)。 - - **按钮底色全局改深**:`components/onboarding/ui/PrimaryButton.tsx` 的 `colors.primary`(#2C83FD) → `colors.primaryBtn`(#1F6FDB)。原白字对比仅 **3.64:1,不满足 WCAG AA**;`primaryBtn` 为 **4.82:1**,且 AGENTS.md 配色规范本就要求 primary 不作白字按钮底。**副作用:整条注册流程按钮视觉变深,如已对外出图需重新确认。** - - 账号与安全页的「微信账号」项已随入口一并移除(`MOCK_ACCOUNT_SECURITY`),避免留下有出口无入口的死项;「修改登录密码」项补上 `route: '/settings/password'`,此前点击无任何反应。 - -### 4.2 功能完整性缺口(标注【待确认】为推断项,需与产品对齐) - -- **通关小游戏(7 关)**:框架页 `game.tsx` + `game/level.tsx` 已建,7 关(认识自己 → 能力 → 岗位 → 企业 → 简历 → 面试 → 签约)**内容与数据已完整**:`MOCK_GAME_LEVELS` 共 22 题(self 4 / 其余各 3),题型含单选/多选/输入,配套解析与 `tags` 用于岗位推荐。评分(≥60 通关)、解锁、进度聚合逻辑均在 `submitGameLevel` 实现。✅ 2026-09-10 完成内容增厚(每关由 1–2 题补至 3 题左右)。 -- **阿布阿诺 AI 法律服务**:免费层(咨询 / 模板 / FAQ)页已建;付费层 `legal/upgrade.tsx` 已建,**付费 / 支付链路已接通(mock 支付闭环)**——`legal/checkout.tsx` 选套餐→选支付方式→模拟支付→会员态激活并 AsyncStorage 持久化;`legal.tsx` / `upgrade.tsx` 套餐卡可点击进入结算,会员态在返回时即时刷新。真实支付 SDK 后续仅换 `api.ts` 的 `createLegalOrder` / `payLegalOrder` 函数体。**付费后权益已兑现**:新增 `legal/service.tsx` 会员服务使用页——未开通点会员服务仍引导开通,已开通则进入使用页(服务说明 + 引导语 + 需求输入框 + 提交申请 + 申请记录持久化),`legal.tsx` 会员卡标签按态显示「会员 / 已开通」。 -- **岗位评价发布(双向透明评价生态核心)**:✅ 2026-09-10 接通。此前 `jobs/[id].tsx` 底部发布按钮为「功能开发中」占位——产品核心定位即评价体系却无法发布。现已新增 `createJobReview`(AsyncStorage 键 `job:reviews`,按 jobId 归属),`fetchEmployeeReviews` 合并「本人发布(置顶)+ 内置评价」,`useJobDetail` 暴露 `publishReview`,页面接线含空输入拦截、发布中 loading、成功后清空输入框并自动刷新列表。 -- **隐私政策 / 用户协议内容**:`src/api/api.ts` 中 `MOCK_PRIVACY_POLICY` / `MOCK_USER_AGREEMENT` 已含完整合规草案(各 9–10 章),**18 岁门槛已落地**(隐私政策第六章 + 用户协议前言)。仅余【待确认】占位待法务/产品替换:公司全称、注册地址、客服邮箱/电话、个人信息保护负责人邮箱、管辖法院。属**上架硬性审查项**,占位填妥即可提交审核。 - -### 4.3 注册引导链闭环(2026-09-15 完成) - -**问题**:整条引导链在空转 —— `components/onboarding/screens/` 下 20+ 个引导屏**没有一处调用 API**,用户从 P2 登录到 P22 完成填的姓名 / 城市 / 期望职位 / 期望薪资 / 技能 / 学历 / 公司**全部只写进 `useOnboardingFlow` 的内存 state**(该 Provider 只包在 `/register` 路由内),进程一结束全部蒸发。主界面因此读不到用户填的任何东西:「我的」页偏好**恒为** mock 的「前端开发工程师 / 北京 / 25-35K」。 - -- **落库闭环**:新增持久化模型 `src/types/onboarding.ts`(`OnboardingProfile` 与含登录瞬时字段的 `OnboardingData` 拆开,`phone` / `code` / `agreed` / `wechatTicket` 不落库)+ API `saveOnboardingProfile` / `fetchOnboardingProfile` / `clearOnboardingProfile`(键 `qingsongpin:onboarding:profile`)。写入为**步骤粒度**(`navigate` / `back` / `finish` 各提交一次,不逐键写,避免每次按键都打存储);Provider 挂载时水合,流程仍从 P1 开始(按决定不做断点续填)。 -- **主界面读取(让填的生效)**:`fetchJobPreference` 改为读持久化资料并映射(工作类型默认「全职」;未采集项显示 `UNSET_LABEL`「未设置」,不再返回 `MOCK_JOB_PREFERENCE` 常量);`fetchJobList` 在**默认推荐视图**(`sortBy==='comprehensive'` 且未显式筛选城市 / 关键词)叠加偏好加分(城市 +15 / 职位 +20 / 薪资区间重叠 +10,权重在 `src/constants`),**显式筛选一律不叠加偏好加分**;`fetchUserProfile` 的姓名 / 头像字 / 简介改用采集值(有资料就只用资料,**不再用 mock 的「李明 / 3年工作经验·本科·北京」补空**,空值由页面渲染「未设置」或隐藏该行)。`app/(tabs)/jobs.tsx` 默认城市由写死的「北京」改为「全部城市」—— 不改这一处,偏好加分永远不生效。 -- **简历页接采集资料**:`fetchResume` 原先只覆盖姓名与头像,其余(学历「北京大学」、工作经历「字节跳动」、技能 Vue/React)仍是演示人设「李明」的 —— 用户会在自己的简历里看到别人的学历和公司。现改为 `buildResumeFromProfile` 逐项映射采集值:教育背景(P20 学校 + P18 学历/学习形式)、工作经历(P12 公司 + P11 职位 + P13 入职年月 + P16 工作内容,在职才写「至今」,离职时结束时间留空而不编)、技能(P14)、求职意向;**未采集项一律留空**(专业、入学毕业年份、项目经历、自我评价、手机号、邮箱),页面按空值隐藏对应行,不再拼出「· 本科」「2022.07 - 」这类半截内容,空板块渲染「未填写」。P9 的求职状态选项提为常量 `JOB_STATUS_OPTIONS`(`JOB_STATUS_INACTIVE` / `JOB_STATUS_EMPLOYED_PREFIX` 同处),简历「积极 / 不积极」与「是否至今」的判定与页面读同一份,避免两处文案分叉。 -- **求职意向只认一个答案**:新增 `resolveJobPreference()`(显式编辑 > 引导资料推导 > 空值),「我的」页偏好卡片与简历「求职意向」都读它 —— 原先简历的求职意向只读引导资料,用户在求职意向编辑页改过之后,两个页面会给出不同答案。顺带把「基本信息的所在城市」留空:引导采集的是 P4「**期望**城市」,不能冒充「现居城市」(期望城市仍正确显示在求职意向里)。 -- **「优化简历」不再把简历换成别人**:`optimizeResume` 原样返回 `MOCK_OPTIMIZED_RESUME`,即演示人设「李明 / 北京大学 / 字节跳动」的整份简历 —— 用户点一下按钮,自己的简历就变成了别人的。现改为在**用户自己的简历**上套用 mock 评分块;AI 改写的正文(经历描述 / 自我评价)mock 阶段生成不了,因此不替换用户真实填写的内容,真实后端就绪后由后端返回改写结果。 -- **简历页「编辑 >」接上真实出口**:求职意向卡的「编辑 >」原先无 `onPress`,现指向已存在的 `/job-preference` 编辑页,并在简历页加 `useFocusEffect(refresh)` —— 该页保存的是显式偏好,返回时不重新取数就会显示旧值(`useAsyncData` 只在挂载时取数)。 -- **清演示人设**:`INITIAL` 里预填的「杨幸幸 / 女 / 上海 / 前端开发工程师 / 本科」等全部清空。此前**用户全程不操作也能一路点到落地**,而采集出的「资料」没有一项是他选的。清空后逐页补空值兜底(P4 / P8 / P10 / P13 / P18 等页的 `active` 门槛;`YearMonthWheel` 改为「未选中 + 请滑动选择」,此前把空值钳到索引 0,滚轮显示「2026年」而 state 仍是空串,会静默写入错误年份);`agreed` 由 `true` 改 `false` —— 预勾选不构成《个人信息保护法》要求的明示同意,且它让协议弹窗(`useAgreementGate`)此前永不触发。 -- **缺陷修复**:`navigate()` 去重压栈(修 P16 ↔ P17 往返后返回键错乱);P3「退出登录」补 `useAuth().logout()`(原先只清流程 state,token 未清,退出后重进仍被判为已登录);B3 企业认证重做(营业执照可点可传、联系人不再写进 `data.school`、提交走 `submitEnterpriseCertification`、去掉本地 `useState` 翻出的假「等待审核」态与开发口径文案);P21 头像接入 `expo-image-picker`(**新增依赖**)后拍照 / 相册 / 5 个预设头像各自生效。 -- **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。 -- **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息) - **更正(2026-09-20)**:4.10 第 7 条曾据一次漏看的 grep 断言「没有任何代码读这两个常量、 - 缺的是页脚组件本身」。**该断言是错的** —— `SupportFooter.tsx` 于 2026-09-17(`00522d5`)已存在, - 早于该断言一天,且已挂在 P1Login / P2QuickLogin 上。**「填入即自动出现」这句是成立的。** - 见 4.14 第 7 条。;「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 -- **删 P23 假首页**:`P23Home` 是写死上海 4 条职位的演示页,与真 `(tabs)/jobs` 重复,且含两颗绑同一回调的假权限弹窗;删除后 P22 与 P6 / P16 的快速通道由 `useRegisterFinish` 直接 `replace` 进真主界面。同步清理悬空接口 `saveAndEnter` / `canGoBack` / `meta`(`meta` 恒为 `{time:'',battery:0}`,21 个屏仍在传参、`ScreenShell` 收到即丢弃)与插画占位上的素材说明文字(`Illustration` 在 `source` 为 null 时不再渲染 `hint` —— 它会把「手持 OFFER 职场男性插画」这类开发备注直接画在用户界面上)。 -- **测试**:新增 `job-match.test.ts`(14 例,锁定匹配规则与「显式筛选不叠加偏好」边界)、`salary-options.test.ts`(10 例)、`onboarding-profile.test.ts`(映射往返 + 字段漂移检测)、`resume-profile.test.ts`(12 例:采集齐全逐项映射、一路跳过时各板块为空、离职不写「至今」、求职意向读显式编辑过的偏好、AI 优化后仍是本人简历、「不再出现演示人设」字符串扫描)、`user-profile.test.ts`(6 例:姓名/头像字/简介映射、一路跳过时不显示演示人设、头像原始值按预设 key / 本地 URI 两类透传)、`onboardingStepLabel`(3 例)。`npm test` **97 例全绿**;`tsc` / `eslint` / `check-routes`(67 路由 / 0 失效跳转 / 0 孤立页)均 0 问题。 -- **待验证 / 待补**:`expo-image-picker` 的原生权限文案(`app.config.js` 的 `photosPermission` / `cameraPermission`,并显式关掉插件默认申请的麦克风权限)**只在出包时生效**,Expo Go 用的是它自己的 `Info.plist`,需真机出包验证;手工路径(真机走完 P1→P22 →「我的」页偏好与所填一致 → `/jobs` 偏好岗位上浮;引导途中杀 App 重进数据仍在)**尚未在设备上执行**。 -- **落地页仍为 mock / 仍是死控件(本轮未动,待产品拍板)**:① `/resume` 的「简历评分 72 分」与 5 条 AI 优化点、`/profile` 的投递 28 / 收藏 12 / 沟通 8 / Offer 3、八大维度评分与勋章,全是与用户无关的写死数据(AI 评分与统计属产品能力,需真实算法或后端)。**「评分保留展示、不加示例标注」已于 2026-09-15 拍板**(见 4.6 末条),但「写死 mock」这一事实不变;② ~~`/resume` 仍有 10 处 `Pressable` **无 `onPress`**~~ **已修(2026-09-15,见 4.4)**:简历域 26 处死控件全部接线,`check-affordances.mjs` 简历域命中 0;③ ~~P21 采集的头像落地后无消费方~~ **已修**:「我的」页头部改为显示用户真正选的头像 —— `UserProfile` 新增 `avatarKey`(**存原始值**:预设素材 key 或本地图片 URI,API 层只透传,解析在组件层 `isAvatarPreset()`,因为 `src/types` 不可 import `components/`),选过就渲染真实头像,没选过才回落到姓名首字。**注意**:拍照 / 相册得到的是 `expo-image-picker` 的缓存目录 URI,系统清缓存后该图片可能失效(渲染空白圆圈,不崩);接真实后端时应改为上传取回永久 URL。 - -### 4.4 简历域功能补齐(2026-09-15 完成) - -**问题**:引导链闭环之后,简历成了新的瓶颈 —— `app/resume.tsx` 与 `app/resume/export.tsx` 共 **26 处** `Pressable` 点下去没有任何反应(顶部「保存」「导出」、「编辑基本信息」、四个「添加*」、「AI 优化 / AI 润色 / 一键全部优化」、模板 / 纸型 / 方向 / 预览 / AI 智能优化 / 分享简历),且**简历根本没有可写通道** —— `fetchResume()` 每次从引导资料现算派生,storage 里没有任何简历键。根因不是「忘了绑回调」,而是无处可写,所以先建数据层。处理口径:**实现功能,不是删按钮**。 - -- **覆盖层数据层**(新增类型 `ResumeOverride` / `ResumeItemInput` / `ResumeAIActionResult` / `ResumeExportOutcome`):简历按「**用户显式编辑过的板块 > 引导资料派生**」取值,键 `qingsongpin:resume:override`;**键出现(含 `[]`、`''`)即归用户,键不存在才用引导派生** —— 删光教育经历不会再冒出引导那条(`undefined` 被 `JSON.stringify` 丢弃,正好用 `??` 而非 `||` 区分「未编辑」与「清空」)。整表保存而非逐条写:逐条写会以空数组为基底,把引导派生的整块改丢。登出时一并清除该键(`clearResumeOverride`),避免换账号后看到上一个人的简历。 -- **7 个可写接口 + 4 个新页面**:`saveResumeBasicInfo / saveResumeSelfEvaluation / saveResumeSkills / saveResumeEducation / saveResumeExperience / saveResumeProjects / saveResumeSnapshot`;页面 `/resume/basic`、`/resume/education`、`/resume/experience`、`/resume/project`(编辑)、`/resume/preview`(只读预览)。**多字段板块走独立编辑页,技能与自我评价走页面内弹窗**(按字段量分开处理:弹窗装不下教育/工作/项目的字段量,编辑页对单字段又太重)。新增条目**不带 `id` 字段**(不是给空串 —— 空串不是 nullish,会被当成「已有 id」写成空),id 由 API 层补,同毫秒连加两条也不会撞号。 -- **头部「保存」补充功能**:把当前简历 6 个可编辑板块整体写入覆盖层 + 保存时间戳(`qingsongpin:resume:saved-at`),页面显示「上次保存:MM-DD HH:mm」(从未保存则不显示该行)。 -- **AI 类按钮:保留入口 + 坦诚提示**(`optimizeResumeExperience` / `polishResumeSelfEvaluation` / `optimizeResumeWithJD`):mock 恒返回 `available:false` + 一句「AI 改写需要后端模型支持,当前版本暂未开放;你的简历内容不会被改动」,页面只提示、不改简历。**绝不在正文未改写时把 `aiOptimized` 置 true** —— 那会让列表显示「已优化」徽标,用户等于被自己的界面骗了。参数加 `_` 前缀,去掉下划线即表示能力已接入。 -- **导出设置真正保存**:`saveResumeExportSettings` 原先是 `Object.assign(DEFAULT_EXPORT_SETTINGS, …)` —— 只改模块内常量,杀进程即丢,「保存」按钮实则保存不了,还会污染其它调用方读到的默认值。现改落 `qingsongpin:resume:export-settings`(读时用默认值补齐后加字段,存储损坏回落默认)。默认文件名原为演示人设「李明_前端工程师简历」,现为空串,由 `resolveExportFileName` 兜底成「姓名_期望职位简历」。模板 / 纸型 / 方向的选择弹层**就地在页内改设置、随「保存」落盘**(跳新页会出现「模板已存、格式没存」)。 -- **导出与分享真做**(新增依赖 `expo-print` / `expo-sharing` / `expo-file-system`):「立即导出」原先返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出文件、页面还丢弃返回值、不给任何提示,是纯假成功。现改为**本机真出 PDF**:`buildResumeExportHtml`(纯函数,空板块整段略去、用户文本全转义、模板 id 白名单校验)→ `printToFileAsync` 按纸型 / 方向的真实页宽页高打印 → 重命名成用户设的文件名(打印引擎生成的是随机临时名,不改的话分享面板里是一串乱码);「分享简历」拉起系统面板分享这份文件。**封面页、自荐信、隐藏联系方式、纸型 / 方向四个开关由此第一次真正生效**(此前全是装饰):自荐信以自我评价为正文,**自我评价为空时不生成**(没有素材就编一封自荐信属于伪造内容)。Word / 图片格式与导出密码需后端导出服务,在**选中的那一刻**与导出成功后都写明。 -- **未做的验证(诚实口径)**:真机 smoke test(生成 PDF 并拉起分享面板)**尚未执行**;Expo Go 是否内置 `expo-print` / `expo-sharing` 本轮无法联网核实(Expo 文档被网络策略拦截)。已完成的替代验证:`expo-doctor` 20/21 通过(依赖版本与 SDK 57 要求一致)、`expo export --platform android` 打包通过(新模块 import 可解析)、两个原生封装失败时一律返回 `{ ok: false, message }` 并弹出原因,**不返回假链接**。若 Expo Go 确缺模块,症状是**启动即崩**(`requireNativeModule` 在 import 期执行,非静默失败),届时改用 development build 出包即可,代码无需改。 -- **审计工具常驻**:新增 `scripts/check-affordances.mjs`(扫「无处理函数 / 空处理函数」的 `Pressable`,支持 `affordance-ok` 豁免标记),与 `check-routes.mjs` 配对,加 `npm run check:affordances` / `check:routes` / `typecheck` 三个 script(暂不进 CI 硬门槛:其余 22 处未修完,`--error` 会一直红)。全仓死控件由 **37 → 22**,**简历域 0 处**(**22 → 0 于 2026-09-17 完成,见 4.8**)。**「不进硬门槛」的理由已于 2026-09-18 消失**(假交互同批清零,脚本并补上了弹窗式假交互的检测口径),`check:affordances` 已改为带 `--error` 的硬门槛,见 4.9。 -- **测试**:新增 `resume-override.test.ts`(26 例:覆盖层语义、清空不回落到引导、编辑引导派生条目不改丢、同毫秒 id 不撞号、技能 trim 去重、存储损坏回落、AI 三接口调用前后简历深相等)、`resume-export.test.ts`(20 例:空板块不产出、隐藏联系方式、HTML 转义、模板 id 白名单、自荐信无素材不生成、纸型页宽、文件名清洗)。`npm test` **155 例全绿**;`tsc` / `eslint` / `check-routes`(72 路由 / 0 失效跳转)均 0 问题。 -- **本轮未动(待续做,已探明落点;截至 2026-09-17 均已清完,见 4.8)**:`app/job-evaluations.tsx` 筛选与回复(`replyJobReview` 与 hook 的 `reply` **已存在**,接线即可)、`app/holland-result.tsx` 行动卡(`HollandAction.route` 已定义)、`app/(enterprise)/enterprise-chat.tsx` 待处理任务项、`app/interview-score.tsx` 历史与草稿、`app/(tabs)/chat.tsx` 4 处(加入会议 / 导航需 `Linking` 封装,仓库 0 处)、`app/(tabs)/jobs.tsx` 搜索框 state 未传给 `useJobList`(**真 bug**)与 `FILTERS` 三项未实现、`app/tools/*` 若干。**注意**:`jobs.tsx` 与 `resume.tsx` 里成对的 `filterPanel` / `modalSheet` 空 `onPress` 是**吞冒泡守卫,需保留**(已加豁免标记,不属死控件)。 - -### 4.5 工程与上线 - -- **应用图标**:✅ 已完成(提交 `878710c`)。`assets/icon.png` 已替换为「轻松聘」logo,实测 1024×1024,符合商店要求。 -- **启动屏**:✅ 已接线。安装 `expo-splash-screen@57.0.9`,在 `app.config.js` 以 config plugin 形式配置 `assets/splash-icon.png`(1024×1024、`imageWidth: 200`、`resizeMode: 'contain'`、白底)。此前该资源为死资源且冷启动白屏。因应用为纯亮色(`userInterfaceStyle: 'light'`),未配置 `dark` 分支。**注意**:启动屏属原生配置,Expo Go 内不生效,需 `eas build` 出包后可见。 -- **EAS / OTA**:`eas.json` 配置骨架(development / preview / production + submit)已建;`app.config.js` 的 `runtimeVersion` / `ios.bundleIdentifier` / `android.package` / `extra.eas.projectId` 字段已就位。**以下两项占位符会直接阻塞 `eas build` / `eas submit`,上架前必做**: - 1. `owner: 'REPLACE_WITH_EAS_OWNER'` —— 需替换为你的 Expo 账号名; - 2. `extra.eas.projectId: 'REPLACE_WITH_PROJECT_ID'` —— 需替换为真实 EAS 项目 ID。 - - 两者均可由 `eas login` 后执行 `eas init` 自动注入,**不要手工填**。`ios.bundleIdentifier` / `android.package` 当前为 `com.qingsongpin.app`,可按开发者账号调整。 -- **`updates`(OTA)块当前为注释状态,属有意为之**:见提交 `aa7b171`。占位的 `REPLACE_WITH_PROJECT_ID` 会让 Expo Go(开发期)启动时去拉取不存在的远程更新,报 "Failed to download remote update" 并蓝屏。待 `eas init` 注入真实 projectId 后再取消注释启用 OTA。 -- **真实后端**:全量 mock(`src/api/api.ts` 7385 行),未切换真实 `fetch`。 -- **技术债**:8 处 `any` 软警告已全部清理(图标断言改为 `keyof typeof Ionicons.glyphMap`、路由断言改为 `as string`),`eslint .` 现 0 error 0 warning,`tsc --noEmit` 保持 0 错误。 - -### 4.6 后端接口预留约定(2026-09-15) - -**口径**(用户 2026-09-15 拍板):**需要展示数据的地方照常展示;每个数据函数都要把真实后端请求写好、注释着;接后端时放开注释即可。** - -- **落地**:`src/api/api.ts` 127 个 `export async function` **全部**在函数体内留了真实请求,分两式(约定写在该文件顶部): - - **纯 mock 返回**的函数 → `// TODO: 真实请求(后端就绪后替换)` + 注释的 `const res = await fetch(...)` / `return res.json();` - - **以本地存储为唯一真相源**的函数(引导资料、简历编辑覆盖层、导出设置、薪资记录等)→ 单行 `// TODO: 后端就绪后改为 await fetch(...);当前以本地存储为唯一真相源` —— 放开时要**把本地写换成请求**,不是加一行。 - - **AI 能力**(简历三处):参数名带 `_` 前缀 = 尚未接入;**去掉下划线 + 放开注释** = 该能力上线。 -- **本轮补齐**:原先 29 个函数(简历域 6 + 运营/企业端/游戏/支付/隐私设置 23)正文里连注释版请求都没有,现已全部补上;放宽的判定是「函数体里出现过 `fetch(`」,**注释着的也算**,因此不依赖注释写法。 -- **常驻审计**:新增 `scripts/check-backend-stubs.mjs`(`npm run check:backend-stubs`,与 `check-routes` / `check-affordances` 配对)。确实不该接后端的函数,在函数体内标 `backend-stub-ok` 豁免。与 `check-affordances` 一样**暂不进 CI 硬门槛**。 -- **不做的**:不为了「看起来有接口」而编造未定义的 URL —— 上表 29 个函数的 `@url` 在各自 JSDoc 里本来就已定义,本轮只是把**已定义的接口**落成注释代码。 -- **沿用上轮口径**:`/resume` 的「简历评分 72 分」**保留展示、不加「示例」标注**(评分是产品的 AI 能力,接口已在 `fetchResume` 内预留注释版,后端就绪后由后端下发);但它目前仍是写死的 mock,与用户数据无关,这一点不变。 - -### 4.7 引导页重做 / 运营商一键登录修复(2026-09-16 批次) - -> 该批次与 4.8 同属长期未提交的工作区改动,一并入库(见 4.8 末条)。 - -**① 一键登录取不到手机号 —— 根因不在取号,在页面根本没读结果。** `P2QuickLogin`(P2Q)有两个独立缺陷叠加,表现为「号码永远显示占位」: - -| # | 缺陷 | 现状态 | -|---|---|---| -| 1 | 页面读的是 `data.phone` —— 该字段**只有短信登录通道(P2)才写入**,走一键登录进来时恒为空 | ✅ 改读 `useCarrierPhone()` 的 `maskedPhone` | -| 2 | `loginByCarrier()` 返回的 `phone` **被直接丢弃**,后端换了真号码也传不下去 | ✅ 登录成功后 `setData({ phone: result.phone })` 写回引导资料(P2P/P2R 预填、B3 联系人电话同源) | - -- **新增取号链路**:`fetchCarrierPhone()`(接口层取号 + `maskPhone` 脱敏)+ `useCarrierPhone`(进页面即预取,loading / error / refresh 齐备)+ `loginByCarrier(carrierToken)` 换登录态。**空令牌直接 reject**,页面据此置灰「一键登录」(空令牌调登录接口必然失败,不如不让点)。 -- **补上失败分支**(此前不存在:取号失败也照样显示号码、按钮照样可点):号码位分「正在获取 / 未能获取」,并给出**重新获取**与**改用验证码登录**两个出口;`carrierToken` 空的判定取代了原先写死的「本机号码」占位。 -- **不冒充的能力**:《xx认证服务条款》由运营商在认证页内提供,本仓库没有该文档,故只弹如实说明,不做成能打开的链接;页脚服务热线为空时整块不渲染。 -- **真实取号是端上原生 SDK(Expo Go 跑不了)**,当前 `fetchCarrierPhone` 返回写死号码 `15000009367` 用于跑通页面链路。接入路线图、两个硬约束(端上号码不可信 / 令牌一次性 5 分钟)、6 步执行清单与手动验收路径见新增文档 **`运营商一键登录接入方案.md`**(`src/api/api.ts` 的 JSDoc 原先就指向该文件名,此前文档缺失,本轮补齐)。 - -**② 引导页(`/guide`)重做 + 补「重新查看引导」入口。** - -- **有出口就必须有入口**:引导页此前只能由首次启动时 `/` 的重定向进入,看过一次就再也进不去。`app/settings.tsx` 菜单已加 `{ id: 'guide', title: '查看引导页', route: '/guide' }`。 -- **两种进入方式、出口不同**(写错会把已登录用户甩回注册流程):首页跳来 → 看完 `replace('/register')`;设置页回顾(`isTokenValid`)→ 只 `back()`,不改引导标记、不进注册流程。 -- **内容与配图重做**:四屏文案全部重写(聚焦差异化能力),配图改走新增组件 `ImageSlot`。 -- **配图位带文字标注**(按 2026-09-16 要求「要配图的地方页面留有文字标注放什么图」):素材未到位时页面上画虚线占位框 + 「配图待补」+ **该放什么图**的说明(取自 `assets.ts` 的 `hint`),补图后标注自动消失。新增 4 个引导页素材位 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,**换真图只改 `assets.ts` 里对应一行的 `source`**。与 `Illustration` 的分工:那个是小图标位(头像 / Logo),占位只画色块;`ImageSlot` 是内容大图位,占位必须自解释。 - - **待补素材**:`assets.ts` 注册 17 个素材位,其中 **16 个 `source` 仍为 `null`**(含引导页 4 张,另一张 `logo` 已是真图)。 -- **「首次启动没出现」的核查结论**:`app/index.tsx`(本轮未改动)在 hydration 完成后按 `onboardingSeen` 放行 —— 没看过即 `Redirect /guide`,默认值 `false`(`AuthContext` 的 `memoryAuth`),存储里没有该键时不会误判为已看过。因此**跳过引导只可能来自「本机已看过一次」**(看完即 `completeOnboarding()` 写入 `qingsongpin:auth`),属设计行为;要复现真·首次启动需清应用数据或卸载重装。**该结论为代码走查所得,本机未复现(无设备可测)。** -- **顺带**:设置页「清除缓存」的体积改为运行时读取(`useAppCache` + `src/lib/cache.ts`),不再写死数字;页脚服务热线抽成 `SupportFooter`(号码为空则整块不渲染)。 - -### 4.8 全仓死控件清零(2026-09-17) - -**结果**:`node scripts/check-affordances.mjs` **9 处 → 0 处**,全仓再无「点了没反应」的 `Pressable`。上一轮遗留的 9 处逐项落地(口径仍是**实现功能,不是删按钮**;确实做不到的就删入口或写明原因): - -1. **企业沟通 `app/(enterprise)/enterprise-chat.tsx`**:待处理任务卡(待回复 / 待评分)此前无 `onPress` → 现「待回复」切到未回复 Tab、「待评分」跳 `/interview-score`,去向由接口层 `PendingTask.tabKey` / `route` 下发;搜索框接真实过滤;Tab 计数与列表**同源** —— `buildCommunicationTabs` / `buildPendingTasks` 均由会话列表推导(此前页面写死的 12/3/2/1/4 与 3 条示例数据对不上,会出现「Tab 上写 3、列表 0 条」)。 -2. **人才推荐 `app/(enterprise)/talent-recommendation.tsx`**:顶部「搜索 + 漏斗」两枚图标 —— 漏斗无对应页面 → **摘掉**;搜索改成真关键词过滤(姓名 / 职位 / 技能标签 / 学校)。「调整筛选条件」原为弹「功能开发中」→ 改为真复位(切回全部 + 清关键词)。Tab 计数同样由候选人列表推导(`matchTalentTab`:匹配度 ≥90 / 985-211 院校名单 / 经验 ≥5 年 / 硕士及以上)。「查看人才市场报告」仍无对应页面 → 保留入口 + 明确提示,不假装跳转。 -3. **申诉举证 `app/appeal.tsx`**:原为空 `onPress` → 接真实选图(新增 `src/lib/imagePicker.ts` + `useImagePick`),可拍照 / 相册 / 移除缩略图,证据随 `submitAppeal` 一并提交。**PDF 类材料后端上传接口不存在,在页面上写明**,而不是给个点不动的按钮。 -4. **候选人详情 `app/candidate-detail.tsx`**:「发起沟通」→ `/chat/[id]`(此前点不动)。 -5. **面试评分 `app/interview-score.tsx`**:「保存草稿」接真实本地暂存(键 `qingsongpin:interview:score-draft:`,**按面试 id 分开不串台**),再次进入显示「检测到未提交草稿」+ 恢复 / 放弃;提交成功后才清草稿。 -6. **入职准备清单 `app/onboarding.tsx`**:勾选状态**落库**(此前退出再进入勾选全丢,`completed` 恒为 3/18);新增自定义待办分组(**只有这一组可删**,模板条目删了下次进页面还会出现,接口层直接 reject);「导出 / 分享」由 `buildChecklistText` 生成纯文本经系统分享面板发出(`shareText`)。 -7. **微信验证码页 `components/onboarding/screens/P2WechatCode.tsx`**:点验证码输入区未聚焦 → 加 `inputRef` 聚焦。 - -**顺带修掉的两类「假入口」**(同属「不留假交互」口径): - -- **薪资计算器「更多工具」**原先三项都指回计算器自己(点了等于没点)→ 改由接口层 `fetchSalaryToolLinks` 下发真实路由(`/jobs/offer-compare`、`/jobs/rank`、`/tools/skill-cloud`);无路由的项渲染成纯文本、不显示箭头。 -- **技能词云**:「生成学习路径」原无实现 → 接口层 `fetchSkillLearningPath` 按行业技能词权重取待补技能(条数按匹配度缺口换算、限定 3–6 条),页面弹层展示;词云搜索接真实过滤(`filterSkillWords`)。 - -**测试**:新增 3 个文件 **51 例** —— `onboarding-checklist.test.ts`(清单落库 / 自定义条目增删 / 导出文本 / 存储损坏回落)、`enterprise-filters.test.ts`(两页 Tab + 关键词过滤 / **Tab 计数与过滤结果逐项一致** / 学习路径规则)、`interview-score-draft.test.ts`(草稿往返 / 不串台 / 覆盖保存 / 幂等清除)。**存储 mock 由「恒返回 null」改为带状态的 Map** —— 否则「存了要能读回来」这类行为根本没被验证。`npm test` **219 例全绿**;`tsc --noEmit` / `eslint .` 0 问题。 - -**三个审计脚本全绿**:`check-routes`(72 路由 / 0 失效跳转)、`check-affordances`(**0 处**)、`check-backend-stubs`(**137** 个接口函数均留注释版真实请求)。 -> 注:`check-routes` 另报 12 个「无入口指向的孤立页」(`/jobs/[id]`、`/chat/[id]`、`/legal/checkout`、`/settings/privacy/[id]`、`/growth`、`/profile` 等),已逐个核对**均为误报** —— 它们是动态路由或 Tab 页,入口由模板字符串(`router.push(\`/jobs/${id}\`)`)或 Tab 配置给出,静态扫描看不到。 - -**提交**:本批次(4.7 + 4.8)与前一日积压的工作区改动一并入库;`package-lock.json`(本项目统一用 `yarn.lock`)与调试截图 `shot-check.png` 加入 `.gitignore`,不入库。 - ---- - -### 4.9 假交互盲区 + 四态缺口 + 企业端写通道(2026-09-18 批次) - -#### 首条:4.8「死控件清零」是错误结论,错在审计口径 - -4.8 写「全仓死控件清零:9 → 0」,并据此把 P2 的「死控件清零」标记为已完成。**这个结论当时就是错的。** - -`scripts/check-affordances.mjs` 当时只查两类:控件**没有** `onPress`、`onPress` 是空函数。它查不到第三类 —— **有处理函数,但点下去只弹一句 `Alert.alert('提示', '功能开发中,敬请期待')`**。全仓按这个口径实测有 **37 处**,分布在 13 个页面。因为脚本报 0,文档就记成了 0,没有人再去数。 - -教训不是「上次漏了」,而是:**由脚本背书的结论,必须连同脚本的检测口径一起读**(本文件凡标 ✅ 的审计项都已按此补注)。 - -#### 1. 给审计脚本补盲区(本批次最有长期价值的一项) - -`scripts/check-affordances.mjs` 新增第 ② 类检测:**弹窗式假交互** —— `Alert.alert` 文案命中「功能开发中 / 敬请期待 / 暂未开放 / 尚未开放 / 即将上线 / …」即告警。 - -难的不是匹配文案,是**不误报**。`app/resume.tsx` / `app/resume/export.tsx` 那类「`await` 调完接口、拿到 `result` 后如实报告 AI 能力未接入」是 4.4 节确立的**正确做法**(保留入口 + 坦诚提示 + 不假装成功),文案与假交互一模一样。区分轴取「**处理函数里有没有真实动作**」:有 `await` / `.then(` / `async` 说明确实去问过后端,属如实报告;纯弹窗则判为假交互。 - -- **灵敏度已用夹具双向验证**(非「在当前仓库报 0」就算过):3 个反例全部抓出(内联箭头 / 命名函数 / 含同步计算的),3 个正例全部放过(含「`await` 与弹窗之间插一句同步赋值」这个易误报的形状)。夹具跑完即删。 -- 新增 `affordance-pending-backend` 豁免标记,专给**知情保留的后端待办**(区别于 `affordance-ok` 的「有意为之」):标记后不告警,但 `--pending` 能一次列出全部欠账 —— 欠账不该靠告警数量为 0 来假装不存在。 -- 新增 `--pending` 模式列出欠账清单(当前 3 处:聊天附件四图标 / 接受 Offer / 导出文件下载,后端就绪后按单兑现)。 -- `--pending` 计数只认「注释内容以标记开头」的行 —— 否则连解释这个标记本身的说明文字也会被算成一条待办,**数出来的数字就是假的**,正是本批次要治的病。 - -#### 2. 37 处假交互的处理结果 - -口径按「**能真做的真做,不能做的删入口**」,三类落地: - -**① 能真做 → 真实现(不再弹窗)** - -| 页面 | 原状 | 现状态 | -|---|---|---| -| 我的页「评分申诉」 | 弹「功能开发中」 | `router.push('/appeal')` —— `app/appeal.tsx`(申诉页)**早就建好了**,纯属 4.8 批次漏网的接线 | -| 岗位详情 / 霍兰德结果 / 面试配置 / 岗位排行 / Offer 对比 / 隐私设置 | 弹「功能开发中」 | 真导出:新增 5 个纯文本拼装函数(`buildJobShareText`、`buildHollandReportText`、`buildInterviewConfigText`、`buildRankReportText`、`buildPrivacySummaryText`、`buildOfferCompareText`)置于 **API 层**,经系统分享面板发出 | -| 企业端岗位管理 搜索 / 筛选 / 排序 | 搜索框**无 `value`/`onChangeText`,是纯展示死输入框**(真缺陷);筛选 / 排序弹窗 | 全部真实现;Tab 过滤逻辑由页面 `useMemo` **下沉到 API 层**(原与 AGENTS.md「业务规则放 API 层」相悖) | - -**② 无落点 → 删入口(不留假按钮)** - -聊天页「查看全部预测题」「查看详细薪资报告」「右上角更多」、账号注销「去提现」、数据导出「查看更多历史」、Offer 对比「完整建议报告」(**summary/detail 页面早已全文展示,点进去没有任何更多内容**)、Offer 添加卡、人才市场报告、企业沟通「批量发 Offer / 群发通知」。删入口时**同步删掉 `src/api/api.ts` 里对应的下发项**,不留「数据里有、页面不渲染」的悬空字段(`reportAction`、`actionText`、`AITool` 的 id→route 页面映射等)。类型层同步收紧:`PendingTask` 改为 `route` / `tabKey` 二选一的判别联合,页面不再需要不可达的兜底分支。 - -**③ 需后端 → 保留入口 + 如实文案 + 标记欠账(3 处)** - -聊天输入区四图标(附件上传、表情资源需后端接口)、Offer 对比「接受 Offer」(需 `POST /api/offers/:id/accept`)、数据导出「下载文件」(`RecentExportRecord` 无下载地址字段)。文案由「功能开发中,敬请期待」改为如实的「**该操作需要后端支持,当前版本暂未开放**」,并打 `affordance-pending-backend` 标记。 - -> 这一条与「不能做的删入口」有张力:选择保留,因为聊天输入区四图标是行业标准布局,删掉会让输入区显得残缺。沿用 4.4 节「AI 按钮」先例(保留 + 坦诚提示 + 不假装成功)。 - -**顺带修掉作者已知模式却漏做的一处**:`app/(tabs)/growth.tsx` 的 AI 工具区用「id 比对 switch」分发路由且**无 else 分支**,加一个工具就点不动 —— 改为路由由 API 层随工具数据下发(`AITool.route` 设为必填,页面不再自己维护 id→路由映射)。 - -#### 3. 四态缺口 18 处(错误态 9 + 空态 9),其中 **8 处是真缺陷** - -**① error 被完全丢弃 —— 接口故障被静默渲染成业务空态(最严重)** - -- `app/legal/checkout.tsx`:`error` 从未解构 → 网络故障时页面显示「**套餐不存在,请从会员套餐页重新进入**」,把用户引向完全错误的自救路径。 -- `app/legal/service.tsx`:`useLegalData()` 与 `useLegalServiceRequests()` 两个 hook 都提供 `error`,页面两个都没解构 → 同款误导。 - -**② hook 提供了 `refresh`,页面漏解构 —— 无重试入口,用户只能杀进程** - -`app/(enterprise)/enterprise-overview.tsx`、`app/candidate-detail.tsx`、`app/interview-score.tsx`、`app/job-publish.tsx`。统一改用既有 `src/components/StateView.tsx` 的 ``(**不新写样板**);`job-publish` 顺带把加载态的裸 `ActivityIndicator` 换成 ``。 - -**③ `refresh` 已在作用域内却没接**:`app/job-evaluations.tsx`(一行修复)。 - -**④ 错误态语义错用**:`app/article/[id].tsx` 错误分支误用 `EmptyState` → 改 `ErrorState`(把错误态视觉规范收回一处维护)。 - -**⑤ 其余 error 静默**:`app/settings.tsx` 缓存读取失败原先静默显示 `--`(把「读失败」伪装成「本来就是空」)→ 改为「计算中 / 读取失败 / 实际体积」三态。 - -**空态补齐 9 处**(列表为空时页面一片空白,按文件数:`growth.tsx` 2、`legal.tsx` 2,`ability.tsx` / `hr-evaluation.tsx` / `jobs/[id].tsx` / `jobs/offer-compare.tsx` / `progress.tsx` 各 1):岗位详情员工评价、进度页空 Tab、成长页 AI 工具区 / 干货区、法律服务两个网格、Offer 对比列表、HR 评价、四维能力提升建议。其中 `app/legal.tsx` 与 `app/progress.tsx` 属**同文件内两套标准**(同页模板/FAQ/套餐三处都做了保护,服务网格与 companies 却漏了),`growth.tsx` 的「更多」入口在无文章时不渲染(否则点了没反应)。 - -> **另有 1 处不属此列**:`app/(enterprise)/job-management.tsx` 的「筛选后无结果」空态(`length === 0` 守卫共 10 处,减去上述 9 处即此 1 处)。它对应的是「筛选无匹配」而非「列表本来就空」,语义不同,故不计入本行。分开写是为了让 10 这个数能被对上,不至于出现「标题写 6、列表列 9」这类对不上的数字。 - -#### 4. 企业端岗位「下架 + 刷新」写通道 - -范式照抄入职清单(项目里最完整的写通道):**读旧值 → 合并 → `storageSet` 全量写回 → 返回完整聚合对象**。 - -- **API 层**:新增 `updateJobStatus(jobId, status)`(`PATCH /api/enterprise/jobs/:id/status`)与 `refreshJob(jobId)`(`POST /api/enterprise/jobs/:id/refresh`),均留注释版真实 `fetch`;存储键 `enterprise:jobs-status` / `enterprise:jobs-refreshed`。 -- **修掉一处 mock 自相矛盾**:原 `MOCK_JOB_MANAGEMENT` 只有 3 个岗位,Tab 计数却写死 12 / 8 / 3 / 1 —— 点「已下线(3)」看到的是空列表。现扩为 **12 个自洽岗位**,且 **Tab 计数与「在招岗位」统计一律由列表推导**(`buildJobManagementTabs`),从机制上不可能再对不上。 -- **类型层**:`JobManagementJob` 补 `publishedAt` / `refreshedAt?`(原无任何时间字段,「刷新」无法显示刷新时间)。 -- **Hook 层**:`useJobManagement` 原无任何写方法 → 加 `setStatus` / `refreshJobItem` 与进行中标志 `mutatingId`(供页面禁用按钮防重复提交)。 -- **页面层**:「下架」加破坏性二次确认,成功后用返回值替换本地镜像。 -- **测试**:新增 `src/__tests__/job-management-write.test.ts` **22 例** —— 状态往返 / 计数随下架变化并在重新上架后复原 / 只改目标岗位 / 存储损坏回落 / 存储失败 reject / 刷新时间写入与幂等 / 空列表计数 / 关键词与城市·部门 AND 语义 / 四种排序 / **输入数组不被就地修改** / 空结果不回退成全量。测试常量一开始写错(把 3 个下线岗位当成 4 个),**是断言错、代码对**,已修正常量。 - -#### 5. 门槛复跑(2026-09-18 实测,非文档转述) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` **19 个文件 241 例全绿** · `check-routes` **0 失效跳转**(12 个孤立页仍为动态路由 / Tab 页误报)· `check-affordances` **0 处**(口径已含弹窗式假交互)· `check-backend-stubs` **139 个接口函数**均留注释版真实请求。 - -#### 6. 与既定方案的两处偏离(如实记录) - -- **Offer 对比的报告导出**:方案原定复用 `exportResumePdf`,但该函数签名是 `exportResumePdf(html, fileName, settings: ResumeExportSettings)` —— 复用意味着为「Offer 对比」这个与简历无关的域**编造一个 12 字段的简历设置对象**。改为**纯文本导出**(`buildOfferCompareText` + `shareText`),与其余 5 处导出一致。 -- **`app/onboarding.tsx` 的空态未补**:该页分组来自代码级固定模板(恒为 4 组),空分支是**不可达的死 UI**,故不加。 - -#### 7. 模拟器实测结果(Android 模拟器 `emulator-5554`,非代码走查) - -**已实测通过** - -| 项 | 结果 | -|---|---| -| 企业端「下架」写通道 | `招聘中 8→7`、`已下线 3→4`、`在招岗位 8→7` 同步变化,被下架岗位从列表消失 | -| **持久化(最关键一条)** | `adb shell am force-stop` 杀进程后重启,仍是 7/4,**未回退成 8/3** —— 证明写通道走的是 storage 而非改内存 mock | -| Tab 计数与列表同源 | `全部(12)/招聘中(8)/已下线(3)/草稿(1)` 与 12 条数据自洽;草稿岗位显示「未发布,暂无排名」而非编造排名 | -| 下架二次确认 | 弹窗点名岗位并说明影响,非泛泛「确定吗」 | -| 错误态(注入故障触发) | `/legal/checkout` 显示「套餐加载失败,请重试」+ 重试;`/legal/service` 同。**修复前此处会显示「套餐不存在,请从会员套餐页重新进入」**,把用户引向无效路径 | -| 空态(注入空数据触发) | 成长页「暂无可用工具」「暂无干货文章」带图标占位,且「更多」入口消失 | -| 分享面板 | `/jobs/[id]` 分享真拉起系统面板,文本为真实拼装内容;代码未违反「`ok:true` 只代表面板已拉起」 | -| 接线目标页 | `/appeal` 完整渲染真实申诉表单 | - -> 触发方式说明:mock 的 `fetchLegalPlans` 走 `mockRequest` **永不失败**,空态数据也非空,故 error 态与空态**在正常运行时不可达**。上表两项是**临时注入故障 / 空数据**后截图所验,注入代码已全部还原(已用 `grep` 确认无残留、`tsc` 复跑 0 错误、应用恢复正常态截图核对)。这里如实记录,是因为「注入后才可见」与「本来就可见」的验证强度不同。 - -**实测中发现并修掉的一个新缺陷(代码走查看不出来)** - -`app/jobs/[id].tsx` 头部:`headerBtn` 是 `position: 'absolute'` 但**未写 `left`/`right`**,在 `justifyContent: 'center'` 的父容器里返回键与分享键落到同一处彼此重叠 —— 截图里连返回箭头都看不见、标题上有一道错位横杠,**分享键实测点不到**。该样式**改动前就存在**(本批次只把假 `Alert` 换成真 handler),但它直接抵消本批次的工作:**加了真入口却点不到,等于没加**。已补 `left: 0` / `right: 0` 两条样式,修复后返回在左上、分享在右上、面板可正常拉起。 -> 严格复查了全仓同类写法(解析每个 style 块的 `position: 'absolute'` 是否成对给出水平锚点),仅剩 3 处且均合理(`sliderThumb` 用 `marginLeft` 偏移、`savingToast` 用 `alignSelf` 居中、`pieSlice` 靠变换定位)—— **该文件是全仓唯一一处真缺陷**,不是撒网式改动。 - -**仍未验证(不要当成验过)** - -- ~~企业端「重新上架」的实际点击路径(写入逻辑与「下架」同源、有单测覆盖,但未在模拟器上点过)~~ → **2026-09-18 已实测通过,见 4.11** -- ~~其余 5 处导出(霍兰德结果 / 面试配置 / 岗位排行 / 隐私摘要 / Offer 对比)的面板拉起 —— 只实测了岗位分享这一处~~ → **2026-09-18 已全部实测通过(含岗位分享共 6 处),内容经系统分享面板预览逐项核对,见 4.12** -- ~~`app/settings.tsx` 缓存「读取失败」态:缓存体积不走 `mockRequest`(实测注入全局失败后仍正常读出 `381KB`),需文件系统级故障才能触发~~ → **2026-09-18 结案:结论是「不可达」而非「未验证」** —— `readCacheBytes()` 把所有异常吞成 `0`,`error` 恒为 `null`,该分支是死代码;已加测试锁,见 4.13 第 8 条 -- ~~4.9 第 3 条其余空态(岗位详情员工评价 / 进度页空 Tab / 法律服务两网格 / Offer 对比 / HR 评价 / 四维能力建议):均为同一条件渲染写法,只实测了成长页两处~~ → **2026-09-18 已全部实测通过(注入空数据逐个截图),见 4.13** - -#### 8. 本批次未做的(明确记录,避免反复讨论) - -- **暗模式**:全仓 **3302 处**静态 `colors.X` 引用分布在 **124 个文件**,其中 **119 个文件**的样式是模块级 `StyleSheet.create`(2026-09-18 实测)—— 与运行时切主题根本不相容。属重构级工程(需逐个样式表改造 + 视觉回归手段,当前无自动化视觉回归)。**经决策本项目维持纯亮色**,`darkColors` 保留待用,已在 `AGENTS.md` 第九节记录。 -- **企业端「编辑岗位」**:需给 `job-publish.tsx` 加编辑态 + `fetchJobPublishForm(jobId)` + `updateJob()`,而 `JobManagementJob` **缺 9 个表单字段**(职责 / 要求 / 福利 / 地址 / 工作类型等,且 `salary` 是展示串 `'25-35K'` 而表单要两个 number)→ 预填数据不完整,风险较大,暂缓。 -- **StateView 全量替换 56 个页面**:改动面过大、会淹没本批次 diff,不利于 review → 只改本批次碰到的页。 -- **`src/components` / `src/context` / `src/lib` 缺 `index.ts` barrel**:规格偏差属实(AGENTS.md 要求每个子目录统一导出),但纯属代码组织、与功能无关,不混入本批次。 - ---- - -### 4.10 平台资质证照页 + 死链修复 + 推送缺口(2026-09-18 批次) - -#### 0. 先分清两个都叫「营业执照」的东西 - -本批次新增的是**平台运营主体自身**的证照公示页(合规 + 应用商店上架审核用)。 -它和企业端的 `EnterpriseCertificationForm`(`src/types/enterprise.ts`)**不是一回事**: -后者是**企业用户上传**自己的营业执照以取得企业认证,主体是入驻企业、目的是拿认证。 -两者都涉及「营业执照」,但主体 / 目的 / 入口全不同,已在类型文件顶部写明,避免后来者合并。 - -#### 1. 新建「资质证照」页(走完整四层链路) - -`app/settings/qualifications.tsx` 此前**不存在**,且 `app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,**不可复用**(这也是 2026-09-15 把该入口摘掉的原因)。 - -按 AGENTS.md 四层约定新建,无一层跳过: - -| 层 | 文件 | 内容 | -|---|---|---| -| types | `src/types/qualification.ts` | `QualificationLicense` / `QualificationInfo` / `QualificationLicenseType` | -| api | `src/api/api.ts` `fetchQualifications()` | `GET /api/settings/qualifications`,留注释版真实 `fetch`(故 stubs 139 → **140**) | -| hook | `src/hooks/useQualifications.ts` | 复用 `useAsyncData`,返回 `{ info, loading, error, refresh, isEmpty }` | -| page | `app/settings/qualifications.tsx` | 四态齐全(`LoadingState` / `ErrorState` / `EmptyState` / 成功),颜色全取自 `src/theme` | - -公示三类证照:**营业执照 / ICP 备案 / 人力资源服务许可证**。 - -#### 2. 一张图都没有,所以逐张写明「此处需填充什么图」 - -按 AGENTS.md「需要填充图片的地方…请标明这里需要填充什么图片」,每张证照渲染虚线占位块 + 具体补图说明,而不是留空: - -- 营业执照 →「原件照片或彩色扫描件(须能辨认统一社会信用代码与登记机关公章)」 -- ICP 备案 →「工信部备案系统查询结果截图(须含备案号与主办单位名称)」 -- 人力资源服务许可证 →「原件照片或彩色扫描件(招聘平台合规所需)」 - -#### 3. 不编造证件编号 —— 并用测试把它锁死 - -**统一社会信用代码 / 备案号 / 许可证编号**这些只能由运营主体与法务提供,当前尚未取得,故 `number` / `issuer` / `validPeriod` 一律为 `null`,页面显示「待补充」。 - -> 这里可以「先填个像样的号让页面好看」,但编造的证件号既是不实信息,**也可能撞上某个真实主体的号**。故不填。 - -新增 `src/__tests__/qualifications.test.ts`(**10 例**)把这条原则锁住,其中最关键的一例是 -「不编造证件编号:真实材料到位前 number 必须全部为 null」。 - -**并做了灵敏度验证(只跑通不算数)**:临时塞入 `91310000MA1FL1234X` 后,该例**如期失败**,其余 9 例仍通过;随后已完全还原并用 grep 复核。一个不会被违反的断言等于没写。 - -测试还锁住了两条一致性关系:运营主体名字与「关于我们」**同源**(本项目有「同一个名字被填成两个」的前科,故直接取 `MOCK_ABOUT_INFO.company`,不重复填写);占位说明必须**含证照名**(防止退化成一句通用废话)。 - -#### 4. 恢复两处入口(有页面出口就一定要有入口) - -- `app/settings.tsx`「通用」组 → 新增「资质证照」(置于「清除缓存」「关于我们」之间) -- `app/settings/about.tsx` 的链接列表 → 新增「资质证照」 - -#### 5. 顺带修掉一类脚本看不见的假交互:「关于我们」页两条死链 - -`MOCK_ABOUT_INFO.links` 里「隐私政策」「用户服务协议」**没有 `route`**,而页面的处理函数是这样的: - -```ts -if (link.route) { router.push(link.route); return; } -// 协议类页面待开发:仅占位,避免死路由 -``` - -即**有 `onPress`、点下去却什么都不发生** —— 既无跳转也无提示。审计脚本查不到这一类(它有处理函数,不是空函数),属 4.9 首条所说的同一盲区。 - -而这两个页面**早已存在**(`app/settings/privacy-policy.tsx` / `user-agreement.tsx`),且**同一批链接在设置菜单里本来就配了 route** —— 是漏接线,不是待开发。已补齐两条 route。 - -同时把页面的兜底分支由「静默什么都不做」改为**不做成可点**:无 route 的条目渲染成普通行 + 如实标注「待开放」,不再给一个静默失效的箭头。 - -**实测**:点「隐私政策」已能跳转并渲染全文(此前无任何反应)。 - -#### 6. 实测结果(Android 模拟器 `emulator-5554`,非代码走查) - -| 项 | 结果 | -|---|---| -| 页面渲染 | 三张证照卡 + 各自补图占位 + 页脚核验提示,均正常 | -| 设置 → 资质证照 | 点击跳转正常 | -| 关于我们 → 资质证照 | 点击跳转正常 | -| 关于我们 → 隐私政策 | **原点了毫无反应,现正常跳转并渲染全文** | -| JS 报错 | logcat 无 `ReactNativeError` / `RedBox` / `Invariant Violation` | - -**实测中发现并修掉一个布局缺陷**:证照信息行原写死 `infoLabel: { width: 96 }`,「统一社会信用代码」八个字放不下、**折成两行**,行高被撑开、右侧「待补充」位置错乱。改为按内容撑开(`flexShrink: 0`)+ `minWidth: 96` 让短标签左对齐。**这个缺陷只有截图才看得出来,代码走查发现不了。** - -#### 7. ~~另一处「文档与代码不符」~~ **(结论有误,2026-09-20 撤回)** - -> **撤回说明(4.14 第 7 条)**:本条原断言「`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 全仓 grep 无任何 -> 消费者,缺的是页脚组件本身」。**该断言经复核为错误**:`components/onboarding/ui/SupportFooter.tsx` -> 于 2026-09-17(`00522d5`)已创建 —— **早于本条一天** —— 它读这两个常量、号码非空才渲染、支持 -> 点击拨号,并已挂在 `P1Login.tsx:67` 与 `P2QuickLogin.tsx:130`。号码已填,故**页脚是渲染出来的**。 -> 原「文档与代码不符」的其实是本条自己:一次没查全的 grep 得出的结论。 -> 以下保留原文,仅作错误留档,**不要按它去「补页脚」**(页脚已存在,那样会做出第二个)。 - -文档第 335 行①称合规页脚「已填 `15154608969`……**填入即自动出现并接 `tel:` 拨号**」。 -~~实测:`SUPPORT_HOTLINE` / `SUPERVISION_PHONE` 在 `src/constants/index.ts` 有定义,但全仓 grep 无任何消费者 —— 没有一处代码读它,所以永远不会有页脚出现。~~ - -~~即那句「填入即自动出现」目前是不成立的:缺的不是号码,是页脚组件本身。~~ -要么补一个合规页脚(需产品定位置),要么把该条描述改回如实。**不擅自新建页脚**(涉及放哪一页、什么形态,属产品决策)。 - -#### 8. 提交约定补了一条:**提交之后必须推送** - -`AGENTS.md` 原约定只写「每天 17:50 **提交**」,没写「推送」。实测后果: -**2026-09-11 至 09-17 整整一周的 12 个提交从未离开本机**,远端一直停在 `f6d4202`(09-10)。 -影响:① 这段工作只存在于一台机器上,磁盘故障即丢失;② 后端同事看不到前端接口契约; -③ 9:20 的「拉取」形同虚设(本地领先 12、落后 0,拉不到任何东西)。 - -已推送(`f6d4202..00522d5`,现 `0 0` 同步),并把约定改为**两步:`git commit` → `git push`**。 - -#### 9. 本批次门槛复跑(2026-09-18 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error** · `npm test` **20 个文件 251 例全绿** · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · `check-backend-stubs` **140 个接口函数**。 - -#### 10. 本批次仍未做的 - -- **资质证照的真实内容**:三类证照的编号 / 发证机关 / 有效期 / 证照图**全部待法务与资质方提供**。页面与数据契约已就绪,材料到位后只改 `src/api/api.ts` 里 `MOCK_QUALIFICATIONS` 的字段(`number` / `issuer` / `validPeriod` / `imageUrl`),并把 `qualifications.test.ts` 中锁 `null` 的断言一并更新。 -- **合规页脚**:见第 7 条,需产品定位置与形态。 -- 4.9 第 8 条列出的「仍未验证」项:~~企业端「重新上架」点击路径~~ **已于 4.11 在模拟器实测通过**;~~其余 5 处导出分享~~ **已于 4.12 全部实测通过**(另加 `jobs/[id]` 岗位分享,共 6 处);~~其余 6 处空态~~ **已于 4.13 用注入空数据实测通过**。~~`settings` 缓存读取失败态~~ **已于 4.13 结案 —— 结论是「不可达」而非「未验证」**。**至此 4.9 第 8 条全部收口**,仅剩一项本机无法验证的外部依赖项(生产包 `qingsongpin://` 深链,见 4.12 第 6 条)。 - ---- - -### 4.11 企业端状态文案缺陷 + 状态映射下沉(2026-09-18 批次) - -#### 0. 缺陷:草稿说自己是「重新上架」 - -4.9 第 4 条做的写通道,回模拟器复看时发现第一张卡片(标着**草稿**)的按钮是「**重新上架**」。溯源到 `app/(enterprise)/job-management.tsx`: - -```tsx -const isActive = job.status === 'active'; -... -{isActive ? '下架' : '重新上架'} -``` - -`JobManagementJobStatus` 有三个值(`active | offline | draft`),这个三元只分了两支 —— **草稿落进了「已下线」那一支**。确认框更明显:`重新上架「X」后,候选人可以再次看到该岗位。` 草稿**从未上架过**,「重新」「再次」两句都不成立。(同一处确认按钮写的是「上架」,说明作者当时自己也没统一。) - -#### 1. 修法:不是补个三元,是把映射移到 API 层 - -页面里再补一层 `status === 'draft' ? … : …`,只会让第二个同款缺陷更容易出现。按 AGENTS.md「业务规则放 API 层」,新增两个纯函数: - -- `buildJobStatusAction(job)`(`src/api/api.ts`)→ `{ label, icon, confirmTitle, confirmMessage, confirmButton, nextStatus, destructive }` -- `getJobStatusLabel(status)`(`src/api/api.ts`)→ 角标文案,**从 `JOB_MANAGEMENT_TABS` 反查**,不另立一份 -- `JobStatusAction`(`src/types/enterprise.ts`)→ 类型里写明「草稿与已下线必须给出不同文案」 - -| 状态 | 按钮 | 目标 | 破坏性 | 确认框正文 | -|---|---|---|---|---| -| 招聘中 | 下架 | `offline` | 是 | 下架「X」后,候选人将无法再看到该岗位,已投递的简历不受影响 | -| 已下线 | 重新上架 | `active` | 否 | 重新上架「X」后,候选人可以**再次**看到该岗位 | -| 草稿 | 上架 | `active` | 否 | 上架「X」后,候选人**即可**看到该岗位 | - -页面侧同时删掉了自己维护的 `STATUS_LABEL`(三个标签与 `JOB_MANAGEMENT_TABS` 逐字重复,会各改各的)。**严格说这不算用户可见缺陷,属顺带去重**,单独列出来是为了不夸大。 - -#### 2. 测试锁 + 灵敏度验证 - -`src/__tests__/job-management-write.test.ts` 新增 9 例。关键几条: - -- 草稿分支:`label` 不得含「重新」、`confirmMessage` 不得含「再次」 -- 草稿与已下线的按钮/标题/正文**三者都必须不同**(两种处境不能共用一句话) -- 角标文案与 `fetchJobManagement().tabs` 逐字一致(同源,不是又抄一份字面量) -- 遍历全部 12 个岗位:`nextStatus` 与当前状态不同,且符合写死的目标契约 - -**灵敏度验证**:把草稿分支的 `label` 改回「重新上架」→ **2 例立刻转红** → 完整回退并 grep 确认还原。只跑通测试不能说明测试有效,这一步不能省。 - -#### 3. 实测结果(Android 模拟器 `emulator-5554`,非代码走查) - -| 项 | 结果 | -|---|---| -| 草稿卡片按钮 | 「上架」(修复前是「重新上架」) | -| 草稿确认框 | 「上架岗位 / …候选人**即可**看到该岗位」,无「重新」无「再次」 | -| 已下线卡片按钮 | 「重新上架」 | -| 已下线确认框 | 「重新上架 / …候选人可以**再次**看到该岗位」 | -| 点「上架」后的计数 | 草稿 1→0、招聘中 7→8、在招岗位统计 7→8、全部仍 12 | -| 空掉的草稿 Tab | 正确显示空态「这个分类下还没有岗位」 | -| JS 报错 | logcat 无 `ReactNativeError` / `RedBox` / `Invariant Violation` | - -#### 4. 两处如实记录 - -- **模拟器上的草稿样本被我用掉了**:验证「上架」写入时把那张草稿变成了在招,**已落库**。UI 只能在 `active`↔`offline` 之间切,**回不到 `draft`**;且「设置 → 清除缓存」只删文件系统缓存目录(`src/lib/cache.ts`),**不动 AsyncStorage**,清不掉 `qingsongpin:enterprise:jobs-status`。要把草稿样本变回来只能清应用数据(`adb shell pm clear`,会一并清掉登录态)。属本机开发数据,不影响仓库。 -- **这不是又一个「文档说已清零」**:本缺陷是**代码走查没发现、复看截图才发现**的(与 4.10 第 6 条的标签折行同一类)。4.9 第 4 条当时只跑了 tsc/eslint/单测,这三道门槛都看不见「文案对不对」。 - -#### 5. 门槛复跑(2026-09-18 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error** · `npm test` **20 个文件 260 例全绿**(较 4.10 的 251 例 +9) · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · `check-backend-stubs` **140 个接口函数**。 - ---- - -### 4.12 六处导出 / 分享真机验证 + 霍兰德参与人数两处不一致(2026-09-18 批次) - -#### 1. 为什么这次验证比前几次更硬 - -4.9 把 6 处「点了只弹功能开发中」改成了真导出,但一直**只有代码走查,没有设备验证**,在 4.9 第 8 条里如实挂着「未验证」。 - -这次能验得硬,是因为一个偶然的有利条件:**Android 系统分享面板会预览即将分享的文本**。所以截图不只是「面板弹出来了」,还能顺便读到**拼装出来的内容对不对**——数字、名称、状态是否与页面一致,一眼可核。 - -全仓 `shareText` 调用点共 **7 处**,本轮验了 6 处;另一处是 `app/onboarding.tsx`(入职清单导出)—— **该处已于 4.13 第 10 条实测通过,至此 7 处全部验过**。 - -#### 2. 实测结果(Android 模拟器 `emulator-5554`) - -| # | 页面 | 入口 | 分享面板预览(原文摘录) | 与页面是否一致 | -|---|---|---|---|---| -| 1 | `jobs/[id]` 岗位详情 | 右上分享 | 高级前端工程师|字节跳动 / 北京·朝阳区·3-5年·本科 / 薪资 25-40K·14薪… | ✅ | -| 2 | `settings/privacy` 隐私设置 | 导出隐私设置摘要 | 我的隐私设置摘要(当前等级:较高)/ 你的数据保护状态良好… | ✅ 等级与页面一致 | -| 3 | `interview` AI模拟面试 | 右上分享 | AI 模拟面试配置(高级前端工程师·字节跳动)/ 模式:全真模拟 / 难度:中等·时长:30分钟… | ✅ | -| 4 | `jobs/offer-compare` | 导出对比报告 | 【Offer 对比报告】/ 参与对比 2 个 Offer… | ✅ 与页面 2 个 Offer 一致 | -| 5 | `jobs/rank` 竞争力排名 | 导出分析报告 | 竞争力分析报告(高级前端工程师·字节跳动)/ 综合评分 88 分(优秀)/ 当前排名 127/1286·超越 90.1% 候选人·前 9.9… | ✅ 127 / 1286 / 90.1% / 9.9% / 88 分 全中 | -| 6 | `holland-result` 测评结果 | 右上分享 | 霍兰德职业兴趣测评报告 / 类型代码:ISR·研究型+社会型+现实型… | ✅ 与页面 ISR 型一致 | - -**6 处全部真拉起系统面板,内容与页面一致,无一处谎报成功。** - -#### 3. 一个能证伪的实验:分享的到底是「本地选择」还是「接口默认值」 - -`app/interview.tsx` 的分享实现特意传入**页面上的当前选择**(`mode` / `questionTypes` / `specials`),而不是接口默认值 —— 但面板预览只显示 3 行,题型那行被截掉,光看截图证不了。 - -于是改一个**第 2 行可见**的字段做对照实验: - -| 步骤 | 面板预览第 2 行 | -|---|---| -| 模式选「全真模拟」后分享 | 模式:全真模拟 | -| 同一页面改选「快速问答」后再分享 | 模式:**快速问答** | - -同一页面、同一接口数据,只有本地选择变了,分享文本随之变 —— **证明它读的是本地选择态,不是接口默认值**。这个结论只看代码得不出来。 - -#### 4. 顺带发现的新缺陷:霍兰德参与人数两处不一致(已修) - -验证第 6 条时经过霍兰德介绍页,看到「已有 **124,580** 人完成测评」;而成长页同一张卡片写的是「已有 **12,846** 人完成」。查证属实 —— `src/api/api.ts` 里有两个互不相干的 mock: - -- `MOCK_HOLLAND.totalParticipants = 12846`(测评会话,成长页卡片读它) -- `MOCK_HOLLAND_INTRO.totalParticipants = 124580`(介绍页读它) - -**同一个统计量、同一个用户、隔一屏就是两个数。** 与之前修过的「全部 12 / 已下线 3」是同一类缺陷(一个数字两处来源),也正是本文件反复强调「数字必须对得上」的那一类。 - -**修法**:抽出 `HOLLAND_TOTAL_PARTICIPANTS` 单一常量,两处引用它。 - -**关于取值**:两个数都没有事实依据(mock),我取了 `124580`(介绍页那个,也是用户先看到的那个)。**这是判断不是事实**,真实人数应由后端下发;接后端时删掉该常量,两处改为各自读接口返回值。 - -**测试锁**:新增 `src/__tests__/holland.test.ts`(2 例),核心一条是「介绍页与测评页的人数必须一致」。 -**灵敏度验证**:把测评页改回独立数字 `12846` → 该例立刻转红(`Expected: 12846 / Received: 124580`)→ 完整回退并 grep 确认两处都在引用常量、无裸字面量残留。 - -**设备复验**:改完后成长页显示「已有 **124,580** 人完成」,与介绍页一致。 - -#### 5. 两处「同页两套标准」(本轮发现,未改) - -| 文件 | 现象 | 为什么不改 | -|---|---|---| -| `app/jobs/offer-compare.tsx:243` | 错误态是**手写** UI(`加载失败,请重试` + 自绘重试按钮),而不是全项目统一的 `` | **功能是对的**(重试真的接了 `refresh`),只是写法不统一。按「StateView 先不全改,只改本轮碰到的页」的决定,本轮碰到的页本该顺手改,但改它会把 4.12 的 diff 混进样式调整,故单列记录、留待专门批次 | -| `app/jobs/rank.tsx:266` | 加载态是裸 ``,不是 `` | 同上,纯视觉规范统一 | - -两处都**不是缺陷**,不影响功能,登记在此是为了不丢失。 - -#### 6. 深链不路由:原因已查明(**2026-09-18 更正,原解释是错的**) - -验证过程中试过 `qingsongpin://jobs/1`、`qingsongpin://settings/privacy` 等深链,**均未跳转**,app 只是恢复了上次的路由。 - -这件事本节最初写的是「当前跑的是 dev build,scheme 很可能由 **expo-dev-client** 的 launcher 接管,生产包行为未必相同」。 -**这个解释是错的**,必须更正 —— 一个错的归因比「未知」更危险:它会让人去查 dev-client 配置,而那里根本没有问题。 - -**实测证据(三条互不依赖)**: - -| # | 命令 / 检查 | 结果 | -|---|---|---| -| 1 | `grep expo-dev-client package.json` | **无此依赖**(只有 `expo: ~57.0.22`)—— 项目没装 dev-client | -| 2 | `adb shell pm list packages -3` | 本机第三方包**只有 `host.exp.exponent`**(即 Expo Go),无本应用的原生包 | -| 3 | 前台 Activity | `host.exp.exponent/.experience.ExperienceActivity` —— 跑在 Expo Go 里 | - -**根因(一条命令直接证死)**: - -```bash -$ adb shell am start -a android.intent.action.VIEW -d "qingsongpin://jobs/1" -Error: Activity not started, unable to resolve Intent { ... dat=qingsongpin://jobs/... } - -$ adb shell am start -a android.intent.action.VIEW -d "exp://127.0.0.1:8081/--/jobs/1" -Starting: Intent { ... dat=exp://127.0.0.1:8081/... } # 正常启动 -``` - -`unable to resolve Intent` 的含义是 **Android 在整台设备上找不到任何能处理 `qingsongpin://` 的应用** —— 不是「应用收到了但忽略了」。因为**没有任何已安装的原生包注册过这个 scheme**:`app.config.js` 里那句 `scheme: 'qingsongpin'` 要出**原生包**才会写进 AndroidManifest;Expo Go 只注册 `exp://`。 - -**结论**:这是 **Expo Go 的已知限制,不是本项目的缺陷**。所以 —— - -- 「`qingsongpin://` 在 Expo Go 里点不动」**永远测不出别的结果**,不必再试; -- 生产包是否路由,**仍未验证**(本机没有原生包可测),但**成因已不再是谜**:只要出包后 AndroidManifest 里的 scheme 生效即可。**建议首次出包后专门验一次**,不要等上线才发现; -- 潜在影响面如前述:微信登录回调、分享出去的内容被点开等,都依赖这个 scheme。 - -**顺带得到一个可复用的验证手段**:Expo Go 下可用 -`adb shell am start -d "exp://127.0.0.1:8081/--/<路由>"` -直接跳到任意路由。4.13 那 6 个空态就是靠它逐个抵达的(比一层层点进去快且不易点错)。 - -#### 7. 本轮本机副作用(如实记录) - -- 为够到 `holland-result`,在模拟器上答了 4 道题(该 mock 的 `nextIndex >= 3` 即跳结果页)。`submitHollandAnswer` 是空壳、不写存储,故**成长页仍显示「立即测评」**,没有留下持久状态。 -- 为够到分享按钮,把 Expo 调试悬浮球从右上拖到了屏幕下方(否则它盖住所有页面右上角的分享图标)。 - -#### 8. 门槛复跑(2026-09-18 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error** · `npm test` **21 个文件 262 例全绿**(较 4.11 的 260 例 +2) · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · `check-backend-stubs` **140 个接口函数**。 - ---- - -### 4.13 六处空态实测 + 无效图标名缺陷 + `check:icons` 新门槛 + `settings` 缓存失败态结案 + 分享文本测试锁(2026-09-18 批次) - -#### 1. 这一轮在补什么 - -4.9 第 8 条「仍未验证」清单里,4.11 关掉了「重新上架点击路径」、4.12 关掉了「6 处导出分享」,本节关掉最后两项:6 个空态(第 3 条),以及 `settings` 缓存读取失败态(第 8 条 —— 结论不是「验不了」,而是**那条分支根本不可达**)。 - -那 6 个空态此前只有一句代码走查式的推断:「均为同一条件渲染写法,只实测了成长页两处」。**推断不是验证** —— 本文件吃过太多次「代码看着对、设备上是错的」的亏,故补测。 - -**为什么这 6 处非测不可**:光读 `x.length === 0 ? <空态> : <列表>` 这行守卫,证明不了它**可达**。页面完全可能在更早的地方 `return`(`if (!data) return `),或整块被父级条件包住 —— 那空态就是一坨**永远渲染不到的死代码**,跟没写一样。这类问题静态读代码读不出来,只能把数据真的弄空。 - -#### 2. 方法:注入空数据 → 截图 → 全量还原 - -mock 数据永远非空,所以空态**在正常运行中不可达**,只能注入。方法与 4.9 那次相同,但这次一次性注入 6 处、跑完再统一还原: - -- 注入前 `cp src/api/api.ts /tmp/api.ts.bak` 留底(**不做手工反向编辑**,避免漏还原) -- 用 `node -e` 打补丁,**每处都断言「匹配次数 === 1」**,匹配数不对就整体中止不写盘(防止正则误伤别处) -- 还原后核对 **md5 与注入前备份完全一致**,并 grep 扫描全部注入特征串 - -**还原证据**: - -``` -$ md5sum /tmp/api.ts.bak src/api/api.ts -956a855445b9392886c47a9804b056e7 */tmp/api.ts.bak -956a855445b9392886c47a9804b056e7 *src/api/api.ts ← 逐字节一致 - -$ grep -n "slice(0, 0)\|companies: \[\]\|reviews: \[\]\|improvements: \[\]\|offers: \[\], scores: \[\]\|as LegalService\[\]" src/api/api.ts -✅ 无残留 -``` - -> 这里用 **md5 相等**而不是「grep 没搜到残留」作主证据:grep 只能证明「这几串没了」,md5 能证明「整个文件与注入前一个字节不差」。前者是抽样,后者是全集。 - -#### 3. 实测结果(Android 模拟器 `emulator-5554`) - -| # | 页面 | 注入成空的数据 | 实测渲染 | 同页其它内容是否受影响 | -|---|---|---|---|---| -| 1 | `progress` 求职进度 | `companies` | 「该阶段暂无投递记录 / 投递岗位后,进度会在这里按阶段更新」+ 公文包图标 | ✅ 常驻标题「求职中 2」仍在,下方 Offer 区块正常 | -| 2 | `legal` 法律服务 | 服务网格 | 「暂无免费法律服务」「暂无会员法律服务」**两个网格各自独立**出空态 | ✅ 文书模板 / FAQ / 会员套餐均正常 | -| 3 | `hr-evaluation` HR评价 | `reviews` | 空态卡 + 对话气泡图标 | ✅ 且标题计数显示为 **「HR评价(0)」** | -| 4 | `ability` 四维能力 | `improvements` | 「完成测评后,这里会给出针对性的提升建议」+ 灯泡图标 | ✅ 四维分数、趋势图正常(注入精准,未误伤) | -| 5 | `jobs/offer-compare` | `offers` | 「暂无可对比的 Offer」+ 副文案 | ✅ **下方三个区块正确消失** | -| 6 | `jobs/[id]` 岗位详情 | `reviews` | 「暂无员工评价 / 成为第一个分享这家公司真实情况的人」+ 气泡图标 | ✅ 常驻标题「员工评价」仍在 | - -**6 处全部按设计渲染,无一处是死代码。** 第 3 条附带证明了一件事:标题里的 `(0)` 是**由数据推导**的,不是写死的(数据空 → 计数跟着变 0)。 - -第 5 条尤其值得记:这个守卫的**目的**就是「offers 为空时,下方三个对比区块会渲染成空壳」—— 实测确认三个区块确实消失了,说明守卫挡在了正确的位置。 - -#### 4. 注入过程中发现的新缺陷:`gavel-outline` 不是合法图标名(已修) - -在 `legal` 页注入空数据时,屏幕底部弹出一条此前从未见过的报错: - -``` -"gavel-outline" is not a valid icon name for family ... -``` - -**这是个真缺陷,且一直存在** —— 它跟注入无关,只是 4.9 那批空态此前**从未真正渲染过**,所以这页没被认真走过。 - -根因在 `src/api/api.ts` 的文书模板数据里: - -``` -name: '劳动仲裁申请书', icon: 'gavel-outline' ← 原值 -``` - -核对 Ionicons 全库 **1357 个字形,`gavel` 一个都没有**(`gavel-outline`、`gavel` 均不存在,含 "gavel" 的字形 0 个)。后果:「劳动仲裁申请书」这一条**图标渲染为空白**。 - -**为什么 `tsc` 0 错误也拦不住** —— 这是本节最值得记的一点。页面写的是: - -```tsx - -``` - -这个 `as` 断言把「任意字符串」硬骗成合法字形名,**编译器就此闭嘴**。而图标名来自数据层(`api.ts` 的 mock),类型本就是 `string`,静态类型无从校验。**类型系统在这里是盲的,不是「没查到」,是「查不了」。** - -**修法**:换成 `hammer-outline`(Ionicons 里真实存在)。选它的理由:原作者的意图明显是「法槌」,而 Ionicons 没有法槌,锤子是语义最接近的等价物;且同组另两条也各按语义取图标(`checkbox-outline` / `exit-outline`)。 - -**顺带做了全仓同类扫描**,结果分两类: - -| 命中 | 判定 | -|---|---| -| `gavel-outline` | **真缺陷**,已修 | -| `🏅 🎯 ⭐ 🏆 📮 📚`(6 个) | **误报** —— 这是 `MOCK_BADGES` 的勋章图标,由 `{badge.icon}` 直接当字符渲染,本就该是 emoji 不是 Ionicons 名 | - -**设备复验**:修复后重启,「劳动仲裁申请书」的**锤子图标正常渲染**(蓝色),且 `adb logcat` 中 `not a valid icon name` **零条**。 - -#### 5. 新增门槛 `npm run check:icons`(防复发) - -只修这一处是治标 —— 只要数据层还能塞任意字符串、页面还在用 `as` 断言,同类问题就会再来。故把这次的人工扫描**固化成脚本** `scripts/check-icons.mjs`: - -- 字形表**直接从装的包里读**(`@expo/vector-icons` 的 `Ionicons.json`),**不另存一份** —— 另存一份必然与真实图标库脱节,那就是又一个「两处来源」 -- 查 `` 字面量与 `icon: '...'` 两种写法 -- emoji 白名单判据取**「是否含非 ASCII 字符」**,不硬编码那 6 个值(以后加勋章不必回来改脚本) -- 与其它门槛一致,`--error` 模式有命中即 `exit 1` - -**灵敏度验证(必须做,否则「通过」毫无意义)**:把 `hammer-outline` 注入回 `gavel-outline` → 脚本**准确报出 `src\api\api.ts:6166`、命中名与命中行、exit 1** → 完整还原 → 脚本复跑转绿、`grep gavel` 仅剩注释里的说明文字(无代码引用)。 - -**已知边界**(如实记录):只查**静态字面量**。运行时拼出来的图标名(`` icon: `tab-${i}` ``)查不了 —— 本仓目前没有这种写法。 - -#### 6. 更正 4.12 第 6 条的错误归因 - -详见 4.12 第 6 条(已就地更正):深链 `qingsongpin://` 不路由**不是** expo-dev-client 造成的 —— 本项目**根本没装 dev-client**,当前跑在 **Expo Go** 里(本机第三方包只有 `host.exp.exponent`),Expo Go 只注册 `exp://`,故 Android 报 `unable to resolve Intent`(**整机没有任何应用能处理该 scheme**)。属 Expo Go 已知限制,非项目缺陷。 - -#### 7. 本轮本机副作用(如实记录) - -- 为凑齐 6 个空态,在模拟器上注入过空数据并**已全量还原**(md5 逐字节核对,见第 2 节)。还原后继续做图标修复,之后**重启过一次应用**用于复验图标,**当前处于正常的非空数据态**。 -- 截图产物 `inj-*.png` / `fix-*.png` 为验证中间物,**不留在仓库**(已 `rm` 并确认无残留)。 -- 另:`/tmp/api.ts.bak` 是 api.ts 的注入前留底,在系统临时目录,**不在仓库内**。 - -#### 8. 最后一项的真相:`settings` 缓存「读取失败」态**不可达**,不是「未验证」 - -4.9 第 8 条把这项记成「需**文件系统级故障**才能触发,`mockRequest` 造不出来,故未验证」。这个记载**不准确** —— 顺着调用链读下去发现,它不是「没有条件验」,而是**任何条件下都走不到**: - -``` -app/settings.tsx ← hook(useAppCache) ← api(fetchCacheSize) ← lib(readCacheBytes) - ↑ - try { ... } catch { return 0 } ← 故障在这里被吞掉 -``` - -`src/lib/cache.ts` 的 `readCacheBytes()` 把 **所有** 异常吞掉并返回 `0`(目录不存在、无权限、原生模块缺失,一律如此)。`0` 是合法体积值,调用方无从区分「真的是 0」与「读失败了」。因此 `useAsyncData` 永远拿不到 rejection → `error` **恒为 `null`** → 页面 `cacheError ? '读取失败'` 分支是**死代码**。 - -**这解释了 4.9 那次注入实验为什么失败**:当时注入全局失败后「仍正常读出 381KB」,不是因为故障不够「文件系统级」,而是故障在这条链上**根本没有出口**。 - -**这是可证的,不需要设备** —— 新增 `src/__tests__/cache.test.ts`(12 例),用 mock 直接构造「会抛错的文件系统」,锁住两条契约: - -| 锁住的契约 | 对应用例 | -|---|---| -| `readCacheBytes()` 永不抛错:构造失败 / 目录不存在 / 正常路径,分别返回 0 / 0 / 体积 | 4 例 | -| `fetchCacheSize()` / `clearAppCache()` **resolve 而非 reject**(哪怕文件系统在抛错) | 2 例,即「error 恒为 null」的直接证据 | -| 清缓存的容错:单个条目删不掉不阻断其余;列目录失败返回 0 | 3 例 | - -**灵敏度验证(必须做)**:把 `readCacheBytes` 的 `try/catch` 摘掉、让故障上抛 → **恰好 3 例转红**,且正是上面那三条例外契约的用例(`构造目录抛错时返回 0` / `fetchCacheSize 仍然 resolve` / `clearAppCache 仍 resolve`)→ 从备份完整还原(**md5 逐字节一致**)→ 复跑 12 例全绿。 - -**判定:这不是缺陷,是一项需要如实记载的设计取舍。** `cache.ts` 的注释写明了理由(「清缓存本身不该因为读不到体积而失败」)—— 读不到体积时显示 `0B` 而非报错,是有意为之。**但由此产生的后果必须写清楚**:`settings` 页那个「读取失败」分支**永远不会显示**,不要把它当成已覆盖的错误态。 - -> 与 `app/onboarding.tsx` 空态的对比(同属「不可达」但成因不同): -> 那个是**已知不可达、故干脆没写**(4.9 记录在案);这个是**写了、但不可达**。后者更危险 —— 它看起来像「错误态已覆盖」,实际是个摆设。 - -#### 9. 仍未验证(不要当成验过) - -- **生产包的 `qingsongpin://` 深链**:本机无原生包,测不了(成因已查明,见 4.12 第 6 条)。**这是唯一一项真正的「未验证」。** -- 4.9 第 8 条其余项目已全部关闭:企业端「重新上架」见 4.11,6 处导出分享见 4.12,6 处空态见本节第 3 条,`settings` 缓存失败态见本节第 8 条(结论是「不可达」而非「未验证」)。 - -#### 10. 第七处分享路径实测 + 5 个拼装函数补上测试锁 - -**(1)第七处分享路径:`app/onboarding.tsx` 入职清单导出** - -4.12 验了 6 处,唯独漏了这处。本次补上(`exp://` 深链直达 `/onboarding`,点右上分享): - -| 核对项 | 分享面板预览 | 与页面是否一致 | -|---|---|---| -| 首行 | `入职准备清单(字节跳动 · 高级前端工程师)` | ✅ 公司、职位一致 | -| 次行 | `入职日期 2026-09-06 · 已完成 3/18` | ✅ 日期与「总体进度 3/18」一致 | - -**7 处分享路径至此全部实测通过。** - -**(2)补测试锁:4.9 方案里写了「并补单测」,当时只补了范式那一个** - -`buildChecklistText` 有 3 例测试(它是范式),而 4.9 同批写的另外 5 个拼装函数**一直是 0 覆盖**: - -`buildJobShareText` · `buildHollandReportText` · `buildInterviewConfigText` · `buildRankReportText` · `buildPrivacySummaryText` - -**为什么这事不能只靠真机验证糊过去**:真机验的是**当时那组 mock 值** —— 面板预览里数字对上了,只说明「这一次拼对了」,不说明「以后一直拼得对」。而分享文本会**离开本机、给到别人看**:页面显示错自己看得见,发出去的内容错了看不见。这几个又都是纯函数,是单测最划算的地方。 - -新增 `src/__tests__/share-text.test.ts`(**28 例**),重点锁三类容易悄悄坏掉的地方: - -| 类别 | 举例 | 例数 | -|---|---|---| -| **空值守卫** | 无评分数据不输出该行(不输出 `null`/`0 分`);薪资月数为 0 不写「0薪」;真实薪资样本数 0 不展示;福利为空不留「福利:」空壳;评价数 0 说「暂无员工评价」而非「员工评价 0 条」 | 约 14 例 | -| **取的是本地选择态** | 面试配置分享**只输出被选中的题型**,未选中的不得出现 | 6 例 | -| **对外可见的格式** | 首行格式、`—— 来自「轻松聘」` 签名、不留悬空分隔符、空分组不产生连续空行 | 约 8 例 | - -其中「只输出被选中的题型」这条,是把 **4.12 那个真机证伪实验(改选后再分享、面板预览随之变)固化成不依赖设备的断言** —— 今后谁把它改回「输出接口默认值」,不必再上模拟器,跑测试就会红。 - -**灵敏度验证(两处注入,分别对应两个类别)**: - -| 注入的回归 | 结果 | -|---|---| -| 让面试配置**忽略传入的选择**、输出接口全部题型(即 4.12 证伪过的那个错误写法) | **恰好 3 例转红**:核心那条「只输出被选中的题型」+「未知题型 code 被忽略」+「没选题型时该行不出现」 | -| 摘掉 `buildJobShareText` 的 `overallScore` 空值守卫 | **1 例转红**,正是「没有评分数据时整行不出现」 | - -两次注入均从备份完整还原(**md5 逐字节一致**),复跑 28 例全绿。 - -> 写测试时自己写错过一条断言,如实记一笔:本想断言「建议没有描述时不留悬空的 `——`」,写成对**整段文本** `not.toContain('——')` —— 而签名行 `—— 来自「轻松聘」` 本身就含 `——`,必然失败。**是断言写错了,不是代码错了**;改为断言**那一行**等于 `· 补足项目数据`。留着这段是因为它说明一个常见坑:断言的**粒度**要落在被测行为上,对整段文本做否定断言很容易被无关内容打脸。 - -#### 11. 「数据层引用外部词表」的系统性排查(本轮顺带做完) - -4.13 第 4 条的图标缺陷暴露出一类共同形状:**字符串写在数据层,却必须与某个外部词表对得上,而类型系统看不见**。既然找到了这类形状,就顺手把同类词表全查一遍,避免只修个案: - -| 词表 | 排查方式 | 结论 | -|---|---|---| -| **Ionicons 字形名** | 新建 `check:icons` 扫全仓 | ❌ 曾有 1 处(`gavel-outline`),**已修**;现 0 处 | -| **路由字符串** | `check-routes` **确认能覆盖数据层**:注入一个拼错的数据层路由 `/favourites-typo` → 脚本准确报出 `src/api/api.ts` 并计数 1 → 还原 | ✅ 已被既有门槛覆盖,**无需新增**(此前未验证过它是否覆盖数据层,本次补验) | -| **图标字体家族** | 全仓 grep `from '@expo/vector-icons'` | ✅ **只有 `Ionicons` 一家**,故 `check:icons` 覆盖了全部图标面 | -| **硬编码色值** | `grep '#[0-9A-Fa-f]{3,8}'`(排除 `src/theme/colors.ts`) | ✅ **0 处** —— AGENTS.md 那条「组件内不写死色值」真的成立 | -| **`rgba()` / 具名颜色** | grep 后逐个看 | ✅ 6 处 `rgba` 全是半透明白/黑叠加层(白卡上的蒙版等),4 处 `'transparent'`;**都不是品牌色**,无对应 token,属正常 | -| **颜色出现在数据层** | `grep 'color: #...'` in `api.ts` | ✅ **0 处** | -| **语义色键**(可疑但实为正确设计) | `thumbnailColor: 'violet' \| 'orange' \| 'green'` | ✅ 这是**联合类型的语义键,不是色值**;两个页面各自 `Record<键, 主题token>` 映射。将来加第 4 个键,两处 Record 都会因类型不完整而**编译报错** —— 类型系统正好守住了这个失败模式 | - -**结论:三类词表(图标名 / 路由 / 颜色)现已全部被门槛或类型系统覆盖**,其中图标名是本轮新补的。排查过程与结论一并记在此,避免以后重复做。 - -#### 12. 门槛复跑(2026-09-18 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error** · `npm test` **23 个文件 302 例全绿**(较 4.12 的 262 例 +40:`cache.test.ts` 12 例 + `share-text.test.ts` 28 例) · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · `check-backend-stubs` **140 个接口函数** · `check:icons`(**本轮新增**)**0 处无效图标名**。 - ---- - -### 4.14 求职意向词表统一 + 四处数据自相矛盾(2026-09-20 批次) - -#### 1. 起点:一次「找同类缺陷」的排查,不是一次功能开发 - -4.13 把「数据层引用外部词表」的三类(图标名 / 路由 / 颜色)排查完了。本轮换了个方向问: -**这个项目反复吃亏的那类缺陷,还有哪些没被扫过?** 已发生过的先例是「**同一个业务事实被写了两份, -且两处取值不同**」——霍兰德参与人数(124,580 / 12,846)、运营主体公司名、客服电话。 -沿这条线扫全仓,找到两组问题,均已修复并实测。 - -#### 2. 缺陷一:引导采集的求职意向,在「设置求职意向」页一个 chip 都选不中 - -**这是本轮最严重的一条,且每个走完引导的用户都会撞上。** - -`resolveJobPreference()`(`api.ts`)在用户未显式保存过偏好时,走 `deriveJobPreference(profile)` -从引导资料推导;而 `app/job-preference.tsx` 的 chip 用 `opt === value` 严格相等判断选中。 -问题在于**两处各有一份选项词表,且三组都匹配不上**: - -| 字段 | 引导产出(`api.ts`) | 该页选项(改前) | 后果 | -|---|---|---|---| -| 期望薪资 | `10K-20K`(`SALARY_MIN_OPTIONS` 阶梯带 K) | `10-20K`(最低值不带 K) | 无 chip 选中 | -| 求职状态 | `离职-随时到岗` 等 4 项(`JOB_STATUS_OPTIONS`) | `积极求职中` 等 3 项,**零重合** | 无 chip 选中 | -| 期望城市 | P5 的 16 城 | 该页 10 城 | 天津 / 重庆 / 青岛 / 长沙 / 郑州 / 东莞 **6 城**无选中 | - -用户刚在引导里填完,进来看到「期望职位」(纯文本输入)有值、其余三组全空 —— 像是资料丢了。 - -**并且这不只是显示问题。** 简历求职意向的积极 / 不积极判定是: - -```ts -status: preference.status === JOB_STATUS_INACTIVE ? 'inactive' : 'active' -``` - -而 `JOB_STATUS_INACTIVE` 是引导词表的 `'在职-暂不考虑'`。用户在求职意向页选它自己的 -「在职暂不考虑」(没有那两个短横)并保存后,覆盖值写进 `user:job-preference`, -**与判定常量不是同一个字符串,比较恒为 false —— 简历仍把他记成积极求职中。** - -#### 3. 修法:三组选项表下沉到 `src/constants`,两页共用一份 - -按 AGENTS.md「同一个事实只有一份来源」处理,**不是把两处数字调成一样**: - -| 新增常量 | 说明 | -|---|---| -| `EXPECTED_CITY_OPTIONS` | 期望城市 16 城;`P5City` 与求职意向页共用(原两处各一份) | -| `HOT_CITY_OPTIONS` | P5 置顶的热门城市 | -| `EXPECTED_SALARY_OPTIONS` | 快捷档,**写法与引导 P7 一致**(`最低K-最高K`) | -| `WORK_TYPE_OPTIONS` | 工作类型;顺带由测试锁住「`PREFERENCE_DEFAULT_WORK_TYPE` 必须取自本列表」 | - -薪资那一组有个绕不开的点:**P7 是两个独立阶梯(最低 / 最高各 9 档),能拼出快捷档覆盖不到的 -组合**(如 `8K-12K`、`10K-15K`)。所以不可能靠「把档位写全」解决。新增纯函数 -`withCurrentOption(options, current)`(`src/utils`):当前值不在列表里时插在首位(通常是「不限」) -之后并选中,保证**存了什么就显示什么**。空串(未设置)不补 —— 「未设置」不该变成一个可选项。 - -`CITIES`(8 城)**没有**合并进来:它是岗位筛选用的列表,城市得有 mock 岗位与社保基数才有意义, -与「用户想去的城市」不是一回事。已在注释里写明两者的区别,避免后来者顺手合并。 - -#### 4. 实测(Android 模拟器 `emulator-5554`,非代码走查) - -该机恰有一份引导资料,构成了完美的复现样本:期望职位「运维工程师」、期望城市「北京」、 -**期望薪资「10K-15K」**(不在快捷档里)、**求职状态「离职-随时到岗」**(旧词表完全没有这一项)。 - -「我的」页 → 求职偏好 → 编辑,改前四组中三组会全空;改后实测: - -| 组 | 实测结果 | -|---|---| -| 期望城市 | 16 城全部渲染,**「北京」选中**(改前无选中) | -| 期望薪资 | 快捷档已是 `5K-10K` / `10K-20K` / `20K-30K` / `30K-50K` 写法;**用户的 `10K-15K` 被补在「不限」之后并选中** —— 这条同时证明了 `withCurrentOption` 在真机上生效,而不是只过了单测 | -| 工作类型 | 「全职」选中 | -| 求职状态 | 词表已换成 `离职-随时到岗` / `在职-月内到岗` / `在职-考虑机会` / `在职-暂不考虑`,**「离职-随时到岗」选中**(改前无选中) | - -#### 5. 缺陷二:四处「同一个事实两个数」 - -| 事实 | 改前 | 改后 | 依据 | -|---|---|---|---| -| 当前用户的 Offer 数 | 我的页 **3**;求职进度页 **2** | **2** | `MOCK_JOB_PROGRESS` 内部即有 4 处指向 2(`offerCount` / `stats.offer` / `offers` 数组实为 2 条 / 时间轴「已收获 2 个」),3 是孤例 | -| 求职进度「每月 Offer 数」之和 | 3(5/7/8 月各 1) | **2** | 与同对象的 `offerCount` 自相矛盾,等于把本页结论自己推翻一半 | -| 企业端「在招岗位」 | 总览写死 **1280**;岗位管理由列表算出 **8** | 两处均 **8** | `buildJobManagementStats` 的注释本就写着「不能是写死的 12」;改由 `countActiveJobs()` 一处计算,两边共用 | -| 企业端「评价数」 | 总览「评价人数」**1280**、同屏四宫格「累计评价」**28**、岗位评价管理 **128** | 三处均 **136** | 由 `MOCK_JOB_EVALUATION_SUMMARIES` 的 `reviewCount` 汇总(42+38+25+31)—— 明细才是单一来源,三个总数都是派生值 | -| 企业名 | 总览「北京智联科技有限公司」;其余 3 页「智联科技」 | 均 **智联科技**(`ENTERPRISE_DISPLAY_NAME`) | 数据层没有「全称 / 简称」字段,这两个是同一事实的两个值,不是刻意的简称设计 | - -企业名的 6 处字面量此前**写在页面里**(3 个企业端页面的加载 / 错误态 `Header`,因为那时拿不到数据), -属「页面写死业务数据」。现统一读常量,全仓该字符串只剩 1 处。 - -**实测确认**:企业总览页「评价人数 136 / 累计评价 136 / 在招岗位 8」,同屏不再自相矛盾; -岗位管理页「招聘中(8)」与总览页的 8 一致,企业名两页均为「智联科技」。 - -#### 6. 回归锁:15 例测试 + 两处灵敏度验证 - -新增 `src/__tests__/job-preference-options.test.ts`(**15 例**),分四组: - -1. **可表征性**:P7 两个阶梯的**任意组合**(9×9)拼出的字符串,经 `withCurrentOption` 后都能被选中; - 引导的四个求职状态、16 个城市逐个能选中;`PREFERENCE_DEFAULT_WORK_TYPE` 必须在 `WORK_TYPE_OPTIONS` 内。 -2. **端到端**:写入一份引导资料(城市「天津」、薪资 `10K`/`20K`、状态「在职-月内到岗」—— - 三项都是改前会落空的取值),`fetchJobPreference()` 的产物逐项可选中。 -3. **简历判定**:在求职意向页选 `JOB_STATUS_INACTIVE` 并保存,`fetchResume().jobIntention.status` - 必须是 `inactive`;选其余三项必须是 `active`。**这条锁的就是上面那个「选了暂不考虑仍被记成积极求职」的缺口。** -4. **接线锁**:直接读源码,断言 `app/job-preference.tsx` 从 constants 取四组选项、且不再声明 - 本地选项数组;`P5City.tsx` 同理。 - -> 第 4 组是必要的:前三组锁的是「**常量之间**自洽」,而原缺陷的形态是**页面自己又写了一份** —— -> 常量没毛病、页面不读它,照样对不上。故补一条源码级断言锁住接线。 -> **老实标出它的边界**:这是源码级断言,不是渲染断言(本项目 25 个测试文件均为纯逻辑测试, -> 未引入组件测试)。它能拦住「改回本地字面量」,拦不住「读了常量后又做一层转换」。 - -**灵敏度验证(只跑通不算数,会失败的断言才是断言)**:临时注入两处缺陷 —— -① 把 `EXPECTED_SALARY_OPTIONS` 的 `10K-20K` 改回 `10-20K`;② 把求职意向页改回本地 -选项数组 + 使用本地 `CITIES`。重跑:**2 例如期失败**(「常见组合直接命中快捷档」与「接线锁」), -其余 13 例仍通过。随后 `cp` 还原并 `cmp` 逐字节比对确认与注入前一致,grep 无残留。 - -#### 7. 更正 4.10 第 7 条的一处**反向**错误:页脚一直是存在的 - -4.10 第 7 条与「五、P0(补充)」①都写着: - -> `SUPPORT_HOTLINE` / `SUPERVISION_PHONE` …**全仓 grep 无任何消费者** … 缺的是**页脚组件本身** - -**这个结论是错的,且是朝「把已完成的当成未完成」的方向错。** 实际: -`components/onboarding/ui/SupportFooter.tsx` **在 2026-09-17(`00522d5`)就已存在** —— -比 4.10 写下这句话(09-18)早一天。该组件读这两个常量、号码非空才渲染、支持点击拨号, -并已挂在 `P1Login.tsx:67` 与 `P2QuickLogin.tsx:130` 两处。号码已填,故页脚**是渲染出来的**。 - -也就是说:**「填入即自动出现」这句话成立**,4.10 那条 ⚠️ 建立在一次漏看的 grep 上。 -本批次已把这两处描述改回如实。 - -> 这类错误和本文件反复强调的那类(把没验的当成验了)是同一枚硬币的两面: -> **一次没查全的 grep,和一次没做的验证一样,都会让文档失真。** - -#### 8. 本批次门槛复跑(2026-09-20 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` **24 个文件 317 例全绿** -(较 4.13 的 302 例 +15) · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · -`check:icons` **0 处无效图标名** · `check-backend-stubs` **140 个接口函数**。 - -#### 9. 本批次仍未做 - -- **`CITIES`(8 城)与 `EXPECTED_CITY_OPTIONS`(16 城)仍是两份**,见第 3 条 —— - 这是**有意的**,不是漏改。若产品要求两处一致,需先给这 6 个城市补 mock 岗位与社保基数。 -- ~~**`formatViews` 与薪资拼接未下沉**~~ → **(2026-09-20 已做一半,见 4.15)** - `job-management.tsx` 内联的 `job.views >= 1000 ? ...k : job.views` 已下沉为 `formatCompact` - (**刻意保持 `2.0k` 带 `.0` 的现状口径**,与 `formatViews` 的「万」并存,理由是两种场景的 - 可读性取舍不同,不是漏统一);`app/holland.tsx` 的 `toLocaleString()` 也换成了 `formatThousands`, - 与成长页同源(Hermes 无完整 ICU 时 `toLocaleString()` 可能不加千分位,两页会显示成两个样子)。 - **仍未做**:职位详情与列表页各自拼 `{salaryMin}-{salaryMax}K`,无 `formatSalaryRange`。 - 改动面涉及薪资在 4 个页面的展示口径,需产品确认是否需要统一。**列出以待下轮,不当作已完成。** -- **`app/resume.tsx:1086` 的 `rgba(0,0,0,0.4)`**:`src/theme/colors.ts` 已有同用途的 `colors.scrim` - (10+ 处已正确使用),此处是重复造值。 - ~~另 5 处 `rgba(255,255,255,x)` 属**缺失 token**。~~ - **(2026-09-20 更正,见 4.15)** 「5 处 = 缺一个 token」这个判断下得太浅:逐处看下来它们 - **不是同一个 token 被写重 5 次,而是 4 个不同的角色**(进度条轨道 / 卡片底 / 分割线 / 弱底色), - 收敛成 1 个会把这几个原本有区别的用法抹平。已按角色拆成 4 个 token。 - ~~eslint 的 `no-hardcoded-hex-color` 规则只匹配纯 hex,拦不住 `rgba(`。~~ - **(2026-09-20 更正)** 这条成立,且**确实是漏洞**,已在 4.15 补上并做注入验证。 -- 4.10 第 10 条列出的外部依赖项(资质证照真实内容 / EAS 占位符 / 隐私政策【待确认】)均**无变化**。 - ---- - -### 4.15 数据自相矛盾批量收敛(第二组)+ 半透明色 token 化 + lint 补盲区(2026-09-20 批次,接 4.14) - -#### 1. 起点:4.14 结案后,把「找同类缺陷」继续做完 - -4.14 只覆盖了**排查到的一半**。把剩下的一半做完后,又查出 **10 组**「同一个事实两个数」, -其中多数是**跨页面 / 跨层级**的(列表卡与详情页、我的页与求职进度页、统计卡与它正下方的列表), -比 4.14 那批更隐蔽 —— 因为它们不在同一屏上,肉眼翻页时很难对照。 - -#### 2. 修法:一律从权威源**派生**,不做两个字面量的手工对齐 - -这 10 组如果只是「把 A 改成和 B 一样」,下次改数据照样分叉。故修法统一为: -**让重复的那一份在接口层由权威源算出来**,使矛盾在结构上无法复现。 - -| # | 事实 | 改前两处取值 | 权威源与派生方式 | -|---|---|---|---| -| 1 | 求职进度投递总数 | `stats.total` 28 / 时间轴文案「向 28 家」/ `deliveryCount` 28,但 `stats` 五项之和 = 6+2+2+20 = **30** | `ended` 由其余四项**相减**得出,求和关系成为结构性的 | -| 2 | 月度图表三列 | 投递合计 **52**、面试合计 **16**,卡片写 28 / 6 | 六个月的三个数重排为 2+3+4+5+6+8=28 / 0+1+1+1+1+2=6 / 0+0+0+0+0+2=2 | -| 3 | 我的页收藏数 | 徽标 **12** / 收藏列表 **3** | `favorites: MOCK_FAVORITES.length` | -| 4 | 我的页勋章 | 「投递满100次」显示**已获得**,而实际只投了 28 次 | `obtained`/`progress` 由 `buildUserStats()` 派生 | -| 5 | 匹配成功横幅 | 横幅写 **3** 家 / 会话列表 `matched: true` 只有 **2** 条 | `total` 由 `MOCK_CONVERSATIONS.filter(matched)` 派生 | -| 6 | 岗位详情综合分 | 恒 **92 分·优秀**(`void id;`),9 个岗位里只有第 1 个对得上 | `jobScoreById(id)`,雷达图八维按该岗位实际分**等比缩放** | -| 7 | 对比页「综合评分」 | 产品经理 **85** / UI 设计师 **78**,与列表里的 **78 / 85** 正好互换 | 「综合评分」这一维改由 `jobScoreById` 取 | -| 8 | 候选人详情身份 | 4–8 号候选人点进去**全叫「张明远」** | `candidateIdentityOf(id)` 取推荐列表里的那一条 | -| 9 | 岗位 1 的公司所在地 | 列表卡 **海淀区** / 详情页 **朝阳区** | 撤掉详情页对该字段的覆盖,列表成为唯一来源 | -| 10 | 待回复卡片 | 卡片写 **3** 人,但它点下去切到的「未回复」Tab 只有 **2** 条 | 改用 `status === 'pending'`,与去向同源 | - -第 10 项值得单独说:原先统计的是「**有未读消息**的会话数」,而它跳转的目标 Tab 是 -「**未回复**」。两个集合本来就不同,于是点进去条数凭空变少。**判据应是「与去向一致」, -不是「某个内部集合的长度」** —— 这条也写进了测试注释。 - -#### 3. 顺带修掉一处同屏矛盾:求职进度页的标题 - -`app/progress.tsx` 的段落标题原为 `求职中 {stats.interviewing}`,而它正下方是按 Tab 过滤的 -`companies` —— mock 里只有 **4 条**(28 条投递的**抽样**)。于是标题写 2、下面列 4 行; -切到「面试中」Tab 只剩 1 行,卡片仍写 2;切到「已结束」Tab,1 行列表压在「求职中 2」下面。 - -「面试中几家」这个事实当时有**三份副本**(统计卡 / 标题 / Tab 列表)。已去掉标题这一份, -标题只说明「这是哪一段的列表」,数字以统计卡为唯一来源。 - -> **未解决、如实记录**:`companies` 是 28 条投递的**抽样**(4 条),所以统计卡上的 -> 「面试中 2 / 待反馈 6 / 已结束 18」在 Tab 列表里**永远对不上行数**。这是 mock 数据 -> **全量 vs 抽样**的结构问题,不是可以靠对齐两个字面量解决的。两条路(把 `companies` -> 扩成 28 条并让 `stats` 由它派生 / 明确标注为「近期流程」抽样并去掉会误导的统计数字) -> 都会改变用户看到的页面,**需产品定夺,本批次未动**。 -> -> **这不是推断,是逐个 Tab 截图看到的**(见第 8 条实测):统计卡「待反馈 6」→ 点进 Tab **1 行**; -> 「面试中 2」→ **1 行**;「已结束 18」→ **1 行**。三处全部对不上。 - -#### 4. 半透明色:5 处 `rgba(255,255,255,x)` 收敛为 4 个 token(值不变) - -`src/theme/colors.ts` 新增,值**等于现状**,一像素未改: - -| token | 值 | 角色 | -|---|---|---| -| `onBrandSurface` | `rgba(255,255,255,0.2)` | 品牌底上的卡片 / 徽标底 | -| `onBrandSurfaceWeak` | `rgba(255,255,255,0.15)` | 品牌底上的更弱底色 | -| `onBrandLine` | `rgba(255,255,255,0.2)` | 品牌底上的分割线 | -| `onBrandTrack` | `rgba(255,255,255,0.35)` | 品牌底上的进度条轨道 | - -另 `app/resume.tsx:1086` 的 `rgba(0,0,0,0.4)` 改用已有的 `colors.scrim`(10+ 处已正确使用)。 - -#### 5. lint 补盲区:`no-hardcoded-hex-color` 此前拦不住 `rgba()` - -4.14 就记下了这个漏洞(「规则只匹配纯 hex」),本批次补上: -`eslint.config.js` 的函数式颜色匹配扩到 `rgb|rgba|hsl|hsla`,并**加了一个 `TemplateElement` 访问器** -—— 否则模板字符串里的颜色(`` background: `${x}``)照样漏网。 - -**注入验证(会失败才算数)**:临时把 `rgba(1, 2, 3, 0.5)` 写进 `app/holland.tsx`,`eslint .` 报 - -``` -173:31 error 禁止写死颜色字面量 "rgba(1, 2, 3, 0.5)",请从 src/theme/colors.ts 导入颜色 token local/no-hardcoded-hex-color -``` - -EXIT=1,规则确实生效;随后还原,`grep -c __probe` = 0,`eslint .` EXIT=0。 - -> **一次险些得出错误结论的过程,如实记录**:第一次注入用 `python -c` 做字符串替换, -> 脚本**静默失败**(`python` 是 Windows 商店的占位存根,非交互调用时什么都不做、也不报错), -> 文件其实没被改动。若没先 `grep __probe` 确认注入物存在,就会拿一次「未注入」的运行去证明 -> 「规则有效」—— 和 4.14 第 7 条那个漏看的 grep 是同一类错误。**注入之后必须先确认注入物在文件里。** - -#### 6. 回归锁:22 例测试 + 三处注入验证 - -新增 `src/__tests__/data-consistency.test.ts`(**22 例**)。写法上刻意把断言写成 -**「A 必须等于 B」的关系**而非「A 等于 28」:这样以后调整 mock 数据不会让测试变红, -**只有把派生关系改回写字面量才会红** —— 锁的正是要防的那件事。 - -**灵敏度验证**:一次性注入三个缺陷,确认命中的断言各不相同 —— -① `favorites` 改回字面量 12 → 收藏数 1 例红; -② `fetchJobOverallScore` 改回恒定 92 → 评分 2 例红; -③ `candidateIdentityOf` 改为恒取第 0 条 → 身份 2 例红。 -合计 **5 例如期失败**,其余 17 例仍通过;逐字还原后 `grep -c __probe` = 0。 - -**另一处「测试自己写错了」的如实记录(比测试通过更有价值)**:本文件初版把 -`interviewCount`(累计收到 **6** 场面试邀请,与月度图表 `interviews` 之和、时间轴「收到 6 场面试邀请」同源) -和 `stats.interviewing`(**当前**面试中 **2** 家)当成了「同一份被写重的值」,断言两者相等并因此变红。 -**红的是测试,不是数据。** 已在文件里保留一条反向断言把它钉死 -(`interviewCount >= interviewing` 且 `not.toBe`),并在注释里写明这个坑 —— 否则下一个人会再犯一次。 - -#### 7. 工具函数下沉(与 4.14 第 9 条同一类别) - -- `formatCompact(n)`:企业端看板内联的 `n >= 1000 ? (n/1000).toFixed(1)+'k' : n` 下沉到 `src/utils`。 - **刻意保留 `.0`**(2000 → `2.0k`),与 `formatViews` 去掉 `.0` 的做法**不一致且是有意的** —— - 改口径会动到看板现在的显示,属另一批事。 -- `formatThousands`:`app/holland.tsx` 的 `toLocaleString()` 换成它,与成长页同源。 - -#### 8. 模拟器实测结果(Android 模拟器 `emulator-5554`,非代码走查) - -用 Expo Go 的深链约定 `exp://127.0.0.1:8081/--/progress` 直接路由到目标页,逐屏截图核对: - -| 页面 | 实测看到 | 说明 | -|---|---|---| -| 求职进度 · 统计卡 | **28 / 6 / 2 / 2 / 18** | **6+2+2+18 = 28**,求和关系成立(改前是 6+2+2+20 = 30 ≠ 28) | -| 求职进度 · Offer 提醒 | 「你有 **2** 个 Offer 即将到期」 | 与统计卡 Offer 2 一致 | -| 求职进度 · 月度图表 | 6月 5/1/0 · 7月 6/1/0 · 8月 8/2/2 | 三列合计 28 / 6 / 2,与顶部计数一致 | -| 求职进度 · 段落标题 | 「求职中」/「待反馈」/「面试中」/「已结束」 | 标题随 Tab 变化,**不再出现与列表矛盾的数字** | -| 我的 · 统计行 | 投递 **28** / 收藏 **3** / 沟通 8 / Offer **2** | 收藏 **12 → 3**(与收藏列表同源);投递、Offer 与求职进度页一致 | -| 我的 · 勋章 | 求职达人 **28/100**、面试高手 **6/10**、诚信之星 **95/100** | **改前「投递满100次」显示「已获得」**,而实际只有 28 次投递 | - -其中「诚信之星 95/100」是一条**同屏可自查**的交叉验证:同页上方「八大维度评分 → 诚信记录」 -显示的就是 **95** —— 勋章的分与维度的分是同一处来源,肉眼即可对照。 - -> 深链注意:`exp://…/--/` 会触发 App **整包 reload**(不是热跳转),每换一页约需 20 秒; -> 这与 4.12 第 6 条查明的结论一致 —— 项目自定义 scheme 未注册,只有 Expo Go 的 `exp://` 生效。 -> -> **没验到的**:企业端「候选人详情」(第 2 表第 8 项:4–8 号不再叫「张明远」)、岗位详情评分 -> (第 6 项)、对比页评分互换(第 7 项)、收藏列表条数(第 3 项的列表侧)**均有测试锁盖住, -> 但本轮未逐屏截图**。按本文件一贯口径记为**未做真机验证**,不当成已验证。 - -#### 9. 本批次门槛复跑(2026-09-20 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` -**25 个文件 339 例全绿**(较 4.14 的 317 例 +22) · `check-routes` **0 失效跳转**(12 个「孤立页」为 -已知误报,见本文件开头说明) · `check-affordances` **0 处** · `check:icons` **0 处无效图标名** -(扫描 194 个文件 / 1357 个有效字形) · `check-backend-stubs` **140 个接口函数**。 - -#### 10. 本批次仍未做 - -- **`companies` 全量 vs 抽样**(见第 3 条),需产品定夺(已由第 8 条实测证实:三个 Tab 全部对不上)。 -- **薪资区间拼接未下沉** `formatSalaryRange`(见 4.14 第 9 条,涉及 4 个页面的展示口径)。 -- **`chats: 8`**(我的页沟通数)**与勋章 6「完成10次测评」的 `4/10` 仍是字面量** —— - 全仓找不到可派生的计数源(没有「测评次数」计数器)。**不编造来源**,故保留字面量并在此标注; - 它们目前不与任何其它数字冲突,但一旦有了计数源就应改回派生。 -- 4.14 第 9 条列出的 `CITIES` / `EXPECTED_CITY_OPTIONS` 两份词表,状态不变(有意保留)。 -- 4.10 第 10 条的外部依赖项(资质证照真实内容 / EAS 占位符 / 隐私政策【待确认】)均**无变化**。 - ---- - -### 4.16 重复声明批量收敛(同一张表 / 同一段规则在多个页面各写一份)(2026-09-20 批次,接 4.15) - -#### 1. 起点:4.14 / 4.15 治的是「取值不同」,本批治的是「写法重复」 - -前两批处理的形态是**同一个事实两处取值不同**(数字对不上、词表匹配不上)。 -本批是它的另一半:**取值当时相同,但同一张映射表被抄了多份**。 -这类缺陷平时看不出来,代价有两个: -- **改一处漏一处**:产品说「良好」换个蓝,页面里躺着五份同值映射,改哪份全凭记性; -- **测试锁不住**:`jobLevelTextColors` 有测试,但页面不读它,测试锁的是**没人渲染的那份**。 - -本批**不改任何取值**(改前后逐值对照过),是纯重构。判据也从「两个数是否相等」 -换成「**这段定义是否只有一处**」,故新增的锁以**源码级接线锁**为主。 - -#### 2. 修法表:14 处本地声明 → 6 张单一来源的表 - -| # | 事实 | 原先几份 / 在哪 | 现在 | -|---|---|---|---| -| 1 | 评级 → 文字色 | **5 份**:growth 的 `LEVEL_TEXT_COLOR`、jobs 的 `LEVEL_COLOR`、ability 与 assessment 各一份同名 `LEVEL_TEXT_COLOR`、hr-evaluation 的中文键版 | `theme` 的 `jobLevelTextColors`(+ 派生的 `levelLabelTextColors`) | -| 2 | 霍兰德六型 → 强调色 | **3 份**:`holland.tsx` / `holland/question.tsx` / `holland-result.tsx` 各一份 `TYPE_COLORS` | `theme` 的 `hollandTypeColors` | -| 3 | 语义色名 → 色值 | **3 份**:`enterprise-overview` / `interview-score` / `interview` 各一份 `COLOR_TOKENS` | `theme` 的 `semanticColorTokens` | -| 4 | 文章缩略图 底色 / 图标色 | **2 种写法**:成长页一个 `{violet:{bg,color}}` 对象表;详情页拆成 `THUMBNAIL_BG` + `THUMBNAIL_COLOR` 两份平行 `Record` | `theme` 的 `articleThumbnailColors` | -| 5 | 社保基数 clamp 到城市上下限 | **3 份**:`api.ts:computeSalaryResult`(权威)、计算器页 `clampSocialBase`、计算器页 `handleSelectCity` 内联 | 接口层 `clampSocialBase(city, value)`,两处调用 | -| 6 | 时间戳 → 「月-日 时:分」 | **2 份**:`utils.formatDateTime`(收 ISO 串)与计算器页 `formatRecordTime`(收时间戳) | `formatDateTime(value: string \| number)` | -| 7 | 手机号脱敏 | **2 份**:`api.maskPhone` 与登录页**逐字节相同**的内联表达式 | 页面调用 `maskPhone` | -| 8 | 学历 / 工作类型选项 | 发布页自持两份:`EDUCATION_OPTIONS`(与 constants 逐字相同)、`WORK_TYPE_LABELS`(同一组中文标签的另一种形状) | 均取自 `src/constants` | - -放在哪个文件是**按职责**定的,不是就近塞: -- 颜色映射进 `src/theme/colors.ts` —— 该文件自己就写着「**色值是本文件独占的职责**」, - 且已有 `templateAccentColors` 这个先例; -- clamp 进 `src/api/api.ts` —— AGENTS.md 规定业务规则在接口层,而**权威的那一份本来就在那里**; -- 时间格式化进 `src/utils` —— 日期格式化是 utils 的既轻松聘责(该文件已有 `formatDateTime`); -- 选项词表进 `src/constants` —— 4.14 定的单一来源。 - -#### 3. 一处必须**派生**而不是并列复写:`levelLabelTextColors` - -hr-evaluation 那份的键是**中文评级**(`'优秀'`),不是 `JobLevel`。 -根因在数据层:`DimensionScore.level` 是 `JobLevel` 联合类型,而 `EvaluationDimension.level` -声明为 `string` 且**直接下发中文评级**。所以这**不是**「同一张表抄两遍」,而是**同一个事实、 -两个键域**。若只是把字面量搬个地方,等于把「两份同值」换成「两份同值、只是搬了家」。 - -故写成**按引用派生**: - -```ts -export const levelLabelTextColors: Record = { - 优秀: jobLevelTextColors.excellent, - 良好: jobLevelTextColors.good, - 中等: jobLevelTextColors.medium, -}; -``` - -将来「良好」换色只改 `jobLevelTextColors` 一处,这张表自动跟随 —— 两张表各自写着同一个色值 -才是分叉的起点。回归锁断言的是**关系**:「每一档与它同键的那档同值」, -而不是「优秀等于 #175AB0」。 - -#### 4. clamp 提取时的一个坑:`city` 必须变成显式参数 - -三段 clamp 看着一模一样,直接「简化成调用 helper」会**静默算错城市**: - -```ts -// 页面上那个 helper 读的是 inputs.city —— 也就是**旧城市** -const clampSocialBase = (v: number) => { /* 用 inputs.city 的上下限 */ }; -// 而选城市时它必须用**新城市**的上下限,于是页面只好把同一段 Math.min/Math.max 内联重写 -const handleSelectCity = (city: string) => { /* 用 city 的上下限,另写一遍 */ }; -``` - -把 `city` 提成显式参数后,两处调用都说得清「按哪个城市」: - -```ts -export function clampSocialBase(city: string, value: number): number { - const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE; - return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, value)); -} -``` - -这不是「顺手美化」,而是**重复的成因**:正因为 helper 拿不到目标城市,才被迫复制一份。 -回归锁用一个**前提校验**保证不空转 —— 先断言深圳上限 > 北京上限(否则这例证明不了什么), -再断言「在深圳拉到上限后切北京,结果必须落到北京上限」。 - -#### 5. 一条**未被统一**的配色分歧(需产品定夺,未擅自改) - -`app/(tabs)/profile.tsx` 的 `DIMENSION_LEVEL` 里,`good.text = colors.textSecondary`(灰); -而上面第 1 项那五处对「良好」一律用 `colors.primary`(蓝)。 - -**两处对同一个档位取不同的色,是一个真实存在的分歧**,但本批**没有动它**: -- 它键的是 `DimensionScore['level']`(只有两档),与岗位评级的三档表**不是同一张表**; -- 「哪个对」是产品判断(个人页的维度条要不要跟岗位标签同一个蓝),不是重构能决定的; -- 后果有限:两处不在同一屏,用户不会同屏看到矛盾(与 4.15 那些「同屏两个数」不同)。 - -若产品确认应统一,改法是让 `DIMENSION_LEVEL` 的 `good.text` 取 -`jobLevelTextColors.good`,一行即可。**在此之前不要擅自统一。** - -#### 6. 顺带查出的一条死值:`JobExperienceRequirement` 的「1年以内」 - -`'1年以内'` 全仓**只出现在类型声明里一次**:mock 岗位数据一次都没用到它, -岗位筛选的 `EXPERIENCE_OPTIONS`(constants)里也没有它 —— 即这个取值 -**既筛不到、也没有样本**,只有发布页能选出来。 - -本批**刻意不合并**两张经验表,因为它们**不是同一个事实**,名字像而已: - -| 表 | 用途 | 含「不限」? | 含「1年以内」? | -|---|---|---|---| -| `constants.EXPERIENCE_OPTIONS` | 岗位**筛选**项 | ✅(表示不加限制) | ❌ | -| `job-publish` 的本地 `EXPERIENCE_OPTIONS` | 企业发布岗位的**必填要求** | ❌(必须明确要求) | ✅(与联合类型逐项对应) | - -强行合并会让发布页多出一个不该有的「不限」选项。已加**反向锁**把这个差异写死, -防止后来者按名字想当然地合并。至于「1年以内」这个死值该留还是该删,属数据取舍,需产品定夺。 - -#### 7. 如实记录:一条被**证伪**的子代理结论 - -本轮开工前有一份子代理报告,列了若干「重复声明」。逐条核对的结果: - -| 报告的说法 | 核验结果 | -|---|---| -| `DIMENSION_LEVEL` 重复声明 | **不成立**。全仓只有 `profile.tsx` 一处 | -| `THUMBNAIL_*` 重复「×2」 | 描述不精确。不是两份同名表,是**一个对象表 + 两份平行 `Record`**(两种写法) | -| clamp 在 `:302-305` 与 `:294-296` | 行号不准。实际三份,第三份在 `api.ts` 里(报告没提,而那才是权威那份) | - -这与本文件一贯口径一致:**代理报告是模型输出,不是证据**。 -每一条都独立 `grep` + 逐行读过才动手,**没有一条是照报告直接改的**。 - -#### 8. 回归锁与注入验证 - -新增 `src/__tests__/shared-color-maps.test.ts`(**26 例**)。断言分两类: -- **关系**(「A 必须等于 B」):中文评级表与 `jobLevelTextColors` 同键同值、 - 六型 / 语义色 / 缩略图各色**互不相同**(撞色会让雷达图六顶点或四宫格看不出区分)、 - 计算器的养老金额可由 `clampSocialBase` 反推(证明两处走同一条规则)、 - `formatDateTime` 两种入参格式一致; -- **接线锁**(读源码):14 处旧声明名一律 `not.toMatch`,且页面必须 `toContain` 新表名 —— - 值对了但页面不读表,缺陷照旧。 - -**注入验证(本批 6 处,一次性注入、6 例如期全红、逐字还原)**: - -| 注入 | 期望变红的锁 | 结果 | -|---|---|---| -| `ability.tsx` 加回 `const LEVEL_TEXT_COLOR` | 「四个页面读 theme」 | ✅ 红 | -| `levelLabelTextColors['良好']` 改回字面量 | 「中文评级表由 jobLevelTextColors 派生」 | ✅ 红 | -| `api.ts` 把 `input.city` 换成 `'北京'` | 「计算器与页面走同一条 clamp 规则」 | ✅ 红 | -| 计算器页内联 `Math.min(cityProfile.socialBaseUpper, …)` | 「计算器页面不再自己 clamp」 | ✅ 红 | -| 登录页恢复内联脱敏 | 「登录页调用 maskPhone」 | ✅ 红 | -| 发布页加 `label: '全职'` 字面量 | 「发布页取自 constants」 | ✅ 红 | - -**一处如实记录**:本文件里那条 clamp 的接线锁**第一版是写错的** —— 断言写成 -「页面不得出现 `socialBaseUpper`」,而页面**本来就该**读它(用于展示 -「基数范围 ¥6,821 – ¥33,891」这行提示)。断言本身错了,不是代码错了; -已改为精确锁「上下限字段与 `Math.min/Math.max` **同现**」(那才是旧版内联 clamp 的形状)。 - -#### 9. 真机实测(Android 模拟器 emulator-5554,截图核对,非代码走查) - -本批**取值未变**,故真机的价值不在「颜色对不对」,而在**没崩、没落到兜底**: - -| 页面 | 核对内容 | 结果 | -|---|---|---| -| 成长中心 | 能力等级色 + **三个缩略图色系全部解析**(紫 / 橙 / 绿各一组) | ✅ 三者都渲染出各自的色,无 `undefined` | -| HR 评价详情 | 中文评级表:六维里「优秀」「良好」都有色 | ✅ 无一落到兜底灰 | -| 企业端总览 | 语义色名表:统计四宫格与快捷入口 | ✅ **五个色各不相同**(蓝/紫/绿/红/橙)—— 若有键写错会静默全变蓝,此处多色并存即证明未落兜底 | -| 发布岗位 | 学历 5 项(含「不限」,「本科」选中)+ 经验 6 项(含「1年以内」,无「不限」)+ 工作类型 4 项(「全职」选中) | ✅ 与文档所述一致,值↔标签配对未被打乱 | -| 薪资格算器 | 切城市:北京 → 深圳,提示更新为「¥4,492 – ¥43,659(深圳)」,基数 25000 在区间内保持不变 | ✅ `handleSelectCity` 生效 | - -> **没验到的(按一贯口径记为「未做真机验证」,不当成已验证)**: -> ① **clamp 本身未在真机上制造出越界值** —— 社保基数输入框是**逐键 clamp** 的 -> (`onChange` 每次都过 clamp),手工输入根本无法停留在目标值上,属既有交互、非本批改动; -> 故 clamp 的语义**以单测 + 注入为准**(注入 `'北京'` 那例即为该语义的灵敏性证明)。 -> ② 岗位列表 / 能力 / 测评三页的**评级色**,霍兰德三页的**六型色**, -> 面试评分 / AI 面试两页的**语义色**,以及登录页的**掩码手机号**与**薪资格算器历史记录的时间格式** —— -> 均有类型约束(键是字面量联合,写错即编译不过)或测试锁盖住,但**本轮未逐屏截图**。 - -#### 10. 本批次门槛复跑(2026-09-20 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` -**26 个文件 365 例全绿**(较 4.15 的 339 例 +26) · `check-routes` **0 失效跳转** · -`check-affordances` **0 处** · `check:icons` **0 处无效图标名**(194 文件 / 1357 字形) · -`check-backend-stubs` **140 个接口函数**。六处注入物已 `grep` 确认为 **0 残留**。 - -#### 11. 本批次仍未做 - -- 第 5 条的「良好」配色分歧(需产品定夺)。 -- 第 6 条的「1年以内」死值该留该删(需产品定夺)。 -- **数据层 `color: string` 未收紧成联合类型**:`semanticColorTokens` 的键因此只能保持 - `Record`,代价是数据层写错色名会**静默**落到调用方的 `?? colors.primary`。 - 根治要把 6 个类型字段(`EnterpriseStat.color` 等)收紧成联合类型并同步全部 mock 数据, - 属另一批。**本批已在代码注释与测试里把「这是刻意的」写死**,防止后来者顺手收紧又放开。 -- 4.15 第 10 条列的各项(`companies` 全量 vs 抽样、`formatSalaryRange` 未下沉、 - `chats: 8` 与测评勋章仍是字面量、`CITIES` / `EXPECTED_CITY_OPTIONS` 两份词表有意保留)**状态不变**。 -- 早前批次提到的 `WORK_TYPE_OPTIONS` 标签重复**已在本批修掉**(见第 2 表第 8 项)。 - ---- - -### 4.17 全站卡片圆角统一 8px + 去卡片阴影(2026-09-20 批次,接 4.16) - -#### 1. 起点:一条新增的硬规则,来自用户而非本轮排查 - -`AGENTS.md` 于 2026-09-20 14:12 新增一行(用户改动,本轮首次实现): - -> 整个项目所有卡片的圆角都必须是8px,无阴影 - -同一次编辑还把提交规则从「每天 17:50 提交」改成「**提交并推送到远端**」。两处都写进本批。 - -**为什么不能靠 `grep 'borderRadius'` 一把梭**:项目从来没有「卡片」的样式键约定 —— -同一屏里 `card` / `box` / `panel` / `section` / `wrapper` 混用,`cardBtn`(胶囊按钮)、 -`cardTag`(标签)、`cardIcon`(圆形图标底)也共用 `card` 前缀。**先立判定口径,再动手**, -否则「统一 8px」会连胶囊按钮和圆形头像一起压成方角,这是本批最大的一次性风险。 - -#### 2. 判定口径(本轮定义,后续沿用) - -| 判为「卡片」 | 判为「非卡片」 | -|---|---| -| 样式键为 `card` 或以 Card 结尾/内嵌(`scoreCard`、`emptyCard`、`preambleCard`) | 同名前缀的子件:`CardBtn`/`CardBullet`/`CardDot`/`CardBadge`/`CardIcon`/`CardWrap`/`CardTag`/`CardChip` | -| 逐条读过块体、确认是「独立承载一组内容的面板」的 17 个非 card 命名样式(`emptyBox`/`emptyList`/`emptyHint`/`searchEmpty`/`pathSummary`/`section`/`cardBase`/`cardOffer`/`salaryBox`/`draftBanner`/`matchBox`/`aiTip`) | 按钮、标签、chip、输入框、Tab、列表行(`row`/`*Item`)、头像与圆点 | - -**圆 / 胶囊排除规则**:`半径 × 2 ≥ min(字面量宽, 字面量高)` 时判为圆或胶囊,**不动**。 -命中并被正确跳过的实例:`cardBtn` 48 高 / 半径 24、`radioOuter` 18×18 / 半径 9、 -`countdownCard` 60×60 / 半径 30、Holland 结果的类型序号圆标。 - -#### 3. 改动规模(`git diff` 实测,非估计) - -| 项 | 数量 | -|---|---| -| 圆角半径改为 8 | **147 处**(分布于 50 个 `.tsx`/`.ts`) | -| 删除的阴影行(`shadowColor`/`shadowOffset`/`shadowOpacity`/`shadowRadius`/`elevation`) | **54 行**,7 个文件 | -| 其中:删除模块级 `const shadow = {…}` 常量 + 其 `...shadow` 展开点 | `app/jobs/[id].tsx` 共 7 行 | -| 卡片类样式总数 / 其中仍非 8px | **167 / 0** | - -改动前 `167` 个卡片类样式里,`130` 个由脚本改、`37` 个本来已是 8; -另有 `17` 个走人工复核的面板命名,`130 + 17 = 147` 与 `git diff` 行数**逐一对上**。 - -#### 4. 两个审计脚本自身的缺陷(差点改错别人的圆角,如实记录) - -审计脚本(临时工具,不入库)第一版把**单行样式块**当成普通块解析: - -```ts -modalCard: { backgroundColor: colors.white, borderRadius: 16, padding: 20 }, -``` - -这类样式在本行内就闭合了,脚本却继续往下读,**把下一个样式块的值算成了它的值**。 -后果不是漏改,是**可能取到邻居的半径然后改到邻居头上**。发现的路径是抽样复核时看到 -`countdownCard` 被报成「半径 30 / 60×60 的圆」(它实际是单行 `borderRadius: 16`, -那个 60×60 是隔壁 `countdownItem` 的),以及 `P3Identity` 的 `card` 被误判成 -「100%×48 的胶囊」而整块漏掉 —— 修掉解析后它才被正确识别并改成 8。 - -第二个缺陷更隐蔽:`shadowOffset: { width: 0, height: 2 }` 里的 `width`/`height` -被当成**元素尺寸**,于是所有带阴影的卡片都满足「半径×2 ≥ 尺寸」而被判为胶囊、 -**整类静默跳过** —— 而带阴影的恰恰是最该改的卡片。修法是取尺寸前先剔掉 `shadowOffset` -与全部 `shadow*` 行。修复后重新审计,`P3Identity` 等一批真卡片才浮出来。 - -两个缺陷都属于「**报错方式为静默漏判**」而非崩溃,所以本批不采信任何一次性的脚本输出, -而是用 `verify-radius.js` 对 `git diff -U0` 的**每一行新增半径**反查它落在哪个样式块内 -(结论:`147` 行中 `130` 行在 card 命名样式内、`17` 行在人工复核的面板白名单内、**0 行在名单外**)。 - -#### 5. 一次被拒绝的批量回滚,以及随之改走的「前向验证」 - -第一版脚本在 `app/onboarding.tsx:580` 崩溃(单行块无独立 `borderRadius` 行), -此时已写入 79 处改动。我尝试用 `git diff --name-only | grep -v AGENTS.md | xargs git checkout --` -整体回滚重来,**被权限策略拒绝**(判定为对未提交工作的不可逆本地销毁)。 - -我没有绕过它,而是改走**前向验证**:不回滚,直接证明这 79 处(连后续的 68 处)全部合法。 -事后看这个方向更对 —— 那次回滚还会连带**抹掉用户自己刚写的 `AGENTS.md` 编辑**, -而当时我并不知道那份编辑的存在(它不在我这条命令的视野里)。**教训:把「回到干净状态」 -当作修复手段之前,先确认干净状态里没有别人的东西。** - -#### 6. 刻意不动的两处(范围边界,不是遗漏) - -- `components/onboarding/ui/SliderCaptcha.tsx:294-297` 的滑块阴影**保留**: - 它是**控件**不是卡片,去阴影会让滑块与轨道在视觉上糊成一体。全库 `elevation:` / - `shadowOpacity:` 现仅此一处(已 `grep` 全库确认)。 -- `src/theme/colors.ts` 的 `shadow: '#000000'` token **保留**:`colors.test.ts:11` 仍在断言它。 - 代价是「卡片不再用阴影」这条约定**在 token 层看不出来**,属已知的文档-代码缝隙,未加注释(避免 - 为一个零引用的 token 增加维护面)。 - -#### 7. 覆盖边界:116 条「像卡片但不叫 card」的样式**未动** - -以「白/浅底 + 内边距或描边 + 圆角 ≠ 8 + 非圆非胶囊」反查,全库仍有 **116 条**这样的样式 -(口径见审计脚本注释)。逐类看过:**几乎全是按钮、标签、chip、输入框、Tab 项、列表行、头像底** -—— 它们不是卡片,压成 8 反而是错的。**这不是「差不多改完了」的余数,是本轮口径的边界**; -若将来真要把某一类纳入,需要单独一次「这一类算不算卡片」的判定,不能顺手改。 - -#### 8. 真机实测(Android 模拟器 `emulator-5554`,截图核对,非代码走查) - -| 页面 | 看什么 | 结果 | -|---|---|---| -| `jobs` 岗位列表 | 卡片原本带阴影(`searchBox` 5 行) | 已平;8px 直角;仍能与 `pageBg #F6F8F9` 区分 | -| `jobs/1` 岗位详情 | 3 张卡片 + 模块级 `shadow` 常量已删 | 已平;层级靠白底与描边区分,未塌成一片 | -| `(tabs)/profile` 求职端 | `statsCard`/`enterpriseCard` | 8px;语义色(蓝/橙/绿)未受影响 | -| `(enterprise)/enterprise-overview` | 4 张卡片共删 15 行阴影(本批最多) | 已平;数据块仍可辨认 | -| `job-publish` | `card` 删 5 行阴影 | 已平;表单分区仍清晰 | -| `tools/skill-cloud` | 4 张卡由 14 → 8 | 8px;**注:行业 Tab 行存在既有的纵向裁切/重叠,与本批无关,未追** | -| `holland-result` | 渐变横幅 + 3 张类型卡 | 8px;内部序号圆标与标签胶囊**未误伤** | -| `resume/export` | 预览卡 / 格式卡 / 模板卡 | 8px;模板缩略图内框圆角独立,未被压平 | - -**未实测**:`components/resume/EditKit.tsx`、`components/onboarding/ui/Modal.tsx`、 -`components/onboarding/screens/P3Identity.tsx`、`LegalDocScreen` 的圆角改动只有代码级验证 -(它们嵌在弹层/引导流/长文档里,深链直达需先走完前置流程),**未截图**。 - -#### 9. 本批次门槛复跑(2026-09-20 实测) - -`tsc --noEmit` **0 错误** · `eslint .` **0 error / 0 warning** · `npm test` -**26 个文件 365 例全绿** · `check-routes` **0 失效跳转** · `check-affordances` **0 处** · -`check:icons` **0 处无效图标名**(194 文件 / 1357 字形) · `check-backend-stubs` **140 个接口函数**。 - -#### 10. 本批次仍未做 - -- 第 7 条的 **116 条同名前缀样式**是否要纳入「卡片」口径(需产品/设计定夺,不擅自扩大)。 -- 4.16 第 5/6 条的两项产品定夺(「良好」取色分歧、`'1年以内'` 死值)**状态不变**。 -- 4.15/4.16 列的其余开放项**状态不变**(`companies` 全量 vs 抽样、`formatSalaryRange` 未下沉、 - `chats: 8` 与测评勋章字面量、`CITIES` / `EXPECTED_CITY_OPTIONS` 两份词表有意保留、 - 数据层 `color: string` 未收紧)。 -- **4.16 第七节列出的 A–E 五条重复声明发现**(skill-cloud 行业 Tab 6 选 3 有数据、 - `GROUP_LABEL` ≡ `SKILL_GROUP_LABEL`、发岗表单选项表未上锁、「全部行业」同音异义、 - jobs 页三个筛选 chip 落在假交互脚本盲区)**本批未实现**,已分析完,留待下一批。 - ---- - -### 4.18 边距间隔统一 8px:页面留白 / 卡片内边距 / 卡片间隔(2026-09-20 批次,接 4.17) - -#### 1. 目标值在本批次中途被改过一次(16 → 8),这是本节的起点 - -`AGENTS.md` 第 15 行原为「…无阴影,**边距间隔一致**」,未给数值。三个维度上 16 都是众数 -(卡片内边距 66/169、卡片间隔 30/92、页面留白 27/71),**「取众数 16」是一个站得住但错的猜测**。 -我按 16 落了 176 处之后,用户把该行改成「…边距间隔一致,**为8px**。」——目标值变成 8。 - -**不能靠回滚实现**:回滚只能把「被我改过的值」还原成原值,而原来就是 16 的那 66 张卡 -同样必须降到 8,还原不回 8。故改为**按目标值重算**,并把全部原始值落盘(脚本的中间产物 -`spacing8-edits.json`,**不入库**,需要逆向核对时由本机临时文件提供)。事后看,这次踩坑的根因不是判断失误,而是 -**在规则未定量时就开工**——规则定量前本应先问,这一点记在这里。 - -#### 2. 判定口径(本节定义,后续沿用) - -用户确认「边距间隔一致,为8px」**包含页面左右留白**(即「卡片离屏幕边缘的距离」), -故三个维度统一为 8: - -| 维度 | 载体 | 现状 | -|---|---|---| -| 页面留白 | 主 ScrollView 的 `contentContainerStyle` 上的 `padding` / `paddingHorizontal`,或靠卡片自身 `marginHorizontal` 撑出的留白 | **全部 8** | -| 卡片内边距 | 卡片样式块上的 `padding` / `paddingHorizontal` / `paddingVertical` | **全部 8** | -| 卡片间隔 | 卡片的 `marginTop` / `marginBottom`,以及**分隔卡片的容器 `gap`** | **全部 8** | - -判定「是不是卡片」改为**结构判定**而非命名判定:`有 borderRadius` 且(`有 backgroundColor` -或 `有 borderWidth`)。4.17 用的是命名判定(`card` / `*Card`),**有漏网**(见第 3 条)。 - -#### 3. 命名判定漏掉了什么(本批补齐) - -`app/(enterprise)/enterprise-chat.tsx` 的 `pendingItem`(radius 10)、`app/holland.tsx` 的 -`dimItem`(radius 12)、`app/resume/{education,experience,project}.tsx` 的 `row`(radius 14)、 -`app/interview-score.tsx` 与 `app/candidate-detail.tsx` 的 `suggestionRow`(radius 12)、 -`app/appeal.tsx` 的 `reasonRow`(radius 10)、`app/game/level.tsx` 的 `explanation`(radius 6)、 -`app/holland/question.tsx` 与 `app/game/level.tsx` 的 `option`(radius 12/6)、 -`app/tools/salary-calculator.tsx` 的 `taxItem`(radius 10)——**这些名字里都没有 "Card", -但结构上就是卡片**,4.17 整批漏过。本批一并归 8。 - -另有一批 **radius 本就是 8、但内边距/外边距不是 8** 的卡同样漏过: -`matchBanner`/`offerBanner`(`(tabs)/chat.tsx`,padding 16)、`salaryBox`(`candidate-detail.tsx` 14、 -`interview-score.tsx` 16)、`cardBase`/`cardOffer`/`banner`(`chat/[id].tsx` 12/12/10)、 -`replyBlock`(`job-evaluations.tsx` 10)、`rank.section`(16)、`memberBanner`(`legal.tsx` 与 -`legal/upgrade.tsx` 各一处)、`draftBanner`(`interview-score.tsx`)、`pathSummary`(`skill-cloud.tsx` 14)、 -`aiTip`(`resume.tsx`)、`qualifications` 的 `imagePlaceholder` / `licenseImage` / -`checkout.channelRow` / `levelDesc` / `matchBox`。 - -#### 4. 改动规模(`git diff --numstat` 实测,非估计) - -`73 个 .tsx 文件 + AGENTS.md`,`+390 / -398`。按属性计新增行中的 `= 8`: -`padding` 209、`marginBottom` 74、`paddingHorizontal` 37、`marginTop` 18、`paddingVertical` 17、 -`borderRadius` 15、`gap` 8、`marginHorizontal` 5、`margin` 1;另有 1 处属性行被删除 -(`offer-compare` 的 `offerList.paddingHorizontal: 4` —— 外层 `content` 已是 8,内层再加 4 -会让首张 Offer 卡离屏 12,归零后与全站一致)。 - -#### 5. 明确排除的类别(附理由,不是遗漏) - -| 排除项 | 理由 | -|---|---| -| 表单控件 `input` / `textarea` / `inputBox` / `inputWrap` / `uploadBox` / `checkbox` / `knob` / `sliderFill` | 圆角与内边距随控件形态走,不是卡片 | -| 标签 / 胶囊 / 选项卡 `chip` / `tag` / `tab` / `categoryTab` / `filterItem` / `emptyTabItem` | 半径由胶囊高度决定(半高即全圆),压到 8 会变成方角 | -| 按钮 `btn` / `modalSubmit` / `saveCancel` / `saveConfirm` / `sheetCancel` / `modalConfirm` | 同上 | -| 图标底 / 装饰 `iconWrap` / `logoBox` / `progress` / `counter` / `sectionBar` / `barTrack` / `barFill` | 纯装饰 | -| 错误重试态的 `center` 容器(`flex:1` + `paddingHorizontal:40` + `retryBtn`) | 它长得像卡片列表容器,实为居中错误态。全仓同名同形,**误改会把重试页的居中打散** | -| `components/onboarding/**` | 引导页自成一套圆角体系(12/14/18),见第 6 条 | - -#### 6. 刻意不动的三处(范围边界) - -- **`app/resume/preview.tsx` 的 `paper.padding: 20`**:它是导出文档的**屏幕代理版心**, - 对应 `src/utils/resumeHtml.ts:208` 的 `@page` 页边距 `34px 38px`。压到 8 会让「预览」 - 和它存在的唯一目的(预览导出效果)脱钩。**保留**。 -- **`components/onboarding/ui/SliderCaptcha.tsx` 的 `knob` 阴影**:滑块手柄是控件不是卡片, - 4.17 起就作为对照项保留,状态不变。 -- **`components/onboarding/ui/ScreenShell.tsx` 的 `scrollContent.padding: 20`(+ `P1Login` 的 24)**: - 这是 **26 个引导页**共用的页壳,20 → 8 等于重排整个首启流程。**未改**,列为待定夺(见第 10 条)。 - -#### 7. 两个脚本自身的缺陷(各浪费了一轮,如实记录) - -- **heredoc 吞掉一层反斜杠**:`<('P1')`),没有 URL 参数, -deep-link 只能到 P1。**临时**把初始步改成目标页截图,**看完已还原**,并 `git diff` 确认该文件无残留改动。 -- 用像素测量代替肉眼(截图缩放后目视判读不可靠):`/tmp/mdj/px.js` 解码 PNG 后找某行第一个非背景像素, - 密度 420dpi → 比例 **2.625**(不是 2.75,第一版算错过)。 -- 实测:P1 按钮 8dp · P9 首张 RadioRow 8dp / 底部按钮 8dp · P17 卡片 8dp / 按钮 8dp · - B1 卡片居中(`center` 的 `alignItems: 'center'`,设计如此)/ 按钮 8dp · P12 提示卡 8dp / 按钮 8dp。 -- **头部返回箭头:改动前后同一行 52px → 73px,差 21px = 正好 8dp** —— 一次同时证明了 - 「热重载生效」与「还原生效」。 -- 截图里的灰色齿轮圆是 **Expo 开发菜单浮钮**,不是应用 UI。P12 搜索框里的 `111` 是设备本地 - 留存的引导数据(`profile.school` 同类),不是本批改动。 - -**7. 门槛**:`npx tsc --noEmit` 0 · `npx eslint .` 0 · `npm test` 26 套件 / 365 用例全过 · -`check-routes` · `check:affordances` · `check:icons`(194 文件 / 1357 字形)· `check-backend-stubs`(140 函数)全绿。 -改动量 **11 文件(含新增 `scripts/check-spacing.mjs`)+ 状态文档**。 - -**8. 把这份核对**落成门槛**,不再是「跑完就没了的一次性脚本** -- 新增 `scripts/check-spacing.mjs` + `npm run check:spacing`(`--error` 有命中即 exit 1), - 与 `check-routes` / `check-affordances` / `check-icons` / `check-backend-stubs` 同族同体例。 -- **动机**:4.17 / 4.18 两批都靠「临时写个脚本、跑完删掉」,于是这次的教训下一批就得重学一遍。 - 规则要能被强制,否则「改一次值动 73 个文件」会一直复发。 -- 脚本里把判据**写成了注释与代码**:结构判定而非命名判定、按词尾而非全名排除、圆用几何判据、 - 排除项逐条打印。**给排除表加名字要写理由**——否则排除表本身会变成新的黑箱。 -- **可失败性已在仓库脚本上复验**:往 `src/` 塞一个含 4 类违规(r=12 的卡 / padding 12 的卡 / - marginTop 12 的卡 / 留白 16 的页)的探针文件 → 四维全命中、**exit 1**;删掉探针 → **exit 0**。 -- **终值**:卡片 196(含引导页),圆角 = 8:**196 / 196**,内边距 ≠ 8:0,卡片间隔 ≠ 8:0, - 页面留白 ≠ 8:0;另剔除 359 项(圆 / 控件)并逐项列出。 - -**9. 仍未做 / 待定夺** -- **主应用底部按钮 16 vs 卡片 8**:14 个带 footer 的主应用页面是 `padding: 16`,同屏卡片是 8, - 两者左右各差 8。这是**主应用既有的状态**,且底部按钮不属于规则里的「卡片」;要不要一起统一到 8, - **需要产品/设计一句话**(改的是 14 个主应用页面,比引导页那批影响更大,故不擅动)。 -- **非卡片元素之间的间距仍不一致**:同为图标底色块,`P10FirstWork` / `P13JoinDate` 是 `marginBottom: 12`, - `P8Resume1` / `P9JobStatus` / `P18Education` 是 18。按「规则主语是所有卡片」的读法,这些不在范围内; - 若要按字面「边距间隔一致」执行,则属另一批。 -- **仍未建立设计令牌层**(4.18 第 9 条):8px 与各间距值仍是**裸字面量**,没有单一来源。 - 本批补上了**强制**(`npm run check:spacing`,见第 8 条)但没有补**来源** —— - 现在「值跑偏了会被拦住」,但「改一次值仍要动 N 个文件」。根因只解决了一半,建议下一批做 - (`src/theme` 目前只有 `colors.ts`;可参照 `colors.ts` 的 `semanticColorTokens` 做法建 spacing 令牌)。 - ---- - -### 4.20 改名「轻松聘」 + 引导页改三屏满屏主题色(2026-09-20 批次,接 4.19) - -**起因**:产品确定软件名从「有职」改为「轻松聘」;引导页从四屏改为三屏,标题由产品给定, -整页背景用页面主题色,底部一个「开启体验」按钮。 - -**1. 改名范围(`grep -o | wc -l` 实测,不是估计)** - -| 面 | 文件数 | 出现次数 | -|---|---|---| -| 代码 + 配置里的显示名「有职」→「轻松聘」 | 13 | 50 | -| 标识符 `youzhi` → `qingsongpin` | 17 | 17 | -| 文档(`项目开发状态.md` / 两份接入方案 / `screenshots/`) | 5 | 有职 12 / youzhi 19 | -| **合计** | **27 个文件**(含重叠) | 有职 62 / youzhi 36 | - -改法是**带断言的白名单脚本**(`/tmp/mdj/rename.js`):每个文件先写死「改之前应该出现几次」, -对不上就抛错、**一个文件都不写**。跑第一步就有用 —— `项目开发状态.md` 我数的是 18 处, -脚本报实际 19 处(有 2 处挤在同一行),拦住了一次目测不准的替换。 - -**2. 三处**不是纯文本、是**行为**变化,单独列出** -- **`app.config.js` 的 `slug` / `scheme` / `ios.bundleIdentifier` / `android.package`** - `youzhi` → `qingsongpin`(`com.youzhi.app` → `com.qingsongpin.app`)。 - **现在改无代价**(从未上架、`owner` / `projectId` 还是占位符);**一旦上架就改不动了**。 -- **`src/lib/storage.ts` 的 `KEY_PREFIX`:`'youzhi:'` → `'qingsongpin:'`**。 - 前缀变了 = 本机**已存的登录态 / 简历覆盖层 / 引导采集答案 / 草稿全部读不到**(不是清空,是再也找不到)。 - 本机只有开发数据、无真实用户,可接受;**但若线上已有用户数据,这一步必须写迁移,不能直接换前缀**。 - 副作用正好可用:改完前缀本机回到「首次启动」,`/` 直接落到引导页 —— 顺手完成了端到端验证(见第 5 条)。 -- **`screenshots/capture-manifest.json` 的 `scheme` 必须与 `app.config.js` 同步**,否则截图流水线的深链失效。已同步。 -- (另有 `app/(tabs)/chat.tsx` 高德 URL 的 `src=youzhi` → `src=qingsongpin`,仅来源标识,无功能影响。) - -**3. 引导页三屏重做(`app/guide.tsx`)** - -| 屏 | 标题(产品给定) | 副标题(我写的) | -|---|---|---| -| 1 | 求职开挂 AI护驾 | AI 简历优化、AI 模拟面试、AI 合同咨询,从投递到签约都有人接话。 | -| 2 | 真实能力 一测便知 | 霍兰德兴趣测评 + 能力自评,先看清自己的底牌,再决定投什么岗。 | -| 3 | 一起解锁 职场秘籍 | 成长页的职场文章与闯关任务,把该准备的提前准备好。 | - -- **副标题只写仓库里查得到的已上线模块**(`'AI简历优化'` / `'AI面试官真实场景演练'` / 法务页 - `'免费的 AI 咨询与合同模板'` / `app/holland` / `app/(tabs)/growth.tsx` 的 article 与 `app/game`), - 没有编造未上线能力 —— 现在写进去的每一句都能在代码里指到出处。 -- **底色没有复用现成的 `headerGradient*` 三支**,新增 token `onboardingGradient` - = `[primary, primaryBtn, primaryDark]`(#2C83FD → #1F6FDB → #175AB0)。理由写进了 `colors.ts`: - 现成三支末端是青色 `#4FD1C5`,白字压上去只有约 **1.9:1**,它们能用是因为白字只压在**顶部**深色区; - 引导页是**整页**底色、正文在中下部,整条渐变必须始终深于白字阈值。配 `locations={[0, 0.34, 1]}` - 把 4.8:1 那一档提前到 34% 处,正文实际落在 5.2:1 以上。 -- **按钮反过来做**:白底 + `primaryText`(#104686 白底 9.4:1 AAA)。没有用品牌蓝底白字 —— - `primary` 白字只有 3.6:1,`primaryBtn` 白字 4.8:1 也只为大字准备。 -- **状态栏加了 ``**:根布局是 `style="dark"`,整页深蓝底会让图标变成黑压黑。 - 旧引导页是白底所以没暴露;**任何深色头部页面都会中招**,属既有缺陷,本页先行修掉。 -- **按钮文案是我的判断,不是用户明说的**:用户只说「页面底部写上开启体验按钮」。 - 三屏都用「开启体验」的话后两屏等于摆设,故按**前两屏「下一步」、第三屏「开启体验」**处理; - 若要求三屏一致,改 `isLast` 那一个三元表达式即可。 -- 配图仍是三个素材位(`guideAiCopilot` / `guideAbilityTest` / `guideGrowthUnlock`), - `hint` 直接画在页面上(ImageSlot 的「配图待补 + 该放什么图」)。 - -**4. 需要补的图片(我生成不了,按「没有图片请标明」逐项列出)** -改名后**所有带字标的位图都过期了**,已逐张打开确认,不是推测: - -| 文件 | 现状(已打开看过) | 要换成 | -|---|---|---| -| `assets/icon.png` | 1024×1024,蓝底白字**「有职」**+上箭头 | 「轻松聘」同规格 1024×1024 应用图标 | -| `assets/brand-logo.jpg` | 同上字标,登录页 `BrandHeader` 与 `ASSET_REGISTRY.logo` 在用 | 「轻松聘」字标 | -| `assets/splash-icon.png` | 启动屏,冷启动第一眼 | 「轻松聘」字标(同规格) | -| `assets/android-icon-foreground.png` / `-background.png` / `-monochrome.png` | 自适应图标三分层 | 由新图标重新导出 | -| 引导页三张插画 | 仍是占位框 | 见 `components/onboarding/assets.ts` 里三条 `hint` | - -(注:`项目开发状态.md` 第五章 P0 里「应用图标已完成」那条指的是 `878710c` 做的**「有职」**图标, -本批改名后该结论已过期,待新图到位再更新。) - -**5. 验证(模拟器 `emulator-5554`,像素测量,非目测)** -- 端到端:`exp://127.0.0.1:8081/--/` 进入 → 因 `KEY_PREFIX` 改名本机回到首次启动 → **自动落到引导页** ✓ -- 底色渐变实测(`x=540`):y=70 `#2c82fc`(= primary)· y=1300 `#1c68cd` · y=2300 `#175ab1`(= primaryDark), - 与 `locations=[0,0.34,1]` 的理论值逐点吻合。 -- CTA 按钮:左边界 **x=42px = 16.0 dp**、距右 **16.0 dp**、纵向 **y=2132~2263 = 50.0 dp**, - 文字色实测 **#104686**(= `primaryText`)✓ -- 滑到第 3 屏:标题「一起解锁 职场秘籍」、指示点第 3 个点亮、按钮变「开启体验」✓ -- 门槛:`tsc` 0 · `eslint` 0 · `npm test` 26 套件 / 365 用例全过 · `check:spacing` 卡片 196 全 8px · - `check:routes` · `check:affordances` · `check:icons` · `check:backend-stubs` 全绿。 -- **上下各 24dp 的黑/白条是系统栏**(状态栏黑、手势导航栏白),在 Expo Go 里由 Expo Go 自己的主题决定, - 不是本页画的;桌面上截同一位置可看到系统栏是透明的,可佐证。非本批引入。 - -**6. 仍未做 / 待定夺** -- **改名只改了「能改的」**:字标位图(第 4 条)必须由设计出图;两份接入方案里 - 「微信开放平台 / 运营商那边登记的包名」若已提交过,也需要同步改(文档已改,**外部登记状态我无从核实**)。 -- 引导页三屏按钮文案是否统一为「开启体验」,见第 3 条末 —— 一行改动,等一句话。 -- 4.19 第 9 条的三项(主应用 footer 16 vs 卡片 8、非卡片间距、设计令牌层)**均未动**。 - -### 4.21 间距设计令牌层 + onboarding 非卡片间距收敛(2026-09-21 批次,接 4.20 第 6 条末) - -**起因**:4.18/4.19/4.20 把卡片圆角 / 内边距 / 间隔 / 留白统一到了 8px,但**间距仍全是裸字面量**,两个遗留问题: -① 改一次间距值要动 N 个文件;② 非卡片间距(图标底块、段标题上下、`tip` 提示距离)在多个引导页取 12/14/18/22,**同角色不同值,无单一来源**; -③ 更重要的是——`check-spacing.mjs` 是 `exit 1` 硬门槛,一旦把卡片改成 `spacing.card` 这类引用,原脚本读不到数字会**静默跳过该卡片**,审计网被撕开。 - -**1. 新增间距单一来源 `src/theme/spacing.ts`**(形式对齐 `colors.ts`) -- 标尺:`xs:4 / sm:8 / md:12 / lg:16 / xl:24`。 -- 语义令牌(挂在同一个 `spacing` 对象上,页面只 `import { spacing }` 即可):`card:8 / gutter:8 / framework:16 / block:12 / section:12`。 -- 从 `src/theme/index.ts` 导出。 - -**2. 扩展 `scripts/check-spacing.mjs`,让令牌化不失效**(核心防回归) -- 顶部加 `SPACING_TOKENS` 镜像映射(必须与 `spacing.ts` 取值同步,文件头已写警告)。 -- 新增 `resolveSpacing(text, prop)`:`spacing.x` / `spacingTokens.x` 都能解析成数值;未知令牌名按 null(至多误判,绝不静默通过)。 -- 卡片判定(原 `readNumber(...) === null → continue`)与 `isCircle` 改用 `resolveSpacing`——**修掉一个潜在 bug**:原逻辑对令牌化的 `borderRadius` 会读成 null 直接跳过卡片。 -- 输出新增「令牌引用命中 N 处」一行,证明令牌化后审计仍在工作。 -- **自检证明门禁存活**:注入临时文件,令牌化的 `borderRadius: spacing.lg(16)` / `padding: spacing.framework(16)` / 留白 `padding: spacing.md(12)` 三处均被抓出并 `exit 1`;合法令牌化卡片(`spacing.card`=8)通过。临时文件已删除。 - -**3. onboarding 非卡片间距令牌化 + 收敛(35 个文件)** -- 仅改写间距属性(`margin*` / `gap` / `paddingVertical` 等,**绝不碰 `fontSize`**),按值映射: - `18 → block(12)`、`22 → section(12)`、`14 → block(12)`、`10 → sm(8)`、`原 12 → block`。 -- 每个文件补 `spacing` 导入(导入路径已确认统一为 `../../../src/theme`)。 -- 收敛效果:段标题上下、提示距离、图标底块间距统一到 12,原先 14/18/22 的漂移被收口。 - -**4. B5(app/ 框架 `padding:16` → `spacing.framework`)——已于 2026-09-21 补做完成** -- 25 个 app 文件的 `padding:16` / `paddingHorizontal:16` / `paddingVertical:16` 全部收敛为 `spacing.framework`(值不变,仍为 16,走单一来源)。 -- 每个文件补 `spacing` 到 theme 导入(路径分三类:`../../src/theme` 42 处、`../src/theme` 26 处、`../../../src/theme` 3 处,脚本按实际路径补,无双花括号 bug)。 -- 框架 16px 有意比卡片宽(页面骨架),不被 `check-spacing` 审计;令牌化后审计仍能解析(resolveSpacing),不撕裂门禁网。 - -**5. 验证(全绿)** -`tsc` 0 · `eslint` 0 · `npm test` 26 套件 / 365 用例 · `check:spacing` 卡片全 8px · `check:routes` · `check:affordances` · `check:icons` 全绿。 - -**6. 残留 / 待跟进** -- ① **卡片未批量迁移到 `spacing.card`**:~~保留字面量 `8`~~ **已于 2026-09-21 收尾完成(见 4.23)—— 全仓卡片 `borderRadius / padding(含 Horizontal/Vertical): 8` 收敛为 `spacing.card`,审计令牌引用命中 954 处且仍 0 违规,审计网未撕裂**。 -- ② B5 已完成(见第 4 条,2026-09-21 补做)。 -- ③ `AGENTS.md` 第 10 行仍写「边距间隔一致,为 8px」,现非卡片块间距为 12,措辞略偏——非阻断,待文档对齐(可改成「卡片间距 8px / 块间距 12px」)。 - ---- - -## 五、后续增量开发建议(优先级) - -- **P0(上架前必做)**:~~图标替换~~ **应用图标【`878710c` 做的 1024×1024 是「有职」字标,2026-09-20 改名后已过期,须重出图,见 4.20 第 4 条】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.5】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。 -- **P0(补充 · 2026-09-15 新发现)**:① ~~页脚服务热线 / 举报监督电话待填~~ **已填 `15154608969`**,且 **`SupportFooter` 已挂在 P1Login / P2QuickLogin 两页上,页脚实际渲染**(2026-09-20 复核;4.10 第 7 条那句「无消费者 / 缺页脚组件」已撤回,见 4.14 第 7 条);**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② ~~缺「资质证照」页~~ **页面【已完成,见 4.10】—— 四层链路齐备(`src/types/qualification.ts` / `fetchQualifications` / `useQualifications` / `app/settings/qualifications.tsx`),设置页与「关于我们」页两处入口已恢复,三类证照的补图占位已逐张标明;**但证照的真实编号 / 发证机关 / 有效期 / 证照图仍全部待法务与资质方提供**(材料到位后只改 `MOCK_QUALIFICATIONS` 字段 + 更新测试断言),**未编造任何证件编号**。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。 -- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 -- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · ~~死控件清零~~ **死控件已清零(22 → 0,2026-09-17,见 4.8)** · ~~假交互清零~~ **假交互已清零(37 → 0,2026-09-18,**但 4.8 当时的「已完成」结论是错的,见 4.9 首条**)** · ~~卡片圆角统一 8px + 去阴影~~ **已完成(147 处圆角 + 54 行阴影,覆盖边界见 4.17;新增卡片须从 `src/theme` 出圆角常量这一层尚未建立)** · 真实后端对接 · OTA 通道验证。 -- **P1(补充 · 2026-09-20 第三批,需产品定夺)**:① **「良好」这一档在个人页与其余五处取色不同** —— - `app/(tabs)/profile.tsx` 的 `DIMENSION_LEVEL.good.text` 是 `textSecondary`(灰), - 而岗位评级那五处一律 `primary`(蓝)。两处不同屏、后果有限,故**未擅自统一**; - 若确认要统一,让 `DIMENSION_LEVEL.good.text` 取 `jobLevelTextColors.good` 即可,一行。详见 4.16 第 5 条。 - ② **`JobExperienceRequirement` 的「1年以内」是死值** —— 全仓仅见于类型声明, - mock 岗位数据没用过、筛选表也没这一项(既筛不到也无样本),只有发布页能选出来。 - 留还是删需产品定夺,详见 4.16 第 6 条。 -- **P1(补充 · 2026-09-20,需产品定夺)**:**求职进度页 `companies` 是「全量」还是「抽样」** —— mock 里只有 4 条,而统计卡写「投递 28 / 待反馈 6 / 面试中 2 / Offer 2 / 已结束 18」,于是任何 Tab 点进去行数都对不上卡片。**这不是能靠对齐字面量解决的问题**,两条路(① 把 `companies` 扩到 28 条、`stats` 由它派生;② 明确标注为「近期流程」抽样、并撤掉会误导的统计数字)**都会改变用户看到的页面**,故本轮只去掉了标题里那份矛盾的副本(见 4.15 第 3 条),主体未动。**不要在没有产品结论的情况下自行选一条。** -- **P2(补充 · 2026-09-18)**:**3 处知情保留的后端待办**(聊天附件四图标 / 接受 Offer / 导出文件下载),后端接口就绪后按 `npm run check:affordances:pending` 列出的清单逐条兑现;另**已做的真机验证与仍未验证的项**见 4.9 第 8 条(已由 4.11 / 4.12 / 4.13 逐项关闭:`settings` 缓存失败态的最终结论是**不可达死代码**,见 4.13 第 8 条)—— 不要把没验过的当成验过了。 -- **P1(补充 · 2026-09-17)**:① **运营商一键登录取号**需运营商号码认证服务 + EAS 自定义客户端,接入路线图见 `运营商一键登录接入方案.md`(端上换 1 处 SDK 调用 + 后端换 1 个接口,页面/hook/类型零改动);② **引导页 4 张配图**待提供素材(`assets.ts` 的 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,换图只改 `source` 一行);③ **`assets.ts` 内 16 个素材位 `source` 仍为 `null`**(营业执照示例图、5 张预设头像、3 张场景插画等)—— 未补时按约定降级:引导页那 4 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。 - ---- - -## 六、附:审查命令速查 - -```bash -# 类型检查(硬门槛,必须 0 错误) -npx tsc --noEmit - -# 代码规范 -npx eslint . - -### 4.22 产品待定三项落地(2026-09-21 批次,接 4.21,消解 4.16/4.19 待定口径) - -此前 4.16/4.19 遗留三项需产品定夺的口径,本批由产品拍板后落地,均为低风险纯前端改动,门禁与 365 测试全绿。 - -**① 「良好」档取色统一为蓝(消解 4.16 配色分歧)** -- 原分歧:`jobLevelTextColors.good = colors.primary`(蓝),但 `app/(tabs)/profile.tsx` 的 `DIMENSION_LEVEL.good.text = colors.textSecondary`(灰),两处对「良好」取色不一致。 -- 决策:统一用蓝 primary。改动 `app/(tabs)/profile.tsx`:导入 `jobLevelTextColors` 并令 `DIMENSION_LEVEL.good.text = jobLevelTextColors.good`(单源派生,bar/bg 仍保留灰阶以区别于「优秀」的实心蓝条)。 -- 文件:`app/(tabs)/profile.tsx`(改)。 - -**② 发布端去掉「1年以内」经验档(消解悬空值)** -- 原问题:`JobExperienceRequirement` 含 `'1年以内'`,但求职端筛选 `EXPERIENCE_OPTIONS` 无此档(应届生直接跳 1-3年);该值发布能选、筛选过滤不到,且 mock 数据从未使用。 -- 决策:从类型与发布选项移除「1年以内」,与筛选档位对齐(应届生/1-3年/3-5年/5-10年/10年以上),「不限」仍仅筛选侧有。 -- 文件:`src/types/enterprise.ts`(改,移除联合成员)、`app/job-publish.tsx`(改,移除选项 + 更新注释)、`src/__tests__/shared-color-maps.test.ts`(改,断言由「含1年以内」改为「不含1年以内且不含不限」)。 - -**③ 求职进度页 companies 抽样 + 查看全部入口** -- 原问题:`app/progress.tsx` 公司列表为 28 条投递的抽样(mock 仅 4 条),此前缺数量/空态保护(offers 有、companies 漏了,已在 4.21 前后补齐空态)。 -- 决策:首屏抽样前 5 家,超量时提供「查看全部 N 家」入口展开全量;切换 Tab 重置为抽样视图。 -- 文件:`app/progress.tsx`(改:新增 `showAll` 状态 + `PREVIEW_COUNT=5` 抽样逻辑 + `useEffect` 重置 + 「查看全部」按钮 + `viewAllBtn/viewAllText` 样式)。 - -**验收**:tsc 0 · eslint 0 · check:spacing 0 · check:routes 0 · check:affordances 0 · check:icons 0 · check:backend-stubs 0 · 365 测试全绿(26 套件)。 - -# 路由闭合审计(改动路由后必跑) -node scripts/check-routes.mjs - -# 假交互审计(改动交互后必跑;**已设为硬门槛 —— 有命中即 exit 1**) -# 检测①无处理函数/空处理函数 ②点了只弹「功能开发中/暂未开放」的弹窗式假交互 -# 豁免标记:affordance-ok(有意为之,如弹层吞冒泡) -# affordance-pending-backend(知情保留的后端待办) -npm run check:affordances # 等价 node scripts/check-affordances.mjs --error -npm run check:affordances:pending # 列出知情保留的后端待办清单(当前 3 处) - -# 后端接口预留审计(新增/改接口后必跑;豁免标记 backend-stub-ok) -node scripts/check-backend-stubs.mjs - -# 图标名审计(改动图标后必跑;**已设为硬门槛 —— 有命中即 exit 1**) -# 查传给 Ionicons 的无效字形名(如 gavel-outline —— 全库 1357 个字形里没有 gavel) -# 页面用 `as keyof typeof Ionicons.glyphMap` 断言会吞掉这类类型错误,故需要此脚本 -npm run check:icons # 等价 node scripts/check-icons.mjs --error - -# 间距与圆角审计(改动样式后必跑;**已设为硬门槛 —— 有命中即 exit 1**) -# 核对 AGENTS.md 的「所有卡片圆角 8px + 边距间隔一致 8px」,四个维度: -# 卡片圆角 / 卡片内边距 / 卡片间隔 / 页面留白 -# 卡片用**结构判定**(有 borderRadius 且有背景或描边),不用命名判定 —— -# 命名判定漏过 pendingItem / dimItem / matchBanner / salaryBox 这类「名字没 card 但是卡片」的块 -# 排除项(圆 / 控件)**逐条打印**,名单改动必须写理由 -# 新增卡片样式后若报错,先看是漏排除还是真违规,**不要直接往排除表里塞名字** -npm run check:spacing # 等价 node scripts/check-spacing.mjs --error - -# 单元测试(src/__tests__ 下纯逻辑) -npm test - -# 依赖与 SDK 一致性(动过依赖后跑,1 项预存问题:yarn.lock 与 package-lock.json 并存) -npx expo-doctor - -# 打包自检(能过说明新 import 可解析,不替代真机验证) -npx expo export --platform android --output-dir <临时目录> - -# 启动 -npm start # 即 expo start -``` - ---- - -### 4.23 卡片间隔令牌化收尾(2026-09-21 批次,接 4.21 残留 ①) - -**目标**:把 4.21 起新增的 `spacing` 令牌层彻底闭环——卡片相关的字面量 `8` 全部收敛为 `spacing.card`(值恒 8,纯令牌化、零视觉变化),消除「卡片圆角/内边距仍写死数字」的最后残留,统一走 `src/theme` 单一来源。 - -**改动范围(82 个 .tsx 文件)** -- 目标属性:`borderRadius: 8` / `padding: 8` / `paddingHorizontal: 8` / `paddingVertical: 8` → `spacing.card`(正则按属性名精确匹配,不动 `margin` / `gap` / 其它数字,避免误伤非卡片间距)。 -- 自动补齐 `spacing` 的 theme 导入:已有 theme 导入则并入命名列表;无则按文件目录深度计算相对路径(`../` 层数)插入 `import { spacing } from '<相对路径>src/theme'`。 -- 数值恒等(8 === spacing.card),故 tsc / eslint / 运行行为均无变化,仅代码形态令牌化。 - -**防回归(与 4.21 同一顾虑)** -- `scripts/check-spacing.mjs` 的 `resolveSpacing` 已支持解析 `spacing.card`(SPACING_TOKENS.card = 8),令牌化后审计仍能解析数值,不会把卡片静默跳过。 -- 迁移后审计输出「令牌引用命中 954 处」且仍为 0 违规,证明令牌化后审计网未撕裂、仍在工作。 - -**验收**:tsc 0 · eslint 0 · check:spacing 0(196 卡片全 8px、令牌命中 954)· check:routes 0 · check:affordances 0 · check:icons 0 · check:backend-stubs 0 · 365 测试全绿(26 套件)。 - -**残留 / 待跟进** -- ① `AGENTS.md` 第 10 行仍写「边距间隔一致,为 8px」,现非卡片块间距为 12,措辞略偏——非阻断,待文档对齐(可改成「卡片间距 8px / 块间距 12px」)。(同 4.21 残留 ③,仍未动) -- ② 本次为机械令牌化,未逐屏核对视觉(行为不变,风险低);如需 100% 把握可补一次真机走查,但非必须。