轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

8358 lines
312 KiB

/**
* 接口层:所有数据请求统一从这里发起。
*
* 约定:
* - 每个函数用 JSDoc 标注请求方式(@method)、URL(@url)、参数(@param)、返回值(@returns)
* - 所有 mock 数据统一在本文件管理;真实后端就绪后,仅替换函数体为 fetch 即可
* - 业务规则计算(等级映射等)与排序/筛选/搜索/分页在本层完成
*
* 后端接口预留(每个函数体内都写好了真实请求,只是注释着 —— 接后端时放开即可):
* - 纯 mock 返回的函数:`// TODO: 真实请求(后端就绪后替换)` + 注释的 fetch / res.json()
* - 以本地存储为唯一真相源的函数:`// TODO: 后端就绪后改为 await fetch(...);当前以本地存储为唯一真相源`
* (这类函数现在写的是 storage,放开注释时要把本地写换成请求)
* - AI 能力:参数名带 `_` 前缀表示尚未接入;去掉下划线 + 放开注释 = 该能力上线
* 放开注释时记得同步删掉紧邻的 mock 返回,别让两套并存。
*/
import Constants from 'expo-constants';
import type {
AbilityAssessment,
AITool,
Article,
Badge,
CompareData,
Conversation,
OfferCompareData,
DimensionScore,
EmployeeReview,
EnterpriseEntry,
HollandAssessment,
HollandIntro,
HollandType,
Interview,
Job,
JobDetail,
JobLevel,
JobListParams,
JobOverallScore,
JobPreference,
MatchResult,
MenuItem,
Notification,
OverallScore,
ConversationDetail,
ChatMessage,
RankOverview,
Resume,
ResumeAIActionResult,
ResumeBasicInfo,
ResumeEducation,
ResumeExperience,
ResumeExportOutcome,
ResumeExportSettings,
ResumeItemInput,
ResumeOptimizeResult,
ResumeOverride,
ResumeProject,
ResumeSelfEvaluation,
ResumeSkill,
SalaryData,
SalaryBracket,
SalaryCalcInput,
SalaryCalcResult,
SalaryCalcRecord,
SalaryInsuranceItem,
SalaryStructureItem,
SalaryTaxItem,
SalaryToolLink,
UserProfile,
UserStats,
WeightDimension,
WeightSettings,
WeightTemplate,
SkillCloudData,
SkillCloudFilter,
SkillLearningItem,
SkillLearningPath,
SkillWord,
SkillCategory,
TopSkill,
ChecklistGroup,
OnboardingChecklist,
OnboardingData,
OnboardingProfile,
JobProgress,
IdentityOption,
AppRole,
AIInterviewData,
InterviewMode,
InterviewQuestionTypeCode,
InterviewSession,
HREvaluation,
EvaluationAppealForm,
AppealDimension,
GameLevel,
GameLevelResult,
GameProgress,
SubmitGameLevelPayload,
LegalBrand,
LegalConversation,
LegalFAQ,
LegalMessage,
LegalMembership,
LegalOrder,
LegalPaymentChannel,
LegalPlan,
LegalService,
LegalServiceRequest,
LegalTemplate,
ToggleSetting,
ActionSetting,
AboutInfo,
QualificationInfo,
UpdateTogglePayload,
PrivacyCenter,
AccountCancelData,
DeleteAllData,
DndSetting,
KeywordBlockSetting,
LegalDocument,
PrivacyDetailSetting,
PersonalDataExport,
CandidateAbility,
CandidateDetail,
CandidateReview,
EnterpriseOverview,
EnterpriseQuickAction,
EnterpriseStat,
InterviewScoreData,
InterviewScoreDraft,
InterviewScoreDimension,
InterviewScoreTag,
JobEvaluationDimension,
JobEvaluationManagement,
JobEvaluationSummary,
JobEvaluationEmptyState,
JobPublishFormData,
JobQualityEstimate,
JobReview,
JobManagement,
JobManagementJob,
JobStatusAction,
JobManagementJobStatus,
JobManagementSortKey,
JobManagementTab,
TalentCandidate,
TalentRecommendation,
TalentRecommendationTab,
EnterpriseChatItem,
EnterpriseChatStatus,
EnterpriseCommunication,
EnterpriseCommunicationTab,
PendingTask,
EnterpriseCertificationBenefit,
EnterpriseCertificationData,
EnterpriseCertificationForm,
EnterpriseCertificationStatus,
MatchModeOption,
OperationRules,
AuditReport,
WechatAccountStatus,
WechatLoginResult,
WechatBindPhonePayload,
WechatSession,
ChangePasswordPayload,
PasswordResetPayload,
PasswordRuleError,
CarrierFailureReason,
CarrierName,
CarrierPhoneResult,
} from '../types';
import {
ALL_CITY_LABEL,
CARRIER_SDK_TIMEOUT_MS,
CARRIER_TOKEN_TTL_MS,
DEFAULT_SOCIAL_PROFILE,
JOB_MATCH_CITY_BONUS,
JOB_MATCH_POSITION_BONUS,
JOB_MATCH_SALARY_BONUS,
JOB_LEVEL_LABEL,
JOB_STATUS_EMPLOYED_PREFIX,
JOB_STATUS_INACTIVE,
MOCK_DELAY,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
PASSWORD_RULE_LABEL,
POSITION_NOISE_WORDS,
ENTERPRISE_DISPLAY_NAME,
PREFERENCE_DEFAULT_WORK_TYPE,
RESUME_AI_UNAVAILABLE_TIP,
SALARY_MAX_OPTIONS,
SALARY_RANGES,
SOCIAL_SECURITY_CITY_PROFILES,
TOKEN_TTL_MS,
} from '../constants';
import { buildResumeExportHtml, isValidPhone, isValidSmsCode } from '../utils';
import { colors } from '../theme';
import { storageGet, storageSet, storageRemove } from '../lib/storage';
import { exportResumePdf } from '../lib/resumeExport';
import { clearCacheDir, readCacheBytes } from '../lib/cache';
import { getCarrierNumber, CarrierAuthError, MOCK_CARRIER_PHONE } from '../lib/carrier-sdk';
/** 接口地址(通过 expo-constants 从 app.config.js 的 extra 读取) */
export const API_BASE_URL: string =
Constants.expoConfig?.extra?.apiBaseUrl ?? 'https://dev.api.com';
/* ===================== 模拟数据(真实后端就绪后删除) ===================== */
/** 岗位列表(原始数据,不含 level;level 由接口层按分数计算) */
const MOCK_JOBS: Omit<Job, 'level'>[] = [
{
id: '1',
companyName: '字节跳动',
companyLocation: '北京 · 海淀区',
positionName: '高级前端工程师',
salaryMin: 25,
salaryMax: 40,
score: 92,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '北京',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '2',
companyName: '腾讯科技',
companyLocation: '深圳 · 南山区',
positionName: '产品经理',
salaryMin: 20,
salaryMax: 35,
score: 78,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '深圳',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '3',
companyName: '阿里巴巴',
companyLocation: '杭州 · 余杭区',
positionName: 'UI设计师',
salaryMin: 15,
salaryMax: 25,
score: 85,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '杭州',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '4',
companyName: '美团',
companyLocation: '北京 · 朝阳区',
positionName: 'Java开发工程师',
salaryMin: 18,
salaryMax: 30,
score: 65,
experience: '3-5年',
education: '本科',
industry: '互联网/IT',
city: '北京',
benefits: ['五险一金', '年终奖金', '弹性工作'],
},
{
id: '5',
companyName: '字节跳动',
companyLocation: '上海 · 浦东新区',
positionName: '前端实习生',
salaryMin: 6,
salaryMax: 9,
score: 60,
experience: '应届生',
education: '本科',
industry: '互联网/IT',
city: '上海',
benefits: ['五险一金', '弹性工作'],
},
{
id: '6',
companyName: '招商银行',
companyLocation: '深圳 · 福田区',
positionName: '金融分析师',
salaryMin: 30,
salaryMax: 50,
score: 88,
experience: '5-10年',
education: '硕士',
industry: '金融',
city: '深圳',
benefits: ['五险一金', '年终奖金', '股权激励'],
},
{
id: '7',
companyName: '新东方',
companyLocation: '北京 · 朝阳区',
positionName: '课程顾问',
salaryMin: 12,
salaryMax: 20,
score: 72,
experience: '1-3年',
education: '大专',
industry: '教育',
city: '北京',
benefits: ['五险一金', '带薪年假'],
},
{
id: '8',
companyName: '华润医疗',
companyLocation: '广州 · 天河区',
positionName: '医学代表',
salaryMin: 15,
salaryMax: 25,
score: 75,
experience: '3-5年',
education: '本科',
industry: '医疗健康',
city: '广州',
benefits: ['五险一金', '交通补助'],
},
{
id: '9',
companyName: '宁德时代',
companyLocation: '宁德 · 东侨区',
positionName: '电池工程师',
salaryMin: 28,
salaryMax: 45,
score: 90,
experience: '5-10年',
education: '硕士',
industry: '制造业',
city: '宁德',
benefits: ['五险一金', '年终奖金', '股权激励'],
},
];
/** 会话列表 */
const MOCK_CONVERSATIONS: Conversation[] = [
{
id: 'conv-1',
contactName: '张HR(字节跳动)',
companyName: '字节跳动',
avatar: '字',
lastMessage: '您的简历已通过初筛,方便约个时间面试吗?',
timestamp: '09:32',
unread: true,
matched: true,
},
{
id: 'conv-2',
contactName: '李HR(腾讯科技)',
companyName: '腾讯科技',
avatar: '腾',
lastMessage: '面试时间已确认:明天 10:00 视频面试',
timestamp: '昨天',
unread: true,
matched: true,
},
{
id: 'conv-3',
contactName: '王经理(阿里巴巴)',
companyName: '阿里巴巴',
avatar: '阿',
lastMessage: '感谢您的投递,我们会在 3 个工作日内反馈',
timestamp: '周一',
unread: false,
matched: false,
},
{
id: 'conv-4',
contactName: '招聘助手(美团)',
companyName: '美团',
avatar: '美',
lastMessage: '您有一条新的面试邀请待确认',
timestamp: '08:15',
unread: false,
matched: true,
},
];
/** 系统通知列表 */
const MOCK_NOTIFICATIONS: Notification[] = [
{
id: 'noti-1',
title: 'Offer 通知',
content: '恭喜!您收到了 2 个 Offer,请及时查看并处理',
timestamp: '10:20',
type: 'offer',
read: false,
},
{
id: 'noti-2',
title: '系统通知',
content: '您的简历已更新,更多企业将看到您的信息',
timestamp: '09:00',
type: 'system',
read: false,
},
{
id: 'noti-3',
title: '活动推广',
content: '本周六下午举办「求职面试技巧」线上讲座,欢迎报名',
timestamp: '昨天',
type: 'promotion',
read: true,
},
{
id: 'noti-4',
title: '系统通知',
content: '您的账号已通过企业认证',
timestamp: '周一',
type: 'system',
read: true,
},
];
/** 面试日程列表 */
const MOCK_INTERVIEWS: Interview[] = [
{
id: 'iv-1',
companyName: '字节跳动',
positionName: '前端开发终面',
time: '10:00',
type: 'video',
meetingUrl: 'https://meeting.example.com/abc123',
},
{
id: 'iv-2',
companyName: '腾讯科技',
positionName: '产品经理二面',
time: '14:30',
type: 'offline',
location: '深圳市南山区科技园南区',
},
];
/**
* 双向匹配结果。
*
* 由会话列表的 `matched` 标志派生 —— 顶部横幅(「与 N 家企业双向匹配成功」)与会话卡上的
* 「✓ 已匹配」标签是**同一个事实**,各写一份必然对不上:原先横幅名单写
* 「字节跳动、腾讯科技、阿里巴巴」,而会话里阿里巴巴没标已匹配、美团反而标了。
*/
function buildMatchResult(): MatchResult {
const matched = MOCK_CONVERSATIONS.filter((chat) => chat.matched);
return {
total: matched.length,
companies: matched.map((chat) => chat.companyName),
};
}
/** 霍兰德六维名称映射 */
const HOLLAND_NAMES: Record<HollandType, string> = {
R: '现实型',
I: '研究型',
A: '艺术型',
S: '社会型',
E: '企业型',
C: '常规型',
};
/**
* 霍兰德测评累计参与人数。
*
* 【为什么抽成常量】此前介绍页写 124580、测评页写 12846 —— **同一个统计量两个数**:
* 用户在介绍页看到「已有 124,580 人完成测评」,回到成长页又看到「已有 12,846 人完成」。
* 与「全部 12 / 已下线 3」属同一类缺陷(一个数字两处来源),故收敛到一处。
*
* 真实人数应由后端下发,接入前两处引用同一常量,不得各写各的;
* 接后端时删掉本常量,改为两处各自读接口返回值。
*/
const HOLLAND_TOTAL_PARTICIPANTS = 124580;
/** 霍兰德职业兴趣测评 */
const MOCK_HOLLAND: HollandAssessment = {
totalParticipants: HOLLAND_TOTAL_PARTICIPANTS,
currentQuestionIndex: 11,
totalQuestions: 60,
question: {
id: 'hq-12',
order: 12,
text: '我喜欢动手操作机械和工具',
},
options: [
{ value: 1, label: '非常不喜欢' },
{ value: 2, label: '不喜欢' },
{ value: 3, label: '一般' },
{ value: 4, label: '喜欢' },
{ value: 5, label: '非常喜欢' },
],
dimensions: [
{ type: 'R', name: HOLLAND_NAMES.R, score: 72, percent: 72 },
{ type: 'I', name: HOLLAND_NAMES.I, score: 88, percent: 88 },
{ type: 'A', name: HOLLAND_NAMES.A, score: 45, percent: 45 },
{ type: 'S', name: HOLLAND_NAMES.S, score: 85, percent: 85 },
{ type: 'E', name: HOLLAND_NAMES.E, score: 60, percent: 60 },
{ type: 'C', name: HOLLAND_NAMES.C, score: 50, percent: 50 },
],
result: {
code: 'ISR',
name: '研究型 + 社会型 + 现实型',
dimensions: [
{ type: 'I', name: HOLLAND_NAMES.I, score: 88, percent: 88 },
{ type: 'S', name: HOLLAND_NAMES.S, score: 85, percent: 85 },
{ type: 'R', name: HOLLAND_NAMES.R, score: 72, percent: 72 },
{ type: 'E', name: HOLLAND_NAMES.E, score: 60, percent: 60 },
{ type: 'C', name: HOLLAND_NAMES.C, score: 50, percent: 50 },
{ type: 'A', name: HOLLAND_NAMES.A, score: 45, percent: 45 },
],
analyses: [
{
type: 'I',
name: HOLLAND_NAMES.I,
score: 88,
description: '你喜欢思考与分析,偏好独立研究、动手实验。适合技术类、研发类、数据分析类工作。',
careers: ['研发工程师', '数据分析师', '科学家'],
},
{
type: 'S',
name: HOLLAND_NAMES.S,
score: 85,
description: '你喜欢与人交流,乐于助人,关心他人感受。适合教育、培训、咨询、社工等岗位。',
careers: ['教师', '咨询师', 'HRBP'],
},
{
type: 'R',
name: HOLLAND_NAMES.R,
score: 72,
description: '你喜欢动手操作,注重实际效果,偏好具体任务。适合工程师、技术员、设计师等。',
careers: ['工程师', '技术员', '设计师'],
},
],
jobs: [
{
id: '1',
title: '数据分析师',
companyName: '字节跳动',
salary: '25-40K',
city: '北京',
tags: ['数据分析', 'SQL', 'Python'],
match: 95,
},
{
id: '2',
title: '前端开发工程师',
companyName: '腾讯科技',
salary: '22-35K',
city: '深圳',
tags: ['React', 'TypeScript'],
match: 92,
},
{
id: '3',
title: '产品经理',
companyName: '阿里巴巴',
salary: '20-35K',
city: '杭州',
tags: ['B端产品', 'SaaS'],
match: 88,
},
],
actions: [
{ id: 'a1', title: '完善技能图谱,精准匹配岗位', desc: '根据测评结果补充优势技能标签', icon: 'construct-outline', route: '/resume' },
{ id: 'a2', title: '优化简历,提升投递成功率', desc: '突出研究型与社会型项目经历', icon: 'document-text-outline', route: '/resume' },
{ id: 'a3', title: '查看行业新服务,合理定价', desc: '了解目标岗位市场薪资区间', icon: 'cash-outline', route: '/tools/salary-calculator' },
],
},
};
/** 四维能力测评(原始数据,不含 level;level 由接口层按总分计算) */
const MOCK_ABILITY: Omit<AbilityAssessment, 'level'> = {
overallScore: 86,
percentile: 78,
dimensions: [
{
label: '专业能力',
value: 88,
percentileText: '超过 80% 同岗位求职者',
subItems: [
{ id: 'pro-1', label: '核心技术掌握度', score: 90, maxScore: 100 },
{ id: 'pro-2', label: '工程化能力', score: 86, maxScore: 100 },
{ id: 'pro-3', label: '算法基础', score: 75, maxScore: 100 },
],
},
{
label: '项目经验',
value: 75,
percentileText: '超过 62% 同岗位求职者',
subItems: [
{ id: 'exp-1', label: '项目规模', score: 70, maxScore: 100 },
{ id: 'exp-2', label: '业务理解', score: 78, maxScore: 100 },
{ id: 'exp-3', label: '技术深度', score: 76, maxScore: 100 },
],
},
{
label: '技能广度',
value: 82,
percentileText: '超过 75% 同岗位求职者',
subItems: [
{ id: 'skill-1', label: '前端技术栈', score: 90, maxScore: 100 },
{ id: 'skill-2', label: '后端协作', score: 75, maxScore: 100 },
{ id: 'skill-3', label: '设计能力', score: 70, maxScore: 100 },
],
},
{
label: '成长潜力',
value: 90,
percentileText: '超过 85% 同岗位求职者',
subItems: [
{ id: 'grow-1', label: '学习敏锐度', score: 92, maxScore: 100 },
{ id: 'grow-2', label: '问题解决', score: 88, maxScore: 100 },
{ id: 'grow-3', label: '创新意识', score: 85, maxScore: 100 },
],
},
],
trends: [
{ month: '3月', professional: 68, project: 62, skill: 70, growth: 72 },
{ month: '4月', professional: 72, project: 65, skill: 74, growth: 76 },
{ month: '5月', professional: 76, project: 68, skill: 76, growth: 80 },
{ month: '6月', professional: 80, project: 70, skill: 78, growth: 82 },
{ month: '7月', professional: 84, project: 72, skill: 80, growth: 86 },
{ month: '8月', professional: 88, project: 75, skill: 82, growth: 90 },
],
improvements: [
{
id: 'imp-1',
title: '增加项目端到端经验',
description: '建议参与中大型项目全流程,提升技术深度和团队协作经验。',
icon: 'git-merge-outline',
route: '/game',
},
{
id: 'imp-2',
title: '夯实算法基础',
description: '每天刷 1-2 道算法题,重点关注数组、字符串与常见数据结构。',
icon: 'code-slash-outline',
route: '/game',
},
{
id: 'imp-3',
title: '补充跨端/全栈技能',
description: '学习 React Native 或 Node.js,拓展技能广度,提升全栈协作能力。',
icon: 'layers-outline',
route: '/game',
},
],
};
/** AI 求职工具 */
const MOCK_AI_TOOLS: AITool[] = [
{
id: 'ai-resume',
title: 'AI简历优化',
description: '一键优化,通过率提升80%',
action: '立即优化',
variant: 'primary',
icon: 'document-text-outline',
route: '/resume',
},
{
id: 'ai-interview',
title: 'AI模拟面试',
description: '真人感面试实战演练',
action: '开始练习',
variant: 'success',
icon: 'chatbubbles-outline',
route: '/interview',
},
];
/** 职场干货文章 */
const MOCK_ARTICLES: Article[] = [
{
id: 'art-1',
title: '如何写出让HR眼前一亮的简历',
tag: '职场攻略',
views: 23000,
thumbnailColor: 'violet',
icon: 'document-text-outline',
content: '简历往往是你与 HR 之间唯一的第一印象,而对方平均只花 6-10 秒扫读一份简历。核心原则只有三条:一页纸、结果导向、量化成就。\n\n先用「岗位关键词」反向定制:把 JD 里的高频能力词自然融入项目描述,让 ATS 筛选系统更容易命中。再套用 STAR 法则(情境-任务-行动-结果)拆解每段经历,例如「主导 X 项目,通过 Y 动作,带来 Z% 的留存提升」明显比「负责 X 项目」更有说服力。\n\n最后做减法:删掉与岗位无关的兴趣社团,把空间留给能证明你胜任的硬证据。轻松聘「简历诊断」会基于行业评分模型给出逐项改进建议,投递前先自检一遍,能少走很多弯路。',
},
{
id: 'art-2',
title: '面试中最容易踩坑的10个问题',
tag: '面试技巧',
views: 18000,
thumbnailColor: 'orange',
icon: 'help-circle-outline',
content: '面试不是考试,而是一次双向匹配。最容易翻车的 10 个问题集中在:自我介绍、离职原因、期望薪资、职业规划、自身缺点、压力情境、对公司的了解、对前东家的评价、到岗时间,以及最后的「你还有什么想问的」。\n\n高频雷区有三个:把离职原因说成吐槽前公司、在薪资上先亮底牌、用「我没什么缺点」来回避成长话题。正确姿势是用「事实加反思」替代情绪,把每个问题都变成展示你方法论的机会。\n\n最后一题「你有什么想问的」最容易被忽略,其实它是你反向评估公司是否靠谱的关键窗口——问团队节奏、问岗位成功标准、问业务下一步,比问福利更有价值。轻松聘 AI 模拟面试可针对你的目标岗位做专项陪练。',
},
{
id: 'art-3',
title: '2024年互联网行业薪资报告',
tag: '行业洞察',
views: 35000,
thumbnailColor: 'green',
icon: 'stats-chart-outline',
content: '薪资报告的价值不在数字本身,而在帮你建立谈薪的「锚点」与「区间」。2024 年互联网行业整体回归理性:中厂与垂直领域岗位增长稳定,纯补贴型高薪收缩,复合能力(业务加技术加沟通)溢价明显。\n\n看报告要学会三层拆解:一是按城市与年限看基线,二是按岗位序列看带宽(P25、P50、P75),三是评估自己的「可替代性」——越稀缺的能力,越能突破带宽上限。\n\n谈薪时不要只报一个数,给一个区间并说明依据(市场分位加你的独特价值),更容易落到上限。轻松聘薪资计算器已内置城市与序列系数,可一键估算你的合理区间。',
},
];
/** 当前用户个人信息(我的页) */
const MOCK_USER_PROFILE: UserProfile = {
id: 'u-1',
name: '李明',
avatar: '李',
bio: '3年工作经验·本科·北京',
avatarKey: null,
};
/**
* 四宫格里唯一没有明细可对照的一列:「沟通」。
* 消息页有 4 个会话,但全仓没有任何一处把「沟通数」定义为会话数,
* 语义未经确认,故不擅自改数,只把字面量抽出来标明来路。
*/
const MOCK_CHAT_COUNT = 8;
/**
* 用户统计数据(我的页四宫格)。
*
* 投递 / 收藏 / Offer 三列**一律从各自的主口径派生**,不在此写死 —— 写死必然漂移:
* · 投递、Offer → `MOCK_JOB_PROGRESS`(求职进度页的 deliveryCount / stats.total /
* 时间轴文案四处都用它,是本项目的岗位投递主口径)。原 offers 写 3,那边是 2。
* · 收藏 → `MOCK_FAVORITES`(「我的收藏」页真正的明细列表)。原 favorites 写 12,
* 而收藏页只有 3 条 —— 用户点进去一数就发现对不上。
*/
function buildUserStats(): UserStats {
return {
deliveries: PROGRESS_DELIVERY_COUNT,
favorites: MOCK_FAVORITES.length,
chats: MOCK_CHAT_COUNT,
offers: PROGRESS_OFFER_COUNT,
};
}
/**
* 求职者端功能菜单
* 只含求职者相关功能;「企业入驻」作为切换到企业端的入口保留在此。
* 企业侧功能(发布岗位 / 岗位管理等)一律不在此下发,避免角色混淆。
*/
const MOCK_CANDIDATE_MENU_ITEMS: MenuItem[] = [
{ id: 'resume', title: '我的简历', icon: 'document-text-outline', route: '/resume' },
{ id: 'favorites', title: '我的收藏', icon: 'heart-outline', route: '/favorites' },
{ id: 'history', title: '浏览历史', icon: 'time-outline', route: '/history' },
{ id: 'compare', title: '岗位对比', icon: 'git-compare-outline', route: '/jobs/compare' },
{ id: 'offer-compare', title: 'Offer对比', icon: 'gift-outline', route: '/jobs/offer-compare' },
{ id: 'assessment', title: '我的测评', icon: 'bar-chart-outline', route: '/assessment' },
{ id: 'interview', title: 'AI模拟面试', icon: 'videocam-outline', route: '/interview' },
{ id: 'rank', title: '竞争力排名', icon: 'podium-outline', route: '/jobs/rank' },
{ id: 'hr-evaluation', title: 'HR评价详情', icon: 'ribbon-outline', route: '/hr-evaluation' },
{ id: 'onboarding', title: '入职准备清单', icon: 'checkmark-done-outline', route: '/onboarding' },
{ id: 'skill-cloud', title: '行业技能词云', icon: 'cloud-outline', route: '/tools/skill-cloud' },
{ id: 'salary', title: '薪资计算器', icon: 'calculator-outline', route: '/tools/salary-calculator' },
{ id: 'progress', title: '求职进度', icon: 'list-outline', route: '/progress' },
{ id: 'game', title: '通关小游戏', icon: 'trophy-outline', route: '/game' },
{ id: 'legal', title: '阿布阿诺法律服务', icon: 'shield-checkmark-outline', route: '/legal' },
{ id: 'enterprise-certification', title: '企业入驻', icon: 'business-outline', route: '/enterprise-certification' },
];
/**
* 企业端功能菜单
* 只含企业 / HR 相关功能,在企业端「我的 - 更多服务」下发。
* 不设「候选人详情」菜单入口:该页需要候选人 id,独立入口会落到兜底数据,
* 正确路径是「人才推荐 → 列表点击 → 带 id 进详情」。
* 运营规则配置 / 数据审计等后台项见 MOCK_ENTERPRISE_MANAGE_ACTIONS。
*/
const MOCK_ENTERPRISE_MENU_ITEMS: MenuItem[] = [
{ id: 'job-publish', title: '发布岗位', icon: 'add-circle-outline', route: '/job-publish' },
{ id: 'job-management', title: '岗位管理', icon: 'business-outline', route: '/job-management' },
{ id: 'job-evaluations', title: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations' },
{ id: 'talent-recommendation', title: '人才推荐', icon: 'people-outline', route: '/talent-recommendation' },
{ id: 'interview-score', title: '面试评分', icon: 'create-outline', route: '/interview-score' },
{ id: 'enterprise-chat', title: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat' },
];
/** 企业端入口 */
const MOCK_ENTERPRISE_ENTRY: EnterpriseEntry = {
title: '切换到企业端',
description: '如果你是HR或企业管理者,可以切换到企业端管理招聘',
action: '去企业端 →',
};
/** 综合评分 */
const MOCK_OVERALL_SCORE: OverallScore = {
score: 88,
level: '优秀',
rank: '超过85%的求职者',
};
/** 八大维度评分 */
const MOCK_DIMENSION_SCORES: DimensionScore[] = [
{ id: 'edu', name: '教育背景', score: 92, level: 'excellent' },
{ id: 'intern', name: '实习经历', score: 85, level: 'excellent' },
{ id: 'skill', name: '专业技能', score: 88, level: 'excellent' },
{ id: 'project', name: '项目经验', score: 78, level: 'good' },
{ id: 'cert', name: '证书资质', score: 72, level: 'good' },
{ id: 'stability', name: '工作稳定性', score: 90, level: 'excellent' },
{ id: 'integrity', name: '诚信记录', score: 95, level: 'excellent' },
{ id: 'salary', name: '薪资匹配度', score: 82, level: 'excellent' },
];
/**
* 求职偏好空值:引导未采集时返回它,由页面渲染「未设置」。
* 此前这里返回写死的演示偏好(前端开发工程师 / 北京 / 25-35K),
* 导致「我的」页的偏好恒为假数据,与用户填的无关。
*/
const EMPTY_JOB_PREFERENCE: JobPreference = {
position: '',
city: '',
salary: '',
workType: PREFERENCE_DEFAULT_WORK_TYPE,
status: '',
};
/**
* 勋章列表(已获得 + 未获得;未获得带获取进度)。
*
* 每枚勋章的描述本身就是一个计数门槛(「投递满100次」「获得5个offer」…),
* 所以进度与「是否已获得」**必须由用户统计派生**。原先两者各写一份,后果是同一个
* 「我的」页上下自相矛盾:四宫格写着投递 28、Offer 2,正下方的勋章列表却显示
* 「投递满100次 · 已获得」「投递达人 128/200」「offer收割机 3/5」。
* 派生还有个好处:门槛一到自动点亮,不需要谁记得回来改 `obtained`。
*
* 例外:badge-6「完成10次测评」的「测评次数」全仓没有可对照的明细口径
* (霍兰德与四维测评各只有一份结果,不含计数),语义未经确认,暂保留原值。
*/
function buildBadges(): Badge[] {
const stats = buildUserStats();
const interviewCount = PROGRESS_INTERVIEW_COUNT;
/** 「诚信记录满分」的分就是八大维度里那一项,不另记一笔 */
const integrity = MOCK_DIMENSION_SCORES.find((item) => item.id === 'integrity')?.score ?? 0;
return [
{
id: 'badge-1',
title: '求职达人',
description: '投递满100次',
icon: '🏅',
obtained: stats.deliveries >= 100,
progress: Math.min(stats.deliveries, 100),
target: 100,
},
{
id: 'badge-2',
title: '面试高手',
description: '面试10次成功',
icon: '🎯',
obtained: interviewCount >= 10,
progress: Math.min(interviewCount, 10),
target: 10,
},
{
id: 'badge-3',
title: '诚信之星',
description: '诚信记录满分',
icon: '⭐',
obtained: integrity >= 100,
progress: Math.min(integrity, 100),
target: 100,
},
{
id: 'badge-4',
title: 'offer收割机',
description: '获得5个offer',
icon: '🏆',
obtained: stats.offers >= 5,
progress: Math.min(stats.offers, 5),
target: 5,
},
{
id: 'badge-5',
title: '投递达人',
description: '投递满200次',
icon: '📮',
obtained: stats.deliveries >= 200,
progress: Math.min(stats.deliveries, 200),
target: 200,
},
{ id: 'badge-6', title: '学习之星', description: '完成10次测评', icon: '📚', obtained: false, progress: 4, target: 10 },
];
}
/** 岗位详情附加/覆盖信息(id → 详情页专属字段;只覆盖列表页没有、或确实需要细化的字段) */
const MOCK_JOB_DETAILS: Record<string, Partial<JobDetail>> = {
'1': {
// 这里原先还覆盖了 companyLocation(写「朝阳区」),与 MOCK_JOBS 里的「海淀区」冲突 ——
// 同一个岗位在列表卡和详情页显示两个城区。列表页该字段本就有完整值,不属「未细化」,
// 故撤掉这层覆盖,让 MOCK_JOBS 成为该字段的唯一来源。
industry: '互联网',
benefits: ['五险一金', '年终奖金', '弹性工作', '免费三餐'],
companySize: '10000人以上',
status: '刚发布',
views: 1286,
salaryMonths: 14,
},
};
/** 岗位综合评分(8 维度,供雷达图) */
const MOCK_JOB_OVERALL_SCORE: JobOverallScore = {
totalScore: 92,
level: '优秀',
rank: '超过91%同行业岗位',
dimensions: [
{ id: 'salary', name: '薪资竞争力', value: 92 },
{ id: 'growth', name: '发展空间', value: 90 },
{ id: 'atmosphere', name: '工作氛围', value: 86 },
{ id: 'benefit', name: '福利待遇', value: 84 },
{ id: 'team', name: '团队水平', value: 88 },
{ id: 'culture', name: '企业文化', value: 85 },
{ id: 'intensity', name: '工作强度', value: 78 },
{ id: 'authenticity', name: '岗位真实性', value: 95 },
],
};
/** 真实薪资区间 */
const MOCK_SALARY_DATA: SalaryData = {
ranges: [
{ experience: '1-3年', min: 15, max: 25 },
{ experience: '3-5年', min: 25, max: 40 },
{ experience: '5-10年', min: 40, max: 65 },
{ experience: '10年以上', min: 65, max: 100 },
],
dataSource: '基于2,846份真实简历数据',
totalCount: 2846,
};
/** 员工评价列表 */
const MOCK_EMPLOYEE_REVIEWS: EmployeeReview[] = [
{
id: 'r-1',
userName: '张**',
userRole: '在职员工,前端开发',
time: '3天前',
content: '团队氛围很好,技术栈比较新,成长空间大,就是加班有点多...',
tags: ['团队氛围好', '技术先进'],
},
{
id: 'r-2',
userName: '李**',
userRole: '离职员工,高级前端',
time: '2周前',
content: '薪资待遇不错,福利也还行,就是晋升通道不够清晰...',
tags: ['薪资高', '福利好'],
},
{
id: 'r-3',
userName: '王**',
userRole: '在职员工,技术主管',
time: '1个月前',
content: '公司文化很好,同事都很优秀,能学到很多东西...',
tags: ['文化好', '成长快'],
},
];
/** 对比页里与「岗位分」同义的那个维度名(其余维度是独立评分,不参与派生) */
const COMPARE_SCORE_DIMENSION = '综合评分';
/** 岗位对比数据(3 个岗位:九大维度评分 + 基本信息 + 福利) */
const MOCK_COMPARE_DATA: CompareData = {
jobs: [
{ id: '1', title: '高级前端工程师', company: '字节跳动', salary: '25-40K', color: colors.primary },
{ id: '2', title: '产品经理', company: '腾讯科技', salary: '20-35K', color: colors.compareRed },
{ id: '3', title: 'UI设计师', company: '阿里巴巴', salary: '15-25K', color: colors.compareOrange },
],
dimensionScores: [
{ dimension: '综合评分', scores: [92, 85, 78] },
{ dimension: '薪资竞争力', scores: [88, 82, 70] },
{ dimension: '发展空间', scores: [90, 86, 75] },
{ dimension: '工作氛围', scores: [85, 88, 90] },
{ dimension: '福利待遇', scores: [86, 80, 72] },
{ dimension: '团队水平', scores: [91, 84, 76] },
{ dimension: '企业文化', scores: [84, 87, 80] },
{ dimension: '工作强度', scores: [72, 78, 85] },
{ dimension: '岗位真实性', scores: [95, 90, 88] },
],
basicInfo: {
fields: ['公司规模', '所在行业', '工作地点', '经验要求', '学历要求', '工作类型'],
values: [
['10000人以上', '10000人以上', '10000人以上'],
['互联网/IT', '互联网/IT', '互联网/IT'],
['北京', '深圳', '杭州'],
['3-5年', '3-5年', '3-5年'],
['本科', '本科', '本科'],
['全职', '全职', '全职'],
],
},
benefits: [
['五险一金', '年终奖金', '弹性工作', '免费三餐'],
['五险一金', '年终奖金', '弹性工作', '股票期权'],
['五险一金', '年终奖金', '弹性工作'],
],
};
/* ===================== 业务规则 ===================== */
/** 分数 → 等级映射(业务规则,接口层计算) */
function scoreToLevel(score: number): JobLevel {
if (score >= 85) return 'excellent';
if (score >= 70) return 'good';
return 'medium';
}
/**
* 岗位 id → 该岗位的分数。
*
* `MOCK_JOBS` 是岗位分的**唯一来源**:列表卡印它、排序用它、等级由它算。
* 对比页与详情页原先各写一份固定分,于是同一个岗位在列表里是 78 分、
* 点进详情变 92 分、在对比页又是 85 分 —— 三处三个数。
*/
function jobScoreById(id: string): number {
return MOCK_JOBS.find((job) => job.id === id)?.score ?? 0;
}
/** 能力总分 → 等级映射(能力测评阈值高于岗位匹配:≥90 优秀 / ≥75 良好) */
function abilityScoreToLevel(score: number): JobLevel {
if (score >= 90) return 'excellent';
if (score >= 75) return 'good';
return 'medium';
}
/* ===================== 接口函数 ===================== */
/** 模拟请求(用 setTimeout 模拟网络延迟;真实后端就绪后替换为 fetch) */
function mockRequest<T>(data: T): Promise<T> {
return new Promise((resolve) => setTimeout(() => resolve(data), MOCK_DELAY));
}
/**
* 获取岗位列表(含排序/筛选/搜索/分页,等级由分数在接口层计算)
* @method GET
* @url /api/jobs
* @param params { JobListParams } 排序/筛选/搜索/分页参数
* @returns Promise<{ jobs: Job[]; total: number }> 分页后的岗位数组与总数
*/
export async function fetchJobList(
params: JobListParams,
): Promise<{ jobs: Job[]; total: number }> {
// TODO: 真实请求(后端就绪后替换)
// const query = new URLSearchParams({ sortBy: params.sortBy, page: String(params.page ?? 1) });
// const res = await fetch(`${API_BASE_URL}/api/jobs?${query}`, { method: 'GET' });
// return res.json();
// 仅在「默认推荐视图」叠加偏好加分:用户主动选了城市或输入关键词时,
// 一律尊重用户的选择,不额外加分(也把回归面限制在默认视图内)
const hasCityFilter = !!params.city && params.city !== ALL_CITY_LABEL;
const hasKeyword = !!params.keyword?.trim();
const preference =
params.sortBy === 'comprehensive' && !hasCityFilter && !hasKeyword
? await readEffectivePreference()
: null;
return new Promise((resolve) => {
setTimeout(() => {
// 1. 从 mock 数据源复制
let result: Omit<Job, 'level'>[] = [...MOCK_JOBS];
// 2. 关键词搜索(岗位名 / 公司名)
if (params.keyword && params.keyword.trim() !== '') {
const keyword = params.keyword.trim().toLowerCase();
result = result.filter(
(job) =>
job.positionName.toLowerCase().includes(keyword) ||
job.companyName.toLowerCase().includes(keyword),
);
}
// 3. 筛选:行业
if (params.industry && params.industry !== '全部行业') {
result = result.filter((job) => job.industry === params.industry);
}
// 4. 筛选:城市
if (params.city && params.city !== ALL_CITY_LABEL) {
const city = params.city;
result = result.filter((job) => job.city.includes(city));
}
// 筛选:薪资范围(岗位薪资区间与任一所选区间有交集即命中)
if (params.salary && params.salary.length > 0) {
const salary = params.salary;
result = result.filter((job) =>
salary.some((label) => {
const range = SALARY_RANGES.find((item) => item.label === label);
return range ? job.salaryMin <= range.max && job.salaryMax >= range.min : false;
}),
);
}
// 筛选:经验要求(多选,命中任一即保留)
if (params.experience && params.experience.length > 0) {
const experience = params.experience;
result = result.filter((job) => experience.includes(job.experience));
}
// 筛选:学历要求(多选,命中任一即保留)
if (params.education && params.education.length > 0) {
const education = params.education;
result = result.filter((job) => education.includes(job.education));
}
// 5. 排序
switch (params.sortBy) {
case 'salary':
result.sort((a, b) => b.salaryMax - a.salaryMax);
break;
case 'latest':
result.sort((a, b) => parseInt(b.id, 10) - parseInt(a.id, 10));
break;
case 'comprehensive':
default:
if (preference) {
// 已采集偏好:按「岗位分数 + 命中加分」重排,让符合偏好的岗位上浮
result = result
.map((job) => ({ job, match: matchScore(job, preference) }))
.sort((a, b) => b.match - a.match)
.map((item) => item.job);
} else {
result.sort((a, b) => b.score - a.score);
}
break;
}
// 6. 计算总数
const total = result.length;
// 7. 分页
const page = params.page ?? 1;
const pageSize = params.pageSize ?? 10;
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, total);
const paginatedJobs = result.slice(startIndex, endIndex);
// 8. 等级映射(业务规则)
const jobs: Job[] = paginatedJobs.map((job) => ({
...job,
level: scoreToLevel(job.score),
}));
resolve({ jobs, total });
}, MOCK_DELAY);
});
}
/* ===================== 我的收藏 / 浏览历史(mock 数据) ===================== */
/** 收藏的职位 id 列表(mock 阶段取 MOCK_JOBS 子集) */
const MOCK_FAVORITE_IDS = ['1', '3', '6'];
/** 浏览历史的职位 id 列表(mock 阶段取 MOCK_JOBS 子集) */
const MOCK_HISTORY_IDS = ['2', '4', '5', '7'];
const MOCK_FAVORITES: Omit<Job, 'level'>[] = MOCK_JOBS.filter((job) =>
MOCK_FAVORITE_IDS.includes(job.id),
);
const MOCK_HISTORY: Omit<Job, 'level'>[] = MOCK_JOBS.filter((job) =>
MOCK_HISTORY_IDS.includes(job.id),
);
/**
* 获取我的收藏(职位)列表
* @method GET
* @url /api/user/favorites
* @returns Promise<Omit<Job, 'level'>[]> 收藏的职位数组
*/
export async function fetchFavorites(): Promise<Omit<Job, 'level'>[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/favorites`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_FAVORITES);
}
/**
* 获取浏览历史(职位)列表
* @method GET
* @url /api/user/history
* @returns Promise<Omit<Job, 'level'>[]> 浏览过的职位数组(按时间倒序)
*/
export async function fetchHistory(): Promise<Omit<Job, 'level'>[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/history`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HISTORY);
}
/**
* 获取会话列表
* @method GET
* @url /api/messages/conversations
* @returns Promise<Conversation[]> 会话数组
*/
export async function fetchConversations(): Promise<Conversation[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/conversations`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_CONVERSATIONS);
}
/**
* 获取系统通知列表
* @method GET
* @url /api/messages/notifications
* @returns Promise<Notification[]> 通知数组
*/
export async function fetchNotifications(): Promise<Notification[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/notifications`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_NOTIFICATIONS);
}
/**
* 获取面试日程列表
* @method GET
* @url /api/messages/interviews
* @returns Promise<Interview[]> 面试日程数组
*/
export async function fetchInterviews(): Promise<Interview[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/interviews`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_INTERVIEWS);
}
/**
* 获取双向匹配结果
* @method GET
* @url /api/messages/match-result
* @returns Promise<MatchResult> 匹配结果
*/
export async function fetchMatchResult(): Promise<MatchResult> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/messages/match-result`, { method: 'GET' }); return res.json();
return mockRequest(buildMatchResult());
}
/**
* 获取霍兰德职业兴趣测评信息
* @method GET
* @url /api/growth/holland
* @returns Promise<HollandAssessment> 测评参与信息
*/
export async function fetchHollandAssessment(): Promise<HollandAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HOLLAND);
}
/**
* 提交霍兰德测评答案(mock 阶段返回下一题或最终结果)
* @method POST
* @url /api/growth/holland/answers
* @param questionId {string} 题目 id
* @param value {number} 选项分值
* @returns Promise<HollandAssessment> 更新后的测评进度或结果
*/
export async function submitHollandAnswer(
_questionId: string,
_value: number,
): Promise<HollandAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/answers`, { method: 'POST', body: JSON.stringify({ questionId, value }) }); return res.json();
return mockRequest(MOCK_HOLLAND);
}
/**
* 获取霍兰德测评结果
* @method GET
* @url /api/growth/holland/result
* @returns Promise<NonNullable<HollandAssessment['result']>> 测评结果
*/
export async function fetchHollandResult(): Promise<NonNullable<HollandAssessment['result']>> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/result`, { method: 'GET' }); return res.json();
if (!MOCK_HOLLAND.result) {
throw new Error('暂无测评结果');
}
return mockRequest(MOCK_HOLLAND.result);
}
/**
* 获取四维能力测评结果(等级由总分在接口层计算)
* @method GET
* @url /api/growth/ability
* @returns Promise<AbilityAssessment> 能力测评结果
*/
export async function fetchAbilityAssessment(): Promise<AbilityAssessment> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ability`, { method: 'GET' }); return res.json();
return mockRequest({
...MOCK_ABILITY,
level: abilityScoreToLevel(MOCK_ABILITY.overallScore),
});
}
/**
* 获取 AI 求职工具列表
* @method GET
* @url /api/growth/ai-tools
* @returns Promise<AITool[]> 工具数组
*/
export async function fetchAITools(): Promise<AITool[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ai-tools`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_AI_TOOLS);
}
/**
* 获取职场干货文章列表
* @method GET
* @url /api/growth/articles
* @returns Promise<Article[]> 文章数组
*/
export async function fetchArticles(): Promise<Article[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/articles`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ARTICLES);
}
/**
* 获取当前用户个人信息(我的页)
* @method GET
* @url /api/user/me
* @returns Promise<UserProfile> 用户个人信息
*/
export async function fetchUserProfile(): Promise<UserProfile> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/me`, { method: 'GET' }); return res.json();
const profile = await fetchOnboardingProfile();
if (!profile) return mockRequest(MOCK_USER_PROFILE);
const { name, avatar } = displayNameAndAvatar(profile.resumeName);
return mockRequest({
// 有引导资料就只用资料里的值:不拿 mock 的「李明 / 3年工作经验·本科·北京」补空,
// 空值由页面渲染「未设置」或隐藏该行
...MOCK_USER_PROFILE,
name,
avatar,
bio: buildProfileBio(profile),
// 头像原始值直接透传,由页面决定怎么渲染(素材 key / 本地 URI / 回落展示字符)
avatarKey: profile.avatar,
});
}
/** 姓名 → 展示用姓名与头像字(头像取姓氏,与 mock 的「李」同风格) */
function displayNameAndAvatar(resumeName: string): { name: string; avatar: string } {
const name = resumeName.trim();
return { name, avatar: name.slice(0, 1) };
}
/**
* 资料 → 「我的」页简介:只拼引导真正采集到的项,未采集的略过,
* 不用 mock 的工作年限/学历/城市冒充用户信息
*/
function buildProfileBio(profile: OnboardingProfile): string {
return [profile.education, profile.city].filter(Boolean).join('·');
}
/**
* 获取用户统计数据(投递/收藏/沟通/Offer)
* @method GET
* @url /api/user/stats
* @returns Promise<UserStats> 用户统计数据
*/
export async function fetchUserStats(): Promise<UserStats> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/stats`, { method: 'GET' }); return res.json();
return mockRequest(buildUserStats());
}
/**
* 获取个人中心功能菜单列表
* @method GET
* @url /api/user/menus
* @param role { AppRole } 当前角色(端),按角色下发不同菜单,避免求职者端出现企业功能、反之亦然
* @returns Promise<MenuItem[]> 菜单数组
*/
export async function fetchMenuItems(role: AppRole = 'candidate'): Promise<MenuItem[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/menus?role=${role}`, { method: 'GET' }); return res.json();
const items = role === 'enterprise' ? MOCK_ENTERPRISE_MENU_ITEMS : MOCK_CANDIDATE_MENU_ITEMS;
return mockRequest(items);
}
/**
* 获取企业端入口信息
* @method GET
* @url /api/user/enterprise-entry
* @returns Promise<EnterpriseEntry> 企业端入口
*/
export async function fetchEnterpriseEntry(): Promise<EnterpriseEntry> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/enterprise-entry`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ENTERPRISE_ENTRY);
}
/**
* 获取综合评分
* @method GET
* @url /api/user/overall-score
* @returns Promise<OverallScore> 综合评分
*/
export async function fetchOverallScore(): Promise<OverallScore> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/overall-score`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_OVERALL_SCORE);
}
/**
* 获取八大维度评分
* @method GET
* @url /api/user/dimension-scores
* @returns Promise<DimensionScore[]> 维度评分数组
*/
export async function fetchDimensionScores(): Promise<DimensionScore[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/dimension-scores`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_DIMENSION_SCORES);
}
/* ===================== 注册引导资料(mock 阶段持久化到 AsyncStorage) ===================== */
const ONBOARDING_PROFILE_KEY = 'onboarding:profile';
/**
* 保存注册引导采集到的求职者资料
* @method POST
* @url /api/user/onboarding-profile
* @param profile 引导采集的资料(不含手机号 / 验证码 / 协议勾选等登录瞬时字段)
* @returns Promise<void>
* @remark 由引导流程按步骤提交(见 useOnboardingFlow),不模拟网络延迟,避免拖慢每一步跳转
*/
export async function saveOnboardingProfile(profile: OnboardingProfile): Promise<void> {
// TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(profile) });
await storageSet(ONBOARDING_PROFILE_KEY, JSON.stringify(profile));
}
/**
* 获取注册引导采集到的求职者资料
* @method GET
* @url /api/user/onboarding-profile
* @returns Promise<OnboardingProfile | null> 从未采集过时为 null(区分「没填过」与「填了空值」)
*/
export async function fetchOnboardingProfile(): Promise<OnboardingProfile | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'GET' }); return res.json();
const raw = await storageGet(ONBOARDING_PROFILE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as OnboardingProfile;
} catch {
// 存储内容损坏时按「未采集」处理,不阻断引导流程
return null;
}
}
/**
* 清除注册引导资料(退出登录时调用,避免把上一位用户填的资料留给下一位)
* @method DELETE
* @url /api/user/onboarding-profile
* @returns Promise<void>
*/
export async function clearOnboardingProfile(): Promise<void> {
// TODO: await fetch(`${API_BASE_URL}/api/user/onboarding-profile`, { method: 'DELETE' });
await storageRemove(ONBOARDING_PROFILE_KEY);
}
/**
* 流程状态 → 持久化资料(数据转换属 API 层职责)。
* @param data 引导流程当前状态
* @returns 可落库的资料;登录瞬时字段(phone / code / agreed / wechatTicket)被剔除
* @remark `OnboardingProfile` 由 `OnboardingData` 派生,漏掉字段会编译失败
*/
export function toOnboardingProfile(data: OnboardingData): OnboardingProfile {
return {
identity: data.identity,
isStudent: data.isStudent,
city: data.city,
expectedPositions: data.expectedPositions,
expectedSalaryMin: data.expectedSalaryMin,
expectedSalaryMax: data.expectedSalaryMax,
resumeName: data.resumeName,
gender: data.gender,
birthYear: data.birthYear,
birthMonth: data.birthMonth,
jobStatus: data.jobStatus,
disabledJobseeker: data.disabledJobseeker,
firstWorkYear: data.firstWorkYear,
firstWorkMonth: data.firstWorkMonth,
currentPosition: data.currentPosition,
company: data.company,
joinYear: data.joinYear,
joinMonth: data.joinMonth,
skills: data.skills,
workContent: data.workContent,
education: data.education,
eduType: data.eduType,
school: data.school,
avatar: data.avatar,
// 企业端才保留认证信息
recruiter:
data.identity === 'recruiter'
? {
company: data.recruiterCompany,
contactName: data.recruiterContact,
licenseUri: data.recruiterLicenseUri,
}
: null,
};
}
/**
* 持久化资料 → 流程状态(数据转换属 API 层职责)。
* @param profile 已落库的资料
* @param base 兜底状态(通常为 INITIAL),未采集字段与登录瞬时字段沿用它
* @returns 合并后的流程状态
*/
export function fromOnboardingProfile(
profile: OnboardingProfile,
base: OnboardingData,
): OnboardingData {
return {
...base,
identity: profile.identity,
isStudent: profile.isStudent,
city: profile.city,
expectedPositions: profile.expectedPositions,
expectedSalaryMin: profile.expectedSalaryMin,
expectedSalaryMax: profile.expectedSalaryMax,
resumeName: profile.resumeName,
gender: profile.gender,
birthYear: profile.birthYear,
birthMonth: profile.birthMonth,
jobStatus: profile.jobStatus,
disabledJobseeker: profile.disabledJobseeker,
firstWorkYear: profile.firstWorkYear,
firstWorkMonth: profile.firstWorkMonth,
currentPosition: profile.currentPosition,
company: profile.company,
joinYear: profile.joinYear,
joinMonth: profile.joinMonth,
skills: profile.skills,
workContent: profile.workContent,
education: profile.education,
eduType: profile.eduType,
school: profile.school,
avatar: profile.avatar,
recruiterCompany: profile.recruiter?.company ?? base.recruiterCompany,
recruiterContact: profile.recruiter?.contactName ?? base.recruiterContact,
recruiterLicenseUri: profile.recruiter?.licenseUri ?? base.recruiterLicenseUri,
};
}
/* ===================== 求职偏好(引导资料推导 + 用户显式编辑) ===================== */
const JOB_PREFERENCE_KEY = 'user:job-preference';
/** 读取用户在「求职意向」页显式保存过的偏好(未编辑过为 null) */
async function readJobPreferenceOverride(): Promise<JobPreference | null> {
const raw = await storageGet(JOB_PREFERENCE_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as JobPreference;
} catch {
// 存储内容损坏时按「未编辑过」处理,回退到引导资料推导
return null;
}
}
/** 引导资料 → 求职偏好。未采集的字段留空串,不做假数据填充 */
function deriveJobPreference(profile: OnboardingProfile): JobPreference {
return {
position: profile.expectedPositions[0] ?? '',
city: profile.city,
salary:
profile.expectedSalaryMin && profile.expectedSalaryMax
? `${profile.expectedSalaryMin}-${profile.expectedSalaryMax}`
: '',
workType: PREFERENCE_DEFAULT_WORK_TYPE,
status: profile.jobStatus ?? '',
};
}
/**
* 期望薪资字符串 → 数值区间
* @param text 形如 `20K`(单值)或 `20-35K`(区间)
* @returns 数值区间;无法解析时为 null(不参与匹配,避免凭空加分)
*/
function parseSalaryRange(text: string): { min: number; max: number } | null {
const nums = text.match(/\d+/g);
if (!nums || nums.length === 0) return null;
const values = nums.map(Number);
return { min: Math.min(...values), max: Math.max(...values) };
}
/**
* 职位名归一化:剔除级别与通用后缀,让「前端开发工程师」能与「高级前端工程师」命中。
* 全被剔除时退回原名(如职位名就是「工程师」),避免空串命中一切。
*/
function normalizePosition(name: string): string {
const stripped = POSITION_NOISE_WORDS.reduce((acc, word) => acc.replace(word, ''), name).trim();
return stripped === '' ? name : stripped;
}
/** 期望职位与岗位职位是否命中(归一化后双向包含,粗粒度匹配) */
function isPositionMatched(jobPositionName: string, expectedPositions: string[]): boolean {
const job = normalizePosition(jobPositionName);
return expectedPositions.some((position) => {
if (!position.trim()) return false;
const want = normalizePosition(position);
return job.includes(want) || want.includes(job);
});
}
/**
* 期望月薪「最高」可选阶梯:过滤掉低于已选最低值的档位。
* 规则放这里而不是页面里 —— 「最高不得低于最低」是业务规则,
* 页面只负责渲染返回的档位、不写数值比较。
* @param min 已选最低值(如 `20K`);未选或无法解析时返回完整阶梯
* @returns 可选的最高值档位
*/
export function salaryMaxOptions(min: string): string[] {
const floor = parseSalaryRange(min);
if (!floor) return [...SALARY_MAX_OPTIONS];
return SALARY_MAX_OPTIONS.filter((option) => (parseSalaryRange(option)?.min ?? 0) >= floor.max);
}
/**
* 改选最低期望月薪后,返回与之一致的 { 最低, 最高 }。
* 原最高值低于新的最低值时必须清掉 —— 否则会存出「最低 30K / 最高 15K」这种
* 自相矛盾的偏好,并顺着 `fetchJobPreference` 一路显示到「我的」页。
* @param min 新的最低值
* @param currentMax 当前最高值
* @returns 校正后的最低/最高值(最高值被清空时为 `''`)
*/
export function applySalaryMin(
min: string,
currentMax: string,
): { expectedSalaryMin: string; expectedSalaryMax: string } {
const floor = parseSalaryRange(min);
const ceiling = parseSalaryRange(currentMax);
// 最低值本身无法解析时无从比较,保留原最高值(只有能证明「最高 < 最低」才清)
const keepMax = !floor || (!!ceiling && ceiling.max >= floor.max);
return { expectedSalaryMin: min, expectedSalaryMax: keepMax ? currentMax : '' };
}
/** 岗位推荐用的有效偏好 */
interface EffectivePreference {
positions: string[];
city: string;
salary: { min: number; max: number } | null;
}
/**
* 岗位推荐用的有效偏好:显式保存过的意向优先,否则由引导资料推导。
* 两处取值源(引导资料 / 求职意向编辑页)的优先级只在这里决定。
*/
async function readEffectivePreference(): Promise<EffectivePreference | null> {
const override = await readJobPreferenceOverride();
if (override) {
return {
positions: override.position ? [override.position] : [],
city: override.city,
salary: parseSalaryRange(override.salary),
};
}
const profile = await fetchOnboardingProfile();
if (!profile) return null;
return {
positions: profile.expectedPositions,
city: profile.city,
salary:
profile.expectedSalaryMin && profile.expectedSalaryMax
? parseSalaryRange(`${profile.expectedSalaryMin}-${profile.expectedSalaryMax}`)
: null,
};
}
/**
* 岗位匹配分 = 岗位自身分数 + 命中期望城市 / 职位 / 薪资的加分。
* 权重见 src/constants,此处不写死魔法数字。
*/
function matchScore(job: Omit<Job, 'level'>, preference: EffectivePreference): number {
let score = job.score;
if (preference.city && job.city.includes(preference.city)) {
score += JOB_MATCH_CITY_BONUS;
}
if (isPositionMatched(job.positionName, preference.positions)) {
score += JOB_MATCH_POSITION_BONUS;
}
if (
preference.salary &&
job.salaryMin <= preference.salary.max &&
job.salaryMax >= preference.salary.min
) {
score += JOB_MATCH_SALARY_BONUS;
}
return score;
}
/**
* 有效求职偏好:用户在「求职意向」页显式保存过的偏好 > 由引导资料推导 > 空值。
* 「我的」页偏好卡片与简历「求职意向」都读它,保证两处展示同一个答案。
*/
async function resolveJobPreference(): Promise<JobPreference> {
const override = await readJobPreferenceOverride();
if (override) return override;
const profile = await fetchOnboardingProfile();
return profile ? deriveJobPreference(profile) : EMPTY_JOB_PREFERENCE;
}
/**
* 获取求职偏好
* @method GET
* @url /api/user/job-preference
* @returns Promise<JobPreference> 求职偏好;未采集到的字段为空串,由页面渲染「未设置」
* @remark 取值优先级见 `resolveJobPreference`
*/
export async function fetchJobPreference(): Promise<JobPreference> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'GET' }); return res.json();
return mockRequest(await resolveJobPreference());
}
/**
* 获取勋章列表
* @method GET
* @url /api/user/badges
* @returns Promise<Badge[]> 勋章数组
*/
export async function fetchBadges(): Promise<Badge[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/user/badges`, { method: 'GET' }); return res.json();
return mockRequest(buildBadges());
}
/**
* 获取岗位详情(含详情页专属字段;等级由分数在接口层计算)
* @method GET
* @url /api/jobs/:id
* @param id { string } 岗位 id
* @returns Promise<JobDetail> 岗位详情
*/
export async function fetchJobDetail(id: string): Promise<JobDetail> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/jobs/${id}`, { method: 'GET' });
// return res.json();
const base = MOCK_JOBS.find((job) => job.id === id);
if (!base) throw new Error(`岗位不存在: ${id}`);
// 详情 = 列表基础数据 + 详情页专属字段(无对应覆盖时用兜底值)
const detail: JobDetail = {
...base,
level: scoreToLevel(base.score),
companySize: '1000-9999人',
status: '热招中',
views: 0,
salaryMonths: 13,
...MOCK_JOB_DETAILS[id],
};
return mockRequest(detail);
}
/**
* 获取岗位综合评分(含 8 维度雷达图数据)
* @method GET
* @url /api/jobs/:id/score
* @param id { string } 岗位 id
* @returns Promise<JobOverallScore> 综合评分
*/
export async function fetchJobOverallScore(id: string): Promise<JobOverallScore> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/score`, { method: 'GET' }); return res.json();
// 必须按 id 取该岗位自己的分:原先 `void id;` 直接返回固定的一份(恒 92 分 · 优秀),
// 于是列表卡上「产品经理 78 分 · 良好」点进去变成「92 分 · 优秀」,9 个岗位里只有第 1 个对得上。
const score = jobScoreById(id);
const base = MOCK_JOB_OVERALL_SCORE.totalScore;
const factor = base > 0 ? score / base : 1;
return mockRequest({
...MOCK_JOB_OVERALL_SCORE,
totalScore: score,
level: JOB_LEVEL_LABEL[scoreToLevel(score)],
// 八维画像 mock 阶段只有这一份,按该岗位的实际综合分等比缩放后再画雷达图;
// 否则一个 65 分的岗位会配上一张最低值 78 分的雷达图,卡片自己和自己对不上。
// 真实后端就绪后由接口按岗位返回各自的画像,这段缩放随之删除。
dimensions: MOCK_JOB_OVERALL_SCORE.dimensions.map((dim) => ({
...dim,
value: Math.round(dim.value * factor),
})),
});
}
/**
* 获取岗位真实薪资区间
* @method GET
* @url /api/jobs/:id/salary
* @param id { string } 岗位 id
* @returns Promise<SalaryData> 薪资数据
*/
export async function fetchSalaryData(id: string): Promise<SalaryData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/salary`, { method: 'GET' }); return res.json();
void id;
return mockRequest(MOCK_SALARY_DATA);
}
/**
* 获取员工评价列表
* @method GET
* @url /api/jobs/:id/reviews
* @param id { string } 岗位 id
* @returns Promise<EmployeeReview[]> 评价数组
*/
export async function fetchEmployeeReviews(id: string): Promise<EmployeeReview[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'GET' }); return res.json();
// mock 阶段:内置评价 + 该岗位下本人已发布的评价(本人发布的最新在前)
const mine = (await readJobReviews()).filter((r) => r.jobId === id);
return mockRequest([...mine, ...MOCK_EMPLOYEE_REVIEWS]);
}
/* ===================== 岗位评价发布(mock 阶段持久化到 AsyncStorage) ===================== */
const JOB_REVIEWS_KEY = 'job:reviews';
/** 本人发布评价的存储结构:在 EmployeeReview 上补充归属岗位 */
interface StoredJobReview extends EmployeeReview {
jobId: string;
}
/** 读取本人发布的全部评价 */
async function readJobReviews(): Promise<StoredJobReview[]> {
const raw = await storageGet(JOB_REVIEWS_KEY);
return raw ? (JSON.parse(raw) as StoredJobReview[]) : [];
}
/**
* 发布岗位评价
* @method POST
* @url /api/jobs/:id/reviews
* @param id 岗位 id
* @param content 评价内容
* @returns Promise<EmployeeReview> 新建的评价
* @remark 真实后端下需做敏感词校验与重复提交限制,此处不做
*/
export async function createJobReview(id: string, content: string): Promise<EmployeeReview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/${id}/reviews`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
const review: StoredJobReview = {
id: `my-review-${Date.now()}`,
jobId: id,
userName: '我',
userRole: '我的评价',
time: '刚刚',
content,
tags: [],
};
const all = await readJobReviews();
all.unshift(review);
await storageSet(JOB_REVIEWS_KEY, JSON.stringify(all));
return mockRequest(review);
}
/**
* 获取岗位对比数据(按 jobIds 过滤九大维度评分 + 基本信息 + 福利)
* @method GET
* @url /api/jobs/compare
* @param jobIds { string[] } 参与对比的岗位 id 数组
* @returns Promise<CompareData> 岗位对比数据
*/
export async function fetchCompareData(jobIds: string[]): Promise<CompareData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/compare?jobIds=${jobIds.join(',')}`, { method: 'GET' }); return res.json();
// 按传入 jobIds 过滤对比数据(无匹配时兜底返回全部,避免空页面)
const selected = MOCK_COMPARE_DATA.jobs
.map((job, index) => (jobIds.includes(job.id) ? index : -1))
.filter((index) => index >= 0);
const indices =
selected.length > 0 ? selected : MOCK_COMPARE_DATA.jobs.map((_, index) => index);
const data: CompareData = {
jobs: indices.map((index) => MOCK_COMPARE_DATA.jobs[index]),
dimensionScores: MOCK_COMPARE_DATA.dimensionScores.map((dim) => ({
dimension: dim.dimension,
// 「综合评分」必须等于该岗位自己的分数 —— 列表卡上印的就是它。
// 原先对比页另写一份,把产品经理记成 85、UI 设计师记成 78,与列表里的 78 / 85 正好互换。
scores: indices.map((index) =>
dim.dimension === COMPARE_SCORE_DIMENSION
? jobScoreById(MOCK_COMPARE_DATA.jobs[index].id)
: dim.scores[index],
),
})),
basicInfo: {
fields: MOCK_COMPARE_DATA.basicInfo.fields,
values: MOCK_COMPARE_DATA.basicInfo.values.map((row) => indices.map((index) => row[index])),
},
benefits: indices.map((index) => MOCK_COMPARE_DATA.benefits[index]),
};
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 对比 mock 数据(字节跳动 vs 腾讯科技) */
const MOCK_OFFER_COMPARE_DATA: OfferCompareData = {
offers: [
{
id: 'offer-1',
company: '字节跳动',
position: '高级前端',
salary: '25-40K',
status: 'pending',
selected: true,
color: colors.primary,
},
{
id: 'offer-2',
company: '腾讯科技',
position: '产品经理',
salary: '20-35K',
status: 'pending',
selected: true,
color: colors.compareRed,
},
],
scores: [
{ score: 92, level: '优秀', stars: 5 },
{ score: 85, level: '良好', stars: 4 },
],
benefitGroups: [
{
title: '薪资',
rows: [
{ label: '基本工资', values: ['25K', '22K'], highlights: [true, false] },
{ label: '年终奖', values: ['2-4个月', '3-6个月'] },
{ label: '股票期权', values: ['有(RSU)', '有(期权)'] },
{ label: '签字费', values: ['5万', '3万'], highlights: [true, false] },
{ label: '年度调薪', values: ['有', '有'] },
],
},
{
title: '福利',
rows: [
{ label: '五险一金', values: ['全额', '全额'] },
{ label: '补充医疗', values: ['有', '有'] },
{ label: '带薪年假', values: ['15天', '18天'], highlights: [false, true] },
{ label: '免费三餐', values: ['有', '有'] },
{ label: '健身房', values: ['有', '有'] },
{ label: '住房补贴', values: ['有(1500/月)', '无'], highlights: [true, false] },
],
},
{
title: '发展',
rows: [
{ label: '晋升通道', values: ['技术/管理双线', '技术为主'], highlights: [true, false] },
{ label: '培训体系', values: ['完善', '完善'] },
{ label: '转岗机会', values: ['多', '一般'], highlights: [true, false] },
],
},
],
workLifeBalance: [
{ label: '平均工时', values: ['9.5小时', '10.2小时'], highlights: [true, false] },
{ label: '出差频率', values: ['经常', '偶尔'] },
{ label: '周末加班', values: ['大小周', '双休'], highlights: [false, true] },
],
aiSuggestion: {
summary: '综合薪资、福利、发展空间等多维度评估,字节跳动的 Offer 综合评分更高。',
detail:
'但腾讯的工作生活平衡更好,建议根据个人职业规划选择。若追求短期收入与快速成长,字节更优;若看重工作节奏与长期稳定,腾讯更合适。',
},
actions: {
exportLabel: '导出对比报告',
acceptLabel: '接受字节Offer',
acceptOfferId: 'offer-1',
},
};
/**
* 获取 Offer 对比数据
* @method GET
* @url /api/offers/compare
* @param offerIds { string[] } 参与对比的 Offer id 数组
* @returns Promise<OfferCompareData> Offer 对比数据
*/
export async function fetchOfferCompareData(offerIds?: string[]): Promise<OfferCompareData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/offers/compare?offerIds=${offerIds?.join(',') ?? ''}`, { method: 'GET' }); return res.json();
if (!offerIds || offerIds.length === 0) {
return mockRequest(MOCK_OFFER_COMPARE_DATA);
}
const filteredOffers = MOCK_OFFER_COMPARE_DATA.offers.filter((o) => offerIds.includes(o.id));
const indices = MOCK_OFFER_COMPARE_DATA.offers
.map((o, index) => (offerIds.includes(o.id) ? index : -1))
.filter((index) => index >= 0);
const data: OfferCompareData = {
offers: filteredOffers.length > 0 ? filteredOffers : MOCK_OFFER_COMPARE_DATA.offers,
scores: indices.length > 0 ? indices.map((i) => MOCK_OFFER_COMPARE_DATA.scores[i]) : MOCK_OFFER_COMPARE_DATA.scores,
benefitGroups: MOCK_OFFER_COMPARE_DATA.benefitGroups.map((group) => ({
title: group.title,
rows: group.rows.map((row) => ({
label: row.label,
values: indices.length > 0 ? indices.map((i) => row.values[i]) : row.values,
highlights: row.highlights ? (indices.length > 0 ? indices.map((i) => row.highlights![i]) : row.highlights) : undefined,
})),
})),
workLifeBalance: MOCK_OFFER_COMPARE_DATA.workLifeBalance.map((row) => ({
label: row.label,
values: indices.length > 0 ? indices.map((i) => row.values[i]) : row.values,
highlights: row.highlights ? (indices.length > 0 ? indices.map((i) => row.highlights![i]) : row.highlights) : undefined,
})),
aiSuggestion: MOCK_OFFER_COMPARE_DATA.aiSuggestion,
actions: MOCK_OFFER_COMPARE_DATA.actions,
};
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 列表按状态算出来,页面只读结果 ——
* 原先聊天页把这句写成了固定的「2 个」,与用户实际收到的 Offer 无关。
*
* @method GET
* @url /api/offers/pending/count
* @returns Promise<number> 待处理(status === 'pending')的 Offer 数量
*/
export async function fetchPendingOfferCount(): Promise<number> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/offers/pending/count`, { method: 'GET' }); return res.json();
const pending = MOCK_OFFER_COMPARE_DATA.offers.filter((offer) => offer.status === 'pending');
return mockRequest(pending.length);
}
/* ===================== 评分权重设置 ===================== */
/** 默认权重维度(9 个,总和 100%) */
const MOCK_WEIGHT_DIMENSIONS: WeightDimension[] = [
{ id: 'salary', name: '薪资竞争力', weight: 20 },
{ id: 'growth', name: '发展空间', weight: 15 },
{ id: 'atmosphere', name: '工作氛围', weight: 12 },
{ id: 'benefit', name: '福利待遇', weight: 12 },
{ id: 'team', name: '团队水平', weight: 10 },
{ id: 'culture', name: '企业文化', weight: 10 },
{ id: 'intensity', name: '工作强度', weight: 8, isReversed: true },
{ id: 'location', name: '地理位置', weight: 8 },
{ id: 'authenticity', name: '岗位真实性', weight: 5 },
];
/** 预设权重模板(3 个,各维度权重按维度名称匹配) */
const MOCK_WEIGHT_TEMPLATES: WeightTemplate[] = [
{
id: 'salary-first',
name: '薪资优先',
description: '薪资30% · 发展15%',
weights: {
薪资竞争力: 30,
发展空间: 15,
工作氛围: 10,
福利待遇: 12,
团队水平: 10,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
},
},
{
id: 'growth-first',
name: '成长优先',
description: '发展30% · 团队15%',
weights: {
薪资竞争力: 12,
发展空间: 30,
工作氛围: 10,
福利待遇: 10,
团队水平: 15,
企业文化: 8,
工作强度: 5,
地理位置: 5,
岗位真实性: 5,
},
},
{
id: 'stable-first',
name: '稳定优先',
description: '福利25%',
weights: {
薪资竞争力: 12,
发展空间: 12,
工作氛围: 12,
福利待遇: 25,
团队水平: 8,
企业文化: 10,
工作强度: 6,
地理位置: 10,
岗位真实性: 5,
},
},
];
/**
* 获取评分权重设置(含维度 + 预设模板)
* @method GET
* @url /api/jobs/weight-settings
* @returns Promise<WeightSettings> 权重设置对象
*/
export async function fetchWeightSettings(): Promise<WeightSettings> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/weight-settings`, { method: 'GET' }); return res.json();
return mockRequest({
dimensions: MOCK_WEIGHT_DIMENSIONS.map((d) => ({ ...d })),
templates: MOCK_WEIGHT_TEMPLATES,
});
}
/**
* 保存评分权重设置(各维度权重总和应为 100%)
* @method POST
* @url /api/jobs/weight-settings
* @param dimensions { WeightDimension[] } 完整维度权重列表
* @returns Promise<WeightDimension[]> 保存后的维度列表
*/
export async function saveWeightSettings(
dimensions: WeightDimension[],
): Promise<WeightDimension[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/weight-settings`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ dimensions }),
// });
// return res.json();
return mockRequest(dimensions.map((d) => ({ ...d })));
}
/* ===================== 同岗位竞争力排名 ===================== */
/**
* 获取同岗位竞争力排名数据(综合排名 + 六维度百分位 + TOP10 候选人 + 提升建议)
* @method GET
* @url /api/jobs/rank
* @param jobId { string } 岗位 id
* @returns Promise<RankOverview> 排名页完整数据(含数据态与空状态)
*/
export async function fetchRankData(jobId: string): Promise<RankOverview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/jobs/rank?jobId=${jobId}`, { method: 'GET' }); return res.json();
// 当前为 mock 阶段,jobId 仅用于标注真实接口入参,避免未使用参数告警
void jobId;
return mockRequest({
hasData: true,
rankData: {
rank: 127,
jobTitle: '高级前端工程师',
company: '字节跳动',
totalCandidates: 1286,
percentile: 90.1,
overallScore: 88,
scoreLevel: '优秀',
rankPosition: '前 9.9%',
},
dimensions: [
{ id: 'skill', name: '专业技能', percentile: '前 15%', score: 92 },
{ id: 'experience', name: '工作经历', percentile: '前 22%', score: 86 },
{ id: 'education', name: '教育背景', percentile: '前 30%', score: 80 },
{ id: 'project', name: '项目经验', percentile: '前 12%', score: 94 },
{ id: 'match', name: '技能匹配', percentile: '前 18%', score: 89 },
{ id: 'completeness', name: '简历完整度', percentile: '前 25%', score: 84 },
],
candidates: [
{ id: 'c1', name: '候选人 A', score: 96, tags: ['清华硕士', '5 年大厂'] },
{ id: 'c2', name: '候选人 B', score: 93, tags: ['浙大本科', '3 年独角兽'] },
{ id: 'c3', name: '候选人 C', score: 91, tags: ['海归硕士', '4 年外企'] },
],
tips: [
{
id: 't1',
title: '补充项目经历可提升 5-8 个百分位',
description: '完善你的项目经历描述,突出技术选型与业务成果',
type: 'action',
route: '/resume/project',
},
{
id: 't2',
title: '添加技能标签提升匹配度',
description: '补充 TypeScript、性能优化等岗位高频技能关键词',
type: 'tip',
},
{
id: 't3',
title: '你的教育背景处于中位区间',
description: '可通过突出工作经历与项目成果弥补学历竞争力',
type: 'tip',
},
],
emptyState: {
title: '暂无排名数据',
subtitle: '完善简历和测评信息后,系统将为你计算竞争力排名',
actionText: '去完善简历',
resumeProgress: [
{ id: 'basic', label: '基本信息', completed: true },
{ id: 'education', label: '教育经历', completed: true },
{ id: 'work', label: '工作经历', completed: false, tip: '添加工作经历可提升 20% 排名准确度' },
{ id: 'project', label: '项目经历', completed: false },
{ id: 'skill', label: '技能标签', completed: false },
],
},
});
}
/**
* 把竞争力排名拼成可导出的纯文本(导出 / 分享用,纯函数)。
* @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 个具体分类) */
const MOCK_SKILL_CATEGORIES: SkillCategory[] = [
{ id: 'all', label: '全部' },
{ id: 'frontend', label: '前端' },
{ id: 'backend', label: '后端' },
{ id: 'devops', label: '工程工具' },
{ id: 'database', label: '数据库' },
{ id: 'design', label: '设计' },
];
/** 词云筛选可选项 */
const MOCK_SKILL_FILTERS: SkillCloudFilter = {
industries: ['互联网/IT', '金融', '教育', '医疗', '制造', '房地产'],
positions: ['高级前端工程师', '前端开发工程师', '全栈工程师', 'Java开发工程师'],
cities: ['全国', '北京', '上海', '深圳', '杭州'],
experiences: ['应届生', '1-3年', '3-5年', '5-10年'],
};
/** 各行业技能词(权重 0-100) */
const MOCK_SKILL_WORDS: Record<string, SkillWord[]> = {
'互联网/IT': [
{ id: 's1', text: 'React', weight: 96, group: 'tech' },
{ id: 's2', text: 'TypeScript', weight: 92, group: 'tech' },
{ id: 's3', text: 'Node.js', weight: 85, group: 'tech' },
{ id: 's4', text: 'Vue', weight: 78, group: 'tech' },
{ id: 's5', text: 'Webpack', weight: 64, group: 'tool' },
{ id: 's6', text: '数据分析', weight: 88, group: 'domain' },
{ id: 's7', text: '用户体验', weight: 72, group: 'soft' },
{ id: 's8', text: '需求拆解', weight: 70, group: 'soft' },
{ id: 's9', text: 'Git', weight: 90, group: 'tool' },
{ id: 's10', text: '性能优化', weight: 75, group: 'domain' },
{ id: 's11', text: '云服务', weight: 68, group: 'tech' },
{ id: 's12', text: '敏捷协作', weight: 66, group: 'soft' },
{ id: 's13', text: '前端工程化', weight: 82, group: 'tech' },
{ id: 's14', text: '跨端开发', weight: 73, group: 'tech' },
],
'金融': [
{ id: 'f1', text: '财务建模', weight: 94, group: 'domain' },
{ id: 'f2', text: 'Python', weight: 88, group: 'tech' },
{ id: 'f3', text: '风险管理', weight: 86, group: 'domain' },
{ id: 'f4', text: 'Excel', weight: 90, group: 'tool' },
{ id: 'f5', text: '行业研究', weight: 80, group: 'soft' },
{ id: 'f6', text: '估值', weight: 76, group: 'domain' },
{ id: 'f7', text: '沟通表达', weight: 72, group: 'soft' },
],
'教育': [
{ id: 'e1', text: '课程设计', weight: 90, group: 'domain' },
{ id: 'e2', text: '演讲表达', weight: 84, group: 'soft' },
{ id: 'e3', text: '社群运营', weight: 78, group: 'soft' },
{ id: 'e4', text: 'Office', weight: 86, group: 'tool' },
{ id: 'e5', text: '用户洞察', weight: 74, group: 'domain' },
],
};
/** 热门技能 TOP 10 */
const MOCK_TOP_SKILLS: TopSkill[] = [
{ id: 't1', name: 'React', rank: 1, change: 12, demand: 96 },
{ id: 't2', name: 'TypeScript', rank: 2, change: 18, demand: 92 },
{ id: 't3', name: 'Node.js', rank: 3, change: 8, demand: 85 },
{ id: 't4', name: '前端工程化', rank: 4, change: 15, demand: 82 },
{ id: 't5', name: 'Vue', rank: 5, change: -3, demand: 78 },
{ id: 't6', name: '性能优化', rank: 6, change: 6, demand: 75 },
{ id: 't7', name: '跨端开发', rank: 7, change: 22, demand: 73 },
{ id: 't8', name: 'Webpack', rank: 8, change: -5, demand: 64 },
{ id: 't9', name: '云服务', rank: 9, change: 10, demand: 68 },
{ id: 't10', name: '敏捷协作', rank: 10, change: 4, demand: 66 },
];
/** 默认行业(词云列表无匹配时兜底) */
const DEFAULT_SKILL_INDUSTRY = '互联网/IT';
/**
* 获取行业技能词云
* @method GET
* @url /api/tools/skill-cloud
* @param industry { string } 行业名称
* @returns Promise<SkillCloudData> 技能词云数据
*/
export async function fetchSkillCloud(industry: string): Promise<SkillCloudData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/skill-cloud?industry=${encodeURIComponent(industry)}`, { method: 'GET' }); return res.json();
const words = MOCK_SKILL_WORDS[industry] ?? MOCK_SKILL_WORDS[DEFAULT_SKILL_INDUSTRY];
const resolvedIndustry = MOCK_SKILL_WORDS[industry] ? industry : DEFAULT_SKILL_INDUSTRY;
return mockRequest({
industry: resolvedIndustry,
position: '高级前端工程师',
city: '全国',
experience: '3-5年',
activeCategoryId: 'all',
filters: MOCK_SKILL_FILTERS,
categories: MOCK_SKILL_CATEGORIES,
words,
topSkills: MOCK_TOP_SKILLS,
matchPercent: 80,
updatedAt: '数据更新于 2026-08',
});
}
/**
* 按关键词过滤技能词(页面搜索框用,纯函数)。
* 空关键词返回全部,避免页面里再写一遍过滤逻辑。
* @param words 技能词数组
* @param keyword 关键词(大小写不敏感,首尾空格忽略)
* @returns SkillWord[] 命中的技能词
*/
export function filterSkillWords(words: SkillWord[], keyword: string): SkillWord[] {
const kw = keyword.trim().toLowerCase();
if (kw === '') return words;
return words.filter((w) => w.text.toLowerCase().includes(kw));
}
/** 技能分组 → 中文名(学习路径的推荐理由里要用) */
const SKILL_GROUP_LABEL: Record<SkillWord['group'], string> = {
tech: '技术技能',
soft: '软技能',
domain: '领域能力',
tool: '工具',
};
/**
* 生成技能学习路径
* 业务规则(接口层):以该行业技能词的权重为优先级,从高到低挑出待补技能;
* 待补数量按「匹配度缺口」换算,限制在 3-6 条之间,避免一次甩出一长串。
* @method GET
* @url /api/tools/skill-learning-path
* @param industry { string } 行业名称
* @returns Promise<SkillLearningPath> 学习路径
*/
export async function fetchSkillLearningPath(industry: string): Promise<SkillLearningPath> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/skill-learning-path?industry=${encodeURIComponent(industry)}`, { method: 'GET' }); return res.json();
const words = MOCK_SKILL_WORDS[industry] ?? MOCK_SKILL_WORDS[DEFAULT_SKILL_INDUSTRY];
const resolvedIndustry = MOCK_SKILL_WORDS[industry] ? industry : DEFAULT_SKILL_INDUSTRY;
const matchPercent = 80;
// 缺口越大补得越多,但先补权重最高的那几个(3 条起步、6 条封顶)
const gap = Math.max(0, 100 - matchPercent);
const wanted = Math.min(6, Math.max(3, Math.round(gap / 10)));
const items: SkillLearningItem[] = [...words]
.sort((a, b) => b.weight - a.weight)
.slice(0, wanted)
.map((w, i) => ({
id: w.id,
name: w.text,
group: w.group,
weight: w.weight,
priority: i + 1,
reason: `行业热度权重 ${w.weight},${SKILL_GROUP_LABEL[w.group]}类里排在前列的硬要求`,
}));
// 补齐这些技能后的目标匹配度:按待补技能的权重占比折算,最多到 95
const avgWeight = items.length
? items.reduce((sum, it) => sum + it.weight, 0) / items.length
: 0;
const targetPercent = Math.min(95, Math.round(matchPercent + (gap * avgWeight) / 100));
return mockRequest({
industry: resolvedIndustry,
position: '高级前端工程师',
matchPercent,
targetPercent,
items,
generatedAt: '刚刚生成',
});
}
/* ===================== 薪资计算器 ===================== */
/** 各行业平均月薪参考(计算器对比用) */
const MOCK_SALARY_BRACKETS: SalaryBracket[] = [
{ industry: '互联网/IT', avgMonthly: 28, sampleCount: 2846 },
{ industry: '金融', avgMonthly: 35, sampleCount: 1320 },
{ industry: '教育', avgMonthly: 14, sampleCount: 980 },
{ industry: '医疗健康', avgMonthly: 18, sampleCount: 760 },
{ industry: '制造业', avgMonthly: 22, sampleCount: 1540 },
];
/** 薪资计算页「更多工具」(均为项目内真实页面,route 与 app/ 下路由一一对应) */
const MOCK_SALARY_TOOL_LINKS: SalaryToolLink[] = [
{
id: 'offer-compare',
title: 'Offer 对比',
desc: '把几个 offer 的薪资、福利放一起比',
icon: 'git-compare-outline',
route: '/jobs/offer-compare',
},
{
id: 'salary-rank',
title: '竞争力排名',
desc: '看看同岗位里你的薪资处于什么位置',
icon: 'podium-outline',
route: '/jobs/rank',
},
{
id: 'skill-cloud',
title: '行业技能词云',
desc: '目标行业的高频技能与要求权重',
icon: 'cloud-outline',
route: '/tools/skill-cloud',
},
];
/**
* 薪资计算页「更多工具」入口。
* 只放项目里真实存在的页面(原先页面内写死了「年终奖个税计算 / 离职补偿金计算 /
* 公积金社保证明」三条,路由全部指回薪资计算器自身,属于点不动的假入口,已摘除)。
* @method GET
* @url /api/tools/salary-related
* @returns Promise<SalaryToolLink[]> 相关工具入口数组
*/
export async function fetchSalaryToolLinks(): Promise<SalaryToolLink[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/salary-related`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_SALARY_TOOL_LINKS);
}
/**
* 获取各行业平均月薪参考
* @method GET
* @url /api/tools/salary-brackets
* @returns Promise<SalaryBracket[]> 行业薪资参考数组
*/
export async function fetchSalaryBrackets(): Promise<SalaryBracket[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/salary-brackets`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_SALARY_BRACKETS);
}
/**
* 把社保基数 clamp 到**指定城市**的上下限内(业务规则在本层)。
*
* `city` 是显式参数而不是从输入对象里读 —— 这条正是原先三份副本各写一遍的根因:
* 页面切城市时要按**新城市**的上下限 clamp,而读 `inputs.city` 的 helper 只会拿到**旧城市**,
* 于是页面只好把同一段 `Math.min/Math.max` 内联重写一遍。城市成为参数后,
* 「按哪个城市的上下限」由调用方说清楚,特殊情况不再需要特殊写法。
*
* 养老/医疗/失业的比例全国一致,城市差异只体现在基数上下限;无该城市档案时用默认档案。
*
* @param city 城市名(与 `SOCIAL_SECURITY_CITY_PROFILES` 的键一致)
* @param value 待 clamp 的社保基数(元)
* @returns 落在 [lower, upper] 内的基数
*/
export function clampSocialBase(city: string, value: number): number {
const profile = SOCIAL_SECURITY_CITY_PROFILES[city] ?? DEFAULT_SOCIAL_PROFILE;
return Math.min(profile.socialBaseUpper, Math.max(profile.socialBaseLower, value));
}
/**
* 薪资计算(业务规则:五险一金 + 个税估算,接口层纯函数)。
* 计算器为实时交互,故同步计算,避免 300ms mock 延迟影响输入体验。
* @param input 薪资计算输入(元/百分比)
* @returns SalaryCalcResult 计算结果(元)
*/
export function computeSalaryResult(input: SalaryCalcInput): SalaryCalcResult {
const monthlyGross = input.monthlyGross;
// 年终奖摊入月度,得到月度税前合计
const monthlyTotal = monthlyGross * (1 + input.bonusMonths / 12);
// 按所在城市 clamp 社保基数到该城市上下限(规则见 clampSocialBase)
const socialBase = clampSocialBase(input.city, input.socialBase);
// 社保个人缴纳比例:养老 8% + 医疗 2% + 失业 0.5%
const pension = socialBase * 0.08;
const medical = socialBase * 0.02;
const unemployment = socialBase * 0.005;
const providentFund = socialBase * input.providentFundRate;
const monthlySocial = pension + medical + unemployment + providentFund;
// 个税:月度应纳税所得额 = 税前 - 五险一金 - 5000 起征点 - 专项附加扣除
const taxable = Math.max(
0,
monthlyTotal - monthlySocial - 5000 - input.specialDeduction,
);
const monthlyTax = calcMonthlyTax(taxable);
const monthlyNet = Math.max(0, monthlyTotal - monthlySocial - monthlyTax);
const annualGross = monthlyGross * 12 + monthlyGross * input.bonusMonths;
const annualNet = monthlyNet * 12;
const totalYearlyGross = annualGross;
const totalYearlyNet = annualNet;
const totalYearlySocial = monthlySocial * 12;
const totalYearlyTax = monthlyTax * 12;
const insuranceItems: SalaryInsuranceItem[] = [
{ label: '养老保险', rate: 0.08, amount: Math.round(pension) },
{ label: '医疗保险', rate: 0.02, amount: Math.round(medical) },
{ label: '失业保险', rate: 0.005, amount: Math.round(unemployment) },
{ label: '住房公积金', rate: input.providentFundRate, amount: Math.round(providentFund) },
];
const taxItems: SalaryTaxItem[] = [
{ label: '应纳税所得额', amount: Math.round(taxable) },
{ label: '适用税率', amount: monthlyTax > 0 ? getTaxRate(taxable) : 0, rate: monthlyTax > 0 ? getTaxRate(taxable) : 0 },
{ label: '月度个税', amount: Math.round(monthlyTax) },
{ label: '年度个税', amount: Math.round(monthlyTax * 12) },
];
const structure: SalaryStructureItem[] = [
{ label: '税前工资', value: Math.round(totalYearlyGross), percent: 100, color: 'primary' },
{ label: '五险一金', value: Math.round(totalYearlySocial), percent: Math.round((totalYearlySocial / totalYearlyGross) * 100), color: 'info' },
{ label: '个人所得税', value: Math.round(totalYearlyTax), percent: Math.round((totalYearlyTax / totalYearlyGross) * 100), color: 'action' },
{ label: '实际到手', value: Math.round(totalYearlyNet), percent: Math.round((totalYearlyNet / totalYearlyGross) * 100), color: 'success' },
];
return {
input,
monthlyGross,
bonusMonths: input.bonusMonths,
monthlyTotal: Math.round(monthlyTotal),
monthlySocial: Math.round(monthlySocial),
monthlyTax: Math.round(monthlyTax),
monthlyNet: Math.round(monthlyNet),
annualGross: Math.round(annualGross),
annualNet: Math.round(annualNet),
insuranceItems,
taxItems,
structure,
};
}
/* ===================== 薪资计算记录(本地持久化) =====================
* mock 阶段以 storage(qingsongpin: 前缀)为唯一真相源读写,不另建模块级内存缓存;
* 真实后端就绪后把三个函数体替换为 fetch 即可,签名不变。
*/
const SALARY_RECORD_KEY = 'salary:records';
/** 读取全部薪资计算记录(原始顺序) */
async function readSalaryRecords(): Promise<SalaryCalcRecord[]> {
const raw = await storageGet(SALARY_RECORD_KEY);
return raw ? (JSON.parse(raw) as SalaryCalcRecord[]) : [];
}
/**
* 获取薪资计算保存记录(按创建时间倒序)
* @method GET
* @url /api/salary/records
* @returns Promise<SalaryCalcRecord[]> 已保存的计算记录列表
*/
export async function fetchSalaryRecords(): Promise<SalaryCalcRecord[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/salary/records`, { method: 'GET' }); return res.json();
const all = await readSalaryRecords();
return all.sort((a, b) => b.createdAt - a.createdAt);
}
/**
* 保存一笔薪资计算记录
* @method POST
* @url /api/salary/records
* @param input { SalaryCalcInput } 计算输入参数(完整快照)
* @param result { SalaryCalcResult } 计算结果快照
* @param note? { string } 用户备注(可选,用于区分不同场景)
* @returns Promise<SalaryCalcRecord> 新建的记录
*/
export async function saveSalaryRecord(
input: SalaryCalcInput,
result: SalaryCalcResult,
note?: string,
): Promise<SalaryCalcRecord> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/salary/records`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ input, result, note }),
// }); return res.json();
const record: SalaryCalcRecord = {
id: `salary-${Date.now()}`,
input,
result,
note: note?.trim() ? note.trim() : undefined,
createdAt: Date.now(),
};
const all = await readSalaryRecords();
all.unshift(record);
await storageSet(SALARY_RECORD_KEY, JSON.stringify(all));
return mockRequest(record);
}
/**
* 删除一笔薪资计算记录
* @method DELETE
* @url /api/salary/records/:id
* @param id { string } 记录 id
* @returns Promise<void>
*/
export async function deleteSalaryRecord(id: string): Promise<void> {
// TODO: await fetch(`${API_BASE_URL}/api/salary/records/${id}`, { method: 'DELETE' });
const all = await readSalaryRecords();
const next = all.filter((r) => r.id !== id);
await storageSet(SALARY_RECORD_KEY, JSON.stringify(next));
}
/** 月度超额累进个税(单位元) */
function calcMonthlyTax(t: number): number {
if (t <= 0) return 0;
if (t <= 3000) return t * 0.03;
if (t <= 12000) return t * 0.1 - 210;
if (t <= 25000) return t * 0.2 - 1410;
if (t <= 35000) return t * 0.25 - 2660;
if (t <= 55000) return t * 0.3 - 4410;
if (t <= 80000) return t * 0.35 - 7160;
return t * 0.45 - 15160;
}
/** 获取当前应纳税所得额适用税率 */
function getTaxRate(t: number): number {
if (t <= 0) return 0;
if (t <= 3000) return 0.03;
if (t <= 12000) return 0.1;
if (t <= 25000) return 0.2;
if (t <= 35000) return 0.25;
if (t <= 55000) return 0.3;
if (t <= 80000) return 0.35;
return 0.45;
}
/* ===================== 求职进度 ===================== */
/**
* 求职进度的三个主计数。
*
* 抽出来是因为同一个数原先在**一个对象里写了三遍**:`deliveryCount`、`stats.total`、
* 以及时间轴文案里的「向 28 家企业投递简历」。三份各自可改,就出现了 6+2+2+20 = 30 ≠ 28
* 这种「同一张卡上自己和自己对不上」。现在 `stats` 与时间轴都从这两个常量派生,
* 求和关系是**结构上的**,不靠谁记得回来对齐。
*/
const PROGRESS_DELIVERY_COUNT = 28;
const PROGRESS_INTERVIEW_COUNT = 6;
const PROGRESS_OFFER_COUNT = 2;
const PROGRESS_PENDING_COUNT = 6;
const PROGRESS_INTERVIEWING_COUNT = 2;
/** 求职进度(完整版:统计 / Offer / 公司流程 / 月度统计) */
const MOCK_JOB_PROGRESS: JobProgress = {
deliveryCount: PROGRESS_DELIVERY_COUNT,
interviewCount: PROGRESS_INTERVIEW_COUNT,
offerCount: PROGRESS_OFFER_COUNT,
// ended 由其余四项推出,统计卡五个数字并排一行、用户心算即得,必须加起来等于 total
stats: {
total: PROGRESS_DELIVERY_COUNT,
pending: PROGRESS_PENDING_COUNT,
interviewing: PROGRESS_INTERVIEWING_COUNT,
offer: PROGRESS_OFFER_COUNT,
ended:
PROGRESS_DELIVERY_COUNT -
PROGRESS_PENDING_COUNT -
PROGRESS_INTERVIEWING_COUNT -
PROGRESS_OFFER_COUNT,
},
offers: [
{
id: 'offer-1',
companyName: '字节跳动',
positionName: '高级前端工程师',
salary: '25-40K·15薪',
deadline: '8月31日截止',
daysLeft: 3,
},
{
id: 'offer-2',
companyName: '腾讯科技',
positionName: '产品经理',
salary: '20-35K·14薪',
deadline: '9月2日截止',
daysLeft: 5,
},
],
companies: [
{
id: 'c1',
companyName: '字节跳动',
positionName: '高级前端工程师',
salary: '25-40K',
stage: '面试中',
stageLabel: '终面',
nextAction: '明天 14:00 · 视频面试',
date: '08-28',
},
{
id: 'c2',
companyName: '腾讯科技',
positionName: '产品经理',
salary: '20-35K',
stage: 'Offer',
stageLabel: 'Offer 沟通中',
nextAction: 'HR 等待你的确认',
date: '08-27',
},
{
id: 'c3',
companyName: '阿里巴巴',
positionName: '前端开发工程师',
salary: '18-30K',
stage: '待反馈',
stageLabel: '简历筛选中',
nextAction: '预计 3 天内反馈',
date: '08-26',
},
{
id: 'c4',
companyName: '美团',
positionName: 'Java 开发工程师',
salary: '20-30K',
stage: '已结束',
stageLabel: '流程已结束',
nextAction: '岗位已关闭',
date: '08-20',
},
],
// 图表三列各自的和,都必须等于页顶的 deliveryCount / interviewCount / offerCount。
// 上一轮只对齐了 offers 一列:deliveries 合计 52、interviews 合计 16,而卡片上写的是 28 / 6。
// 图表只显示最近三个月(6/7/8 月),光这三个月投递就是 34,已超过「累计 28」。
// 两个 Offer 的截止日是 8/31 与 9/2,故都落在 8 月。
monthlyStats: [
{ month: '3月', deliveries: 2, interviews: 0, offers: 0 },
{ month: '4月', deliveries: 3, interviews: 1, offers: 0 },
{ month: '5月', deliveries: 4, interviews: 1, offers: 0 },
{ month: '6月', deliveries: 5, interviews: 1, offers: 0 },
{ month: '7月', deliveries: 6, interviews: 1, offers: 0 },
{ month: '8月', deliveries: 8, interviews: 2, offers: 2 },
],
steps: [
{
id: 'p1',
title: '已投递',
desc: `向 ${PROGRESS_DELIVERY_COUNT} 家企业投递简历`,
status: 'done',
date: '2026-08-01',
},
{ id: 'p2', title: '简历被查看', desc: '12 家企业查看了你的简历', status: 'done', date: '2026-08-05' },
{
id: 'p3',
title: '面试邀约',
desc: `收到 ${PROGRESS_INTERVIEW_COUNT} 场面试邀请`,
status: 'done',
date: '2026-08-12',
},
{ id: 'p4', title: '面试进行中', desc: '字节跳动终面 / 腾讯二面待完成', status: 'active' },
{
id: 'p5',
title: 'Offer 阶段',
desc: `已收获 ${PROGRESS_OFFER_COUNT} 个 Offer,待决策`,
status: 'todo',
},
{ id: 'p6', title: '入职准备', desc: '背调 / 合同签订 / 入职清单', status: 'todo' },
],
};
/**
* 获取求职进度时间轴
* @method GET
* @url /api/tools/job-progress
* @returns Promise<JobProgress> 求职进度数据
*/
export async function fetchJobProgress(): Promise<JobProgress> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/job-progress`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_JOB_PROGRESS);
}
/* ===================== 简历 ===================== */
/** 简历 mock 数据 */
const MOCK_RESUME: Resume = {
score: {
score: 72,
maxScore: 100,
optimizePoints: [
{ id: 'opt-1', title: '项目经历描述可量化', score: 8, optimized: false },
{ id: 'opt-2', title: '缺少技能关键词', score: 5, optimized: false },
{ id: 'opt-3', title: '自我评价太空泛', score: 3, optimized: false },
{ id: 'opt-4', title: '工作经历缺少成果数据', score: 4, optimized: false },
{ id: 'opt-5', title: '求职意向可更明确', score: 2, optimized: false },
],
},
basicInfo: {
name: '李明',
avatar: '李',
phone: '138-8888-8888',
email: 'liming@email.com',
location: '北京 · 朝阳区',
},
jobIntention: {
position: '高级前端工程师',
salary: '25-35K',
city: '北京',
status: 'active',
statusLabel: '积极求职中',
},
education: [
{
id: 'edu-1',
school: '北京大学',
major: '计算机科学与技术',
degree: '本科',
startYear: '2018',
endYear: '2022',
},
],
experience: [
{
id: 'exp-1',
company: '字节跳动',
position: '前端开发工程师',
startDate: '2022.07',
endDate: '至今',
description: '负责公司核心产品的前端开发工作,参与了多个项目的开发。',
aiOptimized: false,
},
],
projects: [
{
id: 'proj-1',
name: 'XX电商平台重构',
role: '前端负责人',
description: '使用React重构了整个电商平台,将首屏加载时间从3.5s优化至1.2s,页面性能提升65%,日活用户增长40%。',
aiOptimized: true,
},
],
skills: [
{ name: 'Vue' },
{ name: 'React' },
{ name: 'TypeScript' },
{ name: 'Webpack' },
{ name: 'CSS3' },
{ name: 'Git' },
],
selfEvaluation: {
content: '本人性格开朗,工作认真负责,具有良好的团队合作精神。熟悉前端开发流程,有较强的学习能力和问题解决能力。',
},
};
/** AI 优化后的简历 */
const MOCK_OPTIMIZED_RESUME: Resume = {
...MOCK_RESUME,
score: {
score: 92,
maxScore: 100,
optimizePoints: MOCK_RESUME.score.optimizePoints.map((p) => ({ ...p, optimized: true })),
},
experience: [
{
...MOCK_RESUME.experience[0],
description:
'负责公司核心产品的前端架构设计与开发,主导 React 18 + TypeScript 技术栈升级;通过代码拆分、懒加载与性能监控,将首屏加载时间从 3.5s 优化至 1.2s,核心页面性能提升 65%;推动组件库建设,沉淀 30+ 可复用业务组件。',
aiOptimized: true,
},
],
selfEvaluation: {
content:
'3 年前端开发经验,具备完整的大型 Web 项目架构与性能优化能力。擅长 React / TypeScript 技术栈,对工程化、组件化、性能监控有深入实践,能够独立推动技术方案落地并持续提升团队开发效率。',
},
};
/**
* 默认导出设置。
* `fileName` 默认为空串:原默认值「李明_前端工程师简历」是演示人设,
* 会让每个用户的导出文件都顶着别人的名字,真实姓名由 `resolveExportFileName` 兜底。
*/
const DEFAULT_EXPORT_SETTINGS: ResumeExportSettings = {
format: 'pdf',
templateId: 'classic',
hideContact: false,
exportPassword: false,
paperSize: 'A4',
orientation: 'portrait',
fileName: '',
includeCover: false,
includeRecommendation: false,
};
/**
* 导出文件名:用户填了就用,没填按「姓名_期望职位简历」兜底,姓名职位都没有才用「我的简历」。
* @param resume 简历(取姓名与期望职位)
* @param fileName 用户在导出设置里填的文件名
* @returns 实际落盘的文件名(不含扩展名)
* @remark 放在 API 层:它是产品定的命名规则,导出与分享两条路必须用同一个名字,
* 页面各自拼一遍必然分叉(曾把默认值写成演示人设「李明_前端工程师简历」)
*/
export function resolveExportFileName(
resume: Pick<Resume, 'basicInfo' | 'jobIntention'>,
fileName: string,
): string {
const custom = fileName.trim();
if (custom !== '') return custom;
const name = resume.basicInfo.name.trim();
const position = resume.jobIntention.position.trim();
if (name !== '' && position !== '') return `${name}_${position}简历`;
if (name !== '') return `${name}的简历`;
if (position !== '') return `${position}简历`;
return '我的简历';
}
/* ===================== 简历:用户编辑覆盖层 =====================
* 简历的默认内容由引导资料派生(`buildResumeFromProfile`),用户在编辑页里改过的板块存在
* `resume:override`,优先级高于派生值 —— 与求职偏好 `user:job-preference` 同一套
* 「显式编辑 > 引导推导」语义。真实后端就绪后把这组函数体换成 fetch 即可(签名不变)。
*/
/** 简历覆盖层存储键(与引导资料分键存放,避免互相覆盖) */
const RESUME_OVERRIDE_KEY = 'resume:override';
/** 保存时间存储键:头部「保存」写入,页面显示「上次保存 …」 */
const RESUME_SAVED_AT_KEY = 'resume:saved-at';
/** 导出设置存储键 */
const RESUME_EXPORT_SETTINGS_KEY = 'resume:export-settings';
/** 读取覆盖层;未编辑过或存储内容损坏 → 空对象(损坏时按「未编辑过」处理,不抛错) */
async function readResumeOverride(): Promise<ResumeOverride> {
const raw = await storageGet(RESUME_OVERRIDE_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as ResumeOverride;
} catch {
return {};
}
}
/** 合并写入:只覆盖 patch 里出现的板块,其它板块此前的编辑保留 */
async function mergeResumeOverride(patch: ResumeOverride): Promise<void> {
const current = await readResumeOverride();
await storageSet(RESUME_OVERRIDE_KEY, JSON.stringify({ ...current, ...patch }));
}
/** 未套覆盖层的简历:引导资料派生;完全没走过注册流程(无资料)时回落演示简历 */
async function buildDerivedResume(): Promise<Resume> {
const profile = await fetchOnboardingProfile();
if (!profile) return MOCK_RESUME;
return buildResumeFromProfile(profile, await resolveJobPreference());
}
/**
* 把覆盖层叠到派生简历上:板块级覆盖 —— 键出现就以覆盖层为准。
*
* 必须用 `??` 而不是 `||`:空数组、空串都是用户的合法编辑结果
* (用户删光了教育经历,不该再冒出引导资料里的那一条)。
*/
function applyResumeOverride(derived: Resume, override: ResumeOverride): Resume {
return {
...derived,
basicInfo: override.basicInfo ?? derived.basicInfo,
education: override.education ?? derived.education,
experience: override.experience ?? derived.experience,
projects: override.projects ?? derived.projects,
skills: override.skills ?? derived.skills,
selfEvaluation: override.selfEvaluation ?? derived.selfEvaluation,
};
}
/** 简历条目 id:板块前缀 + 时间戳 + 随机后缀(同一毫秒内连续新增两条不会撞 id) */
function createResumeItemId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 给新增条目补 id,并按 id 去重(页面拼接出错时也不会产生重复的列表 key) */
function withResumeItemIds<T extends { id: string }>(
items: ResumeItemInput<T>[],
prefix: string,
): T[] {
const seen = new Map<string, T>();
for (const item of items) {
const id = item.id ?? createResumeItemId(prefix);
seen.set(id, { ...item, id } as T);
}
return [...seen.values()];
}
/**
* 引导资料 → 简历。
*
* 只映射引导**真正采集到**的字段,未采集的一律留空,不再拿 `MOCK_RESUME` 的
* 「李明 / 北京大学 / 字节跳动」顶上 —— 那会让用户在自己的简历里看到别人的
* 学历与工作经历。页面按空值隐藏对应行(见 `app/resume.tsx`)。
*
* @param preference 有效求职偏好(`resolveJobPreference`):「求职意向」板块与
* 「我的」页偏好卡片读同一份,用户在求职意向编辑页改过也一致
*/
function buildResumeFromProfile(profile: OnboardingProfile, preference: JobPreference): Resume {
const { name, avatar } = displayNameAndAvatar(profile.resumeName);
// 采集项:毕业学校(P20)、学历 + 学习形式(P18)。
// 专业、入学/毕业年份引导未采集 → 留空,不套用 mock 的「计算机科学与技术 2018-2022」
const education: ResumeEducation[] = profile.school
? [
{
id: 'edu-onboarding',
school: profile.school,
major: '',
degree: [profile.education, profile.eduType].filter(Boolean).join(' '),
startYear: '',
endYear: '',
},
]
: [];
// 采集项:当前公司(P12)、当前职位(P11)、入职年月(P13)、工作内容(P16)。
// 在职才写「至今」;离职时结束时间引导未采集 → 留空,而不是编一个离职时间
const experience: ResumeExperience[] = profile.company
? [
{
id: 'exp-onboarding',
company: profile.company,
position: profile.currentPosition ?? '',
startDate: `${profile.joinYear}${profile.joinMonth}`,
endDate: profile.jobStatus?.startsWith(JOB_STATUS_EMPLOYED_PREFIX) ? '至今' : '',
description: profile.workContent,
aiOptimized: false,
},
]
: [];
return {
// 简历评分与优化点是产品的 AI 能力,mock 阶段恒返回同一份(真实后端就绪后由后端打分)
score: MOCK_RESUME.score,
basicInfo: {
name,
avatar,
// 手机号 / 邮箱引导未采集(登录手机号属瞬时字段,按 PIPL 不落库)
phone: '',
email: '',
// 引导采集的是 P4「期望城市」,不是「现居城市」,不能拿它冒充所在城市
location: '',
},
jobIntention: {
position: preference.position,
salary: preference.salary,
city: preference.city,
status: preference.status === JOB_STATUS_INACTIVE ? 'inactive' : 'active',
statusLabel: preference.status,
},
education,
experience,
// 项目经历引导未采集
projects: [],
skills: profile.skills.map((skillName) => ({ name: skillName })),
// 自我评价引导未采集
selfEvaluation: { content: '' },
};
}
/**
* 获取我的简历
* @method GET
* @url /api/resume
* @returns Promise<Resume> 简历数据
* @remark 内容 = 引导采集资料派生(`qingsongpin:onboarding:profile` + 有效求职偏好)叠加用户编辑
* 覆盖层(`qingsongpin:resume:override`):某板块被编辑过就以覆盖层为准,未编辑过用派生值。
* 只有完全没走过注册流程(无资料)时才回落到演示简历
*/
export async function fetchResume(): Promise<Resume> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume`, { method: 'GET' }); return res.json();
const derived = await buildDerivedResume();
return mockRequest(applyResumeOverride(derived, await readResumeOverride()));
}
/**
* AI 一键优化简历
* @method POST
* @url /api/resume/optimize
* @returns Promise<ResumeOptimizeResult> 优化后的简历
* @remark 优化结果**在用户自己的简历上套用 mock 的评分块**:
* 返回 `MOCK_OPTIMIZED_RESUME` 会把整份简历换成演示人设「李明」——
* 用户点一下「优化简历」,姓名、学历、公司全变成别人的。
* AI 改写的正文(经历描述 / 自我评价)mock 阶段生成不了,因此**不替换**用户
* 真实填写的内容,只更新评分与优化点状态;真实后端就绪后由后端返回改写结果。
*/
export async function optimizeResume(): Promise<ResumeOptimizeResult> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/optimize`, { method: 'POST' }); return res.json();
const profile = await fetchOnboardingProfile();
if (!profile) {
return mockRequest({ score: MOCK_OPTIMIZED_RESUME.score.score, resume: MOCK_OPTIMIZED_RESUME });
}
const base = buildResumeFromProfile(profile, await resolveJobPreference());
return mockRequest({
score: MOCK_OPTIMIZED_RESUME.score.score,
// 用户自己编辑过的板块同样保留,优化不覆盖用户的输入
resume: { ...applyResumeOverride(base, await readResumeOverride()), score: MOCK_OPTIMIZED_RESUME.score },
});
}
/* ===================== 简历:按板块保存(用户编辑) ===================== */
/**
* 保存简历基本信息
* @method PUT
* @url /api/resume/basic-info
* @param input 姓名 / 手机号 / 邮箱 / 所在城市(不含头像)
* @returns Promise<ResumeBasicInfo> 落库后的基本信息(头像字由本层按姓名派生)
* @remark 保存后该板块进入覆盖层:此后引导资料里的姓名不再影响简历
* (与「求职意向」编辑页的覆盖语义一致)
*/
export async function saveResumeBasicInfo(
input: Omit<ResumeBasicInfo, 'avatar'>,
): Promise<ResumeBasicInfo> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/basic-info`, { method: 'PUT', body: JSON.stringify(input) }); return res.json();
const info: ResumeBasicInfo = { ...input, avatar: displayNameAndAvatar(input.name).avatar };
await mergeResumeOverride({ basicInfo: info });
return mockRequest(info);
}
/**
* 保存自我评价
* @method PUT
* @url /api/resume/self-evaluation
* @param content 正文(trim 后为空表示清空该板块)
* @returns Promise<ResumeSelfEvaluation> 落库后的内容
*/
export async function saveResumeSelfEvaluation(content: string): Promise<ResumeSelfEvaluation> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation`, { method: 'PUT', body: JSON.stringify({ content }) }); return res.json();
const section: ResumeSelfEvaluation = { content: content.trim() };
await mergeResumeOverride({ selfEvaluation: section });
return mockRequest(section);
}
/**
* 保存技能特长(整表替换)
* @method PUT
* @url /api/resume/skills
* @param skills 标签列表
* @returns Promise<ResumeSkill[]> 规整后的列表
* @remark 规整规则在本层:逐项 trim、丢弃空串、按名称去重(保留首次出现),页面不重复实现
*/
export async function saveResumeSkills(skills: ResumeSkill[]): Promise<ResumeSkill[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/skills`, { method: 'PUT', body: JSON.stringify({ skills }) }); return res.json();
const names: string[] = [];
for (const skill of skills) {
const name = skill.name.trim();
if (name !== '' && !names.includes(name)) names.push(name);
}
const normalized: ResumeSkill[] = names.map((name) => ({ name }));
await mergeResumeOverride({ skills: normalized });
return mockRequest(normalized);
}
/**
* 保存教育背景(整表替换)
* @method PUT
* @url /api/resume/education
* @param items 用户此刻看到的完整列表;id 缺省表示新增项,由本层生成
* @returns Promise<ResumeEducation[]> 落库后的列表(新增项已带 id)
* @remark 传整表而不是逐条增删改:列表基底是「覆盖层 ?? 引导派生」的合并结果,
* 逐条写会在「编辑引导派生出来的那条」时把整个板块改丢(此时覆盖层里还没有该板块)
*/
export async function saveResumeEducation(
items: ResumeItemInput<ResumeEducation>[],
): Promise<ResumeEducation[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/education`, { method: 'PUT', body: JSON.stringify({ items }) }); return res.json();
const normalized = withResumeItemIds(items, 'edu');
await mergeResumeOverride({ education: normalized });
return mockRequest(normalized);
}
/** 保存工作经历(整表替换)。@url /api/resume/experience @remark 同 saveResumeEducation */
export async function saveResumeExperience(
items: ResumeItemInput<ResumeExperience>[],
): Promise<ResumeExperience[]> {
// TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/experience`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源
const normalized = withResumeItemIds(items, 'exp');
await mergeResumeOverride({ experience: normalized });
return mockRequest(normalized);
}
/** 保存项目经历(整表替换)。@url /api/resume/projects @remark 同 saveResumeEducation */
export async function saveResumeProjects(
items: ResumeItemInput<ResumeProject>[],
): Promise<ResumeProject[]> {
// TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/projects`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(items) });当前以本地存储为唯一真相源
const normalized = withResumeItemIds(items, 'proj');
await mergeResumeOverride({ projects: normalized });
return mockRequest(normalized);
}
/**
* 保存我的简历到本机
* @method POST
* @url /api/resume/save
* @param resume 当前展示的简历
* @returns Promise<{ savedAt: string }> 保存时间(ISO 串)
* @remark 把六个可编辑板块整体固化进覆盖层(此后再改引导资料不影响这份简历),并记录保存时间
* 供页面显示「上次保存」。求职意向不入快照:它由 `resolveJobPreference` 单独供给,
* 同一份数据不能有两个来源。后端就绪后改为云端保存,页面与 hook 无需改动。
*/
export async function saveResumeSnapshot(resume: Resume): Promise<{ savedAt: string }> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/save`, { method: 'POST', body: JSON.stringify(resume) }); return res.json();
await mergeResumeOverride({
basicInfo: resume.basicInfo,
education: resume.education,
experience: resume.experience,
projects: resume.projects,
skills: resume.skills,
selfEvaluation: resume.selfEvaluation,
});
const savedAt = new Date().toISOString();
await storageSet(RESUME_SAVED_AT_KEY, savedAt);
return mockRequest({ savedAt });
}
/**
* 获取简历上次保存时间
* @method GET
* @url /api/resume/saved-at
* @returns Promise<string> ISO 时间串;从未保存过返回空串(页面据此不渲染该行)
*/
export async function fetchResumeSavedAt(): Promise<string> {
// TODO: 后端就绪后改为 await fetch(`${API_BASE_URL}/api/resume/saved-at`, { method: 'GET' });当前以本地存储为唯一真相源
return mockRequest((await storageGet(RESUME_SAVED_AT_KEY)) ?? '');
}
/**
* 清除简历编辑覆盖层与保存时间
* @method DELETE
* @url /api/resume/override
* @returns Promise<void>
* @remark 退出登录时调用(与 `clearOnboardingProfile` 成对):否则下一位用户在本机登录后,
* 会看到上一位用户编辑过的简历板块
*/
export async function clearResumeOverride(): Promise<void> {
// TODO: await fetch(`${API_BASE_URL}/api/resume/override`, { method: 'DELETE' });
await storageRemove(RESUME_OVERRIDE_KEY);
await storageRemove(RESUME_SAVED_AT_KEY);
}
/* ===================== 简历:AI 能力(后端未接入) =====================
* 三个函数的 mock 分支一律返回 available:false 且**不碰 storage**:正文没有被改写的
* 情况下把 `aiOptimized` 置 true 会让页面显示「已优化」徽标 —— 那是假状态。
* 后端就绪后只改函数体(由后端改写并落库,或由本层写覆盖层),页面代码一行都不用动;
* 参数名去掉下划线即表示该能力已接入。
*/
/** AI 优化单条工作经历。@url /api/resume/experience/:id/optimize */
export async function optimizeResumeExperience(_id: string): Promise<ResumeAIActionResult> {
// TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
// const res = await fetch(`${API_BASE_URL}/api/resume/experience/${_id}/optimize`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: _id }),
// });
// return res.json();
return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
}
/** AI 润色自我评价。@url /api/resume/self-evaluation/polish */
export async function polishResumeSelfEvaluation(_content: string): Promise<ResumeAIActionResult> {
// TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
// const res = await fetch(`${API_BASE_URL}/api/resume/self-evaluation/polish`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: _content }),
// });
// return res.json();
return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
}
/** AI 按岗位 JD 定向优化简历。@url /api/resume/optimize/jd */
export async function optimizeResumeWithJD(_jd: string): Promise<ResumeAIActionResult> {
// TODO: 真实请求(后端就绪后放开;参数名去掉下划线即表示该能力已接入)
// const res = await fetch(`${API_BASE_URL}/api/resume/optimize/jd`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jd: _jd }),
// });
// return res.json();
return mockRequest({ available: false, message: RESUME_AI_UNAVAILABLE_TIP });
}
/**
* 获取简历导出设置
* @method GET
* @url /api/resume/export-settings
* @returns Promise<ResumeExportSettings> 导出设置
* @remark 以 storage(`qingsongpin:resume:export-settings`)为唯一真相源;老数据缺后加的字段时
* 用默认值补齐(不能整份替换,否则新字段会变成 undefined),存储损坏回落默认值
*/
export async function fetchResumeExportSettings(): Promise<ResumeExportSettings> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'GET' }); return res.json();
const raw = await storageGet(RESUME_EXPORT_SETTINGS_KEY);
if (!raw) return mockRequest({ ...DEFAULT_EXPORT_SETTINGS });
try {
const stored = JSON.parse(raw) as Partial<ResumeExportSettings>;
return mockRequest({ ...DEFAULT_EXPORT_SETTINGS, ...stored });
} catch {
return mockRequest({ ...DEFAULT_EXPORT_SETTINGS });
}
}
/**
* 保存简历导出设置
* @method PUT
* @url /api/resume/export-settings
* @param settings 导出设置
* @remark 原实现是 `Object.assign(DEFAULT_EXPORT_SETTINGS, settings)`:只改模块内常量,
* 杀掉进程就丢(等于「保存」按钮保存不了),还会污染其它调用方读到的默认值
*/
export async function saveResumeExportSettings(settings: ResumeExportSettings): Promise<void> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export-settings`, { method: 'PUT', body: JSON.stringify(settings) }); return res.json();
await storageSet(RESUME_EXPORT_SETTINGS_KEY, JSON.stringify(settings));
return mockRequest(undefined);
}
/**
* 导出简历
* @method POST
* @url /api/resume/export
* @param settings 导出设置
* @returns Promise<ResumeExportOutcome> 本机文件地址与文件名;失败给出原因
* @remark 原实现返回写死的 `https://cdn.example.com/resume/xxx.pdf` —— 不产出任何文件,
* 页面拿到假链接又没有提示,等于「点了没事发生」。本机阶段改为真出 PDF:
* 取当前简历(含用户编辑)→ 生成 HTML → 打印成本机文件。
* 非 PDF 格式仍按 PDF 导出,由页面在格式选择处与导出成功后明确提示(需后端导出服务)。
*/
export async function exportResume(
settings: ResumeExportSettings,
): Promise<ResumeExportOutcome> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/resume/export`, { method: 'POST', body: JSON.stringify(settings) }); return res.json();
const resume = await fetchResume();
const fileName = resolveExportFileName(resume, settings.fileName);
return exportResumePdf(buildResumeExportHtml(resume, settings), fileName, settings);
}
/* ===================== 入职准备清单 ===================== */
/** 入职准备清单分组(材料 / 手续 / 学习 / 生活 / 每日提醒) */
const MOCK_CHECKLIST_GROUPS: ChecklistGroup[] = [
{
id: 'material',
title: '材料准备',
icon: 'folder-outline',
expanded: true,
items: [
{ id: 'm1', title: '身份证原件及复印件', done: true, required: true },
{ id: 'm2', title: '学历学位证书', done: true, required: true },
{ id: 'm3', title: '离职证明', done: true, required: true },
{ id: 'm4', title: '入职体检报告', done: false, required: true },
{ id: 'm5', title: '银行卡(工资卡)', done: false, required: true },
{ id: 'm6', title: '一寸/二寸证件照', done: false },
],
},
{
id: 'procedure',
title: '手续办理',
icon: 'document-text-outline',
expanded: true,
items: [
{ id: 'p1', title: '签订劳动合同', done: false, required: true },
{ id: 'p2', title: '社保公积金转移', done: false, required: true },
{ id: 'p3', title: '入职信息登记', done: false, required: true },
{ id: 'p4', title: '领取工牌与办公设备', done: false },
],
},
{
id: 'study',
title: '学习准备',
icon: 'book-outline',
expanded: false,
items: [
{ id: 's1', title: '熟悉公司业务与产品线', done: false },
{ id: 's2', title: '了解团队成员与协作流程', done: false },
{ id: 's3', title: '技术栈预习(React / TypeScript)', done: false },
{ id: 's4', title: '搭建本地开发环境', done: false },
],
},
{
id: 'life',
title: '生活准备',
icon: 'home-outline',
expanded: false,
items: [
{ id: 'l1', title: '租房 / 通勤路线规划', done: false },
{ id: 'l2', title: '准备办公用品', done: false },
{ id: 'l3', title: '准备入职着装', done: false },
{ id: 'l4', title: '调整作息时间', done: false },
],
},
];
/** 入职准备清单 */
const MOCK_ONBOARDING_CHECKLIST: OnboardingChecklist = {
daysLeft: 7,
position: '高级前端工程师',
company: '字节跳动',
total: 18,
completed: 3,
onboardingDate: '2026-09-06',
groups: MOCK_CHECKLIST_GROUPS,
dailyReminder: {
enabled: true,
time: '09:00',
},
tips: [
{
id: 'tip-1',
title: '提前 15 分钟到公司',
desc: '首日建议提前到达,留出办理入职手续的时间',
icon: 'time-outline',
},
{
id: 'tip-2',
title: '主动认识团队成员',
desc: '主动自我介绍,快速融入团队氛围',
icon: 'people-outline',
},
{
id: 'tip-3',
title: '准备自我介绍',
desc: '准备 1 分钟自我介绍,包含背景与擅长方向',
icon: 'chatbubble-outline',
},
],
};
/** 勾选状态存储键:模板条目按 id 存 done,自定义条目另存一份 */
const CHECKLIST_DONE_KEY = 'onboarding:checklist-done';
const CHECKLIST_CUSTOM_KEY = 'onboarding:checklist-custom';
/** 自定义待办的存储结构 */
interface CustomChecklistItem {
id: string;
title: string;
done: boolean;
}
async function readDoneMap(): Promise<Record<string, boolean>> {
const raw = await storageGet(CHECKLIST_DONE_KEY);
if (raw === null) return {};
try {
return JSON.parse(raw) as Record<string, boolean>;
} catch {
return {};
}
}
async function readCustomItems(): Promise<CustomChecklistItem[]> {
const raw = await storageGet(CHECKLIST_CUSTOM_KEY);
if (raw === null) return [];
try {
const parsed = JSON.parse(raw) as CustomChecklistItem[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/**
* 组装清单:模板分组叠加本机勾选状态,再拼上用户自定义的那组。
* 勾选状态必须落库 —— 否则同一台设备上退出再进来,勾过的又变回未勾选。
*/
async function buildOnboardingChecklist(): Promise<OnboardingChecklist> {
const [doneMap, customItems] = await Promise.all([readDoneMap(), readCustomItems()]);
const groups: ChecklistGroup[] = MOCK_CHECKLIST_GROUPS.map((g) => ({
...g,
items: g.items.map((item) => ({ ...item, done: doneMap[item.id] ?? item.done })),
}));
if (customItems.length > 0) {
groups.push({
id: 'custom',
title: '我的自定义待办',
icon: 'create-outline',
expanded: true,
items: customItems.map((c) => ({ id: c.id, title: c.title, done: c.done })),
});
}
const completed = groups.reduce((sum, g) => sum + g.items.filter((i) => i.done).length, 0);
const total = groups.reduce((sum, g) => sum + g.items.length, 0);
return { ...MOCK_ONBOARDING_CHECKLIST, groups, completed, total };
}
/**
* 获取入职准备清单
* @method GET
* @url /api/tools/onboarding-checklist
* @returns Promise<OnboardingChecklist> 入职准备清单数据
*/
export async function fetchOnboardingChecklist(): Promise<OnboardingChecklist> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist`, { method: 'GET' }); return res.json();
return mockRequest(await buildOnboardingChecklist());
}
/**
* 切换入职待办完成状态
* @method PATCH
* @url /api/tools/onboarding-checklist/items/:id
* @param itemId { string } 待办 id
* @param done { boolean } 是否完成
* @returns Promise<OnboardingChecklist> 更新后的清单
*/
export async function toggleChecklistItem(
itemId: string,
done: boolean,
): Promise<OnboardingChecklist> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'PATCH', body: JSON.stringify({ done }) }); return res.json();
const doneMap = await readDoneMap();
await storageSet(CHECKLIST_DONE_KEY, JSON.stringify({ ...doneMap, [itemId]: done }));
// 自定义条目另有一份带标题的存档,勾选同样要落到那份里
const customItems = await readCustomItems();
if (customItems.some((c) => c.id === itemId)) {
await storageSet(
CHECKLIST_CUSTOM_KEY,
JSON.stringify(customItems.map((c) => (c.id === itemId ? { ...c, done } : c))),
);
}
return mockRequest(await buildOnboardingChecklist());
}
/**
* 添加一条自定义待办
* @method POST
* @url /api/tools/onboarding-checklist/items
* @param title { string } 待办标题(去空格后不能为空)
* @returns Promise<OnboardingChecklist> 更新后的清单;标题为空时 reject
*/
export async function addChecklistItem(title: string): Promise<OnboardingChecklist> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items`, { method: 'POST', body: JSON.stringify({ title }) }); return res.json();
const trimmed = title.trim();
if (trimmed === '') return Promise.reject(new Error('待办内容不能为空'));
const customItems = await readCustomItems();
await storageSet(
CHECKLIST_CUSTOM_KEY,
JSON.stringify([
...customItems,
{ id: `custom-${Date.now()}`, title: trimmed, done: false },
]),
);
return mockRequest(await buildOnboardingChecklist());
}
/**
* 删除一条自定义待办(模板条目不允许删,删了下次进页面又会出现)
* @method DELETE
* @url /api/tools/onboarding-checklist/items/:id
* @param itemId { string } 自定义待办 id
* @returns Promise<OnboardingChecklist> 更新后的清单;非自定义条目 reject
*/
export async function removeChecklistItem(itemId: string): Promise<OnboardingChecklist> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/onboarding-checklist/items/${itemId}`, { method: 'DELETE' }); return res.json();
const customItems = await readCustomItems();
if (!customItems.some((c) => c.id === itemId)) {
return Promise.reject(new Error('模板待办不能删除'));
}
await storageSet(
CHECKLIST_CUSTOM_KEY,
JSON.stringify(customItems.filter((c) => c.id !== itemId)),
);
const doneMap = await readDoneMap();
if (itemId in doneMap) {
const { [itemId]: _removed, ...rest } = doneMap;
await storageSet(CHECKLIST_DONE_KEY, JSON.stringify(rest));
}
return mockRequest(await buildOnboardingChecklist());
}
/**
* 把清单拼成可导出的纯文本(导出 / 分享用,纯函数)。
* @param checklist 入职准备清单
* @returns string 形如「入职准备清单(公司 · 岗位)」+ 分组 + 勾选标记
*/
export function buildChecklistText(checklist: OnboardingChecklist): string {
const lines: string[] = [
`入职准备清单(${checklist.company} · ${checklist.position})`,
`入职日期 ${checklist.onboardingDate} · 已完成 ${checklist.completed}/${checklist.total}`,
'',
];
checklist.groups.forEach((group) => {
lines.push(`【${group.title}】`);
group.items.forEach((item) => {
lines.push(`${item.done ? '[x]' : '[ ]'} ${item.title}`);
});
lines.push('');
});
return lines.join('\n').trim();
}
/* ===================== AI 模拟面试 ===================== */
/** AI 模拟面试配置 */
const MOCK_AI_INTERVIEW: AIInterviewData = {
mode: 'full',
modes: [
{ id: 'full', label: '全真模拟', desc: '完整流程,高仿真', icon: 'videocam-outline' },
{ id: 'special', label: '专项突破', desc: '针对弱项强化训练', icon: 'fitness-outline' },
{ id: 'fast', label: '快速问答', desc: '10分钟快速练习', icon: 'flash-outline' },
],
jobInfo: {
position: '高级前端工程师',
company: '字节跳动',
difficulty: '中等',
duration: 30,
},
questionTypes: [
{ id: 'tech', label: '技术面', color: 'primary', selected: true },
{ id: 'behavior', label: '行为面', color: 'success', selected: true },
{ id: 'logic', label: '智力题', color: 'action', selected: false },
],
specials: [
{ id: 'sp1', label: '前端框架原理', selected: false },
{ id: 'sp2', label: '工程化实践', selected: false },
{ id: 'sp3', label: '性能优化', selected: false },
{ id: 'sp4', label: '项目深挖', selected: false },
{ id: 'sp5', label: '算法与数据结构', selected: false },
],
improvements: [
{ id: 'imp1', label: '技术表达', percent: 10, color: 'primary' },
{ id: 'imp2', label: '应变能力', percent: 12, color: 'success' },
],
recent: [
{ id: 'r1', date: '3月15日', title: '前端开发模拟面试', result: '良好', resultColor: 'success' },
{ id: 'r2', date: '3月10日', title: '技术专项突破', result: '优秀', resultColor: 'primary' },
],
};
/**
* 获取 AI 模拟面试配置
* @method GET
* @url /api/interview/config
* @returns Promise<AIInterviewData> 模拟面试配置
*/
export async function fetchAIInterviewData(): Promise<AIInterviewData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/interview/config`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_AI_INTERVIEW);
}
/**
* 开始模拟面试
* @method POST
* @url /api/interview/start
* @returns Promise<InterviewSession> 面试会话
*/
export async function startInterview(): Promise<InterviewSession> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/interview/start`, { method: 'POST' }); return res.json();
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 评价详情 */
const MOCK_HR_EVALUATION: HREvaluation = {
overallScore: 88,
percentile: 78,
trend: [
{ month: '3月', score: 82 },
{ month: '4月', score: 84 },
{ month: '5月', score: 83 },
{ month: '6月', score: 85 },
{ month: '7月', score: 86 },
{ month: '8月', score: 88 },
],
dimensions: [
{ id: 'd1', name: '技术能力', score: 92, maxScore: 100, level: '优秀', change: 2 },
{ id: 'd2', name: '项目经验', score: 88, maxScore: 100, level: '优秀', change: 1 },
{ id: 'd3', name: '沟通表达', score: 85, maxScore: 100, level: '良好', change: 3 },
{ id: 'd4', name: '团队协作', score: 90, maxScore: 100, level: '优秀', change: 4 },
{ id: 'd5', name: '学习能力', score: 86, maxScore: 100, level: '良好', change: 2 },
{ id: 'd6', name: '稳定性', score: 80, maxScore: 100, level: '良好', change: -1 },
],
reviews: [
{
id: 'rv1',
company: '字节跳动',
rating: 5,
date: '2026-08-20',
content: '技术基础扎实,对 React 与 TypeScript 理解深入,工程化实践经验丰富。沟通顺畅,能够快速理解业务需求并给出技术方案。',
tags: ['技术扎实', '沟通顺畅', '工程能力强'],
status: 'accepted',
},
{
id: 'rv2',
company: '腾讯科技',
rating: 4,
date: '2026-08-12',
content: '项目经验较为丰富,主导过中大型前端项目。在性能优化方向有一定积累,建议进一步补充跨端开发经验。',
tags: ['项目丰富', '性能优化', '可补充跨端'],
status: 'accepted',
},
{
id: 'rv3',
company: '阿里巴巴',
rating: 4,
date: '2026-08-05',
content: '学习能力较强,能够快速上手新技术栈。团队协作意识好,建议在未来加强系统设计与架构能力的沉淀。',
tags: ['学习能力强', '协作好', '可加强架构'],
status: 'accepted',
},
],
rules: [
'综合评分由六维能力加权计算得出,技术能力权重最高',
'评价数据来自已完成面试流程的企业 HR,确保真实有效',
'评分每月更新一次,反映你近期的求职表现',
],
};
/**
* 获取 HR 评价详情
* @method GET
* @url /api/evaluation/hr
* @returns Promise<HREvaluation> HR 评价详情
*/
export async function fetchHREvaluation(): Promise<HREvaluation> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/evaluation/hr`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_HR_EVALUATION);
}
/* ===================== 评价申诉 ===================== */
/** 申诉维度(六维能力) */
const MOCK_APPEAL_DIMENSIONS: AppealDimension[] = [
{ id: 'd1', name: '技术能力', selected: false },
{ id: 'd2', name: '项目经验', selected: false },
{ id: 'd3', name: '沟通表达', selected: false },
{ id: 'd4', name: '团队协作', selected: false },
{ id: 'd5', name: '学习能力', selected: false },
{ id: 'd6', name: '稳定性', selected: false },
];
/** 评价申诉表单初始数据 */
const MOCK_APPEAL_FORM: EvaluationAppealForm = {
type: 'overall',
dimensions: MOCK_APPEAL_DIMENSIONS,
reason: 'untrue',
reasonText: '',
evidence: [],
contactPhone: '',
contactEmail: '',
agree: false,
};
/**
* 获取评价申诉表单数据
* @method GET
* @url /api/evaluation/appeal
* @returns Promise<EvaluationAppealForm> 申诉表单初始数据
*/
export async function fetchAppealForm(): Promise<EvaluationAppealForm> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/evaluation/appeal`, { method: 'GET' }); return res.json();
return mockRequest({ ...MOCK_APPEAL_FORM });
}
/**
* 提交评价申诉
* @method POST
* @url /api/evaluation/appeal
* @param payload { EvaluationAppealForm } 申诉表单
* @returns Promise<{ appealNo: string; estimatedDays: number }> 申诉结果
*/
export async function submitAppeal(
payload: EvaluationAppealForm,
): Promise<{ appealNo: string; estimatedDays: number }> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/evaluation/appeal`, { method: 'POST', body: JSON.stringify(payload) }); return res.json();
void payload;
return mockRequest({
appealNo: `AP${Date.now().toString().slice(-8)}`,
estimatedDays: 3,
});
}
/* ===================== 企业数据总览 ===================== */
/** 企业总览快捷入口 */
const MOCK_ENTERPRISE_QUICK_ACTIONS: EnterpriseQuickAction[] = [
{ id: 'publish', label: '发布岗位', icon: 'add-circle-outline', route: '/job-publish', color: 'primary' },
{ id: 'jobs', label: '岗位管理', icon: 'business-outline', route: '/job-management', color: 'primary' },
{ id: 'evaluations', label: '岗位评价管理', icon: 'chatbubbles-outline', route: '/job-evaluations', color: 'success' },
{ id: 'candidates', label: '人才推荐', icon: 'people-outline', route: '/talent-recommendation', color: 'info' },
{ id: 'communication', label: '企业沟通', icon: 'chatbubble-outline', route: '/enterprise-chat', color: 'action' },
{ id: 'interviews', label: '面试评分', icon: 'calendar-outline', route: '/interview-score', color: 'action' },
];
/**
* 企业端管理后台入口
* 低频管理操作,单独成区展示,不占用快捷入口的横排位;
* 同时避免「运营规则配置 / 数据审计」等后台术语出现在求职者端菜单。
*/
const MOCK_ENTERPRISE_MANAGE_ACTIONS: EnterpriseQuickAction[] = [
{ id: 'operation-rules', label: '运营规则配置', icon: 'options-outline', route: '/operation-rules', color: 'primary' },
{ id: 'audit-report', label: '数据审计报告', icon: 'shield-checkmark-outline', route: '/audit-report', color: 'info' },
];
/** 岗位评价摘要 */
const MOCK_JOB_EVALUATION_SUMMARIES: JobEvaluationSummary[] = [
{ id: 'je1', positionName: '高级前端工程师', department: '技术部', score: 4.9, reviewCount: 42, change: 0.2 },
{ id: 'je2', positionName: '产品经理', department: '产品部', score: 4.7, reviewCount: 38, change: 0.1 },
{ id: 'je3', positionName: 'UI设计师', department: '设计部', score: 4.6, reviewCount: 25, change: -0.1 },
{ id: 'je4', positionName: '后端开发工程师', department: '技术部', score: 4.5, reviewCount: 31, change: 0.3 },
];
/**
* 企业累计评价数 = 各岗位评价数之和。
*
* 总览页的「累计评价」与「评价人数」、岗位评价管理页的「N 条评价」读的是同一个事实,
* 此前三处各写一个数(1280 / 28 / 128),同一屏上就自相矛盾。故由明细汇总,不再各写。
*/
const ENTERPRISE_REVIEW_TOTAL = MOCK_JOB_EVALUATION_SUMMARIES.reduce(
(sum, item) => sum + item.reviewCount,
0,
);
/**
* 企业总览统计。
*
* 「在招岗位」由岗位列表实时算出(见 `countActiveJobs`)—— 下架一个岗位,总览页与
* 岗位管理页要同时减 1。此前总览写死 1280、岗位管理算出 8,同一家企业在两个页面对不上。
* 其余三项依赖后端投递 / 面试 / 申诉数据,mock 阶段沿用固定值。
*/
function buildEnterpriseStats(activeJobCount: number): EnterpriseStat[] {
return [
{ id: 'jobs', label: '在招岗位', value: activeJobCount, change: 12, icon: 'briefcase-outline', color: 'primary' },
{ id: 'interviews', label: '本月面试', value: 156, change: 8, icon: 'videocam-outline', color: 'info' },
{ id: 'reviews', label: '累计评价', value: ENTERPRISE_REVIEW_TOTAL, change: -3, icon: 'chatbubble-outline', color: 'success' },
{ id: 'appeals', label: '待处理申诉', value: 12, change: 2, icon: 'alert-circle-outline', color: 'action' },
];
}
/**
* 企业总览数据(`stats` 由 `fetchEnterpriseOverview` 现算后拼上,故此处不含)。
* 企业名取 `ENTERPRISE_DISPLAY_NAME`:此前总览写全称「北京智联科技有限公司」、
* 其余三页写简称「智联科技」,同一家企业两个名字,改一个另一个不知道。
*/
const MOCK_ENTERPRISE_OVERVIEW: Omit<EnterpriseOverview, 'stats'> = {
id: 'ent-1',
name: ENTERPRISE_DISPLAY_NAME,
logo: '智',
industry: '互联网/IT',
scale: '1000-9999人',
stage: 'B轮',
overallScore: 4.8,
reviewCount: ENTERPRISE_REVIEW_TOTAL,
jobEvaluations: MOCK_JOB_EVALUATION_SUMMARIES,
quickActions: MOCK_ENTERPRISE_QUICK_ACTIONS,
manageActions: MOCK_ENTERPRISE_MANAGE_ACTIONS,
menuItems: MOCK_ENTERPRISE_MENU_ITEMS,
};
/**
* 获取企业数据总览
* @method GET
* @url /api/enterprise/overview
* @returns Promise<EnterpriseOverview> 企业总览数据
*/
export async function fetchEnterpriseOverview(): Promise<EnterpriseOverview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/overview`, { method: 'GET' }); return res.json();
// 「在招岗位」与岗位管理页取同一个数:都从(含覆盖的)岗位列表实时算出
const jobs = await applyJobManagementOverrides(MOCK_JOB_MANAGEMENT_JOBS);
return mockRequest({ ...MOCK_ENTERPRISE_OVERVIEW, stats: buildEnterpriseStats(countActiveJobs(jobs)) });
}
/* ===================== 发布岗位 ===================== */
/** 默认发布岗位表单 */
const DEFAULT_JOB_PUBLISH_FORM: JobPublishFormData = {
positionName: '高级前端工程师',
department: '技术部',
headcount: 2,
city: '北京',
address: '朝阳区望京SOHO T3',
workType: 'full-time',
education: '本科',
experience: '3-5年',
salaryMin: 25,
salaryMax: 40,
salaryMonths: 14,
benefits: ['五险一金', '年终奖金', '弹性工作', '带薪年假', '股票期权'],
responsibilities:
'1. 负责公司核心产品前端架构设计与开发\n2. 参与前端工程化建设,提升研发效率\n3. 与产品、设计、后端紧密配合,保证交付质量',
requirements:
'1. 本科及以上学历,3年以上前端开发经验\n2. 精通 React / TypeScript,熟悉现代前端工程化\n3. 具备良好的沟通能力和团队协作精神',
skills: ['React', 'TypeScript', 'Node.js', 'Webpack'],
visibility: 'all',
urgent: true,
acceptFresh: false,
deadline: '2026-12-31',
};
/** 岗位质量预估 */
const MOCK_JOB_QUALITY_ESTIMATE: JobQualityEstimate = {
score: 92,
level: '优秀',
suggestions: [
'岗位职责描述清晰,建议补充团队规模信息',
'薪资范围具有竞争力,可突出股权激励',
],
};
/**
* 获取发布岗位表单初始数据
* @method GET
* @url /api/enterprise/jobs/publish-form
* @returns Promise<JobPublishFormData> 发布岗位表单
*/
export async function fetchJobPublishForm(): Promise<JobPublishFormData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/jobs/publish-form`, { method: 'GET' }); return res.json();
return mockRequest({ ...DEFAULT_JOB_PUBLISH_FORM });
}
/**
* 预估岗位质量
* @method POST
* @url /api/enterprise/jobs/quality-estimate
* @param form { JobPublishFormData } 岗位表单
* @returns Promise<JobQualityEstimate> 质量预估结果
*/
export async function estimateJobQuality(form: JobPublishFormData): Promise<JobQualityEstimate> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/jobs/quality-estimate`, { method: 'POST', body: JSON.stringify(form) }); return res.json();
void form;
return mockRequest({ ...MOCK_JOB_QUALITY_ESTIMATE });
}
/**
* 发布岗位
* @method POST
* @url /api/enterprise/jobs
* @param form { JobPublishFormData } 岗位表单
* @returns Promise<{ jobId: string }> 发布结果
*/
export async function publishJob(form: JobPublishFormData): Promise<{ jobId: string }> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/jobs`, { method: 'POST', body: JSON.stringify(form) }); return res.json();
void form;
return mockRequest({ jobId: `job-${Date.now().toString().slice(-8)}` });
}
/* ===================== 岗位评价管理 ===================== */
/** 岗位评价维度 */
const MOCK_JOB_EVALUATION_DIMENSIONS: JobEvaluationDimension[] = [
{ id: 'jd1', name: '岗位真实性', score: 4.9, maxScore: 5, change: 0.1 },
{ id: 'jd2', name: '薪资透明度', score: 4.7, maxScore: 5, change: 0.2 },
{ id: 'jd3', name: '面试体验', score: 4.6, maxScore: 5, change: -0.1 },
{ id: 'jd4', name: '发展空间', score: 4.5, maxScore: 5, change: 0.3 },
{ id: 'jd5', name: '工作强度', score: 4.3, maxScore: 5, change: 0.0 },
{ id: 'jd6', name: '团队氛围', score: 4.8, maxScore: 5, change: 0.2 },
];
/** 岗位评价列表 */
const MOCK_JOB_REVIEWS: JobReview[] = [
{
id: 'jr1',
userName: '张**',
userRole: '在职员工,前端开发',
positionName: '高级前端工程师',
rating: 5,
date: '2026-08-20',
content: '面试流程规范,HR 反馈及时。岗位描述与实际工作基本一致,团队技术氛围很好。',
tags: ['面试规范', '反馈及时', '氛围好'],
replied: true,
},
{
id: 'jr2',
userName: '李**',
userRole: '求职者,产品经理',
positionName: '产品经理',
rating: 4,
date: '2026-08-15',
content: '整体体验不错,面试官专业。但面试等待时间稍长,希望可以优化。',
tags: ['面试官专业', '等待时间长'],
replied: false,
},
{
id: 'jr3',
userName: '王**',
userRole: '在职员工,UI设计',
positionName: 'UI设计师',
rating: 5,
date: '2026-08-10',
content: '薪资透明,福利完善,工作节奏合理。非常推荐加入这家公司。',
tags: ['薪资透明', '福利好'],
replied: true,
},
];
/** 岗位评价管理数据 */
const MOCK_JOB_EVALUATION_MANAGEMENT: JobEvaluationManagement = {
overallScore: 4.8,
change: 0.1,
reviewCount: ENTERPRISE_REVIEW_TOTAL,
positiveRate: 85,
replyRate: 72,
complaintRate: 8,
trend: [
{ month: '3月', score: 4.5 },
{ month: '4月', score: 4.6 },
{ month: '5月', score: 4.6 },
{ month: '6月', score: 4.7 },
{ month: '7月', score: 4.7 },
{ month: '8月', score: 4.8 },
],
dimensions: MOCK_JOB_EVALUATION_DIMENSIONS,
jobScores: MOCK_JOB_EVALUATION_SUMMARIES,
reviews: MOCK_JOB_REVIEWS,
};
/**
* 获取岗位评价管理数据
* @method GET
* @url /api/enterprise/evaluations
* @returns Promise<JobEvaluationManagement> 岗位评价管理数据
* @remark backend-stub-ok:回复内容读 AsyncStorage(见 withReviewReplies)
*/
/** 企业回复的存储结构:评价 id → 回复内容 */
const JOB_REVIEW_REPLIES_KEY = 'enterprise:review-replies';
/** 读取已提交的评价回复 */
async function readReviewReplies(): Promise<Record<string, string>> {
const raw = await storageGet(JOB_REVIEW_REPLIES_KEY);
if (!raw) return {};
try {
return JSON.parse(raw) as Record<string, string>;
} catch {
// 存储损坏时按「没回复过」处理,不阻断评价列表展示
return {};
}
}
/** 把已提交的回复合并进评价列表(真实后端下这两个字段由接口直接返回) */
async function withReviewReplies(reviews: JobReview[]): Promise<JobReview[]> {
const replies = await readReviewReplies();
return reviews.map((review) =>
replies[review.id] === undefined
? review
: { ...review, replied: true, replyContent: replies[review.id] },
);
}
export async function fetchJobEvaluationManagement(): Promise<JobEvaluationManagement> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations`, { method: 'GET' }); return res.json();
const reviews = await withReviewReplies(MOCK_JOB_REVIEWS);
/*
* 只合并回复内容,**不重算回复率**:回复率的分母是「全部评价」(reviewCount = 128),
* 而这里能列出的只有 3 条样例,拿样例算出来的是 67%,比原值 72% 更假。
* 真实后端下这两个数字都由服务端给出。
*/
return mockRequest({ ...MOCK_JOB_EVALUATION_MANAGEMENT, reviews });
}
/**
* 回复岗位评价
* @method POST
* @url /api/enterprise/evaluations/:id/reply
* @param reviewId { string } 评价 id
* @param reply { string } 回复内容
* @returns Promise<JobReview> 回复后的评价
* @remark mock 阶段回复内容落 AsyncStorage:不落库的话刷新一次回复就没了,
* 界面上「已回复」会自己变回「回复」,等于骗用户
*/
export async function replyJobReview(
reviewId: string,
reply: string,
): Promise<JobReview> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/evaluations/${reviewId}/reply`, { method: 'POST', body: JSON.stringify({ reply }) }); return res.json();
const content = reply.trim();
if (content === '') {
return Promise.reject(new Error('回复内容不能为空'));
}
const replies = await readReviewReplies();
replies[reviewId] = content;
await storageSet(JOB_REVIEW_REPLIES_KEY, JSON.stringify(replies));
const review = MOCK_JOB_REVIEWS.find((r) => r.id === reviewId) ?? MOCK_JOB_REVIEWS[0];
return mockRequest({ ...review, replied: true, replyContent: content });
}
/* ===================== 候选人详情 ===================== */
/** 候选人能力维度 */
const MOCK_CANDIDATE_ABILITIES: CandidateAbility[] = [
{ id: 'ca1', name: '专业技能', score: 92, maxScore: 100 },
{ id: 'ca2', name: '项目经验', score: 85, maxScore: 100 },
{ id: 'ca3', name: '逻辑思维', score: 88, maxScore: 100 },
{ id: 'ca4', name: '沟通表达', score: 92, maxScore: 100 },
{ id: 'ca5', name: '团队协作', score: 80, maxScore: 100 },
{ id: 'ca6', name: '学习能力', score: 95, maxScore: 100 },
{ id: 'ca7', name: '抗压能力', score: 75, maxScore: 100 },
{ id: 'ca8', name: '文化匹配', score: 88, maxScore: 100 },
];
/** 候选人评价 */
const MOCK_CANDIDATE_REVIEWS: CandidateReview[] = [
{
id: 'cr1',
interviewer: '王经理',
position: '高级前端工程师',
date: '2026-08-25',
rating: 5,
content: '技术基础扎实,对 React 与 TypeScript 理解深入,工程化实践经验丰富。沟通顺畅。',
tags: ['技术扎实', '沟通顺畅', '工程能力强'],
},
{
id: 'cr2',
interviewer: '李总监',
position: '前端负责人',
date: '2026-08-20',
rating: 4,
content: '项目经验丰富,主导过中大型前端项目。在性能优化方向有一定积累。',
tags: ['项目丰富', '性能优化'],
},
];
/** 候选人详情 */
const MOCK_CANDIDATE_DETAIL: CandidateDetail = {
id: 'candidate-1',
basicInfo: {
name: '张明远',
age: 28,
gender: '男',
experience: '5年经验',
education: '本科',
city: '北京',
phone: '138****8888',
email: 'zhang****@email.com',
jobStatus: '在职看机会',
},
matchPercent: 96,
overallScore: 88,
percentile: 78,
trend: [
{ month: '3月', score: 82 },
{ month: '4月', score: 84 },
{ month: '5月', score: 83 },
{ month: '6月', score: 85 },
{ month: '7月', score: 86 },
{ month: '8月', score: 88 },
],
abilities: MOCK_CANDIDATE_ABILITIES,
reviews: MOCK_CANDIDATE_REVIEWS,
hireSuggestions: [
{ type: 'strong', title: '强烈推荐录用', desc: '综合匹配度高,建议尽快发起 offer 流程', selected: true },
{ type: 'consider', title: '可以考虑', desc: '技术能力达标,建议进一步考察项目管理能力' },
{ type: 'reject', title: '不推荐', desc: '与岗位匹配度较低,建议再看看其他候选人' },
],
salarySuggestion: {
range: '30-33K',
level: 'P6',
note: '建议按 P6 职级定薪,可结合候选人当前薪资适当上浮',
},
invited: false,
};
/**
* 候选人身份:直接取自人才推荐列表 `MOCK_TALENT_CANDIDATES`。
*
* 这里原先另存了一份覆盖表,且**只写了 cand-1 / cand-2 / cand-3**,取不到时兜底到 cand-1。
* 后果是:列表里点 cand-4「陈思远 · 匹配 94%」的「查看详情」,详情页显示的是张明远的
* 姓名 / 年龄 / 匹配度 / 百分位(cand-5 ~ cand-8 同样)—— 连人是谁都对不上。
* 列表才是这份资料的明细来源,改为按 id 直接取它。
*
* 取不到时仍兜底到第一条:详情页存在「不带 id 的独立入口」,那时需要一个能渲染的占位。
*/
function candidateIdentityOf(candidateId: string): TalentCandidate {
return (
MOCK_TALENT_CANDIDATES.find((item) => item.id === candidateId) ?? MOCK_TALENT_CANDIDATES[0]
);
}
/**
* 获取候选人详情
* @method GET
* @url /api/enterprise/candidates/:id
* @param candidateId { string } 候选人 id
* @returns Promise<CandidateDetail> 候选人详情
*/
export async function fetchCandidateDetail(candidateId: string): Promise<CandidateDetail> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/candidates/${candidateId}`, { method: 'GET' }); return res.json();
const identity = candidateIdentityOf(candidateId);
const detail: CandidateDetail = {
...MOCK_CANDIDATE_DETAIL,
id: candidateId,
matchPercent: identity.matchPercent,
overallScore: identity.overallScore,
percentile: identity.percentile,
basicInfo: {
...MOCK_CANDIDATE_DETAIL.basicInfo,
name: identity.name,
age: identity.age,
city: identity.city,
},
};
return mockRequest(detail);
}
/**
* 邀请候选人面试
* @method POST
* @url /api/enterprise/candidates/:id/invite
* @param candidateId { string } 候选人 id
* @returns Promise<{ success: boolean }> 邀请结果
*/
export async function inviteCandidate(candidateId: string): Promise<{ success: boolean }> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/candidates/${candidateId}/invite`, { method: 'POST' }); return res.json();
void candidateId;
return mockRequest({ success: true });
}
/* ===================== 面试评分 ===================== */
/** 面试评分维度 */
const MOCK_INTERVIEW_SCORE_DIMENSIONS: InterviewScoreDimension[] = [
{ id: 'is1', name: '专业技能', score: 90, maxScore: 100 },
{ id: 'is2', name: '项目经验', score: 85, maxScore: 100 },
{ id: 'is3', name: '逻辑思维', score: 88, maxScore: 100 },
{ id: 'is4', name: '沟通表达', score: 92, maxScore: 100 },
{ id: 'is5', name: '团队协作', score: 80, maxScore: 100 },
{ id: 'is6', name: '学习能力', score: 95, maxScore: 100 },
{ id: 'is7', name: '抗压能力', score: 75, maxScore: 100 },
{ id: 'is8', name: '文化匹配', score: 88, maxScore: 100 },
];
/** 面试评价标签 */
const MOCK_INTERVIEW_SCORE_TAGS: InterviewScoreTag[] = [
{ id: 'tag1', label: '技术扎实', selected: true, color: 'primary' },
{ id: 'tag2', label: '表达清晰', selected: true, color: 'success' },
{ id: 'tag3', label: '项目经验丰富', selected: false, color: 'info' },
{ id: 'tag4', label: '应变能力强', selected: false, color: 'action' },
{ id: 'tag5', label: '需加强架构', selected: false, color: 'danger' },
];
/** 面试评分数据 */
const MOCK_INTERVIEW_SCORE_DATA: InterviewScoreData = {
candidateId: 'candidate-1',
candidateName: '张明远',
positionName: '高级前端工程师',
interviewTime: '2026-08-30 14:00',
overallScore: 87,
dimensions: MOCK_INTERVIEW_SCORE_DIMENSIONS,
tags: MOCK_INTERVIEW_SCORE_TAGS,
comment:
'候选人整体表现优秀,技术功底扎实,有大型项目经验。沟通表达流畅,学习能力强。对 React 底层原理有深入理解,建议录用。',
hireSuggestions: [
{ type: 'strong', title: '强烈推荐录用', desc: '综合素质优秀,建议尽快定薪发 offer', selected: true },
{ type: 'consider', title: '可以考虑', desc: '基本符合要求,建议二面进一步确认' },
{ type: 'reject', title: '不推荐', desc: '与岗位要求存在明显差距' },
],
salarySuggestion: {
range: '30-33K',
level: 'P6',
note: '建议按 P6 职级定薪,可结合候选人期望薪资协商',
},
};
/**
* 获取面试评分数据
* @method GET
* @url /api/enterprise/interviews/:id/score
* @param interviewId { string } 面试 id
* @returns Promise<InterviewScoreData> 面试评分数据
*/
export async function fetchInterviewScore(interviewId: string): Promise<InterviewScoreData> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score`, { method: 'GET' }); return res.json();
void interviewId;
return mockRequest({ ...MOCK_INTERVIEW_SCORE_DATA });
}
/**
* 提交面试评分
* @method POST
* @url /api/enterprise/interviews/:id/score
* @param interviewId { string } 面试 id
* @param payload { InterviewScoreData } 评分数据
* @returns Promise<{ success: boolean }> 提交结果
*/
export async function submitInterviewScore(
interviewId: string,
payload: InterviewScoreData,
): Promise<{ success: boolean }> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score`, { method: 'POST', body: JSON.stringify(payload) }); return res.json();
void interviewId;
void payload;
return mockRequest({ success: true });
}
/** 面试评分草稿的存储键(按面试 id 分开,避免串到另一场面试上) */
function interviewScoreDraftKey(interviewId: string): string {
return `interview:score-draft:${interviewId}`;
}
/**
* 保存面试评分草稿
* @method PUT
* @url /api/enterprise/interviews/:id/score/draft
* @param interviewId { string } 面试 id
* @param score { InterviewScoreData } 当前评分内容
* @returns Promise<InterviewScoreDraft> 含保存时间
*/
export async function saveInterviewScoreDraft(
interviewId: string,
score: InterviewScoreData,
): Promise<InterviewScoreDraft> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'PUT', body: JSON.stringify(score) }); return res.json();
const draft: InterviewScoreDraft = { score, savedAt: new Date().toISOString() };
await storageSet(interviewScoreDraftKey(interviewId), JSON.stringify(draft));
return mockRequest(draft);
}
/**
* 读取面试评分草稿
* @method GET
* @url /api/enterprise/interviews/:id/score/draft
* @param interviewId { string } 面试 id
* @returns Promise<InterviewScoreDraft | null> 没存过或存储不可用时为 null
*/
export async function fetchInterviewScoreDraft(
interviewId: string,
): Promise<InterviewScoreDraft | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'GET' }); return res.json();
const raw = await storageGet(interviewScoreDraftKey(interviewId));
if (raw === null) return null;
try {
return JSON.parse(raw) as InterviewScoreDraft;
} catch {
// 存的内容坏了就当作没有草稿,不要把异常抛到页面上
return null;
}
}
/**
* 删除面试评分草稿(提交评分成功、或用户放弃草稿时调用)
* @method DELETE
* @url /api/enterprise/interviews/:id/score/draft
* @param interviewId { string } 面试 id
*/
export async function clearInterviewScoreDraft(interviewId: string): Promise<void> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/enterprise/interviews/${interviewId}/score/draft`, { method: 'DELETE' }); return res.json();
await storageRemove(interviewScoreDraftKey(interviewId));
}
/* ===================== 运营规则配置 ===================== */
/** 匹配模式选项 */
const MOCK_MATCH_MODE_OPTIONS: MatchModeOption[] = [
{
id: 'strict',
name: '严格模式',
desc: '仅推荐高度匹配的候选人,降低误配风险,适合对岗位要求精确匹配',
},
{
id: 'recommended',
name: '推荐模式',
desc: '综合技能、经验、期望进行智能推荐,推荐质量与数量较平衡',
recommended: true,
},
{
id: 'broad',
name: '广泛模式',
desc: '更多候选人,需要人工筛选较多,适合招聘需求量大或新兴岗位',
},
];
/** 默认运营规则配置 */
const MOCK_OPERATION_RULES: OperationRules = {
matchMode: 'recommended',
matchModeOptions: MOCK_MATCH_MODE_OPTIONS,
matchThreshold: 70,
visibilityThreshold: 65,
dailyRecommendLimit: 20,
autoFilters: [
{ id: 'af-1', name: '学历硬性筛选', description: '过滤不满足学历要求的候选人', enabled: true },
{ id: 'af-2', name: '经验硬性筛选', description: '过滤不满足经验年限的候选人', enabled: true },
{ id: 'af-3', name: '薪资期望筛选', description: '过滤薪资期望超出预算上限的候选人', enabled: false },
{ id: 'af-4', name: '地域匹配筛选', description: '优先推荐期望城市与岗位城市一致的候选人', enabled: true },
{ id: 'af-5', name: '黑名单过滤', description: '自动过滤存在诚信问题的候选人', enabled: true },
],
urgentPriority: true,
campusPriority: false,
notifyNewDelivery: true,
};
/**
* 获取运营规则配置
* @method GET
* @url /api/operation/rules
* @returns Promise<OperationRules> 运营规则配置
*/
export async function fetchOperationRules(): Promise<OperationRules> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/operation/rules`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_OPERATION_RULES });
}
/**
* 保存运营规则配置
* @method POST
* @url /api/operation/rules
* @param rules { OperationRules } 运营规则配置
* @returns Promise<OperationRules> 保存后的配置
*/
export async function saveOperationRules(rules: OperationRules): Promise<OperationRules> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/operation/rules`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(rules),
// });
// return res.json();
return mockRequest({ ...rules });
}
/* ===================== 数据审计报告 ===================== */
/** 数据审计报告 mock */
const MOCK_AUDIT_REPORT: AuditReport = {
period: '2024.09.01 - 2024.09.30',
grade: 'A',
gradeDesc: '平台整体风险可控,今日拦截异常 3 次',
todayBlocked: 3,
kpis: [
{ id: 'kpi-1', label: '风险识别数', value: 47, change: 12, unit: '个', icon: 'alert-circle-outline', color: colors.danger },
{ id: 'kpi-2', label: '审计覆盖记录', value: 128, change: 8, unit: '万', icon: 'document-text-outline', color: colors.primary },
{ id: 'kpi-3', label: '待处理异常', value: 15, change: -3, unit: '个', icon: 'time-outline', color: colors.compareOrange },
{ id: 'kpi-4', label: '合规评分', value: 98, change: 2, unit: '分', icon: 'shield-checkmark-outline', color: colors.success },
],
threatTrend: [
{ date: '09/01', riskCount: 4, handledCount: 3 },
{ date: '09/05', riskCount: 6, handledCount: 5 },
{ date: '09/09', riskCount: 3, handledCount: 3 },
{ date: '09/13', riskCount: 8, handledCount: 6 },
{ date: '09/17', riskCount: 5, handledCount: 5 },
{ date: '09/21', riskCount: 7, handledCount: 7 },
{ date: '09/25', riskCount: 4, handledCount: 4 },
{ date: '09/29', riskCount: 6, handledCount: 6 },
],
riskDistribution: [
{ id: 'rd-1', label: '虚假简历', percent: 35, color: colors.primary },
{ id: 'rd-2', label: '违规岗位', percent: 25, color: colors.danger },
{ id: 'rd-3', label: '异常沟通', percent: 20, color: colors.compareOrange },
{ id: 'rd-4', label: '刷量行为', percent: 15, color: colors.info },
{ id: 'rd-5', label: '其他', percent: 5, color: colors.textHint },
],
};
/**
* 获取数据审计报告
* @method GET
* @url /api/operation/audit-report
* @returns Promise<AuditReport> 数据审计报告
*/
export async function fetchAuditReport(): Promise<AuditReport> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/operation/audit-report`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_AUDIT_REPORT });
}
/* ===================== 企业认证 ===================== */
/** 企业认证权益 */
const MOCK_ENTERPRISE_CERTIFICATION_BENEFITS: EnterpriseCertificationBenefit[] = [
{ id: 'b1', text: '主页展示企业认证标识,提升信任度' },
{ id: 'b2', text: '发布岗位数量上限提升 50%' },
{ id: 'b3', text: '优先获得优质候选人推荐' },
{ id: 'b4', text: '解锁数据分析与风控报告' },
{ id: 'b5', text: '专属客服 1 对 1 服务' },
];
/** 企业认证默认数据 */
const MOCK_ENTERPRISE_CERTIFICATION: EnterpriseCertificationData = {
status: 'unverified',
statusText: '完成企业认证,享受更多招聘权益',
benefits: MOCK_ENTERPRISE_CERTIFICATION_BENEFITS,
form: {
companyName: '',
creditCode: '',
contactName: '',
contactPhone: '',
},
};
/**
* 获取企业认证信息
* @method GET
* @url /api/enterprise/certification
* @returns Promise<EnterpriseCertificationData> 企业认证数据
*/
export async function fetchEnterpriseCertification(): Promise<EnterpriseCertificationData> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_ENTERPRISE_CERTIFICATION });
}
/**
* 提交企业认证
* @method POST
* @url /api/enterprise/certification
* @param form { EnterpriseCertificationForm } 认证表单
* @returns Promise<{ success: boolean; status: EnterpriseCertificationStatus }> 提交结果
*/
export async function submitEnterpriseCertification(
form: EnterpriseCertificationForm,
): Promise<{ success: boolean; status: EnterpriseCertificationStatus }> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/certification`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form),
// });
// return res.json();
void form;
return mockRequest({ success: true, status: 'pending' });
}
/* ===================== 霍兰德测评介绍 ===================== */
const MOCK_HOLLAND_INTRO: HollandIntro = {
title: '霍兰德职业兴趣测评',
subtitle: '发现你的职业兴趣类型,找到最适合你的职业方向',
totalParticipants: HOLLAND_TOTAL_PARTICIPANTS,
duration: '约10分钟',
dimensions: [
{ type: 'R', name: '现实型', desc: '喜欢动手操作与具体任务' },
{ type: 'I', name: '研究型', desc: '喜欢分析思考与探索未知' },
{ type: 'A', name: '艺术型', desc: '喜欢创意表达与自由创作' },
{ type: 'S', name: '社会型', desc: '喜欢与人交往帮助他人' },
{ type: 'E', name: '企业型', desc: '喜欢组织领导与影响他人' },
{ type: 'C', name: '常规型', desc: '喜欢规范有序的事务处理' },
],
features: [
{ id: 'dim', icon: 'apps-outline', title: '6大兴趣维度', desc: '现实型、研究型、艺术型、社会型、企业型、常规型' },
{ id: 'time', icon: 'time-outline', title: '约10分钟', desc: '60道题目,利用碎片时间即可完成' },
{ id: 'rec', icon: 'locate-outline', title: '精准推荐', desc: '基于测评结果推荐匹配度高的岗位' },
],
advantages: [
'专业心理学理论支撑',
'个性化职业推荐报告',
'不限次数免费重做',
'结果永久保存',
],
};
/**
* 获取霍兰德职业兴趣测评介绍
* @method GET
* @url /api/growth/holland-intro
* @returns Promise<HollandIntro> 测评介绍
*/
export async function fetchHollandIntro(): Promise<HollandIntro> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/growth/holland-intro`, { method: 'GET' });
// return res.json();
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');
}
/* ===================== 岗位管理(企业端) ===================== */
/** 岗位管理的 Tab 定义(key → 文案)。计数不写死,由 buildJobManagementTabs 从列表算 */
const JOB_MANAGEMENT_TABS: { key: string; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'active', label: '招聘中' },
{ key: 'offline', label: '已下线' },
{ key: 'draft', label: '草稿' },
];
/**
* 12 个岗位与 Tab 计数同源:此前 mock 只有 3 个岗位却写着「全部 12 / 已下线 3」,
* 点「已下线(3)」会看到空列表 —— 数字与数据对不上。这里补齐 12 条并让计数由列表推导。
*/
const MOCK_JOB_MANAGEMENT_JOBS: JobManagementJob[] = [
{
id: 'job-1',
title: '高级前端工程师',
salary: '25-35K',
salaryMonths: 14,
city: '北京',
experience: '3-5年',
education: '本科',
tags: ['React', 'TypeScript', 'Node.js'],
status: 'active',
department: '技术部',
headcount: 2,
overallScore: 88,
rankText: '同岗排名 Top 3%',
applicants: 128,
views: 2300,
matches: 56,
publishedAt: '2026-09-15T09:30:00.000Z',
},
{
id: 'job-2',
title: '产品经理',
salary: '20-30K',
salaryMonths: 13,
city: '北京',
experience: '3-5年',
education: '本科',
tags: ['B端', '数据产品'],
status: 'active',
department: '产品部',
headcount: 1,
overallScore: 75,
rankText: '同岗排名 Top 25%',
applicants: 86,
views: 1800,
matches: 32,
publishedAt: '2026-09-12T10:10:00.000Z',
},
{
id: 'job-3',
title: 'Java后端开发',
salary: '30-45K',
salaryMonths: 15,
city: '北京',
experience: '5-10年',
education: '本科',
tags: ['Java', 'Spring Boot', 'MySQL'],
status: 'active',
department: '技术部',
headcount: 3,
overallScore: 92,
rankText: '同岗排名 Top 2%',
applicants: 156,
views: 3100,
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,
}));
}
/**
* 在招岗位数(`status === 'active'`)。
* 岗位管理页顶部概览与企业总览共用 —— 两处各算一次就会各有一个数。
*/
function countActiveJobs(jobs: JobManagementJob[]): number {
return jobs.filter((job) => job.status === 'active').length;
}
/**
* 顶部数据概览。
* 「在招岗位」由列表实时算出 —— 下架一个岗位,这里要跟着减 1,不能是写死的 12。
* 其余三项依赖后端投递/面试数据,暂沿用 mock 文案。
*/
function buildJobManagementStats(jobs: JobManagementJob[]): JobManagement['stats'] {
const activeCount = countActiveJobs(jobs);
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: ENTERPRISE_DISPLAY_NAME,
hasData: true,
stats: buildJobManagementStats(jobs),
tabs: buildJobManagementTabs(jobs),
jobs,
benefits: [
'接收候选人主动投递',
'使用AI智能匹配推荐人才',
'在线沟通与面试安排',
'查看岗位竞争力排名',
'获取招聘数据看板',
],
};
}
/**
* 获取企业端岗位管理数据
* @method GET
* @url /api/enterprise/job-management
* @returns Promise<JobManagement> 岗位管理数据(tabs 计数由 jobs 推导,保证一致)
* @remark 返回的是**全量**岗位,筛选/排序由 filterJobManagementJobs 在前端做;
* 后端就绪后可改为把 tabKey/keyword/sortKey 作为查询参数下推,函数签名不变
*/
export async function fetchJobManagement(): Promise<JobManagement> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/job-management`, { method: 'GET' });
// return res.json();
return mockRequest(await buildJobManagement());
}
/* ===================== 人才推荐(企业端) ===================== */
/** 「匹配度高」的分数线 */
const HIGH_MATCH_PERCENT = 90;
/** 985/211 院校名单(用于「985/211」筛选) */
const KEY_SCHOOLS = new Set([
'北京大学',
'清华大学',
'上海交通大学',
'浙江大学',
'南京大学',
'哈尔滨工业大学',
'北京邮电大学',
]);
/** 「5年经验」→ 5;解析不出时返回 0(按不满足处理) */
function parseExperienceYears(experience: string): number {
const matched = /(\d+)\s*年/.exec(experience);
return matched ? Number(matched[1]) : 0;
}
/** 候选人是否命中某个 Tab(与 TalentRecommendationTab 的 key 一一对应) */
function matchTalentTab(candidate: TalentCandidate, tabKey: string): boolean {
switch (tabKey) {
case 'high':
return candidate.matchPercent >= HIGH_MATCH_PERCENT;
case '985211':
return KEY_SCHOOLS.has(candidate.school);
case '5years':
return parseExperienceYears(candidate.experience) >= 5;
case 'master':
return candidate.education !== '本科';
case 'all':
default:
return true;
}
}
/**
* 按 Tab 与关键词过滤候选人(纯函数)。
* @param candidates 全部候选人
* @param tabKey Tab key('all' 表示不过滤)
* @param keyword 搜索关键词,匹配姓名 / 职位 / 技能标签 / 学校;空串不过滤
* @returns TalentCandidate[] 命中的候选人
*/
export function filterTalentCandidates(
candidates: TalentCandidate[],
tabKey: string,
keyword: string,
): TalentCandidate[] {
const kw = keyword.trim().toLowerCase();
return candidates.filter((c) => {
if (!matchTalentTab(c, tabKey)) return false;
if (kw === '') return true;
return (
c.name.toLowerCase().includes(kw) ||
c.title.toLowerCase().includes(kw) ||
c.school.toLowerCase().includes(kw) ||
c.tags.some((tag) => tag.toLowerCase().includes(kw))
);
});
}
/** Tab 计数由候选人列表推导,避免页面上显示的数字和列表里的人数对不上 */
function buildTalentTabs(candidates: TalentCandidate[]): TalentRecommendationTab[] {
const countOf = (tabKey: string) =>
candidates.filter((c) => matchTalentTab(c, tabKey)).length;
return [
{ key: 'all', label: '全部', count: candidates.length },
{ key: 'high', label: '匹配度高', count: countOf('high') },
{ key: '985211', label: '985/211', count: countOf('985211') },
{ key: '5years', label: '5年以上', count: countOf('5years') },
{ key: 'master', label: '硕士及以上', count: countOf('master') },
];
}
/** 推荐候选人列表(上面那份 mock 的原始数据,Tab 计数与 todayCount 都从它推导) */
const MOCK_TALENT_CANDIDATES: TalentCandidate[] = [
{
id: 'cand-1',
name: '张明远',
avatar: '张',
title: '高级前端工程师 · 5年经验',
experience: '5年经验',
age: 28,
school: '北京大学',
city: '北京',
expectedSalary: '期望薪资 30-35K',
matchPercent: 96,
overallScore: 88,
percentile: 99,
tags: ['React', 'TypeScript', 'Node.js'],
education: '硕士',
},
{
id: 'cand-2',
name: '李雨桐',
avatar: '李',
title: '产品经理 · 3年经验',
experience: '3年经验',
age: 26,
school: '上海交通大学',
city: '北京',
expectedSalary: '期望薪资 25-30K',
matchPercent: 82,
overallScore: 75,
percentile: 75,
tags: ['大厂背景', '项目经验', '沟通能力强'],
education: '本科',
},
{
id: 'cand-3',
name: '王建国',
avatar: '王',
title: 'Java后端开发 · 7年经验',
experience: '7年经验',
age: 33,
school: '浙江大学',
city: '北京',
expectedSalary: '期望薪资 35-45K',
matchPercent: 91,
overallScore: 92,
percentile: 92,
tags: ['技术扎实', '经验丰富', '高绩效'],
education: '本科',
},
{
id: 'cand-4',
name: '陈思远',
avatar: '陈',
title: '资深前端工程师 · 6年经验',
experience: '6年经验',
age: 30,
school: '清华大学',
city: '北京',
expectedSalary: '期望薪资 40-50K',
matchPercent: 94,
overallScore: 90,
percentile: 96,
tags: ['架构经验', '跨端开发', '带团队'],
education: '硕士',
},
{
id: 'cand-5',
name: '赵敏',
avatar: '赵',
title: '数据分析师 · 4年经验',
experience: '4年经验',
age: 27,
school: '南京大学',
city: '上海',
expectedSalary: '期望薪资 25-32K',
matchPercent: 88,
overallScore: 84,
percentile: 88,
tags: ['SQL', 'Python', '业务理解'],
education: '硕士',
},
{
id: 'cand-6',
name: '孙浩',
avatar: '孙',
title: '测试开发工程师 · 5年经验',
experience: '5年经验',
age: 29,
school: '北京邮电大学',
city: '北京',
expectedSalary: '期望薪资 22-28K',
matchPercent: 79,
overallScore: 78,
percentile: 72,
tags: ['自动化测试', '性能测试'],
education: '本科',
},
{
id: 'cand-7',
name: '周雨',
avatar: '周',
title: 'UI 设计师 · 8年经验',
experience: '8年经验',
age: 34,
school: '中国美术学院',
city: '杭州',
expectedSalary: '期望薪资 28-35K',
matchPercent: 76,
overallScore: 80,
percentile: 70,
tags: ['设计系统', 'B 端设计'],
education: '本科',
},
{
id: 'cand-8',
name: '吴桐',
avatar: '吴',
title: '算法工程师 · 3年经验',
experience: '3年经验',
age: 26,
school: '哈尔滨工业大学',
city: '深圳',
expectedSalary: '期望薪资 35-45K',
matchPercent: 85,
overallScore: 86,
percentile: 90,
tags: ['推荐算法', '论文产出'],
education: '博士',
},
];
const MOCK_TALENT_RECOMMENDATION: TalentRecommendation = {
companyName: ENTERPRISE_DISPLAY_NAME,
hasData: true,
todayCount: MOCK_TALENT_CANDIDATES.length,
tabs: buildTalentTabs(MOCK_TALENT_CANDIDATES),
candidates: MOCK_TALENT_CANDIDATES,
poolStats: [
{ id: 'active', value: '12,580', label: '活跃候选人' },
{ id: 'new', value: '326', label: '今日新增' },
{ id: 'matched', value: '4,820', label: '匹配人才' },
],
recommendedActions: [
'降低学历要求,候选人数 +35%',
'放宽经验要求,候选人数 +50%',
'开启广泛模式,获取更多推荐',
'发布更多岗位信息,吸引主动投递',
],
dataReference: [
{ label: '主动联系简历数比被动接收高', value: '60%' },
{ label: '24小时内回复的面试转化率提升', value: '2.3倍' },
{ label: '完善企业资料可提升候选人信任度', value: '45%' },
],
};
/**
* 获取企业端人才推荐数据
* @method GET
* @url /api/enterprise/talent-recommendation
* @returns Promise<TalentRecommendation> 人才推荐数据
*/
export async function fetchTalentRecommendation(): Promise<TalentRecommendation> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/talent-recommendation`, { method: 'GET' });
// return res.json();
return mockRequest(MOCK_TALENT_RECOMMENDATION);
}
/* ===================== 企业沟通 ===================== */
/** 企业沟通会话列表(status 决定落在哪个 Tab 下) */
const MOCK_ENTERPRISE_CHATS: EnterpriseChatItem[] = [
{
id: 'chat-1',
userId: 'u-1',
name: '张明远',
avatar: '张',
title: '高级前端工程师 · 最近活跃',
lastMessage: '你的简历很符合我们的要求,方便下周面试吗?',
time: '10分钟前',
unreadCount: 1,
labels: [],
status: 'pending',
},
{
id: 'chat-2',
userId: 'u-2',
name: '李雨桐',
avatar: '李',
title: '产品经理',
lastMessage: '你好,我看贵司的产品经理岗位很匹配,想进一步沟通。',
time: '30分钟前',
unreadCount: 1,
labels: [],
status: 'pending',
},
{
id: 'chat-3',
userId: 'u-3',
name: '王建国',
avatar: '王',
title: 'Java后端开发',
lastMessage: '已收到面试邀请,确认参加。',
time: '2小时前',
unreadCount: 0,
labels: ['面试待评分'],
status: 'interview',
},
{
id: 'chat-4',
userId: 'u-4',
name: '陈思远',
avatar: '陈',
title: '资深前端工程师',
lastMessage: '面试时间我这边可以,麻烦发个会议链接。',
time: '3小时前',
unreadCount: 1,
labels: ['面试待评分'],
status: 'interview',
},
{
id: 'chat-5',
userId: 'u-5',
name: '赵敏',
avatar: '赵',
title: '数据分析师',
lastMessage: '薪资和期权方案我这边基本认可,什么时候可以发正式 offer?',
time: '昨天',
unreadCount: 0,
labels: ['Offer沟通'],
status: 'offer',
},
{
id: 'chat-6',
userId: 'u-6',
name: '孙浩',
avatar: '孙',
title: '测试开发工程师',
lastMessage: '考虑后决定接受另一家的机会,感谢贵司的认可。',
time: '3天前',
unreadCount: 0,
labels: ['已归档'],
status: 'closed',
},
{
id: 'chat-7',
userId: 'u-7',
name: '周雨',
avatar: '周',
title: 'UI 设计师',
lastMessage: '作品集已更新,麻烦再看看新的三个案例。',
time: '4天前',
unreadCount: 0,
labels: ['已归档'],
status: 'closed',
},
{
id: 'chat-8',
userId: 'u-8',
name: '吴桐',
avatar: '吴',
title: '运维工程师',
lastMessage: '这边流程走完了,简历先留档,后续有合适岗位再联系。',
time: '5天前',
unreadCount: 0,
labels: ['已归档'],
status: 'closed',
},
{
id: 'chat-9',
userId: 'u-9',
name: '郑楠',
avatar: '郑',
title: '算法工程师',
lastMessage: '面试未通过,简历已进人才库。',
time: '上周',
unreadCount: 0,
labels: ['已归档'],
status: 'closed',
},
];
/** 三个待办任务的计数由会话列表推导(业务规则放接口层,避免页面上写死徽标数字) */
function buildPendingTasks(chats: EnterpriseChatItem[]): PendingTask[] {
return [
{
id: 'reply',
label: '待回复',
// 这张卡片点下去会切到「未回复」Tab(见下方 tabKey),所以数字必须与那个 Tab 的计数
// 同源,否则点进去只会看到条数凭空变少。原先统计的是「有未读消息的会话数」,
// 与「未回复」(status === 'pending')根本不是同一个集合。
count: chats.filter((c) => c.status === 'pending').length,
hint: '24小时内未回复',
icon: 'chatbubble-ellipses-outline',
color: 'action',
// 点「待回复」切到「未回复」Tab,别让带箭头的卡片点下去什么也不发生
tabKey: 'pending',
},
{
id: 'score',
label: '待评分',
count: chats.filter((c) => c.labels.includes('面试待评分')).length,
hint: '面试结束待及时评分',
icon: 'star-outline',
color: 'compareOrange',
route: '/interview-score',
},
];
}
/** Tab 计数同样由会话列表推导 */
function buildCommunicationTabs(chats: EnterpriseChatItem[]): EnterpriseCommunicationTab[] {
const countOf = (status: EnterpriseChatStatus) =>
chats.filter((c) => c.status === status).length;
return [
{ key: 'all', label: '全部', count: chats.length },
{ key: 'pending', label: '未回复', count: countOf('pending') },
{ key: 'interview', label: '面试邀请', count: countOf('interview') },
{ key: 'offer', label: 'Offer沟通', count: countOf('offer') },
{ key: 'closed', label: '已归档', count: countOf('closed') },
];
}
const MOCK_ENTERPRISE_COMMUNICATION: EnterpriseCommunication = {
companyName: ENTERPRISE_DISPLAY_NAME,
hasData: true,
pendingTasks: buildPendingTasks(MOCK_ENTERPRISE_CHATS),
tabs: buildCommunicationTabs(MOCK_ENTERPRISE_CHATS),
chats: MOCK_ENTERPRISE_CHATS,
dataReference: [
{ label: '主动联系简历数比被动接收高', value: '60%' },
{ label: '24小时内回复的面试转化率提升', value: '2.3倍' },
{ label: '完善企业资料可提升候选人信任度', value: '45%' },
],
};
/**
* 获取企业端沟通数据
* @method GET
* @url /api/enterprise/communication
* @returns Promise<EnterpriseCommunication> 沟通数据
*/
export async function fetchEnterpriseCommunication(): Promise<EnterpriseCommunication> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/communication`, { method: 'GET' });
// return res.json();
return mockRequest(MOCK_ENTERPRISE_COMMUNICATION);
}
/**
* 按 Tab 与关键词过滤沟通会话(纯函数,页面只渲染结果)。
* @param chats 全部会话
* @param tabKey Tab key('all' 表示不过滤)
* @param keyword 搜索关键词,匹配姓名 / 职位 / 最后一条消息;空串不过滤
* @returns EnterpriseChatItem[] 命中的会话
*/
export function filterEnterpriseChats(
chats: EnterpriseChatItem[],
tabKey: string,
keyword: string,
): EnterpriseChatItem[] {
const kw = keyword.trim().toLowerCase();
return chats.filter((chat) => {
if (tabKey !== 'all' && chat.status !== tabKey) return false;
if (kw === '') return true;
return (
chat.name.toLowerCase().includes(kw) ||
chat.title.toLowerCase().includes(kw) ||
chat.lastMessage.toLowerCase().includes(kw)
);
});
}
/* ===================== 岗位评价管理空状态 ===================== */
const MOCK_JOB_EVALUATION_EMPTY: JobEvaluationEmptyState = {
hasData: true,
tabs: [
{ key: 'all', label: '全部', count: 0 },
{ key: 'positive', label: '好评', count: 0 },
{ key: 'neutral', label: '中评', count: 0 },
{ key: 'negative', label: '差评', count: 0 },
{ key: 'pending', label: '待回复', count: 0 },
],
emptyTitle: '还没有收到评价',
emptySubtitle: '候选人面试后可以对岗位进行评价,评价越多,企业可信度越高',
tips: [
'及时回复候选人消息',
'面试安排专业有序',
'薪资福利透明公开',
'真诚对待每一位候选人',
],
};
/**
* 获取岗位评价管理空状态数据
* @method GET
* @url /api/enterprise/job-evaluation-empty
* @returns Promise<JobEvaluationEmptyState> 空状态数据
*/
export async function fetchJobEvaluationEmptyState(): Promise<JobEvaluationEmptyState> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/enterprise/job-evaluation-empty`, { method: 'GET' });
// return res.json();
return mockRequest(MOCK_JOB_EVALUATION_EMPTY);
}
/* ===================== 选择身份 ===================== */
/** 身份选项(C 端求职者 / 企业端 HR) */
const MOCK_IDENTITY_OPTIONS: IdentityOption[] = [
{
id: 'candidate',
title: '我是求职者',
desc: '浏览岗位、查看公司评分、对比 Offer、提升竞争力',
icon: 'school-outline',
role: 'candidate',
},
{
id: 'hr',
title: '我是企业 / HR',
desc: '发布岗位、筛选人才、管理评价、展示企业形象',
icon: 'briefcase-outline',
role: 'enterprise',
},
];
/**
* 获取身份选项(首次启动 / 身份切换)
* @method GET
* @url /api/tools/identity-options
* @returns Promise<IdentityOption[]> 身份选项数组
*/
export async function fetchIdentityOptions(): Promise<IdentityOption[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/tools/identity-options`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_IDENTITY_OPTIONS);
}
/* ===================== 通关小游戏 ===================== */
/** 通关小游戏 7 关配置 */
const MOCK_GAME_LEVELS: GameLevel[] = [
{
id: 'self',
order: 1,
title: '认识自己',
subtitle: '性格与基本信息',
icon: 'person-outline',
description: '收集性格、年龄、学历、专业、学校、住址、兴趣爱好等基本信息,建立求职画像。',
nextLevel: 'ability',
questions: [
{
id: 'self-1',
title: '你目前最高学历是?',
type: 'single',
options: [
{ id: 'a', text: '高中/中专及以下', isCorrect: false },
{ id: 'b', text: '大专', isCorrect: false },
{ id: 'c', text: '本科', isCorrect: true },
{ id: 'd', text: '硕士及以上', isCorrect: true },
],
explanation: '学历是岗位筛选的重要门槛,准确填写有助于平台匹配对应层级的岗位。',
tags: ['学历'],
},
{
id: 'self-2',
title: '你希望未来 3 年在哪个城市工作?',
type: 'input',
placeholder: '例如:北京、上海、杭州',
explanation: '城市选择直接影响薪资预期与生活成本,建议结合行业聚集地进行选择。',
tags: ['城市'],
},
{
id: 'self-3',
title: '你最擅长的 3 项技能或优势是什么?',
type: 'input',
placeholder: '如:文案写作、数据分析、沟通协调',
explanation: '清晰描述自身优势,是定位岗位与撰写简历的第一步,建议结合具体事例。',
tags: ['自我认知'],
},
{
id: 'self-4',
title: '以下哪些做法有助于明确职业方向?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '做职业测评并结合兴趣', isCorrect: true },
{ id: 'b', text: '通过实习/兼职真实体验', isCorrect: true },
{ id: 'c', text: '盲目跟风最热门的岗位', isCorrect: false },
{ id: 'd', text: '完全由他人替你决定', isCorrect: false },
],
explanation: '测评、实践与自我反思结合,才能找到真正匹配的方向;盲从他人容易走偏。',
tags: ['职业方向'],
},
],
},
{
id: 'ability',
order: 2,
title: '认识能力',
subtitle: '技能与成果',
icon: 'barbell-outline',
description: '明确自身具备的技能与可提升空间,包括项目开发、竞赛比赛、奖项、技能等维度。',
nextLevel: 'job',
questions: [
{
id: 'ability-1',
title: '以下哪些属于可写入简历的硬技能证明?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: 'GitHub 开源项目', isCorrect: true },
{ id: 'b', text: '学科竞赛获奖', isCorrect: true },
{ id: 'c', text: '微信朋友圈活跃', isCorrect: false },
{ id: 'd', text: '专业认证/资格证书', isCorrect: true },
],
explanation: '开源项目、竞赛获奖、专业认证都是简历中极具说服力的硬技能证明。',
tags: ['技能证明'],
},
{
id: 'ability-2',
title: '以下哪些方式能有效提升简历的含金量?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '参与真实项目并沉淀成果', isCorrect: true },
{ id: 'b', text: '考取行业权威证书', isCorrect: true },
{ id: 'c', text: '撰写技术博客 / 作品集', isCorrect: true },
{ id: 'd', text: '花钱买来路不明的"水证"', isCorrect: false },
],
explanation: '真实项目、权威证书与可展示的作品,是简历含金量最硬的支撑。',
tags: ['能力提升'],
},
{
id: 'ability-3',
title: '描述一段经历时,最能体现能力的写法是?',
type: 'single',
options: [
{ id: 'a', text: '只写"负责相关工作"', isCorrect: false },
{ id: 'b', text: '用 STAR 法则并量化结果', isCorrect: true },
{ id: 'c', text: '堆砌"吃苦耐劳"等形容词', isCorrect: false },
{ id: 'd', text: '直接复制他人经历', isCorrect: false },
],
explanation: '情境-任务-行动-结果 + 数据,让能力可被验证。',
tags: ['能力表达'],
},
],
},
{
id: 'job',
order: 3,
title: '认识岗位',
subtitle: '职责与发展',
icon: 'briefcase-outline',
description: '了解不同岗位的职责要求、发展路径和薪资水平,明确目标岗位。',
nextLevel: 'company',
questions: [
{
id: 'job-1',
title: '在筛选岗位时,最应优先关注的是?',
type: 'single',
options: [
{ id: 'a', text: '公司离地铁站的距离', isCorrect: false },
{ id: 'b', text: '岗位 JD 中的核心技能要求', isCorrect: true },
{ id: 'c', text: '办公室装修风格', isCorrect: false },
{ id: 'd', text: '招聘人员的头像', isCorrect: false },
],
explanation: '岗位 JD 中的核心技能要求决定了你是否能胜任,是筛选岗位的首要依据。',
tags: ['岗位理解'],
},
{
id: 'job-2',
title: '阅读 JD(岗位描述)时,应重点提取哪些信息?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '核心职责与任职要求', isCorrect: true },
{ id: 'b', text: '薪资范围与结构', isCorrect: true },
{ id: 'c', text: '发展空间与汇报线', isCorrect: true },
{ id: 'd', text: '公司团建照片', isCorrect: false },
],
explanation: '职责、要求、薪资、发展是判断"该不该投"的关键。',
tags: ['岗位理解'],
},
{
id: 'job-3',
title: '你最想从事的岗位名称是?',
type: 'input',
placeholder: '如:前端工程师 / 产品经理',
explanation: '明确目标岗位名,才能精准匹配 JD 并针对性准备面试。',
tags: ['目标岗位'],
},
],
},
{
id: 'company',
order: 4,
title: '认识企业',
subtitle: '文化与前景',
icon: 'business-outline',
description: '了解目标企业的背景、文化、发展前景,学会通过公开信息判断企业是否靠谱。',
nextLevel: 'resume',
questions: [
{
id: 'company-1',
title: '判断一家企业是否靠谱,最不值得参考的是?',
type: 'single',
options: [
{ id: 'a', text: '工商注册信息与经营年限', isCorrect: false },
{ id: 'b', text: '员工真实评价与离职率', isCorrect: false },
{ id: 'c', text: '公司官网口号与宣传视频', isCorrect: true },
{ id: 'd', text: '融资阶段与核心业务', isCorrect: false },
],
explanation: '官网口号往往经过包装,工商信息、员工评价、业务健康度才是判断企业的硬指标。',
tags: ['企业判断'],
},
{
id: 'company-2',
title: '以下哪些是判断企业风险的信号?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '频繁大规模裁员', isCorrect: true },
{ id: 'b', text: '社保公积金按最低基数缴纳', isCorrect: true },
{ id: 'c', text: '劳动纠纷频发', isCorrect: true },
{ id: 'd', text: '公司前台装修豪华', isCorrect: false },
],
explanation: '裁员、社保、纠纷是真实风险信号,装修不能代表经营健康。',
tags: ['风险识别'],
},
{
id: 'company-3',
title: '了解一家公司真实口碑,最靠谱的渠道是?',
type: 'single',
options: [
{ id: 'a', text: '公司官网与招聘海报', isCorrect: false },
{ id: 'b', text: '在职/离职员工匿名评价 + 脉脉/看准等平台', isCorrect: true },
{ id: 'c', text: '公司年会视频', isCorrect: false },
{ id: 'd', text: '老板个人朋友圈', isCorrect: false },
],
explanation: '一线员工的真实评价,比官方宣传更接近事实。',
tags: ['口碑调研'],
},
],
},
{
id: 'resume',
order: 5,
title: '认识简历',
subtitle: '制作与优化',
icon: 'document-text-outline',
description: '借助 AI 助手了解简历制作的过程及要求,学习如何写出高通过率的简历。',
nextLevel: 'interview',
questions: [
{
id: 'resume-1',
title: '简历中描述项目经历的最佳写法是?',
type: 'single',
options: [
{ id: 'a', text: '负责前端开发', isCorrect: false },
{ id: 'b', text: '使用 STAR 法则,量化结果', isCorrect: true },
{ id: 'c', text: '罗列所有课程名称', isCorrect: false },
{ id: 'd', text: '写满三页纸', isCorrect: false },
],
explanation: 'STAR 法则(情境-任务-行动-结果)加量化数据,是项目经历最说服人的写法。',
tags: ['简历'],
},
{
id: 'resume-2',
title: '简历中应避免哪些问题?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '错别字与排版混乱', isCorrect: true },
{ id: 'b', text: '虚构或夸大经历', isCorrect: true },
{ id: 'c', text: '与岗位无关内容过多', isCorrect: true },
{ id: 'd', text: '用数据量化成果', isCorrect: false },
],
explanation: '错字、造假、跑题会直接减分;量化成果才是加分项。',
tags: ['简历避坑'],
},
{
id: 'resume-3',
title: '针对不同岗位投递,简历应?',
type: 'single',
options: [
{ id: 'a', text: '一份简历投遍所有岗位', isCorrect: false },
{ id: 'b', text: '按 JD 关键词针对性修改', isCorrect: true },
{ id: 'c', text: '大量使用花哨模板', isCorrect: false },
{ id: 'd', text: '隐瞒关键经历', isCorrect: false },
],
explanation: '针对性修改能显著提升匹配度与通过率。',
tags: ['简历优化'],
},
],
},
{
id: 'interview',
order: 6,
title: '认识面试',
subtitle: '模拟与技巧',
icon: 'chatbubbles-outline',
description: '借助 AI 助手开展模拟面试,提前熟悉真实面试场景和常见问题。',
nextLevel: 'offer',
questions: [
{
id: 'interview-1',
title: '面试中被问到「你最大的缺点是什么」,合适的回答是?',
type: 'single',
options: [
{ id: 'a', text: '我没有缺点', isCorrect: false },
{ id: 'b', text: '选择一个真实但无关岗位核心能力的缺点,并说明改进措施', isCorrect: true },
{ id: 'c', text: '抱怨上一家公司', isCorrect: false },
{ id: 'd', text: '沉默不答', isCorrect: false },
],
explanation: '坦诚展示自我认知与改进计划,能体现你的成长型思维。',
tags: ['面试'],
},
{
id: 'interview-2',
title: '面试前应如何准备?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '研究公司与岗位背景', isCorrect: true },
{ id: 'b', text: '准备 STAR 案例', isCorrect: true },
{ id: 'c', text: '模拟常见题型', isCorrect: true },
{ id: 'd', text: '完全不准备靠临场发挥', isCorrect: false },
],
explanation: '研究、案例、模拟三者结合,临场更稳。',
tags: ['面试准备'],
},
{
id: 'interview-3',
title: '被问期望薪资时,合适的做法是?',
type: 'single',
options: [
{ id: 'a', text: '完全不敢谈,随便应允', isCorrect: false },
{ id: 'b', text: '提前调研行业薪资区间后给范围', isCorrect: true },
{ id: 'c', text: '直接报一个离谱高价', isCorrect: false },
{ id: 'd', text: '说"你定就行"', isCorrect: false },
],
explanation: '基于调研给区间,既体现专业也保护自身权益。',
tags: ['薪资谈判'],
},
],
},
{
id: 'offer',
order: 7,
title: '认识签约',
subtitle: 'Offer 与入职',
icon: 'file-tray-full-outline',
description: '学习 Offer 选择、入职准备、试用期注意事项、合同签订等关键环节。',
questions: [
{
id: 'offer-1',
title: '签订劳动合同前,必须确认的关键条款不包括?',
type: 'single',
options: [
{ id: 'a', text: '薪资结构与发放时间', isCorrect: false },
{ id: 'b', text: '试用期时长与转正条件', isCorrect: false },
{ id: 'c', text: '公司团建频率', isCorrect: true },
{ id: 'd', text: '工作岗位与工作地点', isCorrect: false },
],
explanation: '团建频率不属于劳动合同必备条款,薪资、试用期、岗位地点才是核心。',
tags: ['签约'],
},
{
id: 'offer-2',
title: '签订劳动合同时应关注的合法权益包括?(多选)',
type: 'multiple',
options: [
{ id: 'a', text: '五险一金缴纳', isCorrect: true },
{ id: 'b', text: '试用期时长合规', isCorrect: true },
{ id: 'c', text: '加班与休假规定', isCorrect: true },
{ id: 'd', text: '公司团建频率', isCorrect: false },
],
explanation: '社保、试用期、工时休假是法定权益核心。',
tags: ['签约权益'],
},
{
id: 'offer-3',
title: '关于试用期,下列说法正确的是?',
type: 'single',
options: [
{ id: 'a', text: '试用期可随意辞退且不补偿', isCorrect: false },
{ id: 'b', text: '试用期最长不超 6 个月且须缴社保', isCorrect: true },
{ id: 'c', text: '试用期不缴社保是行业惯例合法', isCorrect: false },
{ id: 'd', text: '试用期长短由公司随便定', isCorrect: false },
],
explanation: '法律对试用期上限与社保有明确规定,不能随意。',
tags: ['试用期'],
},
],
},
];
/** 默认游戏进度:第一关可玩 */
const DEFAULT_GAME_PROGRESS: GameProgress = {
currentLevel: 'self',
unlockedLevels: ['self'],
results: [],
totalPercent: 0,
};
/** 内存中的游戏进度(mock 阶段用,刷新后重置) */
let MOCK_GAME_PROGRESS: GameProgress = { ...DEFAULT_GAME_PROGRESS };
/**
* 获取所有关卡配置
* @method GET
* @url /api/game/levels
* @returns Promise<GameLevel[]> 关卡数组
*/
export async function fetchGameLevels(): Promise<GameLevel[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/game/levels`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_GAME_LEVELS);
}
/**
* 获取当前游戏进度
* @method GET
* @url /api/game/progress
* @returns Promise<GameProgress> 游戏进度
*/
export async function fetchGameProgress(): Promise<GameProgress> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/game/progress`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_GAME_PROGRESS);
}
/**
* 提交关卡答案并计算结果
* @method POST
* @url /api/game/levels/:levelId/submit
* @param payload {SubmitGameLevelPayload} 关卡 id 与作答
* @returns Promise<GameProgress> 更新后的游戏进度
*/
export async function submitGameLevel(
payload: SubmitGameLevelPayload,
): Promise<GameProgress> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/game/levels/${payload.levelId}/submit`, { method: 'POST', body: JSON.stringify(payload) }); return res.json();
const level = MOCK_GAME_LEVELS.find((l) => l.id === payload.levelId);
if (!level) {
return mockRequest(MOCK_GAME_PROGRESS);
}
let correctCount = 0;
payload.answers.forEach((answer) => {
const question = level.questions.find((q) => q.id === answer.questionId);
if (!question) return;
if (question.type === 'input') {
// 输入题只要有内容即算完成(不判定对错)
if (answer.inputValue && answer.inputValue.trim().length > 0) correctCount += 1;
} else {
const correctIds =
question.options?.filter((o) => o.isCorrect).map((o) => o.id) ?? [];
const isCorrect =
correctIds.length > 0 &&
correctIds.length === answer.selectedIds.length &&
correctIds.every((id) => answer.selectedIds.includes(id));
if (isCorrect) correctCount += 1;
answer.isCorrect = isCorrect;
}
});
const score = level.questions.length > 0 ? Math.round((correctCount / level.questions.length) * 100) : 100;
const completed = score >= 60;
const existingIndex = MOCK_GAME_PROGRESS.results.findIndex((r) => r.levelId === payload.levelId);
const result: GameLevelResult = {
levelId: payload.levelId,
completed,
score,
answers: payload.answers,
completedAt: completed ? new Date().toISOString() : undefined,
};
if (existingIndex >= 0) {
MOCK_GAME_PROGRESS.results[existingIndex] = result;
} else {
MOCK_GAME_PROGRESS.results.push(result);
}
if (completed && level.nextLevel && !MOCK_GAME_PROGRESS.unlockedLevels.includes(level.nextLevel)) {
MOCK_GAME_PROGRESS.unlockedLevels.push(level.nextLevel);
MOCK_GAME_PROGRESS.currentLevel = level.nextLevel;
}
const completedCount = MOCK_GAME_PROGRESS.results.filter((r) => r.completed).length;
MOCK_GAME_PROGRESS.totalPercent = Math.round((completedCount / MOCK_GAME_LEVELS.length) * 100);
return mockRequest(MOCK_GAME_PROGRESS);
}
/**
* 重置游戏进度(仅 mock 阶段方便演示)
* @method POST
* @url /api/game/reset
* @returns Promise<GameProgress> 重置后的进度
*/
export async function resetGameProgress(): Promise<GameProgress> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/game/reset`, { method: 'POST' });
// return res.json();
MOCK_GAME_PROGRESS = { ...DEFAULT_GAME_PROGRESS };
return mockRequest(MOCK_GAME_PROGRESS);
}
/* ===================== 阿布阿诺 AI 法律服务助手 ===================== */
/** 阿布阿诺品牌信息 */
const MOCK_LEGAL_BRAND: LegalBrand = {
name: '阿布阿诺',
company: '阿布阿诺人工智能科技有限公司',
slogan: '求职路上,法律护航',
logo: '🛡️',
};
/** 法律服务入口列表 */
const MOCK_LEGAL_SERVICES: LegalService[] = [
{
id: 'free-consult',
title: '免费法律咨询',
description: '劳动权益、劳动合同、求职陷阱等基础法律问题快速解答。',
icon: 'chatbubble-ellipses-outline',
type: 'free',
action: 'chat',
},
{
id: 'free-templates',
title: '文书模板下载',
description: '劳动合同审查清单、离职申请书、劳动仲裁申请书等常用模板。',
icon: 'document-attach-outline',
type: 'free',
action: 'templates',
},
{
id: 'free-faq',
title: '常见问题速查',
description: '试用期被辞退、拖欠工资、不签劳动合同等高频问题。',
icon: 'help-circle-outline',
type: 'free',
action: 'faq',
},
{
id: 'member-lawyer',
title: '一对一律师咨询',
description: '专业劳动法律师在线答疑,定制解决方案。',
icon: 'people-outline',
type: 'member',
action: 'upgrade',
requestHint: '请简要描述你遇到的劳动法律问题,例如试用期被辞退、拖欠工资、未签劳动合同等,律师将在 24 小时内响应。',
},
{
id: 'member-review',
title: '合同/文书审核',
description: '劳动合同、竞业协议、保密协议等深度审核与风险提示。',
icon: 'reader-outline',
type: 'member',
action: 'upgrade',
requestHint: '请说明需要审核的文书类型与你重点关注的风险点,例如劳动合同中的违约金条款、竞业限制范围等。',
},
{
id: 'member-advisor',
title: '专属法律顾问',
description: '全年专属法律顾问,随时响应求职与职场法律问题。',
icon: 'shield-checkmark-outline',
type: 'member',
action: 'upgrade',
requestHint: '请描述你希望顾问长期跟进的事项,例如在职期间的权益保障、跳槽风险把控等。',
},
];
/** 法律常见问题 */
const MOCK_LEGAL_FAQS: LegalFAQ[] = [
{
id: 'faq-1',
question: '试用期被辞退,公司需要赔偿吗?',
answer: '如果公司无法证明你「不符合录用条件」,属于违法解除,可要求赔偿金(2N)或继续履行合同。',
category: '试用期',
},
{
id: 'faq-2',
question: '入职后公司一直不签劳动合同怎么办?',
answer: '自用工之日起超过一个月不满一年未签合同的,劳动者可主张每月二倍工资差额。',
category: '劳动合同',
},
{
id: 'faq-3',
question: '公司拖欠工资,我可以直接离职吗?',
answer: '用人单位未及时足额支付劳动报酬的,劳动者可以解除劳动合同并要求经济补偿金(N)。',
category: '工资',
},
{
id: 'faq-4',
question: 'Offer 上写的薪资和合同不一致,以哪个为准?',
answer: '一般以劳动合同为准,但 Offer、聊天记录、邮件等也可作为佐证。建议入职前要求书面确认薪资结构。',
category: 'Offer',
},
];
/** 法律文书模板 */
const MOCK_LEGAL_TEMPLATES: LegalTemplate[] = [
{
id: 'tmpl-1',
name: '劳动合同审查清单',
description: '入职前必查 12 项关键条款',
icon: 'checkbox-outline',
url: 'https://dev.api.com/legal/templates/contract-checklist',
content: `劳动合同审查清单(入职前必查 12 项)
1. 用人单位名称、法定代表人、注册地址是否与营业执照一致
2. 合同期限(固定/无固定/以完成一定工作任务为期限)与试用期约定是否合法(试用期不超合同期 1/12,且最长 6 个月)
3. 工作地点、岗位名称、工作内容是否明确,有无「公司可随意调岗」等霸王条款
4. 工时制度(标准/综合计算/不定时)及加班工资计算基数与倍数
5. 劳动报酬:基本工资、绩效、补贴构成,发放时间与方式
6. 社会保险与住房公积金的缴纳基数、比例、起始时间
7. 试用期工资是否不低于转正工资 80% 且不低于当地最低工资
8. 竞业限制范围、期限(不超 2 年)、补偿标准是否明确
9. 培训服务期约定及违约金上限(不超培训费用)
10. 解除劳动合同的条件与经济补偿(N / N+1 / 2N)是否合法
11. 违约金条款是否超出法定范围(仅培训、竞业可约定违约金)
12. 争议解决方式(仲裁/诉讼)与管辖地是否合理`,
},
{
id: 'tmpl-2',
name: '离职申请书',
description: '规范离职通知,保留证据',
icon: 'exit-outline',
url: 'https://dev.api.com/legal/templates/resignation-letter',
content: `离职申请书
致:____(公司名称)____ 人力资源部
本人 ____(姓名)____,身份证号 ____,于 ____(入职日期)____ 入职,现任 ____(部门 / 岗位)____。
现因 ____(离职原因,如:个人职业发展)____,根据《劳动合同法》第三十七条,本人提前三十日以书面形式通知公司,拟于 ____(最后工作日期)____ 正式解除劳动关系。
请公司按约定办理工作交接、出具离职证明,并结清工资、加班费及未休年假工资;社会保险与住房公积金请缴存至 ____(年 / 月)____。
此致
敬礼
申请人:____(签名)____
联系电话:____
日期:____ 年 ____ 月 ____ 日`,
},
{
id: 'tmpl-3',
name: '劳动仲裁申请书',
description: '劳动争议维权起步文书',
// 原为 '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',
content: `劳动仲裁申请书
申请人:____(姓名)____,性别 ____,身份证号 ____,联系电话 ____,住址 ____
被申请人:____(公司全称)____,统一社会信用代码 ____,住所地 ____,法定代表人 ____
仲裁请求:
1. 请求裁决被申请人支付违法解除劳动合同赔偿金 ____ 元;
2. 请求裁决被申请人支付拖欠工资 ____ 元及加班费 ____ 元;
3. 请求裁决被申请人补缴 ____ 年 ____ 月至 ____ 年 ____ 月的社会保险与住房公积金。
事实与理由:
____(简述入职时间、岗位、工资标准、争议发生经过及证据,如劳动合同、工资条、考勤记录、聊天记录等)____
此致
____(用人单位所在地或合同履行地)____ 劳动人事争议仲裁委员会
申请人:____(签名)____
日期:____ 年 ____ 月 ____ 日
附:1. 申请人身份证复印件;2. 证据清单及材料 ____ 份`,
},
];
/** 会员套餐 */
const MOCK_LEGAL_PLANS: LegalPlan[] = [
{
id: 'plan-month',
name: '月度会员',
price: 29,
period: '/月',
benefits: ['3 次一对一律师咨询', '1 份文书审核', '常见问题不限次答疑'],
},
{
id: 'plan-year',
name: '年度会员',
price: 199,
period: '/年',
recommended: true,
benefits: ['不限次一对一律师咨询', '不限次文书审核', '专属法律顾问', '优先响应'],
},
];
/** 法律咨询默认欢迎会话 */
const MOCK_LEGAL_CONVERSATION: LegalConversation = {
id: 'default',
title: '阿布阿诺法律咨询',
messages: [
{
id: 'm1',
role: 'assistant',
content: '你好,我是阿布阿诺 AI 法律助手。我可以帮你解答求职过程中的劳动权益、劳动合同、试用期辞退等法律问题。请问你遇到什么问题了?',
timestamp: new Date().toISOString(),
},
],
};
/**
* 获取阿布阿诺品牌信息
* @method GET
* @url /api/legal/brand
* @returns Promise<LegalBrand> 品牌信息
*/
export async function fetchLegalBrand(): Promise<LegalBrand> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/brand`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_BRAND);
}
/**
* 获取法律服务入口列表
* @method GET
* @url /api/legal/services
* @returns Promise<LegalService[]> 服务列表
*/
export async function fetchLegalServices(): Promise<LegalService[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/services`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_SERVICES);
}
/**
* 获取法律常见问题
* @method GET
* @url /api/legal/faqs
* @returns Promise<LegalFAQ[]> FAQ 列表
*/
export async function fetchLegalFAQs(): Promise<LegalFAQ[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/faqs`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_FAQS);
}
/**
* 获取法律文书模板
* @method GET
* @url /api/legal/templates
* @returns Promise<LegalTemplate[]> 模板列表
*/
export async function fetchLegalTemplates(): Promise<LegalTemplate[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/templates`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_TEMPLATES);
}
/**
* 获取会员套餐
* @method GET
* @url /api/legal/plans
* @returns Promise<LegalPlan[]> 套餐列表
*/
export async function fetchLegalPlans(): Promise<LegalPlan[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/plans`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_PLANS);
}
/* ===================== 付费 / 订单 / 会员态(mock 阶段持久化到 AsyncStorage) ===================== */
const LEGAL_MEMBERSHIP_KEY = 'legal:membership';
const LEGAL_ORDERS_KEY = 'legal:orders';
/** 读取会员态(storage 为唯一真相源,避免内存态过期) */
async function readLegalMembership(): Promise<LegalMembership | null> {
const raw = await storageGet(LEGAL_MEMBERSHIP_KEY);
return raw ? (JSON.parse(raw) as LegalMembership) : null;
}
/** 写入会员态(null 表示清除) */
async function writeLegalMembership(m: LegalMembership | null): Promise<void> {
if (m) {
await storageSet(LEGAL_MEMBERSHIP_KEY, JSON.stringify(m));
} else {
await storageRemove(LEGAL_MEMBERSHIP_KEY);
}
}
/** 读取订单列表 */
async function readLegalOrders(): Promise<LegalOrder[]> {
const raw = await storageGet(LEGAL_ORDERS_KEY);
return raw ? (JSON.parse(raw) as LegalOrder[]) : [];
}
/** 写入订单列表 */
async function writeLegalOrders(orders: LegalOrder[]): Promise<void> {
await storageSet(LEGAL_ORDERS_KEY, JSON.stringify(orders));
}
/**
* 获取当前会员态
* @method GET
* @url /api/legal/membership
* @returns Promise<LegalMembership | null> 未开通则返回 null
*/
export async function fetchLegalMembership(): Promise<LegalMembership | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/membership`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalMembership());
}
/**
* 获取付费订单列表(按创建时间倒序)
* @method GET
* @url /api/legal/orders
* @returns Promise<LegalOrder[]> 订单列表
*/
export async function fetchLegalOrders(): Promise<LegalOrder[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalOrders());
}
/**
* 创建会员订单(mock 阶段仅本地落库,不接真实支付)
* @method POST
* @url /api/legal/orders
* @param planId 套餐 id
* @param channel 支付方式
* @returns Promise<LegalOrder> 新建的待支付订单
*/
export async function createLegalOrder(
planId: string,
channel: LegalPaymentChannel,
): Promise<LegalOrder> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/orders`, { method: 'POST', body: JSON.stringify({ planId, channel }) }); return res.json();
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === planId);
if (!plan) {
throw new Error('套餐不存在');
}
const order: LegalOrder = {
id: `order-${Date.now()}`,
planId: plan.id,
planName: plan.name,
price: plan.price,
channel,
status: 'pending',
createdAt: new Date().toISOString(),
};
const orders = await readLegalOrders();
orders.unshift(order);
await writeLegalOrders(orders);
return mockRequest(order);
}
/**
* 支付订单(mock 阶段模拟支付处理并激活会员态)
* @method POST
* @url /api/legal/orders/:id/pay
* @param orderId 订单 id
* @returns Promise<LegalOrder> 支付后的订单(status=paid)
* @remark 真实接入时,应由后端支付回调确认支付结果后再激活会员态,前端不自行判定成功
*/
export async function payLegalOrder(orderId: string): Promise<LegalOrder> {
// TODO: 支付不能只发一个请求 —— 需走支付 SDK,且以后端回调确认为准(本机返回的 paid 不可信)。
// 后端就绪后放开下面两行,并把本机的「直接置 paid」换成「等回调后再读订单」:
// const res = await fetch(`${API_BASE_URL}/api/legal/orders/${orderId}/pay`, { method: 'POST' });
// return res.json();
const orders = await readLegalOrders();
const idx = orders.findIndex((o) => o.id === orderId);
if (idx === -1) {
throw new Error('订单不存在');
}
const paid: LegalOrder = {
...orders[idx],
status: 'paid',
paidAt: new Date().toISOString(),
};
orders[idx] = paid;
await writeLegalOrders(orders);
// 根据套餐周期计算会员有效期并激活会员态
const plans = await fetchLegalPlans();
const plan = plans.find((p) => p.id === paid.planId);
if (plan) {
const now = new Date();
const expires = new Date(now.getTime());
// period: '/年' 或 '/月'
if (plan.period.includes('年')) {
expires.setFullYear(now.getFullYear() + 1);
} else {
expires.setMonth(now.getMonth() + 1);
}
await writeLegalMembership({
planId: plan.id,
planName: plan.name,
startedAt: now.toISOString(),
expiresAt: expires.toISOString(),
});
}
// 模拟支付处理延时
await new Promise((resolve) => setTimeout(resolve, 800));
return mockRequest(paid);
}
/* ===================== 会员服务使用申请(付费后权益兑现) ===================== */
const LEGAL_REQUESTS_KEY = 'legal:requests';
/** 读取服务申请列表 */
async function readLegalRequests(): Promise<LegalServiceRequest[]> {
const raw = await storageGet(LEGAL_REQUESTS_KEY);
return raw ? (JSON.parse(raw) as LegalServiceRequest[]) : [];
}
/** 写入服务申请列表 */
async function writeLegalRequests(requests: LegalServiceRequest[]): Promise<void> {
await storageSet(LEGAL_REQUESTS_KEY, JSON.stringify(requests));
}
/**
* 获取会员服务使用申请列表(按提交时间倒序)
* @method GET
* @url /api/legal/service-requests
* @returns Promise<LegalServiceRequest[]> 申请列表
*/
export async function fetchLegalServiceRequests(): Promise<LegalServiceRequest[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'GET' }); return res.json();
return mockRequest(await readLegalRequests());
}
/**
* 提交会员服务使用申请(仅已开通会员可提交,权限由后端校验)
* @method POST
* @url /api/legal/service-requests
* @param serviceId 服务 id
* @param content 用户填写的需求描述
* @returns Promise<LegalServiceRequest> 新建的申请
*/
export async function createLegalServiceRequest(
serviceId: string,
content: string,
): Promise<LegalServiceRequest> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/service-requests`, { method: 'POST', body: JSON.stringify({ serviceId, content }) }); return res.json();
const services = await fetchLegalServices();
const service = services.find((s) => s.id === serviceId);
if (!service) {
throw new Error('服务不存在');
}
const request: LegalServiceRequest = {
id: `request-${Date.now()}`,
serviceId: service.id,
serviceName: service.title,
content,
status: 'submitted',
createdAt: new Date().toISOString(),
};
const requests = await readLegalRequests();
requests.unshift(request);
await writeLegalRequests(requests);
return mockRequest(request);
}
/**
* 获取默认法律咨询服务会话
* @method GET
* @url /api/legal/conversations/default
* @returns Promise<LegalConversation> 咨询会话
*/
export async function fetchLegalConversation(): Promise<LegalConversation> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/conversations/default`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_LEGAL_CONVERSATION);
}
/**
* 发送法律咨询消息(mock 阶段模拟 AI 回复)
* @method POST
* @url /api/legal/conversations/default/messages
* @param content {string} 用户输入
* @returns Promise<LegalMessage[]> 最新两条消息(用户 + 助手)
*/
export async function sendLegalMessage(content: string): Promise<LegalMessage[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/legal/conversations/default/messages`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
const now = new Date().toISOString();
const userMessage: LegalMessage = {
id: `u-${Date.now()}`,
role: 'user',
content,
timestamp: now,
};
const assistantMessage: LegalMessage = {
id: `a-${Date.now()}`,
role: 'assistant',
content:
'收到你的问题。作为阿布阿诺 AI 法律助手,我建议你先保留相关证据(聊天记录、合同、Offer、工资流水等)。如情况复杂,可升级到一对一律师咨询获取专业方案。',
timestamp: now,
};
return mockRequest([userMessage, assistantMessage]);
}
/* ===================== 设置:隐私设置 ===================== */
/** 隐私开关(内存态,mock 阶段刷新后重置) */
const MOCK_PRIVACY_SETTINGS: ToggleSetting[] = [
{
id: 'resume-visible',
title: '简历对 HR 可见',
description: '关闭后企业将无法搜索到你的简历',
enabled: true,
},
{
id: 'anonymous-browse',
title: '匿名浏览企业',
description: '浏览公司主页时不留下访问记录',
enabled: false,
},
{
id: 'show-job-status',
title: '展示求职状态',
description: '在个人主页公开「在职-考虑机会」等状态',
enabled: true,
},
{
id: 'show-salary',
title: '展示薪资期望',
description: '允许企业在推荐列表中看到你的期望薪资',
enabled: false,
},
{
id: 'allow-bgcheck',
title: '允许背景调查',
description: '入职前授权企业进行雇佣背景核实',
enabled: true,
},
{
id: 'personalized',
title: '个性化岗位推荐',
description: '基于你的浏览与投递行为推荐匹配岗位',
enabled: true,
},
];
/**
* 获取隐私设置开关列表
* @method GET
* @url /api/settings/privacy
* @returns Promise<ToggleSetting[]> 隐私开关列表
*/
export async function fetchPrivacySettings(): Promise<ToggleSetting[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/privacy`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_PRIVACY_SETTINGS);
}
/**
* 更新单个隐私开关
* @method PATCH
* @url /api/settings/privacy/:id
* @param payload {UpdateTogglePayload} 开关 id 与目标状态
* @returns Promise<ToggleSetting[]> 更新后的开关列表
*/
export async function updatePrivacySetting(
payload: UpdateTogglePayload,
): Promise<ToggleSetting[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/privacy/${payload.id}`, { method: 'PATCH', body: JSON.stringify(payload) }); return res.json();
const target = MOCK_PRIVACY_SETTINGS.find((item) => item.id === payload.id);
if (target) {
target.enabled = payload.enabled;
}
return mockRequest(MOCK_PRIVACY_SETTINGS);
}
/* ===================== 隐私中心(截图版) ===================== */
const MOCK_PRIVACY_CENTER: PrivacyCenter = {
privacyLevel: {
level: 'high',
label: '较高',
description: '你的数据保护状态良好,但仍有可调整空间。进一步提升隐私',
},
sections: [
{
id: 'account',
title: '账号隐私',
items: [
{
id: 'real-name-visible',
title: '真实姓名可见性',
type: 'switch',
enabled: true,
route: '/settings/privacy/real-name',
},
{
id: 'contact-visible',
title: '联系方式可见性',
type: 'switch',
enabled: true,
route: '/settings/privacy/contact',
},
{ id: 'avatar-public', title: '头像公开', type: 'switch', enabled: true },
{ id: 'searchable', title: '允许被搜索', type: 'switch', enabled: true },
{ id: 'resume-view-notify', title: '简历被浏览通知', type: 'switch', enabled: true },
],
},
{
id: 'authorization',
title: '数据使用授权',
items: [
{ id: 'personalized-recommend', title: '个性化推荐', type: 'switch', enabled: true },
{
id: 'algo-training',
title: '数据用于算法训练',
description: '匿名数据用于提升匹配效果',
type: 'switch',
enabled: false,
route: '/settings/privacy/algo',
},
{
id: 'third-party-analysis',
title: '允许第三方数据分析',
type: 'switch',
enabled: false,
route: '/settings/privacy/third-party',
},
{
id: 'location-auth',
title: '位置信息授权',
description: '仅用于岗位推荐',
type: 'switch',
enabled: true,
route: '/settings/privacy/location',
},
{
id: 'contacts-auth',
title: '通讯录权限',
type: 'switch',
enabled: false,
route: '/settings/privacy/contacts',
},
],
},
{
id: 'anti-harassment',
title: '求职防骚扰',
items: [
{
id: 'block-unmatched',
title: '屏蔽未匹配企业联系',
type: 'switch',
enabled: true,
},
{ id: 'block-headhunter', title: '屏蔽猎头/中介', type: 'switch', enabled: false },
{
id: 'dnd-mode',
title: '勿扰模式',
description: '已屏蔽 3 个关键词',
type: 'switch',
enabled: true,
route: '/settings/privacy/dnd',
},
{
id: 'keyword-block',
title: '关键词屏蔽',
description: '已屏蔽 3 个关键词',
type: 'value',
value: '3个',
route: '/settings/privacy/keywords',
},
],
},
{
id: 'data-management',
title: '数据管理',
items: [
{
id: 'personal-data-export',
title: '个人数据导出',
type: 'link',
route: '/personal-data-export',
},
{ id: 'account-cancel', title: '账号注销', type: 'link', route: '/settings/account-cancel' },
{
id: 'delete-all-data',
title: '删除全部数据',
type: 'danger-link',
route: '/settings/delete-data',
},
{ id: 'privacy-policy', title: '隐私政策', type: 'link', route: '/settings/privacy-policy' },
{
id: 'user-agreement',
title: '用户协议',
type: 'link',
route: '/settings/user-agreement',
},
],
},
],
explanation:
'我们承诺严格保护你的个人隐私,所有数据使用均符合《个人信息保护法》。你可以随时调整隐私设置,我们不会向第三方出售或泄露你的个人信息。',
};
/**
* 获取隐私中心聚合数据
* @method GET
* @url /api/settings/privacy-center
* @returns Promise<PrivacyCenter> 隐私中心数据
*/
export async function fetchPrivacyCenter(): Promise<PrivacyCenter> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center`, { method: 'GET' });
// return res.json();
return mockRequest(MOCK_PRIVACY_CENTER);
}
/**
* 更新隐私中心开关项
* @method PATCH
* @url /api/settings/privacy-center/:id
* @param payload {UpdateTogglePayload} 开关 id 与目标状态
* @returns Promise<PrivacyCenter> 更新后的隐私中心数据
*/
export async function updatePrivacyCenterSetting(
payload: UpdateTogglePayload,
): Promise<PrivacyCenter> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy-center/${payload.id}`, {
// method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
// });
// return res.json();
for (const section of MOCK_PRIVACY_CENTER.sections) {
const target = section.items.find((item) => item.id === payload.id);
if (target && target.type === 'switch') {
target.enabled = payload.enabled;
}
}
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');
}
/* ===================== 隐私二级设置页 ===================== */
/**
* 6 个开关类二级页的数据,键为页面 id(对应路由 /settings/privacy/[id])
* 【待确认】各页细选项的文案与默认开关状态,需产品与法务复核
*/
const MOCK_PRIVACY_DETAILS: Record<string, PrivacyDetailSetting> = {
'real-name': {
id: 'real-name',
title: '真实姓名可见性',
intro: '控制企业查看你的简历时能否看到真实姓名。关闭后将展示为「张先生」「李女士」等脱敏形式。',
master: { id: 'real-name-master', title: '向企业展示真实姓名', enabled: true },
options: [
{ id: 'rn-delivered', title: '已投递的企业可见', enabled: true },
{ id: 'rn-verified', title: '已完成企业认证的公司可见', enabled: true },
{ id: 'rn-masked', title: '未投递企业展示脱敏姓名', description: '如「张先生」', enabled: true },
],
note: '无论是否展示真实姓名,企业在发出 offer 前均需经过你的明示同意才能获取完整身份信息。',
},
contact: {
id: 'contact',
title: '联系方式可见性',
intro: '控制手机号、邮箱等联系方式的可见范围。建议进入面试环节后再向企业开放。',
master: { id: 'contact-master', title: '向企业展示联系方式', enabled: true },
options: [
{ id: 'ct-phone', title: '手机号', enabled: true },
{ id: 'ct-email', title: '邮箱', enabled: true },
{ id: 'ct-wechat', title: '微信号', enabled: false },
{ id: 'ct-after-interview', title: '仅进入面试后自动开放', enabled: true },
],
note: '开启「仅进入面试后自动开放」后,企业需将你推进至面试环节才能查看完整联系方式。',
},
algo: {
id: 'algo',
title: '数据用于算法训练',
intro: '开启后,你的匿名化行为数据将用于优化岗位匹配与推荐算法。训练数据经过去标识化处理,不含可识别身份的信息。',
master: { id: 'algo-master', title: '数据用于算法训练', description: '匿名数据用于提升匹配效果', enabled: false },
options: [
{ id: 'ag-browse', title: '使用浏览行为', enabled: false },
{ id: 'ag-delivery', title: '使用投递记录', enabled: false },
{ id: 'ag-chat', title: '使用沟通记录', description: '不含聊天正文,仅使用结构化结果', enabled: false },
],
note: '关闭后你仍可正常使用全部功能,但推荐精准度可能下降。你可随时重新开启。',
},
'third-party': {
id: 'third-party',
title: '允许第三方数据分析',
intro: '第三方数据分析用于生成行业薪酬报告与人才流动趋势。分析结果均为聚合统计,不含个人身份信息,且不会用于向你推送广告。',
master: { id: 'tp-master', title: '允许第三方数据分析', enabled: false },
options: [
{ id: 'tp-salary', title: '薪酬水平分析', enabled: false },
{ id: 'tp-flow', title: '人才流动趋势', enabled: false },
{ id: 'tp-skill', title: '技能需求统计', enabled: false },
],
note: '我们仅向具备数据安全资质的合作方提供聚合结果,并签署数据处理协议。',
},
location: {
id: 'location',
title: '位置信息授权',
intro: '位置信息仅用于为你推荐附近岗位与计算通勤时间。关闭后,推荐结果将按你填写的期望城市计算。',
master: { id: 'loc-master', title: '位置信息授权', description: '仅用于岗位推荐', enabled: true },
options: [
{ id: 'loc-precise', title: '使用精确定位', enabled: true },
{ id: 'loc-background', title: '允许后台获取位置', description: '关闭后仅在使用应用时获取', enabled: false },
{ id: 'loc-commute', title: '用于通勤时间计算', enabled: true },
],
note: '我们不会持续上传你的位置轨迹,位置信息仅在本地完成距离计算后即被丢弃。',
},
contacts: {
id: 'contacts',
title: '通讯录权限',
intro: '授权通讯录后,你可以快速发现已在平台的好友,也能邀请同事加入。我们仅在本地比对通讯录,不会上传或存储你的联系人信息。',
master: { id: 'cnt-master', title: '通讯录权限', enabled: false },
options: [
{ id: 'cnt-discover', title: '发现通讯录好友', enabled: false },
{ id: 'cnt-invite', title: '向好友推荐岗位', enabled: false },
{ id: 'cnt-network', title: '同步人脉关系', description: '用于展示可能认识的同行', enabled: false },
],
note: '通讯录比对通过本地哈希完成,服务端无法还原你的联系人姓名与号码。',
},
};
/**
* 获取隐私二级设置页数据(开关类)
* @method GET
* @url /api/settings/privacy/:id
* @param id { string } 页面 id,如 real-name / contact / algo
* @returns Promise<PrivacyDetailSetting | null> 找不到时返回 null
*/
export async function fetchPrivacyDetailSetting(
id: string,
): Promise<PrivacyDetailSetting | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/privacy/${id}`, { method: 'GET' }); return res.json();
const detail = MOCK_PRIVACY_DETAILS[id];
return mockRequest(detail ? { ...detail } : null);
}
/**
* 更新隐私二级设置页的开关(主开关或细选项)
* @method PATCH
* @url /api/settings/privacy/:id
* @param id { string } 页面 id
* @param optionId { string } 主开关或细选项 id
* @param enabled { boolean } 目标状态
* @returns Promise<PrivacyDetailSetting | null>
*/
export async function updatePrivacyDetailSetting(
id: string,
optionId: string,
enabled: boolean,
): Promise<PrivacyDetailSetting | null> {
// TODO: await fetch(`${API_BASE_URL}/api/settings/privacy/${id}`, {
// method: 'PATCH', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ optionId, enabled }),
// });
const detail = MOCK_PRIVACY_DETAILS[id];
if (!detail) return mockRequest(null);
if (detail.master.id === optionId) {
detail.master.enabled = enabled;
} else {
const option = detail.options.find((item) => item.id === optionId);
if (option) option.enabled = enabled;
}
return mockRequest({ ...detail });
}
/* ===================== 勿扰模式 ===================== */
const MOCK_DND_SETTING: DndSetting = {
enabled: true,
startTime: '22:00',
endTime: '08:00',
allowUrgent: true,
blockedCount: 128,
};
/**
* 获取勿扰模式设置
* @method GET
* @url /api/settings/privacy/dnd
* @returns Promise<DndSetting>
*/
export async function fetchDndSetting(): Promise<DndSetting> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_DND_SETTING });
}
/**
* 更新勿扰模式设置
* @method PATCH
* @url /api/settings/privacy/dnd
* @param payload { Partial<DndSetting> } 需更新的字段
* @returns Promise<DndSetting>
*/
export async function updateDndSetting(payload: Partial<DndSetting>): Promise<DndSetting> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy/dnd`, {
// method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
// });
// return res.json();
Object.assign(MOCK_DND_SETTING, payload);
return mockRequest({ ...MOCK_DND_SETTING });
}
/* ===================== 关键词屏蔽 ===================== */
const MOCK_KEYWORD_BLOCK: KeywordBlockSetting = {
keywords: ['外包', '兼职', '刷单'],
blockedCount: 3,
suggestions: ['无责底薪', '日结', '在家兼职', '急招临时工'],
};
/**
* 获取关键词屏蔽设置
* @method GET
* @url /api/settings/privacy/keywords
* @returns Promise<KeywordBlockSetting>
*/
export async function fetchKeywordBlock(): Promise<KeywordBlockSetting> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] });
}
/**
* 添加屏蔽关键词
* @method POST
* @url /api/settings/privacy/keywords
* @param keyword { string } 待屏蔽的关键词
* @returns Promise<KeywordBlockSetting>
*/
export async function addBlockKeyword(keyword: string): Promise<KeywordBlockSetting> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keyword }),
// });
// return res.json();
const value = keyword.trim();
if (value && !MOCK_KEYWORD_BLOCK.keywords.includes(value)) {
MOCK_KEYWORD_BLOCK.keywords.push(value);
}
return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] });
}
/**
* 移除屏蔽关键词
* @method DELETE
* @url /api/settings/privacy/keywords/:keyword
* @param keyword { string } 待移除的关键词
* @returns Promise<KeywordBlockSetting>
*/
export async function removeBlockKeyword(keyword: string): Promise<KeywordBlockSetting> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/privacy/keywords/${encodeURIComponent(keyword)}`, { method: 'DELETE' });
// return res.json();
MOCK_KEYWORD_BLOCK.keywords = MOCK_KEYWORD_BLOCK.keywords.filter((item) => item !== keyword);
return mockRequest({ ...MOCK_KEYWORD_BLOCK, keywords: [...MOCK_KEYWORD_BLOCK.keywords] });
}
/* ===================== 账号注销 ===================== */
const MOCK_ACCOUNT_CANCEL: AccountCancelData = {
title: '账号注销',
consequences: [
'账号将无法登录且不可恢复,平台内所有数据将被清空且永久删除',
'正在进行的投递、面试邀约与沟通记录将全部终止',
'已获得的会员权益、优惠券、成长值与勋章将失效且不予退还',
'若你已完成企业认证,认证信息与关联招聘流程将一并注销',
],
checklist: [
// 原先这里带「去提现」,但全项目没有钱包/提现页 —— 悬空的入口已摘掉
{ id: 'balance', label: '账户余额已清零', resolved: false },
{ id: 'orders', label: '无进行中的付费订单', resolved: true },
{ id: 'delivery', label: '无进行中的投递与面试', resolved: true },
{ id: 'enterprise', label: '企业认证已解除', resolved: true },
],
reasons: ['已找到工作', '不再需要该服务', '隐私担忧', '使用体验不佳', '其他'],
canCancel: false,
};
/**
* 获取账号注销前置信息
* @method GET
* @url /api/settings/account-cancel
* @returns Promise<AccountCancelData>
*/
export async function fetchAccountCancel(): Promise<AccountCancelData> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/account-cancel`, { method: 'GET' });
// return res.json();
return mockRequest({
...MOCK_ACCOUNT_CANCEL,
canCancel: MOCK_ACCOUNT_CANCEL.checklist.every((item) => item.resolved),
});
}
/**
* 提交账号注销申请
* @method POST
* @url /api/settings/account-cancel
* @param reason { string } 注销原因
* @returns Promise<{ success: boolean; message: string }>
*/
export async function submitAccountCancel(
_reason: string,
): Promise<{ success: boolean; message: string }> {
// TODO: await fetch(`${API_BASE_URL}/api/settings/account-cancel`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ reason }),
// });
return mockRequest({
success: true,
message: '注销申请已提交,我们将在 7 个工作日内完成审核并以短信通知你',
});
}
/* ===================== 删除全部数据 ===================== */
const MOCK_DELETE_ALL_DATA: DeleteAllData = {
intro: '你可以选择删除部分或全部个人数据。删除操作不可撤销,请在确认前仔细阅读各项说明。',
categories: [
{ id: 'resume', label: '简历数据', description: '简历正文、附件与历史版本', count: '12 条' },
{ id: 'delivery', label: '投递记录', description: '历史投递与进展状态', count: '128 条' },
{ id: 'chat', label: '沟通记录', description: '与企业 HR 的聊天消息', count: '1,024 条' },
{ id: 'assessment', label: '测评结果', description: '能力测评与职业兴趣测评', count: '8 条' },
{ id: 'behavior', label: '行为数据', description: '浏览、搜索与点击记录', count: '约 5,000 条' },
],
warning: '删除后数据将无法恢复,并可能影响岗位推荐准确度与竞争力排名的计算结果。',
confirmPlaceholder: '请输入「删除全部数据」以确认',
};
/**
* 获取可删除的数据分类
* @method GET
* @url /api/settings/delete-data
* @returns Promise<DeleteAllData>
*/
export async function fetchDeleteAllData(): Promise<DeleteAllData> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/delete-data`, { method: 'GET' });
// return res.json();
return mockRequest({ ...MOCK_DELETE_ALL_DATA });
}
/**
* 提交删除数据请求
* @method POST
* @url /api/settings/delete-data
* @param categoryIds { string[] } 待删除的分类 id 列表
* @returns Promise<{ success: boolean; message: string }>
*/
export async function submitDeleteData(
categoryIds: string[],
): Promise<{ success: boolean; message: string }> {
// TODO: await fetch(`${API_BASE_URL}/api/settings/delete-data`, {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ categoryIds }),
// });
return mockRequest({
success: true,
message: `已提交删除申请,涉及 ${categoryIds.length} 项数据,预计 3 个工作日内处理完成`,
});
}
/* ===================== 法律文档(隐私政策 / 用户协议) ===================== */
/**
* 隐私政策正文
* 【待确认】文中公司全称、客服邮箱、数据存储地等需法务确认后替换占位内容(客服电话已于 2026-09-15 填入 15154608969)
*/
const MOCK_PRIVACY_POLICY: LegalDocument = {
id: 'privacy-policy',
title: '隐私政策',
version: 'V1.0',
updatedAt: '2026-08-31',
effectiveAt: '2026-08-31',
preamble:
'轻松聘(以下简称「我们」)深知个人信息对你的重要性,并会尽全力保护你的个人信息安全可靠。我们致力于维持你对我们的信任,恪守以下原则保护你的个人信息:权责一致原则、目的明确原则、选择同意原则、最少够用原则、确保安全原则、主体参与原则、公开透明原则。本政策适用于你通过轻松聘平台(含移动客户端、网站及关联服务)所进行的全部活动。',
sections: [
{
heading: '一、我们收集的信息',
paragraphs: [
'在您注册账号或使用平台服务时,我们会收集您主动提供的信息,包括手机号码、验证码、密码等注册信息;以及您后续完善简历时填写的姓名、性别、出生日期、教育经历、工作经历、项目经历、技能标签、期望岗位与期望薪资等。',
'在您使用服务过程中,我们会收集设备信息(设备型号、操作系统版本、唯一设备标识符)、日志信息(浏览记录、搜索关键词、点击记录、IP 地址、访问时间)以及位置信息(仅在您授权后收集,用于推荐附近岗位)。',
'若您使用企业端服务,我们还将收集企业工商信息、营业执照、招聘联系人身份信息及认证材料,用于完成企业资质审核。',
],
},
{
heading: '二、我们如何使用信息',
paragraphs: [
'我们收集的信息将用于:为您创建并维护账号、展示与匹配岗位、生成竞争力排名与测评报告、处理您的投递与沟通请求、向您发送面试邀约与重要通知。',
'为提升推荐的准确性,我们会在您授权的前提下使用匿名化、去标识化后的行为数据优化算法模型。您可在「设置 - 隐私中心」中随时关闭该授权,关闭后您仍可正常使用全部功能。',
'我们不会将您的个人信息用于本政策未载明的其他目的。如确需变更使用目的,我们将重新征得您的明示同意。',
],
},
{
heading: '三、信息的共享、转让与公开披露',
paragraphs: [
'我们不会向任何第三方出售您的个人信息。除下列情形外,我们不会与第三方共享您的信息:(1)获得您的明示同意;(2)您主动向企业投递简历后,向该企业提供您授权范围内的简历内容;(3)为实现「岗位推荐」等功能而向合作的服务供应商提供必要信息,且该等供应商受保密义务约束;(4)根据法律法规或主管部门的强制性要求。',
'我们仅在聚合、匿名化后向第三方提供行业薪酬报告与人才流动趋势等统计分析结果,该等结果无法识别到特定个人。',
'如因合并、分立、重组等导致信息转移,我们将告知您接收方的名称与联系方式,并要求其继续受本政策约束;若接收方变更原有处理目的,将重新征求您的同意。',
],
},
{
heading: '四、信息的存储与保护',
paragraphs: [
'我们在中华人民共和国境内收集和产生的个人信息将存储于境内。我们仅在本政策所述目的所必需的期间内保留您的个人信息,超出保留期限后将进行删除或匿名化处理。',
'我们已采取符合业界标准的安全防护措施保护您的个人信息,包括传输层加密、存储加密、访问权限控制、安全审计与应急预案。但请您理解,任何安全措施都无法做到绝对无懈可击,我们将持续努力提升防护水平。',
'若不幸发生个人信息泄露等安全事件,我们将按照法律法规要求,及时以站内信、短信或邮件等方式告知您事件的基本情况、影响范围、已采取的措施及您的应对建议。',
],
},
{
heading: '五、您的权利',
paragraphs: [
'您对自己的个人信息享有查询、更正、补充、删除、复制、撤回同意以及注销账号的权利。您可直接在「设置 - 隐私中心」或「设置 - 个人数据导出」中行使上述权利,我们将在 15 个工作日内予以响应。',
'您可随时在隐私中心调整真实姓名可见性、联系方式可见性、个性化推荐、算法训练授权、第三方数据分析、位置与通讯录授权等开关。关闭相应授权后,我们将停止对应的信息处理活动。',
'如您认为我们的处理行为损害了您的合法权益,您可通过本政策载明的联系方式向我们投诉,我们会在收到投诉后 15 个工作日内作出答复;您也可向有关主管部门举报或提起诉讼。',
],
},
{
heading: '六、未成年人保护',
paragraphs: [
'本平台主要面向具有劳动能力的成年人提供求职招聘服务。若您未满 18 周岁,请在监护人的陪同下阅读本政策,并在取得监护人明示同意后再使用本平台服务。',
'我们不会主动收集未满 18 周岁用户的个人信息。若我们是未事先获得监护人同意而收集了未成年人的个人信息,我们会尽快删除相关数据。',
'如您对您所监护的未成年人的个人信息有疑问,请通过本政策载明的联系方式与我们联系。',
],
},
{
heading: '七、Cookie 与同类技术',
paragraphs: [
'为保障产品正常运转、提升使用体验,我们会在您的设备上存储名为 Cookie 的小型数据文件。您可根据自己的偏好管理或删除 Cookie。',
'我们使用 Cookie 主要用于:记住您的登录状态、保存您的筛选偏好、分析页面访问情况以优化产品。关闭 Cookie 后,部分功能可能无法正常使用。',
],
},
{
heading: '八、本政策的更新',
paragraphs: [
'我们的隐私政策可能适时修订。未经您明示同意,我们不会削减您依据本政策所享有的权利。',
'当政策发生重大变更时,我们会在版本更新后通过站内推送、弹窗或短信等显著方式向您提示,您可查阅最新版本。重大变更包括但不限于:服务模式发生重大变化、个人信息处理目的与方式发生重大变化、您参与个人信息处理方面的权利及其行使方式发生重大变化。',
],
},
{
heading: '九、联系我们',
paragraphs: [
'如您对本政策或您的个人信息处理有任何疑问、意见或建议,可通过以下方式与我们联系:客服电话 15154608969;客服邮箱与个人信息保护负责人邮箱:【待确认:客服邮箱 / 个人信息保护负责人邮箱】。',
'公司名称:【待确认:公司全称】。注册地址:【待确认:注册地址】。我们将在收到您的请求后 15 个工作日内答复。',
],
},
],
};
/**
* 用户协议正文
* 【待确认】文中公司全称、争议解决管辖法院、违约金条款等需法务确认后替换占位内容
*/
const MOCK_USER_AGREEMENT: LegalDocument = {
id: 'user-agreement',
title: '用户协议',
version: 'V1.0',
updatedAt: '2026-08-31',
effectiveAt: '2026-08-31',
preamble:
'本协议是你与轻松聘平台运营方【待确认:公司全称】(以下简称「平台」)之间就使用轻松聘平台服务所订立的协议。在使用平台各项服务前,请你务必审慎阅读、充分理解本协议各条款内容,特别是以粗体或下划线标注的免责条款、责任限制条款与争议解决条款。如你未满 18 周岁,请在法定监护人的陪同下阅读并决定是否同意本协议。',
sections: [
{
heading: '一、协议的范围与接受',
paragraphs: [
'本协议适用于你使用轻松聘平台提供的全部服务,包括但不限于职位浏览与搜索、简历创建与投递、在线沟通、能力测评、竞争力排名、AI 模拟面试、求职进度管理以及企业端的职位发布、人才搜索与招聘管理功能。',
'你通过点击「同意」「注册」或以其他任何方式实际使用平台服务,即视为你已阅读并同意受本协议约束。若你不同意本协议,请立即停止使用平台服务。',
'平台有权在必要时修订本协议并在平台上公布。修订后的协议自公布之日起生效。若你继续使用平台服务,即视为你已接受修订后的协议。',
],
},
{
heading: '二、账号注册与使用',
paragraphs: [
'你需要使用本人真实手机号码完成注册,并保证所提供的注册信息真实、准确、完整,且及时更新。因你提供的信息不实导致的全部后果由你自行承担。',
'账号的所有权归平台所有,你仅享有账号的使用权。未经平台书面同意,你不得以任何形式出借、转让、出租或售卖账号。',
'你应妥善保管账号与密码,因你主动泄露、遗失或被他人盗用而导致的损失由你自行承担。发现账号异常时,你应立即通过平台提供的渠道申请冻结并修改密码。',
],
},
{
heading: '三、用户行为规范',
paragraphs: [
'你承诺在使用平台服务时遵守中华人民共和国法律法规、社会主义制度、国家利益、公民合法权益、公共秩序、社会道德风尚与信息真实性等「七条底线」。',
'你不得利用平台服务制作、复制、发布、传播如下内容:反对宪法所确定的基本原则的;危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;损害国家荣誉和利益的;歪曲、丑化、亵渎、否定英雄烈士事迹和精神的;宣扬恐怖主义、极端主义或者煽动实施恐怖活动、极端主义活动的;煽动民族仇恨、民族歧视、破坏民族团结的;破坏国家宗教政策,宣扬邪教和封建迷信的;散布谣言,扰乱经济秩序和社会秩序的;散布淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;侮辱或者诽谤他人,侵害他人名誉、隐私和其他合法权益的;以及含有其他违反法律法规禁止性内容的信息。',
'你不得利用平台服务从事以下行为:使用任何插件、外挂、系统或第三方工具干扰平台正常运行;批量注册账号、批量投递或恶意刷取数据;爬取、抓取或以其他技术手段获取平台数据;发布虚假职位或虚假简历;冒用他人身份或企业名义;以及损害平台或第三方合法权益的其他行为。',
],
},
{
heading: '四、求职者服务规则',
paragraphs: [
'你应确保所填写的简历信息真实、准确,不得虚构学历、工作经历、项目经历或技能水平。因简历信息不实导致企业拒绝录用、解除劳动关系或产生其他争议的,由你自行承担全部责任。',
'平台提供的岗位信息由企业自行发布,平台已尽合理努力对企业资质与职位真实性进行审核,但不对职位的具体内容、薪酬实际发放、劳动条件及后续履约承担保证责任。你在应聘过程中应保持审慎,注意甄别招聘诈骗风险。',
'平台的竞争力排名、匹配度、测评结果等均为基于现有数据的参考性分析,不构成对你求职结果或录用概率的承诺或保证。',
],
},
{
heading: '五、企业服务规则',
paragraphs: [
'企业用户须提供真实、合法、有效的营业执照与认证材料,并确保所发布的职位信息真实、合法、准确,不存在就业歧视(包括但不限于性别、民族、宗教信仰、地域、年龄、健康状况等)内容。',
'企业用户不得发布虚假职位、以招聘为名实施培训贷、收取押金、扣押证件、传销或其他违法活动。一经发现,平台有权立即下架职位、冻结或注销账号,并保留向有关主管部门报告的权利。',
'企业用户应依法保护所获取的求职者个人信息,仅可将简历用于自身招聘目的,不得转售、泄露或用于其他商业用途。',
],
},
{
heading: '六、知识产权',
paragraphs: [
'平台及平台所使用的软件、技术、商标、材料等所有知识产权归平台或相关权利人所有。未经许可,任何人不得擅自使用、修改、复制、传播、反向工程或创造衍生作品。',
'你在使用平台服务期间发布上传的文字、图片、视频、音频等均由你原创或已获合法授权。你同意授予平台为提供服务之目的在全球范围内免费、非独占的使用许可。',
'你发布的原创内容著作权仍归你所有,但你的发布行为视为同意平台在本平台内外进行展示、推广及其他合法使用。',
],
},
{
heading: '七、免责声明与责任限制',
paragraphs: [
'鉴于网络服务的特殊性,你同意平台有权随时变更、中断或终止部分或全部服务(包括收费服务)。如因服务器维护、网络拥塞、黑客攻击、电信部门技术调整或故障、第三方网站或页面原因造成服务中断或数据丢失,平台不承担责任。',
'平台不对下列情形承担责任:你因第三方(包括其他用户、企业用户、外部链接指向的网站)的行为而遭受的损失;你自行泄露账号密码或授权他人使用而造成的损失;因不可抗力导致的损失。',
'在法律允许的最大范围内,平台就本协议所承担的赔偿责任总额,不超过你因使用该服务而向平台支付的费用总额;如你未支付费用,则平台不承担赔偿责任。',
],
},
{
heading: '八、违约处理',
paragraphs: [
'若你违反本协议任一条款,平台有权独立判断并立即采取以下一项或多项措施:警告并要求限期改正;删除违规内容;限制或禁止使用部分或全部功能;暂停或终止账号服务;注销账号并清除全部数据;以及追究法律责任。',
'因你的违约行为给平台或第三方造成损失的,你应承担全部赔偿责任,赔偿范围包括但不限于直接经济损失、商誉损失、律师费、诉讼费、公证费以及平台为处理投诉而支出的合理费用。',
],
},
{
heading: '九、协议变更与终止',
paragraphs: [
'平台有权在必要时修订本协议。协议修订后,平台将在相关页面公布修订后的协议。你继续使用平台服务即视为你已接受修订后的协议。',
'你有权随时停止使用平台服务并注销账号。账号注销后,本协议终止,但你在使用服务期间的既有义务(包括但不限于赔偿责任、保密义务)不因协议终止而免除。',
],
},
{
heading: '十、法律适用与争议解决',
paragraphs: [
'本协议的订立、执行与解释以及争议的解决均适用中华人民共和国大陆地区法律(不含冲突法)。',
'如双方就本协议内容或其执行发生任何争议,应尽量友好协商解决;协商不成时,任何一方均可向【待确认:管辖法院所在地】有管辖权的人民法院提起诉讼。',
'本协议所有条款的标题仅为阅读方便,本身并无实际涵义,不能作为本协议涵义解释的依据。',
],
},
],
};
/**
* 获取法律文档(隐私政策 / 用户协议)
* @method GET
* @url /api/settings/legal-document/:id
* @param id { string } 文档 id,privacy-policy 或 user-agreement
* @returns Promise<LegalDocument | null> 找不到时返回 null
*/
export async function fetchLegalDocument(id: string): Promise<LegalDocument | null> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/legal-document/${id}`, { method: 'GET' }); return res.json();
if (id === 'privacy-policy') return mockRequest({ ...MOCK_PRIVACY_POLICY });
if (id === 'user-agreement') return mockRequest({ ...MOCK_USER_AGREEMENT });
return mockRequest(null);
}
/* ===================== 个人数据导出 ===================== */
const MOCK_PERSONAL_DATA_EXPORT: PersonalDataExport = {
title: '导出你的个人数据',
description:
'你可以导出在平台产生的所有个人数据,包括基本信息、简历数据、求职记录、测评结果、消息记录、行为数据等。数据将以结构化的方式导出,便于你备份或迁移。',
categories: [
{
id: 'basic',
label: '基本信息',
description: '姓名、电话、邮箱、头像等',
size: '约20KB',
items: [{ id: 'basic-info', label: '基本信息', size: '约20KB', checked: true }],
},
{
id: 'resume',
label: '简历数据',
description: '简历内容、投递记录、收藏等',
size: '约30KB',
items: [{ id: 'resume-data', label: '简历数据', size: '约30KB', checked: true }],
},
{
id: 'job',
label: '求职记录',
description: '投递、沟通、面试记录、Offer记录等',
size: '约100KB',
items: [{ id: 'job-records', label: '求职记录', size: '约100KB', checked: true }],
},
{
id: 'assessment',
label: '测评数据',
description: '能力测评、职业兴趣测评结果等',
size: '约30KB',
items: [{ id: 'assessment-data', label: '测评数据', size: '约30KB', checked: true }],
},
{
id: 'message',
label: '消息记录',
description: '聊天消息、系统通知等',
size: '约200KB',
items: [{ id: 'message-data', label: '消息记录', size: '约200KB', checked: true }],
},
{
id: 'behavior',
label: '行为数据',
description: '浏览、搜索、点击记录等',
size: '约500KB',
items: [{ id: 'behavior-data', label: '行为数据', size: '约500KB', checked: true }],
},
],
formats: [
{
id: 'json',
label: 'JSON',
icon: 'logo-nodejs',
description: '结构化数据,便于开发',
},
{
id: 'excel',
label: 'Excel',
icon: 'grid-outline',
description: '表格格式,便于查看分析',
},
],
timeRanges: [
{ id: 'all', label: '全部数据' },
{ id: 'year', label: '近一年' },
{ id: 'quarter', label: '近三个月' },
{ id: 'custom', label: '自定义' },
],
selectedFormat: 'json',
selectedTimeRange: 'all',
preview: {
categoryCount: 4,
totalSize: '约200KB',
estimatedTime: '1-2分钟',
validity: '导出后7天',
},
recentExports: [
{
id: 'r1',
name: '完整数据导出.zip',
date: '2024-08-01 14:30',
size: '25KB',
status: 'expired',
},
{
id: 'r2',
name: '简历数据.xlsx',
date: '2024-07-31 10:12',
size: '80KB',
status: 'completed',
},
{
id: 'r3',
name: '测评数据.json',
date: '2024-07-25 09:08',
size: '30KB',
status: 'expired',
},
],
};
/**
* 获取个人数据导出配置
* @method GET
* @url /api/settings/personal-data-export
* @returns Promise<PersonalDataExport> 个人数据导出数据
*/
export async function fetchPersonalDataExport(): Promise<PersonalDataExport> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'GET' });
// return res.json();
return mockRequest(MOCK_PERSONAL_DATA_EXPORT);
}
/**
* 申请导出个人数据
* @method POST
* @url /api/settings/personal-data-export
* @returns Promise<{ success: boolean; message: string }> 申请结果
*/
export async function requestPersonalDataExport(): Promise<{ success: boolean; message: string }> {
// TODO: 真实请求(后端就绪后替换)
// const res = await fetch(`${API_BASE_URL}/api/settings/personal-data-export`, { method: 'POST' });
// return res.json();
return mockRequest({ success: true, message: '导出申请已提交,预计 1-2 分钟后可在最近导出中下载。' });
}
/* ===================== 设置:消息通知 ===================== */
/** 通知开关(内存态) */
const MOCK_NOTIFICATION_SETTINGS: ToggleSetting[] = [
{
id: 'interview-remind',
title: '面试提醒',
description: '面试开始前 1 小时推送提醒',
enabled: true,
},
{
id: 'offer-notify',
title: 'Offer 通知',
description: '收到 Offer 或录用意向时通知我',
enabled: true,
},
{
id: 'job-recommend',
title: '岗位推荐',
description: '每日推送匹配度高的新岗位',
enabled: true,
},
{
id: 'hr-reply',
title: '企业回复',
description: 'HR 查看简历或回复消息时通知我',
enabled: true,
},
{
id: 'system-msg',
title: '系统消息',
description: '平台公告、规则更新等系统通知',
enabled: true,
},
{
id: 'marketing',
title: '活动与优惠',
description: '会员活动、法律服务优惠等营销推送',
enabled: false,
},
];
/**
* 获取消息通知开关列表
* @method GET
* @url /api/settings/notification
* @returns Promise<ToggleSetting[]> 通知开关列表
*/
export async function fetchNotificationSettings(): Promise<ToggleSetting[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/notification`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_NOTIFICATION_SETTINGS);
}
/**
* 更新单个通知开关
* @method PATCH
* @url /api/settings/notification/:id
* @param payload {UpdateTogglePayload} 开关 id 与目标状态
* @returns Promise<ToggleSetting[]> 更新后的开关列表
*/
export async function updateNotificationSetting(
payload: UpdateTogglePayload,
): Promise<ToggleSetting[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/notification/${payload.id}`, { method: 'PATCH', body: JSON.stringify(payload) }); return res.json();
const target = MOCK_NOTIFICATION_SETTINGS.find((item) => item.id === payload.id);
if (target) {
target.enabled = payload.enabled;
}
return mockRequest(MOCK_NOTIFICATION_SETTINGS);
}
/* ===================== 设置:账号与安全 ===================== */
/** 账号与安全操作项 */
const MOCK_ACCOUNT_SECURITY: ActionSetting[] = [
{
id: 'phone',
title: '手机号',
description: '用于登录与接收重要通知',
value: '138****8888',
variant: 'normal',
},
{
id: 'password',
title: '修改登录密码',
description: '建议定期更新密码以保护账号安全',
variant: 'normal',
route: '/settings/password',
},
{
id: 'devices',
title: '登录设备管理',
description: '查看并移除已登录的设备',
variant: 'normal',
},
{
id: 'deactivate',
title: '注销账号',
description: '注销后账号不可恢复,且注销审核期间无法登录',
variant: 'danger',
},
];
/**
* 获取账号与安全操作项列表
* @method GET
* @url /api/settings/account-security
* @returns Promise<ActionSetting[]> 操作项列表
*/
export async function fetchAccountSecurity(): Promise<ActionSetting[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/account-security`, { method: 'GET' }); return res.json();
return mockRequest(MOCK_ACCOUNT_SECURITY);
}
/* ===================== 设置:关于我们 ===================== */
/** 关于我们信息 */
const MOCK_ABOUT_INFO: AboutInfo = {
appName: '轻松聘',
version: 'v1.0.0',
company: '阿布阿诺人工智能科技有限公司',
description:
'轻松聘是面向全社会青年求职者的双向透明求职生态平台,提供公司调研、真实评价、行业声誉与岗位评价分级,帮助用户找到更适合自己的工作。',
links: [
// 前两条原先没有 route,导致在「关于我们」页点下去毫无反应(静默失效)。
// 但这两个页面**早已存在**(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' },
],
};
/**
* 获取关于我们信息
* @method GET
* @url /api/settings/about
* @returns Promise<AboutInfo> 关于我们信息
*/
export async function fetchAboutInfo(): Promise<AboutInfo> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/settings/about`, { method: 'GET' }); return res.json();
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 单聊会话 mock 数据(对应用户截图:张HR / 字节跳动 / 高级前端 / 95%匹配) */
const MOCK_CONVERSATION_DETAIL: ConversationDetail = {
id: 'zhang-hr',
contactName: '张HR',
companyName: '字节跳动',
positionName: '高级前端',
avatar: '张',
online: true,
matchInfo: { score: 95 },
messages: [
{
id: 'm0',
role: 'system',
content: '你们已经成为好友,开始聊天吧',
},
{
id: 'm1',
role: 'hr',
content: '您好,我是字节跳动的张HR,看到您的简历了,非常匹配我们的高级前端岗位。',
timestamp: '10:30',
},
{
id: 'm2',
role: 'hr',
content: '方便约个时间面试吗?我们这边流程比较快~',
timestamp: '10:30',
},
{
id: 'm3',
role: 'user',
content: '您好张HR,感谢关注!请问这个岗位的具体要求是什么?',
timestamp: '10:32',
},
{
id: 'm4',
role: 'hr',
content: '主要考察React、TypeScript和工程化能力,您可以先看看面试题准备一下~',
timestamp: '10:35',
},
{
id: 'm5',
role: 'hr',
timestamp: '10:35',
card: {
type: 'interview',
title: '面试题预测 · 前端高频',
items: [
'React Hooks 原理及常见坑',
'前端性能优化方案',
'微前端架构实践',
],
},
},
{
id: 'm6',
role: 'hr',
timestamp: '10:35',
card: {
type: 'salary',
title: '市场薪资参考',
subtitle: '该岗位 3-5 年经验市场薪资:22-38K\n你的期望薪资 30K 处于合理区间',
},
},
{
id: 'm7',
role: 'hr',
card: {
type: 'offer',
title: '你收到了新Offer,点击对比',
subtitle: '字节跳动 vs 腾讯科技,2个Offer',
actionText: '立即对比',
},
},
],
};
/**
* 获取 HR 单聊会话详情
* @method GET
* @url /api/chat/conversations/:id
* @param id {string} 会话 id
* @returns Promise<ConversationDetail> 会话详情
*/
export async function fetchConversation(id: string): Promise<ConversationDetail> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/chat/conversations/${id}`, { method: 'GET' }); return res.json();
return mockRequest({ ...MOCK_CONVERSATION_DETAIL, id });
}
/**
* 发送聊天消息(mock 阶段自动追加用户消息 + HR 自动回复)
* @method POST
* @url /api/chat/conversations/:id/messages
* @param conversationId {string} 会话 id
* @param content {string} 用户输入
* @returns Promise<ChatMessage[]> 新增的两条消息(用户 + 助手)
*/
export async function sendChatMessage(
_conversationId: string,
content: string,
): Promise<ChatMessage[]> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/chat/conversations/${conversationId}/messages`, { method: 'POST', body: JSON.stringify({ content }) }); return res.json();
const now = new Date();
const time = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`;
const userMessage: ChatMessage = {
id: `u-${Date.now()}`,
role: 'user',
content,
timestamp: time,
};
const hrReply: ChatMessage = {
id: `a-${Date.now()}`,
role: 'hr',
content: '收到,我会尽快查看并回复你。',
timestamp: time,
};
return mockRequest([userMessage, hrReply]);
}
/* ===================== 账号:手机号 + 验证码登录 ===================== */
export interface LoginResult {
/** 登录令牌(mock 态为占位串,真实后端返回 JWT 等) */
token: string;
/** 登录手机号 */
phone: string;
}
/**
* 手机号 + 短信验证码登录(登录即注册,二合一)。
* @method POST
* @url /api/auth/login-by-phone
* @param phone 手机号(11 位)
* @param code 短信验证码(6 位)
* @returns 登录结果(含 token)
*/
export function loginByPhone(phone: string, code: string): Promise<LoginResult> {
if (!isValidPhone(phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
if (!isValidSmsCode(code)) {
return Promise.reject(new Error('验证码不正确'));
}
return mockRequest<LoginResult>({ token: `mock_token_${phone}`, phone });
}
/**
* 手机号脱敏(业务规则在本层,页面不自己切字符串)。
* @param phone 手机号
* @returns 11 位时返回 150****9367;非 11 位原样返回(宁可露出也不截断成错的)
*/
export function maskPhone(phone: string): string {
return phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(7)}` : phone;
}
/**
* 厂商返回的运营商原始标识 → CarrierName。
*
* 归一化放在接口层而不是适配层:各家厂商给的形状不一样(中文名 / 大写缩写 / 数字码),
* 而「本项目只认这四个值」是业务规则,不该污染只负责搬运的适配层。
* 认不出来一律 unknown —— 宁可显示「运营商」也不猜错。
*/
export function carrierOf(operator: string): CarrierName {
const s = operator.toUpperCase();
if (s.includes('CMCC') || s.includes('移动') || s.includes('CHINA_MOBILE')) return 'cmcc';
if (s.includes('CUCC') || s.includes('联通') || s.includes('CHINA_UNICOM')) return 'cucc';
if (s.includes('CTCC') || s.includes('电信') || s.includes('CHINA_TELECOM')) return 'ctcc';
return 'unknown';
}
/**
* 取号失败的统一返回:号码与令牌一律空串,只带失败原因。
* 页面靠 `carrierToken === ''` 判定失败并置灰按钮 —— 这里绝不能返回假号码,
* 否则失败分支永远进不去,用户会对着一个必然失败的按钮反复点。
*/
function emptyCarrierResult(reason: CarrierFailureReason): CarrierPhoneResult {
return {
phone: '',
maskedPhone: '',
carrierToken: '',
carrier: 'unknown',
expiresAt: 0,
failureReason: reason,
};
}
/**
* 取本机号码(运营商一键登录第 1 步:取号)。
*
* 真实实现在**端上**完成,不经过我们的服务器:走 src/lib/carrier-sdk.ts 调运营商号码认证
* SDK(原生模块,Expo Go 跑不了,必须 dev build / 出包 —— 详见 运营商一键登录接入方案.md)。
*
* 这里只做三件属于业务规则的事:套超时、归一化运营商、脱敏。取号能力本身不在本文件。
*
* @method POST
* @url /api/auth/carrier/number
* @returns Promise<CarrierPhoneResult> 本机号码 + 掩码 + 一次性鉴权令牌;取号失败时号码与令牌为空串
*/
export async function fetchCarrierPhone(): Promise<CarrierPhoneResult> {
try {
const r = await getCarrierNumber(CARRIER_SDK_TIMEOUT_MS);
if (!r.phone || !r.token) {
return emptyCarrierResult('unknown');
}
return {
phone: r.phone,
maskedPhone: maskPhone(r.phone),
carrierToken: r.token,
carrier: carrierOf(r.operator),
expiresAt: Date.now() + CARRIER_TOKEN_TTL_MS,
failureReason: null,
};
} catch (error) {
// 取号失败不向上抛:页面要的是「没取到号」这个状态,不是异常。
return emptyCarrierResult(
error instanceof CarrierAuthError ? error.reason : 'unknown',
);
}
}
/**
* 运营商一键登录(本机号码认证第 2 步:用取号令牌换登录态)。
*
* 取号拿到的号码只在端上做展示,**可信号码以后端向运营商换取的结果为准**,
* 所以返回的 phone 由接口层给出,页面拿它写回资料,不自己拼预取到的号码。
*
* @method POST
* @url /api/auth/login-by-carrier
* @param carrierToken 取号接口下发的一次性鉴权令牌(空串表示没取到号,直接判失败)
* @returns Promise<LoginResult> 登录结果(含 token 与已核验的手机号)
*/
export function loginByCarrier(carrierToken: string): Promise<LoginResult> {
// TODO: const res = await fetch(`${API_BASE_URL}/api/auth/login-by-carrier`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carrierToken }) }); return res.json();
if (!carrierToken) {
return Promise.reject(new Error('本机号码未取到,请重新获取或改用验证码登录'));
}
return mockRequest<LoginResult>({ token: `mock_token_carrier_${carrierToken}`, phone: MOCK_CARRIER_PHONE });
}
/* ===================== 账号:密码规则(登录/重置/改密三处共用) ===================== */
/**
* 校验登录密码强度。
*
* 规则:8–20 位,且同时包含字母和数字。
* 放在接口层的原因:注册、重置、修改三处共用同一份规则,页面只渲染文案不写判断。
*
* @param password 待校验密码
* @returns 校验失败的错误码;null 表示通过
*/
export function validatePassword(password: string): PasswordRuleError | null {
if (password.length === 0) return 'empty';
if (password.length < PASSWORD_MIN_LENGTH) return 'too-short';
if (password.length > PASSWORD_MAX_LENGTH) return 'too-long';
if (!/[a-zA-Z]/.test(password) || !/\d/.test(password)) return 'need-letter-digit';
return null;
}
/**
* 校验两次输入的密码是否一致。
* @param password 新密码
* @param confirm 确认密码
* @returns 'mismatch';一致返回 null
*/
export function validatePasswordConfirm(
password: string,
confirm: string,
): PasswordRuleError | null {
return password === confirm ? null : 'mismatch';
}
/**
* 错误码 → 中文提示文案(枚举映射集中在此,页面不写死文案)。
* @param error 密码规则错误码
* @returns 可直接展示的中文提示
*/
export function getPasswordRuleMessage(error: PasswordRuleError): string {
return PASSWORD_RULE_LABEL[error];
}
/* ===================== 账号:手机号 + 密码登录(真实实现见 src/api/auth.ts) ===================== */
/**
* 重置 / 首次设置登录密码(忘记密码与首次设密码共用同一入口)。
*
* 「登录即注册」下用户天生没有密码,因此首次获得密码走的就是本接口(忘记密码)。
* 重置成功后直接下发登录态,避免用户重置完再登录一次。
*
* @method POST
* @url /api/auth/reset-password
* @param payload 手机号 + 短信验证码 + 新密码
* @returns 登录结果(含 token)
*/
export function resetPassword(payload: PasswordResetPayload): Promise<LoginResult> {
if (!isValidPhone(payload.phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
if (!isValidSmsCode(payload.code)) {
return Promise.reject(new Error('验证码不正确'));
}
const ruleError = validatePassword(payload.password);
if (ruleError) {
return Promise.reject(new Error(getPasswordRuleMessage(ruleError)));
}
return mockRequest<LoginResult>({
token: `mock_token_${payload.phone}`,
phone: payload.phone,
});
}
/**
* 修改登录密码(已登录态,需校验原密码)。
*
* @method POST
* @url /api/auth/change-password
* @param payload 原密码 + 新密码 + 确认新密码
* @returns 无返回体;失败时 reject
*/
export function changePassword(payload: ChangePasswordPayload): Promise<void> {
if (payload.oldPassword.length === 0) {
return Promise.reject(new Error('请输入原密码'));
}
const ruleError = validatePassword(payload.newPassword);
if (ruleError) {
return Promise.reject(new Error(getPasswordRuleMessage(ruleError)));
}
if (payload.newPassword !== payload.confirmPassword) {
return Promise.reject(new Error(getPasswordRuleMessage('mismatch')));
}
if (payload.newPassword === payload.oldPassword) {
return Promise.reject(new Error(getPasswordRuleMessage('same-as-old')));
}
return mockRequest<void>(undefined);
}
/* ===================== 账号:微信登录(三种账号状态) ===================== */
/*
* mock 专用:切换下一次微信登录返回的账号状态,便于在 Expo Go 里逐个验证三种分支。
* 真实后端就绪后删除本变量与 setMockWechatAccountStatus。
*/
let MOCK_WECHAT_STATUS: WechatAccountStatus = 'new';
/**
* 【仅 mock 阶段】设置下一次微信登录命中哪种账号状态。
* @param status 'new' 第一次登录 | 'active' 第二次及以后 | 'deleted' 注销审核期
*/
export function setMockWechatAccountStatus(status: WechatAccountStatus): void {
MOCK_WECHAT_STATUS = status;
}
/**
* 用微信授权 code 换取登录结果。
*
* 后端职责:code → access_token + openid → 查库判定账号状态。
* 注意 code 一次性且 5 分钟过期,因此后端需在本响应里下发可复用的临时凭证,
* 供后续「绑定手机号」回传(前端拿不到第二个 code)。
*
* @method POST
* @url /api/auth/wechat
* @param code 微信授权返回的一次性 code
* @returns 账号状态与对应令牌;new 时 token 为临时凭证,deleted 时 token 无意义
*/
export function fetchWechatLoginResult(code: string): Promise<WechatLoginResult> {
if (!code) {
return Promise.reject(new Error('微信授权失败,请重试'));
}
if (MOCK_WECHAT_STATUS === 'active') {
return mockRequest<WechatLoginResult>({
status: 'active',
token: `mock_wx_token_${code.slice(-6)}`,
openid: 'mock_openid_active',
expiresAt: Date.now() + TOKEN_TTL_MS,
role: 'candidate',
profile: {
id: 'u-wx-001',
nickname: '微信用户_8f3a',
avatarUri: '',
gender: 'unspecified',
},
});
}
// 注销审核期内:不可登录、不可复活,前端只提示并停留在登录页
if (MOCK_WECHAT_STATUS === 'deleted') {
return mockRequest<WechatLoginResult>({
status: 'deleted',
token: '',
openid: 'mock_openid_deleted',
});
}
return mockRequest<WechatLoginResult>({
status: 'new',
token: `mock_wx_temp_${code.slice(-6)}`,
openid: 'mock_openid_new',
});
}
/**
* 微信新用户绑定手机号,完成注册(第一次登录的必经步骤)。
*
* 用户走完微信授权后 openid 未绑定任何账号,必须用手机号 + 短信验证码完成注册,
* 后端据此把 openid 与新账号关联。此时 code 已失效,只能回传临时凭证。
*
* @method POST
* @url /api/auth/wechat/bind-phone
* @param payload 绑定参数(临时凭证 + 手机号 + 验证码)
* @returns 正式会话(token + 过期时间 + 用户资料)
*/
export function bindWechatAndRegister(
payload: WechatBindPhonePayload,
): Promise<WechatSession> {
if (!isValidPhone(payload.phone)) {
return Promise.reject(new Error('手机号格式不正确'));
}
if (!isValidSmsCode(payload.code)) {
return Promise.reject(new Error('验证码不正确'));
}
return mockRequest<WechatSession>({
token: `mock_wx_session_${payload.openid.slice(-6)}`,
expiresAt: Date.now() + TOKEN_TTL_MS,
profile: {
id: `u-wx-${payload.openid.slice(-6)}`,
nickname: `微信用户_${payload.phone.slice(-4)}`,
avatarUri: '',
gender: 'unspecified',
},
});
}
/**
* 保存求职意向(求职者选择身份后的必经设置步骤,用于岗位推荐匹配)。
* @method POST
* @url /api/user/job-preference
* @param preference 求职意向(期望职位/城市/薪资/工作类型/求职状态)
* @returns 保存后的求职意向
* @remark 存入独立的 `user:job-preference` 键:这是用户显式编辑的偏好,
* 与引导采集的资料(`onboarding:profile`)分开存放,避免互相覆盖
*/
export async function saveJobPreference(preference: JobPreference): Promise<JobPreference> {
// TODO: await fetch(`${API_BASE_URL}/api/user/job-preference`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(preference) });
await storageSet(JOB_PREFERENCE_KEY, JSON.stringify(preference));
return mockRequest<JobPreference>(preference);
}
/* ===================== 本地缓存(设置页「清除缓存」) ===================== */
/**
* 读取应用缓存占用(字节)。
*
* 没有对应的后端接口:读的是**设备上的缓存目录**,因此不走 fetch。
* 读不到(目录不存在 / jest 环境无原生模块)返回 0,页面据此显示 0B。
*
* @returns Promise<number> 缓存占用字节数
* @remark backend-stub-ok:本地文件系统读取,非网络资源
*/
export async function fetchCacheSize(): Promise<number> {
// backend-stub-ok
return readCacheBytes();
}
/**
* 清空应用缓存。
*
* 没有对应的后端接口:清的是设备上的缓存目录内容(保留目录本身)。
* 用户已登录状态、已填资料、简历等都在 AsyncStorage / 文档目录,不受影响。
*
* @returns Promise<number> 本次清理掉的字节数(用于「已清除 xx」提示)
* @remark backend-stub-ok:本地文件系统写入,非网络资源
*/
export async function clearAppCache(): Promise<number> {
// backend-stub-ok
return clearCacheDir();
}