Recruitment_site/docs/superpowers/plans/2026-03-24-recruitment-webs...

89 KiB
Raw Permalink Blame History

集团招聘网站实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 构建集团招聘网站,支持多公司多角色管理、职位发布、简历投递全流程。

Architecture: Django 4.2 + DRF 后端提供 RESTful APIVue3 单页应用前端分三个区域(公开门户/求职者中心/管理后台JWT 认证PostgreSQL 存储业务数据。

Tech Stack: Django 4.2, DRF, simplejwt, PostgreSQL, Redis, Vue3, Vite, Pinia, Element Plus


文件结构总览

offer/
├── offer_backend/
│   ├── config/
│   │   ├── settings/
│   │   │   ├── base.py
│   │   │   ├── development.py
│   │   │   └── production.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   ├── apps/
│   │   ├── accounts/
│   │   │   ├── models.py          # 自定义 User 模型 + 角色
│   │   │   ├── serializers.py     # 登录/注册/用户序列化
│   │   │   ├── views.py           # JWT 登录、注册、用户管理
│   │   │   ├── permissions.py     # IsSuperAdmin, IsAdmin, IsSeeker
│   │   │   ├── urls.py
│   │   │   └── tests/
│   │   │       └── test_auth.py
│   │   ├── organizations/
│   │   │   ├── models.py          # Organization自关联树
│   │   │   ├── serializers.py
│   │   │   ├── views.py
│   │   │   ├── urls.py
│   │   │   └── tests/
│   │   │       └── test_organizations.py
│   │   ├── jobs/
│   │   │   ├── models.py          # Job职位
│   │   │   ├── serializers.py
│   │   │   ├── views.py
│   │   │   ├── filters.py         # django-filter 搜索过滤
│   │   │   ├── urls.py
│   │   │   └── tests/
│   │   │       └── test_jobs.py
│   │   ├── resumes/
│   │   │   ├── models.py          # ResumeJSONB 教育/工作经历)
│   │   │   ├── serializers.py
│   │   │   ├── views.py
│   │   │   ├── urls.py
│   │   │   └── tests/
│   │   │       └── test_resumes.py
│   │   └── applications/
│   │       ├── models.py          # Applicationresume_snapshot
│   │       ├── serializers.py
│   │       ├── views.py
│   │       ├── emails.py          # 邮件通知发送
│   │       ├── urls.py
│   │       └── tests/
│   │           └── test_applications.py
│   ├── manage.py
│   └── requirements.txt
└── offer_frontend/
    ├── src/
    │   ├── api/
    │   │   ├── client.js          # axios 实例 + JWT 拦截器
    │   │   ├── auth.js
    │   │   ├── organizations.js
    │   │   ├── jobs.js
    │   │   ├── resumes.js
    │   │   └── applications.js
    │   ├── router/
    │   │   └── index.js           # 路由 + 守卫
    │   ├── stores/
    │   │   ├── auth.js            # 用户状态 + token
    │   │   └── job.js             # 职位搜索状态
    │   ├── layouts/
    │   │   ├── PortalLayout.vue
    │   │   ├── SeekerLayout.vue
    │   │   └── AdminLayout.vue
    │   ├── views/
    │   │   ├── portal/
    │   │   │   ├── HomeView.vue
    │   │   │   ├── JobListView.vue
    │   │   │   ├── JobDetailView.vue
    │   │   │   ├── CompanyListView.vue
    │   │   │   └── CompanyDetailView.vue
    │   │   ├── auth/
    │   │   │   ├── LoginView.vue
    │   │   │   └── RegisterView.vue
    │   │   ├── seeker/
    │   │   │   ├── ResumeView.vue
    │   │   │   ├── ApplicationsView.vue
    │   │   │   └── ProfileView.vue
    │   │   └── admin/
    │   │       ├── JobManageView.vue
    │   │       ├── ApplicationManageView.vue
    │   │       ├── OrganizationManageView.vue
    │   │       └── UserManageView.vue
    │   ├── components/
    │   │   ├── JobCard.vue
    │   │   └── CompanyCard.vue
    │   ├── App.vue
    │   └── main.js
    ├── index.html
    ├── vite.config.js
    └── package.json

Task 1: 初始化 Django 后端项目

Files:

  • Create: offer_backend/requirements.txt

  • Create: offer_backend/manage.py

  • Create: offer_backend/config/settings/base.py

  • Create: offer_backend/config/settings/development.py

  • Create: offer_backend/config/urls.py

  • Step 1: 创建目录结构

cd C:/code/offer
mkdir -p offer_backend/config/settings
mkdir -p offer_backend/apps
touch offer_backend/config/__init__.py
touch offer_backend/config/settings/__init__.py
  • Step 2: 写 requirements.txt
# offer_backend/requirements.txt
Django==4.2.20
djangorestframework==3.16.0
djangorestframework-simplejwt==5.3.1
django-cors-headers==4.3.1
django-filter==23.5
psycopg2-binary==2.9.9
redis==5.0.1
django-redis==5.4.0
Pillow==10.3.0
python-decouple==3.8
  • Step 3: 初始化 Django 项目
cd offer_backend
pip install -r requirements.txt
django-admin startproject config .
  • Step 4: 写 config/settings/base.py
# offer_backend/config/settings/base.py
from pathlib import Path
from decouple import config

BASE_DIR = Path(__file__).resolve().parent.parent.parent

SECRET_KEY = config('SECRET_KEY')

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    # Third party
    'rest_framework',
    'rest_framework_simplejwt',
    'corsheaders',
    'django_filters',
    # Local
    'apps.accounts',
    'apps.organizations',
    'apps.jobs',
    'apps.resumes',
    'apps.applications',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'config.urls'

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

AUTH_USER_MODEL = 'accounts.User'

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
    'DEFAULT_PERMISSION_CLASSES': (
        'rest_framework.permissions.IsAuthenticated',
    ),
    'DEFAULT_FILTER_BACKENDS': [
        'django_filters.rest_framework.DjangoFilterBackend',
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
}

from datetime import timedelta
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(hours=2),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}

STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
  • Step 5: 写 config/settings/development.py
# offer_backend/config/settings/development.py
from .base import *

DEBUG = True
ALLOWED_HOSTS = ['*']

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': config('DB_NAME', default='offer_db'),
        'USER': config('DB_USER', default='postgres'),
        'PASSWORD': config('DB_PASSWORD', default='postgres'),
        'HOST': config('DB_HOST', default='localhost'),
        'PORT': config('DB_PORT', default='5432'),
    }
}

CACHES = {
    'default': {
        'BACKEND': 'django_redis.cache.RedisCache',
        'LOCATION': config('REDIS_URL', default='redis://127.0.0.1:6379/1'),
    }
}

CORS_ALLOW_ALL_ORIGINS = True

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
  • Step 6: 写 config/urls.py
# offer_backend/config/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/auth/', include('apps.accounts.urls')),
    path('api/organizations/', include('apps.organizations.urls')),
    path('api/jobs/', include('apps.jobs.urls')),
    path('api/resumes/', include('apps.resumes.urls')),
    path('api/applications/', include('apps.applications.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
  • Step 7: 创建 .env 文件
# offer_backend/.env
SECRET_KEY=your-secret-key-change-in-production
DB_NAME=offer_db
DB_USER=postgres
DB_PASSWORD=postgres
DB_HOST=localhost
DB_PORT=5432
REDIS_URL=redis://127.0.0.1:6379/1
  • Step 8: 验证启动
cd offer_backend
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py check

Expected: System check identified no issues

  • Step 9: Commit
git add offer_backend/
git commit -m "feat: initialize Django backend project structure"

Task 2: 配置 pytest 测试基础设施(必须在 Task 3 之前完成)

Files:

  • Create: offer_backend/conftest.py

  • Create: offer_backend/pytest.ini

  • Step 1: 添加 pytest 依赖

offer_backend/requirements.txt 末尾添加:

pytest==8.1.1
pytest-django==4.8.0
cd offer_backend
pip install pytest pytest-django
  • Step 2: 写 pytest.ini
# offer_backend/pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.development
python_files = tests/test_*.py
python_classes = Test*
python_functions = test_*
  • Step 3: 写 conftest.py
# offer_backend/conftest.py
import pytest
from django.test import Client

@pytest.fixture
def client():
    return Client()
  • Step 4: Commit
git add offer_backend/conftest.py offer_backend/pytest.ini offer_backend/requirements.txt
git commit -m "chore: add pytest configuration for Django tests"

Task 3: accounts app — 自定义用户模型

Files:

  • Create: offer_backend/apps/accounts/models.py

  • Create: offer_backend/apps/accounts/tests/test_auth.py

  • Create: offer_backend/apps/accounts/serializers.py

  • Create: offer_backend/apps/accounts/views.py

  • Create: offer_backend/apps/accounts/permissions.py

  • Create: offer_backend/apps/accounts/urls.py

  • Step 1: 创建 accounts app

cd offer_backend
python manage.py startapp accounts apps/accounts
  • Step 2: 写失败测试
# offer_backend/apps/accounts/tests/test_auth.py
import pytest
from django.contrib.auth import get_user_model

User = get_user_model()

@pytest.mark.django_db
class TestUserModel:
    def test_create_seeker(self):
        user = User.objects.create_user(
            username='seeker1', password='pass123', role='seeker'
        )
        assert user.role == 'seeker'
        assert user.is_seeker is True
        assert user.is_admin is False

    def test_create_admin(self):
        user = User.objects.create_user(
            username='admin1', password='pass123', role='admin'
        )
        assert user.is_admin is True

    def test_create_superadmin(self):
        user = User.objects.create_user(
            username='super1', password='pass123', role='superadmin'
        )
        assert user.is_superadmin is True
  • Step 3: 运行确认失败
cd offer_backend
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/accounts/tests/test_auth.py -v

Expected: ImportErrorModuleNotFoundError

  • Step 4: 写 models.py
# offer_backend/apps/accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models

class User(AbstractUser):
    ROLE_CHOICES = [
        ('superadmin', '超级管理员'),
        ('admin', '公司管理员'),
        ('seeker', '求职者'),
    ]
    role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='seeker')
    phone = models.CharField(max_length=20, blank=True)
    organization = models.ForeignKey(
        'organizations.Organization',
        null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='admins'
    )

    @property
    def is_superadmin(self):
        return self.role == 'superadmin'

    @property
    def is_admin(self):
        return self.role == 'admin'

    @property
    def is_seeker(self):
        return self.role == 'seeker'

    class Meta:
        verbose_name = '用户'
        verbose_name_plural = '用户'
  • Step 5: 写 permissions.py
# offer_backend/apps/accounts/permissions.py
from rest_framework.permissions import BasePermission

class IsSuperAdmin(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and request.user.is_superadmin

class IsCompanyAdmin(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and request.user.is_admin

class IsAdminOrSuperAdmin(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and (
            request.user.is_admin or request.user.is_superadmin
        )

class IsSeeker(BasePermission):
    def has_permission(self, request, view):
        return request.user.is_authenticated and request.user.is_seeker
  • Step 6: 写 serializers.py
# offer_backend/apps/accounts/serializers.py
from rest_framework import serializers
from django.contrib.auth import get_user_model

User = get_user_model()

class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=6)

    class Meta:
        model = User
        fields = ['username', 'email', 'phone', 'password']

    def create(self, validated_data):
        return User.objects.create_user(
            **validated_data, role='seeker'
        )

class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'phone', 'role', 'organization']
        read_only_fields = ['role']

class AdminUserSerializer(serializers.ModelSerializer):
    """超管用于创建/管理公司管理员账号"""
    password = serializers.CharField(write_only=True, min_length=6)

    class Meta:
        model = User
        fields = ['id', 'username', 'email', 'phone', 'role', 'organization', 'password', 'is_active']

    def create(self, validated_data):
        password = validated_data.pop('password')
        user = User(**validated_data)
        user.set_password(password)
        user.save()
        return user
  • Step 7: 写 views.py
# offer_backend/apps/accounts/views.py
from rest_framework import generics, status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from django.contrib.auth import get_user_model
from .serializers import RegisterSerializer, UserSerializer, AdminUserSerializer
from .permissions import IsSuperAdmin

User = get_user_model()

class RegisterView(generics.CreateAPIView):
    serializer_class = RegisterSerializer
    permission_classes = [AllowAny]

class MeView(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        return Response(UserSerializer(request.user).data)

    def patch(self, request):
        serializer = UserSerializer(request.user, data=request.data, partial=True)
        serializer.is_valid(raise_exception=True)
        serializer.save()
        return Response(serializer.data)

class UserManageViewSet(generics.ListCreateAPIView):
    """超管:管理所有用户"""
    serializer_class = AdminUserSerializer
    permission_classes = [IsSuperAdmin]
    queryset = User.objects.all()

class UserDetailView(generics.RetrieveUpdateDestroyAPIView):
    serializer_class = AdminUserSerializer
    permission_classes = [IsSuperAdmin]
    queryset = User.objects.all()
  • Step 8: 写 urls.py
# offer_backend/apps/accounts/urls.py
from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
from .views import RegisterView, MeView, UserManageViewSet, UserDetailView

urlpatterns = [
    path('register/', RegisterView.as_view()),
    path('login/', TokenObtainPairView.as_view()),
    path('token/refresh/', TokenRefreshView.as_view()),
    path('me/', MeView.as_view()),
    path('users/', UserManageViewSet.as_view()),
    path('users/<int:pk>/', UserDetailView.as_view()),
]
  • Step 9: 运行迁移并测试
cd offer_backend
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py makemigrations accounts
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/accounts/tests/test_auth.py -v

Expected: 3 tests PASSED

  • Step 10: Commit
git add offer_backend/apps/accounts/
git commit -m "feat: add custom User model with role-based permissions"

Task 4: organizations app

Files:

  • Create: offer_backend/apps/organizations/models.py

  • Create: offer_backend/apps/organizations/serializers.py

  • Create: offer_backend/apps/organizations/views.py

  • Create: offer_backend/apps/organizations/urls.py

  • Create: offer_backend/apps/organizations/tests/test_organizations.py

  • Step 1: 创建 app

cd offer_backend
python manage.py startapp organizations apps/organizations
  • Step 2: 写失败测试
# offer_backend/apps/organizations/tests/test_organizations.py
import pytest
from apps.organizations.models import Organization

@pytest.mark.django_db
class TestOrganizationModel:
    def test_create_group(self):
        org = Organization.objects.create(name='示例集团', email='group@example.com')
        assert org.parent is None
        assert org.is_active is True

    def test_create_subsidiary(self):
        parent = Organization.objects.create(name='示例集团', email='group@example.com')
        child = Organization.objects.create(
            name='子公司A', email='a@example.com', parent=parent
        )
        assert child.parent == parent

    def test_list_subsidiaries(self):
        parent = Organization.objects.create(name='集团', email='g@example.com')
        Organization.objects.create(name='子A', email='a@example.com', parent=parent)
        Organization.objects.create(name='子B', email='b@example.com', parent=parent)
        assert parent.children.count() == 2
  • Step 3: 写 models.py
# offer_backend/apps/organizations/models.py
from django.db import models

class Organization(models.Model):
    name = models.CharField(max_length=100, verbose_name='公司名称')
    parent = models.ForeignKey(
        'self', null=True, blank=True,
        on_delete=models.SET_NULL,
        related_name='children',
        verbose_name='上级公司'
    )
    logo = models.ImageField(upload_to='org_logos/', null=True, blank=True)
    description = models.TextField(blank=True, verbose_name='公司简介')
    email = models.EmailField(blank=True, verbose_name='联系邮箱')
    is_active = models.BooleanField(default=True)
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        verbose_name = '组织架构'
        verbose_name_plural = '组织架构'

    def __str__(self):
        return self.name
  • Step 4: 写 serializers.py
# offer_backend/apps/organizations/serializers.py
from rest_framework import serializers
from .models import Organization

class OrganizationSerializer(serializers.ModelSerializer):
    class Meta:
        model = Organization
        fields = ['id', 'name', 'parent', 'logo', 'description', 'email', 'is_active']

class OrganizationTreeSerializer(serializers.ModelSerializer):
    """带子公司列表,用于门户展示"""
    children = serializers.SerializerMethodField()

    class Meta:
        model = Organization
        fields = ['id', 'name', 'logo', 'description', 'email', 'children']

    def get_children(self, obj):
        return OrganizationSerializer(
            obj.children.filter(is_active=True), many=True
        ).data
  • Step 5: 写 views.py 和 urls.py
# offer_backend/apps/organizations/views.py
from rest_framework import viewsets
from rest_framework.permissions import AllowAny
from .models import Organization
from .serializers import OrganizationSerializer, OrganizationTreeSerializer
from apps.accounts.permissions import IsSuperAdmin

class OrganizationPublicViewSet(viewsets.ReadOnlyModelViewSet):
    """公开只读:门户展示用"""
    queryset = Organization.objects.filter(is_active=True, parent__isnull=False)
    serializer_class = OrganizationSerializer
    permission_classes = [AllowAny]

class OrganizationManageViewSet(viewsets.ModelViewSet):
    """超管:完整增删改查"""
    queryset = Organization.objects.all()
    serializer_class = OrganizationSerializer
    permission_classes = [IsSuperAdmin]
# offer_backend/apps/organizations/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import OrganizationPublicViewSet, OrganizationManageViewSet

router = DefaultRouter()
router.register('public', OrganizationPublicViewSet, basename='org-public')
router.register('manage', OrganizationManageViewSet, basename='org-manage')

urlpatterns = [path('', include(router.urls))]
  • Step 6: 迁移并测试
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py makemigrations organizations
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/organizations/tests/ -v

Expected: 3 tests PASSED

  • Step 7: Commit
git add offer_backend/apps/organizations/
git commit -m "feat: add Organization model with tree structure"

Task 5: jobs app

Files:

  • Create: offer_backend/apps/jobs/models.py

  • Create: offer_backend/apps/jobs/filters.py

  • Create: offer_backend/apps/jobs/serializers.py

  • Create: offer_backend/apps/jobs/views.py

  • Create: offer_backend/apps/jobs/urls.py

  • Create: offer_backend/apps/jobs/tests/test_jobs.py

  • Step 1: 创建 app

python manage.py startapp jobs apps/jobs
  • Step 2: 写失败测试
# offer_backend/apps/jobs/tests/test_jobs.py
import pytest
from django.contrib.auth import get_user_model
from apps.organizations.models import Organization
from apps.jobs.models import Job

User = get_user_model()

@pytest.fixture
def org():
    return Organization.objects.create(name='测试公司', email='test@test.com')

@pytest.fixture
def admin_user(org):
    return User.objects.create_user(
        username='admin1', password='pass123',
        role='admin', organization=org
    )

@pytest.mark.django_db
class TestJobModel:
    def test_create_job(self, org):
        job = Job.objects.create(
            organization=org, title='Python工程师',
            category='技术', location='北京', salary='20k-30k',
            description='职位描述', status='published'
        )
        assert job.status == 'published'
        assert str(job) == 'Python工程师'

@pytest.mark.django_db
class TestJobAPI:
    def test_public_can_list_published_jobs(self, client, org):
        Job.objects.create(
            organization=org, title='已发布职位',
            status='published', category='技术',
            location='北京', salary='10k'
        )
        Job.objects.create(
            organization=org, title='草稿职位',
            status='draft', category='技术',
            location='北京', salary='10k'
        )
        response = client.get('/api/jobs/public/')
        assert response.status_code == 200
        assert response.data['count'] == 1
  • Step 3: 写 models.py
# offer_backend/apps/jobs/models.py
from django.db import models

class Job(models.Model):
    STATUS_CHOICES = [
        ('draft', '草稿'),
        ('published', '已发布'),
        ('closed', '已关闭'),
    ]
    organization = models.ForeignKey(
        'organizations.Organization',
        on_delete=models.CASCADE,
        related_name='jobs'
    )
    title = models.CharField(max_length=100, verbose_name='职位名称')
    category = models.CharField(max_length=50, verbose_name='职位类别')
    location = models.CharField(max_length=100, verbose_name='工作地点')
    salary = models.CharField(max_length=50, verbose_name='薪资范围')
    description = models.TextField(verbose_name='职位描述')
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft')
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-created_at']
        verbose_name = '职位'

    def __str__(self):
        return self.title
  • Step 4: 写 filters.py
# offer_backend/apps/jobs/filters.py
import django_filters
from .models import Job

class JobFilter(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr='icontains')
    category = django_filters.CharFilter(lookup_expr='exact')
    location = django_filters.CharFilter(lookup_expr='icontains')
    organization = django_filters.NumberFilter(field_name='organization__id')

    class Meta:
        model = Job
        fields = ['title', 'category', 'location', 'organization']
  • Step 5: 写 serializers.py 和 views.py
# offer_backend/apps/jobs/serializers.py
from rest_framework import serializers
from .models import Job
from apps.organizations.serializers import OrganizationSerializer
from apps.organizations.models import Organization

class JobListSerializer(serializers.ModelSerializer):
    organization_name = serializers.CharField(source='organization.name', read_only=True)

    class Meta:
        model = Job
        fields = ['id', 'title', 'category', 'location', 'salary',
                  'organization', 'organization_name', 'status', 'created_at']

class JobDetailSerializer(serializers.ModelSerializer):
    organization = OrganizationSerializer(read_only=True)
    organization_id = serializers.PrimaryKeyRelatedField(
        source='organization',
        queryset=Organization.objects.all(),
        write_only=True
    )

    class Meta:
        model = Job
        fields = ['id', 'title', 'category', 'location', 'salary',
                  'description', 'organization', 'organization_id', 'status', 'created_at']
# offer_backend/apps/jobs/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Job
from .serializers import JobListSerializer, JobDetailSerializer
from .filters import JobFilter
from apps.accounts.permissions import IsAdminOrSuperAdmin

class JobPublicViewSet(viewsets.ReadOnlyModelViewSet):
    """公开只读,仅返回已发布职位"""
    queryset = Job.objects.filter(status='published').select_related('organization')
    filterset_class = JobFilter
    search_fields = ['title', 'description', 'location']
    permission_classes = [permissions.AllowAny]

    def get_serializer_class(self):
        if self.action == 'retrieve':
            return JobDetailSerializer
        return JobListSerializer

class JobManageViewSet(viewsets.ModelViewSet):
    """管理端:公司管理员管理本公司职位"""
    permission_classes = [IsAdminOrSuperAdmin]

    def get_serializer_class(self):
        if self.action in ['retrieve', 'create', 'update', 'partial_update']:
            return JobDetailSerializer
        return JobListSerializer

    def get_queryset(self):
        user = self.request.user
        if user.is_superadmin:
            return Job.objects.all().select_related('organization')
        return Job.objects.filter(organization=user.organization).select_related('organization')

    def perform_create(self, serializer):
        if self.request.user.is_admin:
            serializer.save(organization=self.request.user.organization)
        else:
            serializer.save()
  • Step 6: 写 urls.py
# offer_backend/apps/jobs/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import JobPublicViewSet, JobManageViewSet

router = DefaultRouter()
router.register('public', JobPublicViewSet, basename='job-public')
router.register('manage', JobManageViewSet, basename='job-manage')

urlpatterns = [path('', include(router.urls))]
  • Step 7: 迁移并测试
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py makemigrations jobs
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/jobs/tests/ -v

Expected: 2 tests PASSED

  • Step 8: Commit
git add offer_backend/apps/jobs/
git commit -m "feat: add Job model with search/filter and role-based access"

Task 6: resumes app

Files:

  • Create: offer_backend/apps/resumes/models.py

  • Create: offer_backend/apps/resumes/serializers.py

  • Create: offer_backend/apps/resumes/views.py

  • Create: offer_backend/apps/resumes/urls.py

  • Create: offer_backend/apps/resumes/tests/test_resumes.py

  • Step 1: 创建 app

python manage.py startapp resumes apps/resumes
  • Step 2: 写失败测试
# offer_backend/apps/resumes/tests/test_resumes.py
import pytest
from django.contrib.auth import get_user_model
from apps.resumes.models import Resume

User = get_user_model()

@pytest.fixture
def seeker():
    return User.objects.create_user(username='seeker1', password='pass', role='seeker')

@pytest.mark.django_db
class TestResumeModel:
    def test_create_resume(self, seeker):
        resume = Resume.objects.create(
            user=seeker,
            name='张三',
            gender='male',
            education=[{'school': '北京大学', 'degree': '本科', 'major': '计算机'}],
            experience=[{'company': 'ABC公司', 'position': '工程师', 'duration': '2年'}],
        )
        assert resume.name == '张三'
        assert len(resume.education) == 1
        assert len(resume.experience) == 1

    def test_seeker_has_one_resume(self, seeker):
        Resume.objects.create(user=seeker, name='张三')
        assert Resume.objects.filter(user=seeker).count() == 1
  • Step 3: 写 models.py
# offer_backend/apps/resumes/models.py
from django.db import models
from django.conf import settings

class Resume(models.Model):
    GENDER_CHOICES = [('male', '男'), ('female', '女'), ('other', '其他')]
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='resume'
    )
    name = models.CharField(max_length=50, verbose_name='姓名')
    gender = models.CharField(max_length=10, choices=GENDER_CHOICES, blank=True)
    birthday = models.DateField(null=True, blank=True)
    education = models.JSONField(default=list, verbose_name='教育经历')
    experience = models.JSONField(default=list, verbose_name='工作经历')
    attachment = models.FileField(upload_to='resumes/', null=True, blank=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        verbose_name = '简历'

    def to_snapshot(self):
        """序列化为投递快照,与主表解耦"""
        return {
            'name': self.name,
            'gender': self.gender,
            'birthday': str(self.birthday) if self.birthday else None,
            'education': self.education,
            'experience': self.experience,
            'attachment_url': self.attachment.url if self.attachment else None,
        }
  • Step 4: 写 serializers.py、views.py、urls.py
# offer_backend/apps/resumes/serializers.py
from rest_framework import serializers
from .models import Resume

class ResumeSerializer(serializers.ModelSerializer):
    class Meta:
        model = Resume
        fields = ['id', 'name', 'gender', 'birthday', 'education',
                  'experience', 'attachment', 'updated_at']
        read_only_fields = ['updated_at']
# offer_backend/apps/resumes/views.py
from rest_framework import generics
from rest_framework.permissions import IsAuthenticated
from .models import Resume
from .serializers import ResumeSerializer
from apps.accounts.permissions import IsSeeker

class MyResumeView(generics.RetrieveUpdateAPIView):
    """求职者获取/更新自己的简历(不存在则自动创建)"""
    serializer_class = ResumeSerializer
    permission_classes = [IsSeeker]

    def get_object(self):
        resume, _ = Resume.objects.get_or_create(
            user=self.request.user,
            defaults={'name': self.request.user.username}
        )
        return resume
# offer_backend/apps/resumes/urls.py
from django.urls import path
from .views import MyResumeView

urlpatterns = [
    path('me/', MyResumeView.as_view()),
]
  • Step 5: 迁移并测试
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py makemigrations resumes
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/resumes/tests/ -v

Expected: 2 tests PASSED

  • Step 6: Commit
git add offer_backend/apps/resumes/
git commit -m "feat: add Resume model with JSONB fields and snapshot support"

Task 7: applications app

Files:

  • Create: offer_backend/apps/applications/models.py

  • Create: offer_backend/apps/applications/emails.py

  • Create: offer_backend/apps/applications/serializers.py

  • Create: offer_backend/apps/applications/views.py

  • Create: offer_backend/apps/applications/urls.py

  • Create: offer_backend/apps/applications/tests/test_applications.py

  • Step 1: 创建 app

python manage.py startapp applications apps/applications
  • Step 2: 写失败测试
# offer_backend/apps/applications/tests/test_applications.py
import pytest
from django.contrib.auth import get_user_model
from apps.organizations.models import Organization
from apps.jobs.models import Job
from apps.resumes.models import Resume
from apps.applications.models import Application

User = get_user_model()

@pytest.fixture
def setup(db):
    org = Organization.objects.create(name='公司', email='co@test.com')
    seeker = User.objects.create_user(username='seeker1', password='pass', role='seeker')
    job = Job.objects.create(
        organization=org, title='测试职位', category='技术',
        location='北京', salary='15k', status='published'
    )
    resume = Resume.objects.create(user=seeker, name='张三')
    return {'org': org, 'seeker': seeker, 'job': job, 'resume': resume}

@pytest.mark.django_db
class TestApplicationModel:
    def test_create_application(self, setup):
        app = Application.objects.create(
            job=setup['job'],
            applicant=setup['seeker'],
            resume_snapshot=setup['resume'].to_snapshot(),
        )
        assert app.status == 'pending'
        assert app.resume_snapshot['name'] == '张三'

    def test_cannot_apply_twice(self, setup):
        Application.objects.create(
            job=setup['job'],
            applicant=setup['seeker'],
            resume_snapshot=setup['resume'].to_snapshot(),
        )
        with pytest.raises(Exception):
            Application.objects.create(
                job=setup['job'],
                applicant=setup['seeker'],
                resume_snapshot={},
            )
  • Step 3: 写 models.py
# offer_backend/apps/applications/models.py
from django.db import models
from django.conf import settings

class Application(models.Model):
    STATUS_CHOICES = [
        ('pending', '待查看'),
        ('viewed', '已查看'),
        ('interviewing', '面试中'),
        ('hired', '已录用'),
        ('rejected', '已拒绝'),
    ]
    job = models.ForeignKey('jobs.Job', on_delete=models.CASCADE, related_name='applications')
    applicant = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='applications'
    )
    resume_snapshot = models.JSONField(verbose_name='简历快照')
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
    note = models.TextField(blank=True, verbose_name='HR备注')
    applied_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = [['job', 'applicant']]
        ordering = ['-applied_at']
        verbose_name = '投递记录'
  • Step 4: 写 emails.py
# offer_backend/apps/applications/emails.py
from django.core.mail import send_mail
from django.conf import settings

STATUS_LABELS = {
    'viewed': '已查看',
    'interviewing': '面试邀请',
    'hired': '恭喜录用',
    'rejected': '很遗憾未通过',
}

def notify_status_change(application):
    label = STATUS_LABELS.get(application.status)
    if not label or not application.applicant.email:
        return
    send_mail(
        subject=f'【招聘通知】您投递的"{application.job.title}"状态更新:{label}',
        message=f'您好 {application.applicant.username}\n\n您投递的职位"{application.job.title}"状态已更新为:{label}\n\n请登录平台查看详情。',
        from_email=settings.DEFAULT_FROM_EMAIL if hasattr(settings, 'DEFAULT_FROM_EMAIL') else 'noreply@offer.com',
        recipient_list=[application.applicant.email],
        fail_silently=True,
    )
  • Step 5: 写 serializers.py、views.py、urls.py
# offer_backend/apps/applications/serializers.py
from rest_framework import serializers
from .models import Application

class ApplicationCreateSerializer(serializers.ModelSerializer):
    class Meta:
        model = Application
        fields = ['job']

    def create(self, validated_data):
        request = self.context['request']
        try:
            resume = request.user.resume
        except Exception:
            raise serializers.ValidationError(
                {'detail': '请先完善简历后再投递'}
            )
        if not resume.name:
            raise serializers.ValidationError(
                {'detail': '请先完善简历后再投递'}
            )
        return Application.objects.create(
            job=validated_data['job'],
            applicant=request.user,
            resume_snapshot=resume.to_snapshot(),
        )

class ApplicationSerializer(serializers.ModelSerializer):
    job_title = serializers.CharField(source='job.title', read_only=True)
    company_name = serializers.CharField(source='job.organization.name', read_only=True)

    class Meta:
        model = Application
        fields = ['id', 'job', 'job_title', 'company_name',
                  'resume_snapshot', 'status', 'note', 'applied_at']
        read_only_fields = ['resume_snapshot', 'applied_at']

class ApplicationStatusSerializer(serializers.ModelSerializer):
    """HR 更新状态"""
    class Meta:
        model = Application
        fields = ['status', 'note']
# offer_backend/apps/applications/views.py
from rest_framework import generics, viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Application
from .serializers import ApplicationCreateSerializer, ApplicationSerializer, ApplicationStatusSerializer
from .emails import notify_status_change
from apps.accounts.permissions import IsSeeker, IsAdminOrSuperAdmin

class ApplyView(generics.CreateAPIView):
    serializer_class = ApplicationCreateSerializer
    permission_classes = [IsSeeker]

class MyApplicationsView(generics.ListAPIView):
    serializer_class = ApplicationSerializer
    permission_classes = [IsSeeker]

    def get_queryset(self):
        return Application.objects.filter(applicant=self.request.user).select_related('job__organization')

class ApplicationManageViewSet(viewsets.ReadOnlyModelViewSet):
    """HR 查看本公司投递"""
    serializer_class = ApplicationSerializer
    permission_classes = [IsAdminOrSuperAdmin]

    def get_queryset(self):
        user = self.request.user
        if user.is_superadmin:
            return Application.objects.all().select_related('job__organization', 'applicant')
        return Application.objects.filter(
            job__organization=user.organization
        ).select_related('job__organization', 'applicant')

class ApplicationStatusUpdateView(generics.UpdateAPIView):
    serializer_class = ApplicationStatusSerializer
    permission_classes = [IsAdminOrSuperAdmin]
    queryset = Application.objects.all()

    def perform_update(self, serializer):
        instance = serializer.save()
        notify_status_change(instance)
# offer_backend/apps/applications/urls.py
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import ApplyView, MyApplicationsView, ApplicationManageViewSet, ApplicationStatusUpdateView

router = DefaultRouter()
router.register('manage', ApplicationManageViewSet, basename='application-manage')

urlpatterns = [
    path('apply/', ApplyView.as_view()),
    path('mine/', MyApplicationsView.as_view()),
    path('manage/<int:pk>/status/', ApplicationStatusUpdateView.as_view()),
    path('', include(router.urls)),
]
  • Step 6: 迁移并测试
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py makemigrations applications
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py migrate
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/applications/tests/ -v

Expected: 2 tests PASSED

  • Step 7: 全量后端测试
DJANGO_SETTINGS_MODULE=config.settings.development pytest --tb=short

Expected: All tests PASSED

  • Step 8: Commit
git add offer_backend/apps/applications/
git commit -m "feat: add Application model with resume snapshot and email notification"

Task 8: 初始化 Vue3 前端项目

Files:

  • Create: offer_frontend/package.json

  • Create: offer_frontend/vite.config.js

  • Create: offer_frontend/src/main.js

  • Create: offer_frontend/src/App.vue

  • Create: offer_frontend/src/api/client.js

  • Step 1: 创建 Vite + Vue3 项目

cd C:/code/offer
npm create vite@latest offer_frontend -- --template vue
cd offer_frontend
npm install
  • Step 2: 安装依赖
npm install vue-router@4 pinia axios element-plus @element-plus/icons-vue
npm install -D unplugin-vue-components unplugin-auto-import
  • Step 3: 配置 vite.config.js
// offer_frontend/vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import path from 'path'

export default defineConfig({
  plugins: [
    vue(),
    AutoImport({ resolvers: [ElementPlusResolver()] }),
    Components({ resolvers: [ElementPlusResolver()] }),
  ],
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') }
  },
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8000', changeOrigin: true }
    }
  }
})
  • Step 4: 写 src/api/client.jsaxios + JWT 拦截器)
// offer_frontend/src/api/client.js
import axios from 'axios'

const client = axios.create({ baseURL: '/api' })

client.interceptors.request.use(config => {
  const token = localStorage.getItem('access_token')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})

client.interceptors.response.use(
  res => res,
  async err => {
    const original = err.config
    if (err.response?.status === 401 && !original._retry) {
      original._retry = true
      try {
        const refresh = localStorage.getItem('refresh_token')
        const { data } = await axios.post('/api/auth/token/refresh/', { refresh })
        localStorage.setItem('access_token', data.access)
        original.headers.Authorization = `Bearer ${data.access}`
        return client(original)
      } catch {
        localStorage.clear()
        window.location.href = '/login'
      }
    }
    return Promise.reject(err)
  }
)

export default client
  • Step 5: 写各模块 API 文件
// offer_frontend/src/api/auth.js
import client from './client'
import axios from 'axios'

export const login = (data) => axios.post('/api/auth/login/', data)
export const register = (data) => client.post('/auth/register/', data)
export const getMe = () => client.get('/auth/me/')
export const updateMe = (data) => client.patch('/auth/me/', data)

// offer_frontend/src/api/jobs.js
import client from './client'

export const getJobs = (params) => client.get('/jobs/public/', { params })
export const getJob = (id) => client.get(`/jobs/public/${id}/`)
export const manageJobs = (params) => client.get('/jobs/manage/', { params })
export const createJob = (data) => client.post('/jobs/manage/', data)
export const updateJob = (id, data) => client.patch(`/jobs/manage/${id}/`, data)
export const deleteJob = (id) => client.delete(`/jobs/manage/${id}/`)

// offer_frontend/src/api/organizations.js
import client from './client'

export const getOrganizations = () => client.get('/organizations/public/')
export const getOrganization = (id) => client.get(`/organizations/public/${id}/`)
export const manageOrganizations = () => client.get('/organizations/manage/')
export const createOrganization = (data) => client.post('/organizations/manage/', data)
export const updateOrganization = (id, data) => client.patch(`/organizations/manage/${id}/`, data)
export const deleteOrganization = (id) => client.delete(`/organizations/manage/${id}/`)

// offer_frontend/src/api/resumes.js
import client from './client'

export const getMyResume = () => client.get('/resumes/me/')
export const updateMyResume = (data) => client.patch('/resumes/me/', data)

// offer_frontend/src/api/applications.js
import client from './client'

export const applyJob = (jobId) => client.post('/applications/apply/', { job: jobId })
export const getMyApplications = () => client.get('/applications/mine/')
export const getManageApplications = (params) => client.get('/applications/manage/', { params })
export const updateApplicationStatus = (id, data) => client.patch(`/applications/manage/${id}/status/`, data)
  • Step 6: 写 Pinia stores
// offer_frontend/src/stores/auth.js
import { defineStore } from 'pinia'
import { login as loginApi, getMe } from '@/api/auth'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: null,
    loading: false,
  }),
  getters: {
    isLoggedIn: s => !!s.user,
    isSuperAdmin: s => s.user?.role === 'superadmin',
    isAdmin: s => s.user?.role === 'admin',
    isSeeker: s => s.user?.role === 'seeker',
  },
  actions: {
    async login(username, password) {
      const { data } = await loginApi({ username, password })
      localStorage.setItem('access_token', data.access)
      localStorage.setItem('refresh_token', data.refresh)
      await this.fetchMe()
    },
    async fetchMe() {
      const { data } = await getMe()
      this.user = data
    },
    logout() {
      localStorage.clear()
      this.user = null
    },
  },
})
// offer_frontend/src/stores/job.js
import { defineStore } from 'pinia'
import { getJobs } from '@/api/jobs'

export const useJobStore = defineStore('job', {
  state: () => ({ jobs: [], total: 0, loading: false }),
  actions: {
    async fetchJobs(params) {
      this.loading = true
      const { data } = await getJobs(params)
      this.jobs = data.results
      this.total = data.count
      this.loading = false
    },
  },
})
  • Step 7: 写 main.js
// offer_frontend/src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')
  • Step 8: 验证启动
cd offer_frontend
npm run dev

Expected: Vite 启动成功,浏览器打开无报错

  • Step 9: Commit
git add offer_frontend/
git commit -m "feat: initialize Vue3 frontend with router, pinia, element-plus"

Task 9: 路由与布局

Files:

  • Create: offer_frontend/src/router/index.js

  • Create: offer_frontend/src/layouts/PortalLayout.vue

  • Create: offer_frontend/src/layouts/SeekerLayout.vue

  • Create: offer_frontend/src/layouts/AdminLayout.vue

  • Create: offer_frontend/src/App.vue

  • Step 1: 写路由配置(含守卫)

// offer_frontend/src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes = [
  // 公开门户
  {
    path: '/',
    component: () => import('@/layouts/PortalLayout.vue'),
    children: [
      { path: '', 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') },
      { path: 'companies/:id', name: 'CompanyDetail', component: () => import('@/views/portal/CompanyDetailView.vue') },
    ]
  },
  { path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
  { path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
  // 求职者中心
  {
    path: '/seeker',
    component: () => import('@/layouts/SeekerLayout.vue'),
    meta: { requireAuth: true, role: 'seeker' },
    children: [
      { path: 'resume', name: 'SeekerResume', component: () => import('@/views/seeker/ResumeView.vue') },
      { path: 'applications', name: 'SeekerApplications', component: () => import('@/views/seeker/ApplicationsView.vue') },
      { path: 'profile', name: 'SeekerProfile', component: () => import('@/views/seeker/ProfileView.vue') },
    ]
  },
  // 管理后台
  {
    path: '/admin',
    component: () => import('@/layouts/AdminLayout.vue'),
    meta: { requireAuth: true, role: 'admin' },
    children: [
      { path: 'jobs', name: 'AdminJobs', component: () => import('@/views/admin/JobManageView.vue') },
      { path: 'applications', name: 'AdminApplications', component: () => import('@/views/admin/ApplicationManageView.vue') },
      { path: 'organizations', name: 'AdminOrganizations', component: () => import('@/views/admin/OrganizationManageView.vue'), meta: { role: 'superadmin' } },
      { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/UserManageView.vue'), meta: { role: 'superadmin' } },
    ]
  },
  { path: '/:pathMatch(.*)*', redirect: '/' },
]

const router = createRouter({
  history: createWebHistory(),
  routes,
})

router.beforeEach(async (to, from, next) => {
  const auth = useAuthStore()

  if (to.meta.requireAuth) {
    if (!localStorage.getItem('access_token')) {
      return next({ name: 'Login', query: { redirect: to.fullPath } })
    }
    if (!auth.user) {
      try { await auth.fetchMe() } catch { return next({ name: 'Login' }) }
    }
    const requiredRole = to.meta.role
    if (requiredRole === 'seeker' && !auth.isSeeker) return next({ path: '/' })
    if (requiredRole === 'admin' && !(auth.isAdmin || auth.isSuperAdmin)) return next({ path: '/' })
    if (requiredRole === 'superadmin' && !auth.isSuperAdmin) return next({ path: '/admin/jobs' })
  }
  next()
})

export default router
  • Step 2: 写 PortalLayout.vue
<!-- offer_frontend/src/layouts/PortalLayout.vue -->
<template>
  <el-container class="portal-layout">
    <el-header>
      <div class="header-inner">
        <router-link to="/" class="logo">集团招聘</router-link>
        <nav>
          <router-link to="/jobs">职位列表</router-link>
          <router-link to="/companies">公司介绍</router-link>
        </nav>
        <div class="header-actions">
          <template v-if="auth.isLoggedIn">
            <router-link v-if="auth.isSeeker" to="/seeker/applications">我的投递</router-link>
            <router-link v-else to="/admin/jobs">管理后台</router-link>
            <el-button text @click="logout">退出</el-button>
          </template>
          <template v-else>
            <router-link to="/login"><el-button>登录</el-button></router-link>
            <router-link to="/register"><el-button type="primary">注册</el-button></router-link>
          </template>
        </div>
      </div>
    </el-header>
    <el-main><router-view /></el-main>
    <el-footer>© 集团招聘平台</el-footer>
  </el-container>
</template>

<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const auth = useAuthStore()
const router = useRouter()
const logout = () => { auth.logout(); router.push('/') }
</script>
  • Step 3: 写 SeekerLayout.vue 和 AdminLayout.vue
<!-- offer_frontend/src/layouts/SeekerLayout.vue -->
<template>
  <el-container>
    <el-aside width="200px">
      <el-menu router>
        <el-menu-item index="/seeker/resume">我的简历</el-menu-item>
        <el-menu-item index="/seeker/applications">我的投递</el-menu-item>
        <el-menu-item index="/seeker/profile">账号设置</el-menu-item>
      </el-menu>
    </el-aside>
    <el-main><router-view /></el-main>
  </el-container>
</template>
<script setup></script>
<!-- offer_frontend/src/layouts/AdminLayout.vue -->
<template>
  <el-container>
    <el-aside width="200px">
      <el-menu router>
        <el-menu-item index="/admin/jobs">职位管理</el-menu-item>
        <el-menu-item index="/admin/applications">投递管理</el-menu-item>
        <template v-if="auth.isSuperAdmin">
          <el-menu-item index="/admin/organizations">组织架构</el-menu-item>
          <el-menu-item index="/admin/users">用户管理</el-menu-item>
        </template>
      </el-menu>
    </el-aside>
    <el-main><router-view /></el-main>
  </el-container>
</template>
<script setup>
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
</script>
  • Step 4: Commit
git add offer_frontend/src/router/ offer_frontend/src/layouts/
git commit -m "feat: add router with guards and layout components"

Task 10: 公开门户页面

Files:

  • Create: offer_frontend/src/views/portal/HomeView.vue

  • Create: offer_frontend/src/views/portal/JobListView.vue

  • Create: offer_frontend/src/views/portal/JobDetailView.vue

  • Create: offer_frontend/src/views/portal/CompanyListView.vue

  • Create: offer_frontend/src/views/portal/CompanyDetailView.vue

  • Create: offer_frontend/src/components/JobCard.vue

  • Create: offer_frontend/src/components/CompanyCard.vue

  • Step 1: 写 JobCard.vue

<!-- offer_frontend/src/components/JobCard.vue -->
<template>
  <el-card class="job-card" shadow="hover" @click="$router.push(`/jobs/${job.id}`)">
    <div class="job-title">{{ job.title }}</div>
    <div class="job-meta">
      <span>{{ job.company_name || job.organization_name }}</span>
      <el-divider direction="vertical" />
      <span>{{ job.location }}</span>
      <el-divider direction="vertical" />
      <span class="salary">{{ job.salary }}</span>
    </div>
    <el-tag size="small">{{ job.category }}</el-tag>
  </el-card>
</template>
<script setup>
defineProps({ job: Object })
</script>
<style scoped>
.job-card { cursor: pointer; margin-bottom: 12px; }
.job-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.job-meta { color: #666; font-size: 13px; margin-bottom: 8px; }
.salary { color: #f56c6c; font-weight: 500; }
</style>
  • Step 2: 写 JobListView.vue搜索+列表)
<!-- offer_frontend/src/views/portal/JobListView.vue -->
<template>
  <div class="job-list-page">
    <el-card class="search-bar">
      <el-form :model="filters" inline>
        <el-form-item><el-input v-model="filters.search" placeholder="职位名称/关键词" clearable @change="fetchJobs" /></el-form-item>
        <el-form-item><el-input v-model="filters.location" placeholder="城市" clearable @change="fetchJobs" /></el-form-item>
        <el-form-item><el-input v-model="filters.category" placeholder="职位类别" clearable @change="fetchJobs" /></el-form-item>
        <el-form-item><el-button type="primary" @click="fetchJobs">搜索</el-button></el-form-item>
      </el-form>
    </el-card>

    <div v-loading="loading" class="job-results">
      <div class="result-count"> {{ total }} 个职位</div>
      <JobCard v-for="job in jobs" :key="job.id" :job="job" />
      <el-pagination
        v-if="total > pageSize"
        layout="prev, pager, next"
        :total="total"
        :page-size="pageSize"
        v-model:current-page="page"
        @current-change="fetchJobs"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, reactive, onMounted } from 'vue'
import JobCard from '@/components/JobCard.vue'
import { getJobs } from '@/api/jobs'

const jobs = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const pageSize = 20
const filters = reactive({ search: '', location: '', category: '' })

async function fetchJobs() {
  loading.value = true
  const { data } = await getJobs({ ...filters, page: page.value })
  jobs.value = data.results
  total.value = data.count
  loading.value = false
}

onMounted(fetchJobs)
</script>
  • Step 3: 写 JobDetailView.vue职位详情+投递)
<!-- offer_frontend/src/views/portal/JobDetailView.vue -->
<template>
  <el-row :gutter="24" v-loading="loading">
    <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>
        </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" block @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>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJob } from '@/api/jobs'
import { applyJob } from '@/api/applications'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'

const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const job = ref(null)
const loading = ref(false)
const applied = ref(false)

onMounted(async () => {
  loading.value = true
  const { data } = await getJob(route.params.id)
  job.value = data
  loading.value = false
})

async function handleApply() {
  if (!auth.isLoggedIn) return router.push({ name: 'Login', query: { redirect: route.fullPath } })
  if (!auth.isSeeker) return ElMessage.warning('管理员账号无法投递职位')
  try {
    await applyJob(job.value.id)
    applied.value = true
    ElMessage.success('投递成功!')
  } catch (e) {
    if (e.response?.status === 400) ElMessage.warning('您已投递过该职位')
    else ElMessage.error('投递失败,请先完善简历')
  }
}
</script>
  • Step 4: 写 HomeView.vue、CompanyListView.vue、CompanyDetailView.vue简版
<!-- offer_frontend/src/views/portal/HomeView.vue -->
<template>
  <div class="home">
    <div class="hero">
      <h1>发现你的理想职位</h1>
      <el-input v-model="keyword" placeholder="搜索职位..." size="large" @keyup.enter="search">
        <template #append><el-button @click="search">搜索</el-button></template>
      </el-input>
    </div>
    <div class="latest-jobs">
      <h3>最新职位</h3>
      <JobCard v-for="job in latestJobs" :key="job.id" :job="job" />
    </div>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import JobCard from '@/components/JobCard.vue'
import { getJobs } from '@/api/jobs'

const router = useRouter()
const keyword = ref('')
const latestJobs = ref([])

onMounted(async () => {
  const { data } = await getJobs({ page: 1 })
  latestJobs.value = data.results.slice(0, 6)
})

const search = () => router.push({ name: 'JobList', query: { search: keyword.value } })
</script>
<!-- offer_frontend/src/views/portal/CompanyListView.vue -->
<template>
  <div>
    <h2>公司列表</h2>
    <el-row :gutter="16">
      <el-col v-for="org in orgs" :key="org.id" :span="8">
        <el-card shadow="hover" @click="$router.push(`/companies/${org.id}`)" style="cursor:pointer;margin-bottom:16px">
          <el-avatar :src="org.logo" size="large" />
          <div style="margin-top:8px;font-weight:600">{{ org.name }}</div>
          <div style="color:#666;font-size:13px">{{ org.description?.slice(0,60) }}</div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getOrganizations } from '@/api/organizations'
const orgs = ref([])
onMounted(async () => { const { data } = await getOrganizations(); orgs.value = data.results })
</script>
<!-- offer_frontend/src/views/portal/CompanyDetailView.vue -->
<template>
  <div v-if="org">
    <el-card>
      <template #header><h2>{{ org.name }}</h2></template>
      <p>{{ org.description }}</p>
      <p>联系邮箱{{ org.email }}</p>
    </el-card>
    <h3 style="margin-top:24px">在招职位</h3>
    <JobCard v-for="job in jobs" :key="job.id" :job="job" />
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { getOrganization } from '@/api/organizations'
import { getJobs } from '@/api/jobs'
import JobCard from '@/components/JobCard.vue'

const route = useRoute()
const org = ref(null)
const jobs = ref([])
onMounted(async () => {
  const [orgRes, jobRes] = await Promise.all([
    getOrganization(route.params.id),
    getJobs({ organization: route.params.id })
  ])
  org.value = orgRes.data
  jobs.value = jobRes.data.results
})
</script>
  • Step 5: Commit
git add offer_frontend/src/views/portal/ offer_frontend/src/components/
git commit -m "feat: add public portal pages (home, job list, job detail, companies)"

Task 11: 登录/注册页

Files:

  • Create: offer_frontend/src/views/auth/LoginView.vue

  • Create: offer_frontend/src/views/auth/RegisterView.vue

  • Step 1: 写 LoginView.vue

<!-- offer_frontend/src/views/auth/LoginView.vue -->
<template>
  <div class="auth-page">
    <el-card class="auth-card">
      <h2>登录</h2>
      <el-form :model="form" @submit.prevent="handleLogin">
        <el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
        <el-form-item><el-input v-model="form.password" type="password" placeholder="密码" show-password /></el-form-item>
        <el-button type="primary" native-type="submit" :loading="loading" block>登录</el-button>
      </el-form>
      <div style="margin-top:12px;text-align:center">
        没有账号?<router-link to="/register">立即注册</router-link>
      </div>
    </el-card>
  </div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { ElMessage } from 'element-plus'

const form = reactive({ username: '', password: '' })
const loading = ref(false)
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()

async function handleLogin() {
  loading.value = true
  try {
    await auth.login(form.username, form.password)
    const redirect = route.query.redirect
    if (redirect) return router.push(redirect)
    if (auth.isSeeker) router.push('/seeker/resume')
    else router.push('/admin/jobs')
  } catch {
    ElMessage.error('用户名或密码错误')
  } finally {
    loading.value = false
  }
}
</script>
<style scoped>
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
.auth-card { width: 380px; }
</style>
  • Step 2: 写 RegisterView.vue
<!-- offer_frontend/src/views/auth/RegisterView.vue -->
<template>
  <div class="auth-page">
    <el-card class="auth-card">
      <h2>求职者注册</h2>
      <el-form :model="form" @submit.prevent="handleRegister">
        <el-form-item><el-input v-model="form.username" placeholder="用户名" /></el-form-item>
        <el-form-item><el-input v-model="form.email" placeholder="邮箱" /></el-form-item>
        <el-form-item><el-input v-model="form.phone" placeholder="手机号(可选)" /></el-form-item>
        <el-form-item><el-input v-model="form.password" type="password" placeholder="密码至少6位" show-password /></el-form-item>
        <el-button type="primary" native-type="submit" :loading="loading" block>注册</el-button>
      </el-form>
      <div style="margin-top:12px;text-align:center">
        已有账号?<router-link to="/login">立即登录</router-link>
      </div>
    </el-card>
  </div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { register } from '@/api/auth'
import { ElMessage } from 'element-plus'

const form = reactive({ username: '', email: '', phone: '', password: '' })
const loading = ref(false)
const router = useRouter()

async function handleRegister() {
  loading.value = true
  try {
    await register(form)
    ElMessage.success('注册成功,请登录')
    router.push('/login')
  } catch (e) {
    ElMessage.error(e.response?.data?.username?.[0] || '注册失败,请重试')
  } finally {
    loading.value = false
  }
}
</script>
<style scoped>
.auth-page { display:flex; justify-content:center; align-items:center; min-height:60vh; }
.auth-card { width: 380px; }
</style>
  • Step 3: Commit
git add offer_frontend/src/views/auth/
git commit -m "feat: add login and register views with role-based redirect"

Task 12: 求职者中心页面

Files:

  • Create: offer_frontend/src/views/seeker/ResumeView.vue

  • Create: offer_frontend/src/views/seeker/ApplicationsView.vue

  • Create: offer_frontend/src/views/seeker/ProfileView.vue

  • Step 1: 写 ResumeView.vue在线简历编辑

<!-- offer_frontend/src/views/seeker/ResumeView.vue -->
<template>
  <div v-loading="loading">
    <h2>我的简历</h2>
    <el-form :model="form" label-width="80px" v-if="form">

      <el-card style="margin-bottom:16px">
        <template #header>基本信息</template>
        <el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
        <el-form-item label="性别">
          <el-radio-group v-model="form.gender">
            <el-radio value="male">男</el-radio>
            <el-radio value="female">女</el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item label="生日"><el-date-picker v-model="form.birthday" type="date" /></el-form-item>
      </el-card>

      <el-card style="margin-bottom:16px">
        <template #header>
          教育经历
          <el-button size="small" @click="addEducation" style="float:right">添加</el-button>
        </template>
        <div v-for="(edu, i) in form.education" :key="i" class="edu-item">
          <el-row :gutter="12">
            <el-col :span="8"><el-input v-model="edu.school" placeholder="学校名称" /></el-col>
            <el-col :span="6"><el-input v-model="edu.degree" placeholder="学历" /></el-col>
            <el-col :span="7"><el-input v-model="edu.major" placeholder="专业" /></el-col>
            <el-col :span="3"><el-button type="danger" link @click="form.education.splice(i,1)">删除</el-button></el-col>
          </el-row>
        </div>
      </el-card>

      <el-card style="margin-bottom:16px">
        <template #header>
          工作经历
          <el-button size="small" @click="addExperience" style="float:right">添加</el-button>
        </template>
        <div v-for="(exp, i) in form.experience" :key="i" class="exp-item">
          <el-row :gutter="12">
            <el-col :span="8"><el-input v-model="exp.company" placeholder="公司名称" /></el-col>
            <el-col :span="7"><el-input v-model="exp.position" placeholder="职位" /></el-col>
            <el-col :span="6"><el-input v-model="exp.duration" placeholder="时长2年" /></el-col>
            <el-col :span="3"><el-button type="danger" link @click="form.experience.splice(i,1)">删除</el-button></el-col>
          </el-row>
        </div>
      </el-card>

      <el-card style="margin-bottom:16px">
        <template #header>简历附件</template>
        <el-upload action="/api/resumes/me/" :headers="uploadHeaders" name="attachment" accept=".pdf,.doc,.docx">
          <el-button>上传简历PDF/Word</el-button>
        </el-upload>
      </el-card>

      <el-button type="primary" @click="save" :loading="saving">保存简历</el-button>
    </el-form>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { getMyResume, updateMyResume } 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
  const { data } = await getMyResume()
  form.value = { ...data, education: data.education || [], experience: data.experience || [] }
  loading.value = false
})

const addEducation = () => form.value.education.push({ school: '', degree: '', major: '' })
const addExperience = () => form.value.experience.push({ company: '', position: '', duration: '' })

async function save() {
  saving.value = true
  try {
    await updateMyResume(form.value)
    ElMessage.success('简历已保存')
  } catch {
    ElMessage.error('保存失败')
  } finally {
    saving.value = false
  }
}
</script>
  • Step 2: 写 ApplicationsView.vue
<!-- offer_frontend/src/views/seeker/ApplicationsView.vue -->
<template>
  <div>
    <h2>我的投递</h2>
    <el-table :data="applications" v-loading="loading" border>
      <el-table-column prop="job_title" label="职位" />
      <el-table-column prop="company_name" label="公司" />
      <el-table-column prop="applied_at" label="投递时间" :formatter="formatDate" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getMyApplications } from '@/api/applications'

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 statusLabel = s => STATUS_MAP[s] || s
const statusType = s => STATUS_TYPE[s] || ''
const formatDate = (row, col, val) => val?.slice(0, 10)

onMounted(async () => {
  loading.value = true
  const { data } = await getMyApplications()
  applications.value = data.results
  loading.value = false
})
</script>
  • Step 3: 写 ProfileView.vue
<!-- offer_frontend/src/views/seeker/ProfileView.vue -->
<!-- 注意密码修改需要后端专用接口此页面只修改邮箱和手机号 -->
<template>
  <div>
    <h2>账号设置</h2>
    <el-card style="max-width:480px">
      <el-form :model="form" label-width="80px">
        <el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
        <el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
        <el-button type="primary" @click="save" :loading="saving">保存</el-button>
      </el-form>
    </el-card>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { updateMe } from '@/api/auth'
import { ElMessage } from 'element-plus'

const auth = useAuthStore()
const form = ref({ email: '', phone: '' })
const saving = ref(false)

onMounted(() => { form.value.email = auth.user?.email; form.value.phone = auth.user?.phone })

async function save() {
  saving.value = true
  try {
    await updateMe({ email: form.value.email, phone: form.value.phone })
    ElMessage.success('保存成功')
  } catch { ElMessage.error('保存失败') }
  finally { saving.value = false }
}
</script>
  • Step 4: Commit
git add offer_frontend/src/views/seeker/
git commit -m "feat: add seeker center (resume editor, applications, profile)"

Task 13: 管理后台页面

Files:

  • Create: offer_frontend/src/views/admin/JobManageView.vue

  • Create: offer_frontend/src/views/admin/ApplicationManageView.vue

  • Create: offer_frontend/src/views/admin/OrganizationManageView.vue

  • Create: offer_frontend/src/views/admin/UserManageView.vue

  • Step 1: 写 JobManageView.vue

<!-- offer_frontend/src/views/admin/JobManageView.vue -->
<template>
  <div>
    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
      <h2>职位管理</h2>
      <el-button type="primary" @click="openDialog()">发布职位</el-button>
    </div>
    <el-table :data="jobs" v-loading="loading" border>
      <el-table-column prop="title" label="职位名称" />
      <el-table-column prop="location" label="地点" />
      <el-table-column prop="salary" label="薪资" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-tag :type="row.status === 'published' ? 'success' : row.status === 'draft' ? 'info' : 'danger'">
            {{ { draft:'草稿', published:'已发布', closed:'已关闭' }[row.status] }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="180">
        <template #default="{ row }">
          <el-button size="small" @click="openDialog(row)">编辑</el-button>
          <el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="dialogVisible" :title="editingJob ? '编辑职位' : '发布职位'" width="600px">
      <el-form :model="form" label-width="80px">
        <el-form-item label="职位名称"><el-input v-model="form.title" /></el-form-item>
        <el-form-item label="职位类别"><el-input v-model="form.category" /></el-form-item>
        <el-form-item label="工作地点"><el-input v-model="form.location" /></el-form-item>
        <el-form-item label="薪资范围"><el-input v-model="form.salary" /></el-form-item>
        <el-form-item label="职位描述"><el-input v-model="form.description" type="textarea" :rows="5" /></el-form-item>
        <el-form-item label="状态">
          <el-select v-model="form.status">
            <el-option value="draft" label="草稿" />
            <el-option value="published" label="立即发布" />
          </el-select>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible = false">取消</el-button>
        <el-button type="primary" @click="handleSave" :loading="saving">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageJobs, createJob, updateJob, deleteJob } from '@/api/jobs'
import { ElMessage, ElMessageBox } from 'element-plus'

const jobs = ref([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingJob = ref(null)
const form = reactive({ title: '', category: '', location: '', salary: '', description: '', status: 'draft' })

const fetchJobs = async () => { loading.value = true; const { data } = await manageJobs(); jobs.value = data.results; loading.value = false }

function openDialog(job = null) {
  editingJob.value = job
  if (job) Object.assign(form, job)
  else Object.assign(form, { title: '', category: '', location: '', salary: '', description: '', status: 'draft' })
  dialogVisible.value = true
}

async function handleSave() {
  saving.value = true
  try {
    if (editingJob.value) await updateJob(editingJob.value.id, form)
    else await createJob(form)
    ElMessage.success('保存成功')
    dialogVisible.value = false
    fetchJobs()
  } catch { ElMessage.error('保存失败') }
  finally { saving.value = false }
}

async function handleDelete(id) {
  await ElMessageBox.confirm('确认删除该职位?')
  await deleteJob(id)
  ElMessage.success('已删除')
  fetchJobs()
}

onMounted(fetchJobs)
</script>
  • Step 2: 写 ApplicationManageView.vue查看简历/更新状态)
<!-- offer_frontend/src/views/admin/ApplicationManageView.vue -->
<template>
  <div>
    <h2>投递管理</h2>
    <el-table :data="applications" v-loading="loading" border>
      <el-table-column prop="job_title" label="职位" />
      <el-table-column label="求职者信息">
        <template #default="{ row }">
          {{ row.resume_snapshot?.name }}
        </template>
      </el-table-column>
      <el-table-column prop="applied_at" label="投递时间" :formatter="(r,c,v) => v?.slice(0,10)" />
      <el-table-column prop="status" label="状态">
        <template #default="{ row }">
          <el-select v-model="row.status" size="small" @change="updateStatus(row)">
            <el-option value="pending" label="待查看" />
            <el-option value="viewed" label="已查看" />
            <el-option value="interviewing" label="面试中" />
            <el-option value="hired" label="已录用" />
            <el-option value="rejected" label="已拒绝" />
          </el-select>
        </template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="{ row }">
          <el-button size="small" @click="viewResume(row)">查看简历</el-button>
        </template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="resumeVisible" title="简历详情" width="600px">
      <div v-if="currentResume">
        <p><strong>姓名:</strong>{{ currentResume.name }}</p>
        <p><strong>性别:</strong>{{ currentResume.gender }}</p>
        <el-divider>教育经历</el-divider>
        <div v-for="(e, i) in currentResume.education" :key="i">{{ e.school }} · {{ e.degree }} · {{ e.major }}</div>
        <el-divider>工作经历</el-divider>
        <div v-for="(e, i) in currentResume.experience" :key="i">{{ e.company }} · {{ e.position }} · {{ e.duration }}</div>
        <div v-if="currentResume.attachment_url" style="margin-top:16px">
          <a :href="currentResume.attachment_url" target="_blank">下载简历附件</a>
        </div>
      </div>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getManageApplications, updateApplicationStatus } from '@/api/applications'
import { ElMessage } from 'element-plus'

const applications = ref([])
const loading = ref(false)
const resumeVisible = ref(false)
const currentResume = ref(null)

onMounted(async () => {
  loading.value = true
  const { data } = await getManageApplications()
  applications.value = data.results
  loading.value = false
})

async function updateStatus(row) {
  try {
    await updateApplicationStatus(row.id, { status: row.status })
    ElMessage.success('状态已更新,求职者将收到邮件通知')
  } catch { ElMessage.error('更新失败') }
}

function viewResume(row) {
  currentResume.value = row.resume_snapshot
  resumeVisible.value = true
}
</script>
  • Step 3: 写 OrganizationManageView.vue超管专属
<!-- offer_frontend/src/views/admin/OrganizationManageView.vue -->
<template>
  <div>
    <div style="display:flex;justify-content:space-between;margin-bottom:16px">
      <h2>组织架构管理</h2>
      <el-button type="primary" @click="openDialog()">新增公司</el-button>
    </div>
    <el-table :data="orgs" border>
      <el-table-column prop="name" label="公司名称" />
      <el-table-column label="上级公司">
        <template #default="{ row }">{{ row.parent ? orgs.find(o=>o.id===row.parent)?.name : '(集团)' }}</template>
      </el-table-column>
      <el-table-column prop="email" label="联系邮箱" />
      <el-table-column label="状态">
        <template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'启用':'停用' }}</el-tag></template>
      </el-table-column>
      <el-table-column label="操作" width="120">
        <template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="dialogVisible" :title="editing ? '编辑公司' : '新增公司'" width="480px">
      <el-form :model="form" label-width="90px">
        <el-form-item label="公司名称"><el-input v-model="form.name" /></el-form-item>
        <el-form-item label="上级公司">
          <el-select v-model="form.parent" clearable placeholder="不选则为集团顶级">
            <el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
          </el-select>
        </el-form-item>
        <el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
        <el-form-item label="简介"><el-input v-model="form.description" type="textarea" /></el-form-item>
        <el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible=false">取消</el-button>
        <el-button type="primary" @click="save" :loading="saving">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations, createOrganization, updateOrganization } from '@/api/organizations'
import { ElMessage } from 'element-plus'

const orgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const form = reactive({ name: '', parent: null, email: '', description: '', is_active: true })

const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }

function openDialog(org = null) {
  editing.value = org
  if (org) Object.assign(form, org)
  else Object.assign(form, { name: '', parent: null, email: '', description: '', is_active: true })
  dialogVisible.value = true
}

async function save() {
  saving.value = true
  try {
    if (editing.value) await updateOrganization(editing.value.id, form)
    else await createOrganization(form)
    ElMessage.success('保存成功')
    dialogVisible.value = false
    fetchOrgs()
  } catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(fetchOrgs)
</script>
  • Step 4: 写 UserManageView.vue超管专属
<!-- offer_frontend/src/views/admin/UserManageView.vue -->
<template>
  <div>
    <div style="display:flex;justify-content:space-between;margin-bottom:16px">
      <h2>用户管理</h2>
      <el-button type="primary" @click="openDialog()">新增管理员</el-button>
    </div>
    <el-table :data="users" border>
      <el-table-column prop="username" label="用户名" />
      <el-table-column prop="email" label="邮箱" />
      <el-table-column prop="role" label="角色">
        <template #default="{ row }">
          <el-tag :type="row.role==='superadmin'?'danger':row.role==='admin'?'warning':'info'">
            {{ { superadmin:'超管', admin:'公司管理员', seeker:'求职者' }[row.role] }}
          </el-tag>
        </template>
      </el-table-column>
      <el-table-column label="所属公司">
        <template #default="{ row }">{{ row.organization ? orgs.find(o=>o.id===row.organization)?.name : '-' }}</template>
      </el-table-column>
      <el-table-column label="状态">
        <template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'">{{ row.is_active?'正常':'停用' }}</el-tag></template>
      </el-table-column>
      <el-table-column label="操作" width="100">
        <template #default="{ row }"><el-button size="small" @click="openDialog(row)">编辑</el-button></template>
      </el-table-column>
    </el-table>

    <el-dialog v-model="dialogVisible" :title="editing ? '编辑用户' : '新增管理员'" width="480px">
      <el-form :model="form" label-width="90px">
        <el-form-item label="用户名"><el-input v-model="form.username" /></el-form-item>
        <el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
        <el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
        <el-form-item label="角色">
          <el-select v-model="form.role">
            <el-option value="admin" label="公司管理员" />
            <el-option value="superadmin" label="超级管理员" />
          </el-select>
        </el-form-item>
        <el-form-item label="所属公司" v-if="form.role==='admin'">
          <el-select v-model="form.organization" clearable>
            <el-option v-for="o in orgs" :key="o.id" :value="o.id" :label="o.name" />
          </el-select>
        </el-form-item>
        <el-form-item label="密码" v-if="!editing"><el-input v-model="form.password" type="password" /></el-form-item>
        <el-form-item label="状态"><el-switch v-model="form.is_active" /></el-form-item>
      </el-form>
      <template #footer>
        <el-button @click="dialogVisible=false">取消</el-button>
        <el-button type="primary" @click="save" :loading="saving">保存</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { manageOrganizations } from '@/api/organizations'
import client from '@/api/client'
import { ElMessage } from 'element-plus'

const users = ref([])
const orgs = ref([])
const dialogVisible = ref(false)
const editing = ref(null)
const saving = ref(false)
const form = reactive({ username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })

const fetchUsers = async () => { const { data } = await client.get('/auth/users/'); users.value = data.results || data }
const fetchOrgs = async () => { const { data } = await manageOrganizations(); orgs.value = data.results }

function openDialog(user = null) {
  editing.value = user
  if (user) Object.assign(form, user)
  else Object.assign(form, { username: '', email: '', phone: '', role: 'admin', organization: null, password: '', is_active: true })
  dialogVisible.value = true
}

async function save() {
  saving.value = true
  try {
    if (editing.value) await client.patch(`/auth/users/${editing.value.id}/`, form)
    else await client.post('/auth/users/', form)
    ElMessage.success('保存成功')
    dialogVisible.value = false
    fetchUsers()
  } catch { ElMessage.error('保存失败') } finally { saving.value = false }
}
onMounted(() => { fetchUsers(); fetchOrgs() })
</script>
  • Step 5: Commit
git add offer_frontend/src/views/admin/
git commit -m "feat: add admin management views (jobs, applications, organizations, users)"

Task 14: 收尾 — App.vue + 整体联调

Files:

  • Modify: offer_frontend/src/App.vue

  • Step 1: 写 App.vue

<!-- offer_frontend/src/App.vue -->
<template>
  <router-view />
</template>
  • Step 2: 创建超管账号
cd offer_backend
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py shell -c "
from apps.accounts.models import User
User.objects.create_user(username='superadmin', password='admin123', role='superadmin', email='admin@offer.com')
print('超管账号创建成功')
"
  • Step 3: 同时启动前后端,联调验证
# 终端1后端
cd offer_backend
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py runserver

# 终端2前端
cd offer_frontend
npm run dev

联调检查清单:

  • 求职者注册 → 登录 → 跳转到 /seeker/resume

  • 超管登录 → 跳转到 /admin/jobs

  • 超管创建组织架构 → 创建公司管理员账号

  • 公司管理员登录 → 发布职位

  • 公开页面搜索到已发布职位

  • 求职者填写简历 → 投递职位 → 状态变为"待查看"

  • 公司管理员查看投递 → 更新状态 → 求职者收到邮件console 后端可见)

  • Step 4: 最终 Commit

git add .
git commit -m "feat: complete recruitment website MVP"