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 }}
-
草稿
-
-
-
+
+
+
+
暂无{{ currentTabLabel }}内容
+
$router.push(`/contents/${row.id}/edit`)"
+ class="dashboard-table"
+ >
+
+
+ {{ row.title }}
+
+
+
+
+
+
+ {{ workStatusLabel(row.work_status) }}
+
+
+
+
+ {{ formatDate(row.created_at) }}
+
+
+
+
+
-
+
-
暂无内容
-
-
-
-
-
-
-
{{ item.title }}
-
- {{ statusLabel(item.status) }}
- {{ workStatusLabel(item.work_status) }}
- ·
- {{ item.owner_name }}
- {{ formatDate(item.created_at) }}
-
-
-
+
近期活动
+
查看全部 →
+
暂无近期活动
+
$router.push(`/events/${row.id}`)"
+ class="dashboard-table"
+ >
+
+
+ {{ row.title }}
+
+
+
+
+ {{ eventStatusLabel(row.status) }}
+
+
+
+ {{ row.planned_at ? formatDate(row.planned_at) : '—' }}
+
+
@@ -228,27 +228,32 @@
近期无会议安排
-
-
-
-
{{ formatDay(m.scheduled_at) }}
-
{{ formatMonth(m.scheduled_at) }}
-
-
-
{{ m.title }}
-
-
- {{ m.committee_name }}
+
$router.push(`/meetings/${row.id}`)"
+ class="dashboard-table"
+ >
+
+
+
+
{{ formatDay(row.scheduled_at) }}
+
{{ formatMonth(row.scheduled_at) }}
-
-
{{ formatTime(m.scheduled_at) }}
-
-
+
+
+
+
+ {{ row.title }}
+
+
+
+
+ {{ formatTime(row.scheduled_at) }}
+
+
@@ -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 @@
-
+
-
- {{ (row as any).assignee_ids?.length || 0 }}
+
+ {{ name }}
+
+ +{{ row.assignee_names.length - 2 }}
+
+
+ —
@@ -128,7 +137,7 @@