Files
offerpai_web/src/stores/index.ts
T

567 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, string> = 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<number, string> = {
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 {
/** 缓存时所在的 Tabrecommend / 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<RootState>({
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<any>[] = []
// 按需发起请求,保持顺序以便后续取值
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)
}
})
// 方式1BroadcastChannel 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: {},
})