Compare commits
35 Commits
67dd441502
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 065553f53e | |||
| 1c3ea6aa3c | |||
| c17f75c707 | |||
| 8d6282c3e6 | |||
| 403e5e9fc9 | |||
| 540d61b6a0 | |||
| 2844d99ae1 | |||
| 0783ecb570 | |||
| ad9c448fc7 | |||
| 5ef8b3a9df | |||
| 0fcc19f3f8 | |||
| e6ae57e5fd | |||
| 557a6f30f2 | |||
| dde72be9de | |||
| ddb67cfb6f | |||
| 59ac8ab783 | |||
| 0d1d080cc1 | |||
| a1633978c5 | |||
| 0e6fde08c7 | |||
| 96071d0105 | |||
| e34bb2a21e | |||
| 3dbeddcb8f | |||
| f52a7c56f7 | |||
| a9638fc7ec | |||
| 91992a2443 | |||
| b666f0047d | |||
| 42768cc4c5 | |||
| 833533f780 | |||
| 8b6d424b2f | |||
| 4b16341f92 | |||
| 12baa93e58 | |||
| a3740688ab | |||
| 2b6d2a3fb2 | |||
| 17970ca043 | |||
| b68a6d4f34 |
@@ -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`)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ==================== 第一阶段:构建 ====================
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /build
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
/**
|
||||
* OfferPie Web 前端蓝绿部署流水线
|
||||
*
|
||||
* 工作目录说明:
|
||||
* - Jenkins 会自动为每个项目创建独立的工作空间:/var/jenkins_home/workspace/<项目名>/
|
||||
* - docker-compose.yml、Dockerfile、nginx.conf 都在项目根目录,直接执行即可
|
||||
* - nginx.conf 首次部署时 docker cp 进 nginx 容器
|
||||
*
|
||||
* 内层 Nginx 职责:
|
||||
* - 蓝绿切换(前端静态文件 serve)
|
||||
* - /api/ 代理到 Java 后端(10202)
|
||||
* - /ai-api/ 代理到 Python AI(10502),rewrite 去掉前缀
|
||||
* 架构:Jenkins 本地编译 → scp 镜像到目标机 → SSH 远程蓝绿切换
|
||||
* 目标机目录:/opt/offerpie/web/
|
||||
*/
|
||||
pipeline {
|
||||
agent any
|
||||
@@ -19,14 +12,28 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
CONTAINER_PREFIX = 'offerpie-web' // 容器名前缀,拼接 -blue / -green / -nginx
|
||||
HEALTH_URL = 'http://localhost:80/' // 前端静态页面健康检查
|
||||
// 目标服务器配置
|
||||
DEPLOY_HOST = '8.148.237.97'
|
||||
DEPLOY_PORT = '22'
|
||||
DEPLOY_USER = 'root'
|
||||
DEPLOY_PASS = 'Mzpy520@126.com'
|
||||
|
||||
// 项目配置
|
||||
IMAGE_NAME = 'offerpie-web'
|
||||
IMAGE_TAG = 'latest'
|
||||
CONTAINER_PREFIX = 'offerpie-web'
|
||||
REMOTE_DIR = '/opt/offerpie/web'
|
||||
HEALTH_URL = 'http://localhost:80/'
|
||||
|
||||
// SSH 命令前缀
|
||||
SSH_CMD = "sshpass -p '${DEPLOY_PASS}' ssh -o StrictHostKeyChecking=no -p ${DEPLOY_PORT} ${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||
SCP_CMD = "sshpass -p '${DEPLOY_PASS}' scp -o StrictHostKeyChecking=no -P ${DEPLOY_PORT}"
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('开始提示') {
|
||||
stage('环境检查') {
|
||||
steps {
|
||||
echo "OfferPie Web 前端开始构建"
|
||||
sh 'sshpass -V'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,23 +41,41 @@ pipeline {
|
||||
steps {
|
||||
echo "拉取 ${params.BRANCH} 分支代码"
|
||||
git branch: "${params.BRANCH}",
|
||||
credentialsId: 'ef5fffc1-9b35-403d-9ca6-e1b73eb0e45a',
|
||||
url: 'https://codeup.aliyun.com/5f0ed3b9769820a3e817dee2/offerpie/offerpie_web.git'
|
||||
credentialsId: 'gitea-fab089c1-b55d-4b58-9fad',
|
||||
url: 'http://git.jianshixingqiu.com/offerpai/offerpai_web.git'
|
||||
}
|
||||
}
|
||||
|
||||
stage('本地编译') {
|
||||
steps {
|
||||
echo "开始构建镜像"
|
||||
sh "docker build -t ${IMAGE_NAME}:${IMAGE_TAG} ."
|
||||
echo "导出镜像"
|
||||
sh "docker save -o ${IMAGE_NAME}.tar ${IMAGE_NAME}:${IMAGE_TAG}"
|
||||
}
|
||||
}
|
||||
|
||||
stage('文件传输') {
|
||||
steps {
|
||||
echo "传输文件到目标服务器"
|
||||
sh "${SSH_CMD} 'mkdir -p ${REMOTE_DIR}'"
|
||||
sh "${SCP_CMD} ${IMAGE_NAME}.tar ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/"
|
||||
sh "${SCP_CMD} proxy_nginx.conf ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/proxy_nginx.conf"
|
||||
sh "${SCP_CMD} docker-compose.yml ${DEPLOY_USER}@${DEPLOY_HOST}:${REMOTE_DIR}/docker-compose.yml"
|
||||
}
|
||||
}
|
||||
|
||||
stage('加载镜像') {
|
||||
steps {
|
||||
sh "${SSH_CMD} 'docker load < ${REMOTE_DIR}/${IMAGE_NAME}.tar'"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测部署目标
|
||||
* - 检查 blue 和 green 容器的运行状态
|
||||
* - blue 在运行 → 部署 green;green 在运行 → 部署 blue
|
||||
* - 都不在或都在 → 默认部署 blue
|
||||
*/
|
||||
stage('检测部署目标') {
|
||||
steps {
|
||||
echo "检查当前容器状态"
|
||||
script {
|
||||
def blueRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-blue", returnStdout: true).trim()
|
||||
def greenRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-green", returnStdout: true).trim()
|
||||
def blueRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-blue'", returnStdout: true).trim()
|
||||
def greenRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-green'", returnStdout: true).trim()
|
||||
|
||||
env.DEPLOY_TARGET = ''
|
||||
|
||||
@@ -71,70 +96,42 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建新版本
|
||||
* - 先清理目标颜色的旧容器(如果残留)
|
||||
* - docker-compose build 构建新镜像(含 pnpm build)
|
||||
*/
|
||||
stage('构建新版本') {
|
||||
steps {
|
||||
script {
|
||||
def existingContainer = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}", returnStdout: true).trim()
|
||||
if (existingContainer) {
|
||||
echo "清理已存在的容器: ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}"
|
||||
sh "docker rm -f ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}"
|
||||
}
|
||||
sh "docker-compose build ${env.DEPLOY_TARGET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Nginx
|
||||
* - 检查 nginx 容器是否存在
|
||||
* - 不存在则启动并复制配置文件(首次部署)
|
||||
* - 存在则确保容器运行中
|
||||
*/
|
||||
stage('检查Nginx') {
|
||||
steps {
|
||||
script {
|
||||
def nginxExists = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-nginx", returnStdout: true).trim()
|
||||
if (!nginxExists) {
|
||||
echo "首次部署,初始化 Nginx 容器"
|
||||
sh "docker-compose up -d nginx"
|
||||
sh "sleep 3"
|
||||
// 复制配置文件到容器内
|
||||
sh "docker cp proxy_nginx.conf ${CONTAINER_PREFIX}-nginx:/etc/nginx/nginx.conf"
|
||||
sh "docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload"
|
||||
} else {
|
||||
def nginxRunning = sh(script: "docker ps -q -f name=${CONTAINER_PREFIX}-nginx", returnStdout: true).trim()
|
||||
if (!nginxRunning) {
|
||||
echo "Nginx 容器已停止,重新启动"
|
||||
sh "docker start ${CONTAINER_PREFIX}-nginx"
|
||||
sh "sleep 2"
|
||||
}
|
||||
echo "Nginx 容器已存在且运行中"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('启动新版本') {
|
||||
steps {
|
||||
script {
|
||||
sh "docker-compose up -d ${env.DEPLOY_TARGET}"
|
||||
// 等待 Nginx 容器启动
|
||||
def existingContainer = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'", returnStdout: true).trim()
|
||||
if (existingContainer) {
|
||||
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET}'"
|
||||
}
|
||||
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d ${env.DEPLOY_TARGET}'"
|
||||
sh 'sleep 5'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
* - 进入容器检测静态页面是否正常返回
|
||||
* - 最多重试 3 次,每次间隔 5 秒
|
||||
* - 全部失败则终止部署流程
|
||||
*/
|
||||
stage('检查Nginx') {
|
||||
steps {
|
||||
script {
|
||||
def nginxExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||
if (!nginxExists) {
|
||||
echo "首次部署,初始化 Nginx 容器"
|
||||
sh "${SSH_CMD} 'cd ${REMOTE_DIR} && docker compose up -d nginx'"
|
||||
sh 'sleep 3'
|
||||
} else {
|
||||
def nginxRunning = sh(script: "${SSH_CMD} 'docker ps -q -f name=${CONTAINER_PREFIX}-nginx'", returnStdout: true).trim()
|
||||
if (!nginxRunning) {
|
||||
echo "Nginx 容器已停止,重新启动"
|
||||
sh "${SSH_CMD} 'docker start ${CONTAINER_PREFIX}-nginx'"
|
||||
sh 'sleep 2'
|
||||
}
|
||||
echo "Nginx 容器已存在且运行中"
|
||||
}
|
||||
// docker cp proxy_nginx.conf 进容器
|
||||
sh "${SSH_CMD} 'docker cp ${REMOTE_DIR}/proxy_nginx.conf ${CONTAINER_PREFIX}-nginx:/etc/nginx/nginx.conf'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('健康检查') {
|
||||
steps {
|
||||
script {
|
||||
@@ -144,7 +141,7 @@ pipeline {
|
||||
|
||||
while (retryCount < maxRetries && !healthy) {
|
||||
try {
|
||||
sh "docker exec ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} curl -f ${HEALTH_URL}"
|
||||
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-${env.DEPLOY_TARGET} curl -f ${HEALTH_URL}'"
|
||||
healthy = true
|
||||
} catch (Exception e) {
|
||||
retryCount++
|
||||
@@ -163,38 +160,34 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换流量
|
||||
* - 修改 nginx 配置中的 proxy_pass 指向新版本
|
||||
* - nginx 不挂载宿主机文件,直接 sed -i 修改容器内配置
|
||||
* - reload 使新配置生效,实现零停机切换
|
||||
*/
|
||||
stage('切换流量') {
|
||||
steps {
|
||||
script {
|
||||
sh "docker exec ${CONTAINER_PREFIX}-nginx sed -i 's/proxy_pass http:\\/\\/\\(blue\\|green\\);/proxy_pass http:\\/\\/${env.DEPLOY_TARGET};/' /etc/nginx/nginx.conf"
|
||||
sh "docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload"
|
||||
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx sed -i \"s/proxy_pass http:\\/\\/\\(blue\\|green\\):80;/proxy_pass http:\\/\\/${env.DEPLOY_TARGET}:80;/\" /etc/nginx/nginx.conf'"
|
||||
sh "${SSH_CMD} 'docker exec ${CONTAINER_PREFIX}-nginx nginx -s reload'"
|
||||
echo "✅ 流量已切换到 ${env.DEPLOY_TARGET}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止并删除旧版本
|
||||
* - 不能用 docker-compose down,会删除所有服务(包括 nginx)
|
||||
* - 用 docker rm -f 只删除指定的旧颜色容器
|
||||
*/
|
||||
stage('删除旧版本') {
|
||||
steps {
|
||||
script {
|
||||
def otherExists = sh(script: "docker ps -aq -f name=${CONTAINER_PREFIX}-${env.OTHER_TARGET}", returnStdout: true).trim()
|
||||
def otherExists = sh(script: "${SSH_CMD} 'docker ps -aq -f name=${CONTAINER_PREFIX}-${env.OTHER_TARGET}'", returnStdout: true).trim()
|
||||
if (otherExists) {
|
||||
sh "docker rm -f ${CONTAINER_PREFIX}-${env.OTHER_TARGET}"
|
||||
sh "${SSH_CMD} 'docker rm -f ${CONTAINER_PREFIX}-${env.OTHER_TARGET}'"
|
||||
echo "旧版本 ${env.OTHER_TARGET} 已删除"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('清理') {
|
||||
steps {
|
||||
sh "${SSH_CMD} 'rm -f ${REMOTE_DIR}/${IMAGE_NAME}.tar'"
|
||||
sh "rm -f ${IMAGE_NAME}.tar"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
@@ -203,6 +196,7 @@ pipeline {
|
||||
}
|
||||
failure {
|
||||
echo '❌ 部署失败,请检查日志'
|
||||
sh "rm -f ${IMAGE_NAME}.tar || true"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,16 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AgentApplyProgress: typeof import('./src/components/AgentApplyProgress.vue')['default']
|
||||
AgentApplyProgressPanel: typeof import('./src/components/AgentApplyProgressPanel.vue')['default']
|
||||
AgentChatJobList: typeof import('./src/components/AgentChatJobList.vue')['default']
|
||||
AgentJobPreviewPanel: typeof import('./src/components/AgentJobPreviewPanel.vue')['default']
|
||||
AgentMatchJobAdd: typeof import('./src/components/AgentMatchJobAdd.vue')['default']
|
||||
AgentPendingJobListPanel: typeof import('./src/components/AgentPendingJobListPanel.vue')['default']
|
||||
AgentSettingPanel: typeof import('./src/components/AgentSettingPanel.vue')['default']
|
||||
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']
|
||||
@@ -44,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']
|
||||
@@ -53,6 +59,7 @@ declare module 'vue' {
|
||||
ResumeEditNameDialog: typeof import('./src/components/ResumeEditNameDialog.vue')['default']
|
||||
ResumeExportDialog: typeof import('./src/components/ResumeExportDialog.vue')['default']
|
||||
ResumeIssueFixDrawer: typeof import('./src/components/ResumeIssueFixDrawer.vue')['default']
|
||||
ResumeUploadDialog: typeof import('./src/components/ResumeUploadDialog.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SettingsDeleteAccountDialog: typeof import('./src/components/SettingsDeleteAccountDialog.vue')['default']
|
||||
|
||||
@@ -5,9 +5,6 @@ services:
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "10302:80"
|
||||
depends_on:
|
||||
- blue
|
||||
- green
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost/health"]
|
||||
interval: 30s
|
||||
@@ -16,11 +13,11 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128M
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
cpus: '0.5'
|
||||
|
||||
blue:
|
||||
build: .
|
||||
image: offerpie-web:latest
|
||||
container_name: offerpie-web-blue
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
@@ -41,7 +38,7 @@ services:
|
||||
cpus: '0.25'
|
||||
|
||||
green:
|
||||
build: .
|
||||
image: offerpie-web:latest
|
||||
container_name: offerpie-web-green
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
|
||||
@@ -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%。" />
|
||||
|
||||
@@ -13,18 +13,17 @@
|
||||
"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": {
|
||||
"@prerenderer/renderer-puppeteer": "^1.2.4",
|
||||
"@prerenderer/rollup-plugin": "^0.3.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"puppeteer": "^24.37.5",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
|
||||
@@ -2,5 +2,4 @@ allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
puppeteer: true
|
||||
vue-demi: true
|
||||
|
||||
@@ -23,22 +23,13 @@ http {
|
||||
# 请求体大小限制
|
||||
client_max_body_size 20m;
|
||||
|
||||
# 蓝绿 upstream
|
||||
upstream blue {
|
||||
server offerpie-web-blue:80;
|
||||
}
|
||||
|
||||
upstream green {
|
||||
server offerpie-web-green:80;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Java 后端 API 代理
|
||||
location /api/ {
|
||||
proxy_pass http://8.138.5.14:10202/api/;
|
||||
proxy_pass http://172.17.0.1:10202/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -54,7 +45,7 @@ http {
|
||||
|
||||
# Python AI 代理(去掉 /ai-api 前缀)
|
||||
location /ai-api/ {
|
||||
proxy_pass http://8.138.5.14:10502/;
|
||||
proxy_pass http://172.17.0.1:10502/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
@@ -74,7 +65,7 @@ http {
|
||||
|
||||
# 前端静态文件(蓝绿切换,默认 blue,部署时 sed 切换)
|
||||
location / {
|
||||
proxy_pass http://blue;
|
||||
proxy_pass http://blue:80;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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>
|
||||
|
||||
|
||||
@@ -148,9 +148,9 @@ export function applyJob(params: JobApplyParams) {
|
||||
/**
|
||||
* 取消投递 / 从待投递移除
|
||||
* DELETE /job/apply?jobId=xxx
|
||||
* @param jobId 岗位 ID
|
||||
* @param jobId 岗位 ID(字符串,避免大整数精度丢失)
|
||||
*/
|
||||
export function cancelApplyJob(jobId: number) {
|
||||
export function cancelApplyJob(jobId: string | number) {
|
||||
return request.delete<any, ApiResult>('/job/apply', {
|
||||
params: { jobId },
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -206,6 +211,8 @@ export interface ApplyListParams {
|
||||
pageSize?: number
|
||||
/** 投递状态筛选(0=已投递 1=面试中 2=有Offer 3=未通过 4=已结束) */
|
||||
status?: number | null
|
||||
/** 搜索关键词 */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +224,7 @@ export function fetchApplyList(params: ApplyListParams = {}) {
|
||||
pageNum: params.pageNum ?? 1,
|
||||
pageSize: params.pageSize ?? 10,
|
||||
...(params.status !== null && params.status !== undefined ? { status: params.status } : {}),
|
||||
...(params.keyword ? { keyword: params.keyword } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ export interface MessageDto {
|
||||
bizId: number
|
||||
/** 是否已读 */
|
||||
read: boolean
|
||||
/** 创建时间 */
|
||||
createTime: { seconds: number; nanos: number }
|
||||
/** 创建时间(毫秒时间戳) */
|
||||
createTime: number
|
||||
}
|
||||
|
||||
/** 分页响应结构 */
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 911 B |
|
After Width: | Height: | Size: 839 B |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 480 B |
|
After Width: | Height: | Size: 794 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,266 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
/* 申请进度面板样式 */
|
||||
.agent-apply-progress-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $bg-white;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
|
||||
/* 顶部标题栏 */
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.16rem 0.2rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.16rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
&__close-btn {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
&__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
padding: 0 0.2rem 0.14rem;
|
||||
}
|
||||
|
||||
/* 下拉选择容器 */
|
||||
&__select-wrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__select {
|
||||
padding: 0.08rem 0.12rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.06rem;
|
||||
font-size: 0.13rem;
|
||||
color: $text-dark;
|
||||
background: $bg-white;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
min-width: 1rem;
|
||||
|
||||
&:focus {
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
&__search {
|
||||
flex: 1;
|
||||
padding: 0.08rem 0.12rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.06rem;
|
||||
font-size: 0.13rem;
|
||||
color: $text-dark;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表区域(可滚动) */
|
||||
&__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.2rem 0.2rem;
|
||||
}
|
||||
|
||||
/* 单个岗位项 */
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.14rem 0.16rem;
|
||||
background: $bg-main;
|
||||
border-radius: 0.12rem;
|
||||
margin-bottom: 0.1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧信息区域 */
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 公司 Logo */
|
||||
&__logo {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 0.08rem;
|
||||
background: $bg-middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 0.22rem;
|
||||
height: 0.22rem;
|
||||
color: $text-middle;
|
||||
}
|
||||
}
|
||||
|
||||
/* 岗位详情 */
|
||||
&__detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__company {
|
||||
font-size: 0.13rem;
|
||||
font-weight: 500;
|
||||
color: $text-dark;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__position {
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
margin-top: 0.02rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
&__tags {
|
||||
display: flex;
|
||||
gap: 0.06rem;
|
||||
margin-top: 0.06rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: 0.11rem;
|
||||
color: $text-middle;
|
||||
background: $bg-white;
|
||||
padding: 0.02rem 0.08rem;
|
||||
border-radius: 0.04rem;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 匹配度环 */
|
||||
&__score {
|
||||
position: relative;
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__score-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.1rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 状态下拉 */
|
||||
&__status-select {
|
||||
padding: 0.05rem 0.1rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.04rem;
|
||||
font-size: 0.12rem;
|
||||
color: $text-dark;
|
||||
background: $bg-white;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
|
||||
&:focus {
|
||||
border-color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 删除按钮 */
|
||||
&__delete-btn {
|
||||
width: 0.2rem;
|
||||
height: 0.2rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover svg circle {
|
||||
fill: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
&__loading {
|
||||
text-align: center;
|
||||
padding: 0.16rem 0;
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
/* 暂无更多数据 */
|
||||
&__no-more {
|
||||
text-align: center;
|
||||
padding: 0.16rem 0;
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.13rem;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
border-radius: 0.1rem;
|
||||
padding: 0.14rem 0.16rem;
|
||||
margin-bottom: 0.08rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
/* Agent岗位预览面板样式 */
|
||||
.agent-job-preview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $bg-white;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
|
||||
/* 顶部标题栏 */
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.16rem 0.2rem;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
/* 返回按钮 */
|
||||
&__back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.06rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.15rem;
|
||||
font-weight: 500;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
&__back-icon {
|
||||
width: 0.16rem;
|
||||
height: 0.16rem;
|
||||
}
|
||||
|
||||
/* 添加按钮 */
|
||||
&__add-btn {
|
||||
padding: 0.06rem 0.16rem;
|
||||
background: $btn-dark;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.06rem;
|
||||
font-size: 0.13rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $btn-dark-hover;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移出按钮 */
|
||||
&__remove-btn {
|
||||
padding: 0.06rem 0.16rem;
|
||||
background: $bg-white;
|
||||
color: $text-middle;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.06rem;
|
||||
font-size: 0.13rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $bg-main;
|
||||
color: $danger;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 内容区域(可滚动) */
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
/* 岗位卡片 */
|
||||
&__card {
|
||||
background: $bg-main;
|
||||
border-radius: 0.12rem;
|
||||
padding: 0.2rem;
|
||||
margin-bottom: 0.16rem;
|
||||
}
|
||||
|
||||
&__card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
&__card-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 公司行 */
|
||||
&__company-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.08rem;
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
|
||||
&__company-icon {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border-radius: 0.06rem;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-middle;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 0.18rem;
|
||||
height: 0.18rem;
|
||||
color: $text-middle;
|
||||
}
|
||||
}
|
||||
|
||||
&__company-name {
|
||||
font-size: 0.14rem;
|
||||
font-weight: 500;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
&__time {
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
margin-left: 0.06rem;
|
||||
}
|
||||
|
||||
/* 岗位标题 */
|
||||
&__job-title {
|
||||
font-size: 0.17rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
margin: 0.06rem 0 0.1rem;
|
||||
}
|
||||
|
||||
/* 元信息 */
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.16rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.04rem;
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
}
|
||||
|
||||
&__meta-icon {
|
||||
width: 0.14rem;
|
||||
height: 0.14rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 匹配度环 */
|
||||
&__match {
|
||||
position: relative;
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__ring-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__match-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.13rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 内容段落 */
|
||||
&__section {
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
&__section-title {
|
||||
font-size: 0.14rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
&__section-content {
|
||||
font-size: 0.13rem;
|
||||
color: $text-middle;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@
|
||||
gap: 0.12rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// 公司 Logo
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
/* 待投递岗位列表面板样式 */
|
||||
.agent-pending-job-list-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $bg-white;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
|
||||
/* 顶部标题栏 */
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.16rem 0.2rem;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.16rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
&__close-btn {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 列表区域(可滚动) */
|
||||
&__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.2rem 0.2rem;
|
||||
}
|
||||
|
||||
/* 单个岗位项 */
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.14rem 0.16rem;
|
||||
background: $bg-main;
|
||||
border-radius: 0.12rem;
|
||||
margin-bottom: 0.1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 左侧信息区域 */
|
||||
&__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.12rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 公司 Logo */
|
||||
&__logo {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 0.08rem;
|
||||
background: $bg-middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
svg {
|
||||
width: 0.22rem;
|
||||
height: 0.22rem;
|
||||
color: $text-middle;
|
||||
}
|
||||
}
|
||||
|
||||
/* 岗位详情 */
|
||||
&__detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__company {
|
||||
font-size: 0.13rem;
|
||||
font-weight: 500;
|
||||
color: $text-dark;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__position {
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
margin-top: 0.02rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
&__tags {
|
||||
display: flex;
|
||||
gap: 0.06rem;
|
||||
margin-top: 0.06rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
font-size: 0.11rem;
|
||||
color: $text-middle;
|
||||
background: $bg-white;
|
||||
padding: 0.02rem 0.08rem;
|
||||
border-radius: 0.04rem;
|
||||
}
|
||||
|
||||
/* 右侧区域 */
|
||||
&__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.14rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 匹配度环 */
|
||||
&__score {
|
||||
position: relative;
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
}
|
||||
|
||||
&__ring {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__score-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.1rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 移出按钮 */
|
||||
&__remove-btn {
|
||||
padding: 0.06rem 0.16rem;
|
||||
background: $text-dark;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.12rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
/* 加载中 */
|
||||
&__loading {
|
||||
text-align: center;
|
||||
padding: 0.16rem 0;
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
/* 暂无更多数据 */
|
||||
&__no-more {
|
||||
text-align: center;
|
||||
padding: 0.16rem 0;
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
&__empty {
|
||||
text-align: center;
|
||||
padding: 0.4rem 0;
|
||||
font-size: 0.13rem;
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
@use '../variables' as *;
|
||||
|
||||
/* Agent设置面板样式 */
|
||||
.agent-setting-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: $bg-white;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
|
||||
/* 顶部标题栏 */
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.16rem 0.2rem;
|
||||
border-bottom: 1px solid $border-color;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 0.16rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 关闭按钮 */
|
||||
&__close-btn {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 内容区域(可滚动) */
|
||||
&__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
/* ========== 设置项区块 ========== */
|
||||
&__section {
|
||||
margin-bottom: 0.2rem;
|
||||
border-bottom: 1px solid $border-color;
|
||||
padding-bottom: 0.16rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块标题行 */
|
||||
&__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 0.04rem 0;
|
||||
}
|
||||
|
||||
/* 区块标题 */
|
||||
&__section-title {
|
||||
font-size: 0.18rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
}
|
||||
|
||||
/* 编辑按钮区域(文字 + 箭头) */
|
||||
&__section-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.04rem;
|
||||
}
|
||||
|
||||
&__section-action-text {
|
||||
font-size: 0.12rem;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
/* 方向箭头 */
|
||||
&__arrow {
|
||||
width: 0.14rem;
|
||||
height: 0.14rem;
|
||||
color: $accent;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
/* 展开时箭头朝上 */
|
||||
&__arrow--up {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 收起时的简要信息预览 */
|
||||
&__section-summary {
|
||||
margin-top: 0.1rem;
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__section-empty {
|
||||
color: $text-light;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 展开时的内容区域 */
|
||||
&__section-content {
|
||||
margin-top: 0.14rem;
|
||||
}
|
||||
|
||||
/* ========== 求职目标标签 ========== */
|
||||
&__goal-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.08rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
&__goal-tag {
|
||||
display: inline-block;
|
||||
padding: 0.04rem 0.12rem;
|
||||
background: $theme-color;
|
||||
border: 1px solid $accent;
|
||||
border-radius: 0.14rem;
|
||||
font-size: 0.12rem;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
/* 编辑图标 */
|
||||
&__edit-icon {
|
||||
width: 0.13rem;
|
||||
height: 0.13rem;
|
||||
color: $accent;
|
||||
}
|
||||
|
||||
/* ========== 浏览器插件 ========== */
|
||||
&__browser-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.12rem;
|
||||
}
|
||||
|
||||
&__browser-btn {
|
||||
padding: 0.08rem 0.18rem;
|
||||
background: $bg-white;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.08rem;
|
||||
font-size: 0.13rem;
|
||||
color: $text-dark;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
background: $theme-color;
|
||||
}
|
||||
}
|
||||
|
||||
&__download-wrap {
|
||||
margin-top: 0.16rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__download-btn {
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: $gradient-bg;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.24rem;
|
||||
font-size: 0.14rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* 浏览器安装指引弹窗图片 */
|
||||
&__guide-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 求职助手配置表单 ========== */
|
||||
&__config-group {
|
||||
margin-top: 0.16rem;
|
||||
}
|
||||
|
||||
&__config-label {
|
||||
font-size: 0.13rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
|
||||
&__config-sub {
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
margin-bottom: 0.06rem;
|
||||
}
|
||||
|
||||
&__config-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
&__config-btn {
|
||||
padding: 0.07rem 0.16rem;
|
||||
background: $bg-white;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.2rem;
|
||||
font-size: 0.12rem;
|
||||
color: $text-dark;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: $accent;
|
||||
background: $theme-color;
|
||||
color: $accent;
|
||||
}
|
||||
}
|
||||
|
||||
&__config-selects {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
&__config-select {
|
||||
min-width: 1.2rem;
|
||||
|
||||
&--full {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 简历选择行 */
|
||||
&__resume-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
&__resume-icon {
|
||||
width: 0.18rem;
|
||||
height: 0.18rem;
|
||||
color: $text-middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 开关行 */
|
||||
&__switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
&__switch-text {
|
||||
font-size: 0.12rem;
|
||||
color: $text-dark;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
&__switch-sub {
|
||||
font-size: 0.11rem;
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
font-size: 0.12rem;
|
||||
color: $text-light;
|
||||
cursor: help;
|
||||
margin-left: 0.04rem;
|
||||
}
|
||||
|
||||
/* 提交按钮 */
|
||||
&__submit-wrap {
|
||||
margin-top: 0.24rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__submit-btn {
|
||||
padding: 0.1rem 0.5rem;
|
||||
background: $gradient-bg;
|
||||
color: $bg-white;
|
||||
border: none;
|
||||
border-radius: 0.24rem;
|
||||
font-size: 0.14rem;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +57,12 @@
|
||||
margin-bottom: 0.14rem;
|
||||
|
||||
&:hover {
|
||||
background: darken(#F3F4F5, 3%);
|
||||
background: color.adjust(#F3F4F5, $lightness: -3%);
|
||||
}
|
||||
|
||||
// 拖拽悬停时背景色变化
|
||||
&.is-dragover {
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
@use 'sass:color';
|
||||
@use '../variables' as *;
|
||||
|
||||
// ==================== 简历上传弹窗 ====================
|
||||
.resume-upload-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: $overlay-bg;
|
||||
z-index: 2200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.resume-upload-dialog {
|
||||
width: 5.6rem;
|
||||
background: $bg-white;
|
||||
border-radius: 0.12rem;
|
||||
padding: 0.32rem 0.36rem;
|
||||
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
|
||||
|
||||
// 标题行
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.08rem;
|
||||
}
|
||||
|
||||
// 标题
|
||||
&__title {
|
||||
font-size: 0.2rem;
|
||||
font-weight: 700;
|
||||
color: $text-dark;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
&__close {
|
||||
width: 0.28rem;
|
||||
height: 0.28rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: $text-middle;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.04rem;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: $bg-main;
|
||||
}
|
||||
}
|
||||
|
||||
// 副标题说明
|
||||
&__desc {
|
||||
font-size: 0.13rem;
|
||||
color: $accent;
|
||||
margin: 0 0 0.2rem 0;
|
||||
}
|
||||
|
||||
// 拖拽上传区域
|
||||
&__drop-zone {
|
||||
border: 1px dashed $border-color;
|
||||
border-radius: 0.1rem;
|
||||
padding: 0.5rem 0.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
background: $theme-color;
|
||||
margin-bottom: 0.28rem;
|
||||
|
||||
&:hover {
|
||||
border-color: $accent;
|
||||
}
|
||||
|
||||
// 拖拽悬停状态 — 背景色加深
|
||||
&.is-dragover {
|
||||
background: color.adjust(#F6FCFC, $lightness: -4%);
|
||||
border-color: $accent;
|
||||
}
|
||||
|
||||
// 已选择文件状态
|
||||
&.has-file {
|
||||
padding: 0.3rem 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
// 上传图标(加号圆圈)
|
||||
&__drop-icon {
|
||||
width: 0.52rem;
|
||||
height: 0.52rem;
|
||||
border-radius: 50%;
|
||||
background: $bg-white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $accent;
|
||||
margin-bottom: 0.14rem;
|
||||
box-shadow: 0 0.02rem 0.06rem rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
// 主提示文字
|
||||
&__drop-text {
|
||||
font-size: 0.14rem;
|
||||
font-weight: 600;
|
||||
color: $text-dark;
|
||||
margin: 0 0 0.06rem 0;
|
||||
}
|
||||
|
||||
// 格式提示
|
||||
&__drop-hint {
|
||||
font-size: 0.12rem;
|
||||
color: $text-middle;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
// 已选文件信息
|
||||
&__file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
&__file-icon {
|
||||
font-size: 0.2rem;
|
||||
}
|
||||
|
||||
&__file-name {
|
||||
font-size: 0.14rem;
|
||||
color: $text-dark;
|
||||
font-weight: 500;
|
||||
max-width: 3.6rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__file-remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: $text-middle;
|
||||
font-size: 0.14rem;
|
||||
cursor: pointer;
|
||||
padding: 0.02rem 0.04rem;
|
||||
border-radius: 0.03rem;
|
||||
transition: color 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮区域
|
||||
&__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// 按钮通用
|
||||
&__btn {
|
||||
height: 0.42rem;
|
||||
border-radius: 0.21rem;
|
||||
font-size: 0.14rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
border: none;
|
||||
|
||||
// 取消按钮
|
||||
&--cancel {
|
||||
width: 1.4rem;
|
||||
background: $bg-main;
|
||||
color: $text-dark;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// 确认上传按钮(渐变色)
|
||||
&--confirm {
|
||||
width: 1.8rem;
|
||||
background: $gradient-bg;
|
||||
color: $bg-white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,9 @@
|
||||
@use './components/settings-delete-account-dialog.scss';
|
||||
@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';
|
||||
@@ -67,6 +70,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Element Plus MessageBox rem 适配修正 ====================
|
||||
.el-overlay {
|
||||
// 确保遮罩层 z-index 足够高
|
||||
z-index: 2100 !important;
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
width: 420px !important;
|
||||
padding: 20px !important;
|
||||
border-radius: 8px !important;
|
||||
font-size: 14px !important;
|
||||
|
||||
&__header {
|
||||
padding: 0 0 12px 0 !important;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
&__headerbtn {
|
||||
top: 20px !important;
|
||||
right: 20px !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding: 12px 0 !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
&__message {
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
|
||||
p {
|
||||
font-size: 14px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 20px !important;
|
||||
}
|
||||
|
||||
&__btns {
|
||||
padding: 8px 0 0 0 !important;
|
||||
|
||||
.el-button {
|
||||
font-size: 14px !important;
|
||||
padding: 8px 20px !important;
|
||||
height: 32px !important;
|
||||
border-radius: 4px !important;
|
||||
min-width: 60px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Element Plus Loading rem 适配修正 + 品牌色覆盖 ====================
|
||||
.el-loading-mask {
|
||||
// 全屏 loading 遮罩层 z-index 确保最高
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 自定义滚动条
|
||||
@@ -246,11 +246,40 @@
|
||||
|
||||
// ==================== 第2步:确认目标 ====================
|
||||
|
||||
// 返回上一步按钮 — 位于每步容器左上角
|
||||
&__back-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.04rem;
|
||||
padding: 0.06rem 0.12rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $text-middle;
|
||||
font-size: 0.13rem;
|
||||
cursor: pointer;
|
||||
border-radius: 0.04rem;
|
||||
transition: color 0.2s ease, background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: $text-dark;
|
||||
background: $bg-middle;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 第2步整体容器
|
||||
&__step2 {
|
||||
width: 100%;
|
||||
max-width: 7rem;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
padding-top: 0.36rem;
|
||||
}
|
||||
|
||||
// 对话区域
|
||||
@@ -578,6 +607,8 @@
|
||||
gap: 0.24rem;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-top: 0.36rem;
|
||||
}
|
||||
|
||||
// 右侧表单包裹
|
||||
@@ -824,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;
|
||||
@@ -833,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;
|
||||
@@ -1085,7 +1116,7 @@
|
||||
|
||||
// 工具按钮
|
||||
&__tool-btn {
|
||||
width: 0.28rem;
|
||||
width: 0.38rem;
|
||||
height: 0.28rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
@@ -1097,6 +1128,8 @@
|
||||
color: $text-middle;
|
||||
border-radius: 0.06rem;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 600;
|
||||
|
||||
|
||||
svg {
|
||||
width: 0.18rem;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 不感兴趣反馈弹窗 ====================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<!-- 申请进度面板 — 右侧面板显示已投递岗位列表(带筛选、搜索、滚动分页) -->
|
||||
<div class="agent-apply-progress-panel">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="agent-apply-progress-panel__header">
|
||||
<span class="agent-apply-progress-panel__title">申请进度</span>
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="agent-apply-progress-panel__close-btn" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="#1A1A2E" />
|
||||
<path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏:状态下拉 + 搜索框 -->
|
||||
<div class="agent-apply-progress-panel__filter">
|
||||
<!-- 状态下拉选择 -->
|
||||
<div class="agent-apply-progress-panel__select-wrap">
|
||||
<select v-model="selectedStatus" class="agent-apply-progress-panel__select" @change="handleFilterChange">
|
||||
<option :value="null">全部</option>
|
||||
<option :value="0">已投递</option>
|
||||
<option :value="1">面试中</option>
|
||||
<option :value="2">有Offer</option>
|
||||
<option :value="3">未通过</option>
|
||||
<option :value="4">已结束</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 搜索框 -->
|
||||
<input
|
||||
v-model="keyword"
|
||||
class="agent-apply-progress-panel__search"
|
||||
placeholder="搜索岗位/公司"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容(可滚动,触底加载更多) -->
|
||||
<div ref="listRef" class="agent-apply-progress-panel__list" @scroll="handleScroll">
|
||||
<!-- 岗位项 -->
|
||||
<div
|
||||
v-for="job in jobList"
|
||||
:key="job.id"
|
||||
class="agent-apply-progress-panel__item"
|
||||
>
|
||||
<!-- 左侧信息区域 -->
|
||||
<div class="agent-apply-progress-panel__info">
|
||||
<!-- 公司 Logo -->
|
||||
<div class="agent-apply-progress-panel__logo">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 岗位详情 -->
|
||||
<div class="agent-apply-progress-panel__detail">
|
||||
<div class="agent-apply-progress-panel__company">{{ job.companyShortName || job.companyName }}</div>
|
||||
<div class="agent-apply-progress-panel__position">{{ job.title }}</div>
|
||||
<!-- 标签 -->
|
||||
<div class="agent-apply-progress-panel__tags">
|
||||
<span v-if="job.regionName" class="agent-apply-progress-panel__tag">{{ job.regionName }}</span>
|
||||
<span v-if="job.categoryName" class="agent-apply-progress-panel__tag">{{ job.categoryName }}</span>
|
||||
<span v-for="tag in (job.tags || []).slice(0, 2)" :key="tag" class="agent-apply-progress-panel__tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:匹配度 + 状态下拉 + 删除按钮 -->
|
||||
<div class="agent-apply-progress-panel__right">
|
||||
<!-- 匹配度环形 -->
|
||||
<div class="agent-apply-progress-panel__score">
|
||||
<svg class="agent-apply-progress-panel__ring" viewBox="0 0 44 44">
|
||||
<circle cx="22" cy="22" r="18" fill="none" stroke="#E8E8E8" stroke-width="3" />
|
||||
<circle
|
||||
cx="22" cy="22" r="18" fill="none"
|
||||
stroke="#4FC2C9" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="2 * Math.PI * 18"
|
||||
:stroke-dashoffset="2 * Math.PI * 18 * (1 - (job.matchScore || 0) / 100)"
|
||||
transform="rotate(-90 22 22)"
|
||||
/>
|
||||
</svg>
|
||||
<span class="agent-apply-progress-panel__score-text">{{ job.matchScore || 0 }}%</span>
|
||||
</div>
|
||||
<!-- 状态下拉 -->
|
||||
<select
|
||||
class="agent-apply-progress-panel__status-select"
|
||||
:value="job.status"
|
||||
@change="handleStatusChange(job, $event)"
|
||||
>
|
||||
<option :value="0">已投递</option>
|
||||
<option :value="1">面试中</option>
|
||||
<option :value="2">有Offer</option>
|
||||
<option :value="3">未通过</option>
|
||||
<option :value="4">已结束</option>
|
||||
</select>
|
||||
<!-- 删除按钮 -->
|
||||
<button class="agent-apply-progress-panel__delete-btn" @click="handleDelete(job)">
|
||||
<svg viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" fill="#BFBFBF" />
|
||||
<path d="M5.5 5.5l5 5M10.5 5.5l-5 5" stroke="#fff" stroke-width="1.2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<div v-if="loadingMore" class="agent-apply-progress-panel__loading">加载中...</div>
|
||||
<!-- 没有更多数据 -->
|
||||
<div v-if="noMore && jobList.length > 0" class="agent-apply-progress-panel__no-more">暂无更多数据</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && jobList.length === 0 && !loadingMore" class="agent-apply-progress-panel__empty">暂无数据</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchApplyList } from '@/api/jobs'
|
||||
import { applyJob, cancelApplyJob } from '@/api/agent'
|
||||
import type { JobListItem } from '@/api/jobs'
|
||||
|
||||
/** 事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 关闭面板 */
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
/** 岗位列表数据 */
|
||||
const jobList = ref<JobListItem[]>([])
|
||||
|
||||
/** 当前页码 */
|
||||
const currentPage = ref(1)
|
||||
|
||||
/** 每页条数 */
|
||||
const pageSize = 30
|
||||
|
||||
/** 是否正在加载首页 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 是否正在加载更多 */
|
||||
const loadingMore = ref(false)
|
||||
|
||||
/** 是否没有更多数据 */
|
||||
const noMore = ref(false)
|
||||
|
||||
/** 当前选中的状态筛选 */
|
||||
const selectedStatus = ref<number | null>(null)
|
||||
|
||||
/** 搜索关键词 */
|
||||
const keyword = ref('')
|
||||
|
||||
/** 列表容器 ref */
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 加载投递列表 */
|
||||
async function loadList(page: number) {
|
||||
if (page === 1) {
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await fetchApplyList({
|
||||
pageNum: page,
|
||||
pageSize,
|
||||
status: selectedStatus.value,
|
||||
keyword: keyword.value || undefined,
|
||||
})
|
||||
if (res.code === '0' && res.data) {
|
||||
const list = res.data.list || []
|
||||
if (page === 1) {
|
||||
jobList.value = list
|
||||
} else {
|
||||
jobList.value.push(...list)
|
||||
}
|
||||
/* 判断是否还有更多数据 */
|
||||
const total = Number(res.data.total || 0)
|
||||
if (jobList.value.length >= total || list.length < pageSize) {
|
||||
noMore.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error('[AgentApplyProgressPanel] 加载投递列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 重置列表并重新加载 */
|
||||
function resetAndLoad() {
|
||||
currentPage.value = 1
|
||||
jobList.value = []
|
||||
noMore.value = false
|
||||
loadList(1)
|
||||
}
|
||||
|
||||
/** 筛选状态变更 */
|
||||
function handleFilterChange() {
|
||||
resetAndLoad()
|
||||
}
|
||||
|
||||
/** 搜索框回车 */
|
||||
function handleSearch() {
|
||||
resetAndLoad()
|
||||
}
|
||||
|
||||
/** 滚动触底加载更多 */
|
||||
function handleScroll() {
|
||||
if (!listRef.value || loadingMore.value || noMore.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.value
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||
currentPage.value++
|
||||
loadList(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 修改单个岗位的投递状态 */
|
||||
async function handleStatusChange(job: JobListItem, event: Event) {
|
||||
const newStatus = Number((event.target as HTMLSelectElement).value)
|
||||
try {
|
||||
await applyJob({ jobId: job.id, status: newStatus })
|
||||
job.status = newStatus
|
||||
ElMessage.success('状态已更新')
|
||||
} catch {
|
||||
ElMessage.error('状态更新失败')
|
||||
/* 恢复原值 — 触发视图刷新 */
|
||||
;(event.target as HTMLSelectElement).value = String(job.status)
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除(取消投递) */
|
||||
async function handleDelete(job: JobListItem) {
|
||||
try {
|
||||
await cancelApplyJob(job.id)
|
||||
const idx = jobList.value.findIndex(j => j.id === job.id)
|
||||
if (idx !== -1) {
|
||||
jobList.value.splice(idx, 1)
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
} catch {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../assets/styles/components/agent-apply-progress-panel';
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
:key="job.id"
|
||||
class="agent-chat-job-list__item"
|
||||
v-if="displayJobs.length>0"
|
||||
@click="handleClickJob(job)"
|
||||
>
|
||||
<!-- 左侧:公司图标 + 岗位信息 -->
|
||||
<div class="agent-chat-job-list__info">
|
||||
@@ -79,6 +80,8 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
/** 点击查看全部岗位 */
|
||||
(e: 'viewAll'): void
|
||||
/** 点击岗位查看详情 */
|
||||
(e: 'clickJob', job: AgentRecommendJob): void
|
||||
}>()
|
||||
|
||||
/** 只显示前3个岗位 */
|
||||
@@ -88,6 +91,11 @@ const displayJobs = computed(() => props.jobs.slice(0, 3))
|
||||
function handleViewAll() {
|
||||
emit('viewAll')
|
||||
}
|
||||
|
||||
/** 点击岗位 — 通知父组件打开岗位预览 */
|
||||
function handleClickJob(job: AgentRecommendJob) {
|
||||
emit('clickJob', job)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<!-- Agent岗位预览面板 — 右侧面板显示岗位详情 -->
|
||||
<div class="agent-job-preview-panel" v-loading="loading" element-loading-text="加载中...">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="agent-job-preview-panel__header">
|
||||
<div class="agent-job-preview-panel__back" @click="handleBack">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-job-preview-panel__back-icon">
|
||||
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>岗位详情</span>
|
||||
</div>
|
||||
<!-- 添加按钮 — 仅未投递时显示 -->
|
||||
<button
|
||||
v-if="jobDetail && (jobDetail.applicationStatus === null || jobDetail.applicationStatus === undefined)"
|
||||
class="agent-job-preview-panel__add-btn"
|
||||
:disabled="addLoading"
|
||||
@click="handleAdd"
|
||||
>+ 添加</button>
|
||||
<!-- 移出按钮 — 仅待投递状态时显示 -->
|
||||
<button
|
||||
v-else-if="jobDetail && jobDetail.applicationStatus === -1"
|
||||
class="agent-job-preview-panel__remove-btn"
|
||||
:disabled="addLoading"
|
||||
@click="handleRemove"
|
||||
>移出</button>
|
||||
</div>
|
||||
|
||||
<!-- 岗位详情内容 -->
|
||||
<div v-if="jobDetail" class="agent-job-preview-panel__body">
|
||||
<!-- 岗位卡片 -->
|
||||
<div class="agent-job-preview-panel__card">
|
||||
<div class="agent-job-preview-panel__card-top">
|
||||
<!-- 左侧信息 -->
|
||||
<div class="agent-job-preview-panel__card-left">
|
||||
<div class="agent-job-preview-panel__company-row">
|
||||
<div class="agent-job-preview-panel__company-icon">
|
||||
<img v-if="jobDetail.companyLogoUrl" :src="jobDetail.companyLogoUrl" :alt="jobDetail.companyShortName" />
|
||||
<svg v-else viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="agent-job-preview-panel__company-name">{{ jobDetail.companyShortName || jobDetail.companyName }}</span>
|
||||
<span class="agent-job-preview-panel__time">{{ formatTime }}</span>
|
||||
</div>
|
||||
<h3 class="agent-job-preview-panel__job-title">{{ jobDetail.jobTitle }}</h3>
|
||||
<!-- 岗位元信息 -->
|
||||
<div class="agent-job-preview-panel__meta">
|
||||
<span v-if="jobDetail.regionName" class="agent-job-preview-panel__meta-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-job-preview-panel__meta-icon">
|
||||
<circle cx="8" cy="6.5" r="2.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M8 14s-5-4-5-7.5a5 5 0 0110 0C13 10 8 14 8 14z" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
{{ jobDetail.regionName }}
|
||||
</span>
|
||||
<span class="agent-job-preview-panel__meta-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-job-preview-panel__meta-icon">
|
||||
<rect x="2" y="3" width="12" height="11" rx="1.5" stroke="currentColor" stroke-width="1.2"/>
|
||||
<path d="M2 6.5h12" stroke="currentColor" stroke-width="1.2"/>
|
||||
</svg>
|
||||
{{ formatEmploymentType(jobDetail.employmentType) }}
|
||||
</span>
|
||||
<span v-if="jobDetail.salary" class="agent-job-preview-panel__meta-item">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-job-preview-panel__meta-icon">
|
||||
<path d="M8 1v14M4 4h8M3 8h10M5 12h6" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{{ jobDetail.salary }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧匹配度环 -->
|
||||
<div class="agent-job-preview-panel__match">
|
||||
<svg viewBox="0 0 60 60" class="agent-job-preview-panel__ring-svg">
|
||||
<circle cx="30" cy="30" r="25" stroke-width="4" stroke="#E8E8E8" fill="none" />
|
||||
<circle cx="30" cy="30" r="25" stroke-width="4" fill="none"
|
||||
stroke="#4FC2C9" stroke-linecap="round"
|
||||
:stroke-dasharray="2 * Math.PI * 25"
|
||||
:stroke-dashoffset="2 * Math.PI * 25 * (1 - (jobDetail.matchScore || 0) / 100)"
|
||||
transform="rotate(-90 30 30)"
|
||||
/>
|
||||
</svg>
|
||||
<span class="agent-job-preview-panel__match-text">{{ jobDetail.matchScore || 0 }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 岗位职责 -->
|
||||
<div v-if="jobDetail.description" class="agent-job-preview-panel__section">
|
||||
<h4 class="agent-job-preview-panel__section-title">岗位职责</h4>
|
||||
<div class="agent-job-preview-panel__section-content">{{ jobDetail.description }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 任职要求 -->
|
||||
<div v-if="jobDetail.requirement" class="agent-job-preview-panel__section">
|
||||
<h4 class="agent-job-preview-panel__section-title">任职要求</h4>
|
||||
<div class="agent-job-preview-panel__section-content">{{ jobDetail.requirement }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 加分项 -->
|
||||
<div v-if="jobDetail.bonus" class="agent-job-preview-panel__section">
|
||||
<h4 class="agent-job-preview-panel__section-title">加分项</h4>
|
||||
<div class="agent-job-preview-panel__section-content">{{ jobDetail.bonus }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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<{
|
||||
/** 岗位 ID */
|
||||
jobId: string
|
||||
/** 投递状态(用于控制添加按钮显示) */
|
||||
applicationStatus?: number | null
|
||||
}>()
|
||||
|
||||
/** 事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 返回上一个面板模式 */
|
||||
(e: 'back'): void
|
||||
/** 添加岗位到待投递 */
|
||||
(e: 'add', jobId: string): void
|
||||
/** 从待投递移除岗位 */
|
||||
(e: 'remove', jobId: string): void
|
||||
}>()
|
||||
|
||||
/** 加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 添加按钮加载状态 */
|
||||
const addLoading = ref(false)
|
||||
|
||||
/** 岗位详情数据(扩展 applicationStatus 字段) */
|
||||
const jobDetail = ref<(JobDetailData & { applicationStatus?: number | null }) | null>(null)
|
||||
|
||||
/** 监听 applicationStatus prop 变化,同步到 jobDetail */
|
||||
watch(() => props.applicationStatus, (newVal) => {
|
||||
if (jobDetail.value) {
|
||||
jobDetail.value.applicationStatus = newVal ?? null
|
||||
}
|
||||
})
|
||||
|
||||
/** 格式化时间显示 */
|
||||
const formatTime = computed(() => {
|
||||
// 暂时不显示具体时间,可后续扩展
|
||||
return ''
|
||||
})
|
||||
|
||||
/** 工作类型映射 — 使用全局统一的 formatEmploymentType */
|
||||
|
||||
/** 加载岗位详情 */
|
||||
async function loadDetail() {
|
||||
if (!props.jobId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchJobDetail(props.jobId)
|
||||
if (res.code === '0' && res.data) {
|
||||
jobDetail.value = { ...res.data, applicationStatus: props.applicationStatus ?? null }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AgentJobPreviewPanel] 加载岗位详情失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回按钮 */
|
||||
function handleBack() {
|
||||
emit('back')
|
||||
}
|
||||
|
||||
/** 添加到待投递 */
|
||||
function handleAdd() {
|
||||
emit('add', props.jobId)
|
||||
}
|
||||
|
||||
/** 从待投递移除 */
|
||||
function handleRemove() {
|
||||
emit('remove', props.jobId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../assets/styles/components/agent-job-preview-panel';
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@
|
||||
class="agent-match-job-add__item"
|
||||
>
|
||||
<!-- 左侧:公司图标 + 岗位信息 -->
|
||||
<div class="agent-match-job-add__info">
|
||||
<div class="agent-match-job-add__info" @click="handleClickJob(job)">
|
||||
<!-- 公司 Logo -->
|
||||
<div class="agent-match-job-add__logo">
|
||||
<img v-if="job.companyLogoUrl" :src="job.companyLogoUrl" :alt="job.companyShortName" />
|
||||
@@ -116,6 +116,8 @@ const emit = defineEmits<{
|
||||
(e: 'toggle', job: AgentRecommendJob): void
|
||||
/** 全部添加操作 */
|
||||
(e: 'addAll'): void
|
||||
/** 点击岗位查看详情 */
|
||||
(e: 'clickJob', job: AgentRecommendJob): void
|
||||
}>()
|
||||
|
||||
/** 点击添加/移除 — 通知父组件处理 */
|
||||
@@ -123,6 +125,11 @@ function handleToggle(job: AgentRecommendJob) {
|
||||
emit('toggle', job)
|
||||
}
|
||||
|
||||
/** 点击岗位 — 通知父组件打开岗位预览 */
|
||||
function handleClickJob(job: AgentRecommendJob) {
|
||||
emit('clickJob', job)
|
||||
}
|
||||
|
||||
/** 全部添加 — 通知父组件处理(只传 applicationStatus 为 null 的岗位) */
|
||||
function handleAddAll() {
|
||||
emit('addAll')
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<!-- 待投递岗位列表面板 — 右侧面板显示全部待投递岗位(滚动分页) -->
|
||||
<div class="agent-pending-job-list-panel">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="agent-pending-job-list-panel__header">
|
||||
<span class="agent-pending-job-list-panel__title">待投递岗位列表</span>
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="agent-pending-job-list-panel__close-btn" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="#1A1A2E" />
|
||||
<path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 列表内容(可滚动,触底加载更多) -->
|
||||
<div ref="listRef" class="agent-pending-job-list-panel__list" @scroll="handleScroll">
|
||||
<!-- 岗位项 -->
|
||||
<div
|
||||
v-for="job in jobList"
|
||||
:key="job.id"
|
||||
class="agent-pending-job-list-panel__item"
|
||||
>
|
||||
<!-- 左侧信息区域 -->
|
||||
<div class="agent-pending-job-list-panel__info">
|
||||
<!-- 公司 Logo -->
|
||||
<div class="agent-pending-job-list-panel__logo">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="7" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
|
||||
<path d="M7 7V5a2 2 0 012-2h6a2 2 0 012 2v2" stroke="currentColor" stroke-width="1.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 岗位详情 -->
|
||||
<div class="agent-pending-job-list-panel__detail">
|
||||
<div class="agent-pending-job-list-panel__company">{{ job.companyShortName || job.companyName }}</div>
|
||||
<div class="agent-pending-job-list-panel__position">{{ job.title }}</div>
|
||||
<!-- 标签 -->
|
||||
<div class="agent-pending-job-list-panel__tags">
|
||||
<span v-if="job.regionName" class="agent-pending-job-list-panel__tag">{{ job.regionName }}</span>
|
||||
<span v-if="job.categoryName" class="agent-pending-job-list-panel__tag">{{ job.categoryName }}</span>
|
||||
<span v-for="tag in (job.tags || []).slice(0, 2)" :key="tag" class="agent-pending-job-list-panel__tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:匹配度 + 移出按钮 -->
|
||||
<div class="agent-pending-job-list-panel__right">
|
||||
<!-- 匹配度环形 -->
|
||||
<div class="agent-pending-job-list-panel__score">
|
||||
<svg class="agent-pending-job-list-panel__ring" viewBox="0 0 44 44">
|
||||
<circle cx="22" cy="22" r="18" fill="none" stroke="#E8E8E8" stroke-width="3" />
|
||||
<circle
|
||||
cx="22" cy="22" r="18" fill="none"
|
||||
stroke="#4FC2C9" stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
:stroke-dasharray="2 * Math.PI * 18"
|
||||
:stroke-dashoffset="2 * Math.PI * 18 * (1 - (job.matchScore || 0) / 100)"
|
||||
transform="rotate(-90 22 22)"
|
||||
/>
|
||||
</svg>
|
||||
<span class="agent-pending-job-list-panel__score-text">{{ job.matchScore || 0 }}%</span>
|
||||
</div>
|
||||
<!-- 移出按钮 -->
|
||||
<button
|
||||
class="agent-pending-job-list-panel__remove-btn"
|
||||
:disabled="removingIds.includes(job.id)"
|
||||
@click="handleRemove(job)"
|
||||
>移出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<div v-if="loadingMore" class="agent-pending-job-list-panel__loading">加载中...</div>
|
||||
<!-- 没有更多数据 -->
|
||||
<div v-if="noMore && jobList.length > 0" class="agent-pending-job-list-panel__no-more">暂无更多数据</div>
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!loading && jobList.length === 0" class="agent-pending-job-list-panel__empty">暂无待投递岗位</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchAgentTaskList } from '@/api/jobs'
|
||||
import { cancelApplyJob } from '@/api/agent'
|
||||
import type { JobListItem } from '@/api/jobs'
|
||||
|
||||
/** 事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 关闭面板 */
|
||||
(e: 'close'): void
|
||||
/** 移除岗位后通知父组件 */
|
||||
(e: 'removed', jobId: string): void
|
||||
}>()
|
||||
|
||||
/** 岗位列表数据 */
|
||||
const jobList = ref<JobListItem[]>([])
|
||||
|
||||
/** 当前页码 */
|
||||
const currentPage = ref(1)
|
||||
|
||||
/** 每页条数 */
|
||||
const pageSize = 30
|
||||
|
||||
/** 是否正在加载首页 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 是否正在加载更多 */
|
||||
const loadingMore = ref(false)
|
||||
|
||||
/** 是否没有更多数据 */
|
||||
const noMore = ref(false)
|
||||
|
||||
/** 正在移除中的岗位 ID 列表 */
|
||||
const removingIds = ref<string[]>([])
|
||||
|
||||
/** 列表容器 ref */
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
|
||||
/** 加载待投递列表 */
|
||||
async function loadList(page: number) {
|
||||
if (page === 1) {
|
||||
loading.value = true
|
||||
} else {
|
||||
loadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const res = await fetchAgentTaskList({ pageNum: page, pageSize, tab: 1 })
|
||||
if (res.code === '0' && res.data) {
|
||||
const list = res.data.list || []
|
||||
if (page === 1) {
|
||||
jobList.value = list
|
||||
} else {
|
||||
jobList.value.push(...list)
|
||||
}
|
||||
/* 判断是否还有更多数据 */
|
||||
const total = Number(res.data.total || 0)
|
||||
if (jobList.value.length >= total || list.length < pageSize) {
|
||||
noMore.value = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.error('[AgentPendingJobListPanel] 加载待投递列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动触底加载更多 */
|
||||
function handleScroll() {
|
||||
if (!listRef.value || loadingMore.value || noMore.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.value
|
||||
/* 距离底部 50px 时触发加载 */
|
||||
if (scrollTop + clientHeight >= scrollHeight - 50) {
|
||||
currentPage.value++
|
||||
loadList(currentPage.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** 移出岗位 */
|
||||
async function handleRemove(job: JobListItem) {
|
||||
if (removingIds.value.includes(job.id)) return
|
||||
removingIds.value.push(job.id)
|
||||
try {
|
||||
await cancelApplyJob(job.id)
|
||||
/* 从列表中移除 */
|
||||
const idx = jobList.value.findIndex(j => j.id === job.id)
|
||||
if (idx !== -1) {
|
||||
jobList.value.splice(idx, 1)
|
||||
}
|
||||
emit('removed', job.id)
|
||||
ElMessage.success('已移除')
|
||||
} catch {
|
||||
ElMessage.error('移除失败,请重试')
|
||||
} finally {
|
||||
removingIds.value = removingIds.value.filter(id => id !== job.id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList(1)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../assets/styles/components/agent-pending-job-list-panel';
|
||||
</style>
|
||||
@@ -0,0 +1,659 @@
|
||||
<template>
|
||||
<!-- Agent设置面板 — 右侧面板 -->
|
||||
<div class="agent-setting-panel">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="agent-setting-panel__header">
|
||||
<span class="agent-setting-panel__title">设置</span>
|
||||
<!-- 关闭按钮 -->
|
||||
<button class="agent-setting-panel__close-btn" @click="emit('close')">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="#1A1A2E" />
|
||||
<path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域(可滚动) -->
|
||||
<div class="agent-setting-panel__body">
|
||||
|
||||
<!-- ========== 个人资料设置项 ========== -->
|
||||
<div class="agent-setting-panel__section">
|
||||
<!-- 个人资料标题行:标题 + 编辑按钮(含展开/收起箭头) -->
|
||||
<div class="agent-setting-panel__section-header" @click="toggleProfileExpand">
|
||||
<span class="agent-setting-panel__section-title">个人资料</span>
|
||||
<div class="agent-setting-panel__section-action">
|
||||
<span class="agent-setting-panel__section-action-text">{{ profileExpanded ? '收起' : '编辑' }}</span>
|
||||
<!-- 方向箭头:展开时朝上,收起时朝下 -->
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="agent-setting-panel__arrow"
|
||||
:class="{ 'agent-setting-panel__arrow--up': profileExpanded }"
|
||||
>
|
||||
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 收起时的简要信息预览 -->
|
||||
<div v-if="!profileExpanded" class="agent-setting-panel__section-summary">
|
||||
<span v-if="profile.name">{{ profile.name }}</span>
|
||||
<span v-if="profile.phone"> · {{ profile.phone }}</span>
|
||||
<span v-if="profile.email"> · {{ profile.email }}</span>
|
||||
<span v-if="!profile.name && !profile.phone && !profile.email" class="agent-setting-panel__section-empty">暂未填写个人资料</span>
|
||||
</div>
|
||||
|
||||
<!-- 展开时显示 ProfilePageContent 组件 -->
|
||||
<div v-if="profileExpanded" class="agent-setting-panel__section-content">
|
||||
<ProfilePageContent :profile="profile" @edit="handleEdit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========== 其他设置项(待设计稿补充) ========== -->
|
||||
<div class="agent-setting-panel__section">
|
||||
<!-- 求职目标标题行 + 编辑按钮 -->
|
||||
<div class="agent-setting-panel__section-header" @click="showJobGoalDialog = true">
|
||||
<span class="agent-setting-panel__section-title">求职目标</span>
|
||||
<div class="agent-setting-panel__section-action">
|
||||
<span class="agent-setting-panel__section-action-text">编辑</span>
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-setting-panel__edit-icon">
|
||||
<path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 求职意向标签展示 -->
|
||||
<div class="agent-setting-panel__goal-tags">
|
||||
<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 v-if="intentionEmploymentLabel" class="agent-setting-panel__goal-tag">{{ intentionEmploymentLabel }}</span>
|
||||
<!-- 无意向时的空状态 -->
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__section">
|
||||
<div class="agent-setting-panel__section-header">
|
||||
<span class="agent-setting-panel__section-title">浏览器插件</span>
|
||||
</div>
|
||||
<!-- 浏览器按钮列表 -->
|
||||
<div class="agent-setting-panel__browser-btns">
|
||||
<button
|
||||
v-for="b in browserList"
|
||||
:key="b.key"
|
||||
class="agent-setting-panel__browser-btn"
|
||||
@click="openBrowserGuide(b.key)"
|
||||
>{{ b.label }}</button>
|
||||
</div>
|
||||
<!-- 下载插件按钮 -->
|
||||
<div class="agent-setting-panel__download-wrap">
|
||||
<button class="agent-setting-panel__download-btn" @click="downloadExtension">下载插件</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__section">
|
||||
<div class="agent-setting-panel__section-header">
|
||||
<span class="agent-setting-panel__section-title">求职助手配置</span>
|
||||
</div>
|
||||
|
||||
<!-- 网申常见问题 -->
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">是否愿意接受部门调剂?</div>
|
||||
<div class="agent-setting-panel__config-options">
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptDeptTransfer === '是,服从调剂' }" @click="configForm.acceptDeptTransfer = '是,服从调剂'">是,服从调剂</button>
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptDeptTransfer === '否,不调剂' }" @click="configForm.acceptDeptTransfer = '否,不调剂'">否,不调剂</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">是否接受地点调剂?</div>
|
||||
<div class="agent-setting-panel__config-options">
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptLocationTransfer === '是' }" @click="configForm.acceptLocationTransfer = '是'">是</button>
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.acceptLocationTransfer === '否' }" @click="configForm.acceptLocationTransfer = '否'">否</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">可以参加面试的方式?</div>
|
||||
<div class="agent-setting-panel__config-options">
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.interviewType.includes('线下面试') }" @click="toggleInterviewType('线下面试')">线下面试</button>
|
||||
<button class="agent-setting-panel__config-btn" :class="{ 'agent-setting-panel__config-btn--active': configForm.interviewType.includes('线上远程') }" @click="toggleInterviewType('线上远程')">线上远程</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">你的语言能力?</div>
|
||||
<div class="agent-setting-panel__config-selects">
|
||||
<el-select v-model="configForm.languages[0].language" placeholder="语种" class="agent-setting-panel__config-select">
|
||||
<el-option v-for="lang in languageOptions" :key="lang" :label="lang" :value="lang" />
|
||||
</el-select>
|
||||
<el-select v-model="configForm.languages[0].proficiency" placeholder="掌握程度" class="agent-setting-panel__config-select">
|
||||
<el-option v-for="p in proficiencyOptions" :key="p" :label="p" :value="p" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">预计到岗时间?</div>
|
||||
<div class="agent-setting-panel__config-selects">
|
||||
<el-select v-model="configForm.availableDate" placeholder="请选择" class="agent-setting-panel__config-select">
|
||||
<el-option v-for="d in availableDateOptions" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实习相关(仅实习类型时显示) -->
|
||||
<template v-if="isInternship">
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">每周可实习天数?</div>
|
||||
<div class="agent-setting-panel__config-selects">
|
||||
<el-select v-model="configForm.internDaysPerWeek" placeholder="请选择" class="agent-setting-panel__config-select">
|
||||
<el-option v-for="d in internDaysOptions" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">预计实习时长?</div>
|
||||
<div class="agent-setting-panel__config-selects">
|
||||
<el-select v-model="configForm.internDuration" placeholder="请选择" class="agent-setting-panel__config-select">
|
||||
<el-option v-for="d in internDurationOptions" :key="d" :label="d" :value="d" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 简历设置 -->
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__config-label">
|
||||
简历设置
|
||||
<el-tooltip content="选择投递时使用的默认简历" placement="top">
|
||||
<span class="agent-setting-panel__tip">ⓘ</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="agent-setting-panel__config-sub">设置默认简历</div>
|
||||
<div class="agent-setting-panel__resume-row">
|
||||
<svg viewBox="0 0 16 16" fill="none" class="agent-setting-panel__resume-icon">
|
||||
<path d="M10 1H4a1.5 1.5 0 00-1.5 1.5v11A1.5 1.5 0 004 15h8a1.5 1.5 0 001.5-1.5V4.5L10 1z" stroke="currentColor" stroke-width="1"/>
|
||||
<path d="M10 1v3.5h3.5" stroke="currentColor" stroke-width="1"/>
|
||||
</svg>
|
||||
<el-select v-model="selectedResumeId" placeholder="请选择简历" class="agent-setting-panel__config-select agent-setting-panel__config-select--full">
|
||||
<el-option v-for="r in resumeList" :key="r.id" :label="r.resumeName" :value="r.id || ''" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动优化简历开关 -->
|
||||
<div class="agent-setting-panel__config-group">
|
||||
<div class="agent-setting-panel__switch-row">
|
||||
<div class="agent-setting-panel__switch-text">
|
||||
<span>在投递时帮我针对岗位自动优化简历</span>
|
||||
<el-tooltip content="MVP只补充缺少技能" placement="top">
|
||||
<span class="agent-setting-panel__tip">ⓘ</span>
|
||||
</el-tooltip>
|
||||
<br/>
|
||||
<span class="agent-setting-panel__switch-sub">(MVP只补充缺少技能)</span>
|
||||
</div>
|
||||
<el-switch v-model="autoOptimizeSwitch" active-color="#4FC2C9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交设置按钮 -->
|
||||
<div class="agent-setting-panel__submit-wrap">
|
||||
<button class="agent-setting-panel__submit-btn" :disabled="configSaving" @click="handleSubmitConfig">
|
||||
{{ configSaving ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 个人资料编辑抽屉 -->
|
||||
<ProfileEditDrawer
|
||||
v-model="showEditDrawer"
|
||||
:module="editModule"
|
||||
:initial-data="editInitialData"
|
||||
:saving="saving"
|
||||
@save="handleSaveEdit"
|
||||
/>
|
||||
|
||||
<!-- 求职目标设置弹窗 -->
|
||||
<JobGoalDialog v-model="showJobGoalDialog" />
|
||||
|
||||
<!-- 浏览器安装指引弹窗 -->
|
||||
<el-dialog v-model="showBrowserGuide" :title="currentBrowserLabel + ' 安装指引'" width="80%" top="5vh" destroy-on-close>
|
||||
<el-carousel :autoplay="false" indicator-position="outside" height="70vh" arrow="always">
|
||||
<el-carousel-item v-for="(img, i) in currentBrowserImages" :key="i">
|
||||
<div class="agent-setting-panel__guide-slide">
|
||||
<img :src="img" :alt="currentBrowserLabel + ' 安装步骤 ' + (i + 1)" />
|
||||
</div>
|
||||
</el-carousel-item>
|
||||
</el-carousel>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
import {
|
||||
saveProfile, saveEducation, saveWork, saveInternship, saveProject, saveCompetition,
|
||||
fetchProfile, fetchEducation, fetchWork, fetchInternship, fetchProject, fetchCompetition
|
||||
} from '@/api/profile'
|
||||
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
|
||||
import { resolveRegionName } from '@/utils/region'
|
||||
import { resolveIndustryName } from '@/utils/industry'
|
||||
import { resolveJobCategoryName } from '@/utils/jobCategory'
|
||||
import { fetchResumeList } from '@/api/resume'
|
||||
import type { ResumeListItem } from '@/api/resume'
|
||||
import { fetchAgentConfig, saveAgentConfig } from '@/api/agent'
|
||||
|
||||
/** 事件 */
|
||||
const emit = defineEmits<{
|
||||
/** 关闭面板 */
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const store = useStore()
|
||||
|
||||
// ==================== 个人资料展开/收起状态 ====================
|
||||
|
||||
/** 个人资料区域是否展开 */
|
||||
const profileExpanded = ref(false)
|
||||
|
||||
/** 切换个人资料展开/收起 */
|
||||
function toggleProfileExpand() {
|
||||
profileExpanded.value = !profileExpanded.value
|
||||
}
|
||||
|
||||
// ==================== 求职目标 ====================
|
||||
|
||||
/** 求职目标设置弹窗显隐 */
|
||||
const showJobGoalDialog = ref(false)
|
||||
|
||||
/** 求职意向 — 岗位类型名称列表 */
|
||||
const intentionCategoryNames = computed(() => (store.state.jobIntention?.categoryIds || []).map((id: number) => resolveJobCategoryName(id)).filter(Boolean))
|
||||
|
||||
/** 求职意向 — 行业名称列表 */
|
||||
const intentionIndustryNames = computed(() => (store.state.jobIntention?.industryIds || []).map((id: number) => resolveIndustryName(id)).filter(Boolean))
|
||||
|
||||
/** 求职意向 — 地区名称列表 */
|
||||
const intentionRegionNames = computed(() => (store.state.jobIntention?.regionCodes || []).map((code: string) => resolveRegionName(code)).filter(Boolean))
|
||||
|
||||
/** 求职意向 — 招聘分类文案 */
|
||||
const intentionEmploymentLabel = computed(() => formatEmploymentType(store.state.jobIntention?.recruitCategory))
|
||||
|
||||
// ==================== 浏览器插件 ====================
|
||||
|
||||
/** 插件下载地址 */
|
||||
const extensionDownloadUrl = 'https://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/chrome-mv3-prod.rar'
|
||||
|
||||
/** 浏览器列表 */
|
||||
const browserList = [
|
||||
{ key: 'chrome', label: 'Chrome浏览器' },
|
||||
{ key: 'edge', label: 'Edge浏览器' },
|
||||
{ key: 'safari', label: 'Safari浏览器' },
|
||||
{ key: '360', label: '360浏览器' },
|
||||
{ key: 'qq', label: 'QQ浏览器' },
|
||||
]
|
||||
|
||||
/** 每个浏览器的指引图片数量 */
|
||||
const browserImageCount: Record<string, number> = { chrome: 3, edge: 3, safari: 3, '360': 3, qq: 3 }
|
||||
|
||||
/** 指引图片 OSS 基础路径 */
|
||||
const guidanceImageBase = 'http://offerpie.oss-cn-guangzhou.aliyuncs.com/extension/guidance_image'
|
||||
|
||||
/** 浏览器安装指引弹窗显隐 */
|
||||
const showBrowserGuide = ref(false)
|
||||
|
||||
/** 当前选中的浏览器 key */
|
||||
const currentBrowserKey = ref('')
|
||||
|
||||
/** 当前浏览器名称 */
|
||||
const currentBrowserLabel = computed(() => browserList.find(b => b.key === currentBrowserKey.value)?.label || '')
|
||||
|
||||
/** 当前浏览器的指引图片列表 */
|
||||
const currentBrowserImages = computed(() => {
|
||||
const key = currentBrowserKey.value
|
||||
if (!key) return []
|
||||
const count = browserImageCount[key] || 3
|
||||
return Array.from({ length: count }, (_, i) => `${guidanceImageBase}/guidance-image-${key}-${String(i + 1).padStart(2, '0')}.png`)
|
||||
})
|
||||
|
||||
/** 打开浏览器安装指引弹窗 */
|
||||
function openBrowserGuide(key: string) {
|
||||
currentBrowserKey.value = key
|
||||
showBrowserGuide.value = true
|
||||
}
|
||||
|
||||
/** 下载插件 */
|
||||
function downloadExtension() {
|
||||
window.open(extensionDownloadUrl, '_blank')
|
||||
}
|
||||
|
||||
// ==================== 求职助手配置 ====================
|
||||
|
||||
/** 是否为实习类型 */
|
||||
const isInternship = computed(() => store.state.jobIntention?.recruitCategory === 2)
|
||||
|
||||
/** 配置表单数据 */
|
||||
const configForm = ref({
|
||||
acceptDeptTransfer: '',
|
||||
acceptLocationTransfer: '',
|
||||
interviewType: [] as string[],
|
||||
languages: [{ language: '', proficiency: '' }] as Array<{ language: string; proficiency: string }>,
|
||||
availableDate: '',
|
||||
internDaysPerWeek: '',
|
||||
internDuration: '',
|
||||
})
|
||||
|
||||
/** 语种选项 */
|
||||
const languageOptions = ['英语', '日语', '法语', '德语', '韩语', '西班牙语', '俄语']
|
||||
|
||||
/** 掌握程度选项 */
|
||||
const proficiencyOptions = ['入门', '日常会话', '商务会话', '无障碍沟通', '母语']
|
||||
|
||||
/** 到岗时间选项 */
|
||||
const availableDateOptions = ['一周以内', '两周以内', '一个月以内', '一个月以上']
|
||||
|
||||
/** 实习天数选项 */
|
||||
const internDaysOptions = ['3天及以上', '4天及以上', '5天及以上']
|
||||
|
||||
/** 实习时长选项 */
|
||||
const internDurationOptions = ['3个月', '4个月', '5个月', '6个月及以上']
|
||||
|
||||
/** 切换面试方式(多选) */
|
||||
function toggleInterviewType(type: string) {
|
||||
const idx = configForm.value.interviewType.indexOf(type)
|
||||
idx >= 0 ? configForm.value.interviewType.splice(idx, 1) : configForm.value.interviewType.push(type)
|
||||
}
|
||||
|
||||
/** 简历列表 */
|
||||
const resumeList = ref<ResumeListItem[]>([])
|
||||
|
||||
/** 当前选中的简历 ID */
|
||||
const selectedResumeId = ref('')
|
||||
|
||||
/** 自动优化简历开关 */
|
||||
const autoOptimizeSwitch = ref(true)
|
||||
|
||||
/** 保存中状态 */
|
||||
const configSaving = ref(false)
|
||||
|
||||
/** 加载简历列表 */
|
||||
async function loadResumeList() {
|
||||
try {
|
||||
const res = await fetchResumeList()
|
||||
if (res.code === '0' && res.data) {
|
||||
resumeList.value = res.data
|
||||
const defaultResume = res.data.find(r => r.isDefault === 1)
|
||||
if (defaultResume && defaultResume.id) {
|
||||
selectedResumeId.value = defaultResume.id
|
||||
} else if (res.data.length > 0 && res.data[0].id) {
|
||||
selectedResumeId.value = res.data[0].id
|
||||
}
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载简历列表失败') }
|
||||
}
|
||||
|
||||
/** 加载已有的求职助手配置 — 填充表单 */
|
||||
async function loadAgentConfig() {
|
||||
try {
|
||||
const res = await fetchAgentConfig()
|
||||
if (res.code === '0' && res.data) {
|
||||
const cfg = res.data
|
||||
configForm.value.acceptDeptTransfer = cfg.acceptDeptTransfer || ''
|
||||
configForm.value.acceptLocationTransfer = cfg.acceptLocationTransfer || ''
|
||||
configForm.value.interviewType = cfg.interviewType ? [...cfg.interviewType] : []
|
||||
configForm.value.languages = cfg.languages?.length
|
||||
? cfg.languages.map(l => ({ language: l.language || '', proficiency: l.proficiency || '' }))
|
||||
: [{ language: '', proficiency: '' }]
|
||||
configForm.value.availableDate = cfg.availableDate || ''
|
||||
configForm.value.internDaysPerWeek = cfg.internDaysPerWeek || ''
|
||||
configForm.value.internDuration = cfg.internDuration || ''
|
||||
if (cfg.autoOptimizeResume !== undefined) autoOptimizeSwitch.value = cfg.autoOptimizeResume === 1
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载求职助手配置失败') }
|
||||
}
|
||||
|
||||
/** 提交设置 — 调用 saveAgentConfig 接口 */
|
||||
async function handleSubmitConfig() {
|
||||
configSaving.value = true
|
||||
try {
|
||||
await saveAgentConfig({
|
||||
acceptDeptTransfer: configForm.value.acceptDeptTransfer,
|
||||
acceptLocationTransfer: configForm.value.acceptLocationTransfer,
|
||||
interviewType: configForm.value.interviewType,
|
||||
languages: configForm.value.languages.filter(l => l.language),
|
||||
availableDate: configForm.value.availableDate,
|
||||
internDaysPerWeek: configForm.value.internDaysPerWeek,
|
||||
internDuration: configForm.value.internDuration,
|
||||
autoOptimizeResume: autoOptimizeSwitch.value ? 1 : 0,
|
||||
})
|
||||
ElMessage.success('设置保存成功')
|
||||
} catch {
|
||||
ElMessage.error('设置保存失败,请重试')
|
||||
} finally {
|
||||
configSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 个人资料数据 ====================
|
||||
|
||||
/** 个人档案响应式数据 */
|
||||
const profile = ref({
|
||||
name: '', phone: '', email: '', idNumber: '', regionCode: '', portfolioUrl: '', wechat: '',
|
||||
skills: [] as string[], certificates: [] as string[],
|
||||
education: [] as Array<{ school: string; major: string; studyType: number; degree: number; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
|
||||
works: [] as Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
|
||||
internships: [] as Array<{ companyName: string; position: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
|
||||
projects: [] as Array<{ projectName: string; companyName: string; role: string; startDate: string; endDate: string; description: Array<{ id: string; text: string }> }>,
|
||||
competitions: [] as Array<{ competitionName: string; award: string; awardDate: string; description: Array<{ id: string; text: string }> }>,
|
||||
})
|
||||
|
||||
// ==================== 加载个人资料 ====================
|
||||
|
||||
onMounted(async () => {
|
||||
if (!store.state.regions.length) store.dispatch('loadCommonData')
|
||||
store.dispatch('loadJobIntention')
|
||||
await loadProfile()
|
||||
await loadEducation()
|
||||
await loadWork()
|
||||
await loadInternship()
|
||||
await loadProject()
|
||||
await loadCompetition()
|
||||
await loadResumeList()
|
||||
await loadAgentConfig()
|
||||
})
|
||||
|
||||
/** 加载基本信息 */
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const res = await fetchProfile()
|
||||
if (res.code === '0' && res.data) {
|
||||
const d = res.data
|
||||
profile.value.name = d.name || ''
|
||||
profile.value.phone = d.mobileNumber || ''
|
||||
profile.value.email = d.email || ''
|
||||
profile.value.idNumber = d.idCard || ''
|
||||
profile.value.regionCode = d.regionCode || ''
|
||||
profile.value.wechat = d.wechatNumber || ''
|
||||
profile.value.skills = d.skills || []
|
||||
profile.value.certificates = d.certificates || []
|
||||
profile.value.portfolioUrl = d.portfolioUrl || ''
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载个人资料失败') }
|
||||
}
|
||||
|
||||
/** 加载教育经历 */
|
||||
async function loadEducation() {
|
||||
try {
|
||||
const res = await fetchEducation()
|
||||
if (res.code === '0' && res.data) {
|
||||
profile.value.education = res.data.map(item => ({
|
||||
school: item.school || '', major: item.major || '', studyType: item.studyType ?? 0,
|
||||
degree: item.degree ?? 2, startDate: item.startDate || '', endDate: item.endDate || '',
|
||||
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
|
||||
}))
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载教育经历失败') }
|
||||
}
|
||||
|
||||
/** 加载工作经历 */
|
||||
async function loadWork() {
|
||||
try {
|
||||
const res = await fetchWork()
|
||||
if (res.code === '0' && res.data) {
|
||||
profile.value.works = res.data.map(item => ({
|
||||
companyName: item.companyName || '', position: item.position || '',
|
||||
startDate: item.startDate || '', endDate: item.endDate || '',
|
||||
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
|
||||
}))
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载工作经历失败') }
|
||||
}
|
||||
|
||||
/** 加载实习经历 */
|
||||
async function loadInternship() {
|
||||
try {
|
||||
const res = await fetchInternship()
|
||||
if (res.code === '0' && res.data) {
|
||||
profile.value.internships = res.data.map(item => ({
|
||||
companyName: item.companyName || '', position: item.position || '',
|
||||
startDate: item.startDate || '', endDate: item.endDate || '',
|
||||
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
|
||||
}))
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载实习经历失败') }
|
||||
}
|
||||
|
||||
/** 加载项目经历 */
|
||||
async function loadProject() {
|
||||
try {
|
||||
const res = await fetchProject()
|
||||
if (res.code === '0' && res.data) {
|
||||
profile.value.projects = res.data.map(item => ({
|
||||
projectName: item.projectName || '', companyName: item.companyName || '',
|
||||
role: item.role || '', startDate: item.startDate || '', endDate: item.endDate || '',
|
||||
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
|
||||
}))
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载项目经历失败') }
|
||||
}
|
||||
|
||||
/** 加载竞赛经历 */
|
||||
async function loadCompetition() {
|
||||
try {
|
||||
const res = await fetchCompetition()
|
||||
if (res.code === '0' && res.data) {
|
||||
profile.value.competitions = res.data.map(item => ({
|
||||
competitionName: item.competitionName || '', award: item.award || '',
|
||||
awardDate: item.awardDate || '',
|
||||
description: (item.description || []).map(d => ({ id: d.id || '', text: d.text || '' }))
|
||||
}))
|
||||
}
|
||||
} catch { console.error('[AgentSettingPanel] 加载竞赛经历失败') }
|
||||
}
|
||||
|
||||
// ==================== 编辑抽屉 ====================
|
||||
|
||||
/** 编辑抽屉显隐 */
|
||||
const showEditDrawer = ref(false)
|
||||
|
||||
/** 当前编辑的模块名 */
|
||||
const editModule = ref('info')
|
||||
|
||||
/** 编辑抽屉初始数据 */
|
||||
const editInitialData = ref<Record<string, any>>({})
|
||||
|
||||
/** 保存中状态 */
|
||||
const saving = ref(false)
|
||||
|
||||
/** ProfilePageContent 点击编辑 — 打开对应模块的编辑抽屉 */
|
||||
function handleEdit(section: string) {
|
||||
editModule.value = section
|
||||
if (section === 'info') {
|
||||
editInitialData.value = { name: profile.value.name, email: profile.value.email, phone: profile.value.phone, location: profile.value.regionCode, wechat: profile.value.wechat }
|
||||
} else if (section === 'education') {
|
||||
editInitialData.value = { education: profile.value.education.map(edu => ({ ...edu, description: edu.description.map(d => ({ ...d })) })) }
|
||||
} else if (section === 'work') {
|
||||
editInitialData.value = { works: profile.value.works.map(exp => ({ ...exp, description: exp.description.map(d => ({ ...d })) })) }
|
||||
} else if (section === 'internship') {
|
||||
editInitialData.value = { internships: (profile.value.internships || []).map(exp => ({ ...exp, description: exp.description.map(d => ({ ...d })) })) }
|
||||
} else if (section === 'project') {
|
||||
editInitialData.value = { projects: (profile.value.projects || []).map(proj => ({ ...proj, description: proj.description.map(d => ({ ...d })) })) }
|
||||
} else if (section === 'competition') {
|
||||
editInitialData.value = { competitions: profile.value.competitions.map(comp => ({ ...comp, description: comp.description.map(d => ({ ...d })) })) }
|
||||
} else if (section === 'portfolio') {
|
||||
editInitialData.value = { portfolioUrl: profile.value.portfolioUrl }
|
||||
} else if (section === 'skills') {
|
||||
editInitialData.value = { skills: [...profile.value.skills] }
|
||||
} else if (section === 'certificate') {
|
||||
editInitialData.value = { certificates: [...(profile.value.certificates || [])] }
|
||||
} else {
|
||||
editInitialData.value = {}
|
||||
}
|
||||
showEditDrawer.value = true
|
||||
}
|
||||
|
||||
/** 保存编辑数据 — 调用接口持久化 */
|
||||
async function handleSaveEdit(data: Record<string, any>) {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editModule.value === 'info') {
|
||||
await saveProfile({ name: data.name, email: data.email, mobileNumber: data.phone, regionCode: data.location, wechatNumber: data.wechat })
|
||||
profile.value.name = data.name; profile.value.email = data.email
|
||||
profile.value.phone = data.phone; profile.value.wechat = data.wechat
|
||||
profile.value.regionCode = data.location || ''
|
||||
ElMessage.success('个人信息保存成功')
|
||||
} else if (editModule.value === 'education') {
|
||||
const payload: SaveEducationItem[] = data.education.map((edu: any) => ({ school: edu.school, major: edu.major, degree: edu.degree, studyType: edu.studyType, startDate: edu.startDate, endDate: edu.endDate, description: edu.description.map((d: any) => ({ id: d.id, text: d.text })) }))
|
||||
await saveEducation(payload)
|
||||
profile.value.education = data.education.map((edu: any) => ({ school: edu.school, major: edu.major, studyType: edu.studyType, degree: edu.degree, startDate: edu.startDate, endDate: edu.endDate, description: edu.description.map((d: any) => ({ ...d })) }))
|
||||
ElMessage.success('教育经历保存成功')
|
||||
} else if (editModule.value === 'work') {
|
||||
const payload: SaveWorkItem[] = data.works.map((w: any) => ({ companyName: w.companyName, position: w.position, startDate: w.startDate, endDate: w.endDate || '', description: w.description.map((d: any) => ({ id: d.id, text: d.text })) }))
|
||||
await saveWork(payload)
|
||||
profile.value.works = data.works.map((w: any) => ({ companyName: w.companyName, position: w.position, startDate: w.startDate, endDate: w.endDate, description: w.description.map((d: any) => ({ ...d })) }))
|
||||
ElMessage.success('工作经历保存成功')
|
||||
} else if (editModule.value === 'internship') {
|
||||
const payload: SaveWorkItem[] = data.internships.map((i: any) => ({ companyName: i.companyName, position: i.position, startDate: i.startDate, endDate: i.endDate || '', description: i.description.map((d: any) => ({ id: d.id, text: d.text })) }))
|
||||
await saveInternship(payload)
|
||||
profile.value.internships = data.internships.map((i: any) => ({ companyName: i.companyName, position: i.position, startDate: i.startDate, endDate: i.endDate, description: i.description.map((d: any) => ({ ...d })) }))
|
||||
ElMessage.success('实习经历保存成功')
|
||||
} else if (editModule.value === 'project') {
|
||||
const payload: SaveProjectItem[] = data.projects.map((p: any) => ({ projectName: p.projectName, companyName: p.companyName || '', role: p.role || '', startDate: p.startDate, endDate: p.endDate || '', description: p.description.map((d: any) => ({ id: d.id, text: d.text })) }))
|
||||
await saveProject(payload)
|
||||
profile.value.projects = data.projects.map((p: any) => ({ projectName: p.projectName, companyName: p.companyName, role: p.role, startDate: p.startDate, endDate: p.endDate, description: p.description.map((d: any) => ({ ...d })) }))
|
||||
ElMessage.success('项目经历保存成功')
|
||||
} else if (editModule.value === 'competition') {
|
||||
const payload: SaveCompetitionItem[] = data.competitions.map((c: any) => ({ competitionName: c.competitionName, award: c.award || '', awardDate: c.awardDate || '', description: c.description.map((d: any) => ({ id: d.id, text: d.text })) }))
|
||||
await saveCompetition(payload)
|
||||
profile.value.competitions = data.competitions.map((c: any) => ({ competitionName: c.competitionName, award: c.award, awardDate: c.awardDate, description: c.description.map((d: any) => ({ ...d })) }))
|
||||
ElMessage.success('竞赛经历保存成功')
|
||||
} else if (editModule.value === 'portfolio') {
|
||||
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: profile.value.skills, certificates: profile.value.certificates, portfolioUrl: data.portfolioUrl })
|
||||
profile.value.portfolioUrl = data.portfolioUrl
|
||||
ElMessage.success('作品集保存成功')
|
||||
} else if (editModule.value === 'skills') {
|
||||
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: [...data.skills], certificates: profile.value.certificates })
|
||||
profile.value.skills = [...data.skills]
|
||||
ElMessage.success('技能保存成功')
|
||||
} else if (editModule.value === 'certificate') {
|
||||
await saveProfile({ name: profile.value.name, email: profile.value.email, mobileNumber: profile.value.phone, regionCode: profile.value.regionCode, wechatNumber: profile.value.wechat, skills: profile.value.skills, certificates: [...data.certificates] })
|
||||
profile.value.certificates = [...data.certificates]
|
||||
ElMessage.success('证书保存成功')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('保存失败,请重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use '../assets/styles/components/agent-setting-panel';
|
||||
</style>
|
||||
@@ -62,6 +62,11 @@
|
||||
<!-- ========== 第2步:确认目标 ========== -->
|
||||
<template v-if="currentStep === 2">
|
||||
<div class="agent-page__step2">
|
||||
<!-- 返回上一步按钮 -->
|
||||
<button class="agent-page__back-btn" @click="handleBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>返回上一步</span>
|
||||
</button>
|
||||
<div class="agent-page__chat">
|
||||
<div class="agent-page__chat-row">
|
||||
<div class="agent-page__chat-avatar"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div>
|
||||
@@ -119,6 +124,11 @@
|
||||
<template v-if="currentStep === 3">
|
||||
<!-- 上半部分:网申常见问题 -->
|
||||
<div v-if="step3Sub === 1" class="agent-page__step3">
|
||||
<!-- 返回上一步按钮 -->
|
||||
<button class="agent-page__back-btn" @click="handleBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>返回上一步</span>
|
||||
</button>
|
||||
<div class="agent-page__left">
|
||||
<div class="agent-page__intro-card">
|
||||
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">现在我们来开启自动投递吧</h2></div>
|
||||
@@ -143,6 +153,11 @@
|
||||
</div>
|
||||
<!-- 下半部分:插件安装 -->
|
||||
<div v-if="step3Sub === 2" class="agent-page__step3">
|
||||
<!-- 返回上一阶段按钮 -->
|
||||
<button class="agent-page__back-btn" @click="handleBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>返回上一步</span>
|
||||
</button>
|
||||
<div class="agent-page__left">
|
||||
<div class="agent-page__intro-card">
|
||||
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">现在我们来开启自动投递吧</h2></div>
|
||||
@@ -173,6 +188,11 @@
|
||||
<!-- ========== 第4步:配置求职助手 ========== -->
|
||||
<template v-if="currentStep === 4">
|
||||
<div v-if="!setupComplete" class="agent-page__step3">
|
||||
<!-- 返回上一步按钮 -->
|
||||
<button class="agent-page__back-btn" @click="handleBack">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="16" height="16"><path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>返回上一步</span>
|
||||
</button>
|
||||
<div class="agent-page__left">
|
||||
<div class="agent-page__intro-card">
|
||||
<div class="agent-page__intro-header"><div class="agent-page__intro-icon"><svg viewBox="0 0 24 24" fill="none"><path d="M12 12c2.7 0 5-2.3 5-5s-2.3-5-5-5-5 2.3-5 5 2.3 5 5 5zm0 2c-3.3 0-10 1.7-10 5v2h20v-2c0-3.3-6.7-5-10-5z" fill="currentColor"/></svg></div><h2 class="agent-page__intro-title">马上就好啦!请选择你想要的投递模式</h2></div>
|
||||
@@ -204,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'
|
||||
@@ -240,6 +261,20 @@ function handleNext() {
|
||||
if (currentStep.value < steps.length) currentStep.value++
|
||||
}
|
||||
|
||||
/** 返回上一步(含子阶段判断) */
|
||||
function handleBack() {
|
||||
if (currentStep.value === 3 && step3Sub.value === 2) {
|
||||
// 第3步的第2阶段 → 回到第3步第1阶段
|
||||
step3Sub.value = 1
|
||||
} else if (currentStep.value === 4) {
|
||||
// 第4步 → 回到第3步第2阶段(插件安装)
|
||||
currentStep.value = 3
|
||||
step3Sub.value = 2
|
||||
} else if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 编辑抽屉状态 ====================
|
||||
const showEditDrawer = ref(false)
|
||||
const editModule = ref('info')
|
||||
@@ -368,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[]>([])
|
||||
@@ -383,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: '' }))
|
||||
@@ -399,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: '',
|
||||
@@ -446,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,
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
</template>
|
||||
</div>
|
||||
<!-- 查看全部 -->
|
||||
<div class="agent-main__task-view-all" @click="emit('viewAll')">查看全部</div>
|
||||
<div class="agent-main__task-view-all" @click="handleViewAll">查看全部</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -66,8 +66,10 @@ const props = defineProps<{
|
||||
const emit = defineEmits<{
|
||||
/** 移除岗位后通知父组件刷新列表 */
|
||||
(e: 'removed', jobId: string): void
|
||||
/** 查看全部 */
|
||||
/** 查看全部(进行中 tab) */
|
||||
(e: 'viewAll'): void
|
||||
/** 查看全部(已完成 tab — 申请进度) */
|
||||
(e: 'viewAllCompleted'): void
|
||||
}>()
|
||||
|
||||
// ==================== 状态 ====================
|
||||
@@ -102,7 +104,7 @@ function switchTab(tab: number) {
|
||||
async function loadCompletedList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetchAgentTaskList({ pageNum: 1, pageSize: 100, tab: 2 })
|
||||
const res = await fetchAgentTaskList({ pageNum: 1, pageSize: 30, tab: 2 })
|
||||
if (res.code === '0' && res.data) {
|
||||
completedList.value = res.data.list || []
|
||||
}
|
||||
@@ -116,11 +118,20 @@ async function loadCompletedList() {
|
||||
/** 移除岗位 — 调用 cancelApplyJob 接口并通知父组件 */
|
||||
async function removeJob(job: JobListItem) {
|
||||
try {
|
||||
await cancelApplyJob(Number(job.id))
|
||||
await cancelApplyJob(job.id)
|
||||
emit('removed', job.id)
|
||||
ElMessage.success('已移除')
|
||||
} catch {
|
||||
ElMessage.error('移除失败')
|
||||
}
|
||||
}
|
||||
|
||||
/** 查看全部 — 根据当前 tab 触发不同事件 */
|
||||
function handleViewAll() {
|
||||
if (activeTab.value === 1) {
|
||||
emit('viewAll')
|
||||
} else {
|
||||
emit('viewAllCompleted')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
// 从步骤4返回步骤3时,清理步骤4的状态数据(避免下次进入被旧数据影响)
|
||||
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 消息在列表中的索引
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
<!-- 简历上传区域 -->
|
||||
<div class="profile-welcome-dialog__form">
|
||||
<div class="profile-welcome-dialog__label">*简历</div>
|
||||
<div class="profile-welcome-dialog__upload-area" @click="handleUploadClick">
|
||||
<div
|
||||
class="profile-welcome-dialog__upload-area"
|
||||
:class="{ 'is-dragover': isDragover }"
|
||||
@click="handleUploadClick"
|
||||
@dragover.prevent="isDragover = true"
|
||||
@dragenter.prevent="isDragover = true"
|
||||
@dragleave.prevent="isDragover = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<!-- 未上传状态 -->
|
||||
<template v-if="!uploadedFileName">
|
||||
<div class="profile-welcome-dialog__upload-icon">
|
||||
@@ -18,7 +26,8 @@
|
||||
<path d="M12 14V4m0 0l-4 4m4-4l4 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="profile-welcome-dialog__upload-tip">支持上传PDF、WORD格式,文件大小不超过10M</p>
|
||||
<p class="profile-welcome-dialog__upload-tip fs16 color-3 fw600">点击或拖拽文件到这里上传</p>
|
||||
<p class="profile-welcome-dialog__upload-tip">支持 PDF / DOC / DOCX,单个文件不超过 10MB</p>
|
||||
</template>
|
||||
<!-- 已上传状态 -->
|
||||
<template v-else>
|
||||
@@ -66,6 +75,9 @@ const route = useRoute()
|
||||
/** 已上传的文件名 */
|
||||
const uploadedFileName = ref('')
|
||||
|
||||
/** 拖拽悬停状态 */
|
||||
const isDragover = ref(false)
|
||||
|
||||
/** 内部控制弹窗显隐(用于上传时临时隐藏) */
|
||||
const dialogVisible = ref(false)
|
||||
|
||||
@@ -87,61 +99,84 @@ watch(() => props.modelValue, (val) => {
|
||||
document.body.style.overflow = val ? 'hidden' : ''
|
||||
})
|
||||
|
||||
/** 允许的文件 MIME 类型 */
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
|
||||
/** 处理文件校验与上传(点击和拖拽共用) */
|
||||
async function processFile(file: File) {
|
||||
// 格式校验
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
ElMessage.error('仅支持上传PDF、WORD格式文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过10M')
|
||||
return
|
||||
}
|
||||
|
||||
// 标记上传中,降低弹窗层级让 loading 显示在上面
|
||||
uploading.value = true
|
||||
|
||||
// 全屏加载
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '简历解析中,请耐心等待…',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
customClass: 'profile-welcome-loading',
|
||||
})
|
||||
|
||||
try {
|
||||
// 上传简历文件
|
||||
const res = await uploadResume(file)
|
||||
if (res.code === 0 && res.data?.resumeId) {
|
||||
resumeId.value = String(res.data.resumeId)
|
||||
uploadedFileName.value = file.name
|
||||
|
||||
// 继续调用同步个人资料接口,加载特效延续
|
||||
loading.setText('正在同步个人资料…')
|
||||
await syncProfileFromResume(resumeId.value)
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.success('简历上传并同步成功')
|
||||
} else {
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.error('上传失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 点击上传区域 — 弹出文件选择 */
|
||||
function handleUploadClick() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|
||||
input.onchange = async () => {
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// 文件大小校验(10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过10M')
|
||||
return
|
||||
}
|
||||
|
||||
// 标记上传中,降低弹窗层级让 loading 显示在上面
|
||||
uploading.value = true
|
||||
|
||||
// 全屏加载
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '简历解析中,请耐心等待…',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
customClass: 'profile-welcome-loading',
|
||||
})
|
||||
|
||||
try {
|
||||
// 上传简历文件
|
||||
const res = await uploadResume(file)
|
||||
if (res.code === 0 && res.data?.resumeId) {
|
||||
resumeId.value = String(res.data.resumeId)
|
||||
uploadedFileName.value = file.name
|
||||
|
||||
// 继续调用同步个人资料接口,加载特效延续
|
||||
loading.setText('正在同步个人资料…')
|
||||
await syncProfileFromResume(resumeId.value)
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.success('简历上传并同步成功')
|
||||
} else {
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
loading.close()
|
||||
uploading.value = false
|
||||
ElMessage.error('上传失败,请稍后重试')
|
||||
}
|
||||
if (file) processFile(file)
|
||||
}
|
||||
|
||||
input.click()
|
||||
}
|
||||
|
||||
/** 拖拽放下文件 — 取第一个文件进行上传 */
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragover.value = false
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file) processFile(file)
|
||||
}
|
||||
|
||||
/** 点击开始匹配按钮 */
|
||||
function handleStart() {
|
||||
emit('update:modelValue', false)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<!-- 简历上传弹窗 — 支持点击选择和拖拽上传,确认后返回 File 对象 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="resume-upload-overlay" @click.self="handleCancel">
|
||||
<div class="resume-upload-dialog">
|
||||
<!-- 标题行 -->
|
||||
<div class="resume-upload-dialog__header">
|
||||
<h2 class="resume-upload-dialog__title">上传简历</h2>
|
||||
<button class="resume-upload-dialog__close" aria-label="关闭" @click="handleCancel">
|
||||
<svg viewBox="0 0 16 16" fill="none" width="16" height="16">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 副标题说明 -->
|
||||
<p class="resume-upload-dialog__desc">上传新的简历文件,系统会自动解析内容并同步到简历中心。</p>
|
||||
|
||||
<!-- 上传区域(点击 + 拖拽) -->
|
||||
<div
|
||||
class="resume-upload-dialog__drop-zone"
|
||||
:class="{ 'is-dragover': isDragover, 'has-file': !!selectedFile }"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent="isDragover = true"
|
||||
@dragenter.prevent="isDragover = true"
|
||||
@dragleave.prevent="isDragover = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<!-- 未选择文件状态 -->
|
||||
<template v-if="!selectedFile">
|
||||
<div class="resume-upload-dialog__drop-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="28" height="28">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="resume-upload-dialog__drop-text">点击或拖拽文件到这里上传</p>
|
||||
<p class="resume-upload-dialog__drop-hint">支持 PDF / DOC / DOCX,单个文件不超过 10MB</p>
|
||||
</template>
|
||||
<!-- 已选择文件状态 -->
|
||||
<template v-else>
|
||||
<div class="resume-upload-dialog__file-info">
|
||||
<span class="resume-upload-dialog__file-icon">📄</span>
|
||||
<span class="resume-upload-dialog__file-name">{{ selectedFile.name }}</span>
|
||||
<button class="resume-upload-dialog__file-remove" aria-label="移除文件" @click.stop="removeFile">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="resume-upload-dialog__footer">
|
||||
<button class="resume-upload-dialog__btn resume-upload-dialog__btn--cancel" @click="handleCancel">取消</button>
|
||||
<button
|
||||
class="resume-upload-dialog__btn resume-upload-dialog__btn--confirm"
|
||||
:disabled="!selectedFile"
|
||||
@click="handleConfirm"
|
||||
>确认上传</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/** 组件 Props */
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
/** 组件 Emits */
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'confirm', file: File): void
|
||||
}>()
|
||||
|
||||
/** 拖拽悬停状态 */
|
||||
const isDragover = ref(false)
|
||||
|
||||
/** 已选择的文件 */
|
||||
const selectedFile = ref<File | null>(null)
|
||||
|
||||
/** 允许的文件 MIME 类型 */
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
]
|
||||
|
||||
/** 弹窗打开时重置状态 */
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
selectedFile.value = null
|
||||
isDragover.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** 校验文件格式和大小 */
|
||||
function validateFile(file: File): boolean {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
ElMessage.error('仅支持上传 PDF、DOC、DOCX 格式文件')
|
||||
return false
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('文件大小不能超过 10MB')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 点击区域触发文件选择 */
|
||||
function triggerFileInput() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.pdf,.doc,.docx'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (file && validateFile(file)) {
|
||||
selectedFile.value = file
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
/** 拖拽放下文件 */
|
||||
function handleDrop(e: DragEvent) {
|
||||
isDragover.value = false
|
||||
const file = e.dataTransfer?.files[0]
|
||||
if (file && validateFile(file)) {
|
||||
selectedFile.value = file
|
||||
}
|
||||
}
|
||||
|
||||
/** 移除已选文件 */
|
||||
function removeFile() {
|
||||
selectedFile.value = null
|
||||
}
|
||||
|
||||
/** 取消关闭弹窗 */
|
||||
function handleCancel() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/** 确认上传 — 将 File 对象传给父组件 */
|
||||
function handleConfirm() {
|
||||
if (selectedFile.value) {
|
||||
emit('confirm', selectedFile.value)
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -155,7 +155,7 @@ import { cancelAccount } from '@/api/auth'
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
|
||||
/** 组件 Emits */
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void; (e: 'deleted'): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
const store = useStore()
|
||||
@@ -225,9 +225,10 @@ const handleConfirmDelete = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 完成 — 关闭弹窗并退出登录 */
|
||||
/** 完成 — 关闭弹窗,通知父组件注销完成,跳转首页 */
|
||||
const handleFinish = () => {
|
||||
emit('update:modelValue', false)
|
||||
emit('deleted')
|
||||
store.commit('SET_AUTHENTICATED', false)
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -240,10 +194,13 @@
|
||||
<JobGoalDialog v-model="showGoalDialog" />
|
||||
|
||||
<!-- 注销账号弹窗 -->
|
||||
<SettingsDeleteAccountDialog v-model="showDeleteAccount" />
|
||||
<SettingsDeleteAccountDialog v-model="showDeleteAccount" @deleted="onAccountDeleted" />
|
||||
|
||||
<!-- 邀请注册送会员弹窗 -->
|
||||
<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')
|
||||
// 暂时注释掉loadJobIntention,现在设置弹窗里去掉了求职意向的设置功能,设置求职意向会导致Jobs.vue里岗位列表刷新
|
||||
// store.dispatch('loadJobIntention')
|
||||
loadMemberStatus()
|
||||
}
|
||||
})
|
||||
@@ -385,6 +391,11 @@ const handleDeleteAccount = () => {
|
||||
showDeleteAccount.value = true
|
||||
}
|
||||
|
||||
/** 注销完成回调 — 关闭设置弹窗 */
|
||||
const onAccountDeleted = () => {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
/** 管理订阅 */
|
||||
const handleManageSubscription = () => {
|
||||
ElMessage.info('管理订阅功能开发中')
|
||||
|
||||
@@ -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}`
|
||||
})
|
||||
|
||||
/** 复制链接到剪贴板 */
|
||||
|
||||
@@ -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 排序,routeName(meta.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>
|
||||
@@ -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 时满宽4rem,scale 越小宽度越小)
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}",已跳过`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 || '请求失败')
|
||||
|
||||
@@ -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 字符串(几百字节,不占空间),每条记录包含:
|
||||
* userId(用户ID)、jobId(岗位ID)、fileName(雪花ID文件名)、
|
||||
* storageKey(简历文件在 IndexedDB 中的 key)、localDateTime(存储时间)
|
||||
*
|
||||
* - 简历文件数据: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)
|
||||
}
|
||||
|
||||
@@ -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助手跳转提醒时间记录 │
|
||||
* │ │ │
|
||||
* └──────────────────────────────────────────────────────────────┘
|
||||
*/
|
||||
|
||||
@@ -50,15 +50,13 @@
|
||||
v-if="showTaskListDropdown"
|
||||
:pending-list="applyJobList"
|
||||
@removed="handleTaskRemoved"
|
||||
@view-all="showTaskListDropdown = false"
|
||||
@view-all="handleOpenPendingListPanel"
|
||||
@view-all-completed="handleOpenApplyProgressPanel"
|
||||
/>
|
||||
<!-- 齿轮按钮 -->
|
||||
<!-- <button class="agent-main__tool-btn" title="配置">-->
|
||||
<!-- <svg viewBox="0 0 24 24" fill="none">-->
|
||||
<!-- <circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.5" />-->
|
||||
<!-- <path d="M12 1v2m0 18v2m-9-11h2m18 0h2m-3.3-7.7-1.4 1.4M4.7 19.3l1.4-1.4m0-11.8L4.7 4.7m14.6 14.6-1.4-1.4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />-->
|
||||
<!-- </svg>-->
|
||||
<!-- </button>-->
|
||||
<!-- 设置按钮 -->
|
||||
<button class="agent-main__tool-btn" title="配置" @click="handleOpenSettingsPanel">
|
||||
设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +81,7 @@
|
||||
:summary="getRecommendSummaryFromExtra(msg.extra)"
|
||||
:jobs="getRecommendJobsFromExtra(msg.extra)"
|
||||
@view-all="handleOpenRecommendPanel(msg.id)"
|
||||
@click-job="handleChatJobClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -138,6 +137,7 @@
|
||||
@view-more="handleViewMoreJobs"
|
||||
@toggle="handleToggleJobApply"
|
||||
@add-all="handleAddAllJobs"
|
||||
@click-job="handleMatchJobClick"
|
||||
/>
|
||||
<!-- 模式2:简历生成进度 -->
|
||||
<div v-else-if="rightPanelMode === 'generating'" class="agent-resume-generating">
|
||||
@@ -152,8 +152,33 @@
|
||||
</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
|
||||
v-else-if="rightPanelMode === 'jobPreview'"
|
||||
:job-id="previewJobId"
|
||||
:application-status="previewJobApplicationStatus"
|
||||
@back="handleJobPreviewBack"
|
||||
@add="handleJobPreviewAdd"
|
||||
@remove="handleJobPreviewRemove"
|
||||
/>
|
||||
<!-- 模式5:全部的待投递列表 -->
|
||||
<AgentPendingJobListPanel
|
||||
v-else-if="rightPanelMode === 'pendingList'"
|
||||
@close="handleClosePendingListPanel"
|
||||
@removed="handlePendingListRemoved"
|
||||
/>
|
||||
<!-- 模式6:全部的已投递进度 -->
|
||||
<AgentApplyProgressPanel
|
||||
v-else-if="rightPanelMode === 'applyProgress'"
|
||||
@close="handleCloseApplyProgressPanel"
|
||||
/>
|
||||
<!-- 模式7:设置面板 -->
|
||||
<AgentSettingPanel
|
||||
v-else-if="rightPanelMode === 'settings'"
|
||||
@close="handleCloseSettingsPanel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,6 +197,10 @@ import AgentMatchJobAdd from '@/components/AgentMatchJobAdd.vue'
|
||||
import AgentApplyProgress from '@/components/AgentApplyProgress.vue'
|
||||
import AgentTaskListDropdown from '@/components/AgentTaskListDropdown.vue'
|
||||
import JobResumeTemplate from '@/components/JobResumeTemplate.vue'
|
||||
import AgentJobPreviewPanel from '@/components/AgentJobPreviewPanel.vue'
|
||||
import AgentPendingJobListPanel from '@/components/AgentPendingJobListPanel.vue'
|
||||
import AgentApplyProgressPanel from '@/components/AgentApplyProgressPanel.vue'
|
||||
import AgentSettingPanel from '@/components/AgentSettingPanel.vue'
|
||||
import type { ResumeTemplateData } from '@/components/JobResumeTemplate.vue'
|
||||
import { fetchAgentConfig, saveAgentConfig, fetchAgentRecommend, fetchAgentChatMessages, addAgentChatMessage, applyJob, cancelApplyJob, fetchApplyByJobIds, sendAgentChat, optimizeAgentResume } from '@/api/agent'
|
||||
import type { AgentConfig, AgentRecommendJob, AgentChatMessage, AgentChatHistoryItem } from '@/api/agent'
|
||||
@@ -179,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'
|
||||
|
||||
@@ -370,6 +401,11 @@ async function loadApplyList() {
|
||||
|
||||
/** 切换待投递列表下拉弹窗显隐 */
|
||||
function toggleTaskListDropdown() {
|
||||
/* 投递流程进行中时禁止打开待投递列表 */
|
||||
if (isApplying.value) {
|
||||
ElMessage.warning('请完成或取消投递流程后再打开编辑待投递列表')
|
||||
return
|
||||
}
|
||||
showTaskListDropdown.value = !showTaskListDropdown.value
|
||||
if (showTaskListDropdown.value) {
|
||||
loadApplyList()
|
||||
@@ -400,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -564,6 +597,11 @@ function handleViewMoreJobs() {
|
||||
|
||||
/** 打开右侧匹配岗位面板 — 先查询投递记录更新岗位状态再显示 */
|
||||
async function handleOpenRecommendPanel(msgId: number) {
|
||||
/* 投递流程进行中(模式2简历生成 / 模式3简历预览)时禁止打开推荐面板 */
|
||||
if (rightPanelMode.value === 'generating' || rightPanelMode.value === 'resume') {
|
||||
ElMessage.warning('请完成或退出投递流程后再添加岗位')
|
||||
return
|
||||
}
|
||||
activeRecommendMsgId.value = msgId
|
||||
showRightPanel.value = true
|
||||
panelLoading.value = true
|
||||
@@ -618,10 +656,137 @@ function handleCloseRecommendPanel() {
|
||||
rightPanelMode.value = 'recommend'
|
||||
}
|
||||
|
||||
// ==================== 岗位预览(模式4) ====================
|
||||
|
||||
/** 当前预览的岗位 ID */
|
||||
const previewJobId = ref('')
|
||||
|
||||
/** 当前预览岗位的投递状态 */
|
||||
const previewJobApplicationStatus = ref<number | null>(null)
|
||||
|
||||
/** 进入岗位预览前记录的面板模式(用于返回) */
|
||||
const prevPanelMode = ref<'recommend' | 'generating' | 'resume' | 'jobPreview' | 'pendingList' | 'applyProgress' | 'settings'>('recommend')
|
||||
|
||||
/** 打开岗位预览面板 */
|
||||
function handleOpenJobPreview(jobId: string | number, applicationStatus?: number | null) {
|
||||
previewJobId.value = String(jobId)
|
||||
previewJobApplicationStatus.value = applicationStatus ?? null
|
||||
prevPanelMode.value = rightPanelMode.value
|
||||
rightPanelMode.value = 'jobPreview'
|
||||
showRightPanel.value = true
|
||||
}
|
||||
|
||||
/** 岗位预览返回 — 回到之前的面板模式 */
|
||||
function handleJobPreviewBack() {
|
||||
/* 如果之前是 recommend 模式且有选中的消息,回到推荐面板 */
|
||||
if (prevPanelMode.value === 'recommend' && activeRecommendMsgId.value !== null) {
|
||||
rightPanelMode.value = 'recommend'
|
||||
} else {
|
||||
/* 否则关闭面板 */
|
||||
showRightPanel.value = false
|
||||
rightPanelMode.value = 'recommend'
|
||||
}
|
||||
}
|
||||
|
||||
/** 岗位预览中点击添加 — 添加到待投递 */
|
||||
async function handleJobPreviewAdd(jobId: string) {
|
||||
try {
|
||||
await applyJob({ jobId, status: -1 })
|
||||
/* 更新对话消息中的岗位状态 */
|
||||
updateJobStatusInMessages(Number(jobId), -1)
|
||||
previewJobApplicationStatus.value = -1
|
||||
await loadApplyList()
|
||||
ElMessage.success('岗位已添加到待投递')
|
||||
} catch {
|
||||
ElMessage.error('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 岗位预览中点击移出 — 从待投递移除 */
|
||||
async function handleJobPreviewRemove(jobId: string) {
|
||||
try {
|
||||
await cancelApplyJob(jobId)
|
||||
/* 更新对话消息中的岗位状态 */
|
||||
updateJobStatusInMessages(Number(jobId), null)
|
||||
previewJobApplicationStatus.value = null
|
||||
await loadApplyList()
|
||||
ElMessage.success('岗位已从待投递移除')
|
||||
} catch {
|
||||
ElMessage.error('操作失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** AgentMatchJobAdd 中点击岗位 — 打开岗位预览 */
|
||||
function handleMatchJobClick(job: AgentRecommendJob) {
|
||||
handleOpenJobPreview(job.id, job.applicationStatus)
|
||||
}
|
||||
|
||||
/** AgentChatJobList 中点击岗位 — 打开岗位预览 */
|
||||
function handleChatJobClick(job: AgentRecommendJob) {
|
||||
/* 投递流程进行中时禁止打开岗位详情 */
|
||||
if (isApplying.value) {
|
||||
ElMessage.warning('请完成或取消投递流程后再查看岗位详情')
|
||||
return
|
||||
}
|
||||
handleOpenJobPreview(job.id, job.applicationStatus)
|
||||
}
|
||||
|
||||
// ==================== 待投递列表面板(模式5) ====================
|
||||
|
||||
/** 打开待投递列表面板 */
|
||||
function handleOpenPendingListPanel() {
|
||||
showTaskListDropdown.value = false
|
||||
rightPanelMode.value = 'pendingList'
|
||||
showRightPanel.value = true
|
||||
}
|
||||
|
||||
/** 关闭待投递列表面板 */
|
||||
function handleClosePendingListPanel() {
|
||||
showRightPanel.value = false
|
||||
rightPanelMode.value = 'recommend'
|
||||
}
|
||||
|
||||
/** 待投递列表面板中移除岗位后同步父组件数据 */
|
||||
function handlePendingListRemoved(jobId: string) {
|
||||
const idx = applyJobList.value.findIndex(j => j.id === jobId)
|
||||
if (idx !== -1) {
|
||||
applyJobList.value.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 申请进度面板(模式6) ====================
|
||||
|
||||
/** 打开申请进度面板 */
|
||||
function handleOpenApplyProgressPanel() {
|
||||
showTaskListDropdown.value = false
|
||||
rightPanelMode.value = 'applyProgress'
|
||||
showRightPanel.value = true
|
||||
}
|
||||
|
||||
/** 关闭申请进度面板 */
|
||||
function handleCloseApplyProgressPanel() {
|
||||
showRightPanel.value = false
|
||||
rightPanelMode.value = 'recommend'
|
||||
}
|
||||
|
||||
// ==================== 设置面板(模式7) ====================
|
||||
|
||||
/** 打开设置面板 */
|
||||
function handleOpenSettingsPanel() {
|
||||
rightPanelMode.value = 'settings'
|
||||
showRightPanel.value = true
|
||||
}
|
||||
|
||||
/** 关闭设置面板 */
|
||||
function handleCloseSettingsPanel() {
|
||||
showRightPanel.value = false
|
||||
rightPanelMode.value = 'recommend'
|
||||
}
|
||||
|
||||
// ==================== 投递进度流程 ====================
|
||||
|
||||
/** 右侧面板显示模式:recommend-岗位推荐 / generating-简历生成中 / resume-简历预览 */
|
||||
const rightPanelMode = ref<'recommend' | 'generating' | 'resume'>('recommend')
|
||||
/** 右侧面板显示模式 */
|
||||
const rightPanelMode = ref<'recommend' | 'generating' | 'resume' | 'jobPreview' | 'pendingList' | 'applyProgress' | 'settings'>('recommend')
|
||||
|
||||
/** 简历生成进度百分比(0-100) */
|
||||
const generateProgress = ref(0)
|
||||
@@ -629,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('')
|
||||
|
||||
@@ -770,6 +938,7 @@ async function handleStartApply() {
|
||||
// 打开右侧面板,显示生成进度
|
||||
rightPanelMode.value = 'generating'
|
||||
showRightPanel.value = true
|
||||
applyCancelled.value = false
|
||||
startProgressSimulation()
|
||||
|
||||
try {
|
||||
@@ -778,12 +947,18 @@ async function handleStartApply() {
|
||||
jobId: targetJob.id,
|
||||
})
|
||||
|
||||
// 如果在等待期间已取消,直接退出
|
||||
if (applyCancelled.value) return
|
||||
|
||||
// 接口返回后涨满进度条
|
||||
finishProgress()
|
||||
|
||||
// 等待 1 秒让用户看到 100%
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// 再次检查取消状态
|
||||
if (applyCancelled.value) return
|
||||
|
||||
// 解析简历数据 — 映射接口返回格式到 ResumeTemplateData
|
||||
const apiData = res?.data || res
|
||||
if (apiData) {
|
||||
@@ -801,6 +976,8 @@ async function handleStartApply() {
|
||||
rightPanelMode.value = 'resume'
|
||||
}
|
||||
} catch (e) {
|
||||
// 取消导致的异常不提示
|
||||
if (applyCancelled.value) return
|
||||
console.error('[Agent] 优化简历失败', e)
|
||||
ElMessage.error('简历优化失败,请重试')
|
||||
finishProgress()
|
||||
@@ -895,8 +1072,20 @@ function handleTogglePause() {
|
||||
isPaused.value = !isPaused.value
|
||||
}
|
||||
|
||||
/** 标记当前投递流程是否已被取消(防止异步回调覆盖状态) */
|
||||
const applyCancelled = ref(false)
|
||||
|
||||
/** 取消投递流程 */
|
||||
function handleCancelApply(msg: AgentChatMessage) {
|
||||
// 标记已取消,阻止进行中的异步回调继续操作
|
||||
applyCancelled.value = true
|
||||
// 清除进度条定时器
|
||||
if (progressTimer) {
|
||||
clearInterval(progressTimer)
|
||||
progressTimer = null
|
||||
}
|
||||
// 重置暂停状态
|
||||
isPaused.value = false
|
||||
// 从消息列表中移除该条 apply_progress
|
||||
const idx = chatMessages.value.findIndex(m => m.id === msg.id)
|
||||
if (idx !== -1) {
|
||||
@@ -910,13 +1099,60 @@ function handleCancelApply(msg: AgentChatMessage) {
|
||||
}
|
||||
|
||||
/** 第2步:确认简历 */
|
||||
function handleConfirmResumeStep(msg: AgentChatMessage) {
|
||||
async function handleConfirmResumeStep(msg: AgentChatMessage) {
|
||||
try {
|
||||
const extraData = JSON.parse(msg.extra)
|
||||
// 勾选第2步,进入第3步
|
||||
extraData.step = 3
|
||||
msg.extra = JSON.stringify(extraData)
|
||||
} catch { /* 忽略 */ }
|
||||
|
||||
// 确认简历后,自动生成PDF并缓存到浏览器localStorage
|
||||
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)
|
||||
|
||||
//测试一下通过userId和jobId拿到storageKey,再用storageKey拿到简历文件并下载到浏览器测试一下(测试成功,先注释)
|
||||
// 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步:跳过投递 */
|
||||
@@ -1029,6 +1265,7 @@ async function autoStartNextApply() {
|
||||
// 第1步:优化简历
|
||||
rightPanelMode.value = 'generating'
|
||||
showRightPanel.value = true
|
||||
applyCancelled.value = false
|
||||
startProgressSimulation()
|
||||
|
||||
try {
|
||||
@@ -1037,9 +1274,13 @@ async function autoStartNextApply() {
|
||||
jobId: targetJob.id,
|
||||
})
|
||||
|
||||
if (applyCancelled.value) return
|
||||
|
||||
finishProgress()
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
if (applyCancelled.value) return
|
||||
|
||||
const apiData = res?.data || res
|
||||
if (apiData) {
|
||||
const resumeResult = mapOptimizeResumeToTemplate(apiData)
|
||||
@@ -1053,6 +1294,7 @@ async function autoStartNextApply() {
|
||||
rightPanelMode.value = 'resume'
|
||||
}
|
||||
} catch (e) {
|
||||
if (applyCancelled.value) return
|
||||
console.error('[Agent] 优化简历失败', e)
|
||||
ElMessage.error('简历优化失败,请重试')
|
||||
finishProgress()
|
||||
|
||||
@@ -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,14 +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')
|
||||
// 触发预渲染事件 — 配合 vite.config.ts 中 PrerenderPlugin 的 renderAfterDocumentEvent 设置
|
||||
document.dispatchEvent(new Event('prerender-trigger'))
|
||||
})
|
||||
|
||||
/** 点击登陆按钮 — 打开登录弹窗,登录成功后跳转到 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>
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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"
|
||||
@@ -121,7 +121,7 @@
|
||||
@click="goToDetail(job)"
|
||||
>
|
||||
<div class="jobs-page__job-main">
|
||||
<!-- 左侧:公司图标 + 职位信息 -->
|
||||
<!-- 左侧:公司图标(后面一定要换成正式的公司logo) + 职位信息 -->
|
||||
<div class="jobs-page__job-left">
|
||||
<div class="dflex ">
|
||||
<div class="jobs-page__job-icon mr16">
|
||||
@@ -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,8 +267,17 @@
|
||||
<!-- 职位问题反馈弹窗 -->
|
||||
<JobFeedbackDialog ref="feedbackDialogRef" v-model="showFeedbackDialog" :job-id="feedbackJobId" />
|
||||
|
||||
<!-- 欢迎上传简历弹窗 -->
|
||||
<ProfileWelcomeDialog v-model="showWelcomeDialog" />
|
||||
<!-- 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>
|
||||
|
||||
@@ -268,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'
|
||||
@@ -276,10 +294,11 @@ import JobFeedbackDialog from '@/components/JobFeedbackDialog.vue'
|
||||
import IndustrySelector from '@/components/tools/IndustrySelector.vue'
|
||||
import JobCategorySelector from '@/components/tools/JobCategorySelector.vue'
|
||||
import RegionSelector from '@/components/tools/RegionSelector.vue'
|
||||
import ProfileWelcomeDialog from '@/components/ProfileWelcomeDialog.vue'
|
||||
import { fetchProfile } from '@/api/profile'
|
||||
|
||||
|
||||
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. 注意:这里的字符串不需要再手动转义双引号了
|
||||
@@ -332,8 +351,7 @@ const feedbackJobId = ref<string | null>(null)
|
||||
/** 当前问助手的岗位 ID(传给 AiChat 组件) */
|
||||
const currentAskJobId = ref<string>('')
|
||||
|
||||
/** 欢迎弹窗的显示状态 */
|
||||
const showWelcomeDialog = ref(false)
|
||||
|
||||
|
||||
/** 点击"问助手"按钮,传入岗位 ID 给 AiChat */
|
||||
function askAssistant(job: JobItem) {
|
||||
@@ -429,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 → 接口参数 employmentType(0=全职 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 */
|
||||
@@ -485,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) {
|
||||
@@ -519,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 () => {
|
||||
@@ -552,15 +657,7 @@ onMounted(async () => {
|
||||
// 加载用户个人信息到全局 store
|
||||
store.dispatch('loadUserInfo')
|
||||
|
||||
// 检查个人资料是否存在,不存在则弹出欢迎弹窗
|
||||
// try {
|
||||
// const profileRes = await fetchProfile()
|
||||
// if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
|
||||
// showWelcomeDialog.value = true
|
||||
// }
|
||||
// } catch {
|
||||
// // 接口异常不阻塞页面加载
|
||||
// }
|
||||
|
||||
|
||||
// 加载收藏统计(用于 Tab 标签显示)
|
||||
loadFavoriteCount()
|
||||
@@ -668,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()) {
|
||||
@@ -907,20 +1004,7 @@ watch(
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 监听登录状态变化 — 登录成功后检查个人资料是否存在
|
||||
watch(isAuthenticated, async (newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
// 从未登录变为已登录,检查个人资料
|
||||
try {
|
||||
const profileRes = await fetchProfile()
|
||||
if (profileRes.code === '0' && (!profileRes.data || profileRes.data === null)) {
|
||||
showWelcomeDialog.value = true
|
||||
}
|
||||
} catch {
|
||||
// 接口异常不阻塞
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
// 监听 Tab 切换,重置列表状态并加载对应数据
|
||||
watch(activeTab, (newTab, oldTab) => {
|
||||
|
||||
@@ -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) {
|
||||
// 前4个每2秒切换
|
||||
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>
|
||||
@@ -10,8 +10,8 @@
|
||||
@save="handleSaveEdit"
|
||||
/>
|
||||
|
||||
<!-- 欢迎上传简历弹窗 -->
|
||||
<ProfileWelcomeDialog v-model="showWelcomeDialog" />
|
||||
<!-- 欢迎上传简历弹窗(无个人资料时自动弹出) -->
|
||||
<ResumeUploadDialog v-model="showWelcomeDialog" @confirm="handleWelcomeUpload" />
|
||||
|
||||
<!-- 页面标题 -->
|
||||
<div class="profile-page__header">
|
||||
@@ -60,8 +60,11 @@ import { useStore } from 'vuex'
|
||||
import SideNav from '@/components/SideNav.vue'
|
||||
import ProfileEditDrawer from '@/components/ProfileEditDrawer.vue'
|
||||
import ProfilePageContent from '@/components/ProfilePageContent.vue'
|
||||
import ProfileWelcomeDialog from '@/components/ProfileWelcomeDialog.vue'
|
||||
import { saveProfile, fetchProfile, fetchEducation, saveEducation, fetchWork, saveWork, fetchInternship, saveInternship, fetchProject, saveProject, fetchCompetition, saveCompetition } from '@/api/profile'
|
||||
import ResumeUploadDialog from '@/components/ResumeUploadDialog.vue'
|
||||
import { saveProfile, fetchProfile, fetchEducation, saveEducation, fetchWork, saveWork, fetchInternship, saveInternship, fetchProject, saveProject, fetchCompetition, saveCompetition, syncProfileFromResume } from '@/api/profile'
|
||||
import { uploadResume } from '@/utils/aiRequest'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import 'element-plus/es/components/loading/style/css'
|
||||
import type { SaveEducationItem, SaveWorkItem, SaveProjectItem, SaveCompetitionItem } from '@/api/profile'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -217,6 +220,35 @@ const showEditDrawer = ref(false)
|
||||
/** 欢迎弹窗的显示状态 */
|
||||
const showWelcomeDialog = ref(false)
|
||||
|
||||
/** 欢迎弹窗确认上传回调 — 上传简历并同步个人资料,完成后刷新页面 */
|
||||
async function handleWelcomeUpload(file: File) {
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '简历解析中,请耐心等待…',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
customClass: 'profile-welcome-loading',
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await uploadResume(file)
|
||||
if (res.code === 0 && res.data?.resumeId) {
|
||||
// 继续同步个人资料
|
||||
loading.setText('正在同步个人资料…')
|
||||
await syncProfileFromResume(String(res.data.resumeId))
|
||||
loading.close()
|
||||
ElMessage.success('简历上传并同步成功')
|
||||
// 刷新页面数据
|
||||
router.go(0)
|
||||
} else {
|
||||
loading.close()
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
loading.close()
|
||||
ElMessage.error('上传失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录状态 */
|
||||
const isAuthenticated = computed(() => store.state.isAuthenticated)
|
||||
|
||||
|
||||
@@ -92,6 +92,12 @@
|
||||
:resume-id="exportResumeId"
|
||||
:resume-name="exportResumeName"
|
||||
/>
|
||||
|
||||
<!-- 上传简历弹窗组件 -->
|
||||
<ResumeUploadDialog
|
||||
v-model="uploadDialogVisible"
|
||||
@confirm="handleUploadConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -101,13 +107,15 @@ import { useRouter } from 'vue-router'
|
||||
import SideNav from '@/components/SideNav.vue'
|
||||
import ResumeEditNameDialog from '@/components/ResumeEditNameDialog.vue'
|
||||
import ResumeExportDialog from '@/components/ResumeExportDialog.vue'
|
||||
import ResumeUploadDialog from '@/components/ResumeUploadDialog.vue'
|
||||
import { uploadResume } from '@/utils/aiRequest'
|
||||
import {
|
||||
fetchResumeList, deleteResume, type ResumeListItem,
|
||||
} from '@/api/resume'
|
||||
import { ElMessage, ElMessageBox, ElLoading } from 'element-plus'
|
||||
// ElLoading.service() 是命令式调用,按需引入插件不会自动加载其样式,需手动引入
|
||||
// 命令式调用的组件,按需引入插件不会自动加载其样式,需手动引入
|
||||
import 'element-plus/es/components/loading/style/css'
|
||||
import 'element-plus/es/components/message-box/style/css'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -218,6 +226,11 @@ const exportResumeId = ref('')
|
||||
/** 当前导出的简历名称(用于文件名) */
|
||||
const exportResumeName = ref('')
|
||||
|
||||
// ==================== 上传简历弹窗状态 ====================
|
||||
|
||||
/** 上传弹窗是否可见 */
|
||||
const uploadDialogVisible = ref(false)
|
||||
|
||||
/** 弹出菜单操作项 */
|
||||
const popupActions = ['设为默认简历', '编辑名称岗位', '导出简历', '删除']
|
||||
|
||||
@@ -276,46 +289,38 @@ async function handleAction(action: string, id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传简历 — 弹出文件选择,选择后调用 AI 接口上传 */
|
||||
/** 上传简历 — 打开上传弹窗 */
|
||||
function handleUpload() {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
// 限定 pdf 和 word 格式
|
||||
input.accept = '.pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
uploadDialogVisible.value = true
|
||||
}
|
||||
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
/** 上传弹窗确认回调 — 接收 File 对象,调用接口上传 */
|
||||
async function handleUploadConfirm(file: File) {
|
||||
// 全屏加载提示,AI 接口响应较慢
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '简历解析中,请耐心等待…',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
customClass: 'resume-upload-loading',
|
||||
})
|
||||
|
||||
// 全屏加载提示,AI 接口响应较慢
|
||||
const loading = ElLoading.service({
|
||||
lock: true,
|
||||
text: '简历解析中,请耐心等待…',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
customClass: 'resume-upload-loading',
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await uploadResume(file)
|
||||
if (res.code === 0) {
|
||||
// 上传成功,刷新列表
|
||||
loadResumeList()
|
||||
// 等待让后端异步处理数据,再关闭加载动画并跳转详情页
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
loading.close()
|
||||
// resumeId 已经是字符串(transformResponse 处理过),直接使用
|
||||
goDetail(res.data.resumeId)
|
||||
} else {
|
||||
loading.close()
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const res = await uploadResume(file)
|
||||
if (res.code === 0) {
|
||||
// 上传成功,刷新列表
|
||||
loadResumeList()
|
||||
// 等待让后端异步处理数据,再关闭加载动画并跳转详情页
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
loading.close()
|
||||
ElMessage.error('上传失败,请稍后重试')
|
||||
goDetail(res.data.resumeId)
|
||||
} else {
|
||||
loading.close()
|
||||
ElMessage.error(res.msg || '上传失败')
|
||||
}
|
||||
} catch {
|
||||
loading.close()
|
||||
ElMessage.error('上传失败,请稍后重试')
|
||||
}
|
||||
|
||||
input.click()
|
||||
}
|
||||
|
||||
/** 跳转到简历详情页 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
|
||||