# test-260622-CSS多端适配方案实现 **Repository Path**: zerong-knowledge-sharing/test-260622-CSS_multiTerminalAdaptation ## Basic Information - **Project Name**: test-260622-CSS多端适配方案实现 - **Description**: PC 端与 H5 端多端适配方案实现 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-23 - **Last Updated**: 2026-06-23 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # test-260622-CSS_multiTerminalAdaptation PC 端与 H5 端多端适配方案实现 # AI Travel Map - 智能旅游地图平台 ## 概述 基于 Vue 3 + Vite 构建的"AI+地图"网站平台,支持网页和移动端适配。用户可以通过自然语言与 AI 对话,获取真实的地点推荐、路线规划,并在地图上可视化展示。 ![alt text](image.png) ## 功能特性 ### 核心功能 1. **自然语言交互** - 用户用口语化表达需求(如:"帮我规划成都 3 天美食游") - AI 自动解析意图并调用地图能力 - 支持美食、景点、住宿等多种推荐 2. **真实地点推荐与路线规划** - 基于真实 POI 数据的行程规划 - 多日行程的景点、餐饮串联 - 游玩时长预估和距离计算 3. **地图可视化渲染** - 路线折线在地图上展示 - 地点标记点(Marker)带颜色区分 - 点击标记查看详情和导航 ### 技术栈 | 类别 | 技术方案 | | ------------ | ----------------------- | | 框架 | Vue 3 + Composition API | | 构建工具 | Vite | | CSS 预处理器 | SCSS | | 地图服务 | Leaflet + OpenStreetMap | | 路由 | Vue Router 4 | | 图标 | SVG (Lucide 风格) | ## 项目结构 ``` src/ ├── api/ │ └── mockData.js # Mock数据(POI、路线、聊天记录) ├── assets/ │ └── styles/ │ ├── variables.scss # SCSS变量 │ ├── mixins.scss # SCSS混入 │ └── main.scss # 全局样式 ├── components/ │ ├── icons.js # 图标渲染函数 + tabs配置 │ ├── MobileHeader.vue # 移动端顶栏 │ ├── DesktopSidebar.vue # 桌面端侧边栏 │ ├── MobileSidebar.vue # 移动端抽屉侧边栏 │ ├── ChatPanel.vue # AI对话面板 │ ├── ChatMessage.vue # 聊天气泡 │ ├── MapView.vue # 地图视图 │ └── RouteTimeline.vue # 行程时间线 ├── composables/ │ ├── useResponsive.js # 响应式适配 │ └── useChat.js # 聊天逻辑 ├── directives/ │ └── touch.js # 自定义v-touch手势指令 ├── App.vue # 主应用(布局编排) └── main.js # 入口文件 ``` ## 快速开始 ### 1. 安装依赖 ```bash npm install ``` ### 2. 启动开发服务器 ```bash npm run dev ``` ### 3. 访问应用 打开浏览器访问 `http://localhost:3000` ## 功能演示 ### AI 对话示例 在左侧 AI 对话面板中输入: - `帮我规划成都3天美食游` - 获取完整行程规划 - `推荐好吃的火锅店` - 获取美食推荐 - `推荐景点` - 获取景点推荐 - `推荐住宿` - 获取酒店推荐 ### 地图交互 - **查看 POI**: 点击地图上的标记点查看详细信息 - **导航**: 在 POI 详情面板点击"导航前往" - **路线展示**: AI 生成的行程会以虚线形式在地图上展示 ### 响应式适配 - **PC 端** (≥1024px): 左侧边栏 + 右侧地图 - **平板** (768px-1024px): 收缩侧边栏 + 地图 - **移动端** (<768px): 全屏地图 + 底部弹出面板 --- ## PC 端与 H5 端适配方案(面试要点) > 📁 相关源码:`src/composables/useResponsive.js` · `src/App.vue` · `src/components/MobileHeader.vue` · `src/components/DesktopSidebar.vue` · `src/components/MobileSidebar.vue` · `src/assets/styles/mixins.scss` ### 核心思路 PC 和移动端的根本差异在于**屏幕尺寸**和**交互方式**:PC 是宽屏+鼠标精确操作,移动端是窄屏+手指触摸。因此不能简单地等比缩放,而是要**针对不同场景设计不同的布局结构和交互模式**。 ### 布局策略 采用**整体布局切换**而非单纯的断点覆盖: - **PC 端**:左右分栏布局,侧边栏常驻,地图占满剩余空间,信息层级扁平 - **移动端**:全屏地图为主,侧边栏变为抽屉式覆盖层,聊天面板从底部弹出,通过层级和动画管理空间 两者使用完全不同的 DOM 结构(v-if 条件渲染),而不是同一套结构用 CSS 隐藏/显示,这样避免了移动端加载无用 DOM、PC 端多余的遮罩层等问题。 > 📁 参见 `src/App.vue:2-14`(移动端)和 `src/App.vue:21-80`(桌面端)的 v-if 条件渲染 ### 断点设计 项目定义了 4 个断点:sm(640px)、md(768px)、lg(1024px)、xl(1280px)。核心分界线是 768px——这是移动端和桌面端的分水岭。断点决策依据: - 768px 以下:手机竖屏,内容必须单列堆叠 - 768px-1024px:平板,可显示侧边栏但需要收窄 - 1024px 以上:PC 宽屏,完整双栏布局 > 📁 参见 `src/assets/styles/variables.scss:134-138`(断点变量)和 `src/assets/styles/mixins.scss:30-46`(断点 mixin) ### 响应式状态管理 通过 `useResponsive` composable 统一管理设备状态,在 JS 层面响应窗口变化: - 监听 `window.resize` 事件,实时更新 `width`/`height` - 派生 `isMobile`/`isTablet`/`isDesktop` 等计算属性 - 组件内通过 `v-if="isMobile"` 切换不同的 UI 结构 这种方案比纯 CSS 方案更灵活——可以在 JS 中根据设备类型执行不同的逻辑(比如移动端点击导航跳转高德 URI,PC 端弹出路线详情)。 > 📁 参见 `src/composables/useResponsive.js:9-11`(isMobile/isTablet/isDesktop 计算属性定义) > 📁 参见 `src/composables/useResponsive.js:19-21`(onMounted 注册 resize 监听 + 初始调用) > 📁 参见 `src/composables/useResponsive.js:14-17`(handleResize 回调:同步最新宽高到响应式变量) > 📁 参见 `src/App.vue:28`(导入 useResponsive)、`src/App.vue:30`(解构 isMobile) > 📁 参见 `src/App.vue:32`(模板中 `v-if="isMobile"` / `v-if="!isMobile"` 条件渲染切换布局) ### 组件拆分原则 按**设备场景**拆分组件,而非按功能: - `MobileHeader` / `DesktopSidebar` / `MobileSidebar` — 各自封装对应设备的布局和交互 - `ChatPanel` / `MapView` — 跨设备共享的功能组件 好处是:修改移动端逻辑不会影响 PC 端,两个端可以独立迭代。App.vue 作为布局编排层,只负责根据设备类型组装对应的组件树。 > 📁 参见 `src/App.vue` 模板部分(设备条件渲染组装) ### 触摸适配 移动端额外考虑: - 按钮最小点击区域 40x40px(手指触控精度要求) - 侧边栏抽屉加遮罩层,点击遮罩关闭(符合用户心智) - 聊天面板从底部滑入(符合移动端对话类产品惯例) - 使用 CSS `touch-action` 优化滚动和手势 - 使用自定义 `v-touch` 指令处理滑动手势 > 📁 参见 `src/directives/touch.js`(自定义 v-touch 指令:支持 swipe/swipeleft/swiperight/swipeup/swipedown/longpress/tap) > 📁 参见 `src/main.js:4,19`(全局注册 v-touch 指令) > 📁 参见 `src/components/MobileSidebar.vue:6`(`v-touch.swipeleft` 左滑关闭侧边栏) > 📁 参见 `src/App.vue:61`(`v-touch.swipedown` 下滑关闭聊天面板) > 📁 参见 `src/components/ChatPanel.vue`(`.chat-messages { touch-action: pan-y }` 仅允许垂直滚动) > 📁 参见 `src/components/RouteTimeline.vue`(`.route-days { touch-action: pan-y }` 同上) > 📁 参见 `src/components/MapView.vue`(`.map { touch-action: none }` 禁用浏览器默认手势,交给 Leaflet 自行处理缩放拖拽) ### 性能考虑 - 条件渲染(v-if)而非 CSS 显隐(v-show),移动端不渲染桌面端 DOM - 地图组件在两种布局下复用,避免重复初始化 - 移动端减少阴影、模糊等重绘开销较重的效果 ### 面试回答模板 > 我们的项目通过**三层机制**实现 PC 和 H5 适配: > > 1. **JS 层**:用 composable 监听窗口尺寸,派生设备类型状态,控制条件渲染 > 2. **组件层**:按设备场景拆分独立组件(DesktopSidebar / MobileSidebar / MobileHeader),App.vue 只做编排 > 3. **样式层**:SCSS 断点 + mixins 实现响应式样式,触摸区域适配,布局切换 > > 关键决策是用 **v-if 条件渲染不同 DOM 结构** 而非 v-show 隐藏,这样移动端不会加载桌面端的无用组件,同时两端可以有完全不同的交互模式(常驻侧边栏 vs 抽屉式覆盖层)。 ## Mock 数据说明 项目使用 Mock 数据演示功能: ### POI 数据 (`mockPOIs`) 包含成都地区的景点、餐厅、酒店等真实地点: - **景点**: 宽窄巷子、武侯祠、锦里、大熊猫基地、杜甫草堂、春熙路 - **美食**: 陈麻婆豆腐、龙抄手、蜀九香火锅、甘食记肥肠粉 - **住宿**: 香格里拉大酒店、太古里博舍酒店、瓦当瓦舍旅行酒店 ### 路线数据 (`mockRoutes`) 预设的 3 日行程路线,包含每日景点安排和游玩建议。 ## 自定义配置 ### 修改地图中心点 编辑 `src/components/MapView.vue` 中的 `center` 属性: ```javascript const mapCenter = ref([30.5728, 104.0668]); // 成都 ``` ### 添加新 POI 在 `src/api/mockData.js` 中添加新的 POI 数据: ```javascript { id: 'poi_xxx', name: '地点名称', type: 'attraction', // attraction | restaurant | hotel | transport | shopping address: '地址', lat: 纬度, lng: 经度, rating: 4.5, description: '描述', tags: ['标签1', '标签2'] } ``` ### 添加 AI 响应规则 编辑 `src/composables/useChat.js` 中的 `generateAIResponse` 函数: ```javascript if (lowerMsg.includes("关键词")) { return { content: "AI回复内容", routeId: "route_001", // 可选:关联路线 }; } ``` ## 部署 ### 构建生产版本 ```bash npm run build ``` ### 预览生产版本 ```bash npm run preview ``` ## 组件设计模式(面试要点) > 📁 相关源码:`src/components/` 目录下所有组件 · `src/composables/` · `src/components/icons.js` · `src/assets/styles/variables.scss` · `src/assets/styles/mixins.scss` ### 组件拆分原则 项目的组件拆分遵循 **「设备场景 × 功能职责」双维度**: **按设备场景拆分(布局层):** - `MobileHeader` / `DesktopSidebar` / `MobileSidebar` — 各自封装对应设备的完整 UI 结构 - App.vue 通过 `v-if="isMobile"` 选择渲染哪套组件树 - 好处:修改移动端逻辑不会碰桌面端代码,两端独立迭代 **按功能职责拆分(功能层):** - `ChatPanel` → 聊天面板(输入+消息列表+空状态) - `ChatMessage` → 单条消息气泡(纯展示+格式化) - `RouteTimeline` → 行程时间线(折叠展开+POI 列表) - `MapView` → 地图容器(Leaflet 初始化+标记+路线) 拆分依据:**一个组件只做一件事**。ChatMessage 不关心消息从哪来,只负责渲染一条消息;MapView 不关心 POI 数据怎么产生的,只负责在地图上画出来。 > 📁 参见 `src/components/ChatMessage.vue:38-43`(只接收一个 message prop)、`src/components/MapView.vue:129-146`(只接收 pois/route/center/zoom) ### 六种组件通信模式 项目中实际用到的通信方式: **1. Props Down(父 → 子)** 最基础的模式,数据单向流入子组件: ```vue ``` > 📁 参见 `src/App.vue:35-42`(向 ChatPanel 传递 props)、`src/App.vue:78-84`(向 MapView 传递 props) 原则:子组件只读 props,不直接修改。需要变更时 emit 事件。 **2. Events Up(子 → 父)** 子组件通过 emit 向上传递用户操作: ```vue ``` > 📁 参见 `src/App.vue:37-40`(绑定事件监听)、`src/components/ChatPanel.vue:103`(defineEmits 声明) App.vue 作为事件的收口点,统一处理所有状态变更。 **3. Slots(内容分发)** DesktopSidebar 使用默认插槽,由父组件决定渲染什么内容: ```vue ``` > 📁 参见 `src/components/DesktopSidebar.vue:32`(`` 插槽定义)、`src/App.vue:33-50`(App.vue 通过插槽注入内容) 好处:DesktopSidebar 不需要知道侧边栏里放什么,只负责外壳(导航栏+折叠逻辑),内容由调用方决定。这就是**容器组件 vs 展示组件**的分离。 **4. Provide / Inject(跨层级传递)** 项目中 `useResponsive` 的 `isMobile` 理论上可以用 provide/inject 全局注入,但当前项目层级不深,直接在 App.vue 用 composable 拿到后通过 props 传下去更清晰。如果组件嵌套超过 3 层,会考虑 provide/inject 避免 props 逐层透传。 **5. Composable 共享状态(兄弟组件通信)** ChatPanel 和 RouteTimeline 是兄弟组件,它们通过共享的 `useChat` composable 间接通信: ``` App.vue ├── ChatPanel → sendMessage() → messages 变更 └── RouteTimeline → selectedRoute 变更 ← (同一份引用) ``` > 📁 参见 `src/composables/useChat.js:7-9`(模块级 ref 定义,所有调用者共享同一份引用) 不需要事件总线,因为它们读写的是同一份 composable 导出的响应式引用。 **6. 模块级变量(图标/配置共享)** `icons.js` 导出的图标组件和 tabs 配置,通过 ES Module import 共享: ```javascript import { tabs, ChatIcon, RouteIcon } from "./icons"; ``` > 📁 参见 `src/components/icons.js`(图标渲染函数和 tabs 配置导出) 这是最轻量的共享方式,适合无状态的工具函数和常量。 ### 组件设计模式 **容器组件 vs 展示组件** | 角色 | 组件 | 职责 | | ---- | -------------- | -------------------------------- | | 容器 | App.vue | 状态管理、事件处理、数据组装 | | 容器 | DesktopSidebar | 布局外壳、导航切换 | | 展示 | ChatPanel | 接收数据、触发事件 | | 展示 | ChatMessage | 纯渲染、无状态 | | 展示 | MapView | 接收数据、触发事件 | | 混合 | RouteTimeline | 内部有折叠状态,但数据来自 props | > 📁 容器:`src/App.vue`(状态持有+编排)、`src/components/DesktopSidebar.vue`(布局外壳+插槽) > 📁 展示:`src/components/ChatMessage.vue`(纯渲染)、`src/components/MapView.vue`(只管地图渲染) > 📁 混合:`src/components/RouteTimeline.vue:110`(expandedDays 局部状态 + props 数据) **受控组件 vs 非受控组件** - **受控**:ChatPanel 的 `isCollapsed` 由内部管理(非受控),但 `messages` 由父组件传入(受控数据) - **受控**:DesktopSidebar 的 `collapsed` 由父组件 App.vue 控制(完全受控) - 判断标准:**状态是否需要被父组件感知或影响其他组件**。需要就受控,不需要就非受控。 > 📁 参见 `src/components/DesktopSidebar.vue:36-37`(collapsed 受控 prop)、`src/components/ChatPanel.vue:108`(isCollapsed 非受控内部状态) ### 设计系统复用 所有组件共享同一套设计语言: - **SCSS Variables** — 颜色、字体、间距、圆角、阴影等 token 统一定义 - **SCSS Mixins** — `flex-center`、`button-primary`、`card` 等可复用样式模式 - **BEM 命名** — `chat-message__bubble`、`route-day__header`,语义化且避免样式冲突 - **Scoped Styles** — 每个组件样式隔离,不污染全局 新增组件时只需要 `@import variables.scss + mixins.scss`,就能用全套设计 token,保证视觉一致性。 > 📁 参见 `src/assets/styles/variables.scss`(51 个颜色变量 + 间距/字号/圆角/阴影/断点/过渡等 token) > 📁 参见 `src/assets/styles/mixins.scss`(flex/position/button/card/animation 等可复用 mixin) > 📁 参见 `src/components/ChatPanel.vue:160-162`(组件引入设计系统的标准写法) ### 第三方库集成策略 以 Leaflet 为例,项目采用 **动态导入 + 组件封装**: - `MapView.vue` 内部 `import('leaflet')` 动态加载,不在主 bundle 中 - Leaflet 的初始化、销毁、标记管理全部封装在 MapView 内部 - 父组件只通过 props 传入 `pois`、`route`、`center`,不接触 Leaflet API 好处:第三方库的复杂度被隔离在单个组件内,替换地图 SDK 只需改 MapView 一个文件。 > 📁 参见 `src/components/MapView.vue:161`(`import('leaflet').then()` 动态导入)、`src/components/MapView.vue:299-307`(onMounted 初始化 + onUnmounted 销毁) ### 面试回答模板 > 我们的组件设计遵循三个原则: > > 1. **单一职责**:每个组件只做一件事。ChatMessage 只渲染气泡,MapView 只管地图,App.vue 只做编排 > 2. **明确的通信边界**:Props Down + Events Up 为主,Slots 实现内容分发,Composable 解决兄弟组件通信。不搞事件总线、不搞全局 bus > 3. **设备场景拆分**:按 PC/移动端拆出独立的布局组件(DesktopSidebar / MobileSidebar),而不是一套 DOM 用 CSS 切来切去 > > 设计系统通过 SCSS Variables + Mixins + BEM 命名统一维护,新组件接入零成本。第三方库(如 Leaflet)用动态导入 + 组件封装隔离复杂度。 ### 为什么没用 Vuex / Pinia 项目没有引入 Vuex 或 Pinia,而是采用 **Composable + provide/inject** 的轻量方案。原因: 1. **状态量级不大**:核心状态就是聊天消息列表、选中的路线、加载状态、设备类型,没有复杂的模块间状态依赖 2. **状态作用域清晰**:聊天状态只在 ChatPanel ↔ App 之间流转,设备状态全局共享但只读,不存在跨模块状态竞争 3. **避免过度设计**:对于中小型项目,引入状态管理库会增加心智负担和样板代码,composable 已经够用 > 📁 参见 `src/composables/useChat.js`(完整聊天状态管理)、`src/composables/useResponsive.js`(设备状态管理) 面试时可以补充:如果项目规模增长(比如加入用户登录、收藏夹、多行程管理、离线缓存),会考虑引入 Pinia,因为它的 TypeScript 支持更好、devtools 集成更完善。 ### 状态分层架构 项目的状态分为三层: **第一层:全局共享状态(Composable 单例)** - `useChat` — 聊天消息、AI 加载状态、选中的路线。通过 composable 封装,多次调用返回同一个响应式引用(模块级变量),等价于一个轻量 store - `useResponsive` — 设备类型(isMobile/isTablet/isDesktop)。监听 resize 事件,全局只读 > 📁 参见 `src/composables/useChat.js:7-9`(模块级 ref 定义)、`src/composables/useResponsive.js:6-11`(设备状态计算属性) **第二层:组件局部状态(ref)** - `showSidebar`、`showChat`、`activeTab` 等 UI 状态,只属于 App.vue - `inputText`、`isCollapsed` 等只属于具体组件 - 这些状态不需要共享,直接用 `ref()` 管理 > 📁 参见 `src/App.vue:56-60`(App.vue 局部 UI 状态)、`src/components/ChatPanel.vue:105-108`(ChatPanel 局部状态) **第三层:静态数据(模块导出)** - `mockPOIs`、`mockRoutes`、`mockChatHistory` 等 Mock 数据 - 作为 ES Module 常量导入,不参与响应式系统 - 如果后续对接真实 API,这一层会变成异步请求 + 缓存 > 📁 参见 `src/api/mockData.js`(Mock 数据定义) ### Composable 作为 Store 的实现原理 `useChat` 的核心设计: ```javascript // 模块级变量 — 所有调用者共享同一份引用 const messages = ref([...mockChatHistory]); const selectedRoute = ref(null); export function useChat() { return { messages, selectedRoute, sendMessage }; } ``` - `ref` 定义在函数**外部**(模块作用域),而不是函数内部 - 多个组件调用 `useChat()` 拿到的是**同一个** `messages` 引用 - 本质上就是一个没有 devtools 的简易 Pinia store > 📁 参见 `src/composables/useChat.js:7-9`(模块级 ref)、`src/composables/useChat.js:98-105`(return 导出) ### 数据流向 采用标准的 **Props Down, Events Up** 单向数据流: ``` App.vue (状态持有者) ├── DesktopSidebar ← :activeTab, :collapsed │ └── ChatPanel ← :messages, :isLoading │ └── ChatMessage ← :message ├── MobileSidebar ← :visible, :activeTab ├── MapView ← :pois, :route, :center, :zoom └── MobileHeader (无状态,纯展示) ``` - **App.vue** 是状态的「单一数据源」,持有所有共享状态 - **子组件**通过 props 接收数据,通过 emit 向上传递事件 - **没有子组件直接修改父组件状态**,所有状态变更都在 App.vue 的 handler 中执行 > 📁 参见 `src/App.vue:28-100`(模板中所有 props 绑定和事件监听) ### 状态变更流程 以「用户发送聊天消息」为例: 1. ChatPanel emit `send` 事件 → App.vue `handleSendMessage` 2. App.vue 调用 `useChat().sendMessage()` 3. `sendMessage` 内部:push 用户消息 → 设置 loading → 模拟延迟 → push AI 回复 → 取消 loading 4. `messages` 是共享引用,ChatPanel 通过 watch 自动响应新消息 整个流程是**单向的**:事件向上 → 状态在顶层变更 → 响应式自动向下传播。 > 📁 参见 `src/App.vue:62-65`(handleSendMessage)、`src/composables/useChat.js:58-91`(sendMessage 实现)、`src/components/ChatPanel.vue:156-157`(watch messages 变更自动滚动) ### 如果用 Pinia 会怎样 对于当前项目规模,Pinia 的优势体现不出来。但如果项目扩展到以下场景,引入 Pinia 是值得的: | 场景 | 当前方案 | Pinia 方案 | | ------------------- | ---------------------- | -------------------------------------- | | 聊天历史持久化 | 需要手动 localStorage | `pinia-plugin-persistedstate` 一行搞定 | | 多页面共享状态 | composable 模块变量 | Store 天然全局 | | DevTools 调试 | 无法追踪状态变更时间线 | Pinia devtools 完整支持 | | TypeScript 类型推导 | 需要手动定义返回类型 | `defineStore` 自动推导 | | 状态模块化 | composable 按功能拆分 | Store 按模块拆分,支持嵌套 | ### 面试回答模板 > 我们的项目采用 **Composable 作为轻量 Store** 的方案,没有引入 Vuex/Pinia。 > > 核心思路是:将共享状态定义在 composable 函数的**模块作用域**(函数外部),多次调用返回同一个引用,实现跨组件共享。本质上和 Pinia 的原理一样——都是利用 ES Module 的单例特性。 > > 数据流遵循 **Props Down, Events Up** 的单向流:App.vue 作为状态的单一数据源,子组件通过 props 接收、emit 事件触发变更。 > > 选择这个方案的原因是项目状态量级不大、作用域清晰,引入 Pinia 会增加不必要的复杂度。但如果项目增长到需要持久化、DevTools 调试、复杂模块间状态依赖,会考虑迁移到 Pinia。 - [ ] 接入真实后端 API - [ ] 集成高德/百度地图 API - [ ] 添加用户登录和收藏功能 - [ ] 支持多语言 - [ ] 添加语音交互 - [ ] 集成天气信息 - [ ] 添加导航功能 ## 参考资料 - [Vue 3 官方文档](https://vuejs.org/) - [Vite 官方文档](https://vitejs.dev/) - [Leaflet 官方文档](https://leafletjs.com/) - [SCSS 官方文档](https://sass-lang.com/)