feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ROOM_USER_SPEAKER_ID,
|
||||
endRoom,
|
||||
fetchExpression,
|
||||
getPossessionStatus,
|
||||
getRoom,
|
||||
getRoomUsage,
|
||||
listCharacters,
|
||||
reportRoomRead,
|
||||
roomStreamUrl,
|
||||
sendRoomMessage,
|
||||
setRoomMuted,
|
||||
speakAsPossessed,
|
||||
synthesizeVoice,
|
||||
type CharacterSummary,
|
||||
type PossessionStatus,
|
||||
type RoomDetail,
|
||||
type RoomTurnKind,
|
||||
type RoomUsageSummary,
|
||||
type TachieExpressionResponse,
|
||||
} from "../../../lib/api";
|
||||
import { ChatBubble } from "../../../components/ChatBubble";
|
||||
import { RoomStage, type StageParticipant } from "../../../components/RoomStage";
|
||||
import { isVoiceAutoplayEnabled } from "../../../lib/voice-autoplay-settings";
|
||||
|
||||
// T19-P3:TALK 是一般台詞(既有 ChatBubble 樣式),ACTION 沿用 H-3 的*…*敘述樣式,其餘三種另外標籤。
|
||||
const TURN_KIND_LABEL: Partial<Record<RoomTurnKind, string>> = {
|
||||
DELEGATE: "委託",
|
||||
LEAVE: "離開",
|
||||
SYSTEM: "系統",
|
||||
};
|
||||
|
||||
const USER_ID = "seed-user-primary";
|
||||
const USER_LABEL = "你";
|
||||
|
||||
// T26:佇列積壓過長(使用者離開視窗一段時間後回來,或連續好幾則自走發言)時,只留最近幾則待播,
|
||||
// 較舊的直接跳過——語音是「聽個氣氛」,不需要也不該補播全部積壓內容。
|
||||
const VOICE_QUEUE_MAX_BACKLOG = 3;
|
||||
// 首次解鎖語音時,在使用者手勢內播放一次極短靜音音檔,換取瀏覽器日後允許非手勢觸發的自動播放。
|
||||
const SILENT_AUDIO_DATA_URI =
|
||||
"data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEAQB8AAEAfAAABAAgAAABmYWN0BAAAAAAAAABkYXRhAAAAAA==";
|
||||
|
||||
interface VoiceQueueItem {
|
||||
characterId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type FeedItem =
|
||||
| { kind: "turn"; seq: number; speakerId: string; speakerLabel: string; content: string; turnKind: RoomTurnKind }
|
||||
| { kind: "member"; seq: number; characterId: string; action: "joined" | "left" }
|
||||
| { kind: "closed"; seq: number; reason: string };
|
||||
|
||||
interface RoomChatViewProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
// T19-P2:通用房間畫面——取代舊版 RoomView/SoloChatView 只能各自對應建房表單或單一固定角色的
|
||||
// 限制,改為接受大廳呼叫(T5)或既有房間清單開出的任意 roomId,統一走 SSE(T10)即時接收新訊息、
|
||||
// 並在第一次載入時依 unread(T11)畫出未讀分隔線;分隔線位置在整個瀏覽階段內固定,不隨後續已讀
|
||||
// 狀態更新而移動(符合一般聊天 App「離開再進來才會重新定位」的直覺)。
|
||||
export function RoomChatView({ roomId }: RoomChatViewProps) {
|
||||
const [room, setRoom] = useState<RoomDetail | null>(null);
|
||||
const [characters, setCharacters] = useState<CharacterSummary[]>([]);
|
||||
const [feed, setFeed] = useState<FeedItem[]>([]);
|
||||
const [firstUnreadSeq, setFirstUnreadSeq] = useState<number | null>(null);
|
||||
const [ended, setEnded] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const feedEndRef = useRef<HTMLDivElement | null>(null);
|
||||
// T19-P5:套用中時,這個房間的輸入區要換成「以套用角色的身分發話」,不提供以自己身分發話的選項。
|
||||
const [possession, setPossession] = useState<PossessionStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getPossessionStatus(USER_ID).then(setPossession).catch(() => undefined);
|
||||
}, [roomId]);
|
||||
|
||||
// T19-P3:立繪舞台(目前發話者高亮)與用量顯示。
|
||||
const [expressions, setExpressions] = useState<Record<string, TachieExpressionResponse | null>>({});
|
||||
const [speakingCharacterId, setSpeakingCharacterId] = useState<string | null>(null);
|
||||
const [usage, setUsage] = useState<RoomUsageSummary | null>(null);
|
||||
|
||||
function refreshExpression(characterId: string) {
|
||||
fetchExpression(characterId, USER_ID)
|
||||
.then((expression) => setExpressions((prev) => ({ ...prev, [characterId]: expression })))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
// T26:自走語音自動播放——佇列、播放中旗標、解鎖狀態一律用 ref,避免 SSE handler(只在 roomId
|
||||
// 變化時重新註冊)讀到註冊當下就固定住的舊 state(stale closure)。
|
||||
const [autoplayEnabled, setAutoplayEnabled] = useState(true);
|
||||
const autoplayEnabledRef = useRef(true);
|
||||
const [unlocked, setUnlocked] = useState(false);
|
||||
const unlockedRef = useRef(false);
|
||||
const voiceQueueRef = useRef<VoiceQueueItem[]>([]);
|
||||
const voicePlayingRef = useRef(false);
|
||||
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const voiceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const unmountedRef = useRef(false);
|
||||
// T31:靜音時不推播、不自動播語音,但自走照常進行——同樣用 ref 供 SSE handler 讀最新值。
|
||||
const mutedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
const value = isVoiceAutoplayEnabled();
|
||||
autoplayEnabledRef.current = value;
|
||||
setAutoplayEnabled(value);
|
||||
};
|
||||
sync();
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unmountedRef.current = true;
|
||||
currentAudioRef.current?.pause();
|
||||
if (voiceTimerRef.current) clearTimeout(voiceTimerRef.current);
|
||||
voiceQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
function unlockVoiceAutoplay() {
|
||||
const unlockAudio = new Audio(SILENT_AUDIO_DATA_URI);
|
||||
void unlockAudio.play().catch(() => undefined);
|
||||
unlockedRef.current = true;
|
||||
setUnlocked(true);
|
||||
void processVoiceQueue();
|
||||
}
|
||||
|
||||
function enqueueVoice(item: VoiceQueueItem) {
|
||||
if (!autoplayEnabledRef.current || !unlockedRef.current || mutedRef.current) return;
|
||||
voiceQueueRef.current.push(item);
|
||||
if (voiceQueueRef.current.length > VOICE_QUEUE_MAX_BACKLOG) {
|
||||
voiceQueueRef.current.splice(0, voiceQueueRef.current.length - VOICE_QUEUE_MAX_BACKLOG);
|
||||
}
|
||||
void processVoiceQueue();
|
||||
}
|
||||
|
||||
async function processVoiceQueue() {
|
||||
if (voicePlayingRef.current || unmountedRef.current) return;
|
||||
const next = voiceQueueRef.current.shift();
|
||||
if (!next) return;
|
||||
voicePlayingRef.current = true;
|
||||
try {
|
||||
const result = await synthesizeVoice(next.characterId, USER_ID, next.text);
|
||||
if (unmountedRef.current) return;
|
||||
if (result.audioRef.startsWith("mock://")) {
|
||||
await new Promise<void>((resolve) => {
|
||||
voiceTimerRef.current = setTimeout(resolve, Math.max(result.durationEstimateMs, 300));
|
||||
});
|
||||
} else {
|
||||
await new Promise<void>((resolve) => {
|
||||
const audio = new Audio(result.audioRef);
|
||||
currentAudioRef.current = audio;
|
||||
audio.addEventListener("ended", () => resolve());
|
||||
audio.addEventListener("error", () => resolve());
|
||||
void audio.play().catch(() => resolve());
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 合成失敗:安靜跳過這一則,不阻斷佇列後續播放、不影響訊息本身顯示。
|
||||
} finally {
|
||||
currentAudioRef.current = null;
|
||||
voicePlayingRef.current = false;
|
||||
if (!unmountedRef.current) void processVoiceQueue();
|
||||
}
|
||||
}
|
||||
|
||||
function characterLabel(characterId: string): string {
|
||||
if (characterId === ROOM_USER_SPEAKER_ID) return USER_LABEL;
|
||||
return characters.find((c) => c.id === characterId)?.aliases[0]?.formalName ?? characterId;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void listCharacters().then(setCharacters);
|
||||
}, []);
|
||||
|
||||
// 初次載入:抓歷程+unread,畫面就緒後才開 SSE(避免歷程還沒到就先收到即時事件而重複或錯序)。
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let source: EventSource | null = null;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const detail = await getRoom(roomId);
|
||||
if (cancelled) return;
|
||||
setRoom(detail);
|
||||
mutedRef.current = detail.muted;
|
||||
setEnded(detail.status === "ENDED");
|
||||
setFirstUnreadSeq(detail.unread.firstUnreadSeq);
|
||||
setUsage(detail.usage);
|
||||
setFeed(
|
||||
detail.turns.map((turn) => ({
|
||||
kind: "turn",
|
||||
seq: turn.seq,
|
||||
speakerId: turn.speakerId,
|
||||
speakerLabel: turn.speakerLabel,
|
||||
content: turn.content,
|
||||
turnKind: turn.kind,
|
||||
})),
|
||||
);
|
||||
for (const characterId of detail.participants) refreshExpression(characterId);
|
||||
const lastTurn = detail.turns.at(-1);
|
||||
if (lastTurn && lastTurn.speakerId !== ROOM_USER_SPEAKER_ID) setSpeakingCharacterId(lastTurn.speakerId);
|
||||
|
||||
const lastSeq = detail.turns.length > 0 ? detail.turns[detail.turns.length - 1].seq : -1;
|
||||
source = new EventSource(roomStreamUrl(roomId, lastSeq));
|
||||
source.addEventListener("turn", (event) => {
|
||||
const messageEvent = event as MessageEvent<string>;
|
||||
const data = JSON.parse(messageEvent.data) as {
|
||||
speakerId: string;
|
||||
speakerLabel: string;
|
||||
content: string;
|
||||
kind: RoomTurnKind;
|
||||
};
|
||||
const { kind: turnKind, ...rest } = data;
|
||||
const seq = Number(messageEvent.lastEventId);
|
||||
setFeed((prev) =>
|
||||
prev.some((item) => item.kind === "turn" && item.seq === seq) ? prev : [...prev, { kind: "turn", seq, turnKind, ...rest }],
|
||||
);
|
||||
void reportRoomRead(roomId, USER_ID, seq).catch(() => undefined);
|
||||
// T26:只有角色的新發言才自動播放,使用者自己的發言不需要念給自己聽。
|
||||
if (data.speakerId !== ROOM_USER_SPEAKER_ID) {
|
||||
enqueueVoice({ characterId: data.speakerId, text: data.content });
|
||||
// T19-P3:新發言者上場——更新立繪舞台的高亮對象與表情,並同步刷新用量顯示。
|
||||
setSpeakingCharacterId(data.speakerId);
|
||||
refreshExpression(data.speakerId);
|
||||
void getRoomUsage(roomId).then(setUsage).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
source.addEventListener("member", (event) => {
|
||||
const messageEvent = event as MessageEvent<string>;
|
||||
const data = JSON.parse(messageEvent.data) as { characterId: string; action: "joined" | "left" };
|
||||
const seq = Number(messageEvent.lastEventId);
|
||||
setFeed((prev) => [...prev, { kind: "member", seq, ...data }]);
|
||||
});
|
||||
source.addEventListener("closed", (event) => {
|
||||
const messageEvent = event as MessageEvent<string>;
|
||||
const data = JSON.parse(messageEvent.data) as { reason: string };
|
||||
const seq = Number(messageEvent.lastEventId);
|
||||
setFeed((prev) => [...prev, { kind: "closed", seq, reason: data.reason }]);
|
||||
setEnded(true);
|
||||
});
|
||||
} catch {
|
||||
if (!cancelled) setError("房間查詢失敗,請確認 api 服務是否已啟動");
|
||||
}
|
||||
}
|
||||
|
||||
void init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
source?.close();
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
feedEndRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [feed.length]);
|
||||
|
||||
async function handleSend() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || pending || ended) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (possessedCharacterId) {
|
||||
await speakAsPossessed(roomId, USER_ID, trimmed);
|
||||
} else {
|
||||
await sendRoomMessage(roomId, USER_ID, USER_LABEL, trimmed);
|
||||
}
|
||||
setText(""); // 新訊息透過 SSE 推送進 feed,這裡不需要也不應該自己 append
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "發言失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
if (!room || pending) return;
|
||||
const next = !room.muted;
|
||||
try {
|
||||
await setRoomMuted(roomId, next);
|
||||
mutedRef.current = next;
|
||||
setRoom((prev) => (prev ? { ...prev, muted: next } : prev));
|
||||
} catch {
|
||||
setError("靜音設定失敗,請確認 api 服務是否已啟動");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnd() {
|
||||
if (pending || ended) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await endRoom(roomId);
|
||||
setEnded(true);
|
||||
} catch {
|
||||
setError("結束房間失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !room) {
|
||||
return (
|
||||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!room) {
|
||||
return (
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
載入中……
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// T19-P5:只有「套用中且她本人就在這個房間」才切換成以她的身分發話;套用中但她不在這裡時,
|
||||
// 一般發話入口一律不提供(後端本來就會擋,這裡先不讓使用者白打一輪)。
|
||||
const possessedElsewhere = Boolean(possession?.possessingCharacterId) && !room.participants.includes(possession!.possessingCharacterId!);
|
||||
const possessedCharacterId =
|
||||
possession?.possessingCharacterId && room.participants.includes(possession.possessingCharacterId)
|
||||
? possession.possessingCharacterId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
<span>
|
||||
{room.mode}.{ended ? "已結束" : "進行中"}.參與者:{room.participants.map((id) => characterLabel(id)).join("、") || "(無)"}
|
||||
{usage && usage.generationCount > 0 && (
|
||||
<>
|
||||
.用量 {usage.totalPromptTokens + usage.totalCompletionTokens} tokens({usage.generationCount} 次生成)
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleToggleMute()}
|
||||
disabled={ended}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
{room.muted ? "🔇 已靜音" : "🔊 靜音"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnd()}
|
||||
disabled={pending || ended}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
結束房間
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RoomStage
|
||||
participants={room.participants.map<StageParticipant>((characterId) => ({
|
||||
characterId,
|
||||
label: characterLabel(characterId),
|
||||
expression: expressions[characterId] ?? null,
|
||||
}))}
|
||||
speakingCharacterId={speakingCharacterId}
|
||||
/>
|
||||
|
||||
{autoplayEnabled && !unlocked && !room.muted && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={unlockVoiceAutoplay}
|
||||
className="rounded-2xl border px-3 py-2 text-left text-xs"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
🔊 點擊開啟自走語音自動播放(瀏覽器需要先有一次互動才能自動播放聲音)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
|
||||
{feed.length === 0 && (
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
還沒有人說話。
|
||||
</p>
|
||||
)}
|
||||
{feed.map((item) => (
|
||||
<div key={`${item.kind}-${item.seq}`} className="flex flex-col gap-2">
|
||||
{firstUnreadSeq !== null && item.seq === firstUnreadSeq && (
|
||||
<div className="flex items-center gap-2 text-[10px]" style={{ color: "var(--accent)" }}>
|
||||
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
|
||||
未讀訊息
|
||||
<span className="h-px flex-1" style={{ background: "var(--accent)" }} />
|
||||
</div>
|
||||
)}
|
||||
{item.kind === "turn" && (
|
||||
<div className="flex flex-col" style={{ alignItems: item.speakerId === ROOM_USER_SPEAKER_ID ? "flex-end" : "flex-start" }}>
|
||||
{TURN_KIND_LABEL[item.turnKind] && (
|
||||
<span
|
||||
className="mb-0.5 rounded-full px-2 py-0.5 text-[10px]"
|
||||
style={{ background: "var(--chip-bg)", color: "var(--accent)" }}
|
||||
>
|
||||
{TURN_KIND_LABEL[item.turnKind]}
|
||||
</span>
|
||||
)}
|
||||
<ChatBubble
|
||||
role={item.speakerId === ROOM_USER_SPEAKER_ID ? "user" : "character"}
|
||||
text={item.content}
|
||||
characterId={item.speakerId !== ROOM_USER_SPEAKER_ID ? item.speakerId : undefined}
|
||||
userId={item.speakerId !== ROOM_USER_SPEAKER_ID ? USER_ID : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{item.kind === "member" && (
|
||||
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
|
||||
{characterLabel(item.characterId)} {item.action === "joined" ? "進來了" : "離開了"}
|
||||
</p>
|
||||
)}
|
||||
{item.kind === "closed" && (
|
||||
<p className="self-center text-[10px]" style={{ color: "var(--ink-soft)" }}>
|
||||
房間已關閉
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={feedEndRef} />
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!ended && possessedCharacterId && (
|
||||
<p className="rounded-full px-3 py-1 text-center text-xs font-bold" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
|
||||
你正以「{characterLabel(possessedCharacterId)}」的身分發話
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!ended && possessedElsewhere && (
|
||||
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
|
||||
套用中的角色不在這個房間,無法在此發話——請到她所在的房間,或先退出套用。
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!ended && !possessedElsewhere && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSend();
|
||||
}}
|
||||
placeholder={possessedCharacterId ? `以「${characterLabel(possessedCharacterId)}」的身分說點什麼……` : "說點什麼……"}
|
||||
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={pending || !text.trim()}
|
||||
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
送出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { RoomChatView } from "./RoomChatView";
|
||||
|
||||
export default async function RoomByIdPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||
const { roomId } = await params;
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
|
||||
房間
|
||||
</h1>
|
||||
<Link href="/lobby" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
回到大廳
|
||||
</Link>
|
||||
</div>
|
||||
<RoomChatView roomId={roomId} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user