Compare commits

2 Commits
Author SHA1 Message Date
xuxin 6c03793daf 去掉offer派插件test和www不同环境域名的通信隔离 2026-07-17 20:19:59 +08:00
xuxin 33ecccdcb0 bug处理加文字样式微调 2026-07-17 20:00:24 +08:00
9 changed files with 538 additions and 229 deletions
+1
View File
@@ -42,6 +42,7 @@ declare module 'vue' {
FullscreenLoading: typeof import('./src/components/FullscreenLoading.vue')['default']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IndustrySelector: typeof import('./src/components/tools/IndustrySelector.vue')['default']
JobApplyMethodDialog: typeof import('./src/components/JobApplyMethodDialog.vue')['default']
JobCategorySelector: typeof import('./src/components/tools/JobCategorySelector.vue')['default']
JobDislikeDialog: typeof import('./src/components/JobDislikeDialog.vue')['default']
JobFeedbackDialog: typeof import('./src/components/JobFeedbackDialog.vue')['default']
+3 -2
View File
@@ -167,14 +167,15 @@
display: flex;
align-items: center;
justify-content: center;
padding: 0.08rem 0.16rem;
width: 0.88rem;
height: 0.37rem;
line-height: 0.37rem;
background: $accent;
border: 1px solid $accent;
border-radius: 0.08rem;
color: $bg-white;
font-size: 0.14rem;
font-weight: 600;
line-height: 0.19rem;
cursor: pointer;
transition: background 0.2s;
+1 -1
View File
@@ -6,4 +6,4 @@
export const ENABLE_FORCE_RESUME_UPLOAD = true
/** 浏览器插件目标版本(用于判断用户是否需要更新插件) */
export const PLUGIN_TARGET_VERSION = '0.1.0'
export const PLUGIN_TARGET_VERSION = '0.1.1'
+420
View File
@@ -0,0 +1,420 @@
<template>
<!-- 选择投递方式弹窗 -->
<div v-if="visible" class="job-apply-method__overlay" @click.self="close">
<div class="job-apply-method__dialog">
<!-- 渐变背景层 -->
<div class="job-apply-method__bg"></div>
<!-- 标题 -->
<p class="job-apply-method__title">选择投递方式</p>
<!-- 关闭按钮 -->
<span class="job-apply-method__close" @click="close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="#6A7282" stroke-width="1.5" stroke-linecap="round"/></svg>
</span>
<!-- 两个选项卡 -->
<div class="job-apply-method__options">
<!-- 直接前往官网 -->
<div class="job-apply-method__card" :class="{ 'job-apply-method__card--active': choice === 'direct' }" @click="choice = 'direct'">
<p class="job-apply-method__card-title">直接前往官网</p>
<p class="job-apply-method__card-desc">手动填写网申信息<br>自行记录投递进度</p>
</div>
<!-- 智能投递助手 -->
<div class="job-apply-method__card" :class="{ 'job-apply-method__card--active': choice === 'agent' }" @click="choice = 'agent'">
<!-- 左下角圆形装饰 -->
<div class="job-apply-method__card-circle"></div>
<div class="job-apply-method__card-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1L10 5.5L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 5.5L8 1Z" fill="#52CAD1"/></svg>
<span class="job-apply-method__card-title job-apply-method__card-title--accent">智能投递助手</span>
</div>
<p class="job-apply-method__card-desc">{{ agentCardDesc }}</p>
</div>
</div>
<!-- 底部操作按钮 -->
<button class="job-apply-method__action" :class="actionClass" :disabled="!choice" @click="handleAction">
<svg v-if="actionIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_apply_method)">
<path d="M6.0714 3.63204C6.47007 2.46537 8.08207 2.43004 8.55474 3.52604L8.59474 3.63271L9.13274 5.20604C9.25603 5.56686 9.45527 5.89705 9.71702 6.17432C9.97877 6.4516 10.2969 6.66952 10.6501 6.81337L10.7947 6.86737L12.3681 7.40471C13.5347 7.80337 13.5701 9.41537 12.4747 9.88804L12.3681 9.92804L10.7947 10.466C10.4338 10.5893 10.1035 10.7885 9.82608 11.0502C9.54869 11.312 9.33067 11.6302 9.18674 11.9834L9.13274 12.1274L8.5954 13.7014C8.19674 14.868 6.58474 14.9034 6.11274 13.808L6.0714 13.7014L5.53407 12.128C5.41086 11.7671 5.21165 11.4368 4.9499 11.1594C4.68814 10.882 4.36993 10.664 4.01674 10.52L3.87274 10.466L2.2994 9.92871C1.13207 9.53004 1.09674 7.91804 2.19274 7.44604L2.2994 7.40471L3.87274 6.86737C4.23356 6.74408 4.56374 6.54484 4.84102 6.28309C5.11829 6.02134 5.33621 5.70317 5.48007 5.35004L5.53407 5.20604L6.0714 3.63204ZM7.3334 4.06271L6.79607 5.63604C6.60833 6.18624 6.30292 6.68891 5.90109 7.10904C5.49927 7.52917 5.0107 7.85666 4.4694 8.06871L4.30274 8.12937L2.7294 8.66671L4.30274 9.20404C4.85294 9.39178 5.35561 9.6972 5.77573 10.099C6.19586 10.5008 6.52335 10.9894 6.7354 11.5307L6.79607 11.6974L7.3334 13.2707L7.87074 11.6974C8.05848 11.1472 8.36389 10.6445 8.76571 10.2244C9.16753 9.80425 9.65611 9.47676 10.1974 9.26471L10.3641 9.20471L11.9374 8.66671L10.3641 8.12937C9.81387 7.94163 9.3112 7.63622 8.89107 7.2344C8.47094 6.83258 8.14345 6.34401 7.9314 5.80271L7.8714 5.63604L7.3334 4.06271ZM12.6667 1.33337C12.7915 1.33337 12.9137 1.36836 13.0195 1.43436C13.1253 1.50035 13.2105 1.59471 13.2654 1.70671L13.2974 1.78471L13.5307 2.46871L14.2154 2.70204C14.3404 2.7445 14.45 2.82312 14.5302 2.92792C14.6105 3.03272 14.6578 3.15899 14.6663 3.29072C14.6747 3.42246 14.6438 3.55373 14.5775 3.6679C14.5113 3.78207 14.4126 3.874 14.2941 3.93204L14.2154 3.96404L13.5314 4.19737L13.2981 4.88204C13.2555 5.00699 13.1769 5.11651 13.072 5.1967C12.9672 5.2769 12.8409 5.32417 12.7092 5.33252C12.5774 5.34088 12.4462 5.30994 12.3321 5.24363C12.2179 5.17732 12.1261 5.07862 12.0681 4.96004L12.0361 4.88204L11.8027 4.19804L11.1181 3.96471C10.9931 3.92224 10.8835 3.84363 10.8033 3.73883C10.723 3.63403 10.6756 3.50776 10.6672 3.37603C10.6588 3.24429 10.6897 3.11302 10.7559 2.99885C10.8222 2.88468 10.9208 2.79275 11.0394 2.73471L11.1181 2.70271L11.8021 2.46937L12.0354 1.78471C12.0804 1.65299 12.1654 1.53865 12.2786 1.45771C12.3918 1.37677 12.5276 1.33329 12.6667 1.33337Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_apply_method">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
{{ actionText }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { createChannelBridge } from '@/utils/channelBridge'
import { fetchAgentConfig, applyJob } from '@/api/agent'
import { ElMessage } from 'element-plus'
// ==================== Props & Emits ====================
const props = defineProps<{
/** 控制弹窗显隐(v-model */
modelValue: boolean
/** 岗位来源链接 */
sourceUrl: string
/** 岗位 ID */
jobId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
/** 需要打开会员购买弹窗 */
(e: 'openMember'): void
}>()
// ==================== 基础依赖 ====================
const router = useRouter()
const store = useStore()
// ==================== 弹窗显隐 ====================
/** 弹窗是否显示 */
const visible = computed(() => props.modelValue)
// ==================== 状态变量 ====================
/** 用户选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */
const choice = ref<'direct' | 'agent' | null>(null)
/** AI助手是否已完成配置(status === 1 */
const agentConfigReady = ref(false)
/** 是否正在加载 AI 助手配置 */
const agentConfigLoading = ref(false)
// ==================== 从 Store 读取的状态 ====================
/** 用户会员状态 */
const memberStatus = computed(() => store.state.memberStatus)
/** 用户是否是会员(正式或试用) */
const isMember = computed(() => !!memberStatus.value?.isMember)
// ==================== 计算属性 ====================
/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */
const agentCardDesc = computed(() => {
if (!isMember.value) {
return '自动填写网申信息\n统一管理投递进度'
}
if (!agentConfigReady.value) {
return '完成助手设置后\n可使用智能投递'
}
return '将该岗位加入待投递列表\n在投递助手中统一处理'
})
/** 底部操作按钮文字 */
const actionText = computed(() => {
if (!choice.value) return '请选择投递方式'
if (choice.value === 'direct') return '去官网投递'
if (!isMember.value) return '开通会员使用'
if (!agentConfigReady.value) return '去完成设置'
return '加入待投递列表'
})
/** 底部操作按钮是否显示 icon */
const actionIcon = computed(() => {
if (!choice.value) return false
if (choice.value === 'direct') return false
return true
})
/** 底部操作按钮样式 class */
const actionClass = computed(() => {
if (!choice.value) return 'job-apply-method__action--disabled'
return 'job-apply-method__action--active'
})
// ==================== 方法 ====================
/** 加载 AI 助手配置状态 */
async function loadAgentConfigStatus() {
if (agentConfigLoading.value) return
agentConfigLoading.value = true
try {
const res = await fetchAgentConfig()
if (res.code === '0' && res.data && res.data.status === 1) {
agentConfigReady.value = true
} else {
agentConfigReady.value = false
}
} catch {
agentConfigReady.value = false
} finally {
agentConfigLoading.value = false
}
}
/** 打开弹窗时重置状态并加载配置 */
function open() {
choice.value = null
loadAgentConfigStatus()
}
/** 关闭弹窗 */
function close() {
emit('update:modelValue', false)
}
/** 底部操作按钮点击处理 */
async function handleAction() {
if (!choice.value) return
if (choice.value === 'direct') {
// 直接前往官网 — 先跳转,再异步推送链接给插件(不阻塞跳转)
close()
if (props.sourceUrl) {
window.open(props.sourceUrl, '_blank')
// 异步推送投递链接到插件,不影响用户跳转体验
const url = props.sourceUrl
const bridge = createChannelBridge({ domain: store.state.bridgeDomain })
bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true)
.then((existing) => {
const linkList: string[] = existing?.linkList || []
if (!linkList.includes(url)) {
linkList.push(url)
}
return bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true)
})
.catch((e) => {
console.error('[JobApplyMethodDialog] 跨域 put 投递链接失败', e)
})
.finally(() => {
bridge.destroy()
})
}
return
}
// 智能投递助手分支
if (!isMember.value) {
// 非会员 — 打开会员购买弹窗
close()
emit('openMember')
return
}
if (!agentConfigReady.value) {
// 有会员但没完成 AI 助手配置 — 跳转 Agent 页面
close()
router.push('/agent')
return
}
// 有会员且配置完成 — 加入待投递列表
try {
const res = await applyJob({ jobId: props.jobId, status: -1 })
if (res.code === '0') {
ElMessage.success('已加入待投递列表')
} else {
ElMessage.error(res.msg || '加入待投递列表失败')
}
} catch {
ElMessage.error('加入待投递列表失败')
}
close()
}
// ==================== 暴露方法 ====================
defineExpose({ open })
</script>
<style lang="scss" scoped>
@use '../assets/styles/variables' as *;
/* 组件最外层字号基准 */
.job-apply-method__overlay {
font-size: 0.14rem;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $overlay-bg;
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.job-apply-method__dialog {
position: relative;
width: 4.8rem;
background: $bg-white;
border: 1px solid #F0F0F0;
box-shadow: 0 0 0.16rem rgba(0, 0, 0, 0.05);
border-radius: 0.12rem;
padding: 0.28rem 0.32rem 0.32rem;
box-sizing: border-box;
overflow: hidden;
}
.job-apply-method__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg, #EDF9FA 0%, #FFFFFF 20.63%);
border-radius: 0.12rem;
pointer-events: none;
}
.job-apply-method__title {
position: relative;
font-size: 0.18rem;
font-weight: 620;
color: $text-dark;
text-align: center;
margin: 0 0 0.2rem 0;
line-height: 0.24rem;
}
.job-apply-method__close {
position: absolute;
top: 0.24rem;
right: 0.24rem;
width: 0.24rem;
height: 0.24rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
&:hover {
opacity: 0.7;
}
}
.job-apply-method__options {
position: relative;
display: flex;
gap: 0.16rem;
margin-bottom: 0.24rem;
}
.job-apply-method__card {
position: relative;
flex: 1;
height: 1.06rem;
background: $bg-white;
border: 1px solid #F0F0F0;
border-radius: 0.08rem;
padding: 0.2rem 0;
box-sizing: border-box;
cursor: pointer;
text-align: center;
transition: border-color 0.2s, background 0.2s;
overflow: hidden;
&:hover {
border-color: #D0D0D0;
}
&--active {
border-color: $accent;
background: #EDF9FA;
}
}
.job-apply-method__card-title {
font-size: 0.14rem;
font-weight: 450;
color: #000;
margin: 0;
line-height: 0.19rem;
&--accent {
color: $accent;
}
}
.job-apply-method__card-desc {
font-size: 0.12rem;
font-weight: 330;
color: $text-light;
line-height: 0.2rem;
margin: 0.06rem 0 0 0;
white-space: pre-line;
}
.job-apply-method__card-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.04rem;
}
.job-apply-method__card-circle {
position: absolute;
width: 0.46rem;
height: 0.46rem;
left: -0.05rem;
bottom: -0.08rem;
background: #AEE6EA;
border-radius: 50%;
opacity: 1;
transition: opacity 0.2s;
&::before,
&::after {
content: '';
position: absolute;
width: 0.04rem;
height: 0.09rem;
background: $bg-white;
border-radius: 0.02rem;
top: 40%;
}
&::before {
left: 42%;
}
&::after {
left: 60%;
}
}
.job-apply-method__action {
position: relative;
width: 100%;
height: 0.35rem;
border: none;
border-radius: 0.08rem;
font-size: 0.14rem;
font-weight: 450;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
&--disabled {
background: #EDF9FA;
color: #AEE6EA;
cursor: not-allowed;
}
&--active {
background: $accent;
color: $bg-white;
&:hover {
background: $accent-hover;
}
}
}
</style>
+2 -2
View File
@@ -48,8 +48,8 @@
<!-- 匹配度标题区域 -->
<div class="job-resume-custom-dialog__gap-header">
<div class="job-resume-custom-dialog__gap-left">
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位<span class="job-resume-custom-dialog__gap-title-highlight">匹配度{{ isLowMatch ? '较' : '较' }}</span></h3>
<p class="job-resume-custom-dialog__gap-subtitle">匹配度低于 6.0 分的简历在筛选环节可能会被优先淘汰我们会帮你快速优化提升</p>
<h3 class="job-resume-custom-dialog__gap-title">你的简历与该岗位任职要求差距<span class="job-resume-custom-dialog__gap-title-highlight">{{ isLowMatch ? '较' : '较' }}</span></h3>
<p class="job-resume-custom-dialog__gap-subtitle">竞争力低于 6.0 分的简历在筛选环节可能会被优先淘汰我们会帮你快速优化提升</p>
</div>
<!-- 右侧匹配度分数 -->
<div class="job-resume-custom-dialog__gap-score-area">
+1 -1
View File
@@ -213,7 +213,7 @@ export default createStore<RootState>({
inviteCode: '',
showMemberAccessDialog: false,
/** 通信桥接组件的域名标识(用于 BroadcastChannel 频道隔离) */
bridgeDomain: 'test.offerpai.com.cn',
bridgeDomain: 'offerpai.com.cn',
/** 浏览器插件是否在线 */
plugIsOnline: false,
/** 插件当前使用版本 */
+64 -18
View File
@@ -45,8 +45,8 @@
* ------------------------------------------------------------
* | 数据名(name) | 中文名称 | 说明 |
* | ----------------------------- | -------------- | --------------------------------- |
* * | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } |
* * | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] },useCsl:true |
* | offerpieBrowserPlugUsage | 插件使用状态 | 标识插件正在运行 { usage: string, version: string } |
* | offerpaiDeliveryLinkList | 投递链接列表 | 记录要投递职位的来源链接,目前用于解决投递网站的重定向 { linkList: string[] } |
* ============================================================
*/
@@ -108,8 +108,6 @@ export interface ChannelBridgeOptions {
/** IndexedDB 数据库名称(含固定雪花ID保证唯一性) */
const DB_NAME = 'offerpie_comm_7394028156183472'
/** IndexedDB 版本号 */
const DB_VERSION = 1
/** get 方法 BroadcastChannel 超时时间(毫秒) */
const GET_TIMEOUT_MS = 60
/** emit 方法节流写库间隔(毫秒) */
@@ -235,10 +233,30 @@ function _getCslProxyChannel(domain: string): BroadcastChannel {
// ============ IndexedDB 操作 ============
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore */
/** 数据库连接缓存(避免重复打开和版本冲突) */
const _dbCache = new Map<string, Promise<IDBDatabase>>()
/** 打开/创建 IndexedDB 数据库,按 domain 动态创建 ObjectStore,带连接缓存防并发冲突 */
function openDB(storeName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
// 如果已有进行中或已完成的连接,直接复用
if (_dbCache.has(storeName)) {
return _dbCache.get(storeName)!.then(db => {
// 检查连接是否还有效(可能被 close 了)
try {
// 尝试创建事务验证连接有效性
if (db.objectStoreNames.contains(storeName)) {
return db
}
} catch {
// 连接已关闭,清除缓存重新打开
_dbCache.delete(storeName)
}
return openDB(storeName)
})
}
const promise = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(storeName)) {
@@ -248,9 +266,10 @@ function openDB(storeName: string): Promise<IDBDatabase> {
}
request.onsuccess = () => {
const db = request.result
// 如果 store 不存在(版本没升级的情况),关闭后升版本重建
if (!db.objectStoreNames.contains(storeName)) {
// store 不存在,需要升版本。关闭当前连接再重开
db.close()
_dbCache.delete(storeName)
const version = db.version + 1
const req2 = indexedDB.open(DB_NAME, version)
req2.onupgradeneeded = () => {
@@ -261,37 +280,60 @@ function openDB(storeName: string): Promise<IDBDatabase> {
}
}
req2.onsuccess = () => resolve(req2.result)
req2.onerror = () => reject(req2.error)
req2.onerror = () => { _dbCache.delete(storeName); reject(req2.error) }
req2.onblocked = () => {
// 其他标签页占用数据库导致升级被阻塞,超时后放弃
console.warn('[ChannelBridge] IndexedDB upgrade blocked, retrying...')
_dbCache.delete(storeName)
reject(new Error('IndexedDB upgrade blocked'))
}
} else {
resolve(db)
}
}
request.onerror = () => reject(request.error)
request.onerror = () => { _dbCache.delete(storeName); reject(request.error) }
request.onblocked = () => {
console.warn('[ChannelBridge] IndexedDB open blocked')
_dbCache.delete(storeName)
reject(new Error('IndexedDB open blocked'))
}
})
_dbCache.set(storeName, promise)
return promise
}
/** 写入或更新一条记录到 IndexedDB */
async function dbPut(storeName: string, record: DBRecord): Promise<void> {
try {
const db = await openDB(storeName)
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite')
const store = tx.objectStore(storeName)
store.put(record)
tx.oncomplete = () => { db.close(); resolve() }
tx.onerror = () => { db.close(); reject(tx.error) }
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
} catch (err) {
console.warn('[ChannelBridge] dbPut failed:', err)
}
}
/** 从 IndexedDB 读取一条记录 */
async function dbGet(storeName: string, name: string): Promise<DBRecord | null> {
try {
const db = await openDB(storeName)
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly')
const store = tx.objectStore(storeName)
const req = store.get(name)
req.onsuccess = () => { db.close(); resolve(req.result || null) }
req.onerror = () => { db.close(); reject(req.error) }
req.onsuccess = () => resolve(req.result || null)
req.onerror = () => reject(req.error)
})
} catch (err) {
console.warn('[ChannelBridge] dbGet failed:', err)
return null
}
}
// ============ 核心:创建通信桥接实例 ============
@@ -499,19 +541,23 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
}
// 默认模式:IndexedDB
// 先尝试获取 createTime(从暂存或数据库
// 先尝试获取 createTime(从暂存或内存缓存
let createTime: string
const pending = emitPending.get(key)
if (pending) {
createTime = pending.createTime
} else {
const existing = await dbGet(targetDomain, name)
// 只在首次查库获取 createTime,失败就用当前时间(不阻塞后续 emit)
let existing: DBRecord | null = null
try {
existing = await dbGet(targetDomain, name)
} catch {}
createTime = existing?.createTime || now
}
const updateTime = now
const record: DBRecord = { name, data, createTime, updateTime }
// 暂存最新数据
// 暂存最新数据(不再 delete,保证后续 emit 总能命中缓存不走 dbGet)
emitPending.set(key, record)
// BroadcastChannel 每次都广播(保证 watch 实时性)
@@ -525,7 +571,7 @@ export function createChannelBridge(options: ChannelBridgeOptions) {
emitTimers.delete(key)
const latestRecord = emitPending.get(key)
if (latestRecord) {
emitPending.delete(key)
// 注意:不再 delete emitPending,让下次 emit 继续命中缓存
await dbPut(targetDomain, latestRecord)
}
}, EMIT_THROTTLE_MS))
+19 -5
View File
@@ -169,6 +169,15 @@
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
<!-- 选择投递方式弹窗 -->
<JobApplyMethodDialog
ref="applyMethodDialogRef"
v-model="showApplyMethodDialog"
:source-url="job.sourceUrl"
:job-id="jobId"
@open-member="showMemberDialog = true"
/>
<!-- 全屏加载遮罩 -->
<FullscreenLoading :visible="fullLoading" :text="fullLoadingText" title="Nova 正在帮您努力处理~" />
@@ -187,7 +196,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
@@ -199,6 +208,7 @@ import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
import FullscreenLoading from '@/components/FullscreenLoading.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import StepProgressOverlay from '@/components/StepProgressOverlay.vue'
import JobApplyMethodDialog from '@/components/JobApplyMethodDialog.vue'
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
import type { JobDetailData, SkillGapData } from '@/api/jobs'
@@ -586,10 +596,14 @@ async function toggleFavorite() {
}
}
/** 去投递 — 跳转到来源链接 */
/** 去投递 — 打开选择投递方式弹窗 */
const showApplyMethodDialog = ref(false)
const applyMethodDialogRef = ref<InstanceType<typeof JobApplyMethodDialog> | null>(null)
function handleApply() {
if (job.sourceUrl) {
window.open(job.sourceUrl, '_blank')
}
showApplyMethodDialog.value = true
nextTick(() => {
applyMethodDialogRef.value?.open()
})
}
</script>
+15 -188
View File
@@ -310,50 +310,13 @@
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
<!-- 选择投递方式弹窗 -->
<div v-if="showAgentRemindDialog" class="jobs-page__apply-method-overlay" @click.self="closeAgentRemind">
<div class="jobs-page__apply-method-dialog">
<!-- 渐变背景层 -->
<div class="jobs-page__apply-method-bg"></div>
<!-- 标题 -->
<p class="jobs-page__apply-method-title">选择投递方式</p>
<!-- 关闭按钮 -->
<span class="jobs-page__apply-method-close" @click="closeAgentRemind">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M18 6L6 18M6 6l12 12" stroke="#6A7282" stroke-width="1.5" stroke-linecap="round"/></svg>
</span>
<!-- 两个选项卡 -->
<div class="jobs-page__apply-method-options">
<!-- 直接前往官网 -->
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'direct' }" @click="applyMethodChoice = 'direct'">
<p class="jobs-page__apply-method-card-title">直接前往官网</p>
<p class="jobs-page__apply-method-card-desc">手动填写网申信息<br>自行记录投递进度</p>
</div>
<!-- 智能投递助手 -->
<div class="jobs-page__apply-method-card" :class="{ 'jobs-page__apply-method-card--active': applyMethodChoice === 'agent' }" @click="applyMethodChoice = 'agent'">
<!-- 左下角圆形装饰 -->
<div class="jobs-page__apply-method-card-circle"></div>
<div class="jobs-page__apply-method-card-header">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 1L10 5.5L15 6.5L11.5 10L12.5 15L8 12.5L3.5 15L4.5 10L1 6.5L6 5.5L8 1Z" fill="#52CAD1"/></svg>
<span class="jobs-page__apply-method-card-title jobs-page__apply-method-card-title--accent">智能投递助手</span>
</div>
<p class="jobs-page__apply-method-card-desc">{{ agentCardDesc }}</p>
</div>
</div>
<!-- 底部操作按钮 -->
<button class="jobs-page__apply-method-action" :class="applyMethodActionClass" :disabled="!applyMethodChoice" @click="handleApplyMethodAction">
<svg v-if="applyMethodActionIcon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_166_1554)">
<path d="M6.0714 3.63204C6.47007 2.46537 8.08207 2.43004 8.55474 3.52604L8.59474 3.63271L9.13274 5.20604C9.25603 5.56686 9.45527 5.89705 9.71702 6.17432C9.97877 6.4516 10.2969 6.66952 10.6501 6.81337L10.7947 6.86737L12.3681 7.40471C13.5347 7.80337 13.5701 9.41537 12.4747 9.88804L12.3681 9.92804L10.7947 10.466C10.4338 10.5893 10.1035 10.7885 9.82608 11.0502C9.54869 11.312 9.33067 11.6302 9.18674 11.9834L9.13274 12.1274L8.5954 13.7014C8.19674 14.868 6.58474 14.9034 6.11274 13.808L6.0714 13.7014L5.53407 12.128C5.41086 11.7671 5.21165 11.4368 4.9499 11.1594C4.68814 10.882 4.36993 10.664 4.01674 10.52L3.87274 10.466L2.2994 9.92871C1.13207 9.53004 1.09674 7.91804 2.19274 7.44604L2.2994 7.40471L3.87274 6.86737C4.23356 6.74408 4.56374 6.54484 4.84102 6.28309C5.11829 6.02134 5.33621 5.70317 5.48007 5.35004L5.53407 5.20604L6.0714 3.63204ZM7.3334 4.06271L6.79607 5.63604C6.60833 6.18624 6.30292 6.68891 5.90109 7.10904C5.49927 7.52917 5.0107 7.85666 4.4694 8.06871L4.30274 8.12937L2.7294 8.66671L4.30274 9.20404C4.85294 9.39178 5.35561 9.6972 5.77573 10.099C6.19586 10.5008 6.52335 10.9894 6.7354 11.5307L6.79607 11.6974L7.3334 13.2707L7.87074 11.6974C8.05848 11.1472 8.36389 10.6445 8.76571 10.2244C9.16753 9.80425 9.65611 9.47676 10.1974 9.26471L10.3641 9.20471L11.9374 8.66671L10.3641 8.12937C9.81387 7.94163 9.3112 7.63622 8.89107 7.2344C8.47094 6.83258 8.14345 6.34401 7.9314 5.80271L7.8714 5.63604L7.3334 4.06271ZM12.6667 1.33337C12.7915 1.33337 12.9137 1.36836 13.0195 1.43436C13.1253 1.50035 13.2105 1.59471 13.2654 1.70671L13.2974 1.78471L13.5307 2.46871L14.2154 2.70204C14.3404 2.7445 14.45 2.82312 14.5302 2.92792C14.6105 3.03272 14.6578 3.15899 14.6663 3.29072C14.6747 3.42246 14.6438 3.55373 14.5775 3.6679C14.5113 3.78207 14.4126 3.874 14.2941 3.93204L14.2154 3.96404L13.5314 4.19737L13.2981 4.88204C13.2555 5.00699 13.1769 5.11651 13.072 5.1967C12.9672 5.2769 12.8409 5.32417 12.7092 5.33252C12.5774 5.34088 12.4462 5.30994 12.3321 5.24363C12.2179 5.17732 12.1261 5.07862 12.0681 4.96004L12.0361 4.88204L11.8027 4.19804L11.1181 3.96471C10.9931 3.92224 10.8835 3.84363 10.8033 3.73883C10.723 3.63403 10.6756 3.50776 10.6672 3.37603C10.6588 3.24429 10.6897 3.11302 10.7559 2.99885C10.8222 2.88468 10.9208 2.79275 11.0394 2.73471L11.1181 2.70271L11.8021 2.46937L12.0354 1.78471C12.0804 1.65299 12.1654 1.53865 12.2786 1.45771C12.3918 1.37677 12.5276 1.33329 12.6667 1.33337Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_166_1554">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>
{{ applyMethodActionText }}
</button>
</div>
</div>
<JobApplyMethodDialog
ref="applyMethodDialogRef"
v-model="showAgentRemindDialog"
:source-url="pendingApplyUrl"
:job-id="pendingApplyJobId"
@open-member="showMemberDialog = true"
/>
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
@@ -394,7 +357,6 @@
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { createChannelBridge } from '@/utils/channelBridge'
import { JOB_TYPE_OPTIONS, formatEmploymentType, formatEducation } from '@/stores/index'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
@@ -402,6 +364,7 @@ import JobPageHeader from '@/components/JobPageHeader.vue'
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import JobApplyMethodDialog from '@/components/JobApplyMethodDialog.vue'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
@@ -412,7 +375,6 @@ import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, A
import { getTimeEvent, setTimeEvent, timestampToLocalDateTime } from '@/utils/time'
import { fetchIndustryTree, fetchJobCategoryTree } from '@/api/common'
import type { IndustryItem, JobCategoryItem } from '@/api/common'
import { fetchAgentConfig, applyJob } from '@/api/agent'
import { ElMessage } from 'element-plus'
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
@@ -901,16 +863,18 @@ function handleReport(job: JobListItem) {
// 暂存当前岗位信息
pendingApplyUrl.value = job.sourceUrl
pendingApplyJobId.value = job.id
// 重置选项状态
applyMethodChoice.value = null
// 每次触发都展示弹窗
// 展示弹窗并调用组件 open 方法初始化
showAgentRemindDialog.value = true
// 异步加载 AI 助手配置状态
loadAgentConfigStatus()
nextTick(() => {
applyMethodDialogRef.value?.open()
})
}
// ==================== 选择投递方式弹窗状态 ====================
/** 弹窗组件 ref */
const applyMethodDialogRef = ref<InstanceType<typeof JobApplyMethodDialog> | null>(null)
/** 弹窗是否显示 */
const showAgentRemindDialog = ref(false)
@@ -920,147 +884,10 @@ const pendingApplyUrl = ref('')
/** 暂存当前触发弹窗的岗位 ID */
const pendingApplyJobId = ref('')
/** 用户在弹窗中选择的投递方式:null=未选择, 'direct'=直接前往官网, 'agent'=智能投递助手 */
const applyMethodChoice = ref<'direct' | 'agent' | null>(null)
/** AI助手是否已完成配置(status === 1 */
const agentConfigReady = ref(false)
/** 是否正在加载 AI 助手配置 */
const agentConfigLoading = ref(false)
/** 会员购买弹窗显示状态 */
const showMemberDialog = ref(false)
/** 用户会员状态 — 从 store 读取 */
const memberStatus = computed(() => store.state.memberStatus)
/** 用户是否是会员(正式或试用) */
const isMember = computed(() => !!memberStatus.value?.isMember)
/** 智能投递助手选项卡内的描述文字 — 根据会员和配置状态动态显示 */
const agentCardDesc = computed(() => {
if (!isMember.value) {
return '自动填写网申信息\n统一管理投递进度'
}
if (!agentConfigReady.value) {
return '完成助手设置后\n可使用智能投递'
}
return '将该岗位加入待投递列表\n在投递助手中统一处理'
})
/** 底部操作按钮文字 */
const applyMethodActionText = computed(() => {
if (!applyMethodChoice.value) return '请选择投递方式'
if (applyMethodChoice.value === 'direct') return '去官网投递'
// agent 分支
if (!isMember.value) return '开通会员使用'
if (!agentConfigReady.value) return '去完成设置'
return '加入待投递列表'
})
/** 底部操作按钮是否显示 icon */
const applyMethodActionIcon = computed(() => {
if (!applyMethodChoice.value) return false
if (applyMethodChoice.value === 'direct') return false
// agent 分支都有 icon
return true
})
/** 底部操作按钮样式 class */
const applyMethodActionClass = computed(() => {
if (!applyMethodChoice.value) return 'jobs-page__apply-method-action--disabled'
return 'jobs-page__apply-method-action--active'
})
/** 加载 AI 助手配置状态 */
async function loadAgentConfigStatus() {
if (agentConfigLoading.value) return
agentConfigLoading.value = true
try {
const res = await fetchAgentConfig()
if (res.code === '0' && res.data && res.data.status === 1) {
agentConfigReady.value = true
} else {
agentConfigReady.value = false
}
} catch {
agentConfigReady.value = false
} finally {
agentConfigLoading.value = false
}
}
/** 关闭选择投递方式弹窗 */
function closeAgentRemind() {
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
pendingApplyJobId.value = ''
}
/** 底部操作按钮点击处理 */
async function handleApplyMethodAction() {
if (!applyMethodChoice.value) return
if (applyMethodChoice.value === 'direct') {
// 直接前往官网 — 同时跨域 put 链接给插件
showAgentRemindDialog.value = false
if (pendingApplyUrl.value) {
// 跨域推送投递链接到插件
const bridge = createChannelBridge({ domain: store.state.bridgeDomain })
try {
const existing = await bridge.get<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', true)
const linkList: string[] = existing?.linkList || []
if (!linkList.includes(pendingApplyUrl.value)) {
linkList.push(pendingApplyUrl.value)
}
await bridge.put<{ linkList: string[] }>(store.state.bridgeDomain, 'offerpaiDeliveryLinkList', { linkList }, true)
} catch (e) {
console.error('[Jobs] 跨域 put 投递链接失败', e)
} finally {
bridge.destroy()
}
window.open(pendingApplyUrl.value, '_blank')
}
pendingApplyUrl.value = ''
pendingApplyJobId.value = ''
return
}
// 智能投递助手分支
if (!isMember.value) {
// 非会员 — 打开会员购买弹窗
showAgentRemindDialog.value = false
showMemberDialog.value = true
pendingApplyUrl.value = ''
pendingApplyJobId.value = ''
return
}
if (!agentConfigReady.value) {
// 有会员但没完成 AI 助手配置 — 跳转 Agent 页面
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
pendingApplyJobId.value = ''
router.push('/agent')
return
}
// 有会员且配置完成 — 加入待投递列表
try {
const res = await applyJob({ jobId: pendingApplyJobId.value, status: -1 })
if (res.code === '0') {
ElMessage.success('已加入待投递列表')
} else {
ElMessage.error(res.msg || '加入待投递列表失败')
}
} catch {
ElMessage.error('加入待投递列表失败')
}
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
pendingApplyJobId.value = ''
}
onMounted(async () => {
document.addEventListener('click', closeDropdownOnClickOutside)