diff --git a/offer_backend/apps/applications/serializers.py b/offer_backend/apps/applications/serializers.py index abb3578..89a6955 100644 --- a/offer_backend/apps/applications/serializers.py +++ b/offer_backend/apps/applications/serializers.py @@ -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: diff --git a/offer_backend/apps/jobs/migrations/0002_jobfavorite.py b/offer_backend/apps/jobs/migrations/0002_jobfavorite.py new file mode 100644 index 0000000..7988b95 --- /dev/null +++ b/offer_backend/apps/jobs/migrations/0002_jobfavorite.py @@ -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')}, + }, + ), + ] diff --git a/offer_backend/apps/jobs/models.py b/offer_backend/apps/jobs/models.py index a530a94..835ab1a 100644 --- a/offer_backend/apps/jobs/models.py +++ b/offer_backend/apps/jobs/models.py @@ -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'] diff --git a/offer_backend/apps/jobs/serializers.py b/offer_backend/apps/jobs/serializers.py index 8dc5fc1..596cad3 100644 --- a/offer_backend/apps/jobs/serializers.py +++ b/offer_backend/apps/jobs/serializers.py @@ -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'] diff --git a/offer_backend/apps/jobs/urls.py b/offer_backend/apps/jobs/urls.py index f156e0d..fe4822f 100644 --- a/offer_backend/apps/jobs/urls.py +++ b/offer_backend/apps/jobs/urls.py @@ -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()), +] diff --git a/offer_backend/apps/jobs/views.py b/offer_backend/apps/jobs/views.py index b817abd..0be3ecc 100644 --- a/offer_backend/apps/jobs/views.py +++ b/offer_backend/apps/jobs/views.py @@ -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): """管理端:公司管理员管理本公司职位""" diff --git a/offer_frontend/src/App.vue b/offer_frontend/src/App.vue index 98240ae..c52c129 100644 --- a/offer_frontend/src/App.vue +++ b/offer_frontend/src/App.vue @@ -1,3 +1,15 @@ + diff --git a/offer_frontend/src/router/index.js b/offer_frontend/src/router/index.js index 37e8c78..a5ce303 100644 --- a/offer_frontend/src/router/index.js +++ b/offer_frontend/src/router/index.js @@ -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') }, ] }, diff --git a/offer_frontend/src/views/portal/JobDetailView.vue b/offer_frontend/src/views/portal/JobDetailView.vue index d94e0f8..628a305 100644 --- a/offer_frontend/src/views/portal/JobDetailView.vue +++ b/offer_frontend/src/views/portal/JobDetailView.vue @@ -1,41 +1,140 @@ + + diff --git a/offer_frontend/src/views/seeker/ApplicationsView.vue b/offer_frontend/src/views/seeker/ApplicationsView.vue index d1788f4..367b3e4 100644 --- a/offer_frontend/src/views/seeker/ApplicationsView.vue +++ b/offer_frontend/src/views/seeker/ApplicationsView.vue @@ -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 diff --git a/offer_frontend/src/views/seeker/ResumeView.vue b/offer_frontend/src/views/seeker/ResumeView.vue index 3649e4c..c563551 100644 --- a/offer_frontend/src/views/seeker/ResumeView.vue +++ b/offer_frontend/src/views/seeker/ResumeView.vue @@ -46,8 +46,12 @@ - - 上传简历(PDF/Word) +
+ 当前附件:{{ attachmentName }} + 查看附件 +
+ + {{ form.attachment ? '重新上传' : '上传简历(PDF/Word)' }}
@@ -57,15 +61,12 @@