fix: 暂存
This commit is contained in:
@@ -6,16 +6,16 @@ import { useAccountStore } from '@/stores/modules/account'
|
|||||||
/**
|
/**
|
||||||
* SSE 连接管理 composable(基于 @microsoft/fetch-event-source)
|
* SSE 连接管理 composable(基于 @microsoft/fetch-event-source)
|
||||||
*
|
*
|
||||||
* 登录后调用 connect() 建立全局唯一的 SSE 长连接。
|
* 设计原则:
|
||||||
* 收到服务端事件后解析 JSON 并通过 sseBus 分发给订阅者。
|
* - 全局唯一:同一时间只有一条活跃的 SSE 连接
|
||||||
*
|
* - token 驱动:仅 accessToken 变更时才断开重连
|
||||||
* 鉴权:通过 fetch-event-source 的 headers 传 saToken,和 axios 走同一套机制。
|
* - 防重复:已连接(相同 token)时 connect() 直接跳过
|
||||||
* 401 时精确读取 err.response.status 触发 token 刷新,刷新成功自动重连。
|
* - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅
|
||||||
*
|
*
|
||||||
* 使用示例:
|
* 使用示例:
|
||||||
* ```ts
|
* ```ts
|
||||||
* const { isConnected, connect, disconnect } = useSse()
|
* const { isConnected, connect, disconnect } = useSse()
|
||||||
* watch(() => userStore.accessToken, (v) => v ? connect() : disconnect())
|
* watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect())
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useSse() {
|
export function useSse() {
|
||||||
@@ -23,6 +23,10 @@ export function useSse() {
|
|||||||
const isConnected = ref(false)
|
const isConnected = ref(false)
|
||||||
/** 用于取消 inflight 连接的控制器 */
|
/** 用于取消 inflight 连接的控制器 */
|
||||||
let abortController: AbortController | null = null
|
let abortController: AbortController | null = null
|
||||||
|
/** 当前连接使用的 token(用于判断是否需要重连) */
|
||||||
|
let currentToken: string | null = null
|
||||||
|
/** 是否正在建立连接(防并发) */
|
||||||
|
let connecting = false
|
||||||
/** 当前连接的重试计数 */
|
/** 当前连接的重试计数 */
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
/** 是否正在刷新 token(防并发) */
|
/** 是否正在刷新 token(防并发) */
|
||||||
@@ -33,6 +37,8 @@ export function useSse() {
|
|||||||
*
|
*
|
||||||
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
|
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
|
||||||
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
|
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
|
||||||
|
*
|
||||||
|
* 防重复:如果已用相同 token 连接或正在连接中,直接跳过。
|
||||||
*/
|
*/
|
||||||
async function connect(): Promise<void> {
|
async function connect(): Promise<void> {
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
@@ -42,9 +48,16 @@ export function useSse() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消上一次连接
|
// 相同 token 已连接或正在连接中,跳过
|
||||||
|
if (currentToken === token && (isConnected.value || connecting)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取消旧连接
|
||||||
abort()
|
abort()
|
||||||
|
|
||||||
|
connecting = true
|
||||||
|
currentToken = token
|
||||||
abortController = new AbortController()
|
abortController = new AbortController()
|
||||||
|
|
||||||
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
|
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
|
||||||
@@ -54,6 +67,7 @@ export function useSse() {
|
|||||||
|
|
||||||
/** 连接建立 */
|
/** 连接建立 */
|
||||||
async onopen() {
|
async onopen() {
|
||||||
|
connecting = false
|
||||||
isConnected.value = true
|
isConnected.value = true
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
console.log('[SSE] 连接已建立')
|
console.log('[SSE] 连接已建立')
|
||||||
@@ -75,6 +89,7 @@ export function useSse() {
|
|||||||
|
|
||||||
/** 连接关闭 */
|
/** 连接关闭 */
|
||||||
onclose() {
|
onclose() {
|
||||||
|
connecting = false
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
console.log('[SSE] 连接已关闭')
|
console.log('[SSE] 连接已关闭')
|
||||||
},
|
},
|
||||||
@@ -87,6 +102,7 @@ export function useSse() {
|
|||||||
* 其他错误 → 返回重试延迟(ms),库自动排队重连
|
* 其他错误 → 返回重试延迟(ms),库自动排队重连
|
||||||
*/
|
*/
|
||||||
onerror(err) {
|
onerror(err) {
|
||||||
|
connecting = false
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
|
|
||||||
// 鉴权失败 → 刷新 token(异步 fire-and-forget,onerror 本身保持同步)
|
// 鉴权失败 → 刷新 token(异步 fire-and-forget,onerror 本身保持同步)
|
||||||
@@ -135,7 +151,7 @@ export function useSse() {
|
|||||||
throw err // 超过阈值,停止重连
|
throw err // 超过阈值,停止重连
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回重试间隔(ms),指数退避:3s, 6s, 12s, 24s
|
// 返回重试间隔(ms),指数退避:3s, 6s, 12s, 24s,最大 30s
|
||||||
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
|
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
|
||||||
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
|
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
|
||||||
return delay
|
return delay
|
||||||
@@ -151,7 +167,9 @@ export function useSse() {
|
|||||||
abortController.abort()
|
abortController.abort()
|
||||||
abortController = null
|
abortController = null
|
||||||
}
|
}
|
||||||
|
connecting = false
|
||||||
isConnected.value = false
|
isConnected.value = false
|
||||||
|
currentToken = null
|
||||||
retryCount = 0
|
retryCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ export function createRoomSocket(
|
|||||||
let intentionalClose = false
|
let intentionalClose = false
|
||||||
/** 重连定时器 ID,用于组件卸载时取消 */
|
/** 重连定时器 ID,用于组件卸载时取消 */
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
/** 消息发送队列(连接未就绪时暂存) */
|
||||||
|
let messageQueue: Array<{ type: string; data?: unknown }> = []
|
||||||
|
/** 最大排队消息数 */
|
||||||
|
const MAX_QUEUE_SIZE = 50
|
||||||
|
|
||||||
/** 建立 WebSocket 连接 */
|
/** 建立 WebSocket 连接 */
|
||||||
function connect(): void {
|
function connect(): void {
|
||||||
@@ -68,6 +72,12 @@ export function createRoomSocket(
|
|||||||
ws = new WebSocket(wsUrl)
|
ws = new WebSocket(wsUrl)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
// 用户已离开,直接关闭连接
|
||||||
|
if (intentionalClose) {
|
||||||
|
ws?.close(1000, '用户已离开')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log('[WS] 房间连接已建立, roomId=', roomId)
|
console.log('[WS] 房间连接已建立, roomId=', roomId)
|
||||||
if (retryCount > 0) {
|
if (retryCount > 0) {
|
||||||
@@ -79,6 +89,9 @@ export function createRoomSocket(
|
|||||||
} else {
|
} else {
|
||||||
callbacks.onOpen?.()
|
callbacks.onOpen?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 刷新排队中的消息
|
||||||
|
flushQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.onclose = (event) => {
|
ws.onclose = (event) => {
|
||||||
@@ -135,10 +148,31 @@ export function createRoomSocket(
|
|||||||
// 初始连接
|
// 初始连接
|
||||||
connect()
|
connect()
|
||||||
|
|
||||||
|
/** 刷新消息发送队列 */
|
||||||
|
function flushQueue(): void {
|
||||||
|
if (messageQueue.length === 0) return
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`[WS] 刷新排队消息, count=${messageQueue.length}`)
|
||||||
|
const pending = messageQueue
|
||||||
|
messageQueue = []
|
||||||
|
for (const msg of pending) {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
send(type: string, data?: unknown) {
|
send(type: string, data?: unknown) {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify({ type, data }))
|
ws.send(JSON.stringify({ type, data }))
|
||||||
|
} else if (ws && ws.readyState === WebSocket.CONNECTING) {
|
||||||
|
// 连接尚未就绪,排队等待
|
||||||
|
if (messageQueue.length < MAX_QUEUE_SIZE) {
|
||||||
|
messageQueue.push({ type, data })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`[WS] 消息未发送(连接未就绪), type=${type}, readyState=${ws?.readyState ?? 'null'}`)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
@@ -149,8 +183,10 @@ export function createRoomSocket(
|
|||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
}
|
}
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close(1000, '用户离开')
|
// 仅 OPEN 状态可安全关闭,CONNECTING 时由 onopen 处理
|
||||||
ws = null
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close(1000, '用户离开')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isOpen() {
|
isOpen() {
|
||||||
|
|||||||
@@ -101,7 +101,20 @@ export const useRoomStore = defineStore('room', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 添加聊天消息 */
|
/** 添加聊天消息 */
|
||||||
|
/** 添加聊天消息(自动去重:同用户同内容 2 秒内只保留一条) */
|
||||||
function addMessage(msg: ChatMessage) {
|
function addMessage(msg: ChatMessage) {
|
||||||
|
// 去重:检查最后一条消息是否与即将添加的相同
|
||||||
|
const last = messages.value[messages.value.length - 1]
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
last.userId === msg.userId &&
|
||||||
|
last.content === msg.content &&
|
||||||
|
Math.abs(last.timestamp - msg.timestamp) < 2000
|
||||||
|
) {
|
||||||
|
// 用后端的时间戳更新(更准确),其它不变
|
||||||
|
last.timestamp = msg.timestamp
|
||||||
|
return
|
||||||
|
}
|
||||||
messages.value.push(msg)
|
messages.value.push(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const canvasSize = PADDING * 2 + CELL_SIZE * (BOARD_SIZE - 1)
|
|||||||
|
|
||||||
const store = useRoomStore()
|
const store = useRoomStore()
|
||||||
const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store)
|
const { boardState, currentTurn, currentUserId, isPlayer, isPlaying } = storeToRefs(store)
|
||||||
const socket = inject<RoomSocket>('roomSocket', null)!
|
const socket = inject<RoomSocket>('roomSocket')!
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||||
const hoverPos = ref<{ row: number; col: number } | null>(null)
|
const hoverPos = ref<{ row: number; col: number } | null>(null)
|
||||||
@@ -101,8 +101,9 @@ function drawBoard() {
|
|||||||
if (board) {
|
if (board) {
|
||||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
for (let r = 0; r < BOARD_SIZE; r++) {
|
||||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
for (let c = 0; c < BOARD_SIZE; c++) {
|
||||||
if (board[r] && board[r][c]) {
|
const stone = board[r]?.[c]
|
||||||
drawStone(ctx, r, c, board[r][c] === 1 ? '#1a1a1a' : '#f5f5f5')
|
if (stone) {
|
||||||
|
drawStone(ctx, r, c, stone === 1 ? '#1a1a1a' : '#f5f5f5')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +124,7 @@ function drawBoard() {
|
|||||||
// ---- 最后一手标记(红色圆圈) ----
|
// ---- 最后一手标记(红色圆圈) ----
|
||||||
const moves = store.moves
|
const moves = store.moves
|
||||||
if (moves.length > 0) {
|
if (moves.length > 0) {
|
||||||
const lastMove = moves[moves.length - 1]
|
const lastMove = moves[moves.length - 1]!
|
||||||
const x = PADDING + lastMove.col * CELL_SIZE
|
const x = PADDING + lastMove.col * CELL_SIZE
|
||||||
const y = PADDING + lastMove.row * CELL_SIZE
|
const y = PADDING + lastMove.row * CELL_SIZE
|
||||||
ctx.strokeStyle = '#E74C3C'
|
ctx.strokeStyle = '#E74C3C'
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const { players, currentUserId } = storeToRefs(store)
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
/** 通过 inject 获取 WS 连接(父组件 provide) */
|
/** 通过 inject 获取 WS 连接(父组件 provide) */
|
||||||
const socket = inject<RoomSocket>('roomSocket', null)!
|
const socket = inject<RoomSocket>('roomSocket')!
|
||||||
|
|
||||||
/** 当前用户是否已准备 */
|
/** 当前用户是否已准备 */
|
||||||
const isReady = computed(() => {
|
const isReady = computed(() => {
|
||||||
|
|||||||
@@ -2,13 +2,21 @@
|
|||||||
<div class="room-chat">
|
<div class="room-chat">
|
||||||
<!-- 消息列表 -->
|
<!-- 消息列表 -->
|
||||||
<div ref="messagesContainer" class="room-chat__messages">
|
<div ref="messagesContainer" class="room-chat__messages">
|
||||||
<div v-for="(msg, idx) in store.messages" :key="idx" class="room-chat__msg">
|
<div
|
||||||
<span class="room-chat__msg-user">{{ msg.userId === store.currentUserId ? '我' : getUserName(msg.userId) }}</span>
|
v-for="(msg, idx) in store.messages"
|
||||||
<span class="room-chat__msg-text">{{ msg.content }}</span>
|
:key="idx"
|
||||||
</div>
|
class="room-chat__msg"
|
||||||
<div v-if="store.messages.length === 0" class="room-chat__empty">
|
:class="{ 'room-chat__msg--self': msg.userId === store.currentUserId }"
|
||||||
暂无消息
|
>
|
||||||
|
<div class="room-chat__msg-meta">
|
||||||
|
<span class="room-chat__msg-user">{{
|
||||||
|
msg.userId === store.currentUserId ? '我' : getUserName(msg.userId)
|
||||||
|
}}</span>
|
||||||
|
<span class="room-chat__msg-time">{{ formatTime(msg.timestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="room-chat__msg-bubble">{{ msg.content }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="store.messages.length === 0" class="room-chat__empty">暂无消息</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 输入区 -->
|
<!-- 输入区 -->
|
||||||
@@ -17,9 +25,15 @@
|
|||||||
v-model="input"
|
v-model="input"
|
||||||
class="room-chat__input"
|
class="room-chat__input"
|
||||||
placeholder="输入消息..."
|
placeholder="输入消息..."
|
||||||
maxlength="200"
|
:maxlength="CHAT_MAX_LENGTH"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="sendMessage"
|
||||||
/>
|
/>
|
||||||
|
<span
|
||||||
|
class="room-chat__char-count"
|
||||||
|
:class="{ 'room-chat__char-count--warn': input.length > CHAT_MAX_LENGTH * 0.8 }"
|
||||||
|
>
|
||||||
|
{{ input.length }}/{{ CHAT_MAX_LENGTH }}
|
||||||
|
</span>
|
||||||
<button class="room-chat__send-btn" @click="sendMessage">发送</button>
|
<button class="room-chat__send-btn" @click="sendMessage">发送</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,9 +45,12 @@ import { useRoomStore } from '@/stores'
|
|||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import type { RoomSocket } from '@/common/websocket/roomSocket'
|
import type { RoomSocket } from '@/common/websocket/roomSocket'
|
||||||
|
|
||||||
|
/** 聊天最大字符数(与后端 CHAT_MAX_LENGTH 保持一致) */
|
||||||
|
const CHAT_MAX_LENGTH = 500
|
||||||
|
|
||||||
const store = useRoomStore()
|
const store = useRoomStore()
|
||||||
const { messages, players } = storeToRefs(store)
|
const { messages, players } = storeToRefs(store)
|
||||||
const socket = inject<RoomSocket>('roomSocket', null)
|
const socket = inject<RoomSocket>('roomSocket')
|
||||||
|
|
||||||
/** 发送消息(空安全,socket 尚未建立时静默丢弃) */
|
/** 发送消息(空安全,socket 尚未建立时静默丢弃) */
|
||||||
function safeSend(type: string, data?: unknown) {
|
function safeSend(type: string, data?: unknown) {
|
||||||
@@ -48,19 +65,39 @@ const messagesContainer = ref<HTMLElement>()
|
|||||||
/**
|
/**
|
||||||
* 监听消息列表变化,自动滚动到底部
|
* 监听消息列表变化,自动滚动到底部
|
||||||
*/
|
*/
|
||||||
watch(() => messages.value.length, async () => {
|
watch(
|
||||||
await nextTick()
|
() => messages.value.length,
|
||||||
|
async () => {
|
||||||
|
await nextTick()
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 滚动到消息列表底部
|
||||||
|
*/
|
||||||
|
function scrollToBottom() {
|
||||||
if (messagesContainer.value) {
|
if (messagesContainer.value) {
|
||||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
/** 发送聊天消息 */
|
/** 发送聊天消息(乐观更新:先显示在本地,再通过 WS 发送) */
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
const content = input.value.trim()
|
const content = input.value.trim()
|
||||||
if (!content) return
|
if (!content || content.length > CHAT_MAX_LENGTH) return
|
||||||
safeSend('chat', { content })
|
|
||||||
|
// 乐观更新:立即添加到本地消息列表
|
||||||
|
const localMsg = {
|
||||||
|
userId: store.currentUserId,
|
||||||
|
content,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
store.addMessage(localMsg)
|
||||||
input.value = ''
|
input.value = ''
|
||||||
|
|
||||||
|
// 通过 WS 发送,广播回来自动去重
|
||||||
|
safeSend('chat', { content })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +107,20 @@ function sendMessage() {
|
|||||||
*/
|
*/
|
||||||
function getUserName(userId: string): string {
|
function getUserName(userId: string): string {
|
||||||
const player = players.value.find((p) => p.userId === userId)
|
const player = players.value.find((p) => p.userId === userId)
|
||||||
return player?.nickname ?? '未知用户'
|
return player?.nickname ?? '观战者'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化时间戳为 HH:mm
|
||||||
|
* @param ts 毫秒时间戳
|
||||||
|
* @returns 格式化后的时间字符串
|
||||||
|
*/
|
||||||
|
function formatTime(ts: number): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${hh}:${mm}`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -87,22 +137,63 @@ function getUserName(userId: string): string {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-chat__msg {
|
.room-chat__msg {
|
||||||
font-size: 13px;
|
display: flex;
|
||||||
line-height: 1.5;
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
max-width: 85%;
|
||||||
|
|
||||||
|
&--self {
|
||||||
|
align-self: flex-end;
|
||||||
|
|
||||||
|
.room-chat__msg-bubble {
|
||||||
|
background-color: var(--color-primary);
|
||||||
|
color: var(--text-inverse);
|
||||||
|
border-radius: 10px 10px 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chat__msg-meta {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(&--self) {
|
||||||
|
align-self: flex-start;
|
||||||
|
|
||||||
|
.room-chat__msg-bubble {
|
||||||
|
background-color: var(--bg-surface-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 10px 10px 10px 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chat__msg-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-chat__msg-user {
|
.room-chat__msg-user {
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-primary);
|
color: var(--text-secondary);
|
||||||
margin-right: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-chat__msg-text {
|
.room-chat__msg-time {
|
||||||
color: var(--text-primary);
|
font-size: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.room-chat__msg-bubble {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-chat__empty {
|
.room-chat__empty {
|
||||||
@@ -114,7 +205,8 @@ function getUserName(userId: string): string {
|
|||||||
|
|
||||||
.room-chat__input-area {
|
.room-chat__input-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
border-top: 1px solid var(--border-light);
|
border-top: 1px solid var(--border-light);
|
||||||
}
|
}
|
||||||
@@ -134,6 +226,18 @@ function getUserName(userId: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-chat__char-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 42px;
|
||||||
|
text-align: right;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
|
&--warn {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.room-chat__send-btn {
|
.room-chat__send-btn {
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -142,9 +246,14 @@ function getUserName(userId: string): string {
|
|||||||
color: var(--text-inverse);
|
color: var(--text-inverse);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-primary-hover);
|
background-color: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user