ソースを参照

feat(components): 添加机器人任务详情弹窗组件及相关功能

feat(assets): 新增机器人相关图片资源

feat(config): 添加机器人配置文件

feat(composables): 实现任务详情和SSE相关逻辑

feat(data): 添加机器人任务数据模块

refactor(components/Header): 更新项目名称显示

chore(package.json): 添加marked依赖

docs(env.d.ts): 扩展类型声明

style(components/Footer): 优化机器人展示样式和交互

feat(components): 实现Markdown渲染组件

feat(components): 添加任务检测表格组件

feat(assets/styles): 添加任务详情弹窗样式文件

chore: 添加git提交信息规范文档
easyforever 1 週間 前
コミット
b9911c2de9

+ 78 - 0
.trae/rules/git-commit-message.md

@@ -0,0 +1,78 @@
+---
+alwaysApply: true
+scene: git_message
+---
+
+# Git Commit Message 规范(Vue3 + TS 项目专用)
+
+请严格遵循 **Conventional Commits** 格式生成提交信息:
+`[type]([scope]): [subject]`
+
+---
+
+## 1. 提交类型(必填,只能用以下列表)
+
+| 类型       | 说明                                                     |
+| :--------- | :------------------------------------------------------- |
+| `feat`     | 新增功能/模块/页面                                       |
+| `fix`      | 修复 Bug(包括样式、逻辑、交互问题)                     |
+| `docs`     | 仅修改文档、注释                                         |
+| `style`    | 仅调整代码格式(空格、换行、分号等,不影响逻辑)         |
+| `refactor` | 重构代码(既不新增功能,也不修复 Bug)                   |
+| `perf`     | 性能优化(渲染、请求、加载速度等)                       |
+| `test`     | 新增/修改测试用例                                        |
+| `build`    | 构建流程、依赖项、打包配置变更                           |
+| `ci`       | CI/CD 配置变更                                           |
+| `chore`    | 其他不影响源码/测试的变更(如 `.gitignore`、编辑器配置) |
+
+---
+
+## 2. 作用域(必填,根据你的项目目录填写)
+
+必须从以下模块中选择,保持与项目结构一致:
+
+- `views`: 页面/路由视图(如首页、列表页、详情页)
+- `components`: 公共组件/业务组件
+- `stores`: Pinia 状态管理
+- `router`: 路由配置
+- `utils`: 工具函数/通用方法
+- `assets`: 静态资源(图片、样式文件)
+- `config`: 配置文件(如 robotConfig.ts)
+- `data`: 模拟数据/常量配置
+- `App`: 根组件/全局配置
+- `config`: 构建/环境配置(如 vite.config.ts、tsconfig.json)
+
+---
+
+## 3. 主题描述(必填)
+
+- 语言:优先使用英文,若中文更清晰也可使用
+- 长度:不超过 72 个字符
+- 格式:动宾结构,清晰描述变更内容,结尾不加句号
+- 禁止:模糊描述(如“修改代码”、“调整样式”)
+
+---
+
+## 4. 完整示例(直接参考格式)
+
+✅ 正确示例:
+
+- `feat(views): add elderly institution list page`
+- `fix(components): fix modal title background color issue`
+- `refactor(stores): optimize user info store logic`
+- `chore: update gitignore rules`
+- `perf(utils): optimize echarts data processing function`
+
+❌ 错误示例:
+
+- `修改了一些东西`
+- `feat: 做了个新功能`
+- `fix(views): 改了页面`
+
+---
+
+## 5. 额外要求
+
+1. 一次提交尽量只做一件事,避免多类型变更混在一起
+2. 若变更涉及多个模块,可在 `scope` 中用逗号分隔(如 `feat(views, components): add new page and related components`)
+3. 重大变更可在提交信息的 body 中补充说明(可选)

+ 1 - 0
package.json

@@ -13,6 +13,7 @@
     "axios": "^1.7.9",
     "echarts": "^5.5.1",
     "element-plus": "^2.8.5",
+    "marked": "^18.0.2",
     "pinia": "^2.2.6",
     "swiper": "^12.1.3",
     "vue": "^3.5.13",

BIN
src/assets/images/modal_border.png


BIN
src/assets/images/modal_title_bg.png


BIN
src/assets/images/number_box.png


BIN
src/assets/images/robot_ pedestal.png


BIN
src/assets/images/robot_analyst.png


BIN
src/assets/images/robot_auditor.jpg


BIN
src/assets/images/robot_coordinatedDispatcher.jpg


BIN
src/assets/images/robot_dietaryNutritionist.jpg


BIN
src/assets/images/robot_familyLiaison.jpg


BIN
src/assets/images/robot_safetyInspector.jpg


BIN
src/assets/images/robot_warningSupervisor.jpg


+ 252 - 0
src/assets/styles/TaskDetailModal.scss

@@ -0,0 +1,252 @@
+$neon-green: #00d6c0;
+$neon-blue: #00fff4;
+$neon-cyan: #00ffe6;
+
+.task-detail-container {
+  width: 100%;
+  height: 100%;
+  // 主要内容区域
+  display: grid;
+  grid-template-columns: 750px 1fr;
+  grid-template-rows: 1fr 176px;
+  grid-template-areas:
+    'robot content'
+    'task-list task-list';
+  gap: 40px;
+
+  .robot-section {
+    grid-area: robot;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 30px;
+    padding: 100px 0;
+
+    .section-title {
+      font-size: 48px;
+      color: $neon-cyan;
+      font-weight: 600;
+      text-align: center;
+      line-height: 72px;
+      letter-spacing: 2px;
+    }
+
+    .robot-container {
+      position: relative;
+      margin-top: 40px;
+      padding-top: 40px;
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+
+      .robot-image {
+        width: 486px;
+        height: 438px;
+        margin-left: 20px;
+      }
+
+      .placeholder-image {
+        width: 100%;
+        height: 100%;
+        background: transparent;
+      }
+
+      .robot-pedestal {
+        width: 512px;
+        height: 83px;
+      }
+
+      .robot-status {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+        display: flex;
+        gap: 16px;
+
+        .status-dot {
+          width: 16px;
+          height: 16px;
+          border-radius: 50%;
+          animation: scale-pulse 1.5s infinite ease-in-out;
+
+          &:nth-child(1) {
+            background: #dd3131;
+          }
+
+          &:nth-child(2) {
+            background: #fa8b0d;
+            animation-delay: 0.4s;
+          }
+
+          &:nth-child(3) {
+            background: #fdd330;
+            animation-delay: 0.8s;
+          }
+        }
+      }
+    }
+
+    .stats-section {
+      width: 100%;
+      margin-top: 80px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 40px;
+
+      .stat-item {
+        display: flex;
+        align-items: center;
+        gap: 10px;
+
+        .stat-label {
+          color: $neon-cyan;
+          font-weight: 600;
+          font-size: 40px;
+          line-height: 72px;
+          letter-spacing: 2px;
+          white-space: nowrap;
+        }
+
+        .number-container {
+          display: flex;
+          gap: 10px;
+
+          .number-item {
+            width: 82px;
+            height: 95px;
+            padding-bottom: 6px;
+            background: url('@/assets/images/number_box.png');
+            background-size: 100% 100%;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            font-weight: 600;
+            font-size: 73px;
+            line-height: 72px;
+            letter-spacing: 2px;
+            color: $neon-cyan;
+          }
+        }
+
+        .stat-unit {
+          color: $neon-cyan;
+          font-weight: 600;
+          font-size: 40px;
+          line-height: 72px;
+          letter-spacing: 2px;
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+
+  .content-section {
+    grid-area: content;
+    height: 100%;
+    display: flex;
+    flex-direction: column;
+    gap: 30px;
+    border: 1px solid $neon-cyan;
+    border-left-color: #086f6a;
+    border-right-color: #086f6a;
+    // border-radius: 8px;
+    padding-bottom: 20px;
+    overflow: hidden;
+  }
+
+  .task-list-section {
+    grid-area: task-list;
+    width: calc(100% - 40px);
+    height: 176px;
+    margin: 0 0 0 auto;
+    padding: 20px 30px;
+    border-top: 2px solid rgba(0, 255, 230, 0.5);
+    border-bottom: 2px solid rgba(0, 255, 230, 0.5);
+    background: radial-gradient(ellipse at center, transparent 60%, rgba(9, 51, 56, 0.8) 100%);
+
+    .task-list-container {
+      height: 100%;
+      display: flex;
+      overflow-x: auto;
+
+      &::-webkit-scrollbar {
+        display: none;
+      }
+
+      .task-item {
+        flex-shrink: 0;
+        width: 408px;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        gap: 10px;
+        min-width: 150px;
+        padding: 20px 0;
+        border-top: 1px solid rgba(0, 255, 230, 0.3);
+        border-left: 1px solid rgba(0, 255, 230, 0.3);
+        cursor: pointer;
+
+        &:last-child {
+          border-right: 1px solid rgba(0, 255, 230, 0.3);
+        }
+
+        &.is-selected {
+          background: rgba(0, 255, 230, 0.1);
+        }
+
+        .task-title {
+          display: flex;
+          align-items: center;
+          font-weight: 400;
+          font-size: 32px;
+          color: $neon-cyan;
+          line-height: 72px;
+          letter-spacing: 1px;
+
+          .clock-icon {
+            color: $neon-cyan;
+            margin-right: 8px;
+          }
+        }
+
+        .task-time {
+          font-weight: 400;
+          font-size: 32px;
+          color: $neon-blue;
+        }
+
+        .task-status {
+          width: 28px;
+          height: 28px;
+          margin-left: 32px;
+          border-radius: 50%;
+
+          &.success {
+            background: #13ccff;
+          }
+
+          &.warning,
+          &.info {
+            background: #00ff8c;
+          }
+
+          &.danger {
+            background: #f72626;
+          }
+        }
+      }
+    }
+  }
+}
+
+// 状态点动画
+@keyframes scale-pulse {
+  0%,
+  100% {
+    transform: scale(1);
+  }
+  50% {
+    transform: scale(1.5);
+  }
+}

+ 83 - 38
src/components/Footer.vue

@@ -5,47 +5,56 @@
 
     <div class="footer-content">
       <div class="robot-container">
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">膳食营养AI监管员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">消防安全AI监管员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">津贴补贴AI审核员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">居家服务AI调度员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">家属沟通AI联络员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">AI协同调度监管员</div>
-        </div>
-        <div class="robot-item">
-          <div class="robot-placeholder"></div>
-          <div class="robot-label">廉政风险AI监督员</div>
+        <div
+          v-for="robot in robots"
+          :key="robot.type"
+          class="robot-item"
+          @click="showTaskDetail(robot.type)"
+        >
+          <div class="robot-placeholder">
+            <img :src="getImageUrl(robot.image)" :alt="robot.name" class="robot-image" />
+          </div>
+          <div class="robot-label">{{ robot.name }}</div>
         </div>
       </div>
     </div>
+
+    <!-- 任务详情弹窗 -->
+    <TaskDetailModal
+      :visible="taskDetailVisible"
+      :robot-type="currentRobotType"
+      @close="taskDetailVisible = false"
+    />
   </footer>
 </template>
 
 <script setup lang="ts">
-// Footer组件
+import { ref } from 'vue'
+import TaskDetailModal from './TaskDetailModal.vue'
+import { robotConfig } from '../config/robotConfig'
+
+// 弹窗状态
+const taskDetailVisible = ref(false)
+const currentRobotType = ref('fireSafety')
+const robots = robotConfig
+
+// 显示任务详情
+const showTaskDetail = (robotType: string) => {
+  currentRobotType.value = robotType
+  taskDetailVisible.value = true
+}
+
+// 获取图片URL
+const getImageUrl = (image: string) => {
+  return new URL(`../assets/images/${image}`, import.meta.url).href
+}
 </script>
 
 <style lang="scss" scoped>
 // SCSS变量定义
 $footer-height: 218.62px;
-$robot-height: 93.9px;
+$robot-width: 88px;
+$robot-height: 94px;
 $neon-cyan: #78fee0;
 $transition-speed: 0.3s ease;
 
@@ -96,22 +105,57 @@ $transition-speed: 0.3s ease;
         gap: 10px;
         width: 204px;
         height: 144px;
+        cursor: pointer;
+        transition: all $transition-speed;
+
+        &:hover {
+          transform: translateY(-10px);
+
+          .robot-placeholder::after {
+            background: rgba(0, 255, 230, 0.4);
+            box-shadow: 0 0 20px rgba(0, 255, 230, 0.5);
+          }
+
+          .robot-label {
+            color: #fff;
+            text-shadow: 0 0 10px rgba(0, 255, 230, 0.8);
+          }
+        }
 
         // 机器人占位符
         .robot-placeholder {
-          width: 88px;
-          height: $robot-height;
-          background: rgba(0, 255, 230, 0.2);
-          border: 1px solid $neon-cyan;
-          border-radius: 8px;
+          position: relative;
+          width: 182px;
+          height: 90px;
           display: flex;
           align-items: center;
           justify-content: center;
+          transition: all $transition-speed;
+
+          &::after {
+            content: '';
+            position: absolute;
+            bottom: -28px;
+            left: 50%;
+            transform: translateX(-50%) perspective(200px) rotateX(70deg);
+            width: 80%;
+            height: 80px;
+            border: 2px solid #2dd4bf;
+            border-top: none;
+            // background: linear-gradient(
+            //   to bottom,
+            //   rgba(45, 212, 191, 0.1),
+            //   rgba(45, 212, 191, 0.3)
+            // );
+            background: linear-gradient(to top, rgba(45, 212, 191, 0.3), rgba(45, 212, 191, 0.05));
+            box-shadow: 0 0 20px rgba(0, 255, 230, 0.3);
+          }
 
-          &:after {
-            content: '机器人';
-            color: $neon-cyan;
-            font-size: 14px;
+          .robot-image {
+            width: $robot-width;
+            height: $robot-height;
+            object-fit: cover;
+            border-radius: 7px;
           }
         }
 
@@ -122,6 +166,7 @@ $transition-speed: 0.3s ease;
           line-height: 42px;
           text-align: center;
           white-space: nowrap;
+          transition: all $transition-speed;
         }
       }
     }

+ 1 - 1
src/components/Header.vue

@@ -44,7 +44,7 @@ const selectedYear = ref(new Date().getFullYear())
 const dropdownOpen = ref(false)
 
 // Props
-const projectName = ref('淮阳区纪检同步·养老互老数字化治理中心')
+const projectName = ref('淮阳区“五心联护”智慧养老服务平台')
 
 // 计算属性:生成年份选项(当前年及前后5年)
 const years = computed(() => {

+ 147 - 0
src/components/MarkdownRenderer.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="markdown-body" v-html="renderedHtml"></div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { marked } from 'marked'
+
+interface Props {
+  content: string
+  sanitize?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  content: '',
+  sanitize: true,
+})
+
+// 配置marked库,启用gfm模式以支持单个换行符转换为<br>
+marked.setOptions({
+  gfm: true,
+  breaks: true, // 启用单换行符转换为<br>
+})
+
+const renderedHtml = computed(() => {
+  if (!props.content) return ''
+  const html = marked.parse(props.content) as string
+  return props.sanitize ? html : html
+})
+</script>
+
+<style lang="scss" scoped>
+.markdown-body {
+  color: #00ffe6;
+  font-size: 30px;
+  line-height: 1.6;
+
+  :deep(h1),
+  :deep(h2),
+  :deep(h3),
+  :deep(h4),
+  :deep(h5),
+  :deep(h6) {
+    color: #00ffe6;
+    margin-top: 24px;
+    margin-bottom: 16px;
+    font-weight: 600;
+  }
+
+  :deep(h1) {
+    font-size: 48px;
+  }
+
+  :deep(h2) {
+    font-size: 42px;
+  }
+
+  :deep(h3) {
+    font-size: 36px;
+  }
+
+  :deep(p) {
+    margin-bottom: 16px;
+  }
+
+  :deep(ul),
+  :deep(ol) {
+    margin-bottom: 16px;
+    padding-left: 24px;
+  }
+
+  :deep(li) {
+    margin-bottom: 8px;
+  }
+
+  :deep(strong) {
+    color: #00ffe6;
+    font-weight: 600;
+  }
+
+  :deep(code) {
+    background: rgba(0, 255, 230, 0.1);
+    padding: 2px 6px;
+    border-radius: 4px;
+    font-family: 'Courier New', monospace;
+    font-size: 27px;
+  }
+
+  :deep(pre) {
+    background: rgba(0, 0, 0, 0.3);
+    padding: 16px;
+    border-radius: 8px;
+    overflow-x: auto;
+    margin-bottom: 16px;
+
+    code {
+      background: none;
+      padding: 0;
+      font-size: 27px;
+    }
+  }
+
+  :deep(blockquote) {
+    border-left: 4px solid #00ffe6;
+    padding-left: 16px;
+    margin: 16px 0;
+    color: rgba(0, 255, 230, 0.7);
+  }
+
+  :deep(table) {
+    width: 100%;
+    border-collapse: collapse;
+    margin-bottom: 16px;
+  }
+
+  :deep(th),
+  :deep(td) {
+    border: 1px solid rgba(0, 255, 230, 0.3);
+    padding: 8px 12px;
+    text-align: left;
+  }
+
+  :deep(th) {
+    background: rgba(0, 255, 230, 0.1);
+  }
+
+  :deep(a) {
+    color: #00fff4;
+    text-decoration: underline;
+
+    &:hover {
+      color: #00d6c0;
+    }
+  }
+
+  :deep(hr) {
+    border: none;
+    border-top: 1px solid rgba(0, 255, 230, 0.3);
+    margin: 24px 0;
+  }
+
+  :deep(img) {
+    max-width: 100%;
+    height: auto;
+  }
+}
+</style>

+ 157 - 0
src/components/ModalDialog.vue

@@ -0,0 +1,157 @@
+<template>
+  <div class="modal-overlay" v-if="visible" @click="handleOverlayClick">
+    <div class="modal-wrapper" @click.stop>
+      <div class="modal-container">
+        <!-- 标题区域 -->
+        <div class="modal-header">
+          <h2 class="modal-title">{{ title }}</h2>
+          <!-- 右侧插槽 -->
+          <div class="modal-header-right">
+            <slot name="header-right"></slot>
+          </div>
+        </div>
+
+        <!-- 内容区域 -->
+        <div class="modal-body">
+          <slot></slot>
+        </div>
+      </div>
+
+      <!-- 关闭按钮 -->
+      <button class="modal-close" @click="handleClose">
+        <CloseBold class="close-symbol" />
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+// import { defineProps, defineEmits } from 'vue'
+import { CloseBold } from '@element-plus/icons-vue'
+
+interface Props {
+  visible: boolean
+  title: string
+}
+
+const props = defineProps<Props>()
+const emit = defineEmits<{
+  close: []
+}>()
+
+const handleClose = () => {
+  emit('close')
+}
+
+const handleOverlayClick = () => {
+  emit('close')
+}
+</script>
+
+<style lang="scss" scoped>
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background-color: rgba(0, 0, 0, 0.7);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+
+  .modal-wrapper {
+    position: relative;
+
+    .modal-container {
+      width: 3320px;
+      height: 1800px;
+      background-color: rgba(0, 39, 41, 0.9);
+      border: 1px solid #b8e8f0;
+      border-radius: 8px;
+      box-shadow: inset 0 0 8px rgba(184, 232, 240, 0.15);
+      position: relative;
+
+      .modal-header {
+        height: 107px;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: relative;
+        background: linear-gradient(to right, rgba(6, 65, 68, 0.6), rgba(255, 255, 255, 0.05));
+
+        .modal-title {
+          font-weight: 600;
+          font-size: 48px;
+          color: #00ffe6;
+          line-height: 72px;
+          letter-spacing: 2px;
+          margin: 0;
+          white-space: pre;
+        }
+
+        .modal-header-right {
+          position: absolute;
+          right: 100px;
+          display: flex;
+          align-items: center;
+        }
+      }
+
+      .modal-body {
+        padding: 40px;
+        height: calc(100% - 107px);
+        overflow-y: auto;
+
+        &::-webkit-scrollbar {
+          width: 12px;
+        }
+
+        &::-webkit-scrollbar-track {
+          background: rgba(0, 255, 230, 0.1);
+          border-radius: 6px;
+        }
+
+        &::-webkit-scrollbar-thumb {
+          background: rgba(0, 255, 230, 0.5);
+          border-radius: 6px;
+        }
+
+        &::-webkit-scrollbar-thumb:hover {
+          background: rgba(0, 255, 230, 0.8);
+        }
+      }
+    }
+
+    .modal-close {
+      position: absolute;
+      top: -62px;
+      right: -62px;
+      width: 124px;
+      height: 124px;
+      border-radius: 50%;
+      background: #004b47;
+      box-shadow: inset 0px 4px 10px 0px rgba(0, 255, 230, 0.5);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      cursor: pointer;
+      transition: all 0.3s ease;
+      z-index: 1001;
+
+      &:hover {
+        background-color: #004b47e3;
+        transform: scale(1.1);
+      }
+
+      .close-symbol {
+        width: 60px;
+        color: rgba(120, 254, 224, 0.6);
+        font-weight: bold;
+        line-height: 1;
+      }
+    }
+  }
+}
+</style>

+ 202 - 0
src/components/TaskDetailModal.vue

@@ -0,0 +1,202 @@
+<template>
+  <ModalDialog
+    :visible="visible"
+    :title="`${taskData.title}     任务时间:${taskData.taskTime}`"
+    @close="handleClose"
+  >
+    <!-- 右侧详情按钮 -->
+    <template #header-right>
+      <button class="detail-btn" @click="toggleDetails">
+        <el-icon v-if="showDetails" style="margin-right: 18px"><DArrowLeft /></el-icon>
+        <el-icon v-else style="margin-right: 18px"><DArrowRight /></el-icon>
+        {{ showDetails ? '返回' : '详情' }}
+      </button>
+    </template>
+
+    <!-- 弹窗内容 -->
+    <div class="task-detail-container">
+      <!-- 左侧机器人区域 -->
+      <div class="robot-section">
+        <h3 class="section-title">{{ taskData.robotName }}</h3>
+        <div class="robot-container">
+          <el-image :src="getRobotImage(robotType)" :alt="taskData.robotName" class="robot-image">
+            <template #placeholder>
+              <div class="placeholder-image"></div>
+            </template>
+            <template #error>
+              <div class="error-image">图片加载失败</div>
+            </template>
+          </el-image>
+          <img src="../assets/images/robot_ pedestal.png" alt="机器人底座" class="robot-pedestal" />
+          <div class="robot-status" v-if="isCurrentTaskRunning">
+            <span class="status-dot"></span>
+            <span class="status-dot"></span>
+            <span class="status-dot"></span>
+          </div>
+        </div>
+
+        <!-- 统计数据 -->
+        <div class="stats-section">
+          <div class="stat-item">
+            <div class="stat-label">工作任务:</div>
+            <div class="number-container">
+              <div class="number-item" v-for="(digit, index) in workTaskDigits" :key="index">
+                {{ digit }}
+              </div>
+            </div>
+            <div class="stat-unit">次</div>
+          </div>
+          <div class="stat-item">
+            <div class="stat-label">发现问题:</div>
+            <div class="number-container">
+              <div class="number-item" v-for="(digit, index) in problemDigits" :key="index">
+                {{ digit }}
+              </div>
+            </div>
+            <div class="stat-unit">个</div>
+          </div>
+        </div>
+      </div>
+
+      <!-- 右侧内容区域 -->
+      <div class="content-section">
+        <!-- 详情视图 -->
+        <TaskDetailView
+          v-if="showDetails"
+          :show-details="showDetails"
+          :is-loading-details="isLoadingDetails"
+          :task-details="taskDetails"
+          :useSSE="useSSE"
+          :sseUrl="sseUrl"
+          :useMarkdown="useMarkdown"
+        />
+
+        <!-- 常规视图 -->
+        <TaskDetectionTable
+          v-else
+          :selected-task-data="selectedTaskData"
+          :is-current-task-running="isCurrentTaskRunning"
+          :current-institution-name="currentInstitutionName"
+          :inspected-count="inspectedCount"
+          :institutions-list="institutionsList"
+          :is-checked="isChecked"
+          :is-checking="isChecking"
+          :get-institution-color="getInstitutionColor"
+        />
+      </div>
+
+      <!-- 底部任务列表 -->
+      <div class="task-list-section">
+        <div class="task-list-container" ref="taskListContainer">
+          <div
+            class="task-item"
+            v-for="(task, index) in taskData.taskList"
+            :key="index"
+            :class="{ 'is-selected': selectedTaskIndex === index }"
+            @click="handleTaskClick(index)"
+          >
+            <div>
+              <div class="task-title">
+                <el-icon class="clock-icon"><Clock /></el-icon>
+                任务时间
+              </div>
+
+              <div class="task-time">{{ task.time }}</div>
+            </div>
+
+            <div class="task-status" :class="getTaskStatus(index)"></div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </ModalDialog>
+</template>
+
+<script setup lang="ts">
+import { DArrowRight, DArrowLeft, Clock } from '@element-plus/icons-vue'
+import ModalDialog from './ModalDialog.vue'
+import TaskDetailView from './TaskDetailView.vue'
+import TaskDetectionTable from './TaskDetectionTable.vue'
+import { useTaskDetail } from '../composables/useTaskDetail'
+
+interface Props {
+  visible: boolean
+  robotType: string
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  close: []
+}>()
+
+// 使用任务详情组合式函数
+const {
+  taskData,
+  taskListContainer,
+  selectedTaskIndex,
+  showDetails,
+  taskDetails,
+  isLoadingDetails,
+  sseUrl,
+  useSSE,
+  useMarkdown,
+  workTaskDigits,
+  problemDigits,
+  selectedTaskData,
+  currentInstitutionName,
+  inspectedCount,
+  institutionsList,
+  isCurrentTaskRunning,
+  isChecked,
+  isChecking,
+  toggleDetails,
+  handleTaskClick,
+  getRobotImage,
+  getTaskStatus,
+  getInstitutionColor,
+} = useTaskDetail(props)
+
+// 处理弹窗关闭
+const handleClose = () => {
+  // 调用组合式函数中的清理逻辑
+  emit('close')
+}
+</script>
+
+<style lang="scss" scoped>
+@use '../assets/styles/TaskDetailModal.scss' as *;
+
+$transition-speed: 0.3s ease;
+
+// 头部右侧按钮区域
+:deep(.modal-header-right) {
+  gap: 20px;
+
+  .detail-btn {
+    position: relative;
+    width: 310px;
+    height: 80px;
+    background: rgba(255, 255, 255, 0.1);
+    color: $neon-cyan;
+    padding: 0;
+    font-size: 32px;
+    cursor: pointer;
+    transition: all $transition-speed;
+    border-radius: 0;
+    box-shadow:
+      inset 6px 0 0 rgba(0, 201, 181, 1),
+      0 8px 8px 0 rgba(0, 0, 0, 0.25),
+      inset 0 0 6px 0 rgba(0, 255, 230, 0.1);
+    font-weight: normal;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: none;
+
+    &:hover {
+      background: rgba(255, 255, 255, 0.2);
+    }
+  }
+}
+</style>

+ 148 - 0
src/components/TaskDetailView.vue

@@ -0,0 +1,148 @@
+<template>
+  <!-- 详情视图 -->
+  <div class="details-view-content">
+    <div class="details-content">
+      <MarkdownRenderer v-if="useMarkdown" :content="displayContent" />
+      <div v-else v-html="displayContent"></div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, watch, onUnmounted } from 'vue'
+import { useSSE } from '../composables/useSSE'
+import MarkdownRenderer from './MarkdownRenderer.vue'
+
+interface Props {
+  showDetails: boolean
+  isLoadingDetails: boolean
+  taskDetails: string
+  sseUrl?: string
+  useSSE: boolean
+  useMarkdown?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  useMarkdown: true,
+})
+
+const { data: sseData, isConnected, error, connect, disconnect, reset } = useSSE(props.sseUrl || '')
+
+const displayContent = computed(() => {
+  return props.useSSE ? sseData.value : props.taskDetails
+})
+
+watch(
+  () => props.showDetails,
+  (newVal) => {
+    if (newVal && props.useSSE && props.sseUrl) {
+      reset()
+      connect()
+    } else {
+      disconnect()
+    }
+  }
+)
+
+watch(
+  () => props.sseUrl,
+  (newUrl) => {
+    if (props.showDetails && props.useSSE && newUrl) {
+      disconnect()
+      reset()
+      connect()
+    }
+  }
+)
+
+onUnmounted(() => {
+  disconnect()
+})
+</script>
+
+<style lang="scss" scoped>
+$neon-green: #00d6c0;
+$neon-blue: #00fff4;
+$neon-cyan: #00ffe6;
+
+// 详情视图样式
+.details-view-content {
+  height: 100%;
+  margin-top: 40px;
+  padding: 0px 40px;
+  overflow-y: auto;
+
+  &::-webkit-scrollbar {
+    width: 6px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: rgba(0, 255, 230, 0.1);
+    border-radius: 3px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: linear-gradient(to bottom, #00ffe6, #00d6c0);
+    border-radius: 3px;
+    box-shadow: 0 0 4px rgba(0, 255, 230, 0.5);
+  }
+
+  &::-webkit-scrollbar-thumb:hover {
+    background: linear-gradient(to bottom, #00d6c0, #00b3a0);
+  }
+
+  .details-content {
+    font-size: 20px;
+    line-height: 1.6;
+    color: $neon-cyan;
+
+    h1,
+    h2,
+    h3,
+    h4,
+    h5,
+    h6 {
+      color: $neon-cyan;
+      margin-top: 24px;
+      margin-bottom: 16px;
+    }
+
+    h1 {
+      font-size: 32px;
+    }
+
+    h2 {
+      font-size: 28px;
+    }
+
+    h3 {
+      font-size: 24px;
+    }
+
+    p {
+      margin-bottom: 16px;
+    }
+
+    ul,
+    ol {
+      margin-bottom: 16px;
+      padding-left: 24px;
+    }
+
+    li {
+      margin-bottom: 8px;
+    }
+
+    strong {
+      color: $neon-cyan;
+      font-weight: 600;
+    }
+
+    .highlight {
+      background: rgba(0, 255, 230, 0.1);
+      padding: 2px 4px;
+      border-radius: 4px;
+    }
+  }
+}
+</style>

+ 276 - 0
src/components/TaskDetectionTable.vue

@@ -0,0 +1,276 @@
+<template>
+  <!-- 常规视图 -->
+  <div class="task-progress">
+    <div class="progress-title">
+      <!-- 正在排查的机构 - 仅运行中的任务显示 -->
+      <span v-if="isCurrentTaskRunning" class="current-institution">
+        正在排查【{{ currentInstitutionName }}】
+      </span>
+
+      <!-- 已排查的机构数量和列表 - 仅运行中的任务显示 -->
+      <span v-if="isCurrentTaskRunning" class="inspected-count">
+        已经排查{{ inspectedCount }}家机构
+      </span>
+
+      <!-- 机构列表 -->
+      <span class="institutions-list">
+        <span
+          v-for="(institution, index) in institutionsList"
+          :key="index"
+          class="institution-item"
+          :style="{ color: getInstitutionColor(institution) }"
+        >
+          【{{ institution.name }}】{{ index < institutionsList.length - 1 ? '、' : '' }}
+        </span>
+      </span>
+    </div>
+  </div>
+
+  <!-- 检测事项表格 -->
+  <div class="detection-table">
+    <div
+      class="detection-item"
+      v-for="(item, index) in selectedTaskData.detectionItems || []"
+      :key="index"
+    >
+      <h4 class="detection-title">{{ item.title }}</h4>
+      <table class="result-table">
+        <colgroup>
+          <col style="width: 25%" />
+          <col style="width: 20%" />
+          <col style="width: 35%" />
+          <col style="width: 20%" />
+        </colgroup>
+        <thead>
+          <tr>
+            <th>具体检测事项</th>
+            <th>AI检查结果</th>
+            <th>问题详情(如有)</th>
+            <th>证据溯源(点击查看截图)</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="(row, rowIndex) in item.tableData" :key="rowIndex">
+            <td>{{ row.item }}</td>
+            <td
+              :class="{
+                checking: isChecking(index, rowIndex),
+              }"
+            >
+              <span
+                v-if="
+                  isChecked(index, rowIndex) ||
+                  !isCurrentTaskRunning ||
+                  index < 1 ||
+                  (index === 1 && rowIndex < 1)
+                "
+              >
+                <span v-if="row.result === '正常'">
+                  <el-icon class="check-icon"><Check /></el-icon>
+                  {{ row.result }}
+                </span>
+                <span v-else-if="row.result === '低风险'">
+                  <el-icon class="warning-icon"><Warning /></el-icon>
+                  {{ row.result }}
+                </span>
+                <span v-else-if="row.result === '高风险'">
+                  <el-icon class="danger-icon"><Close /></el-icon>
+                  {{ row.result }}
+                </span>
+                <span v-else-if="row.result === '异常'">
+                  <el-icon class="danger-icon"><Close /></el-icon>
+                  {{ row.result }}
+                </span>
+                <span v-else>{{ row.result }}</span>
+              </span>
+              <span v-else-if="isChecking(index, rowIndex)">正在检测中...</span>
+              <span v-else>-</span>
+            </td>
+            <td>
+              {{
+                isChecked(index, rowIndex) ||
+                !isCurrentTaskRunning ||
+                index < 1 ||
+                (index === 1 && rowIndex < 1)
+                  ? row.details
+                  : '-'
+              }}
+            </td>
+            <td>
+              {{
+                isChecked(index, rowIndex) ||
+                !isCurrentTaskRunning ||
+                index < 1 ||
+                (index === 1 && rowIndex < 1)
+                  ? row.evidence
+                  : '-'
+              }}
+            </td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Check, Warning, Close } from '@element-plus/icons-vue'
+
+interface Institution {
+  name: string
+  problemCount?: number
+}
+
+interface TableRow {
+  item: string
+  result: string
+  details: string
+  evidence: string
+}
+
+interface DetectionItem {
+  title: string
+  tableData: TableRow[]
+}
+
+interface TaskData {
+  detectionItems?: DetectionItem[]
+  institutionsList?: Institution[]
+}
+
+interface Props {
+  selectedTaskData: TaskData
+  isCurrentTaskRunning: boolean
+  currentInstitutionName: string
+  inspectedCount: number
+  institutionsList: Institution[]
+  isChecked: (detectionIndex: number, itemIndex: number) => boolean
+  isChecking: (detectionIndex: number, itemIndex: number) => boolean
+  getInstitutionColor: (institution: Institution) => string
+}
+
+defineProps<Props>()
+</script>
+
+<style lang="scss" scoped>
+$neon-green: #00d6c0;
+$neon-blue: #00fff4;
+$neon-cyan: #00ffe6;
+
+.task-progress {
+  flex-shrink: 0;
+  padding: 0 20px;
+  background: radial-gradient(
+    50% 50% at 50% 50%,
+    rgba(255, 255, 255, 0) 0%,
+    rgba(0, 255, 230, 0.1) 100%
+  );
+  overflow: hidden;
+
+  .progress-title {
+    display: flex;
+    align-items: center;
+    font-size: 32px;
+    font-weight: 500;
+    color: $neon-cyan;
+    line-height: 72px;
+    letter-spacing: 1px;
+    gap: 15px;
+
+    .current-institution {
+      font-weight: 500;
+      font-size: 40px;
+      line-height: 72px;
+      letter-spacing: 2px;
+      white-space: nowrap;
+    }
+
+    .inspected-count {
+      white-space: nowrap;
+    }
+
+    .institutions-list {
+      flex: 1;
+      min-width: 0;
+      overflow-x: auto;
+      white-space: nowrap;
+
+      &::-webkit-scrollbar {
+        display: none;
+      }
+
+      .institution-item {
+        margin-right: 10px;
+
+        &:last-child {
+          margin-right: 0;
+        }
+      }
+    }
+  }
+}
+
+.detection-table {
+  display: flex;
+  flex-direction: column;
+  gap: 30px;
+  padding: 0 20px;
+  overflow-y: auto;
+
+  /* 自定义滚动条样式 */
+  &::-webkit-scrollbar {
+    width: 4px;
+  }
+
+  &::-webkit-scrollbar-track {
+    background: rgba(0, 255, 230, 0.1);
+    border-radius: 2px;
+  }
+
+  &::-webkit-scrollbar-thumb {
+    background: linear-gradient(to bottom, #00ffe6, #00d6c0);
+    border-radius: 2px;
+    box-shadow: 0 0 4px rgba(0, 255, 230, 0.5);
+  }
+
+  &::-webkit-scrollbar-thumb:hover {
+    background: linear-gradient(to bottom, #00d6c0, #00b3a0);
+  }
+
+  .detection-item {
+    .detection-title {
+      font-weight: 400;
+      font-size: 40px;
+      color: rgba(0, 255, 230, 0.7);
+      line-height: 72px;
+      letter-spacing: 2px;
+      margin-bottom: 15px;
+    }
+
+    .result-table {
+      width: 100%;
+      table-layout: fixed;
+      border-collapse: collapse;
+
+      th,
+      td {
+        padding: 15px;
+        text-align: center;
+        border: 1px solid rgba(0, 255, 230, 0.2);
+        color: $neon-green;
+        font-size: 32px;
+      }
+
+      th {
+        background: rgba(0, 255, 230, 0.02);
+        font-weight: bold;
+        color: $neon-cyan;
+      }
+
+      tr:hover {
+        background: rgba(0, 255, 230, 0.05);
+      }
+    }
+  }
+}
+</style>

+ 17 - 0
src/composables/useMarkdown.ts

@@ -0,0 +1,17 @@
+import { marked } from 'marked'
+
+export function useMarkdown() {
+  const renderMarkdown = (content: string): string => {
+    if (!content) return ''
+    return marked.parse(content) as string
+  }
+
+  return {
+    renderMarkdown,
+  }
+}
+
+export function parseMarkdown(content: string): string {
+  if (!content) return ''
+  return marked.parse(content) as string
+}

+ 61 - 0
src/composables/useSSE.ts

@@ -0,0 +1,61 @@
+import { ref, onUnmounted } from 'vue'
+
+export function useSSE(url: string) {
+  const data = ref<string>('')
+  const isConnected = ref(false)
+  const error = ref<string | null>(null)
+  let eventSource: EventSource | null = null
+
+  const connect = () => {
+    try {
+      eventSource = new EventSource(url)
+      isConnected.value = true
+      error.value = null
+
+      eventSource.onmessage = (event) => {
+        data.value += event.data
+      }
+
+      eventSource.onerror = (e) => {
+        console.error('SSE error:', e)
+        error.value = 'SSE连接出错'
+        isConnected.value = false
+      }
+
+      eventSource.onopen = () => {
+        console.log('SSE连接已建立')
+        isConnected.value = true
+      }
+    } catch (e) {
+      console.error('SSE连接失败:', e)
+      error.value = 'SSE连接失败'
+      isConnected.value = false
+    }
+  }
+
+  const disconnect = () => {
+    if (eventSource) {
+      eventSource.close()
+      eventSource = null
+      isConnected.value = false
+    }
+  }
+
+  const reset = () => {
+    data.value = ''
+    error.value = null
+  }
+
+  onUnmounted(() => {
+    disconnect()
+  })
+
+  return {
+    data,
+    isConnected,
+    error,
+    connect,
+    disconnect,
+    reset
+  }
+}

ファイルの差分が大きいため隠しています
+ 544 - 0
src/composables/useTaskDetail.ts


+ 44 - 0
src/config/robotConfig.ts

@@ -0,0 +1,44 @@
+// 机器人配置文件
+export interface RobotConfig {
+  type: string
+  name: string
+  image: string
+}
+
+export const robotConfig: RobotConfig[] = [
+  {
+    type: 'dietaryNutritionist',
+    name: 'AI膳食营养员',
+    image: 'robot_dietaryNutritionist.jpg',
+  },
+  {
+    type: 'safetyInspector',
+    name: 'AI安全巡查员',
+    image: 'robot_safetyInspector.jpg',
+  },
+  {
+    type: 'auditor',
+    name: 'AI审计员',
+    image: 'robot_auditor.jpg',
+  },
+  {
+    type: 'analyst',
+    name: 'AI分析师',
+    image: 'robot_analyst.png',
+  },
+  {
+    type: 'familyLiaison',
+    name: 'AI家属联络员',
+    image: 'robot_familyLiaison.jpg',
+  },
+  {
+    type: 'coordinatedDispatcher',
+    name: 'AI协同调度员',
+    image: 'robot_coordinatedDispatcher.jpg',
+  },
+  {
+    type: 'warningSupervisor',
+    name: 'AI预警与督办员',
+    image: 'robot_warningSupervisor.jpg',
+  },
+]

ファイルの差分が大きいため隠しています
+ 608 - 0
src/data/robotTasks.ts


+ 6 - 0
src/env.d.ts

@@ -1,5 +1,11 @@
 /// <reference types="vite/client" />
 
+declare module '*.vue' {
+  import type { DefineComponent } from 'vue'
+  const component: DefineComponent<{}, {}, any>
+  export default component
+}
+
 declare module '*.png' {
   const content: string
   export default content

+ 2 - 12
src/views/HomeView.vue

@@ -148,11 +148,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, computed } from 'vue'
-import * as echarts from 'echarts'
-import { Bell, Warning, InfoFilled, Picture } from '@element-plus/icons-vue'
-import { Swiper, SwiperSlide } from 'swiper/vue'
-import { Autoplay } from 'swiper/modules'
+import { onMounted, onUnmounted } from 'vue'
 import 'swiper/css'
 import Header from '../components/Header.vue'
 import Footer from '../components/Footer.vue'
@@ -171,15 +167,9 @@ import { housingData } from '../data/housing'
 import { clothingData } from '../data/clothing'
 import { dignityData } from '../data/dignity'
 
-// 滚动相关
-const scrollIntervals = ref<{ [key: string]: number }>({})
-
 // 生命周期
 onMounted(() => {
-  onUnmounted(() => {
-    // cleanupCharts()
-    Object.values(scrollIntervals.value).forEach((interval) => clearInterval(interval))
-  })
+  onUnmounted(() => {})
 })
 </script>