Files
super_video/src/views/interview/midterm-shooting/shooting/filmingLocation-a/index.vue
T
zhongyidianandClaude Opus 4.7 3e258b87d9 feat: 完善课程学习与技能树体验
完善课程资源、学习进度与技能树解锁体验,统一视频播放与引导相关交互。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:30:18 +08:00

385 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { getBackgroundImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useRouter } from 'vue-router'
import { useInterviewStore } from '@/stores/modules/interview'
import { useViewfinderA } from './useViewfinderA'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
const router = useRouter()
const interviewStore = useInterviewStore()
const start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => {
interviewStore.markViewed('InterviewFilmingLocationA')
router.push({ name: 'InterviewFilmingLocationB' })
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 手机模式取景
const {
currentZoom,
isCapturing,
isDragging,
setZoom,
startDrag,
onDrag,
endDrag,
getTransform,
currentOffsetX,
currentOffsetY,
handleWheel,
} = useViewfinderA()
// 刻度盘配置常量
const DIAL_STEP_DEG = 30 // 1x 到 2x 之间的角度差(度)
const ZOOM_MIN = 1
const ZOOM_MAX = 3
const ZOOM_CENTER = 2
const TICK_STEP = 0.1 // 刻度步长
const MAJOR_TICK_INTERVAL = 5 // 主刻度间隔(0.5x)
const INTEGER_TICK_INTERVAL = 10 // 整数刻度间隔(1.0x)
// 刻度数据类型
interface DialTick {
zoom: number
angle: number
isMajor: boolean
isInteger: boolean
label: string
}
// 计算圆盘旋转角度
// 目标:将当前 zoom 对应的刻度旋转到 0 度位置
// 3x 在上方(-30deg),1x 在下方(+30deg)
const dialRotation = computed(() => {
return (currentZoom.value - ZOOM_CENTER) * DIAL_STEP_DEG
})
// 格式化缩放倍数显示
const formattedZoom = computed(() => {
return currentZoom.value.toFixed(1) + 'x'
})
// 生成刻度数据
const dialTicks = computed<DialTick[]>(() => {
const ticks: DialTick[] = []
const minTick = ZOOM_MIN / TICK_STEP
const maxTick = ZOOM_MAX / TICK_STEP
for (let z = minTick; z <= maxTick; z += 1) {
const zoom = z * TICK_STEP
// 刻度相对于中心(2x)的角度
// 3x -> -30deg(上方),1x -> +30deg(下方)
const angle = -(zoom - ZOOM_CENTER) * DIAL_STEP_DEG
const isMajor = z % MAJOR_TICK_INTERVAL === 0
const isInteger = z % INTEGER_TICK_INTERVAL === 0
ticks.push({
zoom,
angle,
isMajor,
isInteger,
label: isInteger ? zoom + 'x' : '',
})
}
return ticks
})
const photoImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const CANVAS_WIDTH = 871
const CANVAS_HEIGHT = 420
const CANVAS_SCALE = 2 // 2倍高清
const FLASH_DURATION = 100 // 闪光动画时长(ms)
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const RESULT_DISPLAY_DELAY = 300 // 结果显示延迟(ms)
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
/**
* 拍照功能
* 使用 Canvas 手动绘制图片,避免 DOM 截图的兼容性问题
*/
const capturePhoto = async () => {
if (isLoading.value) return
isCapturing.value = true
await new Promise((resolve) => setTimeout(resolve, FLASH_DURATION))
await capture({
width: CANVAS_WIDTH,
height: CANVAS_HEIGHT,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
resultDelay: RESULT_DISPLAY_DELAY,
onDraw: (ctx) => {
if (!photoImage.value) {
throw new Error('Photo image not found')
}
// 计算变换
// 1. 移动到中心
ctx.translate(CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2)
// 2. 应用拖拽偏移
ctx.translate(currentOffsetX.value, currentOffsetY.value)
// 3. 应用缩放
ctx.scale(currentZoom.value, currentZoom.value)
// 4. 移回左上角绘制
ctx.drawImage(
photoImage.value,
-CANVAS_WIDTH / 2,
-CANVAS_HEIGHT / 2,
CANVAS_WIDTH,
CANVAS_HEIGHT,
)
},
onComplete: () => {
isCapturing.value = false
},
})
}
</script>
<template>
<div class="w-full h-full">
<div
class="relative w-full h-full bg-cover bg-center bg-no-repeat shooting-bg"
:style="{
backgroundImage: getBackgroundImage('interview/shooting/操场-模糊.png'),
}"
>
<ImgButton
image="interview/shooting/操场-侧边标题.png"
class="title-button absolute left-0"
/>
<ImgButton
image="shooting/返回.png"
class="back-button absolute right-0"
@click="back"
/>
<StartOverlay
scene="a"
v-if="!start"
@start="start = true"
/>
<!-- 手机拍照界面 -->
<div
class="absolute bottom-0 w-full h-[766px]"
v-else
>
<img
class="phone-frame absolute w-full h-[766px] z-10 pointer-events-none"
src="@/assets/images/shooting/shoot.png"
alt=""
/>
<!-- 拍摄区域 -->
<div
class="photo-wrapper absolute pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px]"
>
<div class="photo-container w-full h-full overflow-hidden relative">
<!-- 拍摄图片 -->
<img
ref="photoImage"
class="absolute pointer-events-auto w-[871px] h-[420px] ease-out select-none cursor-move touch-none"
:class="{
'transition-transform duration-300': !isDragging,
'cursor-grab': !isDragging,
'cursor-grabbing': isDragging,
}"
:style="{ transform: getTransform() }"
src="@/assets/images/interview/shooting/操场-清晰.png"
alt=""
crossorigin="anonymous"
@mousedown="startDrag"
@mousemove="onDrag"
@mouseup="endDrag"
@mouseleave="endDrag"
@wheel="handleWheel"
@touchstart="startDrag"
@touchmove="onDrag"
@touchend="endDrag"
draggable="false"
/>
<!-- 拍照闪光效果 -->
<div
v-if="isCapturing"
class="flash-overlay absolute inset-0 bg-white animate-flash"
></div>
</div>
</div>
<div
class="shooting-decor relative pointer-events-none top-[19.2%] left-[48.3%] -translate-x-1/2 w-[871px] h-[420px] bg-cover bg-center bg-no-repeat"
:style="{
backgroundImage: getBackgroundImage('shooting/拍摄装饰.png'),
}"
>
<!-- 仿苹果半圆盘缩放控件 -->
<div
class="zoom-dial-container absolute pointer-events-auto w-[150px] h-[300px] top-1/2 -translate-y-1/2 -rotate-2 right-[179px] overflow-hidden select-none mask-gradient"
>
<!-- 指示器 (固定在中心) -->
<div
class="absolute top-1/2 left-0 w-full -translate-y-1/2 z-20 flex items-center pointer-events-none"
>
<div
class="w-0 h-0 border-t-[8px] border-t-transparent border-l-[14px] border-l-yellow-400 border-b-[8px] border-b-transparent ml-3"
></div>
<div
class="ml-3 bg-black/70 backdrop-blur-md text-yellow-400 font-bold px-3 py-1 rounded text-lg shadow-lg"
>
{{ formattedZoom }}
</div>
</div>
<!-- 旋转圆盘 -->
<!-- 圆心在容器右侧外部,形成左侧弧形 -->
<div
class="dial-wheel absolute top-1/2 right-[-480px] w-[600px] h-[600px] rounded-full bg-black/30 backdrop-blur-md border-[5px] border-white/20 transition-transform duration-100 ease-out origin-center cursor-grab active:cursor-grabbing"
:style="{
transform: `translateY(-50%) rotate(${dialRotation}deg)`,
}"
>
<div
v-for="tick in dialTicks"
:key="tick.zoom"
class="absolute top-1/2 left-0 w-full h-0 flex justify-start items-center origin-center pointer-events-auto"
:style="{
transform: `translate(0, -50%) rotate(${tick.angle}deg)`,
}"
@click.stop="setZoom(tick.zoom)"
>
<!-- 刻度线本体 (位于圆盘左边缘) -->
<div
class="absolute left-3 bg-white/90 transition-all duration-200"
:class="{
'w-10 h-1 opacity-90': tick.isInteger,
'w-6 h-0.5 opacity-60': !tick.isInteger && tick.isMajor,
'w-3 h-[1px] opacity-40': !tick.isMajor,
}"
></div>
<!-- 刻度文字 -->
<div
v-if="tick.isInteger"
class="absolute left-[52px] text-white/90 font-bold text-lg drop-shadow-md"
:style="{
transform: `rotate(${-tick.angle - dialRotation}deg)`,
}"
>
<span
:class="{
'text-yellow-400 scale-110': currentZoom === tick.zoom,
}"
class="block transition-all duration-200"
>
{{ tick.label }}
</span>
</div>
</div>
</div>
</div>
<!-- 拍摄按钮 -->
<ImgButton
image="shooting/按钮-拍摄.png"
class="capture-button absolute pointer-events-auto right-14 top-1/2 -translate-y-1/2 transition-transform active:scale-95"
@click="capturePhoto"
/>
</div>
</div>
<!-- 结果弹窗 -->
<Overlay
:visible="showModal"
@close="handleModalClose"
>
<div
class="relative w-[1000px] h-[600px] flex items-center justify-center"
>
<!-- 截图内容 -->
<div
v-if="capturedImageUrl"
class="bg-white p-2 rounded-md -rotate-6"
>
<img
:src="capturedImageUrl"
alt="拍摄结果"
/>
</div>
<!-- 操作按钮 -->
<div class="absolute bottom-1/2 -right-1/3 translate-y-1/2">
<ImgButton
image="shooting/按钮-前往下一个取景地"
@click="handleFinish"
/>
</div>
</div>
</Overlay>
</div>
<LoadingOverlay
:visible="isLoading"
text="正在出片中..."
/>
</div>
</template>
<style lang="less">
@keyframes flash {
0% {
opacity: 0;
}
50% {
opacity: 0.8;
}
100% {
opacity: 0;
}
}
.animate-flash {
animation: flash 0.3s ease-out;
}
.mask-gradient {
mask-image: linear-gradient(
to bottom,
transparent,
black 15%,
black 85%,
transparent
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent,
black 15%,
black 85%,
transparent
);
}
</style>