From 7c7c89171f17ef05e6288da9adc6e1538ca42782 Mon Sep 17 00:00:00 2001 From: Zhenyu Zheng Date: Wed, 25 Feb 2026 23:57:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B4=BB=E5=8A=A8=E7=AE=A1=E7=90=86=20?= =?UTF-8?q?SOP=20=E6=B8=85=E5=8D=95=E5=85=A8=E5=8A=9F=E8=83=BD=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - ChecklistItem/ChecklistTemplateItem 新增 SOP 字段(is_mandatory、responsible_role、description、reference_url、completed_at、deadline_offset_days、estimated_hours) - events.py 新增 POST/DELETE /events/{id}/checklist 端点,扩展 ChecklistItemUpdate 字段 - event_templates.py 新增模板条目 CRUD(POST/PATCH/DELETE /event-templates/{id}/items)及模板 DELETE 端点,修复列表/详情/更新权限校验 - 上传测试修复:autouse fixture 隔离 boto3/S3 依赖 - 新增 Alembic migration(sop_fields + reference_url) 前端: - EventDetail.vue:清单 Tab 支持手动添加/编辑/删除单条清单项;新增「从模板导入」对话框(预览条目 + 按偏移量计算 due_date) - Events.vue:创建活动表单新增 SOP 模板选择器 - EventTemplates.vue:新建 SOP 模板管理页面,支持条目拖拽排序(vuedraggable) - App.vue:活动管理改为子菜单,新增 SOP 模板入口 - frontend/Dockerfile:npm ci + BuildKit 缓存加速,基础镜像固定版本 - 路由新增 /event-templates 测试: - test_events_api.py 新增 TestChecklistCRUD(POST/PATCH/DELETE 全路径)、TestChecklistCompletedAt、TestCreateEventFromTemplate - test_event_templates_api.py 完整覆盖权限、条目 CRUD、删除模板 --- ...e6_add_reference_url_to_checklist_items.py | 34 + ...a13ac_add_sop_fields_to_checklist_items.py | 54 ++ backend/app/api/event_templates.py | 106 ++- backend/app/api/events.py | 49 ++ backend/app/models/event.py | 10 + backend/app/schemas/event.py | 46 ++ backend/tests/test_event_templates_api.py | 433 +++++++++- backend/tests/test_events_api.py | 539 +++++++++++- backend/tests/test_upload_api.py | 11 + frontend/Dockerfile | 12 +- frontend/package-lock.json | 21 +- frontend/package.json | 3 +- frontend/src/App.vue | 20 +- frontend/src/api/event.ts | 140 +++- frontend/src/router/index.ts | 6 + frontend/src/views/EventDetail.vue | 548 +++++++++++- frontend/src/views/EventTemplates.vue | 777 ++++++++++++++++++ frontend/src/views/Events.vue | 40 +- 18 files changed, 2801 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/74ae746f13e6_add_reference_url_to_checklist_items.py create mode 100644 backend/alembic/versions/b465db6a13ac_add_sop_fields_to_checklist_items.py create mode 100644 frontend/src/views/EventTemplates.vue diff --git a/backend/alembic/versions/74ae746f13e6_add_reference_url_to_checklist_items.py b/backend/alembic/versions/74ae746f13e6_add_reference_url_to_checklist_items.py new file mode 100644 index 0000000..b599c5e --- /dev/null +++ b/backend/alembic/versions/74ae746f13e6_add_reference_url_to_checklist_items.py @@ -0,0 +1,34 @@ +"""add_reference_url_to_checklist_items + +Revision ID: 74ae746f13e6 +Revises: b465db6a13ac +Create Date: 2026-02-25 22:21:23.947814 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '74ae746f13e6' +down_revision: Union[str, None] = 'b465db6a13ac' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('checklist_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('reference_url', sa.String(length=500), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('checklist_items', schema=None) as batch_op: + batch_op.drop_column('reference_url') + + # ### end Alembic commands ### diff --git a/backend/alembic/versions/b465db6a13ac_add_sop_fields_to_checklist_items.py b/backend/alembic/versions/b465db6a13ac_add_sop_fields_to_checklist_items.py new file mode 100644 index 0000000..29523ba --- /dev/null +++ b/backend/alembic/versions/b465db6a13ac_add_sop_fields_to_checklist_items.py @@ -0,0 +1,54 @@ +"""add_sop_fields_to_checklist_items + +Revision ID: b465db6a13ac +Revises: ffbd6edaf13b +Create Date: 2026-02-25 21:59:55.302075 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b465db6a13ac' +down_revision: Union[str, None] = 'ffbd6edaf13b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('checklist_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('description', sa.Text(), nullable=True)) + batch_op.add_column(sa.Column('is_mandatory', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('responsible_role', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True)) + + with op.batch_alter_table('checklist_template_items', schema=None) as batch_op: + batch_op.add_column(sa.Column('is_mandatory', sa.Boolean(), nullable=True)) + batch_op.add_column(sa.Column('responsible_role', sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column('deadline_offset_days', sa.Integer(), nullable=True)) + batch_op.add_column(sa.Column('estimated_hours', sa.Float(), nullable=True)) + batch_op.add_column(sa.Column('reference_url', sa.String(length=500), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('checklist_template_items', schema=None) as batch_op: + batch_op.drop_column('reference_url') + batch_op.drop_column('estimated_hours') + batch_op.drop_column('deadline_offset_days') + batch_op.drop_column('responsible_role') + batch_op.drop_column('is_mandatory') + + with op.batch_alter_table('checklist_items', schema=None) as batch_op: + batch_op.drop_column('completed_at') + batch_op.drop_column('responsible_role') + batch_op.drop_column('is_mandatory') + batch_op.drop_column('description') + + # ### end Alembic commands ### diff --git a/backend/app/api/event_templates.py b/backend/app/api/event_templates.py index 964c756..dee1f45 100644 --- a/backend/app/api/event_templates.py +++ b/backend/app/api/event_templates.py @@ -1,11 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import or_ from sqlalchemy.orm import Session from app.core.dependencies import get_current_user from app.database import get_db from app.models import User from app.models.event import ChecklistTemplateItem, EventTemplate -from app.schemas.event import EventTemplateCreate, EventTemplateListOut, EventTemplateOut, EventTemplateUpdate +from app.schemas.event import ( + ChecklistTemplateItemCreate, + ChecklistTemplateItemOut, + ChecklistTemplateItemUpdate, + EventTemplateCreate, + EventTemplateListOut, + EventTemplateOut, + EventTemplateUpdate, +) router = APIRouter() @@ -17,7 +26,12 @@ def list_templates( ): return ( db.query(EventTemplate) - .filter(EventTemplate.is_public == True) # noqa: E712 + .filter( + or_( + EventTemplate.is_public == True, # noqa: E712 + EventTemplate.created_by_id == current_user.id, + ) + ) .order_by(EventTemplate.created_at.desc()) .all() ) @@ -58,7 +72,7 @@ def get_template( template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() if not template: raise HTTPException(404, "模板不存在") - if not template.is_public: + if not template.is_public and template.created_by_id != current_user.id: raise HTTPException(403, "无权访问此模板") return template @@ -73,8 +87,94 @@ def update_template( template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() if not template: raise HTTPException(404, "模板不存在") + if template.created_by_id != current_user.id and not current_user.is_superuser: + raise HTTPException(403, "无权修改此模板") for key, value in data.model_dump(exclude_unset=True).items(): setattr(template, key, value) db.commit() db.refresh(template) return template + + +@router.delete("/{template_id}", status_code=204) +def delete_template( + template_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() + if not template: + raise HTTPException(404, "模板不存在") + if template.created_by_id != current_user.id and not current_user.is_superuser: + raise HTTPException(403, "无权删除此模板") + db.delete(template) + db.commit() + + +# ─── Template Checklist Item CRUD ───────────────────────────────────────────── + +@router.post("/{template_id}/items", response_model=ChecklistTemplateItemOut, status_code=201) +def add_template_item( + template_id: int, + data: ChecklistTemplateItemCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() + if not template: + raise HTTPException(404, "模板不存在") + if template.created_by_id != current_user.id and not current_user.is_superuser: + raise HTTPException(403, "无权修改此模板") + item = ChecklistTemplateItem(template_id=template_id, **data.model_dump()) + db.add(item) + db.commit() + db.refresh(item) + return item + + +@router.patch("/{template_id}/items/{item_id}", response_model=ChecklistTemplateItemOut) +def update_template_item( + template_id: int, + item_id: int, + data: ChecklistTemplateItemUpdate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() + if not template: + raise HTTPException(404, "模板不存在") + if template.created_by_id != current_user.id and not current_user.is_superuser: + raise HTTPException(403, "无权修改此模板") + item = db.query(ChecklistTemplateItem).filter( + ChecklistTemplateItem.id == item_id, + ChecklistTemplateItem.template_id == template_id, + ).first() + if not item: + raise HTTPException(404, "条目不存在") + for key, value in data.model_dump(exclude_unset=True).items(): + setattr(item, key, value) + db.commit() + db.refresh(item) + return item + + +@router.delete("/{template_id}/items/{item_id}", status_code=204) +def delete_template_item( + template_id: int, + item_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + template = db.query(EventTemplate).filter(EventTemplate.id == template_id).first() + if not template: + raise HTTPException(404, "模板不存在") + if template.created_by_id != current_user.id and not current_user.is_superuser: + raise HTTPException(403, "无权修改此模板") + item = db.query(ChecklistTemplateItem).filter( + ChecklistTemplateItem.id == item_id, + ChecklistTemplateItem.template_id == template_id, + ).first() + if not item: + raise HTTPException(404, "条目不存在") + db.delete(item) + db.commit() diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 20faf0b..35e8b8f 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -1,7 +1,10 @@ +from datetime import timedelta + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from app.core.dependencies import get_current_user +from app.core.timezone import utc_now from app.database import get_db from app.models import User from app.models.community import Community @@ -16,6 +19,7 @@ IssueLink, ) from app.schemas.event import ( + ChecklistItemCreate, ChecklistItemOut, ChecklistItemUpdate, EventCreate, @@ -99,10 +103,18 @@ def create_event( template = db.query(EventTemplate).filter(EventTemplate.id == data.template_id).first() if template: for titem in template.checklist_items: + due = None + if titem.deadline_offset_days is not None and event.planned_at: + due = (event.planned_at + timedelta(days=titem.deadline_offset_days)).date() db.add(ChecklistItem( event_id=event.id, phase=titem.phase, title=titem.title, + description=titem.description, + is_mandatory=titem.is_mandatory, + responsible_role=titem.responsible_role, + reference_url=titem.reference_url, + due_date=due, order=titem.order, )) @@ -206,11 +218,48 @@ def update_checklist_item( raise HTTPException(404, "检查项不存在") for key, value in data.model_dump(exclude_unset=True).items(): setattr(item, key, value) + if data.status == "done" and item.completed_at is None: + item.completed_at = utc_now() + elif data.status in ("pending", "skipped"): + item.completed_at = None + db.commit() + db.refresh(item) + return item + + +@router.post("/{event_id}/checklist", response_model=ChecklistItemOut, status_code=201) +def create_checklist_item( + event_id: int, + data: ChecklistItemCreate, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(404, "活动不存在") + item = ChecklistItem(event_id=event_id, **data.model_dump()) + db.add(item) db.commit() db.refresh(item) return item +@router.delete("/{event_id}/checklist/{item_id}", status_code=204) +def delete_checklist_item( + event_id: int, + item_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + item = db.query(ChecklistItem).filter( + ChecklistItem.id == item_id, ChecklistItem.event_id == event_id + ).first() + if not item: + raise HTTPException(404, "检查项不存在") + db.delete(item) + db.commit() + + # ─── Personnel ──────────────────────────────────────────────────────────────── @router.get("/{event_id}/personnel", response_model=list[EventPersonnelOut]) diff --git a/backend/app/models/event.py b/backend/app/models/event.py index 1d2be65..006dafa 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -51,6 +51,11 @@ class ChecklistTemplateItem(Base): ) title = Column(String(300), nullable=False) description = Column(Text, nullable=True) + is_mandatory = Column(Boolean, default=False) + responsible_role = Column(String(100), nullable=True) + deadline_offset_days = Column(Integer, nullable=True) + estimated_hours = Column(Float, nullable=True) + reference_url = Column(String(500), nullable=True) order = Column(Integer, default=0) template = relationship("EventTemplate", back_populates="checklist_items") @@ -127,12 +132,17 @@ class ChecklistItem(Base): SAEnum("pre", "during", "post", name="checklist_item_phase_enum"), nullable=False ) title = Column(String(300), nullable=False) + description = Column(Text, nullable=True) + is_mandatory = Column(Boolean, default=False) + responsible_role = Column(String(100), nullable=True) + reference_url = Column(String(500), nullable=True) status = Column( SAEnum("pending", "done", "skipped", name="checklist_status_enum"), default="pending" ) assignee_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) due_date = Column(Date, nullable=True) notes = Column(Text, nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) order = Column(Integer, default=0) event = relationship("Event", back_populates="checklist_items") diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 6520290..451bcb9 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -8,14 +8,36 @@ class ChecklistTemplateItemCreate(BaseModel): phase: str # pre / during / post title: str description: str | None = None + is_mandatory: bool = False + responsible_role: str | None = None + deadline_offset_days: int | None = None + estimated_hours: float | None = None + reference_url: str | None = None order: int = 0 +class ChecklistTemplateItemUpdate(BaseModel): + phase: str | None = None + title: str | None = None + description: str | None = None + is_mandatory: bool | None = None + responsible_role: str | None = None + deadline_offset_days: int | None = None + estimated_hours: float | None = None + reference_url: str | None = None + order: int | None = None + + class ChecklistTemplateItemOut(BaseModel): id: int phase: str title: str description: str | None + is_mandatory: bool + responsible_role: str | None + deadline_offset_days: int | None + estimated_hours: float | None + reference_url: str | None order: int model_config = {"from_attributes": True} @@ -178,20 +200,44 @@ class ChecklistItemOut(BaseModel): id: int phase: str title: str + description: str | None + is_mandatory: bool + responsible_role: str | None + reference_url: str | None status: str assignee_id: int | None due_date: date | None notes: str | None + completed_at: datetime | None order: int model_config = {"from_attributes": True} +class ChecklistItemCreate(BaseModel): + phase: str + title: str + description: str | None = None + is_mandatory: bool = False + responsible_role: str | None = None + reference_url: str | None = None + due_date: date | None = None + notes: str | None = None + order: int = 0 + + class ChecklistItemUpdate(BaseModel): + phase: str | None = None + title: str | None = None + description: str | None = None + is_mandatory: bool | None = None + responsible_role: str | None = None + reference_url: str | None = None status: str | None = None assignee_id: int | None = None due_date: date | None = None notes: str | None = None + order: int | None = None # ─── Event Personnel ────────────────────────────────────────────────────────── diff --git a/backend/tests/test_event_templates_api.py b/backend/tests/test_event_templates_api.py index b48cfca..4f8a155 100644 --- a/backend/tests/test_event_templates_api.py +++ b/backend/tests/test_event_templates_api.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.models.community import Community -from app.models.event import EventTemplate +from app.models.event import ChecklistTemplateItem, EventTemplate from app.models.user import User @@ -14,12 +14,14 @@ def _create_template( name: str = "测试模板", event_type: str = "online", is_public: bool = False, + created_by_id: int | None = None, ) -> EventTemplate: t = EventTemplate( community_id=community_id, name=name, event_type=event_type, is_public=is_public, + created_by_id=created_by_id, ) db_session.add(t) db_session.commit() @@ -27,41 +29,99 @@ def _create_template( return t +def _create_template_item( + db_session: Session, + template_id: int, + phase: str = "pre", + title: str = "准备工作", + order: int = 1, +) -> ChecklistTemplateItem: + item = ChecklistTemplateItem( + template_id=template_id, + phase=phase, + title=title, + order=order, + ) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + return item + + class TestListEventTemplates: def test_list_empty(self, client: TestClient, auth_headers: dict): resp = client.get("/api/event-templates", headers=auth_headers) assert resp.status_code == 200 assert resp.json() == [] - def test_list_returns_public_templates_only( + def test_list_includes_public_templates( self, client: TestClient, auth_headers: dict, db_session: Session, test_community: Community, ): - """列表仅返回公开模板,私有模板不出现""" - _create_template(db_session, test_community.id, name="私有模板", is_public=False) + """公开模板对所有用户可见""" _create_template(db_session, test_community.id, name="公开模板", is_public=True) resp = client.get("/api/event-templates", headers=auth_headers) assert resp.status_code == 200 names = [t["name"] for t in resp.json()] assert "公开模板" in names - assert "私有模板" not in names - def test_list_includes_public_templates( + def test_list_excludes_other_users_private_templates( self, client: TestClient, auth_headers: dict, db_session: Session, test_another_community: Community, ): - """公开模板对所有社区可见""" - _create_template(db_session, test_another_community.id, name="公开模板", is_public=True) + """其他用户的私有模板不在列表中""" + _create_template( + db_session, + test_another_community.id, + name="他人私有模板", + is_public=False, + created_by_id=9999, # 不存在的用户 id + ) resp = client.get("/api/event-templates", headers=auth_headers) assert resp.status_code == 200 names = [t["name"] for t in resp.json()] - assert "公开模板" in names + assert "他人私有模板" not in names + + def test_list_includes_own_private_templates( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """自己创建的私有模板也出现在列表中""" + _create_template( + db_session, + test_community.id, + name="我的私有模板", + is_public=False, + created_by_id=test_user.id, + ) + resp = client.get("/api/event-templates", headers=auth_headers) + assert resp.status_code == 200 + names = [t["name"] for t in resp.json()] + assert "我的私有模板" in names + + def test_list_public_template_from_another_community( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_another_community: Community, + ): + """其他社区的公开模板对所有社区可见""" + _create_template(db_session, test_another_community.id, name="他社区公开模板", is_public=True) + resp = client.get("/api/event-templates", headers=auth_headers) + assert resp.status_code == 200 + names = [t["name"] for t in resp.json()] + assert "他社区公开模板" in names class TestCreateEventTemplate: @@ -105,9 +165,42 @@ def test_create_template_with_checklist_items( assert "pre" in phases assert "during" in phases + def test_create_template_with_sop_fields( + self, + client: TestClient, + auth_headers: dict, + ): + """创建模板时可以携带 SOP 字段""" + payload = { + "name": "SOP 模板", + "event_type": "online", + "checklist_items": [ + { + "phase": "pre", + "title": "通知嘉宾", + "order": 1, + "is_mandatory": True, + "responsible_role": "主办方", + "deadline_offset_days": -7, + "estimated_hours": 2.5, + "reference_url": "https://example.com/guide", + "description": "提前一周通知所有嘉宾", + }, + ], + } + resp = client.post("/api/event-templates", json=payload, headers=auth_headers) + assert resp.status_code == 201 + data = resp.json() + item = data["checklist_items"][0] + assert item["is_mandatory"] is True + assert item["responsible_role"] == "主办方" + assert item["deadline_offset_days"] == -7 + assert item["estimated_hours"] == 2.5 + assert item["reference_url"] == "https://example.com/guide" + class TestGetEventTemplate: - def test_get_existing_template( + def test_get_existing_public_template( self, client: TestClient, auth_headers: dict, @@ -120,6 +213,22 @@ def test_get_existing_template( assert resp.status_code == 200 assert resp.json()["id"] == t.id + def test_get_own_private_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """创建者可以访问自己的私有模板""" + t = _create_template( + db_session, test_community.id, is_public=False, created_by_id=test_user.id + ) + resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["id"] == t.id + def test_get_public_template_from_other_community( self, client: TestClient, @@ -131,14 +240,20 @@ def test_get_public_template_from_other_community( resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) assert resp.status_code == 200 - def test_get_private_template_from_other_community_forbidden( + def test_get_other_users_private_template_forbidden( self, client: TestClient, auth_headers: dict, db_session: Session, test_another_community: Community, ): - t = _create_template(db_session, test_another_community.id, is_public=False) + """其他用户的私有模板无权访问""" + t = _create_template( + db_session, + test_another_community.id, + is_public=False, + created_by_id=9999, + ) resp = client.get(f"/api/event-templates/{t.id}", headers=auth_headers) assert resp.status_code == 403 @@ -152,14 +267,18 @@ def test_get_nonexistent_template( class TestUpdateEventTemplate: - def test_update_template_name( + def test_update_own_template( self, client: TestClient, auth_headers: dict, db_session: Session, test_community: Community, + test_user: User, ): - t = _create_template(db_session, test_community.id, name="旧名称") + """创建者可以修改自己的模板""" + t = _create_template( + db_session, test_community.id, name="旧名称", created_by_id=test_user.id + ) resp = client.patch( f"/api/event-templates/{t.id}", json={"name": "新名称"}, @@ -168,6 +287,27 @@ def test_update_template_name( assert resp.status_code == 200 assert resp.json()["name"] == "新名称" + def test_update_other_users_template_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """非创建者无权修改模板""" + t = _create_template( + db_session, + test_community.id, + name="他人模板", + created_by_id=9999, + ) + resp = client.patch( + f"/api/event-templates/{t.id}", + json={"name": "改名"}, + headers=auth_headers, + ) + assert resp.status_code == 403 + def test_update_nonexistent_template( self, client: TestClient, @@ -179,3 +319,268 @@ def test_update_nonexistent_template( headers=auth_headers, ) assert resp.status_code == 404 + + def test_superuser_can_update_any_template( + self, + client: TestClient, + superuser_auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """超级管理员可以修改任意模板""" + t = _create_template( + db_session, + test_community.id, + name="原名称", + created_by_id=9999, + ) + resp = client.patch( + f"/api/event-templates/{t.id}", + json={"name": "超管改名"}, + headers=superuser_auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["name"] == "超管改名" + + +class TestDeleteEventTemplate: + def test_delete_own_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """创建者可以删除自己的模板""" + t = _create_template( + db_session, test_community.id, created_by_id=test_user.id + ) + resp = client.delete(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 204 + assert db_session.query(EventTemplate).filter(EventTemplate.id == t.id).first() is None + + def test_delete_other_users_template_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """非创建者无权删除模板""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + resp = client.delete(f"/api/event-templates/{t.id}", headers=auth_headers) + assert resp.status_code == 403 + + def test_delete_nonexistent_template( + self, + client: TestClient, + auth_headers: dict, + ): + resp = client.delete("/api/event-templates/99999", headers=auth_headers) + assert resp.status_code == 404 + + def test_superuser_can_delete_any_template( + self, + client: TestClient, + superuser_auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """超级管理员可以删除任意模板""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + resp = client.delete(f"/api/event-templates/{t.id}", headers=superuser_auth_headers) + assert resp.status_code == 204 + + +class TestTemplateItemCRUD: + def test_add_item_to_own_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """创建者可以向自己的模板添加条目""" + t = _create_template( + db_session, test_community.id, created_by_id=test_user.id + ) + payload = { + "phase": "pre", + "title": "新条目", + "order": 1, + "is_mandatory": True, + "responsible_role": "策划", + "deadline_offset_days": -3, + "estimated_hours": 1.0, + "reference_url": "https://example.com", + "description": "条目说明", + } + resp = client.post( + f"/api/event-templates/{t.id}/items", + json=payload, + headers=auth_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "新条目" + assert data["is_mandatory"] is True + assert data["responsible_role"] == "策划" + assert data["deadline_offset_days"] == -3 + + def test_add_item_to_other_users_template_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """非创建者无权向模板添加条目""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + resp = client.post( + f"/api/event-templates/{t.id}/items", + json={"phase": "pre", "title": "强行添加", "order": 1}, + headers=auth_headers, + ) + assert resp.status_code == 403 + + def test_add_item_nonexistent_template( + self, + client: TestClient, + auth_headers: dict, + ): + resp = client.post( + "/api/event-templates/99999/items", + json={"phase": "pre", "title": "测试", "order": 1}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_update_template_item( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """创建者可以修改模板条目""" + t = _create_template( + db_session, test_community.id, created_by_id=test_user.id + ) + item = _create_template_item(db_session, t.id, title="旧标题") + resp = client.patch( + f"/api/event-templates/{t.id}/items/{item.id}", + json={"title": "新标题", "responsible_role": "主持人"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "新标题" + assert data["responsible_role"] == "主持人" + + def test_update_template_item_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """非创建者无权修改条目""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + item = _create_template_item(db_session, t.id) + resp = client.patch( + f"/api/event-templates/{t.id}/items/{item.id}", + json={"title": "强行修改"}, + headers=auth_headers, + ) + assert resp.status_code == 403 + + def test_update_nonexistent_item( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """修改不存在条目返回 404""" + t = _create_template( + db_session, test_community.id, created_by_id=test_user.id + ) + resp = client.patch( + f"/api/event-templates/{t.id}/items/99999", + json={"title": "不存在"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_delete_template_item( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user: User, + ): + """创建者可以删除模板条目""" + t = _create_template( + db_session, test_community.id, created_by_id=test_user.id + ) + item = _create_template_item(db_session, t.id) + resp = client.delete( + f"/api/event-templates/{t.id}/items/{item.id}", + headers=auth_headers, + ) + assert resp.status_code == 204 + assert ( + db_session.query(ChecklistTemplateItem) + .filter(ChecklistTemplateItem.id == item.id) + .first() + ) is None + + def test_delete_template_item_forbidden( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """非创建者无权删除条目""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + item = _create_template_item(db_session, t.id) + resp = client.delete( + f"/api/event-templates/{t.id}/items/{item.id}", + headers=auth_headers, + ) + assert resp.status_code == 403 + + def test_superuser_can_manage_any_template_items( + self, + client: TestClient, + superuser_auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """超级管理员可以管理任意模板的条目""" + t = _create_template( + db_session, test_community.id, created_by_id=9999 + ) + resp = client.post( + f"/api/event-templates/{t.id}/items", + json={"phase": "during", "title": "超管添加", "order": 1}, + headers=superuser_auth_headers, + ) + assert resp.status_code == 201 + assert resp.json()["title"] == "超管添加" diff --git a/backend/tests/test_events_api.py b/backend/tests/test_events_api.py index fffffdf..fc2bcc6 100644 --- a/backend/tests/test_events_api.py +++ b/backend/tests/test_events_api.py @@ -1,10 +1,11 @@ """Events API 测试""" import pytest +from datetime import date, timedelta from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models.community import Community -from app.models.event import Event +from app.models.event import ChecklistItem, Event, EventTemplate, ChecklistTemplateItem def _create_event( @@ -13,12 +14,14 @@ def _create_event( title: str = "测试活动", status: str = "planning", event_type: str = "offline", + planned_at=None, ) -> Event: event = Event( community_id=community_id, title=title, status=status, event_type=event_type, + planned_at=planned_at, ) db_session.add(event) db_session.commit() @@ -26,6 +29,59 @@ def _create_event( return event +def _create_checklist_item( + db_session: Session, + event_id: int, + title: str = "测试清单项", + phase: str = "pre", + status: str = "pending", +) -> ChecklistItem: + item = ChecklistItem( + event_id=event_id, + title=title, + phase=phase, + status=status, + order=1, + ) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + return item + + +def _create_template_with_items( + db_session: Session, + community_id: int, + created_by_id: int, + planned_offset_days: int = -7, +) -> EventTemplate: + template = EventTemplate( + community_id=community_id, + name="测试 SOP 模板", + event_type="online", + is_public=True, + created_by_id=created_by_id, + ) + db_session.add(template) + db_session.flush() + + item = ChecklistTemplateItem( + template_id=template.id, + phase="pre", + title="提前准备", + order=1, + deadline_offset_days=planned_offset_days, + is_mandatory=True, + responsible_role="策划", + description="详细说明", + reference_url="https://example.com", + ) + db_session.add(item) + db_session.commit() + db_session.refresh(template) + return template + + class TestDeleteEvent: def test_delete_event_success( self, @@ -77,3 +133,484 @@ def test_delete_completed_event( ) resp = client.delete(f"/api/events/{event.id}", headers=auth_headers) assert resp.status_code == 204 + + +class TestChecklistCompletedAt: + def test_done_status_sets_completed_at( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """将清单项状态改为 done 时,completed_at 自动设置""" + event = _create_event(db_session, test_community.id) + item = _create_checklist_item(db_session, event.id, status="pending") + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={"status": "done"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "done" + assert data["completed_at"] is not None + + def test_done_status_does_not_overwrite_existing_completed_at( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """已有 completed_at 的条目再次标记 done 不覆盖原时间""" + from datetime import datetime, timezone + event = _create_event(db_session, test_community.id) + fixed_time = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + item = ChecklistItem( + event_id=event.id, + title="已完成项", + phase="pre", + status="done", + order=1, + completed_at=fixed_time, + ) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={"status": "done"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + # completed_at 不被覆盖,仍是原来的时间 + assert data["completed_at"] is not None + assert "2025-01-01" in data["completed_at"] + + def test_pending_status_clears_completed_at( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """将已完成条目改回 pending 时,completed_at 被清除""" + from datetime import datetime, timezone + event = _create_event(db_session, test_community.id) + item = ChecklistItem( + event_id=event.id, + title="待重置项", + phase="pre", + status="done", + order=1, + completed_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={"status": "pending"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["completed_at"] is None + + def test_skipped_status_clears_completed_at( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """将条目标为 skipped 时,completed_at 被清除""" + from datetime import datetime, timezone + event = _create_event(db_session, test_community.id) + item = ChecklistItem( + event_id=event.id, + title="跳过项", + phase="pre", + status="done", + order=1, + completed_at=datetime(2025, 1, 1, tzinfo=timezone.utc), + ) + db_session.add(item) + db_session.commit() + db_session.refresh(item) + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={"status": "skipped"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert resp.json()["completed_at"] is None + + +class TestCreateEventFromTemplate: + def test_create_event_copies_template_checklist( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user, + ): + """从模板创建活动时,清单条目被正确复制""" + template = _create_template_with_items( + db_session, test_community.id, test_user.id + ) + + payload = { + "title": "模板测试活动", + "event_type": "online", + "community_id": test_community.id, + "community_ids": [test_community.id], + "template_id": template.id, + } + resp = client.post("/api/events", json=payload, headers=auth_headers) + assert resp.status_code == 201 + event_id = resp.json()["id"] + + checklist_resp = client.get( + f"/api/events/{event_id}/checklist", headers=auth_headers + ) + assert checklist_resp.status_code == 200 + items = checklist_resp.json() + assert len(items) == 1 + item = items[0] + assert item["title"] == "提前准备" + assert item["is_mandatory"] is True + assert item["responsible_role"] == "策划" + assert item["description"] == "详细说明" + assert item["reference_url"] == "https://example.com" + + def test_create_event_computes_due_date_from_offset( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + test_user, + ): + """deadline_offset_days 相对活动日期正确计算 due_date""" + template = _create_template_with_items( + db_session, test_community.id, test_user.id, planned_offset_days=-7 + ) + + planned_date = date.today() + timedelta(days=30) + payload = { + "title": "有日期活动", + "event_type": "online", + "community_id": test_community.id, + "community_ids": [test_community.id], + "template_id": template.id, + "planned_at": planned_date.isoformat(), + } + resp = client.post("/api/events", json=payload, headers=auth_headers) + assert resp.status_code == 201 + event_id = resp.json()["id"] + + checklist_resp = client.get( + f"/api/events/{event_id}/checklist", headers=auth_headers + ) + items = checklist_resp.json() + assert len(items) == 1 + expected_due = (planned_date + timedelta(days=-7)).isoformat() + assert items[0]["due_date"] == expected_due + + def test_create_event_without_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """不指定模板时,活动清单为空""" + payload = { + "title": "无模板活动", + "event_type": "offline", + "community_id": test_community.id, + "community_ids": [test_community.id], + } + resp = client.post("/api/events", json=payload, headers=auth_headers) + assert resp.status_code == 201 + event_id = resp.json()["id"] + + checklist_resp = client.get( + f"/api/events/{event_id}/checklist", headers=auth_headers + ) + assert checklist_resp.json() == [] + + def test_create_event_with_nonexistent_template( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """指定不存在的模板 ID 时,活动正常创建但清单为空""" + payload = { + "title": "无效模板活动", + "event_type": "online", + "community_id": test_community.id, + "community_ids": [test_community.id], + "template_id": 99999, + } + resp = client.post("/api/events", json=payload, headers=auth_headers) + assert resp.status_code == 201 + event_id = resp.json()["id"] + + checklist_resp = client.get( + f"/api/events/{event_id}/checklist", headers=auth_headers + ) + assert checklist_resp.json() == [] + + +class TestChecklistCRUD: + """POST/PATCH/DELETE /events/{event_id}/checklist 端点测试""" + + # ── POST create ──────────────────────────────────────────────────────────── + + def test_create_checklist_item_minimal( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """仅提供必填字段(phase + title)可成功创建清单项""" + event = _create_event(db_session, test_community.id) + resp = client.post( + f"/api/events/{event.id}/checklist", + json={"phase": "pre", "title": "最简清单项"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["title"] == "最简清单项" + assert data["phase"] == "pre" + assert data["status"] == "pending" + assert data["is_mandatory"] is False + assert data["description"] is None + + def test_create_checklist_item_full( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """提供全部可选字段时,所有字段均被正确保存""" + event = _create_event(db_session, test_community.id) + payload = { + "phase": "during", + "title": "完整清单项", + "description": "详细说明", + "is_mandatory": True, + "responsible_role": "主持人", + "reference_url": "https://example.com", + "due_date": "2025-12-01", + "notes": "备注内容", + "order": 5, + } + resp = client.post( + f"/api/events/{event.id}/checklist", + json=payload, + headers=auth_headers, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["phase"] == "during" + assert data["title"] == "完整清单项" + assert data["description"] == "详细说明" + assert data["is_mandatory"] is True + assert data["responsible_role"] == "主持人" + assert data["reference_url"] == "https://example.com" + assert data["due_date"] == "2025-12-01" + assert data["notes"] == "备注内容" + assert data["order"] == 5 + + def test_create_checklist_item_different_phases( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """可为三个阶段分别创建清单项""" + event = _create_event(db_session, test_community.id) + for phase in ("pre", "during", "post"): + resp = client.post( + f"/api/events/{event.id}/checklist", + json={"phase": phase, "title": f"{phase} 清单项"}, + headers=auth_headers, + ) + assert resp.status_code == 201 + assert resp.json()["phase"] == phase + + checklist_resp = client.get( + f"/api/events/{event.id}/checklist", headers=auth_headers + ) + assert len(checklist_resp.json()) == 3 + + def test_create_checklist_item_event_not_found( + self, + client: TestClient, + auth_headers: dict, + ): + """活动不存在时返回 404""" + resp = client.post( + "/api/events/999999/checklist", + json={"phase": "pre", "title": "项目"}, + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_create_checklist_item_no_auth( + self, + client: TestClient, + db_session: Session, + test_community: Community, + ): + """未登录时返回 401""" + event = _create_event(db_session, test_community.id) + resp = client.post( + f"/api/events/{event.id}/checklist", + json={"phase": "pre", "title": "未认证项"}, + ) + assert resp.status_code == 401 + + # ── PATCH update (extended fields) ──────────────────────────────────────── + + def test_update_checklist_item_extended_fields( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """PATCH 支持更新 title/description/phase/is_mandatory/responsible_role/reference_url""" + event = _create_event(db_session, test_community.id) + item = _create_checklist_item(db_session, event.id, title="原标题", phase="pre") + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={ + "title": "新标题", + "description": "新说明", + "phase": "post", + "is_mandatory": True, + "responsible_role": "后勤", + "reference_url": "https://new.example.com", + }, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "新标题" + assert data["description"] == "新说明" + assert data["phase"] == "post" + assert data["is_mandatory"] is True + assert data["responsible_role"] == "后勤" + assert data["reference_url"] == "https://new.example.com" + + def test_update_checklist_item_partial( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """PATCH 只传部分字段时,其余字段不受影响""" + event = _create_event(db_session, test_community.id) + item = _create_checklist_item(db_session, event.id, title="保持不变", phase="pre") + + resp = client.patch( + f"/api/events/{event.id}/checklist/{item.id}", + json={"is_mandatory": True}, + headers=auth_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["title"] == "保持不变" + assert data["phase"] == "pre" + assert data["is_mandatory"] is True + + # ── DELETE ──────────────────────────────────────────────────────────────── + + def test_delete_checklist_item_success( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """成功删除清单项,返回 204,数据库中不再存在""" + event = _create_event(db_session, test_community.id) + item = _create_checklist_item(db_session, event.id, title="待删除清单项") + item_id = item.id + + resp = client.delete( + f"/api/events/{event.id}/checklist/{item_id}", + headers=auth_headers, + ) + assert resp.status_code == 204 + + deleted = db_session.query(ChecklistItem).filter(ChecklistItem.id == item_id).first() + assert deleted is None + + def test_delete_checklist_item_not_found( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """删除不存在的清单项返回 404""" + event = _create_event(db_session, test_community.id) + resp = client.delete( + f"/api/events/{event.id}/checklist/999999", + headers=auth_headers, + ) + assert resp.status_code == 404 + + def test_delete_checklist_item_wrong_event( + self, + client: TestClient, + auth_headers: dict, + db_session: Session, + test_community: Community, + ): + """用错误的 event_id 删除时返回 404(跨活动隔离)""" + event1 = _create_event(db_session, test_community.id, title="活动1") + event2 = _create_event(db_session, test_community.id, title="活动2") + item = _create_checklist_item(db_session, event1.id) + + # 用 event2 的路径去删 event1 的条目 + resp = client.delete( + f"/api/events/{event2.id}/checklist/{item.id}", + headers=auth_headers, + ) + assert resp.status_code == 404 + + # 原条目仍然存在 + still_exists = db_session.query(ChecklistItem).filter(ChecklistItem.id == item.id).first() + assert still_exists is not None + + def test_delete_checklist_item_no_auth( + self, + client: TestClient, + db_session: Session, + test_community: Community, + ): + """未登录时返回 401""" + event = _create_event(db_session, test_community.id) + item = _create_checklist_item(db_session, event.id) + resp = client.delete(f"/api/events/{event.id}/checklist/{item.id}") + assert resp.status_code == 401 diff --git a/backend/tests/test_upload_api.py b/backend/tests/test_upload_api.py index 074bc7c..f824d91 100644 --- a/backend/tests/test_upload_api.py +++ b/backend/tests/test_upload_api.py @@ -6,11 +6,22 @@ - POST /api/upload/{content_id}/cover """ import io +import pytest +from unittest.mock import patch from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app.models.community import Community from app.models.content import Content +from app.services.storage import LocalStorage + + +@pytest.fixture(autouse=True) +def use_local_storage(tmp_path): + """测试期间强制使用本地存储,避免依赖 boto3 / S3 配置""" + storage = LocalStorage(str(tmp_path)) + with patch("app.api.upload.get_storage", return_value=storage): + yield class TestUploadFile: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index b7b9baa..82c1da6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,14 +1,18 @@ -FROM node:20-alpine AS builder +# syntax=docker/dockerfile:1 +FROM node:20-alpine3.21 AS builder WORKDIR /app -COPY package.json ./ -RUN npm install +# 先复制依赖清单(含 lockfile)——这两个文件不变时,下面两步完全走缓存 +COPY package.json package-lock.json ./ +# --mount=type=cache 让 npm 缓存跨构建复用:即使 package.json 变化也只下载新增包 +RUN --mount=type=cache,target=/root/.npm \ + npm ci --prefer-offline COPY . . RUN npm run build -FROM nginx:alpine +FROM nginx:1.27-alpine3.21 COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a0f02f5..19f4660 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,8 @@ "pinia": "^2.3.0", "vue": "^3.5.13", "vue-echarts": "^8.0.1", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", @@ -3159,6 +3160,12 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://repo.huaweicloud.com/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3388,6 +3395,18 @@ "typescript": ">=5.0.0" } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://repo.huaweicloud.com/repository/npm/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://repo.huaweicloud.com/repository/npm/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 14013eb..fdba650 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,8 @@ "pinia": "^2.3.0", "vue": "^3.5.13", "vue-echarts": "^8.0.1", - "vue-router": "^4.5.0" + "vue-router": "^4.5.0", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@vitejs/plugin-vue": "^5.2.1", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 38e10d9..4f101fe 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -82,11 +82,21 @@ - - - - 活动管理 - + + + + + + 活动列表 + + + + SOP 模板 + + diff --git a/frontend/src/api/event.ts b/frontend/src/api/event.ts index 3ed204a..e97ace4 100644 --- a/frontend/src/api/event.ts +++ b/frontend/src/api/event.ts @@ -73,13 +73,51 @@ export interface ChecklistItem { id: number phase: string title: string + description: string | null + is_mandatory: boolean + responsible_role: string | null + reference_url: string | null status: string assignee_id: number | null due_date: string | null notes: string | null + completed_at: string | null order: number } +export interface ChecklistTemplateItem { + id: number + phase: string + title: string + description: string | null + is_mandatory: boolean + responsible_role: string | null + deadline_offset_days: number | null + estimated_hours: number | null + reference_url: string | null + order: number +} + +export interface EventTemplateListItem { + id: number + name: string + event_type: string + is_public: boolean + created_at: string +} + +export interface EventTemplate { + id: number + community_id: number | null + name: string + event_type: string + description: string | null + is_public: boolean + created_by_id: number | null + created_at: string + checklist_items: ChecklistTemplateItem[] +} + export interface Personnel { id: number role: string @@ -194,11 +232,41 @@ export async function getChecklist(eventId: number) { return res.data } -export async function updateChecklistItem(eventId: number, itemId: number, data: { status?: string; notes?: string; due_date?: string | null }) { +export async function updateChecklistItem(eventId: number, itemId: number, data: Partial<{ + phase: string + title: string + description: string | null + is_mandatory: boolean + responsible_role: string | null + reference_url: string | null + status: string + due_date: string | null + notes: string | null + order: number +}>) { const res = await apiClient.patch(`/events/${eventId}/checklist/${itemId}`, data) return res.data } +export async function createChecklistItem(eventId: number, data: { + phase: string + title: string + description?: string | null + is_mandatory?: boolean + responsible_role?: string | null + reference_url?: string | null + due_date?: string | null + notes?: string | null + order?: number +}) { + const res = await apiClient.post(`/events/${eventId}/checklist`, data) + return res.data +} + +export async function deleteChecklistItem(eventId: number, itemId: number) { + await apiClient.delete(`/events/${eventId}/checklist/${itemId}`) +} + // ─── Personnel ──────────────────────────────────────────────────────────────── export async function listPersonnel(eventId: number) { @@ -257,3 +325,73 @@ export async function deleteTask(eventId: number, tid: number) { export async function deleteEvent(id: number): Promise { await apiClient.delete(`/events/${id}`) } + +// ─── Event Templates ────────────────────────────────────────────────────────── + +export async function listTemplates() { + const res = await apiClient.get('/event-templates') + return res.data +} + +export async function getTemplate(id: number) { + const res = await apiClient.get(`/event-templates/${id}`) + return res.data +} + +export async function createTemplate(data: { + name: string + event_type: string + description?: string | null + is_public?: boolean +}) { + const res = await apiClient.post('/event-templates', data) + return res.data +} + +export async function updateTemplate(id: number, data: { + name?: string + event_type?: string + description?: string | null + is_public?: boolean +}) { + const res = await apiClient.patch(`/event-templates/${id}`, data) + return res.data +} + +export async function addTemplateItem(templateId: number, data: { + phase: string + title: string + description?: string | null + is_mandatory?: boolean + responsible_role?: string | null + deadline_offset_days?: number | null + estimated_hours?: number | null + reference_url?: string | null + order?: number +}) { + const res = await apiClient.post(`/event-templates/${templateId}/items`, data) + return res.data +} + +export async function updateTemplateItem(templateId: number, itemId: number, data: Partial<{ + phase: string + title: string + description: string | null + is_mandatory: boolean + responsible_role: string | null + deadline_offset_days: number | null + estimated_hours: number | null + reference_url: string | null + order: number +}>) { + const res = await apiClient.patch(`/event-templates/${templateId}/items/${itemId}`, data) + return res.data +} + +export async function deleteTemplateItem(templateId: number, itemId: number) { + await apiClient.delete(`/event-templates/${templateId}/items/${itemId}`) +} + +export async function deleteTemplate(templateId: number) { + await apiClient.delete(`/event-templates/${templateId}`) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1a3453a..99f8673 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -184,6 +184,12 @@ const router = createRouter({ component: () => import('../views/EventDetail.vue'), meta: { requiresAuth: true }, }, + { + path: '/event-templates', + name: 'EventTemplates', + component: () => import('../views/EventTemplates.vue'), + meta: { requiresAuth: true }, + }, // Phase 4a 占位路由 (People) { path: '/people', diff --git a/frontend/src/views/EventDetail.vue b/frontend/src/views/EventDetail.vue index dcc3191..928edad 100644 --- a/frontend/src/views/EventDetail.vue +++ b/frontend/src/views/EventDetail.vue @@ -71,6 +71,20 @@
+ + + + +
+ 将自动生成 {{ selectedTemplate.checklist_items.length }} 条清单项 +
+
@@ -129,9 +143,22 @@
+
+ + 添加清单项 + + + 从模板导入 + +
-

{{ phase.label }}

+
+

{{ phase.label }}

+ + 添加 + +
本阶段无清单项
- - {{ item.title }} - 已完成 - 进行中 +
+ + {{ item.title }} + {{ item.responsible_role }} + 必须 + 已完成 + +
+ 编辑 + 删除 +
+
+
+ {{ item.description }} + 参考链接 → +
-
暂无清单项(创建活动时选择模板可自动生成)
+
暂无清单项,点击「添加清单项」或创建活动时选择模板可自动生成
@@ -338,6 +382,99 @@ + + +
+
+ 选择模板 + + + +
+ +
+
+ +
+
该模板暂无清单条目
+
共 {{ importTotalCount }} 条清单项将被导入
+
+
请先选择一个模板
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -368,7 +505,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { ArrowLeft, Calendar, Location, Link, Plus, Connection } from '@element-plus/icons-vue' +import { ArrowLeft, Calendar, Location, Link, Plus, Connection, ArrowRight } from '@element-plus/icons-vue' import { useCommunityStore } from '../stores/community' import { useAuthStore } from '../stores/auth' import { @@ -376,6 +513,8 @@ import { createEvent, getChecklist, updateChecklistItem, + createChecklistItem, + deleteChecklistItem, listPersonnel, addPersonnel, confirmPersonnel, @@ -388,8 +527,10 @@ import { deleteEvent, updateEventStatus, updateEvent, + listTemplates, + getTemplate, } from '../api/event' -import type { EventDetail, ChecklistItem, Personnel, FeedbackItem, EventTask } from '../api/event' +import type { EventDetail, ChecklistItem, Personnel, FeedbackItem, EventTask, EventTemplateListItem, EventTemplate } from '../api/event' const route = useRoute() const router = useRouter() @@ -424,6 +565,38 @@ const savingTask = ref(false) const savingPersonnel = ref(false) const savingFeedback = ref(false) +// Checklist item dialog +const showChecklistItemDialog = ref(false) +const editingChecklistItem = ref(null) +const checklistItemForm = ref({ + phase: 'pre' as string, + title: '', + description: '', + is_mandatory: false, + responsible_role: '', + reference_url: '', + due_date: null as string | null, + notes: '', +}) +const savingChecklistItem = ref(false) + +// Import template dialog +const showImportTemplateDialog = ref(false) +const importTemplateId = ref(null) +const importingTemplateDetail = ref(null) +const loadingImportTemplate = ref(false) +const importingChecklist = ref(false) + +const importItemsByPhase = computed(() => ({ + pre: importingTemplateDetail.value?.checklist_items.filter(i => i.phase === 'pre') ?? [], + during: importingTemplateDetail.value?.checklist_items.filter(i => i.phase === 'during') ?? [], + post: importingTemplateDetail.value?.checklist_items.filter(i => i.phase === 'post') ?? [], +})) + +const importTotalCount = computed(() => + (importingTemplateDetail.value?.checklist_items.length ?? 0) +) + const isEditing = ref(false) const saving = ref(false) const editForm = ref({ @@ -436,8 +609,34 @@ const editForm = ref({ description: '', status: 'planning' as string, community_ids: [] as number[], + template_id: null as number | null, }) +// ─── Template ───────────────────────────────────────────────────────────────── +const templateList = ref([]) +const selectedTemplate = ref(null) +const expandedItems = ref(new Set()) + +function toggleExpand(id: number) { + if (expandedItems.value.has(id)) { + expandedItems.value.delete(id) + } else { + expandedItems.value.add(id) + } +} + +async function onTemplateChange(val: number | null) { + if (val) { + try { + selectedTemplate.value = await getTemplate(val) + } catch { + selectedTemplate.value = null + } + } else { + selectedTemplate.value = null + } +} + const ganttEl = ref(null) const taskForm = ref({ title: '', task_type: 'task', phase: 'pre', start_date: null as string | null, end_date: null as string | null }) @@ -515,6 +714,7 @@ async function loadEvent() { description: '', status: 'planning', community_ids: communityStore.currentCommunityId ? [communityStore.currentCommunityId] : [], + template_id: null, } } else { event.value = await getEvent(eventId.value!) @@ -565,6 +765,7 @@ function startEdit() { community_ids: event.value.community_ids?.length ? [...event.value.community_ids] : (event.value.community_id ? [event.value.community_id] : []), + template_id: event.value.template_id, } isEditing.value = true } @@ -597,6 +798,7 @@ async function saveEdit() { status: editForm.value.status, community_id: communityIds[0] || null, community_ids: communityIds, + template_id: editForm.value.template_id || null, }) router.push(`/events/${newEvent.id}`) } else { @@ -755,6 +957,147 @@ async function handleUpdateFeedbackStatus(fid: number, status: string) { } } +// ─── Checklist Item CRUD ────────────────────────────────────────────────────── +function handleAddChecklistItem(phase: string) { + editingChecklistItem.value = null + checklistItemForm.value = { + phase, + title: '', + description: '', + is_mandatory: false, + responsible_role: '', + reference_url: '', + due_date: null, + notes: '', + } + showChecklistItemDialog.value = true +} + +function handleEditChecklistItem(item: ChecklistItem) { + editingChecklistItem.value = item + checklistItemForm.value = { + phase: item.phase, + title: item.title, + description: item.description || '', + is_mandatory: item.is_mandatory, + responsible_role: item.responsible_role || '', + reference_url: item.reference_url || '', + due_date: item.due_date || null, + notes: item.notes || '', + } + showChecklistItemDialog.value = true +} + +async function handleDeleteChecklistItem(item: ChecklistItem) { + if (!eventId.value) return + try { + await ElMessageBox.confirm(`确定删除清单项「${item.title}」?`, '确认', { type: 'warning' }) + await deleteChecklistItem(eventId.value, item.id) + checklist.value = checklist.value.filter(i => i.id !== item.id) + ElMessage.success('已删除') + } catch { /* cancelled */ } +} + +async function handleSaveChecklistItem() { + if (!eventId.value) return + if (!checklistItemForm.value.title.trim()) { + ElMessage.warning('请输入标题') + return + } + savingChecklistItem.value = true + try { + if (editingChecklistItem.value) { + const updated = await updateChecklistItem(eventId.value, editingChecklistItem.value.id, { + phase: checklistItemForm.value.phase, + title: checklistItemForm.value.title, + description: checklistItemForm.value.description || null, + is_mandatory: checklistItemForm.value.is_mandatory, + responsible_role: checklistItemForm.value.responsible_role || null, + reference_url: checklistItemForm.value.reference_url || null, + due_date: checklistItemForm.value.due_date || null, + notes: checklistItemForm.value.notes || null, + }) + const idx = checklist.value.findIndex(i => i.id === editingChecklistItem.value!.id) + if (idx !== -1) checklist.value[idx] = updated + ElMessage.success('已更新') + } else { + const created = await createChecklistItem(eventId.value, { + phase: checklistItemForm.value.phase, + title: checklistItemForm.value.title, + description: checklistItemForm.value.description || null, + is_mandatory: checklistItemForm.value.is_mandatory, + responsible_role: checklistItemForm.value.responsible_role || null, + reference_url: checklistItemForm.value.reference_url || null, + due_date: checklistItemForm.value.due_date || null, + notes: checklistItemForm.value.notes || null, + }) + checklist.value.push(created) + ElMessage.success('已添加') + } + showChecklistItemDialog.value = false + } catch { + ElMessage.error('保存失败') + } finally { + savingChecklistItem.value = false + } +} + +// ─── Import Template ────────────────────────────────────────────────────────── +function openImportTemplateDialog() { + importTemplateId.value = null + importingTemplateDetail.value = null + showImportTemplateDialog.value = true +} + +async function onImportTemplateChange(id: number | null) { + if (!id) { importingTemplateDetail.value = null; return } + loadingImportTemplate.value = true + try { + importingTemplateDetail.value = await getTemplate(id) + } catch { + ElMessage.error('加载模板详情失败') + importingTemplateDetail.value = null + } finally { + loadingImportTemplate.value = false + } +} + +async function handleImportFromTemplate() { + if (!eventId.value || !importingTemplateDetail.value) return + importingChecklist.value = true + try { + const items = importingTemplateDetail.value.checklist_items + const plannedAt = event.value?.planned_at ? new Date(event.value.planned_at) : null + + const created = await Promise.all(items.map(item => { + let dueDate: string | null = null + if (item.deadline_offset_days !== null && plannedAt) { + const d = new Date(plannedAt) + d.setDate(d.getDate() + item.deadline_offset_days) + dueDate = d.toISOString().split('T')[0] + } + return createChecklistItem(eventId.value!, { + phase: item.phase, + title: item.title, + description: item.description ?? null, + is_mandatory: item.is_mandatory, + responsible_role: item.responsible_role ?? null, + reference_url: item.reference_url ?? null, + due_date: dueDate, + order: item.order, + }) + })) + + checklist.value.push(...created) + ElMessage.success(`已导入 ${created.length} 条清单项`) + showImportTemplateDialog.value = false + } catch { + ElMessage.error('导入失败,请重试') + } finally { + importingChecklist.value = false + } +} + // ─── Gantt Chart ────────────────────────────────────────────────────────────── async function renderGantt() { if (!ganttEl.value || ganttTasks.value.length === 0) return @@ -784,6 +1127,12 @@ watch([ganttTasks, activeTab], async ([, tab]) => { // ─── Lifecycle ──────────────────────────────────────────────────────────────── onMounted(async () => { + // Load template list for new event form + try { + templateList.value = await listTemplates() + } catch { + templateList.value = [] + } await loadEvent() // Load all tabs data in parallel await Promise.all([loadChecklist(), loadPersonnel(), loadTasks(), loadFeedback()]) @@ -905,8 +1254,15 @@ onMounted(async () => { margin-bottom: 20px; } +.phase-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + .phase-title { - margin: 0 0 8px; + margin: 0; font-size: 13px; font-weight: 600; color: #475569; @@ -914,6 +1270,17 @@ onMounted(async () => { letter-spacing: 0.5px; } +.phase-add-btn { + font-size: 12px; + color: #94a3b8; + opacity: 0; + transition: opacity 0.15s; +} + +.checklist-phase:hover .phase-add-btn { + opacity: 1; +} + .phase-empty { font-size: 13px; color: #cbd5e1; @@ -922,12 +1289,17 @@ onMounted(async () => { .checklist-item { display: flex; - align-items: center; - gap: 10px; + flex-direction: column; padding: 6px 0; border-bottom: 1px solid #f1f5f9; } +.checklist-row { + display: flex; + align-items: center; + gap: 8px; +} + .checklist-item.done .checklist-title { text-decoration: line-through; color: #94a3b8; @@ -939,6 +1311,68 @@ onMounted(async () => { color: #1e293b; } +.role-badge { + font-size: 12px; + color: #64748b; + background: #f1f5f9; + border-radius: 4px; + padding: 1px 6px; + flex-shrink: 0; +} + +.item-actions { + display: flex; + gap: 2px; + margin-left: 4px; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s; +} + +.checklist-item:hover .item-actions { + opacity: 1; +} + +.expand-btn { + cursor: pointer; + color: #94a3b8; + transition: transform 0.2s ease, color 0.15s ease; + flex-shrink: 0; +} + +.expand-btn:hover { + color: #0095ff; +} + +.expand-btn.expanded { + transform: rotate(90deg); + color: #0095ff; +} + +.item-description { + margin-top: 6px; + margin-left: 28px; + padding: 8px 12px; + background: #f8fafc; + border-left: 3px solid #0095ff; + border-radius: 0 6px 6px 0; + font-size: 13px; + color: #64748b; + line-height: 1.6; +} + +.ref-link { + display: inline-block; + margin-top: 4px; + font-size: 12px; + color: #0095ff; + text-decoration: none; +} + +.ref-link:hover { + text-decoration: underline; +} + /* Feedback */ .feedback-card { background: #f8fafc; @@ -1005,6 +1439,92 @@ onMounted(async () => { overflow-x: auto; } +/* Import template dialog */ +.import-template-body { + display: flex; + flex-direction: column; + gap: 16px; +} + +.import-template-selector { + display: flex; + align-items: center; + gap: 12px; +} + +.import-label { + font-size: 14px; + color: #475569; + white-space: nowrap; +} + +.import-preview { + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 12px 16px; + max-height: 320px; + overflow-y: auto; +} + +.import-phase { + margin-bottom: 12px; +} + +.import-phase:last-child { + margin-bottom: 0; +} + +.import-phase-title { + font-size: 11px; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.import-item-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 0; + border-bottom: 1px solid #f1f5f9; + font-size: 13px; +} + +.import-item-row:last-child { + border-bottom: none; +} + +.import-mandatory { + flex-shrink: 0; +} + +.import-item-title { + flex: 1; + color: #1e293b; +} + +.import-item-role { + font-size: 12px; + color: #94a3b8; + flex-shrink: 0; +} + +.import-empty { + text-align: center; + color: #94a3b8; + font-size: 13px; + padding: 24px 0; +} + +.import-count { + margin-top: 10px; + text-align: right; + font-size: 12px; + color: #64748b; +} + /* frappe-gantt global override (scoped doesn't apply to library DOM) */ :deep(.gantt-container) svg { font-family: inherit; diff --git a/frontend/src/views/EventTemplates.vue b/frontend/src/views/EventTemplates.vue new file mode 100644 index 0000000..8a22a1c --- /dev/null +++ b/frontend/src/views/EventTemplates.vue @@ -0,0 +1,777 @@ + + + + + diff --git a/frontend/src/views/Events.vue b/frontend/src/views/Events.vue index 0426fa8..ce5a154 100644 --- a/frontend/src/views/Events.vue +++ b/frontend/src/views/Events.vue @@ -131,7 +131,7 @@ /> - + @@ -159,6 +159,26 @@ /> + + + + +
+ 暂无可用模板,可先在 + SOP 模板管理 + 中创建 +
+
@@ -180,8 +200,8 @@ import FullCalendar from '@fullcalendar/vue3' import dayGridPlugin from '@fullcalendar/daygrid' import interactionPlugin from '@fullcalendar/interaction' import type { CalendarOptions, EventClickArg, EventDropArg } from '@fullcalendar/core' -import { listEvents, createEvent, updateEvent, deleteEvent } from '../api/event' -import type { EventListItem } from '../api/event' +import { listEvents, createEvent, updateEvent, deleteEvent, listTemplates } from '../api/event' +import type { EventListItem, EventTemplateListItem } from '../api/event' import { useAuthStore } from '../stores/auth' const router = useRouter() @@ -204,6 +224,8 @@ const calendarRef = ref() let debounceTimer: ReturnType | null = null +const templateList = ref([]) + const createForm = ref({ title: '', event_type: 'offline', @@ -211,6 +233,7 @@ const createForm = ref({ planned_at: null as string | null, location: '', description: '', + template_id: null as number | null, }) const statusLabel: Record = { @@ -332,8 +355,16 @@ async function loadEvents() { } } +async function loadTemplateList() { + try { + templateList.value = await listTemplates() + } catch { + templateList.value = [] + } +} + function openCreateDialog() { - createForm.value = { title: '', event_type: 'offline', community_id: null, planned_at: null, location: '', description: '' } + createForm.value = { title: '', event_type: 'offline', community_id: null, planned_at: null, location: '', description: '', template_id: null } showCreateDialog.value = true } @@ -351,6 +382,7 @@ async function handleCreate() { planned_at: createForm.value.planned_at || null, location: createForm.value.location || null, description: createForm.value.description || null, + template_id: createForm.value.template_id || null, }) showCreateDialog.value = false ElMessage.success('活动已创建')