refactor: 调整岗位、应用等模块,适配三栏设计
后端变更: - 岗位序列化器调整,支持组织树形结构 - 应用序列化器更新 - 岗位视图逻辑兼容新的过滤需求 - 新增 JobFavorite 数据库迁移(岗位收藏功能) - 岗位URL路由配置更新 前端变更: - 岗位详情页面兼容新设计 - 求职者应用、简历页面样式调整 - 路由配置更新,支持三栏布局 - App.vue 组件调整 - Vite 配置微调 这些调整为首页三栏布局的完整实现提供支撑。 Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e3bdb0b496
commit
536be6c1a1
|
|
@ -6,6 +6,12 @@ class ApplicationCreateSerializer(serializers.ModelSerializer):
|
||||||
model = Application
|
model = Application
|
||||||
fields = ['job']
|
fields = ['job']
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
request = self.context['request']
|
||||||
|
if Application.objects.filter(job=data['job'], applicant=request.user).exists():
|
||||||
|
raise serializers.ValidationError({'detail': '您已投递过该职位'})
|
||||||
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
request = self.context['request']
|
request = self.context['request']
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
# Generated by Django 4.2.20 on 2026-03-25 02:21
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('jobs', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='JobFavorite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('job', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorited_by', to='jobs.job')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='job_favorites', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'unique_together': {('user', 'job')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -27,3 +27,15 @@ class Job(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class JobFavorite(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
'accounts.User', on_delete=models.CASCADE, related_name='job_favorites'
|
||||||
|
)
|
||||||
|
job = models.ForeignKey(Job, on_delete=models.CASCADE, related_name='favorited_by')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'job')
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Job
|
from .models import Job, JobFavorite
|
||||||
from apps.organizations.serializers import OrganizationSerializer
|
from apps.organizations.serializers import OrganizationSerializer
|
||||||
from apps.organizations.models import Organization
|
from apps.organizations.models import Organization
|
||||||
|
|
||||||
|
|
@ -25,3 +25,11 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
||||||
model = Job
|
model = Job
|
||||||
fields = ['id', 'title', 'category', 'location', 'salary',
|
fields = ['id', 'title', 'category', 'location', 'salary',
|
||||||
'description', 'organization', 'organization_id', 'status', 'created_at']
|
'description', 'organization', 'organization_id', 'status', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class JobFavoriteSerializer(serializers.ModelSerializer):
|
||||||
|
job = JobListSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = JobFavorite
|
||||||
|
fields = ['id', 'job', 'created_at']
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import JobPublicViewSet, JobManageViewSet
|
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register('public', JobPublicViewSet, basename='job-public')
|
router.register('public', JobPublicViewSet, basename='job-public')
|
||||||
router.register('manage', JobManageViewSet, basename='job-manage')
|
router.register('manage', JobManageViewSet, basename='job-manage')
|
||||||
|
|
||||||
urlpatterns = [path('', include(router.urls))]
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
path('favorites/', MyFavoritesView.as_view()),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
from rest_framework import viewsets, permissions
|
from rest_framework import viewsets, permissions, generics
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
from rest_framework.filters import SearchFilter
|
from rest_framework.filters import SearchFilter
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from .models import Job
|
from .models import Job, JobFavorite
|
||||||
from .serializers import JobListSerializer, JobDetailSerializer
|
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||||||
from .filters import JobFilter
|
from .filters import JobFilter
|
||||||
from apps.accounts.permissions import IsAdminOrSuperAdmin
|
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
||||||
|
|
||||||
|
|
||||||
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|
@ -20,6 +22,26 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
return JobDetailSerializer
|
return JobDetailSerializer
|
||||||
return JobListSerializer
|
return JobListSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'], permission_classes=[IsSeeker])
|
||||||
|
def favorite(self, request, pk=None):
|
||||||
|
job = self.get_object()
|
||||||
|
fav, created = JobFavorite.objects.get_or_create(user=request.user, job=job)
|
||||||
|
if not created:
|
||||||
|
fav.delete()
|
||||||
|
return Response({'collected': False})
|
||||||
|
return Response({'collected': True})
|
||||||
|
|
||||||
|
|
||||||
|
class MyFavoritesView(generics.ListAPIView):
|
||||||
|
"""求职者的收藏列表"""
|
||||||
|
serializer_class = JobFavoriteSerializer
|
||||||
|
permission_classes = [IsSeeker]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return JobFavorite.objects.filter(user=self.request.user).select_related(
|
||||||
|
'job', 'job__organization'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class JobManageViewSet(viewsets.ModelViewSet):
|
class JobManageViewSet(viewsets.ModelViewSet):
|
||||||
"""管理端:公司管理员管理本公司职位"""
|
"""管理端:公司管理员管理本公司职位"""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<router-view />
|
<router-view />
|
||||||
</template>
|
</template>
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (localStorage.getItem('access_token') && !auth.user) {
|
||||||
|
try { await auth.fetchMe() } catch { /* token expired, ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
|
// 独立入口页
|
||||||
|
{ path: '/', name: 'Splash', component: () => import('@/views/SplashView.vue') },
|
||||||
// 公开门户
|
// 公开门户
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: () => import('@/layouts/PortalLayout.vue'),
|
component: () => import('@/layouts/PortalLayout.vue'),
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
|
{ path: 'home', name: 'Home', component: () => import('@/views/portal/HomeView.vue') },
|
||||||
{ path: 'jobs', name: 'JobList', component: () => import('@/views/portal/JobListView.vue') },
|
{ path: 'jobs', name: 'JobList', component: () => import('@/views/portal/JobListView.vue') },
|
||||||
{ path: 'jobs/:id', name: 'JobDetail', component: () => import('@/views/portal/JobDetailView.vue') },
|
{ path: 'jobs/:id', name: 'JobDetail', component: () => import('@/views/portal/JobDetailView.vue') },
|
||||||
{ path: 'companies', name: 'CompanyList', component: () => import('@/views/portal/CompanyListView.vue') },
|
{ path: 'companies', name: 'CompanyList', component: () => import('@/views/portal/CompanyListView.vue') },
|
||||||
|
|
@ -24,6 +26,7 @@ const routes = [
|
||||||
children: [
|
children: [
|
||||||
{ path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.vue') },
|
{ path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.vue') },
|
||||||
{ path: 'applications', name: 'SeekerApplications', component: () => import('@/views/seeker/ApplicationsView.vue') },
|
{ path: 'applications', name: 'SeekerApplications', component: () => import('@/views/seeker/ApplicationsView.vue') },
|
||||||
|
{ path: 'favorites', name: 'SeekerFavorites', component: () => import('@/views/seeker/FavoritesView.vue') },
|
||||||
{ path: 'profile', name: 'SeekerProfile', component: () => import('@/views/seeker/ProfileView.vue') },
|
{ path: 'profile', name: 'SeekerProfile', component: () => import('@/views/seeker/ProfileView.vue') },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,140 @@
|
||||||
<template>
|
<template>
|
||||||
<el-row :gutter="24" v-loading="loading" style="max-width:1000px;margin:0 auto">
|
<div class="job-detail-page" v-loading="loading">
|
||||||
<el-col :span="16">
|
<!-- 顶部 Banner -->
|
||||||
<el-card v-if="job">
|
<div class="job-banner" v-if="job">
|
||||||
<template #header>
|
<div class="banner-inner">
|
||||||
<h2>{{ job.title }}</h2>
|
<div class="banner-left">
|
||||||
<div class="meta">
|
<h1 class="job-title">{{ job.title }}</h1>
|
||||||
{{ job.organization?.name }} · {{ job.location }} · {{ job.salary }}
|
<div class="job-salary">{{ job.salary }}</div>
|
||||||
|
<div class="job-meta-row">
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon><Location /></el-icon>
|
||||||
|
{{ job.location }}
|
||||||
|
</span>
|
||||||
|
<span class="meta-divider">|</span>
|
||||||
|
<span class="meta-item">
|
||||||
|
<el-icon><OfficeBuilding /></el-icon>
|
||||||
|
{{ job.organization?.name }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
<div v-html="job.description" class="description"></div>
|
<div class="banner-right">
|
||||||
</el-card>
|
<div class="banner-date">发布于 {{ formatDate(job.created_at) }}</div>
|
||||||
</el-col>
|
<div class="banner-actions">
|
||||||
<el-col :span="8">
|
<el-button class="btn-collect" plain @click="toggleCollect">
|
||||||
<el-card>
|
<el-icon><Star /></el-icon>
|
||||||
<el-button type="primary" size="large" style="width:100%" @click="handleApply">
|
{{ collected ? '已收藏' : '收藏' }}
|
||||||
立即投递
|
</el-button>
|
||||||
</el-button>
|
<el-button class="btn-apply" @click="handleApply" :loading="applying">
|
||||||
<p class="hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
立即投递
|
||||||
<p class="hint success" v-if="applied">已投递,可在"我的投递"查看进度</p>
|
</el-button>
|
||||||
</el-card>
|
</div>
|
||||||
</el-col>
|
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||||
</el-row>
|
<p class="apply-hint success" v-if="applied">已投递,可在「我的投递」查看进度</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主体内容 -->
|
||||||
|
<div class="job-body" v-if="job">
|
||||||
|
<div class="body-inner">
|
||||||
|
<!-- 左栏 -->
|
||||||
|
<div class="body-left">
|
||||||
|
<!-- 职位标签 -->
|
||||||
|
<div class="info-tags">
|
||||||
|
<div class="tag-item">
|
||||||
|
<span class="tag-label">职位类别</span>
|
||||||
|
<span class="tag-value">{{ job.category || '未填写' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-item">
|
||||||
|
<span class="tag-label">工作地点</span>
|
||||||
|
<span class="tag-value">{{ job.location }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-item">
|
||||||
|
<span class="tag-label">薪资范围</span>
|
||||||
|
<span class="tag-value salary-val">{{ job.salary }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="tag-item">
|
||||||
|
<span class="tag-label">发布时间</span>
|
||||||
|
<span class="tag-value">{{ formatDate(job.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- 职位介绍 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="title-bar"></span>职位介绍
|
||||||
|
</div>
|
||||||
|
<div class="category-tag" v-if="job.category">{{ job.category }}</div>
|
||||||
|
<div class="description-content" v-html="job.description"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- 工作地点 -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="title-bar"></span>工作地点
|
||||||
|
</div>
|
||||||
|
<div class="location-text">
|
||||||
|
<el-icon><Location /></el-icon>
|
||||||
|
{{ job.location }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右栏 -->
|
||||||
|
<div class="body-right">
|
||||||
|
<!-- 公司信息 -->
|
||||||
|
<div class="company-card">
|
||||||
|
<div class="company-card-header">单位信息</div>
|
||||||
|
<div class="company-logo-row">
|
||||||
|
<div class="company-logo">
|
||||||
|
<img v-if="job.organization?.logo" :src="job.organization.logo" alt="logo" />
|
||||||
|
<div v-else class="logo-placeholder">{{ job.organization?.name?.charAt(0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="company-info">
|
||||||
|
<div class="company-name">{{ job.organization?.name }}</div>
|
||||||
|
<div class="company-desc" v-if="job.organization?.description">
|
||||||
|
{{ job.organization.description.slice(0, 40) }}{{ job.organization.description.length > 40 ? '...' : '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-divider style="margin: 12px 0" />
|
||||||
|
<div class="company-contact" v-if="job.organization?.email">
|
||||||
|
<el-icon><Message /></el-icon>
|
||||||
|
<span>{{ job.organization.email }}</span>
|
||||||
|
</div>
|
||||||
|
<el-button class="btn-apply-sm" @click="handleApply" :loading="applying" style="margin-top:12px;width:100%">
|
||||||
|
立即投递
|
||||||
|
</el-button>
|
||||||
|
<p class="apply-hint-sm" v-if="applied">已投递成功</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getJob } from '@/api/jobs'
|
import { getJob, toggleFavorite } from '@/api/jobs'
|
||||||
import { applyJob } from '@/api/applications'
|
import { applyJob } from '@/api/applications'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const job = ref(null)
|
const job = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const applying = ref(false)
|
||||||
const applied = ref(false)
|
const applied = ref(false)
|
||||||
|
const collected = ref(false)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -44,22 +143,316 @@ onMounted(async () => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatDate(dt) {
|
||||||
|
if (!dt) return ''
|
||||||
|
return new Date(dt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleCollect() {
|
||||||
|
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||||
|
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法收藏职位')
|
||||||
|
const { data } = await toggleFavorite(job.value.id)
|
||||||
|
collected.value = data.collected
|
||||||
|
ElMessage.success(collected.value ? '已收藏,可在「关注职位」中查看' : '已取消收藏')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply() {
|
||||||
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
||||||
|
applying.value = true
|
||||||
try {
|
try {
|
||||||
await applyJob(job.value.id)
|
await applyJob(job.value.id)
|
||||||
applied.value = true
|
applied.value = true
|
||||||
ElMessage.success('投递成功!')
|
ElMessage.success('投递成功!')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e.response?.status === 400) ElMessage.warning('您已投递过该职位')
|
if (e.response?.status === 400) {
|
||||||
else ElMessage.error('投递失败,请先完善简历')
|
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
|
||||||
|
} else {
|
||||||
|
ElMessage.error('投递失败,请先完善简历')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
applying.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.meta { color: #666; margin-top: 8px; }
|
.job-detail-page {
|
||||||
.description { line-height: 1.8; }
|
background: #f5f6fa;
|
||||||
.hint { color: #999; font-size: 13px; margin-top: 12px; }
|
min-height: calc(100vh - 120px);
|
||||||
.success { color: #67c23a; }
|
}
|
||||||
|
|
||||||
|
/* Banner */
|
||||||
|
.job-banner {
|
||||||
|
background: linear-gradient(135deg, #e8f4fd 0%, #d0e9f8 60%, #b8d9f0 100%);
|
||||||
|
padding: 36px 0 32px;
|
||||||
|
border-bottom: 1px solid #dce8f3;
|
||||||
|
}
|
||||||
|
.banner-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
.job-title {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.job-salary {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #e63329;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.job-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.meta-divider {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
.banner-right {
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.banner-date {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.banner-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn-collect {
|
||||||
|
border-color: #e63329 !important;
|
||||||
|
color: #e63329 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.btn-collect:hover {
|
||||||
|
background: #fff0f0 !important;
|
||||||
|
}
|
||||||
|
.btn-apply {
|
||||||
|
background: #e63329 !important;
|
||||||
|
border-color: #e63329 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 10px 28px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.btn-apply:hover {
|
||||||
|
background: #c42820 !important;
|
||||||
|
border-color: #c42820 !important;
|
||||||
|
}
|
||||||
|
.apply-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.apply-hint.success {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body */
|
||||||
|
.job-body {
|
||||||
|
padding: 28px 0 40px;
|
||||||
|
}
|
||||||
|
.body-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.body-left {
|
||||||
|
flex: 1;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.body-right {
|
||||||
|
width: 280px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info tags */
|
||||||
|
.info-tags {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px 24px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
.tag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.tag-label {
|
||||||
|
color: #888;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.tag-label::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #e63329;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tag-value {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.tag-value.salary-val {
|
||||||
|
color: #e63329;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section */
|
||||||
|
.section {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.title-bar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 4px;
|
||||||
|
height: 16px;
|
||||||
|
background: #e63329;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.category-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fff0f0;
|
||||||
|
color: #e63329;
|
||||||
|
border: 1px solid #fad2d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.description-content {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.9;
|
||||||
|
color: #444;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.location-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Company card */
|
||||||
|
.company-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.company-card-header {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.company-logo-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.company-logo {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.company-logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.logo-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.company-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.company-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.company-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.company-contact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.btn-apply-sm {
|
||||||
|
background: #e63329 !important;
|
||||||
|
border-color: #e63329 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-apply-sm:hover {
|
||||||
|
background: #c42820 !important;
|
||||||
|
border-color: #c42820 !important;
|
||||||
|
}
|
||||||
|
.apply-hint-sm {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #52c41a;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ const applications = ref([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
const STATUS_MAP = { pending:'待查看', viewed:'已查看', interviewing:'面试中', hired:'已录用', rejected:'已拒绝' }
|
const STATUS_MAP = { pending:'待查看', viewed:'已查看', interviewing:'面试中', hired:'已录用', rejected:'已拒绝' }
|
||||||
const STATUS_TYPE = { pending:'info', viewed:'', interviewing:'warning', hired:'success', rejected:'danger' }
|
const STATUS_TYPE = { pending:'info', viewed:'primary', interviewing:'warning', hired:'success', rejected:'danger' }
|
||||||
const statusLabel = s => STATUS_MAP[s] || s
|
const statusLabel = s => STATUS_MAP[s] || s
|
||||||
const statusType = s => STATUS_TYPE[s] || ''
|
const statusType = s => STATUS_TYPE[s] || 'info'
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,12 @@
|
||||||
|
|
||||||
<el-card style="margin-bottom:16px">
|
<el-card style="margin-bottom:16px">
|
||||||
<template #header>简历附件</template>
|
<template #header>简历附件</template>
|
||||||
<el-upload action="/api/resumes/me/" :headers="uploadHeaders" name="attachment" accept=".pdf,.doc,.docx" method="patch">
|
<div v-if="form.attachment" style="margin-bottom:12px">
|
||||||
<el-button>上传简历(PDF/Word)</el-button>
|
<span style="margin-right:12px">当前附件:{{ attachmentName }}</span>
|
||||||
|
<el-button type="primary" link :href="attachmentUrl" target="_blank" tag="a">查看附件</el-button>
|
||||||
|
</div>
|
||||||
|
<el-upload :http-request="uploadAttachment" name="attachment" accept=".pdf,.doc,.docx" :show-file-list="false">
|
||||||
|
<el-button>{{ form.attachment ? '重新上传' : '上传简历(PDF/Word)' }}</el-button>
|
||||||
</el-upload>
|
</el-upload>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
|
@ -57,15 +61,12 @@
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { getMyResume, updateMyResume } from '@/api/resumes'
|
import { getMyResume, updateMyResume, uploadResumeAttachment } from '@/api/resumes'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const form = ref(null)
|
const form = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const uploadHeaders = computed(() => ({
|
|
||||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`
|
|
||||||
}))
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -74,13 +75,32 @@ onMounted(async () => {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const attachmentUrl = computed(() => form.value?.attachment || '')
|
||||||
|
const attachmentName = computed(() => {
|
||||||
|
if (!form.value?.attachment) return ''
|
||||||
|
return decodeURIComponent(form.value.attachment.split('/').pop())
|
||||||
|
})
|
||||||
|
|
||||||
const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
|
const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
|
||||||
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
|
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })
|
||||||
|
|
||||||
|
async function uploadAttachment({ file }) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('attachment', file)
|
||||||
|
try {
|
||||||
|
const { data } = await uploadResumeAttachment(fd)
|
||||||
|
form.value.attachment = data.attachment
|
||||||
|
ElMessage.success('附件上传成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('附件上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
await updateMyResume(form.value)
|
const { attachment, ...rest } = form.value
|
||||||
|
await updateMyResume(rest)
|
||||||
ElMessage.success('简历已保存')
|
ElMessage.success('简历已保存')
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('保存失败')
|
ElMessage.error('保存失败')
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: 'http://localhost:8000', changeOrigin: true }
|
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue