第09章 样式与交互细节
9.1 样式架构
项目的样式分为两部分:
- 组件内样式:各 Vue 组件的
<style>块 - 全局样式:
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 防止引用已移除元素
弹窗的焦点管理:
- 打开前记录当前焦点元素
- 打开后聚焦名称输入框
- 关闭后恢复原焦点
- 卸载时置空引用
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)
collapsedref 控制折叠- 箭头旋转动画
导入文件
// 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
link 徽章
<!-- 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
标记名称/描述在渲染到 PSV tooltip 前经过 HTML 转义,防止 XSS 注入。
9.6 交互细节总结
| 交互 | 实现方式 |
|---|---|
| 弹窗动效 | Teleport + Transition |
| 焦点管理 | lastFocused 记录/恢复 |
| Tab 圈定 | 收集可聚焦元素首尾循环 |
| 距离滑块 | 保持方位角移动距离 |
| 预览脉冲 | CSS 动画 |
| 跳转 hover | 微放大提示 |
| 全局 Esc | 弹窗/添加模式退出 |
| 防注入 | HTML 转义 |
9.7 下一步
理解了样式与交互后,进入 第10章 构建配置与部署 了解构建与部署。