From a5959f9a5b6e8ecafa12ae950265f3ca7d48904e Mon Sep 17 00:00:00 2001 From: TianyangZhang Date: Tue, 24 Mar 2026 16:50:58 +0800 Subject: [PATCH] docs: add recruitment website implementation plan --- .../plans/2026-03-24-recruitment-website.md | 2802 +++++++++++++++++ 1 file changed, 2802 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-recruitment-website.md diff --git a/docs/superpowers/plans/2026-03-24-recruitment-website.md b/docs/superpowers/plans/2026-03-24-recruitment-website.md new file mode 100644 index 0000000..c35ee50 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-recruitment-website.md @@ -0,0 +1,2802 @@ +# 集团招聘网站实施计划 + +> **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//', 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//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 + + + + +``` + +- [ ] **Step 3: 写 SeekerLayout.vue 和 AdminLayout.vue** + +```vue + + + +``` + +```vue + + + +``` + +- [ ] **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 + + + + +``` + +- [ ] **Step 2: 写 JobListView.vue(搜索+列表)** + +```vue + + + + +``` + +- [ ] **Step 3: 写 JobDetailView.vue(职位详情+投递)** + +```vue + + + + +``` + +- [ ] **Step 4: 写 HomeView.vue、CompanyListView.vue、CompanyDetailView.vue(简版)** + +```vue + + + +``` + +```vue + + + +``` + +```vue + + + +``` + +- [ ] **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 + + + + +``` + +- [ ] **Step 2: 写 RegisterView.vue** + +```vue + + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **Step 2: 写 ApplicationsView.vue** + +```vue + + + +``` + +- [ ] **Step 3: 写 ProfileView.vue** + +```vue + + + + +``` + +- [ ] **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 + + + +``` + +- [ ] **Step 2: 写 ApplicationManageView.vue(查看简历/更新状态)** + +```vue + + + +``` + +- [ ] **Step 3: 写 OrganizationManageView.vue(超管专属)** + +```vue + + + +``` + +- [ ] **Step 4: 写 UserManageView.vue(超管专属)** + +```vue + + + +``` + +- [ ] **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 + + +``` + +- [ ] **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" +```