第09章 样式与交互细节

9.1 样式架构

项目的样式分为两部分:

  1. 组件内样式:各 Vue 组件的 <style>
  2. 全局样式src/styles/psv.css,覆盖 PSV 默认样式

9.2 PSV 全局样式覆盖

src/styles/psv.css(共 55 行)负责覆盖 Photo Sphere Viewer 的默认样式。

9.2.1 tooltip/面板样式

/* psv.css:4-32 */
.psv-panel-content,
.psv-marker-content {
  padding: 8px;
  min-width: 160px;
}

.psv-marker-content h3 {
  margin: 0 0 4px;
  font-size: 14px;
  color: #333;
}

.psv-marker-content p {
  margin: 0;
  font-size: 12px;
  color: #666;
}
  • 标记 tooltip 内容加 padding 和 min-width
  • h3/p 深色主题配色
  • 提升文字可读性

9.2.2 预览 pin 脉冲动效

/* psv.css:35-46 */
.psv-marker--preview {
  animation: psv-preview-pulse 1.1s ease-in-out infinite;
}

.psv-marker--preview .psv-marker-image {
  transform-origin: bottom center;
}

@keyframes psv-preview-pulse {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.22); }
}

添加标记时的红色预览 pin 使用脉冲动画

  • 1.1 秒循环
  • 缩放 1 → 1.22 → 1
  • transform-origin: bottom center 保证从底部中心缩放

9.2.3 跳转标记 hover 微放大

/* psv.css:49-55 */
.psv-marker--link:hover {
  transform: scale(1.15);
}

跳转标记(绿色箭头)hover 时放大 1.15,提示可点击。

9.3 组件交互细节

9.3.1 MarkerModal 弹窗

Teleport + Transition

<!-- MarkerModal.vue:2-3 -->
<Teleport to="body">
  <Transition name="modal">
    <!-- 弹窗内容 -->
  </Transition>
</Teleport>
  • Teleport to="body":将弹窗渲染到 body,避免被父容器裁剪
  • <Transition name="modal">:淡入 + 上移缩放动效

焦点管理

// MarkerModal.vue:170, 183-206
// 打开前记录 lastFocused = document.activeElement
// nextTick 后聚焦名称输入框
// 关闭后 lastFocused?.focus?.() 恢复焦点
// onBeforeUnmount 置空 lastFocused 防止引用已移除元素

弹窗的焦点管理:

  1. 打开前记录当前焦点元素
  2. 打开后聚焦名称输入框
  3. 关闭后恢复原焦点
  4. 卸载时置空引用

Tab 焦点圈定

// MarkerModal.vue:264-288
function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    e.stopPropagation()  // 阻止 App 全局处理器误退出添加模式
    // 关闭弹窗
  }
  if (e.key === 'Tab') {
    // 收集可聚焦元素,首尾循环
  }
}
  • Esc 键:stopPropagation 阻止 App 全局处理器误退出添加模式
  • Tab 键:焦点在弹窗内首尾循环,防止焦点逃逸

距离滑块

// MarkerModal.vue:222-239
const distM = computed(() => distanceMeters(sceneCoordinates, formCoords))
const sliderDist = computed(() => clamp(distM.value, 50, 3000))

function onDistInput() {
  // 用 bearingBetween 保持方位角不变
  // destination 沿方向移动距离
  // 更新经纬度并 emit coords-change
}

距离滑块(50–3000 米):

  • 计算当前标记到拍摄点的距离
  • 拖动时保持方位角不变,沿方向移动距离
  • 实时同步预览

类型选择

<!-- MarkerModal.vue:44-63 -->
<!-- 两个 radio 卡片(info 📍 / link ➜) -->
<!-- link 时显示目标场景下拉 -->
  • 两个 radio 卡片选择类型
  • link 类型时显示目标场景下拉(targetScenes 排除当前场景)

9.3.2 MarkerList 标记列表

折叠

// MarkerList.vue:103, 5-9
const collapsed = ref(false)
  • collapsed ref 控制折叠
  • 箭头旋转动画

导入文件

// MarkerList.vue:141-154
// 隐藏 <input type="file">
// FileReader.readAsText 读取后 emit import
// 读取失败 toast 报错
// 读取后重置 input.value 以便重复选择同一文件
  • 隐藏文件输入框
  • FileReader.readAsText 读取
  • 读取后重置 input.value 以便重复选择同一文件

时间格式化

// MarkerList.vue:125-135
// 当天显示 HH:mm,否则 MM-DD HH:mm
<!-- MarkerList.vue:51-56 -->
<!-- link 标记显示绿色 ➜ 目标场景名 徽章 -->

SCENES 常量查目标场景名。

9.3.3 ToastHost 全局反馈

<!-- ToastHost.vue -->
<Teleport to="body">
  <TransitionGroup name="toast" tag="div">
    <!-- toast 列表 -->
  </TransitionGroup>
</Teleport>
  • Teleport to="body":渲染到 body
  • <TransitionGroup name="toast" tag="div">必须显式 tag="div" 才能正确应用 flex 布局
  • iconOf() 按类型返回 ✓/✕/ℹ
  • aria-live="polite" 无障碍
  • 点击 toast 可 dismiss

9.4 全局键盘快捷键

// App.vue:121-134
window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    if (flow.showModal.value) {
      flow.onModalCancel()  // 弹窗开着 → 取消弹窗
    } else if (flow.adding.value) {
      flow.exitAdding()     // 添加模式 → 退出
    }
  }
})

全局 Esc 快捷键:

  • 弹窗开着 → 取消弹窗
  • 添加模式 → 退出添加模式

注意:弹窗内的 Esc 由 MarkerModal 自行处理并 stopPropagation,避免重复响应。

9.5 安全处理:HTML 转义

// scenes.ts:107-114
function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
}

标记名称/描述在渲染到 PSV tooltip 前经过 HTML 转义,防止 XSS 注入。

9.6 交互细节总结

交互 实现方式
弹窗动效 Teleport + Transition
焦点管理 lastFocused 记录/恢复
Tab 圈定 收集可聚焦元素首尾循环
距离滑块 保持方位角移动距离
预览脉冲 CSS 动画
跳转 hover 微放大提示
全局 Esc 弹窗/添加模式退出
防注入 HTML 转义

9.7 下一步

理解了样式与交互后,进入 第10章 构建配置与部署 了解构建与部署。