feat(frontend): 首页三栏布局完全重设计,新增企业选择器和岗位联动

- 重构首页为三栏联动布局(企业 + 岗位 + 详情)
- 左栏:深蓝背景企业列表,支持集团和子公司分层显示
  - 选中企业时显示金色左边框 + 背景色变化
  - 每个企业/子公司显示实时在招岗位数
  - 子公司采用缩进 + 树形连线视觉
- 中栏:选中企业的岗位列表
  - 岗位卡片显示位置/薪资/类别标签
  - 朱红左边框高亮选中岗位
- 右栏:岗位详情内容(完整复制 JobDetailView)
  - Banner:深蓝红色渐变背景,显示岗位名/薪资/企业
  - 操作:收藏 + 投递按钮,权限校验
  - 详情:信息网格 + 职位介绍 + 工作地点 + 企业卡片
  - 完整的投递流程和状态反馈
- 配色系统:
  - 深蓝 #0E1E3D(左栏背景)
  - 朱红 #B5272C(选中、强调)
  - 金色 #C8973A(accent)
  - 米色 #FAF7F3(右侧背景)
- 完整的加载/错误/空状态处理
- 骨架屏动画和交互反馈

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
TianyangZhang 2026-03-25 13:12:57 +08:00
parent 88c0bb223c
commit 7e86ec5ea0
3 changed files with 692 additions and 24 deletions

View File

@ -6,3 +6,5 @@ export const manageJobs = (params) => client.get('/jobs/manage/', { params })
export const createJob = (data) => client.post('/jobs/manage/', data) export const createJob = (data) => client.post('/jobs/manage/', data)
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data) export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`) export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)
export const toggleFavorite = (id) => client.post(`/jobs/public/${id}/favorite/`)
export const getMyFavorites = () => client.get('/jobs/favorites/')

View File

@ -2,3 +2,6 @@ import client from './client'
export const getMyResume = () => client.get('/resumes/me/') export const getMyResume = () => client.get('/resumes/me/')
export const updateMyResume = (data) => client.patch('/resumes/me/', data) export const updateMyResume = (data) => client.patch('/resumes/me/', data)
export const uploadResumeAttachment = (formData) => client.patch('/resumes/me/', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})

View File

@ -1,37 +1,700 @@
<template> <template>
<div class="home"> <div class="three-panel">
<div class="hero">
<h1>发现你的理想职位</h1> <!-- 左栏公司列表 -->
<el-input v-model="keyword" placeholder="搜索职位..." size="large" @keyup.enter="search"> <aside class="panel-left">
<template #append><el-button @click="search">搜索</el-button></template> <div class="left-header">
</el-input> <span class="left-title">全部企业</span>
</div> <span class="left-count">{{ totalOrgs }}</span>
<div class="latest-jobs"> </div>
<h3>最新职位</h3> <div class="left-body">
<JobCard v-for="job in latestJobs" :key="job.id" :job="job" /> <template v-if="orgsLoading">
</div> <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-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>
</div>
<div class="mid-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>
<template v-else>
<div
v-for="job in jobs"
:key="job.id"
class="job-row"
:class="{ active: selectedJob?.id === job.id }"
@click="selectJob(job)"
>
<div class="job-row-title">{{ job.title }}</div>
<div class="job-row-tags">
<span class="tag tag-loc">{{ job.location }}</span>
<span class="tag tag-sal">{{ job.salary }}</span>
<span class="tag tag-cat">{{ job.category }}</span>
</div>
</div>
</template>
</div>
</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="#B5272C" 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="#B5272C" 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="#C8973A" 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> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import JobCard from '@/components/JobCard.vue' import { useAuthStore } from '@/stores/auth'
import { getJobs } from '@/api/jobs' import { ElMessage } from 'element-plus'
import { getOrganizations } from '@/api/organizations'
import { getJobs, getJob, toggleFavorite } from '@/api/jobs'
import { applyJob } from '@/api/applications'
const router = useRouter() const router = useRouter()
const keyword = ref('') const auth = useAuthStore()
const latestJobs = ref([])
onMounted(async () => { const orgs = ref([])
const { data } = await getJobs({ page: 1 }) const orgsLoading = ref(false)
latestJobs.value = data.results.slice(0, 6) const orgsError = ref(false)
const selectedOrg = ref(null)
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)
}) })
const search = () => router.push({ name: 'JobList', query: { search: keyword.value } }) function formatDate(dt) {
if (!dt) return ''
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
}
async function selectOrg(org) {
selectedOrg.value = org
selectedJob.value = null
jobs.value = []
jobsError.value = false
jobsLoading.value = true
try {
const { data } = await getJobs({ organization: org.id })
jobs.value = data.results
} catch { jobsError.value = true }
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 }
}
onMounted(async () => {
orgsLoading.value = true
try {
const { data } = await getOrganizations()
orgs.value = data.results
if (orgs.value.length > 0) selectOrg(orgs.value[0])
} catch { orgsError.value = true }
finally { orgsLoading.value = false }
})
</script> </script>
<style scoped> <style scoped>
.hero { text-align: center; padding: 60px 20px; background: linear-gradient(135deg, #409eff22, #fff); } /* ── 变量 ── */
.hero h1 { font-size: 36px; margin-bottom: 24px; } .three-panel {
.el-input { max-width: 500px; } --red: #B5272C;
.latest-jobs { max-width: 900px; margin: 40px auto; } --dark: #0E1E3D;
--gold: #C8973A;
--gold-lt: #F0D080;
--cream: #FAF7F3;
--border: #E5DDD5;
--text: #1A1A2E;
--muted: #7A8094;
display: flex;
height: calc(100vh - 220px);
min-height: 520px;
overflow: hidden;
border-radius: 6px;
border: 1px solid var(--border);
box-shadow: 0 4px 24px rgba(14,30,61,0.12);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
/* ── 左栏 ── */
.panel-left {
width: 228px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--dark);
border-right: 1px solid rgba(255,255,255,0.07);
}
.left-header {
padding: 16px 18px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.08);
flex-shrink: 0;
}
.left-title {
font-size: 13px;
font-weight: 700;
color: rgba(255,255,255,0.9);
letter-spacing: 0.1em;
}
.left-count {
background: var(--red);
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
min-width: 24px;
text-align: center;
}
.left-body { flex: 1; overflow-y: auto; }
.left-body::-webkit-scrollbar { width: 4px; }
.left-body::-webkit-scrollbar-track { background: transparent; }
.left-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 2px; }
/* 公司行 */
.org-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-left: 3px solid transparent;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: background 0.15s;
}
.org-row:hover { background: rgba(255,255,255,0.06); }
.org-row.active {
border-left-color: var(--gold);
background: rgba(200,151,58,0.1);
}
.org-avatar {
width: 34px; height: 34px;
border-radius: 6px;
background: linear-gradient(135deg, #C8973A, #9A6E28);
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: 28px; height: 28px; font-size: 12px;
background: linear-gradient(135deg, #3D5A8A, #1E3460);
}
.org-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.org-name {
font-size: 13px; font-weight: 600;
color: rgba(255,255,255,0.88);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.child-name { font-size: 12px; color: rgba(255,255,255,0.7); }
.org-row.active .org-name { color: var(--gold-lt); }
.org-stat { font-size: 11px; color: rgba(255,255,255,0.38); }
.org-stat em { font-style: normal; color: var(--gold); font-weight: 700; }
/* 子公司缩进 */
.org-child { padding-left: 12px; background: rgba(0,0,0,0.12); }
.child-indent {
display: flex; align-items: center; flex-shrink: 0;
padding-left: 6px;
}
.child-line {
display: block; width: 10px; height: 1px;
background: rgba(200,151,58,0.35);
}
/* ── 中栏 ── */
.panel-mid {
width: 268px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--cream);
border-right: 1px solid var(--border);
}
.mid-header {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2px solid var(--border);
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 {
font-size: 11px; color: var(--muted);
background: #EDE8E1; border-radius: 10px; padding: 1px 8px;
}
.mid-body { flex: 1; overflow-y: auto; }
/* 岗位行 */
.job-row {
padding: 13px 16px;
border-left: 3px solid transparent;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
background: #fff;
}
.job-row:hover { background: #F5EFE8; }
.job-row.active {
border-left-color: var(--red);
background: #FFF5F5;
}
.job-row-title {
font-size: 13px; font-weight: 600;
color: var(--text); margin-bottom: 7px;
}
.job-row.active .job-row-title { color: var(--red); }
.job-row-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.tag {
font-size: 11px; padding: 2px 7px;
border-radius: 3px; font-weight: 500;
}
.tag-loc { background: #E8EEF8; color: #2C5282; }
.tag-sal { background: #FFF3E0; color: #B7610A; }
.tag-cat { background: #E8F5E9; color: #2E7D32; }
/* ── 右栏 ── */
.panel-right {
flex: 1;
overflow-y: auto;
background: #F8F4EF;
}
/* Banner */
.detail-banner {
position: relative;
background: linear-gradient(135deg, #0E1E3D 0%, #1A2E56 50%, #1C1C3A 100%);
overflow: hidden;
}
.banner-deco {
position: absolute; inset: 0;
background:
repeating-linear-gradient(45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,0.03) 21px),
repeating-linear-gradient(-45deg, transparent, transparent 20px, rgba(200,151,58,0.03) 20px, rgba(200,151,58,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(200,151,58,0.15);
border: 1px solid rgba(200,151,58,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(200,151,58,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(200,151,58,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(181,39,44,0.4);
}
.btn-apply:hover { background: #9A1F23; box-shadow: 0 6px 20px rgba(181,39,44,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(14,30,61,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(14,30,61,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, #C8973A, #9A6E28);
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: #9A1F23; }
.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 {
display: flex; align-items: center; justify-content: center;
height: 100%; min-height: 100px;
color: var(--muted); font-size: 13px;
}
.full-tip { flex-direction: column; gap: 12px; }
.empty-icon { opacity: 0.5; }
.detail-loading { padding: 24px 28px; }
.skeleton-row {
height: 14px; background: linear-gradient(90deg, #E8E0D8 25%, #F0E8E0 50%, #E8E0D8 75%);
background-size: 200% 100%;
border-radius: 3px; margin-bottom: 10px;
animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
</style> </style>