仓库初始化+岗位相关页面

This commit is contained in:
xuxin
2026-03-24 21:06:00 +08:00
commit 0468339d23
113 changed files with 18644 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# 开发环境 — 请求走 vite proxy,前缀 /api 会被代理到 http://127.0.0.1:8080/api
VITE_API_BASE_URL=/api
+2
View File
@@ -0,0 +1,2 @@
# 生产环境 — 根据实际部署地址修改
VITE_API_BASE_URL=/api
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const ElMessage: typeof import('element-plus/es').ElMessage
const ElMessageBox: typeof import('element-plus/es').ElMessageBox
}
+42
View File
@@ -0,0 +1,42 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AiChat: typeof import('./src/components/AiChat.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
IndustrySelector: typeof import('./src/components/tools/IndustrySelector.vue')['default']
JobCategorySelector: typeof import('./src/components/tools/JobCategorySelector.vue')['default']
JobDislikeDialog: typeof import('./src/components/JobDislikeDialog.vue')['default']
JobFeedbackDialog: typeof import('./src/components/JobFeedbackDialog.vue')['default']
JobGoalDialog: typeof import('./src/components/JobGoalDialog.vue')['default']
JobPageHeader: typeof import('./src/components/JobPageHeader.vue')['default']
LoginDialog: typeof import('./src/components/LoginDialog.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']
RegionSelector: typeof import('./src/components/tools/RegionSelector.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SettingsDialog: typeof import('./src/components/SettingsDialog.vue')['default']
SideNav: typeof import('./src/components/SideNav.vue')['default']
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>jobassistantweb</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{
"name": "jobassistantweb",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.3",
"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/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",
"vite": "^7.3.1",
"vue-tsc": "^3.1.5"
}
}
+3348
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- esbuild
- puppeteer
- vue-demi
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+20
View File
@@ -0,0 +1,20 @@
<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>
<style lang="scss">
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
</style>
+50
View File
@@ -0,0 +1,50 @@
import request from '@/utils/request'
/** 通用响应结构 */
export interface ApiResult<T = any> {
code: string
msg: string
data?: T
timestamp: string
uuid: string
}
/** 登录成功返回的用户信息 */
export interface LoginData {
userId: string
nick: string
}
/**
* 发送短信验证码
* POST /public/sms/sendCode?mobileNumber=xxx
* 返回 data: true 表示发送成功,data: false 表示发送失败
*/
export function sendSmsCode(mobileNumber: string) {
return request.post<any, ApiResult<boolean>>('/public/sms/sendCode', null, {
params: { mobileNumber },
})
}
/**
* 短信验证码登录
* POST /public/login/smsLogin
* Body: { mobileNumber, code, inviteCode? }
* 登录成功后后端会 Set-Cookie: Token=xxx
*/
export function smsLogin(mobileNumber: string, code: string, inviteCode?: string) {
return request.post<any, ApiResult<LoginData>>('/public/login/smsLogin', {
mobileNumber,
code,
...(inviteCode ? { inviteCode } : {}),
})
}
/**
* 退出登录
* POST /public/logout
* 不需要参数,Cookie 会自动携带
*/
export function logout() {
return request.post<any, ApiResult>('/public/logout')
}
+91
View File
@@ -0,0 +1,91 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api/auth'
/** 行业树节点(二级) */
export interface IndustryChild {
id: string
name: string
level: number
}
/** 行业树节点(一级,含子节点) */
export interface IndustryItem {
id: string
name: string
level: number
children: IndustryChild[]
}
/**
* 获取行业树分类数据
* GET /public/industries/tree
* 返回一级二级嵌套的行业分类
*/
export function fetchIndustryTree() {
return request.get<any, ApiResult<IndustryItem[]>>('/public/industries/tree')
}
// ==================== 岗位分类相关 ====================
/** 岗位树节点(三级 / 末级) */
export interface JobCategoryLeaf {
id: string
name: string
level: number
}
/** 岗位树节点(二级,含三级子节点) */
export interface JobCategoryChild {
id: string
name: string
level: number
children: JobCategoryLeaf[]
}
/** 岗位树节点(一级,含二级子节点) */
export interface JobCategoryItem {
id: string
name: string
level: number
children: JobCategoryChild[]
}
/**
* 获取岗位树分类数据
* GET /public/job-categories/tree
* 返回一级二级三级嵌套的岗位分类
*/
export function fetchJobCategoryTree() {
return request.get<any, ApiResult<JobCategoryItem[]>>('/public/job-categories/tree')
}
// ==================== 地区分类相关 ====================
/** 地区树节点(三级 / 区县级) */
export interface RegionLeaf {
code: string
name: string
}
/** 地区树节点(二级 / 市级,可能含三级子节点) */
export interface RegionChild {
code: string
name: string
children?: RegionLeaf[]
}
/** 地区树节点(一级 / 省级,含二级子节点) */
export interface RegionItem {
code: string
name: string
children: RegionChild[]
}
/**
* 获取地区树分类数据
* GET /public/regions/tree
* 返回省市区三级嵌套的地区分类
*/
export function fetchRegionTree() {
return request.get<any, ApiResult<RegionItem[]>>('/public/regions/tree')
}
+192
View File
@@ -0,0 +1,192 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api/auth'
// ==================== 匹配详情 ====================
/** 匹配度详情 */
export interface MatchDetail {
/** 行业匹配分 */
industryScore: number
/** 技能匹配分 */
skillScore: number
/** 经验匹配分 */
experienceScore: number
}
// ==================== 岗位列表项 ====================
/** 岗位列表项(接口返回结构) */
export interface JobListItem {
/** 岗位 ID */
id: string
/** 岗位标题 */
title: string
/** 公司全称 */
companyName: string
/** 公司简称 */
companyShortName: string
/** 公司类型(如"上市企业" */
companyType: string
/** 地区名称 */
regionName: string
/** 岗位分类名称 */
categoryName: string
/** 标签列表 */
tags: string[]
/** 原始招聘链接 */
sourceUrl: string
/** 是否已收藏 */
isFavorite: boolean
/** 岗位状态:0=有效 1=已下架 2=已过期 */
status: number
/** 综合匹配分(0-100 */
matchScore: number
/** 匹配度详情 */
matchDetail: MatchDetail
}
// ==================== 分页结构 ====================
/** 分页数据结构 */
export interface JobPageData {
/** 当前页码 */
pageNum: string
/** 每页条数 */
pageSize: string
/** 总记录数 */
total: string
/** 岗位列表 */
list: JobListItem[]
}
// ==================== 请求参数 ====================
/** 岗位列表请求参数 */
export interface JobListParams {
/** 当前页码,从1开始,默认1 */
pageNum?: number
/** 每页条数,默认15 */
pageSize?: number
/** 地区编码列表 */
regionCodes?: string[]
/** 岗位类型 ID 列表 */
categoryIds?: number[]
/** 行业 ID 列表 */
industryIds?: number[]
/** 工作类型:0=全职 1=兼职 */
employmentType?: number
/** 指定岗位 ID 列表(用于收藏列表) */
jobIds?: number[]
/** 岗位状态过滤(0=有效 1=已下架 2=已过期,可多选,null或空=查所有) */
statusFilter?: number[]
}
// ==================== 接口方法 ====================
/**
* 获取岗位列表
* POST /job/list
* @param params 岗位列表查询参数
*/
export function fetchJobList(params: JobListParams = {}) {
return request.post<any, ApiResult<JobPageData>>('/job/list', {
pageNum: params.pageNum ?? 1,
pageSize: params.pageSize ?? 15,
...params,
})
}
// ==================== 岗位详情 ====================
/** 匹配度详情(岗位详情用) */
export interface JobMatchScoreDto {
/** 行业得分(0-100 */
industryScore: number
/** 技能得分(0-100 */
skillScore: number
/** 经验得分(0-100 */
experienceScore: number
}
/** 岗位详情出参 */
export interface JobDetailData {
/** 岗位 ID */
jobId: string
/** 岗位标题 */
jobTitle: string
/** 薪资描述 */
salary: string
/** 工作类型 0=全职 1=兼职 */
employmentType: number
/** 学历要求 0=不限 1=大专 2=本科 3=硕士 4=博士 */
education: number
/** 最低工作年限 */
minExperience: number
/** 岗位职责 */
description: string
/** 任职要求 */
requirement: string
/** 加分项 */
bonus: string
/** 岗位标签 */
tags: string[]
/** 技能标签 */
skillTags: string[]
/** 来源链接 */
sourceUrl: string
/** 岗位类型名称 */
categoryName: string
/** 要求的行业经验名称 */
requiredIndustryName: string
/** 公司 ID */
companyId: string
/** 公司名称 */
companyName: string
/** 公司简称 */
companyShortName: string
/** 公司 Logo URL */
companyLogoUrl: string
/** 公司类型 */
companyType: string
/** 公司所属行业名称 */
companyIndustryName: string
/** 公司标签 */
companyTags: string[]
/** 公司简介 */
companySummary: string
/** 公司描述 */
companyDescription: string
/** 成立时间 */
companyFoundedYear: string
/** 公司地址 */
companyAddress: string
/** 公司规模 */
companyScale: string
/** 公司官网 */
companyWebsite: string
/** 融资状态 */
companyFinancingStage: string
/** 最新估值 */
companyLatestValuation: string
/** 公司新闻 */
companyNews: string[]
/** 地区名称 */
regionName: string
/** 匹配总分 */
matchScore: number
/** 匹配度详情 */
matchDetail: JobMatchScoreDto
/** 是否已收藏 */
isFavorite: boolean
}
/**
* 获取岗位详情
* GET /job/detail?jobId=xxx
* @param jobId 岗位 ID
*/
export function fetchJobDetail(jobId: string) {
return request.get<any, ApiResult<JobDetailData>>('/job/detail', {
params: { jobId },
})
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 获取当前用户有权限的路由列表
*
* 【mock 阶段】直接返回写死的数据,模拟后端接口
* 【对接真实接口时】把下面的 mock 数据替换成:
* return axios.get('/api/user/menus').then(res => res.data)
*
* 返回格式约定:
* path — 路由路径
* name — 路由名称(唯一标识,也用于 removeRoute
* component — 字符串,对应前端组件映射表的 key
* meta — 可选,传给路由的 meta 信息(图标、标题等)
*/
export interface MenuItemRaw {
path: string
name: string
component: string
meta?: {
label?: string
icon?: string
badge?: string
/** 'footer' 表示该菜单项显示在底部区域而非主导航 */
position?: string
}
}
export async function fetchUserRoutes(): Promise<MenuItemRaw[]> {
// TODO: 替换为真实接口 → return axios.get('/api/user/menus').then(res => res.data)
return [
{ path: '/agent', name: 'Agent', component: 'Agent', meta: { label: 'AI助手', icon: 'nav-agent-icon', badge: 'NEW' } },
{ path: '/profile', name: 'Profile', component: 'Profile', meta: { label: '个人资料', icon: 'nav-profile-icon' } },
{ path: '/resume', name: 'Resume', component: 'Resume', meta: { label: '简历', icon: 'nav-resume-icon' } },
{ path: '/settings', name: 'Settings', component: 'Settings', meta: { label: '设置', icon: 'nav-setting-icon', position: 'footer' } },
]
}
+309
View File
@@ -0,0 +1,309 @@
import request from '@/utils/request'
import type { ApiResult } from '@/api/auth'
// ==================== 个人资料主表相关 ====================
/** 保存/更新个人资料主表 — 请求参数 */
export interface SaveProfileParams {
/** 真实姓名 */
name?: string
/** 邮箱 */
email?: string
/** 手机号码 */
mobileNumber?: string
/** 身份证号 */
idCard?: string
/** 所在城市编码 */
regionCode?: string
/** 微信号 */
wechatNumber?: string
/** 作品集链接 */
portfolioUrl?: string
/** 工作年限 */
workYears?: number
/** 拥有经验的行业ID列表 */
experienceIndustryIds?: number[]
/** 技能标签列表 */
skills?: string[]
/** 证书标签列表 */
certificates?: string[]
}
/**
* 保存/更新个人资料主表信息
* POST /user/profile
* Cookie 自动携带身份信息,无需额外传 userId
*/
export function saveProfile(data: SaveProfileParams) {
return request.post<any, ApiResult>('/user/profile', data)
}
/** 获取个人资料主表 — 返回数据结构 */
export interface ProfileData {
/** 主键 ID */
id?: number
/** 真实姓名 */
name?: string
/** 邮箱 */
email?: string
/** 手机号码 */
mobileNumber?: string
/** 身份证号 */
idCard?: string
/** 所在城市编码 */
regionCode?: string
/** 微信号 */
wechatNumber?: string
/** 作品集链接 */
portfolioUrl?: string
/** 工作年限 */
workYears?: number
/** 拥有经验的行业ID列表 */
experienceIndustryIds?: number[]
/** 技能标签列表 */
skills?: string[]
/** 证书标签列表 */
certificates?: string[]
}
/**
* 获取个人资料主表信息
* GET /user/profile
* Cookie 自动携带身份信息,无需额外传参
*/
export function fetchProfile() {
return request.get<any, ApiResult<ProfileData>>('/user/profile')
}
// ==================== 教育经历相关 ====================
/** 描述段落 */
export interface DescriptionParagraph {
/** 段落标识,前端生成的短ID */
id?: string
/** 段落文本内容 */
text: string
}
/** 教育经历 — 查询返回数据结构 */
export interface EducationItem {
/** 主键 ID */
id?: number
/** 学校名称 */
school?: string
/** 专业 */
major?: string
/** 学历 1=大专 2=本科 3=硕士 4=博士 */
degree?: number
/** 学习形式 0=全日制 1=非全日制 */
studyType?: number
/** 入学时间,格式:YYYY-MM */
startDate?: string
/** 毕业时间,格式:YYYY-MM */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/** 教育经历 — 保存请求参数(单条) */
export interface SaveEducationItem {
/** 学校名称 */
school: string
/** 专业 */
major?: string
/** 学历 1=大专 2=本科 3=硕士 4=博士 */
degree: number
/** 学习形式 0=全日制 1=非全日制 */
studyType?: number
/** 入学时间,格式:YYYY-MM */
startDate?: string
/** 毕业时间,格式:YYYY-MM */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/**
* 获取教育经历列表
* GET /user/profile/education
* Cookie 自动携带身份信息,无需额外传参
*/
export function fetchEducation() {
return request.get<any, ApiResult<EducationItem[]>>('/user/profile/education')
}
/**
* 保存教育经历(全量覆盖)
* POST /user/profile/education
* Body: SaveEducationItem[]
*/
export function saveEducation(data: SaveEducationItem[]) {
return request.post<any, ApiResult>('/user/profile/education', data)
}
// ==================== 工作经历相关 ====================
/** 工作经历 — 查询返回数据结构 */
export interface WorkItem {
/** 主键 ID */
id?: number
/** 公司名称 */
companyName?: string
/** 职位 */
position?: string
/** 开始时间 */
startDate?: string
/** 结束时间 */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/** 工作经历 — 保存请求参数(单条) */
export interface SaveWorkItem {
/** 公司名称 */
companyName: string
/** 职位 */
position: string
/** 开始时间,格式:2023.06 */
startDate: string
/** 结束时间,格式:2023.09,至今则为空 */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/**
* 获取工作经历列表
* GET /user/profile/work
*/
export function fetchWork() {
return request.get<any, ApiResult<WorkItem[]>>('/user/profile/work')
}
/**
* 保存工作经历(全量覆盖)
* POST /user/profile/work
* Body: SaveWorkItem[]
*/
export function saveWork(data: SaveWorkItem[]) {
return request.post<any, ApiResult>('/user/profile/work', data)
}
// ==================== 实习经历相关 ====================
// 实习经历字段与工作经历一致,复用 WorkItem / SaveWorkItem 类型
/**
* 获取实习经历列表
* GET /user/profile/internship
*/
export function fetchInternship() {
return request.get<any, ApiResult<WorkItem[]>>('/user/profile/internship')
}
/**
* 保存实习经历(全量覆盖)
* POST /user/profile/internship
* Body: SaveWorkItem[]
*/
export function saveInternship(data: SaveWorkItem[]) {
return request.post<any, ApiResult>('/user/profile/internship', data)
}
// ==================== 项目经历相关 ====================
/** 项目经历 — 查询返回数据结构 */
export interface ProjectItem {
/** 主键 ID */
id?: number
/** 所属公司 */
companyName?: string
/** 项目名称 */
projectName?: string
/** 担任角色 */
role?: string
/** 开始时间 */
startDate?: string
/** 结束时间 */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/** 项目经历 — 保存请求参数(单条) */
export interface SaveProjectItem {
/** 所属公司 */
companyName?: string
/** 项目名称 */
projectName: string
/** 担任角色 */
role?: string
/** 开始时间,格式:2023.06 */
startDate: string
/** 结束时间,格式:2023.09,至今则为空 */
endDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/**
* 获取项目经历列表
* GET /user/profile/project
*/
export function fetchProject() {
return request.get<any, ApiResult<ProjectItem[]>>('/user/profile/project')
}
/**
* 保存项目经历(全量覆盖)
* POST /user/profile/project
* Body: SaveProjectItem[]
*/
export function saveProject(data: SaveProjectItem[]) {
return request.post<any, ApiResult>('/user/profile/project', data)
}
// ==================== 竞赛经历相关 ====================
/** 竞赛经历 — 查询返回数据结构 */
export interface CompetitionItem {
/** 主键 ID */
id?: number
/** 竞赛名称 */
competitionName?: string
/** 获奖情况 */
award?: string
/** 获奖时间 */
awardDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/** 竞赛经历 — 保存请求参数(单条) */
export interface SaveCompetitionItem {
/** 竞赛名称 */
competitionName: string
/** 获奖情况,如全国二等奖 */
award?: string
/** 获奖时间,格式:2023.07 */
awardDate?: string
/** 描述段落 */
description?: DescriptionParagraph[]
}
/**
* 获取竞赛经历列表
* GET /user/profile/competition
*/
export function fetchCompetition() {
return request.get<any, ApiResult<CompetitionItem[]>>('/user/profile/competition')
}
/**
* 保存竞赛经历(全量覆盖)
* POST /user/profile/competition
* Body: SaveCompetitionItem[]
*/
export function saveCompetition(data: SaveCompetitionItem[]) {
return request.post<any, ApiResult>('/user/profile/competition', data)
}
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long
+187
View File
@@ -0,0 +1,187 @@
.ai-chat {
width: 3.6rem;
height: 100vh;
background: #f3f4f6;
display: flex;
flex-direction: column;
box-sizing: border-box;
position: fixed;
right: 0;
top: 0;
border-left: 1px solid #e5e7eb;
&__banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #4FC2C9 0%, #42A8B3 100%);
color: #fff;
padding: 0.1rem 0.16rem;
font-size: 0.13rem;
cursor: pointer;
border-radius: 0.2rem;
margin: 0.15rem;
height: 0.41rem;
}
&__banner-arrow {
font-size: 0.16rem;
padding-bottom: 0.05rem;
box-sizing: border-box;
}
&__header {
display: flex;
align-items: center;
gap: 0.1rem;
padding: 0.16rem;
border-bottom: 1px solid #e5e7eb;
}
&__avatar {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
background: #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.2rem;
}
&__info {
flex: 1;
}
&__name {
font-size: 0.16rem;
font-weight: 600;
color: #1a1a2e;
}
&__desc {
font-size: 0.12rem;
color: #6b7280;
margin-top: 0.02rem;
}
&__messages {
flex: 1;
overflow-y: auto;
padding: 0.16rem;
}
&__msg {
margin-bottom: 0.12rem;
display: flex;
&--ai {
justify-content: flex-start;
}
&--user {
justify-content: flex-end;
}
}
&__msg-bubble {
border-radius: 0.1rem;
padding: 0.12rem 0.14rem;
font-size: 0.13rem;
line-height: 1.6;
max-width: 85%;
}
&__msg--ai &__msg-bubble {
background: #fff;
border: 1px solid #e5e7eb;
color: #374151;
}
&__msg--user &__msg-bubble {
background: #f0f3f6;
color: #1a1a2e;
}
&__msg-title {
font-weight: 600;
font-size: 0.14rem;
margin-bottom: 0.06rem;
color: #1a1a2e;
}
&__msg-text {
color: #6b7280;
font-size: 0.12rem;
}
&__quick-questions {
display: flex;
flex-direction: column;
gap: 0.08rem;
margin-top: 0.12rem;
}
&__quick-item {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.08rem;
padding: 0.1rem 0.14rem;
color: #6b7280;
font-size: 0.12rem;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #f9fafb;
color: #374151;
}
}
&__input-area {
display: flex;
align-items: center;
gap: 0.08rem;
padding: 0.12rem 0.16rem;
border-top: 1px solid #e5e7eb;
background: #fff;
}
&__input {
flex: 1;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 0.06rem;
padding: 0.1rem 0.12rem;
color: #1a1a2e;
font-size: 0.12rem;
outline: none;
&::placeholder {
color: #9ca3af;
}
&:focus {
border-color: #9ca3af;
}
}
&__send-btn {
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: #1a1a2e;
border: none;
color: #fff;
font-size: 0.16rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
&:hover {
background: #2e3142;
}
}
}
@@ -0,0 +1,4 @@
// HelloWorld 组件样式
.hello-world {
// 在这里添加 HelloWorld 组件的样式
}
@@ -0,0 +1,307 @@
// ========================================
// 行业选择器组件样式(分栏联动版)
// 全局 1rem = 100px,表单元素用固定 height 控制
// 颜色统一使用 variables.scss 变量
// ========================================
@use '../variables' as *;
.industry-selector {
position: relative;
display: inline-block;
// ==================== 触发按钮 ====================
&__trigger {
display: flex;
align-items: center;
gap: 0.04rem;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.2rem;
padding: 0.05rem 0.14rem;
color: $text-dark;
font-size: 0.12rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
max-width: 2rem;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
// 触发按钮文字(超长省略)
&__display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 1.6rem;
}
// 下拉箭头图标
&__arrow {
width: 0.1rem;
height: 0.1rem;
color: $text-light;
flex-shrink: 0;
transition: transform 0.2s;
&--open {
transform: rotate(180deg);
}
}
// ==================== 下拉面板 ====================
&__panel {
position: absolute;
top: calc(100% + 0.06rem);
left: 0;
width: 3.6rem;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 0.1rem;
}
// ==================== 选中区(已选标签) ====================
&__selected-area {
display: flex;
flex-wrap: wrap;
gap: 0.06rem;
margin-bottom: 0.1rem;
max-height: 0.8rem;
overflow-y: auto;
}
// 单个已选标签
&__tag {
display: inline-flex;
align-items: center;
gap: 0.03rem;
height: 0.22rem;
background: $theme-color;
border: 1px solid rgba(79, 194, 201, 0.3);
border-radius: 0.04rem;
padding: 0 0.08rem;
font-size: 0.11rem;
line-height: 0.22rem;
color: $accent-hover;
white-space: nowrap;
box-sizing: border-box;
}
// 标签关闭按钮
&__tag-close {
width: 0.1rem;
height: 0.1rem;
cursor: pointer;
color: $accent;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: $danger;
}
}
// ==================== 搜索框 ====================
&__search-wrap {
margin-bottom: 0.08rem;
}
&__search {
display: block;
width: 100%;
height: 0.3rem;
box-sizing: border-box;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
outline: none;
background: $bg-main;
margin: 0;
transition: border-color 0.2s;
&::placeholder {
color: $text-light;
line-height: 0.3rem;
}
&:focus {
border-color: $accent;
background: $bg-white;
}
}
// ==================== 搜索结果列表 ====================
&__search-results {
max-height: 2rem;
overflow-y: auto;
border: 1px solid $border-color;
border-radius: 0.06rem;
background: $bg-white;
}
// 搜索结果单行
&__search-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.3rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
& + & {
border-top: 1px solid $border-color;
}
}
// 搜索无结果提示
&__search-empty {
padding: 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
// 选中勾图标
&__check {
width: 0.12rem;
height: 0.12rem;
color: $accent;
flex-shrink: 0;
}
// ==================== 分栏联动选择区 ====================
&__columns {
display: flex;
border: 1px solid $border-color;
border-radius: 0.06rem;
overflow: hidden;
height: 2.4rem;
}
// 单栏通用样式
&__col {
overflow-y: auto;
height: 100%;
// 左栏:一级行业列表
&--left {
width: 1.2rem;
flex-shrink: 0;
border-right: 1px solid $border-color;
background: $bg-main;
}
// 右栏:二级行业列表
&--right {
flex: 1;
background: $bg-white;
}
}
// 栏内每一行
&__col-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.32rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.32rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: $theme-color;
color: $accent-hover;
}
// 左栏当前激活项
&--active {
background: $bg-white;
color: $accent;
font-weight: 600;
// 左侧加一条品牌色竖线
box-shadow: inset 3px 0 0 $accent;
}
// 右栏已选中项
&--selected {
color: $accent;
background: $theme-color;
}
}
// 右栏空状态提示
&__col-empty {
padding: 0.2rem 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
// ==================== 底部操作按钮 ====================
&__actions {
display: flex;
justify-content: space-between;
margin-top: 0.1rem;
gap: 0.1rem;
}
&__btn {
flex: 1;
height: 0.3rem;
border-radius: 0.06rem;
font-size: 0.12rem;
line-height: 0.3rem;
cursor: pointer;
border: 1px solid $border-color;
transition: all 0.2s;
&--reset {
background: $bg-main;
color: $text-dark;
&:hover {
border-color: $text-light;
color: $text-dark;
}
}
&--confirm {
background: $accent;
color: $bg-white;
border-color: $accent;
&:hover {
background: $accent-hover;
border-color: $accent-hover;
}
}
}
}
@@ -0,0 +1,315 @@
// ========================================
// 岗位选择器组件样式(三栏联动版)
// 全局 1rem = 100px,表单元素用固定 height 控制
// 颜色统一使用 variables.scss 变量
// 风格与行业选择器保持一致
// ========================================
@use '../variables' as *;
.job-category-selector {
position: relative;
display: inline-block;
// ==================== 触发按钮 ====================
&__trigger {
display: flex;
align-items: center;
gap: 0.04rem;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.2rem;
padding: 0.05rem 0.14rem;
color: $text-dark;
font-size: 0.12rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
max-width: 2rem;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
// 触发按钮文字(超长省略)
&__display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 1.6rem;
}
// 下拉箭头图标
&__arrow {
width: 0.1rem;
height: 0.1rem;
color: $text-light;
flex-shrink: 0;
transition: transform 0.2s;
&--open {
transform: rotate(180deg);
}
}
// ==================== 下拉面板 ====================
&__panel {
position: absolute;
top: calc(100% + 0.06rem);
left: 0;
width: 4.8rem;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 0.1rem;
}
// ==================== 选中区(已选标签) ====================
&__selected-area {
display: flex;
flex-wrap: wrap;
gap: 0.06rem;
margin-bottom: 0.1rem;
max-height: 0.8rem;
overflow-y: auto;
}
// 单个已选标签
&__tag {
display: inline-flex;
align-items: center;
gap: 0.03rem;
height: 0.22rem;
background: $theme-color;
border: 1px solid rgba(79, 194, 201, 0.3);
border-radius: 0.04rem;
padding: 0 0.08rem;
font-size: 0.11rem;
line-height: 0.22rem;
color: $accent-hover;
white-space: nowrap;
box-sizing: border-box;
}
// 标签关闭按钮
&__tag-close {
width: 0.1rem;
height: 0.1rem;
cursor: pointer;
color: $accent;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: $danger;
}
}
// ==================== 搜索框 ====================
&__search-wrap {
margin-bottom: 0.08rem;
}
&__search {
display: block;
width: 100%;
height: 0.3rem;
box-sizing: border-box;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
outline: none;
background: $bg-main;
margin: 0;
transition: border-color 0.2s;
&::placeholder {
color: $text-light;
line-height: 0.3rem;
}
&:focus {
border-color: $accent;
background: $bg-white;
}
}
// ==================== 搜索结果列表 ====================
&__search-results {
max-height: 2rem;
overflow-y: auto;
border: 1px solid $border-color;
border-radius: 0.06rem;
background: $bg-white;
}
// 搜索结果单行
&__search-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.3rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
& + & {
border-top: 1px solid $border-color;
}
}
// 搜索无结果提示
&__search-empty {
padding: 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
// 选中勾图标
&__check {
width: 0.12rem;
height: 0.12rem;
color: $accent;
flex-shrink: 0;
}
// ==================== 三栏联动选择区 ====================
&__columns {
display: flex;
border: 1px solid $border-color;
border-radius: 0.06rem;
overflow: hidden;
height: 2.4rem;
}
// 单栏通用样式
&__col {
overflow-y: auto;
height: 100%;
// 左栏:一级分类
&--left {
width: 1.2rem;
flex-shrink: 0;
border-right: 1px solid $border-color;
background: $bg-main;
}
// 中栏:二级分类
&--mid {
width: 1.4rem;
flex-shrink: 0;
border-right: 1px solid $border-color;
background: $bg-white;
}
// 右栏:三级分类(末级可选中)
&--right {
flex: 1;
background: $bg-white;
}
}
// 栏内每一行
&__col-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.32rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.32rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: $theme-color;
color: $accent-hover;
}
// 左栏/中栏当前激活项
&--active {
background: $bg-white;
color: $accent;
font-weight: 600;
box-shadow: inset 3px 0 0 $accent;
}
// 右栏已选中项
&--selected {
color: $accent;
background: $theme-color;
}
}
// 栏内空状态提示
&__col-empty {
padding: 0.2rem 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
// ==================== 底部操作按钮 ====================
&__actions {
display: flex;
justify-content: space-between;
margin-top: 0.1rem;
gap: 0.1rem;
}
&__btn {
flex: 1;
height: 0.3rem;
border-radius: 0.06rem;
font-size: 0.12rem;
line-height: 0.3rem;
cursor: pointer;
border: 1px solid $border-color;
transition: all 0.2s;
&--reset {
background: $bg-main;
color: $text-dark;
&:hover {
border-color: $text-light;
color: $text-dark;
}
}
&--confirm {
background: $accent;
color: $bg-white;
border-color: $accent;
&:hover {
background: $accent-hover;
border-color: $accent-hover;
}
}
}
}
@@ -0,0 +1,145 @@
@use '../variables' as *;
.job-goal-dialog {
.el-dialog__header {
display: none;
}
.el-dialog__body {
padding: 0;
}
&__header {
display: flex;
align-items: center;
gap: 0.12rem;
padding: 0.2rem 0.24rem 0.16rem;
}
&__close-btn {
background: none;
border: none;
cursor: pointer;
color: $text-dark;
font-size: 0.16rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0.04rem;
border-radius: 0.04rem;
transition: background 0.2s;
&:hover {
background: $bg-main;
}
}
&__title {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
}
&__section {
padding: 0.12rem 0.24rem;
}
&__label {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.12rem;
}
&__tags {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
margin-bottom: 0.1rem;
}
&__tag {
display: inline-flex;
align-items: center;
gap: 0.06rem;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.16rem;
padding: 0.05rem 0.12rem;
font-size: 0.13rem;
color: $text-dark;
}
&__tag-close {
cursor: pointer;
font-size: 0.12rem;
color: $text-light;
transition: color 0.2s;
&:hover {
color: $danger;
}
}
&__select {
width: 100%;
.el-select__wrapper {
background: $bg-main;
border-radius: 0.08rem;
box-shadow: none;
border: 1px solid $border-color;
}
}
&__type-group {
display: flex;
gap: 0.12rem;
}
&__type-btn {
flex: 1;
padding: 0.1rem 0;
border: 1px solid $border-color;
border-radius: 0.2rem;
background: $bg-white;
color: $text-dark;
font-size: 0.13rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $text-light;
}
&--active {
background: $text-light;
border-color: $text-light;
color: $bg-white;
font-weight: 500;
}
}
&__footer {
padding: 0.3rem 0.24rem 0.24rem;
display: flex;
justify-content: center;
}
&__save-btn {
width: 60%;
padding: 0.12rem 0;
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.24rem;
font-size: 0.16rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: $btn-dark-hover;
}
}
}
@@ -0,0 +1,77 @@
@use '../variables' as *;
.job-page-header {
margin-bottom: 0;
&__title {
font-size: 0.24rem;
font-weight: 700;
color: $text-dark;
margin: 0 !important;
padding: 0;
line-height: 1.1;
}
&__subtitle {
font-size: 0.13rem;
color: $text-light;
margin: 0.02rem 0 0 0 !important;
padding: 0;
}
&__tabs {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.04rem;
margin: 0.1rem 0 0.16rem 0;
background: $bg-white;
border-radius: 0.22rem;
padding: 0.04rem;
}
&__goal-btn {
font-size: 0.18rem;
font-weight: 700;
color: $text-dark;
background: none;
border: none;
cursor: pointer;
padding: 0.04rem 0.16rem;
white-space: nowrap;
transition: color 0.2s;
margin-left: auto;
&:hover {
color: $accent-hover;
}
}
&__tab {
font-size: 0.13rem;
color: $text-light;
cursor: pointer;
padding: 0.06rem 0.22rem;
border-radius: 0.18rem;
transition: all 0.25s ease;
user-select: none;
font-weight: 500;
&:hover {
color: $text-dark;
background: $theme-color;
}
&--active {
background: $accent;
color: $bg-white;
font-weight: 600;
box-shadow: 0 0.02rem 0.08rem rgba(79, 194, 201, 0.3);
&:hover {
background: $accent-hover;
color: $bg-white;
}
}
}
}
@@ -0,0 +1,242 @@
// 会员购买弹窗样式
@use '../variables' as *;
// 遮罩层
.member-dialog-overlay {
position: fixed;
inset: 0;
background: $overlay-bg;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.member-dialog {
background: $bg-white;
border-radius: 0.2rem;
width: 8.4rem;
max-height: 90vh;
overflow-y: auto;
padding: 0.36rem 0.4rem;
position: relative;
box-shadow: 0 0.1rem 0.4rem rgba(0, 0, 0, 0.15);
// 关闭按钮
&__close {
position: absolute;
top: 0.16rem;
right: 0.2rem;
font-size: 0.18rem;
color: $text-light;
cursor: pointer;
transition: color 0.2s;
z-index: 1;
&:hover {
color: $text-dark;
}
}
// 顶部标语
&__slogan {
text-align: center;
font-size: 0.22rem;
font-weight: 700;
color: $btn-dark;
margin-bottom: 0.3rem;
letter-spacing: 0.01rem;
}
// ==================== 套餐卡片区域 ====================
&__plans {
display: flex;
gap: 0.2rem;
margin-bottom: 0.32rem;
}
&__plan-card {
flex: 1;
border: 1px solid $border-color;
border-radius: 0.14rem;
padding: 0.22rem 0.2rem;
cursor: pointer;
transition: all 0.25s ease;
display: flex;
flex-direction: column;
align-items: flex-start;
&:hover {
border-color: $accent;
box-shadow: 0 0.04rem 0.16rem rgba(79, 194, 201, 0.12);
}
// 选中态 — 中间卡片高亮
&--active {
border-color: $btn-dark;
background: $btn-dark;
color: $bg-white;
box-shadow: 0 0.06rem 0.24rem rgba(26, 26, 46, 0.2);
.member-dialog__plan-name,
.member-dialog__plan-original,
.member-dialog__plan-price-num,
.member-dialog__plan-price-unit {
color: $bg-white;
}
.member-dialog__plan-btn {
background: $accent;
color: $bg-white;
border-color: $accent;
&:hover {
background: $accent-hover;
border-color: $accent-hover;
}
}
.member-dialog__plan-discount {
background: rgba(255, 255, 255, 0.2);
color: $bg-white;
}
}
}
&__plan-name {
font-size: 0.15rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.06rem;
}
&__plan-original {
font-size: 0.11rem;
color: $text-light;
text-decoration: line-through;
margin-bottom: 0.08rem;
}
&__plan-price {
display: flex;
align-items: baseline;
gap: 0.02rem;
margin-bottom: 0.16rem;
}
&__plan-price-num {
font-size: 0.28rem;
font-weight: 700;
color: $text-dark;
line-height: 1;
}
&__plan-price-unit {
font-size: 0.12rem;
color: $text-dark;
}
&__plan-discount {
font-size: 0.1rem;
background: $theme-color;
color: $accent;
border-radius: 0.1rem;
padding: 0.02rem 0.08rem;
margin-left: 0.06rem;
font-weight: 600;
white-space: nowrap;
}
&__plan-btn {
width: 100%;
padding: 0.1rem 0;
border-radius: 0.2rem;
font-size: 0.13rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: 1px solid $border-color;
background: $bg-main;
color: $text-dark;
text-align: center;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
// ==================== 权益对比区域 ====================
&__benefits {
display: flex;
gap: 0.16rem;
}
&__benefit-col {
flex: 1;
border: 1px solid $border-color;
border-radius: 0.12rem;
padding: 0.2rem 0.18rem;
display: flex;
flex-direction: column;
gap: 0.14rem;
// 中间列特殊样式
&--center {
border-color: rgba(79, 194, 201, 0.3);
background: linear-gradient(180deg, $theme-color 0%, $bg-white 100%);
}
}
&__benefit-title {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
padding-bottom: 0.1rem;
border-bottom: 1px solid $border-color;
// 会员权益标题强调色
&--accent {
color: $accent;
border-bottom-color: rgba(79, 194, 201, 0.3);
}
}
&__benefit-item {
font-size: 0.12rem;
color: #555;
display: flex;
align-items: center;
gap: 0.08rem;
line-height: 1.6;
}
&__benefit-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 0.36rem;
height: 0.2rem;
background: $btn-dark;
color: $bg-white;
border-radius: 0.04rem;
font-size: 0.1rem;
font-weight: 600;
flex-shrink: 0;
}
&__benefit-other {
font-size: 0.12rem;
color: $text-light;
margin-top: auto;
padding-top: 0.12rem;
border-top: 1px solid $border-color;
}
&__benefit-highlight {
font-weight: 700;
color: $text-dark;
margin-left: 0.12rem;
}
}
@@ -0,0 +1,498 @@
@use '../variables' as *;
@use 'sass:color';
// ==================== 个人资料编辑抽屉 ====================
// 遮罩层
.profile-drawer-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $overlay-bg;
z-index: 2000;
}
// 抽屉主体 — 固定在右侧,占满屏幕高度
// 重置 font-size + line-height 避免 html font-size:100px 导致行高撑大间距
.profile-drawer {
position: fixed;
top: 0;
right: 0;
width: 6.2rem;
height: 100vh;
background: $bg-white;
z-index: 2001;
box-shadow: -0.04rem 0 0.2rem rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
font-size: 14px;
line-height: 1.5;
// 顶部栏
&__header {
display: flex;
align-items: center;
gap: 0.1rem;
padding: 0.16rem 0.2rem;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
&__close-btn {
background: none;
border: none;
padding: 0.04rem;
cursor: pointer;
color: $text-dark;
display: flex;
align-items: center;
border-radius: 0.04rem;
transition: all 0.2s;
&:hover {
color: $accent;
background: $theme-color;
}
}
&__close-icon {
width: 0.16rem;
height: 0.16rem;
}
&__title {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
margin: 0;
}
// 表单内容区 — 中间可滚动
&__body {
flex: 1;
overflow-y: auto;
padding: 0.2rem 0.2rem 3rem;
// 地区选择器下拉面板提升层级,避免被其他元素遮挡
.region-selector__panel {
z-index: 2100;
}
}
// 单个字段
&__field {
margin-bottom: 0.18rem;
}
&__label {
font-size: 0.13rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.06rem;
}
&__required {
color: $danger;
margin-right: 0.02rem;
}
&__input {
width: 100%;
box-sizing: border-box;
padding: 0.1rem 0.14rem;
font-size: 0.13rem;
color: $text-dark;
background: $bg-main;
border: 1px solid transparent;
border-radius: 0.06rem;
outline: none;
transition: all 0.2s;
&::placeholder {
color: $text-light;
}
&:focus {
border-color: $accent;
background: $bg-white;
}
}
// 多行文本输入框(经历描述等)
&__textarea {
width: 100%;
box-sizing: border-box;
padding: 0.1rem 0.14rem;
font-size: 0.13rem;
color: $text-dark;
background: $bg-main;
border: 1px solid transparent;
border-radius: 0.06rem;
outline: none;
transition: all 0.2s;
resize: vertical;
font-family: inherit;
line-height: 1.6;
&::placeholder {
color: $text-light;
}
&:focus {
border-color: $accent;
background: $bg-white;
}
// 矮版 textarea — 用于分段描述,右侧留出删除按钮空间
&--short {
padding-right: 0.36rem;
}
}
// ==================== 描述段落编辑 ====================
// 单条描述段落容器 — textarea + 右侧删除按钮
&__desc-item {
position: relative;
margin-bottom: 0.08rem;
}
// 描述段落内的删除按钮(叉号)
&__desc-remove {
position: absolute;
right: 0.18rem;
top: 0.08rem;
background: none;
border: none;
padding: 0.02rem;
cursor: pointer;
color: $text-light;
display: flex;
align-items: center;
border-radius: 0.03rem;
transition: all 0.2s;
&:hover {
color: $danger;
}
}
&__desc-remove-icon {
width: 0.12rem;
height: 0.12rem;
}
// 小号新增按钮 — 用于"新增一段描述"
&__add-btn--small {
font-size: 0.11rem;
padding: 0.04rem 0.12rem;
margin-top: 0.04rem;
margin-bottom: 0;
}
// 空状态占位
&__empty {
text-align: center;
padding: 0.6rem 0;
}
&__empty-text {
font-size: 0.14rem;
color: $text-light;
}
// ==================== 教育经历模块 ====================
// 单条教育经历卡片
&__edu-card {
padding-bottom: 0.2rem;
margin-bottom: 0.2rem;
border-bottom: 1px solid $border-color;
&:last-of-type {
border-bottom: none;
}
}
// 教育经历标题栏
&__edu-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.14rem;
}
// 序号标识
&__edu-index {
display: flex;
align-items: center;
gap: 0.06rem;
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
}
&__edu-index-icon {
width: 0.1rem;
height: 0.1rem;
color: $accent;
}
// 删除按钮
&__edu-delete {
background: none;
border: none;
padding: 0.04rem;
cursor: pointer;
color: $text-light;
display: flex;
align-items: center;
border-radius: 0.04rem;
transition: all 0.2s;
&:hover {
color: $danger;
background: rgba($danger, 0.06);
}
}
&__edu-delete-icon {
width: 0.15rem;
height: 0.15rem;
}
// 横排字段容器(学院+专业、学历+GPA、入学+毕业时间)
&__row {
display: flex;
gap: 0.12rem;
}
// 半宽字段
&__field--half {
flex: 1;
min-width: 0;
}
// 下拉选择器包裹(带箭头图标)
&__select-wrap {
position: relative;
}
&__select-wrap &__input {
padding-right: 0.3rem;
}
&__select-arrow {
position: absolute;
right: 0.1rem;
top: 50%;
transform: translateY(-50%);
width: 0.12rem;
height: 0.12rem;
color: $text-light;
pointer-events: none;
}
// 新增教育经历按钮
&__add-btn {
text-align: center;
background: none;
border: 1px solid $border-color;
border-radius: 0.2rem;
padding: 0.06rem 0.16rem;
font-size: 0.12rem;
color: $text-dark;
cursor: pointer;
transition: all 0.2s;
margin-top: 0.04rem;
&:hover {
border-color: $accent;
color: $accent;
}
}
&__add-icon {
font-size: 0.14rem;
font-weight: 600;
}
// ==================== 日期选择器样式覆盖 ====================
// Element Plus 的 el-date-picker 在 html font-size:100px 下需要用 px 还原
&__date-picker {
width: 100% !important;
.el-input__wrapper {
background: $bg-main !important;
border: 1px solid transparent !important;
border-radius: 6px !important;
box-shadow: none !important;
padding: 4px 12px !important;
font-size: 13px !important;
line-height: 1.5 !important;
height: auto !important;
&:hover {
border-color: $border-color !important;
}
&.is-focus {
border-color: $accent !important;
background: $bg-white !important;
}
}
.el-input__inner {
font-size: 13px !important;
color: $text-dark !important;
height: auto !important;
line-height: 1.5 !important;
&::placeholder {
color: $text-light !important;
}
}
.el-input__prefix,
.el-input__suffix {
font-size: 14px !important;
}
.el-input__icon {
width: 14px !important;
height: 14px !important;
font-size: 14px !important;
}
}
// ==================== 技能模块 ====================
// 技能标签列表容器
&__skills-list {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
margin-bottom: 0.2rem;
min-height: 0.4rem;
}
// 单个技能标签
&__skill-tag {
display: inline-flex;
align-items: center;
gap: 0.06rem;
padding: 0.06rem 0.1rem 0.06rem 0.14rem;
background: $bg-main;
border-radius: 0.16rem;
font-size: 0.13rem;
color: $text-dark;
transition: all 0.2s;
&:hover {
background: color.adjust($bg-main, $lightness: -3%);
}
}
// 技能标签文本
&__skill-text {
line-height: 1.4;
}
// 技能标签删除按钮
&__skill-remove {
background: none;
border: none;
padding: 0.02rem;
cursor: pointer;
color: $text-light;
display: flex;
align-items: center;
border-radius: 50%;
transition: all 0.2s;
&:hover {
color: $danger;
background: rgba($danger, 0.1);
}
}
&__skill-remove-icon {
width: 0.12rem;
height: 0.12rem;
}
// 底部保存按钮
&__footer {
padding: 0.16rem 0.2rem;
border-top: 1px solid $border-color;
text-align: center;
flex-shrink: 0;
}
&__save-btn {
width: 2.0rem;
padding: 0.1rem 0;
font-size: 0.14rem;
font-weight: 600;
color: $bg-white;
background: $btn-dark;
border: none;
border-radius: 0.24rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: $btn-dark-hover;
}
}
}
// ==================== 过渡动画 ====================
// 日期选择器弹出面板样式覆盖(teleport 到 body,需在全局作用域)
.profile-drawer-date-popper {
font-size: 14px !important;
line-height: 1.5 !important;
.el-date-picker {
font-size: 14px !important;
}
.el-picker-panel__body {
font-size: 14px !important;
}
.el-date-picker__header {
font-size: 14px !important;
}
.el-month-table td .cell {
font-size: 13px !important;
padding: 8px 0 !important;
}
}
// 遮罩层淡入淡出
.profile-drawer-overlay-enter-active,
.profile-drawer-overlay-leave-active {
transition: opacity 0.3s ease;
}
.profile-drawer-overlay-enter-from,
.profile-drawer-overlay-leave-to {
opacity: 0;
}
// 抽屉滑入滑出
.profile-drawer-slide-enter-active,
.profile-drawer-slide-leave-active {
transition: transform 0.3s ease;
}
.profile-drawer-slide-enter-from,
.profile-drawer-slide-leave-to {
transform: translateX(100%);
}
@@ -0,0 +1,165 @@
@use '../variables' as *;
.profile-page-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.16rem;
padding-bottom: 0.4rem;
// 卡片通用
&__card {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.2rem 0.24rem;
}
&__card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.14rem;
}
&__card-title {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
margin: 0;
}
&__edit-btn {
background: none;
border: none;
color: $text-light;
cursor: pointer;
padding: 0.04rem;
border-radius: 0.04rem;
display: flex;
align-items: center;
transition: all 0.2s;
&:hover {
color: $accent;
background: $theme-color;
}
}
&__edit-icon {
width: 0.15rem;
height: 0.15rem;
}
// 个人信息
&__info-name {
font-size: 0.18rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.12rem;
}
&__info-contacts {
display: flex;
flex-wrap: wrap;
gap: 0.16rem;
margin-bottom: 0.06rem;
}
&__contact-item {
font-size: 0.12rem;
color: #555;
display: flex;
align-items: center;
gap: 0.04rem;
}
// 通用 section item
&__section-item {
padding: 0.12rem 0;
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
}
&__item-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&__item-left {
flex: 1;
min-width: 0;
}
&__item-title {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.03rem;
}
&__item-sub {
font-size: 0.12rem;
color: #666;
margin-bottom: 0.02rem;
}
&__item-date {
font-size: 0.11rem;
color: $accent;
margin-bottom: 0.04rem;
}
&__item-period {
font-size: 0.11rem;
color: $text-light;
white-space: nowrap;
flex-shrink: 0;
margin-left: 0.12rem;
}
&__item-desc {
font-size: 0.12rem;
color: #666;
line-height: 1.6;
margin: 0.06rem 0 0 0;
}
&__item-list {
margin: 0.08rem 0 0 0;
padding-left: 0.16rem;
list-style: disc;
li {
font-size: 0.12rem;
color: #555;
line-height: 1.8;
}
}
// 标签(技能 / 证书)
&__tags {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
}
&__tag {
font-size: 0.12rem;
color: $text-dark;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.16rem;
padding: 0.05rem 0.16rem;
transition: all 0.2s;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
}
@@ -0,0 +1,311 @@
// ========================================
// 地区选择器组件样式(分栏联动,支持二栏/三栏)
// 全局 1rem = 100px,表单元素用固定 height 控制
// 颜色统一使用 variables.scss 变量
// 风格与行业/岗位选择器保持一致
// ========================================
@use '../variables' as *;
.region-selector {
position: relative;
//display: inline-block;
// ==================== 触发按钮 ====================
&__trigger {
display: flex;
align-items: center;
gap: 0.04rem;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.2rem;
padding: 0.05rem 0.14rem;
color: $text-dark;
font-size: 0.12rem;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
max-width: 2rem;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
&__display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 1.6rem;
}
&__arrow {
width: 0.1rem;
height: 0.1rem;
color: $text-light;
flex-shrink: 0;
transition: transform 0.2s;
&--open {
transform: rotate(180deg);
}
}
// ==================== 下拉面板 ====================
&__panel {
position: absolute;
top: calc(100% + 0.06rem);
left: 0;
width: 4.2rem;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.1rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 100;
padding: 0.1rem;
// level=2 时只有两栏,面板收窄
&--two-col {
width: 3rem;
}
}
// ==================== 选中区 ====================
&__selected-area {
display: flex;
flex-wrap: wrap;
gap: 0.06rem;
margin-bottom: 0.1rem;
max-height: 0.8rem;
overflow-y: auto;
}
&__tag {
display: inline-flex;
align-items: center;
gap: 0.03rem;
height: 0.22rem;
background: $theme-color;
border: 1px solid rgba(79, 194, 201, 0.3);
border-radius: 0.04rem;
padding: 0 0.08rem;
font-size: 0.11rem;
line-height: 0.22rem;
color: $accent-hover;
white-space: nowrap;
box-sizing: border-box;
}
&__tag-close {
width: 0.1rem;
height: 0.1rem;
cursor: pointer;
color: $accent;
flex-shrink: 0;
transition: color 0.15s;
&:hover {
color: $danger;
}
}
// ==================== 搜索框 ====================
&__search-wrap {
margin-bottom: 0.08rem;
}
&__search {
display: block;
width: 100%;
height: 0.3rem;
box-sizing: border-box;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
outline: none;
background: $bg-main;
margin: 0;
transition: border-color 0.2s;
&::placeholder {
color: $text-light;
line-height: 0.3rem;
}
&:focus {
border-color: $accent;
background: $bg-white;
}
}
// ==================== 搜索结果 ====================
&__search-results {
max-height: 2rem;
overflow-y: auto;
border: 1px solid $border-color;
border-radius: 0.06rem;
background: $bg-white;
}
&__search-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.3rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.3rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
& + & {
border-top: 1px solid $border-color;
}
}
&__search-empty {
padding: 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
&__check {
width: 0.12rem;
height: 0.12rem;
color: $accent;
flex-shrink: 0;
}
// ==================== 分栏联动选择区 ====================
&__columns {
display: flex;
border: 1px solid $border-color;
border-radius: 0.06rem;
overflow: hidden;
height: 2.4rem;
}
&__col {
overflow-y: auto;
height: 100%;
&--left {
width: 1.1rem;
flex-shrink: 0;
border-right: 1px solid $border-color;
background: $bg-main;
}
&--mid {
width: 1.2rem;
flex-shrink: 0;
border-right: 1px solid $border-color;
background: $bg-white;
// level=2 时中栏是最后一栏,撑满剩余空间且去掉右边框
&:last-child {
flex: 1;
border-right: none;
}
}
&--right {
flex: 1;
background: $bg-white;
}
}
&__col-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 0.32rem;
padding: 0 0.1rem;
font-size: 0.12rem;
line-height: 0.32rem;
color: $text-dark;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
background: $theme-color;
color: $accent-hover;
}
&--active {
background: $bg-white;
color: $accent;
font-weight: 600;
box-shadow: inset 3px 0 0 $accent;
}
&--selected {
color: $accent;
background: $theme-color;
}
}
&__col-empty {
padding: 0.2rem 0.1rem;
font-size: 0.12rem;
line-height: 1;
color: $text-light;
text-align: center;
}
// ==================== 底部操作按钮 ====================
&__actions {
display: flex;
justify-content: space-between;
margin-top: 0.1rem;
gap: 0.1rem;
}
&__btn {
flex: 1;
height: 0.3rem;
border-radius: 0.06rem;
font-size: 0.12rem;
line-height: 0.3rem;
cursor: pointer;
border: 1px solid $border-color;
transition: all 0.2s;
&--reset {
background: $bg-main;
color: $text-dark;
&:hover {
border-color: $text-light;
color: $text-dark;
}
}
&--confirm {
background: $accent;
color: $bg-white;
border-color: $accent;
&:hover {
background: $accent-hover;
border-color: $accent-hover;
}
}
}
}
@@ -0,0 +1,451 @@
@use '../variables' as *;
// ==================== 设置弹窗 ====================
.settings-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $overlay-bg;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.settings-dialog {
position: relative;
width: 8.6rem;
height: 6.2rem;
background: $bg-white;
border-radius: 0.12rem;
display: flex;
overflow: hidden;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
&__close {
position: absolute;
top: 0.12rem;
right: 0.16rem;
font-size: 0.16rem;
color: $text-light;
cursor: pointer;
z-index: 1;
transition: color 0.2s;
&:hover {
color: $text-dark;
}
}
// 左侧导航
&__sidebar {
width: 2.0rem;
background: $bg-main;
padding: 0.2rem 0;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
box-sizing: border-box;
}
&__nav {
flex: 1;
display: flex;
flex-direction: column;
}
&__nav-item {
display: flex;
align-items: center;
gap: 0.08rem;
padding: 0.12rem 0.2rem;
font-size: 0.14rem;
color: $text-dark;
cursor: pointer;
transition: all 0.2s;
border-left: 0.03rem solid transparent;
font-weight: 500;
&:hover {
background: $bg-white;
color: $accent;
}
&--active {
background: $bg-white;
color: $accent;
border-left-color: $accent;
font-weight: 600;
}
}
&__nav-icon {
font-size: 0.16rem;
flex-shrink: 0;
width: 0.18rem;
text-align: center;
}
// 底部操作
&__bottom-actions {
margin-top: auto;
padding: 0.16rem 0.2rem 0.08rem;
border-top: 1px solid $border-color;
}
&__bottom-btn {
display: block;
width: 100%;
background: none;
border: none;
text-align: left;
padding: 0.1rem 0;
font-size: 0.13rem;
color: $text-dark;
font-weight: 600;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $accent;
}
&--active {
color: $accent;
}
&--danger:hover {
color: $danger;
}
}
// 右侧内容区
&__content {
flex: 1;
padding: 0.24rem 0.32rem;
min-width: 0;
overflow-y: auto;
}
&__content-title {
font-size: 0.22rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.24rem 0;
}
// ===== 账号与安全 =====
&__section {
margin-bottom: 0.28rem;
}
&__section-label {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.06rem;
}
&__section-value {
font-size: 0.16rem;
color: #555;
margin-bottom: 0;
}
&__danger-section {
margin-top: 0.32rem;
}
&__danger-title {
font-size: 0.15rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.06rem;
}
&__danger-row {
display: flex;
align-items: center;
justify-content: space-between;
}
&__danger-desc {
font-size: 0.13rem;
color: #666;
margin: 0;
}
&__danger-btn {
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0.07rem 0.16rem;
font-size: 0.13rem;
color: $text-dark;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
&:hover {
border-color: $danger;
color: $danger;
}
}
// ===== 会员 =====
&__member-card {
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.1rem;
padding: 0.2rem 0.24rem;
margin-bottom: 0.24rem;
}
&__member-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.1rem;
}
&__member-title-row {
display: flex;
align-items: center;
gap: 0.1rem;
}
&__member-name {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
}
&__member-badge {
background: $accent;
color: $bg-white;
font-size: 0.11rem;
padding: 0.02rem 0.08rem;
border-radius: 0.1rem;
}
&__member-terms {
font-size: 0.12rem;
color: $accent;
cursor: pointer;
text-decoration: underline;
&:hover {
color: $accent-hover;
}
}
&__member-info-row {
display: flex;
align-items: center;
justify-content: space-between;
}
&__member-price {
font-size: 0.13rem;
color: #555;
span {
margin-left: 0.12rem;
color: $text-light;
}
}
&__member-manage-btn {
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0.07rem 0.16rem;
font-size: 0.13rem;
color: $text-dark;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $accent;
color: $accent;
}
}
&__member-issue {
text-align: center;
padding: 0.2rem 0;
}
&__member-issue-title {
font-size: 0.15rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.1rem;
}
&__member-issue-desc {
font-size: 0.12rem;
color: #666;
line-height: 1.8;
margin-bottom: 0.16rem;
max-width: 5.0rem;
margin-left: auto;
margin-right: auto;
}
&__member-issue-actions {
display: flex;
justify-content: center;
gap: 0.16rem;
}
&__member-issue-btn {
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0.08rem 0.24rem;
font-size: 0.13rem;
color: $text-dark;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: $accent;
color: $accent;
}
}
// ===== 岗位更新提醒 =====
&__reminder-block {
margin-bottom: 0.28rem;
}
&__reminder-block-title {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.14rem;
}
&__reminder-target {
display: flex;
align-items: center;
justify-content: space-between;
}
&__reminder-tags {
display: flex;
gap: 0.1rem;
flex-wrap: wrap;
}
&__reminder-tag {
display: inline-block;
padding: 0.06rem 0.16rem;
font-size: 0.13rem;
color: $text-dark;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.04rem;
font-weight: 500;
}
&__reminder-edit-btn {
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.06rem;
padding: 0.07rem 0.2rem;
font-size: 0.13rem;
color: $text-dark;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover {
border-color: $accent;
color: $accent;
}
}
&__reminder-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.2rem;
}
&__reminder-info {
flex: 1;
min-width: 0;
}
&__reminder-label {
font-size: 0.14rem;
color: $text-dark;
font-weight: 600;
margin-bottom: 0.06rem;
}
&__reminder-desc {
font-size: 0.13rem;
color: #666;
line-height: 1.6;
}
// ===== 弹窗 =====
&__dialog-content {
font-size: 0.13rem;
color: #555;
line-height: 1.8;
max-height: 4.0rem;
overflow-y: auto;
padding: 0.1rem 0;
}
&__dialog-section {
margin-bottom: 0.16rem;
h4 {
font-size: 0.14rem;
color: $text-dark;
margin: 0 0 0.06rem 0;
}
p {
margin: 0;
font-size: 0.12rem;
color: #666;
line-height: 1.8;
}
}
// ===== 隐私协议 =====
&__privacy-content {
font-size: 0.13rem;
color: #555;
line-height: 1.8;
}
&__privacy-section {
margin-bottom: 0.2rem;
h4 {
font-size: 0.15rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.1rem 0;
}
p {
margin: 0 0 0.08rem 0;
font-size: 0.13rem;
color: #555;
line-height: 1.8;
text-align: justify;
}
}
}
+257
View File
@@ -0,0 +1,257 @@
@use '../variables' as *;
.side-nav {
display: flex;
flex-direction: column;
width: 2rem;
height: 100vh;
background: #1a1a2e;
color: #fff;
padding: 0.2rem 0.12rem;
box-sizing: border-box;
position: fixed;
left: 0;
top: 0;
&__header {
display: flex;
align-items: center;
gap: 0.08rem;
padding: 0.1rem 0.08rem;
margin-bottom: 0.2rem;
}
&__avatar {
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: #2e3142;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.18rem;
}
&__logo-text {
font-size: 0.16rem;
font-weight: 600;
color: #fff;
}
&__menu {
display: flex;
flex-direction: column;
gap: 0.04rem;
flex: 1;
}
&__item {
display: flex;
align-items: center;
gap: 0.08rem;
padding: 0.1rem 0.12rem;
border-radius: 0.14rem;
color: #9598ab;
text-decoration: none;
cursor: pointer;
transition: background 0.2s, color 0.2s;
font-size: 0.14rem;
&:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
}
&--active {
background: #1F2937;
color: #fff;
}
}
&__item-icon {
width: 0.2rem;
height: 0.2rem;
object-fit: contain;
}
&__item-label {
flex: 1;
}
&__badge {
background: #e85635;
color: #fff;
font-size: 0.1rem;
padding: 0.02rem 0.06rem;
border-radius: 0.2rem;
font-weight: 600;
}
&__footer {
display: flex;
flex-direction: column;
gap: 0.04rem;
border-top: 1px solid #2e3142;
padding-top: 0.12rem;
}
}
.side-nav__dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: $overlay-bg;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.side-nav__dialog {
background: $bg-white;
border-radius: 0.12rem;
width: 4rem;
max-width: 90vw;
box-shadow: 0 0.04rem 0.2rem rgba(0, 0, 0, 0.15);
}
.side-nav__dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.16rem 0.2rem;
border-bottom: 1px solid $border-color;
font-size: 0.16rem;
font-weight: 600;
color: $text-dark;
}
.side-nav__dialog-close {
cursor: pointer;
color: $text-light;
font-size: 0.16rem;
&:hover {
color: $text-dark;
}
}
.side-nav__dialog-body {
padding: 0.2rem;
font-size: 0.14rem;
color: $text-dark;
}
// 反馈弹窗
.feedback-dialog {
padding: 0.28rem 0.24rem;
width: 4.83rem;
&__title {
font-size: 0.2rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.24rem;
}
&__section {
margin-bottom: 0.2rem;
}
&__label {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.12rem;
}
&__options {
display: flex;
flex-direction: column;
gap: 0.08rem;
}
&__option {
padding: 0.08rem 0.16rem;
background: $bg-main;
border-radius: 0.08rem;
font-size: 0.14rem;
color: $text-dark;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
&:hover {
background: $theme-color;
border-color: $accent;
}
&--active {
background: $theme-color;
border-color: $accent;
color: $accent-hover;
}
}
&__textarea {
width: 100%;
padding: 0.12rem 0.14rem;
background: $bg-main;
border: 1px solid transparent;
border-radius: 0.08rem;
font-size: 0.13rem;
color: $text-dark;
resize: none;
outline: none;
box-sizing: border-box;
font-family: inherit;
transition: border-color 0.2s;
&::placeholder {
color: $text-light;
}
&:focus {
border-color: $accent;
}
}
&__actions {
display: flex;
gap: 0.12rem;
margin-top: 0.24rem;
}
&__btn {
flex: 1;
padding: 0.12rem 0;
border-radius: 0.24rem;
font-size: 0.14rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid $border-color;
&--cancel {
background: $bg-white;
color: $text-dark;
&:hover {
background: $bg-main;
}
}
&--submit {
background: $btn-dark;
color: $bg-white;
border-color: $btn-dark;
&:hover {
background: $btn-dark-hover;
border-color: $btn-dark-hover;
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
// 样式统一入口文件
// 页面样式
@use './pages/home.scss';
@use './pages/agent.scss';
@use './pages/jobs.scss';
@use './pages/resume.scss';
@use './pages/resume-detail.scss';
@use './pages/job-detail.scss';
@use './pages/profile.scss';
@use './pages/login.scss';
// 组件样式
@use './components/hello-world.scss';
@use './components/side-nav.scss';
@use './components/ai-chat.scss';
@use './components/job-page-header.scss';
@use './components/job-goal-dialog.scss';
@use './components/settings-dialog.scss';
@use './components/member-dialog.scss';
@use './components/profile-edit-drawer.scss';
@use './components/industry-selector.scss';
@use './components/job-category-selector.scss';
@use './components/region-selector.scss';
// 全局样式(优先级最高)
@use './auto.scss';
// ==================== Element Plus 组件 rem 适配修正 ====================
// 项目 html font-size: 100px,导致 Element Plus 内部 rem 单位被放大
// 以下对常用弹出类组件强制使用 px 单位还原正常尺寸
// ElMessage 提示条
.el-message {
font-size: 14px !important;
padding: 10px 16px !important;
min-width: 300px !important;
border-radius: 8px !important;
.el-message__icon {
font-size: 16px !important;
margin-right: 8px !important;
width: 16px !important;
height: 16px !important;
}
.el-message__content {
font-size: 14px !important;
line-height: 1.4 !important;
}
.el-message__closeBtn {
font-size: 14px !important;
}
}
+248
View File
@@ -0,0 +1,248 @@
@use '../variables' as *;
// ==================== 求职助手页面样式 ====================
.agent-page {
min-height: 100vh;
background: $bg-main;
// 主内容区域(左侧导航栏右边的部分)
&__content {
margin-left: 2rem;
flex: 1;
padding: 0.3rem 0.4rem;
overflow-y: auto;
height: 100vh;
box-sizing: border-box;
}
// ==================== 顶部步骤导航条 ====================
&__steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0.06rem;
margin-bottom: 0.36rem;
}
// 单个步骤项
&__step {
display: flex;
align-items: center;
gap: 0.06rem;
padding: 0.06rem 0.14rem;
border-radius: 0.2rem;
cursor: pointer;
transition: all 0.25s ease;
user-select: none;
// 激活态 — 深色背景白色文字
&--active {
background: $text-dark;
.agent-page__step-number {
background: $bg-white;
color: $text-dark;
}
.agent-page__step-label {
color: $bg-white;
}
}
}
// 步骤序号圆圈
&__step-number {
width: 0.22rem;
height: 0.22rem;
border-radius: 50%;
background: $text-light;
color: $bg-white;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.12rem;
font-weight: 600;
flex-shrink: 0;
line-height: 1;
transition: all 0.25s ease;
}
// 步骤文字标签
&__step-label {
font-size: 0.13rem;
color: $text-light;
white-space: nowrap;
transition: all 0.25s ease;
}
// 步骤间分隔箭头
&__step-arrow {
display: flex;
align-items: center;
color: $text-light;
svg {
width: 0.14rem;
height: 0.14rem;
}
}
// ==================== 主体内容区域 — 左右两栏 ====================
&__main {
display: flex;
gap: 0.24rem;
align-items: flex-start;
}
// 左侧说明引导区域
&__left {
width: 4.2rem;
flex-shrink: 0;
}
// 右侧个人档案区域
&__right {
flex: 1;
min-width: 0;
}
// ==================== 左侧引导卡片 ====================
&__intro-card {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.3rem;
}
// 卡片头部:图标 + 标题
&__intro-header {
display: flex;
align-items: flex-start;
gap: 0.12rem;
margin-bottom: 0.2rem;
}
// 用户图标
&__intro-icon {
width: 0.28rem;
height: 0.28rem;
flex-shrink: 0;
color: $text-dark;
margin-top: 0.02rem;
svg {
width: 100%;
height: 100%;
}
}
// 引导标题
&__intro-title {
font-size: 0.16rem;
font-weight: 700;
color: $text-dark;
line-height: 1.5;
margin: 0;
}
// 说明描述文字
&__intro-desc {
font-size: 0.13rem;
color: $text-light;
line-height: 1.7;
margin-bottom: 0.28rem;
padding-left: 0.4rem;
}
// 导入个人资料行
&__import-row {
display: flex;
align-items: center;
gap: 0.1rem;
padding: 0.14rem 0.16rem;
background: $bg-main;
border-radius: 0.08rem;
margin-bottom: 0.28rem;
margin-left: 0.4rem;
}
// 导入图标
&__import-icon {
width: 0.18rem;
height: 0.18rem;
color: $text-dark;
flex-shrink: 0;
svg {
width: 100%;
height: 100%;
}
}
// 导入文字
&__import-text {
font-size: 0.13rem;
color: $text-dark;
font-weight: 500;
}
// 确认并进入按钮
&__confirm-btn {
display: block;
width: 2rem;
height: 0.42rem;
margin-left: 0.4rem;
background: $text-dark;
color: $bg-white;
border: none;
border-radius: 0.21rem;
font-size: 0.14rem;
font-weight: 500;
cursor: pointer;
transition: background 0.25s ease;
line-height: 0.42rem;
text-align: center;
padding: 0;
&:hover {
background: $btn-dark-hover;
}
&:active {
transform: scale(0.98);
}
}
// ==================== 右侧个人档案包裹 ====================
&__profile-wrapper {
background: $bg-main;
border-radius: 0.12rem;
border: 1px solid $border-color;
padding: 0.24rem;
max-height: calc(100vh - 1.4rem);
overflow-y: auto;
// 自定义滚动条
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: $border-color;
border-radius: 2px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
}
// 个人档案标题
&__profile-title {
font-size: 0.15rem;
font-weight: 700;
color: $text-dark;
margin-bottom: 0.16rem;
padding-bottom: 0.12rem;
border-bottom: 1px solid $border-color;
}
}
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
// Home 页面样式
.home-page {
// 在这里添加 Home 页面的样式
h1{
color: blue;
}
}
+632
View File
@@ -0,0 +1,632 @@
@use '../variables' as *;
// ==================== 岗位详情页 ====================
.job-detail {
&__content {
margin-left: 2rem;
margin-right: 3.2rem;
flex: 1;
padding: 0.12rem 0.56rem 0.12rem 0.18rem;
height: 100vh;
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
display: flex;
flex-direction: column;
}
// ---- 页面标题 ----
&__header {
margin-bottom: 0;
}
&__page-title {
font-size: 0.24rem;
font-weight: 700;
color: $text-dark;
margin: 0;
line-height: 1.1;
}
&__page-subtitle {
font-size: 0.13rem;
color: $text-light;
margin: 0.02rem 0 0 0;
}
// ---- Tab 切换 ----
&__tabs {
display: inline-flex;
align-items: center;
gap: 0.04rem;
margin: 0.1rem 0 0.16rem 0;
background: $bg-white;
border-radius: 0.22rem;
padding: 0.04rem;
}
&__tab {
font-size: 0.13rem;
color: $text-light;
cursor: pointer;
padding: 0.06rem 0.22rem;
border-radius: 0.18rem;
transition: all 0.25s ease;
user-select: none;
font-weight: 500;
&:hover {
color: $text-dark;
background: $theme-color;
}
&--active {
background: $accent;
color: $bg-white;
font-weight: 600;
box-shadow: 0 0.02rem 0.08rem rgba(79, 194, 201, 0.3);
&:hover {
background: $accent-hover;
color: $bg-white;
}
}
}
// ---- 顶部操作栏 ----
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.14rem;
}
&__close-btn {
width: 0.32rem;
height: 0.32rem;
border-radius: 50%;
border: 1px solid $border-color;
background: $bg-white;
color: $text-dark;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: $theme-color;
border-color: $accent;
color: $accent;
}
}
&__close-icon {
width: 0.14rem;
height: 0.14rem;
}
&__toolbar-right {
display: flex;
align-items: center;
gap: 0.1rem;
}
&__tool-btn {
width: 0.32rem;
height: 0.32rem;
border-radius: 50%;
border: 1px solid $border-color;
background: $bg-white;
color: $text-light;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
color: $accent;
border-color: $accent;
background: $theme-color;
}
}
&__tool-icon {
width: 0.14rem;
height: 0.14rem;
}
&__apply-btn {
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.2rem;
padding: 0.07rem 0.22rem;
font-size: 0.13rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: $btn-dark-hover;
}
}
// ---- 内容区域 ----
&__body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.14rem;
padding-bottom: 0.4rem;
padding-right: 0.05rem;
}
// ---- 导航 Tab ----
&__nav-tabs {
display: flex;
align-items: center;
gap: 0.2rem;
margin-bottom: 0.04rem;
}
&__nav-tab {
font-size: 0.16rem;
font-weight: 600;
color: $text-light;
cursor: pointer;
padding-bottom: 0.06rem;
border-bottom: 2px solid transparent;
transition: all 0.2s;
&:hover {
color: $text-dark;
}
&--active {
color: $text-dark;
border-bottom-color: $text-dark;
}
}
&__nav-tab-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.16rem;
padding-right: 0.05rem;
}
&__link-btn {
font-size: 0.12rem;
color: $text-light;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $accent;
}
}
// ---- 卡片 ----
&__card {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.22rem 0.24rem;
}
&__card-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.3rem;
}
&__card-left {
flex: 1;
min-width: 0;
}
// ---- 公司 & 职位头部 ----
&__company-row {
display: flex;
align-items: center;
gap: 0.1rem;
margin-bottom: 0.1rem;
}
&__company-icon {
width: 0.36rem;
height: 0.36rem;
border-radius: 0.08rem;
background: $theme-color;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color;
flex-shrink: 0;
}
&__company-svg {
width: 0.2rem;
height: 0.2rem;
color: $accent;
}
&__company-name {
font-size: 0.15rem;
font-weight: 600;
color: $text-dark;
}
&__job-title {
font-size: 0.2rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.1rem 0;
}
&__job-meta {
display: flex;
align-items: center;
gap: 0.2rem;
flex-wrap: wrap;
}
&__meta-item {
display: flex;
align-items: center;
gap: 0.05rem;
font-size: 0.12rem;
color: $text-light;
}
&__meta-icon {
width: 0.14rem;
height: 0.14rem;
color: $text-light;
flex-shrink: 0;
}
// ---- 匹配度 ----
&__match-area {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
}
&__match-ring {
position: relative;
width: 0.72rem;
height: 0.72rem;
display: flex;
align-items: center;
justify-content: center;
}
&__ring-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
&__match-score {
font-size: 0.18rem;
font-weight: 700;
color: $accent-hover;
position: relative;
z-index: 1;
}
&__match-label {
font-size: 0.1rem;
color: $text-light;
margin-top: 0.04rem;
}
&__match-details {
display: flex;
align-items: center;
gap: 0.12rem;
margin-top: 0.12rem;
}
&__match-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.03rem;
}
&__match-mini-ring {
position: relative;
width: 0.4rem;
height: 0.4rem;
display: flex;
align-items: center;
justify-content: center;
}
&__mini-ring-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
&__mini-score {
font-size: 0.1rem;
font-weight: 600;
color: $text-dark;
position: relative;
z-index: 1;
}
&__match-item-label {
font-size: 0.09rem;
color: $text-light;
white-space: nowrap;
}
// ---- 优化简历提示条 ----
&__optimize-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: $btn-dark;
color: $bg-white;
border-radius: 0.1rem;
padding: 0.14rem 0.2rem;
font-size: 0.13rem;
}
&__optimize-btn {
background: $bg-white;
color: $text-dark;
border: none;
border-radius: 0.06rem;
padding: 0.07rem 0.18rem;
font-size: 0.12rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
&:hover {
background: $theme-color;
color: $accent-hover;
}
}
// ---- 描述文本 ----
&__desc-text {
font-size: 0.13rem;
color: $text-dark;
line-height: 1.8;
margin: 0 0 0.12rem 0;
}
// ---- 标签 ----
&__tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
}
&__tag {
font-size: 0.12rem;
color: $text-dark;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.04rem;
padding: 0.04rem 0.14rem;
transition: all 0.2s;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
// ---- 区块标题 ----
&__section-title {
font-size: 0.17rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.14rem 0;
}
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.14rem;
.job-detail__section-title {
margin-bottom: 0;
}
}
&__skill-hint {
display: flex;
align-items: center;
gap: 0.04rem;
font-size: 0.11rem;
color: $text-light;
}
&__hint-icon {
width: 0.12rem;
height: 0.12rem;
flex-shrink: 0;
}
// ---- 技能标签 ----
&__skill-tags {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
margin-bottom: 0.16rem;
}
&__skill-tag {
display: inline-flex;
align-items: center;
gap: 0.04rem;
font-size: 0.12rem;
color: $text-dark;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.2rem;
padding: 0.05rem 0.14rem;
transition: all 0.2s;
&--matched {
background: $theme-color;
border-color: $accent;
color: $accent-hover;
}
}
&__skill-close {
width: 0.1rem;
height: 0.1rem;
color: $accent;
cursor: pointer;
}
// ---- 列表 ----
&__list {
font-size: 0.13rem;
color: $text-dark;
line-height: 2;
margin: 0;
padding-left: 0.2rem;
li {
padding-left: 0.04rem;
}
}
// ---- 公司信息 ----
&__company-info {
display: flex;
gap: 0.4rem;
}
&__company-info-left {
flex: 1;
min-width: 0;
}
&__company-header {
display: flex;
align-items: center;
gap: 0.1rem;
margin-bottom: 0.12rem;
}
&__company-logo {
width: 0.36rem;
height: 0.36rem;
border-radius: 50%;
background: $bg-main;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid $border-color;
flex-shrink: 0;
}
&__company-info-name {
font-size: 0.16rem;
font-weight: 600;
color: $text-dark;
}
&__company-desc {
font-size: 0.12rem;
color: $text-dark;
line-height: 1.8;
margin: 0;
}
&__company-info-right {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.08rem;
}
&__company-meta-item {
font-size: 0.12rem;
color: $text-dark;
line-height: 1.6;
}
&__meta-label {
color: $text-light;
}
&__company-link {
color: $accent;
text-decoration: none;
transition: color 0.2s;
&:hover {
color: $accent-hover;
text-decoration: underline;
}
}
// ---- 融资 ----
&__funding-label {
font-weight: 600;
color: $text-dark;
}
// ---- 最新动态 ----
&__news-list {
display: flex;
gap: 0.14rem;
}
&__news-item {
flex: 1;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.08rem;
padding: 0.14rem 0.16rem;
transition: all 0.2s;
&:hover {
border-color: $accent;
background: $theme-color;
}
}
&__news-title {
font-size: 0.13rem;
font-weight: 600;
color: $text-dark;
margin: 0 0 0.06rem 0;
}
&__news-desc {
font-size: 0.11rem;
color: $text-light;
line-height: 1.6;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
+702
View File
@@ -0,0 +1,702 @@
// Jobs 页面样式
// 颜色变量已移至全局 variables.scss
@use '../variables' as *;
.jobs-page {
&__content {
margin-left: 2rem;
margin-right: 3.6rem;
flex: 1;
padding: 0.12rem 0.18rem;
height: 100vh;
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
display: flex;
flex-direction: column;
}
// 页面标题
&__header {
margin-bottom: 0;
}
&__title {
font-size: 0.24rem;
font-weight: 700;
color: $text-dark;
margin: 0 !important;
padding: 0;
line-height: 1.1;
}
&__subtitle {
font-size: 0.13rem;
color: $text-light;
margin: 0.02rem 0 0 0 !important;
padding: 0;
}
// Tab 切换
&__tabs {
display: inline-flex;
align-items: center;
gap: 0.04rem;
margin: 0.06rem 0 0.16rem 0;
background: $bg-white;
border-radius: 0.22rem;
padding: 0.04rem;
}
&__tab {
font-size: 0.13rem;
color: $text-light;
cursor: pointer;
padding: 0.06rem 0.22rem;
border-radius: 0.18rem;
transition: all 0.25s ease;
user-select: none;
font-weight: 500;
&:hover {
color: $text-dark;
background: $theme-color;
}
&--active {
background: $accent;
color: $bg-white;
font-weight: 600;
box-shadow: 0 0.02rem 0.08rem rgba(79, 194, 201, 0.3);
&:hover {
background: $accent-hover;
color: $bg-white;
}
}
}
// 筛选条件外层白色背景条
&__filters-bar {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.14rem 0.18rem;
margin-bottom: 0.2rem;
}
// 筛选条件
&__filters {
display: flex;
align-items: center;
gap: 0.12rem;
}
&__filter-group {
display: flex;
align-items: center;
gap: 0.08rem;
}
&__filter-item {
display: flex;
align-items: center;
gap: 0.04rem;
background: $bg-main;
border: 1px solid #E8E8E8;
border-radius: 0.2rem;
padding: 0.05rem 0.14rem;
color: $text-dark;
font-size: 0.12rem;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
position: relative;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
// 工作类型下拉菜单
&__filter-dropdown {
position: absolute;
top: calc(100% + 0.06rem);
left: 0;
background: $bg-white;
border: 1px solid #E8E8E8;
border-radius: 0.08rem;
padding: 0.04rem 0;
box-shadow: 0 0.04rem 0.12rem rgba(0, 0, 0, 0.08);
z-index: 20;
min-width: 100%;
}
// 下拉菜单选项
&__filter-dropdown-item {
padding: 0.06rem 0.16rem;
font-size: 0.12rem;
color: $text-dark;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
// 选中态
&--active {
color: $accent;
font-weight: 600;
}
}
&__filter-arrow-icon {
width: 0.1rem;
height: 0.1rem;
color: $text-light;
flex-shrink: 0;
}
&__search-box {
flex: 1;
display: flex;
align-items: center;
background: $bg-white;
border: 1px solid #E8E8E8;
border-radius: 0.2rem;
padding: 0.07rem 0.14rem;
min-width: 1.6rem;
transition: border-color 0.2s ease;
&:focus-within {
border-color: $accent;
}
}
&__search-svg {
width: 0.14rem;
height: 0.14rem;
color: $text-light;
margin-right: 0.08rem;
flex-shrink: 0;
}
&__search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: $text-dark;
font-size: 0.12rem;
&::placeholder {
color: $text-light;
}
}
// 职位列表
&__list {
display: flex;
flex-direction: column;
gap: 0.12rem;
flex: 1;
overflow-y: auto;
padding-bottom: 0.2rem;
}
&__job-card {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.16rem 0.14rem;
transition: all 0.25s ease;
border: 0.02rem solid transparent;
cursor: pointer;
&:hover {
box-shadow: 0 0.04rem 0.16rem rgba(0, 0, 0, 0.06);
background: #fbfbfb;
transform: translateY(-0.01rem);
}
&--selected {
border-color: $accent;
background: $selected-color;
box-shadow: 0 0.02rem 0.12rem rgba(79, 194, 201, 0.12);
}
}
&__job-main {
display: flex;
align-items: stretch;
gap: 0.2rem;
}
&__job-left {
flex: 1;
display: flex;
gap: 0.16rem;
min-width: 0;
}
&__job-icon {
width: 0.44rem;
height: 0.44rem;
border-radius: 0.1rem;
background: $theme-color;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid #E8E8E8;
}
&__company-svg {
width: 0.24rem;
height: 0.24rem;
color: $accent;
}
&__job-info {
flex: 1;
min-width: 0;
}
&__job-title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
&__job-name {
font-size: 0.15rem;
font-weight: 600;
color: $text-dark;
line-height: 1.4;
}
&__job-more {
color: $text-light;
cursor: pointer;
background: none;
border: none;
padding: 0.04rem;
border-radius: 0.04rem;
transition: all 0.2s;
display: flex;
align-items: center;
&:hover {
color: $text-dark;
background: $bg-main;
}
}
&__more-svg {
width: 0.16rem;
height: 0.16rem;
}
&__job-meta {
font-size: 0.12rem;
color: $text-light;
margin-top: 0.04rem;
line-height: 1.4;
}
&__job-dot {
margin: 0 0.04rem;
color: #D9D9D9;
}
&__job-tip {
font-size: 0.11rem;
color: $accent-hover;
background: $theme-color;
border-radius: 0.04rem;
padding: 0.05rem 0.1rem;
margin-top: 0.1rem;
display: inline-flex;
align-items: center;
gap: 0.04rem;
border: 1px solid rgba(79, 194, 201, 0.2);
}
&__tip-svg {
width: 0.12rem;
height: 0.12rem;
flex-shrink: 0;
}
&__job-tags {
display: flex;
gap: 0.08rem;
margin-top: 0.1rem;
flex-wrap: wrap;
}
&__job-tag {
font-size: 0.11rem;
color: #888;
background: $bg-main;
border-radius: 0.04rem;
padding: 0.03rem 0.1rem;
transition: all 0.2s;
&:hover {
color: $accent-hover;
background: $theme-color;
}
}
// 底部操作栏
&__job-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.14rem;
position: relative;
}
&__job-action-left {
display: flex;
align-items: center;
gap: 0.08rem;
}
&__action-icon-btn {
width: 0.28rem;
height: 0.28rem;
border-radius: 50%;
border: none;
background: $bg-main;
color: $text-light;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: $theme-color;
color: $accent;
}
&--liked {
background: #FFF0ED;
color: $danger;
&:hover {
background: #FFE4DE;
}
}
}
&__action-svg {
width: 0.14rem;
height: 0.14rem;
}
&__job-action-right {
display: flex;
align-items: center;
gap: 0.12rem;
}
&__job-helper {
font-size: 0.12rem;
color: $text-light;
cursor: pointer;
transition: color 0.2s;
display: flex;
align-items: center;
gap: 0.04rem;
background: none;
border: none;
padding: 0.04rem 0.08rem;
border-radius: 0.14rem;
&:hover {
color: $accent;
background: $theme-color;
}
}
&__helper-svg {
width: 0.14rem;
height: 0.14rem;
}
&__job-apply-btn {
background: $bg-main;
border: 1px solid #E8E8E8;
border-radius: 0.2rem;
padding: 0.06rem 0.2rem;
color: $text-dark;
font-size: 0.12rem;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
&--active {
background: $accent;
border-color: $accent;
color: $bg-white;
&:hover {
background: $accent-hover;
border-color: $accent-hover;
}
}
}
// 弹出菜单
&__job-popup {
position: absolute;
right: 0.26rem;
top: -0.64rem;
background: $bg-white;
border: 1px solid #E8E8E8;
border-radius: 0.1rem;
padding: 0.06rem 0;
box-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.1);
z-index: 10;
}
&__job-popup-item {
padding: 0.08rem 0.2rem;
font-size: 0.12rem;
color: $text-dark;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
}
// 匹配度卡片
&__job-match {
width: 1.1rem;
text-align: center;
flex-shrink: 0;
border-radius: 0.12rem;
padding: 0.14rem 0.1rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.02rem;
transition: all 0.25s ease;
&--high {
background: linear-gradient(135deg, $theme-color 0%, rgba(79, 194, 201, 0.15) 100%);
border: 1px solid rgba(79, 194, 201, 0.25);
}
&--low {
background: $bg-main;
border: 1px solid #E8E8E8;
}
}
&__match-ring {
position: relative;
width: 0.64rem;
height: 0.64rem;
display: flex;
align-items: center;
justify-content: center;
}
&__ring-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
&__match-score {
font-size: 0.18rem;
font-weight: 700;
color: $text-dark;
line-height: 1;
position: relative;
z-index: 1;
}
&__match-label {
font-size: 0.1rem;
color: $text-light;
margin-top: 0.04rem;
}
&__match-level {
font-size: 0.1rem;
color: $text-light;
margin-top: 0.01rem;
}
// 加载更多提示
&__loading-more {
text-align: center;
padding: 0.16rem 0;
font-size: 0.12rem;
color: $text-light;
}
// 高匹配度特殊样式
&__job-match--high &__match-score {
color: $accent-hover;
}
&__job-match--high &__match-level {
color: $accent;
font-weight: 500;
}
}
// ==================== 不感兴趣反馈弹窗 ====================
.dislike-dialog {
// 弹窗圆角
.el-dialog {
border-radius: 0.16rem;
overflow: hidden;
}
// 覆盖 el-dialog 默认 body padding
.el-dialog__body {
padding: 0.12rem;
}
// 标题样式
&__title {
font-size: 0.16rem;
font-weight: 600;
color: $text-dark;
line-height: 1.6;
margin-bottom: 0.2rem;
margin-top: 0.12rem;
// 居中标题(问题反馈弹窗用)
&--center {
text-align: center;
}
}
// 选项列表容器
&__options {
display: flex;
flex-direction: column;
gap: 0.1rem;
margin-bottom: 0.16rem;
}
// 单个选项行
&__option {
display: flex;
align-items: center;
background: $bg-main;
border-radius: 0.08rem;
padding: 0.04rem 0.1rem;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
&:hover {
border-color: $accent;
background: $theme-color;
}
// 选中态
&--active {
border-color: $accent;
background: $theme-color;
}
// 覆盖 el-radio 默认样式
.el-radio {
margin-right: 0;
width: 100%;
.el-radio__label {
font-size: 0.14rem;
color: $text-dark;
font-weight: 500;
}
}
}
// 补充描述输入框
&__textarea {
.el-textarea__inner {
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.08rem;
font-size: 0.13rem;
color: $text-dark;
resize: none;
&::placeholder {
color: $text-light;
}
&:focus {
border-color: $accent;
}
}
}
// 底部按钮区域
&__footer {
margin-top: 0.3rem;
display: flex;
gap: 0.16rem;
}
// 按钮通用样式
&__btn {
flex: 1;
padding: 0.12rem 0;
border-radius: 0.08rem;
font-size: 0.14rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
// 取消按钮
&--cancel {
background: $bg-main;
color: $text-light;
border: 1px solid $border-color;
&:hover {
color: $text-dark;
border-color: #ccc;
}
}
// 提交按钮
&--submit {
background: $btn-dark;
color: $bg-white;
&:hover {
background: $btn-dark-hover;
}
}
}
}
+87
View File
@@ -0,0 +1,87 @@
.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-page {
text-align: center;
.login-title {
font-size: 0.28rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.4rem;
}
.login-form {
display: flex;
flex-direction: column;
gap: 0.16rem;
}
.login-input {
height: 0.4rem;
.el-input__wrapper {
background-color: #f5f5f7;
border-radius: 0.08rem;
box-shadow: none;
padding: 0.00rem 0.16rem;
}
}
.code-row {
display: flex;
align-items: center;
gap: 0.12rem;
.login-input {
flex: 1;
}
.send-code-btn {
white-space: nowrap;
border-radius: 0.2rem;
padding: 0.08rem 0.2rem;
font-size: 0.14rem;
}
}
.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;
&:hover,
&:focus {
background-color: #2d2d44;
border-color: #2d2d44;
}
}
.register-link {
margin-top: 0.08rem;
font-size: 0.14rem;
color: #666;
a {
color: #409eff;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
@use '../variables' as *;
// ==================== 个人资料页 ====================
.profile-page {
&__content {
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
box-sizing: border-box;
overflow-y: auto;
background: $bg-main;
display: flex;
flex-direction: column;
}
// 页面标题区
&__header {
margin-bottom: 0.16rem;
}
&__title {
font-size: 0.2rem;
font-weight: 700;
color: $text-dark;
margin: 0;
display: flex;
align-items: center;
gap: 0.06rem;
}
&__title-tip {
font-size: 0.14rem;
color: $text-light;
cursor: help;
}
&__subtitle {
font-size: 0.12rem;
color: $text-light;
margin: 0.06rem 0 0 0;
line-height: 1.6;
max-width: 5.6rem;
}
// 主体区域
&__body {
display: flex;
gap: 0.24rem;
flex: 1;
min-height: 0;
}
// 右侧边栏
&__sidebar {
padding-left: 0.1rem;
width: 3.0rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0.12rem;
}
// 提示卡片
&__tip-card {
background: $btn-dark;
border-radius: 0.12rem;
padding: 0.2rem;
color: $bg-white;
}
&__tip-text {
font-size: 0.13rem;
line-height: 1.7;
margin: 0 0 0.16rem 0;
color: rgba(255, 255, 255, 0.85);
}
&__tip-btn {
display: block;
width: 100%;
background: $bg-white;
color: $text-dark;
border: none;
border-radius: 0.06rem;
padding: 0.08rem 0;
font-size: 0.13rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-align: center;
&:hover {
background: $theme-color;
color: $accent-hover;
}
}
// 导航入口
&__nav-entry {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.14rem 0.16rem;
display: flex;
align-items: center;
gap: 0.08rem;
cursor: pointer;
transition: all 0.2s;
&:hover {
box-shadow: 0 0.02rem 0.1rem rgba(0, 0, 0, 0.06);
background: $theme-color;
}
}
&__nav-entry-icon {
font-size: 0.16rem;
flex-shrink: 0;
}
&__nav-entry-text {
flex: 1;
font-size: 0.13rem;
color: $text-dark;
font-weight: 500;
}
&__nav-arrow {
width: 0.14rem;
height: 0.14rem;
color: $text-light;
flex-shrink: 0;
}
}
+393
View File
@@ -0,0 +1,393 @@
@use '../variables' as *;
// ==================== 简历详情页 ====================
.resume-detail {
&__content {
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
box-sizing: border-box;
overflow-y: auto;
background: $bg-main;
display: flex;
flex-direction: column;
}
&__page-title {
font-size: 0.24rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.16rem 0;
}
// ---- 顶部操作栏 ----
&__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.16rem;
}
&__toolbar-left {
display: flex;
align-items: center;
gap: 0.12rem;
}
&__back-btn {
width: 0.32rem;
height: 0.32rem;
border-radius: 50%;
border: 1px solid $border-color;
background: $bg-white;
color: $text-dark;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: $theme-color;
border-color: $accent;
color: $accent;
}
}
&__back-icon {
width: 0.14rem;
height: 0.14rem;
}
&__tab-name {
font-size: 0.14rem;
font-weight: 500;
color: $text-dark;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.16rem;
padding: 0.05rem 0.16rem;
}
&__toolbar-right {
display: flex;
align-items: center;
gap: 0.1rem;
}
&__tool-btn {
display: flex;
align-items: center;
gap: 0.04rem;
background: none;
border: none;
color: $text-light;
font-size: 0.12rem;
cursor: pointer;
padding: 0.05rem 0.1rem;
border-radius: 0.06rem;
transition: all 0.2s;
white-space: nowrap;
&:hover {
color: $text-dark;
background: $bg-white;
}
&--danger:hover {
color: $danger;
}
}
&__tool-icon {
width: 0.13rem;
height: 0.13rem;
flex-shrink: 0;
}
// ---- 评分区域 ----
&__score-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: $bg-white;
border-radius: 0.12rem;
padding: 0.18rem 0.24rem;
margin-bottom: 0.16rem;
}
&__score-left {
display: flex;
align-items: center;
gap: 0.12rem;
}
&__score-avatar {
width: 0.4rem;
height: 0.4rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $bg-white;
font-size: 0.18rem;
font-weight: 700;
flex-shrink: 0;
}
&__score-badge {
font-size: 0.11rem;
font-weight: 600;
color: $bg-white;
background: $accent;
border-radius: 0.1rem;
padding: 0.03rem 0.1rem;
}
&__score-link {
font-size: 0.12rem;
color: $text-light;
background: none;
border: none;
cursor: pointer;
transition: color 0.2s;
&:hover {
color: $accent;
}
}
&__score-right {
display: flex;
align-items: center;
gap: 0.2rem;
}
&__score-item {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.02rem;
}
&__score-num {
font-size: 0.22rem;
font-weight: 700;
color: $text-dark;
line-height: 1.2;
}
&__score-label {
font-size: 0.1rem;
color: $text-light;
white-space: nowrap;
}
&__diagnose-btn {
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.06rem;
padding: 0.08rem 0.18rem;
font-size: 0.13rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
&:hover {
background: $btn-dark-hover;
}
}
// ---- 简历主体 ----
&__body {
display: flex;
flex-direction: column;
gap: 0.16rem;
padding-bottom: 0.4rem;
}
&__card {
background: $bg-white;
border-radius: 0.12rem;
padding: 0.24rem;
}
// ---- 个人信息卡片 ----
&__card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.16rem;
}
&__user-name {
font-size: 0.2rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.04rem 0;
}
&__user-title {
font-size: 0.13rem;
color: $text-light;
margin: 0;
}
&__card-actions {
display: flex;
align-items: center;
gap: 0.08rem;
flex-shrink: 0;
}
&__card-btn {
font-size: 0.12rem;
border-radius: 0.16rem;
padding: 0.06rem 0.16rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
border: none;
&--outline {
background: $bg-main;
color: $text-dark;
border: 1px solid $border-color;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
&--dark {
background: $btn-dark;
color: $bg-white;
&:hover {
background: $btn-dark-hover;
}
}
}
// ---- 联系方式 ----
&__contact {
display: flex;
align-items: center;
gap: 0.24rem;
flex-wrap: wrap;
}
&__contact-item {
display: flex;
align-items: center;
gap: 0.06rem;
font-size: 0.12rem;
color: $text-dark;
}
&__contact-icon {
width: 0.14rem;
height: 0.14rem;
color: $text-light;
flex-shrink: 0;
}
// ---- 区块标题 ----
&__section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.16rem;
}
&__section-title {
font-size: 0.17rem;
font-weight: 700;
color: $text-dark;
margin: 0 0 0.16rem 0;
}
&__section-header &__section-title {
margin-bottom: 0;
}
// ---- 教育背景 ----
&__edu-item {
padding: 0.1rem 0;
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
}
&__edu-degree {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.04rem;
}
&__edu-meta {
font-size: 0.12rem;
color: $text-light;
line-height: 1.6;
}
// ---- 工作经验 ----
&__exp-item {
padding: 0.1rem 0;
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
}
&__exp-title {
font-size: 0.14rem;
font-weight: 600;
color: $text-dark;
margin-bottom: 0.04rem;
}
&__exp-meta {
font-size: 0.12rem;
color: $text-light;
margin-bottom: 0.06rem;
}
&__exp-desc {
font-size: 0.12rem;
color: $text-dark;
line-height: 1.6;
margin: 0;
}
// ---- 技能标签 ----
&__skills {
display: flex;
flex-wrap: wrap;
gap: 0.08rem;
}
&__skill-tag {
font-size: 0.12rem;
color: $text-dark;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.04rem;
padding: 0.05rem 0.14rem;
transition: all 0.2s;
&:hover {
border-color: $accent;
color: $accent;
background: $theme-color;
}
}
}
+190
View File
@@ -0,0 +1,190 @@
@use '../variables' as *;
// ==================== 简历列表页 ====================
.resume-page {
&__content {
margin-left: 2rem;
flex: 1;
padding: 0.12rem 0.36rem;
height: 100vh;
box-sizing: border-box;
overflow: hidden;
background: $bg-main;
display: flex;
flex-direction: column;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.2rem;
}
&__title {
font-size: 0.24rem;
font-weight: 700;
color: $text-dark;
margin: 0;
}
&__upload-btn {
display: flex;
align-items: center;
gap: 0.06rem;
background: $btn-dark;
color: $bg-white;
border: none;
border-radius: 0.2rem;
padding: 0.08rem 0.2rem;
font-size: 0.13rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: $btn-dark-hover;
}
}
&__upload-icon {
width: 0.14rem;
height: 0.14rem;
}
// 表格容器
&__table-wrap {
background: $bg-white;
border-radius: 0.12rem;
}
&__table {
width: 100%;
border-collapse: collapse;
}
&__th {
text-align: left;
padding: 0.14rem 0.2rem;
font-size: 0.12rem;
font-weight: 600;
color: $text-dark;
border-bottom: 1px solid $border-color;
white-space: nowrap;
&--action {
width: 0.5rem;
}
}
&__row {
cursor: pointer;
transition: background 0.15s;
&:hover {
background: $theme-color;
}
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
}
&__td {
padding: 0.16rem 0.2rem;
font-size: 0.13rem;
color: $text-dark;
vertical-align: middle;
&--action {
position: relative;
text-align: center;
}
}
// 简历名称单元格
&__name-cell {
display: flex;
align-items: center;
gap: 0.1rem;
}
&__avatar {
width: 0.3rem;
height: 0.3rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: $bg-white;
font-size: 0.13rem;
font-weight: 600;
flex-shrink: 0;
}
&__name {
font-weight: 500;
}
&__default-tag {
font-size: 0.1rem;
color: $text-light;
background: $bg-main;
border: 1px solid $border-color;
border-radius: 0.04rem;
padding: 0.02rem 0.06rem;
white-space: nowrap;
}
// 更多按钮
&__more-btn {
background: none;
border: none;
color: $text-light;
cursor: pointer;
padding: 0.04rem;
border-radius: 0.04rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
color: $text-dark;
background: $bg-main;
}
}
&__more-svg {
width: 0.16rem;
height: 0.16rem;
}
// 弹出菜单
&__popup {
position: absolute;
right: 0.3rem;
top: 0.1rem;
background: $bg-white;
border: 1px solid $border-color;
border-radius: 0.1rem;
padding: 0.06rem 0;
box-shadow: 0 0.06rem 0.2rem rgba(0, 0, 0, 0.1);
z-index: 10;
min-width: 1.2rem;
}
&__popup-item {
padding: 0.08rem 0.2rem;
font-size: 0.12rem;
color: $text-dark;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
&:hover {
background: $theme-color;
color: $accent-hover;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// ========================================
// 全局颜色规格 — 项目统一色彩体系
// ========================================
// 主题色 — 用于高亮背景、选中态等
$theme-color: #F6FCFC;
// 选中态背景色
$selected-color: #F6FCFC;
// 页面主背景色
$bg-main: #F3F4F5;
// 白色背景(卡片、输入框等)
$bg-white: #FFFFFF;
// 深色文字(标题、正文)
$text-dark: #000000;
// 浅色文字(副标题、占位符、辅助信息)
$text-light: #BFBFBF;
// 强调色 / 品牌色
$accent: #4FC2C9;
// 强调色悬停态
$accent-hover: #42A8B3;
// 危险色 / 警告色(删除、错误等)
$danger: #E85635;
// 分割线颜色
$border-color: #E8E8E8;
// 遮罩层背景
$overlay-bg: rgba(0, 0, 0, 0.5);
// 按钮深色背景(确认提交等)
$btn-dark: #1A1A2E;
// 按钮深色悬停态
$btn-dark-hover: #2E3142;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

+120
View File
@@ -0,0 +1,120 @@
<template>
<div class="ai-chat">
<!-- 顶部会员横幅 点击打开会员购买弹窗 -->
<div class="ai-chat__banner" @click="showMemberDialog = true">
<span>解锁会员送快上岸!</span>
<span class="ai-chat__banner-arrow"></span>
</div>
<!-- AI 头像和名称 -->
<div class="ai-chat__header">
<div class="ai-chat__avatar">👤</div>
<div class="ai-chat__info">
<div class="ai-chat__name">Nova</div>
<div class="ai-chat__desc">你的AI求职助手</div>
</div>
</div>
<!-- 聊天消息区域 -->
<div class="ai-chat__messages" ref="messagesRef">
<!-- AI 欢迎消息 -->
<div class="ai-chat__msg ai-chat__msg--ai">
<div class="ai-chat__msg-bubble">
<div class="ai-chat__msg-title">欢迎回来李华!</div>
<div class="ai-chat__msg-text">很高兴再次见到你让我们继续您通往理想工作的旅程吧</div>
</div>
</div>
<!-- 动态消息列表 -->
<div
v-for="(msg, i) in messages"
:key="i"
class="ai-chat__msg"
:class="msg.role === 'ai' ? 'ai-chat__msg--ai' : 'ai-chat__msg--user'"
>
<div class="ai-chat__msg-bubble">{{ msg.content }}</div>
</div>
<!-- 快捷问题 -->
<div class="ai-chat__quick-questions">
<div
v-for="(q, i) in quickQuestions"
:key="i"
class="ai-chat__quick-item"
@click="sendQuickQuestion(q)"
>
{{ q }}
</div>
</div>
</div>
<!-- 底部输入框 -->
<div class="ai-chat__input-area">
<input
v-model="inputText"
class="ai-chat__input"
placeholder="搜索职位、公司或关键词..."
@keyup.enter="sendMessage"
/>
<button class="ai-chat__send-btn" @click="sendMessage">
<span></span>
</button>
</div>
<!-- 会员购买弹窗 -->
<MemberDialog v-model="showMemberDialog" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MemberDialog from '@/components/MemberDialog.vue'
// ==================== 状态 ====================
/** 会员购买弹窗的显示状态 */
const showMemberDialog = ref(false)
/** 输入框内容 */
const inputText = ref('')
/** 消息列表容器 DOM 引用(用于滚动控制) */
const messagesRef = ref<HTMLElement>()
// ==================== 类型定义 ====================
/** 聊天消息类型 */
interface ChatMsg {
role: 'ai' | 'user'
content: string
}
// ==================== 数据 ====================
/** 聊天消息列表 */
const messages = ref<ChatMsg[]>([])
/** 快捷问题列表(点击后自动发送) */
const quickQuestions = [
'你想知道关于这个岗位的什么信息?',
'告诉我这个工作为什么适合我?',
'我想修改这个岗位,怎么优化简历?',
'帮我针对这个岗位生成一份面试攻略',
]
// ==================== 事件处理 ====================
/** 点击快捷问题,填入输入框并发送 */
function sendQuickQuestion(question: string) {
inputText.value = question
sendMessage()
}
/** 发送消息(回车或点击发送按钮触发) */
function sendMessage() {
if (!inputText.value.trim()) return
messages.value.push({ role: 'user', content: inputText.value.trim() })
// TODO: 接入AI聊天接口
inputText.value = ''
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<template>
<el-dialog
v-model="visible"
:show-close="true"
width="4.8rem"
class="dislike-dialog"
:close-on-click-modal="true"
>
<!-- 弹窗标题 -->
<div class="dislike-dialog__title">为了给您推荐更适合岗位请告诉我们不匹配的原因</div>
<!-- 原因选项列表 单选 -->
<div class="dislike-dialog__options">
<label
v-for="option in dislikeOptions"
:key="option.value"
class="dislike-dialog__option"
:class="{ 'dislike-dialog__option--active': dislikeReason === option.value }"
>
<el-radio v-model="dislikeReason" :value="option.value">{{ option.label }}</el-radio>
</label>
</div>
<!-- 补充描述输入框 -->
<el-input
v-model="dislikeDetail"
type="textarea"
:rows="4"
placeholder="请描述您的原因"
class="dislike-dialog__textarea"
/>
<!-- 底部按钮 -->
<div class="dislike-dialog__footer">
<button class="dislike-dialog__btn dislike-dialog__btn--cancel" @click="visible = false">取消</button>
<button class="dislike-dialog__btn dislike-dialog__btn--submit" @click="handleDislikeSubmit">提交</button>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
jobId: string | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
/** 不感兴趣的原因(单选) */
const dislikeReason = ref('')
/** 不感兴趣的补充描述 */
const dislikeDetail = ref('')
/** 不感兴趣原因选项列表 */
const dislikeOptions = [
{ value: 'company', label: '对这家公司不感兴趣' },
{ value: 'position', label: '对这个岗位不感兴趣' },
{ value: 'location', label: '工作地点不合适' },
{ value: 'other', label: '其他原因' },
]
/** 提交不感兴趣反馈 */
function handleDislikeSubmit() {
if (!dislikeReason.value) {
ElMessage.warning('请选择一个原因')
return
}
// TODO: 调用接口提交反馈,参数:props.jobId, dislikeReason.value, dislikeDetail.value
console.log('提交不感兴趣反馈', {
jobId: props.jobId,
reason: dislikeReason.value,
detail: dislikeDetail.value,
})
ElMessage.success('反馈已提交,感谢您的反馈')
visible.value = false
}
/** 弹窗打开时重置表单 */
function resetForm() {
dislikeReason.value = ''
dislikeDetail.value = ''
}
defineExpose({ resetForm })
</script>
+80
View File
@@ -0,0 +1,80 @@
<template>
<el-dialog
v-model="visible"
:show-close="true"
width="4.8rem"
class="dislike-dialog"
:close-on-click-modal="true"
>
<!-- 弹窗标题 -->
<div class="dislike-dialog__title dislike-dialog__title--center">问题反馈</div>
<!-- 反馈原因选项列表 单选 -->
<div class="dislike-dialog__options">
<label
v-for="option in feedbackOptions"
:key="option.value"
class="dislike-dialog__option"
:class="{ 'dislike-dialog__option--active': feedbackReason === option.value }"
>
<el-radio v-model="feedbackReason" :value="option.value">{{ option.label }}</el-radio>
</label>
</div>
<!-- 底部按钮 -->
<div class="dislike-dialog__footer">
<button class="dislike-dialog__btn dislike-dialog__btn--cancel" @click="visible = false">取消</button>
<button class="dislike-dialog__btn dislike-dialog__btn--submit" @click="handleFeedbackSubmit">提交</button>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: boolean
jobId: string | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const visible = computed({
get: () => props.modelValue,
set: (val: boolean) => emit('update:modelValue', val),
})
/** 问题反馈的原因(单选) */
const feedbackReason = ref('')
/** 问题反馈原因选项列表 */
const feedbackOptions = [
{ value: 'fraud', label: '怀疑是诈骗或虚假岗位' },
{ value: 'inaccurate', label: '公司信息或职位描述有误' },
{ value: 'expired', label: '该职位已停止招聘/职位已失效' },
]
/** 提交问题反馈 */
function handleFeedbackSubmit() {
if (!feedbackReason.value) {
ElMessage.warning('请选择一个反馈原因')
return
}
// TODO: 调用接口提交问题反馈,参数:props.jobId, feedbackReason.value
console.log('提交问题反馈', {
jobId: props.jobId,
reason: feedbackReason.value,
})
ElMessage.success('问题反馈已提交,感谢您的反馈')
visible.value = false
}
/** 弹窗打开时重置表单 */
function resetForm() {
feedbackReason.value = ''
}
defineExpose({ resetForm })
</script>
+268
View File
@@ -0,0 +1,268 @@
<template>
<!-- 求职目标设置弹窗 -->
<el-dialog
v-model="visible"
width="480px"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="true"
:align-center="false"
:style="{ marginLeft: 'auto', marginRight: '0.2rem' }"
class="job-goal-dialog"
@close="handleClose"
>
<!-- 头部区域 -->
<div class="job-goal-dialog__header">
<!-- 关闭按钮 -->
<button class="job-goal-dialog__close-btn" @click="handleClose">
<el-icon><Close /></el-icon>
</button>
<!-- 弹窗标题 -->
<span class="job-goal-dialog__title">求职目标设置</span>
</div>
<!-- 岗位选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__label">*岗位</div>
<!-- 已选岗位标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.positions"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除岗位标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removePosition(index)"><Close /></el-icon>
</span>
</div>
<!-- 岗位下拉选择器 -->
<el-select
v-model="newPosition"
placeholder="新增岗位"
filterable
class="job-goal-dialog__select"
@change="addPosition"
>
<el-option
v-for="opt in positionOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
</div>
<!-- 行业选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__label">*行业</div>
<!-- 已选行业标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.industries"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除行业标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removeIndustry(index)"><Close /></el-icon>
</span>
</div>
<!-- 行业下拉选择器 -->
<el-select
v-model="newIndustry"
placeholder="新增行业"
filterable
class="job-goal-dialog__select"
@change="addIndustry"
>
<el-option
v-for="opt in industryOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
</div>
<!-- 城市选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__label">*城市</div>
<!-- 已选城市标签列表 -->
<div class="job-goal-dialog__tags">
<span
v-for="(item, index) in form.cities"
:key="index"
class="job-goal-dialog__tag"
>
{{ item }}
<!-- 删除城市标签 -->
<el-icon class="job-goal-dialog__tag-close" @click="removeCity(index)"><Close /></el-icon>
</span>
</div>
<!-- 城市下拉选择器 -->
<el-select
v-model="newCity"
placeholder="新增城市"
filterable
class="job-goal-dialog__select"
@change="addCity"
>
<el-option
v-for="opt in cityOptions"
:key="opt"
:label="opt"
:value="opt"
/>
</el-select>
</div>
<!-- 工作类型选择模块 -->
<div class="job-goal-dialog__section ">
<div class="job-goal-dialog__label">*工作类型</div>
<!-- 工作类型按钮组 -->
<div class="job-goal-dialog__type-group">
<button
v-for="t in jobTypes"
:key="t"
class="job-goal-dialog__type-btn"
:class="{ 'job-goal-dialog__type-btn--active': form.jobType === t }"
@click="form.jobType = t"
>
{{ t }}
</button>
</div>
</div>
<!-- 保存按钮区域 -->
<div class="job-goal-dialog__footer mt10">
<button class="job-goal-dialog__save-btn" @click="handleSave">保存</button>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { Close } from '@element-plus/icons-vue'
/** 组件属性:控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件事件:更新弹窗状态、保存表单数据 */
const emit = defineEmits<{
(e: 'update:modelValue', val: boolean): void
(e: 'save', data: { positions: string[]; industries: string[]; cities: string[]; jobType: string }): void
}>()
/** 弹窗可见状态 */
const visible = ref(props.modelValue)
/** 监听外部传入的 modelValue 同步弹窗状态 */
watch(() => props.modelValue, (v) => { visible.value = v })
/** 监听弹窗状态变化并通知父组件 */
watch(visible, (v) => { emit('update:modelValue', v) })
/** 表单数据 */
const form = reactive({
/** 已选岗位列表 */
positions: ['产品经理'] as string[],
/** 已选行业列表 */
industries: ['互联网'] as string[],
/** 已选城市列表 */
cities: ['北京'] as string[],
/** 工作类型 */
jobType: '全职',
})
/** 新增岗位的绑定值 */
const newPosition = ref('')
/** 新增行业的绑定值 */
const newIndustry = ref('')
/** 新增城市的绑定值 */
const newCity = ref('')
/** 岗位选项列表 */
const positionOptions = ['产品经理', '前端工程师', '后端工程师', 'UI设计师', '数据分析师', '运营', '市场营销']
/** 行业选项列表 */
const industryOptions = ['互联网', '金融', '教育', '医疗健康', '电子商务', '人工智能', '游戏', '新能源', '房地产', '制造业']
/** 城市选项列表 */
const cityOptions = ['北京', '上海', '广州', '深圳', '杭州', '成都', '南京', '武汉']
/** 工作类型选项列表 */
const jobTypes = ['实习', '全职']
/**
* 添加岗位
* @param val 选中的岗位名称
*/
function addPosition(val: string) {
if (val && !form.positions.includes(val)) {
form.positions.push(val)
}
newPosition.value = ''
}
/**
* 移除岗位
* @param index 要移除的岗位索引
*/
function removePosition(index: number) {
form.positions.splice(index, 1)
}
/**
* 添加行业
* @param val 选中的行业名称
*/
function addIndustry(val: string) {
if (val && !form.industries.includes(val)) {
form.industries.push(val)
}
newIndustry.value = ''
}
/**
* 移除行业
* @param index 要移除的行业索引
*/
function removeIndustry(index: number) {
form.industries.splice(index, 1)
}
/**
* 添加城市
* @param val 选中的城市名称
*/
function addCity(val: string) {
if (val && !form.cities.includes(val)) {
form.cities.push(val)
}
newCity.value = ''
}
/**
* 移除城市
* @param index 要移除的城市索引
*/
function removeCity(index: number) {
form.cities.splice(index, 1)
}
/**
* 保存表单数据并关闭弹窗
*/
function handleSave() {
emit('save', {
...form,
positions: [...form.positions],
industries: [...form.industries],
cities: [...form.cities],
})
visible.value = false
}
/**
* 关闭弹窗
*/
function handleClose() {
visible.value = false
}
</script>
+77
View File
@@ -0,0 +1,77 @@
<template>
<div>
<div class="job-page-header">
<h2 class="job-page-header__title">发现理想职位</h2>
<p class="job-page-header__subtitle">找到最适合你的工作机会</p>
</div>
<div class="job-page-header__tabs">
<div
v-for="tab in tabs"
:key="tab.key"
class="job-page-header__tab"
:class="{ 'job-page-header__tab--active': activeTab === tab.key }"
@click="handleTabClick(tab.key)"
>
{{ tab.label }}
</div>
<button class="job-page-header__goal-btn" @click="showGoalDialog = true">
我的求职目标
</button>
</div>
<JobGoalDialog v-model="showGoalDialog" @save="onGoalSave" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import JobGoalDialog from './JobGoalDialog.vue'
const showGoalDialog = ref(false)
function onGoalSave(data: { positions: string[]; cities: string[]; jobType: string }) {
console.log('求职目标已保存', data)
}
// ==================== Props & Emits ====================
/** activeTab: 当前激活的 Tab key,由父组件通过 v-model 传入 */
const props = defineProps<{
activeTab: string
}>()
/** 双向绑定事件,在 Jobs 页面内切换 Tab 时触发 */
const emit = defineEmits<{
(e: 'update:activeTab', value: string): void
}>()
// ==================== 路由相关 ====================
const router = useRouter()
const route = useRoute()
// ==================== 常量数据 ====================
/** Tab 选项列表 */
const tabs = [
{ key: 'recommend', label: '推荐' },
{ key: 'collected', label: '收藏(1' },
{ key: 'applied', label: '已投递(2' },
]
// ==================== 事件处理 ====================
/**
* Tab 点击处理:
* - 在 Jobs 页面:直接切换 activeTab(通过 emit 通知父组件)
* - 在其他页面(如 JobDetail):跳转到 Jobs 页面并携带 tab query 参数
*/
function handleTabClick(key: string) {
if (route.name === 'Jobs') {
emit('update:activeTab', key)
} else {
router.push({ name: 'Jobs', query: { tab: key } })
}
}
</script>
+148
View File
@@ -0,0 +1,148 @@
<template>
<el-dialog
v-model="visible"
width="480px"
:show-close="true"
:close-on-click-modal="false"
:close-on-press-escape="true"
class="login-dialog"
@close="handleClose"
>
<div class="login-page">
<h1 class="login-title">欢迎使用Offer派</h1>
<div class="login-form">
<el-input
v-model="phone"
placeholder="输入手机号"
size="large"
class="login-input mt20"
/>
<div class="code-row mt12 mb12">
<el-input
v-model="code"
placeholder="输入验证码"
size="large"
class="login-input"
/>
<el-button
class="send-code-btn"
:disabled="countdown > 0"
@click="sendCode"
>
{{ countdown > 0 ? countdownText : '发送验证码' }}
</el-button>
</div>
<el-button
type="primary"
size="large"
class="login-btn h50 border-ra20"
@click="handleLogin"
>
登录
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useStore } from 'vuex'
import { useRouter } from 'vue-router'
import { sendSmsCode, smsLogin } from '@/api/auth'
const store = useStore()
const router = useRouter()
const phone = ref('')
const code = ref('')
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
/** 倒计时显示文本,格式 m:ss */
const countdownText = computed(() => {
const m = Math.floor(countdown.value / 60)
const s = countdown.value % 60
return `${m}:${s.toString().padStart(2, '0')}`
})
const visible = computed({
get: () => store.state.showLogin,
set: (val: boolean) => {
if (!val) store.dispatch('closeLogin')
},
})
//发送验证码
async function sendCode() {
if (!phone.value) {
ElMessage.warning('请输入手机号')
return
}
try {
const res = await sendSmsCode(phone.value)
if (res.code === '0' && res.data === true) {
ElMessage.success('验证码已发送')
countdown.value = 300
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
} else {
ElMessage.error(res.msg || '验证码发送失败')
}
} catch {
// 错误已在 request 拦截器中统一处理
}
}
/**
* 登录流程:
* 1. 调 smsLogin 接口,后端成功会 Set-Cookie: Token=xxx
* 2. Cookie 自动保持登录状态,不再存 localStorage
* 3. 关闭弹窗,跳转到登录前想去的页面
*/
async function handleLogin() {
if (!phone.value || !code.value) {
ElMessage.warning('请输入手机号和验证码')
return
}
try {
const res = await smsLogin(phone.value, code.value)
if (res.code !== '0') {
ElMessage.error(res.msg || '登录失败')
return
}
// 登录成功,Cookie Token 已由后端 Set-Cookie 写入
ElMessage.success(`欢迎回来,${res.data?.nick || ''}`)
// 清除验证码倒计时,登录成功后解除发送限制
if (timer) {
clearInterval(timer)
timer = null
}
countdown.value = 0
store.commit('SET_AUTHENTICATED', true)
const redirect = store.state.loginRedirect
store.dispatch('closeLogin')
if (redirect) {
router.push(redirect)
}
} catch {
// 错误已在 request 拦截器中统一处理
}
}
function handleClose() {
store.dispatch('closeLogin')
}
</script>
+162
View File
@@ -0,0 +1,162 @@
<template>
<!-- 会员购买弹窗 通过 Teleport 挂载到 body -->
<Teleport to="body">
<!-- 遮罩层 点击关闭弹窗 -->
<div v-if="modelValue" class="member-dialog-overlay" @click="$emit('update:modelValue', false)">
<!-- 弹窗主体 阻止点击冒泡 -->
<div class="member-dialog" @click.stop>
<!-- 右上角关闭按钮 -->
<span class="member-dialog__close" @click="$emit('update:modelValue', false)"></span>
<!-- 顶部标语 -->
<div class="member-dialog__slogan">每天不到一元获得三倍面试机会</div>
<!-- 套餐卡片区域 -->
<div class="member-dialog__plans">
<div
v-for="plan in plans"
:key="plan.key"
class="member-dialog__plan-card"
:class="{ 'member-dialog__plan-card--active': selectedPlan === plan.key }"
@click="selectedPlan = plan.key"
>
<!-- 套餐名称 -->
<div class="member-dialog__plan-name">{{ plan.name }}</div>
<!-- 原价划线 -->
<div class="member-dialog__plan-original">{{ plan.originalPrice }}</div>
<!-- 现价 -->
<div class="member-dialog__plan-price">
<span class="member-dialog__plan-price-num">¥{{ plan.price }}</span>
<span class="member-dialog__plan-price-unit">/{{ plan.unit }}</span>
<!-- 省钱标签 -->
<span v-if="plan.discount" class="member-dialog__plan-discount">{{ plan.discount }}</span>
</div>
<!-- 立刻升级按钮 -->
<button class="member-dialog__plan-btn" @click.stop="handleUpgrade(plan)">立刻升级</button>
</div>
</div>
<!-- 权益对比区域 -->
<div class="member-dialog__benefits">
<!-- 左列面试率提升工具 -->
<div class="member-dialog__benefit-col">
<div class="member-dialog__benefit-title">面试率提升工具</div>
<div
v-for="item in toolList"
:key="item"
class="member-dialog__benefit-item"
>{{ item }}</div>
<div class="member-dialog__benefit-other">
其他渠道购买 <span class="member-dialog__benefit-highlight">¥210/</span>
</div>
</div>
<!-- 中列会员权益 -->
<div class="member-dialog__benefit-col member-dialog__benefit-col--center">
<div class="member-dialog__benefit-title member-dialog__benefit-title--accent">会员权益</div>
<div
v-for="item in memberBenefits"
:key="item.label"
class="member-dialog__benefit-item"
>
<span class="member-dialog__benefit-badge">{{ item.badge }}</span>
<span>{{ item.label }}</span>
</div>
<div class="member-dialog__benefit-other">
打包价 <span class="member-dialog__benefit-highlight">¥19.99/</span>
</div>
</div>
<!-- 右列免费权益当前 -->
<div class="member-dialog__benefit-col">
<div class="member-dialog__benefit-title">免费权益当前</div>
<div
v-for="item in freeBenefits"
:key="item"
class="member-dialog__benefit-item"
>{{ item }}</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits — 通知父组件更新 modelValue */
defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
// ==================== 类型定义 ====================
/** 套餐项类型 */
interface PlanItem {
key: string
name: string
originalPrice: string
price: string
unit: string
discount: string
}
// ==================== 状态 ====================
/** 当前选中的套餐 */
const selectedPlan = ref('monthly')
/** 监听弹窗开关 — 打开时锁定背景滚动,关闭时恢复 */
watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
})
// ==================== 常量数据 ====================
/** 套餐列表 */
const plans: PlanItem[] = [
{ key: 'quarterly', name: '季度会员', originalPrice: '¥149.97 / 3个月', price: '49.99', unit: '3个月', discount: '67%' },
{ key: 'monthly', name: '月度会员', originalPrice: '¥49.99 / 1个月', price: '19.99', unit: '月', discount: '60%' },
{ key: 'weekly', name: '周会员', originalPrice: '¥71.96 / 1个月', price: '17.99', unit: '1周', discount: '' },
]
/** 面试率提升工具列表 */
const toolList = [
'AI 求职助手',
'AI 针对性简历优化',
'1V1真人导师',
'一键自动填写网申信息',
'内推码',
'实时岗位更新提醒',
]
/** 会员权益列表 */
const memberBenefits = [
{ badge: '无限', label: '自动化网申流程' },
{ badge: '无限', label: '简历过筛率提升' },
{ badge: '✓', label: '找工作不孤单' },
{ badge: '无限', label: '每一次网申节约15Min' },
{ badge: '无限', label: '自动填写最新内推码' },
{ badge: '无限', label: '第一时间投递高质量岗位' },
]
/** 免费权益列表 */
const freeBenefits = [
'受限',
'2次/天',
'不支持',
'4次/天',
'4次/天',
'1次/天',
]
// ==================== 事件处理 ====================
/** 点击立刻升级按钮 */
function handleUpgrade(plan: PlanItem) {
// TODO: 接入支付接口
ElMessage.success(`正在跳转 ${plan.name} 支付页面...`)
}
</script>
File diff suppressed because it is too large Load Diff
+238
View File
@@ -0,0 +1,238 @@
<template>
<div class="profile-page-content">
<!-- 个人信息 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">个人信息</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('info')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="profile-page-content__info-name">{{ profile.name }}</div>
<div class="profile-page-content__info-contacts">
<span class="profile-page-content__contact-item">📱 {{ profile.phone }}</span>
<span class="profile-page-content__contact-item"> {{ profile.email }}</span>
<span class="profile-page-content__contact-item">📍 {{ resolveRegionName(profile.regionCode) }}</span>
</div>
<div class="profile-page-content__info-contacts" v-if="profile.wechat">
<span class="profile-page-content__contact-item">💬 {{ profile.wechat }}</span>
</div>
</div>
<!-- 教育经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">教育经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('education')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(edu, i) in profile.education" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ edu.school }}</div>
<div class="profile-page-content__item-sub">{{ getDegreeText(edu.studyType, edu.degree) }} · {{ edu.major }}</div>
<div class="profile-page-content__item-date">{{ edu.startDate }} - {{ edu.endDate }}</div>
</div>
</div>
<ul class="profile-page-content__item-list" v-if="edu.description?.length && edu.description[0]?.text">
<li v-for="desc in edu.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 工作经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">工作经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('work')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(exp, i) in profile.works" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate || '至今' }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 实习经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">实习经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('internship')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(exp, i) in profile.internships" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ exp.companyName }}</div>
<div class="profile-page-content__item-sub">{{ exp.position }}</div>
</div>
<div class="profile-page-content__item-period">{{ exp.startDate }} - {{ exp.endDate }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in exp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 项目经历 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">项目经历</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('project')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(proj, i) in profile.projects" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ proj.projectName }}</div>
<div class="profile-page-content__item-sub">{{ proj.role }}</div>
</div>
<div class="profile-page-content__item-period">{{ proj.startDate }} - {{ proj.endDate }}</div>
</div>
<ul class="profile-page-content__item-list">
<li v-for="desc in proj.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 技能 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">技能</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('skills')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(skill, i) in profile.skills" :key="i" class="profile-page-content__tag">{{ skill }}</span>
</div>
</div>
<!-- 竞赛 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">竞赛</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('competition')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-for="(comp, i) in profile.competitions" :key="i" class="profile-page-content__section-item">
<div class="profile-page-content__item-row">
<div class="profile-page-content__item-left">
<div class="profile-page-content__item-title">{{ comp.competitionName }} · {{ comp.award }}</div>
</div>
<div class="profile-page-content__item-period">{{ comp.awardDate }}</div>
</div>
<ul class="profile-page-content__item-list" v-if="comp.description[0]?.text">
<li v-for="desc in comp.description" :key="desc.id">{{ desc.text }}</li>
</ul>
</div>
</div>
<!-- 证书 -->
<div class="profile-page-content__card">
<div class="profile-page-content__card-header">
<h3 class="profile-page-content__card-title">证书</h3>
<button class="profile-page-content__edit-btn" @click="handleEdit('certificate')">
<svg viewBox="0 0 16 16" fill="none" class="profile-page-content__edit-icon"><path d="M11.5 2.5l2 2L5 13H3v-2l8.5-8.5z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
</button>
</div>
<div class="profile-page-content__tags">
<span v-for="(cert, i) in profile.certificates" :key="i" class="profile-page-content__tag">{{ cert }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { resolveRegionName } from '@/utils/region'
interface ProfileData {
name: string
phone: string
email: string
idNumber: string
/** 所在城市编码 — 对应接口字段 regionCode */
regionCode: string
wechat?: string
education: Array<{
school: string
major: string
studyType: number
degree: number
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
works: Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
internships: Array<{
companyName: string
position: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
projects: Array<{
projectName: string
companyName: string
role: string
startDate: string
endDate: string
description: Array<{ id: string; text: string }>
}>
skills: string[]
competitions: Array<{
competitionName: string
award: string
awardDate: string
description: Array<{ id: string; text: string }>
}>
certificates: string[]
}
const props = defineProps<{
profile: ProfileData
}>()
const emit = defineEmits<{
edit: [section: string]
}>()
/** 获取学历文本 — 根据学历类型和学历代码生成显示文本 */
function getDegreeText(studyType: number, degree: number): string {
const studyTypeText = studyType === 0 ? '全日制' : '非全日制'
const degreeMap: Record<number, string> = {
1: '大专',
2: '本科',
3: '硕士',
4: '博士',
}
return `${studyTypeText}${degreeMap[degree] || ''}`
}
function handleEdit(section: string) {
emit('edit', section)
}
</script>
<style scoped lang="scss">
@use '../assets/styles/components/profile-page-content';
</style>
+299
View File
@@ -0,0 +1,299 @@
<template>
<!-- 设置弹窗 通过 Teleport 挂载到 body避免被父组件样式影响 -->
<Teleport to="body">
<!-- 遮罩层 点击关闭弹窗 -->
<div v-if="modelValue" class="settings-dialog-overlay" @click="$emit('update:modelValue', false)">
<!-- 弹窗主体 阻止点击冒泡到遮罩层 -->
<div class="settings-dialog" @click.stop>
<!-- 右上角关闭按钮 -->
<span class="settings-dialog__close" @click="$emit('update:modelValue', false)"></span>
<!-- 左侧导航栏 -->
<div class="settings-dialog__sidebar">
<!-- 主导航 Tab 列表 -->
<div class="settings-dialog__nav">
<div
v-for="tab in tabs"
:key="tab.key"
class="settings-dialog__nav-item"
:class="{ 'settings-dialog__nav-item--active': activeTab === tab.key }"
@click="activeTab = tab.key"
>
<span class="settings-dialog__nav-icon">{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</div>
</div>
<!-- 底部操作区 隐私协议 + 退出登录 -->
<div class="settings-dialog__bottom-actions">
<!-- 用户隐私协议按钮 点击切换到隐私协议 tab -->
<button
class="settings-dialog__bottom-btn"
:class="{ 'settings-dialog__bottom-btn--active': activeTab === 'privacy' }"
@click="activeTab = 'privacy'"
>
用户隐私协议
</button>
<!-- 退出登录按钮 点击弹出确认弹窗 -->
<button class="settings-dialog__bottom-btn settings-dialog__bottom-btn--danger" @click="showLogout = true">
退出登录
</button>
</div>
</div>
<!-- 右侧内容区 根据 activeTab 切换显示 -->
<div class="settings-dialog__content">
<!-- Tab: 账号与安全 显示手机号注销账号 -->
<template v-if="activeTab === 'account'">
<h2 class="settings-dialog__content-title">账号与安全</h2>
<div class="settings-dialog__section">
<div class="settings-dialog__section-label">手机号</div>
<p class="settings-dialog__section-value">130****2222</p>
</div>
<div class="settings-dialog__danger-section">
<div class="settings-dialog__danger-title">注销我的账号</div>
<div class="settings-dialog__danger-row">
<p class="settings-dialog__danger-desc">永久删除你的账号及所有相关数据</p>
<button class="settings-dialog__danger-btn" @click="handleDeleteAccount">注销账号</button>
</div>
</div>
</template>
<!-- Tab: 会员 显示当前会员信息订阅管理 -->
<template v-if="activeTab === 'member'">
<h2 class="settings-dialog__content-title">会员</h2>
<div class="settings-dialog__section-label" style="margin-bottom: 0.12rem;">当前会员</div>
<div class="settings-dialog__member-card">
<div class="settings-dialog__member-header">
<div class="settings-dialog__member-title-row">
<span class="settings-dialog__member-name">月度会员</span>
<span class="settings-dialog__member-badge">查看详情</span>
</div>
<span class="settings-dialog__member-terms" @click="handleMemberTerms">会员条款</span>
</div>
<div class="settings-dialog__member-info-row">
<span class="settings-dialog__member-price">
¥19.99/<span>将于2026年3月27日续费</span>
</span>
<button class="settings-dialog__member-manage-btn" @click="handleManageSubscription">管理我的订阅</button>
</div>
</div>
<div class="settings-dialog__member-issue">
<div class="settings-dialog__member-issue-title">订阅状态异常</div>
<p class="settings-dialog__member-issue-desc">
如果你已经和完成了付款或更改了订阅但是没有看到最新状态你可以尝试更新状态或联系我们获取帮助
</p>
<div class="settings-dialog__member-issue-actions">
<button class="settings-dialog__member-issue-btn" @click="handleRefreshStatus">更新状态</button>
<button class="settings-dialog__member-issue-btn" @click="handleContactUs">联系我们</button>
</div>
</div>
</template>
<!-- Tab: 岗位更新提醒 目标岗位即时提醒开关提醒频率 -->
<template v-if="activeTab === 'reminder'">
<h2 class="settings-dialog__content-title">岗位更新提醒</h2>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">目标岗位</div>
<div class="settings-dialog__reminder-target">
<div class="settings-dialog__reminder-tags">
<span class="settings-dialog__reminder-tag" v-for="tag in targetTags" :key="tag">{{ tag }}</span>
</div>
<button class="settings-dialog__reminder-edit-btn" @click="handleEditTarget">编辑</button>
</div>
</div>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">即时岗位提醒</div>
<div class="settings-dialog__reminder-row">
<div class="settings-dialog__reminder-info">
<div class="settings-dialog__reminder-label">开启即时岗位更新提醒</div>
<div class="settings-dialog__reminder-desc">
抢先申请 在岗位发布后一小时内即可收到为你量身定制的最新职位提醒
</div>
</div>
<el-switch v-model="reminders.instant" active-color="#4FC2C9" />
</div>
</div>
<div class="settings-dialog__reminder-block">
<div class="settings-dialog__reminder-block-title">岗位更新提醒频率</div>
<div class="settings-dialog__reminder-row">
<div class="settings-dialog__reminder-info">
<div class="settings-dialog__reminder-desc">
会员用户每天可接收无限次岗位更新提醒免费用户每天最多接收 1
</div>
</div>
<el-select v-model="reminders.frequency" style="width: 1.2rem;">
<el-option label="1次/天" value="1" />
<el-option label="2次/天" value="2" />
<el-option label="5次/天" value="5" />
<el-option label="无限次" value="unlimited" />
</el-select>
</div>
</div>
</template>
<!-- Tab: 用户隐私协议 长文本可滚动查看 -->
<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>
</template>
</div>
</div>
</div>
<!-- 退出登录确认弹窗 放在 Teleport 内部确保层级在 overlay 之上 -->
<el-dialog v-model="showLogout" title="退出登录" width="3.6rem" :close-on-click-modal="true" :append-to-body="false" :z-index="2100">
<p style="font-size: 0.14rem; color: #555; text-align: center;">确定要退出当前账号吗</p>
<template #footer>
<el-button @click="showLogout = false">取消</el-button>
<el-button type="danger" @click="handleLogout">确认退出</el-button>
</template>
</el-dialog>
</Teleport>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { logout } from '@/api/auth'
/** 组件 Props — 控制弹窗显示/隐藏 */
const props = defineProps<{ modelValue: boolean }>()
/** 组件 Emits — 通知父组件更新 modelValue */
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
/** 路由实例 — 退出登录后跳转首页 */
const router = useRouter()
const store = useStore()
/** 左侧导航 Tab 配置 */
const tabs = [
{ key: 'account', label: '账号与安全', icon: '👤' },
{ key: 'member', label: '会员', icon: '🏅' },
{ key: 'reminder', label: '岗位更新提醒', icon: '🔔' },
]
/** 当前选中的 Tab */
const activeTab = ref('account')
/** 退出登录确认弹窗的显示状态 */
const showLogout = ref(false)
/** 监听弹窗开关 — 打开时锁定背景页面滚动,关闭时恢复 */
watch(() => props.modelValue, (val) => {
document.body.style.overflow = val ? 'hidden' : ''
})
/** 岗位更新提醒的配置项 */
const reminders = reactive({
instant: true, // 是否开启即时岗位提醒
frequency: 'unlimited', // 提醒频率:1/2/5/unlimited
})
/** 目标岗位标签列表 */
const targetTags = ref(['产品经理', '全职', '北京'])
/** 编辑目标岗位 */
const handleEditTarget = () => {
ElMessage.info('编辑目标岗位功能开发中')
}
/** 注销账号 — 弹出二次确认 */
const handleDeleteAccount = () => {
ElMessageBox.confirm('此操作将永久删除你的账号及所有数据,是否继续?', '注销账号', {
confirmButtonText: '确认注销',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
ElMessage.success('账号已注销')
}).catch(() => {})
}
/** 管理订阅 */
const handleManageSubscription = () => {
ElMessage.info('管理订阅功能开发中')
}
/** 查看会员条款 */
const handleMemberTerms = () => {
ElMessage.info('会员条款页面开发中')
}
/** 刷新订阅状态 */
const handleRefreshStatus = () => {
ElMessage.success('状态已更新')
}
/** 联系客服 */
const handleContactUs = () => {
ElMessage.info('联系客服功能开发中')
}
/** 退出登录 — 调用接口,后端会清除 Cookie,关闭弹窗,跳转 jobs 页 */
const handleLogout = async () => {
try {
const res = await logout()
if (res.code === '0') {
ElMessage.success('已退出登录')
}
} catch {
// 即使接口失败也关闭弹窗
}
store.commit('SET_AUTHENTICATED', false)
showLogout.value = false
emit('update:modelValue', false)
router.push('/')
}
</script>
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="side-nav">
<!-- 顶部 Logo -->
<div class="side-nav__header">
<div class="side-nav__avatar">
<span class="side-nav__avatar-icon">👤</span>
</div>
<span class="side-nav__logo-text">Offer派</span>
</div>
<!-- 主导航 -->
<nav class="side-nav__menu">
<a
v-for="item in mainMenus"
:key="item.name"
href="javascript:void(0)"
class="side-nav__item"
:class="{ 'side-nav__item--active': isActive(item.name) }"
@click="handleNav(item)"
>
<img class="side-nav__item-icon" :src="item.iconImg" :alt="item.label" />
<span class="side-nav__item-label">{{ item.label }}</span>
<span v-if="item.badge" class="side-nav__badge">{{ item.badge }}</span>
</a>
</nav>
<!-- 底部菜单 -->
<div class="side-nav__footer">
<div class="side-nav__item" v-for="item in footerMenus" :key="item.label" @click="item.action?.()">
<img class="side-nav__item-icon" :src="item.iconImg" :alt="item.label" />
<span class="side-nav__item-label">{{ item.label }}</span>
<span v-if="item.badge" class="side-nav__badge">{{ item.badge }}</span>
</div>
</div>
<!-- 消息通知弹窗 -->
<Teleport to="body">
<div v-if="showMessageDialog" class="side-nav__dialog-overlay" @click="showMessageDialog = false">
<div class="side-nav__dialog" @click.stop>
<div class="side-nav__dialog-header">
<span>消息通知</span>
<span class="side-nav__dialog-close" @click="showMessageDialog = false"></span>
</div>
<div class="side-nav__dialog-body">
<p>暂无新消息</p>
</div>
</div>
</div>
</Teleport>
<!-- 反馈弹窗 -->
<Teleport to="body">
<div v-if="showFeedbackDialog" class="side-nav__dialog-overlay" @click="showFeedbackDialog = false">
<div class="side-nav__dialog feedback-dialog" @click.stop>
<div class="feedback-dialog__title">反馈与帮助</div>
<div class="feedback-dialog__section">
<div class="feedback-dialog__label">*你想向我们反馈什么问题</div>
<div class="feedback-dialog__options">
<div
v-for="opt in feedbackOptions"
:key="opt"
class="feedback-dialog__option"
:class="{ 'feedback-dialog__option--active': feedbackType === opt }"
@click="feedbackType = opt"
>
{{ opt }}
</div>
</div>
</div>
<div class="feedback-dialog__section">
<div class="feedback-dialog__label">*能否提供更多细节</div>
<textarea
v-model="feedbackDetail"
class="feedback-dialog__textarea"
placeholder="请描述你的使用体验或分享您的想法。描述越具体,我们越能更好地处理您的反馈。"
rows="5"
></textarea>
</div>
<div class="feedback-dialog__actions">
<button class="feedback-dialog__btn feedback-dialog__btn--cancel" @click="showFeedbackDialog = false">取消</button>
<button class="feedback-dialog__btn feedback-dialog__btn--submit" @click="handleFeedbackSubmit">确认提交</button>
</div>
</div>
</div>
</Teleport>
<!-- 设置弹窗 -->
<SettingsDialog v-model="showSettingsDialog" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import SettingsDialog from '@/components/SettingsDialog.vue'
import navJobsIcon from '@/assets/images/nav/nav-jobs-icon.png'
import navResumeIcon from '@/assets/images/nav/nav-resume-icon.png'
import navProfileIcon from '@/assets/images/nav/nav-profile-icon.png'
import navAgentIcon from '@/assets/images/nav/nav-agent-icon.png'
import navMessageIcon from '@/assets/images/nav/nav-message-icon.png'
import navSettingIcon from '@/assets/images/nav/nav-setting-icon.png'
import navFeedbackIcon from '@/assets/images/nav/nav-feedback-icon.png'
const route = useRoute()
const router = useRouter()
const store = useStore()
interface MenuItem {
name: string
path: string
iconImg: string
label: string
badge?: string
}
/**
* 图标映射表
* 后端 meta.icon 返回的是字符串 key,这里映射到实际的图片资源
* 【新增图标时】在这里加一条即可
*/
const iconMap: Record<string, string> = {
'nav-jobs-icon': navJobsIcon,
'nav-resume-icon': navResumeIcon,
'nav-profile-icon': navProfileIcon,
'nav-agent-icon': navAgentIcon,
'nav-setting-icon': navSettingIcon,
}
/**
* 静态菜单 — 不需要登录就能看到的导航项(比如"职位")
* 这些菜单始终显示,不依赖后端返回
*/
const staticMenus: MenuItem[] = [
{ name: 'Jobs', path: '/jobs', iconImg: navJobsIcon, label: '职位' },
]
/**
* 动态菜单 — 从 store.state.dynamicMenus(后端返回的数据)转换而来
* 登录后才会有数据,登出后自动清空
* 过滤掉 position === 'footer' 的项(如"设置"),它们显示在底部区域
*/
const dynamicMenuItems = computed<MenuItem[]>(() => {
return store.state.dynamicMenus
.filter((item: any) => item.meta?.position !== 'footer')
.map((item: any) => ({
name: item.name,
path: item.path,
iconImg: iconMap[item.meta?.icon] || '',
label: item.meta?.label || item.name,
badge: item.meta?.badge,
}))
})
/**
* 最终渲染的主菜单 = 静态菜单 + 动态菜单
*/
const mainMenus = computed<MenuItem[]>(() => {
return [...staticMenus, ...dynamicMenuItems.value]
})
const showMessageDialog = ref(false)
const showFeedbackDialog = ref(false)
const showSettingsDialog = ref(false)
const feedbackType = ref('')
const feedbackDetail = ref('')
const feedbackOptions = ['Bug反馈', '功能建议', '使用体验', '订阅及会员权益相关问题', '其它']
function handleFeedbackSubmit() {
// TODO: 调用反馈提交接口
console.log('反馈类型:', feedbackType.value, '详情:', feedbackDetail.value)
feedbackType.value = ''
feedbackDetail.value = ''
showFeedbackDialog.value = false
}
/**
* 底部"设置"按钮 — 从动态菜单中取 position === 'footer' 的项
* 未登录时使用默认图标和文字
*/
const settingsMenu = computed(() => {
const item = store.state.dynamicMenus.find((m: any) => m.meta?.position === 'footer')
if (item) {
return {
iconImg: iconMap[item.meta?.icon] || navSettingIcon,
label: item.meta?.label || '设置',
path: item.path,
}
}
return { iconImg: navSettingIcon, label: '设置', path: '/settings' }
})
const footerMenus = computed(() => [
{ iconImg: navMessageIcon, label: '消息通知', badge: 'NEW', action: () => { showMessageDialog.value = true } },
{ iconImg: settingsMenu.value.iconImg, label: settingsMenu.value.label, action: () => { handleSettingsNav() } },
{ iconImg: navFeedbackIcon, label: '反馈', action: () => { showFeedbackDialog.value = true } },
])
/**
* 设置弹窗:需要登录,没 token 则弹登录框
*/
function handleSettingsNav() {
if (store.state.isAuthenticated) {
showSettingsDialog.value = true
} else {
store.dispatch('openLogin')
}
}
/**
* 导航点击处理:
* - 静态页面(Jobs)直接跳转
* - 动态页面需要 token,没有则弹登录框
*/
const staticNames = staticMenus.map(m => m.name)
function handleNav(item: MenuItem) {
if (staticNames.includes(item.name) || store.state.isAuthenticated) {
router.push(item.path)
} else {
store.dispatch('openLogin', item.path)
}
}
function isActive(name: string) {
return route.name === name
}
</script>
+286
View File
@@ -0,0 +1,286 @@
<template>
<!-- 行业选择器根容器 -->
<div class="industry-selector" ref="selectorRef">
<!-- 触发按钮显示已选行业名称或默认文字"行业" -->
<div class="industry-selector__trigger" @click="toggleDropdown">
<span class="industry-selector__display" :title="displayText">{{ displayText }}</span>
<svg
class="industry-selector__arrow"
:class="{ 'industry-selector__arrow--open': visible }"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 下拉面板 -->
<div v-if="visible" class="industry-selector__panel" @click.stop>
<!-- 选中区小方块标签展示已选中的行业名称 -->
<div class="industry-selector__selected-area" v-if="selectedItems.length">
<span
class="industry-selector__tag"
v-for="item in selectedItems"
:key="item.id"
>
{{ item.name }}
<!-- 点击关闭图标移除该选中项 -->
<svg class="industry-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<!-- 搜索输入框输入超过 2 个字符时触发模糊匹配 -->
<div class="industry-selector__search-wrap">
<input
v-model="searchText"
class="industry-selector__search"
placeholder="搜索行业"
/>
</div>
<!-- 搜索结果列表有匹配结果时显示 -->
<div v-if="searchText.length >= 2 && searchResults.length" class="industry-selector__search-results">
<div
class="industry-selector__search-item"
v-for="r in searchResults"
:key="r.child.id"
@click="toggleItem(r.child)"
>
<!-- 格式一级行业名 二级行业名 -->
<span>{{ r.parentName }} {{ r.child.name }}</span>
<!-- 已选中项显示勾选图标 -->
<svg v-if="isSelected(r.child.id)" class="industry-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
<!-- 搜索无结果提示 -->
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="industry-selector__search-results">
<div class="industry-selector__search-empty">无匹配结果</div>
</div>
<!-- 分栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="industry-selector__columns">
<!-- 左栏一级行业列表 -->
<div class="industry-selector__col industry-selector__col--left">
<div
class="industry-selector__col-item"
:class="{ 'industry-selector__col-item--active': activeParentId === parent.id }"
v-for="parent in industries"
:key="parent.id"
@click="selectParent(parent.id)"
>
{{ parent.name }}
</div>
</div>
<!-- 右栏当前一级下的二级行业列表 -->
<div class="industry-selector__col industry-selector__col--right">
<template v-if="activeChildren.length">
<div
class="industry-selector__col-item"
:class="{ 'industry-selector__col-item--selected': isSelected(child.id) }"
v-for="child in activeChildren"
:key="child.id"
@click="toggleItem(child)"
>
{{ child.name }}
<!-- 已选中显示勾 -->
<svg v-if="isSelected(child.id)" class="industry-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</template>
<div v-else class="industry-selector__col-empty">请先选择左侧行业分类</div>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="industry-selector__actions">
<button class="industry-selector__btn industry-selector__btn--reset" @click="handleReset">重置</button>
<button class="industry-selector__btn industry-selector__btn--confirm" @click="handleConfirm">确认</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useStore } from 'vuex'
import type { IndustryChild, IndustryItem } from '@/api/common'
// ==================== 事件与属性定义 ====================
/** 向父组件发送已选行业 ID 数组(integer[] */
const emit = defineEmits<{
(e: 'update:industryIds', ids: number[]): void
}>()
/** 接收父组件传入的已选行业 ID 数组 */
const props = withDefaults(
defineProps<{
industryIds?: number[]
/** 最多可选数量 */
maxSelect?: number
}>(),
{
maxSelect: 3,
}
)
// ==================== 基础引用 ====================
/** Vuex store 实例 */
const store = useStore()
/** 组件根元素引用,用于点击外部关闭判断 */
const selectorRef = ref<HTMLElement | null>(null)
// ==================== 响应式状态 ====================
/** 下拉面板是否可见 */
const visible = ref(false)
/** 搜索关键词 */
const searchText = ref('')
/** 当前选中的一级行业 ID(左栏高亮项) */
const activeParentId = ref<string>('')
/** 已选中的末级(二级)行业列表(内部临时状态,点确认后才同步给父组件) */
const selectedItems = ref<IndustryChild[]>([])
// ==================== 计算属性 ====================
/** 从全局 store 获取行业树分类数据 */
const industries = computed<IndustryItem[]>(() => store.state.industries)
/** 已选中行业 ID 集合,用于快速判断某项是否选中 */
const selectedIdSet = computed(() => new Set(selectedItems.value.map(i => i.id)))
/** 触发按钮显示文字:无选中显示"行业",有选中则用逗号拼接名称 */
const displayText = computed(() => {
if (!selectedItems.value.length) return '行业'
return selectedItems.value.map(i => i.name).join('')
})
/** 当前左栏选中的一级行业对应的二级子项列表 */
const activeChildren = computed<IndustryChild[]>(() => {
if (!activeParentId.value) return []
const parent = industries.value.find(p => p.id === activeParentId.value)
return parent ? parent.children : []
})
/** 搜索结果:对末级行业 name 做模糊匹配,返回带一级父名称的结果列表 */
const searchResults = computed(() => {
if (searchText.value.length < 2) return []
const keyword = searchText.value.toLowerCase()
const results: { parentName: string; child: IndustryChild }[] = []
for (const parent of industries.value) {
for (const child of parent.children) {
if (child.name.toLowerCase().includes(keyword)) {
results.push({ parentName: parent.name, child })
}
}
}
return results
})
// ==================== 方法 ====================
/** 判断指定行业 ID 是否已被选中 */
function isSelected(id: string) {
return selectedIdSet.value.has(id)
}
/** 点击左栏一级行业,切换右栏显示对应二级列表 */
function selectParent(id: string) {
activeParentId.value = id
}
/** 切换某个末级行业的选中/取消状态,超过上限时提示 */
function toggleItem(child: IndustryChild) {
const idx = selectedItems.value.findIndex(i => i.id === child.id)
if (idx >= 0) {
selectedItems.value.splice(idx, 1)
} else {
if (selectedItems.value.length >= props.maxSelect) {
ElMessage.warning(`最多只能选择${props.maxSelect}个行业`)
return
}
selectedItems.value.push({ ...child })
}
}
/** 从选中区移除指定行业 */
function removeItem(item: IndustryChild) {
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
}
/** 重置:清空所有已选项 */
function handleReset() {
selectedItems.value = []
}
/** 确认:将当前选中结果发送给父组件并关闭面板 */
function handleConfirm() {
emitIds()
visible.value = false
}
/** 向父组件发送当前选中的行业 ID 数组(转为整数) */
function emitIds() {
emit('update:industryIds', selectedItems.value.map(i => Number(i.id)))
}
/** 切换下拉面板的显示/隐藏,打开时清空搜索词并默认选中第一个一级行业 */
function toggleDropdown() {
visible.value = !visible.value
if (visible.value) {
searchText.value = ''
// 默认选中第一个一级行业
if (industries.value.length && !activeParentId.value) {
activeParentId.value = industries.value[0].id
}
}
}
/** 点击组件外部时关闭下拉面板 */
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
visible.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
// ==================== 监听器 ====================
/** 同步外部传入的 industryIds 到内部选中状态 */
watch(
() => props.industryIds,
(ids) => {
if (!ids || !industries.value.length) return
const allChildren: IndustryChild[] = []
for (const p of industries.value) {
allChildren.push(...p.children)
}
selectedItems.value = ids
.map(id => allChildren.find(c => c.id === String(id)))
.filter(Boolean) as IndustryChild[]
},
{ immediate: true }
)
</script>
@@ -0,0 +1,319 @@
<template>
<!-- 岗位选择器根容器 -->
<div class="job-category-selector" ref="selectorRef">
<!-- 触发按钮显示已选岗位名称或默认文字"岗位" -->
<div class="job-category-selector__trigger" @click="toggleDropdown">
<span class="job-category-selector__display" :title="displayText">{{ displayText }}</span>
<svg
class="job-category-selector__arrow"
:class="{ 'job-category-selector__arrow--open': visible }"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 下拉面板 -->
<div v-if="visible" class="job-category-selector__panel" @click.stop>
<!-- 选中区小方块标签展示已选中的岗位名称 -->
<div class="job-category-selector__selected-area" v-if="selectedItems.length">
<span
class="job-category-selector__tag"
v-for="item in selectedItems"
:key="item.id"
>
{{ item.name }}
<!-- 点击关闭图标移除该选中项 -->
<svg class="job-category-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<!-- 搜索输入框输入超过 2 个字符时触发模糊匹配 -->
<div class="job-category-selector__search-wrap">
<input
v-model="searchText"
class="job-category-selector__search"
placeholder="搜索岗位"
/>
</div>
<!-- 搜索结果列表 -->
<div v-if="searchText.length >= 2 && searchResults.length" class="job-category-selector__search-results">
<div
class="job-category-selector__search-item"
v-for="r in searchResults"
:key="r.leaf.id"
@click="toggleItem(r.leaf)"
>
<!-- 格式一级 二级 三级 -->
<span>{{ r.level1Name }} {{ r.level2Name }} {{ r.leaf.name }}</span>
<svg v-if="isSelected(r.leaf.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
<!-- 搜索无结果提示 -->
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="job-category-selector__search-results">
<div class="job-category-selector__search-empty">无匹配结果</div>
</div>
<!-- 三栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="job-category-selector__columns">
<!-- 左栏一级岗位分类 -->
<div class="job-category-selector__col job-category-selector__col--left">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--active': activeLevel1Id === item.id }"
v-for="item in categories"
:key="item.id"
@click="selectLevel1(item.id)"
>
{{ item.name }}
</div>
</div>
<!-- 中栏二级岗位分类 -->
<div class="job-category-selector__col job-category-selector__col--mid">
<template v-if="level2List.length">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--active': activeLevel2Id === item.id }"
v-for="item in level2List"
:key="item.id"
@click="selectLevel2(item.id)"
>
{{ item.name }}
</div>
</template>
<div v-else class="job-category-selector__col-empty">请先选择左侧分类</div>
</div>
<!-- 右栏三级岗位末级可选中 -->
<div class="job-category-selector__col job-category-selector__col--right">
<template v-if="level3List.length">
<div
class="job-category-selector__col-item"
:class="{ 'job-category-selector__col-item--selected': isSelected(item.id) }"
v-for="item in level3List"
:key="item.id"
@click="toggleItem(item)"
>
{{ item.name }}
<svg v-if="isSelected(item.id)" class="job-category-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</template>
<div v-else class="job-category-selector__col-empty">请先选择中间分类</div>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="job-category-selector__actions">
<button class="job-category-selector__btn job-category-selector__btn--reset" @click="handleReset">重置</button>
<button class="job-category-selector__btn job-category-selector__btn--confirm" @click="handleConfirm">确认</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useStore } from 'vuex'
import type { JobCategoryItem, JobCategoryChild, JobCategoryLeaf } from '@/api/common'
// ==================== 事件与属性定义 ====================
/** 向父组件发送已选岗位 ID 数组(integer[] */
const emit = defineEmits<{
(e: 'update:categoryIds', ids: number[]): void
}>()
/** 接收父组件传入的已选岗位 ID 数组 */
const props = withDefaults(
defineProps<{
categoryIds?: number[]
/** 最多可选数量 */
maxSelect?: number
}>(),
{
maxSelect: 3,
}
)
// ==================== 基础引用 ====================
/** Vuex store 实例 */
const store = useStore()
/** 组件根元素引用,用于点击外部关闭判断 */
const selectorRef = ref<HTMLElement | null>(null)
// ==================== 响应式状态 ====================
/** 下拉面板是否可见 */
const visible = ref(false)
/** 搜索关键词 */
const searchText = ref('')
/** 当前选中的一级分类 ID(左栏高亮项) */
const activeLevel1Id = ref<string>('')
/** 当前选中的二级分类 ID(中栏高亮项) */
const activeLevel2Id = ref<string>('')
/** 已选中的末级(三级)岗位列表(内部临时状态,点确认后才同步给父组件) */
const selectedItems = ref<JobCategoryLeaf[]>([])
// ==================== 计算属性 ====================
/** 从全局 store 获取岗位树分类数据 */
const categories = computed<JobCategoryItem[]>(() => store.state.jobCategories)
/** 已选中岗位 ID 集合,用于快速判断某项是否选中 */
const selectedIdSet = computed(() => new Set(selectedItems.value.map(i => i.id)))
/** 触发按钮显示文字:无选中显示"岗位",有选中则用逗号拼接名称 */
const displayText = computed(() => {
if (!selectedItems.value.length) return '岗位'
return selectedItems.value.map(i => i.name).join('')
})
/** 当前一级分类下的二级列表 */
const level2List = computed<JobCategoryChild[]>(() => {
if (!activeLevel1Id.value) return []
const parent = categories.value.find(p => p.id === activeLevel1Id.value)
return parent ? parent.children : []
})
/** 当前二级分类下的三级列表 */
const level3List = computed<JobCategoryLeaf[]>(() => {
if (!activeLevel2Id.value) return []
const mid = level2List.value.find(m => m.id === activeLevel2Id.value)
return mid ? mid.children : []
})
/** 搜索结果:对三级岗位 name 做模糊匹配,返回带一级二级名称的完整路径 */
const searchResults = computed(() => {
if (searchText.value.length < 2) return []
const keyword = searchText.value.toLowerCase()
const results: { level1Name: string; level2Name: string; leaf: JobCategoryLeaf }[] = []
for (const l1 of categories.value) {
for (const l2 of l1.children) {
for (const l3 of l2.children) {
if (l3.name.toLowerCase().includes(keyword)) {
results.push({ level1Name: l1.name, level2Name: l2.name, leaf: l3 })
}
}
}
}
return results
})
// ==================== 方法 ====================
/** 判断指定岗位 ID 是否已被选中 */
function isSelected(id: string) {
return selectedIdSet.value.has(id)
}
/** 点击左栏一级分类,切换中栏并清空右栏 */
function selectLevel1(id: string) {
activeLevel1Id.value = id
activeLevel2Id.value = ''
}
/** 点击中栏二级分类,切换右栏 */
function selectLevel2(id: string) {
activeLevel2Id.value = id
}
/** 切换某个末级岗位的选中/取消状态,超过上限时提示 */
function toggleItem(leaf: JobCategoryLeaf) {
const idx = selectedItems.value.findIndex(i => i.id === leaf.id)
if (idx >= 0) {
selectedItems.value.splice(idx, 1)
} else {
if (selectedItems.value.length >= props.maxSelect) {
ElMessage.warning(`最多只能选择${props.maxSelect}个岗位`)
return
}
selectedItems.value.push({ ...leaf })
}
}
/** 从选中区移除指定岗位 */
function removeItem(item: JobCategoryLeaf) {
selectedItems.value = selectedItems.value.filter(i => i.id !== item.id)
}
/** 重置:清空所有已选项 */
function handleReset() {
selectedItems.value = []
}
/** 确认:将当前选中结果发送给父组件并关闭面板 */
function handleConfirm() {
emitIds()
visible.value = false
}
/** 向父组件发送当前选中的岗位 ID 数组(转为整数) */
function emitIds() {
emit('update:categoryIds', selectedItems.value.map(i => Number(i.id)))
}
/** 切换下拉面板的显示/隐藏,打开时清空搜索词并默认选中第一个一级分类 */
function toggleDropdown() {
visible.value = !visible.value
if (visible.value) {
searchText.value = ''
if (categories.value.length && !activeLevel1Id.value) {
activeLevel1Id.value = categories.value[0].id
}
}
}
/** 点击组件外部时关闭下拉面板 */
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
visible.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
// ==================== 监听器 ====================
/** 同步外部传入的 categoryIds 到内部选中状态 */
watch(
() => props.categoryIds,
(ids) => {
if (!ids || !categories.value.length) return
// 收集所有三级叶子节点
const allLeaves: JobCategoryLeaf[] = []
for (const l1 of categories.value) {
for (const l2 of l1.children) {
allLeaves.push(...l2.children)
}
}
selectedItems.value = ids
.map(id => allLeaves.find(l => l.id === String(id)))
.filter(Boolean) as JobCategoryLeaf[]
},
{ immediate: true }
)
</script>
+386
View File
@@ -0,0 +1,386 @@
<template>
<!-- 地区选择器根容器 -->
<div class="region-selector" ref="selectorRef">
<!-- 触发按钮显示已选地区名称或默认文字"城市" -->
<div class="region-selector__trigger" :style="triggerStyle" @click="toggleDropdown">
<span class="region-selector__display" :title="displayText">{{ displayText }}</span>
<svg
class="region-selector__arrow"
:class="{ 'region-selector__arrow--open': visible }"
viewBox="0 0 12 12"
fill="none"
>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 下拉面板level=2 时两栏较窄level=3 时三栏较宽 -->
<div
v-if="visible"
class="region-selector__panel"
:class="{ 'region-selector__panel--two-col': level === 2 }"
@click.stop
>
<!-- 选中区小方块标签展示已选中的地区名称 -->
<div class="region-selector__selected-area" v-if="selectedItems.length">
<span
class="region-selector__tag"
v-for="item in selectedItems"
:key="item.code"
>
{{ item.name }}
<svg class="region-selector__tag-close" viewBox="0 0 12 12" @click="removeItem(item)">
<path d="M3 3L9 9M9 3L3 9" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</div>
<!-- 搜索输入框 -->
<div class="region-selector__search-wrap">
<input
v-model="searchText"
class="region-selector__search"
placeholder="搜索地区"
/>
</div>
<!-- 搜索结果列表 -->
<div v-if="searchText.length >= 2 && searchResults.length" class="region-selector__search-results">
<div
class="region-selector__search-item"
v-for="r in searchResults"
:key="r.item.code"
@click="toggleItem(r.item)"
>
<span>{{ r.path }}</span>
<svg v-if="isSelected(r.item.code)" class="region-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</div>
<div v-else-if="searchText.length >= 2 && !searchResults.length" class="region-selector__search-results">
<div class="region-selector__search-empty">无匹配结果</div>
</div>
<!-- 分栏联动选择区搜索关键词不足 2 字符时显示 -->
<div v-if="searchText.length < 2" class="region-selector__columns">
<!-- 左栏省级列表 -->
<div class="region-selector__col region-selector__col--left">
<div
class="region-selector__col-item"
:class="{ 'region-selector__col-item--active': activeProvinceCode === item.code }"
v-for="item in regions"
:key="item.code"
@click="selectProvince(item.code)"
>
{{ item.name }}
</div>
</div>
<!-- 中栏市级列表level=2 时为末级可选中level=3 时为中间栏 -->
<div class="region-selector__col region-selector__col--mid">
<template v-if="cityList.length">
<div
class="region-selector__col-item"
:class="cityItemClass(item)"
v-for="item in cityList"
:key="item.code"
@click="onCityClick(item)"
>
{{ item.name }}
<!-- level=2 时显示选中勾 -->
<svg v-if="level === 2 && isSelected(item.code)" class="region-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</template>
<div v-else class="region-selector__col-empty">请先选择左侧省份</div>
</div>
<!-- 右栏区县级列表 level=3 时显示 -->
<div v-if="level === 3" class="region-selector__col region-selector__col--right">
<template v-if="districtList.length">
<div
class="region-selector__col-item"
:class="{ 'region-selector__col-item--selected': isSelected(item.code) }"
v-for="item in districtList"
:key="item.code"
@click="toggleItem(item)"
>
{{ item.name }}
<svg v-if="isSelected(item.code)" class="region-selector__check" viewBox="0 0 12 12">
<path d="M2 6L5 9L10 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
</template>
<div v-else class="region-selector__col-empty">请先选择中间城市</div>
</div>
</div>
<!-- 底部操作按钮 -->
<div class="region-selector__actions">
<button class="region-selector__btn region-selector__btn--reset" @click="handleReset">重置</button>
<button class="region-selector__btn region-selector__btn--confirm" @click="handleConfirm">确认</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useStore } from 'vuex'
import type { RegionItem, RegionChild, RegionLeaf } from '@/api/common'
// ==================== 选中项的统一类型(code + name ====================
/** 选中项统一结构 */
interface SelectedRegion {
code: string
name: string
}
// ==================== 事件与属性定义 ====================
/** 向父组件发送已选地区编码数组(string[]) */
const emit = defineEmits<{
(e: 'update:regionCodes', codes: string[]): void
}>()
/** 接收父组件传入的属性 */
const props = withDefaults(
defineProps<{
/** 已选地区编码数组 */
regionCodes?: string[]
/** 选择级别:2=选到市级,3=选到区县级 */
level?: 2 | 3
/** 最多可选数量 */
maxSelect?: number
/** 父组件传入的触发按钮自定义样式,用于在不同场景下覆盖默认外观 */
triggerStyle?: Record<string, string>
}>(),
{
level: 2,
maxSelect: 3,
}
)
// ==================== 基础引用 ====================
/** Vuex store 实例 */
const store = useStore()
/** 组件根元素引用,用于点击外部关闭判断 */
const selectorRef = ref<HTMLElement | null>(null)
// ==================== 响应式状态 ====================
/** 下拉面板是否可见 */
const visible = ref(false)
/** 搜索关键词 */
const searchText = ref('')
/** 当前选中的省级编码(左栏高亮项) */
const activeProvinceCode = ref<string>('')
/** 当前选中的市级编码(中栏高亮项,仅 level=3 时使用) */
const activeCityCode = ref<string>('')
/** 已选中的地区列表 */
const selectedItems = ref<SelectedRegion[]>([])
// ==================== 计算属性 ====================
/** 从全局 store 获取地区树分类数据 */
const regions = computed<RegionItem[]>(() => store.state.regions)
/** 已选中编码集合,用于快速判断 */
const selectedCodeSet = computed(() => new Set(selectedItems.value.map(i => i.code)))
/** 触发按钮显示文字 */
const displayText = computed(() => {
if (!selectedItems.value.length) return '城市'
return selectedItems.value.map(i => i.name).join('')
})
/** 当前省级下的市级列表 */
const cityList = computed<RegionChild[]>(() => {
if (!activeProvinceCode.value) return []
const province = regions.value.find(p => p.code === activeProvinceCode.value)
return province ? province.children : []
})
/** 当前市级下的区县级列表(仅 level=3 时有意义) */
const districtList = computed<RegionLeaf[]>(() => {
if (!activeCityCode.value) return []
const city = cityList.value.find(c => c.code === activeCityCode.value)
return city?.children || []
})
/** 搜索结果:模糊匹配目标级别的 name,返回带路径的结果 */
const searchResults = computed(() => {
if (searchText.value.length < 2) return []
const keyword = searchText.value.toLowerCase()
const results: { path: string; item: SelectedRegion }[] = []
for (const province of regions.value) {
if (props.level === 2) {
// 选到市级:匹配市级 name
for (const city of province.children) {
if (city.name.toLowerCase().includes(keyword)) {
results.push({
path: `${province.name}${city.name}`,
item: { code: city.code, name: city.name },
})
}
}
} else {
// 选到区县级:匹配区县级 name
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
if (district.name.toLowerCase().includes(keyword)) {
results.push({
path: `${province.name}${city.name}${district.name}`,
item: { code: district.code, name: district.name },
})
}
}
}
}
}
}
return results
})
// ==================== 方法 ====================
/** 判断指定编码是否已被选中 */
function isSelected(code: string) {
return selectedCodeSet.value.has(code)
}
/** 点击左栏省级,切换中栏并清空右栏 */
function selectProvince(code: string) {
activeProvinceCode.value = code
activeCityCode.value = ''
}
/** 点击中栏市级:level=2 时直接选中/取消,level=3 时切换右栏 */
function onCityClick(city: RegionChild) {
if (props.level === 2) {
toggleItem({ code: city.code, name: city.name })
} else {
activeCityCode.value = city.code
}
}
/** 中栏行的 classlevel=2 时用 selected 样式,level=3 时用 active 样式 */
function cityItemClass(city: RegionChild) {
if (props.level === 2) {
return { 'region-selector__col-item--selected': isSelected(city.code) }
}
return { 'region-selector__col-item--active': activeCityCode.value === city.code }
}
/** 切换某个地区的选中/取消状态 */
function toggleItem(item: SelectedRegion) {
const idx = selectedItems.value.findIndex(i => i.code === item.code)
if (idx >= 0) {
selectedItems.value.splice(idx, 1)
} else {
if (selectedItems.value.length >= props.maxSelect) {
ElMessage.warning(`最多只能选择${props.maxSelect}个地区`)
return
}
selectedItems.value.push({ ...item })
}
}
/** 从选中区移除指定地区 */
function removeItem(item: SelectedRegion) {
selectedItems.value = selectedItems.value.filter(i => i.code !== item.code)
}
/** 重置:清空所有已选项并关闭面板 */
function handleReset() {
selectedItems.value = []
emitCodes()
setTimeout(() => {
visible.value = false
}, 0)
}
/** 确认:将当前选中结果发送给父组件并关闭面板 */
function handleConfirm() {
emitCodes()
// 延迟关闭面板,避免 v-if 移除面板后同一个 click 事件落到 trigger 上触发 toggleDropdown
setTimeout(() => {
visible.value = false
}, 0)
}
/** 向父组件发送当前选中的地区编码数组 */
function emitCodes() {
emit('update:regionCodes', selectedItems.value.map(i => i.code))
}
/** 切换下拉面板的显示/隐藏 */
function toggleDropdown() {
visible.value = !visible.value
if (visible.value) {
searchText.value = ''
if (regions.value.length && !activeProvinceCode.value) {
activeProvinceCode.value = regions.value[0].code
}
}
}
/** 点击组件外部时关闭下拉面板 */
function onClickOutside(e: MouseEvent) {
if (selectorRef.value && !selectorRef.value.contains(e.target as Node)) {
visible.value = false
}
}
// ==================== 生命周期 ====================
onMounted(() => {
document.addEventListener('click', onClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside)
})
// ==================== 监听器 ====================
/** 同步外部传入的 regionCodes 到内部选中状态 */
watch(
() => props.regionCodes,
(codes) => {
if (!codes || !regions.value.length) return
// 根据 level 收集对应级别的所有节点
const allNodes: SelectedRegion[] = []
for (const province of regions.value) {
if (props.level === 2) {
for (const city of province.children) {
allNodes.push({ code: city.code, name: city.name })
}
} else {
for (const city of province.children) {
if (city.children) {
for (const district of city.children) {
allNodes.push({ code: district.code, name: district.name })
}
}
}
}
}
selectedItems.value = codes
.map(code => allNodes.find(n => n.code === code))
.filter(Boolean) as SelectedRegion[]
},
{ immediate: true }
)
</script>
+10
View File
@@ -0,0 +1,10 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './stores'
import '@/assets/styles/index.scss'
createApp(App)
.use(router)
.use(store)
.mount('#app')
+41
View File
@@ -0,0 +1,41 @@
import type { RouteRecordRaw } from 'vue-router'
import type { MenuItemRaw } from '@/api/menu'
/**
* 组件映射表
*
* key = 后端返回的 component 字符串
* value = 对应的懒加载组件
*
* 【新增页面时】在这里加一条映射即可,后端返回对应的 key 就能自动注册路由
*/
const componentMap: Record<string, () => Promise<any>> = {
'Agent': () => import('@/views/Agent.vue'),
'Profile': () => import('@/views/Profile.vue'),
'Resume': () => import('@/views/Resume.vue'),
}
/**
* 把后端返回的菜单数据转换成 vue-router 能识别的 RouteRecordRaw
*
* 如果后端返回了一个 component 字符串在映射表里找不到,会跳过该条路由并打印警告
*/
export function buildDynamicRoutes(menus: MenuItemRaw[]): RouteRecordRaw[] {
const routes: RouteRecordRaw[] = []
for (const item of menus) {
const comp = componentMap[item.component]
if (!comp) {
console.warn(`[dynamicRoutes] 组件映射表中找不到 "${item.component}",已跳过`)
continue
}
routes.push({
path: item.path,
name: item.name,
component: comp,
meta: { ...item.meta },
})
}
return routes
}
+58
View File
@@ -0,0 +1,58 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import store from '@/stores'
/**
* 静态路由 — 不需要权限,应用启动就注册
*/
const staticRoutes: RouteRecordRaw[] = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/jobs', name: 'Jobs', component: () => import('@/views/Jobs.vue') },
{
path: '/resume/:id',
name: 'ResumeDetail',
component: () => import('@/views/ResumeDetail.vue'),
meta: { requiresAuth: true },
},
{
path: '/jobs/:id',
name: 'JobDetail',
component: () => import('@/views/JobDetail.vue'),
},
]
const router = createRouter({
history: createWebHistory(),
routes: staticRoutes,
})
/**
* 全局前置守卫 — 核心逻辑:
*
* 1. 动态路由未加载 → 先拉取并注册,再重新进入当前路由
* 2. 需要鉴权的路由没有 token → 回首页
* 3. 其他情况 → 直接放行
*/
router.beforeEach(async (to, _from, next) => {
// 动态路由只需加载一次,与登录状态无关
if (!store.state.routesLoaded) {
try {
await store.dispatch('loadDynamicRoutes', router)
next({ ...to, replace: true })
} catch (err) {
console.error('[router] 加载动态路由失败', err)
next({ name: 'Home' })
}
return
}
// 需要鉴权的路由,未登录则回首页
if (to.meta?.requiresAuth && !store.state.isAuthenticated) {
next({ name: 'Home' })
return
}
next()
})
export default router
+210
View File
@@ -0,0 +1,210 @@
import { createStore } from 'vuex'
import type { Router } from 'vue-router'
import { fetchUserRoutes } from '@/api/menu'
import type { MenuItemRaw } from '@/api/menu'
import { buildDynamicRoutes } from '@/router/dynamicRoutes'
import { fetchIndustryTree, fetchJobCategoryTree, fetchRegionTree } from '@/api/common'
import type { IndustryItem, JobCategoryItem, RegionItem } from '@/api/common'
/** 职位列表页缓存数据(从详情页返回时恢复用) */
export interface JobListCache {
/** 缓存的职位列表 */
list: any[]
/** 当前已加载到的页码 */
pageNum: number
/** 总记录数 */
total: number
/** 列表滚动位置 */
scrollTop: number
}
export interface RootState {
appName: string
showLogin: boolean
loginRedirect: string
/**
* 登录状态 — 登录成功设为 true,退出成功设为 false
* 因为后端 Cookie 是 HttpOnly 的,前端 JS 无法读取,所以用内存状态管理
*/
isAuthenticated: boolean
/**
* 后端返回的原始菜单数据,SideNav 用它来渲染导航
*/
dynamicMenus: MenuItemRaw[]
/**
* 标记动态路由是否已经加载过,避免重复请求
* 每次登出时重置为 false
*/
routesLoaded: boolean
/**
* 记录已注册的动态路由 name,登出时用来逐个 removeRoute
*/
dynamicRouteNames: string[]
/**
* 行业树分类数据(一级二级嵌套)
* 由 /public/industries/tree 接口返回,进首页和 Jobs 页时刷新
* 其他页面/组件可直接从 store 读取
*/
industries: IndustryItem[]
/**
* 岗位树分类数据(一级二级三级嵌套)
* 由 /public/job-categories/tree 接口返回,进首页和 Jobs 页时刷新
* 其他页面/组件可直接从 store 读取
*/
jobCategories: JobCategoryItem[]
/**
* 地区树分类数据(省市区三级嵌套)
* 由 /public/regions/tree 接口返回,进首页和 Jobs 页时刷新
* 其他页面/组件可直接从 store 读取
*/
regions: RegionItem[]
/**
* 职位列表页缓存 — 从详情页返回时恢复列表和滚动位置
* 为 null 表示没有缓存,需要重新请求
*/
jobListCache: JobListCache | null
}
export default createStore<RootState>({
state: {
appName: 'JobAssistant',
showLogin: false,
loginRedirect: '',
isAuthenticated: sessionStorage.getItem('isAuthenticated') === 'true',
dynamicMenus: [],
routesLoaded: false,
dynamicRouteNames: [],
industries: [],
jobCategories: [],
regions: [],
jobListCache: null,
},
getters: {
getAppName: (state) => state.appName,
},
mutations: {
SET_APP_NAME(state, name: string) {
state.appName = name
},
SET_SHOW_LOGIN(state, show: boolean) {
state.showLogin = show
},
SET_LOGIN_REDIRECT(state, path: string) {
state.loginRedirect = path
},
SET_AUTHENTICATED(state, val: boolean) {
state.isAuthenticated = val
if (val) {
sessionStorage.setItem('isAuthenticated', 'true')
} else {
sessionStorage.removeItem('isAuthenticated')
}
},
SET_DYNAMIC_MENUS(state, menus: MenuItemRaw[]) {
state.dynamicMenus = menus
},
SET_ROUTES_LOADED(state, loaded: boolean) {
state.routesLoaded = loaded
},
SET_DYNAMIC_ROUTE_NAMES(state, names: string[]) {
state.dynamicRouteNames = names
},
SET_INDUSTRIES(state, data: IndustryItem[]) {
state.industries = data
},
SET_JOB_CATEGORIES(state, data: JobCategoryItem[]) {
state.jobCategories = data
},
SET_REGIONS(state, data: RegionItem[]) {
state.regions = data
},
SET_JOB_LIST_CACHE(state, cache: JobListCache | null) {
state.jobListCache = cache
},
},
actions: {
updateAppName({ commit }, name: string) {
commit('SET_APP_NAME', name)
},
openLogin({ commit }, redirect = '') {
commit('SET_LOGIN_REDIRECT', redirect)
commit('SET_SHOW_LOGIN', true)
},
closeLogin({ commit }) {
commit('SET_SHOW_LOGIN', false)
commit('SET_LOGIN_REDIRECT', '')
},
/**
* 核心 action:从后端(或 mock)拉取菜单,转成路由并注册
*
* @param router — vue-router 实例,由路由守卫调用时传入
*
* 流程:
* 1. 调 fetchUserRoutes 拿到后端菜单数据
* 2. 存到 state.dynamicMenus(给 SideNav 渲染用)
* 3. 用 buildDynamicRoutes 转成 RouteRecordRaw
* 4. 逐条 router.addRoute 注册
* 5. 记录已注册的路由名,标记 routesLoaded = true
*/
async loadDynamicRoutes({ commit }, router: Router) {
const menus = await fetchUserRoutes()
commit('SET_DYNAMIC_MENUS', menus)
const routes = buildDynamicRoutes(menus)
const names: string[] = []
for (const route of routes) {
router.addRoute(route)
if (route.name) names.push(route.name as string)
}
commit('SET_DYNAMIC_ROUTE_NAMES', names)
commit('SET_ROUTES_LOADED', true)
},
/**
* 登出:重置状态(不再操作 localStorage,登录状态由 Cookie 管理)
* 注意:动态路由不清除,与登录状态无关
*/
logout({ commit }) {
commit('SET_AUTHENTICATED', false)
commit('SET_SHOW_LOGIN', false)
commit('SET_LOGIN_REDIRECT', '')
},
/**
* 加载工具类公共数据(行业分类、岗位分类等)
* 进首页和 Jobs 页时调用,刷新全局数据
*/
async loadCommonData({ commit }) {
try {
const [industryRes, jobCategoryRes, regionRes] = await Promise.all([
fetchIndustryTree(),
fetchJobCategoryTree(),
fetchRegionTree(),
])
if (industryRes.code === '0' && industryRes.data) {
commit('SET_INDUSTRIES', industryRes.data)
}
if (jobCategoryRes.code === '0' && jobCategoryRes.data) {
commit('SET_JOB_CATEGORIES', jobCategoryRes.data)
}
if (regionRes.code === '0' && regionRes.data) {
commit('SET_REGIONS', regionRes.data)
}
} catch (err) {
console.error('[store] 加载公共分类数据失败', err)
}
},
},
modules: {},
})
+19
View File
@@ -0,0 +1,19 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
#app {
margin: 0;
padding: 0;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+4
View File
@@ -0,0 +1,4 @@
declare module '*.png' {
const src: string
export default src
}
+5
View File
@@ -0,0 +1,5 @@
export interface ApiResponse<T> {
code: number
message: string
data: T
}
+14
View File
@@ -0,0 +1,14 @@
/**
* 从 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')
}

Some files were not shown because too many files have changed in this diff Show More