diff --git a/WebApp/client/public/bidirectional/index1.html b/WebApp/client/public/onebyone/index1.html similarity index 93% rename from WebApp/client/public/bidirectional/index1.html rename to WebApp/client/public/onebyone/index1.html index a82342c..d858fda 100644 --- a/WebApp/client/public/bidirectional/index1.html +++ b/WebApp/client/public/onebyone/index1.html @@ -6,133 +6,7 @@ VideoCall - 一对一视频通话 - + diff --git a/WebApp/client/public/onebyone/script.js b/WebApp/client/public/onebyone/script.js new file mode 100644 index 0000000..38b3d6e --- /dev/null +++ b/WebApp/client/public/onebyone/script.js @@ -0,0 +1,703 @@ +/** + * ========================================== + * 1. 类型定义 (Type Definitions) + * 后端可参考此部分设计数据库模型和 API 响应格式 + * ========================================== + */ + +/** + * @typedef {Object} CallSession + * @property {string} id - 通话唯一标识 (UUID) + * @property {'video'|'audio'} type - 通话类型 + * @property {'connecting'|'ongoing'|'ended'|'failed'} status - 通话状态 + * @property {string} startTime - ISO 8601 时间戳 + * @property {number} duration - 已进行秒数 + * @property {boolean} isEncrypted - 是否启用端到端加密 + * @property {LocalUser} localUser - 本地用户信息 + * @property {RemoteUser} remoteUser - 远端用户信息 + */ + +/** + * @typedef {Object} LocalUser + * @property {string} id - 用户ID + * @property {string} name - 显示名称 + * @property {string} avatar - 头像URL + * @property {boolean} isHost - 是否主持人 + * @property {MediaState} mediaState - 媒体状态 + */ + +/** + * @typedef {Object} RemoteUser + * @property {string} id - 用户ID + * @property {string} name - 显示名称 + * @property {string} avatar - 头像URL + * @property {'online'|'offline'|'connecting'} status - 在线状态 + * @property {MediaState} mediaState - 媒体状态 + * @property {'excellent'|'good'|'fair'|'poor'} networkQuality - 网络质量 + */ + +/** + * @typedef {Object} MediaState + * @property {boolean} audio - 音频是否开启 + * @property {boolean} video - 视频是否开启 + * @property {boolean} screenShare - 是否屏幕共享 + * @property {boolean} isSpeaking - 是否正在说话(VAD) + */ + +/** + * @typedef {Object} ChatMessage + * @property {string} id - 消息唯一ID + * @property {string} senderId - 发送者ID + * @property {string} senderName - 发送者名称 + * @property {string} senderAvatar - 发送者头像URL + * @property {string} content - 消息内容 + * @property {'text'|'file'|'system'} type - 消息类型 + * @property {string} timestamp - ISO 8601 时间戳 + * @property {boolean} isSelf - 是否为自己发送 + */ + +/** + * ========================================== + * 2. 模拟数据层 (Mock Data Layer) + * 后端接口返回的数据格式应与此结构一致 + * ========================================== + */ + +// [API_RESPONSE: GET /api/call/:callId] +const mockCallSession = { + id: "call-8842-2024-001", + type: "video", + status: "ongoing", // connecting | ongoing | ended | failed + startTime: "2024-01-15T14:30:00.000Z", + duration: 0, // 秒数,后端可不返回,前端本地计算 + isEncrypted: true, + + // [API_RESPONSE: 嵌套用户信息] + localUser: { + id: "user-local-001", + name: "我", + avatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop", + isHost: true, + mediaState: { + audio: true, + video: true, + screenShare: false, + isSpeaking: false + } + }, + + remoteUser: { + id: "user-remote-002", + name: "Sarah Chen", + avatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=400&h=400&fit=crop", + status: "online", // online | offline | connecting + networkQuality: "excellent", // excellent | good | fair | poor + mediaState: { + audio: true, + video: true, + screenShare: false, + isSpeaking: false + } + } +}; + +// [API_RESPONSE: GET /api/call/:callId/messages] +const mockMessages = [ + { + id: "msg-001", + senderId: "system", + senderName: "系统", + senderAvatar: "/assets/system.png", + content: "通话已建立连接", + type: "system", + timestamp: "2024-01-15T14:30:00.000Z", + isSelf: false + }, + { + id: "msg-002", + senderId: "user-remote-002", + senderName: "Sarah Chen", + senderAvatar: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=100&h=100&fit=crop", + content: "嗨,能听到我说话吗?", + type: "text", + timestamp: "2024-01-15T14:32:15.000Z", + isSelf: false + }, + { + id: "msg-003", + senderId: "user-local-001", + senderName: "我", + senderAvatar: "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop", + content: "很清楚!你的画面也很清晰 👍", + type: "text", + timestamp: "2024-01-15T14:32:45.000Z", + isSelf: true + } +]; + +/** + * ========================================== + * 3. 状态管理 (State Management) + * 使用简单的 Observable 模式,可替换为 Redux/Vuex/Pinia + * ========================================== + */ + +class CallStateManager { + constructor() { + // 核心状态 + this.state = { + session: { ...mockCallSession }, + messages: [...mockMessages], + isSidebarOpen: false, + unreadCount: 0, + localStream: null, // MediaStream 对象 + remoteStream: null // MediaStream 对象 + }; + + // 监听器数组 + this.listeners = []; + + // 初始化 + this.init(); + } + + // 订阅状态变化 + subscribe(callback) { + this.listeners.push(callback); + return () => { + this.listeners = this.listeners.filter(cb => cb !== callback); + }; + } + + // 通知所有监听器 + notify(changes) { + this.listeners.forEach(cb => cb(this.state, changes)); + } + + // 初始化 + init() { + // 启动通话时长计时器 + this.durationInterval = setInterval(() => { + this.state.session.duration++; + this.notify({ type: 'DURATION_UPDATE', duration: this.state.session.duration }); + }, 1000); + + // 模拟远端音频活动 (实际应由 WebRTC VAD 检测触发) + this.simulateRemoteActivity(); + } + + // 更新本地媒体状态 + updateLocalMedia(mediaType, value) { + this.state.session.localUser.mediaState[mediaType] = value; + this.notify({ type: 'LOCAL_MEDIA_CHANGE', mediaType, value }); + + // [API_CALL: POST /api/call/:callId/media] + // [WEBSOCKET_EMIT: media-state-changed] + this.emitMediaStateChange(); + } + + // 更新远端媒体状态 (由 WebSocket 消息触发) + updateRemoteMedia(mediaState) { + this.state.session.remoteUser.mediaState = { + ...this.state.session.remoteUser.mediaState, + ...mediaState + }; + this.notify({ type: 'REMOTE_MEDIA_CHANGE', mediaState }); + } + + // 添加消息 + addMessage(message) { + this.state.messages.push(message); + + // 如果侧边栏关闭且不是自己发的,增加未读 + if (!this.state.isSidebarOpen && !message.isSelf) { + this.state.unreadCount++; + } + + this.notify({ type: 'NEW_MESSAGE', message, unreadCount: this.state.unreadCount }); + } + + // 切换侧边栏 + toggleSidebar() { + this.state.isSidebarOpen = !this.state.isSidebarOpen; + if (this.state.isSidebarOpen) { + this.state.unreadCount = 0; + } + this.notify({ type: 'SIDEBAR_TOGGLE', isOpen: this.state.isSidebarOpen }); + } + + // 结束通话 + endCall() { + clearInterval(this.durationInterval); + this.state.session.status = 'ended'; + this.notify({ type: 'CALL_ENDED' }); + + // [API_CALL: POST /api/call/:callId/leave] + // [WEBSOCKET_EMIT: leave-call] + } + + // 模拟远端活动 (开发测试用) + simulateRemoteActivity() { + setInterval(() => { + if (Math.random() > 0.7) { + const isSpeaking = Math.random() > 0.5; + this.updateRemoteMedia({ isSpeaking }); + } + }, 800); + } + + // 模拟网络质量变化 (开发测试用) + simulateNetworkChange() { + const qualities = ['excellent', 'good', 'fair', 'poor']; + setInterval(() => { + if (Math.random() > 0.8) { + const quality = qualities[Math.floor(Math.random() * qualities.length)]; + this.state.session.remoteUser.networkQuality = quality; + this.notify({ type: 'NETWORK_CHANGE', quality }); + } + }, 5000); + } + + // 发送媒体状态到服务器 + emitMediaStateChange() { + const payload = { + userId: this.state.session.localUser.id, + ...this.state.session.localUser.mediaState + }; + console.log('[WebSocket Emit] media-state-changed:', payload); + // socket.emit('media-state-changed', payload); + } + + // Getters + getState() { return this.state; } + getLocalUser() { return this.state.session.localUser; } + getRemoteUser() { return this.state.session.remoteUser; } + getMessages() { return this.state.messages; } +} + +/** + * ========================================== + * 4. UI 渲染器 (UI Renderer) + * 负责将状态映射到 DOM,与状态管理解耦 + * ========================================== + */ + +class UIRenderer { + constructor(stateManager) { + this.stateManager = stateManager; + this.unsubscribe = stateManager.subscribe(this.render.bind(this)); + + // 缓存 DOM 元素 + this.elements = { + // 头部 + headerTitle: document.getElementById('headerTitle'), + callDuration: document.getElementById('callDuration'), + encryptionBadge: document.getElementById('encryptionBadge'), + unreadBadge: document.getElementById('unreadBadge'), + + // 远端视频 + remoteVideo: document.getElementById('remoteVideo'), + remoteAvatar: document.getElementById('remoteAvatar'), + remoteName: document.getElementById('remoteName'), + remoteStatus: document.getElementById('remoteStatus'), + remoteSpeakingIndicator: document.getElementById('remoteSpeakingIndicator'), + remoteAudioWave: document.getElementById('remoteAudioWave'), + networkStatus: document.getElementById('networkStatus'), + networkStatusText: document.getElementById('networkStatusText'), + connectingOverlay: document.getElementById('connectingOverlay'), + + // 本地视频 + localVideo: document.getElementById('localVideo'), + localVideoPlaceholder: document.getElementById('localVideoPlaceholder'), + localAudioWave: document.getElementById('localAudioWave'), + localInitials: document.getElementById('localInitials'), + + // 侧边栏 + sidebar: document.getElementById('sidebar'), + chatContent: document.getElementById('chatContent'), + userList: document.getElementById('userList'), + + // 控制按钮 + micBtn: document.getElementById('micBtn'), + videoBtn: document.getElementById('videoBtn'), + screenBtn: document.getElementById('screenBtn'), + connectionQuality: document.getElementById('connectionQuality') + }; + } + + // 主渲染入口 + render(state, changes) { + if (!changes) { + // 初始化全量渲染 + this.renderHeader(state); + this.renderRemoteVideo(state); + this.renderLocalVideo(state); + this.renderUserList(state); + this.renderMessages(state); + this.renderControls(state); + return; + } + + // 增量更新 + switch (changes.type) { + case 'DURATION_UPDATE': + this.updateDuration(changes.duration); + break; + case 'LOCAL_MEDIA_CHANGE': + this.updateLocalControl(changes.mediaType, changes.value); + this.updateLocalVideoUI(state); + break; + case 'REMOTE_MEDIA_CHANGE': + this.updateRemoteVideoUI(state); + break; + case 'NEW_MESSAGE': + this.appendMessage(changes.message); + this.updateUnreadBadge(changes.unreadCount); + break; + case 'SIDEBAR_TOGGLE': + this.toggleSidebarUI(changes.isOpen); + break; + case 'NETWORK_CHANGE': + this.updateNetworkStatus(changes.quality); + break; + case 'CALL_ENDED': + this.showCallEnded(state.session.duration); + break; + } + } + + // 渲染头部信息 + renderHeader(state) { + const { session } = state; + this.elements.headerTitle.textContent = `与 ${session.remoteUser.name} 的通话`; + this.elements.encryptionBadge.style.display = session.isEncrypted ? 'flex' : 'none'; + this.updateDuration(session.duration); + } + + updateDuration(seconds) { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + this.elements.callDuration.textContent = + `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; + } + + // 渲染远端视频区域 + renderRemoteVideo(state) { + const { remoteUser } = state.session; + this.elements.remoteName.textContent = remoteUser.name; + this.elements.remoteAvatar.src = remoteUser.avatar; + this.elements.remoteVideo.src = remoteUser.avatar; // 实际应为视频流 + + this.updateRemoteVideoUI(state); + this.updateNetworkStatus(remoteUser.networkQuality); + } + + updateRemoteVideoUI(state) { + const { remoteUser } = state.session; + const media = remoteUser.mediaState; + + // 音频状态 + if (media.isSpeaking && media.audio) { + this.elements.remoteAudioWave.classList.remove('hidden'); + this.elements.remoteSpeakingIndicator.classList.remove('hidden'); + } else { + this.elements.remoteAudioWave.classList.add('hidden'); + this.elements.remoteSpeakingIndicator.classList.add('hidden'); + } + + // 连接状态 + const statusText = { + 'online': '正在通话', + 'connecting': '连接中...', + 'offline': '已断开' + }; + this.elements.remoteStatus.textContent = statusText[remoteUser.status]; + + // 视频占位 (实际应根据 video track 判断) + if (!media.video) { + this.elements.remoteVideo.style.opacity = '0.3'; + } else { + this.elements.remoteVideo.style.opacity = '1'; + } + } + + // 渲染本地视频 + renderLocalVideo(state) { + const { localUser } = state.session; + this.elements.localVideo.src = localUser.avatar; // 实际应为视频流 + this.elements.localInitials.textContent = localUser.name.substring(0, 2); + this.updateLocalVideoUI(state); + } + + updateLocalVideoUI(state) { + const { localUser } = state.session; + const media = localUser.mediaState; + + // 视频开关 + if (!media.video) { + this.elements.localVideoPlaceholder.classList.remove('hidden'); + this.elements.localVideo.style.opacity = '0'; + } else { + this.elements.localVideoPlaceholder.classList.add('hidden'); + this.elements.localVideo.style.opacity = '1'; + } + + // 音频波形 + if (media.isSpeaking && media.audio) { + this.elements.localAudioWave.classList.remove('hidden'); + } else { + this.elements.localAudioWave.classList.add('hidden'); + } + } + + // 渲染用户列表 + renderUserList(state) { + // 实际项目中应根据用户列表动态生成 + // 这里使用静态HTML,仅更新状态 + } + + // 渲染消息列表 + renderMessages(state) { + // 实际项目中应根据消息列表动态生成 + // 这里使用静态HTML,仅更新新消息 + } + + // 追加新消息 + appendMessage(message) { + // 实际项目中应动态生成消息元素 + console.log('New message:', message); + } + + // 渲染控制按钮 + renderControls(state) { + const { localUser } = state.session; + const media = localUser.mediaState; + + this.updateLocalControl('audio', media.audio); + this.updateLocalControl('video', media.video); + this.updateLocalControl('screenShare', media.screenShare); + } + + // 更新本地控制按钮状态 + updateLocalControl(mediaType, value) { + const btnMap = { + 'audio': this.elements.micBtn, + 'video': this.elements.videoBtn, + 'screenShare': this.elements.screenBtn + }; + + const btn = btnMap[mediaType]; + if (btn) { + btn.dataset.active = !value; + btn.querySelector('[data-icon="default"]').classList.toggle('hidden', !value); + btn.querySelector('[data-icon="active"]').classList.toggle('hidden', value); + } + } + + // 更新未读消息徽章 + updateUnreadBadge(count) { + if (count > 0) { + this.elements.unreadBadge.textContent = count; + this.elements.unreadBadge.classList.remove('hidden'); + } else { + this.elements.unreadBadge.classList.add('hidden'); + } + } + + // 切换侧边栏UI + toggleSidebarUI(isOpen) { + this.elements.sidebar.classList.toggle('hidden', !isOpen); + } + + // 更新网络状态 + updateNetworkStatus(quality) { + const qualityText = { + 'excellent': '优秀', + 'good': '良好', + 'fair': '一般', + 'poor': '较差' + }; + + if (quality !== 'excellent') { + this.elements.networkStatus.classList.remove('hidden'); + this.elements.networkStatusText.textContent = `网络${qualityText[quality]}`; + } else { + this.elements.networkStatus.classList.add('hidden'); + } + + this.elements.connectionQuality.textContent = `连接质量: ${qualityText[quality]}`; + } + + // 显示通话结束 + showCallEnded(duration) { + const mins = Math.floor(duration / 60); + const secs = duration % 60; + alert(`通话已结束,时长: ${mins}分${secs}秒`); + } +} + +/** + * ========================================== + * 5. 事件处理 (Event Handlers) + * 处理用户交互事件 + * ========================================== + */ + +// 全局状态管理器实例 +let stateManager; +let uiRenderer; + +// 初始化应用 +function initApp() { + stateManager = new CallStateManager(); + uiRenderer = new UIRenderer(stateManager); + + // 绑定键盘事件 + document.addEventListener('keydown', handleKeyPress); +} + +// 处理键盘事件 +function handleKeyPress(e) { + // 空格键静音 + if (e.code === 'Space') { + e.preventDefault(); + const micBtn = document.getElementById('micBtn'); + toggleMute(micBtn); + } + + // Ctrl+V 切换视频 + if (e.ctrlKey && e.key === 'v') { + e.preventDefault(); + const videoBtn = document.getElementById('videoBtn'); + toggleVideo(videoBtn); + } +} + +// 切换静音 +function toggleMute(btn) { + const isActive = btn.dataset.active === 'true'; + stateManager.updateLocalMedia('audio', !isActive); +} + +// 切换视频 +function toggleVideo(btn) { + const isActive = btn.dataset.active === 'true'; + stateManager.updateLocalMedia('video', !isActive); +} + +// 切换屏幕共享 +function toggleScreenShare(btn) { + const isActive = btn.dataset.active === 'true'; + stateManager.updateLocalMedia('screenShare', !isActive); +} + +// 切换本地视频 +function toggleLocalVideo() { + const videoBtn = document.getElementById('videoBtn'); + toggleVideo(videoBtn); +} + +// 切换侧边栏 +function toggleSidebar() { + stateManager.toggleSidebar(); +} + +// 结束通话 +function endCall() { + stateManager.endCall(); +} + +// 发送消息 +function sendMessage() { + const input = document.getElementById('chatInput'); + const content = input.value.trim(); + + if (content) { + const newMessage = { + id: `msg-${Date.now()}`, + senderId: stateManager.getLocalUser().id, + senderName: stateManager.getLocalUser().name, + senderAvatar: stateManager.getLocalUser().avatar, + content: content, + type: 'text', + timestamp: new Date().toISOString(), + isSelf: true + }; + + stateManager.addMessage(newMessage); + input.value = ''; + + // [API_CALL: POST /api/call/:callId/message] + // [WEBSOCKET_EMIT: send-message] + console.log('[WebSocket Emit] send-message:', newMessage); + } +} + +// 处理聊天输入提交 +function handleChatSubmit(e) { + if (e.key === 'Enter') { + sendMessage(); + } +} + +// 本地视频窗口拖拽功能 +document.addEventListener('DOMContentLoaded', function() { + const videoContainer = document.getElementById('localVideoContainer'); + let isDragging = false; + let offsetX, offsetY; + const edgeThreshold = 20; // 边缘吸附阈值 + + videoContainer.addEventListener('mousedown', function(e) { + // 只有在点击容器本身而不是内部按钮时才开始拖拽 + if (e.target === videoContainer || e.target === videoContainer.querySelector('#localVideo') || e.target === videoContainer.querySelector('#localVideoPlaceholder')) { + isDragging = true; + offsetX = e.clientX - videoContainer.getBoundingClientRect().left; + offsetY = e.clientY - videoContainer.getBoundingClientRect().top; + videoContainer.style.cursor = 'grabbing'; + videoContainer.style.zIndex = '100'; + } + }); + + document.addEventListener('mousemove', function(e) { + if (!isDragging) return; + + let newX = e.clientX - offsetX; + let newY = e.clientY - offsetY; + + // 计算屏幕边界 + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + const containerWidth = videoContainer.offsetWidth; + const containerHeight = videoContainer.offsetHeight; + + // 边缘吸附逻辑 + if (newX < edgeThreshold) { + newX = 0; + } else if (newX > windowWidth - containerWidth - edgeThreshold) { + newX = windowWidth - containerWidth; + } + + if (newY < edgeThreshold) { + newY = 0; + } else if (newY > windowHeight - containerHeight - edgeThreshold) { + newY = windowHeight - containerHeight; + } + + // 更新位置 + videoContainer.style.left = newX + 'px'; + videoContainer.style.top = newY + 'px'; + videoContainer.style.bottom = 'auto'; + videoContainer.style.right = 'auto'; + }); + + document.addEventListener('mouseup', function() { + if (isDragging) { + isDragging = false; + videoContainer.style.cursor = 'move'; + videoContainer.style.zIndex = '10'; + } + }); +}); + +// 页面加载完成后初始化 +window.addEventListener('DOMContentLoaded', initApp); diff --git a/WebApp/client/public/onebyone/style.css b/WebApp/client/public/onebyone/style.css new file mode 100644 index 0000000..70e0082 --- /dev/null +++ b/WebApp/client/public/onebyone/style.css @@ -0,0 +1,125 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap'); + +body { + font-family: 'Inter', sans-serif; + background: #0f172a; + overflow: hidden; +} + +.bg-grid { + background-image: + linear-gradient(rgba(99, 102, 241, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(99, 102, 241, 0.1) 1px, transparent 1px); + background-size: 40px 40px; + +} + +@keyframes gridMove { + 0% { transform: translate(0, 0); } + 100% { transform: translate(20px, 20px); } +} + +.glass { + background: rgba(30, 41, 59, 0.7); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.glass-strong { + background: rgba(15, 23, 42, 0.9); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.control-btn { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.control-btn:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3); +} + +.end-call-pulse { + animation: pulse-red 2s infinite; +} + +@keyframes pulse-red { + 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); } + 50% { box-shadow: 0 0 0 15px rgba(239, 68, 68, 0); } +} + +.chat-bubble { + animation: messageSlide 0.3s ease-out; +} + +@keyframes messageSlide { + from { opacity: 0; transform: translateX(-10px); } + to { opacity: 1; transform: translateX(0); } +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} +.custom-scrollbar::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); +} +.custom-scrollbar::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; +} + +.audio-wave { + display: flex; + align-items: center; + gap: 3px; + height: 20px; +} + +.audio-wave span { + width: 3px; + background: #10b981; + border-radius: 2px; + animation: wave 1s ease-in-out infinite; +} + +.audio-wave span:nth-child(1) { animation-delay: 0s; height: 40%; } +.audio-wave span:nth-child(2) { animation-delay: 0.1s; height: 70%; } +.audio-wave span:nth-child(3) { animation-delay: 0.2s; height: 100%; } +.audio-wave span:nth-child(4) { animation-delay: 0.3s; height: 60%; } +.audio-wave span:nth-child(5) { animation-delay: 0.4s; height: 30%; } + +@keyframes wave { + 0%, 100% { transform: scaleY(0.5); } + 50% { transform: scaleY(1); } +} + +.video-fade-in { + animation: videoFadeIn 0.5s ease-out; +} + +@keyframes videoFadeIn { + from { opacity: 0; transform: scale(1.05); } + to { opacity: 1; transform: scale(1); } +} + +/* 数据绑定标记 - 开发调试时显示 */ +[data-field]::after { + content: attr(data-field); + position: absolute; + top: -18px; + right: 0; + background: #f59e0b; + color: #000; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + z-index: 1000; + white-space: nowrap; +} +[data-field]:hover::after { opacity: 1; } +[data-field] { position: relative; }