Browse Source

feat(web): add security restrictions plugin for kiosk mode

- Add new securityRestrictions plugin to enforce security controls in kiosk mode
- Disable external link navigation in SsChatReply and SsChatReplyMarkdown components
- Prevent right-click context menu, text selection, and developer tool access
- Block keyboard shortcuts (F5, F12, Ctrl+Shift+I, Ctrl+U, Ctrl+P, etc.) in kiosk mode
- Integrate security plugin into main.ts application initialization
- Update device detection utility to support security restriction checks
- Apply CSS restrictions to prevent text selection across all elements
- Maintain normal functionality for non-kiosk devices
- Ensures kiosk displays remain secure and prevent unauthorized access to browser tools
sy 1 tháng trước cách đây
mục cha
commit
71ae1a31d0

+ 8 - 0
web/src/components/ss_chat/components/SsChatReply.vue

@@ -2,6 +2,7 @@
 import { ref, computed } from 'vue'
 import type { PropType } from 'vue'
 import { useGlobalStore } from '@/stores/global'
+import { isKioskDevice } from '@/utils/useDeviceDetection'
 
 import { Refresh, CircleCheck, ArrowUp } from '@element-plus/icons-vue'
 import SsChatReplyMarkdown from '@/components/ss_chat/components/SsChatReplyMarkdown.vue'
@@ -56,6 +57,13 @@ const getLogoUrl = (url: string) => {
 }
 
 const openLink = (url: string) => {
+  // 展示机模式下禁用链接跳转
+  if (isKioskDevice()) {
+    console.warn('展示机模式:链接跳转已被禁用:', url)
+    return false
+  }
+  
+  // 非展示机模式:正常打开链接
   if (url) {
     window.open(url)
   }

+ 18 - 3
web/src/components/ss_chat/components/SsChatReplyMarkdown.vue

@@ -10,6 +10,7 @@ import {ElMessage} from "element-plus"
 import {Howl} from 'howler'
 import PCMAudioPlayer from '@/utils/PCMAudioPlayer'
 import {isEmpty} from "@/utils/common.ts"
+import {isKioskDevice} from '@/utils/useDeviceDetection'
 
 const global = useGlobalStore()
 const ttsToken = ref('')
@@ -393,9 +394,23 @@ const addLinkTargetBlank = () => {
     if (mdContainer) {
       const links = mdContainer.getElementsByTagName('a')
       Array.from(links).forEach(link => {
-        // 可以在这里修改链接属性,例如:
-        link.setAttribute('target', '_blank');
-        link.setAttribute('rel', 'noopener noreferrer');
+        // 展示机模式:禁用所有链接跳转
+        if (isKioskDevice()) {
+          link.addEventListener('click', (e) => {
+            e.preventDefault();
+            e.stopPropagation();
+            console.warn('展示机模式:链接跳转已被禁用:', link.href);
+            return false;
+          }, true);
+          // 保持链接的正常外观,不显示禁止符号
+          link.style.cursor = 'default';
+          link.style.textDecoration = 'none';
+          link.style.color = '#2943D6';
+        } else {
+          // 非展示机模式:正常在新窗口打开
+          link.setAttribute('target', '_blank');
+          link.setAttribute('rel', 'noopener noreferrer');
+        }
       });
     }
   }, 500);

+ 2 - 0
web/src/main.ts

@@ -4,6 +4,7 @@ import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import ElementPlus from 'element-plus'
 import globalMethods from '@/plugins/globalMethods'
+import securityRestrictions from '@/plugins/securityRestrictions'
 import 'element-plus/dist/index.css'
 import './utils/string.extensions'
 
@@ -16,5 +17,6 @@ app.use(createPinia())
 app.use(router)
 app.use(ElementPlus)
 app.use(globalMethods)
+app.use(securityRestrictions)
 
 app.mount('#app')

+ 204 - 0
web/src/plugins/securityRestrictions.ts

@@ -0,0 +1,204 @@
+/**
+ * 安全限制插件
+ * 用于 Kiosk 模式下的安全控制
+ * 仅在立式一体机(展示机)模式下生效
+ */
+
+import type { App } from 'vue'
+import { isKioskDevice } from '@/utils/useDeviceDetection'
+
+// 立即执行:在模块加载时就绑定事件,确保最早生效
+const initSecurityRestrictions = () => {
+  // 检测是否为展示机模式,非展示机模式不启用安全限制
+  if (!isKioskDevice()) {
+    console.log('非展示机模式,安全限制未启用')
+    return
+  }
+
+  console.log('检测到展示机模式,启用安全限制')
+
+  // 1. 禁用所有外部链接跳转
+  const preventExternalLinks = (e: MouseEvent) => {
+    const target = e.target as HTMLElement
+    const link = target.closest('a')
+    
+    if (link && link.href) {
+      // 阻止所有 <a> 标签的默认跳转行为
+      e.preventDefault()
+      e.stopPropagation()
+      console.warn('外部链接跳转已被禁用')
+    }
+  }
+
+  // 2. 禁用右键菜单
+  const preventContextMenu = (e: MouseEvent) => {
+    e.preventDefault()
+    e.stopPropagation()
+    return false
+  }
+
+  // 3. 禁用文本选择
+  const preventSelection = (e: Event) => {
+    e.preventDefault()
+    return false
+  }
+
+  // 4. 禁用特定键盘快捷键
+  const preventKeyboardShortcuts = (e: KeyboardEvent) => {
+      // F5 - 刷新
+      if (e.key === 'F5') {
+        e.preventDefault()
+        console.warn('F5 刷新已被禁用')
+        return false
+      }
+
+      // F12 - 开发者工具
+      if (e.key === 'F12') {
+        e.preventDefault()
+        console.warn('F12 开发者工具已被禁用')
+        return false
+      }
+
+      // Ctrl+Shift+I / Cmd+Option+I - 开发者工具
+      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'I') {
+        e.preventDefault()
+        console.warn('开发者工具快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+Shift+J / Cmd+Option+J - 控制台
+      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'J') {
+        e.preventDefault()
+        console.warn('控制台快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+Shift+C / Cmd+Option+C - 元素检查
+      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'C') {
+        e.preventDefault()
+        console.warn('元素检查快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+U / Cmd+U - 查看源代码
+      if ((e.ctrlKey || e.metaKey) && e.key === 'u') {
+        e.preventDefault()
+        console.warn('查看源代码快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+S / Cmd+S - 保存页面
+      if ((e.ctrlKey || e.metaKey) && e.key === 's') {
+        e.preventDefault()
+        console.warn('保存页面快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+P / Cmd+P - 打印
+      if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
+        e.preventDefault()
+        console.warn('打印快捷键已被禁用')
+        return false
+      }
+
+      // Alt+F4 - 关闭窗口(仅 Windows)
+      if (e.altKey && e.key === 'F4') {
+        e.preventDefault()
+        console.warn('Alt+F4 关闭窗口已被禁用')
+        return false
+      }
+
+      // Ctrl+W / Cmd+W - 关闭标签页
+      if ((e.ctrlKey || e.metaKey) && e.key === 'w') {
+        e.preventDefault()
+        console.warn('关闭标签页快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+R / Cmd+R - 刷新
+      if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
+        e.preventDefault()
+        console.warn('刷新快捷键已被禁用')
+        return false
+      }
+
+      // Ctrl+Shift+R / Cmd+Shift+R - 强制刷新
+      if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'R') {
+        e.preventDefault()
+        console.warn('强制刷新快捷键已被禁用')
+        return false
+      }
+
+    return true
+  }
+
+  // 立即绑定事件监听器(在 DOM 加载前)
+  const bindEvents = () => {
+    // 禁用链接跳转
+    document.addEventListener('click', preventExternalLinks, true)
+
+    // 禁用右键菜单 - 使用捕获阶段和多种方式确保生效
+    document.addEventListener('contextmenu', preventContextMenu, true)
+    window.addEventListener('contextmenu', preventContextMenu, true)
+    document.body?.addEventListener('contextmenu', preventContextMenu, true)
+
+    // 禁用文本选择
+    document.addEventListener('selectstart', preventSelection, true)
+    document.addEventListener('dragstart', preventSelection, true)
+
+    // 禁用键盘快捷键
+    document.addEventListener('keydown', preventKeyboardShortcuts, true)
+    window.addEventListener('keydown', preventKeyboardShortcuts, true)
+
+    // 添加 CSS 禁用文本选择
+    const style = document.createElement('style')
+    style.textContent = `
+      * {
+        -webkit-user-select: none !important;
+        -moz-user-select: none !important;
+        -ms-user-select: none !important;
+        user-select: none !important;
+        -webkit-touch-callout: none !important;
+      }
+      
+      /* 允许输入框选择文本 */
+      input, textarea, [contenteditable="true"] {
+        -webkit-user-select: text !important;
+        -moz-user-select: text !important;
+        -ms-user-select: text !important;
+        user-select: text !important;
+      }
+    `
+    document.head.appendChild(style)
+
+    console.log('安全限制已启用:禁用外部链接、右键菜单、文本选择和特定快捷键')
+  }
+
+  // 如果 DOM 已加载,立即绑定;否则等待 DOMContentLoaded
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', bindEvents)
+  } else {
+    bindEvents()
+  }
+
+  // 额外保险:在 body 存在后再绑定一次
+  if (!document.body) {
+    const observer = new MutationObserver(() => {
+      if (document.body) {
+        document.body.addEventListener('contextmenu', preventContextMenu, true)
+        observer.disconnect()
+      }
+    })
+    observer.observe(document.documentElement, { childList: true })
+  }
+}
+
+// 立即执行初始化
+initSecurityRestrictions()
+
+export default {
+  install(app: App) {
+    // Vue 插件安装时不需要做额外操作,事件已在模块加载时绑定
+    console.log('安全限制插件已安装')
+  }
+}

+ 15 - 1
web/src/utils/useDeviceDetection.ts

@@ -1,3 +1,17 @@
 export const isMobile = () => {
 	return window.screen.width <= 750
-}
+}
+
+/**
+ * 检测是否为立式一体机(展示机)模式
+ * 
+ * 检测策略:通过 URL 参数 ?kiosk=true 判断
+ * 
+ * 使用方式:
+ * - 展示机浏览器访问:https://your-domain.com?kiosk=true
+ */
+export const isKioskDevice = () => {
+	const urlParams = new URLSearchParams(window.location.search)
+	const kioskParam = urlParams.get('kiosk')
+	return kioskParam === 'true' || kioskParam === '1'
+}

+ 35 - 31
web/src/views/HomeView.vue

@@ -261,7 +261,12 @@ watch(() => global.inputLine, () => {
             <div class="faq-card">
               <div class="faq-title">热门问题</div>
               <div class="faq-list">
-                <div v-for="(item, index) in kioskFaqList" :key="index" class="faq-item">
+                <div 
+                  v-for="(item, index) in kioskFaqList" 
+                  :key="index" 
+                  class="faq-item"
+                  @click="quickSend(item.questionContent)"
+                >
                   {{ item.questionContent }}
                 </div>
               </div>
@@ -509,9 +514,19 @@ watch(() => global.inputLine, () => {
                     background: #ffffff;
                     border-radius: 0.75rem;
                     border: 1px solid #D5D6D8;
-                    /* 仅展示模式:不可点击 */
-                    cursor: default;
-                    transition: none;
+                    cursor: pointer;
+                    transition: all 0.2s ease;
+
+                    &:hover {
+                      background: #F6F7FB;
+                      border-color: #2943D6;
+                      transform: translateX(0.25rem);
+                    }
+
+                    &:active {
+                      background: #EAEDFB;
+                      transform: translateX(0.125rem);
+                    }
                   }
                 }
               }
@@ -540,44 +555,33 @@ watch(() => global.inputLine, () => {
         display: flex;
         align-items: center;
         gap: 0.5rem;
-        padding: 0.875rem 2.5rem;
-        border-radius: 3.125rem; /* 胶囊形状 */
-        border: 1px solid rgba(255, 255, 255, 0.4);
+        padding: 0.75rem 1.5rem;
+        border-radius: 2.5rem; /* 胶囊形状 */
+        border: 1px solid #e5e5e5;
         background: #ffffff;
-        box-shadow: 
-          -10px -10px 20px #ffffff,
-          10px 10px 20px #d1d9e6,
-          inset 1px 1px 0px rgba(255, 255, 255, 1);
-        color: #aab2bd;
-        font-size: 1.125rem;
+        color: #333333;
+        font-size: 1rem;
         font-weight: 400;
-        letter-spacing: 0.125rem;
         cursor: pointer;
-        transition: all 0.3s ease;
-        text-shadow: 1px 1px 0 #ffffff;
-        min-width: 100px;
-        min-height: 50px;
-        justify-content: center;
+        transition: all 0.2s ease;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
 
         &:hover {
-          transform: translateY(-2px);
-          box-shadow: 
-            -12px -12px 24px #ffffff,
-            12px 12px 24px #c8d0e7,
-            inset 1px 1px 0px rgba(255, 255, 255, 1);
+          background: #f7f7f7;
+          border-color: #d5d5d5;
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
         }
 
         &:active {
-          transform: translateY(0);
-          background: #f5f5f5;
-          box-shadow: 
-            inset 6px 6px 12px #d1d9e6,
-            inset -6px -6px 12px #ffffff;
-          border: 1px solid transparent;
+          background: #eeeeee;
+          border-color: #cccccc;
+          box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
+          transform: scale(0.98);
         }
 
         .back-icon {
-          font-size: 1.25rem;
+          font-size: 1.125rem;
+          font-weight: 500;
         }
       }