2803 lines
89 KiB
Markdown
2803 lines
89 KiB
Markdown
# 集团招聘网站实施计划
|
||
|
||
> **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: 创建目录结构**
|
||
|
||
```bash
|
||
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 项目**
|
||
|
||
```bash
|
||
cd offer_backend
|
||
pip install -r requirements.txt
|
||
django-admin startproject config .
|
||
```
|
||
|
||
- [ ] **Step 4: 写 config/settings/base.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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 文件**
|
||
|
||
```bash
|
||
# 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: 验证启动**
|
||
|
||
```bash
|
||
cd offer_backend
|
||
DJANGO_SETTINGS_MODULE=config.settings.development python manage.py check
|
||
```
|
||
Expected: `System check identified no issues`
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
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
|
||
```
|
||
|
||
```bash
|
||
cd offer_backend
|
||
pip install pytest pytest-django
|
||
```
|
||
|
||
- [ ] **Step 2: 写 pytest.ini**
|
||
|
||
```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**
|
||
|
||
```python
|
||
# offer_backend/conftest.py
|
||
import pytest
|
||
from django.test import Client
|
||
|
||
@pytest.fixture
|
||
def client():
|
||
return Client()
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd offer_backend
|
||
python manage.py startapp accounts apps/accounts
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
```python
|
||
# 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: 运行确认失败**
|
||
|
||
```bash
|
||
cd offer_backend
|
||
DJANGO_SETTINGS_MODULE=config.settings.development pytest apps/accounts/tests/test_auth.py -v
|
||
```
|
||
Expected: `ImportError` 或 `ModuleNotFoundError`
|
||
|
||
- [ ] **Step 4: 写 models.py**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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: 运行迁移并测试**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
cd offer_backend
|
||
python manage.py startapp organizations apps/organizations
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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]
|
||
```
|
||
|
||
```python
|
||
# 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: 迁移并测试**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
python manage.py startapp jobs apps/jobs
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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']
|
||
```
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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: 迁移并测试**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
python manage.py startapp resumes apps/resumes
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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']
|
||
```
|
||
|
||
```python
|
||
# 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
|
||
```
|
||
|
||
```python
|
||
# offer_backend/apps/resumes/urls.py
|
||
from django.urls import path
|
||
from .views import MyResumeView
|
||
|
||
urlpatterns = [
|
||
path('me/', MyResumeView.as_view()),
|
||
]
|
||
```
|
||
|
||
- [ ] **Step 5: 迁移并测试**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
python manage.py startapp applications apps/applications
|
||
```
|
||
|
||
- [ ] **Step 2: 写失败测试**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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**
|
||
|
||
```python
|
||
# 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']
|
||
```
|
||
|
||
```python
|
||
# 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)
|
||
```
|
||
|
||
```python
|
||
# 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: 迁移并测试**
|
||
|
||
```bash
|
||
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: 全量后端测试**
|
||
|
||
```bash
|
||
DJANGO_SETTINGS_MODULE=config.settings.development pytest --tb=short
|
||
```
|
||
Expected: All tests PASSED
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
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 项目**
|
||
|
||
```bash
|
||
cd C:/code/offer
|
||
npm create vite@latest offer_frontend -- --template vue
|
||
cd offer_frontend
|
||
npm install
|
||
```
|
||
|
||
- [ ] **Step 2: 安装依赖**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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 拦截器)**
|
||
|
||
```js
|
||
// 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 文件**
|
||
|
||
```js
|
||
// 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**
|
||
|
||
```js
|
||
// 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
|
||
},
|
||
},
|
||
})
|
||
```
|
||
|
||
```js
|
||
// 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**
|
||
|
||
```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: 验证启动**
|
||
|
||
```bash
|
||
cd offer_frontend
|
||
npm run dev
|
||
```
|
||
Expected: Vite 启动成功,浏览器打开无报错
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
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: 写路由配置(含守卫)**
|
||
|
||
```js
|
||
// 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**
|
||
|
||
```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**
|
||
|
||
```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>
|
||
```
|
||
|
||
```vue
|
||
<!-- 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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(搜索+列表)**
|
||
|
||
```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(职位详情+投递)**
|
||
|
||
```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(简版)**
|
||
|
||
```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>
|
||
```
|
||
|
||
```vue
|
||
<!-- 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>
|
||
```
|
||
|
||
```vue
|
||
<!-- 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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(在线简历编辑)**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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(查看简历/更新状态)**
|
||
|
||
```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(超管专属)**
|
||
|
||
```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(超管专属)**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```vue
|
||
<!-- offer_frontend/src/App.vue -->
|
||
<template>
|
||
<router-view />
|
||
</template>
|
||
```
|
||
|
||
- [ ] **Step 2: 创建超管账号**
|
||
|
||
```bash
|
||
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: 同时启动前后端,联调验证**
|
||
|
||
```bash
|
||
# 终端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**
|
||
|
||
```bash
|
||
git add .
|
||
git commit -m "feat: complete recruitment website MVP"
|
||
```
|