[Human] chore(project): 初始化导入 91dj H5 客户端项目代码

从本地工程首次入库;.npmrc(含私有 npm 源 token)与 tsbuildinfo 已加入 .gitignore,不随仓库提交。
This commit is contained in:
谢宇宁
2026-10-08 14:22:35 +07:00
commit eada71e211
1421 changed files with 144498 additions and 0 deletions
@@ -0,0 +1,170 @@
<template>
<div
v-if="showing"
class="ai-drama-float-bubble"
:style="{ '--tab-count': String(tabCount) }"
>
<button
type="button"
class="ai-drama-float-bubble__hit"
aria-label="AI爽剧来袭"
@click.stop="onClick"
>
<img
class="ai-drama-float-bubble__img"
src="@/assets/images/png/short-drama/ai-drama-float-bubble.png"
alt="AI爽剧来袭!"
width="131"
height="34"
/>
</button>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useConfigStore } from '@/stores/config';
import { unlockShortVideoAudio } from '@/utils/shortVideoAudioUnlock';
/** 终身只展示 1 次(含老用户更新后的首次启动) */
const STORAGE_KEY = 'ai_drama_float_bubble_once_v1';
const props = defineProps<{
/** 底部 Tab 数量(含暗网时为 6,否则 5) */
tabCount?: number;
/** 当前是否已在短剧 Tab */
activeOnShortVideo?: boolean;
}>();
const route = useRoute();
const router = useRouter();
const configStore = useConfigStore();
const tabCount = computed(() => Math.max(5, Number(props.tabCount) || 6));
const isOnShortVideo = () =>
Boolean(props.activeOnShortVideo) || route.name === 'shortVideo';
const readOnceFlag = () => {
try {
return localStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
};
const writeOnceFlag = () => {
try {
localStorage.setItem(STORAGE_KEY, '1');
} catch {
/* ignore */
}
};
/** 本次是否正在展示(仅首次启动且非短剧 Tab) */
const showing = ref(false);
onMounted(() => {
// 已消耗过首次机会 → 永不出现
if (readOnceFlag()) {
showing.value = false;
return;
}
// Ping 运营全局关闭:不展示,也不消耗本地 once(日后重新开启仍可出现一次)
if (configStore.appConfig?.entryPopupEnabled === false) {
showing.value = false;
return;
}
// 消耗首次机会(老用户更新后第一次进 App 也算)
writeOnceFlag();
// 启动时默认就在短剧 Tab → 不出现
if (isOnShortVideo()) {
showing.value = false;
return;
}
showing.value = true;
});
// 进入短剧 Tab 后立刻消失
watch(
() => [props.activeOnShortVideo, route.name] as const,
() => {
if (isOnShortVideo()) {
showing.value = false;
}
},
);
const onClick = () => {
unlockShortVideoAudio();
showing.value = false;
writeOnceFlag();
void router.replace({ path: '/shortVideo', query: { type: '4' } });
};
</script>
<style scoped lang="scss">
/*
* 与 TabBar 同坐标系:fixed + max-width:780 居中。
* 原先 absolute 相对 tab_wrapper,和 fixed 底栏/安全区错位,真机易乱。
* Figma 23190:99055:三角 Vector x=46 w=12,尖端约在气泡左侧 52px,对准第 2 个 Tab「短剧」中心。
*/
.ai-drama-float-bubble {
--tab-count: 6;
/* 气泡左缘 → 三角尖端(46 + 12/2) */
--arrow-offset: 6px;
position: fixed;
left: 0;
right: 0;
margin: 0 auto;
width: 100%;
max-width: 780px;
/* 与 :deep(.van-tabbar) height:55px + safe-area 对齐,再上抬 6px */
bottom: calc(55px + constant(safe-area-inset-bottom) + 6px);
bottom: calc(55px + env(safe-area-inset-bottom, 0px) + 6px);
z-index: 1002;
height: 34px;
pointer-events: none;
&__hit {
position: absolute;
/* 第 2 个 Tab 中心:兼容部分 WebView 对 calc 除法顺序的限制 */
left: calc(100% * 1.5 / var(--tab-count));
bottom: 0;
width: 50px;
height: 34px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
transform: translateX(calc(-1 * var(--arrow-offset)));
animation: ai-drama-bubble-bob 1.8s ease-in-out infinite;
}
&__img {
display: block;
width: 50px;
height: 50px;
object-fit: contain;
pointer-events: none;
user-select: none;
}
}
@keyframes ai-drama-bubble-bob {
0%,
100% {
transform: translateX(calc(-1 * var(--arrow-offset))) translateY(0);
}
50% {
transform: translateX(calc(-1 * var(--arrow-offset))) translateY(-3px);
}
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div class="tab_wrapper">
<AiDramaFloatBubble :tab-count="tabCount" :active-on-short-video="activeName === 'shortVideo'" />
<van-tabbar
v-model="activeName"
route
fixed
placeholder
:border="false"
:safe-area-inset-bottom="true"
@change="onChange"
data-ignore-track
>
<!-- :icon="activeName == 'home' ? homeIconActived : homeIcon" -->
<van-tabbar-item to="/home" name="home" replace>
<span class="tabbar-item-text" v-if="isHomeTop" @click="backTop('home')">顶部</span>
<span v-else class="tabbar-item-text">首页</span>
<template #icon="props">
<div
class="tabbar-item"
@click="backTop('home')"
:class="[props.active ? (isHomeTop ? 'top' : 'homeActive') : 'home']"
></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/shortVideo" name="shortVideo" replace>
<span class="tabbar-item-text">短剧</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'liveActive' : 'live']"></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/darkNet" name="darkNet" replace v-if="darkWebEnable">
<span class="tabbar-item-text" v-if="isDarkNetTop" @click="backTop('darkNet')">顶部</span>
<span v-else class="tabbar-item-text">
<template v-if="darkWebIconName">
{{ darkWebIconName }}
</template>
<template v-else>暗网</template>
</span>
<template #icon="props">
<template v-if="darkWebIcon">
<lazyImg :src="darkWebIcon" alt="darkWebIcon" v-if="props.active && !isDarkNetTop" />
<div
v-else
class="tabbar-item"
@click="backTop('darkNet')"
:class="[props.active ? (isDarkNetTop ? 'top' : 'darkNetActive') : 'darkNet']"
></div>
</template>
<template v-else>
<div
class="tabbar-item"
@click="backTop('darkNet')"
:class="[props.active ? (isDarkNetTop ? 'top' : 'darkNetActive') : 'darkNet']"
></div>
</template>
</template>
</van-tabbar-item>
<van-tabbar-item to="/aiUndress2" name="aiUndress2" replace>
<span class="tabbar-item-text">AI</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'aiActive' : 'ai']"></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/community" name="community" replace>
<span class="tabbar-item-text" v-if="isCommunityTop" @click="backTop('community')">顶部</span>
<span v-else class="tabbar-item-text">社区</span>
<template #icon="props">
<div
class="tabbar-item"
@click="backTop('community')"
:class="[props.active ? (isCommunityTop ? 'top' : 'communityActive') : 'community']"
></div>
</template>
</van-tabbar-item>
<van-tabbar-item to="/mine" name="mine" replace>
<span class="tabbar-item-text">我的</span>
<template #icon="props">
<div class="tabbar-item" :class="[props.active ? 'mineActive' : 'mine']"></div>
</template>
<div v-if="showRedDot" class="newsTip"></div>
</van-tabbar-item>
</van-tabbar>
<!-- warning提示 -->
<van-popup class="waringBox" v-model:show="state.showWarning" :close-on-click-overlay="false">
<div class="waringBoxTip">
<div class="title">WANRNING</div>
<div class="content">该板块内容极度危险,心理承受弱者请自行离开,切记挑战自己好奇害死猫,请您确认还要继续吗?</div>
<div class="btnBox flex-center-between">
<div class="btn" @click.stop="leave">离开</div>
<div class="btn red" @click="state.showWarning = false">确认</div>
</div>
</div>
</van-popup>
</div>
</template>
<script lang="ts">
export default {
name: 'TabBar',
};
</script>
<script lang="ts" setup>
import { ref, toRefs, watch, reactive, onActivated, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import UserApi from '@/service/user';
import { useUserStore } from '@/stores/user';
import { useConfigStore } from '@/stores/config';
import { useGlobalStore } from '@/stores/global';
import { useImAdStore } from '@/stores/imAd';
import { imTotalUnread } from '@/utils/im/sdk';
import { analyticsEventNavigation } from '@/utils/eventReporter/analytics';
import setTheme from '@/assets/theme/index';
import { unlockShortVideoAudio } from '@/utils/shortVideoAudioUnlock';
import AiDramaFloatBubble from './AiDramaFloatBubble.vue';
const configStore = useConfigStore();
const globalStore = useGlobalStore();
setTheme(configStore.appConfig.festivalUi);
const darkWebEnable = computed(() => {
return configStore.appConfig.darkWebEnable;
});
const darkWebIcon = computed(() => {
return configStore.appConfig.darkWebIcon;
});
const darkWebIconName = computed(() => {
return configStore.appConfig.darkWebIconName;
});
/** 底部 Tab 数量:暗网开启时 6,否则 5(用于悬浮气泡对准「短剧」) */
const tabCount = computed(() => (darkWebEnable.value ? 6 : 5));
const router = useRouter();
const route = useRoute();
const activeName = ref('home');
const state = reactive({
showWarning: false,
userInfo: <Types.User.UserInfoType>{},
});
const refreshRoute = (name: any) => {
activeName.value = name;
};
refreshRoute(route.name);
watch(route, (newVal) => {
refreshRoute(newVal.name);
});
/**
* 获取用户信息
* @param uid
*/
const getInfo = async () => {
try {
let res: any = await UserApi.queryUserInfo(null);
state.userInfo = res;
} catch (e) {
console.log(e);
}
};
getInfo();
const change = (activeName: any) => {
if (activeName == 'darkNet') {
if (state.userInfo.isVip) {
state.showWarning = true;
}
}
};
const leave = () => {
activeName.value = 'home';
router.replace('/home');
state.showWarning = false;
};
const UserStore = useUserStore();
onActivated(() => {
refreshRoute(route.name);
UserStore.getCheckMessageTip();
});
const onChange = (name: string) => {
UserStore.getCheckMessageTip();
if (name == 'home') {
analyticsEventNavigation('Home', '首页');
} else if (name == 'shortVideo') {
// 底部进短视频页:解锁会话有声,抖音/短剧后续竖滑可带声
unlockShortVideoAudio();
analyticsEventNavigation('ShortVideo', '抖音');
} else if (name == 'darkNet') {
analyticsEventNavigation('DarkWeb', '暗网');
} else if (name == 'aiUndress2') {
analyticsEventNavigation('AI', 'AI');
} else if (name == 'community') {
analyticsEventNavigation('Community', '社区');
} else if (name == 'mine') {
analyticsEventNavigation('Mine', '我的');
}
};
const { isNewsTip } = toRefs(UserStore);
const imAdStore = useImAdStore();
const { listHasUnclicked } = storeToRefs(imAdStore);
/** 我的页红点:业务未读 / IM 私信未读 / IM 列表广告未点击 */
const showRedDot = computed(() => {
return Boolean(isNewsTip.value) || Number(imTotalUnread.value) > 0 || listHasUnclicked.value;
});
const backTop = (path: string) => {
let dom: any = null;
if (path == 'home') {
if (!isHomeTop.value) return;
dom = document.getElementById(path + globalStore.activeIndex);
} else if (path == 'darkNet') {
if (!isDarkNetTop.value) return;
dom = document.getElementById(path + globalStore.darkNetActiveIndex);
} else if (path == 'community') {
if (!isCommunityTop.value) return;
dom = document.getElementById(path + globalStore.communityActiveIndex);
}
dom?.scrollTo({ top: 0, behavior: 'smooth' });
const arr = globalStore.keepScroll[`/${path}`];
arr.map((item: any) => {
if (item.scrollObj == globalStore.activeIndex) {
item.showBackTopBtn = false;
}
return item;
});
globalStore.keepScroll[`/${path}`] = arr;
};
const isHomeTop = computed(() => {
return (
activeName.value == 'home' &&
globalStore.keepScroll['/home']?.find((item) => item.scrollObj == globalStore.activeIndex)?.showBackTopBtn
);
});
const isDarkNetTop = computed(() => {
return (
activeName.value == 'darkNet' &&
globalStore.keepScroll['/darkNet']?.find((item) => item.scrollObj == globalStore.darkNetActiveIndex)?.showBackTopBtn
);
});
const isCommunityTop = computed(() => {
return (
activeName.value == 'community' &&
globalStore.keepScroll['/community']?.find((item) => item.scrollObj == globalStore.communityActiveIndex)
?.showBackTopBtn
);
});
</script>
<style lang="scss" scoped>
@media (min-height: 700px) {
.tab_wrapper {
.marqueeBar {
top: -64px !important;
}
}
}
:deep(.van-tabbar) {
background: rgb(0, 0, 0);
height: 55px;
z-index: 1000;
}
.van-tabbar-item--active {
background: rgb(0, 0, 0);
}
:deep(.van-tabbar--fixed) {
max-width: 780px;
margin: 0 auto;
left: 50%;
transform: translateX(-50%);
}
:deep(.van-tabbar__placeholder) {
margin: 0 auto;
}
:deep(.van-icon__image) {
width: 24px;
height: 24px;
}
:deep(.van-tabbar-item__text) {
font-size: 12px;
}
.tab_wrapper {
position: relative;
.marqueeBar {
height: 31px;
width: 100%;
padding: 6px 10px;
position: absolute;
left: 0;
top: -32px;
z-index: 999;
}
}
.waringBox {
.waringBoxTip {
height: 235px;
width: 307px;
background-color: rgb(34, 34, 34);
border-radius: 8px;
overflow: hidden;
.title {
height: 60px;
background-color: rgb(255, 50, 48);
font-size: 24px;
text-align: center;
line-height: 60px;
}
.content {
font-size: 16px;
padding: 23px 20px;
line-height: 20px;
}
.btnBox {
padding: 0 20px;
.btn {
font-size: 15px;
height: 40px;
width: 120px;
background-color: rgb(61, 61, 61);
text-align: center;
line-height: 40px;
border-radius: 40px;
}
.red {
background-color: rgb(255, 50, 48);
}
}
}
}
.newsTip {
position: absolute;
bottom: 16px;
right: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: #fd0563;
}
</style>