diff --git a/backend/alembic/versions/011_add_meeting_online_url.py b/backend/alembic/versions/011_add_meeting_online_url.py new file mode 100644 index 0000000..1d4d212 --- /dev/null +++ b/backend/alembic/versions/011_add_meeting_online_url.py @@ -0,0 +1,25 @@ +"""为 meetings 表添加 online_url 字段,支持线上线下混合会议 + +Revision ID: 011_add_meeting_online_url +Revises: 010_make_community_id_nullable +Create Date: 2026-02-23 +""" +import sqlalchemy as sa +from alembic import op + +revision = "011_add_meeting_online_url" +down_revision = "010_make_community_id_nullable" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("meetings") as batch_op: + batch_op.add_column( + sa.Column("online_url", sa.String(500), nullable=True) + ) + + +def downgrade() -> None: + with op.batch_alter_table("meetings") as batch_op: + batch_op.drop_column("online_url") diff --git a/backend/app/api/contents.py b/backend/app/api/contents.py index f0e22d7..0fb6773 100644 --- a/backend/app/api/contents.py +++ b/backend/app/api/contents.py @@ -101,7 +101,7 @@ def create_content( if data.source_type not in VALID_SOURCE_TYPES: raise HTTPException(400, f"Invalid source_type, must be one of {VALID_SOURCE_TYPES}") content_html = convert_markdown_to_html(data.content_markdown) if data.content_markdown else "" - # 主社区:优先取 community_ids[0],兼容旧逻辑 + # 主社区:取 community_ids[0];若未提供则不关联社区 primary_community_id = data.community_ids[0] if data.community_ids else None content = Content( title=data.title, diff --git a/backend/app/api/meetings.py b/backend/app/api/meetings.py index 30f3c75..0d37a27 100644 --- a/backend/app/api/meetings.py +++ b/backend/app/api/meetings.py @@ -101,6 +101,7 @@ def create_meeting( duration=data.duration, location_type=data.location_type, location=data.location, + online_url=data.online_url, agenda=data.agenda, status="scheduled", work_status="planning", # 固定默认值,不对外暴露 @@ -163,6 +164,7 @@ def get_meeting( "duration": meeting.duration, "location_type": meeting.location_type, "location": meeting.location, + "online_url": meeting.online_url, "status": meeting.status, "work_status": meeting.work_status, "agenda": meeting.agenda, diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 58075cf..8f2bd69 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from app.config import settings -from app.core.dependencies import get_current_community, get_current_user +from app.core.dependencies import get_current_user from app.database import get_db from app.models.content import Content from app.models.user import User @@ -23,7 +23,6 @@ async def upload_file( file: UploadFile = File(...), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), - current_community: int = Depends(get_current_community), ): if not file.filename: raise HTTPException(400, "No filename provided") @@ -60,7 +59,7 @@ async def upload_file( source_type="contribution", source_file=save_name, status="draft", - community_id=current_community, + community_id=None, created_by_user_id=current_user.id, ) db.add(content) diff --git a/backend/app/models/meeting.py b/backend/app/models/meeting.py index 7ed2469..cbf0129 100644 --- a/backend/app/models/meeting.py +++ b/backend/app/models/meeting.py @@ -54,7 +54,8 @@ class Meeting(Base): duration = Column(Integer, default=120) # 持续时长(分钟) location_type = Column(String(50), nullable=True) # online / offline / hybrid - location = Column(String(500), nullable=True) + location = Column(String(500), nullable=True) # 线下会议地址 + online_url = Column(String(500), nullable=True) # 线上会议链接(online / hybrid 时使用) status = Column(String(50), default="scheduled", index=True) # 可选值: scheduled, in_progress, completed, cancelled diff --git a/backend/app/schemas/content.py b/backend/app/schemas/content.py index 51477ac..3d7d687 100644 --- a/backend/app/schemas/content.py +++ b/backend/app/schemas/content.py @@ -15,7 +15,7 @@ class ContentCreate(BaseModel): scheduled_publish_at: datetime | None = None work_status: str = "planning" assignee_ids: list[int] = [] - # 多社区关联:不填则默认使用 X-Community-Id header 对应的社区 + # 多社区关联:不填则内容不关联任何社区 community_ids: list[int] = [] @@ -73,6 +73,8 @@ class ContentListOut(BaseModel): tags: list[str] category: str status: str + work_status: str = "planning" + community_id: int | None = None owner_id: int | None = None scheduled_publish_at: datetime | None = None created_at: datetime diff --git a/backend/app/schemas/governance.py b/backend/app/schemas/governance.py index a8ce3cb..162c574 100644 --- a/backend/app/schemas/governance.py +++ b/backend/app/schemas/governance.py @@ -127,7 +127,8 @@ class MeetingCreate(BaseModel): scheduled_at: datetime duration: int = Field(120, ge=1) location_type: str | None = "online" - location: str | None = None + location: str | None = None # 线下会议地址(offline / hybrid) + online_url: str | None = None # 线上会议链接(online / hybrid) agenda: str | None = None reminder_before_hours: int = 24 assignee_ids: list[int] = [] @@ -140,7 +141,8 @@ class MeetingUpdate(BaseModel): scheduled_at: datetime | None = None duration: int | None = Field(None, ge=1) location_type: str | None = None - location: str | None = None + location: str | None = None # 线下会议地址(offline / hybrid) + online_url: str | None = None # 线上会议链接(online / hybrid) status: str | None = None agenda: str | None = None reminder_before_hours: int | None = None @@ -157,6 +159,7 @@ class MeetingOut(BaseModel): duration: int location_type: str | None = None location: str | None = None + online_url: str | None = None status: str reminder_sent: bool created_by_user_id: int | None = None diff --git a/backend/tests/test_wechat_stats_service.py b/backend/tests/test_wechat_stats_service.py new file mode 100644 index 0000000..efc76e0 --- /dev/null +++ b/backend/tests/test_wechat_stats_service.py @@ -0,0 +1,429 @@ +"""微信公众号统计服务单元测试。 + +补充 test_wechat_stats_api.py 中未覆盖的 Service 层逻辑。 +""" + +from datetime import date, datetime, timedelta + +import pytest +from sqlalchemy.orm import Session + +from app.models.community import Community +from app.models.content import Content +from app.models.publish_record import PublishRecord +from app.models.user import User +from app.models.wechat_stats import WechatArticleStat, WechatStatsAggregate +from app.services.wechat_stats import WechatStatsService + + +# ── Fixtures ── + + +@pytest.fixture(scope="function") +def service() -> WechatStatsService: + """微信统计服务实例。""" + return WechatStatsService() + + +@pytest.fixture(scope="function") +def test_data( + db_session: Session, + test_community: Community, + test_user: User, +) -> tuple[Content, PublishRecord, WechatArticleStat]: + """创建测试数据:内容、发布记录、多条每日统计。""" + content = Content( + title="测试文章", + content_markdown="# 测试", + community_id=test_community.id, + created_by_user_id=test_user.id, + owner_id=test_user.id, + status="published", + ) + db_session.add(content) + db_session.commit() + db_session.refresh(content) + + record = PublishRecord( + content_id=content.id, + channel="wechat", + status="published", + community_id=test_community.id, + published_at=datetime(2025, 1, 15, 10, 0, 0), + ) + db_session.add(record) + db_session.commit() + db_session.refresh(record) + + # 创建多条每日统计 + stats = [] + for i in range(5): + stat = WechatArticleStat( + publish_record_id=record.id, + article_category="technical", + stat_date=date(2025, 1, 10 + i), + read_count=100 * (i + 1), + read_user_count=80 * (i + 1), + like_count=5 * (i + 1), + wow_count=2 * (i + 1), + share_count=3 * (i + 1), + comment_count=1 * (i + 1), + favorite_count=1 * (i + 1), + forward_count=2 * (i + 1), + new_follower_count=10 * (i + 1), + community_id=test_community.id, + ) + db_session.add(stat) + stats.append(stat) + db_session.commit() + + return content, record, stats[0] + + +# ── 工具方法测试 ── + + +class TestPeriodLabel: + """测试 _period_label 静态方法。""" + + def test_daily_label(self, service: WechatStatsService): + """每日标签应为 ISO 日期。""" + d = date(2025, 1, 15) + assert service._period_label("daily", d) == "2025-01-15" + + def test_weekly_label(self, service: WechatStatsService): + """周标签应为 ISO 周格式。""" + d = date(2025, 1, 15) + assert service._period_label("weekly", d) == "2025-W03" + + def test_monthly_label(self, service: WechatStatsService): + """月标签应为 YYYY-MM 格式。""" + d = date(2025, 1, 15) + assert service._period_label("monthly", d) == "2025-01" + + def test_quarterly_label(self, service: WechatStatsService): + """季度标签应为 YYYY-QN 格式。""" + d = date(2025, 1, 15) + assert service._period_label("quarterly", d) == "2025-Q1" + d2 = date(2025, 4, 15) + assert service._period_label("quarterly", d2) == "2025-Q2" + + def test_semi_annual_label(self, service: WechatStatsService): + """半年标签应为 YYYY-HN 格式。""" + d = date(2025, 1, 15) + assert service._period_label("semi_annual", d) == "2025-H1" + d2 = date(2025, 7, 15) + assert service._period_label("semi_annual", d2) == "2025-H2" + + def test_annual_label(self, service: WechatStatsService): + """年标签应为 YYYY 格式。""" + d = date(2025, 1, 15) + assert service._period_label("annual", d) == "2025" + + +class TestGetBucketKey: + """测试 _get_bucket_key 静态方法。""" + + def test_daily_bucket(self, service: WechatStatsService): + """每日桶键应为 ISO 日期。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "daily") == "2025-01-15" + + def test_weekly_bucket(self, service: WechatStatsService): + """周桶键应为 ISO 周格式。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "weekly") == "2025-W03" + + def test_monthly_bucket(self, service: WechatStatsService): + """月桶键应为 YYYY-MM 格式。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "monthly") == "2025-01" + + def test_quarterly_bucket(self, service: WechatStatsService): + """季度桶键应为 YYYY-QN 格式。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "quarterly") == "2025-Q1" + + def test_semi_annual_bucket(self, service: WechatStatsService): + """半年桶键应为 YYYY-HN 格式。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "semi_annual") == "2025-H1" + + def test_annual_bucket(self, service: WechatStatsService): + """年桶键应为 YYYY 格式。""" + d = date(2025, 1, 15) + assert service._get_bucket_key(d, "annual") == "2025" + + +class TestBucketKeyToStart: + """测试 _bucket_key_to_start 静态方法。""" + + def test_daily_to_start(self, service: WechatStatsService): + """每日桶键转开始日期。""" + key = "2025-01-15" + assert service._bucket_key_to_start(key, "daily") == date(2025, 1, 15) + + def test_weekly_to_start(self, service: WechatStatsService): + """周桶键转开始日期(周一)。""" + key = "2025-W03" + result = service._bucket_key_to_start(key, "weekly") + assert result.year == 2025 + assert result.month == 1 + assert result.day == 13 # 2025年第3周周一 + + def test_monthly_to_start(self, service: WechatStatsService): + """月桶键转开始日期(月初)。""" + key = "2025-01" + assert service._bucket_key_to_start(key, "monthly") == date(2025, 1, 1) + + def test_quarterly_to_start(self, service: WechatStatsService): + """季度桶键转开始日期。""" + key = "2025-Q2" + assert service._bucket_key_to_start(key, "quarterly") == date(2025, 4, 1) + + def test_semi_annual_to_start(self, service: WechatStatsService): + """半年桶键转开始日期。""" + key = "2025-H2" + assert service._bucket_key_to_start(key, "semi_annual") == date(2025, 7, 1) + + def test_annual_to_start(self, service: WechatStatsService): + """年桶键转开始日期。""" + key = "2025" + assert service._bucket_key_to_start(key, "annual") == date(2025, 1, 1) + + +class TestBucketKeyToEnd: + """测试 _bucket_key_to_end 静态方法。""" + + def test_daily_to_end(self, service: WechatStatsService): + """每日桶键转结束日期。""" + start = date(2025, 1, 15) + assert service._bucket_key_to_end(start, "daily") == date(2025, 1, 15) + + def test_weekly_to_end(self, service: WechatStatsService): + """周桶键转结束日期(周日)。""" + start = date(2025, 1, 13) + assert service._bucket_key_to_end(start, "weekly") == date(2025, 1, 19) + + def test_monthly_to_end(self, service: WechatStatsService): + """月桶键转结束日期(月末)。""" + start = date(2025, 1, 1) + assert service._bucket_key_to_end(start, "monthly") == date(2025, 1, 31) + + def test_monthly_to_end_december(self, service: WechatStatsService): + """12月月末跨年。""" + start = date(2025, 12, 1) + assert service._bucket_key_to_end(start, "monthly") == date(2025, 12, 31) + + def test_quarterly_to_end(self, service: WechatStatsService): + """季度桶键转结束日期。""" + start = date(2025, 1, 1) + assert service._bucket_key_to_end(start, "quarterly") == date(2025, 3, 31) + + def test_semi_annual_to_end(self, service: WechatStatsService): + """半年桶键转结束日期。""" + start = date(2025, 1, 1) + assert service._bucket_key_to_end(start, "semi_annual") == date(2025, 6, 30) + + def test_annual_to_end(self, service: WechatStatsService): + """年桶键转结束日期。""" + start = date(2025, 1, 1) + assert service._bucket_key_to_end(start, "annual") == date(2025, 12, 31) + + +# ── 趋势数据测试 ── + + +class TestGetTrendWithAggregates: + """测试 get_trend 使用聚合数据的情况。""" + + def test_get_trend_uses_aggregates( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """当存在聚合数据时,应优先使用聚合数据。""" + content, record, _ = test_data + + # 创建聚合数据 + agg = WechatStatsAggregate( + community_id=test_community.id, + period_type="daily", + period_start=date(2025, 1, 10), + period_end=date(2025, 1, 10), + article_category=None, + total_articles=1, + avg_read_count=100, + total_read_count=100, + total_read_user_count=80, + total_like_count=5, + total_wow_count=2, + total_share_count=3, + total_comment_count=1, + total_favorite_count=1, + total_forward_count=2, + total_new_follower_count=10, + ) + db_session.add(agg) + db_session.commit() + + result = service.get_trend( + db_session, + community_id=test_community.id, + period_type="daily", + ) + + assert result["period_type"] == "daily" + assert result["category"] is None + assert len(result["data_points"]) == 1 + assert result["data_points"][0]["read_count"] == 100 + + +class TestComputePeriodTrend: + """测试 _compute_period_trend 方法。""" + + def test_compute_weekly_trend( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """测试周趋势聚合。""" + result = service._compute_period_trend( + db_session, + community_id=test_community.id, + period_type="weekly", + category=None, + start_date=date(2025, 1, 10), + end_date=date(2025, 1, 14), + ) + + assert result["period_type"] == "weekly" + assert len(result["data_points"]) >= 1 + + def test_compute_monthly_trend( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """测试月趋势聚合。""" + result = service._compute_period_trend( + db_session, + community_id=test_community.id, + period_type="monthly", + category=None, + start_date=date(2025, 1, 1), + end_date=date(2025, 1, 31), + ) + + assert result["period_type"] == "monthly" + assert len(result["data_points"]) >= 1 + + def test_compute_quarterly_trend( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """测试季度趋势聚合。""" + result = service._compute_period_trend( + db_session, + community_id=test_community.id, + period_type="quarterly", + category=None, + start_date=date(2025, 1, 1), + end_date=date(2025, 3, 31), + ) + + assert result["period_type"] == "quarterly" + assert len(result["data_points"]) >= 1 + + +# ── 聚合数据重建测试 ── + + +class TestRebuildAggregates: + """测试 rebuild_aggregates 方法。""" + + def test_rebuild_daily_aggregates( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """重建每日聚合数据。""" + count = service.rebuild_aggregates( + db_session, + community_id=test_community.id, + period_type="daily", + start_date=date(2025, 1, 10), + end_date=date(2025, 1, 14), + ) + + assert count >= 1 + + # 验证聚合数据已创建 + aggs = db_session.query(WechatStatsAggregate).filter( + WechatStatsAggregate.community_id == test_community.id, + WechatStatsAggregate.period_type == "daily", + ).all() + assert len(aggs) >= 1 + + def test_rebuild_updates_existing( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """重建时应更新已有聚合数据。""" + # 先创建聚合数据 + service.rebuild_aggregates( + db_session, + community_id=test_community.id, + period_type="daily", + start_date=date(2025, 1, 10), + ) + + # 再次重建(应更新而非创建) + count = service.rebuild_aggregates( + db_session, + community_id=test_community.id, + period_type="daily", + start_date=date(2025, 1, 10), + ) + + assert count >= 1 + + def test_rebuild_with_category( + self, + db_session: Session, + service: WechatStatsService, + test_community: Community, + test_data: tuple[Content, PublishRecord, WechatArticleStat], + ): + """重建指定分类的聚合数据。""" + count = service.rebuild_aggregates( + db_session, + community_id=test_community.id, + period_type="daily", + start_date=date(2025, 1, 10), + end_date=date(2025, 1, 14), + ) + + assert count >= 1 + + # 验证分类聚合数据 + aggs = db_session.query(WechatStatsAggregate).filter( + WechatStatsAggregate.community_id == test_community.id, + WechatStatsAggregate.period_type == "daily", + WechatStatsAggregate.article_category == "technical", + ).all() + assert len(aggs) >= 1 diff --git "a/docs/plannings/03-\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" "b/docs/plannings/03-\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" new file mode 100644 index 0000000..4b643ab --- /dev/null +++ "b/docs/plannings/03-\345\274\200\345\217\221\345\256\236\346\226\275\346\226\271\346\241\210.md" @@ -0,0 +1,1194 @@ +# openGecko — 分步开发实施方案 + +**关联需求文档**: [02-新模块需求分析.md](../requirements/02-新模块需求分析.md)(v2.0) +**文档版本**: v1.0 +**更新日期**: 2026-02-22 + +> 本文档以当前代码库现状为基准,逐阶段给出**精确到文件/函数/SQL**的开发指令,可直接按顺序执行。 + +--- + +## 目录 + +1. [代码库现状速查](#1-代码库现状速查) +2. [前置重构阶段](#2-前置重构阶段) + - 2.1 [权限模型简化](#21-权限模型简化移除-community_admin) + - 2.2 [内容多社区关联](#22-内容多社区关联-content_communities) + - 2.3 [前端导航重组](#23-前端导航重组) +3. [Phase 4a — 人脉中台 + 活动管理基础](#3-phase-4a--人脉中台--活动管理基础) + - 3.1 [PersonProfile + CommunityRole 模型](#31-personprofile--communityrole-模型) + - 3.2 [CommitteeMember 关联 PersonProfile](#32-committeemember-关联-personprofile) + - 3.3 [Event + SOP 模板 + Checklist 模型](#33-event--sop-模板--checklist-模型) + - 3.4 [后端 API 实现](#34-后端-api-实现) + - 3.5 [签到名单导入服务](#35-签到名单导入服务) + - 3.6 [前端页面](#36-前端页面) +4. [Phase 4b — 甘特图 + 反馈 Issue 关联](#4-phase-4b--甘特图--反馈-issue-关联) +5. [Phase 4c — Campaign](#5-phase-4c--campaign) +6. [Phase 4d — 生态洞察](#6-phase-4d--生态洞察) +7. [测试策略](#7-测试策略) + + + +### 1.1 后端已有模块 + +| 文件 | 说明 | 重构影响 | +|------|------|---------| +| `models/user.py` | `User` + `community_users` 表 | `community_users.role` 注释已写 `'admin', 'user'`,实际数据中可能存在 `community_admin` 值 | +| `models/content.py` | `Content`,含 `community_id` 直接外键 | **需改造**:移除 `community_id`,新增 `content_communities` 关联表 | +| `models/committee.py` | `Committee` + `CommitteeMember` | `CommitteeMember` 已有 `github_id` 字段,**需新增** `person_id` | +| `core/dependencies.py` | 认证依赖注入 | 已有 `get_community_admin`(需保留兼容)+ `get_current_active_superuser` | +| `api/contents.py` | 内容 CRUD | `community_id` 单值过滤需改为 `content_communities` JOIN | +| `api/dashboard.py` | 个人工作台 | 内容统计查询需同步更新 | + +### 1.2 数据库迁移现状 + +当前仅有 `alembic/versions/001_initial.py`,所有新迁移从 `002_` 开始编号。 + +### 1.3 前端已有视图 + +``` +MyWork.vue → 个人工作看板(需扩展) +ContentList.vue → 内容列表(社区选择需改多选) +CommitteeDetail.vue → 委员会详情(成员需加 PersonProfile 关联入口) +Dashboard.vue → 个人统计(需新增活动/Campaign 卡片) +``` + +--- + +## 2. 前置重构阶段 + +### 2.1 权限模型简化(移除 community_admin) + +#### 2.1.1 数据库迁移 + +新建 `backend/alembic/versions/002_simplify_roles.py`: + +```python +"""简化权限模型:移除 community_admin 角色 + +Revision ID: 002 +Revises: 001 +""" +from alembic import op + +revision = "002" +down_revision = "001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # 第一步:将所有 community_admin 降级为 user + op.execute( + "UPDATE community_users SET role = 'user' WHERE role = 'community_admin'" + ) + + +def downgrade() -> None: + # 降级不恢复(无法区分哪些 user 原本是 community_admin) + pass +``` + +#### 2.1.2 修改 `core/dependencies.py` + +**删除** `get_community_admin` 函数,**新增** `get_current_admin_or_superuser`: + +```python +async def get_current_admin_or_superuser( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +) -> User: + """管理员(admin)或超级管理员(superuser)可通过。""" + if current_user.is_superuser: + return current_user + # 检查 community_users 表中是否有任意社区的 admin 角色 + stmt = select(community_users.c.role).where( + community_users.c.user_id == current_user.id, + community_users.c.role == "admin", + ).limit(1) + result = db.execute(stmt).scalar() + if not result: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="管理员权限不足", + ) + return current_user +``` + +> **注意**:现有所有使用 `get_community_admin` 的路由需批量替换为 `get_current_admin_or_superuser`。用 `grep -r "get_community_admin" backend/app/api/` 找到全部引用。 + +#### 2.1.3 `check_content_edit_permission` 更新 + +`dependencies.py` 中 `check_content_edit_permission` 函数内引用了 `content.community_id`,**在 §2.2 内容迁移完成后**再删除该引用,当前保持兼容。 + +--- + +### 2.2 内容多社区关联(content_communities) + +#### 2.2.1 数据库迁移(两步策略) + +**第一步** — `003_add_content_communities.py`(新增关联表,迁移数据): + +```python +"""新增 content_communities 关联表 + +Revision ID: 003 +Revises: 002 +""" +import sqlalchemy as sa +from alembic import op + +revision = "003" +down_revision = "002" + + +def upgrade() -> None: + op.create_table( + "content_communities", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("content_id", sa.Integer, + sa.ForeignKey("contents.id", ondelete="CASCADE"), + nullable=False, index=True), + sa.Column("community_id", sa.Integer, + sa.ForeignKey("communities.id", ondelete="CASCADE"), + nullable=False, index=True), + sa.Column("is_primary", sa.Boolean, default=True), + sa.Column("linked_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("linked_by_id", sa.Integer, + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True), + ) + op.create_unique_constraint( + "uq_content_community", + "content_communities", + ["content_id", "community_id"], + ) + # 迁移现有数据:将 contents.community_id 迁入新表 + op.execute(""" + INSERT INTO content_communities (content_id, community_id, is_primary) + SELECT id, community_id, 1 + FROM contents + WHERE community_id IS NOT NULL + """) + + +def downgrade() -> None: + op.drop_table("content_communities") +``` + +**第二步** — `009_remove_content_community_id.py`(迁移验证无误后再执行): + +```python +"""移除 contents.community_id 旧字段 + +Revision ID: 009 +Revises: 008 +""" +from alembic import op + +revision = "009" +down_revision = "008" + + +def upgrade() -> None: + # SQLite 需要 batch_alter_table;PostgreSQL 可直接 drop + with op.batch_alter_table("contents") as batch_op: + batch_op.drop_constraint("fk_contents_community_id", type_="foreignkey") + batch_op.drop_column("community_id") + + +def downgrade() -> None: + with op.batch_alter_table("contents") as batch_op: + batch_op.add_column( + sa.Column("community_id", sa.Integer, nullable=True) + ) +``` + +#### 2.2.2 修改 `models/content.py` + +在现有 `Content` 类末尾追加关联表定义和 relationship,**暂时保留** `community_id` 字段(兼容过渡): + +```python +# 新增关联表(在 content_collaborators 定义之后) +content_communities = Table( + "content_communities", + Base.metadata, + Column("id", Integer, primary_key=True), + Column("content_id", Integer, + ForeignKey("contents.id", ondelete="CASCADE"), + nullable=False, index=True), + Column("community_id", Integer, + ForeignKey("communities.id", ondelete="CASCADE"), + nullable=False, index=True), + Column("is_primary", Boolean, default=True), + Column("linked_at", DateTime, default=datetime.utcnow), + Column("linked_by_id", Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True), +) + +# Content 类中新增 relationship(保留旧 community relationship 作兼容) +communities = relationship( + "Community", + secondary="content_communities", + back_populates="linked_contents", +) +``` + +在 `models/community.py` 的 `Community` 类中追加: + +```python +linked_contents = relationship( + "Content", + secondary="content_communities", + back_populates="communities", +) +``` + +#### 2.2.3 修改 `schemas/content.py` + +```python +# ContentCreate 新增 +community_ids: list[int] = [] # 替代原 community_id(创建时关联的社区列表) + +# ContentOut 新增 +community_ids: list[int] = [] +``` + +#### 2.2.4 修改 `api/contents.py` + +关键改动点(搜索 `community_id` 替换): + +```python +# 创建内容时(原来: content.community_id = community_id) +for cid in content_in.community_ids or [community_id]: + db.execute( + insert(content_communities).values( + content_id=new_content.id, + community_id=cid, + is_primary=(cid == (content_in.community_ids or [community_id])[0]), + linked_by_id=current_user.id, + ) + ) + +# 列表查询过滤(原来: .filter(Content.community_id == community_id)) +.join(content_communities, + content_communities.c.content_id == Content.id +).filter( + content_communities.c.community_id == community_id +) +``` + +--- + +### 2.3 前端导航重组 + +涉及文件:`frontend/src/router/index.ts`、`frontend/src/App.vue` 或侧边栏组件。 + +导航菜单新结构(按 §5 导航设计): +1. 个人工作看板 → `/my-work` +2. 社区治理 → `/communities/:id/governance`、`/committees`、`/meetings` +3. 内容管理 → `/contents` +4. 活动管理 → `/events`(新增,Phase 4a 后激活) +5. 洞察与人脉 → `/people`、`/campaigns`(新增,Phase 4a/4c 后激活) +6. 平台管理 → 仅 `is_superuser || role === 'admin'` 可见 + +> 新模块路由可先注册为空页面占位,功能随 Phase 开发完成后逐步填充。 + +--- + +## 3. Phase 4a — 人脉中台 + 活动管理基础 + +### 3.1 PersonProfile + CommunityRole 模型 + +新建 `backend/app/models/people.py`: + +```python +from datetime import datetime +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship +from app.database import Base + +class PersonProfile(Base): + __tablename__ = "person_profiles" + + id = Column(Integer, primary_key=True, index=True) + display_name = Column(String(200), nullable=False, index=True) + avatar_url = Column(String(500), nullable=True) + github_handle = Column(String(100), nullable=True, unique=True, index=True) + gitcode_handle = Column(String(100), nullable=True, unique=True, index=True) + email = Column(String(200), nullable=True, unique=True, index=True) + phone = Column(String(50), nullable=True) + company = Column(String(200), nullable=True, index=True) + location = Column(String(200), nullable=True) + bio = Column(Text, nullable=True) + tags = Column(JSON, default=list) + notes = Column(Text, nullable=True) + source = Column( + SAEnum("manual", "event_import", "ecosystem_import", name="person_source_enum"), + nullable=False, default="manual", + ) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + community_roles = relationship("CommunityRole", back_populates="person", cascade="all, delete-orphan") + event_attendances = relationship("EventAttendee", back_populates="person") + + +class CommunityRole(Base): + __tablename__ = "community_roles" + + id = Column(Integer, primary_key=True, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + community_name = Column(String(200), nullable=False) + project_url = Column(String(500), nullable=True) + role = Column(String(100), nullable=False) + role_label = Column(String(100), nullable=True) + is_current = Column(Boolean, default=True) + started_at = Column(Date, nullable=True) + ended_at = Column(Date, nullable=True) + source_url = Column(String(500), nullable=True) + updated_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + person = relationship("PersonProfile", back_populates="community_roles") +``` + +数据库迁移 `004_add_people_module.py`(`down_revision = "003"`): + +```python +def upgrade() -> None: + op.create_table("person_profiles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("display_name", sa.String(200), nullable=False), + sa.Column("avatar_url", sa.String(500)), + sa.Column("github_handle", sa.String(100), unique=True), + sa.Column("gitcode_handle", sa.String(100), unique=True), + sa.Column("email", sa.String(200), unique=True), + sa.Column("phone", sa.String(50)), + sa.Column("company", sa.String(200)), + sa.Column("location", sa.String(200)), + sa.Column("bio", sa.Text), + sa.Column("tags", sa.JSON, default=list), + sa.Column("notes", sa.Text), + sa.Column("source", sa.String(30), nullable=False, server_default="manual"), + sa.Column("created_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")), + sa.Column("created_at", sa.DateTime, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime, server_default=sa.func.now()), + ) + op.create_table("community_roles", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("person_id", sa.Integer, sa.ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False), + sa.Column("community_name", sa.String(200), nullable=False), + sa.Column("project_url", sa.String(500)), + sa.Column("role", sa.String(100), nullable=False), + sa.Column("role_label", sa.String(100)), + sa.Column("is_current", sa.Boolean, server_default="1"), + sa.Column("started_at", sa.Date), + sa.Column("ended_at", sa.Date), + sa.Column("source_url", sa.String(500)), + sa.Column("updated_by_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")), + ) + op.create_index("ix_community_roles_person_id", "community_roles", ["person_id"]) + +def downgrade() -> None: + op.drop_table("community_roles") + op.drop_table("person_profiles") +``` + +--- + +### 3.2 CommitteeMember 关联 PersonProfile + +迁移 `008_link_committee_member_to_person.py`(`down_revision = "007"`): + +```python +def upgrade() -> None: + with op.batch_alter_table("committee_members") as batch_op: + batch_op.add_column( + sa.Column("person_id", sa.Integer, + sa.ForeignKey("person_profiles.id", ondelete="SET NULL"), + nullable=True) + ) + batch_op.create_index("ix_committee_members_person_id", ["person_id"]) + +def downgrade() -> None: + with op.batch_alter_table("committee_members") as batch_op: + batch_op.drop_index("ix_committee_members_person_id") + batch_op.drop_column("person_id") +``` + +`models/committee.py` 的 `CommitteeMember` 类末尾增加: + +```python +person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True, index=True) +person = relationship("PersonProfile", foreign_keys=[person_id]) +``` + +--- + +### 3.3 Event + SOP 模板 + Checklist 模型 + +新建 `backend/app/models/event.py`: + +```python +from datetime import datetime +from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship +from app.database import Base + +class EventTemplate(Base): + __tablename__ = "event_templates" + id = Column(Integer, primary_key=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(200), nullable=False) + event_type = Column(SAEnum("online","offline","hybrid", name="event_type_enum"), nullable=False) + description = Column(Text) + is_public = Column(Boolean, default=False) + created_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + checklist_items = relationship("ChecklistTemplateItem", back_populates="template", cascade="all, delete-orphan") + events = relationship("Event", back_populates="template") + +class ChecklistTemplateItem(Base): + __tablename__ = "checklist_template_items" + id = Column(Integer, primary_key=True) + template_id = Column(Integer, ForeignKey("event_templates.id", ondelete="CASCADE"), nullable=False, index=True) + phase = Column(SAEnum("pre","during","post", name="checklist_phase_enum"), nullable=False) + title = Column(String(300), nullable=False) + description = Column(Text) + order = Column(Integer, default=0) + template = relationship("EventTemplate", back_populates="checklist_items") + +class Event(Base): + __tablename__ = "events" + id = Column(Integer, primary_key=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(300), nullable=False) + event_type = Column(SAEnum("online","offline","hybrid", name="event_type_enum2"), nullable=False, default="offline") + template_id = Column(Integer, ForeignKey("event_templates.id", ondelete="SET NULL"), nullable=True) + status = Column( + SAEnum("draft","planning","ongoing","completed","cancelled", name="event_status_enum"), + default="draft", + ) + planned_at = Column(DateTime, nullable=True) + duration_minutes = Column(Integer, nullable=True) + location = Column(String(300), nullable=True) + online_url = Column(String(500), nullable=True) + description = Column(Text) + cover_image_url = Column(String(500), nullable=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + # 内嵌结果字段 + attendee_count = Column(Integer, nullable=True) + online_count = Column(Integer, nullable=True) + offline_count = Column(Integer, nullable=True) + registration_count = Column(Integer, nullable=True) + result_summary = Column(Text, nullable=True) + media_urls = Column(JSON, default=list) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + template = relationship("EventTemplate", back_populates="events") + checklist_items = relationship("ChecklistItem", back_populates="event", cascade="all, delete-orphan") + personnel = relationship("EventPersonnel", back_populates="event", cascade="all, delete-orphan") + attendees = relationship("EventAttendee", back_populates="event", cascade="all, delete-orphan") + feedback_items = relationship("FeedbackItem", back_populates="event", cascade="all, delete-orphan") + tasks = relationship("EventTask", back_populates="event", cascade="all, delete-orphan") + +class ChecklistItem(Base): + __tablename__ = "checklist_items" + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True) + phase = Column(SAEnum("pre","during","post", name="checklist_item_phase_enum"), nullable=False) + title = Column(String(300), nullable=False) + 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) + order = Column(Integer, default=0) + event = relationship("Event", back_populates="checklist_items") + +class EventPersonnel(Base): + __tablename__ = "event_personnel" + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True) + role = Column(String(50), nullable=False) + role_label = Column(String(100), nullable=True) + assignee_type = Column(SAEnum("internal","external", name="personnel_assignee_enum"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True) + confirmed = Column(SAEnum("pending","confirmed","declined", name="personnel_confirm_enum"), default="pending") + time_slot = Column(String(100), nullable=True) + notes = Column(Text) + order = Column(Integer, default=0) + event = relationship("Event", back_populates="personnel") + +class EventAttendee(Base): + __tablename__ = "event_attendees" + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + checked_in = Column(Boolean, default=False) + role_at_event = Column(String(100), nullable=True) + source = Column(SAEnum("manual","excel_import", name="attendee_source_enum"), default="manual") + event = relationship("Event", back_populates="attendees") + person = relationship("PersonProfile", back_populates="event_attendances") + +class FeedbackItem(Base): + __tablename__ = "feedback_items" + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True) + content = Column(Text, nullable=False) + category = Column(String(50), default="question") + raised_by = Column(String(200), nullable=True) + raised_by_person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True) + status = Column(SAEnum("open","in_progress","closed", name="feedback_status_enum"), default="open") + assignee_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + event = relationship("Event", back_populates="feedback_items") + issue_links = relationship("IssueLink", back_populates="feedback", cascade="all, delete-orphan") + +class IssueLink(Base): + __tablename__ = "issue_links" + id = Column(Integer, primary_key=True) + feedback_id = Column(Integer, ForeignKey("feedback_items.id", ondelete="CASCADE"), nullable=False, index=True) + platform = Column(SAEnum("github","gitcode","gitee", name="issue_platform_enum"), nullable=False) + repo = Column(String(200), nullable=False) + issue_number = Column(Integer, nullable=False) + issue_url = Column(String(500), nullable=False) + issue_type = Column(SAEnum("issue","pr", name="issue_type_enum"), default="issue") + issue_status = Column(SAEnum("open","closed", name="issue_status_enum"), default="open") + linked_at = Column(DateTime, default=datetime.utcnow) + linked_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + feedback = relationship("FeedbackItem", back_populates="issue_links") + +class EventTask(Base): + __tablename__ = "event_tasks" + id = Column(Integer, primary_key=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False, index=True) + title = Column(String(300), nullable=False) + task_type = Column(SAEnum("task","milestone", name="task_type_enum"), default="task") + phase = Column(SAEnum("pre","during","post", name="task_phase_enum"), default="pre") + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + progress = Column(Integer, default=0) + status = Column(SAEnum("not_started","in_progress","completed","blocked", name="task_status_enum"), default="not_started") + depends_on = Column(JSON, default=list) # list[int],JSON 存储 + parent_task_id = Column(Integer, ForeignKey("event_tasks.id", ondelete="SET NULL"), nullable=True) + order = Column(Integer, default=0) + event = relationship("Event", back_populates="tasks") +``` + +对应迁移 `005_add_event_module.py`(`down_revision = "004"`)— 按以上表结构创建,此处略去重复 DDL,实际用 `alembic revision --autogenerate` 自动生成后人工检查即可。 + +--- + +### 3.4 后端 API 实现 + +#### 新建 `api/people.py`(关键路由清单) + +``` +GET /api/people → 人脉列表(分页 + 筛选) +POST /api/people → 手动创建 +GET /api/people/{id} → 详情(含计算字段) +PATCH /api/people/{id} → 更新 +DELETE /api/people/{id} → 删除 +GET /api/people/{id}/roles → 社区身份历史 +POST /api/people/{id}/roles → 添加社区身份 +POST /api/people/import → Excel/CSV 批量导入(返回导入结果含疑似匹配列表) +POST /api/people/confirm-merge → 确认/拒绝疑似匹配 +``` + +列表查询参数:`q`(姓名/邮箱模糊)、`tag`、`company`、`source`、`page`、`page_size` + +#### 新建 `api/events.py`(关键路由清单) + +``` +GET /api/events → 活动列表 +POST /api/events → 创建活动(可选 template_id) +GET /api/events/{id} → 活动详情 +PATCH /api/events/{id} → 更新活动(含结果字段) +PATCH /api/events/{id}/status → 推进状态流转 +GET /api/events/{id}/checklist → Checklist 列表 +PATCH /api/events/{id}/checklist/{item_id} → 更新检查项状态 +GET /api/events/{id}/personnel → 人员安排列表 +POST /api/events/{id}/personnel → 添加人员 +PATCH /api/events/{id}/personnel/{pid}/confirm → 更新确认状态 +POST /api/events/{id}/attendees/import → 签到名单 Excel 导入 +GET /api/events/{id}/feedback → 反馈问题列表 +POST /api/events/{id}/feedback → 录入反馈问题 +POST /api/events/{id}/feedback/{fid}/links → 关联 Issue + +GET /api/event-templates → 模板列表 +POST /api/event-templates → 创建模板 +GET /api/event-templates/{id} → 模板详情 +PATCH /api/event-templates/{id} → 更新模板 +``` + +#### 注册路由到 `main.py` + +```python +from app.api import events, event_templates, people # 新增 + +app.include_router(people.router, prefix="/api/people", tags=["people"]) +app.include_router(events.router, prefix="/api/events", tags=["events"]) +app.include_router(event_templates.router, prefix="/api/event-templates", tags=["event-templates"]) +``` + +#### `models/__init__.py` 补充导入 + +```python +from app.models.people import PersonProfile, CommunityRole +from app.models.event import ( + Event, EventTemplate, ChecklistTemplateItem, ChecklistItem, + EventPersonnel, EventAttendee, EventTask, FeedbackItem, IssueLink, +) +``` + +--- + +### 3.5 签到名单导入服务 + +新建 `backend/app/services/people_service.py`: + +```python +from difflib import SequenceMatcher +from sqlalchemy.orm import Session +from app.models.people import PersonProfile +from app.models.event import EventAttendee + +def find_or_suggest(db: Session, row: dict) -> dict: + """ + 对导入的单行记录执行去重匹配。 + 返回: {"status": "matched"|"suggest"|"new", "person_id": int|None, "candidates": list} + """ + github = row.get("github_handle", "").strip().lower() + email = row.get("email", "").strip().lower() + name = row.get("display_name", "").strip() + company = row.get("company", "").strip() + + # 1. github_handle 精确匹配 + if github: + p = db.query(PersonProfile).filter(PersonProfile.github_handle == github).first() + if p: + return {"status": "matched", "person_id": p.id, "candidates": []} + + # 2. email 精确匹配 → 疑似 + if email: + p = db.query(PersonProfile).filter(PersonProfile.email == email).first() + if p: + return {"status": "suggest", "person_id": None, + "candidates": [{"id": p.id, "display_name": p.display_name, "reason": "email"}]} + + # 3. 姓名+公司模糊匹配 + if name: + candidates = [] + for p in db.query(PersonProfile).filter(PersonProfile.display_name.ilike(f"%{name[:4]}%")).limit(50): + ratio = SequenceMatcher(None, f"{name}|{company}", f"{p.display_name}|{p.company or ''}").ratio() + if ratio > 0.70: + candidates.append({"id": p.id, "display_name": p.display_name, + "company": p.company, "ratio": round(ratio, 2), "reason": "name+company"}) + if candidates: + return {"status": "suggest", "person_id": None, + "candidates": sorted(candidates, key=lambda x: -x["ratio"])} + + return {"status": "new", "person_id": None, "candidates": []} + + +def create_person_from_row(db: Session, row: dict, source: str, created_by_id: int | None) -> PersonProfile: + p = PersonProfile( + display_name=row.get("display_name", ""), + email=row.get("email") or None, + github_handle=row.get("github_handle") or None, + company=row.get("company") or None, + source=source, + created_by_id=created_by_id, + ) + db.add(p) + db.flush() # 获取 id + return p +``` + +导入端点核心逻辑(在 `api/events.py` 中): + +```python +@router.post("/{event_id}/attendees/import") +async def import_attendees(event_id: int, file: UploadFile, db=Depends(get_db), user=Depends(get_current_user)): + import openpyxl, io + wb = openpyxl.load_workbook(io.BytesIO(await file.read())) + ws = wb.active + headers = [str(c.value).strip().lower() for c in ws[1]] + + results = {"matched": [], "pending_confirm": [], "created": []} + for row in ws.iter_rows(min_row=2, values_only=True): + data = dict(zip(headers, row)) + match = find_or_suggest(db, data) + if match["status"] == "matched": + # 直接关联 + db.add(EventAttendee(event_id=event_id, person_id=match["person_id"], source="excel_import")) + results["matched"].append(match["person_id"]) + elif match["status"] == "suggest": + results["pending_confirm"].append({"row": data, "candidates": match["candidates"]}) + else: + person = create_person_from_row(db, data, "event_import", user.id) + db.add(EventAttendee(event_id=event_id, person_id=person.id, source="excel_import")) + results["created"].append(person.id) + db.commit() + return results # 前端展示 pending_confirm 供人工确认 +``` + +--- + +### 3.6 前端页面 + +Phase 4a 需新增的前端文件(均放 `frontend/src/views/`): + +| 文件 | 说明 | +|------|------| +| `PeopleList.vue` | 人脉列表 + 标签/公司筛选 | +| `PeopleDetail.vue` | 档案详情(社区身份时间线 + 活动记录)| +| `PeopleImport.vue` | Excel 导入 + 疑似匹配确认对话框 | +| `EventList.vue` | 活动列表 + 状态筛选 | +| `EventDetail.vue` | 活动详情(Tab:基本信息 / Checklist / 人员安排 / 结果)| +| `EventTemplateList.vue` | SOP 模板管理 | + +前端 API 模块新增(`frontend/src/api/`): + +```typescript +// people.ts +export const getPeople = (params) => request.get('/api/people', { params }) +export const createPerson = (data) => request.post('/api/people', data) +export const importPeople = (eventId, file) => { + const form = new FormData() + form.append('file', file) + return request.post(`/api/events/${eventId}/attendees/import`, form) +} + +// events.ts +export const getEvents = (params) => request.get('/api/events', { params }) +export const createEvent = (data) => request.post('/api/events', data) +export const updateChecklistItem = (eventId, itemId, data) => + request.patch(`/api/events/${eventId}/checklist/${itemId}`, data) +``` + +**`MyWork.vue` 扩展**:在现有"分配给我的内容"卡片之后追加: + +```html + + + {{ group.eventTitle }} + + {{ item.title }} + + +``` + +对应后端端点(追加到 `api/dashboard.py`): + +```python +@router.get("/my-checklist") +def my_checklist(db=Depends(get_db), user=Depends(get_current_user)): + items = (db.query(ChecklistItem) + .filter(ChecklistItem.assignee_id == user.id, + ChecklistItem.status == "pending") + .order_by(ChecklistItem.due_date.nullslast(asc)) + .all()) + # 按 event 分组返回 + ... +``` + +--- + +## 4. Phase 4b — 甘特图 + 反馈 Issue 关联 + +### 4.1 甘特图后端 + +`EventTask` 模型已在 Phase 4a 的 `models/event.py` 中定义,本阶段补充 API: + +``` +GET /api/events/{id}/tasks → 任务列表(含子任务树形结构) +POST /api/events/{id}/tasks → 创建任务 +PATCH /api/events/{id}/tasks/{tid} → 更新(进度 / 日期 / 状态) +DELETE /api/events/{id}/tasks/{tid} → 删除 +PATCH /api/events/{id}/tasks/reorder → 批量重排序 +``` + +任务列表返回树形结构时,后端统一展平后由前端重组(`parent_task_id` 字段即树路径): + +```python +def build_task_tree(tasks: list) -> list: + """将平铺任务列表转为树形结构""" + task_map = {t.id: {**t.__dict__, "children": []} for t in tasks} + roots = [] + for t in tasks: + if t.parent_task_id and t.parent_task_id in task_map: + task_map[t.parent_task_id]["children"].append(task_map[t.id]) + else: + roots.append(task_map[t.id]) + return roots +``` + +### 4.2 甘特图前端集成 + +安装依赖: + +```bash +cd frontend && npm install frappe-gantt +``` + +在 `EventDetail.vue` 的甘特图 Tab 中: + +```vue + + + + +``` + +> **Vue 3 兼容性**:`frappe-gantt` v0.6+ 是纯 DOM 操作库,无框架依赖,直接挂载到 `ref` 元素上即可。Phase 4b 启动前先做 10 分钟 POC 验证能正常渲染。 + +### 4.3 反馈 Issue 关联 + +`FeedbackItem` 和 `IssueLink` 模型已在 Phase 4a 定义。本阶段补充: + +1. **Issue 状态定时同步**(新建 `services/issue_sync.py`): + +```python +import httpx +from sqlalchemy.orm import Session +from app.models.event import IssueLink + +async def sync_issue_status(db: Session, token: str | None = None): + headers = {"Authorization": f"token {token}"} if token else {} + links = db.query(IssueLink).filter(IssueLink.platform == "github").all() + async with httpx.AsyncClient() as client: + for link in links: + url = f"https://api.github.com/repos/{link.repo}/issues/{link.issue_number}" + resp = await client.get(url, headers=headers) + if resp.status_code == 200: + link.issue_status = resp.json().get("state", "open") + db.commit() +``` + +2. **在 `main.py` lifespan 中注册 APScheduler**(每日 02:00 执行): + +```python +from apscheduler.schedulers.asyncio import AsyncIOScheduler +scheduler = AsyncIOScheduler() + +@asynccontextmanager +async def lifespan(app: FastAPI): + init_db() + scheduler.add_job(sync_issue_status_job, "cron", hour=2) + scheduler.start() + yield + scheduler.shutdown() +``` + +--- + +## 5. Phase 4c — Campaign + +### 5.1 数据模型 + +新建 `backend/app/models/campaign.py`: + +```python +from datetime import datetime +from sqlalchemy import Column, Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship +from app.database import Base + +class Campaign(Base): + __tablename__ = "campaigns" + id = Column(Integer, primary_key=True) + community_id = Column(Integer, ForeignKey("communities.id", ondelete="CASCADE"), nullable=False, index=True) + name = Column(String(300), nullable=False) + description = Column(Text) + type = Column(SAEnum("promotion","care","invitation","survey", name="campaign_type_enum"), nullable=False) + status = Column(SAEnum("draft","active","completed","archived", name="campaign_status_enum"), default="draft") + target_count = Column(Integer, nullable=True) + owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + start_date = Column(Date, nullable=True) + end_date = Column(Date, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + contacts = relationship("CampaignContact", back_populates="campaign", cascade="all, delete-orphan") + activities = relationship("CampaignActivity", back_populates="campaign", cascade="all, delete-orphan") + +class CampaignContact(Base): + __tablename__ = "campaign_contacts" + id = Column(Integer, primary_key=True) + campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + status = Column( + SAEnum("pending","contacted","responded","converted","declined", name="contact_status_enum"), + default="pending", + ) + channel = Column(SAEnum("email","wechat","phone","in_person","other", name="contact_channel_enum"), nullable=True) + added_by = Column(SAEnum("manual","event_import","ecosystem_import","csv_import", name="contact_source_enum"), default="manual") + last_contacted_at = Column(DateTime, nullable=True) + notes = Column(Text) + assigned_to_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + campaign = relationship("Campaign", back_populates="contacts") + person = relationship("PersonProfile") + +class CampaignActivity(Base): + __tablename__ = "campaign_activities" + id = Column(Integer, primary_key=True) + campaign_id = Column(Integer, ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="CASCADE"), nullable=False, index=True) + action = Column(SAEnum("sent_email","made_call","sent_wechat","in_person_meeting","got_reply","note", + name="campaign_action_enum"), nullable=False) + content = Column(Text) + outcome = Column(String(300)) + operator_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + campaign = relationship("Campaign", back_populates="activities") +``` + +迁移 `006_add_campaign_module.py`(`down_revision = "005"`):用 `--autogenerate` 生成后审查。 + +### 5.2 Campaign API 路由 + +新建 `api/campaigns.py`: + +``` +GET /api/campaigns → 列表(按类型/状态过滤) +POST /api/campaigns → 创建 +GET /api/campaigns/{id} → 详情(含各状态联系人统计) +PATCH /api/campaigns/{id} → 更新 +POST /api/campaigns/{id}/contacts/import-event → 从活动签到导入 +POST /api/campaigns/{id}/contacts/import-people → 从人脉库筛选导入 +POST /api/campaigns/{id}/contacts/import-csv → CSV 批量导入 +POST /api/campaigns/{id}/contacts/{cid}/activities → 记录跟进动作 +PATCH /api/campaigns/{id}/contacts/{cid}/status → 更新联系人状态 +``` + +概览端点需要返回漏斗数据(与前端 ECharts 对接): + +```python +@router.get("/{campaign_id}/funnel") +def campaign_funnel(campaign_id: int, db=Depends(get_db), ...): + from sqlalchemy import func + rows = (db.query(CampaignContact.status, func.count()) + .filter(CampaignContact.campaign_id == campaign_id) + .group_by(CampaignContact.status).all()) + return {status: count for status, count in rows} +``` + +### 5.3 `MyWork.vue` Campaign 扩展 + +后端追加端点 `GET /api/users/me/dashboard/my-campaigns`: + +```python +pending = (db.query(CampaignContact) + .filter(CampaignContact.assigned_to_id == user.id, + CampaignContact.status == "pending") + .all()) +# 按 campaign_id 分组返回 +``` + +--- + +## 6. Phase 4d — 生态洞察 + +### 6.1 数据模型 + +新建 `backend/app/models/ecosystem.py`: + +```python +from datetime import datetime +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import relationship +from app.database import Base + +class EcosystemProject(Base): + __tablename__ = "ecosystem_projects" + id = Column(Integer, primary_key=True) + name = Column(String(200), nullable=False) + platform = Column(SAEnum("github","gitee","gitcode", name="eco_platform_enum"), nullable=False) + org_name = Column(String(200), nullable=False) + repo_name = Column(String(200), nullable=True) + description = Column(Text) + tags = Column(JSON, default=list) + is_active = Column(Boolean, default=True) + last_synced_at = Column(DateTime, nullable=True) + added_by_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + + contributors = relationship("EcosystemContributor", back_populates="project", cascade="all, delete-orphan") + +class EcosystemContributor(Base): + __tablename__ = "ecosystem_contributors" + id = Column(Integer, primary_key=True) + project_id = Column(Integer, ForeignKey("ecosystem_projects.id", ondelete="CASCADE"), nullable=False, index=True) + github_handle = Column(String(100), nullable=False, index=True) + display_name = Column(String(200)) + avatar_url = Column(String(500)) + role = Column(String(50)) + commit_count_90d = Column(Integer) + pr_count_90d = Column(Integer) + star_count = Column(Integer) + followers = Column(Integer) + person_id = Column(Integer, ForeignKey("person_profiles.id", ondelete="SET NULL"), nullable=True, index=True) + last_synced_at = Column(DateTime, default=datetime.utcnow) + + project = relationship("EcosystemProject", back_populates="contributors") + person = relationship("PersonProfile") +``` + +迁移 `007_add_ecosystem_module.py`(`down_revision = "006"`):`--autogenerate` 后审查。 + +### 6.2 GitHub 采集服务 + +新建 `backend/app/services/ecosystem/github_crawler.py`: + +```python +import httpx +from sqlalchemy.orm import Session +from app.models.ecosystem import EcosystemProject, EcosystemContributor +from app.config import settings + +async def sync_project(db: Session, project: EcosystemProject) -> None: + token = settings.GITHUB_PAT # 从 .env 读取,未配置则跳过 + headers = {"Authorization": f"token {token}"} if token else {} + base = "https://api.github.com" + + async with httpx.AsyncClient(timeout=30) as client: + # 获取贡献者列表 + url = f"{base}/repos/{project.org_name}/{project.repo_name}/contributors" + resp = await client.get(url, headers=headers, params={"per_page": 100}) + if resp.status_code != 200: + return + + existing = {c.github_handle: c for c in project.contributors} + for item in resp.json(): + handle = item["login"] + if handle in existing: + existing[handle].commit_count_90d = item.get("contributions") + else: + db.add(EcosystemContributor( + project_id=project.id, + github_handle=handle, + display_name=item.get("login"), + avatar_url=item.get("avatar_url"), + commit_count_90d=item.get("contributions"), + )) + from datetime import datetime + project.last_synced_at = datetime.utcnow() + db.commit() +``` + +`config.py` 中追加: + +```python +GITHUB_PAT: str | None = None # 可选,配置后启用自动同步 +``` + +### 6.3 API 路由 + +新建 `api/ecosystem.py`: + +``` +GET /api/ecosystem → 监控项目列表(仅 admin+) +POST /api/ecosystem → 添加监控项目 +PATCH /api/ecosystem/{id} → 编辑 +POST /api/ecosystem/{id}/sync → 手动触发同步 +GET /api/ecosystem/{id}/contributors → 贡献者列表 +POST /api/ecosystem/{id}/contributors/{handle}/import-person → 导入人脉库 +``` + +权限控制:所有端点使用 `get_current_admin_or_superuser` 依赖。 + +### 6.4 前端 + +新增视图: + +| 文件 | 说明 | +|------|------| +| `EcosystemList.vue` | 监控项目卡片列表 | +| `EcosystemDetail.vue` | 指标趋势图 + 贡献者排行 + 一键导入人脉按钮 | + +趋势图使用 ECharts(已有依赖),折线图展示 30/60/90 天 Star/Fork 趋势。 + +--- + +## 7. 测试策略 + +### 7.1 每个阶段的最低测试要求 + +| Phase | 测试文件 | 覆盖重点 | +|-------|---------|---------| +| 前置重构 | `tests/test_contents_api.py`(更新) | 多社区关联创建/查询;`community_admin` 迁移后权限验证 | +| Phase 4a | `tests/test_people_api.py` | PersonProfile CRUD;去重匹配(`find_or_suggest`);Excel 导入返回 pending_confirm | +| Phase 4a | `tests/test_events_api.py` | Event 创建/状态流转;Checklist 实例化;人员安排 | +| Phase 4b | `tests/test_events_api.py`(扩展)| EventTask CRUD;IssueLink 关联;Issue 状态同步 mock | +| Phase 4c | `tests/test_campaigns_api.py` | Campaign CRUD;多维度联系人导入;漏斗统计 | +| Phase 4d | `tests/test_ecosystem_api.py` | EcosystemProject CRUD(admin 鉴权);sync 端点(mock GitHub API)| + +### 7.2 `people_service` 单元测试(无 HTTP client) + +```python +# tests/test_people_service.py +from unittest.mock import MagicMock +from app.services.people_service import find_or_suggest + +def test_github_exact_match(): + db = MagicMock() + mock_person = MagicMock(id=1) + db.query().filter().first.return_value = mock_person + result = find_or_suggest(db, {"github_handle": "torvalds"}) + assert result["status"] == "matched" + assert result["person_id"] == 1 + +def test_no_match_creates_new(): + db = MagicMock() + db.query().filter().first.return_value = None + db.query().filter().limit().return_value = [] + result = find_or_suggest(db, {"display_name": "张三"}) + assert result["status"] == "new" +``` + +### 7.3 关键陷阱提示 + +| 问题 | 说明 | +|------|------| +| SQLite Enum 冲突 | 同一 Python 进程中多个同名 Enum(如 `event_type_enum` 在 `EventTemplate` 和 `Event` 中)会引发 SQLAlchemy 报错。解决:每个表定义唯一的 `name` 参数,或统一在文件顶部 `event_type = SAEnum("online","offline","hybrid", name="event_type_enum", create_constraint=True)` 然后复用该变量。 | +| `alembic --autogenerate` JSON 字段 | SQLite 不原生支持 JSON 类型,Alembic 自动生成可能写 `String`。手动检查并改为 `sa.JSON`。 | +| `community_id` 过渡期 | 内容迁移两步之间,`contents.community_id` 仍存在。`api/contents.py` 中需同时写入旧字段(兼容)直至执行 `009_remove_content_community_id.py`。 | +| `get_community_admin` 引用 | 用 `grep -r "get_community_admin" backend/app/api/` 找到所有引用,统一替换为 `get_current_admin_or_superuser`,否则权限模型简化后老接口无法调用。 | + +--- + +**文档版本**: v1.0 — 初版实施方案 +**配套文档**: [02-新模块需求分析.md](../requirements/02-新模块需求分析.md) + + + diff --git "a/docs/requirements/02-\346\226\260\346\250\241\345\235\227\351\234\200\346\261\202\345\210\206\346\236\220.md" "b/docs/requirements/02-\346\226\260\346\250\241\345\235\227\351\234\200\346\261\202\345\210\206\346\236\220.md" new file mode 100644 index 0000000..2865928 --- /dev/null +++ "b/docs/requirements/02-\346\226\260\346\250\241\345\235\227\351\234\200\346\261\202\345\210\206\346\236\220.md" @@ -0,0 +1,1091 @@ +# openGecko — 平台重构与新模块产品需求文档(PRD) + +**产品名称**: openGecko — 多社区运营管理平台 +**文档版本**: v2.0 +**更新日期**: 2026-02-22 +**文档状态**: 规划中(待评审) +**关联文档**: [01-需求分析文档.md](./01-需求分析文档.md)(已交付功能基线) + +--- + +## 目录 + +1. [重构动机与目标](#1-重构动机与目标) +2. [六大模块总览](#2-六大模块总览) +3. [权限模型设计](#3-权限模型设计) +4. [模块详细设计](#4-模块详细设计) + - 4.1 [社区治理模块](#41-社区治理模块) + - 4.2 [内容管理模块](#42-内容管理模块) + - 4.3 [活动管理模块](#43-活动管理模块) + - 4.4 [社区洞察与人脉管理模块(含生态洞察与 Campaign)](#44-社区洞察与人脉管理模块) + - 4.5 [平台管理模块](#45-平台管理模块) + - 4.6 [个人工作看板](#46-个人工作看板) +5. [导航结构](#5-导航结构) +6. [数据模型总览](#6-数据模型总览) +7. [技术架构设计](#7-技术架构设计) +8. [分阶段交付计划](#8-分阶段交付计划) +9. [风险与约束](#9-风险与约束) + +--- + +## 1. 重构动机与目标 + +### 1.1 现有架构的问题 + +openGecko 已具备内容生产、多渠道发布和社区治理的基础能力,但随着业务深入,当前架构存在以下主要问题: + +| 问题 | 具体表现 | +|------|---------| +| **模块边界不清晰** | 内容、社区、人员三者高度耦合,内容必须从属于某一固定社区,无法跨社区复用 | +| **权限层级冗余** | 四层权限(superuser / admin / community_admin / user)导致权限判断复杂,社区管理员概念引发业务歧义 | +| **活动运营无工具支撑** | 线上/线下活动策划散落在多维表格,关怀类 Campaign 无系统化跟进工具 | +| **关键人员数据孤岛** | 委员会成员、活动参与者、生态贡献者三份数据互不关联 | +| **缺乏生态视野** | 只能看到自己社区的数据,无法感知外部主流开源社区的技术动向 | + +### 1.2 重构目标 + +1. **确立六大业务模块**,各模块职责单一、边界清晰 +2. **简化权限模型**为三层,去除社区管理员角色 +3. **内容独立化**:内容脱离单一社区归属,改为多社区关联 +4. **补齐活动与人脉两大运营工具** +5. **为平台级洞察能力奠定基础** + + + +--- + +## 2. 六大模块总览 + +### 2.1 模块划分 + +| # | 模块名称 | 定位 | 多租户策略 | +|---|---------|------|-----------| +| 1 | **社区治理** | 围绕某一具体社区展开,管理委员会、会议和治理沙盘 | 社区隔离(必须选定社区才能查看)| +| 2 | **内容管理** | 平台级独立模块,管理所有内容,通过关联关系绑定到一个或多个社区 | 内容本身平台级,关联表按社区隔离 | +| 3 | **活动管理** | 平台级独立模块,管理所有类型活动(线上/线下/混合),通过属性与社区关联 | 活动本身可跨社区,主社区隔离 | +| 4 | **社区洞察与人脉管理** | 对主流社区人员和技术方向的洞察,以人为中心开展工作;Campaign 作为人脉运营的子功能 | PersonProfile 平台级共享,Campaign 按社区隔离 | +| 5 | **个人工作看板** | 每个用户的个人工作台,汇聚所有分配给自己的待办任务 | 按登录用户隔离 | +| 6 | **平台管理** | 平台本身的管理:人员管理、工作量管理、社区整体管理 | 仅管理员及以上可见 | + +### 2.2 模块关系总览 + +```mermaid +graph TB + subgraph M1["① 社区治理"] + GOV[委员会 / 会议 / 社区沙盘] + end + subgraph M2["② 内容管理"] + CT[内容 / 多渠道发布] + end + subgraph M3["③ 活动管理"] + EV[活动 Event / SOP / 甘特图] + end + subgraph M4["④ 洞察与人脉"] + PP[人脉档案 PersonProfile] + CP[Campaign 关怀运营] + ECO[生态洞察 Ecosystem] + end + subgraph M5["⑤ 个人看板"] + WB[我的待办 / 统计] + end + subgraph M6["⑥ 平台管理"] + ADM[用户 / 社区 / 工作量] + end + + CT -- "关联多个社区" --> M1 + EV -- "关联社区" --> M1 + EV -- "签到 → 人脉" --> PP + CP -- "从人脉库选联系人" --> PP + ECO -- "贡献者数据" --> PP + GOV -- "委员会成员可选关联" --> PP + M1 & M2 & M3 & M4 --> WB +``` + +### 2.3 现有能力对应关系 + +| 现有功能 | 重构后归属 | 变化说明 | +|---------|-----------|---------| +| 社区工作台(沙盘)| 社区治理模块 | 无变化,选定社区后查看 | +| 内容管理 + 发布管理 | 内容管理模块 | **内容改为多社区关联**,脱离单一 community_id 直接归属 | +| 委员会 + 会议管理 | 社区治理模块 | 无变化 | +| 个人工作台 `/my-work` | 个人工作看板 | 扩展:新增活动检查项和 Campaign 跟进 | +| 超管区域(社区总览等)| 平台管理模块 | 无变化,权限模型简化 | + +--- + +## 3. 权限模型设计 + +### 3.1 三层权限体系 + +去除原有的「社区管理员(community_admin)」角色,简化为三层: + +| 角色 | 标识 | 说明 | +|------|------|------| +| **超级管理员** | `superuser` | 平台拥有者,拥有全量权限,且唯一可以设置/撤销管理员角色 | +| **管理员** | `admin` | 辅助超级管理员,权限与超级管理员基本一致,唯一差异是不能变更用户角色 | +| **普通用户** | `user` | 业务操作人员,可访问所有业务模块,不能看到平台管理模块(⑥号模块)| + +### 3.2 权限矩阵 + +| 能力 | 普通用户 | 管理员 | 超级管理员 | +|------|---------|--------|-----------| +| 查看社区治理(委员会/会议/沙盘)| ✅ | ✅ | ✅ | +| 编辑委员会 / 会议 | ✅ | ✅ | ✅ | +| 创建/编辑内容 | ✅ | ✅ | ✅ | +| 发布内容到渠道 | ✅ | ✅ | ✅ | +| 创建/管理活动 | ✅ | ✅ | ✅ | +| 创建/管理 Campaign | ✅ | ✅ | ✅ | +| 管理人脉档案(增删改导入导出)| ✅ | ✅ | ✅ | +| 查看生态洞察 | ❌ | ✅ | ✅ | +| 配置生态监控(EcosystemProject)| ❌ | ✅ | ✅ | +| **进入平台管理模块** | ❌ | ✅ | ✅ | +| 管理平台用户(增删停用)| ❌ | ✅ | ✅ | +| 管理社区(增删配置)| ❌ | ✅ | ✅ | +| 查看工作量统计 | ❌ | ✅ | ✅ | +| **设置/撤销管理员角色** | ❌ | ❌ | ✅ | + +### 3.3 代码层影响 + +原有 `community_users` 表中的 `community_admin` 角色值需要迁移处理: + +```python +# 迁移策略:两步执行 +# 第一步:所有 community_admin 一律降级为 user(自动运行) +# Alembic migration: 002_simplify_roles.py +# UPDATE community_users SET role = 'user' WHERE role = 'community_admin'; +# 第二步:人工确认,将应升级为 admin 的账号喵名单邀挋发至管理员手动执行 +``` + +FastAPI 依赖函数调整: +- 移除 `get_community_admin` 依赖 +- `get_current_active_superuser` → 保留(仅超级管理员) +- 新增 `get_current_admin_or_superuser` → 管理员及以上 +- `get_current_user` → 所有已登录用户 + +--- + +## 4. 模块详细设计 + +### 4.1 社区治理模块 + +#### 4.1.1 现有功能保持不变 + +社区治理模块保留所有已交付功能,无破坏性修改: + +- **社区沙盘**:选定社区后查看整体运营数据概览 +- **委员会管理**:委员会 CRUD,成员任期管理 +- **会议管理**:会议创建、纪要记录、ICS 导出 + +#### 4.1.2 新增:CommitteeMember 与 PersonProfile 关联 + +CommitteeMember 新增可选的 `person_id` 字段,管理员可将委员会成员与人脉档案关联: + +```python +# CommitteeMember 模型新增字段(不破坏现有逻辑) +person_id: Mapped[int | None] = mapped_column(ForeignKey("person_profiles.id"), nullable=True) +``` + +**关联后效果**: +- 委员会成员详情页可展示其完整人脉档案(活动参与历史、社区身份) +- 人脉档案页展示该人在本社区委员会的任职记录 +- 关联不强制,不影响未关联的委员会成员的任何现有逻辑 + +#### 4.1.3 页面结构(现有,无变化) + +``` +/communities/:id/governance 社区治理沙盘 +/committees 委员会列表 +/committees/:id 委员会详情 + 成员 +/meetings 会议列表 +/meetings/:id 会议详情 + 纪要 +``` + +--- + +### 4.2 内容管理模块 + +#### 4.2.1 核心变化:内容多社区关联 + +**当前设计**:`contents.community_id` 直接外键,内容必须归属一个社区。 + +**重构后设计**:内容本身是平台级资产,通过 `content_communities` 关联表与多个社区绑定。 + +``` +contents(平台级) + ↕ content_communities(关联表) +communities(多个) +``` + +**`content_communities` 关联表**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| content_id | int | FK → contents | +| community_id | int | FK → communities | +| is_primary | bool | 是否为主社区(内容创建时默认选择的社区)| +| linked_at | datetime | 关联时间 | +| linked_by_id | int | 操作人 | + +**迁移策略**: +- 现有 `contents.community_id` 数据迁移到 `content_communities`,`is_primary = True` +- `contents.community_id` 字段在迁移完成后移除(需两步迁移,中间版本兼容过渡) + +#### 4.2.2 影响范围 + +| 受影响模块 | 调整内容 | +|----------|---------| +| `app/models/content.py` | 移除 `community_id` 字段,新增 `communities` 关系 | +| `app/api/contents.py` | 创建/更新内容时支持关联多个 community_id | +| `app/schemas/content.py` | `community_id` → `community_ids: list[int]` | +| `app/api/dashboard.py` | 社区沙盘统计改用 `content_communities` 查询 | +| 前端内容创建表单 | 社区选择从单选改为多选 | + +#### 4.2.3 页面结构(现有结构不变,调整关联逻辑) + +``` +/contents 内容列表(可按关联社区筛选) +/contents/new 创建内容(社区关联改为多选) +/contents/:id 内容详情 + 编辑 + 关联社区管理 +/contents/calendar 内容日历 +/publish/channels 发布渠道配置 +/publish/wechat-stats 微信阅读统计 +``` + +--- + +### 4.3 活动管理模块 + +#### 4.3.1 模块定位 + +活动管理是平台级独立模块,主要社区通过 `community_id` 字段标识归属,同时支持: +- **线上活动**:直播、Webinar、线上沙龙 +- **线下活动**:技术沙龙、峰会、黑客松 +- **混合活动**:线上线下同步举办 + +> **Campaign 不在本模块**:Campaign(关怀/推广/邀约/调研运营)是以人脉库为基础的运营动作,归属「社区洞察与人脉管理模块」(§4.4.7),本模块仅管理事件性活动(Event)。 + +#### 4.3.2 SOP 模板管理 + +运营人员可以为不同类型的活动(线上直播、线下沙龙、黑客松等)创建标准化执行模板,模板包含三阶段的检查项清单。 + +**SOP 模板(EventTemplate)数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键 | +| community_id | int | 所属社区 | +| name | string | 模板名称(如“线下技术沙龙 SOP”)| +| event_type | enum | online / offline / hybrid | +| description | text | 模板适用场景说明 | +| is_public | bool | 是否对同平台所有社区可见(默认仅本社区) | + +**模板检查项(ChecklistTemplateItem)**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键 | +| template_id | int | FK → EventTemplate | +| phase | enum | pre(活动前)/ during(活动中)/ post(活动后)| +| title | string | 检查项标题 | +| description | text | 执行说明(可选)| +| order | int | 同阶段内排序 | + +**用户故事**: +``` +作为社区管理员, +我想创建一套线下活动 SOP 模板, +以便每次组织活动时不需要从零开始设计检查清单。 + +验收标准: +- 可创建多个模板,不同活动类型使用不同模板 +- 模板检查项支持三阶段划分(活动前/中/后) +- 创建活动时可选择已有模板,自动实例化检查项 +- 模板更新不影响已创建的活动(快照独立) +``` + +#### 4.3.3 活动实例管理 + +**活动(Event)数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| community_id | int | 所属社区 | +| title | string | 活动名称 | +| event_type | enum | online / offline / hybrid | +| template_id | int? | 使用的 SOP 模板(可选)| +| status | enum | draft / planning / ongoing / completed / cancelled | +| planned_at | datetime | 计划举办时间 | +| duration_minutes | int | 预计时长(分钟)| +| location | string? | 线下地点 | +| online_url | string? | 线上直播/会议链接 | +| description | text? | 活动简介 | +| cover_image_url | string? | 封面图 | +| owner_id | int | 负责人(系统用户)| +| attendee_count | int? | 实际参与总人数(活动结束后填写)| +| online_count | int? | 线上参与人数 | +| offline_count | int? | 线下参与人数 | +| registration_count | int? | 报名人数 | +| result_summary | text? | 活动总结(富文本,活动结束后填写)| +| media_urls | list[string] | 活动照片/录制视频链接(JSON 存储)| + +> **设计说明**:活动结果字段直接内嵌在 Event 表中(而非独立的 EventResult 表),因为活动与结果是严格的 1:1 关系,独立建表只会增加 JOIN 复杂度而无任何收益。 + +**活动状态流转**: + +```mermaid +stateDiagram-v2 + [*] --> draft : 创建活动 + draft --> planning : 开始策划 + planning --> ongoing : 活动开始 + ongoing --> completed : 活动结束 + ongoing --> cancelled : 取消活动 + planning --> cancelled : 取消活动 + draft --> cancelled : 取消活动 + completed --> [*] + cancelled --> [*] +``` + +#### 4.3.4 Checklist 执行追踪 + +活动创建后,从模板实例化 ChecklistItem,运营人员在活动执行过程中逐项打勾。 + +**ChecklistItem 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| event_id | int | 所属活动 | +| phase | enum | pre / during / post | +| title | string | 检查项标题(可在实例中修改)| +| status | enum | pending / done / skipped | +| assignee_id | int? | 负责人(系统用户)| +| due_date | date? | 截止日期 | +| notes | text? | 执行备注 | +| order | int | 排序 | + +**用户故事**: +``` +作为活动执行人员, +我想按阶段查看和勾选活动检查项, +以便确保每个关键步骤都被执行到位。 + +验收标准: +- 检查项按 pre / during / post 三阶段分组展示 +- 每项可单独标记完成/跳过,并填写备注 +- 活动详情页顶部显示各阶段完成进度(如"活动前 8/10") +- 支持在活动实例中临时增删检查项(不影响原模板) +``` + +#### 4.3.5 甘特图(项目时间线) + +甘特图与 Checklist 并存,用于可视化活动的项目时间规划,支持任务依赖关系。 + +**EventTask 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| event_id | int | 所属活动 | +| title | string | 任务名称 | +| task_type | enum | task / milestone | +| phase | enum | pre / during / post | +| start_date | date | 开始日期 | +| end_date | date | 结束日期 | +| progress | int | 进度 0-100% | +| status | enum | not_started / in_progress / completed / blocked | +| depends_on | list[int] | 前置任务 ID 列表(JSON 存储,无数据库级外键)| +| parent_task_id | int? | 父任务(支持子任务层级)| +| order | int | 同级排序 | + +**EventTaskAssignee 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| task_id | int | 所属任务 | +| assignee_type | enum | internal / external | +| user_id | int? | 系统内部用户 | +| person_id | int? | 人脉中台的外部人员(如嘉宾)| + +**用户故事**: +``` +作为活动负责人, +我想通过甘特图规划活动的时间线和任务依赖, +以便团队成员清楚各自的工作节点。 + +验收标准: +- 甘特图展示活动所有任务的时间条 +- 支持拖拽调整任务开始/结束日期 +- 任务间可设置前置依赖,以箭头连线展示 +- 里程碑以菱形标记区分 +- 支持任务折叠/展开(父子任务) +- 任务可分配给系统用户或外部人脉档案 +``` + +**前端技术选型**:引入 `frappe-gantt`(MIT 协议,轻量)或 `vue-ganttastic`,避免使用 ECharts 自绘(交互体验差)。 + +#### 4.3.6 人员安排 + +活动中每个角色的分工表,区别于甘特图任务的执行负责人,专注于活动当天的角色分配。 + +**EventPersonnel 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| event_id | int | 所属活动 | +| role | enum | host / speaker / moderator / coordinator / photographer / volunteer / sponsor_rep / other | +| role_label | string? | 自定义角色名(role=other 时必填)| +| assignee_type | enum | internal / external | +| user_id | int? | 系统内部用户 | +| person_id | int? | 人脉中台外部人员 | +| confirmed | enum | pending(待确认)/ confirmed(已确认)/ declined(已拒绝)| +| time_slot | string? | 负责时段(如 "14:00-15:00")| +| notes | text? | 备注(上台顺序、特殊要求等)| +| order | int | 排序 | + +**用户故事**: +``` +作为活动负责人, +我想明确列出活动中每个人员的角色和时段安排, +以便活动当天各司其职、执行顺畅。 + +验收标准: +- 支持添加系统内用户或外部人脉档案为活动人员 +- 每个人员可指定角色、时段和确认状态 +- 人员安排列表可导出为 PDF 或分享给相关人员 +- 人员确认状态可批量发送邮件提醒(依赖 SMTP 配置) +``` + +#### 4.3.7 活动结果记录 + +活动结束后录入执行结果,支持导入签到名单并自动与人脉中台关联。 + +> **数据模型说明**:活动结果字段已直接内嵌于 Event 表(`attendee_count`、`online_count`、`offline_count`、`registration_count`、`result_summary`、`media_urls`),无需独立的 EventResult 表,详见 §4.3.3 Event 数据结构。 + +**签到名单导入**: +- 支持 Excel/CSV 上传 +- 系统提供标准导入模板(必填:姓名、邮箱;推荐:GitHub账号、公司、职位) +- 导入后与 PersonProfile 进行智能匹配(github_handle 优先,其次邮箱,最后姓名+公司) +- 未匹配到的自动创建新 PersonProfile +- 存疑的匹配项(相似度 > 70% 但非完全匹配)展示给运营人员人工确认 + +#### 4.3.8 反馈问题与 Issue 关联 + +活动中收集的问题/反馈可录入系统并与 GitHub/GitCode Issue 关联,跟踪闭环进度。 + +**FeedbackItem 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| event_id | int | 所属活动 | +| content | text | 反馈/问题内容 | +| category | enum | question / suggestion / bug_report / appreciation / other | +| raised_by | string? | 提问人姓名(不强制关联 PersonProfile)| +| raised_by_person_id | int? | 可选关联 PersonProfile(签到导入后可自动匹配)| +| status | enum | open / in_progress / closed | +| assignee_id | int? | 负责跟进的系统用户 | + +**IssueLink 数据结构(预留深度集成能力)**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| feedback_id | int | 所属反馈项 | +| platform | enum | github / gitcode / gitee | +| repo | string | 仓库路径(如 "org/repo")| +| issue_number | int | Issue 编号 | +| issue_url | string | Issue 完整 URL | +| issue_type | enum | issue / pr | +| issue_status | enum | open / closed(定时同步)| +| linked_at | datetime | 关联时间 | +| linked_by_id | int | 操作用户 | + +> **深度集成预留**:IssueLink 表结构已预留 `platform`、`repo`、`issue_number` 字段,未来可接入 GitHub OAuth App 实现在 openGecko 内直接创建/评论/关闭 Issue,无需修改现有数据结构。 + +**用户故事**: +``` +作为社区运营人员, +我想将活动中收集的问题关联到对应的 GitHub Issue, +以便追踪问题是否得到技术团队的处理和回复。 + +验收标准: +- 可手动录入 Issue URL 完成关联 +- 系统每日自动同步 Issue 的 open/closed 状态 +- 反馈列表展示关联 Issue 数量和已关闭比例 +- 支持按状态(open/closed)筛选反馈问题 +``` + +#### 4.3.9 活动模块页面结构 + +``` +/events 活动列表(支持按类型/状态/时间筛选) +/events/new 创建活动(选择模板) +/events/:id 活动详情(Tab 切换) + ├── Tab: 基本信息 + ├── Tab: 甘特图 + ├── Tab: 人员安排 + ├── Tab: Checklist + └── Tab: 活动结果(含签到导入、反馈问题) +/event-templates SOP 模板管理 +``` + +> Campaign 相关路由归属「社区洞察与人脉管理模块」(§4.4.6)。 + +--- + +### 4.4 社区洞察与人脉管理模块 + +#### 4.4.1 模块定位 + +本模块以**人**为核心,覆盖两个方向: +- **人脉管理**:维护和管理值得关注的人员档案,统一沉淀来自活动签到、手动录入、生态洞察各渠道的人员数据 +- **Campaign**:以人脉库为基础,对关键人物开展系统化的推广/关怀/邀约/调研运营 + +生态洞察作为本模块的数据来源之一,提供对外部主流开源社区的人员和技术方向感知能力。 + +#### 4.4.2 PersonProfile 人脉档案 + +**与 CommitteeMember 的关系**: + +PersonProfile 不替换委员会成员(CommitteeMember),而是与之互补: + +| 维度 | CommitteeMember | PersonProfile | +|------|----------------|---------------| +| **定位** | 本社区委员会的治理角色 | 所有值得关注的人(内外部)| +| **关联** | 与委员会绑定,有任期概念 | 独立存在,跨社区、跨场景 | +| **数据来源** | 运营人员手动录入 | 活动导入 / 生态洞察同步 / 手动添加 | +| **关联方式** | CommitteeMember 可选关联一个 PersonProfile | PersonProfile 上聚合参与度数据 | + +**关联策略**:CommitteeMember 新增可选的 `person_id` 字段,管理员可手动将委员会成员与人脉档案关联,但不强制。 + +**PersonProfile 数据结构**: + +| 字段 | 类型 | 约束 | 说明 | +|------|------|------|------| +| id | int | PK, 自增 | **唯一主键**,始终存在 | +| display_name | string | NOT NULL | 显示名(人工可读,允许重名)| +| avatar_url | string? | — | 头像 URL | +| github_handle | string? | 唯一索引(允许 NULL)| GitHub 账号,存在时全局唯一 | +| gitcode_handle | string? | 唯一索引(允许 NULL)| GitCode 账号,存在时全局唯一 | +| email | string? | 唯一索引(允许 NULL)| 邮箱,存在时全局唯一 | +| phone | string? | — | 手机号(仅内部可见)| +| company | string? | — | 所在公司/组织 | +| location | string? | — | 地区 | +| bio | text? | — | 简介 | +| tags | list[string] | — | 自定义标签(如"KOL"、"布道师")| +| notes | text? | — | 运营备注(富文本,仅内部可见)| +| source | enum | NOT NULL | manual / event_import / ecosystem_import | +| created_by_id | int? | FK → User | 创建人(系统导入时为 NULL)| +| created_at | datetime | NOT NULL | 创建时间 | +| updated_at | datetime | NOT NULL | 最后更新时间 | + +> **主键设计说明**:`id`(自增整数)是唯一的物理主键。`github_handle`、`gitcode_handle`、`email` 三个字段各自建立**稀疏唯一索引**(Partial Unique Index,仅在非 NULL 时生效),用于去重匹配,但均非必填。一个没有任何平台账号的人也可以作为有效的 PersonProfile 存在(以 `display_name` 区分)。 + +**计算字段(非存储,实时聚合)**: +- `events_attended`:参与活动次数(来自 EventAttendee 表) +- `last_event_at`:最近一次活动时间 +- `active_community_roles`:当前在任的社区身份数量 +- `campaign_count`:被纳入的 Campaign 数量 + +> **性能说明**:上述计算字段在 MVP 阶段采用实时 COUNT 聚合。当 PersonProfile 记录超过万级时,应改为差异更新缓存(在 Event/Campaign 内容发生变化时更新对应的 PersonProfile 计数器)。 + +#### 4.4.3 身份去重匹配策略 + +导入签到名单或从生态洞察同步时,按以下优先级判断是否为同一人: + +```mermaid +flowchart TD + A[导入一条人员记录] --> B{有 github_handle?} + B -- 是 --> C{库中已存在\n该 github_handle?} + C -- 是 --> MATCH[✅ 匹配成功\n直接关联] + C -- 否 --> D{有 email?} + B -- 否 --> D + D -- 是 --> E{库中已存在\n该 email?} + E -- 是 --> CONFIRM[⚠️ 疑似匹配\n展示候选项,人工确认] + E -- 否 --> F{姓名+公司\n相似度 > 70%?} + D -- 否 --> F + F -- 是 --> CONFIRM + F -- 否 --> CREATE[🆕 创建新 PersonProfile] + CONFIRM -- 确认合并 --> MATCH + CONFIRM -- 拒绝 --> CREATE +``` + +**匹配规则说明**: +- `github_handle` 精确匹配 → 自动关联,无需人工确认(开源社区账号全球唯一) +- `email` 精确匹配 → 标记为疑似,人工确认(同一邮箱可能被多人使用企业邮箱) +- 姓名+公司模糊匹配 → 标记为疑似,人工确认 +- 无任何匹配 → 自动创建新档案 + +**SQLite/PostgreSQL 稀疏唯一索引实现**: +```sql +-- PostgreSQL: 原生支持 Partial Index +CREATE UNIQUE INDEX ix_person_github ON person_profiles(github_handle) + WHERE github_handle IS NOT NULL; + +-- SQLite: 通过 CHECK 约束 + 应用层逻辑实现等效效果 +--(Alembic 迁移中使用 UniqueConstraint + 应用层 None 检查) +``` + +#### 4.4.4 CommunityRole 社区身份历史 + +记录人物在各开源社区中担任的角色,支持历史追踪。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| person_id | int | 所属档案 | +| community_name | string | 社区名称(如 "OpenHarmony")| +| project_url | string? | 项目链接(GitHub/Gitee 主页)| +| role | string | 角色(contributor / committer / maintainer / tsc_member / founder / board_member / other)| +| role_label | string? | 角色自定义名称 | +| is_current | bool | 是否当前在任 | +| started_at | date? | 开始时间 | +| ended_at | date? | 结束时间(NULL 表示至今)| +| source_url | string? | 佐证链接(如贡献者名单页)| +| updated_by_id | int? | 最后更新人 | + +**用户故事**: +``` +作为社区运营人员, +我想查看某位关键人物在各开源社区的历史角色变迁, +以便判断其影响力范围和合作价值。 + +验收标准: +- 人脉档案页展示该人物的社区身份时间线 +- 时间线按时间倒序排列,当前在任角色置顶 +- 支持手动添加/编辑角色记录 +- 当生态洞察模块检测到角色变化时,以提醒形式通知运营人员确认 +``` + +#### 4.4.5 EventAttendee 签到关联表 + +| 字段 | 类型 | 说明 | +|------|------|------| +| event_id | int | 活动 ID | +| person_id | int | 人脉档案 ID | +| checked_in | bool | 是否实际签到(区别于仅报名)| +| role_at_event | string? | 本次活动中的角色("嘉宾"/"观众"/"志愿者")| +| source | enum | manual(手动录入)/ excel_import(Excel 导入)| + +#### 4.4.6 人脉模块页面结构 + +``` +/people 人脉列表(支持按标签/社区角色/活动参与度筛选) +/people/import 批量导入(Excel/CSV) +/people/:id 人脉详情 + ├── 基本信息 + 联系方式 + ├── 社区身份时间线 + ├── 参与活动记录 + └── Campaign 参与记录 +/campaigns Campaign 列表 +/campaigns/new 创建 Campaign +/campaigns/:id Campaign 详情 +``` + +#### 4.4.7 Campaign(人脉运营) + +Campaign 是以人脉库为基础的运营动作管理工具,支持推广/关怀/邀约/调研四类。 + +**Campaign 数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键 | +| community_id | int | 所属社区 | +| name | string | Campaign 名称(如"2026 开发者峰会嘉宾邀约")| +| description | text? | Campaign 背景和目标 | +| type | enum | promotion(推广)/ care(关怀)/ invitation(邀约)/ survey(调研)| +| status | enum | draft / active / completed / archived | +| target_count | int? | 目标触达人数 | +| owner_id | int | Campaign 负责人 | +| start_date | date? | 开始日期 | +| end_date | date? | 目标完成日期 | +| created_at | datetime | 创建时间 | + +**联系人导入(多维度)**: + +Campaign 创建后,从以下来源导入联系人: + +| 来源 | 导入操作 | 说明 | +|------|---------|------| +| **活动参与者** | 选择一场或多场活动 | 导入其签到人员 | +| **生态关键角色** | 按社区/角色类型筛选 | 来自生态洞察模块 | +| **人脉库搜索** | 按标签/公司/角色筛选 | 来自 PersonProfile | +| **手动添加** | 搜索或新建 PersonProfile | 直接操作 | +| **Excel/CSV 导入** | 字段映射后批量导入 | 匹配或创建 PersonProfile | + +**CampaignContact 联系人状态**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| campaign_id | int | 所属 Campaign | +| person_id | int | 联系人 | +| status | enum | pending / contacted / responded / converted / declined | +| channel | enum | email / wechat / phone / in_person / other | +| added_by | enum | manual / event_import / ecosystem_import / csv_import | +| last_contacted_at | datetime? | 最后联系时间 | +| notes | text? | 当前状态备注 | +| assigned_to_id | int? | 本联系人的跟进负责人(可不同于 Campaign 负责人)| + +**CampaignActivity 跟进记录**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| campaign_id | int | 所属 Campaign | +| person_id | int | 联系对象 | +| action | enum | sent_email / made_call / sent_wechat / in_person_meeting / got_reply / note | +| content | text? | 跟进内容摘要 | +| outcome | string? | 跟进结果(如"已接受邀请")| +| operator_id | int | 操作人 | +| created_at | datetime | 记录时间 | + +**用户故事**: +``` +作为社区运营人员, +我想在 Campaign 中查看每位联系人的完整跟进历史, +以便避免重复联系并了解当前进展。 + +验收标准: +- 每位联系人的跟进记录按时间倒序展示 +- 添加跟进记录后联系人状态自动可更新 +- Campaign 概览页展示各状态联系人数量(漏斗图) +- 多人协作同一 Campaign 时,操作记录显示操作人姓名 +``` + +#### 4.4.8 生态洞察(管理员及以上可见) + +##### 4.4.8.1 监控社区管理 + +MVP 阶段聚焦于数据采集和展示,不做 AI 分析,保持架构预留能力。 + +**EcosystemProject(监控社区)数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | int | 主键 | +| name | string | 社区名称 | +| platform | enum | github / gitee / gitcode | +| org_name | string | 组织名(如 "kubernetes")| +| repo_name | string? | 主仓库名(可选,填写后监控单仓库)| +| description | text? | 社区简介 | +| tags | list[string] | 技术标签(如 "云原生"、"AI")| +| is_active | bool | 是否启用监控 | +| last_synced_at | datetime? | 最近一次数据同步时间 | +| added_by_id | int | 添加人(管理员及以上)| + +##### 4.4.8.2 关键角色追踪 + +**EcosystemContributor(关键贡献者)数据结构**: + +| 字段 | 类型 | 说明 | +|------|------|------| +| project_id | int | 所属监控社区 | +| github_handle | string | GitHub 账号 | +| display_name | string | 显示名 | +| avatar_url | string? | 头像 | +| role | string | 从 GitHub 推断:owner / member / contributor | +| commit_count_90d | int? | 近 90 天提交数 | +| pr_count_90d | int? | 近 90 天 PR 数 | +| star_count | int? | 个人 star 数 | +| followers | int? | 粉丝数 | +| person_id | int? | 关联到人脉中台的 PersonProfile(可选,手动确认)| +| last_synced_at | datetime | 数据同步时间 | + +##### 4.4.8.3 数据同步机制 + +- **触发方式**:管理员及以上手动触发 或 后台定时任务(每日凌晨) +- **数据来源**:GitHub REST API / Gitee API(使用 Personal Access Token) +- **采集内容**:仓库基本信息(star/fork/watch 趋势)、贡献者列表(近 90 天 commits)、最近发布的 Release +- **API 限流处理**:GitHub API 未认证限制 60 次/小时,PAT 可达 5000 次/小时,配置 Token 后才能启用自动同步 + +##### 4.4.8.4 关键角色导入人脉库 + +当生态洞察发现某位贡献者值得关注时,运营人员可一键将其导入人脉中台: +- 检查 `github_handle` 是否已存在于 PersonProfile +- 存在则展示现有档案并提示是否更新数据 +- 不存在则创建新 PersonProfile,`source = ecosystem_import` + +##### 4.4.8.5 页面结构 + +``` +/ecosystem 监控社区列表 + 数据概览 +/ecosystem/:id 社区详情 + ├── 基本指标(Star/Fork/Contributor 趋势图) + ├── 关键贡献者排行(按近 90 天活跃度) + └── 最近 Release 记录 +``` + +--- + +### 4.5 平台管理模块 + +平台管理模块是管理员(admin)及超级管理员(superuser)专属区域,普通用户不可见。 + +| 功能项 | 说明 | +|-------|------| +| **用户管理** | 创建/停用平台账号,查看所有用户列表 | +| **角色管理** | 设置/撤销管理员角色(仅超级管理员可操作)| +| **社区管理** | 创建/编辑/停用社区,查看社区整体数据 | +| **工作量统计** | 按用户/社区/时间维度查看内容、活动、Campaign 的工作量 | + +> 本模块功能已在现有超管区域实现,重构后仅调整导航归属,业务逻辑无变化。 + +--- + +### 4.6 个人工作看板 + +个人工作看板(`/my-work`)汇聚当前登录用户所有待完成的任务,无需跨多个模块查找。 + +**现有汇聚内容**: +- 分配给我的内容(草稿/审核中) +- 我参与的会议(即将召开) + +**新增汇聚内容**: +- **活动检查项**:分配给我的 pending ChecklistItem(按活动分组展示) +- **Campaign 跟进**:分配给我的 pending CampaignContact(按 Campaign 分组展示) + +**统计卡片新增**: +- 本月我负责的活动数量 +- 我跟进中的 Campaign 联系人数 + +--- + +## 5. 导航结构 + +### 5.1 新导航结构 + +以下为六大模块对应的导航菜单设计(括号内为可见角色): + +``` +┌─ 个人工作看板(全部用户) +│ └── /my-work — 我的待办 / 统计概览 +│ +├─ 社区治理(全部用户 · 需先选择社区) +│ └── /communities/:id/governance — 治理沙盘 +│ └── /committees — 委员会 +│ └── /meetings — 会议 +│ +├─ 内容管理(全部用户) +│ ├── /contents — 内容列表 +│ ├── /contents/calendar — 内容日历 +│ └── /publish/channels — 发布渠道 +│ +├─ 活动管理(全部用户) +│ ├── /events — 活动列表 +│ └── /event-templates — SOP 模板 +│ +├─ 洞察与人脉(全部用户) +│ ├── /people — 人脉库 +│ ├── /campaigns — Campaign +│ └── /ecosystem — 生态洞察(仅管理员+) +│ +└─ 平台管理(仅管理员+) + ├── 用户管理 + ├── 社区管理 + └── 工作量统计 +``` + +### 5.2 权限与可见性规则 + +| 菜单项 | 普通用户 | 管理员 | 超级管理员 | +|-------|---------|--------|-----------| +| 个人工作看板 | ✅ | ✅ | ✅ | +| 社区治理 | ✅ | ✅ | ✅ | +| 内容管理 | ✅ | ✅ | ✅ | +| 活动管理 | ✅ | ✅ | ✅ | +| 洞察与人脉(人脉库 + Campaign)| ✅ | ✅ | ✅ | +| 洞察与人脉(生态洞察)| ❌ | ✅ | ✅ | +| 平台管理 | ❌ | ✅ | ✅ | + +--- + +## 6. 数据模型总览 + +```mermaid +erDiagram + Community ||--o{ content_communities : "关联内容" + Content ||--o{ content_communities : "关联社区" + content_communities { + int content_id + int community_id + bool is_primary + } + + PersonProfile ||--o{ CommunityRole : "社区身份历史" + PersonProfile ||--o{ EventAttendee : "活动签到" + PersonProfile ||--o{ CampaignContact : "被纳入Campaign" + PersonProfile ||--o{ EcosystemContributor : "来自生态洞察(可选)" + PersonProfile ||--o{ EventPersonnel : "担任活动角色" + + Event ||--o{ ChecklistItem : "执行检查项" + Event ||--o{ EventTask : "甘特图任务" + Event ||--o{ EventPersonnel : "人员安排" + Event ||--o{ EventAttendee : "签到参与者" + Event ||--o{ FeedbackItem : "反馈问题" + Event }o--o| EventTemplate : "来自模板(可选)" + Event }o--o| Content : "活动总结(可选)" + + EventTask ||--o{ EventTask : "子任务(自引用)" + FeedbackItem ||--o{ IssueLink : "关联Issue/PR" + + Campaign ||--o{ CampaignContact : "联系人" + CampaignContact ||--o{ CampaignActivity : "跟进记录" + + EcosystemProject ||--o{ EcosystemContributor : "关键贡献者" + CommitteeMember }o--o| PersonProfile : "可选关联" +``` + +--- + +## 7. 技术架构设计 + +### 7.1 后端新增模块目录 + +``` +backend/app/ +├── api/ +│ ├── events.py # 活动管理 API +│ ├── event_templates.py # SOP 模板 API +│ ├── people.py # 人脉档案 API +│ ├── campaigns.py # Campaign API +│ └── ecosystem.py # 生态洞察 API +├── models/ +│ ├── event.py # Event, EventTemplate, EventTask, +│ │ # EventPersonnel, EventAttendee, +│ │ # ChecklistItem, FeedbackItem, IssueLink +│ ├── people.py # PersonProfile, CommunityRole +│ ├── campaign.py # Campaign, CampaignContact, CampaignActivity +│ └── ecosystem.py # EcosystemProject, EcosystemContributor +├── schemas/ +│ ├── event.py +│ ├── people.py +│ ├── campaign.py +│ └── ecosystem.py +└── services/ + ├── event_service.py # 签到导入 + 人员匹配逻辑 + ├── people_service.py # PersonProfile 去重合并 + ├── campaign_service.py + └── ecosystem/ + ├── github_crawler.py # GitHub REST API 采集 + └── scheduler.py # APScheduler 定时任务 +``` + +### 7.2 关键技术决策 + +| 决策点 | 选型 | 理由 | +|--------|------|------| +| 甘特图前端库 | `frappe-gantt` | MIT 协议,轻量(< 50KB),支持拖拽和依赖箭头 | +| 定时任务 | APScheduler(内置 FastAPI 进程)| 无需 Celery,适合当前体量 | +| Excel 导入 | `openpyxl` | 已有依赖,无需新增 | +| 生态数据采集 | `httpx`(异步)| 项目已有,直接调用 GitHub REST API | +| 人员去重 | Levenshtein 距离(应用层)| 不引入 ES 等重量级工具 | +| 内容多社区关联 | `content_communities` 关联表 | 原 community_id 外键废弃,数据迁移两步进行 | + +### 7.3 数据库迁移规划 + +| 迁移文件 | 内容 | +|---------|------| +| `002_simplify_roles.py` | 移除 `community_users.role = community_admin`,统一为 user | +| `003_add_content_communities.py` | 新增关联表,迁移现有 `contents.community_id` 数据 | +| `004_add_people_module.py` | PersonProfile, CommunityRole, EventAttendee | +| `005_add_event_module.py` | Event, EventTemplate, EventTask, EventPersonnel, ChecklistItem, FeedbackItem, IssueLink | +| `006_add_campaign_module.py` | Campaign, CampaignContact, CampaignActivity | +| `007_add_ecosystem_module.py` | EcosystemProject, EcosystemContributor | +| `008_link_committee_member_to_person.py` | CommitteeMember 新增可选 person_id 外键 | +| `009_remove_content_community_id.py` | 迁移完成后移除 contents.community_id 列 | + +--- + +## 8. 分阶段交付计划 + +```mermaid +gantt + title openGecko 重构 + Phase 4 交付计划 + dateFormat YYYY-MM-DD + axisFormat %m/%d + + section 前置重构 + 权限模型简化(移除 community_admin) :r1, 2026-03-01, 3d + 内容多社区关联(content_communities) :r2, after r1, 5d + 导航结构调整(前端) :r3, after r1, 4d + + section Phase 4a 人脉+活动基础 + PersonProfile + CommunityRole :4a1, after r2, 7d + CommitteeMember 关联可选字段 :4a2, after 4a1, 2d + Event CRUD + 状态流转 :4a3, after 4a1, 5d + SOP 模板 + Checklist :4a4, after 4a3, 5d + 人员安排表 EventPersonnel :4a5, after 4a3, 3d + 签到 Excel 导入 + 智能匹配 :4a6, after 4a4, 7d + 前端:活动/人脉页面 :4a7, after 4a4, 10d + + section Phase 4b 甘特图+Issue关联 + EventTask 甘特图后端 :4b1, after 4a7, 5d + frappe-gantt 前端集成 :4b2, after 4b1, 7d + FeedbackItem + IssueLink :4b3, after 4b1, 5d + 活动结果录入 + /my-work 扩展 :4b4, after 4b3, 4d + + section Phase 4c Campaign + Campaign CRUD + 多维度导入 :4c1, after 4b4, 7d + CampaignActivity 跟进记录 :4c2, after 4c1, 4d + 前端:Campaign 页面 :4c3, after 4c1, 7d + + section Phase 4d 生态洞察 + EcosystemProject + GitHub 采集 :4d1, after 4c3, 7d + EcosystemContributor 展示 :4d2, after 4d1, 5d + 一键导入人脉库 :4d3, after 4d2, 3d +``` + +### 前置重构阶段 + +**目标**:完成权限模型简化和内容多社区关联的数据层重构,为后续新功能奠定基础。 + +**交付内容**: +- [ ] Alembic 迁移:移除 `community_admin` 角色,更新 FastAPI 依赖函数 +- [ ] `content_communities` 关联表 + 数据迁移脚本 +- [ ] 后端 API 调整:内容创建/编辑支持 `community_ids: list[int]` +- [ ] 前端:内容社区选择从单选改为多选,导航菜单重组 + +### Phase 4a:人脉中台 + 活动管理基础 + +**交付内容**: +- [ ] PersonProfile CRUD + CommunityRole 管理 +- [ ] CommitteeMember 可选关联 PersonProfile +- [ ] Event CRUD + 状态流转 + SOP 模板 + Checklist +- [ ] 人员安排表(EventPersonnel) +- [ ] 签到名单 Excel 导入 + 智能匹配 +- [ ] 前端:活动列表/详情、人脉列表/详情 + +### Phase 4b:甘特图 + 反馈 Issue 关联 + +**交付内容**: +- [ ] EventTask 甘特图 CRUD + frappe-gantt 前端集成 +- [ ] FeedbackItem 录入 + IssueLink 关联 + 状态定时同步 +- [ ] 活动结果字段录入(attendee_count / summary / media_urls 等,内嵌于 Event 表) +- [ ] 个人工作看板扩展(活动检查项 + Campaign 跟进) + +### Phase 4c:Campaign + +**交付内容**: +- [ ] Campaign CRUD + CampaignContact 多维度导入 +- [ ] CampaignActivity 跟进记录 + 漏斗图概览 +- [ ] 前端 Campaign 列表/详情页 + +### Phase 4d:生态洞察 + +**交付内容**: +- [ ] EcosystemProject 监控配置 + GitHub API 采集 +- [ ] EcosystemContributor 展示 + 一键导入人脉库 +- [ ] 前端生态洞察页面 + +--- + +## 9. 风险与约束 + +| 风险 | 影响 | 缓解措施 | +|------|------|---------| +| `community_admin` 迁移影响现有数据 | 部分用户权限变化 | 上线前人工确认受影响账号,发送通知 | +| 内容多社区迁移脚本出错 | 内容与社区关联丢失 | 分两步迁移(先加新表/数据,验证后再删旧字段),保留回滚能力 | +| GitHub API 限流 | 生态洞察数据采集失败 | 强制配置 PAT Token 才启用自动同步 | +| PersonProfile 去重误合并 | 人脉数据污染 | 高相似度匹配不自动合并,展示候选项人工确认 | +| frappe-gantt 的 Vue 3 兼容性 | 甘特图无法集成 | Phase 4b 启动前完成技术验证 POC | +| Campaign + 活动数据增长 | 列表页性能下降 | 提前建立 community_id、status、owner_id 复合索引 | + +--- + +**文档版本**: v2.0 +**下一步**: 团队评审 → 确认前置重构范围 → 开始数据库模型设计与 API 接口定义 diff --git a/frontend/src/api/campaign.ts b/frontend/src/api/campaign.ts index c7136e7..7d3d739 100644 --- a/frontend/src/api/campaign.ts +++ b/frontend/src/api/campaign.ts @@ -85,65 +85,65 @@ export interface CampaignFunnel { // ─── Campaign CRUD ──────────────────────────────────────────────────────────── export async function listCampaigns(params?: { type?: string; status?: string }) { - const res = await apiClient.get('/api/campaigns', { params }) + const res = await apiClient.get('/campaigns', { params }) return res.data } export async function createCampaign(data: CampaignCreate) { - const res = await apiClient.post('/api/campaigns', data) + const res = await apiClient.post('/campaigns', data) return res.data } export async function getCampaign(id: number) { - const res = await apiClient.get(`/api/campaigns/${id}`) + const res = await apiClient.get(`/campaigns/${id}`) return res.data } export async function updateCampaign(id: number, data: CampaignUpdate) { - const res = await apiClient.patch(`/api/campaigns/${id}`, data) + const res = await apiClient.patch(`/campaigns/${id}`, data) return res.data } export async function getCampaignFunnel(id: number) { - const res = await apiClient.get(`/api/campaigns/${id}/funnel`) + const res = await apiClient.get(`/campaigns/${id}/funnel`) return res.data } // ─── Contacts ───────────────────────────────────────────────────────────────── export async function listContacts(campaignId: number, params?: { status?: string; page?: number; page_size?: number }) { - const res = await apiClient.get(`/api/campaigns/${campaignId}/contacts`, { params }) + const res = await apiClient.get(`/campaigns/${campaignId}/contacts`, { params }) return res.data } export async function addContact(campaignId: number, data: { person_id: number; channel?: string; notes?: string }) { - const res = await apiClient.post(`/api/campaigns/${campaignId}/contacts`, data) + const res = await apiClient.post(`/campaigns/${campaignId}/contacts`, data) return res.data } export async function updateContactStatus(campaignId: number, contactId: number, data: { status: string; channel?: string; notes?: string }) { - const res = await apiClient.patch(`/api/campaigns/${campaignId}/contacts/${contactId}/status`, data) + const res = await apiClient.patch(`/campaigns/${campaignId}/contacts/${contactId}/status`, data) return res.data } export async function importFromEvent(campaignId: number, data: { event_id: number; channel?: string }) { - const res = await apiClient.post<{ created: number; skipped: number }>(`/api/campaigns/${campaignId}/contacts/import-event`, data) + const res = await apiClient.post<{ created: number; skipped: number }>(`/campaigns/${campaignId}/contacts/import-event`, data) return res.data } export async function importFromPeople(campaignId: number, data: { person_ids: number[]; channel?: string }) { - const res = await apiClient.post<{ created: number; skipped: number }>(`/api/campaigns/${campaignId}/contacts/import-people`, data) + const res = await apiClient.post<{ created: number; skipped: number }>(`/campaigns/${campaignId}/contacts/import-people`, data) return res.data } // ─── Activities ─────────────────────────────────────────────────────────────── export async function listActivities(campaignId: number, contactId: number) { - const res = await apiClient.get(`/api/campaigns/${campaignId}/contacts/${contactId}/activities`) + const res = await apiClient.get(`/campaigns/${campaignId}/contacts/${contactId}/activities`) return res.data } export async function addActivity(campaignId: number, contactId: number, data: { action: string; content?: string; outcome?: string }) { - const res = await apiClient.post(`/api/campaigns/${campaignId}/contacts/${contactId}/activities`, data) + const res = await apiClient.post(`/campaigns/${campaignId}/contacts/${contactId}/activities`, data) return res.data } diff --git a/frontend/src/api/content.ts b/frontend/src/api/content.ts index 0c6a897..2ae4ab6 100644 --- a/frontend/src/api/content.ts +++ b/frontend/src/api/content.ts @@ -19,6 +19,7 @@ export interface Content { community_id: number created_by_user_id: number | null assignee_ids: number[] + community_ids: number[] scheduled_publish_at: string | null created_at: string updated_at: string @@ -32,6 +33,8 @@ export interface ContentListItem { tags: string[] category: string status: string + work_status: string + community_id: number | null owner_id: number | null scheduled_publish_at: string | null created_at: string @@ -62,6 +65,7 @@ export async function fetchContents(params: { status?: string source_type?: string keyword?: string + community_id?: number }): Promise { const { data } = await api.get('/contents', { params }) return data diff --git a/frontend/src/api/event.ts b/frontend/src/api/event.ts index 2e22169..f60abfe 100644 --- a/frontend/src/api/event.ts +++ b/frontend/src/api/event.ts @@ -155,93 +155,93 @@ export interface EventTaskUpdate extends Partial { // ─── Event CRUD ─────────────────────────────────────────────────────────────── export async function listEvents(params?: { status?: string; event_type?: string; page?: number; page_size?: number }) { - const res = await apiClient.get('/api/events', { params }) + const res = await apiClient.get('/events', { params }) return res.data } export async function createEvent(data: EventCreate) { - const res = await apiClient.post('/api/events', data) + const res = await apiClient.post('/events', data) return res.data } export async function getEvent(id: number) { - const res = await apiClient.get(`/api/events/${id}`) + const res = await apiClient.get(`/events/${id}`) return res.data } export async function updateEvent(id: number, data: EventUpdate) { - const res = await apiClient.patch(`/api/events/${id}`, data) + const res = await apiClient.patch(`/events/${id}`, data) return res.data } export async function updateEventStatus(id: number, status: string) { - const res = await apiClient.patch(`/api/events/${id}/status`, { status }) + const res = await apiClient.patch(`/events/${id}/status`, { status }) return res.data } // ─── Checklist ──────────────────────────────────────────────────────────────── export async function getChecklist(eventId: number) { - const res = await apiClient.get(`/api/events/${eventId}/checklist`) + const res = await apiClient.get(`/events/${eventId}/checklist`) return res.data } export async function updateChecklistItem(eventId: number, itemId: number, data: { status?: string; notes?: string; due_date?: string | null }) { - const res = await apiClient.patch(`/api/events/${eventId}/checklist/${itemId}`, data) + const res = await apiClient.patch(`/events/${eventId}/checklist/${itemId}`, data) return res.data } // ─── Personnel ──────────────────────────────────────────────────────────────── export async function listPersonnel(eventId: number) { - const res = await apiClient.get(`/api/events/${eventId}/personnel`) + const res = await apiClient.get(`/events/${eventId}/personnel`) return res.data } export async function addPersonnel(eventId: number, data: PersonnelCreate) { - const res = await apiClient.post(`/api/events/${eventId}/personnel`, data) + const res = await apiClient.post(`/events/${eventId}/personnel`, data) return res.data } export async function confirmPersonnel(eventId: number, pid: number, confirmed: string) { - const res = await apiClient.patch(`/api/events/${eventId}/personnel/${pid}/confirm`, { confirmed }) + const res = await apiClient.patch(`/events/${eventId}/personnel/${pid}/confirm`, { confirmed }) return res.data } // ─── Feedback ───────────────────────────────────────────────────────────────── export async function listFeedback(eventId: number) { - const res = await apiClient.get(`/api/events/${eventId}/feedback`) + const res = await apiClient.get(`/events/${eventId}/feedback`) return res.data } export async function createFeedback(eventId: number, data: { content: string; category?: string; raised_by?: string }) { - const res = await apiClient.post(`/api/events/${eventId}/feedback`, data) + const res = await apiClient.post(`/events/${eventId}/feedback`, data) return res.data } export async function updateFeedback(eventId: number, fid: number, data: { status?: string; assignee_id?: number | null }) { - const res = await apiClient.patch(`/api/events/${eventId}/feedback/${fid}`, data) + const res = await apiClient.patch(`/events/${eventId}/feedback/${fid}`, data) return res.data } // ─── Tasks ──────────────────────────────────────────────────────────────────── export async function listTasks(eventId: number) { - const res = await apiClient.get(`/api/events/${eventId}/tasks`) + const res = await apiClient.get(`/events/${eventId}/tasks`) return res.data } export async function createTask(eventId: number, data: EventTaskCreate) { - const res = await apiClient.post(`/api/events/${eventId}/tasks`, data) + const res = await apiClient.post(`/events/${eventId}/tasks`, data) return res.data } export async function updateTask(eventId: number, tid: number, data: EventTaskUpdate) { - const res = await apiClient.patch(`/api/events/${eventId}/tasks/${tid}`, data) + const res = await apiClient.patch(`/events/${eventId}/tasks/${tid}`, data) return res.data } export async function deleteTask(eventId: number, tid: number) { - await apiClient.delete(`/api/events/${eventId}/tasks/${tid}`) + await apiClient.delete(`/events/${eventId}/tasks/${tid}`) } diff --git a/frontend/src/api/governance.ts b/frontend/src/api/governance.ts index a6da0b9..5828e14 100644 --- a/frontend/src/api/governance.ts +++ b/frontend/src/api/governance.ts @@ -97,7 +97,8 @@ export interface Meeting { scheduled_at: string duration: number location_type?: string - location?: string + location?: string // 线下会议地址(offline / hybrid) + online_url?: string // 线上会议链接(online / hybrid) status: string reminder_sent: boolean created_by_user_id?: number @@ -119,7 +120,8 @@ export interface MeetingCreate { scheduled_at: string duration?: number location_type?: string - location?: string + location?: string // 线下会议地址(offline / hybrid) + online_url?: string // 线上会议链接(online / hybrid) agenda?: string reminder_before_hours?: number assignee_ids?: number[] @@ -131,7 +133,8 @@ export interface MeetingUpdate { scheduled_at?: string duration?: number location_type?: string - location?: string + location?: string // 线下会议地址(offline / hybrid) + online_url?: string // 线上会议链接(online / hybrid) status?: string agenda?: string reminder_before_hours?: number diff --git a/frontend/src/components/calendar/ContentDetailDialog.vue b/frontend/src/components/calendar/ContentDetailDialog.vue new file mode 100644 index 0000000..f30666b --- /dev/null +++ b/frontend/src/components/calendar/ContentDetailDialog.vue @@ -0,0 +1,174 @@ + + + + + 标题 + {{ event.title }} + + + 状态 + + {{ getStatusLabel(getStatus(event)) }} + + + + 来源类型 + {{ getSourceTypeLabel(getSourceType(event)) }} + + + 作者 + {{ getAuthor(event) || '未设置' }} + + + 分类 + {{ getCategory(event) || '未设置' }} + + + 排期时间 + + {{ event.start ? formatDate(event.start) : '未排期' }} + + + + + 关闭 + + 取消排期 + + + 编辑内容 + + + + + + + + diff --git a/frontend/src/components/calendar/UnscheduledPanel.vue b/frontend/src/components/calendar/UnscheduledPanel.vue new file mode 100644 index 0000000..fb851e6 --- /dev/null +++ b/frontend/src/components/calendar/UnscheduledPanel.vue @@ -0,0 +1,204 @@ + + + + + + 未排期内容 + + + + + + + + + + {{ item.title }} + + + {{ getStatusLabel(item.status) }} + + {{ item.author }} + + + + + + + + + + + diff --git a/frontend/src/views/CommunitySandbox.vue b/frontend/src/views/CommunitySandbox.vue index 42b79f7..b5af0e3 100644 --- a/frontend/src/views/CommunitySandbox.vue +++ b/frontend/src/views/CommunitySandbox.vue @@ -192,13 +192,18 @@ class="content-list-item" @click="$router.push(`/contents/${item.id}/edit`)" > - {{ item.title }} - - - {{ statusLabel(item.status) }} - - {{ item.owner_name }} - {{ formatDate(item.created_at) }} + + + + + {{ item.title }} + + {{ statusLabel(item.status) }} + {{ workStatusLabel(item.work_status) }} + · + {{ item.owner_name }} + {{ formatDate(item.created_at) }} + @@ -225,7 +230,10 @@ {{ m.title }} - {{ m.committee_name }} + + + {{ m.committee_name }} + {{ formatTime(m.scheduled_at) }} @@ -438,6 +446,11 @@ function statusLabel(s: string) { return m[s] || s } +function workStatusLabel(s: string | null) { + const m: Record = { planning: '计划中', in_progress: '进行中', completed: '已完成' } + return s ? (m[s] || s) : '' +} + function statusClass(s: string) { const m: Record = { draft: 'badge-gray', @@ -735,32 +748,61 @@ function formatTime(dt: string) { /* ===== 最近内容列表 ===== */ .content-list-item { + display: flex; + align-items: flex-start; + gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background 0.15s; } .content-list-item:last-child { border-bottom: none; } -.content-list-item:hover { color: var(--blue); } + +.content-icon-col { + flex-shrink: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + background: #f0fdf4; + border-radius: 7px; + margin-top: 1px; +} +.content-type-icon { + font-size: 14px; + color: #16a34a; +} + +.content-body { flex: 1; min-width: 0; } + .content-title { font-size: 14px; font-weight: 500; color: var(--text-primary); - margin-bottom: 4px; + margin-bottom: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color 0.15s; } .content-list-item:hover .content-title { color: var(--blue); } + .content-meta { display: flex; align-items: center; - gap: 8px; + gap: 5px; + flex-wrap: wrap; } .meta-text { font-size: 12px; color: var(--text-muted); } +.meta-sep { + font-size: 12px; + color: var(--text-muted); + margin: 0 1px; +} /* Status badges */ .status-badge { @@ -775,6 +817,18 @@ function formatTime(dt: string) { .badge-blue { background: #eff6ff; color: #1d4ed8; } .badge-green { background: #f0fdf4; color: #15803d; } +/* Work status chips */ +.work-badge { + display: inline-block; + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 4px; +} +.wbadge-planning { background: #f8fafc; color: #94a3b8; } +.wbadge-in_progress { background: #fff8ed; color: #b45309; } +.wbadge-completed { background: #f0fdf4; color: #15803d; } + /* ===== 会议列表 ===== */ .meeting-item { display: flex; @@ -787,25 +841,33 @@ function formatTime(dt: string) { } .meeting-item:last-child { border-bottom: none; } .meeting-item:hover .meeting-title { color: var(--blue); } + .meeting-date-col { text-align: center; - width: 36px; + width: 42px; + height: 46px; flex-shrink: 0; + background: #eff6ff; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; } .meeting-day { - font-size: 20px; + font-size: 18px; font-weight: 700; color: var(--blue); line-height: 1; } .meeting-month { - font-size: 11px; - color: var(--text-muted); -} -.meeting-info { - flex: 1; - min-width: 0; + font-size: 10px; + color: #60a5fa; + font-weight: 600; + margin-top: 1px; } + +.meeting-info { flex: 1; min-width: 0; } .meeting-title { font-size: 14px; font-weight: 500; @@ -813,16 +875,25 @@ function formatTime(dt: string) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + transition: color 0.15s; } .meeting-committee { font-size: 12px; color: var(--text-muted); - margin-top: 2px; + margin-top: 3px; + display: flex; + align-items: center; + gap: 3px; } .meeting-time { font-size: 12px; color: var(--text-secondary); flex-shrink: 0; + background: #f8fafc; + border: 1px solid var(--border); + border-radius: 5px; + padding: 2px 7px; + font-weight: 500; } /* ===== 空提示 ===== */ diff --git a/frontend/src/views/ContentCalendar.vue b/frontend/src/views/ContentCalendar.vue index cf9aa96..9af1f2b 100644 --- a/frontend/src/views/ContentCalendar.vue +++ b/frontend/src/views/ContentCalendar.vue @@ -42,49 +42,12 @@ - - - - - 未排期内容 - - - - - - - - - - {{ item.title }} - - - {{ getStatusLabel(item.status) }} - - {{ item.author }} - - - - - - + :events="unscheduledEvents" + :collapsed="panelCollapsed" + @toggle="panelCollapsed = !panelCollapsed" + /> @@ -93,103 +56,23 @@ - - - - 标题 - {{ selectedEvent.title }} - - - 状态 - - {{ getStatusLabel(selectedEvent.extendedProps.status) }} - - - - 来源类型 - {{ getSourceTypeLabel(selectedEvent.extendedProps.source_type) }} - - - 作者 - {{ selectedEvent.extendedProps.author || '未设置' }} - - - 分类 - {{ selectedEvent.extendedProps.category || '未设置' }} - - - 排期时间 - - {{ selectedEvent.start ? formatDate(selectedEvent.start) : '未排期' }} - - - - - 关闭 - - 取消排期 - - - 编辑内容 - - - - - - - - - - - - - - - - - - - - - - - - - - 取消 - - 创建 - - - + + + 顶