Recruitment_site/offer_frontend/src/views/portal/HomeView.vue

700 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="two-panel">
<!-- 左栏公司列表 -->
<aside class="panel-left">
<div class="left-header">
<span class="left-title">全部企业</span>
<span class="left-count">{{ totalOrgs }}</span>
</div>
<div class="left-body">
<template v-if="orgsLoading">
<div v-for="i in 4" :key="i" class="skeleton-row" />
</template>
<template v-else-if="orgsError">
<div class="state-tip">加载失败,请刷新重试</div>
</template>
<template v-else-if="orgs.length === 0">
<div class="state-tip">暂无公司数据</div>
</template>
<template v-else>
<template v-for="org in orgs" :key="org.id">
<div
class="org-row"
:class="{ active: selectedOrg?.id === org.id }"
@click="selectOrg(org)"
>
<div class="org-avatar parent-avatar">
<img v-if="org.logo" :src="org.logo" :alt="org.name" />
<span v-else>{{ org.name[0] }}</span>
</div>
<div class="org-meta">
<span class="org-name">{{ org.name }}</span>
<span class="org-stat">在招 <em>{{ org.job_count }}</em> 个岗位</span>
</div>
</div>
<div
v-for="child in org.children"
:key="child.id"
class="org-row org-child"
:class="{ active: selectedOrg?.id === child.id }"
@click="selectOrg(child)"
>
<div class="child-indent">
<span class="child-line"></span>
</div>
<div class="org-avatar child-avatar">
<img v-if="child.logo" :src="child.logo" :alt="child.name" />
<span v-else>{{ child.name[0] }}</span>
</div>
<div class="org-meta">
<span class="org-name child-name">{{ child.name }}</span>
<span class="org-stat">在招 <em>{{ child.job_count }}</em> 个岗位</span>
</div>
</div>
</template>
</template>
</div>
</aside>
<!-- ── 右栏 ── -->
<section class="panel-right">
<!-- ── 选中公司:公司介绍 + 学历分组 ── -->
<template v-if="selectedOrg">
<div class="right-scroll">
<!-- 公司介绍卡片 -->
<div class="intro-section">
<div class="section-title">公司介绍</div>
<div class="intro-card">
<div class="intro-photo">
<img
:src="selectedOrg.logo || '/images/company-building.png'"
:alt="selectedOrg.name"
@error="(e) => e.target.src = '/images/company-building.png'"
/>
</div>
<div class="intro-info">
<div class="intro-name">{{ selectedOrg.name }}</div>
<div class="intro-desc">{{ selectedOrg.description || '暂无公司简介。' }}</div>
</div>
</div>
</div>
<!-- 学历分组岗位 -->
<div class="edu-sections" v-loading="jobsLoading">
<template v-if="!jobsLoading">
<div
v-for="(edu, index) in eduLevels"
:key="edu.key"
>
<div class="edu-row">
<div class="edu-label-wrap">
<div class="edu-label">{{ edu.label }}</div>
<div class="edu-total-badge" v-if="eduGroups[edu.key].length > 0">共计 {{ eduTotals[edu.key] }} 人</div>
</div>
<div class="job-chips-area">
<template v-if="eduGroups[edu.key].length > 0">
<div
v-for="job in eduGroups[edu.key]"
:key="job.id"
class="job-chip"
@click="goToJobDetail(job)"
>
<span class="chip-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
</svg>
</span>
<span class="chip-name">{{ job.title }}</span>
<span class="chip-count">(招{{ job.headcount }}人)</span>
</div>
</template>
<div v-else class="chips-empty">暂无{{ edu.label }}岗位</div>
</div>
</div>
<div class="edu-divider" v-if="index < eduLevels.length - 1" />
</div>
</template>
</div>
</div>
</template>
<!-- ── 未选公司:搜索 + 职位列表 ── -->
<template v-else>
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-row">
<div class="search-field">
<el-input
v-model="filters.search"
placeholder="职位名称 / 关键词"
clearable
:prefix-icon="SearchIcon"
@keyup.enter="doSearch"
@clear="doSearch"
/>
</div>
<div class="search-field">
<el-input
v-model="filters.location"
placeholder="城市"
clearable
@keyup.enter="doSearch"
@clear="doSearch"
/>
</div>
<div class="search-field">
<el-input
v-model="filters.category"
placeholder="职位类别"
clearable
@keyup.enter="doSearch"
@clear="doSearch"
/>
</div>
<div class="search-field">
<el-select
v-model="filters.education"
placeholder="学历要求"
clearable
@change="doSearch"
>
<el-option value="博士" label="博士" />
<el-option value="硕士" label="硕士" />
<el-option value="本科及以下" label="本科及以下" />
</el-select>
</div>
<el-button type="primary" class="search-btn" @click="doSearch">
搜索
</el-button>
</div>
</div>
<!-- 结果头 -->
<div class="right-header">
<span class="right-title">全部职位</span>
<span class="right-count">共 {{ totalJobs }} 个</span>
</div>
<!-- 职位列表 -->
<div class="right-body" v-loading="jobsLoading">
<template v-if="!jobsLoading && !jobsError && jobs.length === 0">
<div class="state-tip">
{{ hasFilters ? '未找到匹配的职位,请调整筛选条件' : '暂无职位数据' }}
</div>
</template>
<template v-else-if="jobsError">
<div class="state-tip">加载失败,请刷新重试</div>
</template>
<template v-else>
<div
v-for="job in jobs"
:key="job.id"
class="job-row"
@click="goToJobDetail(job)"
>
<div class="job-row-top">
<div class="job-row-title">{{ job.title }}</div>
<div class="job-row-salary">{{ job.salary }}</div>
</div>
<div class="job-row-meta">
<span class="job-org">{{ job.organization_name || job.organization?.name }}</span>
<span class="meta-dot">·</span>
<span>{{ job.location }}</span>
<span class="meta-dot" v-if="job.education">·</span>
<span v-if="job.education">{{ job.education }}</span>
</div>
<div class="job-row-tags">
<span class="tag tag-cat" v-if="job.category">{{ job.category }}</span>
<span class="tag tag-edu" v-if="job.education">{{ job.education }}</span>
<span class="tag tag-loc">{{ job.location }}</span>
</div>
</div>
</template>
<!-- 分页 -->
<div class="pagination-wrap" v-if="totalJobs > pageSize">
<el-pagination
layout="prev, pager, next"
:total="totalJobs"
:page-size="pageSize"
v-model:current-page="currentPage"
@current-change="handlePageChange"
/>
</div>
</div>
</template>
</section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getOrganizations } from '@/api/organizations'
import { getJobs } from '@/api/jobs'
import { Search as SearchIcon } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const orgs = ref([])
const orgsLoading = ref(false)
const orgsError = ref(false)
const selectedOrg = ref(null)
const jobs = ref([])
const jobsLoading = ref(false)
const jobsError = ref(false)
const totalJobs = ref(0)
const currentPage = ref(1)
const pageSize = 20
const filters = reactive({ search: '', location: '', category: '', education: '' })
const eduLevels = [
{ key: '博士', label: '博士' },
{ key: '硕士', label: '硕士' },
{ key: '本科及以下', label: '本科' },
]
const totalOrgs = computed(() => {
return orgs.value.reduce((n, o) => n + 1 + (o.children?.length || 0), 0)
})
const hasFilters = computed(() => {
return filters.search || filters.location || filters.category || filters.education
})
const eduGroups = computed(() => {
const groups = { '博士': [], '硕士': [], '本科及以下': [] }
jobs.value.forEach(job => {
if (groups[job.education] !== undefined) {
groups[job.education].push(job)
}
})
return groups
})
const eduTotals = computed(() => {
const totals = {}
for (const [key, list] of Object.entries(eduGroups.value)) {
totals[key] = list.reduce((sum, job) => sum + (job.headcount || 0), 0)
}
return totals
})
async function fetchJobs(page = 1) {
jobsLoading.value = true
jobsError.value = false
try {
const params = { page }
if (selectedOrg.value) {
params.organization = selectedOrg.value.id
params.page_size = 200 // 选中公司时拉取全部岗位用于分组
}
if (filters.search) params.search = filters.search
if (filters.location) params.location = filters.location
if (filters.category) params.category = filters.category
if (filters.education) params.education = filters.education
const { data } = await getJobs(params)
jobs.value = data.results
totalJobs.value = data.count
currentPage.value = page
} catch (err) {
console.error('Failed to fetch jobs:', err)
jobsError.value = true
} finally {
jobsLoading.value = false
}
}
function doSearch() {
currentPage.value = 1
fetchJobs(1)
}
function handlePageChange(page) {
fetchJobs(page)
}
async function selectOrg(org) {
selectedOrg.value = org
currentPage.value = 1
fetchJobs(1)
}
function clearOrg() {
selectedOrg.value = null
currentPage.value = 1
fetchJobs(1)
}
function goToJobDetail(job) {
router.push({ name: 'JobDetail', params: { id: job.id } })
}
onMounted(async () => {
orgsLoading.value = true
try {
const { data } = await getOrganizations()
orgs.value = data.results
const targetOrgId = route.query.org ? Number(route.query.org) : null
if (targetOrgId) {
for (const org of orgs.value) {
if (org.id === targetOrgId) { selectedOrg.value = org; break }
const child = org.children?.find(c => c.id === targetOrgId)
if (child) { selectedOrg.value = child; break }
}
} else if (orgs.value.length > 0) {
// 默认选中第一家公司
selectedOrg.value = orgs.value[0]
}
} catch { orgsError.value = true }
finally { orgsLoading.value = false }
fetchJobs(1)
})
</script>
<style scoped>
.two-panel {
--gold: var(--brand-red);
--gold-lt: var(--brand-red-3);
--dark: var(--bg-card);
--cream: var(--bg-page);
--border: #E5E6EB;
--text: var(--text-main);
--muted: var(--text-muted);
--blue: var(--brand-red);
--blue-lt: var(--brand-red-bg);
display: flex;
height: calc(100vh - 97px);
min-height: 520px;
overflow: hidden;
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
/* ── 左栏 ── */
.panel-left {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--dark);
border-right: 1px solid var(--border);
}
.left-header {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.left-title {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
letter-spacing: 0.1em;
}
.left-count {
background: var(--gold);
color: #fff;
font-size: 13px;
font-weight: 700;
padding: 2px 7px;
border-radius: 10px;
}
.left-body { flex: 1; overflow-y: auto; }
.left-body::-webkit-scrollbar { width: 3px; }
.left-body::-webkit-scrollbar-thumb { background: #C9CDD4; border-radius: 2px; }
.org-all { border-bottom: 1px solid var(--border) !important; }
.all-avatar { background: linear-gradient(135deg, #4A90D9, #357ABD) !important; }
.org-row {
display: flex;
align-items: center;
gap: 9px;
padding: 9px 14px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
box-shadow: inset 3px 0 0 transparent;
}
.org-row:hover { background: var(--brand-red-bg); }
.org-row.active {
box-shadow: inset 3px 0 0 var(--gold);
background: var(--brand-red-bg);
}
.org-avatar {
width: 32px; height: 32px;
border-radius: 5px;
background: linear-gradient(135deg, #C8161D, #8B1014);
display: flex; align-items: center; justify-content: center;
font-size: 15px; font-weight: 900; color: #fff;
flex-shrink: 0; overflow: hidden;
}
.org-avatar img { width: 100%; height: 100%; object-fit: cover; }
.child-avatar { width: 26px; height: 26px; font-size: 13px; background: linear-gradient(135deg, #A11218, #6B0C10); }
.org-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.org-name { font-size: 14px; font-weight: 600; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.child-name { font-size: 13px; color: var(--text-muted); }
.org-row.active .org-name { color: var(--gold-lt); }
.org-stat { font-size: 13px; color: var(--text-muted); }
.org-stat em { font-style: normal; color: var(--gold); font-weight: 700; }
.org-child { padding-left: 10px; background: var(--bg-page); }
.child-indent { display: flex; align-items: center; flex-shrink: 0; padding-left: 4px; }
.child-line { display: block; width: 8px; height: 1px; background: var(--border); }
/* ── 右栏 ── */
.panel-right {
flex: 1;
display: flex;
flex-direction: column;
background: var(--cream);
min-width: 0;
}
/* 选中公司时的滚动容器 */
.right-scroll {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
}
.intro-section {
flex-shrink: 0;
}
.right-scroll::-webkit-scrollbar { width: 5px; }
.right-scroll::-webkit-scrollbar-thumb { background: #C5CDD8; border-radius: 3px; }
/* 公司介绍 */
.section-title {
font-size: 18px;
font-weight: 700;
color: var(--text);
margin-bottom: 12px;
padding-left: 2px;
}
.intro-card {
background: #fff;
border-radius: 10px;
padding: 16px;
display: flex;
gap: 18px;
align-items: flex-start;
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
margin-bottom: 20px;
}
.intro-photo {
width: 180px;
height: 110px;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: #E8ECF2;
}
.intro-photo img {
width: 100%;
height: 100%;
object-fit: cover;
}
.intro-info {
flex: 1;
min-width: 0;
}
.intro-name {
font-size: 19px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.intro-desc {
font-size: 15px;
color: var(--muted);
line-height: 1.7;
}
/* 学历分组 */
.edu-sections {
background: #fff;
border-radius: 10px;
box-shadow: 0 1px 6px rgba(0,0,0,0.06);
overflow: hidden;
flex: 1;
}
.edu-row {
display: flex;
gap: 0;
align-items: stretch;
padding: 18px 20px;
}
.edu-label-wrap {
width: 70px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: 2px;
}
.edu-label {
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 16px;
font-weight: 700;
color: var(--text);
text-align: center;
min-width: 54px;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.job-chips-area {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-content: flex-start;
padding-left: 16px;
}
.job-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
font-size: 14px;
color: var(--text);
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
white-space: nowrap;
}
.job-chip:hover {
border-color: var(--blue);
color: var(--blue);
background: var(--blue-lt);
box-shadow: 0 2px 6px rgba(43,90,222,0.12);
}
.chip-icon {
color: var(--blue);
display: flex;
align-items: center;
flex-shrink: 0;
}
.chip-name { font-weight: 500; }
.chip-count { color: var(--muted); font-size: 13px; }
.edu-total-badge {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 5px 0;
background: var(--brand-red-bg);
border: 1px solid var(--brand-red-3);
border-radius: 6px;
font-size: 13px;
font-weight: 700;
color: var(--gold);
white-space: nowrap;
}
.chips-empty {
font-size: 14px;
color: #BDC3CB;
padding: 8px 0;
}
.edu-divider {
height: 1px;
background: var(--border);
margin: 0 20px;
}
/* 搜索栏 */
.search-bar {
flex-shrink: 0;
background: #fff;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
}
.search-row { display: flex; gap: 10px; align-items: center; }
.search-field { flex: 1; min-width: 0; }
.search-field :deep(.el-input__wrapper),
.search-field :deep(.el-select .el-input__wrapper) { border-radius: 6px; }
.search-btn {
flex-shrink: 0;
padding: 8px 24px;
border-radius: 6px;
font-weight: 600;
background: var(--gold) !important;
border-color: var(--gold) !important;
}
.search-btn:hover { background: var(--gold-lt) !important; border-color: var(--gold-lt) !important; }
/* 结果头 */
.right-header {
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
background: #fff;
}
.right-title { font-size: 16px; font-weight: 600; color: var(--text); }
.right-count { font-size: 14px; color: var(--muted); }
/* 职位列表 */
.right-body { flex: 1; overflow-y: auto; }
.job-row {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
background: #fff;
}
.job-row:hover { background: #FAFBFC; }
.job-row-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.job-row-title { font-size: 16px; font-weight: 600; color: var(--text); }
.job-row-salary { font-size: 16px; font-weight: 700; color: #E63329; flex-shrink: 0; margin-left: 16px; }
.job-row-meta { font-size: 14px; color: var(--muted); margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
.job-org { color: var(--gold); font-weight: 500; }
.meta-dot { color: #D9D9D9; }
.job-row-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 13px; padding: 2px 8px; border-radius: 3px; font-weight: 500; }
.tag-loc { background: #E8EEF8; color: #2C5282; }
.tag-cat { background: #E8F5E9; color: #2E7D32; }
.tag-edu { background: #F3E5F5; color: #7B1FA2; }
/* 分页 */
.pagination-wrap { padding: 16px 20px; display: flex; justify-content: center; background: #fff; border-top: 1px solid var(--border); }
/* 空态 / 骨架 */
.state-tip { display: flex; align-items: center; justify-content: center; height: 200px; color: var(--muted); font-size: 15px; }
.skeleton-row {
height: 12px; background: linear-gradient(90deg, #E5E6EB 25%, #F2F3F5 50%, #E5E6EB 75%);
background-size: 200% 100%; border-radius: 3px; margin: 8px 14px;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
</style>