import { createStore } from 'vuex' import type { Router } from 'vue-router' import { fetchUserRoutes } from '@/api/menu' import type { MenuItemRaw } from '@/api/menu' import { buildDynamicRoutes } from '@/router/dynamicRoutes' import { fetchIndustryTree, fetchJobCategoryTree, fetchRegionTree } from '@/api/common' import type { IndustryItem, JobCategoryItem, RegionItem } from '@/api/common' import { fetchJobIntention, saveJobIntention } from '@/api/jobs' import type { JobIntention } from '@/api/jobs' import { fetchUserInfo } from '@/api/auth' import type { UserInfo } from '@/api/auth' import { fetchMemberStatus } from '@/api/member' import type { MemberStatus } from '@/api/member' import { createChannelBridge } from '@/utils/channelBridge' import { PLUGIN_TARGET_VERSION } from '@/business.config' /** 招聘分类选项:label → 接口参数 recruitCategory */ export const JOB_TYPE_OPTIONS: { label: string; value: number }[] = [ { label: '全部', value: null }, { label: '社招', value: 0 }, { label: '校招', value: 1 }, { label: '实习', value: 2 }, ] /** 招聘分类映射:数字 → 中文标签 */ export const JOB_TYPE_MAP: Record = Object.fromEntries( JOB_TYPE_OPTIONS.map(item => [item.value, item.label]), ) /** 根据 recruitCategory 值获取中文标签,未匹配返回空字符串 */ export function formatEmploymentType(type: number | undefined | null): string { if (type === undefined || type === null) return '' return JOB_TYPE_MAP[type] ?? '' } /** 学历要求映射:数字 → 中文标签(0=不限 1=大专 2=本科 3=硕士 4=博士) */ export const EDUCATION_MAP: Record = { 0: '不限', 1: '大专', 2: '本科', 3: '硕士', 4: '博士', } /** 根据 education 值获取中文标签,未匹配返回空字符串 */ export function formatEducation(edu: number | undefined | null): string { if (edu === undefined || edu === null) return '' return EDUCATION_MAP[edu] ?? '' } /** 职位列表页缓存数据(从详情页返回时恢复用) */ export interface JobListCache { /** 缓存时所在的 Tab(recommend / collected / applied) */ tab: string /** 缓存的职位列表 */ list: any[] /** 当前已加载到的页码 */ pageNum: number /** 总记录数 */ total: number /** 列表滚动位置 */ scrollTop: number } /** 导师列表页缓存数据(从详情页返回时恢复用) */ export interface MentorListCache { /** 缓存的导师列表 */ list: any[] /** 当前已加载到的页码 */ pageNum: number /** 列表滚动位置 */ scrollTop: number } export interface RootState { appName: string showLogin: boolean loginRedirect: string /** * 登录状态 — 登录成功设为 true,退出成功设为 false * 因为后端 Cookie 是 HttpOnly 的,前端 JS 无法读取,所以用内存状态管理 */ isAuthenticated: boolean /** * 后端返回的原始菜单数据,SideNav 用它来渲染导航 */ dynamicMenus: MenuItemRaw[] /** * 标记动态路由是否已经加载过,避免重复请求 * 每次登出时重置为 false */ routesLoaded: boolean /** * 记录已注册的动态路由 name,登出时用来逐个 removeRoute */ dynamicRouteNames: string[] /** * 行业树分类数据(一级二级嵌套) * 由 /public/industries/tree 接口返回,进首页和 Jobs 页时刷新 * 其他页面/组件可直接从 store 读取 */ industries: IndustryItem[] /** * 岗位树分类数据(一级二级三级嵌套) * 由 /public/job-categories/tree 接口返回,进首页和 Jobs 页时刷新 * 其他页面/组件可直接从 store 读取 */ jobCategories: JobCategoryItem[] /** * 地区树分类数据(省市区三级嵌套) * 由 /public/regions/tree 接口返回,进首页和 Jobs 页时刷新 * 其他页面/组件可直接从 store 读取 */ regions: RegionItem[] /** * 职位列表页缓存 — 从详情页返回时恢复列表和滚动位置 * 为 null 表示没有缓存,需要重新请求 */ jobListCache: JobListCache | null /** * 导师列表页缓存 — 从详情页返回时恢复列表和滚动位置 * 为 null 表示没有缓存,需要重新请求 */ mentorListCache: MentorListCache | null /** * 求职意向数据 — JobGoalDialog 和 Jobs 页面共享 * 由 loadJobIntention action 从接口加载,saveJobIntention action 保存后更新 */ jobIntention: JobIntention /** * 用户个人信息 — 登录后从 /user/manage/info 接口获取 * 多个页面可直接从 store 读取 */ userInfo: UserInfo | null /** * 会员状态信息 — 登录后从 /member/status 接口获取 * 多个页面可直接从 store 读取 */ memberStatus: MemberStatus | null /** * 设置弹窗 — 控制显示/隐藏及初始 Tab */ showSettings: boolean settingsTab: string /** * 邀请码 — 从 URL 参数 invite_code 中提取 * 登录成功后自动清空,避免重复发送 */ inviteCode: string /** * 全局会员权限拦截弹窗 — 路由守卫或页面检测到免费用户时触发 */ showMemberAccessDialog: boolean /** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */ bridgeDomain: string /** 浏览器插件是否在线(3秒内有 BroadcastChannel 心跳则为 true) */ plugIsOnline: boolean /** 插件当前使用版本(从通信数据 offerpieBrowserPlugUsage.version 获取) */ plugUseVersion: string /** 插件目标版本(手动设置,用于判断是否需要更新) */ plugTargetVersion: string /** 插件是否是最新版本(plugUseVersion === plugTargetVersion) */ isLatestPlugin: boolean } export default createStore({ state: { appName: 'JobAssistant', showLogin: false, loginRedirect: '', isAuthenticated: sessionStorage.getItem('isAuthenticated') === 'true', dynamicMenus: [], routesLoaded: false, dynamicRouteNames: [], industries: [], jobCategories: [], regions: [], jobListCache: null, mentorListCache: null, jobIntention: { categoryIds: [], regionCodes: [], industryIds: [], employmentType: null, recruitCategory: null, aiCategoryIds: [], aiIndustryIds: [], }, userInfo: null, memberStatus: null, showSettings: false, settingsTab: 'account', inviteCode: '', showMemberAccessDialog: false, /** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */ bridgeDomain: 'offerpai.com.cn', /** 浏览器插件是否在线 */ plugIsOnline: false, /** 插件当前使用版本 */ plugUseVersion: '', /** 插件目标版本 */ plugTargetVersion: PLUGIN_TARGET_VERSION, /** 插件是否是最新版本 */ isLatestPlugin: true, }, getters: { getAppName: (state) => state.appName, }, mutations: { SET_APP_NAME(state, name: string) { state.appName = name }, SET_SHOW_LOGIN(state, show: boolean) { state.showLogin = show }, SET_LOGIN_REDIRECT(state, path: string) { state.loginRedirect = path }, SET_AUTHENTICATED(state, val: boolean) { state.isAuthenticated = val if (val) { sessionStorage.setItem('isAuthenticated', 'true') } else { sessionStorage.removeItem('isAuthenticated') } }, SET_DYNAMIC_MENUS(state, menus: MenuItemRaw[]) { state.dynamicMenus = menus }, SET_ROUTES_LOADED(state, loaded: boolean) { state.routesLoaded = loaded }, SET_DYNAMIC_ROUTE_NAMES(state, names: string[]) { state.dynamicRouteNames = names }, SET_INDUSTRIES(state, data: IndustryItem[]) { state.industries = data }, SET_JOB_CATEGORIES(state, data: JobCategoryItem[]) { state.jobCategories = data }, SET_REGIONS(state, data: RegionItem[]) { state.regions = data }, SET_JOB_LIST_CACHE(state, cache: JobListCache | null) { state.jobListCache = cache }, SET_MENTOR_LIST_CACHE(state, cache: MentorListCache | null) { state.mentorListCache = cache }, SET_JOB_INTENTION(state, data: JobIntention) { state.jobIntention = { categoryIds: data.categoryIds ?? [], regionCodes: data.regionCodes ?? [], industryIds: data.industryIds ?? [], employmentType: data.employmentType ?? null, recruitCategory: data.recruitCategory ?? null, aiCategoryIds: data.aiCategoryIds ?? [], aiIndustryIds: data.aiIndustryIds ?? [], } }, SET_USER_INFO(state, data: UserInfo | null) { state.userInfo = data }, SET_MEMBER_STATUS(state, data: MemberStatus | null) { state.memberStatus = data }, SET_SHOW_SETTINGS(state, show: boolean) { state.showSettings = show }, SET_SETTINGS_TAB(state, tab: string) { state.settingsTab = tab }, SET_INVITE_CODE(state, code: string) { state.inviteCode = code }, SET_SHOW_MEMBER_ACCESS_DIALOG(state, show: boolean) { state.showMemberAccessDialog = show }, SET_PLUG_IS_ONLINE(state, online: boolean) { state.plugIsOnline = online }, SET_PLUG_USE_VERSION(state, version: string) { state.plugUseVersion = version // 实时计算是否最新版本 state.isLatestPlugin = version === state.plugTargetVersion }, }, actions: { updateAppName({ commit }, name: string) { commit('SET_APP_NAME', name) }, openLogin({ commit }, redirect = '') { commit('SET_LOGIN_REDIRECT', redirect) commit('SET_SHOW_LOGIN', true) }, closeLogin({ commit }) { commit('SET_SHOW_LOGIN', false) commit('SET_LOGIN_REDIRECT', '') }, /** * 核心 action:从后端(或 mock)拉取菜单,转成路由并注册 * * @param router — vue-router 实例,由路由守卫调用时传入 * * 流程: * 1. 调 fetchUserRoutes 拿到后端菜单数据 * 2. 存到 state.dynamicMenus(给 SideNav 渲染用) * 3. 用 buildDynamicRoutes 转成 RouteRecordRaw * 4. 逐条 router.addRoute 注册 * 5. 记录已注册的路由名,标记 routesLoaded = true */ async loadDynamicRoutes({ commit }, router: Router) { const menus = await fetchUserRoutes() commit('SET_DYNAMIC_MENUS', menus) const routes = buildDynamicRoutes(menus) const names: string[] = [] for (const route of routes) { router.addRoute(route) if (route.name) names.push(route.name as string) } commit('SET_DYNAMIC_ROUTE_NAMES', names) commit('SET_ROUTES_LOADED', true) }, /** * 静默刷新路由菜单数据(不重新注册路由,只更新菜单显示和权限状态) * 每次页面切换时由路由守卫调用,用于更新 accessible 权限信息 */ async refreshDynamicMenus({ commit }) { try { const menus = await fetchUserRoutes() commit('SET_DYNAMIC_MENUS', menus) } catch (err) { console.error('[store] 刷新路由菜单数据失败', err) } }, /** * 登出:重置状态(不再操作 localStorage,登录状态由 Cookie 管理) * 注意:动态路由不清除,与登录状态无关 */ logout({ commit }) { commit('SET_AUTHENTICATED', false) commit('SET_SHOW_LOGIN', false) commit('SET_LOGIN_REDIRECT', '') commit('SET_USER_INFO', null) commit('SET_MEMBER_STATUS', null) // 清除 Jobs 页面缓存数据 commit('SET_JOB_LIST_CACHE', null) commit('SET_JOB_INTENTION', { categoryIds: [], regionCodes: [], industryIds: [], employmentType: null, recruitCategory: null, aiCategoryIds: [], aiIndustryIds: [], }) }, /** * 加载工具类公共数据(行业分类、岗位分类等) * 进首页和 Jobs 页时调用 * 如果 store 中已有数据则跳过请求,避免重复加载 * 传入 force = true 可强制刷新 */ async loadCommonData({ commit, state }, { force = false } = {}) { const needIndustry = force || state.industries.length === 0 const needJobCategory = force || state.jobCategories.length === 0 const needRegion = force || state.regions.length === 0 // 三个都已缓存,直接返回 if (!needIndustry && !needJobCategory && !needRegion) return try { const promises: Promise[] = [] // 按需发起请求,保持顺序以便后续取值 promises.push(needIndustry ? fetchIndustryTree() : Promise.resolve(null)) promises.push(needJobCategory ? fetchJobCategoryTree() : Promise.resolve(null)) promises.push(needRegion ? fetchRegionTree() : Promise.resolve(null)) const [industryRes, jobCategoryRes, regionRes] = await Promise.all(promises) if (industryRes && industryRes.code === '0' && industryRes.data) { commit('SET_INDUSTRIES', industryRes.data) } if (jobCategoryRes && jobCategoryRes.code === '0' && jobCategoryRes.data) { commit('SET_JOB_CATEGORIES', jobCategoryRes.data) } if (regionRes && regionRes.code === '0' && regionRes.data) { commit('SET_REGIONS', regionRes.data) } } catch (err) { console.error('[store] 加载公共分类数据失败', err) } }, /** * 从接口加载求职意向数据到 store * JobGoalDialog 和 Jobs 页面都可调用 */ async loadJobIntention({ commit }) { try { const res = await fetchJobIntention() if (res.code === '0' && res.data) { commit('SET_JOB_INTENTION', res.data) } } catch (err) { console.error('[store] 加载求职意向失败', err) } }, /** * 加载用户个人信息到 store * 登录状态下调用,多个页面可直接读取 store.state.userInfo */ async loadUserInfo({ commit }) { try { const res = await fetchUserInfo() if (res.code === '0' && res.data) { commit('SET_USER_INFO', res.data) } } catch (err) { console.error('[store] 加载用户信息失败', err) } }, /** * 加载会员状态到 store * 登录状态下调用,多个页面可直接读取 store.state.memberStatus */ async loadMemberStatus({ commit }) { try { const res = await fetchMemberStatus() if (res.code === '0' && res.data) { commit('SET_MEMBER_STATUS', res.data) } } catch (err) { console.error('[store] 加载会员状态失败', err) } }, /** * 保存求职意向:已登录时调接口保存并更新 store,未登录时仅更新 store * @param data 求职意向数据 */ async saveJobIntention({ commit, state }, data: JobIntention) { if (state.isAuthenticated) { // 已登录 — 调接口持久化,成功后同步 store const res = await saveJobIntention(data) if (res.code === '0') { commit('SET_JOB_INTENTION', data) } return res } else { // 未登录 — 仅本地更新 store,不请求接口 commit('SET_JOB_INTENTION', data) return { code: '0', message: 'ok' } } }, /** * 初始化浏览器插件心跳监听 * 双重检测机制: * 1. BroadcastChannel watch — 实时收心跳(插件与本页面同时在线时最快) * 2. 定时轮询 bridge.get 读 IndexedDB — 每3秒兜底检测(覆盖 BroadcastChannel 不通的场景) * 启动时立即 get 一次快速判断初始在线状态 * 返回销毁函数 */ startPlugHeartbeat({ commit, state }) { const bridge = createChannelBridge({ domain: state.bridgeDomain }) let lastHeartbeat = 0 /** 解析 updateTime 字符串为时间戳(格式:yyyy-MM-dd HH:mm:ss) */ function parseTime(timeStr: string): number { return new Date(timeStr.replace(' ', 'T')).getTime() } /** 根据 updateTime 判断是否在线并更新状态,同时提取 version */ function checkOnline(updateTime: string, version?: string) { const elapsed = Date.now() - parseTime(updateTime) if (elapsed <= 3000) { lastHeartbeat = Date.now() if (!state.plugIsOnline) { commit('SET_PLUG_IS_ONLINE', true) } // 更新插件版本号 if (version && version !== state.plugUseVersion) { commit('SET_PLUG_USE_VERSION', version) } } } // 启动时立即 get 一次,根据 updateTime 判断初始在线状态 bridge.get<{ usage: string; version?: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage').then((data) => { if (data && data.updateTime) { checkOnline(data.updateTime, data.version) } }) // 方式1:BroadcastChannel watch — 实时收心跳 const unwatch = bridge.watch<{ usage: string; version?: string }>( state.bridgeDomain, 'offerpieBrowserPlugUsage', (data) => { if (data && data.updateTime) { checkOnline(data.updateTime, data.version) } } ) // 方式2:定时轮询 get IndexedDB — 兜底(覆盖 BroadcastChannel 断连场景) const pollTimer = setInterval(async () => { try { const data = await bridge.get<{ usage: string; version?: string }>(state.bridgeDomain, 'offerpieBrowserPlugUsage') if (data && data.updateTime) { checkOnline(data.updateTime, data.version) } } catch { /* 忽略读取异常 */ } }, 3000) // 定时检查超时(3秒无心跳判定离线) const checkTimer = setInterval(() => { if (lastHeartbeat > 0 && Date.now() - lastHeartbeat > 3000) { if (state.plugIsOnline) { commit('SET_PLUG_IS_ONLINE', false) } } }, 1000) // 返回销毁函数 return () => { unwatch() clearInterval(pollTimer) clearInterval(checkTimer) bridge.destroy() } }, }, modules: {}, })