diff --git a/backend/app/routers/api.py b/backend/app/routers/api.py index 7fbec0f..97cac1f 100644 --- a/backend/app/routers/api.py +++ b/backend/app/routers/api.py @@ -372,6 +372,20 @@ async def meetup_session(meetup_id: str, authorization: str | None = Header(None if mode not in ("online", "hybrid"): can_join = False lock = lock or "offline" + elif can_join: + # Live rooms need login + RSVP (organizer exempt) + if not user: + can_join = False + lock = "login_required" + else: + organizer_id = meetup.get("organizer_id") or "" + is_host = bool(organizer_id) and organizer_id == user["id"] + if not is_host: + rsvps = community_store.user_rsvp_ids(user["id"]) + mid = meetup.get("id") or meetup_id + if mid not in rsvps and meetup_id not in rsvps: + can_join = False + lock = "rsvp_required" session = MeetupSession( canJoin=can_join, diff --git a/frontend/src/app/changelog/page.tsx b/frontend/src/app/changelog/page.tsx index a4c5e16..1c1001e 100644 --- a/frontend/src/app/changelog/page.tsx +++ b/frontend/src/app/changelog/page.tsx @@ -8,6 +8,14 @@ export const metadata: Metadata = { }; const LOGS = [ + { + date: "2026-09-04", + tag: "直播报名 · 私信角标", + items: [ + "线上直播需登录并报名后进入;支持一键报名进房;显示活动时间;手机默认视频布局", + "右上角私信图标显示未读数", + ], + }, { date: "2026-09-04", tag: "直播 iframe 修复", diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 0e5af84..ed49ecd 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -11057,10 +11057,24 @@ a.weather-card:hover { font-size: 0.8rem; color: var(--text-muted); } +.live-schedule-banner, +.live-schedule { + padding: 8px 20px; + font-size: 0.85rem; + color: var(--text-muted); + background: var(--bg-card); + border-bottom: var(--border-glass); +} +.live-schedule { + padding: 0; + border: none; + background: transparent; + margin-bottom: 0.5rem; +} .live-stage { flex: 1; display: grid; - min-height: calc(100vh - 110px); + min-height: 0; } .live-stage.layout-split { grid-template-columns: 1fr 380px; } .live-stage.layout-video { grid-template-columns: 1fr; } @@ -11202,10 +11216,14 @@ a.weather-card:hover { width: 100%; justify-content: flex-start; } + .live-external-links { + padding: 8px 12px; + } .live-stage { - min-height: calc(100vh - 110px); + min-height: 50vh; } .live-stage.layout-split { grid-template-columns: 1fr; } + .live-iframe { min-height: 50vh; } .live-iframe { min-height: 280px; } .live-chat { border-left: none; border-top: var(--border-glass); min-height: 280px; } } diff --git a/frontend/src/app/meetups/[id]/live/page.tsx b/frontend/src/app/meetups/[id]/live/page.tsx index dc94839..78ec0aa 100644 --- a/frontend/src/app/meetups/[id]/live/page.tsx +++ b/frontend/src/app/meetups/[id]/live/page.tsx @@ -14,7 +14,12 @@ export default async function MeetupLivePage({ params }: { params: Promise<{ id: return ( - + ); } diff --git a/frontend/src/components/MeetupLiveClient.tsx b/frontend/src/components/MeetupLiveClient.tsx index f1240ca..0a843b0 100644 --- a/frontend/src/components/MeetupLiveClient.tsx +++ b/frontend/src/components/MeetupLiveClient.tsx @@ -5,19 +5,45 @@ import Link from "next/link"; import { api } from "@/lib/api"; import { useAuth } from "@/lib/auth"; import { useI18n } from "@/lib/i18n"; +import { useToast } from "@/lib/toast"; import type { MeetupSession } from "@/lib/types"; import RingNext from "@/components/RingNext"; import { useRingSteps } from "@/lib/rings"; -export default function MeetupLiveClient({ meetupId, title }: { meetupId: string; title: string }) { +type Layout = "split" | "video" | "chat"; + +function defaultLayout(): Layout { + if (typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches) { + return "video"; + } + return "split"; +} + +export default function MeetupLiveClient({ + meetupId, + title, + date = "", + time = "", +}: { + meetupId: string; + title: string; + date?: string; + time?: string; +}) { const { token } = useAuth(); + const { toast } = useToast(); const { t } = useI18n(); const rings = useRingSteps(); const [session, setSession] = useState(null); const [error, setError] = useState(false); - const [layout, setLayout] = useState<"split" | "video" | "chat">("split"); + const [layout, setLayout] = useState("split"); + const [rsvping, setRsvping] = useState(false); useEffect(() => { + setLayout(defaultLayout()); + }, []); + + const loadSession = () => { setError(false); setSession(null); api @@ -27,8 +53,29 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string setSession(null); setError(true); }); + }; + + useEffect(() => { + loadSession(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [meetupId, token]); + const handleRsvpAndJoin = async () => { + if (!token) { + return; + } + setRsvping(true); + try { + await api.rsvpMeetup(meetupId, undefined, token); + toast(t.meetups.rsvpOk, "success"); + loadSession(); + } catch { + toast(t.meetups.rsvpFail, "error"); + } finally { + setRsvping(false); + } + }; + if (error) { return (
@@ -64,26 +111,45 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string ? t.live.hostRequired : reason === "offline" ? t.live.offlineOnly - : t.live.loginRequired; - const href = - reason === "vip_required" - ? "/join" - : reason === "offline" - ? "/meetups" - : `/login?next=/meetups/${meetupId}/live`; - const label = - reason === "vip_required" ? t.join.payBtn : reason === "offline" ? t.live.back : t.nav.login; + : reason === "rsvp_required" + ? t.live.rsvpRequired + : t.live.loginRequired; return (

{title}

+ {(date || time) && ( +

+ 🗓️ {date} + {time ? ` ${time}` : ""} +

+ )}

{copy}

- - {label} - + {reason === "rsvp_required" && token ? ( + + ) : reason === "vip_required" ? ( + + {t.join.payBtn} + + ) : reason === "offline" ? ( + + {t.live.back} + + ) : ( + + {t.nav.login} + + )} {t.live.back} @@ -94,6 +160,11 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string ); } + const scheduleLabel = + date || time + ? t.live.startsAt.replace("{when}", `${date}${time ? ` ${time}` : ""}`.trim()) + : ""; + return (
@@ -112,6 +183,7 @@ export default function MeetupLiveClient({ meetupId, title }: { meetupId: string ))}
+ {scheduleLabel &&
{scheduleLabel}
}
{session.videoUrl && ( diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index edd83f9..aa48839 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -20,15 +20,21 @@ export default function Navbar() { const [openGroup, setOpenGroup] = useState(null); const [active, setActive] = useState(""); const [unread, setUnread] = useState(0); + const [chatUnread, setChatUnread] = useState(0); const navRef = useRef(null); useEffect(() => { if (!token) { setUnread(0); + setChatUnread(0); return; } const refresh = () => { api.getNotificationUnread(token).then((r) => setUnread(r.count)).catch(() => setUnread(0)); + api + .getConversations(token) + .then((list) => setChatUnread(list.reduce((s, c) => s + (c.unreadCount || 0), 0))) + .catch(() => setChatUnread(0)); }; refresh(); const id = window.setInterval(refresh, 60000); @@ -235,7 +241,7 @@ export default function Navbar() { {user ? ( - ✉️ + ✉️{chatUnread > 0 && {chatUnread}} ) : (