89 KiB
集团招聘网站实施计划
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 API,Vue3 单页应用前端分三个区域(公开门户/求职者中心/管理后台),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 # Resume(JSONB 教育/工作经历)
│ │ │ ├── serializers.py
│ │ │ ├── views.py
│ │ │ ├── urls.py
│ │ │ └── tests/
│ │ │ └── test_resumes.py
│ │ └── applications/
│ │ ├── models.py # Application(resume_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 4: 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: ImportError 或 ModuleNotFoundError
- 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.js(axios + 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"