6 Коміти d2ceed036a ... 71ae1a31d0

Автор SHA1 Опис Дата
  sy 71ae1a31d0 feat(web): add security restrictions plugin for kiosk mode 1 місяць тому
  sy 4cdec58760 feat(web): refactor app title styling and update button neumorphism design 1 місяць тому
  sy 7b227c1164 feat(web): refactor input box layout and improve recording error handling 1 місяць тому
  sy 3ae5b7d29e feat(web): optimize font sizes for 1080x1920 kiosk display 1 місяць тому
  sy 83ac7d28a8 feat(web): optimize service quick bar layout for kiosk display 1 місяць тому
  sy 31589a2c37 feat(web): optimize kiosk layout for 1080x1920 vertical displays 1 місяць тому

+ 190 - 0
web/KIOSK_LAYOUT_OPTIMIZATION.md

@@ -0,0 +1,190 @@
+# 立式一体机对话界面优化
+
+## 设计目标
+
+针对 1080×1920 分辨率立式一体机,优化对话界面布局,确保所有可操作元素位于用户易触及区域(Y≤1500px)。
+
+## 核心改动
+
+### 1. 设备检测
+- **检测条件**:宽度 1000-1200px,高度 1700-2000px
+- **实现位置**:`HomeView.vue` 和 `SsInputBox.vue`
+- **状态管理**:使用 `computed` 从窗口尺寸派生,无额外状态
+
+### 2. 四段式布局(仅对话模式)
+
+```
+┌──────────────────────────────┐ Y=0
+│ 顶部导航栏(100px)           │ ← 返回首页 + 标题
+├──────────────────────────────┤ Y=100
+│ 服务快捷栏(100px)           │ ← 四川特色、政策解读等
+├──────────────────────────────┤ Y=200
+│                              │
+│ 中部对话区(flex: 1)         │ ← 消息列表 + 输入框
+│                              │
+├──────────────────────────────┤ Y≈1500
+│ 底部常见问题区(420px)       │ ← 仅展示,不可点击
+└──────────────────────────────┘ Y=1920
+```
+
+### 3. 统一宽度对齐
+
+**设计原则**:所有内容区域与顶部导航栏的内容区域对齐
+
+**对齐策略**:使用 CSS 变量定义统一的内容宽度
+```css
+--kiosk-content-padding: 2rem;
+--kiosk-content-width: calc(100vw - var(--kiosk-content-padding) * 2);
+```
+
+**应用范围**:
+- 顶部导航栏:`padding: 0 2rem`(定义内容区域边界)
+- 服务快捷栏:`width: var(--kiosk-content-width)`
+- 聊天消息区:`max-width: var(--kiosk-content-width)`
+- 输入框:`max-width: var(--kiosk-content-width)`
+- 常见问题:`width: var(--kiosk-content-width)`
+
+**计算结果**:
+- 在 1080px 宽的屏幕上:`calc(100vw - 4rem)` = 1080px - 64px = 1016px
+- 所有内容区域左右边缘与"返回首页"按钮和"商小川智能助手"标题对齐
+
+**优势**:
+- 响应式设计:自动适配不同屏幕宽度
+- 完美对齐:所有内容与顶部导航栏内容区域对齐
+- 代码简洁:使用 CSS 变量避免重复计算
+
+### 4. 关键特性
+
+#### 顶部导航栏(100px)
+- 固定定位,始终可见
+- 包含:返回首页按钮(左)+ 应用标题(中)
+- 按钮尺寸:120×60px,满足最小触控目标
+
+#### 服务快捷栏(100px)
+- **复用原有设计**:保留 `SsService` 组件的渐变背景、阴影效果
+- **均匀分布**:5个服务项使用 `flex: 1` 和 `justify-content: space-between` 均匀分布
+- **样式类复用**:使用与原组件相同的 `itemClass` 逻辑
+- **背景色**:
+  - 默认:`linear-gradient(180deg, rgba(250, 255, 253, 0.8), rgba(225, 245, 248, 0.8))`
+  - 黄色:`linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 240, 239, 0.7))`
+  - 中心:`linear-gradient(90deg, #c2dff9, #c6e2fa)`
+- **尺寸**:每项 `flex: 1`,最小宽度 140px,高度 70px,图标半透明显示
+
+#### 中部对话区
+- 使用 flexbox 自适应高度
+- 包含消息滚动区域和输入框
+- 输入框位置确保在 Y≤1400px 范围内(200px + 1200px)
+
+#### 底部常见问题区(420px)
+- 固定高度,仅用于信息展示
+- 从 `global.commonProblem` 取前 5 条
+- 不可点击,避免用户弯腰操作
+
+### 5. 侧边栏处理
+- 对话模式下通过 `v-if` 完全不渲染侧边栏
+- 条件:`v-if="!(global.spread && isKioskDevice)"`
+
+## 文件修改清单
+
+### web/src/views/HomeView.vue
+- 添加 `isKioskDevice` computed 属性(设备检测)
+- 添加 `kioskFaqList` computed 属性(常见问题列表)
+- 添加 `getServiceClass` 方法(复用服务项样式逻辑)
+- 添加 `kiosk-top-nav` 顶部导航栏组件
+- 添加 `kiosk-service-bar` 服务快捷栏(复用原有设计)
+- 添加 `kiosk-bottom-faq` 底部展示区
+- 添加 `.kiosk-mode` 样式规则
+- 定义 `--kiosk-content-width` CSS 变量(768px)
+
+### web/src/components/SsInputBox.vue
+- 添加 `isKioskDevice` computed 属性
+- 添加 `.kiosk-chat` 类名支持
+- 调整 flexbox 布局以适配四段式结构
+- **定义 CSS 变量**:`--kiosk-content-width: calc(100vw - 4rem)` 用于统一宽度对齐
+- **修改宽度设置**:`.ss-chat-container` 和 `.input-container` 使用 `var(--kiosk-content-width)`
+
+## 设计原则遵循
+
+✅ **单一数据源**:设备检测通过 computed 从窗口尺寸派生,无冗余状态  
+✅ **组件复用**:复用 `SsService` 的样式设计和 `SsCommonProblem` 数据  
+✅ **清晰注释**:关键逻辑添加中文注释说明设计意图  
+✅ **最小改动**:仅在必要位置添加条件渲染和样式  
+✅ **响应式设计**:使用 media query 隔离立式一体机样式  
+✅ **视觉一致性**:保持原有的渐变背景、阴影、图标等视觉元素
+
+## 使用说明
+
+### 触发条件
+当同时满足以下条件时,自动启用立式一体机布局:
+1. 窗口宽度:1000-1200px
+2. 窗口高度:1700-2000px
+3. 用户进入对话模式(`global.spread === true`)
+
+### 退出方式
+点击顶部"返回首页"按钮,调用 `goHome()` 方法重置状态。
+
+## 可操作区域分布
+
+| 区域 | Y 坐标范围 | 高度 | 可操作性 |
+|------|-----------|------|---------|
+| 顶部导航 | 0-100px | 100px | ✅ 强烈推荐 |
+| 服务快捷栏 | 100-200px | 100px | ✅ 强烈推荐 |
+| 对话区域 | 200-1500px | ~1300px | ✅ 核心操作区 |
+| 常见问题 | 1500-1920px | 420px | ⚠️ 仅展示 |
+
+## 宽度对齐说明
+
+**核心原则**:所有内容区域与顶部导航栏的内容区域完美对齐
+
+**实现方式**:
+1. 顶部导航栏定义内容边界:`padding: 0 2rem`
+2. 使用 CSS 变量计算内容宽度:
+   ```css
+   --kiosk-content-padding: 2rem;
+   --kiosk-content-width: calc(100vw - var(--kiosk-content-padding) * 2);
+   ```
+3. 所有内容区域使用相同的宽度变量
+
+**应用组件**:
+- 服务快捷栏:`width: var(--kiosk-content-width)`
+- 聊天消息区:`max-width: var(--kiosk-content-width)`
+- 输入框:`max-width: var(--kiosk-content-width)`
+- 常见问题:`width: var(--kiosk-content-width)`
+
+**计算示例**(1080px 屏幕):
+- `100vw` = 1080px
+- `var(--kiosk-content-padding) * 2` = 2rem * 2 = 4rem = 64px
+- `var(--kiosk-content-width)` = 1080px - 64px = 1016px
+
+**对齐效果**:
+```
+┌─────────────────────────────┐ 屏幕边缘
+│  ← 返回首页  商小川智能助手  │ ← padding: 0 2rem
+│  ↑                        ↑ │
+│  └────────────────────────┘ │ ← 内容区域宽度 1016px
+│  ┌────────────────────────┐ │
+│  │    服务快捷栏 1016px    │ │
+│  └────────────────────────┘ │
+│  ┌────────────────────────┐ │
+│  │   聊天消息区 1016px     │ │
+│  │   + 输入框 1016px      │ │
+│  └────────────────────────┘ │
+│  ┌────────────────────────┐ │
+│  │   热门问题 1016px       │ │
+│  └────────────────────────┘ │
+└─────────────────────────────┘
+```
+
+**优势**:
+- ✅ 完美对齐:所有内容左右边缘与顶部导航栏内容对齐
+- ✅ 响应式:自动适配不同屏幕宽度
+- ✅ 可维护:修改 padding 时所有组件自动调整
+- ✅ 语义清晰:通过 CSS 变量明确表达设计意图
+
+## 后续优化建议
+
+1. **超时返回**:60 秒无操作自动返回首页(保护隐私)
+2. **语音输入**:在顶部导航栏添加语音输入快捷按钮
+3. **字体调优**:根据实际设备测试进一步调整字号和行高
+4. **无障碍支持**:添加 ARIA 标签和键盘导航支持
+5. **服务栏滚动指示**:添加左右箭头提示可滚动查看更多服务

+ 6 - 1
web/src/components/SsCommonProblem.vue

@@ -170,14 +170,19 @@ onMounted(() => {
           width: calc(100% - 3.75rem);
 
           .header-title {
-            font-size: 1.75rem; /* 1.5rem → 1.75rem */
+            font-size: 1.875rem; /* 1.75rem → 1.875rem */
             margin-bottom: 0.5rem;
           }
+
+          .header-subject {
+            font-size: 1rem; /* 增大副标题字体 */
+          }
         }
       }
 
       .problem-list {
         .problem-item {
+          font-size: 1.125rem; /* 增大问题列表字体 */
           margin-bottom: 1rem;
         }
       }

+ 83 - 6
web/src/components/SsInputBox.vue

@@ -27,6 +27,16 @@ let scrollDirection = 'down'
 let scrollFlag = false;
 let beforeScrollTop = 0;
 
+/**
+ * 检测是否为立式一体机设备
+ * 用于应用特定的布局样式
+ */
+const isKioskDevice = computed(() => {
+  const w = window.innerWidth
+  const h = window.innerHeight
+  return (w >= 1000 && w <= 1200 && h >= 1700 && h <= 2000)
+})
+
 const sendMessageFlag = computed(() => {
   let msg = message.value.trim();
   return msg == '' || global.inReply
@@ -251,7 +261,32 @@ defineExpose({
 </script>
 
 <template>
-  <div class="chat-container" :class="{spread: global.spread}">
+  <div class="chat-container" :class="{spread: global.spread, 'kiosk-chat': isKioskDevice && global.spread}">
+    <!-- 立式一体机模式:输入框在顶部 -->
+    <div v-if="isKioskDevice && global.spread" class="input-box input-box-top" :class="{spread: global.spread}">
+      <div class="input-container hover-scale scale101">
+        <el-input ref="textareaRef" v-model="message" class="input-container-input" placeholder="请输入你想咨询的问题..."
+                  :type="global.spread ? 'textarea' : 'text'"
+                  :autosize="{ minRows: 1, maxRows: 3 }"
+                  @keyup.enter="sendMessage"
+                  maxlength="200"
+                  @input="calculateLines"
+                  @change="calculateLines"
+        />
+        <div class="input-buttons">
+          <div class="input-buttons-limit">{{ message.length }}/200</div>
+          <div class="input-buttons-right">
+            <ss-recording @set-message="setMessage"/>
+            <div class="buttons-separate"></div>
+            <el-button class="send-button" v-if="global.inReply" circle @click="stopInReply">
+              <div class="stop-block"></div>
+            </el-button>
+            <el-button class="send-button" v-else :icon="Top" circle @click="sendMessage"/>
+          </div>
+        </div>
+      </div>
+    </div>
+    
     <el-scrollbar ref="scrollbarRef" class="ss-chat" @scroll="handleScroll">
       <div class="ss-chat-container">
         <template v-for="(item, idx) in replyList">
@@ -263,7 +298,9 @@ defineExpose({
       <div ref="bottomRef"></div>
       <div style="height: 1rem"></div>
     </el-scrollbar>
-    <div class="input-box" :class="{spread: global.spread}">
+    
+    <!-- 非立式一体机模式:输入框在底部 -->
+    <div v-if="!(isKioskDevice && global.spread)" class="input-box" :class="{spread: global.spread}">
       <div class="input-container hover-scale scale101">
         <el-input ref="textareaRef" v-model="message" class="input-container-input" placeholder="请输入你想咨询的问题..."
                   :type="global.spread ? 'textarea' : 'text'"
@@ -486,21 +523,42 @@ defineExpose({
 /* 1080x1920 中等尺寸设备 - 输入框优化 */
 @media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
   .chat-container {
+    /* 定义统一的内容区域宽度:与顶部导航栏对齐 */
+    --kiosk-content-padding: 2rem;
+    --kiosk-content-width: calc(100vw - var(--kiosk-content-padding) * 2);
+
+    .ss-chat {
+      /* 聊天容器:与顶部导航栏内容区域对齐 */
+      .ss-chat-container {
+        max-width: var(--kiosk-content-width);
+        width: 100%;
+        margin: 0 auto;
+        padding: 0;
+      }
+    }
+
     .input-box {
       margin-bottom: 4rem;
-      margin-right: 1.5rem;
-      margin-left: 1.5rem;
+      margin-right: 0;
+      margin-left: 0;
 
       .input-container {
-        max-width: calc(1100px + 2rem); /* 与 main-box 宽度对齐 */
+        max-width: var(--kiosk-content-width); /* 与顶部导航栏内容区域对齐 */
+        width: 100%;
+        margin: 0 auto;
         padding: 1.75rem 1.5rem;
         box-shadow: 0 0.5rem 1rem 0 rgba(0, 0, 0, 0.12);
 
+        /* 增大输入框文字大小 */
+        .input-container-input {
+          font-size: 1.125rem;
+        }
+
         .input-buttons {
           font-size: 1.5rem;
 
           .input-buttons-limit {
-            font-size: 1rem;
+            font-size: 1.125rem; /* 1rem → 1.125rem */
           }
 
           .input-buttons-right {
@@ -528,6 +586,25 @@ defineExpose({
         }
       }
     }
+
+    /* 立式一体机对话模式:输入框固定在对话区顶部 */
+    &.kiosk-chat {
+      display: flex;
+      flex-direction: column;
+      
+      .input-box-top {
+        order: 1;
+        flex-shrink: 0;
+        margin-top: 2rem;
+        margin-bottom: 1.5rem;
+      }
+      
+      .ss-chat {
+        order: 2;
+        flex: 1;
+        height: auto !important;
+      }
+    }
   }
 }
 

+ 92 - 46
web/src/components/SsRecording.vue

@@ -44,24 +44,35 @@ const stopCountDown = () => {
 }
 
 // 开始录音
-const startRecording = () => {
+const startRecording = async () => {
+  if (!recordEsm) {
+    ElMessage.error('录音插件未初始化')
+    cancelRecording()
+    return
+  }
+
   try {
-    recordEsm.startRecording({deviceId: deviceId.value}).then(() => {
-      isRecording.value = true
-      isProcessing.value = true
-      useTime.value = 0
-      startCountDown()
-    }).catch(() => {
-      ElMessage.warning('未找到或未授权录音设备')
-      availableAudioDevicesFlag.value = false
-      cancelRecording()
-    })
-  } catch (err) {
-    console.error(err)
-    ElMessage.error('录音错误')
-    recordingDialog.value = false
-    isProcessing.value = false
-    recordingDialog.value = false
+    await recordEsm.startRecording({deviceId: deviceId.value})
+    isRecording.value = true
+    isProcessing.value = true
+    useTime.value = 0
+    startCountDown()
+    console.log('录音已启动')
+  } catch (err: any) {
+    console.error('录音启动失败:', err)
+    availableAudioDevicesFlag.value = false
+    
+    // 根据错误类型提供更友好的提示
+    if (err.name === 'NotAllowedError') {
+      ElMessage.warning('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
+    } else if (err.name === 'NotFoundError') {
+      ElMessage.warning('未找到可用的麦克风设备')
+    } else if (err.name === 'NotReadableError') {
+      ElMessage.warning('麦克风被其他应用占用,请关闭其他使用麦克风的程序')
+    } else {
+      ElMessage.warning(`无法启动录音: ${err.message || '未知错误'}`)
+    }
+    cancelRecording()
   }
 }
 
@@ -82,7 +93,10 @@ const cancelRecording = () => {
 }
 
 const openRecordingDialog = () => {
+  console.log('打开录音对话框')
   isActiveCancel.value = false
+  isRecording.value = false
+  isProcessing.value = false
   recordingDialog.value = true
   nextTick(() => {
     initRecord(() => startRecording());
@@ -95,39 +109,69 @@ const setMessage = (message: string): void => {
   emit('setMessage', message)
 }
 
-const scanAvailableAudioDevices = (success: Function | null = null, fail: Function | null = null) => {
-  if (deviceId.value != '') {
-    if (success) success()
-  } else {
-    RecordPlugin.getAvailableAudioDevices().then((devices) => {
-      if (devices.length <= 0) {
-        availableAudioDevicesFlag.value = false
-        if (fail) fail()
-      } else {
-        devices.forEach((device) => {
-          if (deviceId.value == '') deviceId.value = device.deviceId
-        })
-        if (success) success();
+const scanAvailableAudioDevices = async (success: Function | null = null, fail: Function | null = null) => {
+  try {
+    // 先请求麦克风权限,这样才能获取到设备列表
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+    
+    // 获取权限后立即停止流,避免占用设备
+    stream.getTracks().forEach(track => track.stop())
+    
+    // 现在可以获取设备列表了
+    const devices = await RecordPlugin.getAvailableAudioDevices()
+    
+    if (devices.length <= 0) {
+      availableAudioDevicesFlag.value = false
+      ElMessage.error('未找到可用的音频设备,请检查麦克风连接')
+      if (fail) fail()
+    } else {
+      // 选择第一个可用设备
+      if (deviceId.value === '') {
+        deviceId.value = devices[0].deviceId
       }
-    })
+      console.log('找到音频设备:', devices.length, '个')
+      if (success) success()
+    }
+  } catch (err: any) {
+    console.error('获取音频设备失败:', err)
+    availableAudioDevicesFlag.value = false
+    
+    // 根据错误类型提供更友好的提示
+    if (err.name === 'NotAllowedError') {
+      ElMessage.error('麦克风权限被拒绝,请在浏览器设置中允许访问麦克风')
+    } else if (err.name === 'NotFoundError') {
+      ElMessage.error('未找到可用的麦克风设备,请检查设备连接')
+    } else {
+      ElMessage.error('无法访问麦克风,请检查浏览器权限设置')
+    }
+    
+    if (fail) fail()
   }
 }
 
-const initRecord = (callback: Function) => {
-  if (waveformRef.value) {
+const initRecord = async (callback: Function) => {
+  if (!waveformRef.value) {
+    cancelRecording()
+    ElMessage.warning('初始化失败')
+    return
+  }
+
+  try {
     wavesurfer = WaveSurfer.create({
       container: waveformRef.value,
       waveColor: '#5A92F8',
       progressColor: 'transparent',
     })
+    
     recordEsm = wavesurfer.registerPlugin(
-        RecordPlugin.create({
-          renderRecordedAudio: false,
-          scrollingWaveform: false,
-          continuousWaveform: false,
-          continuousWaveformDuration: 30
-        }),
+      RecordPlugin.create({
+        renderRecordedAudio: false,
+        scrollingWaveform: false,
+        continuousWaveform: false,
+        continuousWaveformDuration: 30
+      }),
     )
+    
     recordEsm.on('record-end', async (audioBlob: any) => {
       try {
         stopCountDown();
@@ -150,13 +194,15 @@ const initRecord = (callback: Function) => {
         recordingDialog.value = false
       }
     })
-    scanAvailableAudioDevices(callback, () => {
-      ElMessage.error('未找到音频设备')
+    
+    // 扫描设备并启动录音
+    await scanAvailableAudioDevices(callback, () => {
       cancelRecording()
     })
-  } else {
+  } catch (err) {
+    console.error('初始化录音插件失败:', err)
+    ElMessage.error('录音功能初始化失败')
     cancelRecording()
-    ElMessage.warning('初始化失败')
   }
 }
 
@@ -184,14 +230,14 @@ onUnmounted(() => {
         :close-on-press-escape="false"
     >
       <div v-if="isProcessing" class="waveformRef-title">录音将在{{ countDown }}秒后自动结束</div>
-      <div v-else class="waveformRef-title">正则扫描麦克风设备</div>
+      <div v-else class="waveformRef-title">正在检测麦克风设备...</div>
       <div ref="waveformRef" class="waveformRef"></div>
       <template #footer>
         <el-button round @click="cancelRecording">取消</el-button>
-        <el-button type="primary" round class="stop-recording" @click="stopRecording">
+        <el-button type="primary" round class="stop-recording" :disabled="!isRecording" @click="stopRecording">
           <el-image v-if="isRecording" class="record-icon" fit="contain" src="/images/recording-stop.png"/>
           <el-image v-else class="record-icon loading" fit="contain" src="/images/recording-loading.png"/>
-          {{ isRecording ? '我说完了' : '音频处理中' }}
+          {{ isRecording ? '我说完了' : '正在准备...' }}
         </el-button>
       </template>
     </el-dialog>

+ 5 - 1
web/src/components/SsService.vue

@@ -246,9 +246,13 @@ onMounted(() => {
           width: calc(100% - 3.5rem);
 
           .title {
-            font-size: 1.25rem; /* 1.125rem → 1.25rem */
+            font-size: 1.375rem; /* 1.25rem → 1.375rem */
             margin-bottom: 0.5rem;
           }
+
+          .subject {
+            font-size: 1rem; /* 增大副标题字体 */
+          }
         }
       }
 

+ 49 - 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)
   }
@@ -453,4 +461,45 @@ const refreshProblem = () => {
     }
   }
 }
+
+/* 1080x1920 立式一体机 - 聊天消息字体优化 */
+@media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
+  .ss-chat-reply {
+    .quota {
+      .quota-intro {
+        .quota-small {
+          .quota-title {
+            font-size: 1rem; /* 0.88rem → 1rem */
+          }
+        }
+
+        .quota-detail {
+          .quota-line {
+            .line-title {
+              font-size: 1rem; /* 0.88rem → 1rem */
+            }
+
+            .line-content {
+              .search-text {
+                font-size: 1rem; /* 0.88rem → 1rem */
+              }
+
+              .source-list {
+                .source-item {
+                  .source-title {
+                    font-size: 1.0625rem; /* 0.94rem → 1.0625rem */
+                  }
+
+                  .source-content {
+                    font-size: 1rem; /* 0.88rem → 1rem */
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
 </style>

+ 61 - 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);
@@ -610,4 +625,47 @@ watch(() => props.content, (newVal, oldVal) => {
 
 
 }
+
+/* 1080x1920 立式一体机 - Markdown 内容字体优化 */
+@media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
+  .markdown {
+    .content {
+      font-size: 1.25rem; /* 1.125rem → 1.25rem */
+
+      p {
+        font-size: 1.25rem; /* 1.125rem → 1.25rem */
+        line-height: 1.8;
+      }
+
+      li {
+        font-size: 1.25rem; /* 1.125rem → 1.25rem */
+        line-height: 1.8;
+      }
+
+      h1 {
+        font-size: 2.125rem; /* 2rem → 2.125rem */
+      }
+
+      h2 {
+        font-size: 1.875rem; /* 1.75rem → 1.875rem */
+      }
+
+      h3 {
+        font-size: 1.625rem; /* 1.5rem → 1.625rem */
+      }
+
+      h4 {
+        font-size: 1.375rem; /* 1.25rem → 1.375rem */
+      }
+
+      code {
+        font-size: 1.125rem; /* 1rem → 1.125rem */
+      }
+
+      pre code {
+        font-size: 1.125rem; /* 1rem → 1.125rem */
+      }
+    }
+  }
+}
 </style>

+ 13 - 0
web/src/components/ss_chat/components/SsChatSendMessage.vue

@@ -77,4 +77,17 @@ const editMessage = () => {
     }
   }
 }
+
+/* 1080x1920 立式一体机 - 发送消息字体优化 */
+@media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
+  .ss-chat-send-message {
+    .message {
+      .bubble {
+        font-size: 1.25rem; /* 1.125rem → 1.25rem */
+        line-height: 1.6;
+        padding: 1.25rem 1.875rem;
+      }
+    }
+  }
+}
 </style>

+ 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'
+}

+ 353 - 6
web/src/views/HomeView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import {ref, watch, onMounted, defineAsyncComponent} from 'vue'
+import {ref, watch, onMounted, defineAsyncComponent, computed} from 'vue'
 
 import {BasicApi} from '@/api/basic'
 import {RequestError} from '@/utils/request'
@@ -39,6 +39,20 @@ const mainBoxFlag = ref(true)
 const timeoutFlag = ref()
 const mainContentClass = ref({})
 
+/**
+ * 立式一体机设备检测
+ * 检测条件:宽度 1000-1200px,高度 1700-2000px
+ */
+const isKioskDevice = ref(false)
+
+/**
+ * 立式一体机模式下的常见问题列表
+ * 从 global.commonProblem 派生,取前 4 条用于底部展示
+ */
+const kioskFaqList = computed(() => {
+  return global.commonProblem.slice(0, 4)
+})
+
 const inputBox = ref<InstanceType<typeof SsInputBox> | null>(null)
 const commonProblem = ref<InstanceType<typeof SsCommonProblem> | null>(null)
 const hotlineRef = ref<InstanceType<typeof SsHotline> | null>(null)
@@ -118,11 +132,51 @@ const openPolicyFile = () => {
   }
 }
 
+/**
+ * 返回首页:重置对话状态
+ */
+const goHome = () => {
+  global.setSpread(false)
+  global.setMenuSwitch(false)
+  global.setInReply(false)
+}
+
+/**
+ * 获取服务项样式类(复用 SsService 组件逻辑)
+ */
+const getServiceClass = (index: number) => {
+  switch (index % 5) {
+    case 0:
+      return 'hover-scale'
+    case 1:
+      return 'hover-scale yellow'
+    case 2:
+      return 'center'
+    case 3:
+      return 'hover-scale yellow'
+    case 4:
+      return 'hover-scale'
+    default:
+      return ''
+  }
+}
+
+/**
+ * 检测是否为立式一体机设备
+ * 判断依据:屏幕宽度 1000-1200px,高度 1700-2000px
+ */
+const detectKioskDevice = () => {
+  const w = window.innerWidth
+  const h = window.innerHeight
+  isKioskDevice.value = (w >= 1000 && w <= 1200 && h >= 1700 && h <= 2000)
+}
+
 const initPage = () => {
   let w = window.screen.width
   if (w <= 750) {
     global.setMenuSwitch(false)
   }
+  detectKioskDevice()
   setMainContentClass()
 }
 
@@ -155,8 +209,40 @@ watch(() => global.inputLine, () => {
 
 <template>
   <div class="root">
-    <ss-header @loaded="componentLoaded"/>
+    <!-- 1080x1920 立式一体机对话模式:顶部导航栏替代 Header -->
+    <div v-if="global.spread && isKioskDevice" class="kiosk-top-nav">
+      <div class="back-home-btn" @click="goHome">
+        <span class="back-icon">←</span>
+        <span>返回首页</span>
+      </div>
+      <h1 class="app-title">商小川<span>智能助手</span></h1>
+      <div class="nav-spacer"></div>
+    </div>
+    
+    <!-- 立式一体机对话模式:服务快捷入口栏(复用原有设计) -->
+    <div v-if="global.spread && isKioskDevice" class="kiosk-service-bar">
+      <div class="service-bar-container">
+        <div 
+          v-for="(item, index) in global.categories" 
+          :key="item.id"
+          class="service-quick-item"
+          :class="getServiceClass(index)"
+          @click="loadService(item.id, item.categoryName, item.imgUrl)"
+        >
+          <div class="service-item-content">
+            <div class="service-title">{{ item.categoryName }}</div>
+          </div>
+          <el-image class="service-item-image" fit="contain" :src="getImageUrl(item.imgUrl)"/>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 默认 Header(非立式一体机模式) -->
+    <ss-header v-else @loaded="componentLoaded"/>
+    
+    <!-- 侧边栏导航:立式一体机对话模式下隐藏 -->
     <ss-navigation
+        v-if="!(global.spread && isKioskDevice)"
         @open-hotline="openHotlineDialog"
         @open-opinion="openOpinionDialog"
         @quick-send="quickSend"
@@ -164,11 +250,30 @@ watch(() => global.inputLine, () => {
         @load-common-problem="loadCommonProblem"
         @loaded="componentLoaded"
     />
-    <div class="container" :class="{spread:  global.spread, 'menu-close': !global.menuSwitch}">
+    <div class="container" :class="{spread: global.spread, 'menu-close': !global.menuSwitch, 'kiosk-mode': isKioskDevice && global.spread}">
       <div class="main">
         <div class="main-content" :class="mainContentClass">
           <ss-headline v-if="mainBoxFlag"/>
           <ss-input-box ref="inputBox" @loaded="componentLoaded"/>
+          
+          <!-- 立式一体机对话模式:底部常见问题展示区 -->
+          <div v-if="global.spread && isKioskDevice" class="kiosk-bottom-faq">
+            <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"
+                  @click="quickSend(item.questionContent)"
+                >
+                  {{ item.questionContent }}
+                </div>
+              </div>
+            </div>
+          </div>
+          
+          <!-- 默认布局:服务导航 + 常见问题 -->
           <div v-if="mainBoxFlag" class="main-box">
             <div class="ss-row ss-row-vertical">
               <div class="ss-col ss-col-service is-mobile">
@@ -197,7 +302,8 @@ watch(() => global.inputLine, () => {
             </div>
           </div>
         </div>
-        <ss-footer @loaded="componentLoaded"/>
+        <!-- 立式一体机对话模式下隐藏 Footer,避免与底部 FAQ 区域重叠 -->
+        <ss-footer v-if="!(global.spread && isKioskDevice)" @loaded="componentLoaded"/>
       </div>
     </div>
     <ss-hotline ref="hotlineRef"/>
@@ -293,13 +399,17 @@ watch(() => global.inputLine, () => {
   }
 }
 
-/* 1080x1920 中等尺寸设备优化 */
+/* 1080x1920 立式一体机专用布局优化 */
 @media screen and (min-width: 751px) and (max-width: 1200px) and (min-height: 1700px) {
   .root {
+    /* 定义统一的内容区域宽度:屏幕宽度减去左右 padding */
+    --kiosk-content-padding: 2rem;
+    --kiosk-content-width: calc(100vw - var(--kiosk-content-padding) * 2);
+
     .container {
       .main {
         .main-content {
-          padding: 5rem 0 5rem 0; /* 增加底部 padding 为 Footer 留空间 */
+          padding: 5rem 0 5rem 0;
 
           .main-box {
             max-width: calc(1100px + 2rem);
@@ -342,6 +452,243 @@ watch(() => global.inputLine, () => {
           }
         }
       }
+
+      /* 立式一体机对话模式:三段式布局 */
+      &.kiosk-mode {
+        padding-left: 0 !important;
+
+        .main {
+          .main-content {
+            display: flex;
+            flex-direction: column;
+            height: 100vh;
+            padding: 200px 0 0 0; /* 顶部留出导航栏(100px) + 服务栏(100px)空间 */
+
+            /* 对话区域:占据主要空间 */
+            .chat-container {
+              flex: 1;
+              display: flex;
+              flex-direction: column;
+              min-height: 0; /* 允许 flex 子元素收缩 */
+
+              /* 聊天消息区域:保持原有宽度(768px),无需覆盖 */
+              /* 输入框:保持原有宽度(768px),无需覆盖 */
+            }
+
+            /* 底部常见问题展示区:固定 420px,与输入框宽度对齐 */
+            .kiosk-bottom-faq {
+              flex-shrink: 0;
+              height: 420px;
+              background: transparent;
+              padding: 0;
+              margin-top: -2rem;
+              overflow: hidden;
+              display: flex;
+              flex-direction: column;
+              align-items: center;
+
+              .faq-card {
+                width: var(--kiosk-content-width); /* 与顶部导航栏内容区域对齐 */
+                max-width: 100%;
+                padding: 2rem;
+                background: #ffffff; /* 白色卡片背景 */
+
+                .faq-title {
+                  font-size: 1.75rem;
+                  font-weight: 600;
+                  color: #101333;
+                  margin-bottom: 1.5rem;
+                  text-align: left; /* 左对齐 */
+                }
+
+                .faq-list {
+                  display: flex;
+                  flex-direction: column;
+                  gap: 1rem;
+
+                  .faq-item {
+                    font-size: 1.25rem;
+                    color: #545764;
+                    line-height: 1.6;
+                    padding: 0.75rem 1rem;
+                    background: #ffffff;
+                    border-radius: 0.75rem;
+                    border: 1px solid #D5D6D8;
+                    cursor: pointer;
+                    transition: all 0.2s ease;
+
+                    &:hover {
+                      background: #F6F7FB;
+                      border-color: #2943D6;
+                      transform: translateX(0.25rem);
+                    }
+
+                    &:active {
+                      background: #EAEDFB;
+                      transform: translateX(0.125rem);
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    }
+
+    /* 立式一体机顶部导航栏:固定 100px */
+    .kiosk-top-nav {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      height: 100px;
+      background: #ffffff;
+      border-bottom: 1px solid #E5E7EB;
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 0 2rem;
+      z-index: 1000;
+
+      .back-home-btn {
+        display: flex;
+        align-items: center;
+        gap: 0.5rem;
+        padding: 0.75rem 1.5rem;
+        border-radius: 2.5rem; /* 胶囊形状 */
+        border: 1px solid #e5e5e5;
+        background: #ffffff;
+        color: #333333;
+        font-size: 1rem;
+        font-weight: 400;
+        cursor: pointer;
+        transition: all 0.2s ease;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+
+        &:hover {
+          background: #f7f7f7;
+          border-color: #d5d5d5;
+          box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
+        }
+
+        &:active {
+          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.125rem;
+          font-weight: 500;
+        }
+      }
+
+      .app-title {
+        font-size: 3rem;
+        font-weight: 700;
+        color: #555964;
+        letter-spacing: 0.125rem;
+        margin: 0;
+        /* 核心:文字双重阴影营造新拟态效果 */
+        text-shadow: 
+          4px 4px 8px #d1d9e6,    /* 右下深色阴影 */
+          -4px -4px 8px #ffffff;  /* 左上高光 */
+        
+        /* 让"智能助手"四个字稍微淡一点,突出"商小川" */
+        span {
+          font-weight: 300;
+          color: #8990a0;
+          font-size: 0.8em; /* 稍微小一点 */
+        }
+      }
+
+      .nav-spacer {
+        width: 120px; /* 占位,保持标题居中 */
+      }
+    }
+
+    /* 立式一体机服务快捷入口栏:紧贴顶部导航栏下方,复用原有设计 */
+    .kiosk-service-bar {
+      position: absolute;
+      top: 100px;
+      left: 0;
+      right: 0;
+      height: 100px; /* 从 120px 减小到 100px */
+      background: #ffffff;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      padding: 0.75rem 0; /* 从 1rem 减小到 0.75rem */
+      z-index: 999;
+      overflow: hidden;
+
+      .service-bar-container {
+        width: var(--kiosk-content-width); /* 与顶部导航栏内容区域对齐 */
+        max-width: 100%;
+        margin: 0 auto; /* 居中对齐 */
+        display: flex;
+        justify-content: space-between; /* 均匀分布 */
+        gap: 1rem;
+        overflow: hidden;
+        padding: 0;
+
+        .service-quick-item {
+          position: relative;
+          flex: 1; /* 弹性宽度,均匀分布 */
+          min-width: 140px; /* 最小宽度保证可读性 */
+          height: 70px; /* 从 90px 减小到 70px */
+          border-radius: 0.75rem;
+          overflow: hidden;
+          cursor: pointer;
+          transition: transform 0.3s ease;
+          /* 复用原有渐变背景 */
+          background: linear-gradient(180deg, rgba(250, 255, 253, 0.8), rgba(225, 245, 248, 0.8));
+          box-shadow: 0 0.25rem 1.25rem 0 rgba(52, 149, 239, 0.4);
+
+          &.yellow {
+            background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 240, 239, 0.7));
+          }
+
+          &.center {
+            background: linear-gradient(90deg, #c2dff9, #c6e2fa);
+          }
+
+          &.hover-scale:hover {
+            transform: scale(1.05);
+          }
+
+          .service-item-content {
+            position: absolute;
+            top: 0;
+            left: 0;
+            right: 0;
+            bottom: 0;
+            z-index: 2;
+            padding: 1rem;
+            display: flex;
+            align-items: center;
+
+            .service-title {
+              color: #101333;
+              font-size: 1.125rem; /* 1rem → 1.125rem */
+              font-weight: 600;
+              line-height: 1.2;
+            }
+          }
+
+          .service-item-image {
+            position: absolute;
+            bottom: 0;
+            right: 0.5rem;
+            z-index: 1;
+            width: 4rem;
+            height: 3rem;
+            opacity: 0.2;
+          }
+        }
+      }
     }
   }
 }