初始化

This commit is contained in:
谢宇宁
2026-09-15 15:25:05 +07:00
commit 77c16ab4a2
1310 changed files with 132250 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
.wrap {
width: 100vw;
padding: 12px 16px;
// background: #262626;
// border-top: 1px solid rgba(0, 0, 0, 0.08);
}
@mixin categoryWrap() {
// margin-top: 18px;
// padding-bottom: 18px;
// border-bottom: $borderBottom;
}
@mixin categoryList() {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
align-items: center;
}
@mixin categoryTitle() {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
color: #fff;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
.more {
color: rgba(255, 255, 255, 0.4);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
.icon {
flex-shrink: 0;
width: 18px;
height: 18px;
// color: #fff;
margin-left: 6px;
}
}
@mixin vanTab() {
--van-tabs-line-height: 24px;
--van-tabs-nav-background: transparent;
--van-tabs-bottom-bar-width: 16px;
--van-tabs-bottom-bar-height: 2px;
--van-tab-font-size: 14px;
--van-tab-text-color: rgba(255, 255, 255, 0.6);
--van-tab-active-text-color: #fff;
--van-tabs-bottom-bar-color: #6954e7;
.tabs {
:deep(.van-tabs__wrap) {
padding: 10px 0;
box-sizing: content-box;
.van-tab__text--ellipsis {
white-space: nowrap;
}
.van-tabs__nav {
.van-tab {
width: auto;
padding: 0 16px;
margin: 0;
flex-grow: 0;
flex-shrink: 0;
flex-basis: 36px;
box-sizing: content-box;
}
.van-tabs__line {
bottom: 12px;
// width: 20px;
// height: 2px;
// @if $theme == 'red' {
// background: url('@/assets/images/png/tab-line-red.svg') no-repeat;
// } @else {
// background: url('@/assets/images/png/tab-line-black.svg') no-repeat;
// }
// background-size: contain;
}
}
}
.list {
margin: 0;
border-bottom: 0;
height: calc(100vh - 44px - 10px - 24px);
height: calc(100vh - 44px - 10px - 24px - constant(safe-area-inset-bottom));
height: calc(100vh - 44px - 10px - 24px - env(safe-area-inset-bottom));
overflow-y: auto;
}
:deep(.van-tabs__content) {
.van-tab__panel {
padding: 0 16px;
}
}
}
}
@mixin vanTab-btn() {
--van-tab-text-color: #acbabf;
--van-tab-active-text-color: $fff;
--van-tabs-line-height: 24px;
--van-tabs-nav-background: transparent;
--van-tabs-bottom-bar-width: 14px;
--van-tabs-bottom-bar-height: 2px;
--van-tabs-bottom-bar-color: $themeBgLgColor;
--van-tab-font-size: 14px;
.tabs {
:deep(.van-tabs__wrap) {
padding: 16px 0 12px;
height: auto;
box-sizing: content-box;
display: flex;
justify-content: center;
.van-tabs__nav {
border-radius: 24px;
background: #20252f;
padding: 4px;
.van-tab {
padding: 5px 32px;
margin: 0;
flex-shrink: 0;
box-sizing: content-box;
border-radius: 15px;
&.van-tab--active {
background: $themeBgLgColor;
color: #fff;
}
}
.van-tabs__line {
display: none;
}
}
}
// .list {
// margin: 0;
// border-bottom: 0;
// height: calc(100vh - 44px - 10px - 24px);
// height: calc(100vh - 44px - 10px - 24px - constant(safe-area-inset-bottom));
// height: calc(100vh - 44px - 10px - 24px - env(safe-area-inset-bottom));
// overflow-y: auto;
// }
:deep(.van-tabs__content) {
.van-tab__panel {
padding: 0 16px;
}
}
}
}
@mixin vanTab-list() {
:deep(.van-tabs) {
--van-tabs-nav-background: transform;
--van-tab-active-text-color: #6954e7;
--van-tab-text-color: rgba(255, 255, 255, 0.6);
--van-tabs-bottom-bar-height: 0;
.van-tabs__nav--line {
padding: 0 16px;
}
.van-tab {
flex: 0;
flex-basis: 40px;
justify-content: flex-start;
margin-right: 12px;
}
.van-tabs__line {
display: none;
}
.van-tab__panel {
padding: 12px 16px;
}
}
}
@mixin collected-btn() {
display: inline-flex;
padding: 4px 12px;
justify-content: center;
align-items: center;
border-radius: 90px;
background: $themeBgColor;
// box-shadow: 0px 2px 10px 0px rgba(255, 66, 68, 0.8);
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 400;
// line-height: 16px; /* 133.333% */
&.collected {
background: #222433;
// box-shadow: none;
color: rgba(255, 255, 255, 0.6);
}
svg {
// width: 14px;
// height: 14px;
font-size: 12px;
}
}
@mixin tag() {
.permission {
position: absolute;
z-index: 2;
top: 0;
right: 0;
display: inline-flex;
padding: 2px 6px;
justify-content: flex-end;
align-items: center;
border-radius: 8px 0px 8px 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 22px; /* 150% */
&.permission1 {
background: linear-gradient(135deg, #b378ff 0%, #482cf5 100%);
}
&.permission2 {
color: #212121;
background: linear-gradient(135deg, #adfcac 0%, #5cef97 100%);
}
svg {
width: 14px;
height: 14px;
}
}
}
@@ -0,0 +1,176 @@
<template>
<div ref="dvLazyImg" class="acg-image-container">
<transition name="bounceFade">
<div v-if="isLoading && decyImg" class="box">
<!-- <img v-show="isLoad" class="left" :src="decyImg" @load="load" />
<img v-show="isLoad" class="right" :src="decyImg" /> -->
<img v-show="isLoad" :src="decyImg" @load="load" />
</div>
</transition>
<transition name="fade">
<div v-if="!isLoad" class="loading"></div>
</transition>
</div>
</template>
<script lang="ts">
export default {
name: 'ImgObserver',
};
</script>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue';
import { useConfigStore } from '@/stores/config';
import AppApi from '@/service/app';
const emit = defineEmits(['updateIndex', 'updateSize']);
const isLoading = ref(false);
const props = defineProps<{
url: string;
index: number;
init: boolean;
}>();
watch(
() => props.init,
(bool: boolean) => {
if (bool) {
isLoading.value = true;
}
},
);
const dvLazyImg = ref();
const observer = new IntersectionObserver(
function (entries) {
const entry = entries[0];
const { intersectionRatio, isIntersecting } = entry;
if (intersectionRatio > 0 || isIntersecting) {
isLoading.value = true;
}
if (intersectionRatio >= 0.5) {
emit('updateIndex', { index: props.index, url: props.url });
}
},
{ threshold: 0.8 },
);
onMounted(() => {
observer.observe(dvLazyImg.value);
});
onBeforeUnmount(() => {
observer.unobserve(dvLazyImg.value);
});
const configStore = useConfigStore();
const decyImg = ref('');
function imgDecypt() {
try {
let imgSrc = '';
const isHttp = new RegExp('http').test(props.url);
isHttp == true ? (imgSrc = props.url) : (imgSrc = configStore.sourceInfo.IMAGE + '/' + props.url);
AppApi.imgDecypt(imgSrc)
.then((url: any) => {
decyImg.value = url;
})
.catch((err) => {
console.log(err);
});
} catch (error) {
console.log(error);
}
}
watch(isLoading, (bool) => {
if (bool) {
imgDecypt();
}
});
const isLoad = ref(false);
const load = (e: Event) => {
const el = e.target as HTMLImageElement;
isLoad.value = true;
emit('updateSize', { url: props.url, height: el.height, width: el.width });
};
</script>
<style lang="scss" scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 1s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
.bounceFade-enter-active {
animation: bounceFade-in 0.4s;
}
.bounceFade-leave-active {
animation: bounceFade-in 0.4s;
}
@keyframes bounceFade-in {
0% {
opacity: 0;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
.acg-image-container {
.loading {
width: 100%;
height: 100%;
background: url('@/assets/images/webp/placeholder.webp') no-repeat;
background-size: cover;
background-position: 50% 50%;
background-color: rgb(52, 52, 52);
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
img {
object-fit: cover !important;
}
}
.box {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover !important;
transition: transform 0.3s;
}
.left {
width: 100%;
clip-path: inset(0 50% 0 0);
}
.right {
position: absolute;
top: 0;
right: 0;
width: 100%;
clip-path: inset(0 0 0 49%);
}
}
&.move {
.left {
transform: translateX(-76.5px);
}
.right {
transform: translateX(76.5px);
}
}
}
</style>
@@ -0,0 +1,210 @@
<template>
<div
v-if="(props.list || []).length"
class="category-content"
:class="[props.coverDirection, `wrap_${props.contentWrap}`, `showType_${props.showType}`]"
>
<div v-if="$props.id" class="title" @click="goTopicPage(props.id, props.contentType, props.title, props.showType)">
<slot name="title"></slot>
<span v-if="props.title">{{ props.title }}</span>
<span class="more flex-vcenter">
{{ props.navTitle || '更多' }}
<SvgIcon class="icon" name="moreIcon" />
</span>
</div>
<div v-if="props.contentWrap" ref="categoryContentList" class="list"></div>
<div v-else ref="categoryContentList" class="list" @touchstart.stop @touchmove.stop @touchend.stop></div>
<Teleport v-if="categoryContentList" :to="categoryContentList">
<CategoryContentItem
v-for="(item, index) in props.list"
:key="index"
:id="item.id"
:url="props.coverDirection === 'landscape' ? item.horizontalCover : item.verticalCover"
:title="item.title"
:contentType="item.mediaType"
:info="item"
>
<template #delBtn>
<div class="delBtn" v-if="show" @click.stop="handleDel(item.id)">
<div class="selectActive"></div>
</div>
</template>
</CategoryContentItem>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import { ref, inject } from 'vue';
import { goTopicPage } from '@/views/acgModule/utils/common';
import CategoryContentItem from './categoryContentItem.vue';
const emit = defineEmits(['onDelCollect', 'del']);
const categoryContentList = ref();
const props = withDefaults(
defineProps<{
contentWrap?: boolean; //换行显示,portrait三个一行,landscape两个一行
contentType?: string; // 'video' | 'image';
coverDirection?: 'landscape' | 'portrait'; //landscape横向,portrait纵向
title?: string;
navTitle?: string;
list: Types.Media.AllMediaInfoItem[];
id?: string;
showType?: number;
isDelCollect?: boolean;
}>(),
{
contentWrap: false,
contentType: 'video',
coverDirection: 'portrait',
title: '',
navTitle: '更多',
list: () => [],
id: '',
showType: 0,
isDelCollect: false,
},
);
const show: boolean | undefined = inject('isEdit', false);
const handleDel = (id: string) => {
emit('del', id);
};
</script>
<style lang="scss" scoped>
@import '../assets/style.scss';
.category-content {
@include categoryWrap();
.title {
@include categoryTitle();
}
.list {
@include categoryList();
}
&.landscape {
.list {
.item {
width: 152px;
height: 110px;
}
}
&.showType_205 {
.list {
.item {
width: 132px;
height: 222px;
margin-right: 7px;
margin-bottom: 6px;
//&:nth-child(2n) {
// margin-right: 0px;
//}
//&:nth-child(3n) {
// margin-right: 7px;
//}
}
}
}
}
&.wrap_true {
.list {
overflow: hidden;
flex-wrap: wrap;
}
&.landscape {
.list {
.item {
width: 168px;
height: 141px;
margin-right: 7px;
margin-bottom: 6px;
&:nth-child(2n) {
margin-right: 0px;
}
}
}
}
&.portrait {
.list {
.item {
width: 111px;
height: 199px;
margin-right: 5px;
margin-bottom: 6px;
&:nth-child(3n) {
margin-right: 0px;
}
}
}
}
// 四宫格-横
&.showType_102 {
.list {
.item {
width: 168px;
height: 263px;
margin-bottom: 6px;
margin-right: 0;
&:nth-child(2n) {
margin-left: 7px;
}
}
}
}
// 四宫格-竖
&.showType_201 {
.list {
.item {
width: 168px;
height: 270px;
margin-right: 7px;
margin-bottom: 6px;
&:nth-child(2n) {
margin-right: 0px;
}
&:nth-child(3n) {
margin-right: 7px;
}
}
}
}
}
}
.delBtn {
position: absolute;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
top: 0;
z-index: 10;
.selectActive {
width: 24px;
height: 24px;
position: absolute;
top: 6px;
right: 6px;
background: rgba(245, 44, 86, 1);
background-image: url('@/assets/images/png/del.png');
background-repeat: no-repeat;
background-size: 14px 14px;
background-position: center;
border-radius: 50%;
}
.selectBorder {
width: 20px;
height: 20px;
border: 1px solid rgba(103, 103, 103, 1);
background: #000;
border-radius: 50%;
position: absolute;
top: 6px;
right: 6px;
}
}
</style>
@@ -0,0 +1,146 @@
<template>
<div class="item" @click="goPage">
<slot name="item"></slot>
<lazyImg class="cover" :src="autoUrl" :key="autoUrl">
<PermissionTag :info="props.info" :content-type="props.contentType" />
<slot name="cover"></slot>
<slot name="collectDel"></slot>
<img
v-if="props.info.mediaType === 'text' && props.info.mediaSubType === 1"
class="textIcon"
src="@/assets/images/png/acg/type_text.png"
/>
</lazyImg>
<div class="title-box">
<div class="title">{{ title }}</div>
<slot></slot>
<UpdateStatus :info="props.info"></UpdateStatus>
</div>
<slot name="delBtn"></slot>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
// import { useRouter } from 'vue-router';
// import { useGlobalStore } from '@/stores/global';
// import MediaApi from '@/service/media';
// import { showToast } from 'vant';
import { goDetailPage } from '../utils/common';
import type { MediaTypeKey } from '../utils/common';
import PermissionTag from './permission-tag.vue';
import UpdateStatus from './updateStatus.vue';
// const router = useRouter();
// const globalStore = useGlobalStore();
const props = withDefaults(
defineProps<{
id: string;
url: string;
title: string;
contentType?: string;
info: Types.Media.AllMediaInfoItem;
}>(),
{
id: '',
url: '',
title: '',
contentType: 'video',
info: {} as any,
},
);
const goPage = () => {
goDetailPage(props.info.mediaType as MediaTypeKey, props.id, props.info.mediaSubType);
};
const autoUrl = computed(() => {
return props.url || props.info.verticalCover || props.info.horizontalCover;
});
// const hasCollected = ref(props.info.mediaStatus?.hasAddBookshelf || false);
// const onFollow = () => {
// const isCollect = !hasCollected.value;
// (isCollect ? MediaApi.postBookshelfAdd : MediaApi.postBookshelfDel)({
// mid: props.id,
// }).then(() => {
// hasCollected.value = isCollect;
// showToast(isCollect ? '成功加入书架' : '成功移出书架');
// });
// };
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.item {
flex-shrink: 0;
width: 132px;
height: 234px;
border-radius: 8px;
display: flex;
flex-direction: column;
margin-right: 8px;
position: relative;
overflow: hidden;
.cover {
width: 100%;
height: calc(100% - 42px);
position: relative;
border-radius: 8px;
overflow: hidden;
:deep(.animationImg) {
img {
border-radius: 5px;
}
}
.collect {
position: absolute;
top: 0;
right: 0;
z-index: 1;
display: flex;
width: 29px;
height: 29px;
}
.textIcon {
position: absolute;
z-index: 2;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
}
}
.title-box {
flex-shrink: 0;
width: 100%;
// height: 33px;
background: transparent;
display: flex;
flex-direction: column;
// .icon {
// width: 18px;
// height: 18px;
// flex-shrink: 0;
// margin-right: 2px;
// margin-bottom: -2px;
// &.video {
// width: 12px;
// height: 12px;
// margin-bottom: 0px;
// }
// }
.title {
color: #fff;
font-size: 12px;
font-weight: 400;
margin: 4px 0 4px;
line-height: 18px;
@include textEllipsis();
}
.updateStatus {
line-height: 16px;
}
}
}
</style>
@@ -0,0 +1,199 @@
<template>
<van-swipe class="category" :autoplay="5000" :show-indicators="false">
<van-swipe-item v-for="(item, index) in props.list" :key="index">
<div
:class="['item', props.mediaType, `item_${(index + 1) % 3}`, titleClass(item.name)]"
@click="$router.push({ path: '/AcgTopicList', query: { kind: item.key, type: mediaType, title: item.name } })"
>
<!-- 标题大于四个字为纵向、其余为横向 -->
<img
v-if="titleClass(item.name) === 'landscape'"
class="title-icon"
src="@/assets/images/png/acg/tag-title-1.png"
/>
<img v-else class="title-icon portrait" src="@/assets/images/png/acg/tag-title-2.png" />
<span class="title">{{ item.name }}</span>
<!-- <img class="cover" src="@/assets/images/png/acg/tag-icon-2.png" /> -->
</div>
</van-swipe-item>
</van-swipe>
</template>
<script lang="ts" setup>
import {} from 'vue';
const props = withDefaults(
defineProps<{
list: any[];
mediaType: string;
}>(),
{
list: () => [],
mediaType: () => '',
},
);
//landscape横向,portrait纵向
const titleClass = (title: string) => {
if (title.length < 4) {
return 'landscape';
} else {
return 'portrait';
}
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.category {
margin: 0 -16px;
padding: 18px 0;
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
align-items: center;
// .item {
// flex-shrink: 0;
// width: 78px;
// display: flex;
// flex-direction: column;
// margin-left: 25px;
// &:first-child {
// margin-left: 30px;
// }
// .icon {
// width: 100%;
// height: 66px;
// }
// .title {
// text-align: center;
// width: 100%;
// color: #ccc;
// font-size: 14px;
// font-style: normal;
// font-weight: 400;
// line-height: normal;
// @include textEllipsis();
// }
// }
.item {
width: 100%;
height: 158px;
position: relative;
&.image {
&.item_1 {
background:
// url(@/assets/images/png/acg/tag-title-1.png) 6px 70px / 78px 6px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-1.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-image-1.png) 125px 100% / 150px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-2.png) 275px 100% / 205px 110px no-repeat;
}
&.item_2 {
background:
// url(@/assets/images/png/acg/tag-title-2.png) 16px 54px / 6px 78px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-2.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-image-2.png) 125px 100% / 150px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-3.png) 275px 100% / 205px 110px no-repeat;
}
&.item_0 {
background:
// url(@/assets/images/png/acg/tag-title-1.png) 6px 70px / 78px 6px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-3.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-image-3.png) 125px 100% / 150px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-1.png) 275px 100% / 205px 110px no-repeat;
}
}
&.video {
&.item_1 {
background:
// url(@/assets/images/png/acg/tag-title-1.png) 6px 70px / 78px 6px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-1.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-video-1.png) 125px 100% / 142px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-2.png) 275px 100% / 205px 110px no-repeat;
}
&.item_2 {
background:
// url(@/assets/images/png/acg/tag-title-2.png) 16px 54px / 6px 78px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-2.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-video-3.png) 115px 100% / 150px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-3.png) 275px 100% / 205px 110px no-repeat;
}
&.item_0 {
background:
// url(@/assets/images/png/acg/tag-title-1.png) 6px 70px / 78px 6px no-repeat,
url(@/assets/images/png/acg/tag-name.png) -64px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-3.png) -90px 100% / 205px 110px no-repeat,
url(@/assets/images/png/acg/tag-icon-video-2.png) 145px 100% / 104px 158px no-repeat,
url(@/assets/images/png/acg/tag-bg-0.png) 85px 100% / 215px 100px no-repeat,
url(@/assets/images/png/acg/tag-name.png) 313px 118px / 92px 36px no-repeat,
url(@/assets/images/png/acg/tag-bg-video-1.png) 275px 100% / 205px 110px no-repeat;
}
}
.title {
color: #fff;
font-size: 19px;
font-weight: 700;
line-height: normal;
word-wrap: break-word;
position: absolute;
top: 80px;
left: 5px;
width: 76px;
}
.cover {
position: absolute;
width: 150px;
height: 158px;
left: 125px;
bottom: 0;
object-fit: contain;
}
.title-icon {
height: 6px;
width: 78px;
position: absolute;
top: 70px;
left: 6px;
&.portrait {
height: 78px;
width: 6px;
position: absolute;
top: 54px;
left: 16px;
}
}
&.portrait {
.title {
top: 50px;
left: 30px;
width: 39px;
}
}
// url(/src/assets/images/png/acg/bg-bobo-right.png) 100% 50px / 42px no-repeat,
// url(/src/assets/images/png/acg/bg-bobo-right.png) 100% 250px / 182px no-repeat;
}
}
</style>
@@ -0,0 +1,124 @@
<template>
<div class="item" @click="getPage">
<slot name="item"></slot>
<lazyImg class="cover" :src="props.info.cover">
<div class="permission permission2">免费</div>
<!-- <div v-else-if="props.info.permission === 1 && props.info.price" class="permission permission1">
<SvgIcon name="diamond" />
{{ props.info.price }}
</div> -->
</lazyImg>
<div class="title-box">
<div class="title">{{ props.info.title }}</div>
<slot></slot>
<UpdateStatus :info="props.info"></UpdateStatus>
</div>
</div>
</template>
<script lang="ts" setup>
import { goDetailPage } from '../utils/common';
import type { MediaTypeKey } from '../utils/common';
import UpdateStatus from './updateStatus.vue';
const props = withDefaults(
defineProps<{
info: Types.Acg.DiscountChildListItem;
}>(),
{
info: () => {},
},
);
const getPage = () => {
goDetailPage(props.info.mediaType as MediaTypeKey, props.info.id, props.info.mediaSubType);
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
@import '../assets/style.scss';
.item {
flex-shrink: 0;
width: 111px;
height: 188px;
border-radius: 8px;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
.cover {
width: 100%;
height: calc(100% - 46px);
position: relative;
border-radius: 8px;
overflow: hidden;
:deep(.animationImg) {
img {
border-radius: 5px;
}
}
.collect {
position: absolute;
top: 0;
right: 0;
z-index: 1;
display: flex;
width: 29px;
height: 29px;
}
.text-1-box {
position: absolute;
z-index: 2;
top: 6px;
left: 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.5);
display: inline-flex;
padding: 2px 4px;
justify-content: center;
align-items: center;
gap: 2px;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
.textIcon {
width: 12px;
height: 12px;
}
}
@include tag();
}
.title-box {
flex-shrink: 0;
width: 100%;
// height: 33px;
background: transparent;
display: flex;
flex-direction: column;
// .icon {
// width: 18px;
// height: 18px;
// flex-shrink: 0;
// margin-right: 2px;
// margin-bottom: -2px;
// &.video {
// width: 12px;
// height: 12px;
// margin-bottom: 0px;
// }
// }
.title {
color: #fff;
font-size: 12px;
font-weight: 400;
margin: 6px 0 4px;
@include textEllipsis();
}
}
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<template>
<div
class="episode-text"
:class="{ active: props.episodeNumber == props.info.episodeNumber }"
v-if="showType == 'text'"
>
第{{ props.info.episodeNumber }}话
</div>
<!-- 方格模式 -->
<div
v-else
class="episode flex-center-center"
:class="{ active: props.episodeNumber == props.info.episodeNumber, video: props.isVideo, tag: hasPaid || goldTag }"
>
{{ props.info.episodeNumber }}
<iconByte v-if="props.episodeNumber == props.info.episodeNumber && props.isVideo" class="icon"></iconByte>
<!-- <div v-if="props.info.listenPermission === 0" class="permission permission0">VIP</div> -->
<!-- <div v-if="hasPaid" class="permission permission1">已解锁</div> -->
<!-- <div v-else-if="goldTag" class="permission permission1">
<SvgIcon name="gold" />
{{ props.info.price }}
</div> -->
<!-- <div v-else-if="props.info.listenPermission === 2" class="permission permission2">免费</div> -->
</div>
</template>
<script lang="ts" setup>
import { computed, inject, Ref } from 'vue';
import iconByte from './icon-byte.vue';
const props = withDefaults(
defineProps<{
isVideo?: boolean;
episodeNumber: number;
info: Types.Acg.MediaContentListItem;
// parentInfo: Types.Media.MediaInfo;
showType?: string; // 默认方格模式,text: 文字列表
}>(),
{
isVideo: true,
episodeNumber: 0,
info: () => {
return {} as Types.Acg.MediaContentListItem;
},
// parentInfo: () => {
// return {} as Types.Media.MediaInfo;
// },
},
);
const parentInfo = inject('historyData', null) as Ref<Types.Media.MediaInfo> | null;
const hasPaid = computed(() => {
if (props.info.hasBuy) return true;
if (parentInfo && parentInfo.value.mediaStatus && parentInfo.value.mediaStatus.hasPaid) return true;
return false;
});
const goldTag = computed(() => {
return props.info.listenPermission === 1 && props.info.price;
});
</script>
<style lang="scss" scoped>
.episode {
height: 58px;
width: 58px;
color: #fff;
font-size: 14px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
overflow: hidden;
position: relative;
border: 1px solid #4b4b4b;
&.active {
border-color: $themeColor;
color: $themeColor;
background: rgba(255, 211, 0, 0.1);
&.tag {
align-items: flex-end !important;
padding-bottom: 12px;
}
}
.icon {
width: 18px;
height: 18px;
margin-bottom: -2px;
margin-left: 4px;
}
.permission {
display: inline-flex;
padding: 2px 4px;
justify-content: flex-end;
align-items: center;
color: #fff;
font-size: 9px;
font-style: normal;
font-weight: 400;
line-height: 14px; /* 160% */
position: absolute;
top: 0;
left: 0;
z-index: 2;
border-radius: 0px 0px 9px 0px;
color: #000;
svg {
width: 12px;
height: 12px;
margin-right: 2px;
}
&.permission0 {
background: linear-gradient(134deg, #f6cbb8 -1.45%, #eeb4aa 102.9%);
}
&.permission1 {
background: #3e64ea;
color: #fff;
}
&.permission2 {
background: linear-gradient(135deg, #adfcac 0%, #5cef97 100%);
}
}
}
.episode-text {
margin-top: 12px;
color: rgba(255, 255, 255, 0.55);
font-size: 16px;
font-weight: 400;
line-height: 22px;
}
.active {
color: rgba(255, 255, 255, 0.9);
font-weight: 500;
}
</style>
@@ -0,0 +1,139 @@
<template>
<div class="item" :class="{ active: props.episodeNumber === props.info.episodeNumber }">
<!-- <div v-if="props.info.listenPermission === 0" class="tag vip flex-vcenter">VIP</div> -->
<div v-if="props.info.price && props.info.listenPermission === 1" class="tag price flex-vcenter">
<SvgIcon name="diamond" />
{{ props.info.price }}
</div>
<div v-else-if="props.info.listenPermission === 2" class="tag">免费</div>
<lazyImg class="cover" :src="props.info.cover" :key="props.info.cover">
<div v-if="progress" class="mask">
<div class="progress-txt">{{ progress }}%</div>
<div class="progress-box">
<span :style="{ width: progress + '%' }"></span>
</div>
</div>
</lazyImg>
<div class="name ellipsis">{{ props.info.name }}</div>
<span class="date">{{ dateFormatYyyyMmDd(props.info.createdAt) }}</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { dateFormatYyyyMmDd } from '@/utils/index';
const props = defineProps<{
episodeNumber: number;
info: Types.Acg.MediaContentListItem;
progress?: 0;
}>();
const progress = computed(() => {
return props.progress || 0;
});
</script>
<style lang="scss" scoped>
.item {
flex-shrink: 0;
width: 111px;
height: 165px;
overflow: hidden;
margin: 0 5px 12px 0;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
position: relative;
&.active {
.cover {
border: 1px solid #ff004f;
}
}
&:nth-child(3n) {
margin-right: 0;
}
.tag {
position: absolute;
top: 0;
right: 0;
z-index: 2;
border-radius: 8px;
background: linear-gradient(135deg, #adfcac 0%, #5cef97 100%);
color: #212121;
padding: 3px 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
&.price {
color: #fff;
background: linear-gradient(135deg, #b378ff 0%, #482cf5 100%);
}
&.vip {
background: linear-gradient(135deg, #ffe580 0%, #facc15 100%);
}
svg {
width: 14px;
height: 14px;
margin-right: 2px;
}
}
.cover {
width: 100%;
height: 111px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
.mask {
position: absolute;
top: 0;
left: 0;
z-index: 2;
height: 100%;
width: 100%;
background: rgba(0, 0, 0, 0.6);
.progress-txt {
position: absolute;
bottom: 10px;
left: 4px;
right: 4px;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.progress-box {
position: absolute;
bottom: 4px;
left: 4px;
right: 4px;
height: 3px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.2);
span {
display: block;
height: 3px;
background-color: $themeBgColor;
border-radius: 3px;
}
}
}
}
.name {
color: #fff;
margin-top: 8px;
padding: 0 6px;
font-size: 12px;
font-style: normal;
font-weight: 500;
}
.date {
padding: 0 6px;
color: #c8c8c8;
font-size: 12px;
font-style: normal;
font-weight: 400;
}
}
</style>
@@ -0,0 +1,159 @@
<template>
<!-- @click.stop="
props.customClick
? void 0
: $router.push({
name: 'AcgImageDetailWatch',
query: { id: props.mediaId, episodeNumber: props.episodeNumber },
})
" -->
<div class="episode-box flex-vcenter" :class="{ progress: progress && props.progressUrl }" @click.stop="onClick">
<lazyImg class="cover" :src="props.info.cover"></lazyImg>
<div class="info">
<!-- <div class="num">第{{ props.episodeNumber }}话</div> -->
<span class="title ellipsis">{{ props.info.name }}</span>
</div>
<SvgIcon name="backWhite"></SvgIcon>
<lazyImg
v-if="progress && props.progressUrl"
class="episode-content"
:src="props.progressUrl"
:key="props.progressUrl"
>
<div class="mask flex-center-center">
<div class="progress-container flex-vcenter">
{{ progress }}%
<div class="progress-box">
<span :style="{ width: progress + '%' }"></span>
</div>
</div>
</div>
</lazyImg>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
const props = defineProps<{
mediaId: string;
info:
| Types.Acg.MediaContentListItem
| {
cover: string;
id: string;
name: string;
urlSet: string[];
};
episodeNumber: number;
mediaSubType: number;
progress?: number;
progressUrl?: string;
customClick?: boolean;
}>();
const progress = computed(() => {
return props.progress || 0;
});
const onClick = () => {
if (props.customClick) return;
const name = props.mediaSubType === 2 ? 'AcgImageDetailWatch2' : 'AcgImageDetailWatch';
router.push({
name,
query: { id: props.mediaId, episodeNumber: props.episodeNumber },
});
};
</script>
<style lang="scss" scoped>
.episode-box {
border-radius: 8px;
background: #fff;
margin: 18px auto;
display: flex;
width: 343px;
// height: 64px;
padding: 8px;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
color: #424242;
&.progress {
padding-bottom: 0;
}
.cover {
flex-shrink: 0;
width: 56px;
height: 48px;
border-radius: 8px;
overflow: hidden;
}
.info {
flex: 1;
margin: 0 8px;
color: #424242;
.num,
.title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
// .title {
// font-size: 14px;
// font-style: normal;
// font-weight: 400;
// line-height: 20px;
// }
}
svg {
transform: rotate(180deg);
}
.episode-content {
margin: 12px -8px 0;
width: calc(100% + 16px);
height: 180px;
border-radius: 0 0 8px 8px;
overflow: hidden;
.mask {
position: absolute;
top: 0;
left: 0;
z-index: 2;
height: 100%;
width: 100%;
background: rgba(0, 0, 0, 0.6);
.progress-container {
padding: 8px 12px;
border-radius: 25px;
background: #fff;
width: 311px;
height: 33px;
position: relative;
color: #333;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: normal;
.progress-box {
margin-left: 4px;
flex: 1;
height: 6px;
border-radius: 6px;
background: rgb(51, 51, 51, 10%);
span {
display: block;
height: 6px;
background-color: #333;
border-radius: 6px;
}
}
}
}
}
}
</style>
@@ -0,0 +1,71 @@
<template>
<div v-if="(props.list || []).length" class="category-content">
<div v-if="$props.title" class="title" @click="goTopicPage(props.id, props.contentType, props.title)">
<span>{{ props.title }}</span>
<SvgIcon class="icon" name="moreIcon" />
</div>
<div v-if="props.contentWrap" ref="categoryContentList" class="list"></div>
<div v-else ref="categoryContentList" class="list" @touchstart.stop @touchmove.stop @touchend.stop></div>
<Teleport v-if="categoryContentList" :to="categoryContentList">
<CategoryContentItem
v-for="(item, index) in props.list"
:key="index"
:id="item.id"
:url="item.horizontalCover || item.verticalCover"
:title="item.title"
:contentType="props.contentType"
:info="item"
></CategoryContentItem>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { goTopicPage } from '@/views/acgModule/utils/common';
import CategoryContentItem from './categoryContentItem.vue';
const categoryContentList = ref();
const props = withDefaults(
defineProps<{
contentWrap?: boolean; //换行显示,portrait三个一行,landscape两个一行
contentType?: string; // 'video' | 'image';
title?: string;
list: Types.Media.AllMediaInfoItem[];
id?: string;
}>(),
{
contentWrap: false,
contentType: 'video',
title: '',
list: () => [],
id: '',
},
);
</script>
<style lang="scss" scoped>
@import '../assets/style.scss';
.category-content {
@include categoryWrap();
.title {
@include categoryTitle();
}
.list {
@include categoryList();
}
.list {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
.item {
width: 168px;
height: 110px;
margin-right: 0;
margin-bottom: 10px;
&:first-child {
width: 343px;
height: 196px;
}
}
}
}
</style>
@@ -0,0 +1,46 @@
<template>
<div class="byte-wrap flex-center-center">
<span v-for="i in 3" :key="i" class="item" :class="'dance_' + arr[i - 1]"></span>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
function shuffleArray(arr: number[]) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
const arr = shuffleArray([1, 2, 3]);
</script>
<style lang="scss" scoped>
.byte-wrap {
width: 16px;
height: 16px;
justify-content: space-evenly !important;
.item {
width: 2px;
height: 11px;
border-radius: 2px;
background-color: #f68804;
animation: dance 0.6s infinite linear alternate;
@for $i from 1 through 3 {
&.dance_#{$i} {
animation-delay: #{0.2 + ($i - 1) * 0.2}s;
}
}
}
@keyframes dance {
0% {
transform: scaleY(1);
}
100% {
transform: scaleY(0.5);
}
}
}
</style>
@@ -0,0 +1,267 @@
<template>
<Teleport to="body">
<van-popup :show="isShow" z-index="2001" position="bottom" @click-overlay="emit('close')">
<div class="wrapper flex-center-center">
<div class="content" :class="[props.theme]" @click.stop="void 0">
<div class="line_top" />
<div class="title">购买作品</div>
<div class="priceList">
<div class="leader">作品解锁</div>
<div class="price gold flex-vcenter">
<SvgIcon name="gold" />
{{ gold }}金币
</div>
</div>
<!-- <div class="priceList">
<div class="leader">作品原价</div>
<div class="price through flex-vcenter">{{ gold }}金币</div>
</div> -->
<!-- <div v-if="userInfo.isVip" class="priceList vip">
<div class="leader">您当前享受{{ sale }}折扣优惠</div>
<div class="price underline flex-vcenter" @click.stop="$router.push('member')">升级会员</div>
</div>
<div v-else class="priceList vip">
<div class="leader">您当前不享受免费观看</div>
<div class="price underline flex-vcenter" @click.stop="$router.push('member')">购买VIP享免费观看</div>
</div> -->
<div class="priceList split">
<div class="leader">我的金币</div>
<div class="price flex-vcenter">
{{ recharge }}
<!-- 100 -->
<span class="go" @click.stop="$router.push('wallet'), emit('close')">充值</span>
</div>
</div>
<div class="btnBox flex-center-between">
<div class="btn flex-center-center" @click="unlockNow('all')">{{ gold }}金币/立即支付</div>
</div>
</div>
</div>
</van-popup>
</Teleport>
</template>
<script lang="ts" setup>
import useLoading from '@/hooks/loading';
import VideoApi from '@/service/video';
import { computed, inject, nextTick, Ref } from 'vue';
import { ProductType } from '@/enums/app';
import { showToast } from 'vant';
import { useUserStore } from '@/stores/user';
import { useRouter } from 'vue-router';
const props = withDefaults(
defineProps<{
videoInfo: any; //Types.Video.VInfosItem | Types.Acg.MediaContentListItem
isOpen: boolean;
theme?: string; //black 为黑色主题,默认为白色主题
}>(),
{
theme: 'black',
},
);
const userStore = useUserStore();
const userInfo = userStore.userInfo;
const historyData = inject('historyData') as Ref<Types.Media.MediaInfo>;
// const salePrice = computed(() => {
// let { contentsPrice = 0, price = 0, updateStatus = 0 } = historyData.value;
// if (updateStatus === 1) return 0;
// if (contentsPrice <= 0 || price <= 0) return 0;
// const res = contentsPrice - price;
// return res < 0 ? 0 : res;
// });
const gold = computed(() => {
return historyData.value.price;
});
const sale = computed(() => {
return 10 - Math.floor(((props.videoInfo.originCoins - props.videoInfo.coins) / props.videoInfo.originCoins) * 10);
});
const recharge = computed(() => {
return (userStore.walletInfo.amount * 100 + userStore.walletInfo.income * 100) / 100;
});
const router = useRouter();
const { openLoading, closeLoading } = useLoading();
const emit = defineEmits(['close', 'buySuccess']);
const isShow = computed(() => {
return props.isOpen;
});
const unlockNow = (type: 'all') => {
try {
if (recharge.value < gold.value) {
emit('close');
router.push('/wallet');
return;
}
let productID = historyData.value.id;
if (!productID) {
showToast('购买出错~');
return;
}
openLoading();
VideoApi.videoBuying({
productType: ProductType.AcgAll,
productID,
// goldVideoCouponNum,
})
.then(async () => {
showToast('购买成功');
userStore.updateWallet();
emit('buySuccess', { type, productID });
})
.catch((e) => {
if (e.toString().indexOf('8000') != -1) {
emit('close');
nextTick(() => {
router.push({
path: '/wallet',
query: {
activeTab: 1,
id: props.videoInfo.id,
title: props.videoInfo.title || '无标题',
},
});
});
}
});
} finally {
closeLoading();
}
};
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
.content {
color: #000;
width: 100%;
padding: 18px 10px 0;
background-color: #fff;
position: relative;
border-radius: 10px 10px 0 0;
padding-bottom: 24px;
.line_top {
width: 36px;
height: 5px;
background: rgba(255, 255, 255, 0.2);
margin: 0 auto;
border-radius: 99px;
}
.title {
padding: 18px 0;
font-size: 20px;
font-weight: 500;
text-align: center;
}
.priceList {
display: flex;
align-items: center;
justify-content: space-between;
margin: 18px 0;
&.vip {
.leader,
.price {
color: #ac7e6e;
}
}
&.split {
border-top: $borderSplit;
margin-top: 18px;
padding-top: 18px;
}
.leader {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.price {
font-size: 14px;
font-weight: 500;
> svg {
height: 18px;
width: 18px;
margin-right: 10px;
}
.go {
text-decoration-line: underline;
color: #a4634d;
margin-left: 12px;
background: linear-gradient(286deg, #a4634d 0%, #ac7e6e 100%);
background-clip: text;
}
}
.gold {
color: #ff9000;
}
.through {
text-decoration: line-through;
color: #747b80;
}
.underline {
text-decoration: underline;
color: rgba(0, 0, 0, 0.5);
}
}
&.black {
background-color: #0f0f0f;
color: rgba(255, 255, 255, 0.9);
.line_top {
background: rgba(255, 255, 255, 0.2);
}
.leader {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
}
.text {
font-size: 20px;
text-align: center;
margin: 30px 0 18px;
span {
font-size: 28px;
color: $themeColor;
}
&.line {
display: flex;
justify-content: center;
.text-center {
position: relative;
&::after {
content: '';
position: absolute;
left: 0;
bottom: 30%;
height: 2px;
width: 100%;
background: #333;
}
}
}
}
p {
font-size: 14px;
text-align: center;
opacity: 0.9;
}
.btnBox {
margin-top: 26px;
.btn {
height: 44px;
width: 100%;
background: #f68804;
text-align: center;
line-height: 44px;
font-size: 14px;
font-weight: 500;
color: $btnPrimaryColor;
border-radius: 6px;
}
}
}
}
</style>
@@ -0,0 +1,83 @@
<template>
<GoldPopup :is-open="visiblePermission1" :video-info="props.info" @close="emit('close')" @buy-success="buySuccess" />
<VipPopup :is-open="visiblePermission0" @close="emit('close')" />
</template>
<script lang="ts" setup>
import GoldPopup from './GoldPopup.vue';
// import VipPopup from './VipPopup.vue';
import { computed, ref, watch, onActivated, inject, Ref } from 'vue';
import { useUserStore } from '@/stores/user';
import UserApi from '@/service/user';
// import { ProductType } from '@/enums/app';
const props = withDefaults(
defineProps<{
info: any; // Types.Media.MediaInfo | Types.Acg.MediaContentListItem;
isOpen: boolean;
}>(),
{
info: () => <any>{},
isOpen: () => false,
},
);
const historyData = inject('historyData') as Ref<Types.Media.MediaInfo>;
const userStore = useUserStore();
const emit = defineEmits(['close', 'buySuccess']);
const visiblePermission0 = ref(false);
const visiblePermission1 = ref(false);
// const visibleTodayFree = ref(false);
const isTypeVip = computed(() => {
return userStore.isVip;
});
// permission 收听权限 0:会员 1:金币购买 2:免费
const permission = computed(() => {
return historyData.value.permission;
});
watch(
() => props.isOpen,
(bool) => {
if (!bool) {
// visibleTodayFree.value = false;
visiblePermission0.value = false;
visiblePermission1.value = false;
return;
} else {
if (permission.value === undefined) {
emit('close');
return;
}
if (permission.value === 1) {
visiblePermission1.value = true;
} else if (permission.value === 0) {
visiblePermission0.value = true;
}
}
},
{
immediate: true,
},
);
onActivated(() => {
// 会员类型要从接口取用户最新信息
if (permission.value === 0 && !isTypeVip.value) {
UserApi.queryUserInfo(null).then((res) => {
userStore.saveUserInfo(res);
if (res.isVip) {
emit('close');
}
});
}
});
const buySuccess = (obj: any) => {
emit('buySuccess', obj);
};
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,78 @@
<template>
<van-overlay :show="isShow" z-index="2001" @click.stop="emit('close')">
<div class="wrapper flex-center-center">
<div class="content">
<div class="title">温馨提示</div>
<div class="horizontalLine"></div>
<div class="text">本{{ typeName }}需要开通VIP会员</div>
<p>开通会员 即可解锁查看完整版</p>
<div class="btnBox flex-center-center" @click.stop="$router.push('member')">立即开通</div>
</div>
</div>
</van-overlay>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const props = defineProps<{
isOpen: boolean;
}>();
const emit = defineEmits(['close']);
const isShow = computed(() => {
return props.isOpen;
});
const typeName = computed(() => {
if ('AcgVideoDetail' === ((route.name as string) || '')) {
return '视频';
} else {
return '章节';
}
});
</script>
<style lang="scss" scoped>
.wrapper {
width: 100%;
height: 100%;
.content {
width: 311px;
height: 242px;
font-size: 16px;
color: #fff;
background: $popupBg;
position: relative;
border-radius: 12px;
.title {
color: #fff;
padding: 32px 0 12px;
font-size: 20px;
font-weight: 500;
text-align: center;
}
.horizontalLine {
height: 1px;
background-color: rgba(255, 255, 255, 0.05);
}
.text {
text-align: center;
margin: 18px 0 30px;
}
p {
text-align: center;
}
}
.btnBox {
margin: 26px auto 0;
color: white;
background: $themeColor;
width: 263px;
height: 44px;
border-radius: 44px;
text-align: center;
line-height: 44px;
font-weight: 500;
font-size: 16px;
}
}
</style>
@@ -0,0 +1,74 @@
<template>
<div v-if="configStore.appConfig.vipMark && props.info.permission === 0" class="permission permission_0">VIP</div>
<div
v-if="configStore.appConfig.coinMark && props.info.permission === 1 && props.info.price"
class="permission permission_1"
>
金币
<!-- {{ props.info.price }} -->
</div>
<!-- <div v-if="configStore.appConfig.freeMark && props.info.permission === 2" class="permission permission_2">免费</div> -->
</template>
<script lang="ts" setup>
import { useConfigStore } from '@/stores/config';
const configStore = useConfigStore();
const props = withDefaults(
defineProps<{
contentType?: string;
info: Types.Media.AllMediaInfoItem;
}>(),
{
contentType: 'video',
info: {} as any,
},
);
</script>
<style lang="scss" scoped>
.text-1-box {
position: absolute;
z-index: 2;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.5);
display: inline-flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 50%;
.textIcon {
width: 14px;
height: 14px;
}
}
.permission {
position: absolute;
top: 4px;
right: 4px;
width: 27px;
height: 16px;
line-height: 16px;
text-align: center;
font-size: 10px;
border-radius: 3px;
font-family: 'PingFang SC';
color: #292929;
background: linear-gradient(135deg, #f8f3ae 0%, #ddac44 100%);
&.permission_1 {
color: #8b3e00;
background: linear-gradient(135deg, #ffe580 0%, #facc15 100%);
}
&.permission_2 {
color: #212121;
background: linear-gradient(135deg, #adfcac 0%, #5cef97 100%);
}
svg {
width: 14px;
height: 14px;
}
}
</style>
@@ -0,0 +1,166 @@
<template>
<div class="popularity">
<div v-if="props.id" class="title" @click="goTopicPage(props.id, props.contentType, props.title)">
<slot name="title"></slot>
<span v-if="props.title">{{ props.title }}</span>
<span class="more flex-vcenter">
{{ props.navTitle || '更多' }}
<SvgIcon class="icon" name="moreIcon" />
</span>
</div>
<div class="list" @touchstart.stop @touchmove.stop @touchend.stop>
<div class="item" v-for="(item, index) in props.list" :key="index" @click="getPage(item)">
<lazyImg class="cover" :src="item.verticalCover">
<img
v-if="item.mediaType === 'text' && item.mediaSubType === 1"
class="textIcon"
src="@/assets/images/png/acg/type_text.png"
/>
<PermissionTag :info="item" :content-type="props.contentType" />
</lazyImg>
<div class="content">
<div class="title ellipsis2">{{ item.title }}</div>
<!-- <div class="desc">{{ item.summary }}</div> -->
<!-- <div class="tag-box">
<span v-for="tag in item.tagDetails || []" :key="tag" class="tag">{{ tag.name }}</span>
</div> -->
<UpdateStatus :info="item"></UpdateStatus>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { goTopicPage } from '@/views/acgModule/utils/common';
import UpdateStatus from './updateStatus.vue';
import { goDetailPage } from '../utils/common';
import type { MediaTypeKey } from '../utils/common';
import PermissionTag from './permission-tag.vue';
const props = withDefaults(
defineProps<{
id: string;
title: string;
list: any[];
contentType?: string; // 'video' | 'image';
navTitle?: string;
}>(),
{
id: '',
title: '',
list: () => [[]],
navTitle: '更多',
contentType: 'video',
},
);
const getPage = (item: any) => {
goDetailPage(item.mediaType as MediaTypeKey, item.id, item.mediaSubType);
// //类型video:动漫,image:漫画 )
// if (props.contentType === 'video') {
// router.push({
// path: '/animationMovieDetails',
// query: {
// vid: item.id,
// },
// });
// } else {
// router.push({
// path: '/AcgCaricatureDetail',
// query: {
// id: item.id,
// },
// });
// }
};
</script>
<style lang="scss">
@import '../assets/style.scss';
@import '@/assets/styles/mixins.scss';
.popularity {
@include categoryWrap();
> .title {
@include categoryTitle();
}
.list {
@include categoryList();
flex-wrap: wrap;
flex-direction: column;
height: 330px;
.item {
width: 262px;
height: 100px;
flex-shrink: 0;
margin-bottom: 10px;
margin-right: 30px;
&:nth-child(3n) {
margin-bottom: 0;
}
display: flex;
.cover {
width: 72px;
height: 100px;
flex-shrink: 0;
margin-right: 10px;
border-radius: 4px;
overflow: hidden;
position: relative;
.textIcon {
position: absolute;
z-index: 2;
top: 4px;
left: 4px;
width: 16px;
height: 16px;
}
}
.content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: center;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
.title {
// @include textEllipsis();
color: #ffffffe5;
font-size: 14px;
line-height: 20px;
margin-bottom: 4px;
}
.desc {
@include lineClamp(2);
color: #666;
margin: 12px 0 8px;
}
.tag-box {
display: flex;
flex-wrap: wrap;
.tag {
color: #00929c;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
padding: 1px 4px;
border-radius: 32px;
border: 1px solid #00929c;
margin: 0 4px 6px 0;
}
}
}
}
}
}
</style>
@@ -0,0 +1,118 @@
<template>
<h2 class="h2Title" v-if="props.title">{{ props.title }}</h2>
<Loading v-if="stateList.loading"></Loading>
<template v-else>
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh" style="height: auto">
<div v-if="stateList.list.length > 0">
<CategoryContent
class="content"
:class="{ marginHide: props.marginHide }"
:contentType="props.contentType"
:contentWrap="true"
:list="stateList.list"
></CategoryContent>
</div>
<Empty v-else style="height: auto"></Empty>
</PullRefresh>
</template>
</template>
<script lang="ts" setup>
import Empty from './../../../components/Empty/index.vue';
import { reactive } from 'vue';
import CategoryContent from './categoryContent.vue';
import AcgApi from '@/service/acg';
const props = defineProps({
title: {
type: String,
required: false,
default() {
return '精彩推荐';
},
},
marginHide: {
type: Boolean,
required: false,
default() {
return false;
},
},
mediaId: {
type: String,
},
contentType: {
type: String,
},
});
const stateList = reactive({
loading: false,
list: <any>[],
});
const params = {
pageNumber: 1,
pageSize: 9,
};
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const getList = async () => {
try {
const TempfilterParams = Object.assign({}, params, { mediaId: props.mediaId });
let res: any = await AcgApi.getMediaRecommend(TempfilterParams);
stateList.loading = false;
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.list || [];
if (params.pageNumber === 1) {
stateList.list = list;
} else {
stateList.list = stateList.list.concat(list);
}
moreParams.isNoData = params.pageNumber == 1 && stateList.list.length == 0;
moreParams.finished = !res.hasNext;
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
const onLoad = () => {
moreParams.loading = true;
params.pageNumber++;
getList();
};
const onRefresh = () => {
params.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList();
};
getList();
</script>
<style lang="scss" scoped>
.h2Title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
margin: 18px 0 0;
}
.content {
margin: 16px;
&.marginHide {
margin: 16px 0;
}
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<template>
<div class="header flex-center-between">
<div
class="searchBtn flex-vcenter"
@click="$router.push({ path: '/AcgSearch', query: { mediaType: props.mediaType } })"
>
<div class="search"></div>
<span>请输入搜索文字</span>
</div>
<div class="theme_btn_bg filter" style="margin-right: 12px" @click="$router.push('myBookshelf')">
<SvgIcon name="bookshelf" class="bookshelf" style="color: #fff"></SvgIcon>
</div>
<div
class="theme_btn_bg filter"
@click="$router.push({ path: '/AcgSearchFilter', query: { mediaType: props.mediaType } })"
>
<SvgIcon name="acg_filter" style="color: #fff"></SvgIcon>
</div>
</div>
</template>
<script lang="ts" setup>
const props = defineProps<{
mediaType: string;
}>();
</script>
<style lang="scss" scoped>
.header {
padding: 7px 16px;
background-color: transparent;
height: 44px;
height: 44px;
.searchBtn {
background: rgba(0, 0, 0, 0.08);
flex: 1;
height: 30px;
border-radius: 30px;
font-size: 14px;
padding: 0 6px 0 12px;
color: #999;
margin-right: 12px;
.search {
height: 14px;
width: 14px;
background: url('@/assets/images/png/home/search.png') no-repeat;
background-size: 100% 100%;
margin-right: 6px;
}
}
.filter {
height: 30px;
width: 30px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
svg {
width: 18px;
height: 18px;
}
}
}
</style>
@@ -0,0 +1,67 @@
<template>
<div ref="rootRef" class="summary-box">
<!-- <div v-if="props.title" class="title">{{ props.title }}</div> -->
<TextOverflow :text="props.text" :maxLines="3">
<template #default="{ clickToggle, expanded }">
<Teleport :to="rootRef">
<div @click.stop="clickToggle" class="expanded flex-center-center">
{{ expanded ? '收起更多' : '更多简介' }}
<SvgIcon name="back" :class="['icon', expanded ? 'down' : 'up']" />
</div>
</Teleport>
</template>
</TextOverflow>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const props = defineProps({
title: {
type: String,
required: false,
default() {
return '';
},
},
text: {
type: String,
required: true,
},
});
const rootRef = ref();
</script>
<style lang="scss" scoped>
.summary-box {
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
position: relative;
// background-color: rgba(255, 255, 255, 0.1);
border-radius: 12px;
width: 100%;
margin-bottom: 16px;
color: #989898;
.title {
font-size: 20px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.expanded {
position: relative;
left: 0;
margin-top: 12px;
// margin-left: -9px;
svg {
transform: rotate(-90deg);
font-size: 18px;
&.down {
transform: rotate(-270deg);
}
}
}
}
</style>
@@ -0,0 +1,40 @@
<template>
<div class="advBox" v-if="advList.length">
<AdvSwiper class="advSwiper" :list="advList" />
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { AdType } from '@/enums/app';
import { getAdItem } from '@/utils/getConfig';
const advList = ref(getAdItem(AdType.ACGADV));
</script>
<style lang="scss" scoped>
.advSwiper {
height: 143px;
border-radius: 0;
overflow: hidden;
transform: translateZ(0px);
position: relative;
:deep(.van-swipe-item) {
height: 143px !important;
border-radius: 0 !important;
}
:deep(.van-swipe__indicators) {
left: auto;
right: 12px;
transform: none;
.van-swipe__indicator {
width: 6px;
height: 6px;
border-radius: 6px;
background: rgb(245, 44, 86, 60%);
border: none;
&.van-swipe__indicator--active {
width: 18px;
}
}
}
}
</style>
@@ -0,0 +1,44 @@
<template>
<div v-if="props.list" class="fiveGrid">
<div class="rectAdvBox" @click="jumpAdv(item)" v-for="item in props.list" :key="item.id">
<lazyImg class="rectAdvIcon" :src="item.cover" />
<div class="rectName ellipsis">{{ item.title }}</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { jumpAdv } from '@/utils/index';
const props = withDefaults(
defineProps<{
list: Types.App.AdsInfoListItem[];
}>(),
{
list: () => [],
},
);
</script>
<style lang="scss" scoped>
.fiveGrid {
padding: 18px 0 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-row-gap: 12px;
grid-column-gap: 10px;
.rectAdvBox {
width: 60px;
.rectAdvIcon {
height: 60px;
width: 60px;
border-radius: 12px;
overflow: hidden;
}
.rectName {
font-size: 12px;
margin-top: 5px;
overflow: hidden;
white-space: nowrap;
text-align: center;
}
}
}
</style>
@@ -0,0 +1,53 @@
<template>
<div
v-if="navMap.length"
class="nav-section flex-vcenter overflow-x-auto"
@touchstart.stop
@touchmove.stop
@touchend.stop
>
<div class="item flex-center-center" v-for="item in navMap" :key="item.name" @click="goPage(item)">
<lazyImg class="icon" :src="item.img" :key="item.img"></lazyImg>
{{ item.name }}
</div>
</div>
</template>
<script lang="ts" setup>
import { useConfigStore } from '@/stores/config';
import { innerJump, compatibleAppStoreJump } from '@/utils/index';
const useConfig = useConfigStore();
const navMap = (useConfig.appConfig?.jgArea || []).filter((item) => item.type === 1);
const goPage = (item: Types.App.JingGangItem) => {
const { link_type, link_url } = item;
const index = link_url.indexOf('yinseinner://');
if (link_type === 2 || index !== -1) {
innerJump(link_url.substring(13));
} else {
compatibleAppStoreJump(link_url);
}
};
</script>
<style lang="scss" scoped>
.nav-section {
width: 100%;
.item {
flex-shrink: 0;
border-radius: 100px;
padding: 12px 0;
margin: 0 20px;
color: $titleColor;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
flex-direction: column;
.icon {
width: 30px;
height: 30px;
margin-bottom: 4px;
}
}
}
</style>
@@ -0,0 +1,95 @@
<template>
<div v-if="props.data.tab.length > 0" class="rank-wrap">
<div class="bg"></div>
<img class="title" src="@/assets/images/png/acg/rank-title.png" />
<van-tabs v-model:active="tabActive" :animated="true" @rendered="onTabRendered" class="tabs" swipeable>
<van-tab v-for="(item, index) in props.data.tab" :key="item.type" :title="item.name" :name="item.type">
<List
v-if="tabIsInit(item.type)"
:type="item.type"
:mediaType="mediaType"
:defaultData="index === 0 ? props.data.list || [] : []"
></List>
</van-tab>
</van-tabs>
</div>
</template>
<script lang="ts" setup>
import { inject } from 'vue';
import tabAction from '@/utils/tabAction';
// import { rankTabList } from '../../utils/common';
import type { MediaTypeKey } from '../../utils/common';
import List from './rankList.vue';
const props = withDefaults(
defineProps<{
data: Types.Acg.DefaultAcgRanking;
}>(),
{
data: () => {
return {
tab: [],
list: [],
};
},
},
);
const mediaType = inject('mediaType') as MediaTypeKey;
const { tabActive, tabIsInit, onTabRendered } = tabAction(false);
if (props.data.tab.length > 0) {
tabActive.value = props.data.tab[0].type;
}
</script>
<style lang="scss" scoped>
@import '../../assets/style.scss';
@import '@/assets/styles/mixins.scss';
.rank-wrap {
width: 375px;
height: 600px;
margin-left: -16px;
position: relative;
// @include vanTab-btn();
@include popupTab();
.bg {
background: url(@/assets/images/png/acg/rank-bg.png) 0 0 / contain no-repeat;
// filter: blur(6px);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.title {
width: 100%;
height: 36px;
margin: 18px 0 12px;
z-index: 2;
}
--van-padding-xs: 16px;
--van-tab-font-size: 12px;
.tabs {
z-index: 2;
--van-tabs-line-height: 28px;
:deep(.van-tabs__wrap) {
.van-tab--active {
border-radius: 100px;
background-color: $themeBgLgColor;
font-weight: normal;
}
.van-tabs__line {
display: none;
}
}
:deep(.van-tabs__content) {
.van-tab__panel {
padding: 0 16px;
}
}
}
}
</style>
@@ -0,0 +1,162 @@
<template>
<div class="list-wrap">
<Loading v-if="state.loading"></Loading>
<template v-else>
<div class="container" v-if="state.list.length > 0">
<CategoryContentItem
v-for="(item, index) in state.list"
:key="index"
:id="item.id"
:url="item.verticalCover || item.horizontalCover"
:title="item.title"
:contentType="item.mediaType"
:info="item"
class="item"
>
<template v-slot:item>
<img v-if="index === 0" class="icon" src="@/assets/images/png/acg/rank-top-1.png" />
<img v-else-if="index === 1" class="icon" src="@/assets/images/png/acg/rank-top-2.png" />
<img v-else-if="index === 2" class="icon" src="@/assets/images/png/acg/rank-top-3.png" />
<div v-else class="icon-txt">{{ index + 1 }}</div>
</template>
</CategoryContentItem>
</div>
<Empty v-else class="empty" />
<div class="btn" @click="goMore">
查看更多
<SvgIcon class="icon" name="moreIcon" />
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { toRaw, reactive, inject } from 'vue';
import CategoryContentItem from '../../components/categoryContentItem.vue';
import AcgApi from '@/service/acg';
const props = withDefaults(
defineProps<{
type: number;
mediaType: string;
defaultData: Types.Media.ModuleItem[];
}>(),
{
type: () => 1,
mediaType: 'video',
defaultData: () => [],
},
);
const state = reactive({
loading: true,
list: <any>[],
});
const params = {
pageNumber: 1,
pageSize: 6,
};
const getList = async () => {
try {
if (props.defaultData.length > 0) {
state.list = props.defaultData;
state.loading = false;
return;
}
const TempfilterParams = Object.assign({}, params, toRaw(props));
let res: any = await AcgApi.getRanking(TempfilterParams);
state.list = res.list || [];
state.loading = false;
} catch (e) {
state.loading = false;
}
};
getList();
const goRankTab = inject('acg_go_rank_tab') as any;
const goMore = () => {
goRankTab && goRankTab();
};
</script>
<style lang="scss" scoped>
.list-wrap {
.content {
padding-bottom: 0;
}
.btn {
width: 343px;
height: 44px;
margin: 0 auto;
display: flex;
justify-content: center;
align-items: center;
align-self: stretch;
border-radius: 100px;
background: $themeBgLgColor;
color: #fff;
.icon {
color: #fff;
width: 12px;
height: 12px;
margin-left: 8px;
}
}
.empty {
margin-top: 18px;
padding-bottom: 18px;
min-height: 428px;
}
.container {
overflow: hidden;
display: flex;
flex-wrap: wrap;
margin-top: 18px;
padding-bottom: 18px;
min-height: 428px;
.item {
width: 111px;
height: 199px;
margin-right: 5px;
margin-bottom: 12px;
position: relative;
// :deep(.text-1-box) {
// display: none;
// }
&:nth-child(3n) {
margin-right: 0px;
}
.icon,
.icon-txt {
&.icon {
object-fit: contain;
}
position: absolute;
z-index: 2;
top: 8px;
left: 8px;
width: 24px;
height: 24px;
&.icon-txt {
background: #000;
border-radius: 50%;
color: #fff;
text-align: center;
font-family: Inter;
font-size: 14px;
font-style: italic;
font-weight: 800;
line-height: 24px; /* 128.571% */
text-indent: -2px;
}
}
}
}
}
</style>
@@ -0,0 +1,268 @@
<!-- 全量视频(海角样式) -->
<template>
<div class="acg-tab-item overflow-y-auto" :id="'home' + tabIndex" ref="rootEl">
<div class="acg-theme">
<HomeHeader
:advType="AdType.COMMUNITYCAROUSEL"
class="advBox"
v-if="!props.pureVersion"
/>
<!-- 标签区 -->
<div class="labelBox" v-if="state.allSection && state.allSection.length">
<div
class="label ellipsis"
v-for="item in state.allSection"
:key="item.sectionID"
@click="toTagShortVideo(item)"
>
{{ item.sectionName }}
</div>
</div>
<div class="tab-sort flex-vcenter" ref="dvSort">
<div
class="item"
:class="{ active: item.key === moduleSort }"
v-for="item in moduleSortMap"
:key="item.key"
@click="onChange(item.key)"
>
{{ item.name }}
</div>
</div>
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="list">
<CategoryContentItem
v-for="(item, index) in state.list"
:key="index"
class="item"
:id="item.id"
:url="item.verticalCover"
:title="item.title"
:contentType="item.mediaType"
:info="item"
></CategoryContentItem>
</div>
</PullRefresh>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, inject } from 'vue';
import CategoryContentItem from '../categoryContentItem.vue';
import HomeApi from '@/service/home';
import { AdType } from '@/enums/app';
import { useRouter } from 'vue-router';
import { getMediaTypeKey } from '../../utils/common';
const router = useRouter();
const dvSort = ref();
const props = defineProps<{
moduleId: string;
moduleName: string;
tabIndex?: number;
type: number;
advType?: number;
pureVersion?: boolean;
}>();
const rootEl = ref();
const moduleSortMap = [
{
name: '最多观看',
key: 3,
},
{
name: '最新发布',
key: 1,
},
{
name: '最多收藏',
key: 7,
},
// {
// name: '本周最热',
// key: 2,
// },
// {
// name: '最多点赞',
// key: 3,
// },
];
const moduleSort = ref(moduleSortMap[0].key);
const params = {
pageNumber: 1,
pageSize: 12,
};
const state = reactive({
list: <Types.Media.AllMediaInfoItem[]>[],
allSection: <Types.Media.allSection[]>[],
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
let sortTop = ref(0);
const getList = async (type = '') => {
let req = {
id: props.moduleId,
data: Object.assign({}, params, { moduleSort: moduleSort.value }),
};
try {
let res: any = await HomeApi.getModuleList(req);
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.allMediaInfo || [];
const allSection = res.allSection || [];
if (allSection.length && params.pageNumber === 1) {
state.allSection = allSection;
}
if (params.pageNumber === 1) {
moreParams.isNoData = list.length ? false : true;
}
if (type === 'refresh') {
state.list = list;
} else {
state.list = [...state.list, ...list];
}
if (!res.hasNext) {
moreParams.finished = true;
}
if (sortTop.value === 0) {
sortTop.value = dvSort.value.offsetTop;
}
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
const onLoad = () => {
moreParams.loading = true;
params.pageNumber++;
getList();
};
const onRefresh = () => {
params.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList('refresh');
};
// 初始化
const init = () => {
// 获取轮播图数据
getList();
};
init();
const onChange = (key: number) => {
moduleSort.value = key;
if (sortTop.value < rootEl.value.scrollTop) {
rootEl.value.scrollTop = sortTop.value;
}
onRefresh();
};
const type = getMediaTypeKey(props.type);
const toTagShortVideo = (val: any) => {
router.push({
path: '/AcgTopicList',
query: {
id: val.sectionID,
title: val.sectionName,
type,
showType: 0,
},
});
};
</script>
<style lang="scss" scoped>
@import '../../assets/style.scss';
.acg-tab-item {
height: calc(100vh - 44px * 2);
width: 100vw;
.tab-sort {
background: #0f0f0f;
padding: 0 26px;
height: 44px;
position: sticky;
left: 0;
top: 0;
z-index: 100;
// background-color: var(--theme-color);
.item {
flex: 1;
text-align: center;
position: relative;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
color: rgba(255, 255, 255, 0.4);
&:not(:last-child)::after {
content: '';
position: absolute;
width: 1px;
height: 12px;
right: 0px;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
}
&.active {
color: #fff;
}
}
}
.list {
display: flex;
flex-wrap: wrap;
padding: 0 16px;
.item {
width: 111px;
height: 190px;
margin-right: 5px;
margin-bottom: 12px;
&:nth-child(3n) {
margin-right: 0px;
}
}
}
.labelBox {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
grid-row-gap: 7px;
grid-column-gap: 9px;
padding: 0 16px;
margin-top: 18px;
.label {
width: auto;
height: 32px;
padding: 0 4px;
border-radius: 2px;
background-color: rgba(255, 255, 255, 0.05);
text-align: center;
line-height: 32px;
font-size: 12px;
}
}
.advBox {
padding: 0 16px;
}
}
</style>
@@ -0,0 +1,249 @@
<template>
<div class="acg-tab-item overflow-y-auto" :id="`acg_${props.moduleId}`">
<!-- <Search class="search-box" :mediaType="mediaType"></Search> -->
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="wrap">
<!-- 广告轮播 -->
<HomeHeader
:advType="AdType.COMMUNITYCAROUSEL"
class="advBox"
v-if="!props.pureVersion"
/>
<!-- <CategoryTag v-if="['video', 'image'].includes(mediaType)" :list="tagList" :mediaType="mediaType"></CategoryTag> -->
<template v-for="(item, index) in state.list" :key="item.sectionID">
<!--
101 一大四小 1
102 四宫格(横acg+影视共用) 1
205 2.5左右滑动(竖ACG)
201 四宫格(竖ACG) 1
202 六宫格(竖ACG) 1
203 九宫格(竖ACG) 1
204 小列表 (竖ACG) 1
-->
<!-- 一大四小 -->
<FiveContent
v-if="item.showType === 101"
:coverDirection="getCoverDirection(item.showType)"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
></FiveContent>
<CategoryContent
v-else-if="[205].includes(item.showType)"
:coverDirection="getCoverDirection(item.showType)"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
:showType="item.showType"
></CategoryContent>
<!-- <Topic
v-else-if="item.showType === 5"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:disVoteCount="item.disVoteCount"
:voteCount="item.voteCount"
:contentType="mediaType"
@disVoteCount="item.disVoteCount++"
@voteCount="item.voteCount++"
></Topic> -->
<CategoryContent
v-else-if="[201].includes(item.showType)"
:contentWrap="true"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
:showType="item.showType"
></CategoryContent>
<CategoryContent
v-else-if="[202, 203].includes(item.showType)"
:contentWrap="true"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
:showType="item.showType"
></CategoryContent>
<Popularity
v-else-if="item.showType === 204"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
></Popularity>
<!-- 默认按照102处理 -->
<CategoryContent
v-else
coverDirection="landscape"
:contentWrap="true"
:id="item.sectionID"
:title="item.sectionName"
:list="item.allMediaInfo || []"
:contentType="mediaType"
></CategoryContent>
<template v-if="(index + 1) % 2 === 0 && !props.pureVersion">
<HeaderAdv
class="advBox"
:advType="AdType.TopicGridAdv"
random
:length="0"
/>
</template>
</template>
</div>
</PullRefresh>
</div>
</template>
<script lang="ts" setup>
import { reactive, computed, ref } from 'vue';
import { storeToRefs } from 'pinia';
import Search from '../search.vue';
import CategoryTag from '../categoryTag.vue';
import CategoryContent from '../categoryContent.vue';
import FiveContent from '../fiveContent.vue';
import Topic from '../topic.vue';
import Popularity from '../popularity.vue';
import HomeApi from '@/service/home';
import { useAcgStore } from '@/stores/acg';
import { AdType } from '@/enums/app';
import { getAdItem } from '@/utils/getConfig';
const acgStore = useAcgStore();
const props = defineProps<{
moduleId: string;
moduleName: string;
showType: number;
advType?: number;
pureVersion?: boolean;
}>();
const mediaType = computed(() => {
if (props.moduleName.includes('小说')) {
return 'text';
}
switch (props.moduleName) {
case '动漫':
return 'video';
case '漫画':
return 'image';
// case '小说':
// return 'text';
case '剧场':
return 'theatre';
default:
return 'video';
}
});
const getCoverDirection = (showType: number) => {
return showType === 2 ? 'portrait' : 'landscape';
};
const params = {
pageNumber: 1,
pageSize: 20,
moduleSort: 1,
};
const state = reactive({
list: <Types.Media.ModuleItem[]>[],
advList: <Array<Types.App.AdsInfoListItem>>[], // 广告轮播
});
const { library } = storeToRefs(acgStore);
const tagList = computed(() => {
return library.value.find((item) => item.key === mediaType.value)?.kind || [];
});
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const getList = async () => {
let req = {
id: props.moduleId,
data: params,
};
try {
let res: any = await HomeApi.getModuleList(req);
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.allSection || [];
const isRefresh = params.pageNumber === 1;
if (isRefresh) {
state.list = list;
} else {
state.list = state.list.concat(list);
}
if (isRefresh && state.list.length == 0) {
moreParams.isNoData = true;
}
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
const onLoad = () => {
moreParams.loading = true;
params.pageNumber++;
getList();
};
const onRefresh = () => {
params.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList();
};
const advList = ref([] as Types.App.AdsInfoListItem[]);
// 初始化
const init = () => {
advList.value = getAdItem(AdType.TopicGridAdv) || [];
console.log(advList.value);
getList();
};
init();
</script>
<style lang="scss" scoped>
@import '../../assets/style.scss';
.acg-tab-item {
height: calc(100vh - 44px * 2);
overflow: visible;
.advSwiper {
height: 143px;
border-radius: 8px;
overflow: hidden;
transform: translateZ(0px);
position: relative;
:deep(.van-swipe-item) {
height: 143px !important;
}
}
.search-box {
background-color: #fff;
position: sticky;
left: 0;
top: 0;
z-index: 22;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.wrap {
border-top: none;
}
}
</style>
@@ -0,0 +1,123 @@
<template>
<!--
模块showType枚举
2: 海角
3: 专题
-->
<van-tabs
v-model:active="tabActive"
:animated="true"
:scrollspy="false"
shrink
:lazy-render="true"
swipeable
@rendered="onTabRendered"
class="tabs oepnX"
>
<van-tab v-for="item in secondTab" :key="item.id" :title="item.moduleName" :name="item.id">
<template v-if="tabIsInit(item.id)">
<component
class="contain overflow-y-auto"
:is="item.showType == 2 ? TabItem : TabHome"
:id="prefixKey + item.id"
:moduleId="item.id"
:moduleName="item.moduleName"
:showType="item.showType"
:advType="props.advType"
:type="props.type"
:pureVersion="item.pureVersion"
/>
</template>
</van-tab>
</van-tabs>
</template>
<script lang="ts" setup>
import { ref, computed, provide, unref, onActivated } from 'vue';
import { useGlobalStore } from '@/stores/global';
// import AcgApi from '@/service/acg';
import { getNewDate } from '../../utils/common';
import type { MediaTypeKey } from '../../utils/common';
import tabAction from '@/utils/tabAction';
import TabItem from './tabItem.vue';
import TabHome from './tabHome.vue';
import { showToast } from 'vant';
import { AdType } from '@/enums/app';
// const componentMap: any = {
// 2: TabHome,
// 3: TabItem,
// };
const props = withDefaults(
defineProps<{
type: number;
mediaType: MediaTypeKey;
moduleName: string;
advType?: number;
}>(),
{
advType: AdType.HOME,
},
);
provide('mediaType', unref(props.mediaType));
const globalStore = useGlobalStore();
const currentTime = ref(getNewDate().getTime());
onActivated(() => {
currentTime.value = getNewDate().getTime();
});
const secondTab = computed(() => {
// return (globalStore.tabObj[props.type === 6 ? 'novel' : 'cartoon'] || []).filter((item: any) => {
// return item.type === props.type;
// });
return globalStore.tabObj[props.type === 6 ? 'novel' : 'cartoon'] || [];
});
const secondTabNavSectionIndex = computed(() => {
return secondTab.value.findIndex((item: any) => [1, 2].includes(item.showType));
});
const prefixKey = 'acg_second_page_';
const { tabActive, tabIsInit, onTabRendered } = tabAction(true, prefixKey);
provide('acg_go_rank_tab', () => {
const item = secondTab.value.find((item: any) => item.showType === 5);
if (item) {
tabActive.value = item.id;
} else {
showToast('暂无更多');
}
});
</script>
<style lang="scss" scoped>
@import '../../assets/style.scss';
.tabs {
:deep(> .van-tabs__wrap) {
--van-tabs-nav-background: transparent;
--van-tab-active-text-color: #fff;
--van-tab-text-color: #9fa19f;
--van-padding-xs: 6px;
.van-tabs__nav--line {
padding-bottom: 0;
}
.van-tab__text {
padding: 4px;
border-radius: 4px;
}
.van-tabs__line {
width: 16px;
bottom: 4px;
height: 2px;
border-radius: 12px;
background: #f68804;
}
}
.contain {
height: calc(100vh - 44px * 3);
}
}
</style>
@@ -0,0 +1,180 @@
<template>
<div class="taskList" v-if="props.list.length > 0">
<div class="taskItem flex-center-between" v-for="item in props.list" :key="item.id">
<div class="leftBox flex-vcenter">
<LazyImg :src="item.icon" class="taskIcon" :key="item.icon" />
<div class="taskInfo">
<div class="taskName">
{{ item.title }}
</div>
<div class="taskDesc" v-if="item.awardList.length > 0">+{{ item.awardList[0]?.num }}</div>
</div>
</div>
<div class="action flex-center-center-direction">
<div class="btn" v-if="item?.state == 0" @click.stop="goDetailPage(props.mediaType, item.objId)">去完成</div>
<div class="btn receive" v-else-if="item?.state == 1" @click.stop="getMediaTaskReceiveList(item)">待领取</div>
<div class="btn received" v-else-if="item?.state == 2">已领取</div>
<span class="progress">({{ item.progress }}/{{ item.target }})</span>
</div>
</div>
</div>
<div v-if="props.completeAllTask.id" class="all-task">
<div class="go flex-center-between">
完美任务完成直达领券
<span v-if="state === 0" class="status0" @click.stop="goDetailPage(props.mediaType, props.completeAllTask.objId)">
去完成
<SvgIcon class="icon" name="moreIcon" />
</span>
<span v-else-if="state === 1" class="status1 flex-vcenter" @click.stop="getMediaTaskReceive()">
立即领取
<SvgIcon class="icon" name="moreIcon" />
</span>
<span v-if="state === 2" class="status2">已领取</span>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { goDetailPage } from '../../utils/common';
import type { MediaTypeKey } from '../../utils/common';
import AcgApi from '@/service/acg';
import { showToast } from 'vant';
const props = withDefaults(
defineProps<{
list: Types.Acg.TaskListItem[];
completeAllTask?: Types.Acg.CompleteAllTask;
mediaType: MediaTypeKey;
}>(),
{
list: () => [],
mediaType: 'image',
completeAllTask: () => {},
},
);
const getMediaTaskReceiveList = (item: Types.Acg.TaskListItem) => {
AcgApi.getMediaTaskReceive({ taskId: item.id }).then(() => {
item.state = 2;
showToast('领取成功');
});
};
const state = ref(props.completeAllTask.state);
watch(
() => props.completeAllTask.state,
(num: number) => {
state.value = num || 0;
},
);
const getMediaTaskReceive = () => {
AcgApi.getMediaTaskReceive({ taskId: props.completeAllTask.id }).then(() => {
state.value = 2;
showToast('领取成功');
});
};
</script>
<style lang="scss" scoped>
.taskList {
margin: 0 auto;
width: 343px;
padding: 0px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.1);
.taskItem {
padding: 12px 0;
border-bottom: 0.5px solid rgba(255, 255, 255, 0.05);
&:last-child {
border-bottom: none;
}
.leftBox {
flex: 1;
.taskIcon {
flex-shrink: 0;
height: 65px;
width: 65px;
// border-radius: 50%;
overflow: hidden;
}
.taskInfo {
flex: 1;
margin-left: 12px;
.taskName {
font-size: 14px;
line-height: 16px;
font-weight: 500;
}
.taskDesc {
font-size: 12px;
color: #aaaaaa;
margin-top: 5px;
line-height: 16px;
width: 170px;
}
}
}
.action {
.btn {
height: 28px;
width: 60px;
background: #ff004f;
border-radius: 40px;
text-align: center;
line-height: 28px;
font-size: 12px;
color: #fff;
&.receive {
color: #692a1f;
background: #ffd5a8;
}
&.received {
background: rgba(255, 255, 255, 0.2);
}
}
.progress {
color: rgba(255, 255, 255, 0.6);
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
}
}
}
.all-task {
margin: 24px 16px;
.go {
width: 100%;
padding: 12px 16px;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 22px; /* 137.5% */
border-radius: 9px;
background: rgba(255, 255, 255, 0.1);
span {
font-size: 12px;
}
.status0 {
color: rgba(255, 255, 255, 0.6);
}
.status1 {
color: #ddac44;
}
.status2 {
color: rgba(255, 255, 255, 0.6);
}
.icon {
flex-shrink: 0;
width: 10px;
height: 10px;
margin-left: 6px;
}
}
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="topic">
<div class="title">
{{ props.title }}
</div>
<div v-for="(item, index) in autoList" :key="index" class="list" @touchstart.stop @touchmove.stop @touchend.stop>
<CategoryContentItem
class="item"
v-for="childItem in item"
:key="childItem.id"
:id="childItem.id"
:url="childItem.verticalCover"
:title="childItem.title"
:info="childItem"
:contentType="props.contentType"
></CategoryContentItem>
</div>
<div class="score-wrap">
<div class="score">
<span>{{ props.disVoteCount }}</span>
<span>{{ props.voteCount }}</span>
</div>
<div class="bar">
<div
class="red"
:style="{
width: redBar + '%',
}"
></div>
<div
class="blue"
:style="{
width: blueBar + '%',
}"
></div>
</div>
<div class="btn">
<div class="red" @click="onVote(2)">
<img class="icon" src="@/assets/images/png/acg/icon-btn-red.png" />
贫乳!!
</div>
<img class="pk" src="@/assets/images/png/acg/topic-pk.png" />
<div class="blue" @click="onVote(1)">
<img class="icon" src="@/assets/images/png/acg/icon-btn-blue.png" />
巨乳!!
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import CategoryContentItem from './categoryContentItem.vue';
import MediaApi from '@/service/media';
import { showToast } from 'vant';
const props = withDefaults(
defineProps<{
title: string;
list: Types.Media.AllMediaInfoItem[];
disVoteCount: number; //红
voteCount: number; //蓝
id: string;
contentType: string;
}>(),
{
disVoteCount: 0,
voteCount: 0,
title: '',
list: () => [],
id: '',
},
);
const emit = defineEmits(['disVoteCount', 'voteCount']);
const autoList = computed(() => {
const list: Types.Media.AllMediaInfoItem[][] = [[]];
const arr = props.list || [];
const len = arr.length;
if (len > 1) {
list.push([]);
const oneLen = Math.floor(len / 2);
list[0] = arr.slice(0, oneLen);
list[1] = arr.slice(oneLen);
} else {
list[0] = arr;
}
return list;
});
const redBar = computed(() => {
return (props.disVoteCount / (props.disVoteCount + props.voteCount)) * 96 || 48;
});
const blueBar = computed(() => {
return (props.voteCount / (props.disVoteCount + props.voteCount)) * 96 || 48;
});
let isVoteIng = false;
const onVote = (status: number) => {
if (isVoteIng) return;
isVoteIng = true;
const key = `section_vote`;
const value = localStorage.getItem(key) || '';
if (value.includes(props.id)) {
showToast('您已经投过票啦~');
return;
}
// 状态 1、支持大胸 2、支持贫胸
MediaApi.postSctionVote({
sId: props.id,
status,
})
.then(() => {
showToast('投票成功');
if (status === 1) {
emit('voteCount');
} else {
emit('disVoteCount');
}
localStorage.setItem(key, value + ',' + props.id);
})
.finally(() => {
isVoteIng = false;
});
};
</script>
<style lang="scss" scoped>
@import '../assets/style.scss';
.topic {
@include categoryWrap();
margin-top: 12px;
.title {
@include categoryTitle();
}
.list {
@include categoryList();
.item {
margin-bottom: 20px;
}
}
.score-wrap {
display: flex;
flex-direction: column;
> div {
display: flex;
align-items: center;
justify-content: space-between;
&.score {
margin-top: 12px;
color: $textColor;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
&.bar {
margin: 12px 0;
height: 10px;
width: 100%;
> div {
height: 100%;
width: 100px;
border-radius: 5px;
&.red {
background: #e05a54;
}
&.blue {
background: #3675df;
}
}
}
&.btn {
overflow: hidden;
> div {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
font-weight: 400;
line-height: normal;
width: 101px;
height: 34px;
border-radius: 8px;
flex-shrink: 0;
> img {
width: 18px;
height: 18px;
flex-shrink: 0;
margin-right: 12px;
}
&.red {
background: #e05a54;
}
&.blue {
background: #3675df;
}
}
.pk {
height: 39px;
margin: 0 10px;
width: 120px;
}
}
}
}
}
</style>
@@ -0,0 +1,30 @@
<template>
<div class="updateStatus" :class="{ end: props.info.updateStatus != 1 }">
<template v-if="props.info.updateStatus != 1">共{{ props.info.totalEpisode }}章 已完结</template>
<template v-else>更新{{ props.info.currentEpisode || props.info.totalEpisode }}章 连载中</template>
</div>
</template>
<script lang="ts" setup>
const props = withDefaults(
defineProps<{
info: Types.Media.MediaInfo | Types.Media.AllMediaInfoItem | Types.Acg.DiscountChildListItem;
}>(),
{
info: {} as any,
},
);
</script>
<style lang="scss" scoped>
.updateStatus {
color: #eec76b;
font-size: 10px;
font-style: normal;
font-weight: 400;
line-height: normal;
// text-indent: 2px;
&.end {
color: rgba(255, 255, 255, 0.45);
}
}
</style>
+773
View File
@@ -0,0 +1,773 @@
<template>
<div class="detail-wrap overflow-y-auto" @scroll="onScroll">
<template v-if="state.info.id">
<div class="header flex-center-between" :style="headStyle">
<SvgIcon name="back_white_round1" class="back" @click="$router.go(-1)" />
<p v-show="scrollTop >= 100" class="title ellipsis">{{ state.info.title }}</p>
<div class="bookself">
<div class="menuItem">
<SvgIcon
class="collected"
name="acg-collect-img-1"
v-if="historyData?.mediaStatus?.hasCollected"
@click="onToogleCollected"
></SvgIcon>
<SvgIcon class="collected" name="acg-collect-img-0" v-else @click="onToogleCollected"></SvgIcon>
<SvgIcon name="acg-share1" @click="openShare" />
</div>
</div>
</div>
<div class="cover-box flex-vcenter">
<lazyImg class="cover" :src="state.info.verticalCover || state.info.horizontalCover"></lazyImg>
<div class="content">
<h1 class="title ellipsis">{{ state.info.title }}</h1>
<div class="status">
<!-- <template v-if="state.info.updateStatus == 2">已完结 共{{ state.info.totalEpisode }}话</template>
<template v-else>更新至第{{ state.info.currentEpisode || state.info.totalEpisode }}话</template> -->
</div>
</div>
</div>
<div class="info-box">
<div class="watch" :class="state.info.mediaType">
<div class="info">
<div class="count flex-vcenter">
<div class="count-item">
<SvgIcon name="eye-open" />
{{ watchCount(state.info.countBrowse) }}
</div>
<div class="count-item">
<SvgIcon name="acg-heart-2" />
{{ watchCount(state.info.countLike) }}
</div>
<div class="count-item">
{{ state.info.permission == 0 ? 'VIP' : (state.info.price || '0') + '金币' }}
</div>
<!-- <div class="collected flex-vcenter" @click="onToogleCollected">
<SvgIcon v-if="!hasCollected" name="acg-collect-star-0" />
<SvgIcon v-else name="acg-collect-star-1" style="color: #f52c56" />
{{ watchCount(state.info.countCollect) }}
</div> -->
</div>
<div :class="['summary', !showMoreSummary && 'ellipsis3']">{{ state.info.summary || '' }}</div>
<div
v-if="state.info.summary"
:class="['more-summary', showMoreSummary && 'showMoreSummary']"
@click="showMoreSummary = !showMoreSummary"
>
更多简介
<SvgIcon name="rightArrow1" />
</div>
<div class="tags" v-if="tagDetails.length">
<div
class="tag"
v-for="item in tagDetails"
:key="item.id"
@click="goAcgTagPage(item.id, item.name, 'image')"
>
{{ item.name }}
</div>
</div>
</div>
<div class="nav" @click="visibleCatalogue = true">
<span class="title">选集</span>
<span class="count flex-vcenter">
{{ EpisodeTitle }}
<SvgIcon name="rightArrow1" />
</span>
</div>
<div
class="episode-box"
ref="episodeBox"
@touchstart.stop
@touchmove.stop
@touchend.stop
@scroll="onScrollEpisodeBox"
>
<Episode
showType="text"
:id="'episode-' + item.id"
class="item"
:episodeNumber="childState.episodeNumber"
:info="item"
v-for="item in autoList?.slice(0, 4) || []"
:key="item.id"
@click="childState.episodeNumber = item.episodeNumber"
></Episode>
</div>
</div>
<RecommendedList :type="state.info.mediaType" :tagId="tagDetails[0]?.id" class="recommendedBox" />
<div class="action">
<div class="btn flex-vcenter" @click="onToogleLiked">
<SvgIcon v-if="historyData?.mediaStatus?.hasLiked" name="acg-heart-1" />
<SvgIcon v-else name="acg-heart-0" />
喜欢
</div>
<div class="btn flex-vcenter go-page" @click="goWatch">
{{ currentEpisodeName }}
</div>
</div>
</div>
</template>
<Loading v-else />
</div>
<van-popup v-model:show="visibleCatalogue" position="bottom" z-index="1001" :style="{ height: '438px' }">
<div class="play-list-box">
<div class="line"></div>
<div class="title flex-center-between">
漫画列表
<div class="right">
<SvgIcon name="acg_sort_0" v-if="sort" />
<SvgIcon name="acg_sort_1" v-else />
<span @click="sort = !sort">
{{ sort ? '正序' : '倒序' }}
</span>
</div>
</div>
<div class="list-box overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="list">
<Episode
showType="text"
class="item"
:episodeNumber="childState.episodeNumber"
:info="item"
v-for="item in autoList || []"
:key="item.id"
@click="handlePopupSwitchEpisode(item.episodeNumber)"
></Episode>
</div>
</PullRefresh>
</div>
</div>
</van-popup>
<PayForWatch
v-if="childState.validateItem && visiblePayForWatch"
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="childState.validateItem"
@close="visiblePayForWatch = false"
@buySuccess="beforeBuySuccess"
></PayForWatch>
<AppSharePopup
coverType="portrait"
:is-open="visibleShare"
:cover="state.info.verticalCover || state.info.horizontalCover"
@close="visibleShare = false"
/>
</template>
<script lang="ts">
export default {
name: 'AcgImageDetail',
};
</script>
<script lang="ts" setup>
import { ref, toRaw, watch, computed, watchEffect, provide, onDeactivated, onActivated } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PayForWatch from '../components/pay/PayForWatch.vue';
import tabAction from '@/utils/tabAction';
import { AdType } from '@/enums/app';
import { HistoryVideoType } from '@/enums/app';
import MediaInfo from '../utils/mediaInfo';
import mediaContentInfo from '../utils/mediaContentInfo';
import Episode from '../components/episode.vue';
import { handleCollected } from '../utils/collect';
import { dateFormatYyyyMmDd, watchCount } from '@/utils/index';
import { Pay_parent_on, Pay_child_on } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
import { goAcgTagPage } from '../utils/common';
import CommunityApi from '@/service/community';
const route = useRoute();
const router = useRouter();
const dvComment = ref();
const showMoreSummary = ref(false);
const { state, historyEpisodeNumber, historyData, addHistory } = MediaInfo({
dbType: HistoryVideoType.AcgImage,
});
Pay_parent_on({ id: route.query.id as string, state, historyData, addHistory });
provide('historyData', historyData);
const {
state: childState,
autoList,
visibleCatalogue,
handlePopupSwitchEpisode,
moreParams,
sort,
onLoad,
onRefresh,
visiblePayForWatch,
validateHasBuy,
currentItem,
} = mediaContentInfo({ dbType: HistoryVideoType.AcgImage, isGetHistoryInit: true, isGetInfo: false });
Pay_child_on(childState.id, childState);
const { id = '' } = route.query;
watch(historyEpisodeNumber, (num) => {
childState.episodeNumber = num;
});
const currentEpisodeName = computed(() => {
return '开始阅读';
});
const EpisodeTitle = computed(() => {
return '全' + state.info.totalEpisode + '话';
});
const tagDetails = computed(() => {
const list = state.info.tagDetails || [];
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return list.filter((item) => {
if (!item) return false;
if (seenIds.has(item.id) || seenNames.has(item.name)) return false;
seenIds.add(item.id);
seenNames.add(item.name);
return true;
});
});
const goWatch = () => {
if (validateHasBuy(currentItem.value)) {
return;
}
router.push({
path: '/AcgImageDetailWatch',
query: {
id,
episode: childState.episodeNumber,
},
});
};
watch(dvComment, (el) => {
if (!el) return;
if (!id) return;
dvComment.value[0].state.videoInfo = toRaw(state.info);
dvComment.value[0].getCommentList();
});
const beforeBuySuccess = (obj: PayInfo) => {
if (obj.type === 'all') {
state.info.mediaStatus.hasPaid = true;
historyData.value.mediaStatus.hasPaid = true;
addHistory();
childState.list.forEach((item) => {
item.hasBuy = true;
});
}
buySuccess(obj);
};
const buySuccess = ({ type }: PayInfo) => {
if (type === 'one') {
const item = (childState.list as Types.Acg.MediaContentListItem[]).find(
(item) => item.episodeNumber === currentItem.value.episodeNumber,
);
if (item) {
item.hasBuy = true;
}
}
visiblePayForWatch.value = false;
goWatch();
};
let isInitCollected = false;
watch(
() => state.info,
(obj) => {
if (isInitCollected) return;
isInitCollected = true;
},
);
// 收藏
const onToogleCollected = async () => {
try {
const tempIsCollect = !historyData.value.mediaStatus.hasCollected;
handleCollected(state.info.id, tempIsCollect).then(() => {
historyData.value.mediaStatus.hasCollected = tempIsCollect;
});
} catch (e) {
console.log(e);
}
};
// 点赞
const onToogleLiked = () => {
const zan = !historyData.value.mediaStatus.hasLiked;
(zan ? CommunityApi.zan : CommunityApi.cancleZan)(
zan
? {
type: 'image',
objID: state.info.id,
}
: {
type: 'image',
objIDs: [state.info.id],
},
).then(() => {
historyData.value.mediaStatus.hasLiked = zan;
});
};
const scrollTop = ref(0);
const onScroll = (e: Event) => {
const currentTop = (e.target as HTMLElement).scrollTop;
scrollTop.value = currentTop;
};
const headStyle = computed(() => {
return {
background: `rgba(0, 0, 0, ${scrollTop.value / 100})`,
};
});
const visibleShare = ref(false);
const openShare = () => {
const { id, verticalCover, horizontalCover } = historyData.value;
let obj = {
id,
cover: verticalCover || horizontalCover,
};
// globalStore.setShareInfo(obj);
visibleShare.value = true;
};
const episodeBox = ref();
let episodeBoxLeft = 0;
let isDeactivated = false;
const onScrollEpisodeBox = (e: Event) => {
if (!episodeBox.value) return;
const scrollLeft = (e.target as HTMLElement).scrollLeft;
episodeBoxLeft = scrollLeft;
};
onDeactivated(() => {
isDeactivated = true;
});
onActivated(() => {
if (episodeBox.value && episodeBoxLeft) {
episodeBox.value.scrollLeft = episodeBoxLeft;
}
});
watch(
() => childState.episodeNumber,
(num: number) => {
if (isDeactivated) {
handlePopupSwitchEpisode(num);
isDeactivated = false;
}
},
);
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
@import '../assets/style.scss';
.detail-wrap {
$themeBgColor2: #222324;
width: 100%;
height: 100%;
@include vanTab();
--van-tab-active-text-color: rgba(255, 255, 255, 0.9);
.borderBottomDiv {
margin: 0 14px;
width: calc(100% - 28px);
}
.header {
position: fixed;
top: 0;
left: 0;
width: 100%;
// background: $themeBgColor2;
height: 39px;
z-index: 10;
padding: 0 16px;
.back {
width: 24px;
height: 24px;
flex-shrink: 0;
color: #292929;
}
.title {
color: #fff;
font-size: 18px;
font-style: normal;
font-weight: 900;
line-height: 24px; /* 133.333% */
margin: 0 16px;
}
.bookself {
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 16px;
border-radius: 11px;
color: #ffffff99;
.menuItem {
display: flex;
align-items: center;
}
.collected {
margin-right: 10px;
color: #fff;
}
svg {
width: 24px;
height: 24px;
}
}
}
.cover-box {
width: 100%;
height: 216px;
padding: 11px 16px 23px;
margin-bottom: 25px;
position: relative;
align-items: flex-end;
.cover {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 1;
&::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
z-index: 2;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, #000 100%);
}
}
.content {
z-index: 3;
flex: 1;
overflow: hidden;
.title {
color: #fff;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.status {
color: #e0e0e0;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
margin: 4px 0 10px;
}
}
}
.info-box {
width: 100%;
border-radius: 24px 24px 0px 0px;
background: linear-gradient(180deg, #000 0%, rgba(0, 0, 0, 0) 100%);
}
.watch {
padding: 16px 0 6px;
margin-top: -50px;
border-radius: 20px 20px 0px 0px;
position: relative;
z-index: 3;
.info {
padding: 0 10px 10px;
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
margin: 12px 0 14px;
color: #e0e0e0;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
.tag {
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
padding: 4px 8px;
font-weight: 400;
color: rgba(255, 255, 255, 0.55);
}
}
.summary {
color: #989898;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 142.857% */
}
.more-summary {
color: #efefef;
font-size: 12px;
margin: 12px auto;
display: flex;
align-items: center;
justify-content: center;
svg {
width: 18px;
height: 18px;
color: #fff;
margin-left: 4px;
}
}
.showMoreSummary {
svg {
transform: rotate(-90deg);
}
}
.count {
color: #a7a7a7;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 180% */
.count-item {
font-size: 12px;
display: flex;
align-items: center;
margin: 10px 24px 16px 0;
svg {
width: 16px;
height: 16px;
color: #fff;
margin-right: 4px;
}
}
}
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 14px;
.title {
font-size: 18px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
}
.count {
font-size: 12px;
color: #989898;
font-weight: 400;
svg {
width: 18px;
height: 18px;
margin-left: 4px;
}
}
}
.episode-box {
margin: 9px 0 0;
padding-left: 16px;
width: 100%;
overflow-y: auto;
display: flex;
flex-wrap: nowrap;
flex-direction: column;
.item {
flex-shrink: 0;
margin-right: 11px;
}
}
// .btn {
// display: flex;
// width: 249px;
// height: 39px;
// justify-content: center;
// align-items: center;
// border-radius: 19px;
// background: $themeBgLgColor;
// color: #fff;
// font-size: 16px;
// font-weight: 500;
// margin: 0 auto;
// }
}
.play-list {
@include categoryWrap();
border: none;
margin-top: 0;
padding-top: 16px;
.title {
padding: 0 14px;
@include categoryTitle();
}
.list {
@include categoryList();
padding-left: 14px;
.item {
height: 118px;
flex-shrink: 0;
width: 97px;
border-radius: 3px;
display: flex;
flex-direction: column;
margin-right: 4px;
position: relative;
overflow: hidden;
.cover {
width: 100%;
height: 97px;
position: relative;
overflow: hidden;
.icon {
position: absolute;
top: 4px;
right: 4px;
z-index: 1;
width: 18px;
height: 14px;
}
.count {
position: absolute;
bottom: 0;
left: 0;
z-index: 1;
width: 97px;
height: 26px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 11px;
font-weight: 500;
display: flex;
align-items: center;
.play {
width: 16px;
height: 16px;
margin: 0 5px;
}
}
}
.title-box {
width: 100%;
height: 21px;
padding: 0 5px;
background: transparent;
display: flex;
align-items: flex-end;
flex: 1;
color: $textColor;
font-size: 11px;
font-weight: 400;
line-height: normal;
@include textEllipsis();
}
}
}
}
.action {
position: fixed;
bottom: 0;
left: 0;
z-index: 10;
width: 100%;
background: linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, #000 100%);
height: 68px;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
padding: 0 16px;
@include SafeArea('padding', 'bottom', 0);
display: flex;
.btn {
width: 108px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.2);
height: 44px;
color: #989898;
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
justify-content: center;
&.go-page {
background: $themeColor;
width: 223px;
color: #fff;
}
svg {
width: 20px;
height: 20px;
margin-right: 10px;
}
}
}
}
.play-list-box {
$paddingBottom: 38px;
width: 100%;
height: 100%;
border-radius: 16px 16px 0px 0px;
background: $popupBg;
padding: 18px 16px $paddingBottom;
@include SafeArea('padding', 'bottom', 0);
display: flex;
flex-direction: column;
color: $textColor;
font-size: 12px;
color: #fff;
> .title {
font-size: 16px;
font-style: normal;
font-weight: 900;
line-height: 25px; /* 140% */
margin: 16px 0 0;
.right {
display: flex;
align-items: center;
svg {
color: rgba(255, 255, 255, 0.55);
margin-right: 4px;
width: 20px;
height: 20px;
}
span {
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
}
.line {
width: 36px;
height: 5px;
border-radius: 100px;
background: #989898;
margin: 0 auto;
}
.list-box {
flex: 1;
}
}
.recommendedBox {
padding: 0 16px;
:deep(.van-tabs__wrap) {
// top: 39px !important;
}
}
</style>
+761
View File
@@ -0,0 +1,761 @@
<template>
<div
v-if="state.info.id"
ref="dvCaricatureWrap"
class="caricature-wrap overflow-y-auto"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@scroll="onContentScroll"
>
<Transition name="top">
<div v-show="visibleHead" class="nav" @click.stop="visibleHead = false">
<SvgIcon name="back_white" class="back" @click.stop="$router.go(-1)" />
<span class="title">{{ currentItem.name }}</span>
<div class="right flex-vcenter">
<div
class="collect"
:class="{ collected: historyData.mediaStatus?.hasCollected }"
@click.stop="onToogleCollected"
>
<SvgIcon name="acg-collect-img-0" class="svg-icon" v-if="!historyData.mediaStatus?.hasCollected" />
<SvgIcon name="acg-collect-img-1" class="svg-icon" style="color: #f52c56" v-else />
</div>
<SvgIcon class="share" name="acg-share1" @click.stop="openShare" />
</div>
<!-- <SvgIcon class="back right" name="acg_catalogue" @click.stop="visibleCatalogue = true" /> -->
</div>
</Transition>
<div class="like-box" :class="isToolShow ? 'top150' : ''" v-show="visibleHead" @click.stop="onToogleLiked">
<SvgIcon v-if="historyData?.mediaStatus?.hasLiked" name="acg-heart-1" />
<SvgIcon v-else name="acg-heart-0" />
喜欢
</div>
<div class="list" @click="onClick">
<ImgObserver
v-for="(url, index) in currentEpisode"
class="item"
:style="getImgHeight(url)"
:class="{ move: visibleCatalogue }"
:key="url"
:index="index"
:url="url"
:init="index - currentProgress <= 1"
@updateIndex="updateIndex"
@updateSize="updateSize"
></ImgObserver>
</div>
<div
v-show="running"
class="running-box"
@click.stop="
{
running = false;
visibleHead = true;
}
"
>
<img class="running" src="@/assets/gif/running.gif" />
</div>
<Transition name="bottom">
<div v-show="visibleHead && !running" ref="bottomRef" class="action">
<div class="box">
<div class="func" @click.stop="visibleCatalogue = true">
<SvgIcon class="icon" name="acg_catalogue" />
目录
</div>
<div class="func" @click.stop="visibleComment = true">
<SvgIcon class="icon" name="acg_comment" />
评论
</div>
<div class="func" @click.stop="isToolShow = !isToolShow">
<SvgIcon class="icon" :name="isToolShow ? 'ddown' : 'acg_progress'" />
进度
</div>
<div class="func" @click.stop="running = true">
<SvgIcon class="icon" name="acg_automatic" />
自动
</div>
<!-- <div class="prev" @click.stop="switchEpisodeNumber(-1)">
<SvgIcon class="icon" name="acg_prev" />
上一话
</div>
<div class="next" @click.stop="switchEpisodeNumber(1)">
<SvgIcon class="icon" name="acg_prev" />
下一话
</div> -->
</div>
<Transition name="van-slide-up">
<div v-show="isToolShow && !running" class="tools">
<div class="prev" @click.stop="switchEpisodeNumber(-1)">
<SvgIcon class="icon" name="backWhite" />
<span>上一篇</span>
</div>
<div class="slider">
<van-slider
style="--van-slider-button-background: #ffd300"
:model-value="progressRatio"
@update:model-value="(v) => (progressRatio = v)"
active-color="#FFD300"
inactive-color="#fff3"
button-size="12px"
bar-height="4px"
@change="reposition"
></van-slider>
</div>
<div class="next" @click.stop="switchEpisodeNumber(1)">
<SvgIcon class="icon" name="backWhite" />
<span>下一篇</span>
</div>
</div>
</Transition>
</div>
</Transition>
<!-- <div v-show="!visibleHead" class="tips">
第{{ state.episodeNumber }}话 {{ currentProgress }}/{{ currentEpisode.length }}
</div> -->
</div>
<Loading v-else />
<van-popup v-model:show="visibleCatalogue" position="left" :style="{ width: '50%', height: '100%' }">
<div class="play-list-box">
<div class="header">
<!-- <SvgIcon class="close" name="popup_close" @click.stop="visibleCatalogue = false" /> -->
<div class="header-title ellipsis">{{ historyData.title }}</div>
<div class="sort flex-center-between">
<span class="menu-info">
共{{ historyData.totalEpisode }}话 {{ getUpdateStatusText(historyData.updateStatus) }}
</span>
<span class="btn" @click="sort = !sort">
<SvgIcon v-show="sort" class="section" name="acg_sort_0" />
<SvgIcon v-show="!sort" class="section" name="acg_sort_1" />
<span>{{ sort ? '正序' : '倒序' }}</span>
</span>
</div>
</div>
<div class="list overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh" finishedText="">
<div
:id="`section_${item.episodeNumber}`"
class="item"
:class="{ active: item.episodeNumber === state.episodeNumber }"
v-for="item in autoList"
:key="item.id"
@click="switchEpisodeNumberCatalogue(item)"
>
{{ item.episodeNumber }}.{{ item.name }}
</div>
</PullRefresh>
</div>
</div>
</van-popup>
<PayForWatch
v-if="state.info.id"
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="state.validateItem.id ? state.validateItem : state.info"
@close="closePayForWatch"
@buySuccess="beforeBuySuccess"
></PayForWatch>
<AppSharePopup
:is-open="visibleShare"
coverType="portrait"
@close="visibleShare = false"
:cover="historyData.verticalCover || state.info.cover"
/>
<CommentBox
v-if="state.info.id"
ref="dvComment"
class="comment"
:isOpen="visibleComment"
objType="cartoon"
@close="visibleComment = false"
:showCollected="false"
></CommentBox>
</template>
<script lang="ts">
export default {
name: 'AcgImageDetailWatch',
};
</script>
<script lang="ts" setup>
import { ref, watch, computed, onDeactivated, onUnmounted, onBeforeUnmount, provide, reactive, toRaw } from 'vue';
import PayForWatch from '../components/pay/PayForWatch.vue';
import mediaContentInfo from '../utils/mediaContentInfo';
import ImgObserver from '../components/ImgObserver.vue';
import { HistoryVideoType } from '@/enums/app';
import { showToast } from 'vant';
import { Pay_emit } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
import { handleCollected, Collected_emit } from '../utils/collect';
import CommunityApi from '@/service/community';
import { getUpdateStatusText } from '../utils/common';
import { debounce } from '@/utils';
// import { useGlobalStore } from '@/stores/global';
// const globalStore = useGlobalStore();
const running = ref(false);
const isToolShow = ref(false);
const visibleHead = ref(true);
const progressRatio = ref(0);
const contentMaxScrollTop = ref(0);
const contentScrollTop = ref(0);
const visibleSides = ref(true);
const bottomRef = ref();
const {
state,
currentItem,
visibleCatalogue,
dvPayForWatch,
visiblePayForWatch,
closePayForWatch,
buySuccess,
switchEpisodeNumber,
autoList,
moreParams,
onLoad,
onRefresh,
switchEpisodeNumberCatalogue,
historyData,
addHistoryReadData,
sort,
} = mediaContentInfo({ isGetHistoryInit: true, dbType: HistoryVideoType.AcgImage });
provide('historyData', historyData);
const dvCaricatureWrap = ref();
const currentProgress = ref(1);
const currentEpisode = computed(() => {
return state.info.urlSet || [];
});
const reposition = () => {
if (dvCaricatureWrap.value) {
dvCaricatureWrap.value.scrollTop = (contentMaxScrollTop.value * progressRatio.value) / 100;
}
};
const currentHistoryReadData = computed(() => {
const arr = historyData.value.historyReadData || [];
return arr.find((item) => item.episodeNumber === state.episodeNumber) || { scrollTop: 0, imgs: {} };
});
watch(
dvCaricatureWrap,
() => {
dvCaricatureWrap.value.scrollTop = currentHistoryReadData.value.scrollTop || 0;
},
{
once: true,
},
);
const onContentScroll = (e: Event) => {
const target = e.target as HTMLElement;
contentScrollTop.value = target.scrollTop;
contentMaxScrollTop.value = target.scrollHeight - target.offsetHeight;
if (contentMaxScrollTop.value > 0) {
progressRatio.value = (contentScrollTop.value / contentMaxScrollTop.value) * 100;
}
const progress =
contentMaxScrollTop.value <= 0 ? 100 : Math.max(1, Math.min(100, Math.ceil((contentScrollTop.value / contentMaxScrollTop.value) * 100)));
addHistoryReadData(state.episodeNumber, {
progress,
scrollTop: contentScrollTop.value,
});
const isEnd = contentScrollTop.value + 10 >= contentMaxScrollTop.value;
if (isEnd) {
console.log('阅读结束')
running.value = false;
visibleSides.value = true;
bottomRef.value?.showTool();
return;
}
if (!running.value) {
if (contentScrollTop.value < 100) {
visibleSides.value = true;
}
// visibleSides.value = contentScrollTop.value < 100
}
};
const getImgHeight = (url: string) => {
const imgs = (currentHistoryReadData.value.imgs || {})[url];
const { width = 0, height = 0 } = imgs || {};
if (width && height) {
return {
height: (window.innerWidth / width) * height + 'px',
};
}
return {};
};
const updateIndex = ({ index, url }: { index: number; url: string }) => {
let progress = 0;
if (dvCaricatureWrap.value.scrollTop === 0) {
progress = 1;
} else {
progress = Number(index) + 1;
}
currentProgress.value = progress;
addHistoryReadData(state.episodeNumber, {
progress: Math.ceil((progress / currentEpisode.value.length) * 100),
progressUrl: url,
scrollTop: dvCaricatureWrap.value.scrollTop,
});
};
const updateSize = (imgObj: any) => {
addHistoryReadData(state.episodeNumber, {}, imgObj);
};
watch(
() => state.episodeNumber,
(num) => {
addHistoryReadData(num, { progress: 1 });
if (dvCaricatureWrap.value) {
dvCaricatureWrap.value.scrollTop = 0;
}
visibleHead.value = true;
},
);
addHistoryReadData(state.episodeNumber + 1, {});
onBeforeUnmount(() => {
addHistoryReadData(state.episodeNumber, {
scrollTop: dvCaricatureWrap.value.scrollTop,
});
addHistoryReadData(state.episodeNumber + 1, {});
});
// 点赞
const onToogleLiked = () => {
const zan = !historyData.value.mediaStatus.hasLiked;
(zan ? CommunityApi.zan : CommunityApi.cancleZan)(
zan
? {
type: 'image',
objID: historyData.value.id,
}
: {
type: 'image',
objIDs: [historyData.value.id],
},
).then(() => {
historyData.value.mediaStatus.hasLiked = zan;
});
};
const onClick = (e: MouseEvent) => {
if (running.value) {
visibleHead.value = !visibleHead.value;
return;
}
const { clientY } = e;
let scrollTop = 0;
const OneThird = window.innerHeight / 3.5;
if (clientY < OneThird) {
scrollTop = -300;
} else if (clientY > OneThird * 2) {
scrollTop = 300;
} else {
visibleHead.value = !visibleHead.value;
return;
}
if (scrollTop !== 0) {
visibleHead.value = false;
dvCaricatureWrap.value.scrollTo({ top: scrollTop + dvCaricatureWrap.value.scrollTop, behavior: 'smooth' });
}
};
let lastScrollTop = 0;
const onTouchStart = (e: TouchEvent) => {
const { clientY } = e.targetTouches[0];
lastScrollTop = clientY;
};
const onTouchMove = (e: TouchEvent) => {
const { clientY } = e.targetTouches[0];
const distance = lastScrollTop - clientY;
if (distance > 30) {
visibleHead.value = false;
return;
}
if (distance < -30) {
visibleHead.value = true;
return;
}
};
// watch(visibleCatalogue, (bool) => {
// if (bool) {
// visibleHead.value = false;
// }
// });
const scrollSpeed = 1;
let moveInterval = null as ReturnType<typeof setInterval> | null;
const cancelAutoMove = () => {
moveInterval && clearInterval(moveInterval);
moveInterval = null;
running.value = false;
};
const autoMove = () => {
const { scrollTop, scrollHeight } = dvCaricatureWrap.value;
if (scrollTop >= scrollHeight) {
// 停止自动移动
cancelAutoMove();
return;
}
let newPosition = scrollTop + scrollSpeed;
dvCaricatureWrap.value.scrollTo(0, newPosition);
};
onDeactivated(() => {
cancelAutoMove();
});
onUnmounted(() => {
cancelAutoMove();
});
watch(running, (bool) => {
if (bool) {
moveInterval = setInterval(autoMove, 10);
showToast({
message: '自动阅读模式',
onClose: () => {
if (moveInterval) {
visibleHead.value = false;
}
},
});
} else {
cancelAutoMove();
}
});
const beforeBuySuccess = (obj: PayInfo) => {
Pay_emit(historyData.value.id, obj);
buySuccess(obj.type);
};
// 收藏
const onToogleCollected = async () => {
try {
const {
id,
mediaStatus: { hasCollected },
} = historyData.value;
const tempIsCollect = !hasCollected;
handleCollected(id, tempIsCollect).then(() => {
historyData.value.mediaStatus.hasCollected = tempIsCollect;
Collected_emit(id, tempIsCollect);
});
} catch (e) {
console.log(e);
}
};
const visibleShare = ref(false);
const openShare = () => {
const { id, verticalCover, horizontalCover } = historyData.value;
let obj = {
id,
cover: verticalCover || horizontalCover,
};
// globalStore.setShareInfo(obj);
visibleShare.value = true;
};
const visibleComment = ref(false);
const dvComment = ref();
watch(dvComment, (el) => {
if (!el) return;
if (!state.info.id || !dvComment.value?.state) return;
console.log(state, 'state.info', state.validateItem);
dvComment.value.state.videoInfo = toRaw({
...state.info,
id: state.id,
});
dvComment.value.getCommentList();
});
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.caricature-wrap {
width: 100%;
height: 100%;
// @include SafeArea('padding', 'bottom', 60px);
.like-box {
position: fixed;
bottom: 81px;
right: 0;
z-index: 100;
border-radius: 999px 0px 0px 999px;
background: rgba(0, 0, 0, 0.5);
width: 86px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: #fff;
svg {
margin-right: 5px;
width: 24px;
height: 24px;
}
}
.top150 {
bottom: 150px !important;
}
.nav {
position: fixed;
left: 0;
top: 0;
z-index: 2;
width: 100%;
height: 44px;
background: #181824;
display: flex;
align-items: center;
padding: 0 6px;
font-size: 16px;
font-weight: 500;
color: #fff;
.back {
flex-shrink: 0;
width: 24px;
height: 24px;
flex-shrink: 0;
color: #fff;
margin-right: 4px;
}
.title {
flex: 1;
text-align: left;
}
.right {
flex-shrink: 0;
margin-left: 4px;
.collect {
svg {
width: 24px;
height: 24px;
}
}
.share {
width: 19px;
height: 19px;
margin: 0 16px 0 12px;
}
}
}
> .list {
padding-top: 1px;
.item {
width: 100%;
min-height: 200px;
height: auto;
:deep(.animationImg) {
overflow: visible;
.loading {
height: 200px;
}
}
}
}
.action {
position: fixed;
box-sizing: content-box;
@include SafeArea('padding', 'bottom', 0);
// left: 88px;
// right: 88px;
height: 48px;
background: #000;
width: 100%;
left: 0;
bottom: 0;
transition: all 0.3s;
.box {
z-index: 2;
border-radius: 100px;
padding: 12px 16px;
// background: rgba(0, 0, 0, 0.60)
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
> div {
display: flex;
flex-direction: column;
align-items: center;
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
.icon {
width: 24px;
height: 24px;
color: rgba(255, 255, 255, 0.9);
}
&.next .icon {
transform: rotate(180deg);
}
&.disabled {
color: rgba(0, 0, 0, 0.4);
}
}
}
}
// .tips {
// position: fixed;
// @include SafeAreaFixed('bottom', 40px);
// right: 20px;
// border-radius: 8px;
// background: rgba(0, 0, 0, 0.6);
// padding: 8px 12px;
// color: #f1f1f1;
// font-size: 14px;
// font-weight: 500;
// }
}
.play-list-box {
position: relative;
width: 100%;
height: 100%;
background: #0f0f0f;
display: flex;
flex-direction: column;
color: rgba(255, 255, 255, 0.9);
.header {
padding-top: 85px;
width: 100%;
.close {
width: 14px;
height: 14px;
}
.header-title {
width: 100%;
text-indent: 16px;
font-size: 18px;
font-weight: 900;
font-family: Roboto;
}
.sort {
flex-shrink: 0;
margin: 16px 0 14px;
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 500;
color: #141414;
background: #f68804;
.btn {
display: flex;
align-items: center;
.section {
width: 20px;
height: 20px;
margin-right: 4px;
}
}
.menu-info {
font-size: 14px;
font-weight: 500;
}
}
}
.list {
flex: 1;
.item {
width: 100%;
height: 19px;
line-height: 19px;
padding: 0 16px;
margin-bottom: 11px;
color: rgba(255, 255, 255, 0.55);
font-size: 16px;
font-weight: 400;
@include textEllipsis();
&.active {
color: #f68804;
}
}
}
}
.running-box {
background-color: #fff;
padding: 10px;
border-radius: 50%;
position: fixed;
bottom: 50px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
.running {
width: 36px;
height: 36px;
}
}
.tools {
position: absolute;
top: -70px;
left: 0;
width: 100%;
height: 70px;
padding: 0 16px;
color: #fff;
background-color: #000;
display: flex;
justify-content: space-between;
.prev,
.next {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 12px;
line-height: 18px;
.icon {
width: 24px;
height: 24px;
margin-bottom: 4px;
}
}
.next .icon {
transform: rotate(180deg);
}
.slider {
width: 245px;
align-self: center;
}
}
.top-enter-active,
.top-leave-active,
.bottom-enter-active,
.bottom-leave-active {
will-change: transform;
transition: transform 0.4s ease;
}
.top-enter-from,
.top-leave-to {
transform: translateY(-100%);
}
.bottom-enter-from,
.bottom-leave-to {
transform: translateY(200%);
}
</style>
+538
View File
@@ -0,0 +1,538 @@
<template>
<div class="detail-wrap overflow-y-auto" @scroll="onScroll">
<template v-if="state.info.id">
<div class="header flex-center-between" :class="{ header_bg: scrollTop > 100 }">
<SvgIcon name="back1" class="back" @click="$router.go(-1)" />
<!-- 详情 -->
<div class="collect" :class="{ collected: state.info.mediaStatus.hasCollected }" @click="onToogleCollected">
<SvgIcon name="acg-collect-star-text-0" class="svg-icon" v-if="!state.info.mediaStatus.hasCollected" />
<SvgIcon name="acg-collect-star-text-1" class="svg-icon" v-else style="color: #f52c56" />
</div>
</div>
<lazyImg class="detail_image_bg" :src="state.info.verticalCover || state.info.horizontalCover">
<div class="detail_box">
<div class="cover-box flex-vcenter">
<lazyImg class="cover" :src="state.info.verticalCover || state.info.horizontalCover"></lazyImg>
<div class="content">
<h1 class="title ellipsis">{{ state.info.title }}</h1>
<div class="actress flex-vcenter" v-if="state.info.actress">
<lazyImg class="favicon" :src="state.info.actress.portrait"></lazyImg>
{{ state.info.actress.name }}
</div>
<div class="episode">
共{{ state.info.totalEpisode || state.info.currentEpisode }}话
<span class="episode_line">/</span>
<span :class="{ episode_status1: statusText == '连载中' }">{{ statusText }}</span>
</div>
<div class="tags">
<span
class="item"
v-for="item in state.info.tagDetails || []"
:key="item.id"
@click="goAcgTagPage(item.id, item.name, 'text')"
>
{{ item.name }}
</span>
</div>
<!-- <div class="summary ellipsis2">{{ state.info.summary }}</div> -->
</div>
</div>
</div>
</lazyImg>
<div class="episode-box">
<Summary v-if="state.info.summary" class="summary" :text="state.info.summary"></Summary>
<div class="title">
共{{ state.info.totalEpisode }}章
<div class="flex-vcenter" @click="visibleCatalogue = true">
目录
<SvgIcon class="icon" name="rightArrow"></SvgIcon>
</div>
</div>
<div class="list">
<div
class="item flex-vcenter"
:class="{ active: item.episodeNumber === childState.episodeNumber }"
v-for="item in childState.list.slice(0, 4)"
:key="item.id"
@click="openWatch(item.episodeNumber)"
>
<span class="name">第{{ item.episodeNumber }}章</span>
<SvgIcon
name="moreIcon"
class="back"
:class="{ active: item.episodeNumber === childState.episodeNumber }"
/>
</div>
</div>
<RecommendList
:media-id="state.info.id"
:contentType="state.info.mediaType"
title="相关推荐"
:margin-hide="true"
></RecommendList>
</div>
<!-- <AdvSwiper v-if="advList.length" class="adv" :list="advList" /> -->
<div class="read-box">
<div class="btn" @click="openWatch(currentEpisode)">
{{ currentEpisodeName }}
</div>
</div>
</template>
<Loading v-else />
<van-popup v-model:show="visibleCatalogue" z-index="1000" position="bottom" round :style="{ width: '100%' }">
<div class="play-list-box">
<div class="line" />
<div class="title">
{{ state.info.title }}
<div class="sort" @click="sort = !sort">
<span :class="{ active: sort }">正序</span>
/
<span :class="{ active: !sort }">倒序</span>
</div>
</div>
<div class="list overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div
:id="`section_${item.episodeNumber}`"
class="item flex-vcenter"
:class="{ active: item.episodeNumber === childState.episodeNumber }"
v-for="item in autoList"
:key="item.id"
@click="openWatch(item.episodeNumber)"
>
<span class="name">第{{ item.episodeNumber }}章</span>
<SvgIcon name="moreIcon" class="back" />
</div>
</PullRefresh>
</div>
</div>
</van-popup>
<PayForWatch
v-if="childState.validateItem && visiblePayForWatch"
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="childState.validateItem"
@close="visiblePayForWatch = false"
@buySuccess="beforeBuySuccess"
></PayForWatch>
</div>
</template>
<script lang="ts">
export default {
name: 'AcgTextDetail',
};
</script>
<script lang="ts" setup>
import { ref, computed, provide, watch } from 'vue';
import { watchCount, dateFormatYyyyMmDd } from '@/utils/index';
import MediaInfo from '../utils/mediaInfo';
// import { AdType } from '@/enums/app';
// import { getAdItem } from '@/utils/getConfig';
import { useRoute, useRouter } from 'vue-router';
import { handleCollected, Collected_emit } from '../utils/collect';
import RecommendList from '../components/recommendList.vue';
import { HistoryVideoType } from '@/enums/app';
import mediaContentInfo from '../utils/mediaContentInfo';
import PayForWatch from '../components/pay/PayForWatch.vue';
import { Pay_parent_on, Pay_child_on, Pay_emit } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
import { goAcgTagPage } from '../utils/common';
import Summary from '../components/summary.vue';
const route = useRoute();
const router = useRouter();
// const advList = ref(getAdItem(AdType.VIDEOSWIPER));
const { state, historyEpisodeNumber, historyData, addHistory, statusText } = MediaInfo({
dbType: HistoryVideoType.AcgText,
});
Pay_parent_on({ id: route.query.id as string, state, historyData, addHistory });
provide('historyData', historyData);
const {
state: childState,
autoList,
visibleCatalogue,
moreParams,
sort,
onLoad,
onRefresh,
visiblePayForWatch,
validateHasBuy,
} = mediaContentInfo({ dbType: HistoryVideoType.AcgText, isGetHistoryInit: true, isGetInfo: false });
Pay_child_on(childState.id, childState);
const currentEpisode = computed(() => {
if (historyEpisodeNumber.value === 0) return 0;
return historyEpisodeNumber.value;
});
watch(currentEpisode, () => {
childState.episodeNumber = currentEpisode.value;
});
const currentEpisodeName = computed(() => {
if (currentEpisode.value === 0) return '开始阅读';
let prefix = '';
if (state.episodeNumber > historyEpisodeNumber.value) {
prefix = '观看';
} else {
prefix = '续看';
}
return prefix + currentEpisode.value.toString().padStart(3, '0') + '话';
});
// 收藏
const onToogleCollected = async () => {
try {
const {
id,
mediaStatus: { hasCollected },
} = state.info;
const tempIsCollect = !hasCollected;
handleCollected(id, tempIsCollect).then(() => {
state.info.mediaStatus.hasCollected = tempIsCollect;
Collected_emit(id, tempIsCollect);
});
} catch (e) {
console.log(e);
}
};
let tempEpisodeNumber = 0;
const openWatch = (num: number) => {
num = num === 0 ? 1 : num;
tempEpisodeNumber = num;
const item = childState.list.find((item) => item.episodeNumber === num);
if (!item) {
goWatch(num);
return;
}
if (validateHasBuy(item)) return;
goWatch(num);
};
const goWatch = (num = 0) => {
// mediaSubType: 0 | 1; //0:文本 1:有声
const { mediaSubType } = state.info;
router.push({
name: `AcgTextDetailWatch${mediaSubType}`,
query: {
id: route.query.id,
episodeNumber: num || historyEpisodeNumber.value || 1,
title: state.info.title,
},
});
visibleCatalogue.value = false;
};
const beforeBuySuccess = (obj: PayInfo) => {
Pay_emit(historyData.value.id, obj);
if (obj.type === 'all') {
state.info.mediaStatus.hasPaid = true;
historyData.value.mediaStatus.hasPaid = true;
addHistory();
childState.list.forEach((item) => {
item.hasBuy = true;
});
}
buySuccess(obj);
};
const buySuccess = ({ type }: PayInfo) => {
if (type === 'one') {
const item = (childState.list as Types.Acg.MediaContentListItem[]).find(
(item) => item.episodeNumber === tempEpisodeNumber,
);
if (item) {
item.hasBuy = true;
}
}
visiblePayForWatch.value = false;
goWatch(tempEpisodeNumber);
};
const scrollTop = ref(0);
const onScroll = (e: Event) => {
scrollTop.value = (e.target as HTMLElement).scrollTop;
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
@import '../assets/style.scss';
.detail-wrap {
height: 100%;
// background: url('@/assets/images/png/acg/text-bg.png') #11111a no-repeat;
background: #000;
.header {
padding: 0 16px;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 44px;
z-index: 10;
font-size: 16px;
font-weight: 500;
.back {
width: 24px;
height: 24px;
flex-shrink: 0;
color: #fff;
}
.collect {
svg {
width: 62px;
height: 24px;
}
}
}
.header_bg {
background: #000;
}
.detail_image_bg {
position: relative;
margin-bottom: 16px;
height: 280px;
overflow: hidden;
:deep(img) {
object-position: top;
filter: brightness(0.5);
}
&::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.45) 0%, rgba(0, 0, 0, 0.78) 65%, #000 100%);
}
.detail_box {
padding: 60px 16px 0;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 2;
background: transparent;
}
}
.cover-box {
width: 100%;
margin-bottom: 16px;
.cover {
flex-shrink: 0;
width: 111px;
height: 146px;
border-radius: 8px;
overflow: hidden;
margin-right: 12px;
}
.content {
flex: 1;
overflow: hidden;
.title {
color: #fff;
font-size: 18px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.actress {
margin: 6px 0;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
.favicon {
width: 18px;
height: 18px;
border-radius: 50%;
overflow: hidden;
margin-right: 2px;
}
}
.episode {
color: #989898;
font-size: 12px;
line-height: 18px; /* 150% */
margin-top: 12px;
.episode_status1 {
color: $themeColor;
}
.episode_line {
margin: 0 8px 0 6px;
}
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
margin: 10px 0 12px;
.item {
flex-shrink: 0;
padding: 4px 12px;
border-radius: 4px;
background: #22252d;
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
}
}
.adv {
height: 75px;
:deep(.van-swipe-item) {
height: 75px !important;
}
}
.read-box {
position: fixed;
z-index: 10;
bottom: 0;
left: 0;
right: 0;
height: 68px;
//background: linear-gradient(180deg, rgba(34, 35, 50, 0) 0%, #222332 100%);
@include SafeArea('padding', 'bottom', 21px);
.btn {
margin: 0 auto;
width: 343px;
padding: 11px 0;
border-radius: 3px;
background: #f68804;
color: var(---f, #fff);
text-align: center;
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
}
}
.episode-box {
position: relative;
z-index: 3;
padding: 0 16px;
margin-top: -72px;
.title {
display: flex;
align-items: center;
justify-content: space-between;
color: #fff;
font-size: 16px;
font-weight: 500;
margin-bottom: 6px;
> div {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
> svg {
width: 18px;
height: 18px;
}
}
}
.item {
padding: 12px 0;
justify-content: space-between;
color: #989898;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&.active {
color: #f68804;
}
.name {
font-size: 14px;
font-weight: 400;
line-height: 18px;
flex: 1;
@include textEllipsis();
}
.back {
width: 18px;
height: 18px;
// transform: rotate(180deg);
flex-shrink: 1;
color: #fff;
&.active {
color: #f68804;
}
}
}
}
}
.play-list-box {
width: 375px;
height: 400px;
border-radius: 18px 18px 0px 0px;
background: #0f0f0f;
color: rgba(255, 255, 255, 0.9);
padding: 13px 16px 0;
@include SafeArea('padding', 'bottom', 18px);
display: flex;
flex-direction: column;
.line {
width: 36px;
height: 5px;
border-radius: 99px;
background: #989898;
margin: 0 auto 16px;
}
.title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
flex-shrink: 0;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
.sort {
color: #747b80;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
.active {
color: #f68804;
}
}
}
.list {
flex: 1;
.item {
padding: 18px 0;
justify-content: space-between;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
&.active {
color: #f68804;
}
.name {
font-size: 14px;
font-weight: 400;
line-height: 18px;
flex: 1;
@include textEllipsis();
}
.back {
width: 18px;
height: 18px;
flex-shrink: 1;
}
}
}
}
</style>
+358
View File
@@ -0,0 +1,358 @@
<template>
<div class="text-wrap" :class="theme">
<Transition name="top">
<Header v-show="visibleAction" class="header" :title="currentItem.id ? currentItem.name : ''">
<template v-slot:right>
<div class="right">
<SvgIcon v-if="theme === ThemeMode.dark" name="theme-mode-light" @click="switchTheme" />
<SvgIcon v-else name="theme-mode-dark" @click="switchTheme" />
<SvgIcon name="acg_catalogue" @click="visibleCatalogue = true" style="margin-left: 12px" />
</div>
</template>
</Header>
</Transition>
<Transition name="top">
<Header
v-show="!visibleAction"
class="header header2"
leftIcon=""
:title="currentItem.id ? `${state.title} / ${currentItem.name}` : ''"
/>
</Transition>
<template v-if="state.info.id">
<div
ref="rootEl"
class="content overflow-y-auto"
v-html="autoText"
@scroll="onScroll"
@click="visibleAction = !visibleAction"
></div>
<Transition name="bottom">
<div v-show="visibleAction" class="action">
<div class="prev" @click="switchEpisodeNumber(-1)">
<SvgIcon class="icon" name="acg_text_prev" />
上一章
</div>
<div class="next" @click="switchEpisodeNumber(1)">
下一章
<SvgIcon class="icon" name="acg_text_prev" />
</div>
</div>
</Transition>
</template>
<Loading v-else></Loading>
</div>
<van-popup
v-model:show="visibleCatalogue"
z-index="1000"
position="right"
:style="{ height: '100%', width: '210px' }"
>
<div class="play-list-box">
<div class="title">{{ state.title }}</div>
<div class="sort">
<span>共{{ historyData.totalEpisode }}章</span>
<span class="status">{{ statusText }}</span>
<span class="btn" @click="sort = !sort">
<SvgIcon v-show="sort" class="section" name="acg_sort_0" />
<SvgIcon v-show="!sort" class="section" name="acg_sort_1" />
<span>{{ sort ? '正序' : '倒序' }}</span>
</span>
</div>
<div class="list overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div
:id="`section_${item.episodeNumber}`"
class="item"
:class="{ active: item.episodeNumber === state.episodeNumber }"
v-for="item in autoList"
:key="item.id"
@click="switchEpisodeNumberCatalogue(item)"
>
{{ item.episodeNumber }}.{{ item.name }}
</div>
</PullRefresh>
</div>
</div>
</van-popup>
<PayForWatch
v-if="state.info.id"
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="state.validateItem.id ? state.validateItem : state.info"
@close="closePayForWatch"
@buySuccess="beforeBuySuccess"
></PayForWatch>
</template>
<script lang="ts">
export default {
name: 'AcgTextDetailWatch0',
};
</script>
<script lang="ts" setup>
import { ref, computed, provide, watch } from 'vue';
import mediaContentInfo from '../utils/mediaContentInfo';
import { HistoryVideoType } from '@/enums/app';
import PayForWatch from '../components/pay/PayForWatch.vue';
import { Pay_emit } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
enum ThemeMode {
light = 'light',
dark = 'dark',
}
const theme = ref(localStorage.getItem('theme') || ThemeMode.light);
const {
state,
statusText,
moreParams,
onLoad,
onRefresh,
sort,
autoList,
visibleCatalogue,
historyData,
visiblePayForWatch,
closePayForWatch,
buySuccess,
switchEpisodeNumberCatalogue,
switchEpisodeNumber,
currentItem,
addHistoryReadData,
} = mediaContentInfo({ dbType: HistoryVideoType.AcgText });
provide('historyData', historyData);
const autoText = computed(() => {
return (state.info.text || '').replace(/\n/g, '<br>');
});
const rootEl = ref();
watch(currentItem, () => {
if (!rootEl.value) return;
const list = historyData.value.historyReadData || [];
const item = list.find((i) => i.episodeNumber === state.episodeNumber) || {};
const progress = Number(item.progress);
rootEl.value.scrollTop = item.scrollTop || 0;
addHistoryReadData(state.episodeNumber, {
progress: Number.isFinite(progress) ? progress : 0,
scrollTop: item.scrollTop || 0,
});
});
const visibleAction = ref(true);
let scrollTop = 0;
const onScroll = (e: Event) => {
const target = e.target as HTMLElement;
const currentTop = target.scrollTop;
const maxScrollTop = target.scrollHeight - target.offsetHeight;
const progress = maxScrollTop <= 0 ? 100 : Math.max(1, Math.min(100, Math.ceil((currentTop / maxScrollTop) * 100)));
addHistoryReadData(state.episodeNumber, {
progress,
scrollTop: currentTop,
});
if (currentTop - scrollTop > 20) {
visibleAction.value = false;
scrollTop = currentTop;
return;
}
if (currentTop - scrollTop < -20) {
visibleAction.value = true;
scrollTop = currentTop;
return;
}
};
const switchTheme = () => {
theme.value = theme.value === ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
};
const beforeBuySuccess = (obj: PayInfo) => {
Pay_emit(historyData.value.id, obj);
buySuccess(obj.type);
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.text-wrap {
padding: 18px 0;
&.light {
--bg-color: #fff;
--icon-color: #000;
--text-header-color: rgba(0, 0, 0, 0.9);
--text-color: #333;
}
&.dark {
--bg-color: #030f18;
--icon-color: #fff;
--text-header-color: #fff;
--text-color: #ffffffe5;
}
height: 100%;
background-color: var(--bg-color);
.header {
position: fixed;
width: 100%;
top: 0;
left: 0;
z-index: 2;
background-color: var(--bg-color) !important;
color: var(--text-header-color);
:deep(.svg-icon) {
color: var(--icon-color);
}
:deep(.title) {
color: var(--icon-color);
left: 44px;
transform: none;
}
.right {
position: absolute;
right: 6px;
top: 10px;
svg {
width: 24px;
height: 24px;
}
}
}
.header2 {
:deep(.title) {
color: #525252;
left: 16px;
transform: none;
}
}
.content {
height: 100%;
padding: 44px 12px 110px;
color: var(--text-color);
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: normal;
}
.action {
position: fixed;
bottom: 0;
left: 0;
z-index: 2;
width: 375px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 10px;
@include SafeArea('padding', 'bottom', 12px);
background-color: var(--bg-color);
color: var(--text-header-color);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: normal;
> div {
height: 44px;
display: flex;
align-items: center;
color: var(--text-header-color);
font-size: 16px;
.icon {
width: 20px;
height: 20px;
margin-right: 6px;
}
&.next .icon {
transform: rotate(180deg);
margin-right: 0;
margin-left: 6px;
}
}
}
}
.top-enter-active,
.top-leave-active,
.bottom-enter-active,
.bottom-leave-active {
will-change: transform;
transition: transform 0.4s ease;
}
.top-enter-from,
.top-leave-to {
transform: translateY(-100%);
}
.bottom-enter-from,
.bottom-leave-to {
transform: translateY(100%);
}
.play-list-box {
position: relative;
width: 100%;
height: 100%;
background: #0f0f0f;
display: flex;
flex-direction: column;
color: #fff;
.title {
flex-shrink: 0;
width: inherit;
height: 44px;
line-height: 44px;
font-size: 16px;
font-weight: 500;
text-align: left;
position: relative;
padding: 0 16px;
}
.sort {
flex-shrink: 0;
width: inherit;
height: 24px;
margin: 0 0 12px;
padding: 2px 16px;
background: #f68804;
display: flex;
justify-content: space-between;
align-items: center;
color: #fff;
font-size: 12px;
font-weight: 500;
.status {
flex: 1;
margin-left: 4px;
}
.btn {
display: flex;
align-items: center;
.section {
width: 16px;
height: 16px;
margin-right: 4px;
color: #fff;
}
}
}
.list {
flex: 1;
.item {
width: 100%;
height: 22px;
line-height: 22px;
padding: 0 16px;
margin-bottom: 12px;
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
font-weight: 400;
@include textEllipsis();
&.active {
color: #f68804;
}
}
}
}
</style>
+626
View File
@@ -0,0 +1,626 @@
<template>
<div class="audio-wrap overflow-y-auto">
<div class="page-nav" v-if="state.info.id">
<SvgIcon name="back_white" class="back" @click="$router.go(-1)" />
{{ state.title }}
<span></span>
</div>
<div v-else class="page-nav">
<SvgIcon name="back_white" class="back" @click="$router.go(-1)" />
</div>
<div class="content-wrap">
<template v-if="state.info.id">
<div class="cd flex-center-center">
<audio
ref="audio"
@timeupdate="onTimeUpdate"
@loadedmetadata="onLoadedmetadata"
@ended="onEnded"
style="display: none"
></audio>
<lazyImg class="cover" :src="currentItem.cover || historyData.verticalCover" />
</div>
<div class="content">正在播放:{{ state.info.name }}</div>
<div class="bar flex-center-between" @touchstart.stop @touchmove.stop @touchend.stop>
<SvgIcon class="icon" name="acg_audio_time_seek-10" @click="upadteProgress(-10)"></SvgIcon>
<van-slider
class="progress"
v-model="currentProgress"
@change="onChange"
@drag-start="onDragStart"
@drag-end="onDragEnd"
/>
<SvgIcon class="icon" name="acg_audio_time_seek+10" @click="upadteProgress(10)"></SvgIcon>
</div>
<div class="time-box flex-center-between">
<span>{{ currentPlayTime }}</span>
<span>{{ duration }}</span>
</div>
<div class="action">
<div class="item">
<SvgIcon :name="playMode" class="mode" @click="onChangePlayMode" />
{{ playModeText }}
</div>
<SvgIcon name="acg_audio_action_prev" class="prev" @click="switchEpisodeNumber(-1)" />
<SvgIcon v-show="!isPlay" name="acg_audio_action_play" class="play" @click="isPlay = true" />
<SvgIcon v-show="isPlay" name="acg_audio_pause" class="play" @click="isPlay = false" />
<SvgIcon name="acg_audio_action_prev" class="prev next" @click="switchEpisodeNumber(1)" />
<div class="item">
<SvgIcon name="acg_catalogue" class="mode" @click="visibleCatalogue = true" />
播放列表
</div>
</div>
<div class="split"></div>
<RecommendList
v-if="mediaId"
title="相关推荐"
:media-id="mediaId"
:contentType="'text'"
:marginHide="true"
></RecommendList>
</template>
<Loading v-else />
</div>
</div>
<van-popup v-model:show="visibleCatalogue" z-index="1000" position="bottom" round :style="{ width: '100%' }">
<div class="play-list-box">
<div class="line" />
<div class="title">
{{ state.title }}
<div class="sort" @click="sort = !sort">
<span :class="{ active: sort }">正序</span>
/
<span :class="{ active: !sort }">倒序</span>
</div>
</div>
<div class="list overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div
:id="`section_${item.episodeNumber}`"
class="item flex-vcenter"
:class="{ active: item.episodeNumber === state.episodeNumber }"
v-for="item in autoList"
:key="item.id"
@click="switchEpisodeNumberCatalogue(item)"
>
<span class="name">第{{ item.episodeNumber }}章</span>
<!-- <SvgIcon
v-if="item.episodeNumber === state.episodeNumber"
name="acg_audio_episode_active"
class="episode_active"
/> -->
<SvgIcon name="moreIcon" class="back" />
</div>
</PullRefresh>
</div>
</div>
</van-popup>
<PayForWatch
v-if="state.info.id"
ref="dvPayForWatch"
:isOpen="visiblePayForWatch"
:info="state.validateItem.id ? state.validateItem : state.info"
@close="visiblePayForWatch = false"
@buySuccess="beforeBuySuccess"
></PayForWatch>
</template>
<script lang="ts">
export default {
name: 'AcgTextDetailWatch1',
};
</script>
<script lang="ts" setup>
import { ref, watch, computed, nextTick, onUnmounted, provide, onActivated, onDeactivated } from 'vue';
// import PopupList from './components/popup-list.vue';
import { videoTime } from '@/utils/index';
import { useConfigStore } from '@/stores/config';
// import PayForWatch from '../components/PayForWatch.vue';
import mediaContentInfo from '../utils/mediaContentInfo';
import { showToast } from 'vant';
import { useRoute } from 'vue-router';
import RecommendList from '../components/recommendList.vue';
import PayForWatch from '../components/pay/PayForWatch.vue';
import { HistoryVideoType } from '@/enums/app';
import { handleCollected } from '../utils/collect';
import { Pay_emit, Pay_child_on } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
const route = useRoute();
const mediaId = (route.query.id || '') as string;
const {
state,
currentItem,
autoList,
visibleCatalogue,
moreParams,
onLoad,
onRefresh,
visiblePayForWatch,
buySuccess,
switchEpisodeNumberCatalogue,
switchEpisodeNumber,
validateHasBuy,
historyData,
addHistory,
addHistoryReadData,
sort,
} = mediaContentInfo({ dbType: HistoryVideoType.AcgText, isGetHistoryInit: true });
provide('historyData', historyData);
Pay_child_on(state.id, state);
const isPlay = ref(false);
const audio = ref();
const currentProgress = ref(0);
const audioTotalTime = ref(0);
const audioCurrentTime = ref(0);
const initPlay = ref(false);
enum PlayMode {
listCycle = 'acg_audio_action_mode1',
singleCycle = 'acg_audio_action_mode2',
}
enum PlayModeText {
listCycle = '多集循环',
singleCycle = '单集循环',
}
const playMode = ref(localStorage.getItem('PlayMode') || PlayMode.listCycle);
const playModeText = computed(() => {
if (playMode.value === PlayMode.listCycle) {
return PlayModeText.listCycle;
} else {
return PlayModeText.singleCycle;
}
});
const onChangePlayMode = () => {
if (playMode.value === PlayMode.listCycle) {
showToast(PlayModeText.singleCycle);
playMode.value = PlayMode.singleCycle;
} else {
showToast(PlayModeText.listCycle);
playMode.value = PlayMode.listCycle;
}
};
const currentUrl = ref('');
watch(
() => state.info.audioUrl,
(url) => {
currentUrl.value = url;
},
);
watch(currentUrl, () => {
if (!currentUrl.value) return '';
// const url = `${baseUrl + currentUrl.value}?token=${useUserStore().token}&c=${globalStore.videoRoadLine.url}`;
const url = useConfigStore().sourceInfo.AUDIO + '/' + currentUrl.value;
nextTick(() => {
audio.value.src = url;
});
});
watch(isPlay, (bool) => {
if (bool && !initPlay.value) {
initPlay.value = true;
}
if (bool) audio.value.play();
else audio.value.pause();
});
watch(
() => state.episodeNumber,
() => {
currentProgress.value = 0;
audioCurrentTime.value = 0;
if (audio.value) {
audio.value.currentTime = 0;
}
addHistoryReadData(state.episodeNumber, {
progress: 0,
currentTime: 0,
});
addHistory(state.episodeNumber);
},
);
const onLoadedmetadata = () => {
if (audio.value === null) return;
audioTotalTime.value = audio.value.duration;
const list = historyData.value.historyReadData || [];
const item = list.find((i) => i.episodeNumber === state.episodeNumber) || {};
const savedProgress = Number(item.progress || 0);
if (audioTotalTime.value > 0 && savedProgress > 0 && savedProgress < 100) {
const restoreTime = (savedProgress / 100) * audioTotalTime.value;
audio.value.currentTime = restoreTime;
audioCurrentTime.value = restoreTime;
currentProgress.value = savedProgress;
} else if (savedProgress >= 100) {
currentProgress.value = 0;
audioCurrentTime.value = 0;
} else {
currentProgress.value = 0;
audioCurrentTime.value = 0;
}
try {
audio.value.play();
isPlay.value = !audio.value.paused;
} catch (e) {
console.log(e);
}
addHistory(state.episodeNumber);
};
const currentPlayTime = computed(() => {
return videoTime(parseInt(audioCurrentTime.value.toString()));
});
const duration = computed(() => {
return videoTime(parseInt(audioTotalTime.value.toString()));
});
let isDrag = false;
const onDragStart = () => {
isDrag = true;
};
const onDragEnd = () => {
isDrag = false;
};
const onTimeUpdate = () => {
if (audio.value === null) return;
if (visiblePayForWatch.value) {
audio.value.pause();
isPlay.value = false;
return;
}
if (validateHasBuy(currentItem.value)) {
audio.value.pause();
isPlay.value = false;
}
const { currentTime } = audio.value;
const duration = Number(audio.value.duration || 0);
if (duration <= 0) return;
const progress = Math.max(0, Math.min(100, (currentTime / duration) * 100));
if (!isDrag) {
currentProgress.value = progress;
}
audioCurrentTime.value = currentTime;
addHistoryReadData(state.episodeNumber, {
progress: Math.round(progress),
currentTime,
});
};
const onEnded = async () => {
addHistoryReadData(state.episodeNumber, {
progress: 100,
currentTime: audio.value?.duration || audioCurrentTime.value,
});
if (playMode.value === PlayMode.singleCycle) {
currentProgress.value = 0;
audio.value.play();
} else {
const bool = await switchEpisodeNumber(1);
if (bool) {
currentProgress.value = 0;
audio.value.play();
} else {
isPlay.value = false;
}
}
};
const onChange = (progress: number) => {
const currentTime = (progress * audio.value.duration) / 100;
audio.value.currentTime = currentTime;
};
const upadteProgress = (num: number) => {
if (!isPlay.value) return;
const currentTime = audio.value.currentTime + num;
audio.value.currentTime = currentTime;
};
const beforeBuySuccess = (obj: PayInfo) => {
Pay_emit(historyData.value.id, obj);
buySuccess(obj.type);
if (obj.type === 'all') {
historyData.value.mediaStatus.hasPaid = true;
addHistory(state.episodeNumber);
}
};
// 收藏
// const onToogleCollected = async () => {
// try {
// const {
// id,
// mediaStatus: { hasCollected },
// } = historyData.value;
// const tempIsCollect = !hasCollected;
// handleCollected(id, tempIsCollect).then(() => {
// historyData.value.mediaStatus.hasCollected = tempIsCollect;
// // Collected_emit(id, tempIsCollect);
// });
// } catch (e) {
// console.log(e);
// }
// };
onUnmounted(() => {
localStorage.setItem('PlayMode', playMode.value);
});
onActivated(() => {
const episodeNumber = Number((route.query.episodeNumber || '0') as string);
if (episodeNumber && episodeNumber != state.episodeNumber) {
state.episodeNumber = episodeNumber;
} else {
if (audio.value) audio.value.play();
}
});
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes shake {
0% {
transform-origin: top center;
transform: rotate(0deg);
}
50% {
transform-origin: top center;
transform: rotate(-10deg);
}
100% {
transform-origin: top center;
transform: rotate(0deg);
}
}
.audio-wrap {
position: relative;
width: 100%;
height: 100%;
--van-slider-active-background: #e1351f;
--van-slider-inactive-background: #3d3d3d;
--van-slider-button-width: 12px;
--van-slider-button-height: 12px;
--van-slider-button-shadow: 0 0 0 4px rgb(245, 44, 86, 61%);
.page-nav {
display: flex;
align-items: center;
// justify-content: space-between;
height: 44px;
width: 100%;
padding: 0;
color: #fff;
font-size: 16px;
padding: 0 16px;
font-weight: 600;
position: sticky;
top: 0;
left: 0;
z-index: 10;
background: #000;
.back {
width: 24px;
height: 24px;
flex-shrink: 0;
margin-right: 4px;
}
.more {
width: 24px;
flex-shrink: 0;
}
}
.content-wrap {
position: relative;
z-index: 2;
padding: 0 16px;
height: 100%;
@include SafeArea('padding', 'bottom', 44px);
.cd {
margin: 0 auto;
width: 100%;
height: 188px;
flex-shrink: 0;
position: relative;
margin-top: 28px;
position: relative;
.cover {
width: 111px;
height: 148px;
border-radius: 9px;
overflow: hidden;
}
}
$margin: 0px;
.content {
flex: 1;
width: 100%;
padding: 18px $margin;
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 150% */
}
.bar {
flex-shrink: 0;
width: calc(100% - $margin * 2);
height: 24px;
margin: 0 $margin 10px;
position: relative;
.progress {
width: 251px;
height: 2px;
border-radius: 8px;
position: relative;
:deep(.van-slider__bar) {
background: #f68804;
}
:deep(.van-slider__button) {
box-shadow: 0 0 0 3px rgba(246, 136, 4, 0.7);
}
}
.icon {
width: 24px;
height: 24px;
}
.time {
flex-shrink: 0;
width: 50px;
color: #fff;
font-size: 9px;
margin-right: 0;
margin-left: 6px;
text-align: right;
&.current {
margin-right: 6px;
margin-left: 0;
text-align: left;
}
}
}
.time-box {
font-size: 12px;
font-weight: 500;
margin: 10px 0 18px;
}
.action {
flex-shrink: 0;
width: calc(100% - $margin * 2);
height: 58px;
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 auto;
.item {
// width: 68px;
display: flex;
flex-direction: column;
align-items: center;
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 150% */
}
.mode {
width: 29px;
height: 24px;
flex-shrink: 0;
color: #fff;
}
.prev,
.next {
width: 28px;
height: 28px;
&.next {
transform: rotate(180deg);
}
}
.play {
width: 28px;
height: 28px;
}
}
.split {
display: flex;
width: 363px;
height: 1px;
margin: 18px auto;
background: rgba(255, 255, 255, 0.02);
}
}
}
.play-list-box {
width: 375px;
height: 400px;
border-radius: 18px 18px 0px 0px;
background: #0f0f0f;
color: rgba(255, 255, 255, 0.9);
padding: 13px 16px 0;
@include SafeArea('padding', 'bottom', 18px);
display: flex;
flex-direction: column;
.line {
width: 36px;
height: 5px;
border-radius: 99px;
background: #989898;
margin: 0 auto 16px;
}
.title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: normal;
flex-shrink: 0;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: space-between;
.sort {
color: rgba(255, 255, 255, 0.55);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
.active {
color: #f68804;
}
}
}
.list {
flex: 1;
.item {
padding: 18px 0;
justify-content: space-between;
border-bottom: 1px solid #ffffff1a;
&.active {
color: #f68804;
}
.name {
font-size: 14px;
font-weight: 400;
line-height: 18px;
flex: 1;
@include textEllipsis();
}
.back {
width: 18px;
height: 18px;
flex-shrink: 1;
}
}
}
}
</style>
@@ -0,0 +1,411 @@
<template>
<div class="introduction-wrap overflow-y-auto">
<div class="titleBox ellipsis2">
{{ props.info.title }}
</div>
<div class="tags">
<span
class="tag"
@click="goAcgTagPage(item.id, item.name, 'video')"
v-for="item in info.tagDetails || []"
:key="item.id"
>
#{{ item.name }}
</span>
</div>
<div class="videoInfoBox flex-vcenter">
<div class="item margin-r-88">
<span>{{ watchCount(props.info.countBrowse) }} 观看量</span>
</div>
<div class="item" @click="onToogleLiked">
<SvgIcon v-if="props.info.mediaStatus.hasLiked" name="acg-like-1" />
<SvgIcon v-else name="acg-like-0" />
<span>{{ watchCount(props.info.countLike) || '点赞' }}</span>
</div>
<div class="item" @click="onToogleCollected">
<SvgIcon name="acg-collect-star-1" v-if="props.info.mediaStatus.hasCollected" style="color: #f52c56"></SvgIcon>
<SvgIcon name="acg-collect-star-0" v-else></SvgIcon>
<span>{{ watchCount(props.info.countCollect) || '收藏' }}</span>
</div>
<div class="item" @click="openShare">
<SvgIcon name="acg-share" />
<span>分享</span>
</div>
</div>
<div
class="actress-box flex-center-between"
v-if="props.info.actress && props.info.actress.id"
@click="
$router.push({
name: 'AcgActress',
query: {
id: props.info.actress.id,
},
})
"
>
<lazyImg class="portrait" :src="props.info.actress.portrait"></lazyImg>
<div class="info">
<p class="name">{{ props.info.actress.name }}</p>
<div class="count-box flex-vcenter">
<SvgIcon name="acg-video-icon-a-video" />
{{ watchCount(props.info.actress.acgCount) }}部
<SvgIcon name="acg-video-icon-a-collect" style="margin-left: 12px" />
{{ watchCount(props.info.actress.collectCount) }}
</div>
</div>
<SvgIcon class="icon" name="moreIcon" />
</div>
<!-- 选集区域 -->
<div class="episodeArea">
<div class="episodeHeader flex-center-between padding16">
<div class="episodeTitle">选集</div>
<div class="episodeAll flex-vcenter" @click="visibleCatalogue = true">
全{{ props.info.totalEpisode }}话
<SvgIcon class="icon" name="moreIcon" />
</div>
</div>
<div class="episodeBox" @touchstart.stop @touchmove.stop @touchend.stop>
<Episode
class="item"
@click="switchEpisodeNumberCatalogue(item)"
v-for="item in autoList"
:key="item.id"
:info="item"
:episode-number="state.episodeNumber"
></Episode>
</div>
</div>
<HomeHeader :advType="AdType.VIDEOADV" />
<!-- <RecommendList
:media-id="props.info.id"
:contentType="props.info.mediaType"
title="猜你喜欢"
:marginHide="true"
></RecommendList> -->
<RecommendedList :type="info.mediaType" :tagId="info.tagDetails?.[0]?.id" />
<!-- 分享弹窗 -->
<AppSharePopup
:is-open="visibleShare"
@close="visibleShare = false"
coverType="portrait"
:cover="props.info.verticalCover || props.info.horizontalCover"
/>
<Teleport to=".video-detail-wrap">
<van-popup v-model:show="visibleCatalogue" position="bottom" z-index="1001" :style="{ height: '438px' }">
<div class="play-list-box">
<div class="line"></div>
<div class="title flex-center-between">
剧集列表
<!-- <span @click="sort = !sort">
{{ sort ? '正序' : '倒序' }}
</span> -->
</div>
<div class="grading-group" v-if="gradingGroup.length > 1">
<div
class="grading-group-item"
v-for="(item, index) in gradingGroup"
:key="index"
@click.stop="state.gradingGroupIndex = index"
>
{{ item[0].episodeNumber }}-{{ item[item.length - 1].episodeNumber }}
</div>
</div>
<div class="list-box overflow-y-auto">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<div class="list">
<Episode
class="item"
:episodeNumber="state.episodeNumber"
:info="item"
v-for="item in gradingGroup[state.gradingGroupIndex]"
:key="item.id"
@click="state.episodeNumber = item.episodeNumber"
></Episode>
</div>
</PullRefresh>
</div>
</div>
</van-popup>
</Teleport>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed, watchEffect, Ref } from 'vue';
import { HistoryVideoType } from '@/enums/app';
import { goAcgTagPage } from '../../utils/common';
import { watchCount, videoTime } from '@/utils/index';
// import RecommendList from '../../components/recommendList.vue';
import { handleCollected, Collected_emit } from '../../utils/collect';
import { AdType } from '@/enums/app';
import mediaContentInfo from '../../utils/mediaContentInfo';
import CommunityApi from '@/service/community';
import { useGlobalStore } from '@/stores/global';
import Episode from '../../components/episode.vue';
const emits = defineEmits(['updateLiked', 'updateCollected', 'updateItem', 'openRoadLine']);
const globalStore = useGlobalStore();
const props = withDefaults(
defineProps<{
info: Types.Media.MediaInfo;
}>(),
{
info: {},
},
);
const {
state,
currentItem,
autoList,
// switchEpisodeNumberCatalogue,
visibleCatalogue,
sort,
moreParams,
onLoad,
onRefresh,
getList,
buySuccess,
replaceRouter,
} = mediaContentInfo({
isGetInfo: false,
isGetHistoryInit: true,
pageSize: 30,
dbType: HistoryVideoType.AcgVideo,
isScrollTop: false,
});
const gradingGroup = computed(() => {
let arr = autoList.value || [];
if (arr.length > 30) {
const result = [];
for (let i = 0; i < arr.length; i += 30) {
result.push(arr.slice(i, i + 30));
}
return result;
}
return [arr];
});
watch(
() => currentItem.value.id,
() => {
emits('updateItem', currentItem.value);
},
{ immediate: true },
);
defineExpose({
buySuccess,
});
const switchEpisodeNumberCatalogue = (item: Types.Acg.MediaContentListItem) => {
visibleCatalogue.value = false;
state.episodeNumber = item.episodeNumber;
replaceRouter(item.episodeNumber);
};
// 打开分享
const visibleShare = ref(false);
const openShare = () => {
const { id, verticalCover, horizontalCover } = props.info;
let obj = {
id,
cover: verticalCover || horizontalCover,
};
// globalStore.setShareInfo(obj);
visibleShare.value = true;
};
const onToogleCollected = () => {
try {
const {
id,
mediaStatus: { hasCollected },
} = props.info;
const tempIsCollect = !hasCollected;
handleCollected(id, tempIsCollect).then(() => {
emits('updateCollected', tempIsCollect);
Collected_emit(id, tempIsCollect);
});
} catch (e) {
console.log(e);
}
};
const onToogleLiked = () => {
const zan = !props.info.mediaStatus.hasLiked;
(zan ? CommunityApi.zan : CommunityApi.cancleZan)(
zan
? {
type: 'video',
objID: props.info.id,
}
: {
type: 'video',
objIDs: [props.info.id],
},
).then(() => {
emits('updateLiked', zan);
});
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.introduction-wrap {
height: 100%;
padding: 0 16px;
.titleBox {
font-size: 14px;
font-weight: 500;
line-height: 22px;
margin: 18px 0;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px 10px;
margin: 10px 0 14px;
.tag {
padding: 4px 8px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
.videoInfoBox {
color: #989898;
font-size: 12px;
margin: 10px 0 18px;
justify-content: space-between;
.item {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 24px;
height: 24px;
margin-right: 2px;
}
}
}
.actress-box {
height: 60px;
margin: 24px 0;
.portrait {
width: 60px;
height: 60px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
}
.info {
flex: 1;
.name {
color: #fff;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
margin-bottom: 5px;
}
.count-box {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
svg {
width: 16px;
height: 16px;
margin-right: 4px;
}
}
}
}
.episodeArea {
margin-bottom: 14px;
border-top: 0.5px solid rgba(255, 255, 255, 0.1);
border-bottom: 0.5px solid rgba(255, 255, 255, 0.1);
padding-top: 10px;
.episodeHeader {
font-weight: 500;
.episodeTitle {
font-size: 16px;
}
.episodeAll {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
svg {
width: 18px;
height: 18px;
}
}
}
.episodeBox {
display: flex;
overflow-x: auto;
margin: 12px 0;
.item {
margin-right: 12px;
flex-shrink: 0;
}
}
}
}
.play-list-box {
$paddingBottom: 38px;
width: 100%;
height: 100%;
border-radius: 20px 20px 0px 0px;
background: #0f0f0f;
padding: 18px 16px $paddingBottom;
@include SafeArea('padding', 'bottom', 0);
display: flex;
flex-direction: column;
color: $textColor;
font-size: 12px;
color: rgba(255, 255, 255, 0.9);
> .title {
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 25px; /* 140% */
margin: 16px 0;
span {
color: #e0e0e0;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
}
.line {
width: 36px;
height: 5px;
border-radius: 100px;
background: rgba(255, 255, 255, 0.2);
margin: 0 auto;
}
.list-box {
flex: 1;
.list {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr 1fr;
grid-column-gap: 5px;
grid-row-gap: 9px;
}
}
}
.margin-r-88 {
margin-right: 88px;
}
</style>
+468
View File
@@ -0,0 +1,468 @@
<template>
<div ref="rootRef" class="video-detail-wrap">
<SvgIcon name="back_white_round" class="back" @click="$router.go(-1)" />
<template v-if="state.info.id">
<div class="movieBox">
<div class="videoAdv" v-if="state.advsList?.length && state.isShowAdv">
<div class="advVideoBox">
<AdvSwiper :list="state.advsList" :autoplay="2000" v-if="state.advsList.length" class="adv" />
<div class="timer_close" v-if="isVip">
<div v-if="state.timer > 0">
{{ state.timer }}s |
<span @click="state.isShowAdv = false">关闭广告</span>
</div>
<div v-else><span @click="state.isShowAdv = false">关闭</span></div>
</div>
<div class="timer_close" v-else>
<div v-if="state.timer > 0">
{{ state.timer }}s |
<span @click="vipJump">VIP可关闭广告</span>
</div>
<div v-else><span @click="state.isShowAdv = false">关闭</span></div>
</div>
</div>
</div>
<template v-else>
<AnimationHorizontalVideoInfo
:videoInfo="currentItem"
:mediaInfo="state.info"
v-if="currentItem?.videoUrl && showVideo"
:key="currentItem?.episodeNumber"
@buySuccess="beforeBuySuccess"
/>
</template>
</div>
<CountdownBanner
@openMemberPay="showPaymentStatusPopup = true"
/>
<div class="changeLineBox flex-vcenter" :class="!isVip ? 'top258' : ''">
<div class="flex-vcenter" @click.stop="openChangeRoadLine">
<SvgIcon name="changeLineIcon" />
<span>线路切换</span>
</div>
</div>
<van-tabs
v-model:active="tabActive"
:animated="true"
@rendered="onTabRendered"
class="tabs"
:class="{ newUser: !isVip }"
>
<van-tab :title="item.label" v-for="item in itemList" :key="item.value" :name="prefixKey + item.value">
<template v-slot:title>
{{ item.label }}
<Teleport to=".van-tabs__nav">
<span v-if="item.value === 2" class="countComment" :class="{ active: tabActive === prefixKey + 2 }">
{{ watchCount(state.info.countComment) }}
</span>
</Teleport>
</template>
<!--
@selectEpisode="selectEpisode" -->
<Introduction
v-if="tabIsInit(prefixKey + item.value) && item.value == 1 && state.info.id"
ref="introductionRef"
:info="state.info"
@updateCollected="updateCollected"
@updateLiked="updateLiked"
@updateItem="updateItem"
></Introduction>
<CommentBox
v-else-if="tabIsInit(prefixKey + item.value) && item.value == 2 && state.info.id"
:id="prefixKey + item.value"
ref="dvComment"
:isOpen="true"
renderType="normal"
objType="cartoon"
theme="black"
class="comment-comp"
@addComment="state.info.countComment += 1"
:showCollected="false"
></CommentBox>
</van-tab>
</van-tabs>
</template>
<Loading v-else />
</div>
<!-- 切换路线框 -->
<SelectionLinePopup :is-open="showChangeRoadLine" @closeSelectLine="closeSelectLine" @close="closeChangeRoadLine" />
<!-- 开通会员 -->
<PaymentStatusPopup
:show="showPaymentStatusPopup"
:video-id="String(state.info?.id || '')"
@close="closePaymentStatusPopup"
/>
</template>
<script lang="ts">
export default {
name: 'AcgVideoDetail',
};
</script>
<script lang="ts" setup>
import { ref, onUnmounted, nextTick, provide, computed, toRaw, watch, Ref, onMounted, defineAsyncComponent } from 'vue';
import { HistoryVideoType, AdType } from '@/enums/app';
import mediaInfo from '../utils/mediaInfo';
import { useUserStore } from '@/stores/user';
import MineApi from '@/service/mine';
import { watchCount } from '@/utils/index';
// import { Pay_emit } from '../utils/pay-mitt';
import type { PayInfo } from '../utils/pay-mitt';
import Introduction from './components/introduction.vue';
import tabAction from '@/utils/tabAction';
import { useRoute, useRouter } from 'vue-router';
import { getAdItem } from '@/utils/getConfig';
import CountdownBanner from '@/components/CountdownBanner/index.vue';
const PaymentStatusPopup = defineAsyncComponent(() => import('@/components/paymentStatusPopup/index.vue'));
const route = useRoute();
const router = useRouter();
const currentItem = ref({}) as Ref<Types.Acg.MediaContentListItem>;
const itemList = [
{
label: '简介',
value: 1,
},
{
label: '评论',
value: 2,
},
];
const showPaymentStatusPopup = ref(false);
const closePaymentStatusPopup = () => {
showPaymentStatusPopup.value = false;
};
const prefixKey = 'acg_video_';
const { tabActive, tabIsInit, onTabRendered } = tabAction(true);
const { isVip } = useUserStore();
const showVideo = ref(true);
const { state, historyData, addHistory } = mediaInfo({ dbType: HistoryVideoType.AcgVideo });
provide('historyData', historyData);
const showChangeRoadLine = ref(false);
// ------------------- 广告相关逻辑完善 -------------------
const vipJump = () => {
router.push('/member');
};
// 获取广告
const getRandomAdv = () => {
state.advsList = <any>[];
let advs = getAdItem(AdType.NEW_FREE_VIDEO).filter((item: any) => item.watchTime > 0);
if (advs.length > 0) {
state.advsList = advs;
if (!(isVip || historyData.value?.mediaStatus?.hasPaid)) {
state.isShowAdv = true;
return;
}
}
state.isShowAdv = false;
};
// 倒计时
const countdown = () => {
let advs = getAdItem(AdType.NEW_FREE_VIDEO);
if (advs && advs.length) {
state.timer = getAdItem(AdType.NEW_FREE_VIDEO)[0].watchTime;
} else {
state.timer = 0;
}
if (state.countdowner) {
clearInterval(state.countdowner);
}
state.countdowner = setInterval(() => {
state.timer--;
if (state.timer <= 0) {
clearInterval(state.countdowner);
state.countdowner = null;
}
}, 1000);
};
onUnmounted(() => {
state.countdowner && clearInterval(state.countdowner);
});
// 只在动漫数据获取到后再处理广告
watch(
() => currentItem.value,
(val) => {
if (val) {
getRandomAdv();
if (state.advsList.length > 0) {
countdown();
}
}
},
{ immediate: true },
);
const openChangeRoadLine = () => {
showChangeRoadLine.value = true;
};
const closeChangeRoadLine = () => {
showChangeRoadLine.value = false;
};
// 关闭线路选择
const closeSelectLine = () => {
showVideo.value = false;
showChangeRoadLine.value = false;
nextTick(() => {
showVideo.value = true;
});
};
const dvComment = ref();
const initComment = () => {
dvComment.value[0].state.videoInfo = toRaw(state.info);
dvComment.value[0].getCommentList();
};
watch(
dvComment,
(el) => {
if (!el) return;
initComment();
},
{
once: true,
},
);
const updateCollected = (collected: boolean) => {
state.info.mediaStatus.hasCollected = collected;
state.info.countCollect = state.info.countCollect + (collected ? +1 : -1);
};
const updateLiked = (liked: boolean) => {
state.info.mediaStatus.hasLiked = liked;
state.info.countLike = state.info.countLike + (liked ? +1 : -1);
};
const updateItem = (item: any) => {
currentItem.value = item;
if (route.query.tabActive) {
tabActive.value = (prefixKey + route.query.tabActive) as string;
}
};
const introductionRef = ref();
const beforeBuySuccess = (obj: PayInfo) => {
if (obj.type === 'all') {
state.info.mediaStatus.hasPaid = true;
historyData.value.mediaStatus.hasPaid = true;
addHistory(Number(route.query.episodeNumber || 1));
}
introductionRef.value[0].buySuccess(obj.type);
};
</script>
<style lang="scss" scoped>
.video-detail-wrap {
height: 100%;
overflow: hidden;
.back {
width: 24px;
height: 24px;
flex-shrink: 0;
position: fixed;
top: 16px;
left: 16px;
z-index: 800;
}
.movieBox {
width: 375px;
height: 212px;
position: sticky;
top: 0;
left: 0;
//margin-left: -6px;
z-index: 2;
.videoAdv {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 99999;
width: 100%;
height: 212px;
background: rgba(0, 0, 0, 1);
.advVideoBox {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
.timer_close {
font-size: 12px;
position: absolute;
top: 10px;
right: 10px;
z-index: 10;
background: rgba(0, 0, 0, 0.4);
padding: 4px 12px;
border-radius: 40px;
color: white;
// span {
// color: #ff7f0f;
// }
}
.adv {
height: 211px;
width: 375px;
overflow: hidden;
:deep(.van-swipe-item) {
height: 211px !important;
width: 375px !important;
}
}
}
}
}
.newUserBg {
height: 46px;
background: url('@/assets/images/png/newUserBg.png') no-repeat;
background-size: 100% 100%;
font-size: 12px;
font-weight: 500;
padding-left: 98px;
padding-right: 9px;
color: #fff;
&.noNewUser {
// background-image: url('@/assets/images/png/newUserBg2.png');
background-image: url('@/assets/images/png/newUserBg.png');
}
.nowOpenBtn {
color: #000;
background: linear-gradient(270deg, #e3b284 0%, #feeede 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 10px;
display: flex;
align-items: center;
svg {
width: 14px;
height: 14px;
margin-left: 4px;
}
}
}
.changeLineBox {
position: absolute;
top: 212px;
right: 16px;
z-index: 2;
height: 44px;
width: 100px;
justify-content: flex-end;
color: #989898;
font-size: 12px;
font-style: normal;
font-weight: 400;
svg {
width: 16px;
height: 16px;
margin-right: 4px;
}
}
.countComment {
position: absolute;
top: 12px;
left: 112px;
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
&.active {
color: rgba(255, 255, 255, 0.9);
}
}
.tabs {
--van-tab-active-text-color: rgba(255, 255, 255, 0.9);
--van-tab-text-color: rgba(255, 255, 255, 0.45);
height: calc(100% - 269px + 11px);
:deep(> .van-tabs__wrap) {
background-color: unset;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
.van-tabs__nav {
background: unset;
.van-tab {
font-size: 14px;
padding: unset;
width: 60px;
flex: unset;
}
.van-tabs__line {
background-color: $themeColor;
height: 4px;
width: 16px;
border-radius: 2px 2px 0 0;
}
}
}
:deep(> .van-tabs__content) {
height: calc(100% - 44px);
overflow-x: hidden;
.van-tab__panel {
height: 100%;
max-width: 100%;
box-sizing: border-box;
}
}
&.newUser {
height: calc(100% - 269px + 11px);
}
}
.comment-comp {
height: 100%;
:deep(.comment-wrap) {
height: 100%;
.wrapper {
height: 100%;
}
.headerBox {
display: none;
}
.slider {
display: none;
}
.main {
background: transparent;
// border-top: 1px solid #e0e0e0;
height: 100%;
}
.bottomBox {
position: sticky;
left: 0;
bottom: 0;
.commentBtnBox {
.commentInput {
padding-left: 0;
}
}
}
}
}
}
.top258 {
top: 258px !important;
}
:deep(.adv_swiper) {
border-radius: unset !important;
.adv-container {
.adv-swiper-slide {
border-radius: unset !important;
.cover {
border-radius: unset !important;
}
}
}
}
</style>
@@ -0,0 +1,114 @@
<template>
<div :id="`acg_topic_sortType_${props.sortType}`" class="topic-list overflow-y-auto">
<template v-if="!state.loading">
<PullRefresh :params="moreParams" @onLoad="onLoad" @onRefresh="onRefresh">
<CategoryContent
class="content"
coverDirection="portrait"
:contentType="props.contentType"
:contentWrap="true"
:list="state.list"
></CategoryContent>
</PullRefresh>
</template>
<Loading v-else />
</div>
</template>
<script lang="ts" setup>
import { toRaw, reactive, watch, computed } from 'vue';
import CategoryContent from '../../components/categoryContent.vue';
import MediaApi from '@/service/media';
import { useRoute } from 'vue-router';
const route = useRoute();
const sId = route.query.id || '';
const kind = route.query.kind || '';
const props = withDefaults(
defineProps<{
sortType: number;
contentType: string;
}>(),
{
sortType: () => 3,
contentType: 'video',
},
);
const showType = computed(() => {
return Number(route.query.showType || '0');
});
const state = reactive({
loading: true,
list: <any>[],
});
const params = {
pageNumber: 1,
pageSize: 12,
};
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const getList = async (type = '') => {
try {
const TempfilterParams = Object.assign({}, params, { sId, sortType: props.sortType, kind });
let res: any = await MediaApi.getMediaTopic(TempfilterParams);
state.loading = false;
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.list || [];
if (params.pageNumber === 1) {
moreParams.isNoData = list.length ? false : true;
}
if (type === 'refresh') {
state.list = list;
} else {
state.list = [...state.list, ...list];
}
if (!res.hasNext) {
moreParams.finished = true;
}
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
}
};
getList();
const onLoad = () => {
moreParams.loading = true;
params.pageNumber++;
getList();
};
const onRefresh = () => {
params.pageNumber = 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList('refresh');
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixins.scss';
.topic-list {
height: calc(100vh - 44px * 2);
padding: 0 16px;
@include SafeArea('padding', 'bottom', 0);
.content {
border-bottom: 0;
margin-top: 0;
}
}
</style>
+75
View File
@@ -0,0 +1,75 @@
<!-- ACG大类列表,漫画、动漫、有声小说 -->
<template>
<div class="topic-wrap">
<Header :title="route.query.title" />
<van-tabs
v-model:active="tabActive"
title-active-color="#fff"
title-inactive-color="#747B80"
:animated="true"
@rendered="onTabRendered"
class="tabs"
>
<van-tab v-for="item in itemList" :key="item.key" :title="item.title" :name="prefixKey + item.key">
<List v-if="tabIsInit(prefixKey + item.key)" :sortType="item.key" :contentType="contentType"></List>
</van-tab>
</van-tabs>
</div>
</template>
<script lang="ts">
export default {
name: 'AcgTopicList',
};
</script>
<script lang="ts" setup>
import List from './components/list.vue';
import tabAction from '@/utils/tabAction';
import { useRoute } from 'vue-router';
const route = useRoute();
const contentType = route.query.type as string;
const itemList = [
{ title: '热门推荐', key: 3 },
{ title: '最新上架', key: 1 },
{ title: '最新热评', key: 4 },
];
const prefixKey = 'acg_topic_sortType_';
const { tabActive, tabIsInit, onTabRendered } = tabAction(true);
</script>
<style lang="scss" scoped>
.topic-wrap {
.tabs {
:deep(.van-tabs__wrap) {
border-radius: 46px;
margin: 0 auto;
//background: #030f18;
padding: 0 40px;
.van-tabs__nav {
background: unset;
.van-tab {
font-size: 14px;
&:not(:nth-last-child(2))::after {
content: '';
position: absolute;
width: 1px;
height: 12px;
right: 0px;
top: 50%;
transform: translateY(-50%);
background: rgba(255, 255, 255, 0.1);
}
}
// .van-tab:nth-of-type(3) {
// &::after {
// display: none;
// }
// }
.van-tabs__line {
display: none;
}
}
}
}
}
</style>
+59
View File
@@ -0,0 +1,59 @@
import MediaApi from '@/service/media';
import { showToast } from 'vant';
import { onMounted, onUnmounted, Ref } from 'vue';
import eventBus from '@/utils/eventBus';
import { AcgEventBusKey } from '@/enums/app';
/**
* @Description 收藏acg
* @param {string} objID
* @param {string} mediaType
* @param {boolean} isCollect
* @return {*}
*/
const handleCollected = async (objID: string, isCollect: boolean) => {
try {
return (isCollect ? MediaApi.postBookshelfAdd : MediaApi.postBookshelfDel)({
id: objID,
}).then(() => {
showToast(isCollect ? '成功收藏' : '成功取消');
});
} catch (e) {
console.log(e);
}
};
/**
* @Description eventBus监听
* @param {string} id
* @param {Ref} hasCollected
* @return {*}
*/
const Collected_on = (id: string, hasCollected: Ref<boolean>, callback?: any) => {
const update = (bool: any) => {
if (callback) {
callback(bool);
return;
}
hasCollected.value = bool;
};
onMounted(() => {
eventBus.on(AcgEventBusKey.Collected + id, update);
});
onUnmounted(() => {
eventBus.off(AcgEventBusKey.Collected + id, update);
});
};
/**
* @Description eventBus赋值
* @param {string} id
* @param {boolean} bool
* @return {*}
*/
const Collected_emit = (id: string, bool: boolean) => {
eventBus.emit(AcgEventBusKey.Collected + id, bool);
};
export { handleCollected, Collected_on, Collected_emit };
+129
View File
@@ -0,0 +1,129 @@
import router from '@/router';
const goTopicPage = (id: string, type: string | number, title: string, showType?: number) => {
if (typeof type === 'number') {
type = getMediaTypeKey(type);
}
router.push({ path: '/AcgTopicList', query: { id, type, title, showType } });
};
const goAcgTagPage = (id: string, tagName: string, newsType?: string) => {
router.push({ path: '/tagVideoList', query: { id, tagName, newsType } });
};
// kind的id对应字符串
enum MediaType {
video = 4,
image = 5,
text = 6,
}
type MediaTypeKey = keyof typeof MediaType;
type MediaTypeValue = { [key in MediaType]: number };
function getMediaTypeKey(value: number): string {
if (value === MediaType.video) {
return 'video';
} else if (value === MediaType.image) {
return 'image';
} else if (value === MediaType.text) {
return 'text';
// } else if (value === MediaType.theatre) {
// return 'theatre';
} else {
return '';
}
}
function getMediaTypeValue(value: string): MediaType | number {
const mediaTypeKeys = Object.keys(MediaType).filter((key) => isNaN(Number(key)));
const mediaTypeValues = mediaTypeKeys.map((key) => MediaType[key]);
const index = mediaTypeKeys.indexOf(value);
if (index !== -1) {
return mediaTypeValues[index];
}
return 0;
}
const goFilterPage = () => {
const route = router.currentRoute;
const { mediaType = 'video' } = route.value.query;
router.push({ path: '/AcgSearchFilter', query: { mediaType } });
};
//获取当前时间
const getNewDate = () => {
return new Date(new Date().getTime() + (window._diffTime || 0));
};
const rankTabList = [
{ title: '日榜', key: 1 },
{ title: '周榜', key: 2 },
{ title: '人气榜', key: 3 },
{ title: '热度榜', key: 4 },
{ title: '钻石榜', key: 5 },
{ title: '连载榜', key: 6 },
];
function getElementDistance(element: HTMLElement, parent: HTMLElement) {
const elementRect = element.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const distance = {
top: elementRect.top - parentRect.top,
right: parentRect.right - elementRect.right,
bottom: parentRect.bottom - elementRect.bottom,
left: elementRect.left - parentRect.left,
};
return distance;
}
const goDetailPage = (mediaType: MediaTypeKey, id: string, mediaSubType?: number) => {
let name = 'AcgImageDetail';
if (mediaType === 'text') {
name = 'AcgTextDetail';
} else if (mediaType === 'video') {
name = 'AcgVideoDetail';
}
router.push({
name,
query: {
id,
},
});
};
const freeSet = new Set();
const getUpdateStatusText = (updateStatus?: number) => {
if (updateStatus === 2) return '已完结';
return '连载中';
};
/** 按 id(无 id 则按 name)去重,保留首次出现顺序 */
const dedupeTagDetails = (tags?: Types.Media.TagDetailsItem[] | null): Types.Media.TagDetailsItem[] => {
if (!tags?.length) return [];
const seen = new Set<string>();
return tags.filter((item) => {
const key = String(item.name || '').trim();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
};
export {
goTopicPage,
goFilterPage,
getMediaTypeKey,
getMediaTypeValue,
getNewDate,
rankTabList,
getElementDistance,
goDetailPage,
freeSet,
getUpdateStatusText,
goAcgTagPage,
dedupeTagDetails,
};
export type { MediaTypeKey, MediaTypeValue };
+66
View File
@@ -0,0 +1,66 @@
import { ref, Ref } from 'vue';
import { getVideoHistoryByKey, addVideoHistory } from '@/utils/index';
import { HistoryVideoType } from '@/enums/app';
import eventBus from '@/utils/eventBus';
const AcgHistoryData = {} as {
[key: string]: {
[key: string]: Types.Media.MediaInfo;
};
};
const key = 'AcgHistoryData_';
const getData = async (id: string, dbType = HistoryVideoType.AcgImage, isParent: boolean) => {
if (!AcgHistoryData[dbType]) {
AcgHistoryData[dbType] = {};
}
let item = AcgHistoryData[dbType][id];
if (item && item.id) {
return item;
}
if (!item) {
AcgHistoryData[dbType][id] = { id: '' } as any;
item = (await getVideoHistoryByKey(id, dbType)) as any;
} else {
item = (await new Promise((resolve) => {
eventBus.on(key + id, (obj: Types.Media.MediaInfo) => {
eventBus.off(key + id);
resolve(obj);
});
})) as any;
}
if (item && item.id) {
AcgHistoryData[dbType][id] = item;
eventBus.emit(key + id, item);
}
return item;
};
export default (dbType = HistoryVideoType.AcgImage, isParent: boolean) => {
const historyEpisodeNumber = ref(0);
const historyData = ref({}) as Ref<Types.Media.MediaInfo>;
const getHistoryData = async (id: string) => {
const history = (await getData(id, dbType, isParent)) as any;
if (history) {
historyData.value = history;
if (history.historyEpisodeNumber) {
historyEpisodeNumber.value = history.historyEpisodeNumber;
}
} else {
historyEpisodeNumber.value = 0;
}
};
const setHistoryData = (data: any, dbType: HistoryVideoType) => {
const item = (AcgHistoryData[dbType] && AcgHistoryData[dbType][data.id]) || {};
const newItem = Object.assign(item, data);
AcgHistoryData[dbType][data.id] = newItem;
eventBus.emit(key + data.id, newItem);
addVideoHistory(newItem, dbType, new Date().getTime());
};
return { historyData, historyEpisodeNumber, getHistoryData, setHistoryData };
};
@@ -0,0 +1,542 @@
import { reactive, ref, ComputedRef, watchEffect, watch, nextTick, computed, onBeforeUnmount } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { HistoryVideoType } from '@/enums/app';
import indexDB from './indexDB';
import { showToast } from 'vant';
import AcgApi from '@/service/acg';
import useLoading from '@/hooks/loading';
import { useUserStore } from '@/stores/user';
import { freeSet } from './common';
interface ReadData {
[key: string]: any;
}
export default ({
mediaId = '',
isScrollTop = true,
isGetInfo = true,
isUpdate = true,
isNeedHistory = true,
isGetHistoryInit = false,
pageSize = 30,
dbType = HistoryVideoType.AcgImage,
}) => {
const userStore = useUserStore();
const { openLoading, closeLoading } = useLoading();
const route = useRoute();
const router = useRouter();
const { id, episodeNumber, title } = route.query;
const state = reactive({
list: [] as Types.Acg.MediaContentListItem[],
info: {} as Types.Acg.MediaContentInfo,
validateItem: {} as Types.Acg.MediaContentListItem,
episodeNumber: Number(episodeNumber || '1'),
id: (mediaId || id) as string,
title,
gradingGroupIndex: 0,
});
const { historyData, historyEpisodeNumber, getHistoryData, setHistoryData } = indexDB(dbType, true);
const addHistory = (num: number) => {
if (num === -1) {
num = state.episodeNumber;
}
const data = { ...historyData.value, historyEpisodeNumber: num, historyReadData };
setHistoryData(data, dbType);
};
const historyReadData = [] as Types.Acg.ReadData[];
const addHistoryReadData = (num: number, obj: ReadData, imgObj?: { url: string; width: number; height: number }) => {
const listItem = state.list.find((item) => item.episodeNumber === num);
if (!listItem) return;
const index = (historyReadData || []).findIndex((item) => item.episodeNumber === num);
const isHas = index > -1;
const imgs = isHas ? historyReadData[index].imgs || {} : {};
if (imgObj?.url) {
imgs[imgObj?.url] = {
width: imgObj?.width,
height: imgObj?.height,
};
}
let item = {};
if (isHas) {
item = historyReadData.splice(index, 1)[0];
}
const newItem = Object.assign({}, { ...item }, { ...listItem, ...obj, imgs });
historyReadData.push(newItem);
};
if (isUpdate) {
onBeforeUnmount(() => {
nextTick(() => {
addHistory(-1);
});
});
}
const currentItem = computed(() => {
if (state.list.length === 0) return {};
return state.list.find((item) => item.episodeNumber === state.episodeNumber) || {};
}) as ComputedRef<Types.Acg.MediaContentListItem>;
let initScrollLeft = true;
watch(currentItem, () => {
if (initScrollLeft) {
goSectionScrollLeft();
initScrollLeft = false;
}
if (['AcgTextDetail', 'AcgImageDetail'].includes((route.name as string) || '')) {
return;
}
validateHasBuy(currentItem.value);
});
watch(
() => state.episodeNumber,
() => {
addHistory(state.episodeNumber);
},
);
const getPageNumber = () => {
return Math.ceil(state.episodeNumber / 30);
};
const pageNumber = getPageNumber();
const params = {
prevPageNumber: pageNumber,
nextPageNumber: pageNumber,
pageSize,
};
const moreParams = reactive({
loading: true,
refreshing: false,
finished: false,
isNoData: false,
error: false,
isHigehtMax: false,
});
const moreParams_finished = {
prev: false,
next: false,
};
const getList = async (type: 'prev' | 'next', pageNumber?: number) => {
try {
const tempParams = {
mediaId: state.id,
pageSize: params.pageSize,
pageNumber: pageNumber ? pageNumber : type === 'prev' ? params.prevPageNumber : params.nextPageNumber,
};
let res: any = await AcgApi.getMediaContentList(tempParams);
if ((res.list || []).length === 0 && state.list.length === 0 && tempParams.pageNumber !== 1) {
params.prevPageNumber = 1;
params.nextPageNumber = 1;
tempParams.pageNumber = 1;
res = await AcgApi.getMediaContentList(tempParams);
}
historyData.value.currentEpisode = res.total;
moreParams.loading = false;
moreParams.refreshing = false;
let list = res.list || [];
if (list.length > 0) {
const reList = (list || []).reduce(
(acc: Types.Acg.MediaContentListItem[], current: Types.Acg.MediaContentListItem) => {
const x = acc.find((item) => item.episodeNumber === current.episodeNumber);
if (!x) {
return acc.concat([current]);
} else {
return acc;
}
},
[],
);
if (isInitGetList && res.hasNext && list.length !== reList.length) {
list = reList;
onLoad();
}
}
isInitGetList = false;
if (state.list.length === 0) {
state.list = list;
} else {
state.list = state.list.concat(list);
}
if (tempParams.pageNumber == 1 && state.list.length == 0) {
moreParams.isNoData = true;
}
if (!res.hasNext) {
moreParams_finished[type] = true;
moreParams.finished = type === 'next' && sort.value;
}
return list;
} catch (e) {
moreParams.loading = false;
moreParams.error = true;
return [];
}
};
const onLoad = () => {
moreParams.loading = true;
if (sort.value) {
params.nextPageNumber += 1;
getList('next');
} else {
if (params.prevPageNumber === 1) {
moreParams.loading = false;
return;
}
params.prevPageNumber -= 1;
getList('prev');
}
};
const onRefresh = () => {
if (sort.value) {
if (params.prevPageNumber == 1) {
moreParams.refreshing = false;
return;
}
params.prevPageNumber -= 1;
moreParams.refreshing = true;
moreParams.finished = false;
getList('prev');
} else {
if (moreParams_finished['next']) {
moreParams.refreshing = false;
return;
}
params.nextPageNumber += 1;
moreParams.refreshing = true;
getList('next');
}
};
let isInitGetList = true;
const init = () => {
getList('next');
};
if (isNeedHistory) {
getHistoryData(state.id).then(() => {
historyReadData.push(...JSON.parse(JSON.stringify(historyData.value.historyReadData || '[]')));
if (isGetHistoryInit) {
if (!episodeNumber) {
state.episodeNumber = historyData.value.historyEpisodeNumber || state.episodeNumber;
}
const pageNumber = getPageNumber();
params.prevPageNumber = pageNumber;
params.nextPageNumber = pageNumber;
}
init();
});
} else {
init();
}
const getContent = async () => {
if (state.info.id) {
openLoading();
}
await AcgApi.getMediaContentInfo({ id: currentItem.value.id }).then((res) => {
state.info = res;
});
closeLoading();
};
if (isGetInfo) {
watch(
() => currentItem.value.id,
(newId, oldId) => {
if (!newId || newId === oldId) return;
getContent();
},
);
}
const statusText = computed(() => {
switch (historyData.value.updateStatus) {
case 1:
return '连载中';
case 2:
return '已完结';
default:
return '已完结';
}
});
//目录
const visibleCatalogue = ref(false);
const sort = ref(true);
const autoList = computed(() => {
return (state.list || []).sort(
sort.value ? (a, b) => a.episodeNumber - b.episodeNumber : (a, b) => b.episodeNumber - a.episodeNumber,
);
});
const scrollToElement = (elementId: string) => {
const element = document.getElementById(elementId) as HTMLElement;
if (!element) return;
const parent = element.closest('.list') as HTMLElement;
const parentRect = parent.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const offsetTop = elementRect.top - parentRect.top;
const offsetBottom = elementRect.bottom - parentRect.bottom;
if (offsetTop < 0 || offsetBottom > 0) {
// parent.scrollTop = element.offsetTop - (parent.offsetHeight - element.offsetHeight) / 2;
parent.scrollTop = element.offsetTop - parent.offsetHeight / 2;
}
};
const goSectionScrollTop = () => {
if (!isScrollTop) return;
nextTick(() => {
scrollToElement(`section_${state.episodeNumber}`);
});
};
let isInit = false;
watchEffect(() => {
if (!visibleCatalogue.value) return;
if (isInit) {
return;
}
isInit = true;
goSectionScrollTop();
});
watch(
() => sort.value,
() => {
moreParams.finished = moreParams_finished[sort.value ? 'next' : 'prev'];
goSectionScrollTop();
},
);
//支付
const dvPayForWatch = ref();
const visiblePayForWatch = ref(false);
const validatePermission = () => {
// 购买优先
if (historyData.value.id && historyData.value.mediaStatus && historyData.value.mediaStatus.hasPaid) return true;
// 会员
if (historyData.value.permission === 0 && userStore.isVip) return true;
// 付费
if (historyData.value.permission === 1) {
if (!historyData.value.price) {
return true;
}
if (userStore.userInfo.vipLevel >= 2) {
return true;
}
}
// 免费 || 前几集免费
if (historyData.value.permission === 2 || historyData.value.freeEpisode >= state.validateItem.episodeNumber)
return true;
return false;
};
const validateHasBuy = (item: Types.Acg.MediaContentListItem) => {
state.validateItem = item;
if (!validatePermission()) {
visiblePayForWatch.value = true;
// showToast('付费章节,购买后才能观看喔');
return true;
}
visiblePayForWatch.value = false;
return false;
};
const buySuccess = (type = 'one') => {
state.info.hasBuy = true;
if (state.validateItem.id) {
state.validateItem.hasBuy = true;
state.episodeNumber = state.validateItem.episodeNumber;
}
visiblePayForWatch.value = false;
visibleCatalogue.value = false;
if (type === 'all') {
state.list.forEach((item) => {
item.hasBuy = true;
});
}
};
// watch(
// dvPayForWatch,
// (el) => {
// debugger;
// if (!el) return;
// validateHasBuy(currentItem.value);
// },
// // {
// // once: true,
// // },
// );
const closePayForWatch = () => {
if (validatePermission()) {
visiblePayForWatch.value = false;
return;
}
router.back();
};
const loadEpisodeList = async (num: number, type: 'prev' | 'next') => {
const item = state.list.find((item) => item.episodeNumber === num);
if (item) return item;
openLoading();
await getList(type);
closeLoading();
return state.list.find((item) => item.episodeNumber === num);
};
const replaceRouter = (num: number) => {
router.replace({
path: route.path,
query: {
...route.query,
episodeNumber: num,
},
});
};
const switchEpisodeNumber = async (num: number, isValidate = true) => {
if (num === -1) {
if (state.episodeNumber !== 1) {
const prevNum = state.episodeNumber - 1;
const item = await loadEpisodeList(prevNum, 'prev');
if (isValidate) {
if (validateHasBuy(item as Types.Acg.MediaContentListItem)) {
return false;
}
}
state.episodeNumber = prevNum;
if (dbType === HistoryVideoType.AcgText) {
addHistoryReadData(prevNum, { progress: 0, scrollTop: 0 });
addHistory(prevNum);
}
replaceRouter(prevNum);
return true;
} else {
showToast('这是第一章了喔~');
return false;
}
} else {
if (state.episodeNumber < historyData.value.currentEpisode) {
const nextNum = state.episodeNumber + 1;
const item = await loadEpisodeList(nextNum, 'next');
if (isValidate) {
if (validateHasBuy(item as Types.Acg.MediaContentListItem)) {
return false;
}
}
state.episodeNumber = nextNum;
if (dbType === HistoryVideoType.AcgText) {
addHistoryReadData(nextNum, { progress: 0, scrollTop: 0 });
addHistory(nextNum);
}
replaceRouter(nextNum);
return true;
} else {
showToast('已经到最后一章了喔~');
return false;
}
}
};
const switchEpisodeNumberCatalogue = (item: Types.Acg.MediaContentListItem) => {
if (validateHasBuy(item)) return;
visibleCatalogue.value = false;
if (dbType === HistoryVideoType.AcgText) {
addHistoryReadData(item.episodeNumber, { progress: 0, scrollTop: 0 });
addHistory(item.episodeNumber);
}
state.episodeNumber = item.episodeNumber;
replaceRouter(item.episodeNumber);
};
const scrollToElement2 = (elementId: string) => {
const element = document.getElementById(elementId) as HTMLElement;
if (!element) return;
const parent = element.closest('.episode-box') as HTMLElement;
const parentRect = parent.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const offsetLeft = elementRect.left - parentRect.left;
const offsetRight = elementRect.right - parentRect.right;
if (offsetLeft < 0 || offsetRight > 0) {
parent.scrollLeft = element.offsetLeft - parent.offsetWidth / 2;
}
};
const goSectionScrollLeft = () => {
const item = state.list.find((item) => item.episodeNumber === state.episodeNumber);
if (!item) return;
nextTick(() => {
scrollToElement2(`episode-${item.id}`);
});
};
const handlePopupSwitchEpisode = (num: number) => {
state.episodeNumber = num;
visibleCatalogue.value = false;
goSectionScrollLeft();
};
return {
state,
currentItem,
statusText,
moreParams,
onLoad,
onRefresh,
getList,
visibleCatalogue,
handlePopupSwitchEpisode,
sort,
autoList,
dvPayForWatch,
visiblePayForWatch,
validateHasBuy,
buySuccess,
closePayForWatch,
switchEpisodeNumber,
historyData,
historyEpisodeNumber,
addHistory,
addHistoryReadData,
switchEpisodeNumberCatalogue,
validatePermission,
goSectionScrollTop,
replaceRouter,
};
};
+80
View File
@@ -0,0 +1,80 @@
import { reactive, computed, onActivated, onUnmounted, provide } from 'vue';
import { useRoute } from 'vue-router';
import MediaApi from '@/service/media';
import { HistoryVideoType } from '@/enums/app';
import indexDB from './indexDB';
import { dedupeTagDetails } from './common';
// import { showToast } from 'vant';
export default ({ id = '', dbType = HistoryVideoType.AcgImage }) => {
const route = useRoute();
const madieId = id || (route.query.id as string);
//内容
const state = reactive({
info: {} as Types.Media.MediaInfo,
episodeNumber: Number(route.query.episode) || 1,
advsList: <any>[],
timer: 0,
isShowAdv: true,
countdowner: <any>null,
});
const { historyData, historyEpisodeNumber, getHistoryData, setHistoryData } = indexDB(dbType, true);
provide('historyData', historyData);
const addHistory = (num = 0) => {
const historyReadData = historyData.value.historyReadData || [];
const obj = { ...historyData.value, ...state.info, historyReadData };
if (num > 0) {
obj.historyEpisodeNumber = num;
}
setHistoryData(obj, dbType);
};
onActivated(() => {
if (!state.info.id) return;
setTimeout(() => {
getHistoryData(madieId);
}, 500);
});
onUnmounted(() => {
getHistoryData(madieId).then(() => {
addHistory();
});
});
const getData = async () => {
await getHistoryData(madieId);
const res = await MediaApi.getMediaInfo({ id: madieId });
const info = res as Types.Media.MediaInfo;
if (info.tagDetails?.length) {
info.tagDetails = dedupeTagDetails(info.tagDetails);
}
state.info = info;
addHistory();
if (!historyData.value.id) {
historyData.value = state.info;
}
};
getData();
const statusText = computed(() => {
switch (state.info.updateStatus) {
case 1:
return '连载中';
case 2:
return '已完结';
default:
return '已完结';
}
});
return {
state,
historyData,
addHistory,
historyEpisodeNumber,
statusText,
};
};
+63
View File
@@ -0,0 +1,63 @@
import { onMounted, onUnmounted, Ref } from 'vue';
import eventBus from '@/utils/eventBus';
import { AcgEventBusKey } from '@/enums/app';
interface PayInfo {
type: 'one' | 'all';
productID: string;
isFree: boolean;
}
const Pay_parent_on = ({
id,
state,
historyData,
addHistory,
}: {
id: string;
state: {
info: Types.Media.MediaInfo;
};
historyData: Ref<Types.Media.MediaInfo>;
addHistory: any;
}) => {
const update = () => {
state.info.mediaStatus.hasPaid = true;
historyData.value.mediaStatus.hasPaid = true;
addHistory();
};
onMounted(() => {
eventBus.on(AcgEventBusKey.HasPaid + id, update as any);
});
onUnmounted(() => {
eventBus.off(AcgEventBusKey.HasPaid + id, update as any);
});
};
const Pay_child_on = (parentId: string, state: any) => {
const update = (id: any) => {
const item = (state.list as Types.Acg.MediaContentListItem[]).find((item) => item.id === id);
if (item) {
item.hasBuy = true;
}
};
onMounted(() => {
eventBus.on(AcgEventBusKey.HasBuy + parentId, update);
});
onUnmounted(() => {
eventBus.off(AcgEventBusKey.HasBuy + parentId, update);
});
};
const Pay_emit = (id: string, obj: PayInfo) => {
if (obj.type === 'one') {
eventBus.emit(AcgEventBusKey.HasBuy + id, obj.productID);
} else {
eventBus.emit(AcgEventBusKey.HasPaid + id);
}
};
export { Pay_parent_on, Pay_child_on, Pay_emit };
export type { PayInfo };
+242
View File
@@ -0,0 +1,242 @@
interface Rules {
toBottom: number;
toTop: number;
toLeft: number;
toRight: number;
isClickCallback?: boolean;
}
export interface Position {
x: number;
y: number;
time: number;
}
export interface Result {
toTop: boolean;
toBottom: boolean;
toLeft: boolean;
toRight: boolean;
}
class TouchSlide {
element;
startCallback: ((event: TouchEvent) => void) | undefined;
moveCallback: ((event: TouchEvent) => void) | undefined;
endCallback: ((event: TouchEvent) => void) | undefined;
isTouch;
isTouchMove;
event: TouchEvent | undefined;
rules = {
toBottom: 0,
toTop: 0,
toLeft: 0,
toRight: 0,
isClickCallback: false,
} as Rules;
scrollDirection: number | undefined;
startPos = {
x: 0,
y: 0,
time: 0,
} as Position;
movePos = [] as Position[];
endPos = {
x: 0,
y: 0,
time: 0,
} as Position;
isInit = false;
scrollDirectionSibling = 0;
result = {
toTop: false,
toBottom: false,
toLeft: false,
toRight: false,
};
eventType = 'click';
constructor({
element,
rules,
startCallback,
moveCallback,
endCallback,
}: {
element: HTMLElement;
rules?: Rules;
startCallback?: (event: TouchEvent) => void;
moveCallback?: (event: TouchEvent) => void;
endCallback?: (event: TouchEvent) => void;
startPos?: Position;
movePos?: Position[];
endPos?: Position;
scrollDirection?: number;
scrollDirectionSibling?: number;
result?: Result;
eventType?: 'click' | 'slide';
}) {
this.element = element;
this.startCallback = startCallback;
this.moveCallback = moveCallback;
this.endCallback = endCallback;
this.isTouch = false;
this.isTouchMove = false;
this.initRules(rules || {});
const options = {
capture: false,
passive: false,
};
this.element.addEventListener('touchstart', this.eventStart.bind(this), options);
this.element.addEventListener('touchmove', this.eventMove.bind(this), options);
this.element.addEventListener('touchend', this.eventEnd.bind(this), options);
}
// 初始化规则,如果没有传参,则返默认.5
initRules({ toBottom = 0, toTop = 0, toLeft = 0, toRight = 0 }) {
if (!toRight && !toLeft && !toTop && !toBottom) {
toRight = toLeft = toTop = toBottom = 0.3;
}
this.rules = {
toBottom,
toTop,
toLeft,
toRight,
};
}
isPassRules() {
if (this.scrollDirection && (this.rules.toBottom || this.rules.toTop)) {
return true;
} else if (!this.scrollDirection && (this.rules.toRight || this.rules.toLeft)) {
return true;
} else {
return false;
}
}
getTargetTouche(event: TouchEvent) {
return event.targetTouches[0];
}
eventStart(event: TouchEvent) {
this.event = event;
const touch = this.getTargetTouche(event);
this.startPos = {
x: touch.screenX,
y: touch.screenY,
time: +new Date(),
};
this.movePos = [];
this.endPos = {
x: 0,
y: 0,
time: 0,
};
this.scrollDirection = 0;
this.isInit = false;
this.isTouch = true;
this.isTouchMove = false;
if (this.startCallback) {
this.startCallback(event);
}
}
eventMove(event: TouchEvent) {
this.event = event;
this.isTouchMove = true;
if (!this.isTouch) {
return;
}
if (event.targetTouches.length > 1 || (event.scale && event.scale !== 1)) {
return;
}
const touch = this.getTargetTouche(event);
this.endPos = {
x: touch.screenX - this.startPos.x,
y: touch.screenY - this.startPos.y,
time: +new Date(),
};
this.movePos.push(JSON.parse(JSON.stringify(this.endPos)));
if (!this.isInit) {
this.isInit = true;
this.scrollDirection = Math.abs(this.endPos.x) < Math.abs(this.endPos.y) ? 1 : 0;
}
this.scrollDirectionSibling = this.scrollDirection ? (this.endPos.y < 0 ? 1 : -1) : this.endPos.x < 0 ? 1 : -1;
if (this.moveCallback && this.isPassRules()) {
this.moveCallback(event);
}
}
eventEnd(event: TouchEvent) {
this.isTouch = false;
if (!this.isPassRules() || !this.isTouchMove) {
if (!this.isTouchMove) {
this.eventType = 'click';
} else {
this.eventType = 'slide';
}
if (this.rules.isClickCallback && this.endCallback) {
this.endCallback(event);
}
return;
}
this.eventType = 'slide';
const movePos = this.movePos,
movePosLen = movePos.length,
endMovePos = movePos[movePosLen - 1],
end2Second = movePos[movePosLen - 2];
let [toTop, toBottom, toLeft, toRight] = [false, false, false, false];
if (endMovePos) {
const rules = this.rules;
const speed = end2Second
? Math.abs(this.scrollDirection ? endMovePos.y - end2Second.y : endMovePos.x - end2Second.x) /
(endMovePos.time - end2Second.time)
: 0,
slideSpeed = 2;
const { innerHeight, innerWidth } = window;
if (this.scrollDirection) {
if (this.scrollDirectionSibling >= 0) {
toTop = speed > slideSpeed || rules['toTop'] * innerHeight <= Math.abs(endMovePos.y);
} else {
toBottom = speed > slideSpeed || rules['toBottom'] * innerHeight <= Math.abs(endMovePos.y);
}
} else {
if (this.scrollDirectionSibling <= 0) {
toRight = speed > slideSpeed || rules['toRight'] * innerWidth <= Math.abs(endMovePos.x);
} else {
toLeft = speed > slideSpeed || rules['toLeft'] * innerWidth <= Math.abs(endMovePos.x);
}
}
}
this.result = {
toTop,
toBottom,
toLeft,
toRight,
};
if (this.endCallback) {
this.endCallback(event);
}
}
}
export default TouchSlide;