MainLayout.vue 10.3 KB
<template>
  <div class="app-container">
    <!-- 主内容区 -->
    <div class="main-content">
      <!-- 左侧导航栏 -->
      <Sidebar />

      <!-- 中间工作区 -->
      <div class="workspace" :class="{ minimized: isPanelMinimized }">
        <!-- 1. 核心常驻页面:用 visibility 替代 v-show(display:none),
             避免 iframe 因 DOM 重排出现闪烁 -->
        <WelcomePage
          v-if="wasVisited('Welcome')"
          class="page-layer"
          :class="{ 'page-active': route.name === 'Welcome' }"
        />

        <WorkspacePage
          v-if="wasVisited('Workspace')"
          class="page-layer"
          :class="{ 'page-active': route.name === 'Workspace' }"
        />

        <KnowledgeBasePage
          v-if="wasVisited('KnowledgeBase')"
          class="page-layer"
          :class="{ 'page-active': route.name === 'KnowledgeBase' }"
        />

        <!-- 2. 其他非常规页面(如设置、个人中心等),依然走正常的路由销毁流程 -->
        <router-view v-if="isOtherRoute" />
      </div>

      <!-- 注意:IntelligencePanel 已移到 Workspace 内部,这里只保留 IntelligencePanelAgent -->
      <IntelligencePanelAgent
        v-if="!hideIntelligencePanelAgent"
        ref="intelligencePanelAgentRef"
        :minimized="isPanelMinimized"
        @minimize-changed="handleMinimizeChange"
        @new-chat="handleNewChat"
        @show-history="handleShowHistory"
      />
    </div>

    <!-- 新用户引导 (el-tour) -->
    <el-tour
      v-model="isTourOpen"
      :mask="true"
      :z-index="3000"
      @finish="handleTourFinish"
      @close="handleTourFinish"
    >
      <el-tour-step
        target="#tour-logo"
        :title="t('tour.step1.title')"
        :description="t('tour.step1.desc')"
      >
        <template #next-button>
          <el-button size="small" type="primary">{{ t('tour.next') }}</el-button>
        </template>
      </el-tour-step>

      <el-tour-step
        target="#tour-quick-ask"
        :title="t('tour.step2.title')"
        :description="t('tour.step2.desc')"
      >
        <template #prev-button>
          <el-button size="small">{{ t('tour.prev') }}</el-button>
        </template>
        <template #next-button>
          <el-button size="small" type="primary">{{ t('tour.next') }}</el-button>
        </template>
      </el-tour-step>

      <el-tour-step
        target="#tour-deep-search"
        :title="t('tour.step3.title')"
        :description="t('tour.step3.desc')"
      >
        <template #prev-button>
          <el-button size="small">{{ t('tour.prev') }}</el-button>
        </template>
        <template #next-button>
          <el-button size="small" type="primary">{{ t('tour.next') }}</el-button>
        </template>
      </el-tour-step>

      <el-tour-step
        target="#tour-workspace"
        :title="t('tour.step4.title')"
        :description="t('tour.step4.desc')"
      >
        <template #prev-button>
          <el-button size="small">{{ t('tour.prev') }}</el-button>
        </template>
        <template #next-button>
          <el-button size="small" type="primary">{{ t('tour.next') }}</el-button>
        </template>
      </el-tour-step>

      <el-tour-step
        target="#tour-knowledge"
        :title="t('tour.step5.title')"
        :description="t('tour.step5.desc')"
      >
        <template #prev-button>
          <el-button size="small">{{ t('tour.prev') }}</el-button>
        </template>
        <template #next-button>
          <el-button size="small" type="primary" @click="handleTourFinish">
            {{ t('tour.finish') }}
          </el-button>
        </template>
      </el-tour-step>
    </el-tour>
  </div>
</template>

<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  defineAsyncComponent,
  reactive,
  watch,
} from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import Sidebar from "@/layout/Sidebar.vue";
import { useAppStore } from "@/stores/app";
// 注意:IntelligencePanel 已移到 Workspace 内部
// import IntelligencePanel from "@/layout/IntelligencePanel.vue";
const IntelligencePanelAgent = defineAsyncComponent(() =>
  import("@/layout/IntelligencePanelAgent.vue")
);

// 常驻页面异步组件
const WelcomePage = defineAsyncComponent(() => import("@/pages/Welcome.vue"));
const WorkspacePage = defineAsyncComponent(() => import("@/pages/Workspace.vue"));
const KnowledgeBasePage = defineAsyncComponent(() =>
  import("@/pages/KnowledgeBase.vue")
);

const route = useRoute();
const { t } = useI18n();
const appStore = useAppStore();

// 状态定义
const isPanelMinimized = ref(false);
const visitedMap = reactive<Record<string, boolean>>({
  Welcome: false,
  Workspace: false,
  KnowledgeBase: false,
});

// 新用户引导状态
const isTourOpen = ref(false);
const TOUR_STORAGE_KEY = "linkmed_onboarding_completed";

const checkAndStartTour = () => {
  const isCompleted = localStorage.getItem(TOUR_STORAGE_KEY);
  // 引导包含欢迎页特有元素,建议仅在欢迎页启动
  if (!isCompleted && !appStore.globalModalVisible && route.name === "Welcome") {
    // 为了确保 DOM 已渲染 (特别是 Sidebar 里的 ID 和异步加载的 WelcomePage)
    setTimeout(() => {
      // 再次检查确认没有弹窗且还没完成,且依然在欢迎页
      if (
        !localStorage.getItem(TOUR_STORAGE_KEY) &&
        !appStore.globalModalVisible &&
        route.name === "Welcome"
      ) {
        isTourOpen.value = true;
      }
    }, 1000);
  }
};

// 监听全局弹窗状态,当弹窗消失且引导未完成时,触发引导
watch(
  [() => appStore.globalModalVisible, () => route.name],
  ([modalVisible, routeName]) => {
    if (!modalVisible && routeName === "Welcome") {
      checkAndStartTour();
    } else if (isTourOpen.value && (modalVisible || routeName !== "Welcome")) {
      // 如果引导正在进行却突然出现了全局弹窗,或者离开了欢迎页,先关闭引导以防重合或目标缺失
      isTourOpen.value = false;
    }
  }
);

const handleTourFinish = () => {
  localStorage.setItem(TOUR_STORAGE_KEY, "true");
  isTourOpen.value = false;
  // 新手引导结束后,自动打开新手任务卡片
  window.dispatchEvent(new CustomEvent("open-newbie-task"));
};

// 检查并记录页面是否被访问过
const wasVisited = (name: string) => {
  if (route.name === name) {
    visitedMap[name] = true;
  }
  return visitedMap[name];
};

// 判断是否为非常驻页面
const isOtherRoute = computed(() => {
  return !["Welcome", "Workspace", "KnowledgeBase"].includes(
    route.name as string
  );
});

// 计算属性
// 注意:hideIntelligencePanel 已移除,因为 IntelligencePanel 已移到 Workspace 内部
const hideIntelligencePanelAgent = computed(() => {
  // console.log("不显示agent智能体:", route.meta.hideIntelligencePanel || !route.query.agent)
  return route.meta.hideIntelligencePanel || !route.query.agent;
});

// 方法
// 注意:IntelligencePanel 的 resize 相关方法已移除,因为 IntelligencePanel 已移到 Workspace 内部
// 这些方法现在由 Workspace 组件管理

const handleMinimizeChange = (minimized: boolean) => {
  isPanelMinimized.value = minimized;
};

const handleNewChat = () => {
  console.log("MainLayout: 新建对话");
  // TODO: 实现新建对话功能
};

const handleShowHistory = () => {
  console.log("MainLayout: 显示历史对话");
  // TODO: 实现显示历史对话功能
};

// 生命周期
// 注意:IntelligencePanel 的 resize 事件监听已移除,因为 IntelligencePanel 已移到 Workspace 内部
onMounted(() => {
  // IntelligencePanel 的 resize 逻辑现在由 Workspace 组件管理
  checkAndStartTour();
});

onBeforeUnmount(() => {
  // IntelligencePanel 的 resize 逻辑现在由 Workspace 组件管理
});
</script>

<style scoped>
.app-container {
  display: flex;
  height: 100vh;
  width: 100vw;
  background: var(--color-bg, #141518);
  color: var(--color-text, #eee);
  overflow: hidden;
}

.main-content {
  flex: 1;
  display: flex;
  overflow: hidden;
  background: var(--color-bg, #18181a);
  gap: 0;
  position: relative;
}

.workspace {
  flex: 1;
  display: flex;
  overflow: hidden;
  min-width: 320px;
  position: relative;
  transition: flex 0.3s ease;
}

/* 常驻页面层:用 opacity 切换,保持 iframe 在布局流中,避免 display:none 重排闪烁。
   不用 visibility:子元素显式 visibility:visible 会穿透父元素 visibility:hidden。
   opacity:0 无此问题,子元素无法超过父元素的 opacity。 */
.page-layer {
  position: absolute;
  inset: 0;
  opacity: 0;
  pointer-events: none;

  &.page-active {
    opacity: 1;
    pointer-events: auto;
  }
}

/* 当AI面板最小化时,workspace自动扩展 */
.workspace.minimized {
  flex: 1;
}

/* 当AI面板最小化时,隐藏其容器 */
:deep(.intelligence-panel.minimized) {
  width: 0 !important;
  min-width: 0 !important;
  max-width: 0 !important;
  flex-shrink: 0 !important;
  overflow: visible !important;
}

/* 可拉伸分隔线样式 */
.resize-handle {
  width: 2px;
  flex-shrink: 0;
  height: 100%;
  align-self: stretch;
  background: transparent;
  cursor: col-resize;
  transition: background-color 0.15s ease;
  position: relative;
  z-index: 10;
}

.resize-handle:hover {
  background: var(--color-primary);
}

.resize-handle:active {
  background: var(--color-primary);
}

/* 扩大可点击区域,但保持视觉宽度 */
.resize-handle::before {
  content: "";
  position: absolute;
  left: -2px;
  right: -2px;
  top: 0;
  bottom: 0;
}

/* 拖拽时的智能面板样式 */
:deep(.intelligence-panel.resizing) {
  transition: none !important;
}

/* 平板端适配 (768px - 1024px) */
@media (min-width: 768px) and (max-width: 1024px) {
  .main-content {
    gap: 0;
  }

  .workspace {
    flex: 1.5;
    min-width: 0;
  }
}

/* 小屏平板端适配 (600px - 768px) */
@media (min-width: 600px) and (max-width: 767px) {
  .main-content {
    gap: 0;
    padding: 6px;
  }

  .workspace {
    flex: 1.2;
    min-width: 0;
  }
}

/* 手机端适配 (小于600px) - 配合同比例缩放逻辑 */
@media (max-width: 599px) {
  .app-container {
    width: 1024px; /* 强制容器宽度与缩放基准一致 */
    overflow-x: hidden;
  }

  .main-content {
    flex-direction: row;
    gap: 0;
    padding: 0;
  }

  .workspace {
    flex: 1;
    min-width: 0;
  }
}
</style>