Browse Source
合并 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
52 changed files with 4435 additions and 500 deletions
@ -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', |
|||
}, |
|||
}); |
|||
@ -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); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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'); |
|||
}); |
|||
}); |
|||
@ -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); |
|||
}); |
|||
}); |
|||
@ -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'); |
|||
}); |
|||
}); |
|||
@ -1,19 +1,124 @@ |
|||
/** |
|||
* 企业端岗位管理数据 hook。 |
|||
* |
|||
* Tab / 搜索词 / 筛选 / 排序的状态放在这里,过滤与排序规则由接口层 |
|||
* (`filterJobManagementJobs`)给出,页面只把点击、输入转成对应的 setter。 |
|||
*/ |
|||
import { useCallback } from 'react'; |
|||
import { fetchJobManagement } from '../api'; |
|||
import type { JobManagement } from '../types'; |
|||
import { useCallback, useMemo, useState } from 'react'; |
|||
import { |
|||
buildJobManagementFilterOptions, |
|||
fetchJobManagement, |
|||
filterJobManagementJobs, |
|||
refreshJob, |
|||
updateJobStatus, |
|||
} from '../api'; |
|||
import type { |
|||
JobManagement, |
|||
JobManagementJobStatus, |
|||
JobManagementSortKey, |
|||
} from '../types'; |
|||
import { useAsyncData } from './useAsyncData'; |
|||
|
|||
/** 筛选面板里已应用的选项(空数组 = 不限) */ |
|||
type JobFilters = { cities: string[]; departments: string[] }; |
|||
|
|||
const NO_FILTERS: JobFilters = { cities: [], departments: [] }; |
|||
|
|||
export function useJobManagement() { |
|||
const fetcher = useCallback(async (): Promise<JobManagement> => fetchJobManagement(), []); |
|||
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 { |
|||
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, |
|||
error, |
|||
refresh, |
|||
refresh: refreshAll, |
|||
}; |
|||
} |
|||
|
|||
@ -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 }; |
|||
} |
|||
@ -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; |
|||
} |
|||
Loading…
Reference in new issue