Browse Source

feat: 清零假交互,补全导出分享与企业端写通道,修复四态与图标缺陷

合并 2026-09-18 当天的 4.9–4.13 五个批次。范围仅前端:后端(Java + SpringBoot)
由其他成员实现,本次新增接口一律按约定在 api.ts 中预留注释版 fetch,未写后端代码。

4.9 假交互盲区 + 四态缺口 + 企业端写通道
- 修掉 4.8「死控件已清零」结论的盲区:审计脚本只认「无处理函数 / 空处理函数」的
  Pressable,查不到「有处理函数、点了只弹『功能开发中』」这一类,全仓实有 37 处。
- 按「能真做的真做,不能做的删入口」逐条处理:能接线的接线(如「评分申诉」→ /appeal);
  能真导出的复用 shareText;无落点的删入口,并同步删掉 api.ts 里对应的下发项,
  不留「数据里有、页面不渲染」的悬空字段。
- 修复 error 被静默丢弃的真 bug:legal/checkout 与 legal/service 的网络故障会被渲染成
  「套餐不存在 / 服务不存在」,把用户引向错误的自救路径;另有 5 处有 error 分支但无重试
  入口、1 处 refresh 已在作用域内却没用。统一改用既有 StateView(ErrorState 自带 onRetry)。
- 企业端岗位管理补「下架 + 刷新」写通道:新增 updateJobStatus / refreshJob,经 storage
  覆盖表叠加(mockRequest 不深拷贝,直接改常量会污染全局且重启即丢),JobManagementJob
  补 refreshedAt 字段,hook 补写方法与进行中标志,页面加二次确认,并补写通道单测。
- 企业端列表页补筛选/排序真实现,修掉一个无 value/onChangeText 的死输入框;Tab 过滤
  从页面 useMemo 下沉到 api 层(原与「业务规则放 API 层」相悖)。
- 扩展 scripts/check-affordances.mjs 覆盖上述盲区,防止同类问题再次逃过审计。

4.10 平台资质证照页 + 死链修复 + 推送缺口
- 新增 app/settings/qualifications.tsx(平台运营主体自身的证照公示,合规 / 应用商店
  上架审核用),配套 src/types/qualification.ts、src/hooks/useQualifications.ts、
  api 取数函数与单测。注意它和企业端 EnterpriseCertificationForm 不是一回事:后者是
  入驻企业上传自己的营业执照以取得认证,主体 / 目的 / 入口全不同,已在类型文件顶部写明。
- 修复死链。
- 补记 09-11~09-17 共 12 个提交从未推送的缺口(远端一直停在 09-10),并把「提交之后
  必须推送」写进 AGENTS.md:17:50 的动作明确为 commit → push 两步,9:20 拉取前先看
  git rev-list --left-right --count HEAD...@{u}。

4.11 企业端状态文案缺陷 + 状态映射下沉
- 修 4.9 写通道回看时暴露的缺陷:标着「草稿」的卡片按钮显示「重新上架」。JobManagementJobStatus
  有三个值(active | offline | draft),三元表达式只分了两支,草稿落进「已下线」那一支;
  确认文案「重新上架后,候选人可以再次看到该岗位」对从未上架过的草稿两句都不成立。
- 状态 → 文案映射下沉到 api 层,页面不再自行分叉。

4.12 六处导出 / 分享真机验证 + 霍兰德参与人数两处不一致
- 6 处导出 / 分享路径在 Android 模拟器上逐个验到系统分享面板,并借面板的文本预览核对
  拼装内容与页面一致(数字、名称、状态)。
- 修正一处数据前后不一致(霍兰德参与人数两处对不上)。

4.13 六处空态实测 + 无效图标名缺陷 + check:icons 新门槛 + 缓存失败态结案 + 分享文本测试锁
- 用「注入空数据 + 逐个截图」把 4.9 遗留的 6 处空态实测通过(progress / legal /
  hr-evaluation / ability / jobs/offer-compare / jobs/[id]),确认按设计渲染、无死代码。
- 修复文书模板 gavel-outline 无效字形名:Ionicons 全库 1357 个字形里没有 gavel,运行时会
  打 "not a valid icon name" 且图标渲染为空白;页面的 `as keyof typeof Ionicons.glyphMap`
  断言把类型错误吞掉了,故 tsc 一直没报。改用语义最接近的 hammer-outline。
- 新增 scripts/check-icons.mjs + npm run check:icons 门槛:直接读取已安装包的 glyphmap
  (另存一份必然与真实图标库脱节),扫描 app/ 与 src/ 中的图标名字面量,含灵敏度验证。
- 结案 settings 缓存「读取失败」态:不是「未验证」,而是 readCacheBytes() 把异常全吞成 0,
  故障传不上来,该分支在任何条件下都不可达。新增 cache.test.ts(12 例),以「构造会抛错的
  文件系统」把这条永不抛错的契约锁住。
- 补上第七处分享路径(入职清单导出)的真机验证,7 处分享至此全部验过。
- 新增 share-text.test.ts(28 例),补 4.9 同批 5 个分享拼装函数一直缺的测试锁(4.9 方案里
  写了「并补单测」,当时只给范式 buildChecklistText 补了)。侧重锁三类:空值守卫、取的
  是本地选择态而非接口默认值、对外可见格式;并把 4.12 那个真机证伪实验固化成不依赖设备的断言。
- 顺带把「数据层引用外部词表」三类系统排查完:图标名(已修 + 已设门槛)、路由(已被
  check-routes 覆盖,本次补验其数据层灵敏度)、颜色(硬编码色值 0 处)。

其他
- AGENTS.md 第九节记录暗模式决策:本项目维持纯亮色。理由是全仓 3302 处静态 colors.X 分布在
  124 个文件,其中 119 个为模块级 StyleSheet.create —— 模块级样式在启动时即固化颜色,与
  运行时切换主题不相容,接入暗模式属重构级工程,且当前无自动化视觉回归手段可验证这 3302 处
  改动。故本轮只记录决策、不改代码,将来应作为独立重构批次推进。
- 项目开发状态.md 同步各批次结论与证据(含一次错误归因的更正:深链不路由不是 expo-dev-client
  缺失,而是应用跑在 Expo Go 里、Expo Go 只注册 exp://,已补 am start 实测证据)。

门槛:tsc 0 / eslint 0 / 23 文件 302 例 / check:routes 0 / check:affordances 0 /
check:backend-stubs 140 / check:icons 0
master
doublex001 1 month ago
parent
commit
0c2ebf6149
  1. 21
      AGENTS.md
  2. 26
      app/(enterprise)/enterprise-chat.tsx
  3. 12
      app/(enterprise)/enterprise-overview.tsx
  4. 561
      app/(enterprise)/job-management.tsx
  5. 38
      app/(enterprise)/talent-recommendation.tsx
  6. 34
      app/(tabs)/growth.tsx
  7. 3
      app/(tabs)/profile.tsx
  8. 20
      app/ability.tsx
  9. 10
      app/article/[id].tsx
  10. 12
      app/candidate-detail.tsx
  11. 64
      app/chat/[id].tsx
  12. 23
      app/holland-result.tsx
  13. 21
      app/hr-evaluation.tsx
  14. 11
      app/interview-score.tsx
  15. 27
      app/interview.tsx
  16. 10
      app/job-evaluations.tsx
  17. 10
      app/job-publish.tsx
  18. 77
      app/jobs/[id].tsx
  19. 112
      app/jobs/offer-compare.tsx
  20. 35
      app/jobs/rank.tsx
  21. 30
      app/legal.tsx
  22. 15
      app/legal/checkout.tsx
  23. 42
      app/legal/service.tsx
  24. 15
      app/personal-data-export.tsx
  25. 24
      app/progress.tsx
  26. 28
      app/settings.tsx
  27. 33
      app/settings/about.tsx
  28. 7
      app/settings/account-cancel.tsx
  29. 24
      app/settings/privacy.tsx
  30. 265
      app/settings/qualifications.tsx
  31. 6
      package.json
  32. 165
      scripts/check-affordances.mjs
  33. 99
      scripts/check-icons.mjs
  34. 168
      src/__tests__/cache.test.ts
  35. 36
      src/__tests__/holland.test.ts
  36. 380
      src/__tests__/job-management-write.test.ts
  37. 101
      src/__tests__/qualifications.test.ts
  38. 371
      src/__tests__/share-text.test.ts
  39. 818
      src/api/api.ts
  40. 16
      src/api/index.ts
  41. 1
      src/hooks/index.ts
  42. 115
      src/hooks/useJobManagement.ts
  43. 29
      src/hooks/useQualifications.ts
  44. 58
      src/types/enterprise.ts
  45. 5
      src/types/growth.ts
  46. 3
      src/types/index.ts
  47. 7
      src/types/message.ts
  48. 2
      src/types/offer-compare.ts
  49. 65
      src/types/qualification.ts
  50. 5
      src/types/rank.ts
  51. 2
      src/types/settings.ts
  52. 653
      项目开发状态.md

21
AGENTS.md

@ -15,6 +15,14 @@ Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before
仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob 仓库:https://gitea.baskbuddy.com/baskbuddy/mydreamjob
请在每周的周一-周五的9:20的时候拉取一次远端仓库、每天17:50提交一次git仓库,其他时间可以改动但不需要提交,说明今天新增与改动的内容,上班时间是9:00-18:00,有页面出口就一定要有入口。 请在每周的周一-周五的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`。
业务逻辑分层管理,不是没有,而是被妥善组织: 业务逻辑分层管理,不是没有,而是被妥善组织:
+------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+ +------------------+--------------------------------------------------+----------------------------------------------------------------------------------------+
| 层级 | 职责 | 业务逻辑示例 | | 层级 | 职责 | 业务逻辑示例 |
@ -375,7 +383,18 @@ Hook层测试:
--- ---
### 🌙 暗模式配色(2026 默认必做 · 翻色不是简单反白) ### 🌙 暗模式配色(本项目**经决策维持纯亮色** · 下表备用,当前不接入)
> **决策(2026-09-18)**:本项目**暂不接入暗模式,维持纯亮色**。原因不是「先不做」,而是成本口径被低估了:
> 全仓 **3302 处**静态 `colors.X` 引用分布在 **124 个文件**,其中 **119 个文件**的样式是**模块级**
> `StyleSheet.create` —— 模块级样式在 App 启动时就固化了颜色,与「运行时切换主题」根本不相容。
> 接入暗模式不是「加个开关」,而是要**逐个样式表改造成随主题求值**,属重构级工程;加上当前没有
> 自动化视觉回归手段,这 3302 处改动无法验证。故本轮**不改代码,只记录决策**。
>
> 下表已备好,`src/theme/colors.ts` 的 `darkColors` 也已存在(亮→暗映射),将来若要做,
> 应作为**独立重构批次**推进(含视觉回归方案),不要顺带塞进功能批次。
>
> 翻色不是简单反白 —— 下表各值的取色理由仍然成立,将来直接可用。
| 变量名 | 亮模式 | 暗模式 | 说明 | | 变量名 | 亮模式 | 暗模式 | 说明 |
|---|---|---|---| |---|---|---|---|

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

@ -2,7 +2,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -186,8 +185,8 @@ function SearchBar({
/* ===================== 子组件:快捷操作栏 ===================== */ /* ===================== 子组件:快捷操作栏 ===================== */
/** 单个快捷操作:有 route 则跳转,无对应页面时由页面层给出「功能开发中」提示 */ /** 单个快捷操作。`route` 必填 —— 每个入口都必须有落点,没有目标页的宁可不放 */
type QuickActionItem = { icon: string; label: string; route?: string }; type QuickActionItem = { icon: string; label: string; route: string };
function QuickActions({ function QuickActions({
actions, actions,
@ -246,11 +245,13 @@ function EmptyState({
/* ===================== 页面 ===================== */ /* ===================== 页面 ===================== */
/*
* 「批量发Offer」「群发通知」原先在这里,但两者都没有对应页面,点了只能弹「功能开发中」。
* 按「不留假交互」的口径摘掉(与 talent-recommendation.tsx 的 POOL_ACTIONS 同一处理)。
*/
const QUICK_ACTIONS: QuickActionItem[] = [ const QUICK_ACTIONS: QuickActionItem[] = [
{ icon: 'mail-outline', label: '批量发邀请', route: '/talent-recommendation' }, { icon: 'mail-outline', label: '批量发邀请', route: '/talent-recommendation' },
{ icon: 'document-text-outline', label: '批量发Offer' },
{ icon: 'calendar-outline', label: '面试评分', route: '/interview-score' }, { icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
{ icon: 'notifications-outline', label: '群发通知' },
]; ];
export default function EnterpriseChatScreen() { export default function EnterpriseChatScreen() {
@ -268,27 +269,20 @@ export default function EnterpriseChatScreen() {
refresh, refresh,
} = useEnterpriseCommunication(); } = useEnterpriseCommunication();
// 快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」 /** 快捷操作:每个入口都由数据保证有落点,页面不再需要「没页面就弹提示」的分支 */
const handleQuickAction = (action: QuickActionItem) => { const handleQuickAction = (action: QuickActionItem) => {
if (action.route) {
router.push(action.route); router.push(action.route);
} else {
Alert.alert('提示', `${action.label}功能开发中,敬请期待`);
}
}; };
// 待办卡片:优先跳页面,其次切 Tab(接口层给的目标,页面不写死映射) // 待办卡片:跳页面或切 Tab,目标由接口层给,页面不写死映射。
// PendingTask 是联合类型,两者必居其一,故不需要「都没有」的兜底分支
const handlePressTask = (task: PendingTask) => { const handlePressTask = (task: PendingTask) => {
if (task.route) {
router.push(task.route as never);
return;
}
if (task.tabKey) { if (task.tabKey) {
setActiveTab(task.tabKey); setActiveTab(task.tabKey);
setKeyword(''); setKeyword('');
return; return;
} }
Alert.alert('提示', `${task.label}功能开发中,敬请期待`); router.push(task.route as never);
}; };
if (loading) { if (loading) {

12
app/(enterprise)/enterprise-overview.tsx

@ -2,7 +2,6 @@ import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -11,6 +10,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useEnterpriseOverview } from '../../src/hooks'; import { useEnterpriseOverview } from '../../src/hooks';
import { LoadingState, ErrorState } from '../../src/components/StateView';
import { useRole } from '../../src/context/RoleContext'; import { useRole } from '../../src/context/RoleContext';
import { colors, headerGradient } from '../../src/theme'; import { colors, headerGradient } from '../../src/theme';
import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types'; import type { EnterpriseOverview, EnterpriseQuickAction, EnterpriseStat, JobEvaluationSummary, MenuItem } from '../../src/types';
@ -256,12 +256,12 @@ function CandidateEntryCard() {
export default function EnterpriseOverviewScreen() { export default function EnterpriseOverviewScreen() {
const router = useRouter(); const router = useRouter();
const { overview, loading, error } = useEnterpriseOverview(); const { overview, loading, error, refresh } = useEnterpriseOverview();
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} /> <LoadingState text="加载中…" />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -269,7 +269,7 @@ export default function EnterpriseOverviewScreen() {
if (error || !overview) { if (error || !overview) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -307,10 +307,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pageBg, backgroundColor: colors.pageBg,
}, },
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
gradientHeader: { gradientHeader: {
paddingBottom: 16, paddingBottom: 16,
}, },

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

@ -1,9 +1,10 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useMemo, useState } from 'react'; import { useState } from 'react';
import type { ReactNode } from 'react';
import { import {
ActivityIndicator,
Alert, Alert,
Modal,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -13,7 +14,10 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobManagement } from '../../src/hooks'; import { useJobManagement } from '../../src/hooks';
import { JOB_MANAGEMENT_SORTS, buildJobStatusAction, getJobStatusLabel } from '../../src/api';
import { ErrorState, LoadingState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { formatDateTime } from '../../src/utils';
import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types'; import type { JobManagement, JobManagementJob, JobManagementTab } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
@ -107,7 +111,21 @@ function StatsOverview({ stats }: { stats: JobManagement['stats'] }) {
/* ===================== 子组件:搜索与筛选 ===================== */ /* ===================== 子组件:搜索与筛选 ===================== */
function SearchFilterBar() { function SearchFilterBar({
keyword,
onChangeKeyword,
sortLabel,
onOpenSort,
onOpenFilter,
activeFilterCount,
}: {
keyword: string;
onChangeKeyword: (value: string) => void;
sortLabel: string;
onOpenSort: () => void;
onOpenFilter: () => void;
activeFilterCount: number;
}) {
return ( return (
<View style={styles.searchBar}> <View style={styles.searchBar}>
<View style={styles.searchInputWrap}> <View style={styles.searchInputWrap}>
@ -116,31 +134,136 @@ function SearchFilterBar() {
style={styles.searchInput} style={styles.searchInput}
placeholder="搜索岗位名称..." placeholder="搜索岗位名称..."
placeholderTextColor={colors.textHint} placeholderTextColor={colors.textHint}
value={keyword}
onChangeText={onChangeKeyword}
returnKeyType="search"
/> />
{keyword.length > 0 && (
<Pressable onPress={() => onChangeKeyword('')} hitSlop={8}>
<Ionicons name="close-circle" size={16} color={colors.textHint} />
</Pressable>
)}
</View> </View>
<Pressable style={styles.filterBtn} onPress={() => Alert.alert('筛选', '岗位筛选功能开发中,敬请期待')}> <Pressable style={styles.filterBtn} onPress={onOpenFilter}>
<Ionicons name="funnel-outline" size={18} color={colors.textPrimary} /> <Ionicons name="funnel-outline" size={18} color={colors.textPrimary} />
<Text style={styles.filterBtnText}>筛选</Text> <Text style={styles.filterBtnText}>筛选</Text>
{activeFilterCount > 0 && (
<View style={styles.filterBadge}>
<Text style={styles.filterBadgeText}>{activeFilterCount}</Text>
</View>
)}
</Pressable> </Pressable>
<Pressable style={styles.sortBtn} onPress={() => Alert.alert('排序', '岗位排序功能开发中,敬请期待')}> <Pressable style={styles.sortBtn} onPress={onOpenSort}>
<Text style={styles.sortBtnText}>最新发布</Text> <Text style={styles.sortBtnText}>{sortLabel}</Text>
<Ionicons name="chevron-down" size={14} color={colors.textHint} /> <Ionicons name="chevron-down" size={14} color={colors.textHint} />
</Pressable> </Pressable>
</View> </View>
); );
} }
/* ===================== 子组件:底部弹层 ===================== */
function Sheet({
visible,
title,
onClose,
children,
footer,
}: {
visible: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={styles.modalOverlay} onPress={onClose}>
{/* affordance-ok:吞冒泡,点弹层内容区不关闭 */}
<Pressable style={styles.sheetPanel} onPress={() => {}}>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{title}</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<ScrollView showsVerticalScrollIndicator={false} style={styles.sheetBody}>
{children}
</ScrollView>
{footer ? <View style={styles.sheetFooter}>{footer}</View> : null}
</Pressable>
</Pressable>
</Modal>
);
}
/** 弹层里的一组多选筛选(「不限」= 清空该组) */
function FilterGroup({
title,
options,
selected,
onToggle,
onClear,
}: {
title: string;
options: string[];
selected: string[];
onToggle: (value: string) => void;
onClear: () => void;
}) {
return (
<View style={styles.filterGroup}>
<Text style={styles.filterGroupTitle}>{title}</Text>
<View style={styles.optionRow}>
{['不限', ...options].map((option) => {
const isNone = option === '不限';
const active = isNone ? selected.length === 0 : selected.includes(option);
return (
<Pressable
key={option}
style={[styles.optionChip, active && styles.optionChipActive]}
onPress={() => (isNone ? onClear() : onToggle(option))}
>
<Text style={[styles.optionText, active && styles.optionTextActive]}>{option}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
/* ===================== 子组件:岗位卡片 ===================== */ /* ===================== 子组件:岗位卡片 ===================== */
function JobCard({ job }: { job: JobManagementJob }) { /** 状态角标:只有草稿与已下线才需要标出来,招聘中靠绿点即可。文案取自接口层 getJobStatusLabel */
/** 岗位卡片。编辑入口已摘除 —— 编辑态需要给发布页加预填数据,本轮未做,不留点了没反应的按钮 */
function JobCard({
job,
refreshing,
onRefresh,
onToggleStatus,
}: {
job: JobManagementJob;
/** 该岗位是否有写操作进行中 */
refreshing: boolean;
onRefresh: (job: JobManagementJob) => void;
onToggleStatus: (job: JobManagementJob) => void;
}) {
const isActive = job.status === 'active';
/** 状态切换动作(按钮图标/文案、确认框文案、目标状态)一律由接口层给出 */
const statusAction = buildJobStatusAction(job);
return ( return (
<View style={styles.jobCard}> <View style={styles.jobCard}>
<View style={styles.jobHeader}> <View style={styles.jobHeader}>
<View style={styles.jobTitleWrap}> <View style={styles.jobTitleWrap}>
<Text style={styles.jobTitle}>{job.title}</Text> <Text style={styles.jobTitle}>{job.title}</Text>
{job.status === 'active' ? <View style={styles.activeDot} /> : null} {isActive ? <View style={styles.activeDot} /> : null}
</View>
{!isActive && (
<View style={styles.statusTag}>
<Text style={styles.statusTagText}>{getJobStatusLabel(job.status)}</Text>
</View> </View>
<Ionicons name="chevron-forward" size={18} color={colors.textHint} /> )}
</View> </View>
<Text style={styles.jobSalary}> <Text style={styles.jobSalary}>
{job.salary} · {job.salaryMonths}薪 {job.salary} · {job.salaryMonths}薪
@ -179,19 +302,40 @@ function JobCard({ job }: { job: JobManagementJob }) {
</View> </View>
<View style={styles.jobActions}> <View style={styles.jobActions}>
<Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> {isActive && (
<Ionicons name="create-outline" size={16} color={colors.primary} /> <Pressable
<Text style={styles.jobActionText}>编辑</Text> style={[styles.jobActionBtn, refreshing && styles.jobActionBtnDisabled]}
</Pressable> onPress={() => onRefresh(job)}
<Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> disabled={refreshing}
>
<Ionicons name="refresh-outline" size={16} color={colors.primary} /> <Ionicons name="refresh-outline" size={16} color={colors.primary} />
<Text style={styles.jobActionText}>刷新</Text> <Text style={styles.jobActionText}>
{job.refreshedAt ? '再次刷新' : '刷新'}
</Text>
</Pressable> </Pressable>
<Pressable style={styles.jobActionBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> )}
<Ionicons name="download-outline" size={16} color={colors.primary} /> <Pressable
<Text style={styles.jobActionText}>下架</Text> style={[styles.jobActionBtn, refreshing && styles.jobActionBtnDisabled]}
onPress={() => onToggleStatus(job)}
disabled={refreshing}
>
{/* 图标与文案同出一处(接口层的 buildJobStatusAction),不各写一半 */}
<Ionicons
name={statusAction.icon as keyof typeof Ionicons.glyphMap}
size={16}
color={statusAction.destructive ? colors.action : colors.primary}
/>
<Text style={[styles.jobActionText, statusAction.destructive && styles.jobActionTextAction]}>
{statusAction.label}
</Text>
</Pressable> </Pressable>
</View> </View>
<Text style={styles.jobTimeHint}>
{job.refreshedAt
? `最近刷新:${formatDateTime(job.refreshedAt)}`
: `发布:${formatDateTime(job.publishedAt)}`}
</Text>
</View> </View>
); );
} }
@ -227,27 +371,85 @@ function EmptyState({ benefits, onPublish }: { benefits: string[]; onPublish: ()
export default function JobManagementScreen() { export default function JobManagementScreen() {
const router = useRouter(); const router = useRouter();
const { management, loading, error, refresh } = useJobManagement(); const {
const [activeTab, setActiveTab] = useState('all'); management,
jobs,
const filteredJobs = useMemo(() => { filterOptions,
if (!management?.hasData) return []; activeTab,
if (activeTab === 'all') return management.jobs; setActiveTab,
return management.jobs.filter((job) => { keyword,
if (activeTab === 'active') return job.status === 'active'; setKeyword,
if (activeTab === 'offline') return job.status === 'offline'; sortKey,
if (activeTab === 'draft') return job.status === 'draft'; setSortKey,
return true; filters,
applyFilters,
resetFilters,
activeFilterCount,
isFiltered,
setStatus,
refreshJobItem,
mutatingId,
loading,
error,
refresh,
} = useJobManagement();
// 弹层内的临时选择(点「确定」后才生效),与 `app/(tabs)/jobs.tsx` 的筛选面板同一套做法
const [filterVisible, setFilterVisible] = useState(false);
const [sortVisible, setSortVisible] = useState(false);
const [draft, setDraft] = useState(filters);
const sortLabel = JOB_MANAGEMENT_SORTS.find((s) => s.key === sortKey)?.label ?? '最新发布';
const openFilter = () => {
setDraft(filters);
setFilterVisible(true);
};
/** 切换草稿里的某个筛选值(多选:已在则移除) */
const toggleDraft = (key: 'cities' | 'departments', value: string) => {
setDraft((prev) => {
const current = prev[key];
return {
...prev,
[key]: current.includes(value) ? current.filter((v) => v !== value) : [...current, value],
};
}); });
}, [management, activeTab]); };
/** 下架 / 上架 / 重新上架。下架是破坏性操作,先二次确认。文案按岗位状态由接口层给出 */
const handleToggleStatus = (job: JobManagementJob) => {
const action = buildJobStatusAction(job);
Alert.alert(action.confirmTitle, action.confirmMessage, [
{ text: '取消', style: 'cancel' },
{
text: action.confirmButton,
style: action.destructive ? 'destructive' : 'default',
onPress: async () => {
try {
await setStatus(job.id, action.nextStatus);
} catch {
// 写入失败要如实告知,不能让用户以为已经生效
Alert.alert('操作失败', '岗位状态未能保存,请稍后重试');
}
},
},
]);
};
const handleRefreshJob = async (job: JobManagementJob) => {
try {
await refreshJobItem(job.id);
} catch {
Alert.alert('操作失败', '刷新未能保存,请稍后重试');
}
};
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName="智联科技" onBack={() => router.back()} />
<View style={styles.center}> <LoadingState />
<ActivityIndicator size="large" color={colors.primary} />
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -256,13 +458,7 @@ export default function JobManagementScreen() {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header companyName="智联科技" onBack={() => router.back()} /> <Header companyName="智联科技" onBack={() => router.back()} />
<View style={styles.center}> <ErrorState onRetry={refresh} />
<Ionicons name="alert-circle-outline" size={40} color={colors.danger} />
<Text style={styles.tip}>加载失败</Text>
<Pressable style={styles.retryBtn} onPress={refresh}>
<Text style={styles.retryText}>重试</Text>
</Pressable>
</View>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -275,10 +471,44 @@ export default function JobManagementScreen() {
{management.hasData ? ( {management.hasData ? (
<View> <View>
<StatsOverview stats={management.stats} /> <StatsOverview stats={management.stats} />
<SearchFilterBar /> <SearchFilterBar
{filteredJobs.map((job) => ( keyword={keyword}
<JobCard key={job.id} job={job} /> onChangeKeyword={setKeyword}
))} sortLabel={sortLabel}
onOpenSort={() => setSortVisible(true)}
onOpenFilter={openFilter}
activeFilterCount={activeFilterCount}
/>
{jobs.length > 0 ? (
jobs.map((job) => (
<JobCard
key={job.id}
job={job}
refreshing={mutatingId === job.id}
onRefresh={handleRefreshJob}
onToggleStatus={handleToggleStatus}
/>
))
) : (
/* 列表为空有两种原因,说清楚是哪一种,并给出对应的下一步 */
<View style={styles.emptyResult}>
<Ionicons name="search-outline" size={40} color={colors.textHint} />
<Text style={styles.emptyResultText}>
{isFiltered ? '没有符合条件的岗位' : '这个分类下还没有岗位'}
</Text>
{isFiltered && (
<Pressable
style={styles.emptyResultBtn}
onPress={() => {
setKeyword('');
resetFilters();
}}
>
<Text style={styles.emptyResultBtnText}>清空筛选条件</Text>
</Pressable>
)}
</View>
)}
</View> </View>
) : ( ) : (
<EmptyState <EmptyState
@ -287,6 +517,71 @@ export default function JobManagementScreen() {
/> />
)} )}
</ScrollView> </ScrollView>
{/* 排序弹层 */}
<Sheet
visible={sortVisible}
title="排序方式"
onClose={() => setSortVisible(false)}
>
{JOB_MANAGEMENT_SORTS.map((option) => (
<Pressable
key={option.key}
style={styles.sortOption}
onPress={() => {
setSortKey(option.key);
setSortVisible(false);
}}
>
<Text
style={[styles.sortOptionText, sortKey === option.key && styles.sortOptionTextActive]}
>
{option.label}
</Text>
{sortKey === option.key && (
<Ionicons name="checkmark" size={18} color={colors.primary} />
)}
</Pressable>
))}
</Sheet>
{/* 筛选弹层。选项来自真实岗位数据,不写死 */}
<Sheet
visible={filterVisible}
title="筛选岗位"
onClose={() => setFilterVisible(false)}
footer={
<View style={styles.sheetActions}>
<Pressable style={styles.resetBtn} onPress={() => setDraft({ cities: [], departments: [] })}>
<Text style={styles.resetBtnText}>重置</Text>
</Pressable>
<Pressable
style={styles.confirmBtn}
onPress={() => {
applyFilters(draft);
setFilterVisible(false);
}}
>
<Text style={styles.confirmBtnText}>确定</Text>
</Pressable>
</View>
}
>
<FilterGroup
title="工作城市"
options={filterOptions.cities}
selected={draft.cities}
onToggle={(value) => toggleDraft('cities', value)}
onClear={() => setDraft((prev) => ({ ...prev, cities: [] }))}
/>
<FilterGroup
title="所属部门"
options={filterOptions.departments}
selected={draft.departments}
onToggle={(value) => toggleDraft('departments', value)}
onClear={() => setDraft((prev) => ({ ...prev, departments: [] }))}
/>
</Sheet>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -575,11 +870,189 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
}, },
jobActionBtnDisabled: {
opacity: 0.4,
},
jobActionText: { jobActionText: {
fontSize: 13, fontSize: 13,
color: colors.primary, color: colors.primary,
marginLeft: 4, marginLeft: 4,
}, },
jobActionTextAction: {
color: colors.action,
},
jobTimeHint: {
marginTop: 10,
fontSize: 11,
color: colors.textHint,
},
statusTag: {
backgroundColor: colors.pageBg,
borderRadius: 8,
paddingHorizontal: 8,
paddingVertical: 2,
},
statusTagText: {
fontSize: 11,
color: colors.textSecondary,
fontWeight: '600',
},
/* ---- 筛选标记 ---- */
filterBadge: {
minWidth: 16,
height: 16,
borderRadius: 8,
backgroundColor: colors.primaryBtn,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
marginLeft: 4,
},
filterBadgeText: {
fontSize: 10,
color: colors.white,
fontWeight: '700',
},
/* ---- 筛选结果为空 ---- */
emptyResult: {
alignItems: 'center',
paddingVertical: 48,
gap: 12,
},
emptyResultText: {
fontSize: 14,
color: colors.textSecondary,
},
emptyResultBtn: {
marginTop: 4,
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: colors.primaryLight,
},
emptyResultBtnText: {
fontSize: 13,
fontWeight: '600',
color: colors.primaryText,
},
/* ---- 底部弹层 ---- */
modalOverlay: {
flex: 1,
backgroundColor: colors.scrim,
justifyContent: 'flex-end',
},
sheetPanel: {
backgroundColor: colors.white,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingBottom: 24,
maxHeight: '80%',
},
sheetHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
sheetTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
sheetBody: {
paddingHorizontal: 16,
},
sheetFooter: {
paddingHorizontal: 16,
paddingTop: 12,
},
sheetActions: {
flexDirection: 'row',
gap: 12,
},
resetBtn: {
flex: 1,
alignItems: 'center',
paddingVertical: 12,
borderRadius: 22,
borderWidth: 1,
borderColor: colors.border,
},
resetBtnText: {
fontSize: 15,
color: colors.textSecondary,
fontWeight: '600',
},
confirmBtn: {
flex: 2,
alignItems: 'center',
paddingVertical: 12,
borderRadius: 22,
backgroundColor: colors.primaryBtn,
},
confirmBtnText: {
fontSize: 15,
color: colors.white,
fontWeight: '700',
},
sortOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
sortOptionText: {
fontSize: 15,
color: colors.textPrimary,
},
sortOptionTextActive: {
color: colors.primaryText,
fontWeight: '700',
},
/* ---- 筛选分组 ---- */
filterGroup: {
marginTop: 16,
},
filterGroupTitle: {
fontSize: 14,
fontWeight: '700',
color: colors.textPrimary,
marginBottom: 10,
},
optionRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 10,
},
optionChip: {
paddingHorizontal: 14,
paddingVertical: 7,
borderRadius: 16,
backgroundColor: colors.pageBg,
borderWidth: 1,
borderColor: colors.borderLight,
},
optionChipActive: {
backgroundColor: colors.primaryLight,
borderColor: colors.primary,
},
optionText: {
fontSize: 13,
color: colors.textSecondary,
},
optionTextActive: {
color: colors.primaryText,
fontWeight: '600',
},
emptyCard: { emptyCard: {
backgroundColor: colors.white, backgroundColor: colors.white,
borderRadius: 16, borderRadius: 16,

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

@ -3,7 +3,6 @@ import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -119,7 +118,11 @@ function TabBar({
/* ===================== 子组件:今日推荐头部 ===================== */ /* ===================== 子组件:今日推荐头部 ===================== */
function TodayHeader({ count, onReport }: { count: number; onReport: () => void }) { /**
* 今日推荐头部。
* 原先右上角有「查看人才市场报告」入口,但项目没有该页面 —— 按「不留假交互」的口径摘掉。
*/
function TodayHeader({ count }: { count: number }) {
return ( return (
<View style={styles.todayHeader}> <View style={styles.todayHeader}>
<View> <View>
@ -129,10 +132,6 @@ function TodayHeader({ count, onReport }: { count: number; onReport: () => void
<Text style={styles.todayUnit}>位优质候选人</Text> <Text style={styles.todayUnit}>位优质候选人</Text>
</View> </View>
</View> </View>
<Pressable style={styles.reportBtn} onPress={onReport}>
<Text style={styles.reportBtnText}>查看人才市场报告</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
</View> </View>
); );
} }
@ -268,16 +267,13 @@ function EmptyState({
/* ===================== 页面 ===================== */ /* ===================== 页面 ===================== */
/** /**
* 人才池快捷操作项:带 route 的跳转目标页,无对应页面的弹「功能开发中」提示。 * 人才池快捷操作项。`route` 必填 —— 每个入口都必须有落点,
* 与 enterprise-chat.tsx 的 QuickActionItem 保持同一约定。 * 批量发邀请 / 批量发Offer / 群发通知三项因项目内没有对应页面,已摘掉。
*/ */
type PoolActionItem = { icon: string; label: string; route?: string }; type PoolActionItem = { icon: string; label: string; route: string };
const POOL_ACTIONS: PoolActionItem[] = [ const POOL_ACTIONS: PoolActionItem[] = [
{ icon: 'mail-outline', label: '批量发邀请' },
{ icon: 'document-text-outline', label: '批量发Offer' },
{ icon: 'calendar-outline', label: '面试评分', route: '/interview-score' }, { icon: 'calendar-outline', label: '面试评分', route: '/interview-score' },
{ icon: 'notifications-outline', label: '群发通知' },
]; ];
export default function TalentRecommendationScreen() { export default function TalentRecommendationScreen() {
@ -301,20 +297,9 @@ export default function TalentRecommendationScreen() {
setSearching((prev) => !prev); setSearching((prev) => !prev);
}; };
// 人才池快捷操作分发:有目标路由则跳转,无对应页面则提示「功能开发中」, /** 人才池快捷操作:每个入口都由数据保证有落点,页面不再需要「没页面就弹提示」的分支 */
// 避免按钮点击后无任何反馈。与 enterprise-chat.tsx 的 handleQuickAction 同约定。
const handlePoolAction = (action: PoolActionItem) => { const handlePoolAction = (action: PoolActionItem) => {
if (action.route) {
router.push(action.route); router.push(action.route);
} else {
Alert.alert('提示', `${action.label}功能开发中,敬请期待`);
}
};
// 以下两处原先绑的是空函数(() => {}),按钮点击后无任何反馈。
// 暂无对应页面,按项目约定弹「功能开发中」提示。
const handleReport = () => {
Alert.alert('提示', '人才市场报告功能开发中,敬请期待');
}; };
/** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */ /** 空状态里的「调整筛选条件」:把筛选真正复位(切回全部 + 清关键词) */
@ -364,10 +349,7 @@ export default function TalentRecommendationScreen() {
<ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}> <ScrollView contentContainerStyle={styles.body} showsVerticalScrollIndicator={false}>
{recommendation.hasData ? ( {recommendation.hasData ? (
<View> <View>
<TodayHeader <TodayHeader count={recommendation.todayCount} />
count={recommendation.todayCount}
onReport={handleReport}
/>
{candidates.length === 0 ? ( {candidates.length === 0 ? (
<View style={styles.emptyList}> <View style={styles.emptyList}>
<Ionicons name="search-outline" size={32} color={colors.textHint} /> <Ionicons name="search-outline" size={32} color={colors.textHint} />

34
app/(tabs)/growth.tsx

@ -252,6 +252,12 @@ export default function GrowthScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>AI求职工具</Text> <Text style={styles.sectionTitle}>AI求职工具</Text>
</View> </View>
{aiTools.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="construct-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>暂无可用工具</Text>
</View>
) : (
<View style={styles.toolsRow}> <View style={styles.toolsRow}>
{aiTools.map((tool) => { {aiTools.map((tool) => {
const style = TOOL_STYLE[tool.variant]; const style = TOOL_STYLE[tool.variant];
@ -259,10 +265,8 @@ export default function GrowthScreen() {
<Pressable <Pressable
key={tool.id} key={tool.id}
style={[styles.toolCard, { backgroundColor: style.bg, borderColor: style.border }]} style={[styles.toolCard, { backgroundColor: style.bg, borderColor: style.border }]}
onPress={() => { // 目标页由接口层下发,页面不再自己写 id→路由的映射
if (tool.id === 'ai-resume') router.push('/resume'); onPress={() => router.push(tool.route as never)}
else if (tool.id === 'ai-interview') router.push('/interview');
}}
> >
<Ionicons name={tool.icon as IconName} size={28} color={style.icon} /> <Ionicons name={tool.icon as IconName} size={28} color={style.icon} />
<Text style={styles.toolTitle}>{tool.title}</Text> <Text style={styles.toolTitle}>{tool.title}</Text>
@ -272,14 +276,25 @@ export default function GrowthScreen() {
); );
})} })}
</View> </View>
)}
{/* 5. 职场干货 */} {/* 5. 职场干货 */}
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>职场干货</Text> <Text style={styles.sectionTitle}>职场干货</Text>
<Pressable onPress={() => { if (articles[0]) router.push(`/article/${articles[0].id}`); }}> {/* 没有文章时不渲染「更多」—— 否则点了没反应 */}
{articles.length > 0 && (
<Pressable onPress={() => router.push(`/article/${articles[0].id}`)}>
<Text style={styles.sectionMore}>更多 &gt;</Text> <Text style={styles.sectionMore}>更多 &gt;</Text>
</Pressable> </Pressable>
)}
</View>
{/* 文章为空时标题与「更多」仍在,下方却空无一物 —— 给出空态 */}
{articles.length === 0 && (
<View style={styles.emptyBox}>
<Ionicons name="newspaper-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>暂无干货文章</Text>
</View> </View>
)}
{articles.map((article) => { {articles.map((article) => {
const thumb = THUMBNAIL_STYLE[article.thumbnailColor]; const thumb = THUMBNAIL_STYLE[article.thumbnailColor];
return ( return (
@ -416,6 +431,15 @@ const styles = StyleSheet.create({
opacity: 0.8, opacity: 0.8,
}, },
/* ---- 通用分区标题 ---- */ /* ---- 通用分区标题 ---- */
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 32,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

3
app/(tabs)/profile.tsx

@ -3,7 +3,6 @@ import { LinearGradient } from 'expo-linear-gradient';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator, ActivityIndicator,
Alert,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -285,7 +284,7 @@ export default function ProfileScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<View style={styles.sectionBar} /> <View style={styles.sectionBar} />
<Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>八大维度评分</Text> <Text style={[styles.sectionTitle, styles.sectionTitleGrow]}>八大维度评分</Text>
<Pressable onPress={() => Alert.alert('评分申诉', '评分申诉功能开发中,敬请期待')} hitSlop={6}> <Pressable onPress={() => router.push('/appeal')} hitSlop={6}>
<Text style={styles.sectionLink}>评分申诉</Text> <Text style={styles.sectionLink}>评分申诉</Text>
</Pressable> </Pressable>
</View> </View>

20
app/ability.tsx

@ -257,9 +257,14 @@ export default function AbilityScreen() {
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>针对性提升建议</Text> <Text style={styles.sectionTitle}>针对性提升建议</Text>
{ability.improvements.map((item) => ( {ability.improvements.length === 0 ? (
<ImprovementCard key={item.id} item={item} /> <View style={styles.emptyBox}>
))} <Ionicons name="bulb-outline" size={28} color={colors.textHint} />
<Text style={styles.emptyText}>完成测评后,这里会给出针对性的提升建议</Text>
</View>
) : (
ability.improvements.map((item) => <ImprovementCard key={item.id} item={item} />)
)}
</View> </View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
@ -298,6 +303,15 @@ const styles = StyleSheet.create({
percentile: { fontSize: 13, color: colors.successText, marginTop: 8 }, percentile: { fontSize: 13, color: colors.successText, marginTop: 8 },
scoreIcon: { justifyContent: 'center' }, scoreIcon: { justifyContent: 'center' },
section: { marginTop: 16 }, section: { marginTop: 16 },
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 28,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 13, color: colors.textSecondary, textAlign: 'center' },
sectionTitle: { fontSize: 18, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 }, sectionTitle: { fontSize: 18, fontWeight: '700', color: colors.textPrimary, marginBottom: 12 },
dimCard: { dimCard: {
backgroundColor: colors.white, backgroundColor: colors.white,

10
app/article/[id].tsx

@ -4,7 +4,7 @@ import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useGrowthData } from '../../src/hooks'; import { useGrowthData } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { formatViews } from '../../src/utils'; import { formatViews } from '../../src/utils';
import type { Article } from '../../src/types'; import type { Article } from '../../src/types';
@ -51,13 +51,7 @@ export default function ArticleScreen() {
{loading ? ( {loading ? (
<LoadingState text="加载文章中…" /> <LoadingState text="加载文章中…" />
) : error ? ( ) : error ? (
<EmptyState <ErrorState onRetry={refresh} />
icon="alert-circle-outline"
title="加载失败"
subtitle="请稍后重试"
actionLabel="重试"
onAction={refresh}
/>
) : !article ? ( ) : !article ? (
<EmptyState icon="document-text-outline" title="文章不存在" subtitle="该内容可能已下架" /> <EmptyState icon="document-text-outline" title="文章不存在" subtitle="该内容可能已下架" />
) : ( ) : (

12
app/candidate-detail.tsx

@ -1,7 +1,6 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { import {
ActivityIndicator,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@ -11,6 +10,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg'; import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg';
import { useCandidateDetail } from '../src/hooks'; import { useCandidateDetail } from '../src/hooks';
import { LoadingState, ErrorState } from '../src/components/StateView';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { CandidateAbility, CandidateReview } from '../src/types'; import type { CandidateAbility, CandidateReview } from '../src/types';
@ -299,12 +299,12 @@ export default function CandidateDetailScreen() {
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const candidateId = (id && id.length > 0) ? id : 'candidate-1'; const candidateId = (id && id.length > 0) ? id : 'candidate-1';
const { candidate, loading, error, invite, inviting, invited } = useCandidateDetail(candidateId); const { candidate, loading, error, refresh, invite, inviting, invited } = useCandidateDetail(candidateId);
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} /> <LoadingState text="加载中…" />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -312,7 +312,7 @@ export default function CandidateDetailScreen() {
if (error || !candidate) { if (error || !candidate) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -360,10 +360,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pageBg, backgroundColor: colors.pageBg,
}, },
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

64
app/chat/[id].tsx

@ -27,7 +27,6 @@ function Header({
avatar, avatar,
online, online,
onBack, onBack,
onMore,
}: { }: {
contactName: string; contactName: string;
companyName: string; companyName: string;
@ -35,7 +34,6 @@ function Header({
avatar: string; avatar: string;
online: boolean; online: boolean;
onBack: () => void; onBack: () => void;
onMore: () => void;
}) { }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
@ -58,9 +56,9 @@ function Header({
</View> </View>
</View> </View>
<Pressable style={styles.headerBtn} onPress={onMore} hitSlop={8}> {/* 原先这里是「更多」按钮,但项目没有单聊菜单页,点了只能弹「功能开发中」。
<Ionicons name="ellipsis-horizontal" size={22} color={colors.textPrimary} /> 按「不留假交互」的口径摘掉,保留占位以维持标题居中(原先为空函数,点了无反应) */}
</Pressable> <View style={styles.headerBtn} />
</View> </View>
); );
} }
@ -149,10 +147,8 @@ function InterviewCard({ card }: { card: ChatCard }) {
</View> </View>
))} ))}
</View> </View>
<Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> {/* 原先这里挂着「查看全部 12 道预测题」按钮,但项目没有面试题库页。
<Text style={styles.cardActionText}>{card.actionText}</Text> 卡片已经把 mock 给出的题目列全了,故摘掉按钮,不留「点了没反应」的入口 */}
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
</View> </View>
); );
} }
@ -167,10 +163,8 @@ function SalaryCard({ card }: { card: ChatCard }) {
{card.subtitle && ( {card.subtitle && (
<Text style={styles.cardSubtitle}>{card.subtitle}</Text> <Text style={styles.cardSubtitle}>{card.subtitle}</Text>
)} )}
<Pressable style={styles.cardAction} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> {/* 原先挂着「查看详细薪资报告」按钮,但项目没有该岗位的详细薪资报告页
<Text style={styles.cardActionText}>{card.actionText}</Text> (`/tools/salary-calculator` 是通用计算器,不是本岗位报告)。摘掉按钮 */}
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable>
</View> </View>
); );
} }
@ -186,10 +180,13 @@ function OfferCard({ card }: { card: ChatCard }) {
{card.subtitle && <Text style={styles.cardOfferSubtitle}>{card.subtitle}</Text>} {card.subtitle && <Text style={styles.cardOfferSubtitle}>{card.subtitle}</Text>}
</View> </View>
</View> </View>
{/* 只有下发了按钮文案才渲染 —— 没有文案的按钮会显示成一条空白的可点区域 */}
{card.actionText ? (
<Pressable style={styles.cardOfferBtn} onPress={() => router.push('/jobs/offer-compare')}> <Pressable style={styles.cardOfferBtn} onPress={() => router.push('/jobs/offer-compare')}>
<Text style={styles.cardOfferBtnText}>{card.actionText}</Text> <Text style={styles.cardOfferBtnText}>{card.actionText}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.actionText} /> <Ionicons name="chevron-forward" size={14} color={colors.actionText} />
</Pressable> </Pressable>
) : null}
</View> </View>
); );
} }
@ -264,16 +261,30 @@ function InputBar({
onSend: () => void; onSend: () => void;
sending: boolean; sending: boolean;
}) { }) {
/*
* 表情 / 图片 / 文件 / 加号四个图标保留:输入区四图标是行业标准布局,删掉会让输入区显得残缺。
* 但它们依赖后端(附件上传、表情资源接口),本机给不了,故按项目 4.4 节「AI 按钮」的先例
* 保留入口 + 如实提示,不假装成功。
*
* 下方标记是本项目「知情保留的后端待办」记号:挡住审计脚本的误判,
* 又能在检索时把已知欠账数出来,不混进「有意为之」里消失。
* 后端就绪后 `node scripts/check-affordances.mjs --pending` 可一次列出全部欠账。
*/
const showPendingHint = () => {
// affordance-pending-backend
Alert.alert('暂未开放', '附件功能需后端支持,当前版本暂未开放');
};
return ( return (
<View style={styles.inputBar}> <View style={styles.inputBar}>
<View style={styles.inputLeftActions}> <View style={styles.inputLeftActions}>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> <Pressable style={styles.inputIconBtn} onPress={showPendingHint}>
<Ionicons name="happy-outline" size={24} color={colors.textSecondary} /> <Ionicons name="happy-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> <Pressable style={styles.inputIconBtn} onPress={showPendingHint}>
<Ionicons name="image-outline" size={24} color={colors.textSecondary} /> <Ionicons name="image-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
<Pressable style={styles.inputIconBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> <Pressable style={styles.inputIconBtn} onPress={showPendingHint}>
<Ionicons name="folder-outline" size={24} color={colors.textSecondary} /> <Ionicons name="folder-outline" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@ -289,7 +300,7 @@ function InputBar({
maxLength={500} maxLength={500}
editable={!sending} editable={!sending}
/> />
<Pressable style={styles.plusBtn} onPress={() => Alert.alert('提示', '功能开发中,敬请期待')}> <Pressable style={styles.plusBtn} onPress={showPendingHint}>
<Ionicons name="add" size={22} color={colors.textSecondary} /> <Ionicons name="add" size={22} color={colors.textSecondary} />
</Pressable> </Pressable>
</View> </View>
@ -325,10 +336,7 @@ export default function ChatDetailScreen() {
setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100); setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 100);
}, [input, sending, sendMessage]); }, [input, sending, sendMessage]);
// 右上角「更多」暂无对应菜单页,按本文件既有约定弹提示(此前为空函数,点了无反应) // 右上角「更多」入口已摘除:项目没有单聊菜单页,留着只能弹「功能开发中」
const handleMore = () => {
Alert.alert('提示', '功能开发中,敬请期待');
};
if (loading) { if (loading) {
return ( return (
@ -362,7 +370,6 @@ export default function ChatDetailScreen() {
avatar={detail.avatar} avatar={detail.avatar}
online={detail.online} online={detail.online}
onBack={() => router.back()} onBack={() => router.back()}
onMore={handleMore}
/> />
{detail.matchInfo && ( {detail.matchInfo && (
@ -624,19 +631,6 @@ const styles = StyleSheet.create({
color: colors.textSecondary, color: colors.textSecondary,
marginBottom: 10, marginBottom: 10,
}, },
cardAction: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 8,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
cardActionText: {
flex: 1,
fontSize: 12,
color: colors.primary,
fontWeight: '600',
},
cardOffer: { cardOffer: {
width: 260, width: 260,
backgroundColor: colors.actionLight, backgroundColor: colors.actionLight,

23
app/holland-result.tsx

@ -13,6 +13,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon } from 'react-native-svg'; import Svg, { Circle, Line, Polygon } from 'react-native-svg';
import { useHollandAssessment } from '../src/hooks'; import { useHollandAssessment } from '../src/hooks';
import { buildHollandReportText } from '../src/api';
import { shareText } from '../src/lib/shareFile';
import { colors, headerGradientShort } from '../src/theme'; import { colors, headerGradientShort } from '../src/theme';
import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types'; import type { HollandAction, HollandDimension, HollandTypeAnalysis } from '../src/types';
@ -85,16 +87,20 @@ function RadarChart({ dimensions }: { dimensions: HollandDimension[] }) {
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onShare }: { onBack: () => void; onShare: () => void }) { function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>测评结果</Text> <Text style={styles.headerTitle}>测评结果</Text>
{onShare ? (
<Pressable style={styles.headerBtn} onPress={onShare} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onShare} hitSlop={8}>
<Ionicons name="share-outline" size={22} color={colors.textPrimary} /> <Ionicons name="share-outline" size={22} color={colors.textPrimary} />
</Pressable> </Pressable>
) : (
<View style={styles.headerBtn} />
)}
</View> </View>
); );
} }
@ -242,15 +248,20 @@ export default function HollandResultScreen() {
const { assessment, loading, error, refresh } = useHollandAssessment(); const { assessment, loading, error, refresh } = useHollandAssessment();
const result = assessment?.result; const result = assessment?.result;
// 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈) /**
const handleShare = () => { * 分享测评报告:把结果拼成文本经系统分享面板发出。
Alert.alert('提示', '分享测评报告功能开发中,敬请期待'); * `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleShare = async () => {
if (!result) return;
const outcome = await shareText(buildHollandReportText(result), '霍兰德职业兴趣测评报告');
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
}; };
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} />
<Loading /> <Loading />
</SafeAreaView> </SafeAreaView>
); );
@ -259,7 +270,7 @@ export default function HollandResultScreen() {
if (error || !result) { if (error || !result) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} />
<ErrorState onRetry={refresh} /> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );

21
app/hr-evaluation.tsx

@ -286,9 +286,15 @@ export default function HREvaluationScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>HR评价({evaluation.reviews.length})</Text> <Text style={styles.sectionTitle}>HR评价({evaluation.reviews.length})</Text>
</View> </View>
{evaluation.reviews.map((review) => ( {/* 标题里带着数量,为 0 时下方不能是一片空白 */}
<ReviewCard key={review.id} review={review} /> {evaluation.reviews.length === 0 ? (
))} <View style={styles.emptyBox}>
<Ionicons name="chatbubble-ellipses-outline" size={28} color={colors.textHint} />
<Text style={styles.emptyText}>暂时还没有 HR 留下评价</Text>
</View>
) : (
evaluation.reviews.map((review) => <ReviewCard key={review.id} review={review} />)
)}
<RulesCard rules={evaluation.rules} /> <RulesCard rules={evaluation.rules} />
</ScrollView> </ScrollView>
@ -403,6 +409,15 @@ const styles = StyleSheet.create({
}, },
dimFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary }, dimFill: { height: 8, borderRadius: 4, backgroundColor: colors.primary },
sectionHeader: { marginBottom: 10, marginTop: 4 }, sectionHeader: { marginBottom: 10, marginTop: 4 },
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 28,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 13, color: colors.textSecondary, textAlign: 'center' },
sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary }, sectionTitle: { fontSize: 16, fontWeight: '700', color: colors.textPrimary },
reviewCard: { reviewCard: {
backgroundColor: colors.white, backgroundColor: colors.white,

11
app/interview-score.tsx

@ -1,7 +1,6 @@
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
ActivityIndicator,
Alert, Alert,
Pressable, Pressable,
ScrollView, ScrollView,
@ -13,6 +12,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg'; import Svg, { Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useInterviewScore } from '../src/hooks'; import { useInterviewScore } from '../src/hooks';
import { LoadingState, ErrorState } from '../src/components/StateView';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import { formatDateTime } from '../src/utils'; import { formatDateTime } from '../src/utils';
import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types'; import type { HireSuggestion, InterviewScoreData, InterviewScoreDimension, InterviewScoreTag } from '../src/types';
@ -278,6 +278,7 @@ export default function InterviewScoreScreen() {
discardDraft, discardDraft,
hasPendingDraft, hasPendingDraft,
draftSavedAt, draftSavedAt,
refresh,
} = useInterviewScore('interview-1'); } = useInterviewScore('interview-1');
/** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */ /** 保存草稿:成功后就地在按钮旁显示保存时间,失败给出提示 */
@ -295,7 +296,7 @@ export default function InterviewScoreScreen() {
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} /> <LoadingState text="加载中…" />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -303,7 +304,7 @@ export default function InterviewScoreScreen() {
if (error || !score) { if (error || !score) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -373,10 +374,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pageBg, backgroundColor: colors.pageBg,
}, },
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

27
app/interview.tsx

@ -11,6 +11,8 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAIInterview } from '../src/hooks'; import { useAIInterview } from '../src/hooks';
import { buildInterviewConfigText } from '../src/api';
import { shareText } from '../src/lib/shareFile';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { import type {
InterviewAbilityImprovement, InterviewAbilityImprovement,
@ -32,16 +34,20 @@ const COLOR_TOKENS: Record<string, string> = {
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack, onShare }: { onBack: () => void; onShare: () => void }) { function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>AI模拟面试</Text> <Text style={styles.headerTitle}>AI模拟面试</Text>
{onShare ? (
<Pressable style={styles.headerBtn} onPress={onShare} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onShare} hitSlop={8}>
<Ionicons name="share-outline" size={22} color={colors.textPrimary} /> <Ionicons name="share-outline" size={22} color={colors.textPrimary} />
</Pressable> </Pressable>
) : (
<View style={styles.headerBtn} />
)}
</View> </View>
); );
} }
@ -288,15 +294,24 @@ export default function InterviewScreen() {
await start(); await start();
}; };
// 右上角「分享」暂无对应页面,按项目约定弹提示(此前绑定空函数,点击无任何反馈) /**
const handleShare = () => { * 分享面试配置:拼成文本经系统分享面板发出。
Alert.alert('提示', '分享面试配置功能开发中,敬请期待'); * 传入的是**页面上的当前选择**(模式 / 题型 / 专项),不是接口默认值 —— 用户改过之后两者不同。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleShare = async () => {
if (!data) return;
const outcome = await shareText(
buildInterviewConfigText(data, mode, questionTypes, specials),
'AI 模拟面试配置',
);
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
}; };
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} />
<Loading /> <Loading />
</SafeAreaView> </SafeAreaView>
); );
@ -305,7 +320,7 @@ export default function InterviewScreen() {
if (error || !data) { if (error || !data) {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<Header onBack={() => router.back()} onShare={handleShare} /> <Header onBack={() => router.back()} />
<ErrorState onRetry={refresh} /> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );

10
app/job-evaluations.tsx

@ -2,7 +2,6 @@ import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
ActivityIndicator,
Alert, Alert,
Modal, Modal,
Pressable, Pressable,
@ -15,6 +14,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg'; import Svg, { Line, Path, Polygon, Text as SvgText } from 'react-native-svg';
import { useJobEvaluationEmptyState, useJobEvaluations } from '../src/hooks'; import { useJobEvaluationEmptyState, useJobEvaluations } from '../src/hooks';
import { LoadingState, ErrorState } from '../src/components/StateView';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { import type {
JobEvaluationDimension, JobEvaluationDimension,
@ -455,7 +455,7 @@ export default function JobEvaluationsScreen() {
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} /> <LoadingState text="加载中…" />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -463,7 +463,7 @@ export default function JobEvaluationsScreen() {
if (error) { if (error) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -519,10 +519,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pageBg, backgroundColor: colors.pageBg,
}, },
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

10
app/job-publish.tsx

@ -12,6 +12,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useJobPublish } from '../src/hooks'; import { useJobPublish } from '../src/hooks';
import { LoadingState, ErrorState } from '../src/components/StateView';
import { colors } from '../src/theme'; import { colors } from '../src/theme';
import type { JobPublishFormData } from '../src/types'; import type { JobPublishFormData } from '../src/types';
@ -225,12 +226,13 @@ export default function JobPublishScreen() {
runEstimate, runEstimate,
publishing, publishing,
publish, publish,
refresh,
} = useJobPublish(); } = useJobPublish();
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<ActivityIndicator size="large" color={colors.primary} /> <LoadingState text="加载中…" />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -238,7 +240,7 @@ export default function JobPublishScreen() {
if (error || !form) { if (error || !form) {
return ( return (
<SafeAreaView style={styles.loadingContainer} edges={['top']}> <SafeAreaView style={styles.loadingContainer} edges={['top']}>
<Text style={styles.errorText}>加载失败,请稍后重试</Text> <ErrorState onRetry={refresh} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -386,10 +388,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.pageBg, backgroundColor: colors.pageBg,
}, },
errorText: {
color: colors.textSecondary,
fontSize: 14,
},
header: { header: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

77
app/jobs/[id].tsx

@ -14,6 +14,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg'; import Svg, { Circle, Line, Polygon, Text as SvgText } from 'react-native-svg';
import { useJobDetail } from '../../src/hooks'; import { useJobDetail } from '../../src/hooks';
import { buildJobShareText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { formatThousands } from '../../src/utils'; import { formatThousands } from '../../src/utils';
import type { JobDimensionScore } from '../../src/types'; import type { JobDimensionScore } from '../../src/types';
@ -105,17 +107,21 @@ function RadarChart({
/* ===================== 子组件 ===================== */ /* ===================== 子组件 ===================== */
/** 顶部导航:返回 + 标题 + 分享 */ /** 顶部导航:返回 + 标题 + 分享(onShare 缺省时不渲染分享按钮,避免留下点了没反应的入口) */
function Header({ onBack }: { onBack: () => void }) { function Header({ onBack, onShare }: { onBack: () => void; onShare?: () => void }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>岗位详情</Text> <Text style={styles.headerTitle}>岗位详情</Text>
<Pressable style={styles.headerBtn} onPress={() => Alert.alert('分享', '分享功能开发中,敬请期待')} hitSlop={8}> {onShare ? (
<Pressable style={styles.headerBtnRight} onPress={onShare} hitSlop={8}>
<Ionicons name="share-social-outline" size={22} color={colors.textPrimary} /> <Ionicons name="share-social-outline" size={22} color={colors.textPrimary} />
</Pressable> </Pressable>
) : (
<View style={styles.headerBtnRight} />
)}
</View> </View>
); );
} }
@ -153,6 +159,19 @@ export default function JobDetailScreen() {
const [comment, setComment] = useState(''); const [comment, setComment] = useState('');
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
/**
* 分享岗位:把当前岗位拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「分享成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleShare = async () => {
if (!detail) return;
const outcome = await shareText(
buildJobShareText(detail, overallScore, salaryData, reviews.length),
`${detail.positionName}|${detail.companyName}`,
);
if (!outcome.ok) Alert.alert('无法分享', outcome.message);
};
// 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新) // 发布评价:空内容拦截,成功后清空输入框(列表由 hook 自动刷新)
const handlePublish = async () => { const handlePublish = async () => {
const text = comment.trim(); const text = comment.trim();
@ -200,7 +219,7 @@ export default function JobDetailScreen() {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} onShare={handleShare} />
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
@ -237,7 +256,8 @@ export default function JobDetailScreen() {
</View> </View>
{/* 2. 岗位综合评分 */} {/* 2. 岗位综合评分 */}
<SectionTitle title="岗位综合评分" link="评分说明" onLink={() => Alert.alert('评分说明', '评分规则说明开发中,敬请期待')} /> {/* 「评分说明」入口已摘除:项目没有评分规则说明页 */}
<SectionTitle title="岗位综合评分" />
<View style={styles.card}> <View style={styles.card}>
<View style={styles.scoreRow}> <View style={styles.scoreRow}>
<RadarChart dimensions={overallScore.dimensions} /> <RadarChart dimensions={overallScore.dimensions} />
@ -259,7 +279,8 @@ export default function JobDetailScreen() {
</View> </View>
{/* 3. 真实薪资区间 */} {/* 3. 真实薪资区间 */}
<SectionTitle title="真实薪资区间" link="全部" onLink={() => Alert.alert('薪资详情', '完整薪资数据开发中,敬请期待')} /> {/* 「全部」入口已摘除:下面这张卡已经把 4 档经验区间列全了,点进去没有更多内容 */}
<SectionTitle title="真实薪资区间" />
<View style={styles.card}> <View style={styles.card}>
{salaryData.ranges.map((range, index) => ( {salaryData.ranges.map((range, index) => (
<View <View
@ -277,6 +298,14 @@ export default function JobDetailScreen() {
{/* 4. 员工评价 */} {/* 4. 员工评价 */}
<SectionTitle title="员工评价" /> <SectionTitle title="员工评价" />
{reviews.length === 0 && (
/* 标题是常驻的,没有这一块时下方会是一片空白,看上去像加载坏了 */
<View style={styles.emptyCard}>
<Ionicons name="chatbubble-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>暂无员工评价</Text>
<Text style={styles.emptyHint}>成为第一个分享这家公司真实情况的人</Text>
</View>
)}
{reviews.map((review) => ( {reviews.map((review) => (
<View key={review.id} style={styles.card}> <View key={review.id} style={styles.card}>
<View style={styles.reviewHeader}> <View style={styles.reviewHeader}>
@ -369,9 +398,25 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.textPrimary,
}, },
/**
* 头部按钮:返回靠左、分享靠右。
* 原先两者共用一条**只写了 `top`、没写 `left`/`right`** 的 absolute 样式,
* 在 `justifyContent: 'center'` 的父容器里落到同一处彼此重叠 —— 返回键与分享键
* 都不在用户预期位置(分享键实测点不到)。absolute 必须成对写清水平锚点。
*/
headerBtn: { headerBtn: {
position: 'absolute', position: 'absolute',
top: 0, top: 0,
left: 0,
width: 48,
height: 48,
alignItems: 'center',
justifyContent: 'center',
},
headerBtnRight: {
position: 'absolute',
top: 0,
right: 0,
width: 48, width: 48,
height: 48, height: 48,
alignItems: 'center', alignItems: 'center',
@ -391,6 +436,26 @@ const styles = StyleSheet.create({
marginBottom: 16, marginBottom: 16,
...shadow, ...shadow,
}, },
/* ---- 空态 ---- */
emptyCard: {
backgroundColor: colors.white,
borderRadius: 16,
paddingVertical: 32,
paddingHorizontal: 16,
marginBottom: 16,
alignItems: 'center',
gap: 8,
},
emptyText: {
fontSize: 14,
fontWeight: '600',
color: colors.textSecondary,
},
emptyHint: {
fontSize: 12,
color: colors.textHint,
textAlign: 'center',
},
/* ---- 基本信息 ---- */ /* ---- 基本信息 ---- */
basicTop: { basicTop: {
flexDirection: 'row', flexDirection: 'row',

112
app/jobs/offer-compare.tsx

@ -12,22 +12,23 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useOfferCompareData } from '../../src/hooks'; import { useOfferCompareData } from '../../src/hooks';
import { buildOfferCompareText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { EmptyState } from '../../src/components/StateView';
import { colors, headerGradientSolid } from '../../src/theme'; import { colors, headerGradientSolid } from '../../src/theme';
import type { OfferBenefitGroup, OfferItem, OfferScore, WorkLifeBalanceRow } from '../../src/types'; import type { OfferBenefitGroup, OfferItem, OfferScore, WorkLifeBalanceRow } from '../../src/types';
/* ===================== 子组件 ===================== */ /* ===================== 子组件 ===================== */
/** 顶部导航:返回 + 标题 + 编辑 */ /** 顶部导航:返回 + 标题。原先右侧还有「编辑」,但页面从未传入 onEdit,是个点了没反应的入口,已摘掉 */
function Header({ onBack, onEdit }: { onBack: () => void; onEdit?: () => void }) { function Header({ onBack }: { onBack: () => void }) {
return ( return (
<View style={styles.header}> <View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}> <Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Text style={styles.headerTitle}>Offer对比</Text> <Text style={styles.headerTitle}>Offer对比</Text>
<Pressable style={styles.headerBtn} onPress={onEdit} hitSlop={8}> <View style={styles.headerBtn} />
<Text style={styles.headerEdit}>编辑</Text>
</Pressable>
</View> </View>
); );
} }
@ -52,16 +53,6 @@ function OfferCard({ offer, selected }: { offer: OfferItem; selected: boolean })
); );
} }
/** 添加 Offer */
function AddOfferCard({ onPress }: { onPress: () => void }) {
return (
<Pressable style={styles.addOfferCard} onPress={onPress}>
<Ionicons name="add" size={28} color={colors.textHint} />
<Text style={styles.addOfferText}>添加Offer</Text>
</Pressable>
);
}
/** 星级评分 */ /** 星级评分 */
function StarRating({ stars }: { stars: number }) { function StarRating({ stars }: { stars: number }) {
return ( return (
@ -203,8 +194,12 @@ function WorkLifeBalance({ offers, rows }: { offers: OfferItem[]; rows: WorkLife
); );
} }
/** AI 智能建议 */ /**
function AISuggestionCard({ summary, detail, reportAction }: { summary: string; detail: string; reportAction: string }) { * AI 智能建议。
* 原先底部挂着「查看完整分析报告」按钮,但建议全文(summary + detail)本卡片已经展示完,
* 点进去没有任何更多内容 —— 属假入口,已摘掉。要留档可用页面底部的「导出对比报告」。
*/
function AISuggestionCard({ summary, detail }: { summary: string; detail: string }) {
return ( return (
<LinearGradient colors={headerGradientSolid} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.aiCard}> <LinearGradient colors={headerGradientSolid} start={{ x: 0, y: 0 }} end={{ x: 1, y: 1 }} style={styles.aiCard}>
<View style={styles.aiHeader}> <View style={styles.aiHeader}>
@ -213,13 +208,6 @@ function AISuggestionCard({ summary, detail, reportAction }: { summary: string;
</View> </View>
<Text style={styles.aiSummary}>{summary}</Text> <Text style={styles.aiSummary}>{summary}</Text>
<Text style={styles.aiDetail}>{detail}</Text> <Text style={styles.aiDetail}>{detail}</Text>
<Pressable
style={styles.aiReportBtn}
onPress={() => Alert.alert('AI智能建议', '完整建议报告功能开发中,敬请期待')}
>
<Text style={styles.aiReportText}>{reportAction}</Text>
<Ionicons name="chevron-forward" size={14} color={colors.white} />
</Pressable>
</LinearGradient> </LinearGradient>
); );
} }
@ -234,6 +222,16 @@ export default function OfferCompareScreen() {
const { data, offers, scores, benefitGroups, workLifeBalance, aiSuggestion, actions, loading, error, refresh } = const { data, offers, scores, benefitGroups, workLifeBalance, aiSuggestion, actions, loading, error, refresh } =
useOfferCompareData(); useOfferCompareData();
/**
* 导出对比报告:拼成文本经系统分享面板发出(与项目其它导出入口同一套做法)。
* `ok` 只代表面板已拉起,不能据此提示「导出成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleExport = async () => {
if (!data) return;
const outcome = await shareText(buildOfferCompareText(data), 'Offer 对比报告');
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (error) { if (error) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
@ -269,16 +267,23 @@ export default function OfferCompareScreen() {
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
{/* Offer 卡片头部 */} {/* Offer 卡片头部。原先尾部跟着「添加Offer」卡片,但项目没有新增 Offer 的表单页、
后端也没有 POST /api/offers —— 属假入口,已摘掉 */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.offerList}> <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.offerList}>
{offers.map((offer) => ( {offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} selected={offer.selected} /> <OfferCard key={offer.id} offer={offer} selected={offer.selected} />
))} ))}
<AddOfferCard
onPress={() => Alert.alert('添加Offer', 'Offer添加功能开发中,敬请期待')}
/>
</ScrollView> </ScrollView>
{/* 对比结果。offers 为空时下方三个区块会渲染成空壳,故整体给出空态 */}
{offers.length === 0 ? (
<EmptyState
icon="git-compare-outline"
title="暂无可对比的 Offer"
subtitle="收到 Offer 后就能在这里横向对比薪资、福利与工作生活平衡"
/>
) : (
<>
{/* 综合评分对比 */} {/* 综合评分对比 */}
<ScoreComparison offers={offers} scores={scores} /> <ScoreComparison offers={offers} scores={scores} />
@ -290,11 +295,9 @@ export default function OfferCompareScreen() {
{/* AI 智能建议 */} {/* AI 智能建议 */}
{aiSuggestion && ( {aiSuggestion && (
<AISuggestionCard <AISuggestionCard summary={aiSuggestion.summary} detail={aiSuggestion.detail} />
summary={aiSuggestion.summary} )}
detail={aiSuggestion.detail} </>
reportAction={aiSuggestion.reportAction}
/>
)} )}
<View style={styles.bottomGap} /> <View style={styles.bottomGap} />
@ -302,15 +305,17 @@ export default function OfferCompareScreen() {
{/* 底部操作栏 */} {/* 底部操作栏 */}
<View style={styles.actionBar}> <View style={styles.actionBar}>
<Pressable <Pressable style={styles.exportBtn} onPress={handleExport}>
style={styles.exportBtn}
onPress={() => Alert.alert('导出对比报告', '报告导出功能开发中,敬请期待')}
>
<Text style={styles.exportText}>{actions?.exportLabel ?? '导出对比报告'}</Text> <Text style={styles.exportText}>{actions?.exportLabel ?? '导出对比报告'}</Text>
</Pressable> </Pressable>
{/* 「接受 Offer」需要 POST /api/offers/:id/accept,须后端支持;保留入口但如实说明,
不假装成功(沿用 4.4 节 AI 按钮的处理方式) */}
<Pressable <Pressable
style={styles.acceptBtn} style={styles.acceptBtn}
onPress={() => Alert.alert('接受Offer', 'Offer确认功能开发中,敬请期待')} onPress={() =>
// affordance-pending-backend
Alert.alert('接受 Offer', '该操作需要后端支持,当前版本暂未开放')
}
> >
<Text style={styles.acceptText}>{actions?.acceptLabel ?? '接受Offer'}</Text> <Text style={styles.acceptText}>{actions?.acceptLabel ?? '接受Offer'}</Text>
</Pressable> </Pressable>
@ -346,11 +351,6 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
color: colors.textPrimary, color: colors.textPrimary,
}, },
headerEdit: {
fontSize: 14,
color: colors.primary,
fontWeight: '600',
},
/* ---- 状态 ---- */ /* ---- 状态 ---- */
stateWrap: { stateWrap: {
@ -444,21 +444,6 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
fontWeight: '800', fontWeight: '800',
}, },
addOfferCard: {
width: 80,
padding: 12,
borderRadius: 12,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.borderStrong,
alignItems: 'center',
justifyContent: 'center',
},
addOfferText: {
marginTop: 6,
fontSize: 12,
color: colors.textHint,
},
/* ---- 通用卡片与分区标题 ---- */ /* ---- 通用卡片与分区标题 ---- */
card: { card: {
@ -619,17 +604,6 @@ const styles = StyleSheet.create({
opacity: 0.9, opacity: 0.9,
lineHeight: 19, lineHeight: 19,
}, },
aiReportBtn: {
marginTop: 12,
flexDirection: 'row',
alignItems: 'center',
},
aiReportText: {
fontSize: 13,
color: colors.textInverse,
fontWeight: '600',
},
/* ---- 底部操作栏 ---- */ /* ---- 底部操作栏 ---- */
actionBar: { actionBar: {
position: 'absolute', position: 'absolute',

35
app/jobs/rank.tsx

@ -12,6 +12,8 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import Svg, { Circle, Rect } from 'react-native-svg'; import Svg, { Circle, Rect } from 'react-native-svg';
import { useRankData } from '../../src/hooks'; import { useRankData } from '../../src/hooks';
import { buildRankReportText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import { formatThousands } from '../../src/utils'; import { formatThousands } from '../../src/utils';
import type { DimensionPercentile, RankData, RankEmptyState, TopCandidate } from '../../src/types'; import type { DimensionPercentile, RankData, RankEmptyState, TopCandidate } from '../../src/types';
@ -35,7 +37,7 @@ function Header({ onBack }: { onBack: () => void }) {
/* ===================== 子组件:综合排名卡片 ===================== */ /* ===================== 子组件:综合排名卡片 ===================== */
function RankSummary({ rank }: { rank: RankData }) { function RankSummary({ rank, onExport }: { rank: RankData; onExport: () => void }) {
return ( return (
<View style={styles.rankCard}> <View style={styles.rankCard}>
<Text style={styles.rankNumber}>第 {rank.rank} 名</Text> <Text style={styles.rankNumber}>第 {rank.rank} 名</Text>
@ -51,13 +53,11 @@ function RankSummary({ rank }: { rank: RankData }) {
<Text style={styles.rankPosition}>百分位排名:{rank.rankPosition}</Text> <Text style={styles.rankPosition}>百分位排名:{rank.rankPosition}</Text>
<Pressable {/* 本页已把维度百分位、TOP 候选人与全部提升建议都渲染出来了,
style={styles.detailLink} 没有「更详细」的隐藏内容可跳,因此这里是导出/分享而非跳转 —— 文案与图标如实反映 */}
onPress={() => Alert.alert('详细分析', '详细竞争力分析报告功能开发中,敬请期待')} <Pressable style={styles.detailLink} onPress={onExport} hitSlop={6}>
hitSlop={6} <Text style={styles.detailLinkText}>导出分析报告</Text>
> <Ionicons name="share-outline" size={14} color={colors.primary} />
<Text style={styles.detailLinkText}>查看我的详细分析</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</Pressable> </Pressable>
</View> </View>
); );
@ -246,6 +246,19 @@ export default function RankScreen() {
const { rankData, dimensions, candidates, tips, emptyState, hasData, loading, error, refresh } = const { rankData, dimensions, candidates, tips, emptyState, hasData, loading, error, refresh } =
useRankData(jobId || '1'); useRankData(jobId || '1');
/**
* 导出竞争力分析报告:把本页已展示的排名、维度百分位与提升建议拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「导出成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleExport = async () => {
if (!rankData) return;
const outcome = await shareText(
buildRankReportText({ hasData: true, rankData, dimensions, tips }),
'竞争力分析报告',
);
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}> <SafeAreaView style={styles.safeArea} edges={['top', 'bottom']}>
@ -316,7 +329,7 @@ export default function RankScreen() {
</View> </View>
{/* 模块3:综合排名卡片 */} {/* 模块3:综合排名卡片 */}
<RankSummary rank={rankData} /> <RankSummary rank={rankData} onExport={handleExport} />
{/* 模块4:竞争力百分位分布图 */} {/* 模块4:竞争力百分位分布图 */}
<View style={styles.section}> <View style={styles.section}>
@ -344,13 +357,13 @@ export default function RankScreen() {
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>提升建议</Text> <Text style={styles.sectionTitle}>提升建议</Text>
{tips.map((tip) => {tips.map((tip) =>
tip.type === 'action' ? ( tip.type === 'action' && tip.route ? (
<TipItem <TipItem
key={tip.id} key={tip.id}
title={tip.title} title={tip.title}
description={tip.description} description={tip.description}
type="action" type="action"
onAction={() => Alert.alert('提升建议', '该提升项功能开发中,敬请期待')} onAction={() => router.push(tip.route as string)}
/> />
) : ( ) : (
<TipItem <TipItem

30
app/legal.tsx

@ -218,7 +218,11 @@ export default function LegalScreen() {
/> />
)} )}
{/* 下面两个网格此前没有空态保护,同文件的模板/FAQ/套餐三处都做了 —— 统一补齐 */}
<SectionTitle title="免费法律援助" /> <SectionTitle title="免费法律援助" />
{freeServices.length === 0 ? (
<EmptyGridHint text="暂无免费法律服务" />
) : (
<View style={styles.serviceGrid}> <View style={styles.serviceGrid}>
{freeServices.map((service) => ( {freeServices.map((service) => (
<ServiceCard <ServiceCard
@ -229,8 +233,12 @@ export default function LegalScreen() {
/> />
))} ))}
</View> </View>
)}
<SectionTitle title="会员法律服务" /> <SectionTitle title="会员法律服务" />
{memberServices.length === 0 ? (
<EmptyGridHint text="暂无会员法律服务" />
) : (
<View style={styles.serviceGrid}> <View style={styles.serviceGrid}>
{memberServices.map((service) => ( {memberServices.map((service) => (
<ServiceCard <ServiceCard
@ -241,6 +249,7 @@ export default function LegalScreen() {
/> />
))} ))}
</View> </View>
)}
{templates.length > 0 && ( {templates.length > 0 && (
<> <>
@ -299,6 +308,16 @@ export default function LegalScreen() {
/* ===================== 公共子组件 ===================== */ /* ===================== 公共子组件 ===================== */
/** 服务网格的空态提示(区块级,不占满整屏) */
function EmptyGridHint({ text }: { text: string }) {
return (
<View style={styles.emptyHint}>
<Ionicons name="documents-outline" size={28} color={colors.textHint} />
<Text style={styles.emptyHintText}>{text}</Text>
</View>
);
}
function SectionTitle({ title }: { title: string }) { function SectionTitle({ title }: { title: string }) {
return ( return (
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
@ -403,6 +422,17 @@ const styles = StyleSheet.create({
serviceGrid: { serviceGrid: {
gap: 8, gap: 8,
}, },
emptyHint: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 28,
alignItems: 'center',
gap: 8,
},
emptyHintText: {
fontSize: 13,
color: colors.textSecondary,
},
serviceCard: { serviceCard: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

15
app/legal/checkout.tsx

@ -4,7 +4,7 @@ import { useCallback, useState } from 'react';
import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { ActivityIndicator, Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership } from '../../src/hooks'; import { useLegalData, useLegalMembership } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { LegalPaymentChannel } from '../../src/types'; import type { LegalPaymentChannel } from '../../src/types';
@ -35,7 +35,7 @@ export default function LegalCheckoutScreen() {
const router = useRouter(); const router = useRouter();
const { planId } = useLocalSearchParams(); const { planId } = useLocalSearchParams();
const id = Array.isArray(planId) ? planId[0] : planId; const id = Array.isArray(planId) ? planId[0] : planId;
const { plans, loading } = useLegalData(); const { plans, loading, error, refresh } = useLegalData();
const { createOrder, payOrder, processing } = useLegalMembership(); const { createOrder, payOrder, processing } = useLegalMembership();
const [channel, setChannel] = useState<LegalPaymentChannel>('wechat'); const [channel, setChannel] = useState<LegalPaymentChannel>('wechat');
@ -68,6 +68,17 @@ export default function LegalCheckoutScreen() {
); );
} }
// 接口失败:必须先于「套餐不存在」判断,否则取数失败会伪装成业务空态,
// 把用户引向「从会员套餐页重新进入」这条无效的自救路径。
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ErrorState message="套餐加载失败,请重试" onRetry={refresh} />
</SafeAreaView>
);
}
// 套餐不存在(异常入口) // 套餐不存在(异常入口)
if (!plan) { if (!plan) {
return ( return (

42
app/legal/service.tsx

@ -13,7 +13,7 @@ import {
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useLegalData, useLegalMembership, useLegalServiceRequests } from '../../src/hooks'; import { useLegalData, useLegalMembership, useLegalServiceRequests } from '../../src/hooks';
import { LoadingState, EmptyState } from '../../src/components/StateView'; import { LoadingState, ErrorState, EmptyState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
/* ===================== 顶部导航 ===================== */ /* ===================== 顶部导航 ===================== */
@ -36,11 +36,12 @@ export default function LegalServiceScreen() {
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const serviceId = Array.isArray(id) ? id[0] : id; const serviceId = Array.isArray(id) ? id[0] : id;
const { services, loading } = useLegalData(); const { services, loading, error, refresh } = useLegalData();
const { membership, refresh: refreshMembership } = useLegalMembership(); const { membership, refresh: refreshMembership } = useLegalMembership();
const { const {
requests, requests,
loading: requestsLoading, loading: requestsLoading,
error: requestsError,
refresh: refreshRequests, refresh: refreshRequests,
submit, submit,
submitting, submitting,
@ -83,6 +84,17 @@ export default function LegalServiceScreen() {
); );
} }
// 接口失败:必须先于「服务不存在」判断,否则取数失败会伪装成业务空态,
// 把用户引向「从法律服务页重新进入」这条无效的自救路径。
if (error) {
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
<ErrorState message="服务加载失败,请重试" onRetry={refresh} />
</SafeAreaView>
);
}
if (!service) { if (!service) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
@ -175,6 +187,14 @@ export default function LegalServiceScreen() {
<Text style={styles.sectionTitle}>我的申请记录</Text> <Text style={styles.sectionTitle}>我的申请记录</Text>
{requestsLoading ? ( {requestsLoading ? (
<ActivityIndicator color={colors.primary} style={styles.requestsLoading} /> <ActivityIndicator color={colors.primary} style={styles.requestsLoading} />
) : requestsError ? (
// 申请记录取数失败不能渲染成「暂无申请记录」——那等于告诉用户记录没了
<View style={styles.errorCard}>
<Text style={styles.errorText}>申请记录加载失败</Text>
<Pressable onPress={refreshRequests} hitSlop={8}>
<Text style={styles.errorRetry}>重试</Text>
</Pressable>
</View>
) : myRequests.length === 0 ? ( ) : myRequests.length === 0 ? (
<View style={styles.emptyCard}> <View style={styles.emptyCard}>
<Text style={styles.emptyText}>暂无申请记录</Text> <Text style={styles.emptyText}>暂无申请记录</Text>
@ -336,6 +356,24 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
color: colors.textSecondary, color: colors.textSecondary,
}, },
errorCard: {
padding: 20,
borderRadius: 8,
backgroundColor: colors.dangerLight,
borderWidth: 1,
borderColor: colors.dangerBorder,
alignItems: 'center',
gap: 8,
},
errorText: {
fontSize: 13,
color: colors.dangerText,
},
errorRetry: {
fontSize: 13,
fontWeight: '600',
color: colors.primaryText,
},
requestList: { requestList: {
gap: 8, gap: 8,
}, },

15
app/personal-data-export.tsx

@ -347,14 +347,19 @@ export default function PersonalDataExportScreen() {
const isLast = index === exportData.recentExports.length - 1; const isLast = index === exportData.recentExports.length - 1;
return ( return (
<View key={record.id} style={isLast ? undefined : styles.rowBorder}> <View key={record.id} style={isLast ? undefined : styles.rowBorder}>
<RecentExportRow record={record} onDownload={() => Alert.alert('下载', '导出文件下载功能开发中,敬请期待')} /> {/* 下载需要后端给出文件地址(RecentExportRecord 无下载地址字段),故按项目 4.4 节先例
保留入口 + 如实说明,不假装成功 */}
<RecentExportRow
record={record}
onDownload={() =>
// affordance-pending-backend
Alert.alert('暂未开放', '导出文件下载需后端支持,当前版本暂未开放')
}
/>
</View> </View>
); );
})} })}
<Pressable style={styles.viewMoreRow} onPress={() => Alert.alert('提示', '历史导出功能开发中,敬请期待')}> {/* 原先这里挂着「查看更多历史导出」,但记录只有 mock 三条、也没有分页接口和历史页,已摘掉 */}
<Text style={styles.viewMoreText}>查看更多历史导出</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textHint} />
</Pressable>
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

24
app/progress.tsx

@ -267,9 +267,17 @@ export default function ProgressScreen() {
<View style={styles.sectionHeader}> <View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>求职中 {progress.stats.interviewing}</Text> <Text style={styles.sectionTitle}>求职中 {progress.stats.interviewing}</Text>
</View> </View>
{companies.map((company) => ( {/* 标题是常驻的,切到没有公司的 Tab 时下方会是一片空白 —— 这里给出空态。
<CompanyRow key={company.id} company={company} /> 同一文件下方对 offers 就做了保护,companies 此前漏了 */}
))} {companies.length === 0 ? (
<View style={styles.emptyBox}>
<Ionicons name="briefcase-outline" size={32} color={colors.textHint} />
<Text style={styles.emptyText}>该阶段暂无投递记录</Text>
<Text style={styles.emptyHint}>投递岗位后,进度会在这里按阶段更新</Text>
</View>
) : (
companies.map((company) => <CompanyRow key={company.id} company={company} />)
)}
{progress.offers.length > 0 && ( {progress.offers.length > 0 && (
<> <>
@ -337,6 +345,16 @@ const styles = StyleSheet.create({
tabActive: { backgroundColor: colors.primary, borderColor: colors.primary }, tabActive: { backgroundColor: colors.primary, borderColor: colors.primary },
tabText: { fontSize: 13, color: colors.textSecondary }, tabText: { fontSize: 13, color: colors.textSecondary },
tabTextActive: { color: colors.white, fontWeight: '600' }, tabTextActive: { color: colors.white, fontWeight: '600' },
emptyBox: {
backgroundColor: colors.surface,
borderRadius: 12,
paddingVertical: 32,
paddingHorizontal: 16,
alignItems: 'center',
gap: 8,
},
emptyText: { fontSize: 14, fontWeight: '600', color: colors.textSecondary },
emptyHint: { fontSize: 12, color: colors.textHint, textAlign: 'center' },
sectionHeader: { sectionHeader: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

28
app/settings.tsx

@ -67,6 +67,12 @@ const SETTINGS_GROUPS: SettingsGroup[] = [
{ id: 'guide', title: '查看引导页', icon: 'compass-outline', route: '/guide' }, { id: 'guide', title: '查看引导页', icon: 'compass-outline', route: '/guide' },
// 缓存体积是运行时读出来的,不写死:实际值在渲染时从 useAppCache 取 // 缓存体积是运行时读出来的,不写死:实际值在渲染时从 useAppCache 取
{ id: 'cache', title: '清除缓存', icon: 'trash-outline' }, { id: 'cache', title: '清除缓存', icon: 'trash-outline' },
{
id: 'qualifications',
title: '资质证照',
icon: 'ribbon-outline',
route: '/settings/qualifications',
},
{ {
id: 'about', id: 'about',
title: '关于我们', title: '关于我们',
@ -97,12 +103,25 @@ export default function SettingsScreen() {
const router = useRouter(); const router = useRouter();
const { logout } = useAuth(); const { logout } = useAuth();
const { role, setRole } = useRole(); const { role, setRole } = useRole();
const { label: cacheLabel, loading: cacheLoading, clear: clearCache, clearing } = useAppCache(); const {
label: cacheLabel,
loading: cacheLoading,
error: cacheError,
clear: clearCache,
clearing,
} = useAppCache();
/** 缓存体积的展示口径:读取中 / 读取失败 / 体积未知 / 实际体积,绝不编造数字 */
const cacheSizeText = cacheLoading
? '读取中'
: cacheError || !cacheLabel
? '体积未知'
: cacheLabel;
/** 清缓存:二次确认后再删,清完按实际清掉的体积给反馈(清不到就说清不到) */ /** 清缓存:二次确认后再删,清完按实际清掉的体积给反馈(清不到就说清不到) */
const handleClearCache = () => { const handleClearCache = () => {
if (clearing) return; if (clearing) return;
Alert.alert('清除缓存', `将清空本机缓存文件(当前 ${cacheLabel || '读取中'})。账号、已填资料与简历不受影响。`, [ Alert.alert('清除缓存', `将清空本机缓存文件(当前 ${cacheSizeText})。账号、已填资料与简历不受影响。`, [
{ text: '取消', style: 'cancel' }, { text: '取消', style: 'cancel' },
{ {
text: '清除', text: '清除',
@ -185,10 +204,11 @@ export default function SettingsScreen() {
<Ionicons name={item.icon} size={18} color={colors.primaryDark} /> <Ionicons name={item.icon} size={18} color={colors.primaryDark} />
</View> </View>
<Text style={styles.rowTitle}>{item.title}</Text> <Text style={styles.rowTitle}>{item.title}</Text>
{/* 缓存行显示运行时读到的体积,读不到就显示占位「--」而不是假数字 */} {/* 缓存行显示运行时读到的体积:读取中 / 读取失败 / 实际体积三态,
不显示假数字,也不把读取失败伪装成「--」这种「本来就是空」的样子 */}
{item.id === 'cache' ? ( {item.id === 'cache' ? (
<Text style={styles.rowValue}> <Text style={styles.rowValue}>
{cacheLoading ? '计算中' : cacheLabel || '--'} {cacheLoading ? '计算中' : cacheError ? '读取失败' : cacheLabel || '--'}
</Text> </Text>
) : item.value ? ( ) : item.value ? (
<Text style={styles.rowValue}>{item.value}</Text> <Text style={styles.rowValue}>{item.value}</Text>

33
app/settings/about.tsx

@ -4,7 +4,6 @@ import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useAboutInfo } from '../../src/hooks'; import { useAboutInfo } from '../../src/hooks';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { AboutLink } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */ /* ===================== 子组件:顶部导航 ===================== */
@ -26,14 +25,6 @@ export default function AboutScreen() {
const router = useRouter(); const router = useRouter();
const { about, loading, error, refresh } = useAboutInfo(); const { about, loading, error, refresh } = useAboutInfo();
const handleLinkPress = (link: AboutLink) => {
if (link.route) {
router.push(link.route);
return;
}
// 协议类页面待开发:仅占位,避免死路由
};
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} /> <Header onBack={() => router.back()} />
@ -83,11 +74,28 @@ export default function AboutScreen() {
<View style={styles.card}> <View style={styles.card}>
{about.links.map((link, index) => { {about.links.map((link, index) => {
const isLast = index === about.links.length - 1; const isLast = index === about.links.length - 1;
const route = link.route;
/*
* 无 route 的条目**不做成可点**。
* 原先是「有 onPress、但函数体里什么都不做」—— 点了毫无反应,
* 既没有跳转也没有提示;审计脚本也看不见这一类(它有处理函数)。
* 现改为:保持可见 + 如实标注「待开放」,而不是给一个静默失效的箭头。
*/
if (!route) {
return (
<View key={link.id} style={[styles.row, isLast && styles.rowLast]}>
<Text style={styles.rowTitle}>{link.title}</Text>
<Text style={styles.rowPending}>待开放</Text>
</View>
);
}
return ( return (
<Pressable <Pressable
key={link.id} key={link.id}
style={[styles.row, isLast && styles.rowLast]} style={[styles.row, isLast && styles.rowLast]}
onPress={() => handleLinkPress(link)} onPress={() => router.push(route)}
hitSlop={4} hitSlop={4}
> >
<Text style={styles.rowTitle}>{link.title}</Text> <Text style={styles.rowTitle}>{link.title}</Text>
@ -218,6 +226,11 @@ const styles = StyleSheet.create({
fontSize: 15, fontSize: 15,
color: colors.textPrimary, color: colors.textPrimary,
}, },
/* 无 route 的条目:如实标注,不用箭头假装可点 */
rowPending: {
fontSize: 13,
color: colors.textHint,
},
copyright: { copyright: {
marginTop: 20, marginTop: 20,
fontSize: 12, fontSize: 12,

7
app/settings/account-cancel.tsx

@ -2,7 +2,6 @@ import { useState } from 'react';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { import {
Alert,
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
ScrollView, ScrollView,
@ -126,13 +125,9 @@ export default function AccountCancelScreen() {
key={item.id} key={item.id}
style={[styles.checkRow, index < data.checklist.length - 1 && styles.rowBorder]} style={[styles.checkRow, index < data.checklist.length - 1 && styles.rowBorder]}
> >
{/* 原先这里挂着「去提现」入口,但全项目没有钱包/提现页 —— 按「不留假交互」的口径摘掉 */}
<View style={styles.rowBody}> <View style={styles.rowBody}>
<Text style={styles.rowTitle}>{item.label}</Text> <Text style={styles.rowTitle}>{item.label}</Text>
{item.actionText ? (
<Pressable onPress={() => Alert.alert('提示', '该待办处理功能开发中,敬请期待')}>
<Text style={styles.actionText}>{item.actionText}</Text>
</Pressable>
) : null}
</View> </View>
<Ionicons <Ionicons
name={item.resolved ? 'checkmark-circle' : 'ellipse-outline'} name={item.resolved ? 'checkmark-circle' : 'ellipse-outline'}

24
app/settings/privacy.tsx

@ -3,6 +3,8 @@ import { useRouter } from 'expo-router';
import { Alert, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { usePrivacyCenter } from '../../src/hooks'; import { usePrivacyCenter } from '../../src/hooks';
import { buildPrivacySummaryText } from '../../src/api';
import { shareText } from '../../src/lib/shareFile';
import { ErrorState, LoadingState } from '../../src/components/StateView'; import { ErrorState, LoadingState } from '../../src/components/StateView';
import { colors } from '../../src/theme'; import { colors } from '../../src/theme';
import type { PrivacyLevelInfo, PrivacySettingItem, PrivacySection } from '../../src/types'; import type { PrivacyLevelInfo, PrivacySettingItem, PrivacySection } from '../../src/types';
@ -23,7 +25,7 @@ function Header({ onBack }: { onBack: () => void }) {
/* ===================== 子组件:隐私等级卡 ===================== */ /* ===================== 子组件:隐私等级卡 ===================== */
function PrivacyLevelCard({ level }: { level: PrivacyLevelInfo }) { function PrivacyLevelCard({ level, onExport }: { level: PrivacyLevelInfo; onExport: () => void }) {
const theme = { const theme = {
high: { bg: colors.successLight, text: colors.successText, icon: 'shield-checkmark' }, high: { bg: colors.successLight, text: colors.successText, icon: 'shield-checkmark' },
medium: { bg: colors.actionLight, text: colors.actionText, icon: 'shield' }, medium: { bg: colors.actionLight, text: colors.actionText, icon: 'shield' },
@ -41,9 +43,11 @@ function PrivacyLevelCard({ level }: { level: PrivacyLevelInfo }) {
<Text style={styles.levelDesc}>{level.description}</Text> <Text style={styles.levelDesc}>{level.description}</Text>
</View> </View>
</View> </View>
<Pressable style={styles.levelBtn} onPress={() => Alert.alert('隐私报告', '隐私报告功能开发中,敬请期待')}> {/* 这里原先叫「查看隐私报告」并弹「功能开发中」。语义上「隐私报告」= 谁访问了我的数据,
<Text style={[styles.levelBtnText, { color: theme.text }]}>查看隐私报告</Text> 需要后端访问日志,本机给不出;能如实做到的是导出「我的隐私设置现状」,故改名并真实现。 */}
<Ionicons name="chevron-forward" size={14} color={theme.text} /> <Pressable style={styles.levelBtn} onPress={onExport}>
<Text style={[styles.levelBtnText, { color: theme.text }]}>导出隐私设置摘要</Text>
<Ionicons name="share-outline" size={14} color={theme.text} />
</Pressable> </Pressable>
</View> </View>
); );
@ -142,6 +146,16 @@ export default function PrivacyCenterScreen() {
const router = useRouter(); const router = useRouter();
const { privacyCenter, loading, error, refresh, toggle } = usePrivacyCenter(); const { privacyCenter, loading, error, refresh, toggle } = usePrivacyCenter();
/**
* 导出隐私设置摘要:拼成文本经系统分享面板发出。
* `ok` 只代表面板已拉起,不能据此提示「导出成功」,只在失败时说明原因(见 lib/shareFile)。
*/
const handleExport = async () => {
if (!privacyCenter) return;
const outcome = await shareText(buildPrivacySummaryText(privacyCenter), '我的隐私设置摘要');
if (!outcome.ok) Alert.alert('无法导出', outcome.message);
};
if (loading) { if (loading) {
return ( return (
<SafeAreaView style={styles.safeArea} edges={['top']}> <SafeAreaView style={styles.safeArea} edges={['top']}>
@ -168,7 +182,7 @@ export default function PrivacyCenterScreen() {
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<PrivacyLevelCard level={privacyCenter.privacyLevel} /> <PrivacyLevelCard level={privacyCenter.privacyLevel} onExport={handleExport} />
{privacyCenter.sections.map((section) => ( {privacyCenter.sections.map((section) => (
<Section key={section.id} section={section} onToggle={toggle} /> <Section key={section.id} section={section} onToggle={toggle} />

265
app/settings/qualifications.tsx

@ -0,0 +1,265 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Image, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { EmptyState, ErrorState, LoadingState } from '../../src/components/StateView';
import { useQualifications } from '../../src/hooks';
import { colors } from '../../src/theme';
import type { QualificationLicense } from '../../src/types';
/* ===================== 子组件:顶部导航 ===================== */
function Header({ onBack }: { onBack: () => void }) {
return (
<View style={styles.header}>
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={8}>
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable>
<Text style={styles.headerTitle}>资质证照</Text>
<View style={styles.headerBtn} />
</View>
);
}
/* ===================== 子组件:信息行 ===================== */
/**
* 证照信息行。值为 `null`(信息尚未提供)时显示「待补充」,
* 不使用 `--` 或空白 —— 空白会被误读成「本条不适用」,而实际是「还没填」。
*/
function InfoRow({ label, value }: { label: string; value: string | null }) {
return (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
{value ? (
<Text style={styles.infoValue}>{value}</Text>
) : (
<Text style={styles.infoPending}>待补充</Text>
)}
</View>
);
}
/* ===================== 子组件:单张证照卡片 ===================== */
function LicenseCard({ license }: { license: QualificationLicense }) {
return (
<View style={styles.card}>
<View style={styles.cardHeader}>
<Ionicons name="ribbon-outline" size={18} color={colors.primaryDark} />
<Text style={styles.cardTitle}>{license.name}</Text>
</View>
{/* 证照图:有图渲染图;没图渲染占位块,并写明这里需要补哪张图 */}
{license.imageUrl ? (
<Image source={{ uri: license.imageUrl }} style={styles.licenseImage} resizeMode="contain" />
) : (
<View style={styles.imagePlaceholder}>
<Ionicons name="image-outline" size={28} color={colors.actionText} />
<Text style={styles.placeholderText}>{license.imagePlaceholder}</Text>
</View>
)}
<View style={styles.infoList}>
<InfoRow label={license.numberLabel} value={license.number} />
<InfoRow label="发证机关" value={license.issuer} />
<InfoRow label="有效期" value={license.validPeriod} />
{license.scope ? <InfoRow label="经营范围" value={license.scope} /> : null}
</View>
</View>
);
}
/* ===================== 页面 ===================== */
export default function QualificationsScreen() {
const router = useRouter();
const { info, loading, error, refresh, isEmpty } = useQualifications();
return (
<SafeAreaView style={styles.safeArea} edges={['top']}>
<Header onBack={() => router.back()} />
{loading ? (
<LoadingState text="证照信息加载中…" />
) : error ? (
<ErrorState onRetry={refresh} />
) : !info || isEmpty ? (
<EmptyState
icon="ribbon-outline"
title="暂无资质证照"
subtitle="平台证照信息尚未公示,请稍后再试"
actionLabel="重新加载"
onAction={refresh}
/>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* 运营主体 */}
<View style={styles.companyCard}>
<Text style={styles.companyLabel}>运营主体</Text>
<Text style={styles.companyName}>{info.company}</Text>
<Text style={styles.companyDesc}>
以下证照为本平台经营所持有的资质文件,供求职者与招聘方核验。
</Text>
</View>
{info.licenses.map((license) => (
<LicenseCard key={license.id} license={license} />
))}
<Text style={styles.verifyTip}>{info.verifyTip}</Text>
</ScrollView>
)}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.pageBg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
height: 48,
backgroundColor: colors.white,
borderBottomWidth: 1,
borderBottomColor: colors.borderLight,
},
headerBtn: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
fontSize: 17,
fontWeight: '700',
color: colors.textPrimary,
},
scroll: {
flex: 1,
},
content: {
padding: 12,
paddingBottom: 32,
gap: 12,
},
companyCard: {
backgroundColor: colors.primaryLight,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.primaryBorder,
padding: 16,
},
companyLabel: {
fontSize: 12,
color: colors.primaryText,
opacity: 0.8,
},
companyName: {
marginTop: 6,
fontSize: 18,
fontWeight: '700',
color: colors.primaryText,
},
companyDesc: {
marginTop: 8,
fontSize: 13,
lineHeight: 20,
color: colors.primaryText,
opacity: 0.85,
},
card: {
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
padding: 16,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
cardTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary,
},
licenseImage: {
marginTop: 12,
width: '100%',
height: 180,
borderRadius: 8,
backgroundColor: colors.pageBg,
},
/* 图片占位:用「提醒卡片底 + 弱警示描边」语义色,一眼看出「这里是待办」 */
imagePlaceholder: {
marginTop: 12,
height: 132,
borderRadius: 8,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: colors.actionBorder,
backgroundColor: colors.actionLight,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
gap: 8,
},
placeholderText: {
fontSize: 12,
lineHeight: 18,
color: colors.actionText,
textAlign: 'center',
},
infoList: {
marginTop: 12,
},
infoRow: {
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: 7,
borderTopWidth: 1,
borderTopColor: colors.borderLight,
},
/*
* 标签列不写死宽度:原写 `width: 96` 时「统一社会信用代码」八个字折成两行,
* 行高被撑开、右侧「待补充」位置错乱。改为按内容撑开(flexShrink: 0),
* 再用 minWidth 让短标签(发证机关 / 有效期)与长标签左对齐。
*/
infoLabel: {
minWidth: 96,
marginRight: 12,
flexShrink: 0,
fontSize: 13,
color: colors.textSecondary,
},
infoValue: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
},
infoPending: {
flex: 1,
fontSize: 13,
fontWeight: '600',
color: colors.actionText,
},
verifyTip: {
marginTop: 4,
fontSize: 12,
lineHeight: 18,
color: colors.textHint,
textAlign: 'center',
},
});

6
package.json

@ -33,8 +33,10 @@
"test": "jest", "test": "jest",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"check:routes": "node scripts/check-routes.mjs", "check:routes": "node scripts/check-routes.mjs",
"check:affordances": "node scripts/check-affordances.mjs", "check:affordances": "node scripts/check-affordances.mjs --error",
"check:backend-stubs": "node scripts/check-backend-stubs.mjs" "check:affordances:pending": "node scripts/check-affordances.mjs --pending",
"check:backend-stubs": "node scripts/check-backend-stubs.mjs",
"check:icons": "node scripts/check-icons.mjs --error"
}, },
"private": true, "private": true,
"devDependencies": { "devDependencies": {

165
scripts/check-affordances.mjs

@ -1,18 +1,37 @@
/** /**
* 可点击控件审计:找出「看起来能点、其实没有处理函数」的 Pressable / Touchable*。 * 可点击控件审计:找出「看起来能点、其实没有真动作」的 Pressable / Touchable*。
* *
* 背景:本项目把「不留假入口」当红线(点下去零反馈的控件会把开发中的状态摆给用户看)。 * 背景:本项目把「不留假入口」当红线(点下去零反馈、或点了只被告知「功能开发中」,
* 与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,这个查「按钮点下去有没有反应」。 * 都是把开发中的状态摆给用户看)。与 `check-routes.mjs` 的关系:那个查「页面能不能到达」,
* 这个查「按钮点下去有没有真办事」。
* *
* 判定:控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut, * 检测两类,分别对应两种历史事故:
*
* ① 无处理函数 / 空处理函数
* 控件标签内既没有 onPress / onPressIn / onLongPress / onPressOut,
* 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。 * 也没有 `disabled`(禁用态是有意的——视觉上应当置灰)与属性展开({...props})。
* *
* 豁免:标签所在行或上一行写了 `affordance-ok` 注释的跳过 —— 底部弹层里 * ② 弹窗式假交互(2026-09 补的盲区)
* `onPress={() => {}}` 是**有意**用来吞掉冒泡的(不吞就会点内容区也关掉弹层), * 有 onPress,但点下去只是 Alert.alert 一句「功能开发中 / 敬请期待 / 暂未开放」。
* 与「忘了接线」是两回事,靠注释把两者区分开。 * 旧脚本只查 ①,于是全仓 37 处这类假交互一路绿灯,文档还据此写下了
* 「死控件已清零」的错误结论 —— 这个盲区必须堵上。
*
* ② 最难的是**不误报**:`app/resume.tsx` 那类「await 调完接口、拿到 result 后如实
* 报告能力未接入」是项目 4.4 节确立的正确做法(保留入口 + 坦诚提示 + 不假装成功),
* 文案与假交互一模一样。区分轴取「处理函数里有没有真实动作」:
* 有 await / .then( / async 说明确实去问过后端,属如实报告;纯弹窗则判为假交互。
*
* 豁免标记(写在命中行、上一行,或该处理函数声明行附近均可):
* - `affordance-ok` 有意为之的控件(如弹层里 onPress={() => {}} 吞冒泡,
* 不吞就会点内容区也关掉弹层,与「忘了接线」是两回事)
* - `affordance-pending-backend` 如实标注的后端待办(如附件上传、接受 Offer):
* 确实是假交互,但属**知情保留**,非疏漏。
* 用这个标记而不是 affordance-ok,是为了让「已知欠账」
* 在检索时仍能被数出来,不会混进「有意为之」里消失。
* *
* 用法:node scripts/check-affordances.mjs # 列出全部 * 用法:node scripts/check-affordances.mjs # 列出全部
* node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI) * node scripts/check-affordances.mjs --error # 有命中则 exit 1(可用于 CI)
* node scripts/check-affordances.mjs --pending # 列出知情保留的后端待办清单
*/ */
import { readdirSync, readFileSync, statSync } from 'node:fs'; import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@ -23,6 +42,39 @@ const HANDLER_PROPS = ['onPress', 'onPressIn', 'onPressOut', 'onLongPress', 'dis
/** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */ /** 有意为之的控件(吞冒泡)在行内或上一行标这个,避免与「忘了接线」混在一起 */
const EXEMPT_MARKER = 'affordance-ok'; const EXEMPT_MARKER = 'affordance-ok';
/** 如实标注的后端待办:确实是假交互,但是知情的,标记出来以便与疏漏区分 */
const PENDING_MARKER = 'affordance-pending-backend';
/** `--pending` 用它计数:注释内容**以标记开头**才算一条待办,避免说明文字被误计 */
const PENDING_LINE_RE = /^\s*(?:\/\/|\/\*+|\*+)?\s*affordance-pending-backend\b/;
/**
* 「功能还没做」的措辞。刻意放宽到「暂未开放 / 即将上线」等:
* 现在全仓的写法就是「暂未开放」,只认「功能开发中」的话,
* 下一个假按钮换个近义词就能溜过去,盲区等于没堵。
*/
const PENDING_TEXT_RE =
/功能开发中|敬请期待|暂未开放|尚未开放|即将上线|开发中|未开放|coming\s*soon|not\s+available\s+yet/i;
/** 处理函数里出现过这些,说明确实去干过活(问过后端 / 读过存储),属如实报告 */
const REAL_WORK_RE = /\bawait\b|\.then\s*\(|\basync\b/;
/** 从这里往前找处理函数的起点,用来判断「这个 Alert 是不是整个处理函数的全部内容」 */
const HANDLER_START_RE = new RegExp(
[
// onPress={() => / onPress={handleX} / onPress={cond ? a : b}
String.raw`onPress(?:In|Out)?\s*=\s*\{?`,
String.raw`onLongPress\s*=\s*\{?`,
// const handleX = async () => / const handleX = () => / function handleX() {
String.raw`(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*=\s*(?:async\s+)?(?:\([^()]{0,200}\)|[A-Za-z_$][\w$]*)\s*=>`,
String.raw`function\s+[A-Za-z_$][\w$]*\s*\([^()]{0,200}\)\s*\{`,
].join('|'),
'g',
);
/** 往前回看的字符数:够覆盖一个处理函数的声明与函数体,又不至于跨到上一个函数 */
const LOOKBACK = 600;
/** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */ /** 从 `<Tag` 的起始下标起,找到该开标签的结束 `>`(粗略忽略字符串内的 `>`,对本仓库足够) */
function findTagEnd(text, from) { function findTagEnd(text, from) {
let depth = 0; let depth = 0;
@ -35,6 +87,43 @@ function findTagEnd(text, from) {
return -1; return -1;
} }
/** 取该下标的 1-based 行号 */
function lineOf(text, index) {
return text.slice(0, index).split('\n').length;
}
/** 命中行 / 上一行 / 前两行里有没有写豁免标记(处理函数的说明注释一般写在声明前) */
function hasMarker(lines, line, marker) {
for (const offset of [1, 2, 3]) {
const text = lines[line - offset];
if (text && text.includes(marker)) return true;
}
return false;
}
/**
* 判断某个 Alert.alert 是不是「处理函数里唯一干的事」。
*
* 做法:往回找最近的一处处理函数起点,截取「起点 → Alert」这段。
* 这段里有 await / .then / async,说明弹窗之前确实发生过真实操作(调接口、读存储),
* 弹窗是在报告操作结果,不是在冒充功能。
*
* 这是启发式而非语法分析:本仓库的 handler 写法高度统一(内联箭头或 `const handleX = () =>`),
* 回看窗口按这个风格取值。误报的兜底是 affordance-pending-backend 标记。
*/
function isBarePendingAlert(text, index) {
const from = Math.max(0, index - LOOKBACK);
const window = text.slice(from, index);
let start = 0;
for (const match of window.matchAll(HANDLER_START_RE)) {
start = match.index;
}
const body = window.slice(start);
return !REAL_WORK_RE.test(body);
}
function walk(dir, out = []) { function walk(dir, out = []) {
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
const full = join(dir, entry); const full = join(dir, entry);
@ -49,22 +138,24 @@ const TAG_RE = /<(Pressable|TouchableOpacity|TouchableHighlight|TouchableWithout
/** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */ /** 空处理函数:绑了 onPress 但什么都不做,点下去同样零反馈 */
const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/; const EMPTY_HANDLER_RE = /onPress=\{\s*\(\s*\)\s*=>\s*(\{\s*\}|undefined|null)\s*\}/;
/** 定位所有 Alert.alert 调用(含实参),用于第 ② 类检测 */
const ALERT_RE = /Alert\.alert\s*\(/g;
const findings = []; const findings = [];
for (const root of ROOTS) { for (const root of ROOTS) {
for (const file of walk(root)) { for (const file of walk(root)) {
const text = readFileSync(file, 'utf8'); const text = readFileSync(file, 'utf8');
const lines = text.split('\n'); const lines = text.split('\n');
/* ---------- ① 无处理函数 / 空处理函数 ---------- */
for (const match of text.matchAll(TAG_RE)) { for (const match of text.matchAll(TAG_RE)) {
const end = findTagEnd(text, match.index); const end = findTagEnd(text, match.index);
if (end < 0) continue; if (end < 0) continue;
const tag = text.slice(match.index, end + 1); const tag = text.slice(match.index, end + 1);
const empty = EMPTY_HANDLER_RE.test(tag); const empty = EMPTY_HANDLER_RE.test(tag);
if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue; if (!empty && HANDLER_PROPS.some((prop) => tag.includes(prop))) continue;
const line = text.slice(0, match.index).split('\n').length; const line = lineOf(text, match.index);
// 豁免标记写在标签行或其上一行(弹层外的说明注释通常写在上一行) if (hasMarker(lines, line, EXEMPT_MARKER)) continue;
if (lines[line - 1].includes(EXEMPT_MARKER) || (lines[line - 2] ?? '').includes(EXEMPT_MARKER)) {
continue;
}
findings.push({ findings.push({
file, file,
line, line,
@ -72,15 +163,61 @@ for (const root of ROOTS) {
tag: lines[line - 1].trim(), tag: lines[line - 1].trim(),
}); });
} }
/* ---------- ② 弹窗式假交互 ---------- */
for (const match of text.matchAll(ALERT_RE)) {
// 只看这次 alert 的实参,别把后面别的 alert 的文案也算进来
const end = text.indexOf('\n', match.index);
const call = text.slice(match.index, end < 0 ? text.length : end);
if (!PENDING_TEXT_RE.test(call)) continue;
const line = lineOf(text, match.index);
if (hasMarker(lines, line, EXEMPT_MARKER)) continue;
if (hasMarker(lines, line, PENDING_MARKER)) continue;
if (!isBarePendingAlert(text, match.index)) continue;
findings.push({
file,
line,
reason: '弹窗式假交互',
tag: lines[line - 1].trim(),
});
}
}
}
/**
* `--pending`:把「知情保留的后端待办」单独列出来。
* 这些是刻意不修的欠账(标记为 affordance-pending-backend),不该混在 0 命中的绿报里
* 假装不存在 —— 后端就绪后照着这张清单逐条兑现即可。
*/
if (process.argv.includes('--pending')) {
const pending = [];
for (const root of ROOTS) {
for (const file of walk(root)) {
const lines = readFileSync(file, 'utf8').split('\n');
lines.forEach((text, i) => {
// 只认「注释内容以标记开头」的行。若用 includes,连解释这个标记本身的说明文字
// 也会被算成一条待办,数出来的数字就是假的 —— 那正是本轮要治的病。
if (PENDING_LINE_RE.test(text)) pending.push({ file, line: i + 1, text: text.trim() });
});
}
} }
console.log(`=== 知情保留的后端待办 (${pending.length}) ===`);
for (const p of pending) console.log(`${p.file}:${p.line} ${p.text}`);
process.exit(0);
} }
if (findings.length === 0) { if (findings.length === 0) {
console.log('✅ 未发现无处理函数的可点击控件'); console.log('✅ 未发现假交互控件(无处理函数 / 弹窗式假交互均无)');
} else { } else {
console.log(`=== 点下去没有反应的可点击控件 (${findings.length}) ===`); console.log(`=== 点下去不办真事的可点击控件 (${findings.length}) ===`);
for (const f of findings) { for (const f of findings) {
console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`); console.log(`${f.file}:${f.line} [${f.reason}] ${f.tag}`);
} }
console.log(
'\n说明:确属后端待办、决定知情保留的,加注释标记 `affordance-pending-backend`;' +
'有意吞冒泡的加 `affordance-ok`。',
);
if (process.argv.includes('--error')) process.exitCode = 1; if (process.argv.includes('--error')) process.exitCode = 1;
} }

99
scripts/check-icons.mjs

@ -0,0 +1,99 @@
/**
* 图标名校验:找出传给 Ionicons 的**无效字形名**。
*
* 背景(2026-09-18 发现的真实缺陷):`src/api/api.ts` 的文书模板里写了一个
* `icon: 'gavel-outline'`,而 Ionicons 全库 1357 个字形里**根本没有 gavel**
* (法槌不在 Ionicons 里)。后果有两个,都逃过了既有全部门槛:
*
* ① 运行时 JSON 里渲染为空白 —— 「劳动仲裁申请书」这一条没有图标;
* ② Expo 打印 `"gavel-outline" is not a valid icon name for family ...`。
*
* 为什么 `tsc` 0 错误也拦不住:页面写的是
* `name={template.icon as keyof typeof Ionicons.glyphMap}`
* 这个 `as` 断言把「任意字符串」骗成了合法字形名,编译器就此闭嘴。
* 图标名来自数据层(api.ts 的 mock),本就是 `string`,静态类型无从校验 ——
* 所以要靠这个脚本,在数据这一侧把它查出来。
*
* 检测范围(只查**静态字面量**,运行时拼出来的名字查不了,属已知边界):
* - `<Ionicons name="xxx" />` 的字面量
* - 对象里的 `icon: 'xxx'` / `icon: "xxx"`
*
* 白名单:`MOCK_BADGES` 的 6 个勋章图标是 **emoji**(🏅🎯⭐🏆📮📚),
* 由 `<Text>{badge.icon}</Text>` 直接渲染成字符,本就不该是 Ionicons 名。
* 判据取「是否全为 emoji / 非 ASCII 字符」,而不是硬编码那 6 个值 ——
* 否则以后新增勋章还得回来改脚本。
*
* 用法:node scripts/check-icons.mjs # 列出全部
* node scripts/check-icons.mjs --error # 有命中则 exit 1(可用于 CI)
*/
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
/** 直接从装的包里读字形表,不另存一份 —— 另存一份就会与真实图标库脱节 */
const GLYPHMAP_PATH =
'@expo/vector-icons/build/vendor/react-native-vector-icons/glyphmaps/Ionicons.json';
const VALID_GLYPHS = new Set(Object.keys(require(GLYPHMAP_PATH)));
const ROOTS = ['app', 'src'];
const SKIP_DIRS = new Set(['node_modules', '__tests__']);
const PATTERNS = [
/<Ionicons[^>]*?\bname="([^"]+)"/g,
/\bicon:\s*'([^']+)'/g,
/\bicon:\s*"([^"]+)"/g,
];
/** emoji / 非 ASCII:这类图标走 <Text> 渲染,不是 Ionicons 名 */
function isEmojiOrNonAscii(s) {
return /[^\x00-\x7F]/.test(s);
}
function walk(dir, out = []) {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full, out);
else if (/\.tsx?$/.test(entry)) out.push(full);
}
return out;
}
const hits = [];
let scanned = 0;
for (const file of ROOTS.flatMap((root) => walk(root))) {
scanned += 1;
const source = readFileSync(file, 'utf8');
const lines = source.split('\n');
for (const pattern of PATTERNS) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(source)) !== null) {
const name = match[1];
if (VALID_GLYPHS.has(name) || isEmojiOrNonAscii(name)) continue;
const line = source.slice(0, match.index).split('\n').length;
hits.push({ file, line, name, text: lines[line - 1]?.trim() ?? '' });
}
}
}
console.log(`扫描 ${scanned} 个文件,Ionicons 有效字形 ${VALID_GLYPHS.size} 个。`);
if (hits.length === 0) {
console.log('✅ 未发现无效 Ionicons 图标名');
} else {
console.log(`\n❌ 发现 ${hits.length} 处无效图标名(运行时会渲染为空白):\n`);
for (const hit of hits) {
console.log(` ${hit.file}:${hit.line}`);
console.log(` 名: ${hit.name}`);
console.log(` 行: ${hit.text}`);
}
console.log('\n改法:换成 Ionicons 里真实存在的名字;名来自数据层时,');
console.log(' 别用 `as keyof typeof Ionicons.glyphMap` 把类型错误咽下去。');
}
if (process.argv.includes('--error') && hits.length > 0) process.exit(1);

168
src/__tests__/cache.test.ts

@ -0,0 +1,168 @@
/**
* 缓存目录读取 / 清理(设置页「清除缓存」)。
*
* 本文件的重点是锁住一条**契约**:`readCacheBytes()` **永不抛错** ——
* 目录不存在、读不到、原生模块缺失,一律返回 `0`。
*
* 【为什么值得专门写一个文件锁它】
* `app/settings.tsx` 里有一个 `cacheError ? '读取失败'` 分支,文档长期把它记成
* 「未验证(需文件系统级故障才能触发,mockRequest 造不出来)」。
*
* 但这次顺着调用链读下来发现:**故障根本传不上来**。链路是
*
* settings.tsx ← hook(useAppCache) ← api(fetchCacheSize) ← lib(readCacheBytes)
* ↑
* try { ... } catch { return 0 } ← 全吞了
*
* 底层把 `catch` 吞干净并返回 `0`,所以 `useAsyncData` 永远不会看到 rejection,
* `error` 恒为 `null`,那个「读取失败」分支是**不可达的死代码** ——
* 不是「我们没有条件去验证它」,而是「任何条件下都不会走到」。
*
* 这是**代码可证的结论**,不需要设备:下面直接构造「抛错的文件系统」来证。
* 与 `app/onboarding.tsx` 空态(恒 4 组,空分支不可达,故不加)属同一类判断,
* 区别在于那个是「知道不可达所以没写」,这个是「写了但不可达」,后者更容易被误读成已覆盖。
*/
jest.mock('expo-file-system', () => ({
Paths: {
get cache() {
return 'file:///mock-cache';
},
},
Directory: jest.fn().mockImplementation(() => {
if (mockFs.mode === 'construct-throws') {
throw new Error('权限不足 / 原生模块缺失');
}
return {
get exists() {
return mockFs.exists;
},
get size() {
return mockFs.size;
},
list: () => {
if (mockFs.mode === 'list-throws') throw new Error('目录读不到');
return mockFs.entries;
},
};
}),
}));
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage(与其它用例同)。
jest.mock('../lib/storage', () => {
const store = new Map<string, string>();
return {
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
/** mock 的文件系统状态(`mock` 前缀是 jest 允许在 mock 工厂里引用的约定) */
const mockFs: {
mode: 'ok' | 'construct-throws' | 'list-throws';
exists: boolean;
size: number;
entries: { delete: () => void }[];
} = { mode: 'ok', exists: true, size: 0, entries: [] };
import { clearAppCache, fetchCacheSize } from '../api/api';
import { clearCacheDir, readCacheBytes } from '../lib/cache';
beforeEach(() => {
mockFs.mode = 'ok';
mockFs.exists = true;
mockFs.size = 0;
mockFs.entries = [];
});
describe('readCacheBytes(读缓存体积)', () => {
it('目录存在时返回目录体积', () => {
mockFs.size = 4096;
expect(readCacheBytes()).toBe(4096);
});
it('体积为 0 时返回 0(0 是合法值,不是失败信号)', () => {
mockFs.size = 0;
expect(readCacheBytes()).toBe(0);
});
it('目录不存在时返回 0', () => {
mockFs.exists = false;
expect(readCacheBytes()).toBe(0);
});
it('构造目录抛错时返回 0 —— 不把异常抛给调用方', () => {
mockFs.mode = 'construct-throws';
expect(() => readCacheBytes()).not.toThrow();
expect(readCacheBytes()).toBe(0);
});
});
describe('fetchCacheSize(页面实际调用的那一层)', () => {
it('正常路径返回体积', async () => {
mockFs.size = 1024;
await expect(fetchCacheSize()).resolves.toBe(1024);
});
it('文件系统抛错时**仍然 resolve 为 0**,而不是 reject', async () => {
mockFs.mode = 'construct-throws';
// 这一条是「settings 页的『读取失败』分支不可达」的直接证据:
// 底层把故障吞成 0,hook 的 error 拿不到任何东西,永远为 null。
await expect(fetchCacheSize()).resolves.toBe(0);
});
});
describe('clearCacheDir(清缓存)', () => {
it('返回清理前的占用体积', () => {
mockFs.size = 2048;
expect(clearCacheDir()).toBe(2048);
});
it('目录不存在时返回 0', () => {
mockFs.exists = false;
expect(clearCacheDir()).toBe(0);
});
it('单个条目删除失败不阻断其余条目', () => {
const deleted: string[] = [];
mockFs.size = 100;
mockFs.entries = [
{
delete: () => {
throw new Error('被系统占用');
},
},
{
delete: () => {
deleted.push('第二个');
},
},
];
// 不抛错,且第二个仍被删掉 —— 「一个删不掉就不清其余」会让用户反复点也清不掉
expect(clearCacheDir()).toBe(100);
expect(deleted).toEqual(['第二个']);
});
it('列目录抛错时返回 0(不返回清理前体积,因为一个都没删成)', () => {
mockFs.size = 500;
mockFs.mode = 'list-throws';
expect(clearCacheDir()).toBe(0);
});
});
describe('clearAppCache(页面调用的清理)', () => {
it('正常路径 resolve,不 reject', async () => {
mockFs.size = 8192;
await expect(clearAppCache()).resolves.toBe(8192);
});
it('文件系统异常时仍 resolve(不把异常抛到页面)', async () => {
mockFs.mode = 'construct-throws';
await expect(clearAppCache()).resolves.toBe(0);
});
});

36
src/__tests__/holland.test.ts

@ -0,0 +1,36 @@
import { fetchHollandAssessment, fetchHollandIntro } from '../api/api';
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage(与其它用例同)。
jest.mock('../lib/storage', () => {
const store = new Map<string, string>();
return {
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
describe('霍兰德测评参与人数(一个统计量只能有一个数)', () => {
it('介绍页与测评页的人数必须一致', async () => {
const [intro, assessment] = await Promise.all([
fetchHollandIntro(),
fetchHollandAssessment(),
]);
// 此前是两处各写各的:介绍页 124580、测评页 12846。
// 用户在介绍页看到「已有 124,580 人完成测评」,回到成长页又看到「已有 12,846 人完成」——
// 同一个统计量、同一个用户、同一屏之内两个数。与「全部 12 / 已下线 3」同类。
expect(intro.totalParticipants).toBe(assessment.totalParticipants);
});
it('是正整数:不是 0、不是负数、不是小数', async () => {
const intro = await fetchHollandIntro();
expect(Number.isInteger(intro.totalParticipants)).toBe(true);
expect(intro.totalParticipants).toBeGreaterThan(0);
});
});

380
src/__tests__/job-management-write.test.ts

@ -0,0 +1,380 @@
import {
buildJobManagementTabs,
buildJobStatusAction,
fetchJobManagement,
filterJobManagementJobs,
getJobStatusLabel,
refreshJob,
updateJobStatus,
} from '../api/api';
import type { JobManagement, JobManagementJob, JobManagementJobStatus } from '../types';
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。
// 用带状态的 Map 版 mock(不是恒返回 null 的空实现),
// 否则「下架后重进页面还是下架状态」这类落库行为根本没被验证到。
jest.mock('../lib/storage', () => {
const store = new Map<string, string>();
return {
__store: store,
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
const store = (jest.requireMock('../lib/storage') as { __store: Map<string, string> }).__store;
/** 存储键(取自 api.ts,用于构造「存储内容损坏」的降级用例) */
const STATUS_KEY = 'enterprise:jobs-status';
const REFRESH_KEY = 'enterprise:jobs-refreshed';
/** mock 岗位总数(12 条:8 在招 / 3 已下线 / 1 草稿) */
const TOTAL_JOBS = 12;
const ACTIVE_JOBS = 8;
const OFFLINE_JOBS = 3;
function findJob(management: JobManagement, jobId: string) {
const found = management.jobs.find((job) => job.id === jobId);
if (!found) throw new Error(`岗位列表里找不到 ${jobId}`);
return found;
}
function tabCount(management: JobManagement, tabKey: string): number {
return management.tabs.find((tab) => tab.key === tabKey)?.count ?? -1;
}
function activeStat(management: JobManagement): number {
return management.stats.find((stat) => stat.id === 'active')?.value ?? -1;
}
beforeEach(() => {
store.clear();
});
describe('fetchJobManagement(岗位管理取数)', () => {
it('Tab 计数由列表推导,与 jobs 实际条数一致', async () => {
const management = await fetchJobManagement();
expect(management.jobs).toHaveLength(TOTAL_JOBS);
expect(tabCount(management, 'all')).toBe(TOTAL_JOBS);
expect(tabCount(management, 'active')).toBe(ACTIVE_JOBS);
// 计数与列表同源:每个 Tab 的计数必须等于按该状态过滤出来的条数
for (const tab of management.tabs) {
const actual = management.jobs.filter(
(job) => tab.key === 'all' || job.status === tab.key,
).length;
expect(tab.count).toBe(actual);
}
});
it('「在招岗位」统计值取自列表,不是写死的数字', async () => {
const management = await fetchJobManagement();
expect(activeStat(management)).toBe(ACTIVE_JOBS);
});
it('存储内容损坏时退回 mock 原值,不把异常抛到页面', async () => {
store.set(STATUS_KEY, '{不是合法 JSON');
store.set(REFRESH_KEY, '[1,2,3]');
const management = await fetchJobManagement();
expect(management.jobs).toHaveLength(TOTAL_JOBS);
expect(findJob(management, 'job-1').status).toBe('active');
});
it('存储里放的是数组(而非对象)时按空表处理', async () => {
store.set(STATUS_KEY, '["active"]');
const management = await fetchJobManagement();
expect(findJob(management, 'job-1').status).toBe('active');
});
});
describe('updateJobStatus(下架 / 重新上架)', () => {
it('下架后状态变为 offline,且重新取数仍然是下架的', async () => {
await updateJobStatus('job-1', 'offline');
// 换一次全新的读取,模拟退出页面再进来
const management = await fetchJobManagement();
expect(findJob(management, 'job-1').status).toBe('offline');
});
it('下架后 Tab 计数与「在招岗位」统计同步减一', async () => {
const updated = await updateJobStatus('job-1', 'offline');
expect(tabCount(updated, 'active')).toBe(ACTIVE_JOBS - 1);
expect(tabCount(updated, 'offline')).toBe(OFFLINE_JOBS + 1);
expect(tabCount(updated, 'all')).toBe(TOTAL_JOBS);
expect(activeStat(updated)).toBe(ACTIVE_JOBS - 1);
});
it('重新上架后计数回到原值(不会只减不增)', async () => {
await updateJobStatus('job-1', 'offline');
const restored = await updateJobStatus('job-1', 'active');
expect(findJob(restored, 'job-1').status).toBe('active');
expect(tabCount(restored, 'active')).toBe(ACTIVE_JOBS);
expect(activeStat(restored)).toBe(ACTIVE_JOBS);
});
it('只影响被改的那一个岗位,其它岗位状态不受牵连', async () => {
const updated = await updateJobStatus('job-1', 'offline');
expect(findJob(updated, 'job-2').status).toBe('active');
expect(findJob(updated, 'job-3').status).toBe('active');
expect(findJob(updated, 'job-9').status).toBe('offline');
});
it('返回的是完整聚合对象(页面可直接用它替换本地镜像)', async () => {
const updated = await updateJobStatus('job-1', 'offline');
expect(updated.companyName).toBe('智联科技');
expect(updated.hasData).toBe(true);
expect(updated.tabs).toHaveLength(4);
expect(updated.stats).toHaveLength(4);
expect(updated.benefits.length).toBeGreaterThan(0);
});
it('写入失败不静默:存储抛错时 reject,页面据此给出「操作失败」提示', async () => {
const storage = jest.requireMock('../lib/storage') as { storageSet: jest.Mock };
storage.storageSet.mockRejectedValueOnce(new Error('磁盘写满'));
await expect(updateJobStatus('job-1', 'offline')).rejects.toThrow('磁盘写满');
});
});
describe('refreshJob(刷新岗位)', () => {
it('写入 refreshedAt,且重新取数仍然在', async () => {
const before = await fetchJobManagement();
expect(findJob(before, 'job-1').refreshedAt).toBeUndefined();
const updated = await refreshJob('job-1');
expect(findJob(updated, 'job-1').refreshedAt).toBeDefined();
expect(Number.isNaN(Date.parse(findJob(updated, 'job-1').refreshedAt as string))).toBe(false);
const reread = await fetchJobManagement();
expect(findJob(reread, 'job-1').refreshedAt).toBe(findJob(updated, 'job-1').refreshedAt);
});
it('只给被刷新那一个岗位打时间,其它岗位仍是「未刷新过」', async () => {
const updated = await refreshJob('job-1');
expect(findJob(updated, 'job-1').refreshedAt).toBeDefined();
expect(findJob(updated, 'job-2').refreshedAt).toBeUndefined();
});
it('刷新不改动岗位状态与计数', async () => {
const updated = await refreshJob('job-1');
expect(findJob(updated, 'job-1').status).toBe('active');
expect(tabCount(updated, 'active')).toBe(ACTIVE_JOBS);
expect(activeStat(updated)).toBe(ACTIVE_JOBS);
});
it('重复刷新是幂等的:时间被覆盖而不是叠加', async () => {
await refreshJob('job-1');
const second = await refreshJob('job-1');
const refreshMap = JSON.parse(store.get(REFRESH_KEY) ?? '{}') as Record<string, string>;
expect(Object.keys(refreshMap)).toEqual(['job-1']);
expect(findJob(second, 'job-1').refreshedAt).toBeDefined();
});
});
describe('buildJobManagementTabs(计数推导,纯函数)', () => {
it('空列表时各 Tab 计数为 0', () => {
const tabs = buildJobManagementTabs([]);
expect(tabs.map((tab) => tab.count)).toEqual([0, 0, 0, 0]);
});
});
describe('filterJobManagementJobs(筛选 + 排序,纯函数)', () => {
it('Tab 过滤只留下对应状态的岗位', async () => {
const { jobs } = await fetchJobManagement();
const offline = filterJobManagementJobs(jobs, { tabKey: 'offline' });
expect(offline).toHaveLength(OFFLINE_JOBS);
expect(offline.every((job) => job.status === 'offline')).toBe(true);
});
it('关键词同时匹配岗位名与部门名,忽略大小写与首尾空格', async () => {
const { jobs } = await fetchJobManagement();
expect(filterJobManagementJobs(jobs, { tabKey: 'all', keyword: ' 前端 ' })).toHaveLength(1);
// 「技术部」是部门名,不是岗位名 —— 也应命中
const techDept = filterJobManagementJobs(jobs, { tabKey: 'all', keyword: '技术部' });
expect(techDept.length).toBeGreaterThan(0);
expect(techDept.every((job) => job.department === '技术部')).toBe(true);
});
it('城市与部门是「与」关系:两者都满足才留下', async () => {
const { jobs } = await fetchJobManagement();
const both = filterJobManagementJobs(jobs, {
tabKey: 'all',
cities: ['北京'],
departments: ['技术部'],
});
expect(both.length).toBeGreaterThan(0);
expect(both.every((job) => job.city === '北京' && job.department === '技术部')).toBe(true);
// 北京没有设计部 —— 与关系下应为空
expect(
filterJobManagementJobs(jobs, { tabKey: 'all', cities: ['北京'], departments: ['设计部'] }),
).toHaveLength(0);
});
it('「最新发布」按发布时间倒序,第一条是最近发布的', async () => {
const { jobs } = await fetchJobManagement();
const sorted = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'latest' });
for (let i = 1; i < sorted.length; i += 1) {
expect(sorted[i - 1].publishedAt >= sorted[i].publishedAt).toBe(true);
}
});
it('投递 / 查看 / 评分排序都是降序,且不丢条目', async () => {
const { jobs } = await fetchJobManagement();
const byApplicants = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'applicants' });
expect(byApplicants).toHaveLength(TOTAL_JOBS);
for (let i = 1; i < byApplicants.length; i += 1) {
expect(byApplicants[i - 1].applicants).toBeGreaterThanOrEqual(byApplicants[i].applicants);
}
const byScore = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'score' });
expect(byScore[0].overallScore).toBeGreaterThanOrEqual(byScore[1].overallScore);
});
it('不改动入参数组(排序在副本上做)', async () => {
const { jobs } = await fetchJobManagement();
const originalOrder = jobs.map((job) => job.id);
filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'score' });
expect(jobs.map((job) => job.id)).toEqual(originalOrder);
});
it('筛不出结果时返回空数组,而不是全量兜底', async () => {
const { jobs } = await fetchJobManagement();
expect(filterJobManagementJobs(jobs, { tabKey: 'all', keyword: '不存在的岗位名' })).toEqual([]);
});
});
/** 取一个指定状态的岗位(mock 数据里三种状态都有,取不到说明数据被改坏了) */
async function jobWithStatus(status: JobManagementJobStatus): Promise<JobManagementJob> {
const { jobs } = await fetchJobManagement();
const found = jobs.find((job) => job.status === status);
if (!found) throw new Error(`mock 岗位数据里没有 status=${status} 的样本`);
return found;
}
describe('buildJobStatusAction(状态 → 按钮/确认文案,纯函数)', () => {
it('在招岗位:文案是「下架」,标为破坏性操作,目标状态 offline', async () => {
const active = await jobWithStatus('active');
const action = buildJobStatusAction(active);
expect(action.label).toBe('下架');
expect(action.nextStatus).toBe('offline');
expect(action.destructive).toBe(true);
// 破坏性操作必须说清后果,且要指名是哪个岗位
expect(action.confirmMessage).toContain(active.title);
expect(action.confirmMessage).not.toBe('');
expect(action.confirmTitle).not.toBe('');
expect(action.confirmButton).not.toBe('');
expect(action.icon).not.toBe('');
});
it('草稿:不得说「重新上架」「再次看到」—— 它从未上架过', async () => {
const draft = await jobWithStatus('draft');
const action = buildJobStatusAction(draft);
// 页面原先写死 `isActive ? '下架' : '重新上架'`,草稿因此落到「重新上架」分支,
// 确认框还说「候选人可以再次看到该岗位」。草稿从未上架,两句都不成立。
expect(action.label).not.toBe('重新上架');
expect(action.label).not.toContain('重新');
expect(action.confirmTitle).not.toContain('重新');
expect(action.confirmMessage).not.toContain('再次');
expect(action.label).toBe('上架');
expect(action.nextStatus).toBe('active');
expect(action.destructive).toBe(false);
});
it('已下线:才用「重新上架」,且不是破坏性操作', async () => {
const offline = await jobWithStatus('offline');
const action = buildJobStatusAction(offline);
expect(action.label).toBe('重新上架');
expect(action.nextStatus).toBe('active');
expect(action.destructive).toBe(false);
});
it('草稿与已下线的确认文案不同 —— 两种处境不能共用一句话', async () => {
const [draftAction, offlineAction] = [
buildJobStatusAction(await jobWithStatus('draft')),
buildJobStatusAction(await jobWithStatus('offline')),
];
expect(draftAction.confirmMessage).not.toBe(offlineAction.confirmMessage);
expect(draftAction.confirmTitle).not.toBe(offlineAction.confirmTitle);
expect(draftAction.label).not.toBe(offlineAction.label);
});
it('遍历全部岗位:目标状态与当前状态不同,且不是原地打转', async () => {
const { jobs } = await fetchJobManagement();
for (const job of jobs) {
const action = buildJobStatusAction(job);
expect(action.nextStatus).not.toBe(job.status);
// 三种状态的合法目标是确定的,写死在这里当契约
const expected: Record<JobManagementJobStatus, JobManagementJobStatus> = {
active: 'offline',
offline: 'active',
draft: 'active',
};
expect(action.nextStatus).toBe(expected[job.status]);
}
});
it('确认框正文都带上了岗位名,不会出现「下架该岗位」这种指代不清的说法', async () => {
const { jobs } = await fetchJobManagement();
for (const job of jobs) {
expect(buildJobStatusAction(job).confirmMessage).toContain(job.title);
}
});
});
describe('getJobStatusLabel(状态角标文案,与 Tab 同源)', () => {
it('与 fetchJobManagement 下发的 Tab 文案逐字一致 —— 不另立一份', async () => {
const { jobs, tabs } = await fetchJobManagement();
for (const job of jobs) {
const tab = tabs.find((item) => item.key === job.status);
expect(tab).toBeDefined();
// 角标与 Tab 用同一份文案(都来自 JOB_MANAGEMENT_TABS),
// 页面原先自己维护了一份 STATUS_LABEL,两处文案会各改各的
expect(getJobStatusLabel(job.status)).toBe(tab?.label);
}
});
it('三种状态各有互不相同的文案', async () => {
const { jobs } = await fetchJobManagement();
const labels = jobs.map((job) => getJobStatusLabel(job.status));
expect(new Set(labels).size).toBe(3);
});
it('未知状态原样返回,不抛错也不编文案', () => {
expect(getJobStatusLabel('archived' as JobManagementJobStatus)).toBe('archived');
});
});

101
src/__tests__/qualifications.test.ts

@ -0,0 +1,101 @@
import { fetchAboutInfo, fetchQualifications } from '../api/api';
import type { QualificationInfo, QualificationLicenseType } from '../types';
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock
jest.mock('../lib/storage', () => ({
storageGet: jest.fn(async () => null),
storageSet: jest.fn(async () => undefined),
storageRemove: jest.fn(async () => undefined),
}));
/** 页面必须公示的三类证照 */
const REQUIRED_TYPES: QualificationLicenseType[] = ['business-license', 'icp', 'hr-service'];
describe('fetchQualifications(平台资质证照)', () => {
let info: QualificationInfo;
beforeAll(async () => {
info = await fetchQualifications();
});
it('返回运营主体与证照列表', () => {
expect(typeof info.company).toBe('string');
expect(info.company.trim().length).toBeGreaterThan(0);
expect(Array.isArray(info.licenses)).toBe(true);
expect(info.licenses.length).toBeGreaterThanOrEqual(REQUIRED_TYPES.length);
});
it('覆盖营业执照 / ICP 备案 / 人力资源服务许可证三类', () => {
const types = info.licenses.map((license) => license.type);
for (const required of REQUIRED_TYPES) {
expect(types).toContain(required);
}
});
/*
* 这条是本文件最重要的断言,锁的是一个**原则**而非实现:
* 证照编号(统一社会信用代码 / 备案号 / 许可证编号)在拿到真实材料前必须保持 null。
* 一旦有人为了让页面「好看」而填进一个编造的号码,这条测试会立刻失败 ——
* 编造的证件号既是假信息,也可能撞上某个真实主体的号。
* 将来真拿到材料后,应连同此断言一起改,而不是悄悄绕过。
*/
it('不编造证件编号:真实材料到位前 number 必须全部为 null', () => {
const withNumber = info.licenses.filter((license) => license.number !== null);
expect(withNumber.map((license) => `${license.name}=${license.number}`)).toEqual([]);
});
it('发证机关与有效期同样不得编造,未提供时为 null', () => {
for (const license of info.licenses) {
expect(license.issuer).toBeNull();
expect(license.validPeriod).toBeNull();
}
});
/*
* 缺图不是「留空」,而是必须写明这里要补哪张图(AGENTS.md 的硬要求)。
* 空字符串同样算不合格 —— 占位块会渲染成一块空白,等于没提示。
*/
it('每张证照都要写明「此处需填充什么图片」', () => {
for (const license of info.licenses) {
expect(typeof license.imagePlaceholder).toBe('string');
expect(license.imagePlaceholder.trim().length).toBeGreaterThan(0);
// 占位说明要能对上具体证照,不能是一句通用废话
expect(license.imagePlaceholder).toContain(license.name);
}
});
it('imageUrl 与 imagePlaceholder 一一对应:有图就不必再提示补图', () => {
for (const license of info.licenses) {
if (license.imageUrl === null) {
expect(license.imagePlaceholder.trim().length).toBeGreaterThan(0);
}
}
});
it('编号字段显示名随证照种类给出,不留给页面判断', () => {
const labelOf = (type: QualificationLicenseType) =>
info.licenses.find((license) => license.type === type)?.numberLabel;
expect(labelOf('business-license')).toBe('统一社会信用代码');
expect(labelOf('icp')).toBe('备案号');
expect(labelOf('hr-service')).toBe('许可证编号');
});
it('核验提示非空(页面页脚据此渲染)', () => {
expect(info.verifyTip.trim().length).toBeGreaterThan(0);
});
/*
* 运营主体名字在本项目有「同一个名字被填成两个」的前科,
* 故此处不重复填写,直接取「关于我们」的 company。这条测试锁住这个同源关系。
*/
it('运营主体与「关于我们」同源,不会两处不一致', async () => {
const about = await fetchAboutInfo();
expect(info.company).toBe(about.company);
});
it('证照 id 唯一(列表 key 稳定)', () => {
const ids = info.licenses.map((license) => license.id);
expect(new Set(ids).size).toBe(ids.length);
});
});

371
src/__tests__/share-text.test.ts

@ -0,0 +1,371 @@
/**
* 分享 / 导出文本的拼装函数(纯函数,API 层)。
*
* 【为什么值得单测,而不是只靠真机验证】
* 4.9 那批把 6 处「点了只弹功能开发中」改成了真导出,4.12 全部在模拟器上验过 ——
* 但真机验的是**当时的 mock 值**:面板预览里数字对上了,说明「这一次拼对了」,
* 不说明「以后一直拼得对」。分享文本会**离开本机、给到别人看**,
* 拼错了比页面显示错更难被发现(页面自己看得见,发出去的内容看不见)。
*
* 这些函数是纯函数,正是单测最划算的地方。4.9 的方案里写了「并补单测」,
* 但当时只给作为范式的 `buildChecklistText` 补了(见 onboarding-checklist.test.ts),
* 其余 5 个一直裸奔 —— 本文件补上。
*
* 侧重点不是「复述实现」,而是锁住三类容易悄悄坏掉的地方:
* ① **空值守卫**:对应的数据没有时,不该输出「0 条」「空行」「悬空的破折号」
* ② **取的是不是本地选择态**:面试配置那份必须读传入的选择,不是接口默认值
* ③ **外部可见的格式**:首行是什么、签名是什么,改了就是对外观感变了
*/
jest.mock('../lib/storage', () => {
const store = new Map<string, string>();
return {
storageGet: jest.fn(async (key: string) => store.get(key) ?? null),
storageSet: jest.fn(async (key: string, value: string) => {
store.set(key, value);
}),
storageRemove: jest.fn(async (key: string) => {
store.delete(key);
}),
};
});
import {
buildHollandReportText,
buildInterviewConfigText,
buildJobShareText,
buildPrivacySummaryText,
buildRankReportText,
fetchAIInterviewData,
fetchHollandAssessment,
fetchJobDetail,
fetchJobOverallScore,
fetchPrivacyCenter,
fetchSalaryData,
} from '../api/api';
import type {
AIInterviewData,
HollandAssessment,
InterviewQuestionTypeCode,
PrivacyCenter,
RankOverview,
} from '../types';
/** 所有分享文本都以这句收尾 —— 对外可见,单独锁一条 */
const SIGNATURE = '—— 来自「有职」';
const textOf = (text: string) => text.split('\n');
const hasLineStartingWith = (text: string, prefix: string) =>
textOf(text).some((line) => line.startsWith(prefix));
describe('buildJobShareText(岗位详情分享)', () => {
async function fixtures() {
const [detail, overallScore, salaryData] = await Promise.all([
fetchJobDetail('1'),
fetchJobOverallScore('1'),
fetchSalaryData('1'),
]);
return { detail, overallScore, salaryData };
}
it('首三行是「岗位|公司」「地点·经验·学历」「薪资区间」', async () => {
const { detail, overallScore, salaryData } = await fixtures();
const lines = textOf(buildJobShareText(detail, overallScore, salaryData, 3));
expect(lines[0]).toBe(`${detail.positionName}|${detail.companyName}`);
expect(lines[1]).toContain(detail.companyLocation);
expect(lines[2]).toBe(
`薪资 ${detail.salaryMin}-${detail.salaryMax}K · ${detail.salaryMonths}薪`,
);
});
it('薪资月数缺失时不输出「0薪」这种话', async () => {
const { detail, overallScore, salaryData } = await fixtures();
const text = buildJobShareText({ ...detail, salaryMonths: 0 }, overallScore, salaryData, 3);
expect(text).not.toContain('0薪');
expect(hasLineStartingWith(text, '薪资 ')).toBe(true);
});
it('没有评分数据时整行不出现(而不是输出 null 或 0 分)', async () => {
const { detail, salaryData } = await fixtures();
const text = buildJobShareText(detail, null, salaryData, 3);
expect(text).not.toContain('岗位综合评分');
expect(text).not.toContain('null');
expect(text).not.toContain('undefined');
});
it('真实薪资样本数为 0 时不输出「真实薪资」(避免展示无依据的数据)', async () => {
const { detail, overallScore, salaryData } = await fixtures();
const text = buildJobShareText(detail, overallScore, { ...salaryData, totalCount: 0 }, 3);
expect(text).not.toContain('真实薪资');
});
it('福利为空时不留下「福利:」这个空壳', async () => {
const { detail, overallScore, salaryData } = await fixtures();
const text = buildJobShareText({ ...detail, benefits: [] }, overallScore, salaryData, 3);
expect(text).not.toContain('福利:');
});
it('评价数为 0 时说「暂无员工评价」,不说「员工评价 0 条」', async () => {
const { detail, overallScore, salaryData } = await fixtures();
const text = buildJobShareText(detail, overallScore, salaryData, 0);
expect(text).toContain('暂无员工评价');
expect(text).not.toContain('员工评价 0 条');
});
it('评价数大于 0 时给出条数', async () => {
const { detail, overallScore, salaryData } = await fixtures();
expect(buildJobShareText(detail, overallScore, salaryData, 3)).toContain('员工评价 3 条');
});
it('以签名收尾', async () => {
const { detail, overallScore, salaryData } = await fixtures();
expect(buildJobShareText(detail, overallScore, salaryData, 3)).toContain(SIGNATURE);
});
});
describe('buildHollandReportText(霍兰德测评报告)', () => {
/** 取真实测评数据结构,再按需覆盖 —— 避免手搭一份与接口脱节的形状 */
async function baseResult(): Promise<NonNullable<HollandAssessment['result']>> {
const assessment = await fetchHollandAssessment();
if (!assessment.result) throw new Error('mock 的测评结果缺失,无法构造用例');
return assessment.result;
}
it('头部是报告名与「类型代码:X · 名称」', async () => {
const result = await baseResult();
const lines = textOf(buildHollandReportText(result));
expect(lines[0]).toBe('霍兰德职业兴趣测评报告');
expect(lines[1]).toBe(`类型代码:${result.code} · ${result.name}`);
});
it('六维为空时连标题都不出现', async () => {
const result = await baseResult();
const text = buildHollandReportText({ ...result, dimensions: [] });
expect(text).not.toContain('【六维得分】');
});
it('某类型没有推荐职业时,不给它加「适合职业:」', async () => {
const result = await baseResult();
const first = result.analyses[0];
if (!first) throw new Error('mock 里没有主导类型解析');
const text = buildHollandReportText({
...result,
analyses: [{ ...first, careers: [] }],
});
expect(text).not.toContain('适合职业:');
});
it('推荐岗位为空时不出现「【推荐岗位】」', async () => {
const result = await baseResult();
const text = buildHollandReportText({ ...result, jobs: [] });
expect(text).not.toContain('【推荐岗位】');
});
it('以签名收尾', async () => {
const result = await baseResult();
expect(buildHollandReportText(result)).toContain(SIGNATURE);
});
});
describe('buildInterviewConfigText(面试配置分享)', () => {
let data: AIInterviewData;
beforeAll(async () => {
data = await fetchAIInterviewData();
});
it('模式输出中文 label,不是「full」这种 code', () => {
const text = buildInterviewConfigText(data, 'full', [], []);
const expected = data.modes.find((m) => m.id === 'full')?.label;
expect(expected).toBeDefined();
expect(text).toContain(`模式:${expected}`);
expect(text).not.toContain('模式:full');
});
it('**只输出被选中的题型** —— 分享的是本地选择,不是接口默认值', () => {
// 4.12 用「改选后再分享、面板预览随之变」在真机上证过这一点;
// 这里把它固化成不依赖设备的断言:传子集 → 未选中的不能出现。
const all = data.questionTypes.map((t) => t.id);
const excluded = data.questionTypes.filter((t) => t.id !== all[0]);
const onlyFirst: InterviewQuestionTypeCode[] = [all[0]];
const text = buildInterviewConfigText(data, 'full', onlyFirst, []);
for (const t of excluded) {
expect(text).not.toContain(t.label);
}
});
it('未知模式 code 回落到 code 本身,不崩也不输出「undefined」', () => {
const text = buildInterviewConfigText(data, 'unknown-mode' as never, [], []);
expect(text).toContain('模式:unknown-mode');
expect(text).not.toContain('undefined');
});
it('未知题型 code 被忽略,不崩也不输出「undefined」', () => {
const text = buildInterviewConfigText(data, 'full', ['nope' as never], []);
expect(text).not.toContain('undefined');
expect(text).not.toContain('题型:');
});
it('没选题型与专项时,这两行都不出现', () => {
const text = buildInterviewConfigText(data, 'full', [], []);
expect(text).not.toContain('题型:');
expect(text).not.toContain('专项突破:');
});
it('以签名收尾', () => {
expect(buildInterviewConfigText(data, 'full', [], [])).toContain(SIGNATURE);
});
});
describe('buildRankReportText(竞争力分析报告)', () => {
it('没有排名数据时不崩,且不输出头部行', () => {
const overview: RankOverview = { hasData: false };
const text = buildRankReportText(overview);
expect(text).not.toContain('竞争力分析报告');
expect(text).toContain(SIGNATURE);
});
it('没有排名但仍有维度与建议时,照样输出这两块', () => {
const overview: RankOverview = {
hasData: true,
dimensions: [{ id: 'd1', name: '技术能力', percentile: '前 20%', score: 88 }],
tips: [{ id: 't1', title: '补足项目数据', description: '用指标描述成果' }],
};
const text = buildRankReportText(overview);
expect(text).toContain('【维度百分位】');
expect(text).toContain('技术能力:前 20%(88 分)');
expect(text).toContain('【提升建议】');
expect(text).toContain('· 补足项目数据 —— 用指标描述成果');
});
it('建议没有描述时不留下悬空的分隔符', () => {
const overview: RankOverview = {
hasData: true,
tips: [{ id: 't1', title: '补足项目数据' }],
};
const text = buildRankReportText(overview);
// 断言**那一行**,不是整段文本:签名行本身就含「——」,
// 对整段断言 not.toContain('——') 会被签名行打脸(这条一开始就写错了,见提交说明)
const tipLine = textOf(text).find((line) => line.startsWith('· '));
expect(tipLine).toBe('· 补足项目数据');
});
});
describe('buildPrivacySummaryText(隐私设置摘要)', () => {
let center: PrivacyCenter;
beforeAll(async () => {
center = await fetchPrivacyCenter();
});
it('首两行是摘要标题与等级说明', () => {
const lines = textOf(buildPrivacySummaryText(center));
expect(lines[0]).toBe(`我的隐私设置摘要(当前等级:${center.privacyLevel.label})`);
expect(lines[1]).toBe(center.privacyLevel.description);
});
it('开关项输出 [已开启] / [已关闭]', () => {
const fixture: PrivacyCenter = {
...center,
sections: [
{
id: 's1',
title: '数据可见性',
items: [
{ id: 'i1', type: 'switch', title: '对 HR 公开简历', enabled: true },
{ id: 'i2', type: 'switch', title: '允许推荐', enabled: false },
],
},
],
};
const text = buildPrivacySummaryText(fixture);
expect(text).toContain('【数据可见性】');
expect(text).toContain('[已开启] 对 HR 公开简历');
expect(text).toContain('[已关闭] 允许推荐');
});
it('取值项输出「标题:值」', () => {
const fixture: PrivacyCenter = {
...center,
sections: [
{
id: 's1',
title: '可见范围',
items: [{ id: 'i1', type: 'value', title: '简历可见范围', value: '仅投递企业' }],
},
],
};
expect(buildPrivacySummaryText(fixture)).toContain('简历可见范围:仅投递企业');
});
it('取值项没填值时只输出标题,不留「标题:」这个空壳', () => {
const fixture: PrivacyCenter = {
...center,
sections: [
{
id: 's1',
title: '可见范围',
items: [{ id: 'i1', type: 'value', title: '简历可见范围', value: '' }],
},
],
};
const text = buildPrivacySummaryText(fixture);
expect(text).toContain('简历可见范围');
expect(text).not.toContain('简历可见范围:');
});
it('以签名收尾', () => {
expect(buildPrivacySummaryText(center)).toContain(SIGNATURE);
});
it('空分组不产生多余的连续空行', () => {
const fixture: PrivacyCenter = {
...center,
sections: [{ id: 's1', title: '空分组', items: [] }],
};
const lines = textOf(buildPrivacySummaryText(fixture));
expect(lines.some((line) => line.includes('【空分组】'))).toBe(true);
// 分组之间只留一个空行,不留「\n\n\n」
expect(buildPrivacySummaryText(fixture)).not.toContain('\n\n\n');
});
});

818
src/api/api.ts

@ -84,6 +84,8 @@ import type {
IdentityOption, IdentityOption,
AppRole, AppRole,
AIInterviewData, AIInterviewData,
InterviewMode,
InterviewQuestionTypeCode,
InterviewSession, InterviewSession,
HREvaluation, HREvaluation,
EvaluationAppealForm, EvaluationAppealForm,
@ -106,6 +108,7 @@ import type {
ToggleSetting, ToggleSetting,
ActionSetting, ActionSetting,
AboutInfo, AboutInfo,
QualificationInfo,
UpdateTogglePayload, UpdateTogglePayload,
PrivacyCenter, PrivacyCenter,
AccountCancelData, AccountCancelData,
@ -133,6 +136,11 @@ import type {
JobQualityEstimate, JobQualityEstimate,
JobReview, JobReview,
JobManagement, JobManagement,
JobManagementJob,
JobStatusAction,
JobManagementJobStatus,
JobManagementSortKey,
JobManagementTab,
TalentCandidate, TalentCandidate,
TalentRecommendation, TalentRecommendation,
TalentRecommendationTab, TalentRecommendationTab,
@ -437,9 +445,21 @@ const HOLLAND_NAMES: Record<HollandType, string> = {
C: '常规型', C: '常规型',
}; };
/**
* 霍兰德测评累计参与人数。
*
* 【为什么抽成常量】此前介绍页写 124580、测评页写 12846 —— **同一个统计量两个数**:
* 用户在介绍页看到「已有 124,580 人完成测评」,回到成长页又看到「已有 12,846 人完成」。
* 与「全部 12 / 已下线 3」属同一类缺陷(一个数字两处来源),故收敛到一处。
*
* 真实人数应由后端下发,接入前两处引用同一常量,不得各写各的;
* 接后端时删掉本常量,改为两处各自读接口返回值。
*/
const HOLLAND_TOTAL_PARTICIPANTS = 124580;
/** 霍兰德职业兴趣测评 */ /** 霍兰德职业兴趣测评 */
const MOCK_HOLLAND: HollandAssessment = { const MOCK_HOLLAND: HollandAssessment = {
totalParticipants: 12846, totalParticipants: HOLLAND_TOTAL_PARTICIPANTS,
currentQuestionIndex: 11, currentQuestionIndex: 11,
totalQuestions: 60, totalQuestions: 60,
question: { question: {
@ -621,6 +641,7 @@ const MOCK_AI_TOOLS: AITool[] = [
action: '立即优化', action: '立即优化',
variant: 'primary', variant: 'primary',
icon: 'document-text-outline', icon: 'document-text-outline',
route: '/resume',
}, },
{ {
id: 'ai-interview', id: 'ai-interview',
@ -629,6 +650,7 @@ const MOCK_AI_TOOLS: AITool[] = [
action: '开始练习', action: '开始练习',
variant: 'success', variant: 'success',
icon: 'chatbubbles-outline', icon: 'chatbubbles-outline',
route: '/interview',
}, },
]; ];
@ -1720,6 +1742,43 @@ export async function fetchCompareData(jobIds: string[]): Promise<CompareData> {
return mockRequest(data); return mockRequest(data);
} }
/**
* 把岗位详情拼成可分享的纯文本(分享用,纯函数)。
* @param detail 岗位详情
* @param overallScore 岗位综合评分(未取到传 null)
* @param salaryData 真实薪资数据(未取到传 null)
* @param reviewCount 员工评价条数
* @returns string 形如「岗位名|公司」+ 薪资 / 评分 / 福利 / 评价数
* @remark 本项目没有岗位的 Web 落地页,因此只拼文本、不编造分享链接。
*/
export function buildJobShareText(
detail: JobDetail,
overallScore: JobOverallScore | null,
salaryData: SalaryData | null,
reviewCount: number,
): string {
const lines: string[] = [
`${detail.positionName}|${detail.companyName}`,
`${detail.companyLocation} · ${detail.experience} · ${detail.education}`,
`薪资 ${detail.salaryMin}-${detail.salaryMax}K${detail.salaryMonths > 0 ? ` · ${detail.salaryMonths}薪` : ''}`,
];
if (overallScore) {
lines.push(`岗位综合评分 ${overallScore.totalScore} 分(${overallScore.level})· ${overallScore.rank}`);
}
if (salaryData && salaryData.totalCount > 0) {
lines.push(`真实薪资:${salaryData.dataSource}`);
}
if (detail.benefits.length > 0) {
lines.push(`福利:${detail.benefits.join('、')}`);
}
lines.push(reviewCount > 0 ? `员工评价 ${reviewCount} 条` : '暂无员工评价');
lines.push('');
lines.push('—— 来自「有职」');
return lines.join('\n');
}
/* ===================== Offer 对比 ===================== */ /* ===================== Offer 对比 ===================== */
/** Offer 对比 mock 数据(字节跳动 vs 腾讯科技) */ /** Offer 对比 mock 数据(字节跳动 vs 腾讯科技) */
@ -1788,7 +1847,6 @@ const MOCK_OFFER_COMPARE_DATA: OfferCompareData = {
summary: '综合薪资、福利、发展空间等多维度评估,字节跳动的 Offer 综合评分更高。', summary: '综合薪资、福利、发展空间等多维度评估,字节跳动的 Offer 综合评分更高。',
detail: detail:
'但腾讯的工作生活平衡更好,建议根据个人职业规划选择。若追求短期收入与快速成长,字节更优;若看重工作节奏与长期稳定,腾讯更合适。', '但腾讯的工作生活平衡更好,建议根据个人职业规划选择。若追求短期收入与快速成长,字节更优;若看重工作节奏与长期稳定,腾讯更合适。',
reportAction: '查看完整分析报告',
}, },
actions: { actions: {
exportLabel: '导出对比报告', exportLabel: '导出对比报告',
@ -1839,6 +1897,58 @@ export async function fetchOfferCompareData(offerIds?: string[]): Promise<OfferC
return mockRequest(data); return mockRequest(data);
} }
/**
* 拼装「Offer 对比报告」纯文本(导出/分享用)。
*
* 页面已把对比结果完整渲染在屏幕上,本函数只是把它转成可粘贴、可转发的文本,
* 不额外加工数据、不计分、不排序 —— 保证导出内容与用户所见一致。
*
* @param data fetchOfferCompareData 的返回值
* @returns 可直接交给 shareText 的多行文本
* @remark 只导出参与对比的 Offer。scores 与 offers 是**按位对应**的(同下标属同一家公司),
* 这在 fetchOfferCompareData 里就是这样约定的,故此处按 index 取,不按公司名匹配。
*/
export function buildOfferCompareText(data: OfferCompareData): string {
const lines: string[] = ['【Offer 对比报告】', ''];
const { offers, scores, benefitGroups, workLifeBalance, aiSuggestion } = data;
lines.push(`参与对比 ${offers.length} 个 Offer`);
offers.forEach((offer, index) => {
const score = scores[index];
const scoreText = score ? ` | 综合评分 ${score.score} 分(${score.level})` : '';
lines.push(`${index + 1}. ${offer.company} · ${offer.position} · ${offer.salary}${scoreText}`);
});
lines.push('');
benefitGroups.forEach((group) => {
lines.push(`— ${group.title} —`);
group.rows.forEach((row) => {
// 值为 null 表示该 Offer 没有这项数据,如实写「—」,不脑补成 0 或「无」
const cells = row.values.map((value, i) => `${offers[i]?.company ?? `Offer${i + 1}`}:${value ?? '—'}`);
lines.push(`${row.label} | ${cells.join(' | ')}`);
});
lines.push('');
});
if (workLifeBalance.length > 0) {
lines.push('— 工作生活平衡 —');
workLifeBalance.forEach((row) => {
const cells = row.values.map((value, i) => `${offers[i]?.company ?? `Offer${i + 1}`}:${value}`);
lines.push(`${row.label} | ${cells.join(' | ')}`);
});
lines.push('');
}
lines.push('— AI 智能建议 —');
lines.push(aiSuggestion.summary);
lines.push(aiSuggestion.detail);
lines.push('');
lines.push('本报告由「有职」App 根据当前对比页数据生成,仅供参考。');
return lines.join('\n');
}
/** /**
* 待处理 Offer 数量(聊天页「有 N 个 Offer 待处理」横幅)。 * 待处理 Offer 数量(聊天页「有 N 个 Offer 待处理」横幅)。
* *
@ -2000,6 +2110,7 @@ export async function fetchRankData(jobId: string): Promise<RankOverview> {
title: '补充项目经历可提升 5-8 个百分位', title: '补充项目经历可提升 5-8 个百分位',
description: '完善你的项目经历描述,突出技术选型与业务成果', description: '完善你的项目经历描述,突出技术选型与业务成果',
type: 'action', type: 'action',
route: '/resume/project',
}, },
{ {
id: 't2', id: 't2',
@ -2029,6 +2140,43 @@ export async function fetchRankData(jobId: string): Promise<RankOverview> {
}); });
} }
/**
* 把竞争力排名拼成可导出的纯文本(导出 / 分享用,纯函数)。
* @param overview 竞争力排名总览
* @returns string 形如「竞争力分析报告(岗位 · 公司)」+ 排名 + 维度百分位 + 提升建议
* @remark 只导出用户本人的指标,不含 TOP 候选人(那是他人数据)。
*/
export function buildRankReportText(overview: RankOverview): string {
const lines: string[] = [];
const rank = overview.rankData;
if (rank) {
lines.push(`竞争力分析报告(${rank.jobTitle} · ${rank.company})`);
lines.push(`综合评分 ${rank.overallScore} 分(${rank.scoreLevel})`);
lines.push(
`当前排名 ${rank.rank}/${rank.totalCandidates} · 超越 ${rank.percentile}% 候选人 · ${rank.rankPosition}`,
);
lines.push('');
}
if (overview.dimensions && overview.dimensions.length > 0) {
lines.push('【维度百分位】');
overview.dimensions.forEach((d) => lines.push(`${d.name}:${d.percentile}(${d.score} 分)`));
lines.push('');
}
if (overview.tips && overview.tips.length > 0) {
lines.push('【提升建议】');
overview.tips.forEach((t) =>
lines.push(`· ${t.title}${t.description ? ` —— ${t.description}` : ''}`),
);
lines.push('');
}
lines.push('—— 来自「有职」');
return lines.join('\n');
}
/* ===================== 行业技能词云 ===================== */ /* ===================== 行业技能词云 ===================== */
/** 技能分类(全部分类 + 5 个具体分类) */ /** 技能分类(全部分类 + 5 个具体分类) */
@ -3410,6 +3558,41 @@ export async function startInterview(): Promise<InterviewSession> {
return mockRequest({ id: `session-${Date.now()}`, total: 10, currentIndex: 1, remainingSeconds: 1800 }); return mockRequest({ id: `session-${Date.now()}`, total: 10, currentIndex: 1, remainingSeconds: 1800 });
} }
/**
* 把面试配置拼成可分享的纯文本(分享用,纯函数)。
* @param data 面试配置聚合数据(用于把 id 映射成文案)
* @param mode 用户当前选择的模式
* @param questionTypeCodes 用户当前选择的题型号
* @param specialIds 用户当前选择的专项 id
* @returns string 形如「AI 模拟面试配置(岗位 · 公司)」+ 模式 / 难度 / 题型 / 专项
* @remark 传入的是**页面上的当前选择**而非接口默认值 —— 默认值可能与用户改后的不一致。
*/
export function buildInterviewConfigText(
data: AIInterviewData,
mode: InterviewMode,
questionTypeCodes: InterviewQuestionTypeCode[],
specialIds: string[],
): string {
const modeLabel = data.modes.find((m) => m.id === mode)?.label ?? mode;
const typeLabels = data.questionTypes
.filter((t) => questionTypeCodes.includes(t.id))
.map((t) => t.label);
const specialLabels = data.specials
.filter((s) => specialIds.includes(s.id))
.map((s) => s.label);
const lines: string[] = [
`AI 模拟面试配置(${data.jobInfo.position} · ${data.jobInfo.company})`,
`模式:${modeLabel}`,
`难度:${data.jobInfo.difficulty} · 时长:${data.jobInfo.duration} 分钟`,
];
if (typeLabels.length > 0) lines.push(`题型:${typeLabels.join('、')}`);
if (specialLabels.length > 0) lines.push(`专项突破:${specialLabels.join('、')}`);
lines.push('');
lines.push('—— 来自「有职」');
return lines.join('\n');
}
/* ===================== HR 评价 ===================== */ /* ===================== HR 评价 ===================== */
/** HR 评价详情 */ /** HR 评价详情 */
@ -4250,7 +4433,7 @@ export async function submitEnterpriseCertification(
const MOCK_HOLLAND_INTRO: HollandIntro = { const MOCK_HOLLAND_INTRO: HollandIntro = {
title: '霍兰德职业兴趣测评', title: '霍兰德职业兴趣测评',
subtitle: '发现你的职业兴趣类型,找到最适合你的职业方向', subtitle: '发现你的职业兴趣类型,找到最适合你的职业方向',
totalParticipants: 124580, totalParticipants: HOLLAND_TOTAL_PARTICIPANTS,
duration: '约10分钟', duration: '约10分钟',
dimensions: [ dimensions: [
{ type: 'R', name: '现实型', desc: '喜欢动手操作与具体任务' }, { type: 'R', name: '现实型', desc: '喜欢动手操作与具体任务' },
@ -4287,24 +4470,58 @@ export async function fetchHollandIntro(): Promise<HollandIntro> {
return mockRequest(MOCK_HOLLAND_INTRO); return mockRequest(MOCK_HOLLAND_INTRO);
} }
/**
* 把霍兰德测评结果拼成可分享的纯文本(分享用,纯函数)。
* @param result 测评结果(HollandAssessment['result'],未完成时为 undefined,调用方需先守卫)
* @returns string 形如「霍兰德职业兴趣测评报告」+ 类型代码 + 六维得分 + 类型解析 + 推荐岗位
*/
export function buildHollandReportText(
result: NonNullable<HollandAssessment['result']>,
): string {
const lines: string[] = ['霍兰德职业兴趣测评报告', `类型代码:${result.code} · ${result.name}`, ''];
if (result.dimensions.length > 0) {
lines.push('【六维得分】');
result.dimensions.forEach((d) => lines.push(`${d.name}:${d.score} 分(超越 ${d.percent}%)`));
lines.push('');
}
if (result.analyses.length > 0) {
lines.push('【主导类型解析】');
result.analyses.forEach((a) => {
lines.push(`${a.name}(${a.score} 分):${a.description}`);
if (a.careers.length > 0) lines.push(` 适合职业:${a.careers.join('、')}`);
});
lines.push('');
}
if (result.jobs.length > 0) {
lines.push('【推荐岗位】');
result.jobs.forEach((j) =>
lines.push(`· ${j.title}|${j.companyName} · ${j.city} · ${j.salary} · 匹配度 ${j.match}%`),
);
lines.push('');
}
lines.push('—— 来自「有职」');
return lines.join('\n');
}
/* ===================== 岗位管理(企业端) ===================== */ /* ===================== 岗位管理(企业端) ===================== */
const MOCK_JOB_MANAGEMENT: JobManagement = { /** 岗位管理的 Tab 定义(key → 文案)。计数不写死,由 buildJobManagementTabs 从列表算 */
companyName: '智联科技', const JOB_MANAGEMENT_TABS: { key: string; label: string }[] = [
hasData: true, { key: 'all', label: '全部' },
stats: [ { key: 'active', label: '招聘中' },
{ id: 'active', label: '在招岗位', value: 12, change: '+2', icon: 'briefcase-outline', color: 'primary' }, { key: 'offline', label: '已下线' },
{ id: 'today', label: '今日新增投递', value: 48, change: '+23%', icon: 'mail-outline', color: 'success' }, { key: 'draft', label: '草稿' },
{ id: 'pending', label: '待处理简历', value: 23, change: '-5', icon: 'document-text-outline', color: 'action' }, ];
{ id: 'interview', label: '即将面试', value: 8, change: '已安排', icon: 'calendar-outline', color: 'info' },
], /**
tabs: [ * 12 个岗位与 Tab 计数同源:此前 mock 只有 3 个岗位却写着「全部 12 / 已下线 3」,
{ key: 'all', label: '全部', count: 12 }, * 点「已下线(3)」会看到空列表 —— 数字与数据对不上。这里补齐 12 条并让计数由列表推导。
{ key: 'active', label: '招聘中', count: 8 }, */
{ key: 'offline', label: '已下线', count: 3 }, const MOCK_JOB_MANAGEMENT_JOBS: JobManagementJob[] = [
{ key: 'draft', label: '草稿', count: 1 },
],
jobs: [
{ {
id: 'job-1', id: 'job-1',
title: '高级前端工程师', title: '高级前端工程师',
@ -4322,6 +4539,7 @@ const MOCK_JOB_MANAGEMENT: JobManagement = {
applicants: 128, applicants: 128,
views: 2300, views: 2300,
matches: 56, matches: 56,
publishedAt: '2026-09-15T09:30:00.000Z',
}, },
{ {
id: 'job-2', id: 'job-2',
@ -4340,6 +4558,7 @@ const MOCK_JOB_MANAGEMENT: JobManagement = {
applicants: 86, applicants: 86,
views: 1800, views: 1800,
matches: 32, matches: 32,
publishedAt: '2026-09-12T10:10:00.000Z',
}, },
{ {
id: 'job-3', id: 'job-3',
@ -4358,8 +4577,437 @@ const MOCK_JOB_MANAGEMENT: JobManagement = {
applicants: 156, applicants: 156,
views: 3100, views: 3100,
matches: 78, matches: 78,
publishedAt: '2026-09-16T14:00:00.000Z',
}, },
], {
id: 'job-4',
title: 'UI设计师',
salary: '15-25K',
salaryMonths: 13,
city: '上海',
experience: '1-3年',
education: '本科',
tags: ['Figma', 'B端设计'],
status: 'active',
department: '设计部',
headcount: 1,
overallScore: 81,
rankText: '同岗排名 Top 18%',
applicants: 64,
views: 1420,
matches: 21,
publishedAt: '2026-09-10T11:20:00.000Z',
},
{
id: 'job-5',
title: '数据分析师',
salary: '18-28K',
salaryMonths: 14,
city: '上海',
experience: '3-5年',
education: '硕士',
tags: ['SQL', 'Python', 'BI'],
status: 'active',
department: '技术部',
headcount: 2,
overallScore: 79,
rankText: '同岗排名 Top 22%',
applicants: 97,
views: 2050,
matches: 44,
publishedAt: '2026-09-08T09:00:00.000Z',
},
{
id: 'job-6',
title: 'Android开发工程师',
salary: '22-35K',
salaryMonths: 14,
city: '深圳',
experience: '3-5年',
education: '本科',
tags: ['Kotlin', 'Jetpack Compose'],
status: 'active',
department: '技术部',
headcount: 2,
overallScore: 84,
rankText: '同岗排名 Top 12%',
applicants: 73,
views: 1660,
matches: 29,
publishedAt: '2026-09-05T16:40:00.000Z',
},
{
id: 'job-7',
title: '市场推广专员',
salary: '10-18K',
salaryMonths: 13,
city: '深圳',
experience: '1-3年',
education: '本科',
tags: ['内容运营', '投放'],
status: 'active',
department: '市场部',
headcount: 3,
overallScore: 68,
rankText: '同岗排名 Top 45%',
applicants: 152,
views: 2480,
matches: 18,
publishedAt: '2026-09-02T10:00:00.000Z',
},
{
id: 'job-8',
title: '测试开发工程师',
salary: '20-30K',
salaryMonths: 14,
city: '杭州',
experience: '3-5年',
education: '本科',
tags: ['自动化测试', 'CI/CD'],
status: 'active',
department: '技术部',
headcount: 1,
overallScore: 76,
rankText: '同岗排名 Top 30%',
applicants: 58,
views: 1190,
matches: 23,
publishedAt: '2026-08-28T13:30:00.000Z',
},
{
id: 'job-9',
title: '运营实习生',
salary: '4-6K',
salaryMonths: 12,
city: '北京',
experience: '应届生',
education: '本科',
tags: ['实习', '可转正'],
status: 'offline',
department: '市场部',
headcount: 2,
overallScore: 61,
rankText: '同岗排名 Top 60%',
applicants: 210,
views: 3600,
matches: 12,
publishedAt: '2026-07-20T09:00:00.000Z',
},
{
id: 'job-10',
title: 'iOS开发工程师',
salary: '25-38K',
salaryMonths: 15,
city: '上海',
experience: '5-10年',
education: '本科',
tags: ['Swift', 'SwiftUI'],
status: 'offline',
department: '技术部',
headcount: 1,
overallScore: 86,
rankText: '同岗排名 Top 9%',
applicants: 41,
views: 980,
matches: 17,
publishedAt: '2026-07-05T15:10:00.000Z',
},
{
id: 'job-11',
title: '视觉设计师',
salary: '12-20K',
salaryMonths: 13,
city: '杭州',
experience: '1-3年',
education: '本科',
tags: ['品牌视觉', '插画'],
status: 'offline',
department: '设计部',
headcount: 1,
overallScore: 72,
rankText: '同岗排名 Top 38%',
applicants: 88,
views: 1540,
matches: 15,
publishedAt: '2026-06-18T11:00:00.000Z',
},
{
id: 'job-12',
title: '算法工程师(推荐方向)',
salary: '35-55K',
salaryMonths: 16,
city: '北京',
experience: '5-10年',
education: '硕士',
tags: ['推荐系统', 'PyTorch'],
status: 'draft',
department: '技术部',
headcount: 1,
overallScore: 0,
rankText: '未发布,暂无排名',
applicants: 0,
views: 0,
matches: 0,
publishedAt: '2026-09-17T17:20:00.000Z',
},
];
/**
* 由岗位列表推导 Tab 计数。
* 计数与列表**必然同源**,不会再出现「Tab 写着 3 个、点进去 0 个」。
*/
export function buildJobManagementTabs(jobs: JobManagementJob[]): JobManagementTab[] {
return JOB_MANAGEMENT_TABS.map((tab) => ({
...tab,
count: tab.key === 'all' ? jobs.length : jobs.filter((job) => job.status === tab.key).length,
}));
}
/** 岗位是否命中某个 Tab(与 JOB_MANAGEMENT_TABS 的 key 一一对应) */
export function matchJobManagementTab(job: JobManagementJob, tabKey: string): boolean {
return tabKey === 'all' ? true : job.status === tabKey;
}
/**
* 岗位状态的展示名(招聘中 / 已下线 / 草稿)。
*
* 直接取自 Tab 定义,**不另写一份** —— 页面原先自己维护了 `STATUS_LABEL`,
* 与 `JOB_MANAGEMENT_TABS` 里的三个标签逐字重复。同一个名字有两处来源,
* 改一处忘一处就会冒出「Tab 写草稿、角标写未发布」这类不一致。
*/
export function getJobStatusLabel(status: JobManagementJobStatus): string {
const tab = JOB_MANAGEMENT_TABS.find((item) => item.key === status);
return tab ? tab.label : status;
}
/**
* 由岗位当前状态推导「状态切换」按钮与二次确认的全部文案(纯函数,业务规则归接口层)。
*
* **关键在于区分「草稿」与「已下线」** —— 页面原先只判断 `status === 'active'`,
* 于是任何非在招岗位都显示「重新上架」:对已下线岗位成立,对**从未发布过的草稿**不成立,
* 确认框里「候选人可以**再次**看到该岗位」更是凭空多了一次并不存在的上架。
* 故这里把三种状态分开给出文案,不在页面里写 if。
*/
export function buildJobStatusAction(job: JobManagementJob): JobStatusAction {
if (job.status === 'active') {
return {
label: '下架',
icon: 'download-outline',
confirmTitle: '下架岗位',
confirmMessage: `下架「${job.title}」后,候选人将无法再看到该岗位,已投递的简历不受影响。`,
confirmButton: '下架',
nextStatus: 'offline',
destructive: true,
};
}
if (job.status === 'draft') {
return {
label: '上架',
icon: 'arrow-up-circle-outline',
confirmTitle: '上架岗位',
confirmMessage: `上架「${job.title}」后,候选人即可看到该岗位。`,
confirmButton: '上架',
nextStatus: 'active',
destructive: false,
};
}
return {
label: '重新上架',
icon: 'arrow-up-circle-outline',
confirmTitle: '重新上架',
confirmMessage: `重新上架「${job.title}」后,候选人可以再次看到该岗位。`,
confirmButton: '上架',
nextStatus: 'active',
destructive: false,
};
}
/** 岗位列表排序方式选项(页面渲染用,避免页面自己写一份 key→文案的映射) */
export const JOB_MANAGEMENT_SORTS: { key: JobManagementSortKey; label: string }[] = [
{ key: 'latest', label: '最新发布' },
{ key: 'applicants', label: '投递最多' },
{ key: 'views', label: '查看最多' },
{ key: 'score', label: '评分最高' },
];
/**
* 岗位列表的筛选 + 排序(纯函数,业务规则归接口层)。
*
* 筛选是**与**关系:Tab、关键词、城市、部门四者都满足才留下。
* 关键词匹配岗位名称与部门名,忽略大小写与首尾空格。
*
* @param jobs 全量岗位
* @param options.tabKey Tab key
* @param options.keyword 搜索词(岗位名 / 部门)
* @param options.cities 选中的城市(空数组 = 不限)
* @param options.departments 选中的部门(空数组 = 不限)
* @param options.sortKey 排序方式
* @returns 过滤并排好序的新数组(不改动入参)
*/
export function filterJobManagementJobs(
jobs: JobManagementJob[],
options: {
tabKey: string;
keyword?: string;
cities?: string[];
departments?: string[];
sortKey?: JobManagementSortKey;
},
): JobManagementJob[] {
const keyword = options.keyword?.trim().toLowerCase() ?? '';
const cities = options.cities ?? [];
const departments = options.departments ?? [];
const result = jobs.filter((job) => {
if (!matchJobManagementTab(job, options.tabKey)) return false;
if (keyword && !`${job.title} ${job.department}`.toLowerCase().includes(keyword)) return false;
if (cities.length > 0 && !cities.includes(job.city)) return false;
if (departments.length > 0 && !departments.includes(job.department)) return false;
return true;
});
switch (options.sortKey) {
case 'applicants':
return result.sort((a, b) => b.applicants - a.applicants);
case 'views':
return result.sort((a, b) => b.views - a.views);
case 'score':
return result.sort((a, b) => b.overallScore - a.overallScore);
case 'latest':
default:
// 时间串是 ISO 8601,字典序即时间序
return result.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
}
/** 从岗位列表里去重取城市 / 部门,供筛选面板渲染真实选项(不写死选项表) */
export function buildJobManagementFilterOptions(jobs: JobManagementJob[]): {
cities: string[];
departments: string[];
} {
return {
cities: Array.from(new Set(jobs.map((job) => job.city))),
departments: Array.from(new Set(jobs.map((job) => job.department))),
};
}
/* ===================== 岗位管理:写通道(下架 / 刷新) ===================== */
/** 岗位状态覆盖表:jobId → status。只存被改过的,其余仍取 mock 原值 */
const JOB_MANAGEMENT_STATUS_KEY = 'enterprise:jobs-status';
/** 岗位刷新时间表:jobId → ISO 时间串 */
const JOB_MANAGEMENT_REFRESH_KEY = 'enterprise:jobs-refreshed';
/** 读存储里的 JSON 对象;读不到或格式坏了都当空表(回落成 mock 原值,不抛异常) */
async function readJsonRecord(key: string): Promise<Record<string, string>> {
const raw = await storageGet(key);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as Record<string, string>;
} catch {
return {};
}
}
/**
* 把存储里的覆盖叠加到 mock 列表上。
*
* 为什么要绕存储:`mockRequest` 不做深拷贝,直接改 `MOCK_JOB_MANAGEMENT_JOBS`
* 会污染其它调用方读到的「原始数据」,而且杀进程就丢 —— 等于下架按钮白按。
*/
async function applyJobManagementOverrides(
jobs: JobManagementJob[],
): Promise<JobManagementJob[]> {
const statusMap = await readJsonRecord(JOB_MANAGEMENT_STATUS_KEY);
const refreshMap = await readJsonRecord(JOB_MANAGEMENT_REFRESH_KEY);
return jobs.map((job) => ({
...job,
status: (statusMap[job.id] as JobManagementJobStatus | undefined) ?? job.status,
refreshedAt: refreshMap[job.id] ?? job.refreshedAt,
}));
}
/**
* 顶部数据概览。
* 「在招岗位」由列表实时算出 —— 下架一个岗位,这里要跟着减 1,不能是写死的 12。
* 其余三项依赖后端投递/面试数据,暂沿用 mock 文案。
*/
function buildJobManagementStats(jobs: JobManagementJob[]): JobManagement['stats'] {
const activeCount = jobs.filter((job) => job.status === 'active').length;
return [
{
id: 'active',
label: '在招岗位',
value: activeCount,
change: '+2',
icon: 'briefcase-outline',
color: 'primary',
},
{ id: 'today', label: '今日新增投递', value: 48, change: '+23%', icon: 'mail-outline', color: 'success' },
{ id: 'pending', label: '待处理简历', value: 23, change: '-5', icon: 'document-text-outline', color: 'action' },
{ id: 'interview', label: '即将面试', value: 8, change: '已安排', icon: 'calendar-outline', color: 'info' },
];
}
/**
* 下架 / 恢复岗位
* @method PATCH
* @url /api/enterprise/jobs/:id/status
* @param jobId 岗位 id
* @param status 目标状态
* @returns Promise<JobManagement> 更新后的完整岗位管理数据(含重算的 stats 与 tabs)
*/
export async function updateJobStatus(
jobId: string,
status: JobManagementJobStatus,
): Promise<JobManagement> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/jobs/${jobId}/status`, {
// method: 'PATCH',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ status }),
// });
// return res.json();
const statusMap = await readJsonRecord(JOB_MANAGEMENT_STATUS_KEY);
statusMap[jobId] = status;
await storageSet(JOB_MANAGEMENT_STATUS_KEY, JSON.stringify(statusMap));
return mockRequest(await buildJobManagement());
}
/**
* 刷新岗位(重新推向候选人,提升曝光)
* @method POST
* @url /api/enterprise/jobs/:id/refresh
* @param jobId 岗位 id
* @returns Promise<JobManagement> 更新后的完整岗位管理数据
* @remark 真实实现应写入服务端时间。此处用本机时间,仅用于界面显示「最近刷新」,
* 不参与任何业务判定,故可接受;后端就绪后以返回值覆盖。
*/
export async function refreshJob(jobId: string): Promise<JobManagement> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/jobs/${jobId}/refresh`, { method: 'POST' });
// return res.json();
const refreshMap = await readJsonRecord(JOB_MANAGEMENT_REFRESH_KEY);
refreshMap[jobId] = new Date().toISOString();
await storageSet(JOB_MANAGEMENT_REFRESH_KEY, JSON.stringify(refreshMap));
return mockRequest(await buildJobManagement());
}
/** 按当前(含覆盖的)岗位列表组装一份完整的岗位管理数据 */
async function buildJobManagement(): Promise<JobManagement> {
const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS);
return {
companyName: '智联科技',
hasData: true,
stats: buildJobManagementStats(jobs),
tabs: buildJobManagementTabs(jobs),
jobs,
benefits: [ benefits: [
'接收候选人主动投递', '接收候选人主动投递',
'使用AI智能匹配推荐人才', '使用AI智能匹配推荐人才',
@ -4368,19 +5016,22 @@ const MOCK_JOB_MANAGEMENT: JobManagement = {
'获取招聘数据看板', '获取招聘数据看板',
], ],
}; };
}
/** /**
* 获取企业端岗位管理数据 * 获取企业端岗位管理数据
* @method GET * @method GET
* @url /api/enterprise/job-management * @url /api/enterprise/job-management
* @returns Promise<JobManagement> 岗位管理数据 * @returns Promise<JobManagement> 岗位管理数据(tabs 计数由 jobs 推导,保证一致)
* @remark 返回的是**全量**岗位,筛选/排序由 filterJobManagementJobs 在前端做;
* 后端就绪后可改为把 tabKey/keyword/sortKey 作为查询参数下推,函数签名不变
*/ */
export async function fetchJobManagement(): Promise<JobManagement> { export async function fetchJobManagement(): Promise<JobManagement> {
// TODO: 真实请求(后端就绪后替换) // TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/job-management`, { method: 'GET' }); // const res = await fetch(`${API_BASE_URL}/api/enterprise/job-management`, { method: 'GET' });
// return res.json(); // return res.json();
return mockRequest(MOCK_JOB_MANAGEMENT); return mockRequest(await buildJobManagement());
} }
/* ===================== 人才推荐(企业端) ===================== */ /* ===================== 人才推荐(企业端) ===================== */
@ -5508,7 +6159,11 @@ const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [
id: 'tmpl-3', id: 'tmpl-3',
name: '劳动仲裁申请书', name: '劳动仲裁申请书',
description: '劳动争议维权起步文书', description: '劳动争议维权起步文书',
icon: 'gavel-outline', // 原为 'gavel-outline' —— Ionicons 全库 1357 个字形里**没有** gavel,
// 运行时会打印 "not a valid icon name" 且图标渲染为空白。
// 页面用 `as keyof typeof Ionicons.glyphMap` 断言吞掉了类型错误,故 tsc 一直没报。
// 用语义最接近的法槌等价物 hammer-outline(仲裁 = 裁决,锤为传统象征)。
icon: 'hammer-outline',
url: 'https://dev.api.com/legal/templates/arbitration-application', url: 'https://dev.api.com/legal/templates/arbitration-application',
content: `劳动仲裁申请书 content: `劳动仲裁申请书
@ -6094,6 +6749,38 @@ export async function updatePrivacyCenterSetting(
return mockRequest(MOCK_PRIVACY_CENTER); return mockRequest(MOCK_PRIVACY_CENTER);
} }
/**
* 把当前隐私设置拼成可导出的纯文本(导出 / 分享用,纯函数)。
* @param center 隐私中心聚合数据
* @returns string 形如「我的隐私设置摘要(当前等级:…)」+ 分组 + 各项开关状态
* @remark 这是「我的隐私设置现状」,不是「谁访问了我的数据」的访问记录 ——
* 后者需要后端日志,本仓库没有该接口,文案上不含糊其辞。
*/
export function buildPrivacySummaryText(center: PrivacyCenter): string {
const lines: string[] = [
`我的隐私设置摘要(当前等级:${center.privacyLevel.label})`,
center.privacyLevel.description,
'',
];
center.sections.forEach((section) => {
lines.push(`【${section.title}】`);
section.items.forEach((item) => {
if (item.type === 'switch') {
lines.push(`${item.enabled ? '[已开启]' : '[已关闭]'} ${item.title}`);
} else if (item.type === 'value' && item.value) {
lines.push(`${item.title}:${item.value}`);
} else {
lines.push(item.title);
}
});
lines.push('');
});
lines.push('—— 来自「有职」');
return lines.join('\n');
}
/* ===================== 隐私二级设置页 ===================== */ /* ===================== 隐私二级设置页 ===================== */
/** /**
@ -6332,7 +7019,8 @@ const MOCK_ACCOUNT_CANCEL: AccountCancelData = {
'若你已完成企业认证,认证信息与关联招聘流程将一并注销', '若你已完成企业认证,认证信息与关联招聘流程将一并注销',
], ],
checklist: [ checklist: [
{ id: 'balance', label: '账户余额已清零', resolved: false, actionText: '去提现' }, // 原先这里带「去提现」,但全项目没有钱包/提现页 —— 悬空的入口已摘掉
{ id: 'balance', label: '账户余额已清零', resolved: false },
{ id: 'orders', label: '无进行中的付费订单', resolved: true }, { id: 'orders', label: '无进行中的付费订单', resolved: true },
{ id: 'delivery', label: '无进行中的投递与面试', resolved: true }, { id: 'delivery', label: '无进行中的投递与面试', resolved: true },
{ id: 'enterprise', label: '企业认证已解除', resolved: true }, { id: 'enterprise', label: '企业认证已解除', resolved: true },
@ -6877,8 +7565,12 @@ const MOCK_ABOUT_INFO: AboutInfo = {
description: description:
'有职是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。', '有职是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。',
links: [ links: [
{ id: 'privacy-policy', title: '隐私政策' }, // 前两条原先没有 route,导致在「关于我们」页点下去毫无反应(静默失效)。
{ id: 'terms', title: '用户服务协议' }, // 但这两个页面**早已存在**(app/settings/privacy-policy.tsx / user-agreement.tsx),
// 且同一批链接在设置菜单(fetchMenuItems)里本就是配了 route 的 —— 属漏接线,不是待开发。
{ id: 'privacy-policy', title: '隐私政策', route: '/settings/privacy-policy' },
{ id: 'terms', title: '用户服务协议', route: '/settings/user-agreement' },
{ id: 'qualifications', title: '资质证照', route: '/settings/qualifications' },
{ id: 'legal', title: '阿布阿诺法律服务', route: '/legal' }, { id: 'legal', title: '阿布阿诺法律服务', route: '/legal' },
], ],
}; };
@ -6894,6 +7586,78 @@ export async function fetchAboutInfo(): Promise<AboutInfo> {
return mockRequest(MOCK_ABOUT_INFO); return mockRequest(MOCK_ABOUT_INFO);
} }
/* ===================== 平台资质证照 ===================== */
/**
* 平台资质证照公示数据。
*
* 【为什么字段大多是 null】本页要公示的是营业执照 / ICP 备案 / 人力资源服务许可证的
* **真实编号与证照图**,这些只能由运营主体与法务提供,当前尚未取得,故一律置 `null`:
* 页面会渲染「待补充」与图片占位块,逐张标明需要补哪张图。
*
* **不编造证件编号** —— 编造的统一社会信用代码 / 备案号既是不实信息,
* 也可能撞上某个真实主体的号。
*
* 【运营主体名字不在此处重复填写】直接取 `MOCK_ABOUT_INFO.company`,
* 与「关于我们」同源,避免同一个名字在两处被填成两个(项目里已出过同类问题)。
*/
const MOCK_QUALIFICATIONS: QualificationInfo = {
company: MOCK_ABOUT_INFO.company,
licenses: [
{
id: 'business-license',
type: 'business-license',
name: '营业执照',
numberLabel: '统一社会信用代码',
number: null,
issuer: null,
validPeriod: null,
scope: null,
imageUrl: null,
imagePlaceholder:
'此处需填充「营业执照」原件照片或彩色扫描件(须能辨认统一社会信用代码与登记机关公章)',
},
{
id: 'icp',
type: 'icp',
name: 'ICP 备案',
numberLabel: '备案号',
number: null,
issuer: null,
validPeriod: null,
scope: null,
imageUrl: null,
imagePlaceholder:
'此处需填充「ICP 备案」工信部备案系统查询结果截图(须含备案号与主办单位名称)',
},
{
id: 'hr-service',
type: 'hr-service',
name: '人力资源服务许可证',
numberLabel: '许可证编号',
number: null,
issuer: null,
validPeriod: null,
scope: null,
imageUrl: null,
imagePlaceholder:
'此处需填充「人力资源服务许可证」原件照片或彩色扫描件(招聘平台合规所需)',
},
],
verifyTip: '以上证照信息可通过发证机关公开渠道核验。',
};
/**
* 获取平台资质证照
* @method GET
* @url /api/settings/qualifications
* @returns Promise<QualificationInfo> 平台资质证照公示信息
*/
export async function fetchQualifications(): Promise<QualificationInfo> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/qualifications`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_QUALIFICATIONS);
}
/* ===================== HR 聊天详情 ===================== */ /* ===================== HR 聊天详情 ===================== */
/** HR 单聊会话 mock 数据(对应用户截图:张HR / 字节跳动 / 高级前端 / 95%匹配) */ /** HR 单聊会话 mock 数据(对应用户截图:张HR / 字节跳动 / 高级前端 / 95%匹配) */
@ -6947,7 +7711,6 @@ const MOCK_CONVERSATION_DETAIL: ConversationDetail = {
'前端性能优化方案', '前端性能优化方案',
'微前端架构实践', '微前端架构实践',
], ],
actionText: '查看全部 12 道预测题',
}, },
}, },
{ {
@ -6958,7 +7721,6 @@ const MOCK_CONVERSATION_DETAIL: ConversationDetail = {
type: 'salary', type: 'salary',
title: '市场薪资参考', title: '市场薪资参考',
subtitle: '该岗位 3-5 年经验市场薪资:22-38K\n你的期望薪资 30K 处于合理区间', subtitle: '该岗位 3-5 年经验市场薪资:22-38K\n你的期望薪资 30K 处于合理区间',
actionText: '查看详细薪资报告',
}, },
}, },
{ {

16
src/api/index.ts

@ -10,6 +10,7 @@ export type {
} from '../types'; } from '../types';
export { export {
fetchAboutInfo, fetchAboutInfo,
fetchQualifications,
fetchAbilityAssessment, fetchAbilityAssessment,
fetchAccountSecurity, fetchAccountSecurity,
fetchAIInterviewData, fetchAIInterviewData,
@ -35,6 +36,15 @@ export {
fetchJobEvaluationEmptyState, fetchJobEvaluationEmptyState,
fetchJobEvaluationManagement, fetchJobEvaluationManagement,
fetchJobManagement, fetchJobManagement,
updateJobStatus,
refreshJob,
buildJobManagementTabs,
buildJobStatusAction,
getJobStatusLabel,
matchJobManagementTab,
filterJobManagementJobs,
buildJobManagementFilterOptions,
JOB_MANAGEMENT_SORTS,
fetchJobPublishForm, fetchJobPublishForm,
fetchTalentRecommendation, fetchTalentRecommendation,
filterTalentCandidates, filterTalentCandidates,
@ -49,14 +59,17 @@ export {
fetchEnterpriseEntry, fetchEnterpriseEntry,
fetchHollandAssessment, fetchHollandAssessment,
fetchHollandResult, fetchHollandResult,
buildHollandReportText,
fetchHREvaluation, fetchHREvaluation,
fetchIdentityOptions, fetchIdentityOptions,
fetchInterviews, fetchInterviews,
fetchJobDetail, fetchJobDetail,
buildJobShareText,
fetchJobList, fetchJobList,
fetchFavorites, fetchFavorites,
fetchHistory, fetchHistory,
fetchOfferCompareData, fetchOfferCompareData,
buildOfferCompareText,
fetchPendingOfferCount, fetchPendingOfferCount,
fetchJobOverallScore, fetchJobOverallScore,
fetchJobPreference, fetchJobPreference,
@ -81,6 +94,7 @@ export {
fetchOverallScore, fetchOverallScore,
fetchPrivacySettings, fetchPrivacySettings,
fetchPrivacyCenter, fetchPrivacyCenter,
buildPrivacySummaryText,
updatePrivacyCenterSetting, updatePrivacyCenterSetting,
fetchPrivacyDetailSetting, fetchPrivacyDetailSetting,
updatePrivacyDetailSetting, updatePrivacyDetailSetting,
@ -119,6 +133,7 @@ export {
fetchPersonalDataExport, fetchPersonalDataExport,
requestPersonalDataExport, requestPersonalDataExport,
fetchRankData, fetchRankData,
buildRankReportText,
fetchResume, fetchResume,
fetchResumeExportSettings, fetchResumeExportSettings,
fetchResumeSavedAt, fetchResumeSavedAt,
@ -163,6 +178,7 @@ export {
resetGameProgress, resetGameProgress,
sendLegalMessage, sendLegalMessage,
startInterview, startInterview,
buildInterviewConfigText,
submitAppeal, submitAppeal,
submitGameLevel, submitGameLevel,
submitHollandAnswer, submitHollandAnswer,

1
src/hooks/index.ts

@ -55,6 +55,7 @@ export { useSmsCode } from './useSmsCode';
export { usePrivacyCenter } from './usePrivacyCenter'; export { usePrivacyCenter } from './usePrivacyCenter';
export { usePrivacyDetailSetting } from './usePrivacyDetailSetting'; export { usePrivacyDetailSetting } from './usePrivacyDetailSetting';
export { usePrivacySettings } from './usePrivacySettings'; export { usePrivacySettings } from './usePrivacySettings';
export { useQualifications } from './useQualifications';
export { useProfileData } from './useProfileData'; export { useProfileData } from './useProfileData';
export { usePersonalDataExport } from './usePersonalDataExport'; export { usePersonalDataExport } from './usePersonalDataExport';
export { useRankData } from './useRankData'; export { useRankData } from './useRankData';

115
src/hooks/useJobManagement.ts

@ -1,19 +1,124 @@
/** /**
* 企业端岗位管理数据 hook。 * 企业端岗位管理数据 hook。
*
* Tab / 搜索词 / 筛选 / 排序的状态放在这里,过滤与排序规则由接口层
* (`filterJobManagementJobs`)给出,页面只把点击、输入转成对应的 setter。
*/ */
import { useCallback } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { fetchJobManagement } from '../api'; import {
import type { JobManagement } from '../types'; buildJobManagementFilterOptions,
fetchJobManagement,
filterJobManagementJobs,
refreshJob,
updateJobStatus,
} from '../api';
import type {
JobManagement,
JobManagementJobStatus,
JobManagementSortKey,
} from '../types';
import { useAsyncData } from './useAsyncData'; import { useAsyncData } from './useAsyncData';
/** 筛选面板里已应用的选项(空数组 = 不限) */
type JobFilters = { cities: string[]; departments: string[] };
const NO_FILTERS: JobFilters = { cities: [], departments: [] };
export function useJobManagement() { export function useJobManagement() {
const fetcher = useCallback(async (): Promise<JobManagement> => fetchJobManagement(), []); const fetcher = useCallback(async (): Promise<JobManagement> => fetchJobManagement(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher); const { data, loading, error, refresh } = useAsyncData(fetcher);
/**
* 写操作返回的整份数据在这里暂存,优先于取数结果。
* 下架/刷新后立刻反映到界面,不必等下一次请求;调用 refresh() 时清掉即可回到服务端真值。
*/
const [override, setOverride] = useState<JobManagement | null>(null);
const [mutatingId, setMutatingId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('all');
const [keyword, setKeyword] = useState('');
const [sortKey, setSortKey] = useState<JobManagementSortKey>('latest');
const [filters, setFilters] = useState<JobFilters>(NO_FILTERS);
const management = override ?? data ?? null;
/** 筛选面板的可选项来自真实岗位数据,不写死选项表 */
const filterOptions = useMemo(
() => buildJobManagementFilterOptions(management?.jobs ?? []),
[management],
);
const jobs = useMemo(
() =>
management
? filterJobManagementJobs(management.jobs, {
tabKey: activeTab,
keyword,
cities: filters.cities,
departments: filters.departments,
sortKey,
})
: [],
[management, activeTab, keyword, filters, sortKey],
);
/** 已生效的筛选条件数(不含 Tab 与搜索词 —— 那两个界面上看得见) */
const activeFilterCount = filters.cities.length + filters.departments.length;
/** 是否处于「筛选/搜索后」的状态,用于空列表时区分「一个岗位都没有」和「没搜到」 */
const isFiltered = keyword.trim() !== '' || activeFilterCount > 0;
/** 下架 / 恢复岗位,成功返回更新后的整份数据 */
const setStatus = useCallback(async (jobId: string, status: JobManagementJobStatus) => {
setMutatingId(jobId);
try {
const updated = await updateJobStatus(jobId, status);
setOverride(updated);
return updated;
} finally {
setMutatingId(null);
}
}, []);
/** 刷新岗位(重新推向候选人) */
const refreshJobItem = useCallback(async (jobId: string) => {
setMutatingId(jobId);
try {
const updated = await refreshJob(jobId);
setOverride(updated);
return updated;
} finally {
setMutatingId(null);
}
}, []);
/** 重新取数:同时丢掉写操作的本地镜像,回到接口返回的真值 */
const refreshAll = useCallback(async () => {
setOverride(null);
await refresh();
}, [refresh]);
return { return {
management: data ?? null, management,
jobs,
filterOptions,
activeTab,
setActiveTab,
keyword,
setKeyword,
sortKey,
setSortKey,
filters,
applyFilters: setFilters,
resetFilters: () => setFilters(NO_FILTERS),
activeFilterCount,
isFiltered,
setStatus,
refreshJobItem,
/** 正在写操作的岗位 id(用于禁用该卡片按钮) */
mutatingId,
loading, loading,
error, error,
refresh, refresh: refreshAll,
}; };
} }

29
src/hooks/useQualifications.ts

@ -0,0 +1,29 @@
/**
* 平台资质证照 hook:拉取运营主体证照公示数据。
*/
import { useCallback } from 'react';
import { fetchQualifications } from '../api';
import type { QualificationInfo } from '../types';
import { useAsyncData } from './useAsyncData';
export interface QualificationsState {
/** 资质证照信息 */
info: QualificationInfo | null;
/** 是否加载中 */
loading: boolean;
/** 错误对象 */
error: Error | null;
/** 刷新数据 */
refresh: () => void;
/** 是否为空(证照列表无数据) */
isEmpty: boolean;
}
export function useQualifications(): QualificationsState {
const fetcher = useCallback(async (): Promise<QualificationInfo> => fetchQualifications(), []);
const { data, loading, error, refresh } = useAsyncData(fetcher);
const info = data;
const isEmpty = !loading && !error && (info?.licenses.length ?? 0) === 0;
return { info, loading, error, refresh, isEmpty };
}

58
src/types/enterprise.ts

@ -410,6 +410,34 @@ export interface JobManagementStat {
/** 岗位状态 */ /** 岗位状态 */
export type JobManagementJobStatus = 'active' | 'offline' | 'draft'; export type JobManagementJobStatus = 'active' | 'offline' | 'draft';
/**
* 岗位「状态切换」动作的完整描述(按钮文案 + 二次确认文案 + 目标状态)。
*
* 【为什么放数据层】这是 status → 文案 的映射,与「分数 → 等级」同类;
* 写在组件里就属 AGENTS.md 禁止的「组件内硬编码判断」。
* 更要紧的是:**草稿与已下线必须给出不同文案** —— 草稿从未上架过,
* 说「重新上架」「再次看到」是不成立的(页面原先正是这么写的)。
*/
export interface JobStatusAction {
/** 列表卡片上的按钮文案 */
label: string;
/** Ionicons 图标名 */
icon: string;
/** 二次确认弹窗标题 */
confirmTitle: string;
/** 二次确认弹窗正文 */
confirmMessage: string;
/** 二次确认弹窗确认按钮文案 */
confirmButton: string;
/** 操作完成后应写入的目标状态 */
nextStatus: JobManagementJobStatus;
/** 是否为破坏性操作(下架会让候选人看不到岗位) */
destructive: boolean;
}
/** 岗位管理列表的排序方式 */
export type JobManagementSortKey = 'latest' | 'applicants' | 'views' | 'score';
/** 岗位管理列表项 */ /** 岗位管理列表项 */
export interface JobManagementJob { export interface JobManagementJob {
/** 岗位 id */ /** 岗位 id */
@ -444,6 +472,13 @@ export interface JobManagementJob {
views: number; views: number;
/** 匹配数 */ /** 匹配数 */
matches: number; matches: number;
/** 发布时间(ISO 8601),「最新发布」排序依据 */
publishedAt: string;
/**
* 最近一次「刷新」时间(ISO 8601)。
* 从未刷新过则缺省 —— 页面据此显示「未刷新过」,而不是编一个时间。
*/
refreshedAt?: string;
} }
/** 岗位管理聚合数据 */ /** 岗位管理聚合数据 */
@ -546,8 +581,12 @@ export interface EnterpriseCommunicationTab {
count: number; count: number;
} }
/** 待处理任务项 */ /**
export interface PendingTask { * 待处理任务项。
* `route` 与 `tabKey` **必须且只能给一个**(联合类型强制)—— 否则卡片点了没反应,
* 页面也不必再留一个「既没有 route 也没有 tabKey」的兜底提示分支。
*/
export type PendingTask = {
/** 任务 id */ /** 任务 id */
id: string; id: string;
/** 任务标题 */ /** 任务标题 */
@ -560,11 +599,18 @@ export interface PendingTask {
icon: string; icon: string;
/** 颜色 token */ /** 颜色 token */
color: string; color: string;
/** 对应的沟通 Tab key,点击后切到该 Tab(与 route 二选一) */ } & (
tabKey?: string; | {
/** 对应的页面路由,点击后跳转(与 tabKey 二选一) */ /** 对应的页面路由,点击后跳转 */
route?: string; route: string;
tabKey?: never;
}
| {
/** 对应的沟通 Tab key,点击后切到该 Tab */
tabKey: string;
route?: never;
} }
);
/** 会话在沟通列表里的归属状态,与 Tab 的 key 一一对应('all' 不是状态) */ /** 会话在沟通列表里的归属状态,与 Tab 的 key 一一对应('all' 不是状态) */
export type EnterpriseChatStatus = 'pending' | 'interview' | 'offer' | 'closed'; export type EnterpriseChatStatus = 'pending' | 'interview' | 'offer' | 'closed';

5
src/types/growth.ts

@ -219,6 +219,11 @@ export interface AITool {
variant: 'primary' | 'success'; variant: 'primary' | 'success';
/** Ionicons 图标名 */ /** Ionicons 图标名 */
icon: string; icon: string;
/**
* 点击后的目标路由。**由接口层下发**,页面不再自己写 id→路由的映射 ——
* 那样新增一个工具就会变成「点了没反应」的卡片。
*/
route: string;
} }
/** 职场干货文章 */ /** 职场干货文章 */

3
src/types/index.ts

@ -63,7 +63,9 @@ export type {
JobExperienceRequirement, JobExperienceRequirement,
JobManagement, JobManagement,
JobManagementJob, JobManagementJob,
JobStatusAction,
JobManagementJobStatus, JobManagementJobStatus,
JobManagementSortKey,
JobManagementStat, JobManagementStat,
JobManagementTab, JobManagementTab,
JobPublishFormData, JobPublishFormData,
@ -284,3 +286,4 @@ export type {
PasswordResetPayload, PasswordResetPayload,
PasswordRuleError, PasswordRuleError,
} from './auth'; } from './auth';
export type { QualificationInfo, QualificationLicense, QualificationLicenseType } from './qualification';

7
src/types/message.ts

@ -79,8 +79,11 @@ export interface ChatCard {
subtitle?: string; subtitle?: string;
/** 列表项(面试题、薪资项等) */ /** 列表项(面试题、薪资项等) */
items?: string[]; items?: string[];
/** 操作按钮文案 */ /**
actionText: string; * 操作按钮文案。**只有真有落点的卡片才下发**(目前只有 Offer 卡指向 /jobs/offer-compare);
* 缺省时不渲染按钮 —— 不给「点了没反应」的入口。
*/
actionText?: string;
/** 跳转路由 */ /** 跳转路由 */
route?: string; route?: string;
} }

2
src/types/offer-compare.ts

@ -67,8 +67,6 @@ export interface OfferAISuggestion {
summary: string; summary: string;
/** 详细分析 */ /** 详细分析 */
detail: string; detail: string;
/** 查看报告按钮文案 */
reportAction: string;
} }
/** Offer 对比 - 完整数据 */ /** Offer 对比 - 完整数据 */

65
src/types/qualification.ts

@ -0,0 +1,65 @@
/**
* 平台资质证照类型定义
*
* 【与「企业认证」的区别 —— 不要合并这两个概念】
* 本文件描述的是**平台运营主体自身**的证照,用途是合规公示与应用商店上架审核;
* 而 `EnterpriseCertificationForm`(src/types/enterprise.ts)是**企业用户上传**自己的
* 营业执照以取得企业认证。两者都涉及「营业执照」,但主体、目的、入口完全不同。
*/
/** 证照种类 */
export type QualificationLicenseType =
| 'business-license' // 营业执照
| 'icp' // ICP 备案
| 'hr-service' // 人力资源服务许可证
| 'other'; // 其他按需公示的证照
/** 单张资质证照 */
export interface QualificationLicense {
/** 证照 id */
id: string;
/** 证照种类 */
type: QualificationLicenseType;
/** 证照名称(如「营业执照」) */
name: string;
/**
* 编号字段的显示名(营业执照为「统一社会信用代码」、ICP 为「备案号」、
* 许可证为「许可证编号」)。
*
* 放在数据层而非页面:这是 type → 文案的映射,
* 写进组件就属「组件内写死判断逻辑」,违反分层约定。
*/
numberLabel: string;
/**
* 证件编号(统一社会信用代码 / ICP 备案号 / 许可证编号)。
*
* 为 `null` 表示信息尚未提供 —— 页面渲染「待补充」,
* **绝不编造号码**(编造证件号既是假数据,也可能撞上真实主体的号)。
*/
number: string | null;
/** 发证机关;`null` 同「待补充」 */
issuer: string | null;
/** 有效期;`null` 同「待补充」 */
validPeriod: string | null;
/** 经营范围 / 备案网站域名等补充说明;无则为 `null` */
scope: string | null;
/**
* 证照图片 URL。
*
* 为 `null` 表示图片素材尚未提供 —— 页面渲染占位块,并借 `imagePlaceholder`
* 明确告知「此处需要填充哪张图」,便于后续按图索骥补充。
*/
imageUrl: string | null;
/** 图片素材填充说明(`imageUrl` 为 `null` 时展示) */
imagePlaceholder: string;
}
/** 平台资质证照聚合数据 */
export interface QualificationInfo {
/** 运营主体名称(与「关于我们」同源,避免两处填成两个名字) */
company: string;
/** 证照列表 */
licenses: QualificationLicense[];
/** 核验提示文案(告知用户这些证照可向何处核验) */
verifyTip: string;
}

5
src/types/rank.ts

@ -53,6 +53,11 @@ export interface ImprovementTip {
description: string; description: string;
/** tip=普通建议,action=可点击操作 */ /** tip=普通建议,action=可点击操作 */
type: 'tip' | 'action'; type: 'tip' | 'action';
/**
* 可点击建议的目标路由(type='action' 时由接口层下发)。
* 缺省时页面按普通建议渲染 —— 不给「点了没反应」的入口。
*/
route?: string;
} }
/** 简历完善进度步骤 */ /** 简历完善进度步骤 */

2
src/types/settings.ts

@ -184,8 +184,6 @@ export interface AccountCancelCheckItem {
label: string; label: string;
/** 是否已处理完成 */ /** 是否已处理完成 */
resolved: boolean; resolved: boolean;
/** 处理入口文案,如 "去提现" */
actionText?: string;
} }
/** 账号注销页数据 */ /** 账号注销页数据 */

653
项目开发状态.md

@ -1,10 +1,13 @@
# 有职 App 开发进度与规划文档 # 有职 App 开发进度与规划文档
> 生成日期:2026-09-10 > 生成日期:2026-09-10
> 最后更新:2026-09-17(**全仓死控件清零:9 处 → 0 处**,企业沟通 / 人才推荐的 Tab 与搜索真正生效、申诉举证接真实选图、面试评分草稿落库、入职清单可增删与导出,见 4.7) > 最后更新: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-15(登录方式改为「验证码 / 密码 / 运营商一键登录」三通道 · 密码闭环打通 · 登录态未写入的严重缺口已修复 · **注册引导链闭环:采集资料真正落库并被主界面读取,删除假首页 P23**,见 4.3 · **简历域死控件清零:新增可写通道与 4 个编辑/预览页,导出真出 PDF、分享真拉面板**,见 4.4 · **127 个接口函数全部预留注释版后端请求,新增审计脚本**,见 4.6) > 上一版: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 处、219 例测试全绿),无需修正阻塞错误。 > 结论先行:**当前项目可运行**(`tsc` 0 错误、`eslint` 0 error、路由 0 失效跳转、假交互 0 处、图标名 0 处无效、302 例测试全绿),无需修正阻塞错误。
>
> **读这份文档请先注意一条**:4.8 曾写「死控件清零」并据此把 P2 标记为已完成,**那个结论当时是错的** —— 审计脚本只查「无处理函数 / 空处理函数」,查不到「有处理函数但点下去只弹一句『功能开发中』」。详见 4.9 首条。凡是本文件里由某个审计脚本背书的「✅」,请连同**该脚本的检测口径**一起读。
--- ---
@ -14,10 +17,11 @@
|---|---|---| |---|---|---|
| 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) | | 类型检查 | `npx tsc --noEmit` | ✅ 0 错误(Exit 0) |
| 代码规范 | `npx eslint .` | ✅ 0 error / 0 warning(2026-09-17 复跑) | | 代码规范 | `npx eslint .` | ✅ 0 error / 0 warning(2026-09-17 复跑) |
| 路由闭合 | `node scripts/check-routes.mjs` | ✅ 72 路由全部存在,0 失效跳转(12 个「孤立页」经核对为动态路由 / Tab 页误报,见 4.8) | | 路由闭合 | `node scripts/check-routes.mjs` | ✅ 73 路由全部存在,0 失效跳转(12 个「孤立页」经核对为动态路由 / Tab 页误报,见 4.8) |
| 死控件审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-17 清零,9 → 0,见 4.8) | | 假交互审计 | `node scripts/check-affordances.mjs` | ✅ **0 处**(2026-09-18 复跑,**口径已含弹窗式假交互**,见 4.9)<br>⚠️ 另有 **3 处知情保留的后端待办**(`--pending` 可列出,非疏漏) |
| 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ 137 个接口函数全部留有注释版真实请求(见 4.6) | | 后端接口预留审计 | `node scripts/check-backend-stubs.mjs` | ✅ **140** 个接口函数全部留有注释版真实请求(见 4.6) |
| 单元测试 | `npm test` | ✅ 18 个文件 **219 例全绿**(2026-09-17) | | 图标名审计 | `node scripts/check-icons.mjs` | ✅ **0 处无效图标名**(2026-09-18 新增门槛,修掉 `gavel-outline` 后建立,见 4.13) |
| 单元测试 | `npm test` | ✅ **23 个文件 302 例全绿**(2026-09-18) |
| 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 | | 入口接线 | 读 `app/_layout.tsx` / `app/index.tsx` | ✅ 根栈 → 入口围栏 → 双端 Tab 完整 |
| 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 | | 应用配置 | `app.config.js` | ✅ name「有职」、scheme `youzhi`、plugins 正确 |
@ -103,7 +107,9 @@
- **清演示人设**:`INITIAL` 里预填的「杨幸幸 / 女 / 上海 / 前端开发工程师 / 本科」等全部清空。此前**用户全程不操作也能一路点到落地**,而采集出的「资料」没有一项是他选的。清空后逐页补空值兜底(P4 / P8 / P10 / P13 / P18 等页的 `active` 门槛;`YearMonthWheel` 改为「未选中 + 请滑动选择」,此前把空值钳到索引 0,滚轮显示「2026年」而 state 仍是空串,会静默写入错误年份);`agreed` 由 `true` 改 `false` —— 预勾选不构成《个人信息保护法》要求的明示同意,且它让协议弹窗(`useAgreementGate`)此前永不触发。 - **清演示人设**:`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 个预设头像各自生效。 - **缺陷修复**:`navigate()` 去重压栈(修 P16 ↔ P17 往返后返回键错乱);P3「退出登录」补 `useAuth().logout()`(原先只清流程 state,token 未清,退出后重进仍被判为已登录);B3 企业认证重做(营业执照可点可传、联系人不再写进 `data.school`、提交走 `submitEnterpriseCertification`、去掉本地 `useState` 翻出的假「等待审核」态与开发口径文案);P21 头像接入 `expo-image-picker`(**新增依赖**)后拍照 / 相册 / 5 个预设头像各自生效。
- **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。 - **死控件(点零反馈)逐项处理**:P6 / P11 搜索框接真实过滤(跨分类匹配 + 空结果提示);P6 写死的进度角标「3/3」改为按 `ONBOARDING_STEP_ORDER` 计算的「第 3 / 16 步」;P6 / P16 的「保存」文案改为「跳过,直接进入」(它实际调 `finish()` 结束注册,并不「保存」);P2 与 P2W 的「接收不到短信」改为可点并给出可执行指引;P1 / P2Q 页脚与 P2Q 条款链接按下条处理。
- **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息);「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。 - **不编造数据(本轮的处理原则)**:页脚「服务热线 / 举报监督电话」改为常量 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`(**现为空,为空则整块不渲染**,填入号码即自动出现并接 `tel:` 拨号;与隐私政策的【待确认】客服电话同源,属同一批待补信息)
⚠️ **2026-09-18 更正**:号码后来确实填了(`15154608969`),但**没有任何代码读这两个常量** ——
所谓「填入即自动出现」并不成立,缺的是页脚组件本身。见 4.10 第 7 条。;「资质证照」入口一并去掉 —— 项目内**没有**该页面,`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,指过去是误导(**需新建资质证照页 + 提供证照信息**);P2Q 原先以「本机号码」名义显示写死的 `150****9367`,改为无号码时只显示槽位标签「本机号码」;P2W 的手机尾号回落值 `9367` 同理去掉;P5「自动定位:上海」(不做定位却把「上海」写死成选中值)与 P2W「语音接听验证码」(无语音通道)两个入口**移除**。
- **删 P23 假首页**:`P23Home` 是写死上海 4 条职位的演示页,与真 `(tabs)/jobs` 重复,且含两颗绑同一回调的假权限弹窗;删除后 P22 与 P6 / P16 的快速通道由 `useRegisterFinish` 直接 `replace` 进真主界面。同步清理悬空接口 `saveAndEnter` / `canGoBack` / `meta`(`meta` 恒为 `{time:'',battery:0}`,21 个屏仍在传参、`ScreenShell` 收到即丢弃)与插画占位上的素材说明文字(`Illustration` 在 `source` 为 null 时不再渲染 `hint` —— 它会把「手持 OFFER 职场男性插画」这类开发备注直接画在用户界面上)。 - **删 P23 假首页**:`P23Home` 是写死上海 4 条职位的演示页,与真 `(tabs)/jobs` 重复,且含两颗绑同一回调的假权限弹窗;删除后 P22 与 P6 / P16 的快速通道由 `useRegisterFinish` 直接 `replace` 进真主界面。同步清理悬空接口 `saveAndEnter` / `canGoBack` / `meta`(`meta` 恒为 `{time:'',battery:0}`,21 个屏仍在传参、`ScreenShell` 收到即丢弃)与插画占位上的素材说明文字(`Illustration` 在 `source` 为 null 时不再渲染 `hint` —— 它会把「手持 OFFER 职场男性插画」这类开发备注直接画在用户界面上)。
- **测试**:新增 `job-match.test.ts`(14 例,锁定匹配规则与「显式筛选不叠加偏好」边界)、`salary-options.test.ts`(10 例)、`onboarding-profile.test.ts`(映射往返 + 字段漂移检测)、`resume-profile.test.ts`(12 例:采集齐全逐项映射、一路跳过时各板块为空、离职不写「至今」、求职意向读显式编辑过的偏好、AI 优化后仍是本人简历、「不再出现演示人设」字符串扫描)、`user-profile.test.ts`(6 例:姓名/头像字/简介映射、一路跳过时不显示演示人设、头像原始值按预设 key / 本地 URI 两类透传)、`onboardingStepLabel`(3 例)。`npm test` **97 例全绿**;`tsc` / `eslint` / `check-routes`(67 路由 / 0 失效跳转 / 0 孤立页)均 0 问题。 - **测试**:新增 `job-match.test.ts`(14 例,锁定匹配规则与「显式筛选不叠加偏好」边界)、`salary-options.test.ts`(10 例)、`onboarding-profile.test.ts`(映射往返 + 字段漂移检测)、`resume-profile.test.ts`(12 例:采集齐全逐项映射、一路跳过时各板块为空、离职不写「至今」、求职意向读显式编辑过的偏好、AI 优化后仍是本人简历、「不再出现演示人设」字符串扫描)、`user-profile.test.ts`(6 例:姓名/头像字/简介映射、一路跳过时不显示演示人设、头像原始值按预设 key / 本地 URI 两类透传)、`onboardingStepLabel`(3 例)。`npm test` **97 例全绿**;`tsc` / `eslint` / `check-routes`(67 路由 / 0 失效跳转 / 0 孤立页)均 0 问题。
- **待验证 / 待补**:`expo-image-picker` 的原生权限文案(`app.config.js` 的 `photosPermission` / `cameraPermission`,并显式关掉插件默认申请的麦克风权限)**只在出包时生效**,Expo Go 用的是它自己的 `Info.plist`,需真机出包验证;手工路径(真机走完 P1→P22 →「我的」页偏好与所填一致 → `/jobs` 偏好岗位上浮;引导途中杀 App 重进数据仍在)**尚未在设备上执行**。 - **待验证 / 待补**:`expo-image-picker` 的原生权限文案(`app.config.js` 的 `photosPermission` / `cameraPermission`,并显式关掉插件默认申请的麦克风权限)**只在出包时生效**,Expo Go 用的是它自己的 `Info.plist`,需真机出包验证;手工路径(真机走完 P1→P22 →「我的」页偏好与所填一致 → `/jobs` 偏好岗位上浮;引导途中杀 App 重进数据仍在)**尚未在设备上执行**。
@ -120,7 +126,7 @@
- **导出设置真正保存**:`saveResumeExportSettings` 原先是 `Object.assign(DEFAULT_EXPORT_SETTINGS, …)` —— 只改模块内常量,杀进程即丢,「保存」按钮实则保存不了,还会污染其它调用方读到的默认值。现改落 `youzhi:resume:export-settings`(读时用默认值补齐后加字段,存储损坏回落默认)。默认文件名原为演示人设「李明_前端工程师简历」,现为空串,由 `resolveExportFileName` 兜底成「姓名_期望职位简历」。模板 / 纸型 / 方向的选择弹层**就地在页内改设置、随「保存」落盘**(跳新页会出现「模板已存、格式没存」)。 - **导出设置真正保存**:`saveResumeExportSettings` 原先是 `Object.assign(DEFAULT_EXPORT_SETTINGS, …)` —— 只改模块内常量,杀进程即丢,「保存」按钮实则保存不了,还会污染其它调用方读到的默认值。现改落 `youzhi:resume:export-settings`(读时用默认值补齐后加字段,存储损坏回落默认)。默认文件名原为演示人设「李明_前端工程师简历」,现为空串,由 `resolveExportFileName` 兜底成「姓名_期望职位简历」。模板 / 纸型 / 方向的选择弹层**就地在页内改设置、随「保存」落盘**(跳新页会出现「模板已存、格式没存」)。
- **导出与分享真做**(新增依赖 `expo-print` / `expo-sharing` / `expo-file-system`):「立即导出」原先返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出文件、页面还丢弃返回值、不给任何提示,是纯假成功。现改为**本机真出 PDF**:`buildResumeExportHtml`(纯函数,空板块整段略去、用户文本全转义、模板 id 白名单校验)→ `printToFileAsync` 按纸型 / 方向的真实页宽页高打印 → 重命名成用户设的文件名(打印引擎生成的是随机临时名,不改的话分享面板里是一串乱码);「分享简历」拉起系统面板分享这份文件。**封面页、自荐信、隐藏联系方式、纸型 / 方向四个开关由此第一次真正生效**(此前全是装饰):自荐信以自我评价为正文,**自我评价为空时不生成**(没有素材就编一封自荐信属于伪造内容)。Word / 图片格式与导出密码需后端导出服务,在**选中的那一刻**与导出成功后都写明。 - **导出与分享真做**(新增依赖 `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 出包即可,代码无需改。 - **未做的验证(诚实口径)**:真机 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**)。 - **审计工具常驻**:新增 `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 问题。 - **测试**:新增 `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` 是**吞冒泡守卫,需保留**(已加豁免标记,不属死控件)。 - **本轮未动(待续做,已探明落点;截至 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` 是**吞冒泡守卫,需保留**(已加豁免标记,不属死控件)。
@ -202,12 +208,624 @@
--- ---
### 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` 的 `<ErrorState onRetry={refresh} />`(**不新写样板**);`job-publish` 顺带把加载态的裸 `ActivityIndicator` 换成 `<LoadingState />`。
**③ `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. 另一处「文档与代码不符」(本批次发现,尚未处理)
文档第 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 条全部收口**,仅剩一项本机无法验证的外部依赖项(生产包 `youzhi://` 深链,见 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**,清不掉 `youzhi: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(`<Text>加载失败,请重试</Text>` + 自绘重试按钮),而不是全项目统一的 `<ErrorState onRetry={refresh} />` | **功能是对的**(重试真的接了 `refresh`),只是写法不统一。按「StateView 先不全改,只改本轮碰到的页」的决定,本轮碰到的页本该顺手改,但改它会把 4.12 的 diff 混进样式调整,故单列记录、留待专门批次 |
| `app/jobs/rank.tsx:266` | 加载态是裸 `<ActivityIndicator>`,不是 `<LoadingState />` | 同上,纯视觉规范统一 |
两处都**不是缺陷**,不影响功能,登记在此是为了不丢失。
#### 6. 深链不路由:原因已查明(**2026-09-18 更正,原解释是错的**)
验证过程中试过 `youzhi://jobs/1`、`youzhi://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 "youzhi://jobs/1"
Error: Activity not started, unable to resolve Intent { ... dat=youzhi://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 在整台设备上找不到任何能处理 `youzhi://` 的应用** —— 不是「应用收到了但忽略了」。因为**没有任何已安装的原生包注册过这个 scheme**:`app.config.js` 里那句 `scheme: 'youzhi'` 要出**原生包**才会写进 AndroidManifest;Expo Go 只注册 `exp://`。
**结论**:这是 **Expo Go 的已知限制,不是本项目的缺陷**。所以 ——
- 「`youzhi://` 在 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 <LoadingState />`),或整块被父级条件包住 —— 那空态就是一坨**永远渲染不到的死代码**,跟没写一样。这类问题静态读代码读不出来,只能把数据真的弄空。
#### 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
<Ionicons name={template.icon as keyof typeof Ionicons.glyphMap} size={22} ... />
```
这个 `as` 断言把「任意字符串」硬骗成合法字形名,**编译器就此闭嘴**。而图标名来自数据层(`api.ts` 的 mock),类型本就是 `string`,静态类型无从校验。**类型系统在这里是盲的,不是「没查到」,是「查不了」。**
**修法**:换成 `hammer-outline`(Ionicons 里真实存在)。选它的理由:原作者的意图明显是「法槌」,而 Ionicons 没有法槌,锤子是语义最接近的等价物;且同组另两条也各按语义取图标(`checkbox-outline` / `exit-outline`)。
**顺带做了全仓同类扫描**,结果分两类:
| 命中 | 判定 |
|---|---|
| `gavel-outline` | **真缺陷**,已修 |
| `🏅 🎯 ⭐ 🏆 📮 📚`(6 个) | **误报** —— 这是 `MOCK_BADGES` 的勋章图标,由 `<Text>{badge.icon}</Text>` 直接当字符渲染,本就该是 emoji 不是 Ionicons 名 |
**设备复验**:修复后重启,「劳动仲裁申请书」的**锤子图标正常渲染**(蓝色),且 `adb logcat` 中 `not a valid icon name` **零条**。
#### 5. 新增门槛 `npm run check:icons`(防复发)
只修这一处是治标 —— 只要数据层还能塞任意字符串、页面还在用 `as` 断言,同类问题就会再来。故把这次的人工扫描**固化成脚本** `scripts/check-icons.mjs`:
- 字形表**直接从装的包里读**(`@expo/vector-icons` 的 `Ionicons.json`),**不另存一份** —— 另存一份必然与真实图标库脱节,那就是又一个「两处来源」
- 查 `<Ionicons name="...">` 字面量与 `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 条(已就地更正):深链 `youzhi://` 不路由**不是** 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. 仍未验证(不要当成验过)
- **生产包的 `youzhi://` 深链**:本机无原生包,测不了(成因已查明,见 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 处无效图标名**。
---
## 五、后续增量开发建议(优先级) ## 五、后续增量开发建议(优先级)
- **P0(上架前必做)**:~~图标替换~~ **应用图标【已完成,`878710c`,1024×1024「有职」logo】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.5】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。 - **P0(上架前必做)**:~~图标替换~~ **应用图标【已完成,`878710c`,1024×1024「有职」logo】** · ~~隐私 / 协议内容填充~~ **隐私/协议草案【已存在,含 18 岁门槛,待填【待确认】占位】** · ~~EAS 初始化~~ **EAS 配置骨架【已完成,`owner` / `projectId` 两处占位符阻塞 `eas build`,需 `eas init` 注入,详见 4.5】** · ~~18 岁门槛核对~~ **18 岁门槛【已在隐私政策第六章 + 用户协议前言落地】** · ~~微信登录接入评估~~ **微信登录接入评估【已完成 → 见 `微信登录接入方案.md`,SDK 接入阻塞于微信开放平台 AppID】** · ~~启动屏接线~~ **启动屏【已完成,`expo-splash-screen` + `splash-icon.png`,需出包验证】**。
- **P0(补充 · 2026-09-15 新发现)**:① ~~页脚服务热线 / 举报监督电话待填~~ **已填 `15154608969`**(`src/constants/index.ts` 的 `SUPPORT_HOTLINE` / `SUPERVISION_PHONE`,为空时页脚整块不渲染、填入即自动出现并接 `tel:` 拨号);**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② **缺「资质证照」页** —— 项目内没有该页面(`app/legal.tsx` 是「阿布阿诺法律服务」付费咨询页,不可复用),上架前需新建并填入营业执照 / ICP / 人力资源服务许可证等信息。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。 - **P0(补充 · 2026-09-15 新发现)**:① ~~页脚服务热线 / 举报监督电话待填~~ **已填 `15154608969`** —— ⚠️ **但 2026-09-18 实测发现该常量全仓无消费者,页脚从未落地,「填入即自动出现」目前不成立,缺的是页脚组件本身,见 4.10 第 7 条**;**隐私政策里【待确认】的客服电话应与之对齐**(同一个号,两处别填成两个)。② ~~缺「资质证照」页~~ **页面【已完成,见 4.10】—— 四层链路齐备(`src/types/qualification.ts` / `fetchQualifications` / `useQualifications` / `app/settings/qualifications.tsx`),设置页与「关于我们」页两处入口已恢复,三类证照的补图占位已逐张标明;**但证照的真实编号 / 发证机关 / 有效期 / 证照图仍全部待法务与资质方提供**(材料到位后只改 `MOCK_QUALIFICATIONS` 字段 + 更新测试断言),**未编造任何证件编号**。③ **注册引导链 + 简历导出的真机手工验证均未执行**(见 4.3 / 4.4 末条)。
- **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。 - **P1(核心差异化)**:~~微信登录接入方案~~ **微信登录接入方案【已落文档,真实 SDK 接入阻塞于微信开放平台移动应用申请】** · ~~企业端快捷入口话术统一~~ **企业端快捷入口话术统一【已完成,6 处 label 对齐规范词;含沟通页快捷操作 onPress 接线,tsc/eslint 0 问题】** · ~~付费法律服务支付~~ **付费法律服务支付【已完成:mock 支付闭环,选套餐→结算→模拟支付→会员激活持久化,tsc/eslint 0 问题】** · ~~通关小游戏 7 关内容~~ **通关小游戏 7 关内容【已完成:结构原已建成,本次增厚至 7 关 22 题,tsc/eslint 0 问题】**。
- **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · ~~死控件清零~~ **死控件已清零(22 → 0,2026-09-17,见 4.8)** · 真实后端对接 · OTA 通道验证。 - **P2(打磨)**:~~技术债清理~~ **技术债已清理(8 处 `any` → 明确类型,`eslint` 0 warning)** · ~~死控件清零~~ **死控件已清零(22 → 0,2026-09-17,见 4.8)** · ~~假交互清零~~ **假交互已清零(37 → 0,2026-09-18,**但 4.8 当时的「已完成」结论是错的,见 4.9 首条**)** · 真实后端对接 · OTA 通道验证。
- **P2(补充 · 2026-09-18)**:**3 处知情保留的后端待办**(聊天附件四图标 / 接受 Offer / 导出文件下载),后端接口就绪后按 `npm run check:affordances:pending` 列出的清单逐条兑现;另**已做的真机验证与仍未验证的项**见 4.9 第 8 条(已由 4.11 / 4.12 / 4.13 逐项关闭:`settings` 缓存失败态的最终结论是**不可达死代码**,见 4.13 第 8 条)—— 不要把没验过的当成验过了。
- **P1(补充 · 2026-09-17)**:① **运营商一键登录取号**需运营商号码认证服务 + EAS 自定义客户端,接入路线图见 `运营商一键登录接入方案.md`(端上换 1 处 SDK 调用 + 后端换 1 个接口,页面/hook/类型零改动);② **引导页 4 张配图**待提供素材(`assets.ts` 的 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,换图只改 `source` 一行);③ **`assets.ts` 内 16 个素材位 `source` 仍为 `null`**(营业执照示例图、5 张预设头像、3 张场景插画等)—— 未补时按约定降级:引导页那 4 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。 - **P1(补充 · 2026-09-17)**:① **运营商一键登录取号**需运营商号码认证服务 + EAS 自定义客户端,接入路线图见 `运营商一键登录接入方案.md`(端上换 1 处 SDK 调用 + 后端换 1 个接口,页面/hook/类型零改动);② **引导页 4 张配图**待提供素材(`assets.ts` 的 `guideEvaluate` / `guideWeightDim` / `guideGameMap` / `guideLegalAI`,换图只改 `source` 一行);③ **`assets.ts` 内 16 个素材位 `source` 仍为 `null`**(营业执照示例图、5 张预设头像、3 张场景插画等)—— 未补时按约定降级:引导页那 4 张显示「配图待补 + 该放什么图」标注,头像 / 插画类降级为色块 + 图标(可用的占位态,非空白)。
--- ---
@ -224,12 +842,21 @@ npx eslint .
# 路由闭合审计(改动路由后必跑) # 路由闭合审计(改动路由后必跑)
node scripts/check-routes.mjs node scripts/check-routes.mjs
# 死控件审计(改动交互后必跑;豁免标记 affordance-ok) # 假交互审计(改动交互后必跑;**已设为硬门槛 —— 有命中即 exit 1**)
node scripts/check-affordances.mjs # 检测①无处理函数/空处理函数 ②点了只弹「功能开发中/暂未开放」的弹窗式假交互
# 豁免标记:affordance-ok(有意为之,如弹层吞冒泡)
# affordance-pending-backend(知情保留的后端待办)
npm run check:affordances # 等价 node scripts/check-affordances.mjs --error
npm run check:affordances:pending # 列出知情保留的后端待办清单(当前 3 处)
# 后端接口预留审计(新增/改接口后必跑;豁免标记 backend-stub-ok) # 后端接口预留审计(新增/改接口后必跑;豁免标记 backend-stub-ok)
node scripts/check-backend-stubs.mjs 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
# 单元测试(src/__tests__ 下纯逻辑) # 单元测试(src/__tests__ 下纯逻辑)
npm test npm test

Loading…
Cancel
Save