diff --git a/README.md b/README.md index c474405..dd43e36 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ docker compose down docker rmi opengecko-backend opengecko-frontend 2>/dev/null || true # 删除持久化数据库(⚠️ 数据不可恢复,谨慎操作) -rm -f backend/opengecko.db +rm -f data/opengecko.db # 重新构建并启动 docker compose up -d --build @@ -136,11 +136,11 @@ docker compose up -d --build ```bash docker compose down && \ docker rmi opengecko-backend opengecko-frontend 2>/dev/null; \ -rm -f backend/opengecko.db && \ +rm -f data/opengecko.db && \ docker compose up -d --build ``` -> **注意**:`backend/opengecko.db` 是通过 `docker-compose.override.yml` 挂载到宿主机的 SQLite 数据库文件,删除后所有数据(社区、内容、用户等)将清空,重启后重新初始化。生产环境使用 PostgreSQL 时此步骤不适用。 +> **注意**:`data/opengecko.db` 是通过 `docker-compose.yml` 中 `./data:/app/data` 卷挂载到宿主机的 SQLite 数据库文件,删除后所有数据(社区、内容、用户等)将清空,重启后重新初始化。生产环境使用 PostgreSQL 时此步骤不适用。 ### 首次使用流程 diff --git a/backend/.env.example b/backend/.env.example index 5ba527b..447a387 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,8 @@ # 数据库 # ───────────────────────────────────────────────────────────────────── # 开发默认 SQLite;生产建议换为 PostgreSQL(见 .env.prod.example) -DATABASE_URL=sqlite:///./opengecko.db +# 路径对应 docker-compose.yml 中 ./data:/app/data 卷,确保数据持久化 +DATABASE_URL=sqlite:///./data/opengecko.db # 连接池(PostgreSQL / MySQL 时生效) # DB_POOL_SIZE=5 diff --git a/backend/alembic/versions/7e70abbef6ae_add_event_communities_many_to_many.py b/backend/alembic/versions/7e70abbef6ae_add_event_communities_many_to_many.py new file mode 100644 index 0000000..02466f2 --- /dev/null +++ b/backend/alembic/versions/7e70abbef6ae_add_event_communities_many_to_many.py @@ -0,0 +1,31 @@ +"""add event_communities many_to_many + +Revision ID: 7e70abbef6ae +Revises: 003_data_migrations +Create Date: 2026-02-24 20:21:52.592608 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = '7e70abbef6ae' +down_revision: Union[str, None] = '003_data_migrations' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'event_communities', + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('community_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['community_id'], ['communities.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('event_id', 'community_id'), + ) + + +def downgrade() -> None: + op.drop_table('event_communities') diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 04fde7d..5d1f182 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -483,9 +483,15 @@ def _send_password_reset_email(to_email: str, token: str) -> None: msg.attach(MIMEText(html_body, "html", "utf-8")) - with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: - if settings.SMTP_USE_TLS: - server.starttls() - server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) - server.sendmail(msg["From"], [to_email], msg.as_string()) + # Port 465: direct SSL/TLS; port 587/others: STARTTLS + if settings.SMTP_PORT == 465: + with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30) as server: + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.sendmail(msg["From"], [to_email], msg.as_string()) + else: + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=30) as server: + if settings.SMTP_USE_TLS: + server.starttls() + server.login(settings.SMTP_USER, settings.SMTP_PASSWORD) + server.sendmail(msg["From"], [to_email], msg.as_string()) diff --git a/backend/app/api/community_dashboard.py b/backend/app/api/community_dashboard.py index a1fcb81..02f541f 100644 --- a/backend/app/api/community_dashboard.py +++ b/backend/app/api/community_dashboard.py @@ -253,11 +253,11 @@ def get_community_dashboard( calendar_events: list[CalendarEvent] = [] # 会议事件:按状态区分颜色 - # scheduled → 蓝色 #0095ff;completed → 灰色 #94a3b8;cancelled → 浅红 #f87171 + # scheduled → 蓝色 #0095ff;completed → 绿色 #10b981;cancelled → 灰色 #94a3b8 MEETING_COLORS = { "scheduled": "#0095ff", - "completed": "#94a3b8", - "cancelled": "#f87171", + "completed": "#10b981", + "cancelled": "#94a3b8", } meeting_events = ( db.query(Meeting) diff --git a/backend/app/api/contents.py b/backend/app/api/contents.py index 0fb6773..3fa9793 100644 --- a/backend/app/api/contents.py +++ b/backend/app/api/contents.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import insert, or_ from sqlalchemy import select as sa_select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, joinedload from app.core.dependencies import check_content_edit_permission, get_current_user from app.database import get_db @@ -12,6 +12,7 @@ from app.schemas.content import ( ContentCalendarOut, ContentCreate, + ContentListOut, ContentOut, ContentScheduleUpdate, ContentStatusUpdate, @@ -74,6 +75,7 @@ def list_contents( source_type: str | None = None, keyword: str | None = None, community_id: int | None = Query(None), + unscheduled: bool = Query(False, description="若为 true,只返回未设置 scheduled_publish_at 的内容"), current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ): @@ -87,8 +89,24 @@ def list_contents( query = query.filter(Content.source_type == source_type) if keyword: query = query.filter(Content.title.contains(keyword)) + if unscheduled: + query = query.filter(Content.scheduled_publish_at.is_(None)) total = query.count() - items = query.order_by(Content.updated_at.desc()).offset((page - 1) * page_size).limit(page_size).all() + items_raw = ( + query + .options(joinedload(Content.assignees)) + .order_by(Content.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + items = [ + ContentListOut( + **{c.name: getattr(item, c.name) for c in item.__table__.columns}, + assignee_names=[u.full_name or u.username for u in item.assignees], + ) + for item in items_raw + ] return PaginatedContents(items=items, total=total, page=page, page_size=page_size) diff --git a/backend/app/api/events.py b/backend/app/api/events.py index 9d82ef8..f4fc097 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -4,6 +4,7 @@ from app.core.dependencies import get_current_user from app.database import get_db from app.models import User +from app.models.community import Community from app.models.event import ( ChecklistItem, Event, @@ -78,10 +79,18 @@ def create_event( if data.event_type not in VALID_EVENT_TYPES: raise HTTPException(400, f"event_type 必须为 {VALID_EVENT_TYPES}") + create_data = data.model_dump(exclude={"community_ids"}) event = Event( owner_id=current_user.id, - **data.model_dump(), + **create_data, ) + # 处理多对多社区关联 + all_ids = list(dict.fromkeys(data.community_ids + ([data.community_id] if data.community_id else []))) + if all_ids: + comms = db.query(Community).filter(Community.id.in_(all_ids)).all() + event.communities = comms + if not event.community_id and comms: + event.community_id = comms[0].id db.add(event) db.flush() @@ -124,8 +133,14 @@ def update_event( event = db.query(Event).filter(Event.id == event_id).first() if not event: raise HTTPException(404, "活动不存在") - for key, value in data.model_dump(exclude_unset=True).items(): + update_data = data.model_dump(exclude_unset=True, exclude={"community_ids"}) + for key, value in update_data.items(): setattr(event, key, value) + # 处理 community_ids 更新 + if data.community_ids is not None: + comms = db.query(Community).filter(Community.id.in_(data.community_ids)).all() + event.communities = comms + event.community_id = comms[0].id if comms else None db.commit() db.refresh(event) return event diff --git a/backend/app/config.py b/backend/app/config.py index 5387617..f103664 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -26,7 +26,7 @@ def cors_origins_list(self) -> list[str]: RATE_LIMIT_DEFAULT: str = "120/minute" # Database - DATABASE_URL: str = "sqlite:///./opengecko.db" + DATABASE_URL: str = "sqlite:///./data/opengecko.db" # Database Connection Pool (for PostgreSQL/MySQL) DB_POOL_SIZE: int = 5 diff --git a/backend/app/database.py b/backend/app/database.py index 84970d7..051fe3c 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -1,8 +1,17 @@ +import os + from sqlalchemy import create_engine from sqlalchemy.orm import DeclarativeBase, sessionmaker from app.config import settings +# Ensure SQLite parent directory exists (e.g., data/ in fresh CI checkout) +if settings.DATABASE_URL.startswith("sqlite:///"): + _db_path = settings.DATABASE_URL[len("sqlite:///"):] + _db_dir = os.path.dirname(_db_path) + if _db_dir: + os.makedirs(_db_dir, exist_ok=True) + # Database connection pool configuration # For SQLite, we need check_same_thread=False # For PostgreSQL/MySQL, use connection pooling settings diff --git a/backend/app/models/event.py b/backend/app/models/event.py index eab7489..03475e4 100644 --- a/backend/app/models/event.py +++ b/backend/app/models/event.py @@ -1,11 +1,19 @@ from datetime import datetime -from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy import JSON, Boolean, Column, Date, DateTime, ForeignKey, Integer, String, Table, Text from sqlalchemy import Enum as SAEnum from sqlalchemy.orm import relationship from app.database import Base +# 活动 ↔ 社区 多对多关联表 +event_communities_table = Table( + "event_communities", + Base.metadata, + Column("event_id", Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True), + Column("community_id", Integer, ForeignKey("communities.id", ondelete="CASCADE"), primary_key=True), +) + class EventTemplate(Base): """活动 SOP 模板""" @@ -90,6 +98,9 @@ class Event(Base): updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) template = relationship("EventTemplate", back_populates="events") + communities = relationship( + "Community", secondary="event_communities", lazy="selectin" + ) checklist_items = relationship( "ChecklistItem", back_populates="event", cascade="all, delete-orphan" ) diff --git a/backend/app/schemas/content.py b/backend/app/schemas/content.py index 3d7d687..41f407b 100644 --- a/backend/app/schemas/content.py +++ b/backend/app/schemas/content.py @@ -79,6 +79,7 @@ class ContentListOut(BaseModel): scheduled_publish_at: datetime | None = None created_at: datetime updated_at: datetime + assignee_names: list[str] = [] model_config = {"from_attributes": True} diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 38a281f..fc683fc 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -66,6 +66,7 @@ class EventCreate(BaseModel): title: str event_type: str = "offline" community_id: int | None = None + community_ids: list[int] = [] template_id: int | None = None planned_at: datetime | None = None duration_minutes: int | None = None @@ -78,6 +79,7 @@ class EventCreate(BaseModel): class EventUpdate(BaseModel): title: str | None = None event_type: str | None = None + community_ids: list[int] | None = None planned_at: datetime | None = None duration_minutes: int | None = None location: str | None = None @@ -97,9 +99,18 @@ class EventStatusUpdate(BaseModel): status: str # draft / planning / ongoing / completed / cancelled +class CommunitySimple(BaseModel): + id: int + name: str + + model_config = {"from_attributes": True} + + class EventOut(BaseModel): id: int community_id: int | None + community_ids: list[int] = [] + communities: list[CommunitySimple] = [] title: str event_type: str template_id: int | None @@ -120,12 +131,22 @@ class EventOut(BaseModel): created_at: datetime updated_at: datetime + @classmethod + def model_validate(cls, obj, **kwargs): + instance = super().model_validate(obj, **kwargs) + # 从 communities 关系派生 community_ids + if hasattr(obj, 'communities'): + instance.community_ids = [c.id for c in obj.communities] + return instance + model_config = {"from_attributes": True} class EventListOut(BaseModel): id: int community_id: int | None + community_ids: list[int] = [] + communities: list[CommunitySimple] = [] title: str event_type: str status: str @@ -134,6 +155,13 @@ class EventListOut(BaseModel): owner_id: int | None created_at: datetime + @classmethod + def model_validate(cls, obj, **kwargs): + instance = super().model_validate(obj, **kwargs) + if hasattr(obj, 'communities'): + instance.community_ids = [c.id for c in obj.communities] + return instance + model_config = {"from_attributes": True} diff --git a/backend/data/.gitkeep b/backend/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/test_smtp.py b/backend/scripts/test_smtp.py new file mode 100644 index 0000000..882f530 --- /dev/null +++ b/backend/scripts/test_smtp.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +SMTP 连通性测试脚本 +用法:从 backend/ 目录执行: + python scripts/test_smtp.py --to you@example.com + +脚本会读取 backend/.env 中的 SMTP 配置,尝试发一封测试邮件。 +如未配置 .env,也可直接传命令行参数(见 --help)。 +""" + +import argparse +import smtplib +import sys +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="测试 SMTP 邮件发送") + parser.add_argument("--host", help="SMTP 服务器地址") + parser.add_argument("--port", type=int, help="SMTP 端口(465=SSL, 587=STARTTLS)") + parser.add_argument("--user", help="SMTP 登录账户") + parser.add_argument("--password", help="SMTP 密码") + parser.add_argument("--from-email", dest="from_email", help="发件人地址(默认同 --user)") + parser.add_argument("--to", required=True, help="收件人地址(用于验证是否收到)") + return parser.parse_args() + + +def load_from_env() -> dict: + """尝试从 .env 文件/环境变量读取配置。""" + try: + import os, pathlib + # 加载 .env + env_path = pathlib.Path(__file__).resolve().parent.parent / ".env" + if env_path.exists(): + for line in env_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + k, _, v = line.partition("=") + # 去除行内注释(# 之前可能有空格) + v = v.split(" #")[0].split("\t#")[0].strip() + os.environ.setdefault(k.strip(), v) + return { + "host": os.environ.get("SMTP_HOST", ""), + "port": int(os.environ.get("SMTP_PORT", "465")), + "user": os.environ.get("SMTP_USER", ""), + "password": os.environ.get("SMTP_PASSWORD", ""), + "from_email": os.environ.get("SMTP_FROM_EMAIL", ""), + } + except Exception as e: + print(f"[warn] 读取 .env 失败: {e}") + return {} + + +def send_test_email(host: str, port: int, user: str, password: str, + from_email: str, to: str) -> None: + from_email = (from_email or user).strip() + user = user.strip() + password = password.strip() + + msg = MIMEMultipart("alternative") + msg["Subject"] = "openGecko SMTP 连通性测试" + msg["From"] = from_email + msg["To"] = to + msg.attach(MIMEText("这是一封 SMTP 测试邮件,收到说明配置正常。", "plain", "utf-8")) + msg.attach(MIMEText( + "

这是一封 SMTP 测试邮件,收到说明 openGecko 邮箱配置正常 ✅

", + "html", "utf-8", + )) + + print(f"连接 {host}:{port} …") + if port == 465: + print("使用 SMTP_SSL (直连 SSL/TLS)") + with smtplib.SMTP_SSL(host, port, timeout=30) as server: + server.set_debuglevel(1) + server.login(user, password) + server.sendmail(from_email, [to], msg.as_string()) + else: + print(f"使用 SMTP + {'STARTTLS' if port == 587 else '明文'}") + with smtplib.SMTP(host, port, timeout=30) as server: + server.set_debuglevel(1) + if port == 587: + server.starttls() + server.login(user, password) + server.sendmail(from_email, [to], msg.as_string()) + + print(f"\n✅ 测试邮件已发送至 {to},请检查收件箱(含垃圾箱)。") + + +def main() -> None: + args = parse_args() + env = load_from_env() + + host = args.host or env.get("host", "") + port = args.port or env.get("port", 465) + user = args.user or env.get("user", "") + password = args.password or env.get("password", "") + from_email = args.from_email or env.get("from_email", "") + + missing = [k for k, v in [("host", host), ("port", port), ("user", user), ("password", password)] if not v] + if missing: + print(f"❌ 缺少以下参数: {', '.join(missing)}") + print(" 请在 backend/.env 中配置,或通过命令行参数传入(--help 查看用法)") + sys.exit(1) + + print(f"配置摘要:\n host={host} port={port} user={user} from={from_email or user} to={args.to}\n") + try: + send_test_email(host, port, user, password, from_email, args.to) + except smtplib.SMTPAuthenticationError as e: + print(f"\n❌ 认证失败: {e}\n 请检查账户名和密码是否正确。") + sys.exit(1) + except smtplib.SMTPConnectError as e: + print(f"\n❌ 连接失败: {e}\n 请检查 host/port 是否正确,以及网络/防火墙设置。") + sys.exit(1) + except Exception as e: + print(f"\n❌ 发送失败: {type(e).__name__}: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/test_auth_users_api.py b/backend/tests/test_auth_users_api.py index bbc6484..a709112 100644 --- a/backend/tests/test_auth_users_api.py +++ b/backend/tests/test_auth_users_api.py @@ -10,6 +10,8 @@ - POST /api/auth/password-reset/confirm """ +from unittest import mock + from fastapi.testclient import TestClient from sqlalchemy.orm import Session @@ -320,10 +322,15 @@ def test_reset_request_dev_mode_returns_token( db_session.add(user) db_session.commit() - response = client.post( - "/api/auth/password-reset/request", - json={"email": "reset_me@example.com"}, - ) + # 模拟无 SMTP 配置(即使 .env 中有配置也强制走开发模式) + with mock.patch("app.api.auth.settings") as mock_settings: + mock_settings.SMTP_HOST = "" + mock_settings.SMTP_USER = "" + mock_settings.FRONTEND_URL = "http://localhost:3000" + response = client.post( + "/api/auth/password-reset/request", + json={"email": "reset_me@example.com"}, + ) assert response.status_code == 200 data = response.json() # 开发模式(测试环境无 SMTP)应有 token diff --git a/frontend/src/api/content.ts b/frontend/src/api/content.ts index 2ae4ab6..d83dd1e 100644 --- a/frontend/src/api/content.ts +++ b/frontend/src/api/content.ts @@ -39,6 +39,7 @@ export interface ContentListItem { scheduled_publish_at: string | null created_at: string updated_at: string + assignee_names: string[] } export interface ContentCalendarItem { @@ -66,6 +67,7 @@ export async function fetchContents(params: { source_type?: string keyword?: string community_id?: number + unscheduled?: boolean }): 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 b226508..1434f47 100644 --- a/frontend/src/api/event.ts +++ b/frontend/src/api/event.ts @@ -5,6 +5,8 @@ import apiClient from './index' export interface EventListItem { id: number community_id: number | null + community_ids: number[] + communities: { id: number; name: string }[] title: string event_type: string status: string @@ -17,6 +19,8 @@ export interface EventListItem { export interface EventDetail { id: number community_id: number | null + community_ids: number[] + communities: { id: number; name: string }[] title: string event_type: string template_id: number | null @@ -42,6 +46,7 @@ export interface EventCreate { title: string event_type?: string community_id?: number | null + community_ids?: number[] template_id?: number | null planned_at?: string | null duration_minutes?: number | null @@ -52,6 +57,7 @@ export interface EventCreate { } export interface EventUpdate extends Partial> { + community_ids?: number[] attendee_count?: number | null result_summary?: string | null } diff --git a/frontend/src/views/CommunitySandbox.vue b/frontend/src/views/CommunitySandbox.vue index 0e3afa0..5e357c6 100644 --- a/frontend/src/views/CommunitySandbox.vue +++ b/frontend/src/views/CommunitySandbox.vue @@ -78,69 +78,24 @@

社区事件日历

- - 📅 - 会议 - - - - - - 🎉 - 活动 - - - - - - 📝 - 内容 - - - + + 会议 + 活动 + 内容 + + + 策划中 + 进行中 + 已完成/发布 + 定时 + 已取消
- +
-
-
- -
-
-
{{ dashboardData.metrics.total_contents }}
-
内容总数
-
-
-
-
- -
-
-
{{ dashboardData.metrics.published_contents }}
-
已发布
-
-
-
-
- -
-
-
{{ dashboardData.metrics.reviewing_contents }}
-
待审核
-
-
-
-
- -
-
-
{{ dashboardData.metrics.draft_contents }}
-
草稿
-
-
@@ -170,7 +125,52 @@
- + +
+
+
+ +
+ 查看全部 → +
+
暂无{{ currentTabLabel }}内容
+ + + + + + + + + + + + +
+ +
@@ -188,36 +188,36 @@
- +
-

最近内容

- 查看全部 → -
-
暂无内容
-
-
-
- -
-
-
{{ item.title }}
- -
-
+

近期活动

+ 查看全部 →
+
暂无近期活动
+ + + + + + + + + + +
@@ -228,27 +228,32 @@
近期无会议安排
-
-
-
-
{{ formatDay(m.scheduled_at) }}
-
{{ formatMonth(m.scheduled_at) }}
-
-
-
{{ m.title }}
-
- - {{ m.committee_name }} + + + + + + + + + + + +
@@ -272,14 +277,14 @@ import VChart from 'vue-echarts' import FullCalendar from '@fullcalendar/vue3' import dayGridPlugin from '@fullcalendar/daygrid' import interactionPlugin from '@fullcalendar/interaction' -import type { CalendarOptions } from '@fullcalendar/core' +import type { CalendarOptions, EventContentArg } from '@fullcalendar/core' import { - Document, Promotion, Clock, EditPen, Stamp, - Calendar, Connection, Plus, Setting, + Stamp, Calendar, Connection, Plus, Setting, } from '@element-plus/icons-vue' import { useAuthStore } from '../stores/auth' import { useCommunityStore } from '../stores/community' import { getCommunityDashboard, type CommunityDashboardResponse } from '../api/communityDashboard' +import { listEvents, type EventListItem } from '../api/event' // 按需注册 ECharts 组件(Tree Shaking) use([CanvasRenderer, LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, DataZoomComponent]) @@ -291,6 +296,24 @@ const communityStore = useCommunityStore() const loading = ref(false) const dashboardData = ref(null) const calendarRef = ref() +const recentEvents = ref([]) + +// Content status tabs +const contentTab = ref('reviewing') +const contentTabs = [ + { key: 'reviewing', label: '待审核' }, + { key: 'draft', label: '草稿' }, + { key: 'published', label: '近期发布' }, +] +const tabContents = computed(() => + (dashboardData.value?.recent_contents ?? []).filter(c => c.status === contentTab.value) +) +const currentTabLabel = computed(() => + contentTabs.find(t => t.key === contentTab.value)?.label ?? '' +) +function contentCountByStatus(status: string) { + return (dashboardData.value?.recent_contents ?? []).filter(c => c.status === status).length +} // 当前激活社区信息 const currentCommunity = computed(() => @@ -327,15 +350,33 @@ async function loadDashboard() { } } +async function loadEvents() { + if (!communityStore.currentCommunityId) return + try { + const res = await listEvents({ community_id: communityStore.currentCommunityId, page: 1, page_size: 6 }) + recentEvents.value = res.items + } catch (e) { + console.error('Failed to load events', e) + } +} + onMounted(() => { - if (communityStore.currentCommunityId) loadDashboard() + if (communityStore.currentCommunityId) { + loadDashboard() + loadEvents() + } }) watch( () => communityStore.currentCommunityId, (newId) => { - if (newId) loadDashboard() - else dashboardData.value = null + if (newId) { + loadDashboard() + loadEvents() + } else { + dashboardData.value = null + recentEvents.value = [] + } } ) @@ -434,14 +475,35 @@ const calendarOptions = computed(() => ({ right: '', }, buttonText: { today: '今天' }, - height: 400, + height: 580, events: (dashboardData.value?.calendar_events ?? []).map((e, idx) => ({ id: `${e.resource_type}_${e.resource_id}_${e.type}_${idx}`, title: e.title, date: e.date, - color: e.color, - extendedProps: { type: e.type, resource_id: e.resource_id, resource_type: e.resource_type }, + color: 'transparent', + textColor: 'transparent', + extendedProps: { type: e.type, resource_id: e.resource_id, resource_type: e.resource_type, statusColor: e.color }, })), + eventContent: (arg: EventContentArg) => { + const { type, statusColor } = arg.event.extendedProps as { type: string; statusColor: string; resource_id: number; resource_type: string } + let barBg = '#f8fafc' + let barText = '#64748b' + let barAccent = '#94a3b8' // 左边框颜色 = 类型色,与状态无关 + if (type?.startsWith('meeting')) { barBg = '#eff6ff'; barText = '#1d4ed8'; barAccent = '#1d4ed8' } + else if (type?.startsWith('event')) { barBg = '#f5f3ff'; barText = '#6d28d9'; barAccent = '#6d28d9' } + else { barBg = '#f0fdf4'; barText = '#15803d'; barAccent = '#15803d' } // content + const dotColor = statusColor || barAccent // 圆点颜色 = 状态色 + const wrapper = document.createElement('div') + wrapper.style.cssText = `display:flex;align-items:center;gap:4px;background:${barBg};border-left:3px solid ${barAccent};padding:1px 5px 1px 4px;border-radius:3px;width:100%;box-sizing:border-box;overflow:hidden;` + const dot = document.createElement('span') + dot.style.cssText = `width:6px;height:6px;border-radius:50%;background:${dotColor};flex-shrink:0;` + const titleEl = document.createElement('span') + titleEl.style.cssText = `font-size:11px;font-weight:500;color:${barText};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;` + titleEl.textContent = arg.event.title + wrapper.appendChild(dot) + wrapper.appendChild(titleEl) + return { domNodes: [wrapper] } + }, eventClick: (info) => { const { type, resource_id } = info.event.extendedProps if (type?.startsWith('meeting')) router.push(`/meetings/${resource_id}`) @@ -464,6 +526,11 @@ function workStatusLabel(s: string | null) { return s ? (m[s] || s) : '' } +function workStatusClass(s: string) { + const m: Record = { planning: 'badge-gray', in_progress: 'badge-orange', completed: 'badge-green' } + return m[s] || 'badge-gray' +} + function statusClass(s: string) { const m: Record = { draft: 'badge-gray', @@ -474,6 +541,20 @@ function statusClass(s: string) { return m[s] || 'badge-gray' } +function eventStatusLabel(s: string) { + const m: Record = { + planning: '策划中', ongoing: '进行中', completed: '已完成', cancelled: '已取消', + } + return m[s] || s +} + +function eventStatusClass(s: string) { + const m: Record = { + planning: 'badge-gray', ongoing: 'badge-blue', completed: 'badge-green', cancelled: 'badge-gray', + } + return m[s] || 'badge-gray' +} + function formatDate(dt: string) { return new Date(dt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }) } @@ -614,10 +695,10 @@ function formatTime(dt: string) { padding: 24px 0; } -/* ===== 8 指标卡片网格 ===== */ +/* ===== 治理指标卡片网格 (3列) ===== */ .metrics-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 24px; } @@ -731,225 +812,189 @@ function formatTime(dt: string) { margin-bottom: 24px; } .calendar-legend { - display: flex; - align-items: center; - gap: 16px; - flex-wrap: wrap; -} -.legend-group { display: flex; align-items: center; gap: 8px; + flex-wrap: wrap; } -.legend-icon { - font-size: 14px; -} -.legend-dot { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 50%; -} -.meeting-scheduled-dot { background: #0095ff; } -.meeting-completed-dot { background: #94a3b8; } -.meeting-cancelled-dot { background: #f87171; } -.event-planning-dot { background: #8b5cf6; } -.event-ongoing-dot { background: #0095ff; } -.event-completed-dot { background: #10b981; } -.publish-dot { background: #10b981; } -.scheduled-dot { background: #f59e0b; } -.legend-text { +.legend-section-label { font-size: 12px; color: var(--text-secondary); - margin-right: 4px; + font-weight: 600; + white-space: nowrap; + margin-right: 2px; } -.calendar-legend { - display: flex; +/* Legend: bar samples for type */ +.legend-bar { + display: inline-block; + font-size: 11px; + font-weight: 500; + padding: 2px 8px 2px 5px; + border-radius: 4px; + border-left: 3px solid; + white-space: nowrap; +} +.lb-meeting { background: #eff6ff; color: #1d4ed8; border-left-color: #1d4ed8; } +.lb-event { background: #f5f3ff; color: #6d28d9; border-left-color: #6d28d9; } +.lb-content { background: #f0fdf4; color: #15803d; border-left-color: #15803d; } + +/* Legend: dot + label samples for status */ +.legend-dot-item { + display: inline-flex; align-items: center; - gap: 14px; + gap: 4px; + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; } .legend-dot { - display: inline-block; width: 8px; height: 8px; border-radius: 50%; + flex-shrink: 0; } -.meeting-scheduled-dot { background: #0095ff; } -.meeting-completed-dot { background: #94a3b8; } -.meeting-cancelled-dot { background: #f87171; } -.event-planning-dot { background: #8b5cf6; } -.event-ongoing-dot { background: #0095ff; } -.event-completed-dot { background: #10b981; } -.publish-dot { background: #10b981; } -.scheduled-dot { background: #f59e0b; } +.ld-purple { background: #8b5cf6; } +.ld-blue { background: #0095ff; } +.ld-green { background: #10b981; } +.ld-orange { background: #f59e0b; } +.ld-gray { background: #94a3b8; } + .legend-divider { width: 1px; - height: 12px; + height: 16px; background: #e2e8f0; - margin: 0 8px; + margin: 0 4px; } -.legend-text { - font-size: 12px; - color: var(--text-secondary); - margin-right: 4px; + +/* ===== 内容动态卡片 ===== */ +.content-status-card { + margin-bottom: 20px; } -/* ===== 底部两栏 ===== */ -.bottom-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 20px; +.content-tabs { + display: flex; + gap: 4px; } -/* ===== 最近内容列表 ===== */ -.content-list-item { +.content-tab { display: flex; - align-items: flex-start; - gap: 10px; - padding: 10px 0; - border-bottom: 1px solid var(--border); + align-items: center; + gap: 6px; + padding: 6px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: #fff; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); cursor: pointer; - transition: background 0.15s; + transition: all 0.15s ease; } -.content-list-item:last-child { border-bottom: none; } -.content-icon-col { - flex-shrink: 0; - width: 30px; - height: 30px; - display: flex; +.content-tab:hover { + border-color: var(--blue); + color: var(--blue); +} + +.content-tab.active { + background: #eff6ff; + border-color: var(--blue); + color: var(--blue); +} + +.tab-count { + display: inline-flex; align-items: center; justify-content: center; - background: #f0fdf4; - border-radius: 7px; - margin-top: 1px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--border); + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; } -.content-type-icon { - font-size: 14px; - color: #16a34a; + +.content-tab.active .tab-count { + background: var(--blue); + color: #fff; } -.content-body { flex: 1; min-width: 0; } +/* ===== 底部两栏 ===== */ +.bottom-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} -.content-title { - font-size: 14px; - font-weight: 500; +/* ===== 底部表格样式 ===== */ +.table-link { color: var(--text-primary); - margin-bottom: 5px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-weight: 500; + cursor: pointer; transition: color 0.15s; } -.content-list-item:hover .content-title { color: var(--blue); } - -.content-meta { - display: flex; - align-items: center; - gap: 5px; - flex-wrap: wrap; +.dashboard-table :deep(.el-table__row) { + cursor: pointer; } -.meta-text { - font-size: 12px; - color: var(--text-muted); +.dashboard-table :deep(.el-table__row:hover > td) { + background: #f8fafc !important; } -.meta-sep { - font-size: 12px; - color: var(--text-muted); - margin: 0 1px; +.dashboard-table :deep(.el-table__row:hover .table-link) { + color: var(--blue); } - -/* Status badges */ -.status-badge { - display: inline-block; +.dashboard-table :deep(.el-table th) { + background: #f8fafc; font-size: 11px; font-weight: 600; - padding: 2px 7px; - border-radius: 99px; -} -.badge-gray { background: #f1f5f9; color: #64748b; } -.badge-orange { background: #fff7ed; color: #c2410c; } -.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; - align-items: center; - gap: 14px; - padding: 10px 0; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; border-bottom: 1px solid var(--border); - cursor: pointer; - transition: background 0.15s; + padding: 8px 0; +} +.dashboard-table :deep(.el-table td) { + border-bottom: 1px solid #f1f5f9; + padding: 8px 0; } -.meeting-item:last-child { border-bottom: none; } -.meeting-item:hover .meeting-title { color: var(--blue); } -.meeting-date-col { - text-align: center; - width: 42px; - height: 46px; - flex-shrink: 0; - background: #eff6ff; - border-radius: 8px; - display: flex; +/* 会议日期单元格 */ +.meeting-cell-date { + display: inline-flex; flex-direction: column; align-items: center; + width: 36px; + height: 38px; + background: #eff6ff; + border-radius: 7px; justify-content: center; } -.meeting-day { - font-size: 18px; +.mcd-day { + font-size: 16px; font-weight: 700; color: var(--blue); line-height: 1; } -.meeting-month { - font-size: 10px; +.mcd-month { + font-size: 9px; color: #60a5fa; font-weight: 600; margin-top: 1px; } -.meeting-info { flex: 1; min-width: 0; } -.meeting-title { - font-size: 14px; - font-weight: 500; - color: var(--text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - transition: color 0.15s; -} -.meeting-committee { - font-size: 12px; - color: var(--text-muted); - 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; +/* Status badges */ +.status-badge { + display: inline-block; + font-size: 11px; + font-weight: 600; padding: 2px 7px; - font-weight: 500; + border-radius: 99px; } +.badge-gray { background: #f1f5f9; color: #64748b; } +.badge-orange { background: #fff7ed; color: #c2410c; } +.badge-blue { background: #eff6ff; color: #1d4ed8; } +.badge-green { background: #f0fdf4; color: #15803d; } /* ===== 空提示 ===== */ .empty-hint { @@ -1116,13 +1161,12 @@ function formatTime(dt: string) { text-decoration: none !important; } -/* 事件胶囊 */ +/* 事件胶囊 — 背景/边框由 eventContent 的 domNodes 控制 */ :deep(.fc-event) { - font-size: 11px; border-radius: 4px !important; border: none !important; - border-left: 3px solid rgba(0,0,0,0.16) !important; - padding: 1px 5px !important; + background: transparent !important; + padding: 0 !important; margin: 0 3px 2px !important; cursor: pointer !important; transition: transform 0.11s, box-shadow 0.11s !important; @@ -1131,6 +1175,10 @@ function formatTime(dt: string) { transform: translateY(-1px); box-shadow: 0 3px 8px rgba(0,0,0,0.12) !important; } +:deep(.fc-event-main) { + overflow: hidden; + border-radius: 3px; +} /* "更多"链接 */ :deep(.fc-daygrid-more-link) { @@ -1159,7 +1207,7 @@ function formatTime(dt: string) { padding: 20px 20px 40px; } .metrics-grid { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(3, 1fr); } .charts-row, .bottom-row { @@ -1168,11 +1216,14 @@ function formatTime(dt: string) { } @media (max-width: 600px) { .metrics-grid { - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr 1fr 1fr; } .community-header { flex-direction: column; align-items: flex-start; } + .content-tabs { + flex-wrap: wrap; + } } diff --git a/frontend/src/views/ContentCalendar.vue b/frontend/src/views/ContentCalendar.vue index 9d9fb8a..0689fc0 100644 --- a/frontend/src/views/ContentCalendar.vue +++ b/frontend/src/views/ContentCalendar.vue @@ -81,6 +81,7 @@ import type { CalendarOptions, EventInput, EventDropArg, DateSelectArg, EventCli import { useCommunityStore } from '../stores/community' import { fetchCalendarEvents, + fetchContents, updateContentSchedule, type ContentCalendarItem, } from '../api/content' @@ -233,10 +234,8 @@ async function loadEvents(start: string, end: string) { end: end.slice(0, 10), status: statusFilter.value || undefined, }) - - // 分离已排期和未排期 + // 只取已排期的内容显示在日历上;未排期内容由 loadUnscheduledContent 单独加载 calendarEvents.value = items.filter((i) => i.scheduled_publish_at) - unscheduledEvents.value = items.filter((i) => !i.scheduled_publish_at) } catch (err: any) { ElMessage.error('加载日历数据失败: ' + (err?.response?.data?.detail || err.message)) } finally { @@ -244,6 +243,31 @@ async function loadEvents(start: string, end: string) { } } +// 单独加载全部未排期内容(不受当前日历视图日期范围限制) +async function loadUnscheduledContent() { + if (!communityStore.currentCommunityId) return + try { + const res = await fetchContents({ + community_id: communityStore.currentCommunityId, + unscheduled: true, + page_size: 100, + ...(statusFilter.value ? { status: statusFilter.value } : {}), + }) + unscheduledEvents.value = res.items.map((item) => ({ + id: item.id, + title: item.title, + status: item.status, + source_type: item.source_type, + author: item.author, + category: item.category, + scheduled_publish_at: null, + created_at: item.created_at, + })) + } catch (err: any) { + console.error('加载未排期内容失败', err) + } +} + async function handleEventDrop(info: EventDropArg) { const contentId = Number(info.event.id) const newDate = info.event.start @@ -264,7 +288,7 @@ async function handleEventDrop(info: EventDropArg) { async function handleEventDragStop(info: any) { // 检测事件是否被拖到了未排期面板区域 - const panelEl = unscheduledPanelRef.value?.panelRef?.value + const panelEl = unscheduledPanelRef.value?.$el if (!panelEl) return const rect = panelEl.getBoundingClientRect() @@ -305,7 +329,7 @@ function initDraggable() { draggableInstance.destroy() draggableInstance = null } - const container = unscheduledPanelRef.value?.containerRef?.value + const container = unscheduledPanelRef.value?.$el?.querySelector('.panel-body') if (!container) return draggableInstance = new Draggable(container, { itemSelector: '.unscheduled-item', @@ -389,7 +413,10 @@ async function refetchEvents() { const calendarApi = calendarRef.value?.getApi() if (calendarApi) { const view = calendarApi.view - await loadEvents(view.activeStart.toISOString(), view.activeEnd.toISOString()) + await Promise.all([ + loadEvents(view.activeStart.toISOString(), view.activeEnd.toISOString()), + loadUnscheduledContent(), + ]) } } @@ -398,6 +425,7 @@ watch( () => communityStore.currentCommunityId, () => { refetchEvents() + loadUnscheduledContent() } ) @@ -418,6 +446,8 @@ watch(panelCollapsed, (collapsed) => { onMounted(() => { nextTick(() => initDraggable()) + // 日历事件由 datesSet 触发加载;未排期内容单独预加载 + loadUnscheduledContent() }) onBeforeUnmount(() => { diff --git a/frontend/src/views/ContentList.vue b/frontend/src/views/ContentList.vue index 72ac877..ea9a671 100644 --- a/frontend/src/views/ContentList.vue +++ b/frontend/src/views/ContentList.vue @@ -88,10 +88,19 @@ - + @@ -128,7 +137,7 @@