forked from zhongyidian/super_video
feat: 对接各个视频数据
This commit is contained in:
1 parent
6103453e32
commit
6ab98c4764
339 files changed
+17316
-1531
No files matched your search
@@ -0,0 +1,520 @@
|
||||
<script lang="tsx" setup>
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { GuideSteps, GuideStepsItem } from '@/components/Guide'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const back = () => {
|
||||
// 跳转设备准备
|
||||
router.back()
|
||||
}
|
||||
const steps = [
|
||||
{ id: 'step1', content: '先认识 Vlog:点击右侧「1.vlog初识」继续' },
|
||||
{ id: 'step2', content: '这是主要内容区域的边框' }
|
||||
]
|
||||
const stepIndex = ref(0)
|
||||
const hiddenbar = ref(false)
|
||||
const currentIndex = ref(0) // 当前显示的图片索引
|
||||
const selectedMenuIndex = ref(0) // 当前选中的菜单项索引
|
||||
|
||||
// 菜单项数据
|
||||
const menuItems = ref([
|
||||
{ id: 'phone', name: '相机/摄像机/微单', disabled: false },
|
||||
{ id: 'phone-stand', name: '滑轨', disabled: true },
|
||||
{ id: 'bluetooth-remote', name: '三脚架', disabled: true },
|
||||
{ id: 'stabilizer', name: '稳定器', disabled: true },
|
||||
{ id: 'props', name: '灯光', disabled: true },
|
||||
{ id: 'phone-lens', name: '滤镜', disabled: true }
|
||||
])
|
||||
|
||||
const phoneList = ref([
|
||||
{ phoneImg: getStaticImage('film/equipment/相机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/摄像机产品.png') },
|
||||
{ phoneImg: getStaticImage('film/equipment/微单产品.png') }
|
||||
])
|
||||
|
||||
const platetitleList = ref([
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') },
|
||||
{ title: getStaticImage('film/equipment/相机标题.png') }
|
||||
])
|
||||
|
||||
const platetextList = ref([
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') },
|
||||
{ text: getStaticImage('film/equipment/相机内容.png') }
|
||||
])
|
||||
const hiddenbarclick = () => {
|
||||
hiddenbar.value = !hiddenbar.value
|
||||
}
|
||||
|
||||
// 选择菜单项
|
||||
const selectMenuItem = (index: number, item: { id: string; name: string; disabled: boolean }) => {
|
||||
if (item.disabled) {
|
||||
console.log(`菜单项 "${item.name}" 已被禁用`)
|
||||
return
|
||||
}
|
||||
selectedMenuIndex.value = index
|
||||
}
|
||||
|
||||
// 下一张图片
|
||||
const nextPhone = () => {
|
||||
if (currentIndex.value < phoneList.value.length - 1) {
|
||||
currentIndex.value++
|
||||
}
|
||||
}
|
||||
|
||||
// 上一张图片
|
||||
const prevPhone = () => {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--
|
||||
}
|
||||
}
|
||||
|
||||
interface SimpleStep {
|
||||
id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const handleStepChange = (_step: SimpleStep | null, index: number) => {
|
||||
stepIndex.value = index
|
||||
}
|
||||
|
||||
const currentHighlight = computed(() =>
|
||||
stepIndex.value === 0 ? '.img-switch-item--vlog' : '.work-border'
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
<div class="equipment-index">
|
||||
<!-- 背景图片 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat equipment-bg"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.EQUIPMENT.BG) }"
|
||||
></div>
|
||||
|
||||
<div class="equipment-bar">
|
||||
<img src="@/assets/images/equipment/侧边导航栏.png" alt="" :hidden="hiddenbar" />
|
||||
<!-- 内容列表 -->
|
||||
<div class="equipment-item" :hidden="hiddenbar">
|
||||
<div class="menu-list">
|
||||
<div
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="item.id"
|
||||
class="menu-item"
|
||||
:class="{
|
||||
'menu-item--active': selectedMenuIndex === index,
|
||||
'menu-item--disabled': item.disabled
|
||||
}"
|
||||
@click="selectMenuItem(index, item)"
|
||||
>
|
||||
<div
|
||||
class="menu-item__indicator"
|
||||
v-if="selectedMenuIndex === index && !item.disabled"
|
||||
></div>
|
||||
<span class="menu-item__text">{{ item.name }}</span>
|
||||
<div class="menu-item__status" v-if="item.disabled">
|
||||
<!-- <span class="status-text">未解锁</span> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备准备按钮 -->
|
||||
<div class="equipment-bt">
|
||||
<img src="@/assets/images/equipment/按钮选中-设备准备.png" alt="" />
|
||||
</div>
|
||||
<div :class="hiddenbar ? 'equipment-unfold1' : 'equipment-unfold2'" @click="hiddenbarclick()">
|
||||
<img
|
||||
:src="
|
||||
hiddenbar
|
||||
? getStaticImage('equipment/按钮-展开.png')
|
||||
: getStaticImage('equipment/按钮-收缩.png')
|
||||
"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="equipment-layout">
|
||||
<div class="equipment-home">
|
||||
<div
|
||||
class="equipment-leftbutton"
|
||||
@click="prevPhone"
|
||||
:style="{ visibility: currentIndex > 0 ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<img src="@/assets/images/equipment/按钮-左滑.png" alt="" />
|
||||
</div>
|
||||
<div class="equipment-phoneImg">
|
||||
<img :src="phoneList[currentIndex].phoneImg" alt="" />
|
||||
</div>
|
||||
<div
|
||||
class="equipment-rightbutton"
|
||||
@click="nextPhone"
|
||||
:style="{ visibility: currentIndex < phoneList.length - 1 ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<img src="@/assets/images/equipment/按钮-右滑.png" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="equipment-purpose">
|
||||
<div class="plate">
|
||||
<img src="@/assets/images/equipment/浮空屏.png" alt="" />
|
||||
</div>
|
||||
|
||||
<div class="title">
|
||||
<img :src="platetitleList[currentIndex].title" alt="" />
|
||||
</div>
|
||||
<div class="plate-content">
|
||||
<div class="text">
|
||||
<img :src="platetextList[currentIndex].text" alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ImgButton class="absolute right-0 top-0" :image="IMAGE_PATHS.COMPONENTS.BACK" @click="back" />
|
||||
<!-- 调试按钮组 -->
|
||||
<!-- <div class="debug-controls">
|
||||
<button @click="enableAllMenuItems" class="debug-btn">启用全部</button>
|
||||
<button @click="disableAllMenuItems" class="debug-btn">禁用全部</button>
|
||||
<button @click="toggleMenuItem('bluetooth-remote')" class="debug-btn">切换蓝牙遥控器</button>
|
||||
</div> -->
|
||||
|
||||
<!-- 引导步骤 -->
|
||||
<GuideSteps
|
||||
:steps="steps"
|
||||
:highlight-selector="currentHighlight"
|
||||
:highlight-padding="12"
|
||||
:highlight-radius="20"
|
||||
@step-change="handleStepChange"
|
||||
position-offset-y="85%"
|
||||
>
|
||||
<GuideStepsItem
|
||||
>拍摄工具本身不分好坏,关键在于是否适合当下的拍摄内容和使用习惯。
|
||||
接下来,我们就一起看看在这个模块中会用到哪些常见、实用的拍摄工具。
|
||||
</GuideStepsItem>
|
||||
</GuideSteps>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.equipment-index {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.equipment-layout {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.equipment-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
|
||||
.equipment-bt {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -135px;
|
||||
width: 577px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-unfold1 {
|
||||
position: absolute;
|
||||
top: 490px;
|
||||
left: -41px;
|
||||
width: 150px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-unfold2 {
|
||||
position: absolute;
|
||||
top: 490px;
|
||||
left: 268px;
|
||||
width: 150px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding-top: 220px;
|
||||
padding-left: 20px;
|
||||
|
||||
.menu-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
//background: linear-gradient(135deg, #1a2a4a 0%, #0f1a2e 100%);
|
||||
border-radius: 12px;
|
||||
padding: 16px 12px;
|
||||
// box-shadow:
|
||||
// 0 0 20px rgba(64, 150, 255, 0.3),
|
||||
// inset 0 0 20px rgba(64, 150, 255, 0.1);
|
||||
// backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24px 32px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
min-height: 44px;
|
||||
|
||||
&:hover {
|
||||
// background: rgba(64, 150, 255, 0.1);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
&--active {
|
||||
// background: rgba(64, 150, 255, 0.2);
|
||||
// box-shadow: 0 0 15px rgba(64, 150, 255, 0.4);
|
||||
|
||||
.menu-item__text {
|
||||
color: #ffffff;
|
||||
text-shadow:
|
||||
0 0 10px rgba(64, 150, 255, 0.8),
|
||||
0 0 20px rgba(64, 150, 255, 0.6),
|
||||
0 0 30px rgba(64, 150, 255, 0.4);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__indicator {
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 45%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 38px;
|
||||
background: #ffffff;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 16px rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
&__text {
|
||||
color: #64a5ff;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 0 8px rgba(64, 150, 255, 0.6);
|
||||
transition: all 0.3s ease;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
&__status {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用状态样式
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: auto; // 保持可点击以显示禁用提示
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
// background: rgba(255, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.menu-item__text {
|
||||
color: #666;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.menu-item__indicator {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.guide-ui {
|
||||
top: 80% !important;
|
||||
}
|
||||
|
||||
.dragon-character {
|
||||
top: 80% !important;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-home {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.equipment-leftbutton {
|
||||
position: absolute;
|
||||
top: 511px;
|
||||
left: 350px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.equipment-phoneImg {
|
||||
position: absolute;
|
||||
top: 313px;
|
||||
left: 500px;
|
||||
width: 446px;
|
||||
height: 484px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-rightbutton {
|
||||
position: absolute;
|
||||
top: 511px;
|
||||
right: 770px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.equipment-purpose {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.plate {
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
left: 1200px;
|
||||
width: 600px;
|
||||
/* 调整为你需要的宽度 */
|
||||
height: 1000px;
|
||||
/* 调整为你需要的高度 */
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.plate-content {
|
||||
position: absolute;
|
||||
top: 144px;
|
||||
right: 100px;
|
||||
width: 600px;
|
||||
height: 797px;
|
||||
overflow: hidden;
|
||||
/* 隐藏超出部分 */
|
||||
object-fit: contain;
|
||||
|
||||
// display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
overflow-y: auto;
|
||||
/* 允许垂直滚动 */
|
||||
scrollbar-width: none;
|
||||
/* Firefox隐藏滚动条 */
|
||||
-ms-overflow-style: none;
|
||||
/* IE和Edge隐藏滚动条 */
|
||||
perspective: 1000px;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
/* Chrome, Safari隐藏滚动条 */
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
// margin-top: 47px;
|
||||
width: 400px;
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 1304px;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
transform: rotate3d(0, 1, 0, -20deg);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调试控制按钮样式
|
||||
.debug-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
z-index: 1000;
|
||||
|
||||
.debug-btn {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
border: 1px solid #333;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="tsx" setup>
|
||||
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { ProgressBar } from '@/components'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const currentStep = ref(2)
|
||||
|
||||
const handleComplete = () => {
|
||||
console.log('完成前置条件')
|
||||
// 处理完成逻辑
|
||||
}
|
||||
|
||||
const handleStepChange = (step: number) => {
|
||||
console.log('步骤变化:', step)
|
||||
// 处理步骤变化逻辑
|
||||
}
|
||||
|
||||
const handleWorkClick = () => {
|
||||
console.log('任务取景按钮点击')
|
||||
// 处理任务取景按钮点击逻辑
|
||||
router.push({ name: 'Work' })
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
// 跳转设备准备
|
||||
router.push({ name: 'equipment' })
|
||||
}
|
||||
const shooting = () => {
|
||||
// 跳转实地取景
|
||||
router.push({ name: 'shooting' })
|
||||
}
|
||||
const highlightShow = ref(true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full h-full relative">
|
||||
<!-- 背景图片-center -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG_CENTER) }"
|
||||
></div>
|
||||
|
||||
<!-- 背景图片 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="absolute top-2 left-1/2 -translate-x-1/2">
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.TITLE" />
|
||||
</div>
|
||||
|
||||
<!-- 任务取景按钮 -->
|
||||
<div
|
||||
class="absolute left-24 top-24"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.VLOG.TASK_CAMERA) }"
|
||||
>
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.TASK_BTN" @click="handleWorkClick" />
|
||||
</div>
|
||||
<!-- 中期拍摄底板 -->
|
||||
<div v-if="highlightShow" class="absolute right-90 ml-[135px]" style="z-index: 998">
|
||||
<img :src="getStaticImage(IMAGE_PATHS.VLOG.Midterm)" />
|
||||
</div>
|
||||
<!-- 进度百分比 -->
|
||||
<div
|
||||
v-if="highlightShow"
|
||||
class="absolute left-1/2 top-[12%] -translate-x-1/2 pointer-events-none"
|
||||
>
|
||||
<div class="flex">
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.Progress" />
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.Four" />
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.Zero" />
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.Percentage" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 中期准备按钮文字 -->
|
||||
<div
|
||||
v-if="highlightShow"
|
||||
class="absolute left-[21%] top-[9%] -translate-x-1/2"
|
||||
style="z-index: 998"
|
||||
>
|
||||
<ImgButton
|
||||
:image="IMAGE_PATHS.VLOG.MidtermEquipment"
|
||||
@click="handleClick"
|
||||
class="ml-[60px]"
|
||||
/>
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermFraming" @click="shooting" class="ml-[40px]" />
|
||||
<ImgButton :image="IMAGE_PATHS.VLOG.MidtermShoot" class="ml-[20px]" />
|
||||
</div>
|
||||
<!-- 遮罩层 -->
|
||||
<div class="Shadow" v-if="highlightShow">
|
||||
<div>
|
||||
<img src="" alt="" />
|
||||
</div>
|
||||
<img src="@/assets/images/midterm/引导遮罩.png" alt="" />
|
||||
</div>
|
||||
<!-- 叉 -->
|
||||
<div
|
||||
v-if="highlightShow"
|
||||
class="absolute right-0 top-0"
|
||||
style="z-index: 998; cursor: pointer"
|
||||
@click="highlightShow = false"
|
||||
>
|
||||
<img src="@/assets/images/midterm/叉.png" alt="" />
|
||||
</div>
|
||||
<!-- 引导任务 -->
|
||||
<!-- <ImgButton class="absolute right-24 bottom-24" :image="IMAGE_PATHS.VLOG.TASK_PERSON" /> -->
|
||||
|
||||
<div class="absolute bottom-0 w-full bright">
|
||||
<ProgressBar
|
||||
:current-step="currentStep"
|
||||
@complete="handleComplete"
|
||||
@step-change="handleStepChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scopeds>
|
||||
.Shadow {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 997 !important;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
|
||||
.bright {
|
||||
z-index: 999 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getBackgroundImage, IMAGE_PATHS } from '@/utils'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleClose = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const goTimeLapse = () => {
|
||||
router.push({ name: 'FilmOnsiteTimeLapse' })
|
||||
}
|
||||
|
||||
const goSlider = () => {
|
||||
router.push({ name: 'FilmOnsiteSlider' })
|
||||
}
|
||||
|
||||
const goSlowShutterDay = () => {
|
||||
router.push({ name: 'FilmOnsiteSlowShutterDay' })
|
||||
}
|
||||
|
||||
const goSlowShutterNight = () => {
|
||||
router.push({ name: 'FilmOnsiteSlowShutterNight' })
|
||||
}
|
||||
|
||||
const goLowAngle = () => {
|
||||
router.push({ name: 'FilmOnsiteLowAngle' })
|
||||
}
|
||||
|
||||
const goRandom = () => {
|
||||
router.push({ name: 'FilmOnsiteRandom' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 内容卡片 + 按钮 -->
|
||||
<div class="absolute inset-0 flex items-center justify-center px-16 pointer-events-none">
|
||||
<div
|
||||
class="max-w-screen-2xl w-full bg-black/35 rounded-3xl border border-white/15 p-16 space-y-10 text-white pointer-events-auto"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-6">
|
||||
<h1 class="text-5xl font-extrabold tracking-wide">现场拍摄</h1>
|
||||
<span class="text-3xl opacity-80">从不同维度练习你的镜头语言</span>
|
||||
</div>
|
||||
<p class="text-2xl leading-8 opacity-90">
|
||||
选择下面任意一个练习模块,进入对应的现场拍摄场景,按提示完成小任务,逐步建立自己的拍摄手感和创作节奏。
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-3 gap-10">
|
||||
<button type="button" class="onsite-btn" @click="goTimeLapse">
|
||||
<span class="onsite-btn-title">延时拍摄</span>
|
||||
<span class="onsite-btn-desc">记录时间流动,让平凡场景变得有节奏、有变化。</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="onsite-btn" @click="goSlider">
|
||||
<span class="onsite-btn-title">滑轨拍摄</span>
|
||||
<span class="onsite-btn-desc">通过平稳移动镜头,营造空间感和叙事推进感。</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="onsite-btn" @click="goSlowShutterDay">
|
||||
<span class="onsite-btn-title">慢门拍摄 · 白天</span>
|
||||
<span class="onsite-btn-desc">在白天环境下练习慢门,营造水流、车流等运动轨迹。</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="onsite-btn" @click="goSlowShutterNight">
|
||||
<span class="onsite-btn-title">慢门拍摄 · 夜晚</span>
|
||||
<span class="onsite-btn-desc">利用城市灯光,拍出具有节奏感和氛围感的夜景画面。</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="onsite-btn" @click="goLowAngle">
|
||||
<span class="onsite-btn-title">低角度拍摄</span>
|
||||
<span class="onsite-btn-desc">从低视角观察世界,突出主体气势,改变日常视角。</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="onsite-btn" @click="goRandom">
|
||||
<span class="onsite-btn-title">创作随机性</span>
|
||||
<span class="onsite-btn-desc">跟随直觉自由取景,捕捉那些意料之外的画面惊喜。</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关闭 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onsite-btn {
|
||||
@apply relative flex flex-col items-start backdrop-blur-md rounded-2xl px-10 py-8 text-left bg-white/5 hover:bg-white/10 border border-white/10 hover:border-white/30 transition-all duration-200 cursor-pointer;
|
||||
}
|
||||
|
||||
.onsite-btn-title {
|
||||
@apply text-4xl font-bold mb-4;
|
||||
}
|
||||
|
||||
.onsite-btn-desc {
|
||||
@apply text-2xl leading-9 opacity-80;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { QuestionQuiz, type Question } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 交互一 & 交互二:选择题
|
||||
const questions = ref<Question[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '画面感受判断',
|
||||
description: '如果你身处滨江岸边,看到一位正在打太极的老人,你会选择哪个角度拍摄?',
|
||||
options: [
|
||||
{ id: 'a', label: '平视拍摄' },
|
||||
{ id: 'b', label: '俯拍' },
|
||||
{ id: 'c', label: '低角度仰拍' }
|
||||
],
|
||||
correctOptionId: '', // 引导题,没有正确答案,任意选择都可进入下一题
|
||||
mediaSrc: 'film/on-site/低角度拍摄jh_1.png'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '结果理解',
|
||||
description: '这个画面通过低角度拍摄,主要解决了哪一个画面问题?',
|
||||
options: [
|
||||
{ id: 'a', label: '放大了人物的真实身高' },
|
||||
{ id: 'b', label: '强化了人物动作的力量感与气势' },
|
||||
{ id: 'c', label: '让背景元素显得更加丰富' },
|
||||
{ id: 'd', label: '让画面信息量变得更多' }
|
||||
],
|
||||
correctOptionId: 'b',
|
||||
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_dijiaodu.mp4')
|
||||
}
|
||||
])
|
||||
|
||||
const showSummaryStep = ref(false)
|
||||
|
||||
// 答题完成,进入交互三(总结)
|
||||
const handleQuestionsComplete = () => {
|
||||
showSummaryStep.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 交互一 & 交互二:选择题 -->
|
||||
<QuestionQuiz
|
||||
v-if="!showSummaryStep"
|
||||
:questions="questions"
|
||||
@complete="handleQuestionsComplete"
|
||||
/>
|
||||
|
||||
<!-- 交互三:总结 -->
|
||||
<div
|
||||
v-if="showSummaryStep"
|
||||
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
||||
>
|
||||
<div class="flex gap-8">
|
||||
<!-- 总结文本内容 -->
|
||||
<div class="w-3/6 text-2xl leading-10 opacity-90 space-y-3">
|
||||
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
|
||||
<p>
|
||||
<span>像案例中这样以蹲姿或趴在地上的姿势进行低角度拍摄,</span>
|
||||
<span>并不会改变人物本身,</span>
|
||||
<span>却能借助降低机位的操作,</span>
|
||||
<span>赋予画面里的人物更强的力量感与气势。</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>同时,低角度也减少了,</span>
|
||||
<span>背景中杂乱元素进入画面的可能性,</span>
|
||||
<span>让观众的注意力更集中在人物的动作本身。</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 总结图片 -->
|
||||
<div class="flex-1 rounded-2xl overflow-hidden">
|
||||
<img
|
||||
class="w-full object-cover"
|
||||
:src="getStaticImage('film/on-site/低角度拍摄jh_3.png')"
|
||||
alt="低角度拍摄总结"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 创作随机性视频 URL(暂时用其他视频代替)
|
||||
const randomVideoUrl = getPublicPath('video/on-site/dygmk_czsjx_batch.mp4')
|
||||
|
||||
// 视频播放状态
|
||||
const videoPlaying = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 视频播放区域 -->
|
||||
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
|
||||
<Video
|
||||
class="w-full h-full"
|
||||
:src="randomVideoUrl"
|
||||
v-model="videoPlaying"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
:controls="true"
|
||||
:hideNativeControls="true"
|
||||
:autoplay="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 滑轨拍摄视频 URL
|
||||
const sliderVideoUrl = getPublicPath('video/on-site/dygmk_hgydjt_batch.mp4')
|
||||
|
||||
// 视频播放状态
|
||||
const videoEnded = ref(false)
|
||||
const videoPlaying = ref(false)
|
||||
const showQuiz = ref(false) // 控制是否显示答题窗口
|
||||
|
||||
// 视频播放完成
|
||||
const handleVideoEnded = () => {
|
||||
videoEnded.value = true
|
||||
showQuiz.value = true // 视频播放完成后显示答题窗口
|
||||
}
|
||||
|
||||
// 交互一 & 交互二:选择题
|
||||
const questions = ref<Question[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '滑轨拍摄',
|
||||
description: '下面哪一种拍摄情境,最适合使用滑轨拍摄?',
|
||||
options: [
|
||||
{
|
||||
id: 'a',
|
||||
label: '人物正对镜头说话,背景简单,镜头静止人物正对镜头说话,背景简单,镜头静止'
|
||||
},
|
||||
{ id: 'b', label: '人物站在窗边,前景有栏杆或墙体,镜头缓慢横向移动' },
|
||||
{ id: 'c', label: '人物快速奔跑,摄影师跟着移动拍摄' }
|
||||
],
|
||||
correctOptionId: 'b',
|
||||
explanation:
|
||||
'滑轨镜头的价值,不在"动",而在稳定、克制地交代空间关系。当前景—人物—背景同时存在时,缓慢的横向移动,会让画面产生层次和叙事感,而不是分散注意力。'
|
||||
}
|
||||
])
|
||||
|
||||
// 答题完成
|
||||
const handleQuestionsComplete = () => {
|
||||
showQuiz.value = false // 答题成功后关闭答题窗口
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 视频播放区域 -->
|
||||
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
|
||||
<Video
|
||||
class="w-full h-full"
|
||||
:src="sliderVideoUrl"
|
||||
v-model="videoPlaying"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
:controls="true"
|
||||
:hideNativeControls="true"
|
||||
:autoplay="true"
|
||||
@ended="handleVideoEnded"
|
||||
>
|
||||
<template #overlay>
|
||||
<QuestionQuiz
|
||||
v-if="showQuiz"
|
||||
:show-question-number="false"
|
||||
:questions="questions"
|
||||
@complete="handleQuestionsComplete"
|
||||
/>
|
||||
</template>
|
||||
</Video>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { QuestionQuiz, type Question } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, getStaticImage, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 交互一 & 交互二:选择题
|
||||
const questions = ref<Question[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '画面判断',
|
||||
description: '在这个白天场景中,画面里的运动变化,是否容易被直接观察到?',
|
||||
options: [
|
||||
{ id: 'a', label: '是,变化本身已经很清楚' },
|
||||
{ id: 'b', label: '否,变化存在,但不容易被完整感知' }
|
||||
],
|
||||
correctOptionId: 'b',
|
||||
mediaSrc: 'film/on-site/白天慢门jh_1.png',
|
||||
explanation: '白天光照条件下,许多运动变化存在于连续时间中,并不容易被单帧画面完整呈现。'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '拍摄方式选择',
|
||||
description:
|
||||
'在光照较强的环境中,如果希望拉长画面中的运动轨迹,拍出视频中的效果,以下哪种方式更合适?',
|
||||
options: [
|
||||
{ id: 'a', label: '提高 ISO' },
|
||||
{ id: 'b', label: '加装 ND 镜片,降低快门速度' },
|
||||
{ id: 'c', label: '使用连拍模式' }
|
||||
],
|
||||
correctOptionId: 'b',
|
||||
mediaSrc: getPublicPath('video/on-site/xianchangpaishe_baitianmanmen.mp4')
|
||||
}
|
||||
])
|
||||
|
||||
const showSummaryStep = ref(false)
|
||||
|
||||
// 答题完成,进入交互三(总结)
|
||||
const handleQuestionsComplete = () => {
|
||||
showSummaryStep.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 交互一 & 交互二:选择题 -->
|
||||
<QuestionQuiz
|
||||
v-if="!showSummaryStep"
|
||||
:questions="questions"
|
||||
@complete="handleQuestionsComplete"
|
||||
/>
|
||||
|
||||
<!-- 交互三:总结 -->
|
||||
<div
|
||||
v-if="showSummaryStep"
|
||||
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
||||
>
|
||||
<div class="flex gap-8">
|
||||
<!-- 总结文本内容 -->
|
||||
<div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3">
|
||||
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
|
||||
<p>
|
||||
<span>通过ND 镜片控制进光量,</span>
|
||||
<span>可以在白天环境下安全降低快门速度,</span>
|
||||
<span>为慢门拍摄创造条件。</span>
|
||||
</p>
|
||||
<p>
|
||||
<span>人为扩大"静止"与"运动"之间的视觉差异,</span>
|
||||
<span>让观众在同一个画面中同时感知,</span>
|
||||
<span>人的运动节奏与空间结构关系。</span>
|
||||
</p>
|
||||
</div>
|
||||
<!-- 总结图片 -->
|
||||
<div
|
||||
class="flex-1 rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"
|
||||
>
|
||||
<img
|
||||
class="w-full h-full object-cover"
|
||||
:src="getStaticImage('film/on-site/白天慢门jh_3.png')"
|
||||
alt="ND镜片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 慢门拍摄夜晚视频 URL(暂时用其他视频代替)
|
||||
const slowShutterNightVideoUrl = getPublicPath('video/on-site/dygmk_mmps_batch.mp4')
|
||||
|
||||
// 视频播放状态
|
||||
const videoPlaying = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full flex items-center justify-center overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat -z-10"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 视频播放区域 -->
|
||||
<div class="w-full h-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20">
|
||||
<Video
|
||||
class="w-full h-full"
|
||||
:src="slowShutterNightVideoUrl"
|
||||
v-model="videoPlaying"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
:controls="true"
|
||||
:hideNativeControls="true"
|
||||
:autoplay="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ImgButton } from '@/components/Button'
|
||||
import { Video, QuestionQuiz, type Question } from '@/components'
|
||||
import { getBackgroundImage, getPublicPath, IMAGE_PATHS } from '@/utils'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 延时拍摄视频 URL(占位,后续可替换)
|
||||
const timeLapseVideoUrl = getPublicPath('video/on-site/xianchangpaishe_yanshipaishe.mp4')
|
||||
|
||||
const handleBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 交互一 & 交互二:选择题
|
||||
const questions = ref<Question[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '观察判断',
|
||||
description: '如果你只用一条普通视频来拍这个场景,你觉得画面最难被感知的是什么?',
|
||||
options: [
|
||||
{ id: 'a', label: '建筑的外形结构' },
|
||||
{ id: 'b', label: '天空的颜色' },
|
||||
{ id: 'c', label: '时间的变化' },
|
||||
{ id: 'd', label: '画面的清晰度' }
|
||||
],
|
||||
correctOptionId: 'c',
|
||||
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '方法选择',
|
||||
description: '如果希望在很短时间内,让观众明显感知“时间正在流动”,你会选择哪种拍摄方式?',
|
||||
options: [
|
||||
{ id: 'a', label: '手持拍摄' },
|
||||
{ id: 'b', label: '慢动作拍摄' },
|
||||
{ id: 'c', label: '延时拍摄' },
|
||||
{ id: 'd', label: '推拉镜头' }
|
||||
],
|
||||
correctOptionId: 'c',
|
||||
mediaSrc: 'film/on-site/延时拍摄jh_1.png' // 占位:请按需替换为真实图片
|
||||
}
|
||||
])
|
||||
|
||||
const showObserveStep = ref(false)
|
||||
const videoPlaying = ref(false) // 视频播放状态
|
||||
|
||||
// 答题完成,进入交互三(视频)
|
||||
const handleQuestionsComplete = () => {
|
||||
showObserveStep.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative w-full h-full overflow-hidden">
|
||||
<!-- 背景 -->
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat"
|
||||
:style="{ backgroundImage: getBackgroundImage(IMAGE_PATHS.SHOOTING.BG) }"
|
||||
></div>
|
||||
|
||||
<!-- 交互一 & 交互二:选择题 -->
|
||||
<QuestionQuiz
|
||||
v-if="!showObserveStep"
|
||||
:questions="questions"
|
||||
@complete="handleQuestionsComplete"
|
||||
/>
|
||||
<!-- 交互三:观察型 - 播放视频 -->
|
||||
<div
|
||||
v-if="showObserveStep"
|
||||
class="absolute inset-0 flex items-center justify-center px-20 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="max-w-screen-2xl w-full bg-black/40 backdrop-blur-md rounded-3xl border border-white/20 p-16 space-y-10 text-white pointer-events-auto"
|
||||
>
|
||||
<div class="flex gap-8">
|
||||
<!-- 总结文本内容 -->
|
||||
<div class="w-2/6 text-2xl leading-10 opacity-90 space-y-3">
|
||||
<h2 class="text-4xl font-bold mb-4 leading-relaxed">总结</h2>
|
||||
<p>在这个延时画面中,注意天空变化与光影移动的节奏,它们在短时间内被连续呈现出来。</p>
|
||||
|
||||
<p><span>延时拍摄的主要目的</span>,<span>是交代时间流逝与环境状态</span>。</p>
|
||||
<p>
|
||||
<span>这里的拍摄对象是江边的建筑群与天空</span
|
||||
>。<span>通过固定机位的连续拍摄</span>,<span>将原本需要较长时间才能观察到的</span
|
||||
><span>天空变化和光影移动</span>,<span>压缩到一个较短的镜头中呈现</span>。
|
||||
</p>
|
||||
<p>
|
||||
<span>这种处理方式</span
|
||||
>,<span>非常适合放置在视频片头阶段</span>,<span>用于快速建立时间感与空间环境</span>,<span>而不会占用过多时长</span>。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 rounded-2xl overflow-hidden border border-white/20 bg-black/60 min-h-[500px]"
|
||||
>
|
||||
<Video
|
||||
class="w-full h-full"
|
||||
:src="timeLapseVideoUrl"
|
||||
v-model="videoPlaying"
|
||||
:muted="false"
|
||||
:volume="1"
|
||||
:controls="true"
|
||||
:hideNativeControls="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<ImgButton
|
||||
class="absolute right-0 top-0 z-20"
|
||||
:image="IMAGE_PATHS.COMPONENTS.BACK"
|
||||
@click="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user