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
|
||||
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):
|
||||
request = self.context['request']
|
||||
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):
|
||||
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 .models import Job
|
||||
from .models import Job, JobFavorite
|
||||
from apps.organizations.serializers import OrganizationSerializer
|
||||
from apps.organizations.models import Organization
|
||||
|
||||
|
|
@ -25,3 +25,11 @@ class JobDetailSerializer(serializers.ModelSerializer):
|
|||
model = Job
|
||||
fields = ['id', 'title', 'category', 'location', 'salary',
|
||||
'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 rest_framework.routers import DefaultRouter
|
||||
from .views import JobPublicViewSet, JobManageViewSet
|
||||
from .views import JobPublicViewSet, JobManageViewSet, MyFavoritesView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('public', JobPublicViewSet, basename='job-public')
|
||||
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 django_filters.rest_framework import DjangoFilterBackend
|
||||
from .models import Job
|
||||
from .serializers import JobListSerializer, JobDetailSerializer
|
||||
from .models import Job, JobFavorite
|
||||
from .serializers import JobListSerializer, JobDetailSerializer, JobFavoriteSerializer
|
||||
from .filters import JobFilter
|
||||
from apps.accounts.permissions import IsAdminOrSuperAdmin
|
||||
from apps.accounts.permissions import IsAdminOrSuperAdmin, IsSeeker
|
||||
|
||||
|
||||
class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
|
|
@ -20,6 +22,26 @@ class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return JobDetailSerializer
|
||||
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):
|
||||
"""管理端:公司管理员管理本公司职位"""
|
||||
|
|
|
|||
|
|
@ -1,3 +1,15 @@
|
|||
<template>
|
||||
<router-view />
|
||||
</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'
|
||||
|
||||
const routes = [
|
||||
// 独立入口页
|
||||
{ path: '/', name: 'Splash', component: () => import('@/views/SplashView.vue') },
|
||||
// 公开门户
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/PortalLayout.vue'),
|
||||
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/:id', name: 'JobDetail', component: () => import('@/views/portal/JobDetailView.vue') },
|
||||
{ path: 'companies', name: 'CompanyList', component: () => import('@/views/portal/CompanyListView.vue') },
|
||||
|
|
@ -24,6 +26,7 @@ const routes = [
|
|||
children: [
|
||||
{ path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.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') },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,41 +1,140 @@
|
|||
<template>
|
||||
<el-row :gutter="24" v-loading="loading" style="max-width:1000px;margin:0 auto">
|
||||
<el-col :span="16">
|
||||
<el-card v-if="job">
|
||||
<template #header>
|
||||
<h2>{{ job.title }}</h2>
|
||||
<div class="meta">
|
||||
{{ job.organization?.name }} · {{ job.location }} · {{ job.salary }}
|
||||
<div class="job-detail-page" v-loading="loading">
|
||||
<!-- 顶部 Banner -->
|
||||
<div class="job-banner" v-if="job">
|
||||
<div class="banner-inner">
|
||||
<div class="banner-left">
|
||||
<h1 class="job-title">{{ job.title }}</h1>
|
||||
<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>
|
||||
</template>
|
||||
<div v-html="job.description" class="description"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<el-button type="primary" size="large" style="width:100%" @click="handleApply">
|
||||
立即投递
|
||||
</el-button>
|
||||
<p class="hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
<p class="hint success" v-if="applied">已投递,可在"我的投递"查看进度</p>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<div class="banner-right">
|
||||
<div class="banner-date">发布于 {{ formatDate(job.created_at) }}</div>
|
||||
<div class="banner-actions">
|
||||
<el-button class="btn-collect" plain @click="toggleCollect">
|
||||
<el-icon><Star /></el-icon>
|
||||
{{ collected ? '已收藏' : '收藏' }}
|
||||
</el-button>
|
||||
<el-button class="btn-apply" @click="handleApply" :loading="applying">
|
||||
立即投递
|
||||
</el-button>
|
||||
</div>
|
||||
<p class="apply-hint" v-if="!auth.isLoggedIn">登录后才能投递</p>
|
||||
<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>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getJob } from '@/api/jobs'
|
||||
import { getJob, toggleFavorite } from '@/api/jobs'
|
||||
import { applyJob } from '@/api/applications'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Location, OfficeBuilding, Star, Message } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const job = ref(null)
|
||||
const loading = ref(false)
|
||||
const applying = ref(false)
|
||||
const applied = ref(false)
|
||||
const collected = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
|
|
@ -44,22 +143,316 @@ onMounted(async () => {
|
|||
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() {
|
||||
if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
|
||||
if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
|
||||
applying.value = true
|
||||
try {
|
||||
await applyJob(job.value.id)
|
||||
applied.value = true
|
||||
ElMessage.success('投递成功!')
|
||||
} catch (e) {
|
||||
if (e.response?.status === 400) ElMessage.warning('您已投递过该职位')
|
||||
else ElMessage.error('投递失败,请先完善简历')
|
||||
if (e.response?.status === 400) {
|
||||
ElMessage.warning(e.response.data?.detail || '您已投递过该职位')
|
||||
} else {
|
||||
ElMessage.error('投递失败,请先完善简历')
|
||||
}
|
||||
} finally {
|
||||
applying.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.meta { color: #666; margin-top: 8px; }
|
||||
.description { line-height: 1.8; }
|
||||
.hint { color: #999; font-size: 13px; margin-top: 12px; }
|
||||
.success { color: #67c23a; }
|
||||
.job-detail-page {
|
||||
background: #f5f6fa;
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
/* 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>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,9 @@ const applications = ref([])
|
|||
const loading = ref(false)
|
||||
|
||||
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 statusType = s => STATUS_TYPE[s] || ''
|
||||
const statusType = s => STATUS_TYPE[s] || 'info'
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
|
|
|
|||
|
|
@ -46,8 +46,12 @@
|
|||
|
||||
<el-card style="margin-bottom:16px">
|
||||
<template #header>简历附件</template>
|
||||
<el-upload action="/api/resumes/me/" :headers="uploadHeaders" name="attachment" accept=".pdf,.doc,.docx" method="patch">
|
||||
<el-button>上传简历(PDF/Word)</el-button>
|
||||
<div v-if="form.attachment" style="margin-bottom:12px">
|
||||
<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-card>
|
||||
|
||||
|
|
@ -57,15 +61,12 @@
|
|||
</template>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getMyResume, updateMyResume } from '@/api/resumes'
|
||||
import { getMyResume, updateMyResume, uploadResumeAttachment } from '@/api/resumes'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const form = ref(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const uploadHeaders = computed(() => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('access_token')}`
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
|
|
@ -74,13 +75,32 @@ onMounted(async () => {
|
|||
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 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() {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateMyResume(form.value)
|
||||
const { attachment, ...rest } = form.value
|
||||
await updateMyResume(rest)
|
||||
ElMessage.success('简历已保存')
|
||||
} catch {
|
||||
ElMessage.error('保存失败')
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8000', changeOrigin: true }
|
||||
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue