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

2803 lines
89 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 集团招聘网站实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 构建集团招聘网站,支持多公司多角色管理、职位发布、简历投递全流程。
**Architecture:** Django 4.2 + DRF 后端提供 RESTful APIVue3 单页应用前端分三个区域(公开门户/求职者中心/管理后台JWT 认证PostgreSQL 存储业务数据。
**Tech Stack:** Django 4.2, DRF, simplejwt, PostgreSQL, Redis, Vue3, Vite, Pinia, Element Plus
---
## 文件结构总览
```
offer/
├── offer_backend/
│ ├── config/
│ │ ├── settings/
│ │ │ ├── base.py
│ │ │ ├── development.py
│ │ │ └── production.py
│ │ ├── urls.py
│ │ └── wsgi.py
│ ├── apps/
│ │ ├── accounts/
│ │ │ ├── models.py # 自定义 User 模型 + 角色
│ │ │ ├── serializers.py # 登录/注册/用户序列化
│ │ │ ├── views.py # JWT 登录、注册、用户管理
│ │ │ ├── permissions.py # IsSuperAdmin, IsAdmin, IsSeeker
│ │ │ ├── urls.py
│ │ │ └── tests/
│ │ │ └── test_auth.py
│ │ ├── organizations/
│ │ │ ├── models.py # Organization自关联树
│ │ │ ├── serializers.py
│ │ │ ├── views.py
│ │ │ ├── urls.py
│ │ │ └── tests/
│ │ │ └── test_organizations.py
│ │ ├── jobs/
│ │ │ ├── models.py # Job职位
│ │ │ ├── serializers.py
│ │ │ ├── views.py
│ │ │ ├── filters.py # django-filter 搜索过滤
│ │ │ ├── urls.py
│ │ │ └── tests/
│ │ │ └── test_jobs.py
│ │ ├── resumes/
│ │ │ ├── models.py # ResumeJSONB 教育/工作经历)
│ │ │ ├── serializers.py
│ │ │ ├── views.py
│ │ │ ├── urls.py
│ │ │ └── tests/
│ │ │ └── test_resumes.py
│ │ └── applications/
│ │ ├── models.py # Applicationresume_snapshot
│ │ ├── serializers.py
│ │ ├── views.py
│ │ ├── emails.py # 邮件通知发送
│ │ ├── urls.py
│ │ └── tests/
│ │ └── test_applications.py
│ ├── manage.py
│ └── requirements.txt
└── offer_frontend/
├── src/
│ ├── api/
│ │ ├── client.js # axios 实例 + JWT 拦截器
│ │ ├── auth.js
│ │ ├── organizations.js
│ │ ├── jobs.js
│ │ ├── resumes.js
│ │ └── applications.js
│ ├── router/
│ │ └── index.js # 路由 + 守卫
│ ├── stores/
│ │ ├── auth.js # 用户状态 + token
│ │ └── job.js # 职位搜索状态
│ ├── layouts/
│ │ ├── PortalLayout.vue
│ │ ├── SeekerLayout.vue
│ │ └── AdminLayout.vue
│ ├── views/
│ │ ├── portal/
│ │ │ ├── HomeView.vue
│ │ │ ├── JobListView.vue
│ │ │ ├── JobDetailView.vue
│ │ │ ├── CompanyListView.vue
│ │ │ └── CompanyDetailView.vue
│ │ ├── auth/
│ │ │ ├── LoginView.vue
│ │ │ └── RegisterView.vue
│ │ ├── seeker/
│ │ │ ├── ResumeView.vue
│ │ │ ├── ApplicationsView.vue
│ │ │ └── ProfileView.vue
│ │ └── admin/
│ │ ├── JobManageView.vue
│ │ ├── ApplicationManageView.vue
│ │ ├── OrganizationManageView.vue
│ │ └── UserManageView.vue
│ ├── components/
│ │ ├── JobCard.vue
│ │ └── CompanyCard.vue
│ ├── App.vue
│ └── main.js
├── index.html
├── vite.config.js
└── package.json
```
---
## Task 1: 初始化 Django 后端项目
**Files:**
- Create: `offer_backend/requirements.txt`
- Create: `offer_backend/manage.py`
- Create: `offer_backend/config/settings/base.py`
- Create: `offer_backend/config/settings/development.py`
- Create: `offer_backend/config/urls.py`
- [ ] **Step 1: 创建目录结构**
```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 3: accounts app — 自定义用户模型
**Files:**
- Create: `offer_backend/apps/accounts/models.py`
- Create: `offer_backend/apps/accounts/tests/test_auth.py`
- Create: `offer_backend/apps/accounts/serializers.py`
- Create: `offer_backend/apps/accounts/views.py`
- Create: `offer_backend/apps/accounts/permissions.py`
- Create: `offer_backend/apps/accounts/urls.py`
- [ ] **Step 1: 创建 accounts app**
```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.jsaxios + 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"
```