feat: 对接各个视频数据

This commit is contained in:
zhongyidian committed 2025-12-30 19:42:47 +08:00
1 parent 6103453e32
commit 6ab98c4764
339 files changed
+17316 -1531

No files matched your search

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { ImgButton } from '@/components/Button'
import { getStaticImage } from '@/utils'
defineEmits<{
start: []
}>()
const props = defineProps<{
scene: 'a' | 'b'
}>()
</script>
<template>
<div class="absolute bottom-0">
<div class="relative">
<img
:src="
getStaticImage(
props.scene === 'a'
? 'film/shooting/龙美术馆拍摄.png'
: 'film/shooting/油罐艺术公园拍摄.png'
)
"
alt=""
/>
<ImgButton
class="absolute bottom-[46%] left-[49%] -translate-x-1/2"
image="shooting/按钮-开始取景.png"
@click="$emit('start')"
/>
</div>
</div>
</template>
@@ -0,0 +1,77 @@
import { ref } from 'vue'
interface CaptureOptions {
width: number
height: number
scale?: number
delay?: number // 截图前的等待时间(用于 UI 更新)
resultDelay?: number // 截图后显示结果的延迟
onDraw: (ctx: CanvasRenderingContext2D) => void | Promise<void>
onComplete?: () => void
}
export function useCanvasCapture() {
const isLoading = ref(false)
const showModal = ref(false)
const capturedImageUrl = ref('')
const capture = async (options: CaptureOptions) => {
if (isLoading.value) return
const { width, height, scale = 2, delay = 50, resultDelay = 0, onDraw, onComplete } = options
isLoading.value = true
try {
// 等待 UI 更新
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay))
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('Cannot get canvas context')
}
// 设置画布大小(高清)
canvas.width = width * scale
canvas.height = height * scale
// 填充白色背景(防止透明)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// 应用缩放
ctx.scale(scale, scale)
// 执行自定义绘制逻辑
await onDraw(ctx)
capturedImageUrl.value = canvas.toDataURL('image/png')
// 延迟显示结果
if (resultDelay > 0) {
setTimeout(() => {
isLoading.value = false
showModal.value = true
onComplete?.()
}, resultDelay)
} else {
isLoading.value = false
showModal.value = true
onComplete?.()
}
} catch (error) {
console.error('Capture failed:', error)
isLoading.value = false
}
}
return {
isLoading,
showModal,
capturedImageUrl,
capture
}
}
@@ -0,0 +1,330 @@
<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 { 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 start = ref(false)
// 基础逻辑
const back = () => router.back()
const handleFinish = () => router.push({ name: 'FilmFilmingLocationB' })
// 截图功能
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('film/shooting/龙美术馆模糊底图.png') }"
>
<ImgButton image="shooting/标题-取景地a.png" class="title-button absolute left-0" />
<ImgButton image="shooting/返回.png" class="back-button absolute right-0" @click="back" />
<p
class="absolute left-1/2 -translate-x-1/2 top-[260px] text-red-500 text-4xl leading-normal font-bold"
>
操作说明:在手机取景框区域,滚动鼠标滚轮可缩放画面,按住鼠标左键拖动可调整画面位置。
</p>
<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/film/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>
@@ -0,0 +1,149 @@
import { ref } from 'vue'
import { getTouchDistance, getClientPosition } from '../utils/touchGestures'
/**
* 手机拍照模式的取景逻辑
* 支持缩放和拖拽功能
*/
export function useViewfinderA() {
// 缩放限制
const MIN_ZOOM = 1
const MAX_ZOOM = 3
// 缩放级别 (保留用于点击按钮)
const zoomLevels = [3, 2, 1]
const currentZoom = ref(1)
const isCapturing = ref(false) // 拍照闪光效果
// 拖拽相关状态
const isDragging = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const offsetX = ref(0)
const offsetY = ref(0)
const currentOffsetX = ref(0)
const currentOffsetY = ref(0)
// 双指缩放相关
const isPinching = ref(false)
const pinchStartDistance = ref(0)
const pinchStartZoom = ref(1)
// 计算边界限制
const limitOffset = () => {
const containerWidth = 871
const containerHeight = 420
const scale = currentZoom.value
// 计算图片缩放后的尺寸
const scaledWidth = containerWidth * scale
const scaledHeight = containerHeight * scale
// 计算最大偏移量(图片边缘不能超出容器中心)
const maxOffsetX = (scaledWidth - containerWidth) / 2
const maxOffsetY = (scaledHeight - containerHeight) / 2
// 限制偏移量
currentOffsetX.value = Math.max(-maxOffsetX, Math.min(maxOffsetX, currentOffsetX.value))
currentOffsetY.value = Math.max(-maxOffsetY, Math.min(maxOffsetY, currentOffsetY.value))
}
// 设置缩放级别
const setZoom = (zoom: number) => {
currentZoom.value = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
limitOffset()
}
// 鼠标滚轮缩放
const handleWheel = (e: WheelEvent) => {
e.preventDefault()
// 滚轮向下是正数(缩小),向上是负数(放大)
const delta = -e.deltaY * 0.001
const newZoom = currentZoom.value + delta
setZoom(newZoom)
}
// 开始拖拽 / 触摸
const startDrag = (e: MouseEvent | TouchEvent) => {
// 双指缩放
if ('touches' in e && e.touches.length === 2) {
isPinching.value = true
isDragging.value = false
pinchStartDistance.value = getTouchDistance(e.touches[0], e.touches[1])
pinchStartZoom.value = currentZoom.value
e.preventDefault()
return
}
// 单指拖拽
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
isPinching.value = false
const { x: clientX, y: clientY } = getClientPosition(e)
dragStartX.value = clientX
dragStartY.value = clientY
offsetX.value = currentOffsetX.value
offsetY.value = currentOffsetY.value
}
// 拖拽中 / 缩放中
const onDrag = (e: MouseEvent | TouchEvent) => {
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const currentDistance = getTouchDistance(e.touches[0], e.touches[1])
const scale = currentDistance / pinchStartDistance.value
setZoom(pinchStartZoom.value * scale)
e.preventDefault()
return
}
if (!isDragging.value) return
const { x: clientX, y: clientY } = getClientPosition(e)
const deltaX = clientX - dragStartX.value
const deltaY = clientY - dragStartY.value
currentOffsetX.value = offsetX.value + deltaX
currentOffsetY.value = offsetY.value + deltaY
limitOffset()
// 防止滚动
if (e.cancelable) {
e.preventDefault()
}
}
// 结束拖拽
const endDrag = () => {
isDragging.value = false
isPinching.value = false
}
// 计算transform样式
const getTransform = () => {
return `translate(${currentOffsetX.value}px, ${currentOffsetY.value}px) scale(${currentZoom.value})`
}
return {
// 状态
zoomLevels,
currentZoom,
isCapturing,
isDragging,
currentOffsetX,
currentOffsetY,
// 方法
setZoom,
startDrag,
onDrag,
endDrag,
handleWheel,
getTransform
}
}
@@ -0,0 +1,287 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { getBackgroundImage, getStaticImage } from '@/utils'
import { ImgButton } from '@/components/Button'
import { Overlay } from '@/components/Overlay'
import { useViewfinderB } from './useViewfinderB'
import { useCanvasCapture } from '../composables/useCanvasCapture'
import StartOverlay from '../components/StartOverlay.vue'
import LoadingOverlay from '@/components/Loading/LoadingOverlay.vue'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { useRouter } from 'vue-router'
import { useFilmStore } from '@/stores'
const router = useRouter()
const start = ref(false)
const showInstruction = ref<boolean>(false)
type ResizeDirection = 'nw' | 'ne' | 'sw' | 'se'
// 基础逻辑
const back = (): void => router.back()
const hideInstruction = (): void => {
showInstruction.value = false
}
const handleStart = (): void => {
start.value = true
showInstruction.value = true
}
// 截图功能
const { isLoading, showModal, capturedImageUrl, capture } = useCanvasCapture()
const handleModalClose = () => {
showModal.value = false
}
// 自由取景框模式
const { viewfinderContainer, viewfinderBox, viewfinder, startDrag, startResize } = useViewfinderB()
const handleStartDrag = (event: MouseEvent | TouchEvent): void => {
hideInstruction()
startDrag(event)
}
const handleStartResize = (direction: ResizeDirection, event: MouseEvent | TouchEvent): void => {
hideInstruction()
startResize(direction, event)
}
const showGuide = ref(false)
const backgroundImage = ref<HTMLImageElement | null>(null)
// Canvas 截图配置常量
const UI_UPDATE_DELAY = 50 // UI 更新延迟(ms)
const CANVAS_SCALE = 2 // Canvas 缩放比例(2倍高清)
const GUIDE_SHOW_DELAY = 1000 // 引导显示延迟(ms)
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
/**
* 截取场景
* 使用 Canvas 手动绘制,确保移动端兼容性
*/
const captureScene = async (): Promise<void> => {
if (isLoading.value) return
hideInstruction()
await capture({
width: viewfinder.value.width,
height: viewfinder.value.height,
scale: CANVAS_SCALE,
delay: UI_UPDATE_DELAY,
onDraw: ctx => {
if (!backgroundImage.value) {
throw new Error('Background image not found')
}
// 计算图片实际尺寸和设计稿尺寸的缩放比例
// viewfinder 的坐标是基于设计稿尺寸(1920x1080)
// 但 backgroundImage 的实际尺寸可能不同
const imageScaleX = backgroundImage.value.naturalWidth / 1920
const imageScaleY = backgroundImage.value.naturalHeight / 1080
// 绘制背景图片的裁剪区域
// 将设计稿坐标转换为图片实际坐标
ctx.drawImage(
backgroundImage.value,
viewfinder.value.x * imageScaleX, // 源图 x(转换为实际坐标)
viewfinder.value.y * imageScaleY, // 源图 y(转换为实际坐标)
viewfinder.value.width * imageScaleX, // 源图 width(转换为实际坐标)
viewfinder.value.height * imageScaleY, // 源图 height(转换为实际坐标)
0, // 目标 x
0, // 目标 y
viewfinder.value.width, // 目标 width
viewfinder.value.height // 目标 height
)
},
onComplete: () => {
// 延迟显示引导
setTimeout(() => {
showGuide.value = true
}, GUIDE_SHOW_DELAY)
}
})
}
/**
* 完成引导,跳转到 Vlog 页面
*/
const handleComplete = () => {
showGuide.value = false
useFilmStore().setFilmUnlockProgress(3)
router.push({
name: 'Film'
})
}
</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('film/shooting/油罐艺术公园模糊底图.png')
}"
>
<!-- 标题 -->
<ImgButton image="shooting/标题-取景地b.png" class="title-button absolute left-0" />
<!-- 返回按钮 -->
<ImgButton
image="shooting/返回.png"
class="back-button absolute right-0 z-50"
@click="back"
/>
<p
v-if="start && showInstruction"
class="absolute left-1/2 -translate-x-1/2 top-[220px] z-30 text-red-500 text-4xl leading-normal font-bold"
>
操作说明:拖动取景框四角调整裁剪范围,点击“取景”键就完成啦!
</p>
<StartOverlay scene="b" v-if="!start" @start="handleStart" />
<!-- 取景 -->
<div class="absolute bottom-0 w-full h-full" v-else>
<!-- 隐藏的背景图片,用于 Canvas 绘制 -->
<img
ref="backgroundImage"
class="absolute inset-0 w-full h-full object-cover pointer-events-none opacity-0"
:src="getStaticImage('film/shooting/龙美术馆清晰图.png')"
alt=""
crossorigin="anonymous"
/>
<!-- 取景框容器 -->
<div ref="viewfinderContainer" class="relative w-full h-full">
<!-- 四个方向的遮罩层 -->
<!-- 上遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: 0,
height: viewfinder.y + 'px'
}"
></div>
<!-- 下遮罩 -->
<div
class="mask-layer absolute left-0 right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + viewfinder.height + 'px',
bottom: 0
}"
></div>
<!-- 左遮罩 -->
<div
class="mask-layer absolute left-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
width: viewfinder.x + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 右遮罩 -->
<div
class="mask-layer absolute right-0 bg-black/60 pointer-events-none z-10"
:style="{
top: viewfinder.y + 'px',
left: viewfinder.x + viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
></div>
<!-- 可拖动调整的取景框 -->
<div
ref="viewfinderBox"
class="viewfinder-frame absolute border-4 border-white cursor-move z-20"
:style="{
left: viewfinder.x + 'px',
top: viewfinder.y + 'px',
width: viewfinder.width + 'px',
height: viewfinder.height + 'px'
}"
@mousedown="handleStartDrag"
@touchstart="handleStartDrag"
>
<!-- 九宫格辅助线 -->
<div class="grid-lines absolute inset-0 grid grid-cols-3 grid-rows-3">
<div
v-for="i in 9"
:key="i"
class="border border-white/40"
:class="{
'border-t-0': i <= 3,
'border-b-0': i >= 7,
'border-l-0': (i - 1) % 3 === 0,
'border-r-0': i % 3 === 0
}"
></div>
</div>
<!-- 四个角的调整手柄 -->
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -top-3 cursor-nwse-resize"
@mousedown.stop="handleStartResize('nw', $event)"
@touchstart.stop="handleStartResize('nw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -top-3 cursor-nesw-resize"
@mousedown.stop="handleStartResize('ne', $event)"
@touchstart.stop="handleStartResize('ne', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -left-3 -bottom-3 cursor-nesw-resize"
@mousedown.stop="handleStartResize('sw', $event)"
@touchstart.stop="handleStartResize('sw', $event)"
></div>
<div
class="resize-handle absolute w-6 h-6 bg-white rounded-full -right-3 -bottom-3 cursor-nwse-resize"
@mousedown.stop="handleStartResize('se', $event)"
@touchstart.stop="handleStartResize('se', $event)"
></div>
</div>
</div>
<!-- 底部取景按钮 - 跟随取景框 -->
<div
class="capture-button-container absolute z-20 -translate-x-1/2 -translate-y-1/2"
:style="{
left: viewfinder.x + viewfinder.width / 2 + 'px',
top: viewfinder.y + viewfinder.height + 'px'
}"
>
<ImgButton class="w-36" image="shooting/按钮-取景" @click="captureScene" />
</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="正在出片中..." />
<!-- 引导步骤 -->
<GuideSteps v-if="showGuide" positionOffsetY="80%" @complete="handleComplete">
<GuideStepsItem> 实地取景已全部完成,现在跟着我走进现场开始正式的拍摄吧~ </GuideStepsItem>
</GuideSteps>
</div>
</template>
@@ -0,0 +1,291 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { getTouchDistance, getTouchCenter, getClientPosition } from '../utils/touchGestures'
// 设计稿尺寸常量
const DESIGN_WIDTH = 1920
const DESIGN_HEIGHT = 1080
/**
* 自由取景框模式的逻辑
* 支持拖动、四角调整大小、双指缩放
*/
export function useViewfinderB() {
// 取景框相关
const viewfinderContainer = ref<HTMLElement>()
const viewfinderBox = ref<HTMLElement>()
const viewfinder = ref({
x: 660, // (1920 - 600) / 2 = 660,居中
y: 340, // (1080 - 400) / 2 = 340,居中
width: 600,
height: 400
})
// 拖动相关状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
// 调整大小相关状态
const isResizing = ref(false)
const resizeCorner = ref<'nw' | 'ne' | 'sw' | 'se' | null>(null)
const resizeStart = ref({
x: 0,
y: 0,
boxX: 0,
boxY: 0,
boxWidth: 0,
boxHeight: 0
})
// 双指缩放相关状态
const isPinching = ref(false)
const pinchStart = ref({
distance: 0,
centerX: 0,
centerY: 0,
boxWidth: 0,
boxHeight: 0,
boxX: 0,
boxY: 0
})
// 将屏幕坐标转换为缩放后的本地坐标
const convertClientToLocal = (clientX: number, clientY: number) => {
if (!viewfinderContainer.value) return { x: clientX, y: clientY }
const containerRect = viewfinderContainer.value.getBoundingClientRect()
// 计算缩放比例
const scaleX = containerRect.width / DESIGN_WIDTH
const scaleY = containerRect.height / DESIGN_HEIGHT
// 计算相对于容器的坐标,并转换为设计稿坐标系
const localX = (clientX - containerRect.left) / scaleX
const localY = (clientY - containerRect.top) / scaleY
return { x: localX, y: localY }
}
// 开始拖动取景框
const startDrag = (e: MouseEvent | TouchEvent) => {
if (isResizing.value || isPinching.value) return
// 检测双指触摸
if ('touches' in e && e.touches.length === 2) {
// 双指缩放
isPinching.value = true
const touch1 = e.touches[0]
const touch2 = e.touches[1]
pinchStart.value = {
distance: getTouchDistance(touch1, touch2),
centerX: (touch1.clientX + touch2.clientX) / 2,
centerY: (touch1.clientY + touch2.clientY) / 2,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y
}
e.preventDefault()
return
}
// 单指拖动
if ('touches' in e && e.touches.length !== 1) return
isDragging.value = true
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
dragStart.value = {
x: localPos.x - viewfinder.value.x,
y: localPos.y - viewfinder.value.y
}
e.preventDefault()
}
// 开始调整大小
const startResize = (corner: 'nw' | 'ne' | 'sw' | 'se', e: MouseEvent | TouchEvent) => {
isResizing.value = true
resizeCorner.value = corner
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
resizeStart.value = {
x: localPos.x,
y: localPos.y,
boxX: viewfinder.value.x,
boxY: viewfinder.value.y,
boxWidth: viewfinder.value.width,
boxHeight: viewfinder.value.height
}
e.preventDefault()
}
// 处理移动
const handleMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value && !isResizing.value && !isPinching.value) return
// 处理双指缩放
if (isPinching.value && 'touches' in e && e.touches.length === 2) {
const touch1 = e.touches[0]
const touch2 = e.touches[1]
const currentDistance = getTouchDistance(touch1, touch2)
const scale = currentDistance / pinchStart.value.distance
// 计算新的尺寸
let newWidth = pinchStart.value.boxWidth * scale
let newHeight = pinchStart.value.boxHeight * scale
const minSize = 200
// 限制最小和最大尺寸
newWidth = Math.max(minSize, Math.min(newWidth, DESIGN_WIDTH))
newHeight = Math.max(minSize, Math.min(newHeight, DESIGN_HEIGHT))
// 保持中心点不变,计算新的位置
const center = getTouchCenter(touch1, touch2)
const localCenter = convertClientToLocal(center.x, center.y)
let newX = localCenter.x - newWidth / 2
let newY = localCenter.y - newHeight / 2
// 边界检测
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - newWidth))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - newHeight))
viewfinder.value.width = newWidth
viewfinder.value.height = newHeight
viewfinder.value.x = newX
viewfinder.value.y = newY
e.preventDefault()
return
}
const { x: clientX, y: clientY } = getClientPosition(e)
const localPos = convertClientToLocal(clientX, clientY)
if (isDragging.value && viewfinderContainer.value) {
// 拖动取景框
let newX = localPos.x - dragStart.value.x
let newY = localPos.y - dragStart.value.y
// 边界检测 - 使用设计稿尺寸
newX = Math.max(0, Math.min(newX, DESIGN_WIDTH - viewfinder.value.width))
newY = Math.max(0, Math.min(newY, DESIGN_HEIGHT - viewfinder.value.height))
viewfinder.value.x = newX
viewfinder.value.y = newY
} else if (isResizing.value && viewfinderContainer.value) {
// 调整取景框大小
const deltaX = localPos.x - resizeStart.value.x
const deltaY = localPos.y - resizeStart.value.y
const minSize = 200 // 最小尺寸
if (resizeCorner.value === 'se') {
// 右下角 - 使用设计稿尺寸限制
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'sw') {
// 左下角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newX = resizeStart.value.boxX + deltaX
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
viewfinder.value.height = Math.max(
minSize,
Math.min(resizeStart.value.boxHeight + deltaY, DESIGN_HEIGHT - viewfinder.value.y)
)
} else if (resizeCorner.value === 'ne') {
// 右上角 - 使用设计稿尺寸限制
const newHeight = resizeStart.value.boxHeight - deltaY
const newY = resizeStart.value.boxY + deltaY
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
viewfinder.value.width = Math.max(
minSize,
Math.min(resizeStart.value.boxWidth + deltaX, DESIGN_WIDTH - viewfinder.value.x)
)
} else if (resizeCorner.value === 'nw') {
// 左上角 - 使用设计稿尺寸限制
const newWidth = resizeStart.value.boxWidth - deltaX
const newHeight = resizeStart.value.boxHeight - deltaY
const newX = resizeStart.value.boxX + deltaX
const newY = resizeStart.value.boxY + deltaY
if (newWidth >= minSize && newX >= 0) {
viewfinder.value.x = newX
viewfinder.value.width = newWidth
}
if (newHeight >= minSize && newY >= 0) {
viewfinder.value.y = newY
viewfinder.value.height = newHeight
}
}
}
e.preventDefault()
}
// 结束拖动或调整
const handleEnd = () => {
isDragging.value = false
isResizing.value = false
isPinching.value = false
resizeCorner.value = null
}
// 挂载时添加全局事件监听
onMounted(() => {
document.addEventListener('mousemove', handleMove)
document.addEventListener('mouseup', handleEnd)
document.addEventListener('touchmove', handleMove)
document.addEventListener('touchend', handleEnd)
})
// 卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('mousemove', handleMove)
document.removeEventListener('mouseup', handleEnd)
document.removeEventListener('touchmove', handleMove)
document.removeEventListener('touchend', handleEnd)
})
return {
// Refs
viewfinderContainer,
viewfinderBox,
// 状态
viewfinder,
isDragging,
isResizing,
isPinching,
// 方法
startDrag,
startResize,
// 常量
DESIGN_WIDTH,
DESIGN_HEIGHT
}
}
@@ -0,0 +1,210 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
import shootingtitle from '@/assets/images/shooting/标题-实地取景.png'
import videoSrc from '/video/xinshou_jiuzhonggoutufa.mp4'
import KnowledgeCard from '@/components/KnowledgeCard/FilmKnowledgeCard.vue'
type Btn = {
key: string
name: string
btnImage: string
images: string[]
desc: string
}
const router = useRouter()
const showModal = ref(false)
const showModal2 = ref(false)
const showModal3 = ref(false)
const currentItem = ref<Btn>({
key: '',
name: '',
btnImage: '',
images: [],
desc: ''
})
const btnList = ref<Btn[]>([
{
key: '1',
name: '引导线构图',
btnImage: 'film/shooting/框架构图',
desc: '引导线构图就是主体放在画面中心,让画面更稳定主体更突出,拍摄时需避免选取杂乱的背景,可以用大光圈或者长焦距让背景虚化使主体从背景中跳出来适合拍摄单个主体,操作比较简单,非常适合新手使用',
images: ['film/shooting/05引导线构图_底图', 'film/shooting/06引导线构图_底图']
},
{
key: '2',
name: '对称构图',
btnImage: 'film/shooting/对称构图',
desc: '对称构图又称九宫格构图,就是把拍摄主体置于九宫格的任意一条三分位置,三分构图法是比较常见的构图法可以视觉上让整体画面更加生动,使用三分构图法拍摄人像时,尽量占据画面的下2格比例和重心会更协调',
images: ['film/shooting/03对称构图_底图', 'film/shooting/04对称构图_底图']
},
{
key: '3',
name: '框架构图',
btnImage: 'film/shooting/引导线构图',
desc: '框架构图属于引导线构图的一种,所以也是借助引导线的视觉引导效果来突出强调主体。另外对角线构图在画面中制造出对角线的走向即可。',
images: ['film/shooting/01框架构图_底图', 'film/shooting/02框架构图_底图']
}
])
const handleModalClose = () => {
showModal.value = false
}
const handleClick = (item: any) => {
currentItem.value = item
showModal2.value = true
}
const currentCompositionKey = computed(() => {
const k = currentItem.value.key
if (k === '1' || k === '2' || k === '3') return k
return '1'
})
const handleClickA = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationA' })
}
const handleClickB = () => {
// 跳转设备准备
router.push({ name: 'FilmFilmingLocationB' })
}
const back = () => {
// 跳转设备准备
router.back()
}
</script>
<template>
<div class="relative w-full h-full">
<!-- 背景图片 -->
<div
class="absolute inset-0 bg-cover bg-center bg-no-repeat shooting-bg"
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
></div>
<div class="shooting-title">
<img :src="shootingtitle" alt="" />
</div>
<div class="shooting-return">
<ImgButton image="shooting/返回.png" @click="back" />
</div>
<div class="absolute left-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="film/shooting/龙美术馆入口.png" @click="handleClickA" />
</div>
<div class="absolute right-[12%] top-1/2 -translate-y-[calc(50%-70px)]">
<ImgButton image="film/shooting/油罐艺术公园入口.png" @click="handleClickB" />
</div>
<div class="shooting-composition">
<ImgButton image="shooting/构图指南.png" @click="showModal = true" />
</div>
<!-- 引导步骤 -->
<GuideSteps
:highlight-selector="'.mybutton'"
:highlight-padding="12"
:highlight-radius="20"
position-offset-y="85%"
>
<GuideStepsItem> 哇,徐汇滨江真的超适合取景!一起看看可以运用哪些构图方法吧~ </GuideStepsItem>
</GuideSteps>
<!-- 保存图片弹窗 -->
<Overlay
:visible="showModal"
:clickable="false"
:customStyle="{
backgroundImage: getBackgroundImage('film/shooting/构图指南底图')
}"
customClass="inset-0 bg-cover bg-center bg-no-repeat"
contentClass="relative w-full h-full"
>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.BACK"
@click="handleModalClose"
/>
<!-- 构图指南 -->
<ImgButton class="absolute left-0 top-0 z-10" image="shooting/标题-构图指南" />
<!-- 新手导航视频 -->
<ImgButton
class="absolute left-28 top-[calc(50%-40px)] -translate-y-1/2"
image="shooting/新手导航-视频回顾"
@click="showModal3 = true"
/>
<div class="absolute top-[30%] right-10">
<ImgButton
v-for="item in btnList"
:key="item.key"
:image="item.btnImage"
@click="handleClick(item)"
/>
</div>
<!-- 知识卡片 -->
<KnowledgeCard
v-if="showModal2 && currentItem.key"
:visible="showModal2"
:title="currentItem.name"
:desc="currentItem.desc"
:images="currentItem.images"
:compositionKey="currentCompositionKey"
@close="showModal2 = false"
/>
<!-- 视频 -->
<Overlay :visible="showModal3" :clickable="false">
<video :src="videoSrc" controls></video>
<!-- 返回 -->
<ImgButton
class="absolute right-0 top-0 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="showModal3 = false"
/>
</Overlay>
</Overlay>
</div>
</template>
<style lang="less" scoped>
.shooting-title {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.shooting-return {
position: absolute;
right: 0;
top: 0;
}
.shooting-left {
position: absolute;
left: 16%;
top: 50%;
}
.shooting-right {
position: absolute;
right: 16%;
top: 50%;
}
.shooting-composition {
position: absolute;
left: 2rem;
bottom: 1rem;
}
</style>
@@ -0,0 +1,47 @@
/**
* 触摸手势工具函数
* 用于处理触摸事件的通用计算
*/
/**
* 计算两个触摸点之间的距离
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 两点之间的欧氏距离
*/
export const getTouchDistance = (touch1: Touch, touch2: Touch): number => {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
/**
* 计算两个触摸点的中心坐标
* @param touch1 第一个触摸点
* @param touch2 第二个触摸点
* @returns 中心点坐标 {x, y}
*/
export const getTouchCenter = (touch1: Touch, touch2: Touch) => {
return {
x: (touch1.clientX + touch2.clientX) / 2,
y: (touch1.clientY + touch2.clientY) / 2
}
}
/**
* 从鼠标或触摸事件中获取客户端坐标
* @param e 鼠标事件或触摸事件
* @returns 客户端坐标 {x, y}
*/
export const getClientPosition = (e: MouseEvent | TouchEvent) => {
if ('touches' in e && e.touches.length > 0) {
return {
x: e.touches[0].clientX,
y: e.touches[0].clientY
}
}
return {
x: (e as MouseEvent).clientX,
y: (e as MouseEvent).clientY
}
}