Compare commits

21 Commits

Author SHA1 Message Date
xuxin 065553f53e 登陆时没有简历先上传一份简历 2026-06-09 14:39:59 +08:00
xuxin 1c3ea6aa3c 登陆页操作流程调整 2026-06-05 17:56:44 +08:00
xuxin c17f75c707 意向字段全部改成使用招聘分类 2026-06-05 17:27:07 +08:00
xuxin 8d6282c3e6 验证码用户手动输入期间延长倒计时,防止用户输入一半之前60秒结束终止了输验证码步骤 2026-06-05 09:54:27 +08:00
xuxin 403e5e9fc9 401状态拦截 2026-06-04 17:47:28 +08:00
xuxin 540d61b6a0 新增登陆页面,去掉之前的窗口登陆 2026-06-04 17:41:07 +08:00
xuxin 2844d99ae1 招聘分类定义修改 2026-06-04 14:54:55 +08:00
xuxin 0783ecb570 添加招聘分类筛选功能 2026-06-04 14:43:33 +08:00
xuxin ad9c448fc7 首页UI微调 2026-06-04 11:12:34 +08:00
xuxin 5ef8b3a9df 首页UI微调 2026-06-03 17:28:09 +08:00
xuxin 0fcc19f3f8 首页UI微调 2026-06-03 12:12:59 +08:00
xuxin e6ae57e5fd 首页和页面缩放调整 2026-06-03 11:03:13 +08:00
xuxin 557a6f30f2 sass警告 2026-06-02 14:21:13 +08:00
xuxin dde72be9de 职位列表去投递定时提醒去AI助手弹窗 2026-06-01 15:46:30 +08:00
xuxin ddb67cfb6f AI助手聊天时候的历史消息组装去掉列表和投递进度等非对话聊天内容 2026-05-29 17:22:22 +08:00
xuxin 59ac8ab783 定制简历浏览器记录缓存加IndexDB存储工具封装,修复同一个页面的不同协议切换问题 2026-05-29 15:45:16 +08:00
xuxin 0d1d080cc1 消息通知左侧列表的时间 2026-05-28 21:26:01 +08:00
xuxin a1633978c5 首页和设置会员页,购买页的协议 2026-05-28 21:22:30 +08:00
xuxin 0e6fde08c7 页面右侧聊天助手的ai输出结果加上md文档格式显示 2026-05-28 17:41:23 +08:00
xuxin 96071d0105 定制简历AI编辑撤回修改按钮展示逻辑,和消息通知显示时间 2026-05-28 16:28:18 +08:00
xuxin e34bb2a21e 聊天助手ai回答内容换行符转换 2026-05-28 15:00:30 +08:00
76 changed files with 3235 additions and 517 deletions
+5 -2
View File
@@ -16,10 +16,13 @@ inclusion: always
## 编码规范
- 颜色用尽量用variables.scss里的统一颜色变量,特别是背景,按钮,文字的颜色
- 除了Home.vue首页外,其他页面我上传了截图要写页面或组件的,需要参考图片里文字和布局结构,在保证美观前提上自由发挥
- 除了Home.vue首页外,其他页面我上传了截图要写页面或组件的,需要参考图片里文字和布局结构,在保证美观前提上自由发挥(如果我粘贴了图片内容里的完整样式过来,那就要参考我给的样式结合图片里的结构和样式代码保证还原度)
- 全局用1rem=100px的格式并注意对某些特殊元素组件的line-height行高影响,纵布局如非必要不用flex-direction: column布局
- 如果是建一个组件,这个组件看我说是用在views里哪个页面的,比如用在Profile.vue里的组件,组件名字最前面要加Profile,而且整个组件的命名不能过度简化,要容易看懂组件的用途;如果检测到某种名字开头的组件数量比如Profile开头的超过15个,就在components里新建个类似profile这样的页面名字的文件夹,把这类命名的组件都移到文件夹里并查找更新组件所有被引用地方的文件地址
## 注意事项
- 页面结构和ts的常量变量和方法都要加中文注释
- 页面结构和ts的常量变量和方法都要加中文注释kiro编程工具沟通要回复中文
- 新建 SCSS 文件如果使用了 variables.scss 中的变量(如 `$bg-white``$accent` 等),必须在文件顶部加 `@use '../variables' as *;`,否则通过 `@use` 方式引入 index.scss 时变量作用域不会穿透,会报 `Undefined variable` 错误
- 因为项目用了 rem(1rem=100px)适配方案,所有 Vue 页面和组件文件的最外层盒子都要加 `font-size: 0.14rem`,避免页面样式中受浏览器默认 rem 行高影响导致文字和布局异常
- 需要占满视口高度的元素(页面主容器、侧边栏、聊天面板等)禁止直接写 `height: 100vh`,必须使用 `height: var(--app-height, 100vh)`。原因:项目在小屏下使用 `transform: scale` 缩放,`100vh` 是缩放后的视口高度而非设计稿高度,会导致底部留白。`--app-height``src/plugins/remAdapt.ts` 动态注入,值为 `视口高度 / scale`PC 端等于 `100vh`
+2
View File
@@ -21,6 +21,7 @@ declare module 'vue' {
AgentSettingsPanel: typeof import('./src/components/AgentSettingsPanel.vue')['default']
AgentSetupWizard: typeof import('./src/components/AgentSetupWizard.vue')['default']
AgentTaskListDropdown: typeof import('./src/components/AgentTaskListDropdown.vue')['default']
AgreementPreviewDialog: typeof import('./src/components/tools/AgreementPreviewDialog.vue')['default']
AiChat: typeof import('./src/components/AiChat.vue')['default']
AiThinkingIndicator: typeof import('./src/components/tools/AiThinkingIndicator.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
@@ -48,6 +49,7 @@ declare module 'vue' {
JobResumeCustomEditPanel: typeof import('./src/components/JobResumeCustomEditPanel.vue')['default']
JobResumeTemplate: typeof import('./src/components/JobResumeTemplate.vue')['default']
LoginDialog: typeof import('./src/components/LoginDialog.vue')['default']
MemberAccessDialog: typeof import('./src/components/MemberAccessDialog.vue')['default']
MemberDialog: typeof import('./src/components/MemberDialog.vue')['default']
ProfileEditDrawer: typeof import('./src/components/ProfileEditDrawer.vue')['default']
ProfilePageContent: typeof import('./src/components/ProfilePageContent.vue')['default']
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Offer派 - 大学生AI求职平台 | 智能岗位匹配·一键自动网申·AI简历优化</title>
<meta name="description" content="Offer派是专为大学生打造的AI求职平台,提供智能岗位匹配、一键自动网申、岗位定制简历、内推人脉直通等功能,让校招求职效率提升80%。" />
+2
View File
@@ -13,12 +13,14 @@
"axios": "^1.13.6",
"element-plus": "^2.13.3",
"html2pdf.js": "^0.14.0",
"markdown-it": "^14.2.0",
"sass": "^1.97.3",
"vue": "^3.5.25",
"vue-router": "^4.6.4",
"vuex": "^4.1.0"
},
"devDependencies": {
"@types/markdown-it": "^14.1.2",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
+71
View File
@@ -20,6 +20,9 @@ importers:
html2pdf.js:
specifier: ^0.14.0
version: 0.14.0
markdown-it:
specifier: ^14.2.0
version: 14.2.0
sass:
specifier: ^1.97.3
version: 1.97.3
@@ -33,6 +36,9 @@ importers:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.29(typescript@5.9.3))
devDependencies:
'@types/markdown-it':
specifier: ^14.1.2
version: 14.1.2
'@types/node':
specifier: ^24.10.1
version: 24.11.0
@@ -487,12 +493,21 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@24.11.0':
resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==}
@@ -587,6 +602,9 @@ packages:
alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
@@ -658,6 +676,10 @@ packages:
peerDependencies:
vue: ^3.3.0
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
@@ -783,6 +805,9 @@ packages:
jspdf@4.2.1:
resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==}
linkify-it@5.0.1:
resolution: {integrity: sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
@@ -803,10 +828,17 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-it@14.2.0:
resolution: {integrity: sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -870,6 +902,10 @@ packages:
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -931,6 +967,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
@@ -1328,12 +1367,21 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/linkify-it@5.0.0': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.24
'@types/lodash@4.17.24': {}
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdurl@2.0.0': {}
'@types/node@24.11.0':
dependencies:
undici-types: 7.16.0
@@ -1460,6 +1508,8 @@ snapshots:
alien-signals@3.1.2: {}
argparse@2.0.1: {}
async-validator@4.2.5: {}
asynckit@0.4.0: {}
@@ -1553,6 +1603,8 @@ snapshots:
transitivePeerDependencies:
- '@vue/composition-api'
entities@4.5.0: {}
entities@7.0.1: {}
es-define-property@1.0.1: {}
@@ -1702,6 +1754,10 @@ snapshots:
dompurify: 3.3.3
html2canvas: 1.4.1
linkify-it@5.0.1:
dependencies:
uc.micro: 2.1.0
local-pkg@1.1.2:
dependencies:
mlly: 1.8.0
@@ -1722,8 +1778,19 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it@14.2.0:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.1
mdurl: 2.0.0
punycode.js: 2.3.1
uc.micro: 2.1.0
math-intrinsics@1.1.0: {}
mdurl@2.0.0: {}
memoize-one@6.0.0: {}
mime-db@1.52.0: {}
@@ -1783,6 +1850,8 @@ snapshots:
proxy-from-env@1.1.0: {}
punycode.js@2.3.1: {}
quansync@0.2.11: {}
raf@3.4.1:
@@ -1864,6 +1933,8 @@ snapshots:
typescript@5.9.3: {}
uc.micro@2.1.0: {}
ufo@1.6.3: {}
undici-types@7.16.0: {}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

-2
View File
@@ -1,13 +1,11 @@
<template>
<el-config-provider :locale="zhCn">
<RouterView />
<LoginDialog />
</el-config-provider>
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
import LoginDialog from '@/components/LoginDialog.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
+17 -3
View File
@@ -1,4 +1,5 @@
import request from '@/utils/request'
import store from '@/stores'
/** 通用响应结构 */
export interface ApiResult<T = any> {
@@ -31,13 +32,26 @@ export function sendSmsCode(mobileNumber: string) {
* POST /public/login/smsLogin
* Body: { mobileNumber, code, inviteCode? }
* 登录成功后后端会 Set-Cookie: Token=xxx
*
* inviteCode 优先使用传入参数,其次从全局 store 中读取(URL 邀请码)
* 登录成功后自动清空全局邀请码,避免重复发送
*/
export function smsLogin(mobileNumber: string, code: string, inviteCode?: string) {
return request.post<any, ApiResult<LoginData>>('/public/login/smsLogin', {
export async function smsLogin(mobileNumber: string, code: string, inviteCode?: string) {
// 优先使用显式传入的邀请码,否则从全局 store 取
const finalInviteCode = inviteCode || store.state.inviteCode || ''
const res = await request.post<any, ApiResult<LoginData>>('/public/login/smsLogin', {
mobileNumber,
code,
...(inviteCode ? { inviteCode } : {}),
...(finalInviteCode ? { inviteCode: finalInviteCode } : {}),
})
// 登录成功后清空全局邀请码
if (res.code === '0' && store.state.inviteCode) {
store.commit('SET_INVITE_CODE', '')
}
return res
}
/**
+27
View File
@@ -118,3 +118,30 @@ export function uploadFileToOss(file: File, pathEnum: OssPathEnum = 'ResumeFile'
headers: { 'Content-Type': 'multipart/form-data' },
})
}
// ==================== 协议查询相关 ====================
/** 协议数据结构 */
export interface AgreementDto {
/** 协议表ID */
id?: number
/** 协议码,唯一标识一类协议 */
agreementCode?: string
/** 修改版本 */
version?: number
/** 协议名称 */
agreementName?: string
/** 协议内容(富文本/Markdown */
content?: string
/** 状态 1=启用 0=禁用 */
status?: number
}
/**
* 根据协议码查询协议内容
* GET /public/agreement?code=xxx
* @param code 协议码
*/
export function fetchAgreement(code: string) {
return request.get<any, ApiResult<AgreementDto>>('/public/agreement', { params: { code } })
}
+9 -4
View File
@@ -75,14 +75,17 @@ export interface JobListParams {
categoryIds?: number[]
/** 行业 ID 列表 */
industryIds?: number[]
/** 工作类型:0=全职 1=兼职 */
employmentType?: number
/** 指定岗位 ID 列表(用于收藏列表) */
jobIds?: number[]
/** 岗位状态过滤(0=有效 1=已下架 2=已过期,可多选,null或空=查所有) */
statusFilter?: number[]
/** 搜索关键词 */
keyword?: string
/** 招聘分类 0=校招 1=实习 2=社招 3=其他 */
recruitCategory?: number
/** 排除岗位ID列表(用于推荐时排除已推荐过的) */
excludeJobIds?: number[]
}
// ==================== 求职意向 ====================
@@ -95,8 +98,10 @@ export interface JobIntention {
regionCodes?: string[]
/** 期望行业 ID 列表 */
industryIds?: number[]
/** 就业类型:0=全职1=实习 */
employmentType?: number
/** 就业类型:0=校招1=实习2=社招 */
employmentType?: number | null
/** 招聘分类:0=社招,1=校招,2=实习,3=其他 */
recruitCategory?: number | null
}
/**
+65 -19
View File
@@ -1,15 +1,35 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api/auth'
/**
* 获取当前用户有权限的路由列表
*
* 【mock 阶段】直接返回写死的数据,模拟后端接口
* 【对接真实接口时】把下面的 mock 数据替换成:
* return axios.get('/api/user/menus').then(res => res.data)
*
* 返回格式约定:
* path — 路由路径
* name — 路由名称(唯一标识,也用于 removeRoute
* component — 字符串,对应前端组件映射表的 key
* meta — 可选,传给路由的 meta 信息(图标、标题等)
* 路由菜单项 — 后端 /route/menu 接口返回的数据结构
*/
export interface RouteMenuVo {
/** 主键 */
id: string
/** 根节点ID */
rootId: string
/** 父级路由ID */
parentId: string
/** 路由名称(用于侧边栏显示) */
routeName: string
/** 前端路径 */
routePath: string
/** 前端组件路径(对应 componentMap 的 key */
component: string
/** 图标 key */
icon: string
/** 排序 */
sortOrder: number
/** 是否有使用权限:true=可用 false=无权限(需会员) */
accessible: boolean
/** 递归子菜单 */
children?: RouteMenuVo[]
}
/**
* 兼容旧版 MenuItemRaw 类型(供 SideNav 和 store 使用)
* 在新接口基础上补充原有字段映射
*/
export interface MenuItemRaw {
path: string
@@ -22,14 +42,40 @@ export interface MenuItemRaw {
/** 'footer' 表示该菜单项显示在底部区域而非主导航 */
position?: string
}
/** 排序字段 */
sortOrder: number
/** 是否有使用权限 */
accessible: boolean
}
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
// TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data)
return [
{ path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } },
{ path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } },
{ path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } },
{ path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } },
]
/**
* 将后端返回的 RouteMenuVo 转换为前端使用的 MenuItemRaw
*/
function mapRouteMenuToMenuItem(item: RouteMenuVo): MenuItemRaw {
return {
path: item.routePath,
name: item.component, // component 作为路由 name(与 componentMap 对应)
component: item.component,
meta: {
label: item.routeName,
icon: item.icon,
},
sortOrder: item.sortOrder,
accessible: item.accessible,
}
}
/**
* 获取当前用户有权限的路由菜单列表
* GET /route/menu
* Cookie 自动携带 Token
*/
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
const res = await request.get<any, ApiResult<RouteMenuVo[]>>('/route/menu')
if (res.code === '0' && res.data) {
// 按 sortOrder 排序后转换格式
const sorted = [...res.data].sort((a, b) => a.sortOrder - b.sortOrder)
return sorted.map(mapRouteMenuToMenuItem)
}
return []
}
+2 -2
View File
@@ -16,8 +16,8 @@ export interface MessageDto {
bizId: number
/** 是否已读 */
read: boolean
/** 创建时间 */
createTime: { seconds: number; nanos: number }
/** 创建时间(毫秒时间戳) */
createTime: number
}
/** 分页响应结构 */
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 911 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,170 @@
@use '../variables' as *;
// 协议预览弹窗样式
.agreement-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.agreement-preview-dialog {
width: 7rem;
max-width: 90vw;
height: 80vh;
background: #fff;
border-radius: 0.12rem;
display: flex;
flex-direction: column;
overflow: hidden;
// 顶部标题栏
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.16rem 0.24rem;
border-bottom: 1px solid #e5e7eb;
}
&__title {
font-size: 0.18rem;
font-weight: 600;
color: $text-dark;
margin: 0;
}
&__close {
font-size: 0.18rem;
color: #9ca3af;
cursor: pointer;
padding: 0.04rem;
transition: color 0.2s;
&:hover {
color: #374151;
}
}
// 内容区域
&__body {
flex: 1;
overflow-y: auto;
padding: 0.24rem;
}
&__loading {
text-align: center;
padding: 0.4rem 0;
color: #999;
font-size: 0.14rem;
}
// Markdown 渲染内容样式
&__content {
font-size: 0.13rem;
color: #555;
line-height: 1.8;
h1, h2, h3, h4, h5, h6 {
color: $text-dark;
font-weight: 600;
margin: 0.14rem 0 0.08rem;
}
h1 { font-size: 0.2rem; }
h2 { font-size: 0.17rem; }
h3 { font-size: 0.15rem; }
h4 { font-size: 0.14rem; }
p {
margin: 0 0 0.08rem;
text-align: justify;
&:last-child { margin-bottom: 0; }
}
ul, ol {
padding-left: 0.2rem;
margin: 0.06rem 0;
}
li {
margin-bottom: 0.04rem;
line-height: 1.8;
}
strong {
font-weight: 700;
color: $text-dark;
}
a {
color: #2563eb;
text-decoration: underline;
}
blockquote {
border-left: 3px solid #cbd5e1;
padding-left: 0.1rem;
margin: 0.08rem 0;
color: #64748b;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.1rem 0;
font-size: 0.12rem;
th, td {
border: 1px solid #e5e7eb;
padding: 0.06rem 0.1rem;
text-align: left;
line-height: 1.6;
}
th {
background: #f8fafc;
font-weight: 600;
color: $text-dark;
}
tr:nth-child(even) {
background: #fafbfc;
}
}
hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 0.1rem 0;
}
code {
background: #f1f5f9;
padding: 0.01rem 0.04rem;
border-radius: 0.03rem;
font-size: 0.12rem;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 0.1rem 0.12rem;
border-radius: 0.06rem;
overflow-x: auto;
margin: 0.08rem 0;
code {
background: none;
padding: 0;
color: inherit;
}
}
}
}
+96 -5
View File
@@ -2,8 +2,10 @@
@use '../variables' as *;
.ai-chat {
width: 4.0rem;
height: 100vh;
width: var(--chat-width, 4.0rem);
max-width: 4.0rem;
min-width: 1.5rem;
height: var(--app-height, 100vh);
background: #f3f4f6;
display: flex;
flex-direction: column;
@@ -19,12 +21,12 @@
justify-content: space-between;
background: #0F172B;
color: #fff;
padding: 0.12rem 0.18rem;
font-size: 0.15rem;
padding: 0.12rem 0.14rem;
font-size: 0.14rem;
font-weight: 600;
cursor: pointer;
border-radius: 0.2rem;
margin: 0.15rem;
margin: 0.15rem;
height: 0.41rem;
}
@@ -94,6 +96,95 @@
font-size: 0.13rem;
line-height: 1.6;
max-width: 85%;
// ===== Markdown 渲染样式 =====
p {
margin: 0 0 0.08rem;
&:last-child { margin-bottom: 0; }
}
h1, h2, h3, h4, h5, h6 {
margin: 0.12rem 0 0.06rem;
font-weight: 600;
line-height: 1.4;
}
h1 { font-size: 0.18rem; }
h2 { font-size: 0.16rem; }
h3 { font-size: 0.14rem; }
ul, ol {
padding-left: 0.2rem;
margin: 0.06rem 0;
}
li {
margin-bottom: 0.04rem;
}
code {
background: #f1f5f9;
padding: 0.01rem 0.04rem;
border-radius: 0.03rem;
font-size: 0.12rem;
font-family: 'Courier New', monospace;
}
pre {
background: #1e293b;
color: #e2e8f0;
padding: 0.1rem 0.12rem;
border-radius: 0.06rem;
overflow-x: auto;
margin: 0.08rem 0;
code {
background: none;
padding: 0;
color: inherit;
font-size: 0.11rem;
}
}
blockquote {
border-left: 3px solid #cbd5e1;
padding-left: 0.1rem;
margin: 0.08rem 0;
color: #64748b;
}
a {
color: #2563eb;
text-decoration: underline;
}
table {
border-collapse: collapse;
margin: 0.08rem 0;
width: 100%;
font-size: 0.12rem;
th, td {
border: 1px solid #e5e7eb;
padding: 0.06rem 0.1rem;
text-align: left;
line-height: 1.6;
}
th {
background: #f8fafc;
font-weight: 600;
}
tr:nth-child(even) {
background: #fafbfc;
}
}
hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 0.1rem 0;
}
}
&__msg--ai &__msg-bubble {
@@ -5,8 +5,8 @@
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: var(--app-height, 100vh);
z-index: 2000;
display: flex;
align-items: center;
@@ -691,7 +691,7 @@
position: relative;
background: $bg-white;
width: 10.4rem;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
display: flex;
flex-direction: column;
@@ -1021,8 +1021,8 @@
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: var(--app-height, 100vh);
background: $overlay-bg;
z-index: 3000;
display: flex;
@@ -0,0 +1,77 @@
/* 会员权限拦截弹窗样式 */
@use '../variables' as *;
.member-access-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $overlay-bg;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.14rem;
}
.member-access-dialog {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.4rem 0.36rem 0.3rem;
width: 3.8rem;
text-align: center;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
}
/* 锁图标 */
.member-access-dialog__icon {
margin-bottom: 0.2rem;
}
.member-access-dialog__lock-svg {
width: 0.48rem;
height: 0.48rem;
color: $accent;
}
/* 提示文字 */
.member-access-dialog__text {
font-size: 0.15rem;
color: $text-dark;
line-height: 1.6;
margin-bottom: 0.3rem;
}
/* 操作按钮区域 */
.member-access-dialog__actions {
display: flex;
gap: 0.16rem;
justify-content: center;
}
.member-access-dialog__btn {
flex: 1;
height: 0.4rem;
border-radius: 0.06rem;
font-size: 0.14rem;
cursor: pointer;
border: none;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
}
/* 灰色取消按钮 */
.member-access-dialog__btn--cancel {
background: $bg-main;
color: $text-middle;
}
/* 主题色确认按钮 */
.member-access-dialog__btn--confirm {
background: $btn-dark;
color: $bg-white;
}
@@ -21,7 +21,7 @@
top: 0;
right: 0;
width: 6.2rem;
height: 100vh;
height: var(--app-height, 100vh);
background: $bg-white;
z-index: 2001;
box-shadow: -0.04rem 0 0.2rem rgba(0, 0, 0, 0.1);
@@ -1,3 +1,4 @@
@use 'sass:color';
@use '../variables' as *;
// ==================== 欢迎使用弹窗 ====================
@@ -56,12 +57,12 @@
margin-bottom: 0.14rem;
&:hover {
background: darken(#F3F4F5, 3%);
background: color.adjust(#F3F4F5, $lightness: -3%);
}
// 拖拽悬停时背景色变化
&.is-dragover {
background: darken(#F3F4F5, 6%);
background: color.adjust(#F3F4F5, $lightness: -6%);
}
}
@@ -26,7 +26,7 @@
top: 0;
right: 0;
width: 8rem;
height: 100vh;
height: var(--app-height, 100vh);
background: $bg-main;
z-index: 2001;
display: flex;
@@ -26,7 +26,7 @@
top: 0;
right: 0;
width: 8rem;
height: 100vh;
height: var(--app-height, 100vh);
background: $bg-main;
z-index: 2001;
display: flex;
@@ -1,3 +1,4 @@
@use 'sass:color';
@use '../variables' as *;
// ==================== 简历上传弹窗 ====================
@@ -83,7 +84,7 @@
// 拖拽悬停状态 — 背景色加深
&.is-dragover {
background: darken(#F6FCFC, 4%);
background: color.adjust(#F6FCFC, $lightness: -4%);
border-color: $accent;
}
@@ -1,3 +1,4 @@
@use 'sass:color';
@use '../variables' as *;
// ==================== 注销账号弹窗 ====================
@@ -247,7 +248,7 @@
color: $bg-white;
&:hover:not(:disabled) {
background: darken(#DC2626, 8%);
background: color.adjust(#DC2626, $lightness: -8%);
}
}
@@ -387,7 +388,7 @@
color: $bg-white;
&:hover {
background: darken(#DC2626, 8%);
background: color.adjust(#DC2626, $lightness: -8%);
}
}
}
@@ -487,5 +487,66 @@
line-height: 1.8;
text-align: justify;
}
// Markdown 渲染额外样式
h1, h2, h3, h5, h6 {
color: $text-dark;
margin: 0.14rem 0 0.08rem;
font-weight: 600;
}
h1 { font-size: 0.2rem; }
h2 { font-size: 0.17rem; }
h3 { font-size: 0.15rem; }
ul, ol {
padding-left: 0.2rem;
margin: 0.06rem 0;
}
li {
margin-bottom: 0.04rem;
line-height: 1.8;
}
strong {
font-weight: 700;
color: $text-dark;
}
a {
color: #2563eb;
text-decoration: underline;
}
blockquote {
border-left: 3px solid #cbd5e1;
padding-left: 0.1rem;
margin: 0.08rem 0;
color: #64748b;
}
table {
border-collapse: collapse;
width: 100%;
margin: 0.1rem 0;
font-size: 0.12rem;
th, td {
border: 1px solid #e5e7eb;
padding: 0.06rem 0.1rem;
text-align: left;
line-height: 1.6;
}
th {
background: #f8fafc;
font-weight: 600;
color: $text-dark;
}
tr:nth-child(even) {
background: #fafbfc;
}
}
}
}
+5 -5
View File
@@ -4,7 +4,7 @@
display: flex;
flex-direction: column;
width: 2rem;
height: 100vh;
height: var(--app-height, 100vh);
background: #1a1a2e;
color: #fff;
padding: 0.2rem 0.12rem;
@@ -107,8 +107,8 @@
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
width: 100%;
height: var(--app-height, 100vh);
background: $overlay-bg;
display: flex;
align-items: center;
@@ -184,7 +184,7 @@
// 左侧消息列表
.side-nav__message-list {
width: 2rem;
width: 2.1rem;
border-right: 1px solid $border-color;
overflow-y: auto;
flex-shrink: 0;
@@ -193,7 +193,7 @@
.side-nav__message-list-item {
display: flex;
align-items: center;
padding: 0.12rem 0.14rem;
padding: 0.12rem 0.10rem;
cursor: pointer;
border-bottom: 1px solid $border-color;
transition: background 0.2s;
+2
View File
@@ -36,6 +36,8 @@
@use './components/profile-welcome-dialog.scss';
@use './components/settings-invite-dialog.scss';
@use './components/resume-upload-dialog.scss';
@use './components/agreement-preview-dialog.scss';
@use './components/member-access-dialog.scss';
// 全局样式(优先级最高)
@use './auto.scss';
+5 -5
View File
@@ -3,7 +3,7 @@
// ==================== 求职助手页面样式 ====================
.agent-page {
min-height: 100vh;
min-height: var(--app-height, 100vh);
background: $bg-main;
font-size: 0.14rem;
// 主内容区域(左侧导航栏右边的部分)
@@ -12,7 +12,7 @@
flex: 1;
padding: 0.3rem 0.4rem;
overflow-y: auto;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
}
@@ -216,7 +216,7 @@
border-radius: 0.12rem;
border: 1px solid $border-color;
padding: 0.24rem;
max-height: calc(100vh - 1.4rem);
max-height: calc(var(--app-height, 100vh) - 1.4rem);
overflow-y: auto;
// 自定义滚动条
@@ -855,7 +855,7 @@
// 左侧主区域
&__left {
flex: 1;
height: calc(100vh - 0.6rem);
height: calc(var(--app-height, 100vh) - 0.6rem);
display: flex;
flex-direction: column;
max-width: 8rem;
@@ -864,7 +864,7 @@
// 右侧匹配岗位面板
&__right {
flex: 1;
height: calc(100vh - 0.6rem);
height: calc(var(--app-height, 100vh) - 0.6rem);
margin-left: 0.2rem;
max-width: 8rem;
overflow-y: auto;
+223 -112
View File
@@ -14,6 +14,7 @@
font-family: system-ui, -apple-system, sans-serif;
// 全局基础行高重置,防止 1rem=100px 导致子元素继承异常行高
line-height: 1.5;
font-size: 0.14rem;
// ==================== 顶部导航栏 ====================
.home-nav {
@@ -23,17 +24,25 @@
// 导航内容容器 — 居中 12rem 宽
&__inner {
width: 12rem;
width: 12.40rem;
margin: 0 auto;
height: 0.68rem;
height: 0.72rem;
padding: 0.2rem;
box-sizing: border-box;
border-radius: 40px;
background: #FFFFFF;
border: 0.01rem solid #FFFFFF;
border-radius: 0.24rem;
display: flex;
align-items: center;
justify-content: space-between;
&--scorlled {
position: fixed;
top: 0.2rem;
left: 50%;
transform: translateX(-50%);
z-index: 100;
background: #FFFFFF;
box-shadow: 0 0 0 .5px rgba(0, 0, 0, .04), 0 0 32px 0 rgba(10, 20, 21, .06);
}
}
// Logo 区域
@@ -61,7 +70,7 @@
// 导航右侧 CTA 按钮
&__btn {
padding: 0.1rem 0.32rem;
border-radius: 9999px;
border-radius: 0.16rem;
background: #111;
color: #fff;
font-size: 0.14rem;
@@ -80,7 +89,7 @@
position: relative;
overflow: hidden;
font-size: 0.14rem;
background: radial-gradient(49.26% 24.58% at 46% 0%, #CEF0F2 0%, #FFFFFF 100%);
background: radial-gradient(34.95% 29.57% at 60.26041666666667% 0%, rgba(206, 240, 242, 1) 0%, rgba(255, 255, 255, 1) 100%);
// 背景色块 — 顶部
&__orb {
position: absolute;
@@ -92,11 +101,13 @@
&--top {
left: 40%;
transform: translateX(-50%);
top: -1.2rem;
width: 8rem;
height: 2.2rem;
background: rgba(82, 202, 209, 0.2);
transform: translateX(-30%) rotate(3deg);
top: -4.9rem;
width: 13.6rem;
height: 6rem;
border-radius: 50%;
filter: blur(0.5rem);
background: rgba(82, 202, 209, 0.24);
opacity: 0;
}
@@ -104,7 +115,7 @@
&--bottom {
left: 40%;
transform: translateX(-50%);
bottom: -2.4rem;
bottom: -1.6rem;
width: 4rem;
height: 2rem;
}
@@ -119,21 +130,21 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.5rem 0 1.2rem;
padding: 1.2rem 0 1.2rem;
gap: 0.5rem;
}
// 左侧文字区
&__left {
flex-shrink: 0;
max-width: 5.5rem;
//width: 5.8rem;
}
// 主标题
&__title {
font-size: 0.72rem;
font-size: 0.54rem;
font-weight: 600;
line-height: 0.9rem;
line-height: 0.71rem;
letter-spacing: -0.03rem;
color: #111;
margin: 0;
@@ -144,37 +155,55 @@
font-size: 0.2rem;
line-height: 0.325rem;
color: #9ca3af;
margin-top: 0.2rem;
max-width: 4.74rem;
margin-top: 0.1rem;
}
// 免费体验按钮
&__cta {
margin-top: 0.4rem;
padding: 0.2rem 0.56rem;
border-radius: 9999px;
background: $accent;
margin-top: 0.69rem;
width: 1.84rem;
height: 0.56rem;
//line-height: 0.24rem;
border-radius: 0.16rem;
background: linear-gradient(90deg, rgba(82, 202, 209, 1) 0%, rgba(83, 217, 200, 1) 100%);
color: #fff;
font-size: 0.18rem;
line-height: 0.28rem;
font-weight: 600;
border: none;
cursor: pointer;
box-shadow: 0 25px 50px -12px rgba(82, 202, 209, 0.2);
transition: background 0.2s;
&:hover { background: $accent-hover; }
}
// 右侧卡片区
&__right {
flex-shrink: 0;
box-shadow: 0px 0px 15.45px rgba(82, 202, 209, 0.1);
border-radius: 0.16rem;
}
&__right-top{
height: 0.5rem;
background: #fff;
border-radius: 0.16rem;
display: flex;
align-items: center;
gap: 0.06rem;
padding-left: 0.3rem;
>div{
height: 0.08rem;
width: 0.08rem;
border-radius: 50%;
background: #52CAD1;
}
}
// 视频播放器 — 16:9 比例,圆角,无控件
&__video {
width: 6.5rem;
aspect-ratio: 16 / 9;
border-radius: 0.24rem;
width: 5.16rem;
height: 2.90rem;
//aspect-ratio: 16 / 9;
border-radius: 0.16rem;
object-fit: cover;
display: block;
}
@@ -308,12 +337,11 @@
// 标题区
&__header {
margin-bottom: 0.6rem;
h2 {
font-size: 0.48rem;
font-weight: 600;
letter-spacing: -0.015rem;
line-height: 0.7rem;
line-height: 0.63rem;
}
}
@@ -322,8 +350,8 @@
width: 0.8rem;
height: 0.06rem;
border-radius: 9999px;
background: rgba(82, 202, 209, 0.3);
margin-top: 0.2rem;
background: #52CAD1;
margin-top: 0.53rem;
}
// 三个统计卡片横向排列
@@ -336,23 +364,25 @@
// 单个统计卡片
.stat-card {
background: #fff;
border-radius: 0.48rem;
padding: 0.5rem;
border-radius: 0.16rem;
width: 2.03rem;
height: 1.87rem;
text-align: center;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.04);
padding-top:0.38rem;
box-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.05);
// 大数字
&__num {
font-size: 0.64rem;
font-size: 0.48rem;
font-weight: 900;
color: $accent;
line-height: 0.85rem;
color: #52CAD1;
line-height: 0.63rem;
}
// 描述文字
&__label {
font-size: 0.14rem;
line-height: 0.2rem;
font-size: 0.16rem;
line-height: 0.22rem;
color: #9ca3af;
margin-top: 0.12rem;
letter-spacing: 0.0035rem;
@@ -362,11 +392,42 @@
// ==================== 岗位展示区 ====================
.home-jobs-showcase {
padding: 1rem 0;
background: rgba(82, 202, 209, 0.05);
background: linear-gradient(to top, rgba(82, 202, 209, 0.05), rgba(82, 202, 209, 0.0001));
position: relative;
overflow: hidden;
background: #fff;
// 背景色块 — 定位实现
&__orb {
position: absolute;
pointer-events: none;
z-index: 0;
// 顶部小块 — 类似 hero 底部色块
&--top {
left: 50%;
transform: translateX(-50%);
top: -3rem;
width: 5rem;
height: 2.5rem;
border-radius: 3.5rem;
background: rgba(82, 202, 209, 0.3);
filter: blur(1.2rem);
}
// 底部全宽 — 从底部向上渐变透明
&--bottom {
left: 0;
bottom: 0;
width: 100%;
height: 7rem;
background: linear-gradient(to top, rgba(82, 202, 209, 0.08), transparent);
}
}
// 内容容器
&__inner {
position: relative;
z-index: 1;
width: 12rem;
margin: 0 auto;
text-align: center;
@@ -394,7 +455,7 @@
box-shadow: 0 10px 40px rgba(82, 202, 209, 0.12);
backdrop-filter: blur(100px);
border-radius: 0.4rem;
padding: 0.64rem;
padding: 0.64rem 0;
}
// 岗位数量统计 — 横向排列
@@ -405,13 +466,16 @@
margin-bottom: 0.6rem;
}
// 岗位滚动卡片容器
// 岗位滚动卡片容器 — 无缝轮播
&__scroll {
display: flex;
gap: 0.16rem;
overflow-x: auto;
padding-bottom: 0.1rem;
&::-webkit-scrollbar { display: none; }
overflow: hidden;
.ticker-track {
display: flex;
gap: 0.16rem;
width: max-content;
animation: ticker-scroll 20s linear infinite;
}
}
}
@@ -419,7 +483,7 @@
.showcase-stat {
text-align: center;
&__num {
font-size: 0.6rem;
font-size: 0.48rem;
line-height: 0.6rem;
font-weight: 900;
color: #1d1d1d;
@@ -440,7 +504,7 @@
backdrop-filter: blur(36px);
border-radius: 0.12rem;
padding: 0.2rem 0.24rem;
min-width: 2.8rem;
min-width: 2.6rem;
text-align: left;
// 公司名 + 时间
@@ -481,9 +545,9 @@
min-width: 0;
h2 {
font-size: 0.52rem;
font-size: 0.48rem;
font-weight: 600;
line-height: 0.65rem;
line-height: 0.63rem;
color: #111;
margin: 0;
}
@@ -492,22 +556,21 @@
font-size: 0.2rem;
line-height: 0.325rem;
color: #999;
margin-top: 0.24rem;
max-width: 4.5rem;
margin-top: 0.16rem;
}
}
// 深色圆角按钮(带图标)
&__btn {
margin-top: 0.4rem;
margin-top: 0.73rem;
display: inline-flex;
align-items: center;
gap: 0.12rem;
padding: 0.2rem 0.4rem;
border-radius: 9999px;
padding: 0.15rem 0.4rem;
border-radius: 0.16rem;
background: #111111;
color: #fff;
font-size: 0.18rem;
font-size: 0.16rem;
line-height: 0.28rem;
font-weight: 600;
border: none;
@@ -530,8 +593,8 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(157.19deg, #E8F8F9 0%, #FBFDF7 100%);
border-radius: 0.48rem;
background: linear-gradient(136.66deg, #E8F8F9 0%, #FBFDF7 100%);
border-radius: 0.16rem;
padding: 0.48rem;
min-height: 3.87rem;
}
@@ -544,6 +607,12 @@
}
}
// 无缝轮播关键帧 — 滚动半程(第一组内容宽度)
@keyframes ticker-scroll {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
// ---- 功能1:匹配卡片 ----
.feature-match-card {
background: rgba(255, 255, 255, 0.9);
@@ -568,7 +637,7 @@
width: 0.64rem;
height: 0.64rem;
border-radius: 50%;
background: linear-gradient(135deg, $accent, #a7f3d0);
//background: linear-gradient(135deg, $accent, #a7f3d0);
flex-shrink: 0;
}
@@ -590,7 +659,7 @@
span {
font-size: 0.1rem;
line-height: 0.15rem;
background: rgba(82, 202, 209, 0.15);
background: rgba(82, 202, 209, 0.2);
color: $accent;
padding: 0.02rem 0.08rem;
border-radius: 0.04rem;
@@ -602,11 +671,10 @@
margin-left: auto;
background: $accent;
color: #fff;
padding: 0.12rem 0.16rem;
padding: 0.12rem 0.11rem;
border-radius: 9999px;
font-size: 0.14rem;
line-height: 0.2rem;
font-weight: 600;
}
// 骨架进度条 — block 自然纵排,无需 flex-direction: column
@@ -614,7 +682,7 @@
.bar {
height: 0.08rem;
border-radius: 9999px;
background: #e5e7eb;
background: #F3F4F6;
&--full { width: 100%; }
&--3q { width: 75%; margin-top: 0.1rem; }
}
@@ -626,11 +694,10 @@
background: $accent;
color: #fff;
text-align: center;
padding: 0.16rem;
padding: 0.15rem;
border-radius: 0.16rem;
font-size: 0.16rem;
line-height: 0.24rem;
font-weight: 600;
}
}
@@ -719,7 +786,6 @@
&__title {
font-size: 0.14rem;
line-height: 0.2rem;
//font-weight: 600;
color: #111;
margin: 0;
}
@@ -735,7 +801,7 @@
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
50% { opacity: 0.6; }
}
// ---- 功能3:简历文档模拟卡片 ----
@@ -812,7 +878,7 @@
background: #e5e7eb;
border-radius: 0.02rem;
&--half { width: 50%; }
&--third { width: 33%; margin-top: 0.05rem; }
&--third { width: 33%; margin-top: 0.05rem;background: #F3F4F6 }
}
// 简历内容条占位 — block 自然纵排
@@ -875,7 +941,7 @@
&__role {
font-size: 0.11rem;
line-height: 0.156rem;
font-weight: 900;
font-weight: 500;
color: #111;
margin: 0.03rem 0 0;
}
@@ -916,7 +982,7 @@
border: 0.9px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.03);
backdrop-filter: blur(66px);
border-radius: 0.32rem;
border-radius: 0.16rem;
border-top-left-radius: 0;
color: #111;
}
@@ -925,7 +991,7 @@
&--user {
background: $accent;
color: #fff;
border-radius: 0.32rem;
border-radius: 0.16rem;
border-top-right-radius: 0;
margin-left: auto;
box-shadow: 0 4px 14px rgba(82, 202, 209, 0.2);
@@ -993,7 +1059,7 @@
background: rgba(255, 255, 255, 0.8);
border-radius: 1rem;
backdrop-filter: blur(100px);
padding: 0.8rem 1.2rem 0.6rem;
padding: 1.0rem 0.05rem;
box-shadow: inset 0 0 0.9rem rgba(255, 255, 255, 0.5);
}
@@ -1014,7 +1080,7 @@
font-size: 0.16rem;
line-height: 0.24rem;
color: #9ca3af;
letter-spacing: 0.016rem;
letter-spacing: 0.0rem;
text-transform: uppercase;
margin-top: 0.24rem;
}
@@ -1024,16 +1090,18 @@
&__cards {
display: flex;
gap: 0.24rem;
justify-content: center;
width: 12rem;
margin: 0 auto;
justify-content: space-between;
margin-bottom: 0.48rem;
}
// 创始人引言深色卡片
&__founder {
width: 12rem;
background: #1a1a2e;
border-radius: 0.48rem;
padding: 0.48rem 0.6rem;
background: #111;
border-radius: 0.16rem;
padding: 0.36rem 0.40rem;
display: flex;
align-items: center;
position: relative;
@@ -1044,8 +1112,8 @@
// 创始人装饰圆环 SVG
&__founder-decor {
position: absolute;
right: 0;
top: 50%;
right: -0.6rem;
bottom: -4.6rem;
transform: translateY(-50%);
width: 4.55rem;
height: 4.55rem;
@@ -1076,8 +1144,8 @@
flex: 1;
blockquote {
font-size: 0.24rem;
line-height: 0.39rem;
font-size: 0.20rem;
line-height: 0.34rem;
color: #fff;
margin: 0;
}
@@ -1091,13 +1159,13 @@
.cite-name {
font-size: 0.2rem;
color: $accent;
font-weight: 600;
font-weight: 500;
}
.cite-role {
font-size: 0.14rem;
color: #999;
margin-left: 0.08rem;
margin-left: 0.16rem;
}
}
}
@@ -1128,11 +1196,14 @@
// 单个评价卡片
.testimonial-card {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
min-width: 0;
max-width: 3.71rem;
background: #fff;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.05);
border-radius: 0.4rem;
border-radius: 0.16rem;
padding: 0.3rem;
position: relative;
@@ -1157,7 +1228,7 @@
display: flex;
align-items: center;
gap: 0.16rem;
margin-top: 0.3rem;
margin-top: 0.16rem;
}
// 作者头像
@@ -1166,8 +1237,6 @@
height: 0.48rem;
border-radius: 50%;
object-fit: cover;
border: 2px solid #fff;
box-shadow: 0 2px 4px -2px rgba(0, 0, 0, 0.1), 0 4px 6px -1px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
@@ -1196,7 +1265,7 @@
// ==================== 岗位搜索入口 ====================
.home-job-search {
padding: 0.8rem 0;
padding-bottom: 0.8rem;
// 内容容器
&__inner {
@@ -1208,14 +1277,14 @@
line-height: 0.36rem;
font-weight: 600;
text-align: center;
margin-bottom: 0.4rem;
margin-bottom: 0.6rem;
}
}
// 筛选条件横向排列
&__filters {
display: flex;
gap: 0.24rem;
gap: 0.60rem;
justify-content: center;
align-items: center;
}
@@ -1244,10 +1313,11 @@
// 搜索按钮
.filter-btn {
height: 0.48rem;
width: 1.84rem;
height: 0.56rem;
padding: 0 0.4rem;
border-radius: 0.24rem;
background: $accent;
border-radius: 0.16rem;
background: #111;
color: #fff;
font-size: 0.16rem;
line-height: 0.26rem;
@@ -1255,7 +1325,7 @@
border: none;
cursor: pointer;
transition: background 0.2s;
&:hover { background: $accent-hover; }
&:hover { }
}
// ==================== 常见问题(FAQ ====================
@@ -1348,33 +1418,74 @@
}
}
// 悬停态 — 图标变品牌色
&:hover {
.faq-item__icon {
background: $accent;
color: #fff;
// "还有其他问题"展开后的反馈表单
&__feedback {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.24rem;
}
// 文本输入框
&__textarea {
width: 100%;
min-height: 1.2rem;
padding: 0.2rem;
border-radius: 0.16rem;
border: none;
background: #f5f5f5;
font-size: 0.14rem;
line-height: 0.22rem;
color: #111;
resize: none;
outline: none;
font-family: inherit;
&::placeholder {
color: #bbb;
}
}
// 展开态 — 图标变品牌色
// 提交按钮
&__submit {
padding: 0.14rem 0.6rem;
border-radius: 9999px;
background: #111;
color: #fff;
font-size: 0.16rem;
line-height: 0.22rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: background 0.2s;
}
// 悬停态
&:hover {
.faq-item__icon {
background: #f3f4f6;
color: #1f2937;
}
}
// 展开态
&--open {
.faq-item__icon {
background: $accent;
color: #fff;
background: #f3f4f6;
color: #1f2937;
}
}
}
// ==================== 底部 CTA 行动号召 ====================
.home-cta {
padding: 0 1.2rem 1.2rem;
width: 12rem;
margin: 0 auto;
padding: 0 0 1.0rem;
width: 100%;
// CTA 内容区
&__inner {
background: linear-gradient(223.56deg, #52CAD1 0%, #53D9C8 100%);
border-radius: 0.6rem;
border-radius: 0;
padding: 1.0rem 0;
text-align: center;
position: relative;
@@ -1393,9 +1504,9 @@
// CTA 按钮
&__btn {
margin-top: 0.2rem;
padding: 0.24rem 0.64rem;
border-radius: 9999px;
margin-top: 0.4rem;
padding: 0.18rem 0.64rem;
border-radius: 0.16rem;
background: #111;
color: #fff;
font-size: 0.2rem;
+3 -3
View File
@@ -4,10 +4,10 @@
.job-detail {
&__content {
margin-left: 2rem;
margin-right: 3.6rem;
margin-right: var(--chat-width, 4.0rem);
flex: 1;
padding: 0.12rem 0.56rem 0.12rem 0.18rem;
height: 100vh;
padding: 0.12rem 0.12rem 0.12rem 0.12rem;
height: var(--app-height, 100vh);
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
+95 -2
View File
@@ -5,10 +5,10 @@
.jobs-page {
&__content {
margin-left: 2rem;
margin-right: 4.0rem;
margin-right: var(--chat-width, 4.0rem);
flex: 1;
padding: 0.12rem 0.18rem;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
@@ -633,6 +633,31 @@
z-index: 10;
}
// 列表底部加载盒子
&__loading-box {
display: flex;
align-items: center;
justify-content: center;
gap: 0.08rem;
padding: 0.24rem 0;
font-size: 0.13rem;
color: $text-light;
}
// 加载旋转动画
&__loading-spinner {
width: 0.18rem;
height: 0.18rem;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: $accent;
border-radius: 50%;
animation: jobs-spin 0.6s linear infinite;
}
@keyframes jobs-spin {
to { transform: rotate(360deg); }
}
// 高匹配度特殊样式
&__job-match--high &__match-score {
color: $accent-hover;
@@ -642,6 +667,74 @@
color: $accent;
font-weight: 500;
}
// ==================== AI助手投递提醒弹窗 ====================
// 遮罩层
&__agent-remind-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: $overlay-bg;
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
// 弹窗主体
&__agent-remind-dialog {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.32rem 0.36rem 0.24rem;
min-width: 3.2rem;
text-align: center;
}
// 提示文字
&__agent-remind-text {
font-size: 0.15rem;
color: $text-dark;
font-weight: 500;
margin-bottom: 0.28rem;
line-height: 1.5;
}
// 按钮区域
&__agent-remind-actions {
display: flex;
gap: 0.16rem;
}
// 按钮通用
&__agent-remind-btn {
flex: 1;
padding: 0.1rem 0;
border-radius: 0.08rem;
font-size: 0.14rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity 0.2s;
&:hover {
opacity: 0.85;
}
// 灰色按钮 — 直接投
&--secondary {
background: $bg-main;
color: $text-middle;
}
// 主题色按钮 — 去AI助手
&--primary {
background: $btn-dark;
color: $bg-white;
}
}
}
// ==================== 不感兴趣反馈弹窗 ====================
+606 -71
View File
@@ -1,87 +1,622 @@
.login-dialog {
width: 6rem;
.el-dialog__header {
padding: 0.16rem 0.16rem 0;
margin-right: 0;
}
.el-dialog__body {
padding: 0 0.4rem 0.4rem;
}
/* 登录页面 — 左右分栏全屏布局 */
.login-view {
font-size: 0.14rem;
display: flex;
align-items: flex-start;
width: 100%;
height: var(--app-height, 100vh);
background: #F7FEFC;
}
.login-page {
/* ==================== 左侧品牌面板 ==================== */
.login-view__left {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 57%;
height: 100%;
background: linear-gradient(180deg, #0A9E97 0%, #12C7BE 50%, #06B6D4 100%);
position: relative;
overflow: hidden;
}
/* 装饰圆圈 */
.login-view__deco-circle {
border-radius: 50%;
position: absolute;
}
.login-view__deco-circle--lg {
width: 4.8rem;
height: 4.8rem;
background: rgba(255, 255, 255, 0.06);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.login-view__deco-circle--sm {
width: 3.2rem;
height: 3.2rem;
background: rgba(255, 255, 255, 0.08);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
/* 左侧中心内容 */
.login-view__left-content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.32rem;
position: relative;
z-index: 1;
}
/* Logo 行 */
.login-view__logo-row {
display: flex;
align-items: center;
gap: 0.12rem;
}
.login-view__logo-box {
display: flex;
justify-content: center;
align-items: center;
width: 0.56rem;
height: 0.56rem;
border-radius: 0.16rem;
font-weight: 700;
font-size: 0.22rem;
line-height: 1;
}
.login-view__logo-box--light {
background: rgba(255, 255, 255, 0.2);
color: #FFFFFF;
}
.login-view__logo-box--green {
background: rgba(9, 170, 119, 0.2);
color: #351616;
}
.login-view__logo-text {
font-weight: 700;
font-size: 0.28rem;
line-height: 0.34rem;
}
.login-view__logo-text--white {
color: #FFFFFF;
}
.login-view__logo-text--dark {
color: #351616;
}
/* 标语 */
.login-view__slogan {
font-weight: 700;
font-size: 0.4rem;
line-height: 0.56rem;
text-align: center;
color: #FFFFFF;
}
.login-title {
font-size: 0.28rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.4rem;
}
.login-view__sub-slogan {
font-weight: 400;
font-size: 0.16rem;
line-height: 0.19rem;
text-align: center;
color: rgba(255, 255, 255, 0.85);
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.16rem;
}
/* 特性药丸标签 */
.login-view__pills {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 0.24rem;
gap: 0.12rem;
}
.login-input {
height: 0.4rem;
.el-input__wrapper {
background-color: #f5f5f7;
border-radius: 0.08rem;
box-shadow: none;
padding: 0.00rem 0.16rem;
}
}
.login-view__pill {
display: flex;
align-items: center;
padding: 0.12rem 0.2rem;
gap: 0.08rem;
background: rgba(255, 255, 255, 0.15);
border-radius: 0.24rem;
font-weight: 500;
font-size: 0.14rem;
line-height: 0.17rem;
color: #FFFFFF;
}
.code-row {
display: flex;
align-items: center;
gap: 0.12rem;
/* ==================== 右侧表单面板 ==================== */
.login-view__right {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 43%;
height: 100%;
background: #F6FDFB;
}
.login-input {
flex: 1;
}
.login-view__logo-row--right {
margin-bottom: 0.4rem;
}
.send-code-btn {
white-space: nowrap;
border-radius: 0.2rem;
padding: 0.08rem 0.2rem;
font-size: 0.14rem;
}
}
/* 表单包裹 */
.login-view__form-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.28rem;
width: 4rem;
}
.login-btn {
margin-top: 0.16rem;
width: 100%;
height: 0.52rem;
border-radius: 0.26rem;
font-size: 0.18rem;
font-weight: 600;
background-color: #1a1a2e;
border-color: #1a1a2e;
/* 标题 */
.login-view__heading {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
&:hover,
&:focus {
background-color: #2d2d44;
border-color: #2d2d44;
}
}
.login-view__title {
font-weight: 700;
font-size: 0.32rem;
line-height: 0.39rem;
color: #132034;
}
.register-link {
margin-top: 0.08rem;
font-size: 0.14rem;
color: #666;
.login-view__subtitle {
font-weight: 400;
font-size: 0.15rem;
line-height: 0.18rem;
color: #64748B;
}
a {
color: #409eff;
text-decoration: none;
/* ==================== 手机号输入行 ==================== */
.login-view__phone-row {
display: flex;
align-items: center;
width: 100%;
height: 0.52rem;
border-radius: 0.12rem;
overflow: hidden;
}
&:hover {
text-decoration: underline;
}
}
.login-view__country-code {
display: flex;
align-items: center;
padding: 0 0.16rem;
gap: 0.06rem;
width: 0.96rem;
height: 100%;
background: #F3FFFD;
border: 1px solid #BFE8E2;
border-radius: 0.12rem 0 0 0.12rem;
cursor: pointer;
}
.login-view__flag {
font-size: 0.16rem;
line-height: 1;
}
.login-view__code-text {
font-weight: 500;
font-size: 0.15rem;
line-height: 0.18rem;
color: #132034;
}
.login-view__code-arrow {
font-size: 0.11rem;
color: #94A3B8;
}
.login-view__separator {
width: 1px;
height: 100%;
background: #BFE8E2;
}
.login-view__phone-input {
flex: 1;
height: 100%;
padding: 0 0.16rem;
background: #FAFBFC;
border: 1px solid #E2E8F0;
border-left: none;
border-radius: 0 0.12rem 0.12rem 0;
font-size: 0.15rem;
color: #132034;
outline: none;
&::placeholder {
color: #94A3B8;
}
}
/* ==================== 发送验证码按钮(步骤一) ==================== */
.login-view__send-btn {
width: 100%;
height: 0.52rem;
background: linear-gradient(90deg, #12C7BE 0%, #06B6D4 100%);
border-radius: 0.12rem;
border: none;
font-weight: 600;
font-size: 0.16rem;
color: #FFFFFF;
cursor: pointer;
transition: opacity 0.2s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.9;
}
}
/* ==================== OTP 验证码输入(步骤二) ==================== */
.login-view__otp-wrap {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.1rem;
width: 100%;
cursor: text;
}
.login-view__otp-hidden-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 2;
font-size: 0.16rem;
caret-color: transparent;
}
.login-view__otp-box {
display: flex;
justify-content: center;
align-items: center;
width: 0.56rem;
height: 0.64rem;
background: #FAFBFC;
border: 1px solid #E2E8F0;
border-radius: 0.12rem;
transition: border-color 0.2s, background 0.2s;
}
.login-view__otp-box--active {
background: #F3FFFD;
border: 2px solid #12C7BE;
}
.login-view__otp-box--filled {
background: #F3FFFD;
border: 2px solid #12C7BE;
}
.login-view__otp-digit {
font-weight: 700;
font-size: 0.24rem;
line-height: 0.29rem;
color: #132034;
}
/* 闪烁光标 */
.login-view__otp-cursor {
display: inline-block;
width: 2px;
height: 0.28rem;
background: #12C7BE;
border-radius: 1px;
animation: login-otp-blink 1s ease-in-out infinite;
}
@keyframes login-otp-blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ==================== 验证码发送状态提示 ==================== */
.login-view__sms-status {
width: 100%;
font-size: 0.13rem;
line-height: 0.16rem;
color: #64748B;
text-align: center;
padding-right: 0.1rem;
margin-top: -0.19rem;
margin-bottom: -0.10rem;
}
.login-view__sms-status--error {
color: #E85635;
}
/* ==================== 状态按钮(步骤二) ==================== */
.login-view__status-btn {
width: 100%;
height: 0.52rem;
background: linear-gradient(90deg, #12C7BE 0%, #06B6D4 100%);
border-radius: 0.12rem;
border: none;
font-weight: 600;
font-size: 0.16rem;
color: #FFFFFF;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.08rem;
transition: opacity 0.2s;
&:disabled {
opacity: 0.7;
cursor: not-allowed;
}
&:not(:disabled):hover {
opacity: 0.9;
}
}
/* 登录中转圈动画 */
.login-view__spinner {
display: inline-block;
width: 0.18rem;
height: 0.18rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: login-spin 0.7s linear infinite;
}
@keyframes login-spin {
to { transform: rotate(360deg); }
}
/* ==================== 协议勾选 ==================== */
.login-view__agreement {
display: flex;
align-items: center;
gap: 0.06rem;
}
.login-view__checkbox {
box-sizing: border-box;
width: 0.18rem;
height: 0.18rem;
background: #FFFFFF;
border: 1.5px solid #CBD5E1;
border-radius: 0.05rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.login-view__checkbox--checked {
background: #12C7BE;
border-color: #12C7BE;
}
.login-view__check-icon {
font-size: 0.11rem;
color: #FFFFFF;
font-weight: 700;
line-height: 1;
}
.login-view__agreement-text {
font-weight: 400;
font-size: 0.12rem;
line-height: 0.15rem;
color: #94A3B8;
}
.login-view__agreement-link {
font-weight: 500;
font-size: 0.12rem;
line-height: 0.15rem;
color: #12C7BE;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* ==================== 简历上传阶段 — 左侧职位卡片面板 ==================== */
.login-view__left--resume {
position: relative;
overflow: hidden;
padding: 0.4rem;
}
.login-view__resume-logo {
position: absolute;
top: 0.3rem;
left: 0.4rem;
display: flex;
align-items: center;
gap: 0.12rem;
z-index: 2;
}
.login-view__job-cards {
position: relative;
width: 100%;
height: 5.5rem;
z-index: 1;
}
.login-view__job-card {
position: absolute;
display: flex;
align-items: center;
gap: 0.1rem;
padding: 0.1rem 0.16rem;
background: #FFFFFF;
border-radius: 0.1rem;
box-shadow: 0 0.02rem 0.08rem rgba(0, 0, 0, 0.06);
white-space: nowrap;
}
.login-view__job-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 0.32rem;
height: 0.32rem;
border-radius: 0.06rem;
font-size: 0.13rem;
font-weight: 700;
flex-shrink: 0;
}
.login-view__job-card-info {
display: flex;
flex-direction: column;
gap: 0.02rem;
}
.login-view__job-card-title {
font-size: 0.13rem;
font-weight: 600;
color: #132034;
}
.login-view__job-card-company {
font-size: 0.11rem;
color: #94A3B8;
}
.login-view__job-card-salary {
font-size: 0.12rem;
font-weight: 600;
color: #E85635;
margin-left: 0.08rem;
}
.login-view__resume-slogan {
position: absolute;
bottom: 1.5rem;
left: 0.4rem;
z-index: 2;
h1 {
font-size: 0.32rem;
font-weight: 700;
color: #132034;
line-height: 0.48rem;
}
}
/* ==================== 简历上传阶段 — 右侧面板 ==================== */
.login-view__form-wrap--resume {
width: 4.6rem;
min-height: 4rem;
}
/* 上传区域 */
.login-view__upload-area {
width: 100%;
padding: 0.6rem 0.4rem;
border: 1px dashed #E2E8F0;
border-radius: 0.12rem;
background: #FFFFFF;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.12rem;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: #12C7BE;
}
}
.login-view__upload-circle {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: #E8F8F7;
}
.login-view__upload-text {
font-size: 0.14rem;
color: #64748B;
}
.login-view__upload-hint {
font-size: 0.12rem;
color: #94A3B8;
}
.login-view__file-input {
display: none;
}
.login-view__upload-btn {
padding: 0.14rem 0.4rem;
background: linear-gradient(90deg, #12C7BE 0%, #06B6D4 100%);
border: none;
border-radius: 0.26rem;
font-size: 0.16rem;
font-weight: 600;
color: #FFFFFF;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
/* 解析中状态 */
.login-view__parsing {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
width: 100%;
min-height: 3rem;
}
.login-view__parsing-title {
font-size: 0.32rem;
font-weight: 700;
color: #132034;
margin-bottom: 0.12rem;
}
.login-view__parsing-subtitle {
font-size: 0.15rem;
color: #64748B;
}
/* 失败状态标题红色 */
.login-view__title--error {
color: #12C7BE;
}
+1 -1
View File
@@ -6,7 +6,7 @@
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
overflow-y: auto;
background: $bg-main;
+1 -1
View File
@@ -6,7 +6,7 @@
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
overflow-y: auto;
background: $bg-main;
+1 -1
View File
@@ -6,7 +6,7 @@
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
height: var(--app-height, 100vh);
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
+4 -3
View File
@@ -27,7 +27,8 @@ $text-light: #BFBFBF;
$text-middle: #777777;
// 强调色 / 品牌色
$accent: #4FC2C9;
//$accent: #4FC2C9;
$accent: #52CAD1;
// 强调色悬停态
$accent-hover: #42A8B3;
@@ -42,7 +43,7 @@ $border-color: #E8E8E8;
$overlay-bg: rgba(0, 0, 0, 0.5);
// 主按钮颜色背景确认提交等
$btn-dark: #4FC2C9;
$btn-dark: #52CAD1;
// 主按钮颜色悬停态
$btn-dark-hover: #42A8B3;
@@ -54,4 +55,4 @@ $btn-dark-hover: #42A8B3;
//$btn-dark-hover: #2E3142;
// 渐变色背景
$gradient-bg: linear-gradient(to right, #4FC2C9, #42A8B3);
$gradient-bg: linear-gradient(to right, #52CAD1, #42A8B3);
+2 -5
View File
@@ -109,6 +109,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { fetchJobDetail } from '@/api/jobs'
import type { JobDetailData } from '@/api/jobs'
import { formatEmploymentType } from '@/stores/index'
/** 组件 Props */
const props = defineProps<{
@@ -150,11 +151,7 @@ const formatTime = computed(() => {
return ''
})
/** 工作类型映射 */
function formatEmploymentType(type: number | undefined): string {
const map: Record<number, string> = { 0: '全职', 1: '兼职' }
return map[type ?? -1] ?? ''
}
/** 工作类型映射 — 使用全局统一的 formatEmploymentType */
/** 加载岗位详情 */
async function loadDetail() {
+5 -4
View File
@@ -66,7 +66,7 @@
<span v-for="name in intentionCategoryNames" :key="'cat-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-for="name in intentionIndustryNames" :key="'ind-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span v-for="name in intentionRegionNames" :key="'reg-' + name" class="agent-setting-panel__goal-tag">{{ name }}</span>
<span class="agent-setting-panel__goal-tag">{{ intentionEmploymentLabel }}</span>
<span v-if="intentionEmploymentLabel" class="agent-setting-panel__goal-tag">{{ intentionEmploymentLabel }}</span>
<!-- 无意向时的空状态 -->
</div>
@@ -234,6 +234,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
@@ -281,8 +282,8 @@ const intentionIndustryNames = computed(() => (store.state.jobIntention?.industr
/** 求职意向 — 地区名称列表 */
const intentionRegionNames = computed(() => (store.state.jobIntention?.regionCodes || []).map((code: string) => resolveRegionName(code)).filter(Boolean))
/** 求职意向 — 就业类型文案 */
const intentionEmploymentLabel = computed(() => store.state.jobIntention?.employmentType === 1 ? '实习' : '全职')
/** 求职意向 — 招聘分类文案 */
const intentionEmploymentLabel = computed(() => formatEmploymentType(store.state.jobIntention?.recruitCategory))
// ==================== ====================
@@ -335,7 +336,7 @@ function downloadExtension() {
// ==================== ====================
/** 是否为实习类型 */
const isInternship = computed(() => store.state.jobIntention?.employmentType === 1)
const isInternship = computed(() => store.state.jobIntention?.recruitCategory === 2)
/** 配置表单数据 */
const configForm = ref({
+5 -4
View File
@@ -224,6 +224,7 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import ProfilePageContent from '@/components/ProfilePageContent.vue'
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
@@ -402,7 +403,7 @@ const showJobGoalDialog = ref(false)
const intentionCategoryNames = computed(() => (store.state.jobIntention.categoryIds || []).map((id: number) => resolveJobCategoryName(id)))
const intentionIndustryNames = computed(() => (store.state.jobIntention.industryIds || []).map((id: number) => resolveIndustryName(id)))
const intentionRegionNames = computed(() => (store.state.jobIntention.regionCodes || []).map((code: string) => resolveRegionName(code)))
const intentionEmploymentLabel = computed(() => store.state.jobIntention.employmentType === 1 ? '实习' : '全职')
const intentionEmploymentLabel = computed(() => formatEmploymentType(store.state.jobIntention.recruitCategory))
interface MatchedJobItem extends JobListItem { feedback: string }
const matchedJobs = ref<MatchedJobItem[]>([])
@@ -417,7 +418,7 @@ async function loadMatchedJobs() {
loadingMatchJobs.value = true; matchedJobs.value = []
try {
const intention = store.state.jobIntention
const res = await fetchJobList({ pageNum: 1, pageSize: 30, regionCodes: intention.regionCodes?.length ? intention.regionCodes : undefined, categoryIds: intention.categoryIds?.length ? intention.categoryIds : undefined, industryIds: intention.industryIds?.length ? intention.industryIds : undefined, employmentType: intention.employmentType ?? undefined })
const res = await fetchJobList({ pageNum: 1, pageSize: 30, regionCodes: intention.regionCodes?.length ? intention.regionCodes : undefined, categoryIds: intention.categoryIds?.length ? intention.categoryIds : undefined, industryIds: intention.industryIds?.length ? intention.industryIds : undefined, recruitCategory: intention.recruitCategory ?? undefined })
if (res.code === '0' && res.data && res.data.list.length > 0) {
const shuffled = [...res.data.list].sort(() => Math.random() - 0.5)
matchedJobs.value = shuffled.slice(0, 3).map(item => ({ ...item, feedback: '' }))
@@ -433,7 +434,7 @@ function handleDislike(index: number) {
}
// ==================== 3 ====================
const isInternship = computed(() => store.state.jobIntention.employmentType === 1)
const isInternship = computed(() => store.state.jobIntention.recruitCategory === 2)
const step3Sub = ref(1)
const step3Form = reactive({
acceptDeptTransfer: '', acceptLocationTransfer: '',
@@ -480,7 +481,7 @@ const setupComplete = ref(false)
function handleStep4Complete() {
const step4Data = settingsPanelRef.value?.getData()
const allSettings = {
jobType: store.state.jobIntention.employmentType === 1 ? 1 : 2,
jobType: store.state.jobIntention.recruitCategory === 2 ? 1 : 2,
agentMode: step4Data?.agentMode ?? 1,
weeklyTarget: step4Data?.weeklyTarget ?? 2,
autoOptimizeResume: step4Data?.autoOptimizeResume ?? 1,
+20 -1
View File
@@ -44,7 +44,7 @@
class="ai-chat__msg"
:class="msg.role === 'assistant' ? 'ai-chat__msg--ai' : 'ai-chat__msg--user'"
>
<div class="ai-chat__msg-bubble" v-html="msg.content"></div>
<div class="ai-chat__msg-bubble" v-html="formatContent(msg.content)"></div>
</div>
<!-- AI 正在思考中加载指示器 -->
@@ -74,12 +74,22 @@
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useStore } from 'vuex'
import markdownit from 'markdown-it'
import MemberDialog from '@/components/MemberDialog.vue'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import { sendNovaChat } from '@/utils/aiRequest'
import type { NovaChatHistoryItem } from '@/utils/aiRequest'
import { fetchResumeList } from '@/api/resume'
// ==================== Markdown ====================
/** markdown-it 实例,用于将 AI 回复的 Markdown 文本渲染为 HTML */
const md = markdownit({
html: false, // HTML XSS
breaks: true, // <br>
linkify: true, //
})
// ==================== Props ====================
/** 组件属性 */
@@ -178,6 +188,13 @@ const quickQuestions = computed(() => {
/** 用户提问列表(等同于 quickQuestions */
const userQuestions = computed(() => quickQuestions.value)
// ==================== ====================
/** 将消息内容通过 markdown-it 渲染为 HTML */
function formatContent(content: string): string {
return md.render(content)
}
// ==================== ====================
/** 滚动到聊天区域底部 */
@@ -252,6 +269,8 @@ async function sendMessage() {
}
}
// ==================== ====================
// ==================== ====================
onMounted(() => {
+8 -7
View File
@@ -62,10 +62,10 @@
/>
</div>
<!-- 工作类型选择模块 -->
<!-- 招聘分类选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__label">*工作类型</div>
<!-- 工作类型按钮组 -->
<div class="job-goal-dialog__label">*招聘分类</div>
<!-- 招聘分类按钮组 -->
<div class="job-goal-dialog__type-group">
<button
v-for="t in jobTypes"
@@ -90,6 +90,7 @@
import { ref, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
import { useStore } from 'vuex'
import { JOB_TYPE_OPTIONS, formatEmploymentType } from '@/stores/index'
import RegionSelector from './tools/RegionSelector.vue'
import IndustrySelector from './tools/IndustrySelector.vue'
import JobCategorySelector from './tools/JobCategorySelector.vue'
@@ -117,8 +118,8 @@ const selectedIndustryIds = ref<number[]>([])
const selectedRegionCodes = ref<string[]>([])
const selectedJobType = ref('全职')
/** 工作类型选项列表 */
const jobTypes = ['实习', '全职']
/** 招聘分类选项列表 — 从全局常量提取 label 生成 */
const jobTypes = JOB_TYPE_OPTIONS.map(item => item.label)
/** 弹窗打开时从 store 同步数据到本地编辑副本 */
watch(() => props.modelValue, (v) => {
@@ -127,7 +128,7 @@ watch(() => props.modelValue, (v) => {
selectedCategoryIds.value = [...(intention.categoryIds || [])]
selectedIndustryIds.value = [...(intention.industryIds || [])]
selectedRegionCodes.value = [...(intention.regionCodes || [])]
selectedJobType.value = intention.employmentType === 1 ? '实习' : '全职'
selectedJobType.value = formatEmploymentType(intention.recruitCategory)
}
})
@@ -174,7 +175,7 @@ async function handleSave() {
categoryIds: [...selectedCategoryIds.value],
industryIds: [...selectedIndustryIds.value],
regionCodes: [...selectedRegionCodes.value],
employmentType: selectedJobType.value === '实习' ? 1 : 0,
recruitCategory: JOB_TYPE_OPTIONS.find(o => o.label === selectedJobType.value)?.value ?? 0,
})
visible.value = false
} catch (e) {
+47 -9
View File
@@ -268,10 +268,19 @@
>
<div class="job-resume-custom-dialog__ai-msg-bubble">{{ msg.content }}</div>
</div>
<!-- 撤销修改气泡 type=updated assistant 消息显示 -->
<div v-if="msg.canRollback" class="job-resume-custom-dialog__ai-rollback">
<!-- 撤销修改气泡 -->
<!-- 已撤销状态所有历史中已撤销的消息都显示 -->
<div v-if="msg.canRollback && msg.rollbackStatus === 'done'" class="job-resume-custom-dialog__ai-rollback">
<span class="job-resume-custom-dialog__ai-rollback-done">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
<path d="M3 8.5l3 3 7-7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
已撤销此次简历修改
</span>
</div>
<!-- 撤销按钮仅当该消息是列表最后一条且为AI修改简历的回答且未撤销时显示 -->
<div v-if="msg.canRollback && msg.rollbackStatus !== 'done' && isLastMessage(i)" class="job-resume-custom-dialog__ai-rollback">
<button
v-if="msg.rollbackStatus !== 'done'"
class="job-resume-custom-dialog__ai-rollback-btn"
@click="handleRollbackClick(i)"
>
@@ -280,12 +289,6 @@
</svg>
撤销修改
</button>
<span v-else class="job-resume-custom-dialog__ai-rollback-done">
<svg viewBox="0 0 16 16" fill="none" class="job-resume-custom-dialog__ai-rollback-icon">
<path d="M3 8.5l3 3 7-7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
已撤销此次简历修改
</span>
</div>
</div>
<!-- AI正在回复的加载指示器 -->
@@ -430,11 +433,37 @@ const isLowMatch = computed(() => props.jobInfo.matchScore < 6)
/** 跳转到指定步骤 */
function goToStep(step: number) {
// 434
if (currentStep.value === 4 && step === 3) {
resetStep4State()
}
if (step === 3) initSkillOptions()
if (step === 4) fetchAndLoadCustomResume()
else currentStep.value = step
}
/** 重置步骤4(预览)的所有状态数据 */
function resetStep4State() {
aiMessages.value = []
aiInputText.value = ''
aiLoading.value = false
isShowDiff.value = false
previewTab.value = 'ai'
cachedOptimizedScore.value = 0
oldResumeTemplateData.value = {
name: '', email: '', mobileNumber: '', wechatNumber: '', summary: '',
educations: [], workExperiences: [], internships: [], projects: [],
competitions: [], skills: [], certificates: [],
}
resumeTemplateData.value = {
name: '', email: '', mobileNumber: '', wechatNumber: '', summary: '',
educations: [], workExperiences: [], internships: [], projects: [],
competitions: [], skills: [], certificates: [],
}
customResumeRawData.value = { resume: {} }
showDownloadMenu.value = false
}
/** 抽屉模式下一步 */
async function handleDrawerNext() {
if (currentStep.value >= 4) return
@@ -914,6 +943,15 @@ const showRollbackConfirm = ref(false)
/** 当前要撤销的消息索引 */
const rollbackMsgIndex = ref(-1)
/**
* 判断该消息是否为消息列表的最后一条
* 撤销按钮只在最后一条消息恰好是AI修改简历的回答时才显示
* @param msgIndex 消息在列表中的索引
*/
function isLastMessage(msgIndex: number): boolean {
return msgIndex === aiMessages.value.length - 1
}
/**
* 点击撤销修改按钮 弹出确认弹窗
* @param msgIndex 消息在列表中的索引
+5 -1
View File
@@ -1,4 +1,8 @@
<template>
<!--
该弹窗登录组件已搁置登录功能已改为独立页面 /login (src/views/Login.vue)
如需恢复弹窗登录重新在 App.vue 中引用此组件即可
-->
<el-dialog
v-model="visible"
width="480px"
@@ -86,7 +90,7 @@ async function sendCode() {
const res = await sendSmsCode(phone.value)
if (res.code === '0' && res.data === true) {
ElMessage.success('验证码已发送')
countdown.value = 300
countdown.value = 60
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && timer) {
+51
View File
@@ -0,0 +1,51 @@
<template>
<!-- 会员权限拦截弹窗 无权限页面点击时弹出 -->
<Teleport to="body">
<div v-if="modelValue" class="member-access-dialog-overlay" @click="$emit('update:modelValue', false)">
<div class="member-access-dialog" @click.stop>
<!-- 提示图标 -->
<div class="member-access-dialog__icon">
<svg viewBox="0 0 48 48" fill="none" class="member-access-dialog__lock-svg">
<rect x="10" y="22" width="28" height="20" rx="3" stroke="currentColor" stroke-width="2.5"/>
<path d="M16 22V16a8 8 0 1116 0v6" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="24" cy="33" r="3" fill="currentColor"/>
</svg>
</div>
<!-- 提示文字 -->
<p class="member-access-dialog__text">该功能仅对会员开放升级会员享受更强大的AI求职功能</p>
<!-- 操作按钮 -->
<div class="member-access-dialog__actions">
<button class="member-access-dialog__btn member-access-dialog__btn--cancel" @click="$emit('update:modelValue', false)">
先不升级
</button>
<button class="member-access-dialog__btn member-access-dialog__btn--confirm" @click="handleViewDetail">
了解详情
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
/**
* 会员权限拦截弹窗
* 当用户点击无权限accessible=false的菜单时弹出
* "了解详情"按钮打开会员购买组件 MemberDialog
*/
/** 组件 Props */
defineProps<{ modelValue: boolean }>()
/** 组件 Emits */
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'openMember'): void
}>()
/** 点击了解详情 — 关闭当前弹窗,通知父组件打开会员购买弹窗 */
function handleViewDetail() {
emit('update:modelValue', false)
emit('openMember')
}
</script>
+13 -1
View File
@@ -237,7 +237,7 @@
<input type="checkbox" v-model="agreeProtocol" />
<span class="member-dialog__order-checkbox-mark"></span>
</label>
<span>我已阅读并同意 <a href="javascript:;">会员服务协议</a> <a href="javascript:;">自动续费协议</a></span>
<span>我已阅读并同意 <a href="javascript:;" @click.prevent="openMemberAgreement">会员服务协议</a> </span>
</div>
<!-- 立即开启按钮 -->
<button
@@ -355,6 +355,9 @@
</div>
</div>
</div>
<!-- 协议预览弹窗 -->
<AgreementPreviewDialog v-model="showAgreementDialog" code="ae8065i3" />
</Teleport>
</template>
@@ -363,6 +366,7 @@ import { ref, computed, watch, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { fetchMemberProductList, createMemberOrder, fetchOrderDetail, type MemberProduct } from '@/api/member'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
@@ -664,4 +668,12 @@ function handleViewMemberBenefits() {
store.commit('SET_SETTINGS_TAB', 'member')
store.commit('SET_SHOW_SETTINGS', true)
}
/** 协议预览弹窗显示状态 */
const showAgreementDialog = ref(false)
/** 打开会员服务协议 */
function openMemberAgreement() {
showAgreementDialog.value = true
}
</script>
+62 -56
View File
@@ -74,7 +74,7 @@
{{ memberStatus.isMember ? '已开通' : '未开通' }}
</span>
</div>
<span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span>
<span class="settings-dialog__member-terms" @click="openAgreementDialog('ae8065i3')">会员条款</span>
</div>
<div class="settings-dialog__member-info-row">
<!-- 已开通显示到期时间和剩余天数 -->
@@ -130,7 +130,7 @@
<span class="settings-dialog__reminder-tag" v-for="name in intentionRegionNames" :key="name">{{ name }}</span>
</div>
</div>
<div class="settings-dialog__reminder-group">
<div v-if="intentionEmploymentLabel" class="settings-dialog__reminder-group">
<span class="settings-dialog__reminder-group-label">类型</span>
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag">{{ intentionEmploymentLabel }}</span>
@@ -167,60 +167,14 @@
<!-- </div>-->
<!-- </div>-->
</template>
<!-- Tab: 用户隐私协议 长文本可滚动查看 -->
<!-- Tab: 用户隐私协议 从接口获取并用 markdown-it 渲染 -->
<template v-if="activeTab === 'privacy'">
<h2 class="settings-dialog__content-title">用户隐私协议</h2>
<div class="settings-dialog__privacy-content">
<div class="settings-dialog__privacy-section">
<h4>引言</h4>
<p>欢迎使用 Offer派以下简称"本平台""我们"我们深知个人信息对您的重要性并会尽全力保护您的个人信息安全我们致力于维持您对我们的信任恪守以下原则保护您的个人信息权责一致原则目的明确原则选择同意原则最少够用原则确保安全原则主体参与原则公开透明原则等同时我们承诺将按照业界成熟的安全标准采取相应的安全保护措施来保护您的个人信息请您在使用本平台服务前仔细阅读并了解本隐私政策</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>我们如何收集和使用您的个人信息</h4>
<p>个人信息是指以电子或者其他方式记录的能够单独或者与其他信息结合识别特定自然人身份或者反映特定自然人活动情况的各种信息我们仅会出于本政策所述的以下目的收集和使用您的个人信息</p>
<p>1. 注册与登录当您注册本平台账号时我们会收集您的手机号码用于创建账号和身份验证您也可以选择填写昵称头像等个人资料来完善您的账户信息手机号码属于敏感信息收集此类信息是为了满足相关法律法规的网络实名制要求如果您不提供手机号码将无法使用本平台的服务</p>
<p>2. 简历管理当您使用简历管理功能时我们会收集您主动填写的简历信息包括但不限于姓名性别出生日期教育经历工作经历项目经验技能特长求职意向期望薪资期望工作地点等这些信息将用于为您提供精准的岗位推荐服务您可以随时在个人中心修改或删除这些信息</p>
<p>3. 岗位推荐与搜索当您使用岗位搜索和推荐功能时我们会收集您的搜索关键词浏览记录收藏记录投递记录等行为数据以便为您提供更加精准和个性化的岗位推荐我们也会根据您的求职意向和简历信息通过算法模型为您匹配合适的职位</p>
<p>4. AI 助手服务当您使用 AI 助手功能时我们会收集您与 AI 的对话内容用于提供智能问答简历优化建议面试辅导等服务对话内容将被加密存储并仅用于改善服务质量我们不会将您的对话内容用于其他商业目的</p>
<p>5. 消息通知为了及时向您推送岗位更新申请状态变更等重要信息我们可能会收集您的设备标识符推送令牌等信息用于实现消息推送功能您可以在设置中随时关闭消息推送</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>我们如何共享转让公开披露您的个人信息</h4>
<p>1. 共享我们不会与任何公司组织和个人共享您的个人信息但以下情况除外1在获取明确同意的情况下共享获得您的明确同意后我们会与其他方共享您的个人信息2我们可能会根据法律法规规定或按政府主管部门的强制性要求对外共享您的个人信息3与授权合作伙伴共享仅为实现本隐私政策中声明的目的我们的某些服务将由授权合作伙伴提供我们可能会与合作伙伴共享您的某些个人信息以提供更好的客户服务和用户体验我们仅会出于合法正当必要特定明确的目的共享您的个人信息并且只会共享提供服务所必要的个人信息</p>
<p>2. 转让我们不会将您的个人信息转让给任何公司组织和个人但以下情况除外1在获取明确同意的情况下转让获得您的明确同意后我们会向其他方转让您的个人信息2在涉及合并收购或破产清算时如涉及到个人信息转让我们会在要求新的持有您个人信息的公司组织继续受此隐私政策的约束否则我们将要求该公司组织重新向您征求授权同意</p>
<p>3. 公开披露我们仅会在以下情况下公开披露您的个人信息1获得您明确同意后2基于法律的披露在法律法律程序诉讼或政府主管部门强制性要求的情况下我们可能会公开披露您的个人信息</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>我们如何保护您的个人信息</h4>
<p>1. 我们已使用符合业界标准的安全防护措施保护您提供的个人信息防止数据遭到未经授权的访问公开披露使用修改损坏或丢失我们会采取一切合理可行的措施保护您的个人信息例如在您的浏览器与服务之间交换数据时受 SSL 加密保护我们同时对网站提供 HTTPS 安全浏览方式我们会使用加密技术确保数据的保密性我们会使用受信赖的保护机制防止数据遭到恶意攻击我们会部署访问控制机制确保只有授权人员才可访问个人信息以及我们会举办安全和隐私保护培训课程加强员工对于保护个人信息重要性的认识</p>
<p>2. 我们会采取一切合理可行的措施确保未收集无关的个人信息我们只会在达成本政策所述目的所需的期限内保留您的个人信息除非需要延长保留期或受到法律的允许</p>
<p>3. 互联网并非绝对安全的环境而且电子邮件即时通讯及与其他用户的交流方式并未加密我们强烈建议您不要通过此类方式发送个人信息请使用复杂密码协助我们保证您的账号安全</p>
<p>4. 互联网环境并非百分之百安全我们将尽力确保或担保您发送给我们的任何信息的安全性如果我们的物理技术或管理防护设施遭到破坏导致信息被非授权访问公开披露篡改或毁坏导致您的合法权益受损我们将承担相应的法律责任</p>
<p>5. 在不幸发生个人信息安全事件后我们将按照法律法规的要求及时向您告知安全事件的基本情况和可能的影响我们已采取或将要采取的处置措施您可自主防范和降低风险的建议对您的补救措施等我们将及时将事件相关情况以邮件信函电话推送通知等方式告知您难以逐一告知个人信息主体时我们会采取合理有效的方式发布公告</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>您的权利</h4>
<p>按照中国相关的法律法规标准以及其他国家地区的通行做法我们保障您对自己的个人信息行使以下权利</p>
<p>1. 访问您的个人信息您有权访问您的个人信息法律法规规定的例外情况除外如果您想行使数据访问权可以通过以下方式自行访问登录本平台进入"个人资料""简历管理"页面即可查看您的个人信息</p>
<p>2. 更正您的个人信息当您发现我们处理的关于您的个人信息有错误时您有权要求我们做出更正您可以通过上述访问方式提出更正申请</p>
<p>3. 删除您的个人信息在以下情形中您可以向我们提出删除个人信息的请求1如果我们处理个人信息的行为违反法律法规2如果我们收集使用您的个人信息却未征得您的同意3如果我们处理个人信息的行为违反了与您的约定4如果您不再使用我们的产品或服务或您注销了账号5如果我们不再为您提供产品或服务</p>
<p>4. 注销账户您随时可注销此前注册的账户您可以通过"设置 - 账号与安全 - 注销账号"进行操作在注销账户之后我们将停止为您提供产品或服务并依据您的要求删除您的个人信息法律法规另有规定的除外</p>
<p>5. 改变您授权同意的范围每个业务功能需要一些基本的个人信息才能得以完成对于额外收集的个人信息的收集和使用您可以随时给予或收回您的授权同意您可以通过关闭相应功能的方式来撤回授权当您收回同意后我们将不再处理相应的个人信息但您收回同意的决定不会影响此前基于您的授权而开展的个人信息处理</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>我们如何处理未成年人的个人信息</h4>
<p>我们的产品和服务主要面向成年人如果没有父母或监护人的同意未成年人不应创建自己的用户账户如果我们发现在未事先获得可证实的父母或法定监护人同意的情况下收集了未成年人的个人信息则会设法尽快删除相关数据对于经父母或法定监护人同意而收集未成年人个人信息的情况我们只会在受到法律允许父母或监护人明确同意或者保护未成年人所必要的情况下使用或公开披露此信息</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>本隐私政策如何更新</h4>
<p>我们可能适时会对本隐私政策进行调整或变更本隐私政策的任何更新将以标注更新时间的方式公布在本平台上除法律法规或监管规定另有强制性规定外经调整或变更的内容一经通知或公布后的7日后生效如您在隐私政策调整或变更后继续使用我们提供的任一服务或访问我们相关网站的我们相信这代表您已充分阅读理解并接受修改后的隐私政策并受其约束</p>
</div>
<div class="settings-dialog__privacy-section">
<h4>如何联系我们</h4>
<p>如果您对本隐私政策有任何疑问意见或建议可以通过以下方式与我们联系发送邮件至 privacy@offerpai.com或通过本平台内的"反馈"功能联系我们一般情况下我们将在15个工作日内回复如果您对我们的回复不满意特别是我们的个人信息处理行为损害了您的合法权益您还可以向网信部门电信主管部门公安部门等监管部门进行投诉或举报或通过向被告住所地有管辖权的法院提起诉讼来寻求解决方案</p>
<p>本隐私政策的最终解释权归本平台所有</p>
<p style="margin-top: 0.16rem; color: #999;">最后更新日期2026年3月1日</p>
</div>
<!-- 加载中 -->
<div v-if="privacyLoading" style="text-align: center; padding: 0.4rem 0; color: #999;">加载中...</div>
<!-- 渲染协议内容 -->
<div v-else class="settings-dialog__privacy-section markdown-body" v-html="privacyHtml"></div>
</div>
</template>
</div>
@@ -244,6 +198,9 @@
<!-- 邀请注册送会员弹窗 -->
<SettingsInviteDialog v-model="showInviteDialog" />
<!-- 协议预览弹窗 -->
<AgreementPreviewDialog v-model="showAgreementDialog" :code="currentAgreementCode" />
</Teleport>
</template>
@@ -251,8 +208,10 @@
import { ref, reactive, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import { logout } from '@/api/auth'
import { fetchMemberStatus, type MemberStatus } from '@/api/member'
import { fetchAgreement } from '@/api/common'
import { timestampToLocalDateTime, timestampDiffDays } from '@/utils/time'
import JobGoalDialog from './JobGoalDialog.vue'
import { resolveRegionName } from '@/utils/region'
@@ -260,6 +219,12 @@ import { resolveIndustryName } from '@/utils/industry'
import { resolveJobCategoryName } from '@/utils/jobCategory'
import SettingsDeleteAccountDialog from './SettingsDeleteAccountDialog.vue'
import SettingsInviteDialog from './SettingsInviteDialog.vue'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
// @ts-ignore
import markdownit from 'markdown-it'
/** markdown-it 实例 — 用于渲染协议内容 */
const md = markdownit({ html: false, breaks: true, linkify: true })
/** 组件 Props — 控制弹窗显示/隐藏,可指定初始 Tab */
const props = defineProps<{ modelValue: boolean; initialTab?: string }>()
@@ -292,9 +257,15 @@ watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
if (val && props.initialTab) {
activeTab.value = props.initialTab
if (props.initialTab === 'privacy') loadPrivacyAgreement()
}
})
/** 监听 Tab 切换 — 切到隐私协议时加载内容 */
watch(activeTab, (tab) => {
if (tab === 'privacy') loadPrivacyAgreement()
})
/** 岗位更新提醒的配置项 */
const reminders = reactive({
instant: true, //
@@ -310,6 +281,40 @@ const showDeleteAccount = ref(false)
/** 邀请注册弹窗显示状态 */
const showInviteDialog = ref(false)
/** 协议预览弹窗显示状态 */
const showAgreementDialog = ref(false)
/** 当前预览的协议码 */
const currentAgreementCode = ref('')
/** 打开协议预览弹窗 */
function openAgreementDialog(code: string) {
currentAgreementCode.value = code
showAgreementDialog.value = true
}
/** 隐私协议内容(Markdown 渲染后的 HTML */
const privacyHtml = ref('')
/** 隐私协议加载状态 */
const privacyLoading = ref(false)
/** 加载隐私协议内容 */
async function loadPrivacyAgreement() {
if (privacyHtml.value) return //
privacyLoading.value = true
try {
const res = await fetchAgreement('hf8375i8')
if (res.data?.content) {
privacyHtml.value = md.render(res.data.content)
} else {
privacyHtml.value = '<p>暂无协议内容</p>'
}
} catch {
privacyHtml.value = '<p>加载失败,请稍后重试</p>'
} finally {
privacyLoading.value = false
}
}
/** 会员状态数据 */
const memberStatus = reactive<MemberStatus>({
isMember: false,
@@ -361,9 +366,9 @@ const intentionRegionNames = computed(() => {
return codes.map((code: string) => resolveRegionName(code))
})
/** 就业类型标签 */
/** 招聘分类标签 */
const intentionEmploymentLabel = computed(() => {
return store.state.jobIntention.employmentType === 1 ? '实习' : '全职'
return formatEmploymentType(store.state.jobIntention.recruitCategory)
})
/** 编辑目标岗位 — 打开求职目标弹窗 */
@@ -375,7 +380,8 @@ const handleEditTarget = () => {
watch(() => props.modelValue, (val) => {
if (val && store.state.isAuthenticated) {
store.dispatch('loadCommonData')
store.dispatch('loadJobIntention')
// loadJobIntentionJobs.vue
// store.dispatch('loadJobIntention')
loadMemberStatus()
}
})
+1 -1
View File
@@ -92,7 +92,7 @@ const inviteCode = computed(() => store.state.userInfo?.inviteCode || '')
/** 邀请链接文案 */
const inviteText = computed(() => {
const code = inviteCode.value
return `https://www.offerpai.com.cn/invite_code=${code}`
return `https://www.offerpai.com.cn?invite_code=${code}`
})
/** 复制链接到剪贴板 */
+48 -6
View File
@@ -52,8 +52,13 @@
:class="{ 'side-nav__message-list-item--active': selectedMessageIdx === idx }"
@click="selectedMessageIdx = idx"
>
<span class="side-nav__message-list-title">{{ msg.title }}</span>
<span v-if="!msg.read" class="side-nav__message-unread-dot"></span>
<div class="dflex wp100 aliite-e fs14">
<div class="">
<span class="side-nav__message-list-title">{{ msg.title }}</span>
<span v-if="!msg.read" class="side-nav__message-unread-dot"></span>
</div>
<div class="fs10 color-8 tar">{{timestampToLocalDateTime(msg.createTime, 'returnDay')}}</div>
</div>
</div>
<!-- 加载中提示 -->
<div v-if="messageLoading" class="side-nav__message-list-loading">加载中...</div>
@@ -64,7 +69,10 @@
<div class="side-nav__message-detail">
<template v-if="currentMessage">
<div class="side-nav__message-detail-title">{{ currentMessage.title }}</div>
<div class="side-nav__message-detail-content">{{ currentMessage.content }}</div>
<div class="dflex aliite-e">
<div class="side-nav__message-detail-content">{{ currentMessage.content }}</div>
<div class="fs12 color-8 w140 tar">{{timestampToLocalDateTime(currentMessage.createTime, 'returnSecond')}}</div>
</div>
</template>
<div v-else class="side-nav__message-detail-empty">请选择一条消息查看</div>
</div>
@@ -115,6 +123,10 @@
<SettingsDialog v-model="showSettingsDialog" :initial-tab="store.state.settingsTab" />
<!-- 邀请注册送会员弹窗 -->
<SettingsInviteDialog v-model="showShareDialog" />
<!-- 会员权限拦截弹窗 -->
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
</div>
</template>
@@ -123,8 +135,11 @@ import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import SettingsDialog from '@/components/SettingsDialog.vue'
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import { checkLogin } from '@/api/auth'
import { fetchMessageList, fetchUnreadCount, markMessageRead } from '@/api/message'
import { timestampToLocalDateTime } from '@/utils/time'
import {userFeedback} from '@/api/setting'
import type { MessageDto } from '@/api/message'
import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png'
@@ -146,6 +161,10 @@ interface MenuItem {
iconImg: string
label: string
badge?: string
/** 排序字段 */
sortOrder: number
/** 是否有使用权限 */
accessible: boolean
}
/**
@@ -164,15 +183,17 @@ const iconMap: Record<string, string> = {
/**
* 静态菜单 不需要登录就能看到的导航项比如"职位"
* 这些菜单始终显示不依赖后端返回
* sortOrder 设为 0 确保在未获取到后端数据时也能正常显示
*/
const staticMenus: MenuItem[] = [
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位' },
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位', sortOrder: 0, accessible: true },
]
/**
* 动态菜单 store.state.dynamicMenus后端返回的数据转换而来
* 登录后才会有数据登出后自动清空
* 过滤掉 position === 'footer' 的项"设置"它们显示在底部区域
* sortOrder 排序routeNamemeta.label作为显示名称
*/
const dynamicMenuItems = computed<MenuItem[]>(() => {
return store.state.dynamicMenus
@@ -183,19 +204,30 @@ const dynamicMenuItems = computed<MenuItem[]>(() => {
iconImg: iconMap[item.meta?.icon] || '',
label: item.meta?.label || item.name,
badge: item.meta?.badge,
sortOrder: item.sortOrder ?? 99,
accessible: item.accessible !== false, // true
}))
.sort((a: MenuItem, b: MenuItem) => a.sortOrder - b.sortOrder)
})
/**
* 最终渲染的主菜单 = 静态菜单 + 动态菜单
* 最终渲染的主菜单 = 静态菜单 + 动态菜单 sortOrder 统一排序
* 如果后端返回了 Jobs 的数据用后端数据覆盖静态菜单的 sortOrder
*/
const mainMenus = computed<MenuItem[]>(() => {
return [...staticMenus, ...dynamicMenuItems.value]
// Jobs使
const dynamicNames = dynamicMenuItems.value.map(m => m.name)
const filteredStatic = staticMenus.filter(s => !dynamicNames.includes(s.name))
return [...filteredStatic, ...dynamicMenuItems.value].sort((a, b) => a.sortOrder - b.sortOrder)
})
const showShareDialog = ref(false)
const showMessageDialog = ref(false)
const showFeedbackDialog = ref(false)
/** 会员权限拦截弹窗 */
const showMemberAccessDialog = ref(false)
/** 会员购买弹窗 */
const showMemberDialog = ref(false)
const showSettingsDialog = computed({
get: () => store.state.showSettings,
set: (val: boolean) => store.commit('SET_SHOW_SETTINGS', val),
@@ -411,15 +443,25 @@ async function handleSettingsNav() {
/**
* 导航点击处理
* - 静态页面Jobs直接跳转
* - accessible false 的菜单 弹出会员权限拦截弹窗
* - 动态页面通过 checkLogin 接口验证未登录则弹登录框
*/
const staticNames = staticMenus.map(m => m.name)
async function handleNav(item: MenuItem) {
//
if (!item.accessible) {
showMemberAccessDialog.value = true
return
}
// Jobs
if (staticNames.includes(item.name)) {
router.push(item.path)
return
}
//
try {
const res = await checkLogin()
if (res.code === '0' && res.data === true) {
@@ -0,0 +1,96 @@
<template>
<!-- 协议预览弹窗 通过 Teleport 挂载到 body -->
<Teleport to="body">
<!-- 遮罩层 -->
<div v-if="modelValue" class="agreement-preview-overlay" @click="$emit('update:modelValue', false)">
<!-- 弹窗主体 -->
<div class="agreement-preview-dialog" @click.stop>
<!-- 顶部标题栏 -->
<div class="agreement-preview-dialog__header">
<h2 class="agreement-preview-dialog__title">{{ agreementName || '协议内容' }}</h2>
<span class="agreement-preview-dialog__close" @click="$emit('update:modelValue', false)"></span>
</div>
<!-- 内容区域 可滚动 -->
<div class="agreement-preview-dialog__body">
<!-- 加载中 -->
<div v-if="loading" class="agreement-preview-dialog__loading">加载中...</div>
<!-- 渲染协议 Markdown 内容 -->
<div v-else class="agreement-preview-dialog__content" v-html="contentHtml"></div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { fetchAgreement } from '@/api/common'
// @ts-ignore
import markdownit from 'markdown-it'
/** markdown-it 实例 */
const md = markdownit({ html: false, breaks: true, linkify: true })
/** 组件属性 */
const props = defineProps<{
/** 控制弹窗显示/隐藏 */
modelValue: boolean
/** 协议码 */
code: string
}>()
/** 组件事件 */
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
/** 协议名称 */
const agreementName = ref('')
/** 渲染后的 HTML 内容 */
const contentHtml = ref('')
/** 加载状态 */
const loading = ref(false)
/** 已加载过的协议缓存(key: code, value: { name, html } */
const cache = new Map<string, { name: string; html: string }>()
/** 加载协议内容 */
async function loadAgreement() {
const code = props.code
if (!code) return
// 使
const cached = cache.get(code)
if (cached) {
agreementName.value = cached.name
contentHtml.value = cached.html
return
}
loading.value = true
contentHtml.value = ''
try {
const res = await fetchAgreement(code)
if (res.data?.content) {
const name = res.data.agreementName || '协议内容'
const html = md.render(res.data.content)
agreementName.value = name
contentHtml.value = html
cache.set(code, { name, html })
} else {
contentHtml.value = '<p>暂无协议内容</p>'
}
} catch {
contentHtml.value = '<p>加载失败,请稍后重试</p>'
} finally {
loading.value = false
}
}
/** 监听弹窗打开 — 触发加载(使用 nextTick 确保 code 已更新) */
watch(() => props.modelValue, (val) => {
if (val) {
nextTick(() => loadAgreement())
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
})
</script>
+39 -16
View File
@@ -1,7 +1,7 @@
/**
* REM
* 1920px 稿1rem = 100px
* 使 transform scale
* 使 transform scale CSS --vh 使
*/
import type { Plugin } from 'vue'
@@ -10,10 +10,10 @@ const remAdaptPlugin: Plugin = {
const docEl = document.documentElement
const designWidth = 1920
// 检测是否为移动端
// 检测是否为移动端/小屏
const isMobile = (): boolean => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|| window.innerWidth < 1050
|| window.innerWidth < 1200
}
// 重新计算缩放
@@ -33,16 +33,48 @@ const remAdaptPlugin: Plugin = {
body.style.width = designWidth + 'px'
body.style.transform = `scale(${scale})`
body.style.transformOrigin = 'top left'
// 设置 html 高度,让滚动正常工作
docEl.style.height = (body.scrollHeight * scale) + 'px'
docEl.style.overflow = 'auto'
// 屏幕越小 scale 越小,适当增大 rem 基准让字体视觉上更大
// 首页和其他页面使用不同的缩放系数
const isHomePage = window.location.pathname === '/' || window.location.pathname === '/index.html'
const homeMultiplier = 0.6 // 首页字体放大系数,可单独调整
const otherMultiplier = 2.0 // 其他页面字体放大系数
const multiplier = isHomePage ? homeMultiplier : otherMultiplier
const homeMaxFontSize = 180 // 首页最大 fontSize 上限
const otherMaxFontSize = 630 // 其他页面最大 fontSize 上限
const maxFontSize = isHomePage ? homeMaxFontSize : otherMaxFontSize
const fontScale = 1 + (1 - scale) * multiplier
const fontSize = Math.min(100 * fontScale, maxFontSize)
docEl.style.fontSize = fontSize + 'px'
// 动态计算聊天面板宽度:屏幕越窄,宽度从 4rem 缩到 1.0rem
// 用 scale 直接驱动(scale=0.625 时满宽4remscale 越小宽度越小)
const chatWidth = 1.0 + (4.0 - 1.0) * scale // scale=1→4rem, scale=0.5→2.5rem, scale=0.3→1.9rem
docEl.style.setProperty('--chat-width', Math.max(chatWidth, 1.0).toFixed(2) + 'rem')
// body 高度 = 视口高度 / scale,使其缩放后刚好等于视口高度
// overflow-y: auto 让内容在 body 内部滚动
const realViewHeight = window.innerHeight / scale
body.style.height = realViewHeight + 'px'
body.style.overflowY = 'auto'
body.style.minHeight = ''
// 设置 CSS 变量让页面组件可以使用真实视口高度(设计稿尺度下)
docEl.style.setProperty('--app-height', realViewHeight + 'px')
// html 固定视口高度 + overflow hidden,裁剪 body 缩放后的布局占位
docEl.style.height = '100vh'
docEl.style.overflow = 'hidden'
} else {
// PC 端:移除缩放
// PC 端:移除缩放相关样式
body.style.width = ''
body.style.height = ''
body.style.minHeight = ''
body.style.overflowY = ''
body.style.transform = ''
body.style.transformOrigin = ''
docEl.style.height = ''
docEl.style.overflow = ''
docEl.style.setProperty('--app-height', '100vh')
docEl.style.setProperty('--chat-width', '4rem')
}
}
@@ -56,15 +88,6 @@ const remAdaptPlugin: Plugin = {
} else {
window.addEventListener('load', recalc)
}
// 监听 DOM 变化,更新高度
const observer = new MutationObserver(() => {
if (isMobile()) {
const scale = docEl.clientWidth / designWidth
docEl.style.height = (document.body.scrollHeight * scale) + 'px'
}
})
observer.observe(document.body, { childList: true, subtree: true })
}
}
+7
View File
@@ -10,6 +10,7 @@ import type { MenuItemRaw } from '@/api/menu'
* key
*/
const componentMap: Record<string, () => Promise<any>> = {
'Jobs': () => import('@/views/Jobs.vue'),
'Agent': () => import('@/views/Agent.vue'),
'Profile': () => import('@/views/Profile.vue'),
'Resume': () => import('@/views/Resume.vue'),
@@ -19,11 +20,17 @@ const componentMap: Record<string, () => Promise<any>> = {
* vue-router RouteRecordRaw
*
* component
* /jobs
*/
const STATIC_PATHS = ['/', '/jobs', '/resume/:id', '/jobs/:id']
export function buildDynamicRoutes(menus: MenuItemRaw[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
for (const item of menus) {
// 跳过已在静态路由中注册的路径
if (STATIC_PATHS.includes(item.path)) continue
const comp = componentMap[item.component]
if (!comp) {
console.warn(`[dynamicRoutes] 组件映射表中找不到 "${item.component}",已跳过`)
+22 -8
View File
@@ -8,7 +8,13 @@ import { checkLogin } from '@/api/auth'
*/
const staticRoutes: RouteRecordRaw[] = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/jobs', name: 'Jobs', component: () => import('@/views/Jobs.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{
path: '/jobs',
name: 'Jobs',
component: () => import('@/views/Jobs.vue'),
meta: { requiresAuth: true },
},
{
path: '/resume/:id',
name: 'ResumeDetail',
@@ -37,10 +43,11 @@ const CHECK_INTERVAL = 5 * 60 * 1000 // 5 分钟
*
*
* 1.
* 2. checkLogin Cookie
* 2.
* 3. checkLogin Cookie
* - isAuthenticated = true
* - isAuthenticated = false
* 3. 5
* 4. 5
*/
router.beforeEach(async (to, _from, next) => {
// 动态路由只需加载一次,与登录状态无关
@@ -55,6 +62,15 @@ router.beforeEach(async (to, _from, next) => {
return
}
// 每次页面切换时静默刷新路由菜单数据(更新权限状态),不阻塞导航
store.dispatch('refreshDynamicMenus')
// 已登录用户访问登录页,直接跳转首页
if (to.name === 'Login' && store.state.isAuthenticated) {
next({ name: 'Home' })
return
}
// 需要鉴权的路由,每次都通过接口校验登录状态
if (to.meta?.requiresAuth) {
try {
@@ -65,16 +81,14 @@ router.beforeEach(async (to, _from, next) => {
store.commit('SET_AUTHENTICATED', true)
next()
} else {
// 未登录或 Cookie 失效
// 未登录或 Cookie 失效 — 跳转登录页
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', to.fullPath)
next(false)
next({ name: 'Login', query: { redirect: to.fullPath } })
}
} catch {
// 请求异常(网络错误等),也视为未登录
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', to.fullPath)
next(false)
next({ name: 'Login', query: { redirect: to.fullPath } })
}
return
}
+47 -3
View File
@@ -10,6 +10,24 @@ import type { JobIntention } from '@/api/jobs'
import { fetchUserInfo } from '@/api/auth'
import type { UserInfo } from '@/api/auth'
/** 招聘分类选项:label → 接口参数 recruitCategory */
export const JOB_TYPE_OPTIONS: { label: string; value: number }[] = [
{ 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] ?? ''
}
/** 职位列表页缓存数据(从详情页返回时恢复用) */
export interface JobListCache {
/** 缓存的职位列表 */
@@ -93,6 +111,12 @@ export interface RootState {
*/
showSettings: boolean
settingsTab: string
/**
* URL invite_code
*
*/
inviteCode: string
}
export default createStore<RootState>({
@@ -112,11 +136,13 @@ export default createStore<RootState>({
categoryIds: [],
regionCodes: [],
industryIds: [],
employmentType: 0,
employmentType: null,
recruitCategory: null,
},
userInfo: null,
showSettings: false,
settingsTab: 'account',
inviteCode: '',
},
getters: {
getAppName: (state) => state.appName,
@@ -165,7 +191,8 @@ export default createStore<RootState>({
categoryIds: data.categoryIds ?? [],
regionCodes: data.regionCodes ?? [],
industryIds: data.industryIds ?? [],
employmentType: data.employmentType ?? 0,
employmentType: data.employmentType ?? null,
recruitCategory: data.recruitCategory ?? null,
}
},
SET_USER_INFO(state, data: UserInfo | null) {
@@ -177,6 +204,9 @@ export default createStore<RootState>({
SET_SETTINGS_TAB(state, tab: string) {
state.settingsTab = tab
},
SET_INVITE_CODE(state, code: string) {
state.inviteCode = code
},
},
actions: {
updateAppName({ commit }, name: string) {
@@ -219,6 +249,19 @@ export default createStore<RootState>({
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
*
@@ -234,7 +277,8 @@ export default createStore<RootState>({
categoryIds: [],
regionCodes: [],
industryIds: [],
employmentType: 0,
employmentType: null,
recruitCategory: null,
})
},
-14
View File
@@ -1,14 +0,0 @@
/**
* document.cookie name
*/
export function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
/**
* Cookie Token
*/
export function isLoggedIn(): boolean {
return !!getCookie('Token')
}
+3 -2
View File
@@ -1,6 +1,7 @@
import axios from 'axios'
import type { AxiosResponse } from 'axios'
import store from '@/stores'
import router from '@/router'
/**
* JSON
@@ -39,9 +40,9 @@ service.interceptors.response.use(
(error) => {
const status = error.response?.status
if (status === 401) {
// 同步重置前端登录状态,弹出登录
// 同步重置前端登录状态,跳转登录
store.commit('SET_AUTHENTICATED', false)
store.dispatch('openLogin', window.location.pathname)
router.push({ name: 'Login', query: { redirect: window.location.pathname } })
ElMessage.error('登录已过期,请重新登录')
} else {
ElMessage.error(error.response?.data?.msg || '请求失败')
+264
View File
@@ -283,3 +283,267 @@ export function generateResumeWordFile(element: HTMLElement, fileName: string):
const blob = new Blob([fullHtml], { type: 'application/msword' })
return new File([blob], `${fileName}.doc`, { type: 'application/msword' })
}
// ==================== 定制简历本地缓存(IndexedDB ====================
/**
*
*
* PDF不直接上传到OSS
* Blob IndexedDB MB localStorage 5~10MB
*
*
* - localStorage key = "CUSTOM_RESUME_CACHE_INDEX"
* CachedResumeRecord[] JSON
* userIdIDjobIdIDfileNameID文件名
* storageKey IndexedDB keylocalDateTime
*
* - IndexedDB = "ResumeFileCache" = "files"
* storageKey PDF Blob Base64
*
* 使
* 1. cacheResumePdfToLocal(element, userId, jobId) PDF并缓存到IndexedDB
* 2. getCachedResumeRecord(userId, jobId) ID+ID获取缓存记录
* 3. getCachedResumeFile(storageKey) storageKey IndexedDB获取 File
* 4. clearExpiredResumeCache(maxAgeDays)
*
*
* - IndexedDB Blob Base64
* - MB~GB级 localStorage
* - Blob new File([blob], name) 使
*/
/** 定制简历缓存索引记录 */
export interface CachedResumeRecord {
/** 系统用户ID */
userId: string
/** 对应岗位ID */
jobId: string
/** 缓存的简历文件名(雪花ID格式,不含扩展名) */
fileName: string
/** 简历文件在 IndexedDB 中的存储 key */
storageKey: string
/** 存储时间(ISO 8601 格式) */
localDateTime: string
}
/** 缓存索引在 localStorage 中的 key(仅存索引JSON,体积极小) */
const CACHE_INDEX_KEY = 'CUSTOM_RESUME_CACHE_INDEX'
/** IndexedDB 数据库名 */
const IDB_NAME = 'ResumeFileCache'
/** IndexedDB 对象仓库名 */
const IDB_STORE = 'files'
/** IndexedDB 版本号 */
const IDB_VERSION = 1
/**
* IndexedDB
* @returns IDBDatabase
*/
function openResumeDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(IDB_NAME, IDB_VERSION)
request.onupgradeneeded = () => {
const db = request.result
// 创建对象仓库(如果不存在)
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE)
}
}
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
}
/**
* IndexedDB Blob
* @param key
* @param blob Blob
*/
async function idbPut(key: string, blob: Blob): Promise<void> {
const db = await openResumeDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite')
const store = tx.objectStore(IDB_STORE)
const request = store.put(blob, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
tx.oncomplete = () => db.close()
})
}
/**
* IndexedDB Blob
* @param key
* @returns Blob null
*/
async function idbGet(key: string): Promise<Blob | null> {
const db = await openResumeDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly')
const store = tx.objectStore(IDB_STORE)
const request = store.get(key)
request.onsuccess = () => resolve(request.result || null)
request.onerror = () => reject(request.error)
tx.oncomplete = () => db.close()
})
}
/**
* IndexedDB key
* @param key
*/
async function idbDelete(key: string): Promise<void> {
const db = await openResumeDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite')
const store = tx.objectStore(IDB_STORE)
const request = store.delete(key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
tx.oncomplete = () => db.close()
})
}
/**
* ID +
* @returns ID字符串
*/
function generateSnowflakeId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 10)
return `${timestamp}${random}`
}
/**
* localStorage
* @returns
*/
function getCacheIndex(): CachedResumeRecord[] {
try {
const raw = localStorage.getItem(CACHE_INDEX_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
/**
* localStorage
* @param records
*/
function saveCacheIndex(records: CachedResumeRecord[]) {
localStorage.setItem(CACHE_INDEX_KEY, JSON.stringify(records))
}
/**
* PDF生成并缓存到浏览器 IndexedDB
* @param element DOM元素
* @param userId ID
* @param jobId ID
* @returns storageKey null
*/
export async function cacheResumePdfToLocal(
element: HTMLElement,
userId: string,
jobId: string,
): Promise<CachedResumeRecord | null> {
try {
// 生成雪花ID作为文件名
const snowflakeId = generateSnowflakeId()
const storageKey = `resume_${snowflakeId}`
// 生成PDF Blob
const options = {
margin: [10, 10, 10, 10] as [number, number, number, number],
filename: `${snowflakeId}.pdf`,
image: { type: 'jpeg' as const, quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' as const },
pagebreak: { mode: ['css', 'legacy'] },
}
const blob: Blob = await html2pdf().set(options).from(element).outputPdf('blob')
// 将 Blob 直接存入 IndexedDB(无需 Base64 编码)
await idbPut(storageKey, blob)
// 构建缓存记录
const record: CachedResumeRecord = {
userId,
jobId,
fileName: snowflakeId,
storageKey,
localDateTime: new Date().toISOString(),
}
// 更新索引(如果同一用户+岗位已有记录,先移除旧的)
const index = getCacheIndex()
const existIdx = index.findIndex(r => r.userId === userId && r.jobId === jobId)
if (existIdx !== -1) {
// 删除旧的 IndexedDB 文件缓存
await idbDelete(index[existIdx].storageKey)
index.splice(existIdx, 1)
}
index.push(record)
saveCacheIndex(index)
return record
} catch (e) {
console.error('[resumeExport] 缓存简历到IndexedDB失败', e)
return null
}
}
/**
* ID和岗位ID查询缓存记录
* @param userId ID
* @param jobId ID
* @returns null
*/
export function getCachedResumeRecord(userId: string, jobId: string): CachedResumeRecord | null {
const index = getCacheIndex()
return index.find(r => r.userId === userId && r.jobId === jobId) || null
}
/**
* storageKey IndexedDB File
* @param storageKey storageKey
* @param fileName storageKey ID
* @returns PDF格式的 File null
*/
export async function getCachedResumeFile(storageKey: string, fileName?: string): Promise<File | null> {
try {
const blob = await idbGet(storageKey)
if (!blob) return null
const name = fileName || storageKey.replace('resume_', '')
return new File([blob], `${name}.pdf`, { type: 'application/pdf' })
} catch {
return null
}
}
/**
*
* @param maxAgeDays 7
*/
export async function clearExpiredResumeCache(maxAgeDays = 7) {
const index = getCacheIndex()
const now = Date.now()
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000
const validRecords: CachedResumeRecord[] = []
for (const record of index) {
const recordTime = new Date(record.localDateTime).getTime()
if (now - recordTime > maxAgeMs) {
// 过期,删除 IndexedDB 中的文件缓存
await idbDelete(record.storageKey)
} else {
validRecords.push(record)
}
}
saveCacheIndex(validRecords)
}
+3 -3
View File
@@ -28,7 +28,7 @@ export function timestampToLocalDateTime(timestamp: number | null | undefined, p
const m = String(date.getMonth() + 1).padStart(2, '0')
if (precision === 'returnMonth') return `${y}-${m}`
const d = String(date.getDate()).padStart(2, '0')
if (precision === 'returnDay') return `${y}-${m}-${d}`
if (precision === 'returnDay') return `${y}/${m}/${d}`
const h = String(date.getHours()).padStart(2, '0')
if (precision === 'returnHour') return `${y}-${m}-${d} ${h}`
const min = String(date.getMinutes()).padStart(2, '0')
@@ -96,8 +96,8 @@ const TIME_EVENT_CACHE_KEY = 'local_time_event_cache'
* ID
*
* member_status_query
*
*
* ai_agent_remind_first AI助手跳转提醒时间记录
* ai_agent_remind_second AI助手跳转提醒时间记录
*
*
*/
+65 -17
View File
@@ -152,7 +152,7 @@
</div>
<!-- 模式3简历预览 -->
<div v-else-if="rightPanelMode === 'resume'" class="agent-main__right-resume">
<JobResumeTemplate :resume-data="applyResumeData" />
<JobResumeTemplate ref="applyResumeTemplateRef" :resume-data="applyResumeData" />
</div>
<!-- 模式4岗位预览 -->
<AgentJobPreviewPanel
@@ -179,7 +179,6 @@
v-else-if="rightPanelMode === 'settings'"
@close="handleCloseSettingsPanel"
/>
<!-- 模式7AI助手设置 -->
</div>
</div>
</div>
@@ -209,6 +208,8 @@ import { fetchAgentTaskList } from '@/api/jobs'
import type { JobListItem } from '@/api/jobs'
import { fetchResumeList } from '@/api/resume'
import { getIntentionCategoryNames, getIntentionRegionNames, getIntentionIndustryNames } from '@/utils/intention'
import { cacheResumePdfToLocal, getCachedResumeRecord, getCachedResumeFile } from '@/utils/resumeExport'
import store from '@/stores'
import AiThinkingIndicator from '@/components/tools/AiThinkingIndicator.vue'
import JobGoalDialog from '@/components/JobGoalDialog.vue'
@@ -435,21 +436,18 @@ async function loadDefaultResumeId() {
/**
* chatMessages 组装为 AI 对话接口需要的 history 格式
* user / assistant content chatMessages.content
* recommend / apply_progress content chatMessages.extra
* 只保留 user assistant 类型的消息recommend / apply_progress 不纳入
* 最终只取最近的 10 从数组末尾往上取
*/
function buildChatHistory(): AgentChatHistoryItem[] {
return chatMessages.value.map(msg => {
const role = (msg.type === 'user') ? 'user' : 'assistant'
let content = ''
if (msg.type === 'user' || msg.type === 'assistant') {
content = msg.content || ''
} else {
/* recommend / apply_progress — 用 extra 作为内容 */
content = msg.extra || msg.content || ''
}
return { role, content }
})
const filtered = chatMessages.value
.filter(msg => msg.type === 'user' || msg.type === 'assistant')
.map(msg => ({
role: msg.type as 'user' | 'assistant',
content: msg.content || ''
}))
/* 只取最近 10 条 */
return filtered.slice(-10)
}
/**
@@ -796,6 +794,9 @@ const generateProgress = ref(0)
/** 当前投递流程的简历数据 */
const applyResumeData = ref<ResumeTemplateData>({} as ResumeTemplateData)
/** 右侧面板简历模板组件引用(用于生成PDF上传) */
const applyResumeTemplateRef = ref<InstanceType<typeof JobResumeTemplate> | null>(null)
/** 当前投递流程的简历名称 */
const applyResumeName = ref('')
@@ -1098,13 +1099,60 @@ function handleCancelApply(msg: AgentChatMessage) {
}
/** 第2步:确认简历 */
function handleConfirmResumeStep(msg: AgentChatMessage) {
async function handleConfirmResumeStep(msg: AgentChatMessage) {
try {
const extraData = JSON.parse(msg.extra)
// 23
extraData.step = 3
msg.extra = JSON.stringify(extraData)
} catch { /* 忽略 */ }
// PDFlocalStorage
await nextTick()
const element = applyResumeTemplateRef.value?.resumeRef
if (element) {
const userId = String(store.state.userInfo?.id || '')
const jobId = String(extraData.jobInfo?.id || '')
if (userId && jobId) {
const cacheRecord = await cacheResumePdfToLocal(element, userId, jobId)
if (cacheRecord) {
// storageKey extraData便
extraData.resumeCacheKey = cacheRecord.storageKey
msg.extra = JSON.stringify(extraData)
//userIdjobIdstorageKeystorageKey()
// const testRecord = getCachedResumeRecord(userId, jobId)
// if (testRecord) {
// const testFile = await getCachedResumeFile(testRecord.storageKey, '')
// if (testFile) {
// const url = URL.createObjectURL(testFile)
// const a = document.createElement('a')
// a.href = url
// a.download = testFile.name
// document.body.appendChild(a)
// a.click()
// document.body.removeChild(a)
// URL.revokeObjectURL(url)
// console.log('[Agent] IndexedDB', testRecord)
// }
// }
}
}
}
// // localStorage
// const element = applyResumeTemplateRef.value?.resumeRef
// if (element) {
// const fileName = applyResumeName.value || ''
// const pdfFile = await generateResumePdfFile(element, fileName)
// const uploadRes = await uploadFileToOss(pdfFile, 'ResumeFile')
// if (uploadRes.code === '0' && uploadRes.data) {
// extraData.resumeFileUrl = uploadRes.data.downloadUrl
// msg.extra = JSON.stringify(extraData)
// }
// }
} catch (e) {
console.error('[Agent] 简历PDF缓存失败', e)
}
}
/** 第3步:跳过投递 */
+204 -74
View File
@@ -6,8 +6,8 @@
<div class="home-hero__orb home-hero__orb--top"></div>
<div class="home-hero__orb home-hero__orb--bottom"></div>
<!-- 顶部导航栏 -->
<header class="home-nav mt32">
<div class="home-nav__inner">
<header class="home-nav mt0">
<div class="home-nav__inner" :class="{ 'home-nav__inner--scorlled': isScrolled }">
<div class="home-nav__logo">
<!-- 导航栏Logo图片 -->
<img src="@/assets/images/logo.png" alt="Offer派" class="home-nav__logo-img" />
@@ -19,17 +19,21 @@
<!-- 已登录时显示进入平台按钮 -->
<button v-else class="home-nav__btn" @click="router.push('/jobs')">进入平台</button>
</div>
</div>
</header>
<div class="home-hero__inner">
<div class="home-hero__left">
<h1 class="home-hero__title">Offer派<br/>收offer就是快!</h1>
<p class="home-hero__desc">智能匹配职位自动填写申请量身定制简历推荐内部人脉不到1分钟统统搞定</p>
<p class="home-hero__desc">智能匹配职位自动填写申请量身定制简历推荐内部人脉<br/>不到1分钟统统搞定</p>
<button class="home-hero__cta" @click="router.push('/jobs')">免费体验</button>
</div>
<!-- 右侧视频展示区 -->
<div class="home-hero__right">
<div class="home-hero__right-top">
<div></div>
<div></div>
<div></div>
</div>
<video
class="home-hero__video"
src="https://jsxq-image-static.oss-cn-shenzhen.aliyuncs.com/aiJob/find/open-intention-video.mp4"
@@ -52,15 +56,15 @@
<div class="home-stats__cards">
<article class="stat-card">
<div class="stat-card__num">第一</div>
<p class="stat-card__label">80%大学生求职首选</p>
<p class="stat-card__label">84.5%大学生求职首选</p>
</article>
<article class="stat-card">
<div class="stat-card__num">3</div>
<div class="stat-card__num">10</div>
<p class="stat-card__label">面试邀约率提升</p>
</article>
<article class="stat-card">
<div class="stat-card__num">82%</div>
<p class="stat-card__label">用户成功拿到offer</p>
<p class="stat-card__label">节约82%校招求职时间</p>
</article>
</div>
</div>
@@ -68,24 +72,35 @@
<!-- Jobs Showcase Section -->
<section class="home-jobs-showcase">
<!-- 背景色块 顶部小块 -->
<div class="home-jobs-showcase__orb home-jobs-showcase__orb--top"></div>
<!-- 背景色块 底部全宽渐变 -->
<div class="home-jobs-showcase__orb home-jobs-showcase__orb--bottom"></div>
<div class="home-jobs-showcase__inner">
<h2>海量优质校招岗位尽在Offer派</h2>
<p class="home-jobs-showcase__sub">实时汇集海量超10000+名校校招职位</p>
<div class="home-jobs-showcase__box">
<div class="home-jobs-showcase__stats">
<div class="showcase-stat">
<div class="showcase-stat__num"><span class="accent">40</span><span class="accent">+</span></div>
<div class="showcase-stat__num"><span><span class="accent">40</span><span class="accent"></span></span><span class="fs32">+</span></div>
<div class="showcase-stat__label">岗位总数</div>
</div>
<div class="showcase-stat">
<div class="showcase-stat__num"><span class="accent">3120</span>个岗位</div>
<div class="showcase-stat__num"><span class="accent">{{ dailyJobCount }}</span><span class="fs32"></span></div>
<div class="showcase-stat__label">今日更新</div>
</div>
</div>
<div class="home-jobs-showcase__scroll">
<div class="job-ticker" v-for="(job, i) in tickerJobs" :key="i">
<span class="job-ticker__company">{{ job.company }}·{{ job.time }}</span>
<span class="job-ticker__title">{{ job.title }}</span>
<!-- 无缝轮播两组相同内容拼接动画滚完第一组后无缝衔接 -->
<div class="ticker-track">
<div class="job-ticker" v-for="(job, i) in tickerJobs" :key="'a' + i">
<span class="job-ticker__company">{{ job.company }}·{{ job.time }}</span>
<span class="job-ticker__title">{{ job.title }}</span>
</div>
<div class="job-ticker" v-for="(job, i) in tickerJobs" :key="'b' + i">
<span class="job-ticker__company">{{ job.company }}·{{ job.time }}</span>
<span class="job-ticker__title">{{ job.title }}</span>
</div>
</div>
</div>
</div>
@@ -96,8 +111,8 @@
<section class="home-feature">
<div class="home-feature__inner">
<div class="home-feature__text">
<h2>个性化<br/>岗位匹配</h2>
<p>第一时间发现真正适合你的岗位精准匹配你的真实技能杜绝虚假信息</p>
<h2>个性化智能<br/>岗位匹配</h2>
<p>第一时间发现真正适合你的岗位精准匹配你的真<br/>实技能杜绝虚假信息</p>
<button class="home-feature__btn" @click="router.push('/jobs')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2"/><circle cx="10" cy="10" r="4" stroke="currentColor" stroke-width="2"/><circle cx="10" cy="10" r="1.5" fill="currentColor"/></svg>
<span>立即匹配</span>
@@ -106,7 +121,7 @@
<div class="home-feature__visual home-feature__visual--match">
<div class="feature-match-card">
<div class="feature-match-card__header">
<div class="feature-match-card__avatar"></div>
<div class="feature-match-card__avatar"><img class="wp100 hp100" src="@/assets/images/home/alex-avatar.png" alt=""></div>
<div class="feature-match-card__info">
<h4>求职者 Alex</h4>
<div class="feature-match-card__tags">
@@ -130,9 +145,9 @@
<div class="home-feature__inner">
<div class="home-feature__text">
<h2>校招一键<br/>自动网申</h2>
<p>每日向数百岗位一键投递覆盖各大企业网申系统告别重复填写节省 80% 的宝贵时间</p>
<p>每日向数百岗位一键投递覆盖各大企业网申系<br/>告别重复填写节省 82% 校招求职时间</p>
<button class="home-feature__btn" @click="router.push('/jobs')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 10l2-8h10l2 8-7 7-7-7z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<img class="w20 h20" src="@/assets/images/home/blue-flash-icon-01.png" alt="">
<span>开启自动网申</span>
</button>
</div>
@@ -148,14 +163,14 @@
</div>
<div class="feature-apply-card__list">
<div class="apply-item">
<div class="apply-item__icon"></div>
<div class="apply-item__icon"><img class="wp100 hp100" src="@/assets/images/home/right-icon-01.png" alt=""></div>
<div class="apply-item__info">
<p class="apply-item__title">腾讯科技 - 校招投递成功</p>
<p class="apply-item__sub">耗时 0.8s · 自动识别填写完毕</p>
</div>
</div>
<div class="apply-item apply-item--active">
<div class="apply-item__icon apply-item__icon--pulse">📋</div>
<div class="apply-item__icon apply-item__icon--pulse"><img class="wp100 hp100" src="@/assets/images/home/load-icon-01.png" alt=""></div>
<div class="apply-item__info">
<p class="apply-item__title">京东校招 - 自动填写中...</p>
<p class="apply-item__sub">进度 65%</p>
@@ -173,9 +188,9 @@
<div class="home-feature__inner">
<div class="home-feature__text">
<h2>岗位定制简历</h2>
<p>10秒内生成针对特定岗位优化的专业简历通过ATS系统突出你的核心优势</p>
<p>10秒内生成针对特定岗位优化的专业简历通过<br/>ATS系统突出你的核心优势</p>
<button class="home-feature__btn" @click="router.push('/jobs')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M4 2h8l4 4v12H4V2z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M12 2v4h4" stroke="currentColor" stroke-width="2"/><path d="M4 11h8" stroke="currentColor" stroke-width="2"/></svg>
<img class="w20 h20" src="@/assets/images/home/blue-file-icon-01.png" alt="">
<span>优化我的简历</span>
</button>
</div>
@@ -199,7 +214,7 @@
</div>
<div class="feature-resume-card__badge">
<svg width="15" height="15" viewBox="0 0 15 15" fill="none"><path d="M3 2h7l3 3v8H3V2z" stroke="#4FC2C9" stroke-width="1.2"/><path d="M6 7h3" stroke="#4FC2C9" stroke-width="1.2"/></svg>
<img class="w15 h15" src="@/assets/images/home/blue-shield-icon-01.png" alt="">
<span>ATS OPTIMIZED</span>
</div>
</div>
@@ -212,9 +227,9 @@
<div class="home-feature__inner">
<div class="home-feature__text">
<h2>名企内推<br/>人脉直通</h2>
<p>实时获取名企最新内推信息自动填写网申内推码简历更快到达HR</p>
<p>实时获取名企最新内推信息自动填写网申内推<br/>简历更快到达HR</p>
<button class="home-feature__btn" @click="router.push('/jobs')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M3 3h10l4 4v10H3V3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><path d="M6 12l2 2 4-4" stroke="currentColor" stroke-width="2"/></svg>
<img class="w20 h20" src="@/assets/images/home/blue-horn-icon-01.png" alt="">
<span>立即投递</span>
</button>
</div>
@@ -239,7 +254,7 @@
<h2>24h全天候<br/>AI求职助手</h2>
<p>随时提供求职指导从岗位筛选到面试技巧你的专属职业规划顾问</p>
<button class="home-feature__btn" @click="router.push('/jobs')">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M2 3h12v10H6l-4 4V3z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
<img class="w20 h20" src="@/assets/images/home/blue-message-icon-01.png" alt="">
<span>立即咨询</span>
</button>
</div>
@@ -269,23 +284,23 @@
<!-- 标题区 -->
<div class="home-testimonials__header">
<h2>万千毕业生的信赖之选</h2>
<p>REAL VOICES FROM THE COMMUNITY</p>
<p>真实求职反馈见证每一次从迷茫到上岸</p>
</div>
<!-- 评价卡片横向排列 -->
<div class="home-testimonials__cards">
<div class="testimonial-card" v-for="(t, i) in testimonials" :key="i">
<!-- 引号装饰 SVG -->
<svg class="testimonial-card__quote" width="48" height="48" viewBox="0 0 48 48" fill="none">
<path d="M6 6h14v36" stroke="rgba(82,202,209,0.2)" stroke-width="2"/>
<path d="M28 6h14v36" stroke="rgba(82,202,209,0.2)" stroke-width="2"/>
</svg>
<p class="testimonial-card__text">"{{ t.text }}"</p>
<div class="testimonial-card__author">
<img class="testimonial-card__avatar" :src="avatarImg" alt="用户头像" />
<div class="testimonial-card__info">
<p class="testimonial-card__name">{{ t.name }}</p>
<p class="testimonial-card__school">{{ t.school }}</p>
<p class="testimonial-card__text">{{ t.text }}</p>
<div class="dflex aliite-e">
<div class="testimonial-card__author">
<img class="testimonial-card__avatar" :src="t.avatar" alt="用户头像" />
<div class="testimonial-card__info">
<p class="testimonial-card__name">{{ t.name }}</p>
<p class="testimonial-card__school">{{ t.school }}</p>
</div>
</div>
<img class="w48 h48" src="@/assets/images/home/blue-icon-768.png" alt="">
</div>
</div>
</div>
@@ -294,12 +309,12 @@
<!-- 装饰圆环 SVG -->
<svg class="home-testimonials__founder-decor" width="455" height="455" viewBox="0 0 455 455" fill="none">
<circle cx="227.5" cy="227.5" r="189.5" stroke="#fff" stroke-width="20" opacity="0.08"/>
<circle cx="227.5" cy="227.5" r="80" stroke="#fff" stroke-width="20" opacity="0.08"/>
<path d="M240 190 L318 224 Q328 227.5 318 231 L240 265 Q227.5 270 215 265 L137 231 Q127 227.5 137 224 L215 190 Q227.5 185 240 190 Z" stroke="#fff" stroke-width="20" fill="none" opacity="0.08" transform="rotate(-45 227.5 227.5)"/>
</svg>
<div class="home-testimonials__founder-content">
<img class="home-testimonials__founder-img" :src="avatarImg" alt="创始人头像" />
<div class="home-testimonials__founder-text">
<blockquote>"{{ founderQuotes[founderIndex].text }}"</blockquote>
<blockquote>很多大学生还在用传统方式找工作海投反复改简历效率很低Offer派利用AI技术让专业和岗位<span class="fw600">深度匹配</span>关联配合<span class="fw600">自动化投递</span>让校招求职流程更丝滑更精准节省了80%的繁琐过程拿到更多Offer</blockquote>
<cite>
<span class="cite-name">{{ founderQuotes[founderIndex].name }}</span>
<span class="cite-role">{{ founderQuotes[founderIndex].role }}</span>
@@ -308,7 +323,7 @@
</div>
</div>
<!-- 分页小圆点 -->
<div class="home-testimonials__dots">
<div v-if="false" class="home-testimonials__dots">
<span
v-for="(_, i) in founderQuotes"
:key="i"
@@ -325,7 +340,7 @@
<div class="home-faq__inner">
<div class="home-faq__header">
<h2>常见问题</h2>
<p>FREQUENTLY ASKED QUESTIONS</p>
<p>关于平台使用隐私安全与岗位来源</p>
</div>
<div class="home-faq__list">
<div
@@ -333,19 +348,38 @@
:key="i"
class="faq-item"
:class="{ 'faq-item--open': faqOpen === i }"
@click="faqOpen = faqOpen === i ? -1 : i"
>
<div class="faq-item__header">
<div class="faq-item__header" @click="faqOpen === i ? null : (faqOpen = i)">
<span class="faq-item__question">{{ faq.q }}</span>
<div class="faq-item__icon">
<div class="faq-item__icon" @click.stop="faqOpen = faqOpen === i ? -1 : i">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2"/>
<line v-if="faqOpen !== i" x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2"/>
<template v-if="faqOpen === i">
<line x1="7" y1="7" x2="17" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="17" y1="7" x2="7" y2="17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</template>
<template v-else>
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</template>
</svg>
</div>
</div>
<div class="faq-item__answer" v-show="faqOpen === i">
<p>{{ faq.a }}</p>
<!-- 最后一项"还有其他问题"显示文本输入框+提交按钮 -->
<template v-if="i === faqs.length - 1">
<div class="faq-item__feedback">
<textarea
v-model="faqFeedbackText"
class="faq-item__textarea"
placeholder="请输入你的问题"
rows="4"
></textarea>
<button class="faq-item__submit" @click.stop="submitFaqFeedback">提交</button>
</div>
</template>
<template v-else>
<p>{{ faq.a }}</p>
</template>
</div>
</div>
</div>
@@ -355,7 +389,7 @@
<!-- CTA Section -->
<section class="home-cta">
<div class="home-cta__inner">
<h2>让Offer不再遥不可及<br/>大学生一站式AI求职</h2>
<h2>让Offer派带你找到<br/>人生第一份好工作</h2>
<button class="home-cta__btn" @click="router.push('/jobs')">免费体验</button>
</div>
</section>
@@ -394,7 +428,7 @@
:displayStyle="filterDisplayStyle"
@update:categoryIds="onHomeCategoryChange"
/>
<button class="filter-btn" @click="goSearchJobs">搜索职位</button>
<button class="filter-btn" @click="goSearchJobs">立即匹配</button>
</div>
</div>
</section>
@@ -412,10 +446,11 @@
<div class="home-footer__col">
<h5>核心功能</h5>
<ul>
<li>智能岗位匹配</li>
<li>AI简历优化</li>
<li>AI求职助手</li>
<li>一键投递</li>
<li @click="router.push('/jobs')">智能岗位匹配</li>
<li @click="router.push('/resume')">AI简历优化</li>
<li @click="router.push('/jobs')">AI求职助手</li>
<li @click="router.push('/jobs')">一键投递</li>
<li><a href="https://www.jianshixingqiu.com/mentor" target="_blank">名企导师</a></li>
</ul>
</div>
<div class="home-footer__col">
@@ -425,14 +460,14 @@
<li><a href="https://www.jianshixingqiu.com/internship" target="_blank">名企实习</a></li>
<li><a href="https://www.jianshixingqiu.com/biunique" target="_blank">名企导师1V1</a></li>
<li><a href="https://www.jianshixingqiu.com/directTrain" target="_blank">Offer直通车</a></li>
<li><a href="https://www.jianshixingqiu.com/mentor" target="_blank">名企导师</a></li>
</ul>
</div>
<div class="home-footer__col">
<h5>其他信息</h5>
<ul>
<li>隐私协议</li>
<li>服务条款</li>
<li @click="openAgreement('hf8375i8')" style="cursor: pointer;">隐私协议</li>
<li @click="openAgreement('ae8065i3')" style="cursor: pointer;">会员服务协议</li>
</ul>
</div>
</div>
@@ -440,23 +475,47 @@
<p>©2016-2026 - 广州油梨信息科技有限公司 版权所有</p>
</div>
</footer>
<!-- 隐私协议预览弹窗 -->
<AgreementPreviewDialog v-model="showPrivacyDialog" :code="agreementCode" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import avatarImg from '@/assets/images/home/avatar-temporary.png'
import studentAvatar0 from '@/assets/images/home/student-avatar-0.png'
import studentAvatar1 from '@/assets/images/home/student-avatar-1.png'
import studentAvatar2 from '@/assets/images/home/student-avatar-2.png'
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
import RegionSelector from '@/components/tools/RegionSelector.vue'
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
/** 路由实例 */
const router = useRouter()
/** Vuex 状态管理实例 */
const store = useStore()
/** 页面是否已向下滚动超过 20px — 用于导航栏背景切换 */
const isScrolled = ref(false)
/** 根据当前日期生成 3000~4000 之间的确定性岗位数量(同一天所有用户看到的数字一致) */
function getDailyJobCount(): number {
const today = new Date()
const seed = today.getFullYear() * 10000 + (today.getMonth() + 1) * 100 + today.getDate()
let hash = seed
hash = ((hash >> 16) ^ hash) * 0x45d9f3b
hash = ((hash >> 16) ^ hash) * 0x45d9f3b
hash = (hash >> 16) ^ hash
return 3000 + (Math.abs(hash) % 1001)
}
/** 今日更新岗位数量 — 每日确定性变化 */
const dailyJobCount = getDailyJobCount()
/** 当前展开的 FAQ 索引,-1 表示全部收起 */
const faqOpen = ref(-1)
@@ -465,13 +524,13 @@ const founderIndex = ref(0)
/** 创始人引言数据 — 支持多条切换 */
const founderQuotes = [
{ text: '很多大学生还在用传统方式找工作,海投、反复改简历,效率很低。Offer派利用AI技术让整个校招流程更丝滑,节省了80%的繁琐过程', name: 'Stella', role: 'Offer派创始人' },
{ text: '我们的目标是让每一位大学生都能高效地找到心仪的工作,AI技术正在改变校招的游戏规则。', name: 'Stella', role: 'Offer派创始人' },
{ text: '很多大学生还在用传统方式找工作,海投、反复改简历,效率很低。Offer派利用AI技术让专业和岗位深度匹配关联,配合自动化投递,让校招求职流程更丝滑,更精准,节省了80%的繁琐过程,拿到更多Offer。”', name: 'TK', role: 'Offer派创始人' },
]
/** 岗位滚动展示数据 — 模拟最新发布的校招岗位 */
const tickerJobs = [
{ company: '字节跳动', time: '15分钟前', title: '前端开发工程师' },
{ company: '字节跳动', time: '15分钟前', title: '高级前端开发工程师' },
{ company: '腾讯', time: '30分钟前', title: '后端开发工程师' },
{ company: '阿里巴巴', time: '1小时前', title: '产品经理' },
{ company: '华为', time: '2小时前', title: '算法工程师' },
@@ -488,18 +547,29 @@ const referralCards = [
/** 用户评价数据 — 展示真实用户的使用反馈 */
const testimonials = [
{ text: 'Offer派求职体验特别好!岗位信息丰富、质量高,AI岗位匹配也很精准,求职助手给的面试建议很专业,一周就拿到了Offer', name: '李同学', school: '西安交通大学' },
{ text: '简历优化功能太强了,针对不同岗位自动调整内容,面试邀约率直接翻倍。', name: '王同学', school: '浙江大学' },
{ text: '一键投递省了我大量时间,再也不用一个个填网申表格了,效率提升太明显。', name: '张同学', school: '北京大学' },
{ text: 'Offer派求职体验特别好!岗位信息丰富、质量高,AI岗位匹配也很精准,求职助手给的面试建议很专业,一周就拿到了offer', name: '李同学', school: '西安交通大学', avatar: studentAvatar0 },
{ text: '“界面设计简洁友好,用起来很舒适。AI助手帮我理清了方向,让迷茫的我建立了自信,还能帮我优化简历,真的很nice~”', name: '王同学', school: '北京大学', avatar: studentAvatar1 },
{ text: '“满分!特别是网申自动填写,不仅可以一键填写,还能快速定制简历内容,完美匹配岗位要求,毕设求职两不误,真心推荐。”', name: '张同学', school: '中国人民大学', avatar: studentAvatar2 },
]
/** 用户反馈输入内容 */
const faqFeedbackText = ref('')
/** 提交用户反馈 */
function submitFaqFeedback() {
if (!faqFeedbackText.value.trim()) return
// TODO:
faqFeedbackText.value = ''
faqOpen.value = -1
}
/** 常见问题列表 — q: 问题, a: 答案,点击可展开/收起 */
const faqs = [
{ q: '这个平台与其他求职网站有什么不同', a: 'Offer派专注于大学生校招场景,利用AI技术实现智能岗位匹配、一键自动网申、岗位定制简历和内推人脉直通,让求职效率提升80%以上。' },
{ q: '平台会分享我的个人信息吗?', a: '绝对不会。我们严格遵守隐私保护法规,您的个人信息仅用于岗位匹配简历投递,不会分享给任何第三方。' },
{ q: '如何收费?', a: '基础功能完全免费,包括岗位浏览、AI匹配和求职助手。高级功能如一键批量投递、定制简历等提供会员订阅服务。' },
{ q: '平台的岗位来源是什么', a: '我们的岗位信息来自各大企业官方校招渠道、合作高校就业中心以及企业HR直接发布,确保信息真实可靠。' },
{ q: '支持哪些企业?', a: '目前已覆盖互联网、金融、制造、快消等行业的数千家企业,包括字节跳动、腾讯、阿里巴巴、华为等头部企业。' },
{ q: '这个平台与其他求职平台有什么不同?', a: '不同于BOSS直聘等传统平台的“手动式”求职,Offer派如同一位经验丰富的职业导师,通过AI能力搭建智能工具,自动处理简历优化、岗位匹配、申请投递等环节,大幅度提升你的求职效率。' },
{ q: '平台如何保障我的个人信息安全?', a: 'Offer派高度重视你的隐私安全。未经你明确同意,我们绝不会将你的个人信息提供给任何第三方。你的所有数据仅用于在平台内为你提供AI智能岗位匹配简历优化等个性化求职服务。' },
{ q: '平台可以免费使用吗?', a: 'Offer派为所有用户免费开放3天会员权益,帮你省心省力找工作。3天后,依然可以免费使用各项功能,但有使用次数限制。如果你需要不受限制地使用所有功能,更快更高效地获得Offer,建议您升级为付费会员,让求职效率翻倍。' },
{ q: '平台的岗位来源是什么?', a: 'Offer派的岗位来自上万家企业官方招聘网站,同时聚合了各大主流平台的最新职位——你不需要在多个网站之间来回切换,就能一站式搜罗全网高质量的工作机会。此外,我们会持续筛查每一则新发布的职位,及时剔除虚假信息,帮你避“坑”,为你提供真实靠谱的岗位信息。' },
{ q: '还有其他问题?', a: '' },
]
// ==================== // ====================
@@ -515,19 +585,20 @@ const homeCategoryIds = computed<number[]>(() => store.state.jobIntention.catego
/** 选择器触发按钮样式 — 匹配首页 .filter-select 的外观 */
const filterTriggerStyle = {
width: '2.35rem',
height: '0.48rem',
'border-radius': '0.24rem',
width: '2.80rem',
'max-width': 'none',
height: '0.56rem',
'border-radius': '0.16rem',
border: '1px solid #e5e7eb',
padding: '0 0.2rem',
background: '#fff',
background: '#F3F4F5',
'justify-content': 'space-between',
}
/** 选择器显示文字样式 — 匹配首页 .filter-select 内文字 */
const filterDisplayStyle = {
'font-size': '0.16rem',
'line-height': '0.24rem',
'line-height': '0.16rem',
color: 'rgba(0, 0, 0, 0.45)',
'max-width': '1.8rem',
}
@@ -562,12 +633,71 @@ function goSearchJobs() {
}
onMounted(() => {
// 20px
const isScaleMode = () => {
return !!document.body.style.transform && document.body.style.transform.includes('scale')
}
const getScrollTop = () => {
return document.body.scrollTop || window.scrollY || document.documentElement.scrollTop
}
const handleScroll = () => {
const scrollTop = getScrollTop()
isScrolled.value = scrollTop > 20
}
// scale rAF top
const tickNav = () => {
if (isScaleMode()) {
const el = document.querySelector('.home-nav__inner') as HTMLElement | null
if (el && isScrolled.value) {
// fixed transform 退 absolute top
el.style.top = (document.body.scrollTop + 20) + 'px'
} else if (el && !isScrolled.value) {
el.style.top = ''
}
} else {
// scale top CSS
const el = document.querySelector('.home-nav__inner') as HTMLElement | null
if (el && el.style.top) {
el.style.top = ''
}
}
requestAnimationFrame(tickNav)
}
requestAnimationFrame(tickNav)
window.addEventListener('scroll', handleScroll)
document.body.addEventListener('scroll', handleScroll)
handleScroll()
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
document.body.removeEventListener('scroll', handleScroll)
})
// URL invite_code
const urlParams = new URLSearchParams(window.location.search)
const inviteCode = urlParams.get('invite_code')
if (inviteCode && inviteCode.length === 10) {
store.commit('SET_INVITE_CODE', inviteCode)
}
//
store.dispatch('loadCommonData')
})
/** 点击登陆按钮 — 打开登录弹窗,登录成功后跳转到 jobs 页面 */
/** 点击登陆按钮 — 跳转到登录页面,登录成功后跳转到 jobs 页面 */
function handleLoginClick() {
store.dispatch('openLogin', '/jobs')
router.push({ name: 'Login', query: { redirect: '/jobs' } })
}
/** 隐私协议弹窗显示状态 */
const showPrivacyDialog = ref(false)
/** 当前要预览的协议码 */
const agreementCode = ref('')
/** 打开协议预览弹窗 */
function openAgreement(code: string) {
agreementCode.value = code
showPrivacyDialog.value = true
}
</script>
+28 -6
View File
@@ -276,6 +276,11 @@
<!-- 岗位专属简历定制弹窗 -->
<JobResumeCustomDialog v-model="showResumeCustomDialog" :job-info="resumeCustomJobInfo" :job-id="jobId" @skip="handleSkipToApply" />
<!-- 会员权限拦截弹窗 -->
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
</div>
</template>
@@ -283,13 +288,17 @@
import { ref, reactive, computed, nextTick, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { formatEmploymentType } from '@/stores/index'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
import JobDislikeDialog from '@/components/JobDislikeDialog.vue'
import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
import JobResumeCustomDialog from '@/components/JobResumeCustomDialog.vue'
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import { fetchJobDetail, toggleJobFavorite, removeJobFavorite, fetchSkillGap } from '@/api/jobs'
import { fetchMemberStatus } from '@/api/member'
import type { JobDetailData, SkillGapData } from '@/api/jobs'
// ==================== ====================
@@ -303,11 +312,7 @@ const jobId = route.params.id as string
// ==================== ====================
/** 工作类型映射:数字 → 中文 */
function formatEmploymentType(type: number | undefined): string {
const map: Record<number, string> = { 0: '全职', 1: '兼职' }
return map[type ?? -1] ?? '未知'
}
/** 工作类型映射:使用全局统一的 formatEmploymentType */
/** 学历要求映射:数字 → 中文 */
function formatEducation(edu: number | undefined): string {
@@ -554,6 +559,11 @@ function handleReport() {
/** 简历定制弹窗显隐 */
const showResumeCustomDialog = ref(false)
/** 会员权限拦截弹窗 */
const showMemberAccessDialog = ref(false)
/** 会员购买弹窗 */
const showMemberDialog = ref(false)
/** 技能差距分析数据 */
const skillGapData = ref<SkillGapData | null>(null)
@@ -571,8 +581,20 @@ const resumeCustomJobInfo = computed(() => ({
defaultResume: skillGapData.value?.resume || null,
}))
/** 生成岗位专属简历 — 调用 skill-gap 接口后打开定制弹窗 */
/** 生成岗位专属简历 — 先检查会员状态,非会员弹权限拦截弹窗 */
async function handleGenerateResume() {
//
try {
const statusRes = await fetchMemberStatus()
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
//
showMemberAccessDialog.value = true
return
}
} catch {
//
}
const loadingInstance = ElLoading.service({
text: '正在分析岗位匹配度...',
background: 'rgba(0, 0, 0, 0.5)',
+129 -22
View File
@@ -112,7 +112,7 @@
</div>
<!-- 职位列表 -->
<div ref="jobListRef" v-loading="loading" class="jobs-page__list pr5" :style="restoring ? { visibility: 'hidden' } : {}" @scroll="onListScroll">
<div ref="jobListRef" class="jobs-page__list pr5" :style="restoring ? { visibility: 'hidden' } : {}" @scroll="onListScroll">
<div
v-for="(job, index) in jobList"
:key="index"
@@ -196,7 +196,7 @@
</svg>
问助手
</button>
<button @click="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
<button @click.stop="handleReport(job.sourceUrl)" class="jobs-page__job-apply-btn" :class="{ 'jobs-page__job-apply-btn--active': job.applied }">
去投递
</button>
</div>
@@ -208,7 +208,7 @@
class="jobs-page__job-popup-item"
v-for="action in popupActions"
:key="action"
@click="handlePopupAction(action, job)"
@click.stop="handlePopupAction(action, job)"
>{{ action }}</div>
</div>
</div>
@@ -244,10 +244,18 @@
</div>
</div>
</div>
<!-- 首次加载中提示 -->
<div v-if="loading" class="jobs-page__loading-box">
<div class="jobs-page__loading-spinner"></div>
<span>加载中...</span>
</div>
<!-- 暂无数据提示 -->
<div v-if="!loading && jobList.length === 0" class="jobs-page__empty">暂无数据</div>
<!-- 加载更多提示 -->
<div v-if="loadingMore" class="jobs-page__loading-more">加载中...</div>
<div v-if="loadingMore" class="jobs-page__loading-box">
<div class="jobs-page__loading-spinner"></div>
<span>加载中...</span>
</div>
<div v-else-if="noMore && jobList.length > 0" class="jobs-page__loading-more">没有更多符合的职位了</div>
</div>
</div>
@@ -259,6 +267,16 @@
<!-- 职位问题反馈弹窗 -->
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
<!-- AI助手投递提醒弹窗 -->
<div v-if="showAgentRemindDialog" class="jobs-page__agent-remind-overlay" @click.self="closeAgentRemind">
<div class="jobs-page__agent-remind-dialog">
<p class="jobs-page__agent-remind-text">去AI助手投递岗位效果更好哦</p>
<div class="jobs-page__agent-remind-actions">
<button class="jobs-page__agent-remind-btn jobs-page__agent-remind-btn--secondary" @click="directApply">直接投</button>
<button class="jobs-page__agent-remind-btn jobs-page__agent-remind-btn--primary" @click="goToAgent">去AI助手</button>
</div>
</div>
</div>
</div>
</template>
@@ -267,6 +285,7 @@
import { ref, watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useStore } from 'vuex'
import { JOB_TYPE_OPTIONS } from '@/stores/index'
import SideNav from '@/components/SideNav.vue'
import AiChat from '@/components/AiChat.vue'
import JobPageHeader from '@/components/JobPageHeader.vue'
@@ -279,6 +298,7 @@ import RegionSelector from '@/components/tools/RegionSelector.vue'
import { fetchJobList, fetchFavoriteList, toggleJobFavorite, removeJobFavorite, fetchFavoriteCount, fetchApplyList, fetchApplyCount, removeJobFromList } from '@/api/jobs'
import type { JobListItem, JobListParams, FavoriteListParams, ApplyListParams, ApplyCountData } from '@/api/jobs'
import { getTimeEvent, setTimeEvent, timestampToLocalDateTime } from '@/utils/time'
// 2.
@@ -427,21 +447,18 @@ const filters = ref<FilterItem[]>([
{ label: '城市', key: 'city', selected: '' },
{ label: '岗位', key: 'position', selected: '' },
{ label: '行业', key: 'industry', selected: '' },
{ label: '工作类型', key: 'jobType', selected: '' },
{ label: '招聘分类', key: 'jobType', selected: '' },
])
/** 工作类型选项映射:label → 接口参数 employmentType0=全职 1=实习) */
const jobTypeOptions: { label: string; value: number }[] = [
{ label: '全职', value: 0 },
{ label: '实习', value: 1 },
]
/** 招聘分类选项映射:从全局 store 常量统一引入 */
const jobTypeOptions = JOB_TYPE_OPTIONS
/** 工作类型下拉菜单是否显示 */
/** 招聘分类下拉菜单是否显示 */
const showJobTypeDropdown = ref(false)
/** 当前选中的工作类型 — 直接读 store.jobIntention.employmentType */
/** 当前选中的招聘分类 — 直接读 store.jobIntention.recruitCategory */
const selectedEmploymentType = computed<number | null>(
() => store.state.jobIntention.employmentType ?? null,
() => store.state.jobIntention.recruitCategory ?? null,
)
/** 选中的行业 id 数组 — 直接读 store.jobIntention.industryIds */
@@ -483,24 +500,24 @@ function onRegionChange(codes: string[]) {
})
}
/** 点击筛选条件按钮 — 仅工作类型展开下拉 */
/** 点击筛选条件按钮 — 仅招聘分类展开下拉 */
function handleFilterClick(filter: FilterItem) {
if (filter.key === 'jobType') {
showJobTypeDropdown.value = !showJobTypeDropdown.value
}
}
/** 选中工作类型选项 */
/** 选中招聘分类选项 */
function selectJobType(filter: FilterItem, option: { label: string; value: number }) {
filter.selected = option.label
showJobTypeDropdown.value = false
store.dispatch('saveJobIntention', {
...store.state.jobIntention,
employmentType: option.value,
recruitCategory: option.value,
})
}
/** 监听 store 中 employmentType 变化,同步工作类型筛选按钮的显示文字 */
/** 监听 store 中 recruitCategory 变化,同步招聘分类筛选按钮的显示文字 */
watch(selectedEmploymentType, (val) => {
const jobTypeFilter = filters.value.find(f => f.key === 'jobType')
if (jobTypeFilter && val !== null) {
@@ -517,11 +534,101 @@ function closeDropdownOnClickOutside(e: MouseEvent) {
}
}
/** 跳转到原链接 */
function handleReport( url:string ) {
if (url) {
/** 跳转到原链接 — 带 AI 助手提醒弹窗频率控制 */
/**
* 提醒弹窗频率逻辑说明
* 1. 未登录用户直接跳转外部链接不弹窗
* 2. 已登录用户首次点击 ai_agent_remind_first 缓存弹窗提醒记录第一次提醒时间
* 3. 有第一次记录但无第二次记录
* - 距第一次记录 1小时直接跳转不弹窗
* - 距第一次记录 > 1小时弹窗提醒记录第二次提醒时间
* 4. 有第二次记录
* - 距第二次记录 7直接跳转不弹窗
* - 距第二次记录 > 7弹窗提醒更新第二次记录时间为当前时间
*/
function handleReport(url: string) {
//
pendingApplyUrl.value = url
//
if (!isAuthenticated.value) {
window.open(url, '_blank')
return
}
const userId = store.state.userInfo?.id
if (!userId) {
window.open(url, '_blank')
return
}
const now = Date.now()
const firstTime = getTimeEvent('ai_agent_remind_first', userId)
const secondTime = getTimeEvent('ai_agent_remind_second', userId)
if (!firstTime) {
// 1
setTimeEvent('ai_agent_remind_first', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
return
}
if (!secondTime) {
// 2 1
const firstTimestamp = new Date(firstTime).getTime()
const diffHours = (now - firstTimestamp) / (1000 * 60 * 60)
if (diffHours <= 1) {
// 1
window.open(url, '_blank')
} else {
// 1
setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
}
return
}
// 3 7
const secondTimestamp = new Date(secondTime).getTime()
const diffDays = (now - secondTimestamp) / (1000 * 60 * 60 * 24)
if (diffDays <= 7) {
// 7
window.open(url, '_blank')
} else {
// 7
setTimeEvent('ai_agent_remind_second', userId, timestampToLocalDateTime(now))
showAgentRemindDialog.value = true
}
}
// ==================== AI ====================
/** AI助手提醒弹窗是否显示 */
const showAgentRemindDialog = ref(false)
/** 暂存待跳转的外部链接 */
const pendingApplyUrl = ref('')
/** 关闭AI助手提醒弹窗 */
function closeAgentRemind() {
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
}
/** 点击"直接投"按钮 — 关闭弹窗并跳转外部链接 */
function directApply() {
showAgentRemindDialog.value = false
if (pendingApplyUrl.value) {
window.open(pendingApplyUrl.value, '_blank')
}
pendingApplyUrl.value = ''
}
/** 点击"去AI助手"按钮 — 关闭弹窗并跳转到 Agent 页面 */
function goToAgent() {
showAgentRemindDialog.value = false
pendingApplyUrl.value = ''
router.push('/agent')
}
onMounted(async () => {
@@ -658,9 +765,9 @@ function buildParams(): JobListParams {
if (selectedIndustryIds.value.length) {
params.industryIds = selectedIndustryIds.value
}
//
//
if (selectedEmploymentType.value !== null) {
params.employmentType = selectedEmploymentType.value
params.recruitCategory = selectedEmploymentType.value
}
//
if (keyword.value.trim()) {
+466
View File
@@ -0,0 +1,466 @@
<template>
<!-- 登录页面 左右分栏布局包含登录和简历上传流程 -->
<div class="login-view">
<!-- ==================== 登录阶段step 1 & 2==================== -->
<template v-if="step === 1 || step === 2">
<!-- 左侧品牌面板 -->
<div class="login-view__left">
<div class="login-view__deco-circle login-view__deco-circle--lg"></div>
<div class="login-view__left-content">
<div class="login-view__logo-row">
<div class="login-view__logo-box login-view__logo-box--light">O</div>
<span class="login-view__logo-text login-view__logo-text--white">Offer派</span>
</div>
<h1 class="login-view__slogan">在Offer派<br/>找到你的理想工作</h1>
<p class="login-view__sub-slogan">AI 驱动的求职平台为你匹配最合适的岗位</p>
<div class="login-view__pills">
<div class="login-view__pill"> AI 智能岗位匹配</div>
<div class="login-view__pill"> 一键生成专属简历</div>
<div class="login-view__pill"> 模拟面试精准备考</div>
</div>
</div>
<div class="login-view__deco-circle login-view__deco-circle--sm"></div>
</div>
<!-- 右侧表单面板 -->
<div class="login-view__right">
<div class="login-view__logo-row login-view__logo-row--right">
<div class="login-view__logo-box login-view__logo-box--green">O</div>
<span class="login-view__logo-text login-view__logo-text--dark">Offer派</span>
</div>
<div class="login-view__form-wrap">
<!-- 步骤一输入手机号发送验证码 -->
<template v-if="step === 1">
<div class="login-view__heading">
<h2 class="login-view__title">手机号登录/注册</h2>
<p class="login-view__subtitle">未注册用户验证后将自动注册并登录</p>
</div>
<div class="login-view__phone-row">
<div class="login-view__country-code">
<span class="login-view__flag">🇨🇳</span>
<span class="login-view__code-text">+86</span>
<span class="login-view__code-arrow"></span>
</div>
<div class="login-view__separator"></div>
<input v-model="phone" type="tel" class="login-view__phone-input" placeholder="请输入手机号码" maxlength="11" />
</div>
<button class="login-view__send-btn" :disabled="!canSend" @click="handleSendCode">发送验证码</button>
</template>
<!-- 步骤二输入验证码登录 -->
<template v-if="step === 2">
<div class="login-view__heading">
<h2 class="login-view__title">请输入验证码</h2>
<p class="login-view__subtitle">短信验证码已发送至 +86{{ phone }} <span class="fw600 ml5 cursor-po" @click="handleChangePhone">修改手机号</span></p>
</div>
<div class="login-view__otp-wrap" @click="focusOtpInput">
<input ref="otpInputRef" v-model="otpValue" type="text" inputmode="numeric" class="login-view__otp-hidden-input" maxlength="6" :disabled="isLoggingIn" @input="handleOtpInput" />
<div v-for="(_, idx) in 6" :key="idx" class="login-view__otp-box" :class="{ 'login-view__otp-box--active': idx === otpValue.length && !isLoggingIn, 'login-view__otp-box--filled': idx < otpValue.length }">
<span v-if="idx < otpValue.length" class="login-view__otp-digit">{{ otpValue[idx] }}</span>
<span v-else-if="idx === otpValue.length && !isLoggingIn" class="login-view__otp-cursor"></span>
</div>
</div>
<p v-if="smsStatusMsg" class="login-view__sms-status" :class="{ 'login-view__sms-status--error': smsStatusIsError }">{{ smsStatusMsg }}</p>
<button class="login-view__status-btn" :disabled="isLoggingIn || countdown > 0" @click="handleResendCode">
<template v-if="isLoggingIn"><span class="login-view__spinner"></span><span>登录中</span></template>
<template v-else-if="countdown > 0">{{ countdown }} 秒后可继续发送</template>
<template v-else>再次发送验证码</template>
</button>
</template>
<!-- 协议勾选步骤一二通用 -->
<div class="login-view__agreement">
<div class="login-view__checkbox" :class="{ 'login-view__checkbox--checked': agreedTerms }" @click="agreedTerms = !agreedTerms">
<span v-if="agreedTerms" class="login-view__check-icon"></span>
</div>
<span class="login-view__agreement-text">登录即表示同意</span>
<span class="login-view__agreement-link" @click="openAgreement('ae8065i3')">用户协议</span>
<span class="login-view__agreement-text"></span>
<span class="login-view__agreement-link" @click="openAgreement('hf8375i8')">隐私政策</span>
</div>
</div>
</div>
</template>
<!-- ==================== 简历上传阶段step 3==================== -->
<template v-if="step === 3">
<!-- 左侧品牌面板 职位卡片风格 -->
<div class="login-view__left login-view__left--resume">
<!-- 顶部 Logo -->
<div class="login-view__resume-logo">
<div class="login-view__logo-box login-view__logo-box--light">O</div>
<span class="login-view__logo-text login-view__logo-text--white">Offer派</span>
</div>
<!-- 浮动职位卡片 -->
<div class="login-view__job-cards">
<div v-for="(job, idx) in jobCards" :key="idx" class="login-view__job-card" :style="job.style">
<span class="login-view__job-card-icon" :style="{ background: job.iconBg, color: job.iconColor }">{{ job.icon }}</span>
<div class="login-view__job-card-info">
<span class="login-view__job-card-title">{{ job.title }}</span>
<span class="login-view__job-card-company">{{ job.company }}</span>
</div>
<span class="login-view__job-card-salary">{{ job.salary }}</span>
</div>
</div>
<!-- 标语 -->
<div class="login-view__resume-slogan">
<h1>校招批量海投</h1>
<h1>年轻人有更多的面试机会</h1>
</div>
</div>
<!-- 右侧简历上传面板 -->
<div class="login-view__right">
<div class="login-view__form-wrap login-view__form-wrap--resume">
<!-- 状态1上传区域 -->
<template v-if="resumeState === 'upload'">
<div class="login-view__heading">
<h2 class="login-view__title">一切美好从简历开始~</h2>
<p class="login-view__subtitle">自动解析您的简历为你匹配最合适的岗位</p>
</div>
<!-- 拖拽上传区域 -->
<div class="login-view__upload-area" @dragover.prevent @drop.prevent="handleDrop" @click="triggerFileInput">
<div class="login-view__upload-circle"></div>
<p class="login-view__upload-text">拖拽简历到这里或点击上传</p>
<p class="login-view__upload-hint">支持 PDF / DOC / DOCX不超过 20MB</p>
</div>
<!-- 隐藏的文件选择器 -->
<input ref="fileInputRef" type="file" accept=".pdf,.doc,.docx" class="login-view__file-input" @change="handleFileChange" />
<!-- 上传按钮 -->
<button class="login-view__upload-btn" @click="triggerFileInput">+ 上传简历</button>
</template>
<!-- 状态2解析中 -->
<template v-if="resumeState === 'parsing'">
<div class="login-view__parsing">
<h2 class="login-view__parsing-title">{{ parsingText }}</h2>
<p class="login-view__parsing-subtitle">自动解析您的简历为你匹配最合适的岗位</p>
</div>
</template>
<!-- 状态3上传失败 -->
<template v-if="resumeState === 'failed'">
<div class="login-view__heading">
<h2 class="login-view__title login-view__title--error">上传失败</h2>
<p class="login-view__subtitle">您的简历解析失败了请重试一次吧~</p>
</div>
<button class="login-view__upload-btn" @click="handleRetryUpload">重新上传</button>
</template>
</div>
</div>
</template>
<!-- 协议预览弹窗 -->
<AgreementPreviewDialog v-model="showAgreementDialog" :code="agreementCode" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
import { useStore } from 'vuex'
import { useRouter, useRoute } from 'vue-router'
import { sendSmsCode, smsLogin } from '@/api/auth'
import { fetchProfile, syncProfileFromResume } from '@/api/profile'
import { uploadResume } from '@/utils/aiRequest'
import AgreementPreviewDialog from '@/components/tools/AgreementPreviewDialog.vue'
const store = useStore()
const router = useRouter()
const route = useRoute()
// ==================== ====================
/** 当前步骤:1=输入手机号 2=输入验证码 3=上传简历 */
const step = ref(1)
// ==================== ====================
/** 手机号 */
const phone = ref('')
/** 验证码6位值 */
const otpValue = ref('')
/** 是否已勾选协议 */
const agreedTerms = ref(false)
/** 倒计时秒数 */
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
/** 是否正在调用登录接口 */
const isLoggingIn = ref(false)
/** 验证码发送状态提示文字 */
const smsStatusMsg = ref('')
/** 状态提示是否为错误样式 */
const smsStatusIsError = ref(false)
/** OTP 输入框引用 */
const otpInputRef = ref<HTMLInputElement | null>(null)
/** 协议弹窗状态 */
const showAgreementDialog = ref(false)
const agreementCode = ref('')
/** 登录成功后的跳转路径(缓存) */
let loginRedirect = '/'
/** 发送按钮是否可用 */
const canSend = computed(() => phone.value.length === 11 && agreedTerms.value)
// ==================== ====================
/** 简历上传子状态:upload=上传区域 parsing=解析中 failed=失败 */
const resumeState = ref<'upload' | 'parsing' | 'failed'>('upload')
/** 解析中显示的文字 */
const parsingText = ref('正在解析个人资料...')
/** 解析文字轮换定时器 */
let parsingTimer: ReturnType<typeof setInterval> | null = null
/** 文件选择器引用 */
const fileInputRef = ref<HTMLInputElement | null>(null)
/** 左侧职位卡片硬编码数据 */
const jobCards = [
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '前端工程师', company: '网易', salary: '18-28K', style: 'top: 0.6rem; left: 0.4rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '运营专员', company: '阿里巴巴', salary: '15-20K', style: 'top: 0.6rem; right: 0.8rem;' },
{ icon: '百', iconBg: '#E8F0FF', iconColor: '#4A7FE5', title: '算法工程师', company: '百度', salary: '30-45K', style: 'top: 1.5rem; left: 0.2rem;' },
{ icon: '美', iconBg: '#FFF3E8', iconColor: '#E8953F', title: '商业分析实习生', company: '美团', salary: '15K', style: 'top: 1.8rem; left: 2.8rem;' },
{ icon: '小', iconBg: '#E8FFE8', iconColor: '#4CAF50', title: '硬件产品', company: '小米', salary: '18-24K', style: 'top: 2.5rem; left: 0.3rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '产品运营', company: '京东', salary: '18-25K', style: 'top: 2.8rem; right: 0.6rem;' },
{ icon: '字', iconBg: '#F0E8FF', iconColor: '#7C4DFF', title: '产品经理', company: '字节跳动', salary: '25-35K', style: 'top: 3.5rem; left: 1.2rem;' },
{ icon: '增', iconBg: '#E8FFF5', iconColor: '#12C7BE', title: '增长运营', company: '拼多多', salary: '20-28K', style: 'top: 3.5rem; right: 0.4rem;' },
{ icon: '滴', iconBg: '#FFF8E1', iconColor: '#FFC107', title: '后端开发', company: '滴滴', salary: '22-32K', style: 'top: 4.3rem; left: 0.5rem;' },
{ icon: '网', iconBg: '#FFE8E8', iconColor: '#E85635', title: '数据分析师', company: '腾讯', salary: '20-30K', style: 'top: 4.6rem; right: 0.8rem;' },
]
/** 解析中文字轮换列表 */
const parsingSteps = ['正在解析个人资料...', '正在解析教育背景...', '正在解析工作经历...', '正在解析技能特长...', '简历解析成功,即将跳转']
// ==================== ====================
/** 打开协议预览 */
function openAgreement(code: string) {
agreementCode.value = code
showAgreementDialog.value = true
}
/** 聚焦OTP隐藏输入框 */
function focusOtpInput() {
otpInputRef.value?.focus()
}
/** 修改手机号 — 回到步骤一并清空所有状态 */
function handleChangePhone() {
step.value = 1
phone.value = ''
otpValue.value = ''
countdown.value = 0
smsStatusMsg.value = ''
smsStatusIsError.value = false
isLoggingIn.value = false
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}
/** 开始60秒倒计时 */
function startCountdown() {
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
}, 1000)
}
/** 步骤一:发送验证码 */
async function handleSendCode() {
if (!phone.value) { ElMessage.warning('请输入手机号'); return }
if (!agreedTerms.value) { ElMessage.warning('请先同意用户协议与隐私政策'); return }
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0') {
smsStatusMsg.value = '验证码发送成功,请输入'
smsStatusIsError.value = false
step.value = 2
startCountdown()
nextTick(() => focusOtpInput())
} else if (res.msg && res.msg.includes('请勿重复发送')) {
smsStatusMsg.value = '验证码还在有效期内,请输入'
smsStatusIsError.value = false
step.value = 2
startCountdown()
nextTick(() => focusOtpInput())
} else {
smsStatusMsg.value = res.msg || '验证码发送失败'
smsStatusIsError.value = true
}
} catch (err: any) {
const msg = err?.response?.data?.msg || '验证码发送失败'
smsStatusMsg.value = msg
smsStatusIsError.value = true
}
}
/** OTP输入处理 */
function handleOtpInput() {
otpValue.value = otpValue.value.replace(/\D/g, '').slice(0, 6)
if (otpValue.value.length === 6) { handleLogin() }
}
/** 步骤二:再次发送验证码 */
async function handleResendCode() {
if (isLoggingIn.value || countdown.value > 0) return
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0') {
smsStatusMsg.value = '验证码发送成功,请输入'
smsStatusIsError.value = false
otpValue.value = ''
startCountdown()
nextTick(() => focusOtpInput())
} else if (res.msg && res.msg.includes('请勿重复发送')) {
smsStatusMsg.value = '验证码还在有效期内,请输入'
smsStatusIsError.value = false
} else {
smsStatusMsg.value = res.msg || '验证码发送失败'
smsStatusIsError.value = true
}
} catch (err: any) {
const msg = err?.response?.data?.msg || '验证码发送失败'
smsStatusMsg.value = msg
smsStatusIsError.value = true
}
}
/** 调用登录接口 */
async function handleLogin() {
isLoggingIn.value = true
try {
const res = await smsLogin(phone.value, otpValue.value)
if (res.code !== '0') {
ElMessage.error('登录失败请核对验证码')
otpValue.value = ''
isLoggingIn.value = false
nextTick(() => focusOtpInput())
return
}
//
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null }
countdown.value = 0
store.commit('SET_AUTHENTICATED', true)
//
loginRedirect = (route.query.redirect as string) || '/'
//
try {
const profileRes = await fetchProfile()
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
//
step.value = 3
resumeState.value = 'upload'
isLoggingIn.value = false
return
}
} catch {
//
}
//
router.replace(loginRedirect)
} catch {
ElMessage.error('登录失败请核对验证码')
otpValue.value = ''
isLoggingIn.value = false
nextTick(() => focusOtpInput())
}
}
// ==================== ====================
/** 触发文件选择 */
function triggerFileInput() {
fileInputRef.value?.click()
}
/** 文件选择器 change 事件 */
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (input.files && input.files[0]) {
startUpload(input.files[0])
}
}
/** 拖放上传 */
function handleDrop(e: DragEvent) {
const file = e.dataTransfer?.files[0]
if (file) { startUpload(file) }
}
/** 重新上传 */
function handleRetryUpload() {
resumeState.value = 'upload'
}
/** 开始上传并解析简历 */
async function startUpload(file: File) {
//
const validTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']
if (!validTypes.includes(file.type)) {
ElMessage.warning('请上传 PDF / DOC / DOCX 格式文件')
return
}
if (file.size > 20 * 1024 * 1024) {
ElMessage.warning('文件大小不能超过 20MB')
return
}
//
resumeState.value = 'parsing'
startParsingAnimation()
try {
const res = await uploadResume(file)
if (res.code === 0 && res.data?.resumeId) {
//
await syncProfileFromResume(String(res.data.resumeId))
//
stopParsingAnimation()
parsingText.value = '简历解析成功,即将跳转'
// 1
setTimeout(() => {
router.replace(loginRedirect)
}, 1000)
} else {
//
stopParsingAnimation()
resumeState.value = 'failed'
}
} catch {
stopParsingAnimation()
resumeState.value = 'failed'
}
}
/** 开始解析文字轮换动画 */
function startParsingAnimation() {
let index = 0
parsingText.value = parsingSteps[0]
parsingTimer = setInterval(() => {
index++
if (index < parsingSteps.length - 1) {
// 42
parsingText.value = parsingSteps[index]
} else {
// 4
parsingText.value = parsingSteps[parsingSteps.length - 2]
if (parsingTimer) { clearInterval(parsingTimer); parsingTimer = null }
}
}, 2000)
}
/** 停止解析文字轮换动画 */
function stopParsingAnimation() {
if (parsingTimer) { clearInterval(parsingTimer); parsingTimer = null }
}
/** 组件卸载时清理定时器 */
onBeforeUnmount(() => {
if (countdownTimer) { clearInterval(countdownTimer) }
if (parsingTimer) { clearInterval(parsingTimer) }
})
</script>
+1 -1
View File
@@ -10,7 +10,7 @@
@save="handleSaveEdit"
/>
<!-- 欢迎上传简历弹窗首次无个人资料时自动弹出 -->
<!-- 欢迎上传简历弹窗无个人资料时自动弹出 -->
<ResumeUploadDialog v-model="showWelcomeDialog" @confirm="handleWelcomeUpload" />
<!-- 页面标题 -->
+26 -1
View File
@@ -412,6 +412,11 @@
</div>
</div>
</div>
<!-- 会员权限拦截弹窗 -->
<MemberAccessDialog v-model="showMemberAccessDialog" @open-member="showMemberDialog = true" />
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
</div>
</template>
@@ -424,6 +429,9 @@ import ResumeAnalysisReportDrawer from '@/components/ResumeAnalysisReportDrawer.
import ResumeIssueFixDrawer from '@/components/ResumeIssueFixDrawer.vue'
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
import MemberAccessDialog from '@/components/MemberAccessDialog.vue'
import MemberDialog from '@/components/MemberDialog.vue'
import { fetchMemberStatus } from '@/api/member'
import {
fetchResumeMain,
fetchResumeEducation,
@@ -463,6 +471,11 @@ import {
// ==================== ====================
/** 会员权限拦截弹窗 */
const showMemberAccessDialog = ref(false)
/** 会员购买弹窗 */
const showMemberDialog = ref(false)
/** 问题类型枚举值 */
type IssueType = 'urgent' | 'optimize' | 'expression'
@@ -748,8 +761,20 @@ function handleClose_report() {
showReportDrawer.value = false
}
/** 重新诊断 / 开始诊断 — 调用 AI 诊断接口,完成后刷新诊断数据 */
/** 重新诊断 / 开始诊断 — 先检查会员状态,非会员弹权限拦截弹窗 */
async function handleDiagnose() {
//
try {
const statusRes = await fetchMemberStatus()
if (statusRes.code === '0' && statusRes.data && !statusRes.data.isMember) {
//
showMemberAccessDialog.value = true
return
}
} catch {
//
}
// AI
const loading = ElLoading.service({
lock: true,
+1
View File
@@ -9,6 +9,7 @@
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,