567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
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 {
|
||
/** 缓存时所在的 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<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: 'test.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)
|
||
}
|
||
})
|
||
|
||
// 方式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: {},
|
||
})
|