chore: bypass pre-commit for now

This commit is contained in:
zhongyingmin committed 2025-09-26 00:12:43 +08:00
1 parent c4ba322e32
commit d6b8682c68
39 files changed
+1328 -477

No files matched your search

@@ -0,0 +1,137 @@
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Overlay } from '@/components/Overlay'
import { ImgButton } from '@/components/Button'
import { getBackgroundImage, getStaticImage, IMAGE_PATHS } from '@/utils'
interface Props {
visible: boolean
title: string
desc: string
images: string[]
compositionKey: '1' | '2' | '3'
}
const props = defineProps<Props>()
const emit = defineEmits<{ (e: 'close'): void }>()
const imageMap = {
'2': 'shooting/示意图-三分法',
'1': 'shooting/示意图-居中',
'3': 'shooting/示意图-对角线'
}
const getImage = computed(() => {
return getBackgroundImage(imageMap[props.compositionKey])
})
// 每张图片的辅助线开关状态
const showBoxLineMap = ref<Record<number, boolean>>({})
watch(
() => props.visible,
v => {
if (v) {
showBoxLineMap.value = {}
}
}
)
const handleToggle = (index: number) => {
showBoxLineMap.value[index] = !showBoxLineMap.value[index]
}
const show = computed(() => props.visible)
</script>
<template>
<Overlay :visible="show" :clickable="false">
<div
class="relative w-[1752px] h-[932px]"
:style="{ backgroundImage: getBackgroundImage('shooting/知识卡片底板') }"
>
<div
class="absolute top-4 left-1/2 -translate-x-1/2 text-[45px] text-white font-bold tracking-[2px] drop-shadow-[2px_2px_4px_rgba(0,0,139,0.8)]"
>
{{ props.title }}
</div>
<div
class="absolute w-full h-[38px] top-32 left-1/2 -translate-x-1/2 bg-no-repeat bg-center"
:style="{ backgroundImage: getBackgroundImage('shooting/文案1') }"
></div>
<div
class="absolute w-[191px] h-[131px] top-[30px] left-[120px] bg-no-repeat bg-center"
:style="{ backgroundImage: getImage }"
></div>
<div
class="absolute top-[calc(50%-30px)] -translate-y-1/2"
v-for="(image, index) in props.images"
:key="index"
:style="{ left: index === 0 ? '15%' : '55%' }"
>
<ImgButton
class="absolute right-6 top-6 z-10"
:image="showBoxLineMap[index] ? 'shooting/辅助线开' : 'shooting/辅助线关'"
@click="handleToggle(index)"
/>
<div class="relative">
<img :src="getStaticImage(image)" alt="" />
<div v-if="showBoxLineMap[index]" class="absolute inset-0 pointer-events-none p-[18px]">
<div class="relative w-full h-full overflow-hidden">
<div v-if="props.compositionKey === '1'" class="absolute inset-0">
<div
class="absolute top-0 left-1/2 w-0.5 h-full bg-white opacity-80 -translate-x-1/2"
></div>
<div
class="absolute left-0 top-1/2 w-full h-0.5 bg-white opacity-80 -translate-y-1/2"
></div>
</div>
<div v-else-if="props.compositionKey === '2'" class="absolute inset-0">
<div
class="absolute top-0 left-1/3 w-0.5 h-full bg-white opacity-80 -translate-x-1/2"
></div>
<div
class="absolute top-0 left-2/3 w-0.5 h-full bg-white opacity-80 -translate-x-1/2"
></div>
<div
class="absolute left-0 top-1/3 w-full h-0.5 bg-white opacity-80 -translate-y-1/2"
></div>
<div
class="absolute left-0 top-2/3 w-full h-0.5 bg-white opacity-80 -translate-y-1/2"
></div>
</div>
<div v-else-if="props.compositionKey === '3'" class="absolute inset-0">
<div class="absolute top-0 left-0 w-full h-full">
<div
class="absolute top-0 right-0 h-0.5 bg-white opacity-80"
style="width: 141.42%; transform: rotate(-33deg); transform-origin: right top"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="absolute w-full px-20 bottom-32 left-1/2 -translate-x-1/2 text-[#395E66] text-3xl font-bold indent-14 leading-[50px]"
>
{{ props.desc }}
</div>
<ImgButton
class="absolute right-2 top-4 translate-x-1/2 -translate-y-1/2 z-10"
:image="IMAGE_PATHS.COMPONENTS.CLOSE"
@click="emit('close')"
/>
</div>
</Overlay>
</template>
<style scoped></style>
+486
View File
@@ -0,0 +1,486 @@
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
type TimeUnit = number
interface VideoSource {
src: string
type?: string
}
interface Props {
// 源:字符串或多源
src?: string
sources?: VideoSource[]
// 初始配置
autoplay?: boolean
muted?: boolean
loop?: boolean
preload?: 'auto' | 'metadata' | 'none'
poster?: string
// UI 配置
controls?: boolean // 是否显示自定义控件
hideNativeControls?: boolean // 隐藏原生控件
autoHideControls?: boolean // 播放时自动隐藏控制条与插槽
autoHideDelay?: number // 自动隐藏延迟(毫秒)
// 行为:受控/回调
modelValue?: boolean // 外部受控播放状态
volume?: number // 0~1
playbackRate?: number
// 尺寸与适配
fit?: 'contain' | 'cover' | 'fill'
}
const props = withDefaults(defineProps<Props>(), {
autoplay: false,
muted: true,
loop: false,
preload: 'auto',
controls: true,
hideNativeControls: true,
autoHideControls: true,
autoHideDelay: 2000,
playbackRate: 1,
fit: 'contain'
})
const emit = defineEmits<{
(e: 'update:modelValue', playing: boolean): void
(e: 'play'): void
(e: 'pause'): void
(e: 'ended'): void
(e: 'timeupdate', currentTime: TimeUnit, duration: TimeUnit): void
(e: 'error', error: unknown): void
(e: 'loadedmetadata', duration: TimeUnit): void
(e: 'ratechange', rate: number): void
(e: 'volumechange', volume: number, muted: boolean): void
(e: 'seeking', currentTime: TimeUnit): void
(e: 'seeked', currentTime: TimeUnit): void
}>()
const videoRef = ref<HTMLVideoElement>()
// 本地状态
const isPlaying = ref(false)
const isSeeking = ref(false)
const currentTime = ref<TimeUnit>(0)
const duration = ref<TimeUnit>(0)
const buffered = ref<TimeUnit>(0)
const internalVolume = ref(props.volume ?? 1)
const internalRate = ref(props.playbackRate)
const showRateOptions = ref(false)
const rateOptions = [0.5, 0.75, 1, 1.25, 1.5, 2]
// UI 可见性与计时器(控制条与所有插槽一起隐藏/显示)
const showUi = ref(true)
let hideTimer: number | undefined
const clearHideTimer = () => {
if (hideTimer !== undefined) {
window.clearTimeout(hideTimer)
hideTimer = undefined
}
}
const scheduleHideIfNeeded = () => {
clearHideTimer()
if (!props.autoHideControls) return
if (!isPlaying.value) return
hideTimer = window.setTimeout(() => {
showUi.value = false
}, props.autoHideDelay)
}
const revealTemporarily = () => {
showUi.value = true
scheduleHideIfNeeded()
}
const hasMultipleSources = computed(() => !!props.sources && props.sources.length > 0)
const fmt = (s: number) => {
const sec = Math.floor(s % 60)
const min = Math.floor((s / 60) % 60)
const hr = Math.floor(s / 3600)
const two = (n: number) => (n < 10 ? `0${n}` : `${n}`)
return hr > 0 ? `${hr}:${two(min)}:${two(sec)}` : `${two(min)}:${two(sec)}`
}
const progressPercent = computed(() =>
duration.value ? (currentTime.value / duration.value) * 100 : 0
)
const bufferedPercent = computed(() =>
duration.value ? (buffered.value / duration.value) * 100 : 0
)
const togglePlay = async () => {
if (!videoRef.value) return
if (videoRef.value.paused) {
try {
await videoRef.value.play()
} catch (err) {
emit('error', err)
}
} else {
videoRef.value.pause()
}
}
const handleTimeInput = (e: Event) => {
if (!videoRef.value) return
const target = e.target as HTMLInputElement
const next = (Number(target.value) / 100) * duration.value
isSeeking.value = true
videoRef.value.currentTime = next
emit('seeking', next)
}
const commitSeek = () => {
if (!videoRef.value) return
isSeeking.value = false
emit('seeked', videoRef.value.currentTime)
}
const handleVolume = (v: number) => {
if (!videoRef.value) return
internalVolume.value = Math.min(1, Math.max(0, v))
videoRef.value.volume = internalVolume.value
videoRef.value.muted = internalVolume.value === 0
}
const handleRate = (r: number) => {
if (!videoRef.value) return
internalRate.value = r
videoRef.value.playbackRate = r
}
// 选择倍速并关闭下拉
const selectRate = (r: number) => {
handleRate(r)
showRateOptions.value = false
}
// 供插槽调用:跳转到指定时间
const seek = (time: number) => {
if (!videoRef.value) return
const safeDuration = Number.isFinite(duration.value) ? duration.value : 0
const next = Math.min(Math.max(0, time), safeDuration)
videoRef.value.currentTime = next
}
// 外部受控播放
watch(
() => props.modelValue,
val => {
if (val == null || !videoRef.value) return
if (val && videoRef.value.paused) {
videoRef.value.play().catch(err => emit('error', err))
} else if (!val && !videoRef.value.paused) {
videoRef.value.pause()
}
}
)
// 同步外部 volume、rate
watch(
() => props.volume,
v => {
if (v == null || !videoRef.value) return
handleVolume(v)
}
)
watch(
() => props.playbackRate,
r => {
if (r == null || !videoRef.value) return
handleRate(r)
}
)
// 监听 video 事件
onMounted(() => {
const el = videoRef.value
if (!el) return
const onPlay = () => {
isPlaying.value = true
emit('update:modelValue', true)
emit('play')
revealTemporarily()
}
const onPause = () => {
isPlaying.value = false
emit('update:modelValue', false)
emit('pause')
clearHideTimer()
showUi.value = true
}
const onEnded = () => emit('ended')
const onTime = () => {
currentTime.value = el.currentTime
// 计算缓冲
if (el.buffered.length) {
buffered.value = el.buffered.end(el.buffered.length - 1)
}
duration.value = el.duration || duration.value
emit('timeupdate', currentTime.value, duration.value)
}
const onLoaded = () => {
duration.value = el.duration || 0
emit('loadedmetadata', duration.value)
}
const onError = (e: Event) => emit('error', e)
const onRate = () => emit('ratechange', el.playbackRate)
const onVolume = () => emit('volumechange', el.volume, el.muted)
el.addEventListener('play', onPlay)
el.addEventListener('pause', onPause)
el.addEventListener('ended', onEnded)
el.addEventListener('timeupdate', onTime)
el.addEventListener('loadedmetadata', onLoaded)
el.addEventListener('error', onError)
el.addEventListener('ratechange', onRate)
el.addEventListener('volumechange', onVolume)
// 初始配置
el.autoplay = props.autoplay
el.muted = props.muted
el.loop = props.loop
el.preload = props.preload
el.playbackRate = internalRate.value
el.volume = internalVolume.value
})
onUnmounted(() => {
const el = videoRef.value
if (!el) return
el.src = ''
el.load()
})
const videoStyle = computed(() => ({
objectFit: props.fit
}))
</script>
<template>
<div
class="sv-video relative w-full h-full select-none"
:class="{ 'cursor-none': isPlaying && !showUi }"
@mousemove="revealTemporarily"
@touchstart.passive="revealTemporarily"
>
<video
ref="videoRef"
:src="!hasMultipleSources ? src : undefined"
:poster="poster"
:preload="preload"
:muted="muted"
:loop="loop"
:autoplay="autoplay"
:controls="!hideNativeControls"
:style="videoStyle"
@click="togglePlay"
class="w-full h-full block bg-black"
>
<template v-if="hasMultipleSources">
<source v-for="s in sources" :key="s.src" :src="s.src" :type="s.type" />
</template>
您的浏览器暂不支持 video 标签
</video>
<!-- 覆盖层插槽:叠在视频之上 -->
<Transition name="sv-fade-fast">
<div v-show="showUi" class="pointer-events-none absolute inset-0">
<slot
name="overlay"
:video="videoRef"
:playing="isPlaying"
:current-time="currentTime"
:duration="duration"
:progress-percent="progressPercent"
:buffered-percent="bufferedPercent"
:volume="internalVolume"
:rate="internalRate"
:toggle-play="togglePlay"
:seek="seek"
:set-volume="handleVolume"
:set-rate="handleRate"
/>
</div>
</Transition>
<!-- 控制条插槽(可完全自定义),提供默认实现作为后备 -->
<Transition name="sv-slide-up">
<div v-if="controls" v-show="showUi">
<slot
name="controls"
:video="videoRef"
:playing="isPlaying"
:current-time="currentTime"
:duration="duration"
:progress-percent="progressPercent"
:buffered-percent="bufferedPercent"
:volume="internalVolume"
:rate="internalRate"
:toggle-play="togglePlay"
:seek="seek"
:set-volume="handleVolume"
:set-rate="handleRate"
>
<div
class="sv-controls absolute left-0 right-0 bottom-0 p-3 bg-gradient-to-t from-black/60 to-transparent text-white"
>
<div class="flex items-center gap-3">
<button
class="w-8 h-8 flex items-center justify-center rounded hover:bg-white/10"
@click.stop="togglePlay"
>
<span v-if="!isPlaying">▶</span>
<span v-else>⏸</span>
</button>
<!-- 左侧扩展位 -->
<slot
name="controls-left"
:video="videoRef"
:playing="isPlaying"
:current-time="currentTime"
:duration="duration"
:progress-percent="progressPercent"
:buffered-percent="bufferedPercent"
:volume="internalVolume"
:rate="internalRate"
:toggle-play="togglePlay"
:seek="seek"
:set-volume="handleVolume"
:set-rate="handleRate"
/>
<div class="text-xs tabular-nums min-w-[60px]">{{ fmt(currentTime) }}</div>
<div class="relative flex-1 h-2">
<div class="absolute inset-0 rounded bg-white/20"></div>
<div
class="absolute inset-y-0 left-0 rounded bg-white/40"
:style="{ width: bufferedPercent + '%' }"
></div>
<div
class="absolute inset-y-0 left-0 rounded bg-blue-500"
:style="{ width: progressPercent + '%' }"
></div>
<input
class="absolute inset-0 w-full opacity-0 cursor-pointer"
type="range"
min="0"
max="100"
step="0.1"
:value="progressPercent"
@input="handleTimeInput"
@change="commitSeek"
/>
</div>
<div class="text-xs tabular-nums min-w-[60px] text-right">{{ fmt(duration) }}</div>
<div class="ml-2 flex items-center gap-2">
<span class="text-sm">🔊</span>
<input
class="w-24 cursor-pointer"
type="range"
min="0"
max="1"
step="0.01"
:value="internalVolume"
@input="(e: Event) => handleVolume(Number((e.target as HTMLInputElement).value))"
/>
</div>
<!-- 倍速:向上展开的下拉菜单(dropup) -->
<div class="ml-2 relative">
<button
class="bg-white/10 rounded px-2 py-1 text-xs hover:bg-white/20"
@click.stop="showRateOptions = !showRateOptions"
>
{{ internalRate }}x
</button>
<div
v-if="showRateOptions"
class="absolute bottom-full right-0 mb-2 bg-black/80 text-white rounded shadow-lg p-1 min-w-[72px] z-10"
>
<button
v-for="r in rateOptions"
:key="r"
class="block w-full text-left px-2 py-1 text-xs hover:bg-white/10 rounded"
@click.stop="selectRate(r)"
>
{{ r }}x
</button>
</div>
</div>
<!-- 右侧扩展位 -->
<slot
name="controls-right"
:video="videoRef"
:playing="isPlaying"
:current-time="currentTime"
:duration="duration"
:progress-percent="progressPercent"
:buffered-percent="bufferedPercent"
:volume="internalVolume"
:rate="internalRate"
:toggle-play="togglePlay"
:seek="seek"
:set-volume="handleVolume"
:set-rate="handleRate"
/>
</div>
</div>
</slot>
</div>
</Transition>
</div>
</template>
<style scoped>
/* 过渡曲线定义在根容器,便于继承 */
.sv-video {
--sv-ease: cubic-bezier(0.17, 0.67, 0.27, 0.99);
}
/* 淡入淡出(用于 overlay) */
.sv-fade-fast-enter-active,
.sv-fade-fast-leave-active {
transition: opacity 0.2s var(--sv-ease);
will-change: opacity;
}
.sv-fade-fast-enter-from,
.sv-fade-fast-leave-to {
opacity: 0;
}
/* 自下而上轻微浮现(用于控制条整体容器) */
.sv-slide-up-enter-active,
.sv-slide-up-leave-active {
transition:
opacity 0.25s var(--sv-ease),
transform 0.25s var(--sv-ease);
will-change: transform, opacity;
}
.sv-slide-up-enter-from,
.sv-slide-up-leave-to {
opacity: 0;
transform: translateY(8px);
}
/* 减少动态偏好支持 */
@media (prefers-reduced-motion: reduce) {
.sv-fade-fast-enter-active,
.sv-fade-fast-leave-active,
.sv-slide-up-enter-active,
.sv-slide-up-leave-active {
transition: none !important;
}
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export { default as Video } from './Video.vue'
export type {} from './Video.vue'
+3
View File
@@ -22,3 +22,6 @@ export * from './Modal'
// 遮罩层组件
export * from './Overlay'
// 视频组件
export * from './Video'