refactor: 简化首页为两栏布局(企业列表 + 职位列表)

移除右侧详情面板,只保留企业选择和职位列表展示:
- 左栏:全部企业(父公司和子公司树形展示)
- 右栏:所选企业的职位列表
- 点击职位链接到详情页面

简化了代码结构,移除了 selectJob、handleApply、handleCollect 等不需要的逻辑。

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 15:12:38 +08:00
parent 2b818f1ce7
commit 20f6b188e3
1 changed files with 30 additions and 389 deletions

View File

@ -1,5 +1,5 @@
<template>
<div class="three-panel">
<div class="two-panel">
<!-- 左栏公司列表 -->
<aside class="panel-left">
@ -59,35 +59,33 @@
</div>
</aside>
<!-- 中栏岗位列表 -->
<section class="panel-mid">
<div class="mid-header">
<span v-if="selectedOrg" class="mid-title">
<span class="mid-title-org">{{ selectedOrg.name }}</span>
<span class="mid-title-sep"> · </span>职位列表
</span>
<span v-else class="mid-title muted"> 请选择企业</span>
<span v-if="jobs.length" class="mid-count">{{ jobs.length }} </span>
<!-- 右栏岗位列表 -->
<section class="panel-right">
<div class="right-header">
<span class="right-title">职位列表</span>
<span v-if="jobs.length" class="right-count">{{ jobs.length }} </span>
</div>
<div class="mid-body">
<div class="right-body">
<template v-if="jobsLoading">
<div v-for="i in 4" :key="i" class="skeleton-row" />
</template>
<template v-else-if="jobsError">
<div class="state-tip">加载失败请刷新重试</div>
</template>
<template v-else-if="jobs.length === 0 && selectedOrg">
<div class="state-tip">暂无在招职位</div>
<template v-else-if="jobs.length === 0">
<div class="state-tip">请选择企业查看职位</div>
</template>
<template v-else>
<div
v-for="job in jobs"
:key="job.id"
class="job-row"
:class="{ active: selectedJob?.id === job.id }"
@click="selectJob(job)"
@click="goToJobDetail(job)"
>
<div class="job-row-title">{{ job.title }}</div>
<div class="job-row-meta">
<span class="job-org">{{ job.organization?.name }}</span>
</div>
<div class="job-row-tags">
<span class="tag tag-loc">{{ job.location }}</span>
<span class="tag tag-sal">{{ job.salary }}</span>
@ -99,138 +97,6 @@
</section>
<!-- 右栏岗位详情 -->
<section class="panel-right">
<!-- 加载 -->
<template v-if="detailLoading">
<div class="detail-loading">
<div v-for="i in 6" :key="i" class="skeleton-row" style="margin-bottom:12px" />
</div>
</template>
<!-- 错误 -->
<template v-else-if="detailError">
<div class="state-tip full-tip">加载失败请刷新重试</div>
</template>
<!-- 详情 -->
<template v-else-if="selectedJob">
<!-- Banner -->
<div class="detail-banner">
<div class="banner-deco"></div>
<div class="banner-content">
<div class="banner-left">
<div class="banner-category">{{ selectedJob.category }}</div>
<h2 class="banner-title">{{ selectedJob.title }}</h2>
<div class="banner-salary">{{ selectedJob.salary }}</div>
<div class="banner-meta">
<span class="bmeta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ selectedJob.location }}
</span>
<span class="bmeta-sep">|</span>
<span class="bmeta-item">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
{{ selectedJob.organization?.name }}
</span>
</div>
</div>
<div class="banner-right">
<div class="banner-date">发布于 {{ formatDate(selectedJob.created_at) }}</div>
<div class="banner-btns">
<button class="btn-collect" @click="handleCollect">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
{{ collected ? '已收藏' : '收藏' }}
</button>
<button class="btn-apply" :class="{ loading: applying }" @click="handleApply">
{{ applying ? '提交中…' : '立即投递' }}
</button>
</div>
<p class="banner-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
<p class="banner-hint success" v-if="applied"> 投递成功可在我的投递查看</p>
</div>
</div>
</div>
<!-- 主体 -->
<div class="detail-main">
<div class="detail-left">
<!-- 信息格 -->
<div class="info-grid">
<div class="info-cell">
<span class="info-label">职位类别</span>
<span class="info-value">{{ selectedJob.category || '未填写' }}</span>
</div>
<div class="info-cell">
<span class="info-label">工作地点</span>
<span class="info-value">{{ selectedJob.location }}</span>
</div>
<div class="info-cell">
<span class="info-label">薪资范围</span>
<span class="info-value red-val">{{ selectedJob.salary }}</span>
</div>
<div class="info-cell">
<span class="info-label">发布时间</span>
<span class="info-value">{{ formatDate(selectedJob.created_at) }}</span>
</div>
</div>
<div class="section-divider"></div>
<!-- 职位介绍 -->
<div class="content-section">
<div class="section-heading"><span class="heading-bar"></span>职位介绍</div>
<div class="desc-text" v-html="selectedJob.description"></div>
</div>
<div class="section-divider"></div>
<!-- 工作地点 -->
<div class="content-section">
<div class="section-heading"><span class="heading-bar"></span>工作地点</div>
<div class="location-row">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#E57373" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
{{ selectedJob.location }}
</div>
</div>
</div>
<!-- 公司卡片 -->
<div class="detail-right">
<div class="company-card">
<div class="company-card-label">招聘单位</div>
<div class="company-card-top">
<div class="cc-logo">
<img v-if="selectedJob.organization?.logo" :src="selectedJob.organization.logo" alt="" />
<span v-else>{{ selectedJob.organization?.name?.[0] }}</span>
</div>
<div class="cc-info">
<div class="cc-name">{{ selectedJob.organization?.name }}</div>
<div class="cc-desc" v-if="selectedJob.organization?.description">
{{ selectedJob.organization.description.slice(0, 50) }}{{ selectedJob.organization.description.length > 50 ? '…' : '' }}
</div>
</div>
</div>
<div class="cc-divider"></div>
<div class="cc-contact" v-if="selectedJob.organization?.email">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#E57373" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
{{ selectedJob.organization.email }}
</div>
<button class="btn-apply-card" :class="{ loading: applying }" @click="handleApply">
{{ applying ? '提交中…' : '立即投递' }}
</button>
<p class="apply-success" v-if="applied"> 投递成功</p>
</div>
</div>
</div>
</template>
<!-- 空状态 -->
<template v-else>
<div class="state-tip full-tip">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#D4A95D" stroke-width="1.5"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
</div>
<p>请从左侧选择企业及职位</p>
</div>
</template>
</section>
</div>
</template>
@ -240,8 +106,7 @@ import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'
import { getOrganizations } from '@/api/organizations'
import { getJobs, getJob, toggleFavorite } from '@/api/jobs'
import { applyJob } from '@/api/applications'
import { getJobs } from '@/api/jobs'
const router = useRouter()
const auth = useAuthStore()
@ -255,12 +120,6 @@ const jobs = ref([])
const jobsLoading = ref(false)
const jobsError = ref(false)
const selectedJob = ref(null)
const detailLoading = ref(false)
const detailError = ref(false)
const applying = ref(false)
const applied = ref(false)
const collected = ref(false)
const totalOrgs = computed(() => {
return orgs.value.reduce((n, o) => n + 1 + (o.children?.length || 0), 0)
@ -284,39 +143,8 @@ async function selectOrg(org) {
finally { jobsLoading.value = false }
}
async function selectJob(job) {
detailError.value = false
detailLoading.value = true
selectedJob.value = null
applied.value = false
collected.value = false
try {
const { data } = await getJob(job.id)
selectedJob.value = data
} catch { detailError.value = true }
finally { detailLoading.value = false }
}
async function handleCollect() {
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
const { data } = await toggleFavorite(selectedJob.value.id)
collected.value = data.collected
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
}
async function handleApply() {
if (!auth.isLoggedIn) return router.push({ name: 'Login' })
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
applying.value = true
try {
await applyJob(selectedJob.value.id)
applied.value = true
ElMessage.success('投递成功!')
} catch (e) {
if (e.response?.status === 400) ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
else ElMessage.error('投递失败,请先完善简历')
} finally { applying.value = false }
function goToJobDetail(job) {
router.push({ name: 'JobDetail', params: { id: job.id } })
}
onMounted(async () => {
@ -332,7 +160,7 @@ onMounted(async () => {
<style scoped>
/* ── 变量 ── */
.three-panel {
.two-panel {
--red: #B8860B;
--dark: #1A1A1A;
--gold: #B8860B;
@ -441,16 +269,16 @@ onMounted(async () => {
background: rgba(184,134,11,0.35);
}
/* ── 中栏 ── */
.panel-mid {
width: 268px;
/* ── 右栏 (职位列表) ── */
.panel-right {
flex: 1;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--cream);
border-right: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.mid-header {
.right-header {
padding: 14px 16px;
display: flex;
align-items: center;
@ -459,14 +287,12 @@ onMounted(async () => {
flex-shrink: 0;
background: #fff;
}
.mid-title { font-size: 13px; font-weight: 600; color: var(--text); }
.mid-title-org { color: var(--red); }
.mid-title.muted { color: var(--muted); font-weight: 400; }
.mid-count {
.right-title { font-size: 13px; font-weight: 600; color: var(--text); }
.right-count {
font-size: 11px; color: var(--muted);
background: #F0F0F0; border-radius: 10px; padding: 1px 8px;
}
.mid-body { flex: 1; overflow-y: auto; }
.right-body { flex: 1; overflow-y: auto; }
/* 岗位行 */
.job-row {
@ -478,15 +304,14 @@ onMounted(async () => {
background: #fff;
}
.job-row:hover { background: #F8F8F8; }
.job-row.active {
border-left-color: var(--red);
background: #FFFAF9;
}
.job-row-title {
font-size: 13px; font-weight: 600;
color: var(--text); margin-bottom: 7px;
color: var(--text); margin-bottom: 5px;
}
.job-row.active .job-row-title { color: var(--red); }
.job-row-meta {
font-size: 11px; color: var(--muted); margin-bottom: 6px;
}
.job-org { color: var(--red); }
.job-row-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.tag {
font-size: 11px; padding: 2px 7px;
@ -496,190 +321,6 @@ onMounted(async () => {
.tag-sal { background: #FFF3E0; color: #B7610A; }
.tag-cat { background: #E8F5E9; color: #2E7D32; }
/* ── 右栏 ── */
.panel-right {
flex: 1;
overflow-y: auto;
background: #FAFAFA;
}
/* Banner */
.detail-banner {
position: relative;
background: linear-gradient(135deg, #1A1A1A 0%, #2A2A2A 50%, #222222 100%);
overflow: hidden;
}
.banner-deco {
position: absolute; inset: 0;
background:
repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(184,134,11,0.03) 20px, rgba(184,134,11,0.03) 21px),
repeating-linear-gradient(-45deg, transparent, transparent 20px, rgba(184,134,11,0.03) 20px, rgba(184,134,11,0.03) 21px);
}
.banner-content {
position: relative;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
padding: 28px 32px;
}
.banner-left { flex: 1; }
.banner-category {
display: inline-block;
padding: 3px 12px;
background: rgba(184,134,11,0.15);
border: 1px solid rgba(184,134,11,0.35);
color: var(--gold-lt);
font-size: 11px; letter-spacing: 0.1em;
border-radius: 2px; margin-bottom: 10px;
}
.banner-title {
font-size: 22px; font-weight: 800;
color: #fff; letter-spacing: 0.05em;
margin: 0 0 8px;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.banner-salary {
font-size: 18px; font-weight: 700;
color: #FF6B6B; margin-bottom: 12px;
}
.banner-meta { display: flex; align-items: center; gap: 8px; }
.bmeta-item {
display: flex; align-items: center; gap: 4px;
font-size: 13px; color: rgba(255,255,255,0.6);
}
.bmeta-sep { color: rgba(255,255,255,0.2); }
.banner-right { text-align: right; flex-shrink: 0; }
.banner-date { font-size: 12px; color: rgba(255,255,255,0.38); margin-bottom: 14px; }
.banner-btns { display: flex; gap: 10px; justify-content: flex-end; }
.btn-collect {
display: flex; align-items: center; gap: 6px;
padding: 9px 18px;
background: transparent;
border: 1px solid rgba(184,134,11,0.5);
color: var(--gold-lt);
border-radius: 3px; font-size: 13px;
cursor: pointer; transition: all 0.2s;
font-family: inherit;
}
.btn-collect:hover { background: rgba(184,134,11,0.1); border-color: var(--gold); }
.btn-apply {
padding: 9px 24px;
background: var(--red);
border: none; color: #fff;
border-radius: 3px; font-size: 13px; font-weight: 700;
cursor: pointer; transition: all 0.2s;
letter-spacing: 0.05em;
font-family: inherit;
box-shadow: 0 4px 16px rgba(184,134,11,0.4);
}
.btn-apply:hover { background: #A67C07; box-shadow: 0 6px 20px rgba(184,134,11,0.5); }
.btn-apply.loading { opacity: 0.7; cursor: not-allowed; }
.banner-hint { font-size: 12px; color: rgba(255,255,255,0.4); margin-top: 8px; }
.banner-hint.success { color: #6FCF97; }
/* 主体 */
.detail-main {
display: flex;
gap: 20px;
padding: 24px 28px;
align-items: flex-start;
}
.detail-left {
flex: 1;
background: #fff;
border-radius: 6px;
padding: 24px 28px;
box-shadow: 0 2px 12px rgba(0,0,0,0.07);
border: 1px solid var(--border);
}
.detail-right { width: 224px; flex-shrink: 0; }
/* 信息格 */
.info-grid {
display: grid; grid-template-columns: 1fr 1fr; gap: 16px 24px;
margin-bottom: 4px;
}
.info-cell { display: flex; flex-direction: column; gap: 4px; }
.info-label {
font-size: 11px; color: var(--muted); letter-spacing: 0.05em;
display: flex; align-items: center; gap: 5px;
}
.info-label::before {
content: ''; width: 5px; height: 5px;
background: var(--red); border-radius: 50%;
}
.info-value { font-size: 14px; font-weight: 600; color: var(--text); }
.red-val { color: var(--red); }
.section-divider {
height: 1px; background: var(--border);
margin: 20px 0;
}
/* 内容区块 */
.content-section { }
.section-heading {
display: flex; align-items: center; gap: 10px;
font-size: 15px; font-weight: 700; color: var(--text);
margin-bottom: 14px; letter-spacing: 0.05em;
}
.heading-bar {
display: block; width: 4px; height: 16px;
background: var(--red); border-radius: 2px;
}
.desc-text {
font-size: 13px; line-height: 2;
color: #3A3A4A; white-space: pre-wrap;
}
.location-row {
display: flex; align-items: center; gap: 6px;
font-size: 13px; color: #3A3A4A;
}
/* 公司卡片 */
.company-card {
background: #fff;
border-radius: 6px;
padding: 18px;
box-shadow: 0 2px 12px rgba(0,0,0,0.07);
border: 1px solid var(--border);
}
.company-card-label {
font-size: 11px; font-weight: 700;
color: var(--muted); letter-spacing: 0.15em;
margin-bottom: 14px; text-transform: uppercase;
}
.company-card-top { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.cc-logo {
width: 48px; height: 48px;
border-radius: 8px; overflow: hidden; flex-shrink: 0;
background: linear-gradient(135deg, #B8860B, #8B6407);
display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 900; color: #fff;
border: 1px solid var(--border);
}
.cc-logo img { width: 100%; height: 100%; object-fit: cover; }
.cc-name { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
.cc-desc { font-size: 11px; color: var(--muted); line-height: 1.6; }
.cc-divider { height: 1px; background: var(--border); margin-bottom: 12px; }
.cc-contact {
display: flex; align-items: center; gap: 6px;
font-size: 12px; color: var(--muted); word-break: break-all;
margin-bottom: 14px;
}
.btn-apply-card {
width: 100%; padding: 10px;
background: var(--red); border: none;
color: #fff; font-size: 13px; font-weight: 700;
border-radius: 3px; cursor: pointer;
transition: background 0.2s;
font-family: inherit; letter-spacing: 0.05em;
}
.btn-apply-card:hover { background: #A67C07; }
.btn-apply-card.loading { opacity: 0.7; cursor: not-allowed; }
.apply-success { text-align: center; font-size: 12px; color: #27AE60; margin-top: 8px; }
/* 空状态 / 骨架 */
.state-tip {